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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user