Skip to main content

wavekat_platform_client/
voice.rs

1//! Voice-product resources synced from the desktop daemon up to the
2//! platform.
3//!
4//! The first shipped marker is [`VoiceCalls`] — per-call metadata for
5//! the platform's `/voice/calls` history page (see
6//! `wavekat-voice/docs/21-platform-call-history-sync.md`). Recordings
7//! (`VoiceRecordings`), transcripts (`VoiceTranscripts`), and summaries
8//! will follow the same shape: a marker type, a wire-record struct, and
9//! a typed query — no new HTTP plumbing.
10//!
11//! All wire shapes use camelCase JSON to match the platform's Hono/Zod
12//! convention. The Rust types stay snake_case so consumers feel native.
13
14use serde::{Deserialize, Serialize};
15
16use crate::client::Client;
17use crate::error::{Error, Result};
18use crate::sign::ReleaseCredential;
19use crate::sync::{stamp_schema_version, HasSyncEnvelope, SyncEndpoint, SyncEnvelope, SyncRequest};
20
21/// Inbound vs. outbound. Wire-stable snake_case strings — never
22/// renumber or rename. New states (e.g. `internal`) would be a wire
23/// addition, not a replacement.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum VoiceCallDirection {
27    Inbound,
28    Outbound,
29}
30
31/// User-visible disposition. Derived from [`VoiceCallEndReason`] by the
32/// daemon; the platform stores both, so future UI surfaces can read
33/// either without re-deriving.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum VoiceCallDisposition {
37    Answered,
38    Missed,
39    Rejected,
40    Cancelled,
41    Failed,
42}
43
44/// Finer-grained terminal reason — kept distinct from
45/// [`VoiceCallDisposition`] because the disposition collapses
46/// `hangup_local` and `hangup_remote` to `Answered`, losing the
47/// "who hung up?" answer the row otherwise carries.
48///
49/// Wire-stable snake_case strings; the daemon's matching enum is the
50/// canonical source.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum VoiceCallEndReason {
54    HangupLocal,
55    HangupRemote,
56    RejectedLocal,
57    RejectedRemote,
58    Missed,
59    CancelledLocal,
60    Failed,
61}
62
63/// One historical call as it crosses the wire from the daemon up to the
64/// platform.
65///
66/// Mirrors the daemon's local `CallRecord` (see
67/// `wavekat-voice/crates/wavekat-voice/src/db.rs`) with one rename:
68/// the daemon's local primary key (`id`) is shipped as `source_id`
69/// because the platform allocates its own row id and treats the
70/// daemon-side UUID as the idempotency key.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct VoiceCallRecord {
74    /// Daemon-generated UUID. The platform's `(user_id, source_id)`
75    /// upsert key — re-syncing the same id is a no-op.
76    pub source_id: String,
77    /// SIP account UUID on the daemon side. Opaque to the platform.
78    pub account_id: String,
79    pub direction: VoiceCallDirection,
80    /// SIP `From:` (inbound) or `To:` (outbound). Free text — caller
81    /// IDs, display names, and SIP URIs all land here.
82    pub party: String,
83    /// RFC 3339. First ring (inbound) or first dial-out (outbound).
84    pub ring_at: String,
85    /// RFC 3339. Present only when the call reached the answered
86    /// state.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub answer_at: Option<String>,
89    /// RFC 3339. Terminal timestamp; the platform uses this as the
90    /// list cursor.
91    pub end_at: String,
92    /// `answer_at` → `end_at` in milliseconds. `None` for calls that
93    /// were never answered.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub duration_ms: Option<i64>,
96    pub disposition: VoiceCallDisposition,
97    pub end_reason: VoiceCallEndReason,
98    /// Free-text error, populated only when `disposition == Failed`.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub error: Option<String>,
101    /// Version + forward-compat fields shared by every sync record.
102    /// Flattened so `schemaVersion` and `extras` sit at the top of
103    /// the JSON object alongside the other columns. See
104    /// [`SyncEnvelope`] and doc 21 §"Versioning and forward
105    /// compatibility".
106    #[serde(flatten, default)]
107    pub envelope: SyncEnvelope,
108}
109
110/// Query params for `GET /api/voice/calls`. All fields optional — the
111/// default returns the newest page.
112#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct VoiceCallsQuery {
115    /// RFC 3339 cursor; rows with `end_at < before` are returned.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub before: Option<String>,
118    /// 1..=200. Server default is 50.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub limit: Option<u32>,
121}
122
123/// Marker for the `/api/voice/calls/{sync,list}` endpoint pair.
124///
125/// Use as a type parameter, never construct: `client.sync::<VoiceCalls>(&items)`.
126pub struct VoiceCalls;
127
128impl SyncEndpoint for VoiceCalls {
129    const RESOURCE: &'static str = "calls";
130    type Record = VoiceCallRecord;
131    type Query = VoiceCallsQuery;
132}
133
134impl HasSyncEnvelope for VoiceCallRecord {
135    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
136        &mut self.envelope
137    }
138}
139
140// ---- VoiceRecordings ------------------------------------------------------
141
142/// One per-call recording's metadata as it crosses the wire from the
143/// daemon up to the platform. The WAV bytes ride on a separate
144/// follow-up call ([`Client::upload_recording_bytes`]) so the
145/// idempotent metadata sync stays small and a flaky bytes upload
146/// doesn't force the daemon to re-ship the row.
147///
148/// Mirrors the daemon's `RecordingArtifact` (see
149/// `wavekat-voice/crates/wavekat-voice/src/recording.rs`) with one
150/// rename: the daemon's local id (`id`) ships as `source_id` because
151/// the platform allocates its own row id and treats the daemon-side
152/// UUID as the idempotency key (same convention as
153/// [`VoiceCallRecord`]).
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct VoiceRecordingRecord {
157    /// Daemon-generated UUID for this recording artifact. Upsert key
158    /// on the platform side.
159    pub source_id: String,
160    /// Daemon's `calls.id` — the call this recording belongs to.
161    /// The platform stores both so the /voice/calls history page can
162    /// link a call to its recording without a separate join table.
163    pub call_source_id: String,
164    /// Byte length of the WAV file the daemon will PUT in the follow-
165    /// up bytes call. The platform refuses a PUT whose body length
166    /// disagrees.
167    pub size_bytes: u64,
168    pub duration_ms: u64,
169    pub sample_rate: u32,
170    pub channels: u16,
171    /// RFC 3339 timestamp the daemon stamped on the artifact at
172    /// finalize time. Drives the platform's `/voice/recordings` GET
173    /// cursor.
174    pub created_at: String,
175    #[serde(flatten, default)]
176    pub envelope: SyncEnvelope,
177}
178
179/// Query params for `GET /api/voice/recordings`.
180#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct VoiceRecordingsQuery {
183    /// RFC 3339 cursor; rows with `created_at < before` are returned.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub before: Option<String>,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub limit: Option<u32>,
188}
189
190/// Marker for the `/api/voice/recordings/{sync,list}` endpoint pair.
191///
192/// The corresponding bytes-upload endpoint
193/// (`PUT /api/voice/recordings/{sourceId}/bytes`) is invoked via
194/// [`Client::upload_recording_bytes`] — it doesn't fit the
195/// `SyncEndpoint` mold (no batch, no JSON body) so it has its own
196/// inherent method on `Client`.
197pub struct VoiceRecordings;
198
199impl SyncEndpoint for VoiceRecordings {
200    const RESOURCE: &'static str = "recordings";
201    type Record = VoiceRecordingRecord;
202    type Query = VoiceRecordingsQuery;
203}
204
205impl HasSyncEnvelope for VoiceRecordingRecord {
206    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
207        &mut self.envelope
208    }
209}
210
211/// One item in the platform's response to
212/// `POST /api/voice/recordings/sync`. Lets the daemon learn the R2
213/// key the platform stamped (so a subsequent bytes PUT can target it)
214/// without re-deriving it, and check whether bytes have already
215/// landed on a prior cycle (so the daemon can mark the local row
216/// synced without re-uploading the WAV).
217#[derive(Debug, Clone, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase")]
219pub struct VoiceRecordingSyncItem {
220    pub source_id: String,
221    pub r2_key: String,
222    pub bytes_uploaded: bool,
223}
224
225/// Full response from `POST /api/voice/recordings/sync`. Superset of
226/// the generic [`crate::SyncResponse`] — see [`Client::sync_recordings`].
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(rename_all = "camelCase")]
229pub struct VoiceRecordingsSyncResponse {
230    pub accepted: u32,
231    pub skipped: u32,
232    pub items: Vec<VoiceRecordingSyncItem>,
233}
234
235// ---- VoiceTranscripts -----------------------------------------------------
236
237/// Wire-stable transcript channel tag. Matches the daemon's
238/// `TranscriptChannelLabel` and `events::TranscriptChannel`.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241pub enum VoiceTranscriptChannel {
242    /// Local mic audio — what the user said.
243    Local,
244    /// Received RTP audio — what the remote party said.
245    Remote,
246}
247
248/// One ASR transcript segment ("final" in wavekat-asr parlance) as it
249/// crosses the wire. Each segment is a row on the daemon side
250/// (`transcripts` table); the daemon batches a slice of them per
251/// upload and the platform upserts per (user_id, source_id).
252#[derive(Debug, Clone, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct VoiceTranscriptRecord {
255    /// Daemon-side row id, formatted as text (the column is an
256    /// autoincrement integer on SQLite). Stable per (call, segment)
257    /// so re-shipping converges.
258    pub source_id: String,
259    /// Daemon's `calls.id` — the call this segment belongs to.
260    pub call_source_id: String,
261    pub channel: VoiceTranscriptChannel,
262    /// Start of the segment in milliseconds relative to the start of
263    /// the call's audio stream (not wall-clock).
264    pub ts_ms: i64,
265    /// End of the segment, same reference frame as `ts_ms`.
266    pub end_ms: i64,
267    /// Recognised text. Free-form; the platform stores it verbatim.
268    pub text: String,
269    #[serde(flatten, default)]
270    pub envelope: SyncEnvelope,
271}
272
273/// Query params for `GET /api/voice/transcripts` — required
274/// `call_source_id` (the endpoint refuses a flat list).
275#[derive(Debug, Clone, Default, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct VoiceTranscriptsQuery {
278    pub call_source_id: String,
279}
280
281/// Marker for the `/api/voice/transcripts/{sync,list}` endpoint pair.
282pub struct VoiceTranscripts;
283
284impl SyncEndpoint for VoiceTranscripts {
285    const RESOURCE: &'static str = "transcripts";
286    type Record = VoiceTranscriptRecord;
287    type Query = VoiceTranscriptsQuery;
288}
289
290impl HasSyncEnvelope for VoiceTranscriptRecord {
291    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
292        &mut self.envelope
293    }
294}
295
296// ---- Anonymous install heartbeat ------------------------------------------
297//
298// A first-run / per-launch ping the desktop daemon fires *before* (and
299// independently of) any platform sign-in, so the platform can count
300// installs and track version / OS adoption for users who never sign in.
301// It hits the public, unauthenticated `POST /api/voice/installs/heartbeat`
302// and upserts a row keyed by `install_id` alone (no user) — distinct
303// from the authenticated `voice_clients` heartbeat, which is keyed by
304// `(user, install_id)`.
305//
306// The environment fields (os / os_version / arch / locale) are gathered
307// *here*, inside the client crate, rather than on the consumer side:
308// the daemon only owns the two values this crate genuinely cannot
309// discover — the persisted `install_id` and its own app version.
310
311/// Best-effort snapshot of the host environment, detected at call time.
312/// Every field is best-effort; a probe that fails contributes `None`
313/// (or, for the always-available `os` / `arch`, the compile-time
314/// target) rather than failing the heartbeat.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct SystemInfo {
317    /// `std::env::consts::OS` — `"macos"`, `"windows"`, `"linux"`, …
318    pub os: String,
319    /// Human OS version, e.g. `"15.5.0"`. `None` when the OS probe
320    /// can't determine it.
321    pub os_version: Option<String>,
322    /// `std::env::consts::ARCH` — `"aarch64"`, `"x86_64"`, …
323    pub arch: String,
324    /// BCP-47 system locale, e.g. `"en-NZ"`. `None` when unset /
325    /// undetectable (common for GUI-launched apps on some platforms).
326    pub locale: Option<String>,
327}
328
329impl SystemInfo {
330    /// Probe the current host. Cheap enough to call per heartbeat; we
331    /// don't cache so a locale change between launches is reflected.
332    pub fn detect() -> Self {
333        let os_version = match os_info::get().version() {
334            os_info::Version::Unknown => None,
335            v => Some(v.to_string()),
336        };
337        SystemInfo {
338            os: std::env::consts::OS.to_string(),
339            os_version,
340            arch: std::env::consts::ARCH.to_string(),
341            locale: sys_locale::get_locale(),
342        }
343    }
344}
345
346/// Body of `POST /api/voice/installs/heartbeat`. The daemon supplies
347/// `install_id` + `app_version`; [`Client::install_heartbeat`] fills the
348/// environment fields from [`SystemInfo::detect`].
349#[derive(Debug, Clone, Serialize, Deserialize)]
350#[serde(rename_all = "camelCase")]
351pub struct InstallHeartbeatRequest {
352    /// The daemon's persisted install UUID — the platform's upsert key.
353    pub install_id: String,
354    /// WaveKat Voice's own version (`env!("CARGO_PKG_VERSION")` on the
355    /// daemon side) — *not* this crate's version.
356    pub app_version: String,
357    pub os: String,
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub os_version: Option<String>,
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub arch: Option<String>,
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub locale: Option<String>,
364}
365
366/// The platform's view of an install row, echoed back from a heartbeat.
367#[derive(Debug, Clone, Serialize, Deserialize)]
368#[serde(rename_all = "camelCase")]
369pub struct InstallHeartbeatResponse {
370    pub id: String,
371    pub install_id: String,
372    pub app_version: String,
373    pub os: String,
374    pub os_version: Option<String>,
375    pub arch: Option<String>,
376    pub locale: Option<String>,
377    pub first_seen_at: String,
378    pub last_seen_at: String,
379}
380
381impl Client {
382    /// `POST /api/voice/installs/heartbeat` — the anonymous, no-auth
383    /// first-run install ping. Detects the host environment internally
384    /// and posts it alongside the caller-supplied `install_id` +
385    /// `app_version`. Associated (not a method) because the endpoint is
386    /// unauthenticated — there's no token, and at first run there's no
387    /// signed-in `Client` to hang it off of.
388    ///
389    /// Though unauthenticated, the request is **signed** with the release
390    /// credential `cred` (a per-version Ed25519 key + master-issued
391    /// certificate the consumer bakes in at build time) so the platform
392    /// can verify it came from a genuine release and reject forged or
393    /// replayed pings — see [`Client::post_public_signed_json`] and
394    /// [`crate::sign`]. The platform needs only the master *public* key to
395    /// verify.
396    ///
397    /// `base_url` is the platform base (e.g. `https://platform.wavekat.com`).
398    pub async fn install_heartbeat(
399        base_url: &str,
400        install_id: &str,
401        app_version: &str,
402        cred: &ReleaseCredential,
403    ) -> Result<InstallHeartbeatResponse> {
404        let sys = SystemInfo::detect();
405        let body = InstallHeartbeatRequest {
406            install_id: install_id.to_string(),
407            app_version: app_version.to_string(),
408            os: sys.os,
409            os_version: sys.os_version,
410            arch: Some(sys.arch),
411            locale: sys.locale,
412        };
413        Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
414            base_url,
415            "/api/voice/installs/heartbeat",
416            &body,
417            cred,
418        )
419        .await
420    }
421}
422
423// ---- Client surface for recordings ----------------------------------------
424//
425// Recordings don't fit the generic `Client::sync` shape cleanly:
426//
427//   - the response carries per-item provenance (the platform-stamped
428//     `r2Key`, plus whether bytes have already landed) that the
429//     daemon needs in order to decide which rows still owe a PUT;
430//   - the bytes upload is its own HTTP call (`PUT
431//     /api/voice/recordings/{sourceId}/bytes`), not a JSON batch.
432//
433// Rather than overloading `SyncEndpoint` to carry these shapes, we
434// expose two inherent methods on `Client` that compose the existing
435// JSON / bytes-PUT primitives.
436
437impl Client {
438    /// `POST /api/voice/recordings/sync` — idempotent batch upsert of
439    /// recording metadata. Returns the per-item `r2Key` the daemon
440    /// should target for the follow-up bytes PUT, and whether bytes
441    /// have already landed for each row.
442    ///
443    /// Batch sizing rules match [`Client::sync`]: the platform rejects
444    /// batches over 100 items; the daemon's uploader chunks at 50.
445    pub async fn sync_recordings(
446        &self,
447        items: &[VoiceRecordingRecord],
448    ) -> Result<VoiceRecordingsSyncResponse> {
449        let stamped = stamp_schema_version::<VoiceRecordings>(items);
450        let body = SyncRequest { items: stamped };
451        self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
452            .await
453    }
454
455    /// `PUT /api/voice/recordings/{sourceId}/bytes` — upload the WAV
456    /// bytes for a recording whose metadata was previously synced via
457    /// [`Client::sync_recordings`]. The platform refuses (`HTTP 413`)
458    /// if `bytes.len()` disagrees with the synced `sizeBytes`.
459    ///
460    /// `source_id` is path-segmented as-is; callers pass the
461    /// daemon-side UUID they used for the metadata sync. Empty /
462    /// path-traversal-shaped ids are not specifically guarded here —
463    /// the platform's Zod schema rejects them server-side, so a
464    /// malformed id surfaces as a 4xx via [`Error::Http`].
465    pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
466        if source_id.is_empty() {
467            return Err(Error::BadRequest("source_id must not be empty".into()));
468        }
469        let path = format!("/api/voice/recordings/{source_id}/bytes");
470        self.put_raw_bytes(&path, "audio/wav", bytes).await
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn record_serializes_with_camel_case_keys() {
480        let r = VoiceCallRecord {
481            source_id: "11111111-1111-4111-8111-111111111111".into(),
482            account_id: "22222222-2222-4222-8222-222222222222".into(),
483            direction: VoiceCallDirection::Inbound,
484            party: "+14155550123".into(),
485            ring_at: "2026-05-16T10:00:00Z".into(),
486            answer_at: Some("2026-05-16T10:00:05Z".into()),
487            end_at: "2026-05-16T10:01:00Z".into(),
488            duration_ms: Some(55_000),
489            disposition: VoiceCallDisposition::Answered,
490            end_reason: VoiceCallEndReason::HangupRemote,
491            error: None,
492            envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
493        };
494        let s = serde_json::to_string(&r).unwrap();
495        assert!(s.contains("\"sourceId\":"), "{s}");
496        assert!(s.contains("\"accountId\":"), "{s}");
497        assert!(s.contains("\"ringAt\":"), "{s}");
498        assert!(s.contains("\"endAt\":"), "{s}");
499        assert!(s.contains("\"durationMs\":55000"), "{s}");
500        // Optional `error` is None — should be omitted from the wire.
501        assert!(!s.contains("\"error\""), "error should be omitted: {s}");
502        // Envelope flattens to the top of the object — schemaVersion
503        // sits next to the other fields rather than nested under
504        // "envelope". Future resources rely on this layout.
505        assert!(
506            s.contains("\"schemaVersion\":1"),
507            "schemaVersion should flatten: {s}"
508        );
509        // `extras` is None, so the envelope contributes no `extras`
510        // key. Stays out of the row to keep the small/fast path.
511        assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
512    }
513
514    #[test]
515    fn record_round_trips_optional_fields() {
516        // An unanswered call has answer_at/duration_ms/error all absent.
517        let raw = r#"{
518            "sourceId": "a",
519            "accountId": "b",
520            "direction": "inbound",
521            "party": "anonymous",
522            "ringAt": "2026-05-16T10:00:00Z",
523            "endAt": "2026-05-16T10:00:30Z",
524            "disposition": "missed",
525            "endReason": "missed"
526        }"#;
527        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
528        assert!(parsed.answer_at.is_none());
529        assert!(parsed.duration_ms.is_none());
530        assert!(parsed.error.is_none());
531        assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
532        assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
533    }
534
535    #[test]
536    fn query_omits_unset_fields() {
537        let q = VoiceCallsQuery::default();
538        let s = serde_json::to_string(&q).unwrap();
539        // Empty object — every field skipped when None.
540        assert_eq!(
541            s, "{}",
542            "default query should serialize to empty object: {s}"
543        );
544    }
545
546    #[test]
547    fn enum_round_trip_via_json() {
548        // The wire form for each direction/disposition/reason must
549        // match what the daemon and platform expect — this guards
550        // against accidental Rust-side renames.
551        for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
552            let s = serde_json::to_string(&d).unwrap();
553            let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
554            assert_eq!(d, back);
555        }
556        for d in [
557            VoiceCallDisposition::Answered,
558            VoiceCallDisposition::Missed,
559            VoiceCallDisposition::Rejected,
560            VoiceCallDisposition::Cancelled,
561            VoiceCallDisposition::Failed,
562        ] {
563            let s = serde_json::to_string(&d).unwrap();
564            let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
565            assert_eq!(d, back);
566        }
567        for r in [
568            VoiceCallEndReason::HangupLocal,
569            VoiceCallEndReason::HangupRemote,
570            VoiceCallEndReason::RejectedLocal,
571            VoiceCallEndReason::RejectedRemote,
572            VoiceCallEndReason::Missed,
573            VoiceCallEndReason::CancelledLocal,
574            VoiceCallEndReason::Failed,
575        ] {
576            let s = serde_json::to_string(&r).unwrap();
577            let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
578            assert_eq!(r, back);
579        }
580    }
581
582    #[test]
583    fn voice_calls_marker_resource_is_calls() {
584        assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
585    }
586
587    #[test]
588    fn record_accepts_unknown_extras_for_forward_compat() {
589        // A newer client shipping a `notes` field that this platform
590        // version doesn't have a column for should round-trip via
591        // the `extras` envelope. The platform persists the blob
592        // verbatim; a future deploy can promote it to a typed
593        // column without data loss.
594        let raw = r#"{
595            "sourceId": "a",
596            "accountId": "b",
597            "direction": "inbound",
598            "party": "anon",
599            "ringAt": "2026-05-16T10:00:00Z",
600            "endAt": "2026-05-16T10:00:30Z",
601            "disposition": "answered",
602            "endReason": "hangup_remote",
603            "schemaVersion": 2,
604            "extras": { "notes": "from staging build" }
605        }"#;
606        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
607        assert_eq!(parsed.envelope.schema_version, Some(2));
608        let extras = parsed.envelope.extras.as_ref().expect("extras present");
609        assert_eq!(extras["notes"], "from staging build");
610    }
611
612    #[test]
613    fn recording_marker_resource_is_recordings() {
614        // Path constant drives the URL in `Client::sync_recordings`;
615        // a rename here would silently 404 against the platform.
616        assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
617    }
618
619    #[test]
620    fn recording_record_serializes_with_camel_case_and_envelope() {
621        let r = VoiceRecordingRecord {
622            source_id: "11111111-1111-4111-8111-111111111111".into(),
623            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
624            size_bytes: 44 + 64_000,
625            duration_ms: 2_000,
626            sample_rate: 8_000,
627            channels: 2,
628            created_at: "2026-05-16T10:01:05Z".into(),
629            envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
630        };
631        let s = serde_json::to_string(&r).unwrap();
632        // Field-by-field wire contract — these strings are also what
633        // the platform's Zod schema expects.
634        assert!(s.contains("\"sourceId\":"), "{s}");
635        assert!(s.contains("\"callSourceId\":"), "{s}");
636        assert!(s.contains("\"sizeBytes\":64044"), "{s}");
637        assert!(s.contains("\"durationMs\":2000"), "{s}");
638        assert!(s.contains("\"sampleRate\":8000"), "{s}");
639        assert!(s.contains("\"channels\":2"), "{s}");
640        assert!(s.contains("\"createdAt\":"), "{s}");
641        // Envelope flattens to the top of the object, same as VoiceCallRecord.
642        assert!(s.contains("\"schemaVersion\":1"), "{s}");
643    }
644
645    #[test]
646    fn recordings_sync_response_round_trips() {
647        // The richer-than-generic response carries per-item provenance —
648        // the daemon's uploader reads `r2Key` for the bytes follow-up
649        // and `bytesUploaded` to short-circuit when the row already
650        // landed on a previous cycle.
651        let raw = r#"{
652            "accepted": 2,
653            "skipped": 0,
654            "items": [
655                {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
656                {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
657            ]
658        }"#;
659        let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
660        assert_eq!(parsed.accepted, 2);
661        assert_eq!(parsed.items.len(), 2);
662        assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
663        assert!(!parsed.items[0].bytes_uploaded);
664        assert!(parsed.items[1].bytes_uploaded);
665    }
666
667    #[test]
668    fn install_heartbeat_request_serializes_with_camel_case_keys() {
669        let req = InstallHeartbeatRequest {
670            install_id: "11111111-1111-4111-8111-111111111111".into(),
671            app_version: "0.0.21".into(),
672            os: "macos".into(),
673            os_version: Some("15.5.0".into()),
674            arch: Some("aarch64".into()),
675            locale: Some("en-NZ".into()),
676        };
677        let s = serde_json::to_string(&req).unwrap();
678        assert!(s.contains("\"installId\":"), "{s}");
679        assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
680        assert!(s.contains("\"os\":\"macos\""), "{s}");
681        assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
682        assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
683        assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
684    }
685
686    #[test]
687    fn install_heartbeat_request_omits_absent_optional_fields() {
688        // A host where the OS version / locale probe came up empty
689        // shouldn't send `null` — keeping the keys out lets the
690        // platform's Zod `.optional()` accept the body and the column
691        // stay NULL rather than the string "null".
692        let req = InstallHeartbeatRequest {
693            install_id: "x".into(),
694            app_version: "0.0.21".into(),
695            os: "linux".into(),
696            os_version: None,
697            arch: None,
698            locale: None,
699        };
700        let s = serde_json::to_string(&req).unwrap();
701        assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
702        assert!(!s.contains("arch"), "arch should be omitted: {s}");
703        assert!(!s.contains("locale"), "locale should be omitted: {s}");
704    }
705
706    #[test]
707    fn install_heartbeat_response_parses_platform_shape() {
708        let raw = r#"{
709            "id": "abc-123",
710            "installId": "11111111-1111-4111-8111-111111111111",
711            "appVersion": "0.0.21",
712            "os": "macos",
713            "osVersion": "15.5.0",
714            "arch": "aarch64",
715            "locale": null,
716            "firstSeenAt": "2026-05-31T10:00:00.000Z",
717            "lastSeenAt": "2026-05-31T10:00:00.000Z"
718        }"#;
719        let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
720        assert_eq!(parsed.id, "abc-123");
721        assert_eq!(parsed.app_version, "0.0.21");
722        assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
723        assert!(parsed.locale.is_none());
724    }
725
726    #[test]
727    fn system_info_detect_fills_os_and_arch() {
728        // os / arch come from compile-time consts, so they're always
729        // non-empty on every supported target. os_version / locale are
730        // best-effort and intentionally not asserted.
731        let sys = SystemInfo::detect();
732        assert!(!sys.os.is_empty(), "os should be a non-empty target string");
733        assert!(
734            !sys.arch.is_empty(),
735            "arch should be a non-empty target string"
736        );
737    }
738
739    #[test]
740    fn transcripts_marker_resource_is_transcripts() {
741        assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
742    }
743
744    #[test]
745    fn transcript_record_serializes_with_camel_case_and_channel_enum() {
746        let r = VoiceTranscriptRecord {
747            source_id: "1".into(),
748            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
749            channel: VoiceTranscriptChannel::Remote,
750            ts_ms: 100,
751            end_ms: 1_500,
752            text: "hello".into(),
753            envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
754        };
755        let s = serde_json::to_string(&r).unwrap();
756        assert!(s.contains("\"sourceId\":"), "{s}");
757        assert!(s.contains("\"callSourceId\":"), "{s}");
758        // The channel enum is wire-stable snake_case — matches the
759        // platform's Zod `enum(VOICE_TRANSCRIPT_CHANNELS)`.
760        assert!(s.contains("\"channel\":\"remote\""), "{s}");
761        assert!(s.contains("\"tsMs\":100"), "{s}");
762        assert!(s.contains("\"endMs\":1500"), "{s}");
763        assert!(s.contains("\"text\":\"hello\""), "{s}");
764        assert!(s.contains("\"schemaVersion\":1"), "{s}");
765    }
766}