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