//! 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(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::() ) }); 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::(&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::("{}").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); }