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    /// We blind-transferred the call to a third party (RFC 3515) and
61    /// dropped our own leg once the target answered. Distinct from
62    /// `HangupLocal`: the user didn't hang up, they handed the call off.
63    /// The destination is carried alongside in
64    /// [`VoiceCallRecord::transfer_target`]. Rows with this reason still
65    /// carry [`VoiceCallDisposition::Answered`].
66    TransferredLocal,
67    /// An established call torn down because its connection died —
68    /// the daemon's RFC 4028 session keepalive stopped getting
69    /// answers (peer crashed, NAT binding dropped). Distinct from
70    /// `HangupLocal`: the user didn't end this call. Rows with this
71    /// reason still carry [`VoiceCallDisposition::Answered`].
72    ConnectionLost,
73    Failed,
74}
75
76/// The audio codec a call negotiated, stamped once audio flows. Wire-
77/// stable snake_case strings matching the daemon's `CallCodec` enum —
78/// the platform validates against this exact list, so a rename here
79/// would bounce every upload with a 400. New codecs (e.g. `ilbc`) are
80/// wire additions, not replacements.
81///
82/// Consumers render this as a quality tier ("HD" for Opus, "Standard"
83/// for the G.711 pair), not the codec name alone — see the desktop
84/// client's call-details page for the canonical presentation.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum VoiceCallCodec {
88    /// Opus wideband (16 kHz) — the "HD" tier.
89    Opus,
90    /// G.711 µ-law — the narrowband "Standard" tier.
91    Pcmu,
92    /// G.711 A-law — the narrowband "Standard" tier.
93    Pcma,
94}
95
96/// One historical call as it crosses the wire from the daemon up to the
97/// platform.
98///
99/// Mirrors the daemon's local `CallRecord` (see
100/// `wavekat-voice/crates/wavekat-voice/src/db.rs`) with one rename:
101/// the daemon's local primary key (`id`) is shipped as `source_id`
102/// because the platform allocates its own row id and treats the
103/// daemon-side UUID as the idempotency key.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase")]
106pub struct VoiceCallRecord {
107    /// Daemon-generated UUID. The platform's `(user_id, source_id)`
108    /// upsert key — re-syncing the same id is a no-op.
109    pub source_id: String,
110    /// SIP account UUID on the daemon side. Opaque to the platform.
111    pub account_id: String,
112    pub direction: VoiceCallDirection,
113    /// SIP `From:` (inbound) or `To:` (outbound). Free text — caller
114    /// IDs, display names, and SIP URIs all land here.
115    pub party: String,
116    /// RFC 3339. First ring (inbound) or first dial-out (outbound).
117    pub ring_at: String,
118    /// RFC 3339. Present only when the call reached the answered
119    /// state.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub answer_at: Option<String>,
122    /// RFC 3339. Terminal timestamp; the platform uses this as the
123    /// list cursor.
124    pub end_at: String,
125    /// `answer_at` → `end_at` in milliseconds. `None` for calls that
126    /// were never answered.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub duration_ms: Option<i64>,
129    pub disposition: VoiceCallDisposition,
130    pub end_reason: VoiceCallEndReason,
131    /// Free-text error, populated only when `disposition == Failed`.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub error: Option<String>,
134    /// Visibility tier of any *active* (not revoked / expired) share on this
135    /// call's recording, or `None` when it isn't shared. Read-only: the
136    /// platform sets it on list (`GET /api/voice/calls`) and detail responses
137    /// so a consumer can badge the row "Public" / "Invited only"; it is
138    /// skipped on serialize, so syncing a call never sends it. `Private` never
139    /// appears here — an unshared call is `None`.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub share_visibility: Option<ShareVisibility>,
142    /// Where a transferred call was sent — the number or SIP address the
143    /// far end was asked to call (RFC 3515 `Refer-To`). Set only when
144    /// `end_reason == TransferredLocal`; `None` for every other call.
145    /// Unlike `share_visibility` this is daemon-owned data, so it *is*
146    /// sent on sync (serialized when present) and echoed back on read.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub transfer_target: Option<String>,
149    /// The negotiated audio codec, present when the call reached the
150    /// audio-flowing state on a daemon new enough to record it; `None`
151    /// for never-answered calls and rows synced by older daemons. Like
152    /// `transfer_target` this is daemon-owned data, so it *is* sent on
153    /// sync (serialized when present) and echoed back on read.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub codec: Option<VoiceCallCodec>,
156    /// Version + forward-compat fields shared by every sync record.
157    /// Flattened so `schemaVersion` and `extras` sit at the top of
158    /// the JSON object alongside the other columns. See
159    /// [`SyncEnvelope`] and doc 21 §"Versioning and forward
160    /// compatibility".
161    #[serde(flatten, default)]
162    pub envelope: SyncEnvelope,
163}
164
165/// Query params for `GET /api/voice/calls`. All fields optional — the
166/// default returns the newest page.
167#[derive(Debug, Clone, Default, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct VoiceCallsQuery {
170    /// RFC 3339 cursor; rows with `end_at < before` are returned.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub before: Option<String>,
173    /// 1..=200. Server default is 50.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub limit: Option<u32>,
176}
177
178/// Marker for the `/api/voice/calls/{sync,list}` endpoint pair.
179///
180/// Use as a type parameter, never construct: `client.sync::<VoiceCalls>(&items)`.
181pub struct VoiceCalls;
182
183impl SyncEndpoint for VoiceCalls {
184    const RESOURCE: &'static str = "calls";
185    type Record = VoiceCallRecord;
186    type Query = VoiceCallsQuery;
187}
188
189impl HasSyncEnvelope for VoiceCallRecord {
190    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
191        &mut self.envelope
192    }
193}
194
195// ---- VoiceRecordings ------------------------------------------------------
196
197/// One per-call recording's metadata as it crosses the wire from the
198/// daemon up to the platform. The WAV bytes ride on a separate
199/// follow-up call ([`Client::upload_recording_bytes`]) so the
200/// idempotent metadata sync stays small and a flaky bytes upload
201/// doesn't force the daemon to re-ship the row.
202///
203/// Mirrors the daemon's `RecordingArtifact` (see
204/// `wavekat-voice/crates/wavekat-voice/src/recording.rs`) with one
205/// rename: the daemon's local id (`id`) ships as `source_id` because
206/// the platform allocates its own row id and treats the daemon-side
207/// UUID as the idempotency key (same convention as
208/// [`VoiceCallRecord`]).
209#[derive(Debug, Clone, Serialize, Deserialize)]
210#[serde(rename_all = "camelCase")]
211pub struct VoiceRecordingRecord {
212    /// Daemon-generated UUID for this recording artifact. Upsert key
213    /// on the platform side.
214    pub source_id: String,
215    /// Daemon's `calls.id` — the call this recording belongs to.
216    /// The platform stores both so the /voice/calls history page can
217    /// link a call to its recording without a separate join table.
218    pub call_source_id: String,
219    /// Byte length of the WAV file the daemon will PUT in the follow-
220    /// up bytes call. The platform refuses a PUT whose body length
221    /// disagrees.
222    pub size_bytes: u64,
223    pub duration_ms: u64,
224    pub sample_rate: u32,
225    pub channels: u16,
226    /// RFC 3339 timestamp the daemon stamped on the artifact at
227    /// finalize time. Drives the platform's `/voice/recordings` GET
228    /// cursor.
229    pub created_at: String,
230    #[serde(flatten, default)]
231    pub envelope: SyncEnvelope,
232}
233
234/// Query params for `GET /api/voice/recordings`.
235#[derive(Debug, Clone, Default, Serialize, Deserialize)]
236#[serde(rename_all = "camelCase")]
237pub struct VoiceRecordingsQuery {
238    /// RFC 3339 cursor; rows with `created_at < before` are returned.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub before: Option<String>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub limit: Option<u32>,
243}
244
245/// Marker for the `/api/voice/recordings/{sync,list}` endpoint pair.
246///
247/// The corresponding bytes-upload endpoint
248/// (`PUT /api/voice/recordings/{sourceId}/bytes`) is invoked via
249/// [`Client::upload_recording_bytes`] — it doesn't fit the
250/// `SyncEndpoint` mold (no batch, no JSON body) so it has its own
251/// inherent method on `Client`.
252pub struct VoiceRecordings;
253
254impl SyncEndpoint for VoiceRecordings {
255    const RESOURCE: &'static str = "recordings";
256    type Record = VoiceRecordingRecord;
257    type Query = VoiceRecordingsQuery;
258}
259
260impl HasSyncEnvelope for VoiceRecordingRecord {
261    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
262        &mut self.envelope
263    }
264}
265
266/// One item in the platform's response to
267/// `POST /api/voice/recordings/sync`. Lets the daemon learn the R2
268/// key the platform stamped (so a subsequent bytes PUT can target it)
269/// without re-deriving it, and check whether bytes have already
270/// landed on a prior cycle (so the daemon can mark the local row
271/// synced without re-uploading the WAV).
272#[derive(Debug, Clone, Serialize, Deserialize)]
273#[serde(rename_all = "camelCase")]
274pub struct VoiceRecordingSyncItem {
275    pub source_id: String,
276    pub r2_key: String,
277    pub bytes_uploaded: bool,
278}
279
280/// Full response from `POST /api/voice/recordings/sync`. Superset of
281/// the generic [`crate::SyncResponse`] — see [`Client::sync_recordings`].
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct VoiceRecordingsSyncResponse {
285    pub accepted: u32,
286    pub skipped: u32,
287    pub items: Vec<VoiceRecordingSyncItem>,
288}
289
290// ---- VoiceTranscripts -----------------------------------------------------
291
292/// Wire-stable transcript channel tag. Matches the daemon's
293/// `TranscriptChannelLabel` and `events::TranscriptChannel`.
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
295#[serde(rename_all = "snake_case")]
296pub enum VoiceTranscriptChannel {
297    /// Local mic audio — what the user said.
298    Local,
299    /// Received RTP audio — what the remote party said.
300    Remote,
301}
302
303/// One ASR transcript segment ("final" in wavekat-asr parlance) as it
304/// crosses the wire. Each segment is a row on the daemon side
305/// (`transcripts` table); the daemon batches a slice of them per
306/// upload and the platform upserts per (user_id, source_id).
307#[derive(Debug, Clone, Serialize, Deserialize)]
308#[serde(rename_all = "camelCase")]
309pub struct VoiceTranscriptRecord {
310    /// Daemon-side row id, formatted as text (the column is an
311    /// autoincrement integer on SQLite). Stable per (call, segment)
312    /// so re-shipping converges.
313    pub source_id: String,
314    /// Daemon's `calls.id` — the call this segment belongs to.
315    pub call_source_id: String,
316    pub channel: VoiceTranscriptChannel,
317    /// Start of the segment in milliseconds relative to the start of
318    /// the call's audio stream (not wall-clock).
319    pub ts_ms: i64,
320    /// End of the segment, same reference frame as `ts_ms`.
321    pub end_ms: i64,
322    /// Recognised text. Free-form; the platform stores it verbatim.
323    pub text: String,
324    #[serde(flatten, default)]
325    pub envelope: SyncEnvelope,
326}
327
328/// Query params for `GET /api/voice/transcripts` — required
329/// `call_source_id` (the endpoint refuses a flat list).
330#[derive(Debug, Clone, Default, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase")]
332pub struct VoiceTranscriptsQuery {
333    pub call_source_id: String,
334}
335
336/// Marker for the `/api/voice/transcripts/{sync,list}` endpoint pair.
337pub struct VoiceTranscripts;
338
339impl SyncEndpoint for VoiceTranscripts {
340    const RESOURCE: &'static str = "transcripts";
341    type Record = VoiceTranscriptRecord;
342    type Query = VoiceTranscriptsQuery;
343}
344
345impl HasSyncEnvelope for VoiceTranscriptRecord {
346    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
347        &mut self.envelope
348    }
349}
350
351// ---- VoiceAccounts --------------------------------------------------------
352
353/// SIP transport for a synced account line. Wire-stable snake_case;
354/// mirrors the daemon's `TransportKind`.
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
356#[serde(rename_all = "snake_case")]
357pub enum VoiceTransport {
358    Udp,
359    Tcp,
360}
361
362/// One SIP account line's *configuration* as it crosses the wire from a
363/// device up to the platform and back down to another device
364/// (`wavekat-voice/docs/40-account-config-sync.md`).
365///
366/// Unlike calls / recordings / transcripts — which are immutable,
367/// one-way pushes — account config is **mutable and bidirectional**: a
368/// line is edited, toggled, renamed, and deleted, and those changes must
369/// restore onto a second device. The same idempotent
370/// `(user_id, source_id)` upsert that [`Client::sync`] performs carries
371/// every kind of change here; a *delete* is a soft-delete that rides as
372/// an upsert with `deleted_at` set, because a hard DELETE can't sync
373/// under a "push the row" model — once the row is gone there's nothing
374/// left to push.
375///
376/// **No secret field, by construction.** The SIP password never appears
377/// on this wire. Config sync (policy levels 1–2) keeps the credential
378/// device-local, and the end-to-end-encrypted secret path (level 3)
379/// ships its ciphertext through a *separate* opaque resource, never as a
380/// field here. Omitting it means level 3 can't be populated by accident
381/// before it exists.
382#[derive(Debug, Clone, Serialize, Deserialize)]
383#[serde(rename_all = "camelCase")]
384pub struct VoiceAccountRecord {
385    /// Daemon-side account UUID (`accounts.id`). The platform's
386    /// `(user_id, source_id)` upsert key — re-syncing the same id
387    /// updates the row in place (mutable), unlike the immutable
388    /// resources where a re-sync is a no-op.
389    pub source_id: String,
390    /// Whether the line registers on daemon boot. Pausing a line is a
391    /// portable preference, so it rides along.
392    pub enabled: bool,
393    pub display_name: String,
394    pub username: String,
395    pub domain: String,
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub auth_username: Option<String>,
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub server: Option<String>,
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub port: Option<u16>,
402    pub transport: VoiceTransport,
403    pub register_expires: u32,
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub keepalive_secs: Option<u32>,
406    /// Record-disclosure beep toggle — a column on the account row, so
407    /// it rides along for free (the account-portable taxonomy in doc 40).
408    pub disclosure_enabled: bool,
409    /// RFC 3339 last-modification time — the **last-write-wins key**. On
410    /// conflict the platform (and a pulling client) keep the copy with
411    /// the later `updated_at`. Whole-row LWW for v1; per-field merge is
412    /// deferred until users actually report lost edits (doc 40).
413    pub updated_at: String,
414    /// RFC 3339 soft-delete tombstone. `None` = live; `Some` = the line
415    /// was deleted on some device at that time. A tombstone syncs like
416    /// any other mutation so the delete propagates to other devices,
417    /// then is reaped locally once confirmed. The platform retains
418    /// tombstones so a late-syncing device still learns about the delete.
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub deleted_at: Option<String>,
421    /// Version + forward-compat fields shared by every sync record.
422    #[serde(flatten, default)]
423    pub envelope: SyncEnvelope,
424}
425
426/// Query params for `GET /api/voice/accounts`. All fields optional.
427#[derive(Debug, Clone, Default, Serialize, Deserialize)]
428#[serde(rename_all = "camelCase")]
429pub struct VoiceAccountsQuery {
430    /// Include soft-deleted tombstones in the response. Absent / false
431    /// returns only live lines — the restore-grade pull a fresh device
432    /// wants. A delta-syncing device sets this `true` to also learn
433    /// about deletes made elsewhere (doc 40).
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub include_deleted: Option<bool>,
436}
437
438/// Marker for the `/api/voice/accounts/{sync,list}` endpoint pair.
439///
440/// Accounts are the first *mutable, bidirectional* sync resource, but
441/// the wire shape is the same idempotent upsert the immutable resources
442/// use — the [`SyncResponse::skipped`](crate::sync::SyncResponse) field
443/// was reserved for exactly this case — so no new HTTP plumbing is
444/// needed: `client.sync::<VoiceAccounts>(&items)` uploads (including
445/// tombstones), `client.list::<VoiceAccounts>(&query)` pulls.
446pub struct VoiceAccounts;
447
448impl SyncEndpoint for VoiceAccounts {
449    const RESOURCE: &'static str = "accounts";
450    type Record = VoiceAccountRecord;
451    type Query = VoiceAccountsQuery;
452}
453
454impl HasSyncEnvelope for VoiceAccountRecord {
455    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
456        &mut self.envelope
457    }
458}
459
460// ---- VoiceFlows (published pull) -------------------------------------------
461//
462// The daemon-facing pull leg of the call-flow ("Receptionist") system —
463// `wavekat-voice/docs/48-ivr-call-flows.md`'s control-plane split. Flows
464// are *authored* on the platform (drafts, publish gate, version
465// history); the daemon only ever reads the published snapshots, caches
466// them locally, and runs them offline. There is no upload direction, so
467// this is not a `SyncEndpoint` (that trait models the `{resource}/sync`
468// + list pair): it's a single typed GET, like the share commands above.
469
470/// One published call-flow snapshot as served by
471/// `GET /api/voice/flows/published`: the latest published version of a
472/// flow the bearer authored. The YAML carries the platform-stamped
473/// `id`/`name`/`version` and is served verbatim — the daemon re-parses
474/// and re-validates it on load rather than trusting the wire.
475#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
476#[serde(rename_all = "camelCase")]
477pub struct VoiceFlowRecord {
478    /// Platform-assigned flow id (`flow_…`), stable across versions.
479    pub id: String,
480    pub name: String,
481    /// Latest published version number (1-based, bumps on publish).
482    pub version: u32,
483    /// The immutable published document, verbatim.
484    pub yaml: String,
485    /// RFC 3339 time this version was published.
486    pub published_at: String,
487}
488
489/// Query params for `GET /api/voice/flows/published`. Cursor-paginated
490/// by flow id ascending; pass the previous page's `next_after` until it
491/// comes back `None` to collect the full set. The full set is what the
492/// daemon's reconcile wants — a cached flow absent from a complete pull
493/// was deleted on the platform.
494#[derive(Debug, Clone, Default, Serialize, Deserialize)]
495#[serde(rename_all = "camelCase")]
496pub struct VoiceFlowsQuery {
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub after: Option<String>,
499    /// Page size, server-capped at 100. `None` = server default (50).
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub limit: Option<u32>,
502}
503
504/// One page of published flow snapshots.
505#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
506#[serde(rename_all = "camelCase")]
507pub struct VoiceFlowsPage {
508    pub items: Vec<VoiceFlowRecord>,
509    /// Cursor for the next page; `None` = end of the set.
510    #[serde(default)]
511    pub next_after: Option<String>,
512}
513
514impl Client {
515    /// `GET /api/voice/flows/published` — one page of the caller's
516    /// published flow snapshots (latest version each). Strictly
517    /// creator-scoped server-side; never returns another user's flows.
518    pub async fn published_flows(&self, query: &VoiceFlowsQuery) -> Result<VoiceFlowsPage> {
519        self.get_json_query::<VoiceFlowsPage, _>("/api/voice/flows/published", query)
520            .await
521    }
522}
523
524// ---- Anonymous install heartbeat ------------------------------------------
525//
526// A first-run / per-launch ping the desktop daemon fires *before* (and
527// independently of) any platform sign-in, so the platform can count
528// installs and track version / OS adoption for users who never sign in.
529// It hits the public, unauthenticated `POST /api/voice/installs/heartbeat`
530// and upserts a row keyed by `install_id` alone (no user) — distinct
531// from the authenticated `voice_clients` heartbeat, which is keyed by
532// `(user, install_id)`.
533//
534// The environment fields (os / os_version / arch / locale) are gathered
535// *here*, inside the client crate, rather than on the consumer side:
536// the daemon only owns the two values this crate genuinely cannot
537// discover — the persisted `install_id` and its own app version.
538
539/// Best-effort snapshot of the host environment, detected at call time.
540/// Every field is best-effort; a probe that fails contributes `None`
541/// (or, for the always-available `os` / `arch`, the compile-time
542/// target) rather than failing the heartbeat.
543#[derive(Debug, Clone, PartialEq, Eq)]
544pub struct SystemInfo {
545    /// `std::env::consts::OS` — `"macos"`, `"windows"`, `"linux"`, …
546    pub os: String,
547    /// Human OS version, e.g. `"15.5.0"`. `None` when the OS probe
548    /// can't determine it.
549    pub os_version: Option<String>,
550    /// `std::env::consts::ARCH` — `"aarch64"`, `"x86_64"`, …
551    pub arch: String,
552    /// BCP-47 system locale, e.g. `"en-NZ"`. `None` when unset /
553    /// undetectable (common for GUI-launched apps on some platforms).
554    pub locale: Option<String>,
555}
556
557impl SystemInfo {
558    /// Probe the current host. Cheap enough to call per heartbeat; we
559    /// don't cache so a locale change between launches is reflected.
560    pub fn detect() -> Self {
561        let os_version = match os_info::get().version() {
562            os_info::Version::Unknown => None,
563            v => Some(v.to_string()),
564        };
565        SystemInfo {
566            os: std::env::consts::OS.to_string(),
567            os_version,
568            arch: std::env::consts::ARCH.to_string(),
569            locale: sys_locale::get_locale(),
570        }
571    }
572}
573
574/// Body of `POST /api/voice/installs/heartbeat`. The daemon supplies
575/// `install_id` + `app_version`; [`Client::install_heartbeat`] fills the
576/// environment fields from [`SystemInfo::detect`].
577#[derive(Debug, Clone, Serialize, Deserialize)]
578#[serde(rename_all = "camelCase")]
579pub struct InstallHeartbeatRequest {
580    /// The daemon's persisted install UUID — the platform's upsert key.
581    pub install_id: String,
582    /// WaveKat Voice's own version (`env!("CARGO_PKG_VERSION")` on the
583    /// daemon side) — *not* this crate's version.
584    pub app_version: String,
585    pub os: String,
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    pub os_version: Option<String>,
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    pub arch: Option<String>,
590    #[serde(default, skip_serializing_if = "Option::is_none")]
591    pub locale: Option<String>,
592}
593
594/// The platform's view of an install row, echoed back from a heartbeat.
595#[derive(Debug, Clone, Serialize, Deserialize)]
596#[serde(rename_all = "camelCase")]
597pub struct InstallHeartbeatResponse {
598    pub id: String,
599    pub install_id: String,
600    pub app_version: String,
601    pub os: String,
602    pub os_version: Option<String>,
603    pub arch: Option<String>,
604    pub locale: Option<String>,
605    pub first_seen_at: String,
606    pub last_seen_at: String,
607}
608
609impl Client {
610    /// `POST /api/voice/installs/heartbeat` — the anonymous, no-auth
611    /// first-run install ping. Detects the host environment internally
612    /// and posts it alongside the caller-supplied `install_id` +
613    /// `app_version`. Associated (not a method) because the endpoint is
614    /// unauthenticated — there's no token, and at first run there's no
615    /// signed-in `Client` to hang it off of.
616    ///
617    /// Though unauthenticated, the request is **signed** with the release
618    /// credential `cred` (a per-version Ed25519 key + master-issued
619    /// certificate the consumer bakes in at build time) so the platform
620    /// can verify it came from a genuine release and reject forged or
621    /// replayed pings — see [`Client::post_public_signed_json`] and
622    /// [`crate::sign`]. The platform needs only the master *public* key to
623    /// verify.
624    ///
625    /// `base_url` is the platform base (e.g. `https://platform.wavekat.com`).
626    pub async fn install_heartbeat(
627        base_url: &str,
628        install_id: &str,
629        app_version: &str,
630        cred: &ReleaseCredential,
631    ) -> Result<InstallHeartbeatResponse> {
632        let sys = SystemInfo::detect();
633        let body = InstallHeartbeatRequest {
634            install_id: install_id.to_string(),
635            app_version: app_version.to_string(),
636            os: sys.os,
637            os_version: sys.os_version,
638            arch: Some(sys.arch),
639            locale: sys.locale,
640        };
641        Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
642            base_url,
643            "/api/voice/installs/heartbeat",
644            &body,
645            cred,
646        )
647        .await
648    }
649}
650
651// ---- Client surface for recordings ----------------------------------------
652//
653// Recordings don't fit the generic `Client::sync` shape cleanly:
654//
655//   - the response carries per-item provenance (the platform-stamped
656//     `r2Key`, plus whether bytes have already landed) that the
657//     daemon needs in order to decide which rows still owe a PUT;
658//   - the bytes upload is its own HTTP call (`PUT
659//     /api/voice/recordings/{sourceId}/bytes`), not a JSON batch.
660//
661// Rather than overloading `SyncEndpoint` to carry these shapes, we
662// expose two inherent methods on `Client` that compose the existing
663// JSON / bytes-PUT primitives.
664
665impl Client {
666    /// `POST /api/voice/recordings/sync` — idempotent batch upsert of
667    /// recording metadata. Returns the per-item `r2Key` the daemon
668    /// should target for the follow-up bytes PUT, and whether bytes
669    /// have already landed for each row.
670    ///
671    /// Batch sizing rules match [`Client::sync`]: the platform rejects
672    /// batches over 100 items; the daemon's uploader chunks at 50.
673    pub async fn sync_recordings(
674        &self,
675        items: &[VoiceRecordingRecord],
676    ) -> Result<VoiceRecordingsSyncResponse> {
677        let stamped = stamp_schema_version::<VoiceRecordings>(items);
678        let body = SyncRequest { items: stamped };
679        self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
680            .await
681    }
682
683    /// `PUT /api/voice/recordings/{sourceId}/bytes` — upload the WAV
684    /// bytes for a recording whose metadata was previously synced via
685    /// [`Client::sync_recordings`]. The platform refuses (`HTTP 413`)
686    /// if `bytes.len()` disagrees with the synced `sizeBytes`.
687    ///
688    /// `source_id` is path-segmented as-is; callers pass the
689    /// daemon-side UUID they used for the metadata sync. Empty /
690    /// path-traversal-shaped ids are not specifically guarded here —
691    /// the platform's Zod schema rejects them server-side, so a
692    /// malformed id surfaces as a 4xx via [`Error::Http`].
693    pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
694        if source_id.is_empty() {
695            return Err(Error::BadRequest("source_id must not be empty".into()));
696        }
697        let path = format!("/api/voice/recordings/{source_id}/bytes");
698        self.put_raw_bytes(&path, "audio/wav", bytes).await
699    }
700}
701
702// ---- Recording sharing ----------------------------------------------------
703//
704// Sharing is a *command* — mutate one recording's share state and get a
705// result back — not the "batch upsert + cursor list" shape `SyncEndpoint`
706// exists for (see wavekat-voice doc 38). So it's a typed method pair on
707// `Client` (mirroring `whoami` rather than `sync::<E>()`), not a marker.
708//
709// The desktop daemon keeps only a *mirror* of what these return; the
710// platform is authoritative for who may open a share. See
711// `wavekat-voice/docs/38-share-a-recording.md`.
712
713/// Access tier for a shared recording, mirroring Loom's model. Wire-stable
714/// snake_case strings — the platform's Zod schema validates against this
715/// exact list, so a rename here would bounce every share command with a 400.
716///
717/// - `Private` — owner only (the default; "not shared").
718/// - `Restricted` — owner + explicitly invited WaveKat accounts; the
719///   recipient must be signed in as an invited identity ("protected by login").
720/// - `Public` — anyone holding the capability link, no sign-in.
721#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
722#[serde(rename_all = "snake_case")]
723pub enum ShareVisibility {
724    Private,
725    Restricted,
726    Public,
727}
728
729/// How a shared recording's caller/callee identity (the call's `party`) is
730/// exposed to a viewer. Wire-stable snake_case, matching the platform's Zod
731/// enum, so a rename here bounces a share command with a 400.
732///
733/// - `Full` — hidden behind a neutral direction label ("Inbound call").
734/// - `Partial` — best-effort redaction (keeps shape, drops the value).
735/// - `None` — the raw `party` is shown.
736///
737/// Absent on the wire → the platform defaults to `Partial` (identity
738/// masked) — privacy-forward without fully erasing the caller. See
739/// `wavekat-platform` docs/14.
740#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
741#[serde(rename_all = "snake_case")]
742pub enum PartyMasking {
743    Full,
744    Partial,
745    None,
746}
747
748/// Body of `POST /api/voice/recordings/{id}/share` — create or update a
749/// recording's share. The recording must already be synced (metadata +
750/// bytes) or the platform returns 404.
751#[derive(Debug, Clone, Serialize, Deserialize)]
752#[serde(rename_all = "camelCase")]
753pub struct ShareRecordingRequest {
754    /// The artifact UUID, as synced (daemon-side `artifacts.id`). Goes in
755    /// the URL path; carried in the struct so callers pass one value.
756    pub recording_source_id: String,
757    pub visibility: ShareVisibility,
758    /// Restricted tier — the WaveKat-account emails allowed to open the
759    /// share. Ignored (and omitted) for `Private` / `Public`.
760    #[serde(default, skip_serializing_if = "Option::is_none")]
761    pub invited_emails: Option<Vec<String>>,
762    /// Per-share visibility controls (platform docs/14) — what a viewer may
763    /// see. Each is omitted when unset; the platform then applies its
764    /// privacy-forward default (identity masked, transcript hidden, audio
765    /// shown, download off). NB the platform treats the request as the
766    /// *full* desired state, so an omitted control is reset to its default,
767    /// not preserved from a prior share — send all of them when editing an
768    /// existing share's controls.
769    #[serde(default, skip_serializing_if = "Option::is_none")]
770    pub party_masking: Option<PartyMasking>,
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub show_transcript: Option<bool>,
773    #[serde(default, skip_serializing_if = "Option::is_none")]
774    pub show_audio: Option<bool>,
775    /// Whether a viewer may *download* the WAV, distinct from hearing it.
776    /// Off by default and only meaningful while `show_audio` is true — the
777    /// platform forces it off otherwise (you can't save what you can't
778    /// hear). A soft control: it hides the viewer's Download affordance,
779    /// not the bytes a listener already fetches to play.
780    #[serde(default, skip_serializing_if = "Option::is_none")]
781    pub allow_download: Option<bool>,
782    /// Per-channel playback defaults — which side is *audible by default*
783    /// in the viewer's player (docs/14). A call has two channels: `local`
784    /// (the owner's microphone, "your side") and `remote` (the other
785    /// party, "their side"). `true` means that side starts muted; the
786    /// viewer can still un-mute it, and the audio file is unchanged — this
787    /// is only the player's starting state. Each is omitted when unset, in
788    /// which case the platform defaults to audible (`false`). Only
789    /// meaningful while `show_audio` is true; ignored when audio is hidden.
790    #[serde(default, skip_serializing_if = "Option::is_none")]
791    pub default_mute_local: Option<bool>,
792    #[serde(default, skip_serializing_if = "Option::is_none")]
793    pub default_mute_remote: Option<bool>,
794    /// Phase 2 — out-of-band password gate. Omitted when unset.
795    #[serde(default, skip_serializing_if = "Option::is_none")]
796    pub password: Option<String>,
797    /// Phase 2 — RFC 3339 auto-revoke time. Omitted when unset.
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub expires_at: Option<String>,
800}
801
802/// The platform's response to a successful share command. `share_url` is
803/// the full https link the user copies; `token` is the opaque capability
804/// identifier embedded in it (returned separately so the daemon can store
805/// it for display without re-parsing the URL).
806#[derive(Debug, Clone, Serialize, Deserialize)]
807#[serde(rename_all = "camelCase")]
808pub struct ShareRecordingResponse {
809    pub visibility: ShareVisibility,
810    pub token: String,
811    pub share_url: String,
812    /// RFC 3339 — when the recording was first shared.
813    pub shared_at: String,
814    /// Effective visibility controls the platform stored (docs/14). Optional
815    /// for tolerance — a platform predating the feature omits them, in which
816    /// case the daemon should assume the defaults (identity masked, transcript
817    /// hidden, audio shown, download off).
818    #[serde(default, skip_serializing_if = "Option::is_none")]
819    pub party_masking: Option<PartyMasking>,
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub show_transcript: Option<bool>,
822    #[serde(default, skip_serializing_if = "Option::is_none")]
823    pub show_audio: Option<bool>,
824    /// Effective download permission — `show_audio && allow_download`, so
825    /// it's never true when the audio is hidden. Absent on a platform
826    /// predating the control (assume off).
827    #[serde(default, skip_serializing_if = "Option::is_none")]
828    pub allow_download: Option<bool>,
829    /// Effective per-channel playback defaults the platform stored — which
830    /// side starts muted in the viewer's player (docs/14). Absent on a
831    /// platform predating the control (assume audible, `false`).
832    #[serde(default, skip_serializing_if = "Option::is_none")]
833    pub default_mute_local: Option<bool>,
834    #[serde(default, skip_serializing_if = "Option::is_none")]
835    pub default_mute_remote: Option<bool>,
836}
837
838/// The platform's response to `GET /api/voice/recordings/{id}/share` — the
839/// *authoritative* current share state for an owned recording. The POST
840/// reply omits the invited-email list and a local mirror can't reflect a
841/// share changed from another device, so the desktop "who can open this"
842/// panel reads here.
843///
844/// A recording that was never shared (or whose share is revoked / expired)
845/// comes back as [`ShareVisibility::Private`] with the optional fields
846/// absent — the same "not shared" state DELETE leaves behind.
847#[derive(Debug, Clone, Serialize, Deserialize)]
848#[serde(rename_all = "camelCase")]
849pub struct ShareStateResponse {
850    pub visibility: ShareVisibility,
851    /// Absent when `visibility == Private` (nothing is shared).
852    #[serde(default, skip_serializing_if = "Option::is_none")]
853    pub token: Option<String>,
854    #[serde(default, skip_serializing_if = "Option::is_none")]
855    pub share_url: Option<String>,
856    /// RFC 3339 — when the recording was first shared. Absent when private.
857    #[serde(default, skip_serializing_if = "Option::is_none")]
858    pub shared_at: Option<String>,
859    /// The restricted tier's audience (lowercased, de-duped). Present
860    /// (possibly empty) only for [`ShareVisibility::Restricted`].
861    #[serde(default, skip_serializing_if = "Option::is_none")]
862    pub invited_emails: Option<Vec<String>>,
863    /// Per-share visibility controls (docs/14). Present for a live share;
864    /// absent when `Private` (nothing is shared, so no controls apply).
865    #[serde(default, skip_serializing_if = "Option::is_none")]
866    pub party_masking: Option<PartyMasking>,
867    #[serde(default, skip_serializing_if = "Option::is_none")]
868    pub show_transcript: Option<bool>,
869    #[serde(default, skip_serializing_if = "Option::is_none")]
870    pub show_audio: Option<bool>,
871    /// Effective download permission — `show_audio && allow_download`, so
872    /// never true when the audio is hidden. Absent when private.
873    #[serde(default, skip_serializing_if = "Option::is_none")]
874    pub allow_download: Option<bool>,
875    /// Effective per-channel playback defaults — which side starts muted in
876    /// the viewer's player (docs/14). Absent when private.
877    #[serde(default, skip_serializing_if = "Option::is_none")]
878    pub default_mute_local: Option<bool>,
879    #[serde(default, skip_serializing_if = "Option::is_none")]
880    pub default_mute_remote: Option<bool>,
881}
882
883impl Client {
884    /// `POST /api/voice/recordings/{id}/share` — create or update a share
885    /// for an already-synced recording. Returns the capability link + token
886    /// the desktop UI puts on the clipboard.
887    ///
888    /// Per the 404-not-403 ownership rule (doc 21 §"Authorization"), asking
889    /// to share a recording the caller doesn't own surfaces as
890    /// [`Error::Http`] with status 404 — existence doesn't leak.
891    pub async fn share_recording(
892        &self,
893        req: &ShareRecordingRequest,
894    ) -> Result<ShareRecordingResponse> {
895        if req.recording_source_id.is_empty() {
896            return Err(Error::BadRequest(
897                "recording_source_id must not be empty".into(),
898            ));
899        }
900        let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
901        self.post_json::<ShareRecordingResponse, _>(&path, req)
902            .await
903    }
904
905    /// `GET /api/voice/recordings/{id}/share` — read the authoritative
906    /// share state for an owned recording, including the restricted tier's
907    /// invited emails (which the share command's reply omits). Like
908    /// [`share_recording`](Self::share_recording), a recording the caller
909    /// doesn't own surfaces as [`Error::Http`] with status 404.
910    pub async fn get_recording_share(
911        &self,
912        recording_source_id: &str,
913    ) -> Result<ShareStateResponse> {
914        if recording_source_id.is_empty() {
915            return Err(Error::BadRequest(
916                "recording_source_id must not be empty".into(),
917            ));
918        }
919        let path = format!("/api/voice/recordings/{recording_source_id}/share");
920        self.get_json::<ShareStateResponse>(&path).await
921    }
922
923    /// `DELETE /api/voice/recordings/{id}/share` — revoke the share. The
924    /// recording reverts to Private and any outstanding link returns 410.
925    pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
926        if recording_source_id.is_empty() {
927            return Err(Error::BadRequest(
928                "recording_source_id must not be empty".into(),
929            ));
930        }
931        let path = format!("/api/voice/recordings/{recording_source_id}/share");
932        self.delete(&path).await
933    }
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939
940    #[test]
941    fn share_visibility_types_are_reachable_from_the_crate_root() {
942        // Regression for the 0.0.13 gap: `PartyMasking` was added to this
943        // module but left out of the crate-root `pub use voice::{…}`, and the
944        // module is private — so a consumer (`wavekat-voice`) couldn't name
945        // the type to build a `ShareRecordingRequest`. Pin every share-control
946        // type to the root path so dropping one fails to compile here, not in
947        // a downstream crate. The body never runs; reachability is the test.
948        #[allow(dead_code)]
949        fn _reachable() {
950            let _: Option<crate::PartyMasking> = Some(crate::PartyMasking::Partial);
951            let _: Option<crate::ShareVisibility> = Some(crate::ShareVisibility::Public);
952            let _: fn(&crate::ShareRecordingRequest) = |_| {};
953            let _: fn(&crate::ShareRecordingResponse) = |_| {};
954        }
955    }
956
957    #[test]
958    fn record_serializes_with_camel_case_keys() {
959        let r = VoiceCallRecord {
960            source_id: "11111111-1111-4111-8111-111111111111".into(),
961            account_id: "22222222-2222-4222-8222-222222222222".into(),
962            direction: VoiceCallDirection::Inbound,
963            party: "+14155550123".into(),
964            ring_at: "2026-05-16T10:00:00Z".into(),
965            answer_at: Some("2026-05-16T10:00:05Z".into()),
966            end_at: "2026-05-16T10:01:00Z".into(),
967            duration_ms: Some(55_000),
968            disposition: VoiceCallDisposition::Answered,
969            end_reason: VoiceCallEndReason::HangupRemote,
970            error: None,
971            share_visibility: None,
972            transfer_target: None,
973            codec: None,
974            envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
975        };
976        let s = serde_json::to_string(&r).unwrap();
977        assert!(s.contains("\"sourceId\":"), "{s}");
978        assert!(s.contains("\"accountId\":"), "{s}");
979        assert!(s.contains("\"ringAt\":"), "{s}");
980        assert!(s.contains("\"endAt\":"), "{s}");
981        assert!(s.contains("\"durationMs\":55000"), "{s}");
982        // Optional `error` is None — should be omitted from the wire.
983        assert!(!s.contains("\"error\""), "error should be omitted: {s}");
984        // Optional `transferTarget` is None here — omitted from the wire,
985        // exactly like a non-transferred call ships.
986        assert!(
987            !s.contains("\"transferTarget\""),
988            "transferTarget should be omitted: {s}"
989        );
990        // Optional `codec` is None (never-answered call, or an older
991        // daemon) — omitted from the wire, never `null`.
992        assert!(!s.contains("\"codec\""), "codec should be omitted: {s}");
993        // Envelope flattens to the top of the object — schemaVersion
994        // sits next to the other fields rather than nested under
995        // "envelope". Future resources rely on this layout.
996        assert!(
997            s.contains("\"schemaVersion\":1"),
998            "schemaVersion should flatten: {s}"
999        );
1000        // `extras` is None, so the envelope contributes no `extras`
1001        // key. Stays out of the row to keep the small/fast path.
1002        assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
1003    }
1004
1005    #[test]
1006    fn record_round_trips_optional_fields() {
1007        // An unanswered call has answer_at/duration_ms/error all absent.
1008        let raw = r#"{
1009            "sourceId": "a",
1010            "accountId": "b",
1011            "direction": "inbound",
1012            "party": "anonymous",
1013            "ringAt": "2026-05-16T10:00:00Z",
1014            "endAt": "2026-05-16T10:00:30Z",
1015            "disposition": "missed",
1016            "endReason": "missed"
1017        }"#;
1018        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1019        assert!(parsed.answer_at.is_none());
1020        assert!(parsed.duration_ms.is_none());
1021        assert!(parsed.error.is_none());
1022        assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
1023        assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
1024    }
1025
1026    #[test]
1027    fn query_omits_unset_fields() {
1028        let q = VoiceCallsQuery::default();
1029        let s = serde_json::to_string(&q).unwrap();
1030        // Empty object — every field skipped when None.
1031        assert_eq!(
1032            s, "{}",
1033            "default query should serialize to empty object: {s}"
1034        );
1035    }
1036
1037    #[test]
1038    fn enum_round_trip_via_json() {
1039        // The wire form for each direction/disposition/reason must
1040        // match what the daemon and platform expect — this guards
1041        // against accidental Rust-side renames.
1042        for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
1043            let s = serde_json::to_string(&d).unwrap();
1044            let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
1045            assert_eq!(d, back);
1046        }
1047        for d in [
1048            VoiceCallDisposition::Answered,
1049            VoiceCallDisposition::Missed,
1050            VoiceCallDisposition::Rejected,
1051            VoiceCallDisposition::Cancelled,
1052            VoiceCallDisposition::Failed,
1053        ] {
1054            let s = serde_json::to_string(&d).unwrap();
1055            let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
1056            assert_eq!(d, back);
1057        }
1058        for r in [
1059            VoiceCallEndReason::HangupLocal,
1060            VoiceCallEndReason::HangupRemote,
1061            VoiceCallEndReason::RejectedLocal,
1062            VoiceCallEndReason::RejectedRemote,
1063            VoiceCallEndReason::Missed,
1064            VoiceCallEndReason::CancelledLocal,
1065            VoiceCallEndReason::TransferredLocal,
1066            VoiceCallEndReason::ConnectionLost,
1067            VoiceCallEndReason::Failed,
1068        ] {
1069            let s = serde_json::to_string(&r).unwrap();
1070            let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
1071            assert_eq!(r, back);
1072        }
1073    }
1074
1075    #[test]
1076    fn connection_lost_pins_its_wire_string() {
1077        // The platform's sync endpoint validates end reasons against
1078        // an exact string list — a rename here would make every
1079        // upload from a session-timer teardown bounce with a 400.
1080        let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
1081        assert_eq!(s, "\"connection_lost\"");
1082    }
1083
1084    #[test]
1085    fn transferred_local_pins_its_wire_string() {
1086        // Same contract as `connection_lost`: the platform validates
1087        // against an exact string list, so a rename here would bounce
1088        // every transferred-call upload with a 400.
1089        let s = serde_json::to_string(&VoiceCallEndReason::TransferredLocal).unwrap();
1090        assert_eq!(s, "\"transferred_local\"");
1091    }
1092
1093    #[test]
1094    fn record_round_trips_transfer_target() {
1095        // A transferred call carries `transferTarget` both ways — the
1096        // daemon ships it (it's its own data, not read-only decoration),
1097        // and the platform echoes it back on read.
1098        let raw = r#"{
1099            "sourceId": "a",
1100            "accountId": "b",
1101            "direction": "inbound",
1102            "party": "Alice <sip:alice@example.com>",
1103            "ringAt": "2026-06-28T10:00:00Z",
1104            "answerAt": "2026-06-28T10:00:05Z",
1105            "endAt": "2026-06-28T10:00:30Z",
1106            "durationMs": 25000,
1107            "disposition": "answered",
1108            "endReason": "transferred_local",
1109            "transferTarget": "1002"
1110        }"#;
1111        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1112        assert_eq!(parsed.end_reason, VoiceCallEndReason::TransferredLocal);
1113        assert_eq!(parsed.transfer_target.as_deref(), Some("1002"));
1114        // And it survives a re-serialize (daemon → platform direction).
1115        let s = serde_json::to_string(&parsed).unwrap();
1116        assert!(s.contains("\"transferTarget\":\"1002\""), "{s}");
1117    }
1118
1119    #[test]
1120    fn codec_pins_its_wire_strings() {
1121        // The platform's sync endpoint validates the codec against an
1122        // exact string list, and the daemon's `CallCodec::as_str` emits
1123        // these same strings — a rename here would bounce every upload
1124        // from an answered call with a 400.
1125        for (codec, wire) in [
1126            (VoiceCallCodec::Opus, "\"opus\""),
1127            (VoiceCallCodec::Pcmu, "\"pcmu\""),
1128            (VoiceCallCodec::Pcma, "\"pcma\""),
1129        ] {
1130            assert_eq!(serde_json::to_string(&codec).unwrap(), wire);
1131            let back: VoiceCallCodec = serde_json::from_str(wire).unwrap();
1132            assert_eq!(back, codec);
1133        }
1134    }
1135
1136    #[test]
1137    fn record_round_trips_codec() {
1138        // An answered call carries `codec` both ways — the daemon ships
1139        // it (its own data, like transferTarget), and the platform
1140        // echoes it back on read so the website can show the call's
1141        // audio quality.
1142        let raw = r#"{
1143            "sourceId": "a",
1144            "accountId": "b",
1145            "direction": "inbound",
1146            "party": "Alice <sip:alice@example.com>",
1147            "ringAt": "2026-07-03T10:00:00Z",
1148            "answerAt": "2026-07-03T10:00:05Z",
1149            "endAt": "2026-07-03T10:00:30Z",
1150            "durationMs": 25000,
1151            "disposition": "answered",
1152            "endReason": "hangup_remote",
1153            "codec": "opus"
1154        }"#;
1155        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1156        assert_eq!(parsed.codec, Some(VoiceCallCodec::Opus));
1157        // And it survives a re-serialize (daemon → platform direction).
1158        let s = serde_json::to_string(&parsed).unwrap();
1159        assert!(s.contains("\"codec\":\"opus\""), "{s}");
1160
1161        // A row from an older daemon has no codec — reads as None.
1162        let legacy = raw.replace(",\n            \"codec\": \"opus\"", "");
1163        let parsed: VoiceCallRecord = serde_json::from_str(&legacy).unwrap();
1164        assert_eq!(parsed.codec, None);
1165    }
1166
1167    #[test]
1168    fn voice_calls_marker_resource_is_calls() {
1169        assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
1170    }
1171
1172    #[test]
1173    fn record_accepts_unknown_extras_for_forward_compat() {
1174        // A newer client shipping a `notes` field that this platform
1175        // version doesn't have a column for should round-trip via
1176        // the `extras` envelope. The platform persists the blob
1177        // verbatim; a future deploy can promote it to a typed
1178        // column without data loss.
1179        let raw = r#"{
1180            "sourceId": "a",
1181            "accountId": "b",
1182            "direction": "inbound",
1183            "party": "anon",
1184            "ringAt": "2026-05-16T10:00:00Z",
1185            "endAt": "2026-05-16T10:00:30Z",
1186            "disposition": "answered",
1187            "endReason": "hangup_remote",
1188            "schemaVersion": 2,
1189            "extras": { "notes": "from staging build" }
1190        }"#;
1191        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1192        assert_eq!(parsed.envelope.schema_version, Some(2));
1193        let extras = parsed.envelope.extras.as_ref().expect("extras present");
1194        assert_eq!(extras["notes"], "from staging build");
1195    }
1196
1197    #[test]
1198    fn call_record_parses_share_visibility_from_list_response() {
1199        // The list / detail endpoints decorate a call with the tier of any
1200        // active share on its recording, so a consumer can badge the row.
1201        let raw = r#"{
1202            "sourceId": "a",
1203            "accountId": "b",
1204            "direction": "outbound",
1205            "party": "+14155550123",
1206            "ringAt": "2026-05-16T10:00:00Z",
1207            "endAt": "2026-05-16T10:00:30Z",
1208            "disposition": "answered",
1209            "endReason": "hangup_remote",
1210            "shareVisibility": "public"
1211        }"#;
1212        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1213        assert_eq!(parsed.share_visibility, Some(ShareVisibility::Public));
1214
1215        let restricted = raw.replace("public", "restricted");
1216        let parsed: VoiceCallRecord = serde_json::from_str(&restricted).unwrap();
1217        assert_eq!(parsed.share_visibility, Some(ShareVisibility::Restricted));
1218    }
1219
1220    #[test]
1221    fn call_record_unshared_has_no_share_visibility() {
1222        // Absent (older platform, or an unshared call) and an explicit
1223        // `null` both read as "not shared" — never `Some(Private)`.
1224        let base = r#"{
1225            "sourceId": "a",
1226            "accountId": "b",
1227            "direction": "inbound",
1228            "party": "anon",
1229            "ringAt": "2026-05-16T10:00:00Z",
1230            "endAt": "2026-05-16T10:00:30Z",
1231            "disposition": "missed",
1232            "endReason": "missed"
1233        }"#;
1234        let parsed: VoiceCallRecord = serde_json::from_str(base).unwrap();
1235        assert_eq!(parsed.share_visibility, None);
1236
1237        let with_null = base.replace(
1238            r#""endReason": "missed""#,
1239            r#""endReason": "missed", "shareVisibility": null"#,
1240        );
1241        let parsed: VoiceCallRecord = serde_json::from_str(&with_null).unwrap();
1242        assert_eq!(parsed.share_visibility, None);
1243    }
1244
1245    #[test]
1246    fn synced_call_omits_share_visibility() {
1247        // `share_visibility` is read-only decoration: a call uploaded via
1248        // sync must not carry it on the wire (skip_serializing_if = None),
1249        // so the round trip from a sync-shaped record stays clean.
1250        let raw = r#"{
1251            "sourceId": "a",
1252            "accountId": "b",
1253            "direction": "inbound",
1254            "party": "anon",
1255            "ringAt": "2026-05-16T10:00:00Z",
1256            "endAt": "2026-05-16T10:00:30Z",
1257            "disposition": "answered",
1258            "endReason": "hangup_remote"
1259        }"#;
1260        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1261        assert_eq!(parsed.share_visibility, None);
1262        let s = serde_json::to_string(&parsed).unwrap();
1263        assert!(
1264            !s.contains("shareVisibility"),
1265            "sync payload leaked share_visibility: {s}"
1266        );
1267    }
1268
1269    #[test]
1270    fn recording_marker_resource_is_recordings() {
1271        // Path constant drives the URL in `Client::sync_recordings`;
1272        // a rename here would silently 404 against the platform.
1273        assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
1274    }
1275
1276    #[test]
1277    fn recording_record_serializes_with_camel_case_and_envelope() {
1278        let r = VoiceRecordingRecord {
1279            source_id: "11111111-1111-4111-8111-111111111111".into(),
1280            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1281            size_bytes: 44 + 64_000,
1282            duration_ms: 2_000,
1283            sample_rate: 8_000,
1284            channels: 2,
1285            created_at: "2026-05-16T10:01:05Z".into(),
1286            envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
1287        };
1288        let s = serde_json::to_string(&r).unwrap();
1289        // Field-by-field wire contract — these strings are also what
1290        // the platform's Zod schema expects.
1291        assert!(s.contains("\"sourceId\":"), "{s}");
1292        assert!(s.contains("\"callSourceId\":"), "{s}");
1293        assert!(s.contains("\"sizeBytes\":64044"), "{s}");
1294        assert!(s.contains("\"durationMs\":2000"), "{s}");
1295        assert!(s.contains("\"sampleRate\":8000"), "{s}");
1296        assert!(s.contains("\"channels\":2"), "{s}");
1297        assert!(s.contains("\"createdAt\":"), "{s}");
1298        // Envelope flattens to the top of the object, same as VoiceCallRecord.
1299        assert!(s.contains("\"schemaVersion\":1"), "{s}");
1300    }
1301
1302    #[test]
1303    fn recordings_sync_response_round_trips() {
1304        // The richer-than-generic response carries per-item provenance —
1305        // the daemon's uploader reads `r2Key` for the bytes follow-up
1306        // and `bytesUploaded` to short-circuit when the row already
1307        // landed on a previous cycle.
1308        let raw = r#"{
1309            "accepted": 2,
1310            "skipped": 0,
1311            "items": [
1312                {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
1313                {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
1314            ]
1315        }"#;
1316        let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
1317        assert_eq!(parsed.accepted, 2);
1318        assert_eq!(parsed.items.len(), 2);
1319        assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
1320        assert!(!parsed.items[0].bytes_uploaded);
1321        assert!(parsed.items[1].bytes_uploaded);
1322    }
1323
1324    #[test]
1325    fn install_heartbeat_request_serializes_with_camel_case_keys() {
1326        let req = InstallHeartbeatRequest {
1327            install_id: "11111111-1111-4111-8111-111111111111".into(),
1328            app_version: "0.0.21".into(),
1329            os: "macos".into(),
1330            os_version: Some("15.5.0".into()),
1331            arch: Some("aarch64".into()),
1332            locale: Some("en-NZ".into()),
1333        };
1334        let s = serde_json::to_string(&req).unwrap();
1335        assert!(s.contains("\"installId\":"), "{s}");
1336        assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
1337        assert!(s.contains("\"os\":\"macos\""), "{s}");
1338        assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
1339        assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
1340        assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
1341    }
1342
1343    #[test]
1344    fn install_heartbeat_request_omits_absent_optional_fields() {
1345        // A host where the OS version / locale probe came up empty
1346        // shouldn't send `null` — keeping the keys out lets the
1347        // platform's Zod `.optional()` accept the body and the column
1348        // stay NULL rather than the string "null".
1349        let req = InstallHeartbeatRequest {
1350            install_id: "x".into(),
1351            app_version: "0.0.21".into(),
1352            os: "linux".into(),
1353            os_version: None,
1354            arch: None,
1355            locale: None,
1356        };
1357        let s = serde_json::to_string(&req).unwrap();
1358        assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
1359        assert!(!s.contains("arch"), "arch should be omitted: {s}");
1360        assert!(!s.contains("locale"), "locale should be omitted: {s}");
1361    }
1362
1363    #[test]
1364    fn install_heartbeat_response_parses_platform_shape() {
1365        let raw = r#"{
1366            "id": "abc-123",
1367            "installId": "11111111-1111-4111-8111-111111111111",
1368            "appVersion": "0.0.21",
1369            "os": "macos",
1370            "osVersion": "15.5.0",
1371            "arch": "aarch64",
1372            "locale": null,
1373            "firstSeenAt": "2026-05-31T10:00:00.000Z",
1374            "lastSeenAt": "2026-05-31T10:00:00.000Z"
1375        }"#;
1376        let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
1377        assert_eq!(parsed.id, "abc-123");
1378        assert_eq!(parsed.app_version, "0.0.21");
1379        assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
1380        assert!(parsed.locale.is_none());
1381    }
1382
1383    #[test]
1384    fn system_info_detect_fills_os_and_arch() {
1385        // os / arch come from compile-time consts, so they're always
1386        // non-empty on every supported target. os_version / locale are
1387        // best-effort and intentionally not asserted.
1388        let sys = SystemInfo::detect();
1389        assert!(!sys.os.is_empty(), "os should be a non-empty target string");
1390        assert!(
1391            !sys.arch.is_empty(),
1392            "arch should be a non-empty target string"
1393        );
1394    }
1395
1396    #[test]
1397    fn transcripts_marker_resource_is_transcripts() {
1398        assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
1399    }
1400
1401    #[test]
1402    fn transcript_record_serializes_with_camel_case_and_channel_enum() {
1403        let r = VoiceTranscriptRecord {
1404            source_id: "1".into(),
1405            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1406            channel: VoiceTranscriptChannel::Remote,
1407            ts_ms: 100,
1408            end_ms: 1_500,
1409            text: "hello".into(),
1410            envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
1411        };
1412        let s = serde_json::to_string(&r).unwrap();
1413        assert!(s.contains("\"sourceId\":"), "{s}");
1414        assert!(s.contains("\"callSourceId\":"), "{s}");
1415        // The channel enum is wire-stable snake_case — matches the
1416        // platform's Zod `enum(VOICE_TRANSCRIPT_CHANNELS)`.
1417        assert!(s.contains("\"channel\":\"remote\""), "{s}");
1418        assert!(s.contains("\"tsMs\":100"), "{s}");
1419        assert!(s.contains("\"endMs\":1500"), "{s}");
1420        assert!(s.contains("\"text\":\"hello\""), "{s}");
1421        assert!(s.contains("\"schemaVersion\":1"), "{s}");
1422    }
1423
1424    #[test]
1425    fn share_visibility_pins_its_wire_strings() {
1426        // The platform validates these against an exact string list; a
1427        // rename would bounce every share command with a 400.
1428        assert_eq!(
1429            serde_json::to_string(&ShareVisibility::Private).unwrap(),
1430            "\"private\""
1431        );
1432        assert_eq!(
1433            serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
1434            "\"restricted\""
1435        );
1436        assert_eq!(
1437            serde_json::to_string(&ShareVisibility::Public).unwrap(),
1438            "\"public\""
1439        );
1440        for v in [
1441            ShareVisibility::Private,
1442            ShareVisibility::Restricted,
1443            ShareVisibility::Public,
1444        ] {
1445            let s = serde_json::to_string(&v).unwrap();
1446            let back: ShareVisibility = serde_json::from_str(&s).unwrap();
1447            assert_eq!(v, back);
1448        }
1449    }
1450
1451    #[test]
1452    fn share_request_serializes_with_camel_case_and_omits_unset() {
1453        let req = ShareRecordingRequest {
1454            recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
1455            visibility: ShareVisibility::Public,
1456            invited_emails: None,
1457            party_masking: None,
1458            show_transcript: None,
1459            show_audio: None,
1460            allow_download: None,
1461            default_mute_local: None,
1462            default_mute_remote: None,
1463            password: None,
1464            expires_at: None,
1465        };
1466        let s = serde_json::to_string(&req).unwrap();
1467        assert!(s.contains("\"recordingSourceId\":"), "{s}");
1468        assert!(s.contains("\"visibility\":\"public\""), "{s}");
1469        // Phase-2 / tier-specific / visibility-control fields stay off the
1470        // wire when unset so the platform's `.optional()` schema accepts the
1471        // body (and the omitted controls fall to the platform defaults).
1472        assert!(!s.contains("invitedEmails"), "{s}");
1473        assert!(!s.contains("partyMasking"), "{s}");
1474        assert!(!s.contains("showTranscript"), "{s}");
1475        assert!(!s.contains("showAudio"), "{s}");
1476        assert!(!s.contains("allowDownload"), "{s}");
1477        assert!(!s.contains("defaultMuteLocal"), "{s}");
1478        assert!(!s.contains("defaultMuteRemote"), "{s}");
1479        assert!(!s.contains("password"), "{s}");
1480        assert!(!s.contains("expiresAt"), "{s}");
1481    }
1482
1483    #[test]
1484    fn share_request_serializes_visibility_controls_camel_case() {
1485        let req = ShareRecordingRequest {
1486            recording_source_id: "a".into(),
1487            visibility: ShareVisibility::Public,
1488            invited_emails: None,
1489            party_masking: Some(PartyMasking::Partial),
1490            show_transcript: Some(false),
1491            show_audio: Some(true),
1492            allow_download: Some(true),
1493            default_mute_local: Some(false),
1494            default_mute_remote: Some(true),
1495            password: None,
1496            expires_at: None,
1497        };
1498        let s = serde_json::to_string(&req).unwrap();
1499        assert!(s.contains("\"partyMasking\":\"partial\""), "{s}");
1500        assert!(s.contains("\"showTranscript\":false"), "{s}");
1501        assert!(s.contains("\"showAudio\":true"), "{s}");
1502        assert!(s.contains("\"allowDownload\":true"), "{s}");
1503        // The owner muted their own side by default but left the other
1504        // party audible — both ride the wire as camelCase booleans.
1505        assert!(s.contains("\"defaultMuteLocal\":false"), "{s}");
1506        assert!(s.contains("\"defaultMuteRemote\":true"), "{s}");
1507    }
1508
1509    #[test]
1510    fn share_request_carries_invited_emails_for_restricted() {
1511        let req = ShareRecordingRequest {
1512            recording_source_id: "a".into(),
1513            visibility: ShareVisibility::Restricted,
1514            invited_emails: Some(vec!["alex@example.com".into()]),
1515            party_masking: None,
1516            show_transcript: None,
1517            show_audio: None,
1518            allow_download: None,
1519            default_mute_local: None,
1520            default_mute_remote: None,
1521            password: None,
1522            expires_at: None,
1523        };
1524        let s = serde_json::to_string(&req).unwrap();
1525        assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
1526        assert!(
1527            s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
1528            "{s}"
1529        );
1530    }
1531
1532    #[test]
1533    fn share_response_parses_platform_shape() {
1534        let raw = r#"{
1535            "visibility": "public",
1536            "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1537            "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1538            "sharedAt": "2026-06-19T10:00:00.000Z"
1539        }"#;
1540        let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
1541        assert_eq!(parsed.visibility, ShareVisibility::Public);
1542        assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
1543        assert!(parsed.share_url.ends_with(&parsed.token));
1544    }
1545
1546    #[test]
1547    fn share_state_parses_restricted_with_invited_emails() {
1548        // The GET read carries the audience back — this is the field the
1549        // POST reply omits and the desktop "who can open this" panel needs.
1550        let raw = r#"{
1551            "visibility": "restricted",
1552            "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1553            "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1554            "sharedAt": "2026-06-19T10:00:00.000Z",
1555            "invitedEmails": ["bob@example.com", "carol@example.com"],
1556            "partyMasking": "full",
1557            "showTranscript": true,
1558            "showAudio": false,
1559            "allowDownload": false,
1560            "defaultMuteLocal": false,
1561            "defaultMuteRemote": true
1562        }"#;
1563        let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
1564        assert_eq!(parsed.visibility, ShareVisibility::Restricted);
1565        assert_eq!(
1566            parsed.invited_emails.as_deref(),
1567            Some(
1568                [
1569                    "bob@example.com".to_string(),
1570                    "carol@example.com".to_string()
1571                ]
1572                .as_slice()
1573            )
1574        );
1575        // The visibility controls ride back on the live-share read.
1576        assert_eq!(parsed.party_masking, Some(PartyMasking::Full));
1577        assert_eq!(parsed.show_transcript, Some(true));
1578        assert_eq!(parsed.show_audio, Some(false));
1579        // Audio hidden here, so download comes back off (platform folds the two).
1580        assert_eq!(parsed.allow_download, Some(false));
1581        // Per-channel playback defaults ride back too.
1582        assert_eq!(parsed.default_mute_local, Some(false));
1583        assert_eq!(parsed.default_mute_remote, Some(true));
1584    }
1585
1586    #[test]
1587    fn share_state_parses_private_with_fields_absent() {
1588        // A never-shared (or revoked) recording reports private with no
1589        // token / url / emails — the optional fields stay None.
1590        let parsed: ShareStateResponse =
1591            serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
1592        assert_eq!(parsed.visibility, ShareVisibility::Private);
1593        assert!(parsed.token.is_none());
1594        assert!(parsed.share_url.is_none());
1595        assert!(parsed.shared_at.is_none());
1596        assert!(parsed.invited_emails.is_none());
1597    }
1598
1599    #[test]
1600    fn share_request_rejects_empty_source_id_before_hitting_network() {
1601        // Guarded client-side so an empty id can't produce a path like
1602        // `/api/voice/recordings//share` that 404s confusingly.
1603        let req = ShareRecordingRequest {
1604            recording_source_id: String::new(),
1605            visibility: ShareVisibility::Private,
1606            invited_emails: None,
1607            party_masking: None,
1608            show_transcript: None,
1609            show_audio: None,
1610            allow_download: None,
1611            default_mute_local: None,
1612            default_mute_remote: None,
1613            password: None,
1614            expires_at: None,
1615        };
1616        // We can't call the async method without a runtime here, but the
1617        // guard mirrors `upload_recording_bytes` — assert the precondition
1618        // shape the method checks.
1619        assert!(req.recording_source_id.is_empty());
1620    }
1621
1622    // ---- VoiceAccounts ----
1623
1624    fn sample_account() -> VoiceAccountRecord {
1625        VoiceAccountRecord {
1626            source_id: "11111111-1111-4111-8111-111111111111".into(),
1627            enabled: true,
1628            display_name: "Work line".into(),
1629            username: "alice".into(),
1630            domain: "sip.example.com".into(),
1631            auth_username: Some("alice-auth".into()),
1632            server: Some("sip.example.com".into()),
1633            port: Some(5060),
1634            transport: VoiceTransport::Udp,
1635            register_expires: 60,
1636            keepalive_secs: Some(50),
1637            disclosure_enabled: true,
1638            updated_at: "2026-06-20T10:00:00Z".into(),
1639            deleted_at: None,
1640            envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
1641        }
1642    }
1643
1644    #[test]
1645    fn accounts_marker_resource_is_accounts() {
1646        // Path constant drives the URL in `Client::sync` / `Client::list`;
1647        // a rename here would silently 404 against the platform.
1648        assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
1649    }
1650
1651    #[test]
1652    fn account_record_serializes_with_camel_case_and_envelope() {
1653        let s = serde_json::to_string(&sample_account()).unwrap();
1654        // Field-by-field wire contract — also what the platform's Zod
1655        // schema expects.
1656        assert!(s.contains("\"sourceId\":"), "{s}");
1657        assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
1658        assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
1659        assert!(s.contains("\"registerExpires\":60"), "{s}");
1660        assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
1661        assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
1662        assert!(s.contains("\"transport\":\"udp\""), "{s}");
1663        assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
1664        // A live line carries no tombstone.
1665        assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
1666        // The secret never crosses this wire, by construction.
1667        assert!(!s.contains("password"), "no password field: {s}");
1668        // Envelope flattens to the top, same as the other resources.
1669        assert!(s.contains("\"schemaVersion\":1"), "{s}");
1670    }
1671
1672    #[test]
1673    fn account_tombstone_serializes_deleted_at() {
1674        // A soft-delete rides as an upsert with deletedAt set — the
1675        // delete-propagation mechanism (doc 40).
1676        let mut r = sample_account();
1677        r.deleted_at = Some("2026-06-20T12:00:00Z".into());
1678        let s = serde_json::to_string(&r).unwrap();
1679        assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
1680    }
1681
1682    #[test]
1683    fn account_record_round_trips_optional_fields() {
1684        // A minimal line — no auth username, server, port, keepalive, or
1685        // tombstone — should parse with those all absent.
1686        let raw = r#"{
1687            "sourceId": "a",
1688            "enabled": false,
1689            "displayName": "Cheap trunk",
1690            "username": "u",
1691            "domain": "d",
1692            "transport": "tcp",
1693            "registerExpires": 120,
1694            "disclosureEnabled": false,
1695            "updatedAt": "2026-06-20T10:00:00Z"
1696        }"#;
1697        let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1698        assert!(!parsed.enabled);
1699        assert!(parsed.auth_username.is_none());
1700        assert!(parsed.server.is_none());
1701        assert!(parsed.port.is_none());
1702        assert!(parsed.keepalive_secs.is_none());
1703        assert!(parsed.deleted_at.is_none());
1704        assert_eq!(parsed.transport, VoiceTransport::Tcp);
1705        assert_eq!(parsed.register_expires, 120);
1706    }
1707
1708    #[test]
1709    fn voice_transport_round_trips_via_json() {
1710        for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
1711            let s = serde_json::to_string(&t).unwrap();
1712            let back: VoiceTransport = serde_json::from_str(&s).unwrap();
1713            assert_eq!(t, back);
1714        }
1715        // Pin the wire strings — the daemon's `TransportKind` and the
1716        // platform's Zod enum both depend on these exact tokens.
1717        assert_eq!(
1718            serde_json::to_string(&VoiceTransport::Udp).unwrap(),
1719            "\"udp\""
1720        );
1721        assert_eq!(
1722            serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
1723            "\"tcp\""
1724        );
1725    }
1726
1727    #[test]
1728    fn accounts_query_omits_unset_and_serializes_include_deleted() {
1729        let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
1730        assert_eq!(empty, "{}", "default query should be empty: {empty}");
1731        let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
1732            include_deleted: Some(true),
1733        })
1734        .unwrap();
1735        assert!(
1736            with_deleted.contains("\"includeDeleted\":true"),
1737            "{with_deleted}"
1738        );
1739    }
1740
1741    // ---- VoiceFlows ----
1742
1743    #[test]
1744    fn flows_query_serializes_cursor_and_omits_absent_fields() {
1745        let empty = serde_json::to_string(&VoiceFlowsQuery::default()).unwrap();
1746        assert_eq!(empty, "{}");
1747        let cursored = serde_json::to_string(&VoiceFlowsQuery {
1748            after: Some("flow_abc".into()),
1749            limit: Some(100),
1750        })
1751        .unwrap();
1752        assert!(cursored.contains("\"after\":\"flow_abc\""), "{cursored}");
1753        assert!(cursored.contains("\"limit\":100"), "{cursored}");
1754    }
1755
1756    #[test]
1757    fn flows_page_parses_platform_shape() {
1758        let raw = r#"{
1759            "items": [{
1760                "id": "flow_1",
1761                "name": "Luigi's — after hours",
1762                "version": 3,
1763                "yaml": "schema_version: 1\n",
1764                "publishedAt": "2026-07-13T10:00:00Z"
1765            }],
1766            "nextAfter": null
1767        }"#;
1768        let page: VoiceFlowsPage = serde_json::from_str(raw).unwrap();
1769        assert_eq!(page.items.len(), 1);
1770        let rec = &page.items[0];
1771        assert_eq!(rec.id, "flow_1");
1772        assert_eq!(rec.version, 3);
1773        assert_eq!(rec.published_at, "2026-07-13T10:00:00Z");
1774        assert!(page.next_after.is_none());
1775
1776        // A mid-walk page carries the cursor.
1777        let more: VoiceFlowsPage =
1778            serde_json::from_str(r#"{ "items": [], "nextAfter": "flow_1" }"#).unwrap();
1779        assert_eq!(more.next_after.as_deref(), Some("flow_1"));
1780    }
1781
1782    #[test]
1783    fn account_record_accepts_unknown_extras_for_forward_compat() {
1784        // A newer client shipping a field this platform version lacks a
1785        // column for round-trips via the `extras` envelope.
1786        let raw = r#"{
1787            "sourceId": "a",
1788            "enabled": true,
1789            "displayName": "x",
1790            "username": "u",
1791            "domain": "d",
1792            "transport": "udp",
1793            "registerExpires": 60,
1794            "disclosureEnabled": true,
1795            "updatedAt": "2026-06-20T10:00:00Z",
1796            "schemaVersion": 2,
1797            "extras": { "ringtone": "classic" }
1798        }"#;
1799        let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1800        assert_eq!(parsed.envelope.schema_version, Some(2));
1801        let extras = parsed.envelope.extras.as_ref().expect("extras present");
1802        assert_eq!(extras["ringtone"], "classic");
1803    }
1804}