Files
kvitt/server/crates/kvitt-server/tests/skeleton/wire_examples.rs
T
jacobh2 6eaccec7ac M12b: category contract v2 on the server (schema 2, fourteen defaults, finer import mapping)
- migrations/0002_categories_v2.sql, byte-identical to contracts/schema/migrations/0002_categories_v2.sql
  (SPEC §14.1.1), with an identity test; SCHEMA_VERSION = 2 (E-1 reports it, the importer's check follows).
- Seed test on a fresh database (the fourteen rows of AC-7.2, server_version 11 to 14) and an upgrade test of a
  v1 database in use (0001 applied through the migrator, then users, a custom, a renamed and an archived default,
  an account, a recurring template, expenses): every earlier row unchanged, the four rows above the old
  sequence, foreign_key_check empty, schema_version 2, a pull from the old cursor returns exactly the four rows.
- DefaultCategoryKey with the fourteen keys (wire enum and contracts/openapi.yaml); icons 33 and colours 14
  (§9.7) in validation.
- The two category examples M12a held back (E-26 response-default, sync/pull-page-categories-v2) and its
  contracts-check patch for them and the enum.
- Importer: splitwise_categories.csv per the §10.8 v0.8 table (D-053), DEFAULT_KEYS of fourteen, synthetic
  fixtures for the finer mapping (Insurance/Försäkringar to insurance among them), a schema-1 refusal test.
- Test harnesses seed users at server_version 15/16 (after the fourteen seeds); pull counts 10 -> 14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 08:25:22 +02:00

136 lines
6.1 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-26-unarchive-category/response-default.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/pull-page-categories-v2.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);
}