Files
kvitt/server/crates/kvitt-server/tests/skeleton/wire_examples.rs
T
jacobh2 06f48ef9e9 M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile
Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned.
kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015).
kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to
the contract schema, error type for all 25 codes, auth extractor and protocol
layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal
to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:30:20 +02:00

134 lines
5.9 KiB
Rust

//! Every request and response example of `contracts/examples/` (SPEC §14.3.5) decodes into its wire type and
//! re-encodes to the same JSON value.
use std::path::PathBuf;
use kvitt_server::error::ErrorBody;
use kvitt_server::wire::*;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
fn round_trip<T: DeserializeOwned + Serialize>(name: &str, example: &Value) {
let decoded: T = serde_json::from_value(example.clone()).unwrap_or_else(|e| {
panic!(
"{name}: does not decode as {}: {e}",
std::any::type_name::<T>()
)
});
let encoded = serde_json::to_value(&decoded).unwrap();
assert_eq!(&encoded, example, "{name}: re-encoding differs");
}
fn check(name: &str, example: &Value) -> bool {
macro_rules! map {
($($file:literal => $t:ty,)*) => {
match name {
$($file => { round_trip::<$t>(name, example); true })*
_ => false,
}
};
}
map! {
"E-1-health/response.json" => Health,
"E-2-setup-status/response.json" => SetupStatus,
"E-3-invites-accept/request.json" => AcceptInviteRequest,
"E-3-invites-accept/response.json" => AcceptInviteResponse,
"E-5-apk-latest/response.json" => ApkLatest,
"E-7-me/response.json" => MeResponse,
"E-8-patch-me/request.json" => PatchMeRequest,
"E-8-patch-me/response.json" => User,
"E-9-put-me-picture/response.json" => User,
"E-10-delete-me-picture/response.json" => User,
"E-12-create-invite/request.json" => CreateInviteRequest,
"E-12-create-invite/response.json" => Invite,
"E-13-list-invites/response.json" => InviteList,
"E-15-list-devices/response.json" => DeviceList,
"E-17-push-token/request.json" => PushTokenRequest,
"E-18-create-account/request.json" => CreateAccountRequest,
"E-18-create-account/response.json" => Account,
"E-19-patch-account/request.json" => PatchAccountRequest,
"E-19-patch-account/response.json" => Account,
"E-20-archive-account/response.json" => Account,
"E-21-unarchive-account/response.json" => Account,
"E-22-delete-account/response.json" => Account,
"E-23-create-category/request.json" => CreateCategoryRequest,
"E-23-create-category/response.json" => Category,
"E-24-patch-category/request.json" => PatchCategoryRequest,
"E-24-patch-category/response.json" => Category,
"E-25-archive-category/response.json" => Category,
"E-26-unarchive-category/response.json" => Category,
"E-27-delete-category/response.json" => Category,
"E-28-create-recurring-template/request.json" => CreateRecurringTemplateRequest,
"E-28-create-recurring-template/response.json" => RecurringTemplate,
"E-29-patch-recurring-template/request.json" => PatchRecurringTemplateRequest,
"E-29-patch-recurring-template/response.json" => RecurringTemplate,
"E-30-delete-recurring-template/response.json" => RecurringTemplate,
"E-31-sync-pull/response.json" => SyncPullResponse,
"E-32-sync-push/request.json" => SyncPushRequest,
"E-32-sync-push/response.json" => SyncPushResponse,
"E-33-settlement-proposal/request.json" => ProposalRequest,
"E-33-settlement-proposal/response.json" => ProposalResponse,
"sync/pull-empty-page.json" => SyncPullResponse,
"sync/pull-page.json" => SyncPullResponse,
"sync/push-request.json" => SyncPushRequest,
"sync/push-request-delete.json" => SyncPushRequest,
"sync/push-request-rejections.json" => SyncPushRequest,
"sync/push-response.json" => SyncPushResponse,
"sync/push-response-delete.json" => SyncPushResponse,
"sync/push-response-rejections.json" => SyncPushResponse,
}
}
#[test]
fn every_example_round_trips() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../contracts/examples");
let mut unmapped = Vec::new();
let mut checked = 0;
let mut dirs: Vec<_> = std::fs::read_dir(&root)
.unwrap()
.map(|e| e.unwrap().path())
.collect();
dirs.sort();
for dir in dirs.into_iter().filter(|p| p.is_dir()) {
let dir_name = dir.file_name().unwrap().to_string_lossy().into_owned();
for file in std::fs::read_dir(&dir).unwrap() {
let path = file.unwrap().path();
let name = format!("{dir_name}/{}", path.file_name().unwrap().to_string_lossy());
let example: Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
if dir_name == "errors" {
round_trip::<ErrorBody>(&name, &example);
checked += 1;
} else if dir_name == "push" {
// FCM payloads (SPEC §14.6) are built by M4's sender, not by a wire type of this crate.
continue;
} else if check(&name, &example) {
checked += 1;
} else {
unmapped.push(name);
}
}
}
assert!(
unmapped.is_empty(),
"examples without a wire type: {unmapped:?}"
);
assert!(checked > 60, "only {checked} examples checked");
}
#[test]
fn absent_and_null_are_distinct_where_the_contract_says_so() {
let patch: PatchCategoryRequest = serde_json::from_str(r#"{"name": null}"#).unwrap();
assert_eq!(patch.name, Some(None));
let patch: PatchCategoryRequest = serde_json::from_str(r#"{"icon": "home"}"#).unwrap();
assert_eq!(patch.name, None);
// E-17: the key is required, the value nullable.
assert!(serde_json::from_str::<PushTokenRequest>("{}").is_err());
let clear: PushTokenRequest = serde_json::from_str(r#"{"fcm_token": null}"#).unwrap();
assert_eq!(clear.fcm_token, None);
// E-12: target_user_id may be absent.
let invite: CreateInviteRequest = serde_json::from_str(r#"{"kind": "partner"}"#).unwrap();
assert_eq!(invite.target_user_id, None);
}