From deccaeca8df8db69b95be1b9bca504572ecfa4d6 Mon Sep 17 00:00:00 2001 From: oratis Date: Sat, 30 May 2026 01:37:33 +0800 Subject: [PATCH] test(desktop): serde contract tests for Rust command structs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock the snake_case JSON shape that tauri-api.ts depends on: - credentials.rs Credentials → api_key/auth_token/base_url + None fields omitted (skip_serializing_if), matching readCredentials' decode. - commands.rs AppInfo (home_dir) + SessionMeta (size_bytes, updated_at_secs) → snake_case, NOT camelCase (these intentionally differ from the tool output structs in tools.rs, which the renderer reads as camelCase). Together with the tools.rs casing_tests (#79) and the TS-side tauri-api contract tests (#84), both ends of every IPC struct are now pinned — the HANDOFF §8a class of bug can't silently reappear. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src-tauri/src/commands.rs | 42 +++++++++++++++++++++++ apps/desktop/src-tauri/src/credentials.rs | 30 ++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 8d99697..b853b51 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -257,3 +257,45 @@ pub fn open_url(_url: String) -> Result<(), String> { // renderer; this is a stub kept for future server-side validation. Ok(()) } + +// ── Serde contract ───────────────────────────────────────────────────── +// AppInfo + SessionMeta are read by tauri-api.ts using snake_case keys +// (home_dir, size_bytes, updated_at_secs). They intentionally do NOT use +// rename_all="camelCase" (unlike the tool output structs in tools.rs). Lock +// that so a stray rename_all can't silently break the renderer. See HANDOFF §8a. +#[cfg(test)] +mod contract_tests { + use super::*; + + fn keys(v: &serde_json::Value) -> Vec { + v.as_object().unwrap().keys().cloned().collect() + } + + #[test] + fn app_info_serializes_snake_case() { + let v = serde_json::to_value(AppInfo { + version: "1.0.0".into(), + platform: "darwin".into(), + home_dir: Some(std::path::PathBuf::from("/Users/x")), + }) + .unwrap(); + let k = keys(&v); + assert!(k.contains(&"home_dir".to_string()), "got {k:?}"); + assert!(!k.contains(&"homeDir".to_string()), "camelCase leaked: {k:?}"); + } + + #[test] + fn session_meta_serializes_snake_case() { + let v = serde_json::to_value(SessionMeta { + id: "s1".into(), + path: std::path::PathBuf::from("/tmp/s1.jsonl"), + size_bytes: 42, + updated_at_secs: 1700, + }) + .unwrap(); + let k = keys(&v); + assert!(k.contains(&"size_bytes".to_string()), "got {k:?}"); + assert!(k.contains(&"updated_at_secs".to_string()), "got {k:?}"); + assert!(!k.contains(&"sizeBytes".to_string()), "camelCase leaked: {k:?}"); + } +} diff --git a/apps/desktop/src-tauri/src/credentials.rs b/apps/desktop/src-tauri/src/credentials.rs index f0de713..a2df4a0 100644 --- a/apps/desktop/src-tauri/src/credentials.rs +++ b/apps/desktop/src-tauri/src/credentials.rs @@ -47,3 +47,33 @@ pub fn write(creds: &Credentials) -> Result<(), String> { } Ok(()) } + +// ── Serde contract ───────────────────────────────────────────────────── +// tauri-api.ts#readCredentials reads `api_key`/`auth_token`/`base_url` (snake) +// and maps them to camelCase itself. Lock that shape + the skip-if-None omission +// the TS side relies on (missing field → undefined). See HANDOFF §8a. +#[cfg(test)] +mod contract_tests { + use super::*; + + #[test] + fn serializes_snake_case_keys() { + let v = serde_json::to_value(Credentials { + api_key: Some("sk".into()), + auth_token: Some("tok".into()), + base_url: Some("https://h/v1".into()), + }) + .unwrap(); + let keys: Vec = v.as_object().unwrap().keys().cloned().collect(); + assert!(keys.contains(&"api_key".to_string()), "got {keys:?}"); + assert!(keys.contains(&"auth_token".to_string()), "got {keys:?}"); + assert!(keys.contains(&"base_url".to_string()), "got {keys:?}"); + assert!(!keys.contains(&"apiKey".to_string()), "camelCase leaked: {keys:?}"); + } + + #[test] + fn omits_none_fields() { + let v = serde_json::to_value(Credentials::default()).unwrap(); + assert_eq!(v.as_object().unwrap().len(), 0, "None fields must be skipped: {v}"); + } +}