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
@@ -465,3 +465,59 @@ async fn sync_push_reaches_fcm_through_the_queue_and_the_sender_task() {
assert_eq!(body["message"]["data"]["entity_id"], id);
assert_eq!(body["message"]["data"]["actor_user_id"], MEMBER_ID);
}
#[derive(Clone, Default)]
struct Captured(Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for Captured {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// The only test of this binary that installs a subscriber. Global, not thread-local, because tracing caches callsite
/// interest process-wide; the other tests' lines are captured too, so only lines of this test's push type are read.
#[tokio::test]
async fn a_successful_send_logs_one_info_line_with_aggregates_only() {
let captured = Captured::default();
let writer = captured.clone();
let subscriber = tracing_subscriber::fmt()
.json()
.with_max_level(tracing::Level::INFO)
.with_writer(move || writer.clone())
.finish();
tracing::subscriber::set_global_default(subscriber).unwrap();
let s = Setup::new().await;
let deliveries = s
.sender()
.deliver(&message(PushType::SettlementDeleted, MEMBER_ID))
.await
.unwrap();
assert_eq!(deliveries, [Delivery::Sent { attempts: 1 }]);
let logs = String::from_utf8(captured.0.lock().unwrap().clone()).unwrap();
let lines: Vec<Value> = logs
.lines()
.map(|l| serde_json::from_str::<Value>(l).unwrap())
.filter(|l| {
l["fields"]["message"] == "push sent"
&& l["fields"]["push_type"] == "settlement_deleted"
})
.collect();
assert_eq!(lines.len(), 1, "{logs}");
let line = &lines[0];
assert_eq!(line["level"], "INFO");
assert_eq!(line["fields"]["devices_sent"], 1);
assert_eq!(line["fields"]["devices_targeted"], 1);
// The member acted, so the admin's one live device got it: its id's last 8 characters only.
assert_eq!(line["fields"]["devices"], "00000001");
let text = line.to_string();
for secret in ["tok-admin", ADMIN_DEVICE_ID, MEMBER_ID, EXPENSE] {
assert!(!text.contains(secret), "{text}");
}
}