8cb42f85a9
core_api/common.rs becomes tests/common/mod.rs and sync_api/m4.rs tests/common/sync_fixtures.rs; each binary includes them through #[path] (the binaries of SPEC §14.7 stay as they are). TempDir's Default moves into the harness instead of three copies; the skeleton helpers take ids, tokens and TempDir from it. Milestone wording in test docs and the server README replaced by what the code does; README seams and test layout updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
136 lines
6.1 KiB
Rust
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 the push 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);
|
|
}
|