diff --git a/CLAUDE.md b/CLAUDE.md index 912d569..7980c37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,14 +10,84 @@ Make "talk to the WaveKat platform from Rust" a solved problem so each consumer - `Client` — reqwest-backed HTTP client with bearer auth attached. - Loopback OAuth handshake (`platform.wavekat.com/cli-login` → loopback `127.0.0.1:/callback`). -- Typed wrappers for stable platform endpoints used by multiple consumers (`/api/me`, token revoke, artifact upload). +- Typed wrappers for stable platform endpoints (`/api/me`, token revoke, artifact upload). +- **Platform sync endpoints** that upload/read user-owned resources. See "Platform sync endpoints belong here" below. - Error types covering network, deserialization, auth-state mismatches. +## Platform sync endpoints belong here + +Any endpoint pair under `/api/voice/{resource}/{sync,list}` — or any +future equivalent that uploads a user-owned resource from a client to +the platform and reads it back — is implemented in this crate as a +`SyncEndpoint` marker, **even if only one consumer uses it today**. + +Reason: every WaveKat client (desktop daemon, CLI, future agents) +ships against the same platform. Making each consumer reinvent the +upload pipeline (batching, retries, cursor pagination, error mapping) +guarantees drift — subtly different batch sizes, different envelope +shapes, different idempotency keys. The bridge crate exists to stop +that. + +Concretely: when you add a new sync-able resource (calls today; +recordings, transcripts, summaries later), you land: + +- A `SyncEndpoint` impl on a zero-sized marker type (`VoiceCalls`, + `VoiceRecordings`, …). +- The wire-shape `Record` + `Query` types alongside it. +- The platform-side migration + routes in `wavekat-platform`. + +Consumers then call `client.sync::(items)` / +`client.list::(query)` — they don't write a new HTTP +pipeline. + +### Every record carries a `SyncEnvelope` + +Each `SyncEndpoint::Record` type embeds `SyncEnvelope` via +`#[serde(flatten)]` and implements `HasSyncEnvelope`: + +```rust +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MyResourceRecord { + /* resource-specific fields */ + + #[serde(flatten, default)] + pub envelope: SyncEnvelope, +} + +impl HasSyncEnvelope for MyResourceRecord { + fn envelope_mut(&mut self) -> &mut SyncEnvelope { &mut self.envelope } +} +``` + +The envelope contributes two fields to the wire: + +- `schemaVersion` — auto-stamped by `Client::sync` from + `SyncEndpoint::CURRENT_SCHEMA_VERSION` when the consumer leaves + it `None`. Lets the platform branch on which client wrote the + row when an additive field change isn't enough. +- `extras` — free-form JSON for fields a newer client knows about + but this platform version doesn't yet have a typed column for. + The platform persists `extras` verbatim so a future deploy can + promote a field out of it into a typed column without data loss. + +### Wire schemas are additive-only + +When you add a field to a `Record`, make it optional with a serde +default (`#[serde(default, skip_serializing_if = "Option::is_none")]` +on `Option<…>`). When you remove one, deprecate first, ignore for a +release, then drop. When the *meaning* of a field needs to change, +introduce a new name and bump `CURRENT_SCHEMA_VERSION`. Hard schema +breaks ship as a new endpoint pair under a new `RESOURCE` segment. + +Design rationale and the calls-first slice that established the +pattern: see [`wavekat-voice/docs/21-platform-call-history-sync.md`](https://github.com/wavekat/wavekat-voice/blob/main/docs/21-platform-call-history-sync.md). + ## What does NOT belong here - **Credential storage policy.** Consumers pick: `wavekat-cli` writes a JSON file at `~/.config/wavekat/auth.json`; `wavekat-voice` uses the OS keychain via the `keyring` crate. The crate's surface takes a `token: String` and returns one — it never reads or writes disk. - **CLI-shaped concerns.** Argument parsing, terminal rendering, progress bars, anything `clap`/`unicode-width` shaped. Those stay in `wavekat-cli`. -- **Consumer-specific endpoints.** If only one product calls it, it stays in that product. Promote to this crate when a second consumer needs it. +- **Truly one-off endpoints.** Things one client genuinely needs and no other client ever will — a CLI-only `wk doctor` debug dump, the desktop-only loopback OAuth callback handler, etc. Sync endpoints are *not* in this bucket: default to landing them here unless you can articulate why no other client will ever want them. - **Async runtime.** Use `reqwest` async; let consumers bring tokio. ## Design principles diff --git a/crates/wavekat-platform-client/src/lib.rs b/crates/wavekat-platform-client/src/lib.rs index fcbd5d5..5fb3948 100644 --- a/crates/wavekat-platform-client/src/lib.rs +++ b/crates/wavekat-platform-client/src/lib.rs @@ -40,10 +40,17 @@ mod client; mod error; mod me; mod oauth; +mod sync; mod token; +mod voice; pub use client::Client; pub use error::{Error, Result}; pub use me::Me; pub use oauth::{loopback_handshake, HandshakeOptions, HandshakeOutcome, PendingHandshake}; +pub use sync::{HasSyncEnvelope, Page, SyncEndpoint, SyncEnvelope, SyncRequest, SyncResponse}; pub use token::Token; +pub use voice::{ + VoiceCallDirection, VoiceCallDisposition, VoiceCallEndReason, VoiceCallRecord, VoiceCalls, + VoiceCallsQuery, +}; diff --git a/crates/wavekat-platform-client/src/sync.rs b/crates/wavekat-platform-client/src/sync.rs new file mode 100644 index 0000000..e6e6d72 --- /dev/null +++ b/crates/wavekat-platform-client/src/sync.rs @@ -0,0 +1,355 @@ +//! Platform sync endpoints — uniform "batch upload + cursor list" shape. +//! +//! Every client→platform sync (calls today; recordings, transcripts, +//! summaries later) goes through the [`SyncEndpoint`] trait. Each +//! resource is a zero-sized marker type (e.g. [`crate::voice::VoiceCalls`]) +//! that nails down: +//! +//! - the URL segment under `/api/voice/` (`RESOURCE`); +//! - the wire-shape [`SyncEndpoint::Record`] type; +//! - the typed [`SyncEndpoint::Query`] for GET pagination. +//! +//! [`Client::sync`] and [`Client::list`] are the only two methods you +//! need on the consumer side — both are parameterised by the marker. +//! +//! See `wavekat-voice/docs/21-platform-call-history-sync.md` for the +//! full design rationale and the wire contract. + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::Value as JsonValue; + +use crate::client::Client; +use crate::error::Result; + +/// Wire-level envelope that every sync record carries. +/// +/// Embedded in each `SyncEndpoint::Record` via `#[serde(flatten)]` +/// so the two fields end up at the top of the JSON object alongside +/// the resource-specific columns. Lets the version-skew story live +/// in one place — not duplicated on every resource type. +/// +/// **`schema_version`**: which wire shape the daemon wrote this row +/// with. `None` on the wire means "I'm not declaring one; treat as +/// `1`." `Client::sync` fills this in from +/// [`SyncEndpoint::CURRENT_SCHEMA_VERSION`] when a consumer leaves +/// it [`None`]. +/// +/// **`extras`**: free-form JSON map for fields the consumer's +/// schema version recognises but the platform's doesn't yet. The +/// platform persists `extras` verbatim so a future deploy can +/// promote a field out of it into a typed column without data loss. +/// The platform deliberately does *not* echo `extras` back on GET — +/// it's an internal-storage construct, not a public field. +/// +/// Both fields are optional in serialization so a row that ships +/// neither stays on the small/fast path. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncEnvelope { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extras: Option, +} + +impl SyncEnvelope { + /// Build an envelope stamped with the endpoint's + /// `CURRENT_SCHEMA_VERSION`. Useful for daemon-side code that + /// wants to construct a record with the version already filled + /// in — `Client::sync` will also fill it in lazily, but doing it + /// at construction time keeps tests and logs honest. + pub fn for_endpoint() -> Self { + Self { + schema_version: Some(E::CURRENT_SCHEMA_VERSION), + extras: None, + } + } +} + +/// Records that carry a [`SyncEnvelope`] expose it via this trait so +/// the bridge crate can stamp the `schemaVersion` field uniformly +/// across resources. One-line impl per record type: +/// +/// ```ignore +/// impl HasSyncEnvelope for VoiceCallRecord { +/// fn envelope_mut(&mut self) -> &mut SyncEnvelope { &mut self.envelope } +/// } +/// ``` +pub trait HasSyncEnvelope { + fn envelope_mut(&mut self) -> &mut SyncEnvelope; +} + +/// Clone `items` and fill in `schemaVersion` on every record whose +/// envelope left it unset. Records that supplied an explicit version +/// are passed through unchanged — useful for tests and for the rare +/// "deliberately ship an older version during a rollback" case. +fn stamp_schema_version(items: &[E::Record]) -> Vec +where + E::Record: Clone + HasSyncEnvelope, +{ + let mut out = items.to_vec(); + for item in &mut out { + let env = item.envelope_mut(); + if env.schema_version.is_none() { + env.schema_version = Some(E::CURRENT_SCHEMA_VERSION); + } + } + out +} + +/// One sync-able platform resource. +/// +/// Implemented by zero-sized marker types — you call methods like +/// `client.sync::(&items)` rather than constructing a +/// `VoiceCalls` value. +pub trait SyncEndpoint { + /// Path segment under `/api/voice/`. e.g. `"calls"`, `"recordings"`. + /// + /// Combined into the full paths + /// `POST /api/voice/{RESOURCE}/sync` and + /// `GET /api/voice/{RESOURCE}`. + const RESOURCE: &'static str; + + /// Current wire-schema version for this resource's `Record` type. + /// + /// Bumped when the meaning of an existing field changes (a rare, + /// deliberate event). Additive field changes don't require a + /// version bump — they ride on the additive-only policy + /// (see `wavekat-voice/docs/21-platform-call-history-sync.md` + /// §"Versioning and forward compatibility"). + /// + /// Used by `Client::sync` so consumers don't manage the version + /// themselves — upgrading the bridge crate picks up the right + /// number automatically. Default is `1`. + const CURRENT_SCHEMA_VERSION: u32 = 1; + + /// One row's worth of data. Must round-trip through JSON; the wire + /// shape uses camelCase per the platform's Hono/Zod convention + /// (apply `#[serde(rename_all = "camelCase")]` on your struct). + /// + /// Records must embed [`SyncEnvelope`] via + /// `#[serde(flatten)] pub envelope: SyncEnvelope` so the + /// `schemaVersion` + `extras` fields appear at the top of the + /// JSON object alongside the resource-specific columns. The + /// trait doesn't enforce this via an associated type because + /// `#[serde(flatten)]` is a serde attribute (not a Rust trait + /// bound), but every record type ships with the envelope and + /// `Client::sync` relies on the field name `schemaVersion`. + /// See `VoiceCallRecord` for the canonical shape. + type Record: Serialize + DeserializeOwned + Send + Sync + 'static; + + /// Query params for `GET /api/voice/{RESOURCE}`. Typically a cursor + /// (`before` as RFC 3339) plus a `limit` and any resource-specific + /// filters (e.g. `account_id`). Serialized as URL query. + type Query: Serialize + Send + Sync; +} + +/// Body shape for `POST /api/voice/{R}/sync`. +/// +/// `items` is the batch. The server caps batches at 100 — chunking +/// is the consumer's responsibility (the daemon's `Uploader` does +/// this automatically; ad-hoc callers should too). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncRequest { + pub items: Vec, +} + +/// Response from `POST /api/voice/{R}/sync`. +/// +/// `accepted` counts rows the platform actually wrote (insert *or* +/// idempotent update). `skipped` counts rows the platform deliberately +/// ignored — reserved for future mutable resources where a stale +/// revision should be dropped without erroring. Always 0 for the +/// immutable calls/recordings/transcripts shipped today; consumers +/// can ignore it for now and still be forward-compatible. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncResponse { + pub accepted: u32, + pub skipped: u32, +} + +/// One page of `GET /api/voice/{R}`. +/// +/// `items` is newest-first. `next_before` is the cursor for the next +/// page (pass it back as the request's `before` field); absent/None +/// means the caller has reached the start of history. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Page { + pub items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_before: Option, +} + +impl Client { + /// `POST /api/voice/{E::RESOURCE}/sync` — idempotent batch upload. + /// + /// The platform upserts keyed by `(user_id, item.source_id)`, so + /// retries after a flaky connection are safe. + /// + /// **Batch size.** The platform rejects batches over 100 items with + /// HTTP 413. This method does *not* chunk for you — pass a slice + /// you're confident about, or use the daemon's `Uploader` which + /// chunks at 50. + /// + /// **Schema version.** Records whose envelope leaves + /// `schemaVersion` unset (the common case — consumers don't need + /// to know the number) have it stamped with + /// [`SyncEndpoint::CURRENT_SCHEMA_VERSION`] before serialization, + /// so the platform always sees an explicit version. Records that + /// set it explicitly are passed through untouched. + pub async fn sync(&self, items: &[E::Record]) -> Result + where + E::Record: Clone + HasSyncEnvelope, + { + let path = format!("/api/voice/{}/sync", E::RESOURCE); + let body = SyncRequest { + items: stamp_schema_version::(items), + }; + self.post_json::(&path, &body).await + } + + /// `GET /api/voice/{E::RESOURCE}` — one page of the caller's rows, + /// newest first, scoped server-side to the bearer's user. + pub async fn list(&self, query: &E::Query) -> Result> { + let path = format!("/api/voice/{}", E::RESOURCE); + self.get_json_query::, _>(&path, query) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // A minimal marker so the trait surface is exercised independently + // of any specific resource type. + struct DummyResource; + + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] + #[serde(rename_all = "camelCase")] + struct DummyRecord { + source_id: String, + payload: String, + } + + #[derive(Debug, Default, Serialize)] + #[serde(rename_all = "camelCase")] + struct DummyQuery { + before: Option, + limit: Option, + } + + impl SyncEndpoint for DummyResource { + const RESOURCE: &'static str = "dummy"; + type Record = DummyRecord; + type Query = DummyQuery; + } + + #[test] + fn sync_request_serializes_with_items_field() { + let body = SyncRequest:: { + items: vec![ + DummyRecord { + source_id: "a".into(), + payload: "x".into(), + }, + DummyRecord { + source_id: "b".into(), + payload: "y".into(), + }, + ], + }; + let s = serde_json::to_string(&body).unwrap(); + assert!(s.contains("\"items\":["), "missing items envelope: {s}"); + assert!( + s.contains("\"sourceId\":\"a\""), + "wire should be camelCase: {s}" + ); + } + + #[test] + fn sync_response_parses_platform_shape() { + let raw = r#"{"accepted": 3, "skipped": 0}"#; + let parsed: SyncResponse = serde_json::from_str(raw).unwrap(); + assert_eq!(parsed.accepted, 3); + assert_eq!(parsed.skipped, 0); + } + + #[test] + fn page_round_trip_without_cursor() { + // The wire either omits next_before or sends null when there's + // no more history. Both should parse to None. + let with_null = r#"{"items": [], "nextBefore": null}"#; + let omitted = r#"{"items": []}"#; + let p1: Page = serde_json::from_str(with_null).unwrap(); + let p2: Page = serde_json::from_str(omitted).unwrap(); + assert!(p1.next_before.is_none()); + assert!(p2.next_before.is_none()); + } + + #[test] + fn resource_const_drives_path() { + // Sanity check — the trait constant is what ends up in the URL. + assert_eq!(::RESOURCE, "dummy"); + } + + // A record that carries the envelope via flatten — exactly the + // shape every real resource type adopts. Verifies the + // stamp-on-send behaviour without depending on `VoiceCalls` + // (which lives in a sibling module). + #[derive(Debug, Clone, Default, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + struct DummyEnvelopedRecord { + source_id: String, + #[serde(flatten, default)] + envelope: SyncEnvelope, + } + + impl HasSyncEnvelope for DummyEnvelopedRecord { + fn envelope_mut(&mut self) -> &mut SyncEnvelope { + &mut self.envelope + } + } + + struct EnvelopedResource; + impl SyncEndpoint for EnvelopedResource { + const RESOURCE: &'static str = "enveloped"; + const CURRENT_SCHEMA_VERSION: u32 = 7; + type Record = DummyEnvelopedRecord; + type Query = DummyQuery; + } + + #[test] + fn stamp_schema_version_fills_in_when_missing() { + let items = vec![DummyEnvelopedRecord { + source_id: "a".into(), + envelope: SyncEnvelope::default(), + }]; + let stamped = stamp_schema_version::(&items); + assert_eq!(stamped[0].envelope.schema_version, Some(7)); + } + + #[test] + fn stamp_schema_version_preserves_explicit_value() { + // A consumer that deliberately set a version (e.g. a rollback + // test) should pass through unchanged. + let items = vec![DummyEnvelopedRecord { + source_id: "a".into(), + envelope: SyncEnvelope { + schema_version: Some(2), + extras: None, + }, + }]; + let stamped = stamp_schema_version::(&items); + assert_eq!(stamped[0].envelope.schema_version, Some(2)); + } + + #[test] + fn for_endpoint_returns_envelope_with_current_version() { + let env = SyncEnvelope::for_endpoint::(); + assert_eq!(env.schema_version, Some(7)); + assert!(env.extras.is_none()); + } +} diff --git a/crates/wavekat-platform-client/src/voice.rs b/crates/wavekat-platform-client/src/voice.rs new file mode 100644 index 0000000..adacc21 --- /dev/null +++ b/crates/wavekat-platform-client/src/voice.rs @@ -0,0 +1,274 @@ +//! Voice-product resources synced from the desktop daemon up to the +//! platform. +//! +//! The first shipped marker is [`VoiceCalls`] — per-call metadata for +//! the platform's `/voice/calls` history page (see +//! `wavekat-voice/docs/21-platform-call-history-sync.md`). Recordings +//! (`VoiceRecordings`), transcripts (`VoiceTranscripts`), and summaries +//! will follow the same shape: a marker type, a wire-record struct, and +//! a typed query — no new HTTP plumbing. +//! +//! All wire shapes use camelCase JSON to match the platform's Hono/Zod +//! convention. The Rust types stay snake_case so consumers feel native. + +use serde::{Deserialize, Serialize}; + +use crate::sync::{HasSyncEnvelope, SyncEndpoint, SyncEnvelope}; + +/// Inbound vs. outbound. Wire-stable snake_case strings — never +/// renumber or rename. New states (e.g. `internal`) would be a wire +/// addition, not a replacement. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VoiceCallDirection { + Inbound, + Outbound, +} + +/// User-visible disposition. Derived from [`VoiceCallEndReason`] by the +/// daemon; the platform stores both, so future UI surfaces can read +/// either without re-deriving. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VoiceCallDisposition { + Answered, + Missed, + Rejected, + Cancelled, + Failed, +} + +/// Finer-grained terminal reason — kept distinct from +/// [`VoiceCallDisposition`] because the disposition collapses +/// `hangup_local` and `hangup_remote` to `Answered`, losing the +/// "who hung up?" answer the row otherwise carries. +/// +/// Wire-stable snake_case strings; the daemon's matching enum is the +/// canonical source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VoiceCallEndReason { + HangupLocal, + HangupRemote, + RejectedLocal, + RejectedRemote, + Missed, + CancelledLocal, + Failed, +} + +/// One historical call as it crosses the wire from the daemon up to the +/// platform. +/// +/// Mirrors the daemon's local `CallRecord` (see +/// `wavekat-voice/crates/wavekat-voice/src/db.rs`) with one rename: +/// the daemon's local primary key (`id`) is shipped as `source_id` +/// because the platform allocates its own row id and treats the +/// daemon-side UUID as the idempotency key. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoiceCallRecord { + /// Daemon-generated UUID. The platform's `(user_id, source_id)` + /// upsert key — re-syncing the same id is a no-op. + pub source_id: String, + /// SIP account UUID on the daemon side. Opaque to the platform. + pub account_id: String, + pub direction: VoiceCallDirection, + /// SIP `From:` (inbound) or `To:` (outbound). Free text — caller + /// IDs, display names, and SIP URIs all land here. + pub party: String, + /// RFC 3339. First ring (inbound) or first dial-out (outbound). + pub ring_at: String, + /// RFC 3339. Present only when the call reached the answered + /// state. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub answer_at: Option, + /// RFC 3339. Terminal timestamp; the platform uses this as the + /// list cursor. + pub end_at: String, + /// `answer_at` → `end_at` in milliseconds. `None` for calls that + /// were never answered. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + pub disposition: VoiceCallDisposition, + pub end_reason: VoiceCallEndReason, + /// Free-text error, populated only when `disposition == Failed`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Version + forward-compat fields shared by every sync record. + /// Flattened so `schemaVersion` and `extras` sit at the top of + /// the JSON object alongside the other columns. See + /// [`SyncEnvelope`] and doc 21 §"Versioning and forward + /// compatibility". + #[serde(flatten, default)] + pub envelope: SyncEnvelope, +} + +/// Query params for `GET /api/voice/calls`. All fields optional — the +/// default returns the newest page. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoiceCallsQuery { + /// RFC 3339 cursor; rows with `end_at < before` are returned. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub before: Option, + /// 1..=200. Server default is 50. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +/// Marker for the `/api/voice/calls/{sync,list}` endpoint pair. +/// +/// Use as a type parameter, never construct: `client.sync::(&items)`. +pub struct VoiceCalls; + +impl SyncEndpoint for VoiceCalls { + const RESOURCE: &'static str = "calls"; + type Record = VoiceCallRecord; + type Query = VoiceCallsQuery; +} + +impl HasSyncEnvelope for VoiceCallRecord { + fn envelope_mut(&mut self) -> &mut SyncEnvelope { + &mut self.envelope + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn record_serializes_with_camel_case_keys() { + let r = VoiceCallRecord { + source_id: "11111111-1111-4111-8111-111111111111".into(), + account_id: "22222222-2222-4222-8222-222222222222".into(), + direction: VoiceCallDirection::Inbound, + party: "+14155550123".into(), + ring_at: "2026-05-16T10:00:00Z".into(), + answer_at: Some("2026-05-16T10:00:05Z".into()), + end_at: "2026-05-16T10:01:00Z".into(), + duration_ms: Some(55_000), + disposition: VoiceCallDisposition::Answered, + end_reason: VoiceCallEndReason::HangupRemote, + error: None, + envelope: SyncEnvelope::for_endpoint::(), + }; + let s = serde_json::to_string(&r).unwrap(); + assert!(s.contains("\"sourceId\":"), "{s}"); + assert!(s.contains("\"accountId\":"), "{s}"); + assert!(s.contains("\"ringAt\":"), "{s}"); + assert!(s.contains("\"endAt\":"), "{s}"); + assert!(s.contains("\"durationMs\":55000"), "{s}"); + // Optional `error` is None — should be omitted from the wire. + assert!(!s.contains("\"error\""), "error should be omitted: {s}"); + // Envelope flattens to the top of the object — schemaVersion + // sits next to the other fields rather than nested under + // "envelope". Future resources rely on this layout. + assert!( + s.contains("\"schemaVersion\":1"), + "schemaVersion should flatten: {s}" + ); + // `extras` is None, so the envelope contributes no `extras` + // key. Stays out of the row to keep the small/fast path. + assert!(!s.contains("\"extras\""), "extras should be omitted: {s}"); + } + + #[test] + fn record_round_trips_optional_fields() { + // An unanswered call has answer_at/duration_ms/error all absent. + let raw = r#"{ + "sourceId": "a", + "accountId": "b", + "direction": "inbound", + "party": "anonymous", + "ringAt": "2026-05-16T10:00:00Z", + "endAt": "2026-05-16T10:00:30Z", + "disposition": "missed", + "endReason": "missed" + }"#; + let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap(); + assert!(parsed.answer_at.is_none()); + assert!(parsed.duration_ms.is_none()); + assert!(parsed.error.is_none()); + assert_eq!(parsed.disposition, VoiceCallDisposition::Missed); + assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed); + } + + #[test] + fn query_omits_unset_fields() { + let q = VoiceCallsQuery::default(); + let s = serde_json::to_string(&q).unwrap(); + // Empty object — every field skipped when None. + assert_eq!( + s, "{}", + "default query should serialize to empty object: {s}" + ); + } + + #[test] + fn enum_round_trip_via_json() { + // The wire form for each direction/disposition/reason must + // match what the daemon and platform expect — this guards + // against accidental Rust-side renames. + for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] { + let s = serde_json::to_string(&d).unwrap(); + let back: VoiceCallDirection = serde_json::from_str(&s).unwrap(); + assert_eq!(d, back); + } + for d in [ + VoiceCallDisposition::Answered, + VoiceCallDisposition::Missed, + VoiceCallDisposition::Rejected, + VoiceCallDisposition::Cancelled, + VoiceCallDisposition::Failed, + ] { + let s = serde_json::to_string(&d).unwrap(); + let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap(); + assert_eq!(d, back); + } + for r in [ + VoiceCallEndReason::HangupLocal, + VoiceCallEndReason::HangupRemote, + VoiceCallEndReason::RejectedLocal, + VoiceCallEndReason::RejectedRemote, + VoiceCallEndReason::Missed, + VoiceCallEndReason::CancelledLocal, + VoiceCallEndReason::Failed, + ] { + let s = serde_json::to_string(&r).unwrap(); + let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap(); + assert_eq!(r, back); + } + } + + #[test] + fn voice_calls_marker_resource_is_calls() { + assert_eq!(::RESOURCE, "calls"); + } + + #[test] + fn record_accepts_unknown_extras_for_forward_compat() { + // A newer client shipping a `notes` field that this platform + // version doesn't have a column for should round-trip via + // the `extras` envelope. The platform persists the blob + // verbatim; a future deploy can promote it to a typed + // column without data loss. + let raw = r#"{ + "sourceId": "a", + "accountId": "b", + "direction": "inbound", + "party": "anon", + "ringAt": "2026-05-16T10:00:00Z", + "endAt": "2026-05-16T10:00:30Z", + "disposition": "answered", + "endReason": "hangup_remote", + "schemaVersion": 2, + "extras": { "notes": "from staging build" } + }"#; + let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap(); + assert_eq!(parsed.envelope.schema_version, Some(2)); + let extras = parsed.envelope.extras.as_ref().expect("extras present"); + assert_eq!(extras["notes"], "from staging build"); + } +}