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// ---- VoiceAccounts --------------------------------------------------------
303
304/// SIP transport for a synced account line. Wire-stable snake_case;
305/// mirrors the daemon's `TransportKind`.
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308pub enum VoiceTransport {
309    Udp,
310    Tcp,
311}
312
313/// One SIP account line's *configuration* as it crosses the wire from a
314/// device up to the platform and back down to another device
315/// (`wavekat-voice/docs/40-account-config-sync.md`).
316///
317/// Unlike calls / recordings / transcripts — which are immutable,
318/// one-way pushes — account config is **mutable and bidirectional**: a
319/// line is edited, toggled, renamed, and deleted, and those changes must
320/// restore onto a second device. The same idempotent
321/// `(user_id, source_id)` upsert that [`Client::sync`] performs carries
322/// every kind of change here; a *delete* is a soft-delete that rides as
323/// an upsert with `deleted_at` set, because a hard DELETE can't sync
324/// under a "push the row" model — once the row is gone there's nothing
325/// left to push.
326///
327/// **No secret field, by construction.** The SIP password never appears
328/// on this wire. Config sync (policy levels 1–2) keeps the credential
329/// device-local, and the end-to-end-encrypted secret path (level 3)
330/// ships its ciphertext through a *separate* opaque resource, never as a
331/// field here. Omitting it means level 3 can't be populated by accident
332/// before it exists.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334#[serde(rename_all = "camelCase")]
335pub struct VoiceAccountRecord {
336    /// Daemon-side account UUID (`accounts.id`). The platform's
337    /// `(user_id, source_id)` upsert key — re-syncing the same id
338    /// updates the row in place (mutable), unlike the immutable
339    /// resources where a re-sync is a no-op.
340    pub source_id: String,
341    /// Whether the line registers on daemon boot. Pausing a line is a
342    /// portable preference, so it rides along.
343    pub enabled: bool,
344    pub display_name: String,
345    pub username: String,
346    pub domain: String,
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub auth_username: Option<String>,
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub server: Option<String>,
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub port: Option<u16>,
353    pub transport: VoiceTransport,
354    pub register_expires: u32,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub keepalive_secs: Option<u32>,
357    /// Record-disclosure beep toggle — a column on the account row, so
358    /// it rides along for free (the account-portable taxonomy in doc 40).
359    pub disclosure_enabled: bool,
360    /// RFC 3339 last-modification time — the **last-write-wins key**. On
361    /// conflict the platform (and a pulling client) keep the copy with
362    /// the later `updated_at`. Whole-row LWW for v1; per-field merge is
363    /// deferred until users actually report lost edits (doc 40).
364    pub updated_at: String,
365    /// RFC 3339 soft-delete tombstone. `None` = live; `Some` = the line
366    /// was deleted on some device at that time. A tombstone syncs like
367    /// any other mutation so the delete propagates to other devices,
368    /// then is reaped locally once confirmed. The platform retains
369    /// tombstones so a late-syncing device still learns about the delete.
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub deleted_at: Option<String>,
372    /// Version + forward-compat fields shared by every sync record.
373    #[serde(flatten, default)]
374    pub envelope: SyncEnvelope,
375}
376
377/// Query params for `GET /api/voice/accounts`. All fields optional.
378#[derive(Debug, Clone, Default, Serialize, Deserialize)]
379#[serde(rename_all = "camelCase")]
380pub struct VoiceAccountsQuery {
381    /// Include soft-deleted tombstones in the response. Absent / false
382    /// returns only live lines — the restore-grade pull a fresh device
383    /// wants. A delta-syncing device sets this `true` to also learn
384    /// about deletes made elsewhere (doc 40).
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub include_deleted: Option<bool>,
387}
388
389/// Marker for the `/api/voice/accounts/{sync,list}` endpoint pair.
390///
391/// Accounts are the first *mutable, bidirectional* sync resource, but
392/// the wire shape is the same idempotent upsert the immutable resources
393/// use — the [`SyncResponse::skipped`](crate::sync::SyncResponse) field
394/// was reserved for exactly this case — so no new HTTP plumbing is
395/// needed: `client.sync::<VoiceAccounts>(&items)` uploads (including
396/// tombstones), `client.list::<VoiceAccounts>(&query)` pulls.
397pub struct VoiceAccounts;
398
399impl SyncEndpoint for VoiceAccounts {
400    const RESOURCE: &'static str = "accounts";
401    type Record = VoiceAccountRecord;
402    type Query = VoiceAccountsQuery;
403}
404
405impl HasSyncEnvelope for VoiceAccountRecord {
406    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
407        &mut self.envelope
408    }
409}
410
411// ---- Anonymous install heartbeat ------------------------------------------
412//
413// A first-run / per-launch ping the desktop daemon fires *before* (and
414// independently of) any platform sign-in, so the platform can count
415// installs and track version / OS adoption for users who never sign in.
416// It hits the public, unauthenticated `POST /api/voice/installs/heartbeat`
417// and upserts a row keyed by `install_id` alone (no user) — distinct
418// from the authenticated `voice_clients` heartbeat, which is keyed by
419// `(user, install_id)`.
420//
421// The environment fields (os / os_version / arch / locale) are gathered
422// *here*, inside the client crate, rather than on the consumer side:
423// the daemon only owns the two values this crate genuinely cannot
424// discover — the persisted `install_id` and its own app version.
425
426/// Best-effort snapshot of the host environment, detected at call time.
427/// Every field is best-effort; a probe that fails contributes `None`
428/// (or, for the always-available `os` / `arch`, the compile-time
429/// target) rather than failing the heartbeat.
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct SystemInfo {
432    /// `std::env::consts::OS` — `"macos"`, `"windows"`, `"linux"`, …
433    pub os: String,
434    /// Human OS version, e.g. `"15.5.0"`. `None` when the OS probe
435    /// can't determine it.
436    pub os_version: Option<String>,
437    /// `std::env::consts::ARCH` — `"aarch64"`, `"x86_64"`, …
438    pub arch: String,
439    /// BCP-47 system locale, e.g. `"en-NZ"`. `None` when unset /
440    /// undetectable (common for GUI-launched apps on some platforms).
441    pub locale: Option<String>,
442}
443
444impl SystemInfo {
445    /// Probe the current host. Cheap enough to call per heartbeat; we
446    /// don't cache so a locale change between launches is reflected.
447    pub fn detect() -> Self {
448        let os_version = match os_info::get().version() {
449            os_info::Version::Unknown => None,
450            v => Some(v.to_string()),
451        };
452        SystemInfo {
453            os: std::env::consts::OS.to_string(),
454            os_version,
455            arch: std::env::consts::ARCH.to_string(),
456            locale: sys_locale::get_locale(),
457        }
458    }
459}
460
461/// Body of `POST /api/voice/installs/heartbeat`. The daemon supplies
462/// `install_id` + `app_version`; [`Client::install_heartbeat`] fills the
463/// environment fields from [`SystemInfo::detect`].
464#[derive(Debug, Clone, Serialize, Deserialize)]
465#[serde(rename_all = "camelCase")]
466pub struct InstallHeartbeatRequest {
467    /// The daemon's persisted install UUID — the platform's upsert key.
468    pub install_id: String,
469    /// WaveKat Voice's own version (`env!("CARGO_PKG_VERSION")` on the
470    /// daemon side) — *not* this crate's version.
471    pub app_version: String,
472    pub os: String,
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub os_version: Option<String>,
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub arch: Option<String>,
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub locale: Option<String>,
479}
480
481/// The platform's view of an install row, echoed back from a heartbeat.
482#[derive(Debug, Clone, Serialize, Deserialize)]
483#[serde(rename_all = "camelCase")]
484pub struct InstallHeartbeatResponse {
485    pub id: String,
486    pub install_id: String,
487    pub app_version: String,
488    pub os: String,
489    pub os_version: Option<String>,
490    pub arch: Option<String>,
491    pub locale: Option<String>,
492    pub first_seen_at: String,
493    pub last_seen_at: String,
494}
495
496impl Client {
497    /// `POST /api/voice/installs/heartbeat` — the anonymous, no-auth
498    /// first-run install ping. Detects the host environment internally
499    /// and posts it alongside the caller-supplied `install_id` +
500    /// `app_version`. Associated (not a method) because the endpoint is
501    /// unauthenticated — there's no token, and at first run there's no
502    /// signed-in `Client` to hang it off of.
503    ///
504    /// Though unauthenticated, the request is **signed** with the release
505    /// credential `cred` (a per-version Ed25519 key + master-issued
506    /// certificate the consumer bakes in at build time) so the platform
507    /// can verify it came from a genuine release and reject forged or
508    /// replayed pings — see [`Client::post_public_signed_json`] and
509    /// [`crate::sign`]. The platform needs only the master *public* key to
510    /// verify.
511    ///
512    /// `base_url` is the platform base (e.g. `https://platform.wavekat.com`).
513    pub async fn install_heartbeat(
514        base_url: &str,
515        install_id: &str,
516        app_version: &str,
517        cred: &ReleaseCredential,
518    ) -> Result<InstallHeartbeatResponse> {
519        let sys = SystemInfo::detect();
520        let body = InstallHeartbeatRequest {
521            install_id: install_id.to_string(),
522            app_version: app_version.to_string(),
523            os: sys.os,
524            os_version: sys.os_version,
525            arch: Some(sys.arch),
526            locale: sys.locale,
527        };
528        Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
529            base_url,
530            "/api/voice/installs/heartbeat",
531            &body,
532            cred,
533        )
534        .await
535    }
536}
537
538// ---- Client surface for recordings ----------------------------------------
539//
540// Recordings don't fit the generic `Client::sync` shape cleanly:
541//
542//   - the response carries per-item provenance (the platform-stamped
543//     `r2Key`, plus whether bytes have already landed) that the
544//     daemon needs in order to decide which rows still owe a PUT;
545//   - the bytes upload is its own HTTP call (`PUT
546//     /api/voice/recordings/{sourceId}/bytes`), not a JSON batch.
547//
548// Rather than overloading `SyncEndpoint` to carry these shapes, we
549// expose two inherent methods on `Client` that compose the existing
550// JSON / bytes-PUT primitives.
551
552impl Client {
553    /// `POST /api/voice/recordings/sync` — idempotent batch upsert of
554    /// recording metadata. Returns the per-item `r2Key` the daemon
555    /// should target for the follow-up bytes PUT, and whether bytes
556    /// have already landed for each row.
557    ///
558    /// Batch sizing rules match [`Client::sync`]: the platform rejects
559    /// batches over 100 items; the daemon's uploader chunks at 50.
560    pub async fn sync_recordings(
561        &self,
562        items: &[VoiceRecordingRecord],
563    ) -> Result<VoiceRecordingsSyncResponse> {
564        let stamped = stamp_schema_version::<VoiceRecordings>(items);
565        let body = SyncRequest { items: stamped };
566        self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
567            .await
568    }
569
570    /// `PUT /api/voice/recordings/{sourceId}/bytes` — upload the WAV
571    /// bytes for a recording whose metadata was previously synced via
572    /// [`Client::sync_recordings`]. The platform refuses (`HTTP 413`)
573    /// if `bytes.len()` disagrees with the synced `sizeBytes`.
574    ///
575    /// `source_id` is path-segmented as-is; callers pass the
576    /// daemon-side UUID they used for the metadata sync. Empty /
577    /// path-traversal-shaped ids are not specifically guarded here —
578    /// the platform's Zod schema rejects them server-side, so a
579    /// malformed id surfaces as a 4xx via [`Error::Http`].
580    pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
581        if source_id.is_empty() {
582            return Err(Error::BadRequest("source_id must not be empty".into()));
583        }
584        let path = format!("/api/voice/recordings/{source_id}/bytes");
585        self.put_raw_bytes(&path, "audio/wav", bytes).await
586    }
587}
588
589// ---- Recording sharing ----------------------------------------------------
590//
591// Sharing is a *command* — mutate one recording's share state and get a
592// result back — not the "batch upsert + cursor list" shape `SyncEndpoint`
593// exists for (see wavekat-voice doc 38). So it's a typed method pair on
594// `Client` (mirroring `whoami` rather than `sync::<E>()`), not a marker.
595//
596// The desktop daemon keeps only a *mirror* of what these return; the
597// platform is authoritative for who may open a share. See
598// `wavekat-voice/docs/38-share-a-recording.md`.
599
600/// Access tier for a shared recording, mirroring Loom's model. Wire-stable
601/// snake_case strings — the platform's Zod schema validates against this
602/// exact list, so a rename here would bounce every share command with a 400.
603///
604/// - `Private` — owner only (the default; "not shared").
605/// - `Restricted` — owner + explicitly invited WaveKat accounts; the
606///   recipient must be signed in as an invited identity ("protected by login").
607/// - `Public` — anyone holding the capability link, no sign-in.
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
609#[serde(rename_all = "snake_case")]
610pub enum ShareVisibility {
611    Private,
612    Restricted,
613    Public,
614}
615
616/// Body of `POST /api/voice/recordings/{id}/share` — create or update a
617/// recording's share. The recording must already be synced (metadata +
618/// bytes) or the platform returns 404.
619#[derive(Debug, Clone, Serialize, Deserialize)]
620#[serde(rename_all = "camelCase")]
621pub struct ShareRecordingRequest {
622    /// The artifact UUID, as synced (daemon-side `artifacts.id`). Goes in
623    /// the URL path; carried in the struct so callers pass one value.
624    pub recording_source_id: String,
625    pub visibility: ShareVisibility,
626    /// Restricted tier — the WaveKat-account emails allowed to open the
627    /// share. Ignored (and omitted) for `Private` / `Public`.
628    #[serde(default, skip_serializing_if = "Option::is_none")]
629    pub invited_emails: Option<Vec<String>>,
630    /// Phase 2 — out-of-band password gate. Omitted when unset.
631    #[serde(default, skip_serializing_if = "Option::is_none")]
632    pub password: Option<String>,
633    /// Phase 2 — RFC 3339 auto-revoke time. Omitted when unset.
634    #[serde(default, skip_serializing_if = "Option::is_none")]
635    pub expires_at: Option<String>,
636}
637
638/// The platform's response to a successful share command. `share_url` is
639/// the full https link the user copies; `token` is the opaque capability
640/// identifier embedded in it (returned separately so the daemon can store
641/// it for display without re-parsing the URL).
642#[derive(Debug, Clone, Serialize, Deserialize)]
643#[serde(rename_all = "camelCase")]
644pub struct ShareRecordingResponse {
645    pub visibility: ShareVisibility,
646    pub token: String,
647    pub share_url: String,
648    /// RFC 3339 — when the recording was first shared.
649    pub shared_at: String,
650}
651
652/// The platform's response to `GET /api/voice/recordings/{id}/share` — the
653/// *authoritative* current share state for an owned recording. The POST
654/// reply omits the invited-email list and a local mirror can't reflect a
655/// share changed from another device, so the desktop "who can open this"
656/// panel reads here.
657///
658/// A recording that was never shared (or whose share is revoked / expired)
659/// comes back as [`ShareVisibility::Private`] with the optional fields
660/// absent — the same "not shared" state DELETE leaves behind.
661#[derive(Debug, Clone, Serialize, Deserialize)]
662#[serde(rename_all = "camelCase")]
663pub struct ShareStateResponse {
664    pub visibility: ShareVisibility,
665    /// Absent when `visibility == Private` (nothing is shared).
666    #[serde(default, skip_serializing_if = "Option::is_none")]
667    pub token: Option<String>,
668    #[serde(default, skip_serializing_if = "Option::is_none")]
669    pub share_url: Option<String>,
670    /// RFC 3339 — when the recording was first shared. Absent when private.
671    #[serde(default, skip_serializing_if = "Option::is_none")]
672    pub shared_at: Option<String>,
673    /// The restricted tier's audience (lowercased, de-duped). Present
674    /// (possibly empty) only for [`ShareVisibility::Restricted`].
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub invited_emails: Option<Vec<String>>,
677}
678
679impl Client {
680    /// `POST /api/voice/recordings/{id}/share` — create or update a share
681    /// for an already-synced recording. Returns the capability link + token
682    /// the desktop UI puts on the clipboard.
683    ///
684    /// Per the 404-not-403 ownership rule (doc 21 §"Authorization"), asking
685    /// to share a recording the caller doesn't own surfaces as
686    /// [`Error::Http`] with status 404 — existence doesn't leak.
687    pub async fn share_recording(
688        &self,
689        req: &ShareRecordingRequest,
690    ) -> Result<ShareRecordingResponse> {
691        if req.recording_source_id.is_empty() {
692            return Err(Error::BadRequest(
693                "recording_source_id must not be empty".into(),
694            ));
695        }
696        let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
697        self.post_json::<ShareRecordingResponse, _>(&path, req)
698            .await
699    }
700
701    /// `GET /api/voice/recordings/{id}/share` — read the authoritative
702    /// share state for an owned recording, including the restricted tier's
703    /// invited emails (which the share command's reply omits). Like
704    /// [`share_recording`](Self::share_recording), a recording the caller
705    /// doesn't own surfaces as [`Error::Http`] with status 404.
706    pub async fn get_recording_share(
707        &self,
708        recording_source_id: &str,
709    ) -> Result<ShareStateResponse> {
710        if recording_source_id.is_empty() {
711            return Err(Error::BadRequest(
712                "recording_source_id must not be empty".into(),
713            ));
714        }
715        let path = format!("/api/voice/recordings/{recording_source_id}/share");
716        self.get_json::<ShareStateResponse>(&path).await
717    }
718
719    /// `DELETE /api/voice/recordings/{id}/share` — revoke the share. The
720    /// recording reverts to Private and any outstanding link returns 410.
721    pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
722        if recording_source_id.is_empty() {
723            return Err(Error::BadRequest(
724                "recording_source_id must not be empty".into(),
725            ));
726        }
727        let path = format!("/api/voice/recordings/{recording_source_id}/share");
728        self.delete(&path).await
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    #[test]
737    fn record_serializes_with_camel_case_keys() {
738        let r = VoiceCallRecord {
739            source_id: "11111111-1111-4111-8111-111111111111".into(),
740            account_id: "22222222-2222-4222-8222-222222222222".into(),
741            direction: VoiceCallDirection::Inbound,
742            party: "+14155550123".into(),
743            ring_at: "2026-05-16T10:00:00Z".into(),
744            answer_at: Some("2026-05-16T10:00:05Z".into()),
745            end_at: "2026-05-16T10:01:00Z".into(),
746            duration_ms: Some(55_000),
747            disposition: VoiceCallDisposition::Answered,
748            end_reason: VoiceCallEndReason::HangupRemote,
749            error: None,
750            envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
751        };
752        let s = serde_json::to_string(&r).unwrap();
753        assert!(s.contains("\"sourceId\":"), "{s}");
754        assert!(s.contains("\"accountId\":"), "{s}");
755        assert!(s.contains("\"ringAt\":"), "{s}");
756        assert!(s.contains("\"endAt\":"), "{s}");
757        assert!(s.contains("\"durationMs\":55000"), "{s}");
758        // Optional `error` is None — should be omitted from the wire.
759        assert!(!s.contains("\"error\""), "error should be omitted: {s}");
760        // Envelope flattens to the top of the object — schemaVersion
761        // sits next to the other fields rather than nested under
762        // "envelope". Future resources rely on this layout.
763        assert!(
764            s.contains("\"schemaVersion\":1"),
765            "schemaVersion should flatten: {s}"
766        );
767        // `extras` is None, so the envelope contributes no `extras`
768        // key. Stays out of the row to keep the small/fast path.
769        assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
770    }
771
772    #[test]
773    fn record_round_trips_optional_fields() {
774        // An unanswered call has answer_at/duration_ms/error all absent.
775        let raw = r#"{
776            "sourceId": "a",
777            "accountId": "b",
778            "direction": "inbound",
779            "party": "anonymous",
780            "ringAt": "2026-05-16T10:00:00Z",
781            "endAt": "2026-05-16T10:00:30Z",
782            "disposition": "missed",
783            "endReason": "missed"
784        }"#;
785        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
786        assert!(parsed.answer_at.is_none());
787        assert!(parsed.duration_ms.is_none());
788        assert!(parsed.error.is_none());
789        assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
790        assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
791    }
792
793    #[test]
794    fn query_omits_unset_fields() {
795        let q = VoiceCallsQuery::default();
796        let s = serde_json::to_string(&q).unwrap();
797        // Empty object — every field skipped when None.
798        assert_eq!(
799            s, "{}",
800            "default query should serialize to empty object: {s}"
801        );
802    }
803
804    #[test]
805    fn enum_round_trip_via_json() {
806        // The wire form for each direction/disposition/reason must
807        // match what the daemon and platform expect — this guards
808        // against accidental Rust-side renames.
809        for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
810            let s = serde_json::to_string(&d).unwrap();
811            let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
812            assert_eq!(d, back);
813        }
814        for d in [
815            VoiceCallDisposition::Answered,
816            VoiceCallDisposition::Missed,
817            VoiceCallDisposition::Rejected,
818            VoiceCallDisposition::Cancelled,
819            VoiceCallDisposition::Failed,
820        ] {
821            let s = serde_json::to_string(&d).unwrap();
822            let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
823            assert_eq!(d, back);
824        }
825        for r in [
826            VoiceCallEndReason::HangupLocal,
827            VoiceCallEndReason::HangupRemote,
828            VoiceCallEndReason::RejectedLocal,
829            VoiceCallEndReason::RejectedRemote,
830            VoiceCallEndReason::Missed,
831            VoiceCallEndReason::CancelledLocal,
832            VoiceCallEndReason::ConnectionLost,
833            VoiceCallEndReason::Failed,
834        ] {
835            let s = serde_json::to_string(&r).unwrap();
836            let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
837            assert_eq!(r, back);
838        }
839    }
840
841    #[test]
842    fn connection_lost_pins_its_wire_string() {
843        // The platform's sync endpoint validates end reasons against
844        // an exact string list — a rename here would make every
845        // upload from a session-timer teardown bounce with a 400.
846        let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
847        assert_eq!(s, "\"connection_lost\"");
848    }
849
850    #[test]
851    fn voice_calls_marker_resource_is_calls() {
852        assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
853    }
854
855    #[test]
856    fn record_accepts_unknown_extras_for_forward_compat() {
857        // A newer client shipping a `notes` field that this platform
858        // version doesn't have a column for should round-trip via
859        // the `extras` envelope. The platform persists the blob
860        // verbatim; a future deploy can promote it to a typed
861        // column without data loss.
862        let raw = r#"{
863            "sourceId": "a",
864            "accountId": "b",
865            "direction": "inbound",
866            "party": "anon",
867            "ringAt": "2026-05-16T10:00:00Z",
868            "endAt": "2026-05-16T10:00:30Z",
869            "disposition": "answered",
870            "endReason": "hangup_remote",
871            "schemaVersion": 2,
872            "extras": { "notes": "from staging build" }
873        }"#;
874        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
875        assert_eq!(parsed.envelope.schema_version, Some(2));
876        let extras = parsed.envelope.extras.as_ref().expect("extras present");
877        assert_eq!(extras["notes"], "from staging build");
878    }
879
880    #[test]
881    fn recording_marker_resource_is_recordings() {
882        // Path constant drives the URL in `Client::sync_recordings`;
883        // a rename here would silently 404 against the platform.
884        assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
885    }
886
887    #[test]
888    fn recording_record_serializes_with_camel_case_and_envelope() {
889        let r = VoiceRecordingRecord {
890            source_id: "11111111-1111-4111-8111-111111111111".into(),
891            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
892            size_bytes: 44 + 64_000,
893            duration_ms: 2_000,
894            sample_rate: 8_000,
895            channels: 2,
896            created_at: "2026-05-16T10:01:05Z".into(),
897            envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
898        };
899        let s = serde_json::to_string(&r).unwrap();
900        // Field-by-field wire contract — these strings are also what
901        // the platform's Zod schema expects.
902        assert!(s.contains("\"sourceId\":"), "{s}");
903        assert!(s.contains("\"callSourceId\":"), "{s}");
904        assert!(s.contains("\"sizeBytes\":64044"), "{s}");
905        assert!(s.contains("\"durationMs\":2000"), "{s}");
906        assert!(s.contains("\"sampleRate\":8000"), "{s}");
907        assert!(s.contains("\"channels\":2"), "{s}");
908        assert!(s.contains("\"createdAt\":"), "{s}");
909        // Envelope flattens to the top of the object, same as VoiceCallRecord.
910        assert!(s.contains("\"schemaVersion\":1"), "{s}");
911    }
912
913    #[test]
914    fn recordings_sync_response_round_trips() {
915        // The richer-than-generic response carries per-item provenance —
916        // the daemon's uploader reads `r2Key` for the bytes follow-up
917        // and `bytesUploaded` to short-circuit when the row already
918        // landed on a previous cycle.
919        let raw = r#"{
920            "accepted": 2,
921            "skipped": 0,
922            "items": [
923                {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
924                {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
925            ]
926        }"#;
927        let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
928        assert_eq!(parsed.accepted, 2);
929        assert_eq!(parsed.items.len(), 2);
930        assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
931        assert!(!parsed.items[0].bytes_uploaded);
932        assert!(parsed.items[1].bytes_uploaded);
933    }
934
935    #[test]
936    fn install_heartbeat_request_serializes_with_camel_case_keys() {
937        let req = InstallHeartbeatRequest {
938            install_id: "11111111-1111-4111-8111-111111111111".into(),
939            app_version: "0.0.21".into(),
940            os: "macos".into(),
941            os_version: Some("15.5.0".into()),
942            arch: Some("aarch64".into()),
943            locale: Some("en-NZ".into()),
944        };
945        let s = serde_json::to_string(&req).unwrap();
946        assert!(s.contains("\"installId\":"), "{s}");
947        assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
948        assert!(s.contains("\"os\":\"macos\""), "{s}");
949        assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
950        assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
951        assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
952    }
953
954    #[test]
955    fn install_heartbeat_request_omits_absent_optional_fields() {
956        // A host where the OS version / locale probe came up empty
957        // shouldn't send `null` — keeping the keys out lets the
958        // platform's Zod `.optional()` accept the body and the column
959        // stay NULL rather than the string "null".
960        let req = InstallHeartbeatRequest {
961            install_id: "x".into(),
962            app_version: "0.0.21".into(),
963            os: "linux".into(),
964            os_version: None,
965            arch: None,
966            locale: None,
967        };
968        let s = serde_json::to_string(&req).unwrap();
969        assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
970        assert!(!s.contains("arch"), "arch should be omitted: {s}");
971        assert!(!s.contains("locale"), "locale should be omitted: {s}");
972    }
973
974    #[test]
975    fn install_heartbeat_response_parses_platform_shape() {
976        let raw = r#"{
977            "id": "abc-123",
978            "installId": "11111111-1111-4111-8111-111111111111",
979            "appVersion": "0.0.21",
980            "os": "macos",
981            "osVersion": "15.5.0",
982            "arch": "aarch64",
983            "locale": null,
984            "firstSeenAt": "2026-05-31T10:00:00.000Z",
985            "lastSeenAt": "2026-05-31T10:00:00.000Z"
986        }"#;
987        let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
988        assert_eq!(parsed.id, "abc-123");
989        assert_eq!(parsed.app_version, "0.0.21");
990        assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
991        assert!(parsed.locale.is_none());
992    }
993
994    #[test]
995    fn system_info_detect_fills_os_and_arch() {
996        // os / arch come from compile-time consts, so they're always
997        // non-empty on every supported target. os_version / locale are
998        // best-effort and intentionally not asserted.
999        let sys = SystemInfo::detect();
1000        assert!(!sys.os.is_empty(), "os should be a non-empty target string");
1001        assert!(
1002            !sys.arch.is_empty(),
1003            "arch should be a non-empty target string"
1004        );
1005    }
1006
1007    #[test]
1008    fn transcripts_marker_resource_is_transcripts() {
1009        assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
1010    }
1011
1012    #[test]
1013    fn transcript_record_serializes_with_camel_case_and_channel_enum() {
1014        let r = VoiceTranscriptRecord {
1015            source_id: "1".into(),
1016            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1017            channel: VoiceTranscriptChannel::Remote,
1018            ts_ms: 100,
1019            end_ms: 1_500,
1020            text: "hello".into(),
1021            envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
1022        };
1023        let s = serde_json::to_string(&r).unwrap();
1024        assert!(s.contains("\"sourceId\":"), "{s}");
1025        assert!(s.contains("\"callSourceId\":"), "{s}");
1026        // The channel enum is wire-stable snake_case — matches the
1027        // platform's Zod `enum(VOICE_TRANSCRIPT_CHANNELS)`.
1028        assert!(s.contains("\"channel\":\"remote\""), "{s}");
1029        assert!(s.contains("\"tsMs\":100"), "{s}");
1030        assert!(s.contains("\"endMs\":1500"), "{s}");
1031        assert!(s.contains("\"text\":\"hello\""), "{s}");
1032        assert!(s.contains("\"schemaVersion\":1"), "{s}");
1033    }
1034
1035    #[test]
1036    fn share_visibility_pins_its_wire_strings() {
1037        // The platform validates these against an exact string list; a
1038        // rename would bounce every share command with a 400.
1039        assert_eq!(
1040            serde_json::to_string(&ShareVisibility::Private).unwrap(),
1041            "\"private\""
1042        );
1043        assert_eq!(
1044            serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
1045            "\"restricted\""
1046        );
1047        assert_eq!(
1048            serde_json::to_string(&ShareVisibility::Public).unwrap(),
1049            "\"public\""
1050        );
1051        for v in [
1052            ShareVisibility::Private,
1053            ShareVisibility::Restricted,
1054            ShareVisibility::Public,
1055        ] {
1056            let s = serde_json::to_string(&v).unwrap();
1057            let back: ShareVisibility = serde_json::from_str(&s).unwrap();
1058            assert_eq!(v, back);
1059        }
1060    }
1061
1062    #[test]
1063    fn share_request_serializes_with_camel_case_and_omits_unset() {
1064        let req = ShareRecordingRequest {
1065            recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
1066            visibility: ShareVisibility::Public,
1067            invited_emails: None,
1068            password: None,
1069            expires_at: None,
1070        };
1071        let s = serde_json::to_string(&req).unwrap();
1072        assert!(s.contains("\"recordingSourceId\":"), "{s}");
1073        assert!(s.contains("\"visibility\":\"public\""), "{s}");
1074        // Phase-2 / tier-specific fields stay off the wire when unset so
1075        // the platform's `.optional()` schema accepts the body.
1076        assert!(!s.contains("invitedEmails"), "{s}");
1077        assert!(!s.contains("password"), "{s}");
1078        assert!(!s.contains("expiresAt"), "{s}");
1079    }
1080
1081    #[test]
1082    fn share_request_carries_invited_emails_for_restricted() {
1083        let req = ShareRecordingRequest {
1084            recording_source_id: "a".into(),
1085            visibility: ShareVisibility::Restricted,
1086            invited_emails: Some(vec!["alex@example.com".into()]),
1087            password: None,
1088            expires_at: None,
1089        };
1090        let s = serde_json::to_string(&req).unwrap();
1091        assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
1092        assert!(
1093            s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
1094            "{s}"
1095        );
1096    }
1097
1098    #[test]
1099    fn share_response_parses_platform_shape() {
1100        let raw = r#"{
1101            "visibility": "public",
1102            "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1103            "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1104            "sharedAt": "2026-06-19T10:00:00.000Z"
1105        }"#;
1106        let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
1107        assert_eq!(parsed.visibility, ShareVisibility::Public);
1108        assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
1109        assert!(parsed.share_url.ends_with(&parsed.token));
1110    }
1111
1112    #[test]
1113    fn share_state_parses_restricted_with_invited_emails() {
1114        // The GET read carries the audience back — this is the field the
1115        // POST reply omits and the desktop "who can open this" panel needs.
1116        let raw = r#"{
1117            "visibility": "restricted",
1118            "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1119            "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1120            "sharedAt": "2026-06-19T10:00:00.000Z",
1121            "invitedEmails": ["bob@example.com", "carol@example.com"]
1122        }"#;
1123        let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
1124        assert_eq!(parsed.visibility, ShareVisibility::Restricted);
1125        assert_eq!(
1126            parsed.invited_emails.as_deref(),
1127            Some(
1128                [
1129                    "bob@example.com".to_string(),
1130                    "carol@example.com".to_string()
1131                ]
1132                .as_slice()
1133            )
1134        );
1135    }
1136
1137    #[test]
1138    fn share_state_parses_private_with_fields_absent() {
1139        // A never-shared (or revoked) recording reports private with no
1140        // token / url / emails — the optional fields stay None.
1141        let parsed: ShareStateResponse =
1142            serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
1143        assert_eq!(parsed.visibility, ShareVisibility::Private);
1144        assert!(parsed.token.is_none());
1145        assert!(parsed.share_url.is_none());
1146        assert!(parsed.shared_at.is_none());
1147        assert!(parsed.invited_emails.is_none());
1148    }
1149
1150    #[test]
1151    fn share_request_rejects_empty_source_id_before_hitting_network() {
1152        // Guarded client-side so an empty id can't produce a path like
1153        // `/api/voice/recordings//share` that 404s confusingly.
1154        let req = ShareRecordingRequest {
1155            recording_source_id: String::new(),
1156            visibility: ShareVisibility::Private,
1157            invited_emails: None,
1158            password: None,
1159            expires_at: None,
1160        };
1161        // We can't call the async method without a runtime here, but the
1162        // guard mirrors `upload_recording_bytes` — assert the precondition
1163        // shape the method checks.
1164        assert!(req.recording_source_id.is_empty());
1165    }
1166
1167    // ---- VoiceAccounts ----
1168
1169    fn sample_account() -> VoiceAccountRecord {
1170        VoiceAccountRecord {
1171            source_id: "11111111-1111-4111-8111-111111111111".into(),
1172            enabled: true,
1173            display_name: "Work line".into(),
1174            username: "alice".into(),
1175            domain: "sip.example.com".into(),
1176            auth_username: Some("alice-auth".into()),
1177            server: Some("sip.example.com".into()),
1178            port: Some(5060),
1179            transport: VoiceTransport::Udp,
1180            register_expires: 60,
1181            keepalive_secs: Some(50),
1182            disclosure_enabled: true,
1183            updated_at: "2026-06-20T10:00:00Z".into(),
1184            deleted_at: None,
1185            envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
1186        }
1187    }
1188
1189    #[test]
1190    fn accounts_marker_resource_is_accounts() {
1191        // Path constant drives the URL in `Client::sync` / `Client::list`;
1192        // a rename here would silently 404 against the platform.
1193        assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
1194    }
1195
1196    #[test]
1197    fn account_record_serializes_with_camel_case_and_envelope() {
1198        let s = serde_json::to_string(&sample_account()).unwrap();
1199        // Field-by-field wire contract — also what the platform's Zod
1200        // schema expects.
1201        assert!(s.contains("\"sourceId\":"), "{s}");
1202        assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
1203        assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
1204        assert!(s.contains("\"registerExpires\":60"), "{s}");
1205        assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
1206        assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
1207        assert!(s.contains("\"transport\":\"udp\""), "{s}");
1208        assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
1209        // A live line carries no tombstone.
1210        assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
1211        // The secret never crosses this wire, by construction.
1212        assert!(!s.contains("password"), "no password field: {s}");
1213        // Envelope flattens to the top, same as the other resources.
1214        assert!(s.contains("\"schemaVersion\":1"), "{s}");
1215    }
1216
1217    #[test]
1218    fn account_tombstone_serializes_deleted_at() {
1219        // A soft-delete rides as an upsert with deletedAt set — the
1220        // delete-propagation mechanism (doc 40).
1221        let mut r = sample_account();
1222        r.deleted_at = Some("2026-06-20T12:00:00Z".into());
1223        let s = serde_json::to_string(&r).unwrap();
1224        assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
1225    }
1226
1227    #[test]
1228    fn account_record_round_trips_optional_fields() {
1229        // A minimal line — no auth username, server, port, keepalive, or
1230        // tombstone — should parse with those all absent.
1231        let raw = r#"{
1232            "sourceId": "a",
1233            "enabled": false,
1234            "displayName": "Cheap trunk",
1235            "username": "u",
1236            "domain": "d",
1237            "transport": "tcp",
1238            "registerExpires": 120,
1239            "disclosureEnabled": false,
1240            "updatedAt": "2026-06-20T10:00:00Z"
1241        }"#;
1242        let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1243        assert!(!parsed.enabled);
1244        assert!(parsed.auth_username.is_none());
1245        assert!(parsed.server.is_none());
1246        assert!(parsed.port.is_none());
1247        assert!(parsed.keepalive_secs.is_none());
1248        assert!(parsed.deleted_at.is_none());
1249        assert_eq!(parsed.transport, VoiceTransport::Tcp);
1250        assert_eq!(parsed.register_expires, 120);
1251    }
1252
1253    #[test]
1254    fn voice_transport_round_trips_via_json() {
1255        for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
1256            let s = serde_json::to_string(&t).unwrap();
1257            let back: VoiceTransport = serde_json::from_str(&s).unwrap();
1258            assert_eq!(t, back);
1259        }
1260        // Pin the wire strings — the daemon's `TransportKind` and the
1261        // platform's Zod enum both depend on these exact tokens.
1262        assert_eq!(
1263            serde_json::to_string(&VoiceTransport::Udp).unwrap(),
1264            "\"udp\""
1265        );
1266        assert_eq!(
1267            serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
1268            "\"tcp\""
1269        );
1270    }
1271
1272    #[test]
1273    fn accounts_query_omits_unset_and_serializes_include_deleted() {
1274        let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
1275        assert_eq!(empty, "{}", "default query should be empty: {empty}");
1276        let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
1277            include_deleted: Some(true),
1278        })
1279        .unwrap();
1280        assert!(
1281            with_deleted.contains("\"includeDeleted\":true"),
1282            "{with_deleted}"
1283        );
1284    }
1285
1286    #[test]
1287    fn account_record_accepts_unknown_extras_for_forward_compat() {
1288        // A newer client shipping a field this platform version lacks a
1289        // column for round-trips via the `extras` envelope.
1290        let raw = r#"{
1291            "sourceId": "a",
1292            "enabled": true,
1293            "displayName": "x",
1294            "username": "u",
1295            "domain": "d",
1296            "transport": "udp",
1297            "registerExpires": 60,
1298            "disclosureEnabled": true,
1299            "updatedAt": "2026-06-20T10:00:00Z",
1300            "schemaVersion": 2,
1301            "extras": { "ringtone": "classic" }
1302        }"#;
1303        let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1304        assert_eq!(parsed.envelope.schema_version, Some(2));
1305        let extras = parsed.envelope.extras.as_ref().expect("extras present");
1306        assert_eq!(extras["ringtone"], "classic");
1307    }
1308}