Server gap pass: invite-partner subcommand, CLI and drain tests, push info log (D-113, D-114)

- kvitt-server invite-partner: a partner invite by the admin as E-12 does (cancels the pending one, 72 h, link with
  ?from=<admin>), exit 2 household full, 3 no admin; README lines in server/ and infra/.
- Tests: invite-partner, migrate (schema at SCHEMA_VERSION, idempotent), healthcheck (200, 503, nothing listening),
  serve SIGTERM drain (in-flight request finishes, new connections refused, exit 0; gives up after SHUTDOWN_DRAIN).
- Push sender: one info line "push sent" per message with push_type, devices_sent, devices_targeted and 8-character
  device-id tails (SPEC §8.7 aggregates); per-device line stays at debug.
- tools/contracts-check.py: stale TODO(spec) replaced by the §9.11 CS-5 citation; shapes updated for invite_kind
  (D-086), app_version_name (D-090) and fid (D-096), 134 checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 22:12:21 +02:00
parent e70fe0d9fe
commit 8660b7fa16
9 changed files with 528 additions and 18 deletions
@@ -1,4 +1,5 @@
//! `kvitt-server bootstrap` and `pair-device` (FR-1, AC-1.1 to AC-1.6, SPEC §10.4), run as the real binary.
//! `kvitt-server bootstrap`, `pair-device` (FR-1, AC-1.1 to AC-1.6, SPEC §10.4) and `invite-partner` (D-113), run as
//! the real binary.
use std::process::{Command, Output};
@@ -97,3 +98,90 @@ async fn bootstrap_prints_an_admin_setup_invite_cancels_the_previous_one_then_re
);
}
}
/// The code and link of `invite-partner`'s output, checked like [`printed_code`] but with the `?from=` of D-087.
fn printed_partner_code(output: &Output, from: &str) -> String {
let stdout = String::from_utf8(output.stdout.clone()).unwrap();
let link = stdout
.lines()
.find_map(|l| l.strip_prefix("Link:"))
.unwrap()
.trim();
let rest = link.strip_prefix("https://kvitt.example:8443/i/").unwrap();
let (code, query) = rest.split_once('?').unwrap();
assert_eq!(query, format!("from={from}"));
assert_eq!(code.len(), 12);
let display = stdout
.lines()
.find_map(|l| l.strip_prefix("Code:"))
.unwrap()
.trim();
assert_eq!(display.replace('-', ""), code);
assert!(stdout.contains("Expires: "));
code.to_owned()
}
#[tokio::test]
async fn invite_partner_needs_an_admin_cancels_the_previous_invite_and_refuses_a_full_household() {
// No users: exit 3, nothing created.
let dir = TempDir::new();
let none = run(&dir.0, &["invite-partner"]);
assert_eq!(none.status.code(), Some(3), "{none:?}");
assert_eq!(
String::from_utf8_lossy(&none.stderr).trim(),
"no admin, run bootstrap first"
);
assert!(none.stdout.is_empty());
// Only the admin: as E-12 with kind partner.
let boot = run(&dir.0, &["bootstrap"]);
let app = TestApp::at(dir).await;
let admin = app
.public(
Method::POST,
"/invites/accept",
Some(json!({"code": printed_code(&boot), "device_name": "Galaxy S26", "display_name": "Jacob Å"})),
)
.await;
assert_eq!(admin.status, 200, "{}", admin.text());
let admin_id = admin.json["user"]["id"].as_str().unwrap().to_owned();
let first = run(app.data_dir(), &["invite-partner"]);
assert_eq!(first.status.code(), Some(0), "{first:?}");
let first_code = printed_partner_code(&first, "Jacob%20%C3%85");
let second = run(app.data_dir(), &["invite-partner"]);
assert_eq!(second.status.code(), Some(0), "{second:?}");
let second_code = printed_partner_code(&second, "Jacob%20%C3%85");
assert_ne!(first_code, second_code);
let by_admin: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM invites WHERE kind = 'partner' AND target_user_id IS NULL AND created_by_user_id = ?",
)
.bind(&admin_id)
.fetch_one(&app.state.db)
.await
.unwrap();
assert_eq!(by_admin, 2);
let accept = |code: String| {
app.public(
Method::POST,
"/invites/accept",
Some(json!({"code": code, "device_name": "Galaxy S26+", "display_name": "Anna"})),
)
};
// A new partner invite cancels the pending one, as E-12 does.
assert_error(&accept(first_code).await, 400, "invite_invalid");
let partner = accept(second_code).await;
assert_eq!(partner.status, 200, "{}", partner.text());
assert_eq!(partner.json["user"]["role"], "member");
assert_eq!(partner.json["invite_kind"], "partner");
// Two users: exit 2, nothing created.
let full = run(app.data_dir(), &["invite-partner"]);
assert_eq!(full.status.code(), Some(2), "{full:?}");
assert_eq!(
String::from_utf8_lossy(&full.stderr).trim(),
"household full"
);
assert!(full.stdout.is_empty());
assert_eq!(app.scalar_i64("SELECT COUNT(*) FROM invites").await, 3);
}