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/// How a call flow's ("receptionist") run ended, folded by the daemon
97/// from the run's terminal trace step. Wire-stable snake_case strings
98/// matching `wavekat_flow::trace::FlowOutcome` — declared here rather
99/// than re-exported so this crate stays free of a `wavekat-flow`
100/// dependency; the two lists must be kept in step.
101///
102/// Consumers prefer this over [`VoiceCallEndReason`] when rendering a
103/// flow-answered call's outcome: the flow's own goodbye sends the BYE,
104/// so the SIP-level reason reads `HangupLocal` ("you hung up") for a
105/// call the user never touched.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum VoiceCallFlowOutcome {
109    /// A `ring` node was answered by a human; the engine stepped out.
110    Answered,
111    /// A `message` node recorded a voicemail.
112    MessageLeft,
113    /// A `transfer` node handed the call to an external number.
114    Transferred,
115    /// A `hangup` node ended the call.
116    HungUp,
117    /// An effect failed mid-run (the call likely dropped).
118    Aborted,
119    /// The flow reached an impossible state. Validation is meant to
120    /// prevent this, so it signals a defect worth alerting on.
121    Defect,
122}
123
124/// One step of a call flow's run, as the daemon projects it from its
125/// local `call_flow_step` events.
126///
127/// Deliberately structural rather than a rendered sentence. The daemon
128/// has an English summary for each step, but the platform's web UI
129/// serves nine locales — shipping prose would make these permanently
130/// untranslatable there. Consumers get the parts and compose the
131/// sentence themselves.
132///
133/// `kind` is a plain `String`, not an enum, and that is the point: step
134/// kinds grow every time the flow engine gains a node type, and a
135/// consumer built against an older version of this crate must still be
136/// able to deserialize a newer daemon's trace. An unknown kind is
137/// rendered as an unnamed marker rather than rejected.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase")]
140pub struct VoiceCallFlowStep {
141    /// Milliseconds from the call's answer time — the same zero the
142    /// recording starts at, so a step lines up with the audio.
143    pub at_ms: i64,
144    /// The engine's step tag: `spoke`, `hours`, `menu_choice`,
145    /// `menu_no_input`, `menu_invalid`, `ring`, `message_recorded`,
146    /// `transferred`, `hung_up`, or the synthetic `answered` marking a
147    /// mid-run take-over by the owner.
148    pub kind: String,
149    /// The flow node this step belongs to, when it names one.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub node: Option<String>,
152    /// The key the caller pressed — `menu_choice` only.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub digit: Option<String>,
155    /// Recorded message length in seconds — `message_recorded` only.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub secs: Option<i64>,
158    /// Where the call was sent — `transferred` only.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub target: Option<String>,
161    /// Whether an hours check landed inside business hours.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub open: Option<bool>,
164    /// Whether a `ring` step was picked up.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub answered: Option<bool>,
167}
168
169/// One historical call as it crosses the wire from the daemon up to the
170/// platform.
171///
172/// Mirrors the daemon's local `CallRecord` (see
173/// `wavekat-voice/crates/wavekat-voice/src/db.rs`) with one rename:
174/// the daemon's local primary key (`id`) is shipped as `source_id`
175/// because the platform allocates its own row id and treats the
176/// daemon-side UUID as the idempotency key.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct VoiceCallRecord {
180    /// Daemon-generated UUID. The platform's `(user_id, source_id)`
181    /// upsert key — re-syncing the same id is a no-op.
182    pub source_id: String,
183    /// SIP account UUID on the daemon side. Opaque to the platform.
184    pub account_id: String,
185    pub direction: VoiceCallDirection,
186    /// SIP `From:` (inbound) or `To:` (outbound). Free text — caller
187    /// IDs, display names, and SIP URIs all land here.
188    pub party: String,
189    /// RFC 3339. First ring (inbound) or first dial-out (outbound).
190    pub ring_at: String,
191    /// RFC 3339. Present only when the call reached the answered
192    /// state.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub answer_at: Option<String>,
195    /// RFC 3339. Terminal timestamp; the platform uses this as the
196    /// list cursor.
197    pub end_at: String,
198    /// `answer_at` → `end_at` in milliseconds. `None` for calls that
199    /// were never answered.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub duration_ms: Option<i64>,
202    pub disposition: VoiceCallDisposition,
203    pub end_reason: VoiceCallEndReason,
204    /// Free-text error, populated only when `disposition == Failed`.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub error: Option<String>,
207    /// Visibility tier of any *active* (not revoked / expired) share on this
208    /// call's recording, or `None` when it isn't shared. Read-only: the
209    /// platform sets it on list (`GET /api/voice/calls`) and detail responses
210    /// so a consumer can badge the row "Public" / "Invited only"; it is
211    /// skipped on serialize, so syncing a call never sends it. `Private` never
212    /// appears here — an unshared call is `None`.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub share_visibility: Option<ShareVisibility>,
215    /// Where a transferred call was sent — the number or SIP address the
216    /// far end was asked to call (RFC 3515 `Refer-To`). Set only when
217    /// `end_reason == TransferredLocal`; `None` for every other call.
218    /// Unlike `share_visibility` this is daemon-owned data, so it *is*
219    /// sent on sync (serialized when present) and echoed back on read.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub transfer_target: Option<String>,
222    /// The negotiated audio codec, present when the call reached the
223    /// audio-flowing state on a daemon new enough to record it; `None`
224    /// for never-answered calls and rows synced by older daemons. Like
225    /// `transfer_target` this is daemon-owned data, so it *is* sent on
226    /// sync (serialized when present) and echoed back on read.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub codec: Option<VoiceCallCodec>,
229    /// Which call flow ("receptionist") answered this call, when one
230    /// did: the platform flow id the daemon held at answer time, and
231    /// the flow's display name *at that moment*. The name is shipped
232    /// verbatim rather than resolved from the flow on read, so a later
233    /// rename or delete doesn't rewrite what history says happened.
234    /// Both `None` for calls the user answered themselves. Daemon-owned
235    /// data like `codec`, so both are sent on sync and echoed on read.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub flow_id: Option<String>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub flow_name: Option<String>,
240    /// How the flow's run ended. `None` when no flow answered, and for
241    /// runs with no terminal step (the caller hung up mid-flow) — there
242    /// [`VoiceCallRecord::end_reason`] is already the honest story.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub flow_outcome: Option<VoiceCallFlowOutcome>,
245    /// The flow run's step-by-step trace, in answer-time order. Drives
246    /// the markers the platform's call-detail page draws on the
247    /// recording waveform.
248    ///
249    /// `None` for human-answered calls and for daemons predating the
250    /// trace. Sent on sync like the other daemon-owned fields, but
251    /// echoed back only on the *detail* read (`GET /api/voice/calls/
252    /// {sourceId}`) — the list route omits it, since nothing on a list
253    /// row renders a trace and it would weigh down every page.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub flow_steps: Option<Vec<VoiceCallFlowStep>>,
256    /// RFC 3339 soft-delete tombstone. `None` = live; `Some` = the user
257    /// deleted this call at that time.
258    ///
259    /// Calls are otherwise immutable one-way pushes, and this is the
260    /// single exception: a delete has to reach the platform somehow, and
261    /// a hard `DELETE` can't sync under a "push the row" model — once
262    /// the row is gone there's nothing left to push. So a delete rides
263    /// as an ordinary upsert with this field set, exactly like
264    /// [`VoiceAccountRecord::deleted_at`].
265    ///
266    /// Where it differs from the account tombstone: **the platform
267    /// treats this one as sticky, not last-write-wins.** An account is
268    /// genuinely mutable, so it carries `updated_at` and conflicts
269    /// resolve on it; a call has no such field because delete is the
270    /// only mutation it has. The platform resolves the column
271    /// `COALESCE(existing, incoming)`, so once a call is deleted a
272    /// later sync of the same `source_id` can never revive it — which
273    /// also means a consumer must not expect to "undelete" by syncing
274    /// the row again with `None`.
275    ///
276    /// Deleting a call is not only a flag on the platform side: the
277    /// recording bytes are removed from object storage, the recording
278    /// and transcript rows are dropped, and any live share link is
279    /// revoked (it answers 410 thereafter). The tombstone row is
280    /// retained so a late-syncing device still learns about the delete
281    /// — read it via `include_deleted` on
282    /// [`VoiceCallsQuery`]. `GET /api/voice/calls/{sourceId}` returns
283    /// 404 for a deleted call rather than echoing the tombstone.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub deleted_at: Option<String>,
286    /// Version + forward-compat fields shared by every sync record.
287    /// Flattened so `schemaVersion` and `extras` sit at the top of
288    /// the JSON object alongside the other columns. See
289    /// [`SyncEnvelope`] and doc 21 §"Versioning and forward
290    /// compatibility".
291    #[serde(flatten, default)]
292    pub envelope: SyncEnvelope,
293}
294
295/// Query params for `GET /api/voice/calls`. All fields optional — the
296/// default returns the newest page.
297#[derive(Debug, Clone, Default, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase")]
299pub struct VoiceCallsQuery {
300    /// Include soft-deleted tombstones in the response. Absent / false
301    /// returns only live calls — what a human-facing list wants. A
302    /// delta-syncing device sets this `true` to learn about deletes
303    /// made on another device or on the web, so it can reap its local
304    /// copy.
305    ///
306    /// Unlike [`VoiceAccountsQuery::include_deleted`] there is no
307    /// "restore a fresh device" use for this: a tombstoned call has had
308    /// its recording and transcript destroyed, so the only thing left
309    /// to learn from it is that it's gone.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub include_deleted: Option<bool>,
312    /// RFC 3339 cursor; rows with `end_at < before` are returned.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub before: Option<String>,
315    /// 1..=200. Server default is 50.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub limit: Option<u32>,
318}
319
320/// Marker for the `/api/voice/calls/{sync,list}` endpoint pair.
321///
322/// Use as a type parameter, never construct: `client.sync::<VoiceCalls>(&items)`.
323pub struct VoiceCalls;
324
325impl SyncEndpoint for VoiceCalls {
326    const RESOURCE: &'static str = "calls";
327    type Record = VoiceCallRecord;
328    type Query = VoiceCallsQuery;
329}
330
331impl HasSyncEnvelope for VoiceCallRecord {
332    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
333        &mut self.envelope
334    }
335}
336
337// ---- VoiceRecordings ------------------------------------------------------
338
339/// One per-call recording's metadata as it crosses the wire from the
340/// daemon up to the platform. The WAV bytes ride on a separate
341/// follow-up call ([`Client::upload_recording_bytes`]) so the
342/// idempotent metadata sync stays small and a flaky bytes upload
343/// doesn't force the daemon to re-ship the row.
344///
345/// Mirrors the daemon's `RecordingArtifact` (see
346/// `wavekat-voice/crates/wavekat-voice/src/recording.rs`) with one
347/// rename: the daemon's local id (`id`) ships as `source_id` because
348/// the platform allocates its own row id and treats the daemon-side
349/// UUID as the idempotency key (same convention as
350/// [`VoiceCallRecord`]).
351#[derive(Debug, Clone, Serialize, Deserialize)]
352#[serde(rename_all = "camelCase")]
353pub struct VoiceRecordingRecord {
354    /// Daemon-generated UUID for this recording artifact. Upsert key
355    /// on the platform side.
356    pub source_id: String,
357    /// Daemon's `calls.id` — the call this recording belongs to.
358    /// The platform stores both so the /voice/calls history page can
359    /// link a call to its recording without a separate join table.
360    pub call_source_id: String,
361    /// Byte length of the WAV file the daemon will PUT in the follow-
362    /// up bytes call. The platform refuses a PUT whose body length
363    /// disagrees.
364    pub size_bytes: u64,
365    pub duration_ms: u64,
366    pub sample_rate: u32,
367    pub channels: u16,
368    /// RFC 3339 timestamp the daemon stamped on the artifact at
369    /// finalize time. Drives the platform's `/voice/recordings` GET
370    /// cursor.
371    pub created_at: String,
372    #[serde(flatten, default)]
373    pub envelope: SyncEnvelope,
374}
375
376/// Query params for `GET /api/voice/recordings`.
377#[derive(Debug, Clone, Default, Serialize, Deserialize)]
378#[serde(rename_all = "camelCase")]
379pub struct VoiceRecordingsQuery {
380    /// RFC 3339 cursor; rows with `created_at < before` are returned.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub before: Option<String>,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub limit: Option<u32>,
385}
386
387/// Marker for the `/api/voice/recordings/{sync,list}` endpoint pair.
388///
389/// The corresponding bytes-upload endpoint
390/// (`PUT /api/voice/recordings/{sourceId}/bytes`) is invoked via
391/// [`Client::upload_recording_bytes`] — it doesn't fit the
392/// `SyncEndpoint` mold (no batch, no JSON body) so it has its own
393/// inherent method on `Client`.
394pub struct VoiceRecordings;
395
396impl SyncEndpoint for VoiceRecordings {
397    const RESOURCE: &'static str = "recordings";
398    type Record = VoiceRecordingRecord;
399    type Query = VoiceRecordingsQuery;
400}
401
402impl HasSyncEnvelope for VoiceRecordingRecord {
403    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
404        &mut self.envelope
405    }
406}
407
408/// One item in the platform's response to
409/// `POST /api/voice/recordings/sync`. Lets the daemon learn the R2
410/// key the platform stamped (so a subsequent bytes PUT can target it)
411/// without re-deriving it, and check whether bytes have already
412/// landed on a prior cycle (so the daemon can mark the local row
413/// synced without re-uploading the WAV).
414#[derive(Debug, Clone, Serialize, Deserialize)]
415#[serde(rename_all = "camelCase")]
416pub struct VoiceRecordingSyncItem {
417    pub source_id: String,
418    pub r2_key: String,
419    pub bytes_uploaded: bool,
420}
421
422/// Full response from `POST /api/voice/recordings/sync`. Superset of
423/// the generic [`crate::SyncResponse`] — see [`Client::sync_recordings`].
424#[derive(Debug, Clone, Serialize, Deserialize)]
425#[serde(rename_all = "camelCase")]
426pub struct VoiceRecordingsSyncResponse {
427    pub accepted: u32,
428    pub skipped: u32,
429    pub items: Vec<VoiceRecordingSyncItem>,
430}
431
432// ---- VoiceTranscripts -----------------------------------------------------
433
434/// Wire-stable transcript channel tag. Matches the daemon's
435/// `TranscriptChannelLabel` and `events::TranscriptChannel`.
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
437#[serde(rename_all = "snake_case")]
438pub enum VoiceTranscriptChannel {
439    /// Local mic audio — what the user said.
440    Local,
441    /// Received RTP audio — what the remote party said.
442    Remote,
443}
444
445/// One ASR transcript segment ("final" in wavekat-asr parlance) as it
446/// crosses the wire. Each segment is a row on the daemon side
447/// (`transcripts` table); the daemon batches a slice of them per
448/// upload and the platform upserts per (user_id, source_id).
449#[derive(Debug, Clone, Serialize, Deserialize)]
450#[serde(rename_all = "camelCase")]
451pub struct VoiceTranscriptRecord {
452    /// Daemon-side row id, formatted as text (the column is an
453    /// autoincrement integer on SQLite). Stable per (call, segment)
454    /// so re-shipping converges.
455    pub source_id: String,
456    /// Daemon's `calls.id` — the call this segment belongs to.
457    pub call_source_id: String,
458    pub channel: VoiceTranscriptChannel,
459    /// Start of the segment in milliseconds relative to the start of
460    /// the call's audio stream (not wall-clock).
461    pub ts_ms: i64,
462    /// End of the segment, same reference frame as `ts_ms`.
463    pub end_ms: i64,
464    /// Recognised text. Free-form; the platform stores it verbatim.
465    pub text: String,
466    #[serde(flatten, default)]
467    pub envelope: SyncEnvelope,
468}
469
470/// Query params for `GET /api/voice/transcripts` — required
471/// `call_source_id` (the endpoint refuses a flat list).
472#[derive(Debug, Clone, Default, Serialize, Deserialize)]
473#[serde(rename_all = "camelCase")]
474pub struct VoiceTranscriptsQuery {
475    pub call_source_id: String,
476}
477
478/// Marker for the `/api/voice/transcripts/{sync,list}` endpoint pair.
479pub struct VoiceTranscripts;
480
481impl SyncEndpoint for VoiceTranscripts {
482    const RESOURCE: &'static str = "transcripts";
483    type Record = VoiceTranscriptRecord;
484    type Query = VoiceTranscriptsQuery;
485}
486
487impl HasSyncEnvelope for VoiceTranscriptRecord {
488    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
489        &mut self.envelope
490    }
491}
492
493// ---- VoiceAccounts --------------------------------------------------------
494
495/// SIP transport for a synced account line. Wire-stable snake_case;
496/// mirrors the daemon's `TransportKind`.
497///
498/// Builds up to 0.0.32 knew only `udp` / `tcp` and failed a whole account
499/// page on anything else. Two things keep that from happening again:
500///
501/// - [`Unknown`](VoiceTransport::Unknown) catches any transport a newer
502///   platform stores that this build doesn't know, so one such line can't
503///   fail the rest of the page. A consumer should leave that line alone,
504///   not guess a transport for it.
505/// - `#[non_exhaustive]`, so the next transport is additive for consumers
506///   that `match` on this type.
507///
508/// The platform also hides transports a client didn't ask for (see
509/// [`VoiceAccountsQuery::transports`]), which is what protects the builds
510/// that predate both.
511#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
512#[serde(rename_all = "snake_case")]
513#[non_exhaustive]
514pub enum VoiceTransport {
515    Udp,
516    Tcp,
517    /// SIP over TLS (RFC 3261 §26.2).
518    Tls,
519    /// A transport this build doesn't know. Only ever produced by
520    /// deserializing; never send it — the platform rejects it.
521    #[serde(other)]
522    Unknown,
523}
524
525impl VoiceTransport {
526    /// Every transport this build can read and write — what
527    /// [`VoiceAccountsQuery::default`] asks the platform for.
528    pub const KNOWN: [VoiceTransport; 3] = [
529        VoiceTransport::Udp,
530        VoiceTransport::Tcp,
531        VoiceTransport::Tls,
532    ];
533
534    /// The wire token (`"udp"`, `"tcp"`, `"tls"`), matching serde.
535    pub fn as_str(self) -> &'static str {
536        match self {
537            Self::Udp => "udp",
538            Self::Tcp => "tcp",
539            Self::Tls => "tls",
540            Self::Unknown => "unknown",
541        }
542    }
543}
544
545/// One SIP account line's *configuration* as it crosses the wire from a
546/// device up to the platform and back down to another device
547/// (`wavekat-voice/docs/40-account-config-sync.md`).
548///
549/// Unlike calls / recordings / transcripts — which are immutable,
550/// one-way pushes — account config is **mutable and bidirectional**: a
551/// line is edited, toggled, renamed, and deleted, and those changes must
552/// restore onto a second device. The same idempotent
553/// `(user_id, source_id)` upsert that [`Client::sync`] performs carries
554/// every kind of change here; a *delete* is a soft-delete that rides as
555/// an upsert with `deleted_at` set, because a hard DELETE can't sync
556/// under a "push the row" model — once the row is gone there's nothing
557/// left to push.
558///
559/// **No secret field, by construction.** The SIP password never appears
560/// on this wire. Config sync (policy levels 1–2) keeps the credential
561/// device-local, and the end-to-end-encrypted secret path (level 3)
562/// ships its ciphertext through a *separate* opaque resource, never as a
563/// field here. Omitting it means level 3 can't be populated by accident
564/// before it exists.
565#[derive(Debug, Clone, Serialize, Deserialize)]
566#[serde(rename_all = "camelCase")]
567pub struct VoiceAccountRecord {
568    /// Daemon-side account UUID (`accounts.id`). The platform's
569    /// `(user_id, source_id)` upsert key — re-syncing the same id
570    /// updates the row in place (mutable), unlike the immutable
571    /// resources where a re-sync is a no-op.
572    pub source_id: String,
573    /// Whether the line registers on daemon boot. Pausing a line is a
574    /// portable preference, so it rides along.
575    pub enabled: bool,
576    pub display_name: String,
577    pub username: String,
578    pub domain: String,
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub auth_username: Option<String>,
581    #[serde(default, skip_serializing_if = "Option::is_none")]
582    pub server: Option<String>,
583    #[serde(default, skip_serializing_if = "Option::is_none")]
584    pub port: Option<u16>,
585    pub transport: VoiceTransport,
586    pub register_expires: u32,
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    pub keepalive_secs: Option<u32>,
589    /// Record-disclosure beep toggle — a column on the account row, so
590    /// it rides along for free (the account-portable taxonomy in doc 40).
591    pub disclosure_enabled: bool,
592    /// RFC 3339 last-modification time — the **last-write-wins key**. On
593    /// conflict the platform (and a pulling client) keep the copy with
594    /// the later `updated_at`. Whole-row LWW for v1; per-field merge is
595    /// deferred until users actually report lost edits (doc 40).
596    pub updated_at: String,
597    /// RFC 3339 soft-delete tombstone. `None` = live; `Some` = the line
598    /// was deleted on some device at that time. A tombstone syncs like
599    /// any other mutation so the delete propagates to other devices,
600    /// then is reaped locally once confirmed. The platform retains
601    /// tombstones so a late-syncing device still learns about the delete.
602    #[serde(default, skip_serializing_if = "Option::is_none")]
603    pub deleted_at: Option<String>,
604    /// Version + forward-compat fields shared by every sync record.
605    #[serde(flatten, default)]
606    pub envelope: SyncEnvelope,
607}
608
609/// Query params for `GET /api/voice/accounts`. Build it with
610/// `..Default::default()` so [`transports`](Self::transports) is filled in.
611#[derive(Debug, Clone, Serialize, Deserialize)]
612#[serde(rename_all = "camelCase")]
613pub struct VoiceAccountsQuery {
614    /// Include soft-deleted tombstones in the response. Absent / false
615    /// returns only live lines — the restore-grade pull a fresh device
616    /// wants. A delta-syncing device sets this `true` to also learn
617    /// about deletes made elsewhere (doc 40).
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub include_deleted: Option<bool>,
620    /// Comma-separated transports this client reads. The platform leaves
621    /// lines in any other transport out of the page — that is how builds
622    /// from before TLS, which send no value, never see a `tls` line.
623    /// Defaults to every [`VoiceTransport::KNOWN`]; set `None` only to
624    /// behave like one of those builds.
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub transports: Option<String>,
627}
628
629impl Default for VoiceAccountsQuery {
630    fn default() -> Self {
631        Self {
632            include_deleted: None,
633            transports: Some(
634                VoiceTransport::KNOWN
635                    .iter()
636                    .map(|t| t.as_str())
637                    .collect::<Vec<_>>()
638                    .join(","),
639            ),
640        }
641    }
642}
643
644/// Marker for the `/api/voice/accounts/{sync,list}` endpoint pair.
645///
646/// Accounts are the first *mutable, bidirectional* sync resource, but
647/// the wire shape is the same idempotent upsert the immutable resources
648/// use — the [`SyncResponse::skipped`](crate::sync::SyncResponse) field
649/// was reserved for exactly this case — so no new HTTP plumbing is
650/// needed: `client.sync::<VoiceAccounts>(&items)` uploads (including
651/// tombstones), `client.list::<VoiceAccounts>(&query)` pulls.
652pub struct VoiceAccounts;
653
654impl SyncEndpoint for VoiceAccounts {
655    const RESOURCE: &'static str = "accounts";
656    /// 2: the sender knows `tls`. The platform won't let a version-1
657    /// record (a build from before TLS, holding a stale copy of a line)
658    /// overwrite a stored TLS line.
659    const CURRENT_SCHEMA_VERSION: u32 = 2;
660    type Record = VoiceAccountRecord;
661    type Query = VoiceAccountsQuery;
662}
663
664impl HasSyncEnvelope for VoiceAccountRecord {
665    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
666        &mut self.envelope
667    }
668}
669
670// ---- VoiceFlows (published pull) -------------------------------------------
671//
672// The daemon-facing pull leg of the call-flow ("Receptionist") system —
673// `wavekat-voice/docs/48-ivr-call-flows.md`'s control-plane split. Flows
674// are *authored* on the platform (drafts, publish gate, version
675// history); the daemon only ever reads the published snapshots, caches
676// them locally, and runs them offline. There is no upload direction, so
677// this is not a `SyncEndpoint` (that trait models the `{resource}/sync`
678// + list pair): it's a single typed GET, like the share commands above.
679
680/// One published call-flow snapshot as served by
681/// `GET /api/voice/flows/published`: the latest published version of a
682/// flow the bearer authored. The YAML carries the platform-stamped
683/// `id`/`name`/`version` and is served verbatim — the daemon re-parses
684/// and re-validates it on load rather than trusting the wire.
685#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
686#[serde(rename_all = "camelCase")]
687pub struct VoiceFlowRecord {
688    /// Platform-assigned flow id (`flow_…`), stable across versions.
689    pub id: String,
690    pub name: String,
691    /// Latest published version number (1-based, bumps on publish).
692    pub version: u32,
693    /// The immutable published document, verbatim.
694    pub yaml: String,
695    /// RFC 3339 time this version was published.
696    pub published_at: String,
697}
698
699/// Query params for `GET /api/voice/flows/published`. Cursor-paginated
700/// by flow id ascending; pass the previous page's `next_after` until it
701/// comes back `None` to collect the full set. The full set is what the
702/// daemon's reconcile wants — a cached flow absent from a complete pull
703/// was deleted on the platform.
704#[derive(Debug, Clone, Default, Serialize, Deserialize)]
705#[serde(rename_all = "camelCase")]
706pub struct VoiceFlowsQuery {
707    #[serde(default, skip_serializing_if = "Option::is_none")]
708    pub after: Option<String>,
709    /// Page size, server-capped at 100. `None` = server default (50).
710    #[serde(default, skip_serializing_if = "Option::is_none")]
711    pub limit: Option<u32>,
712    /// The document versions this caller's flow engine can run —
713    /// `wavekat_flow::SUPPORTED_SCHEMA_VERSIONS`, comma-separated
714    /// ascending ("1,2"). The platform withholds documents in any other
715    /// version rather than serving one the caller would fail to parse.
716    ///
717    /// **Send it.** `None` does not mean "anything goes": the platform
718    /// reads a missing value as version 1 only, because this parameter
719    /// arrived alongside version 2 and a caller that omits it is an
720    /// older build. A client that can run a newer version and stays
721    /// quiet silently loses those flows.
722    //
723    // Explicitly renamed: the struct is camelCase overall, but this
724    // route's query parameter is `schema_versions`, and a silently
725    // camelCased key would be ignored by the server — which reads
726    // exactly like a platform that has no such flows.
727    #[serde(
728        rename = "schema_versions",
729        default,
730        skip_serializing_if = "Option::is_none"
731    )]
732    pub schema_versions: Option<String>,
733}
734
735/// One page of published flow snapshots.
736#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
737#[serde(rename_all = "camelCase")]
738pub struct VoiceFlowsPage {
739    pub items: Vec<VoiceFlowRecord>,
740    /// Cursor for the next page; `None` = end of the set.
741    #[serde(default)]
742    pub next_after: Option<String>,
743}
744
745/// One frozen audio asset of a published flow version, as served by
746/// `GET /api/voice/flows/{id}/versions/{version}/assets` (wavekat-platform
747/// docs 16/17). The bytes were copied into a version-owned R2 object at
748/// publish time and never change, so `content_hash` identifies them
749/// exactly — the daemon diffs its local cache against it rather than
750/// trusting a bare filename, because the *same* `ref` can carry different
751/// bytes across two versions of the same flow.
752#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
753#[serde(rename_all = "camelCase")]
754pub struct VoiceFlowVersionAsset {
755    /// The `vprompt_…` reference exactly as it appears in the flow YAML.
756    #[serde(rename = "ref")]
757    pub asset_ref: String,
758    /// Source telephony format the clip was frozen as (`ulaw_8000`,
759    /// `pcm_16000`, `mp3`, …); the container is WAV unless `mp3`.
760    pub format: String,
761    /// Size of the frozen bytes.
762    pub byte_size: u64,
763    /// Clip duration if the platform knew it at freeze time.
764    #[serde(default)]
765    pub duration_ms: Option<u64>,
766    /// sha256 of the frozen bytes — the cache's content key.
767    pub content_hash: String,
768}
769
770/// The frozen-asset manifest for one published version. Not paginated:
771/// a flow's asset count is bounded by its node count (a phone tree is
772/// tens of clips), so the platform returns them all in one response.
773#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
774#[serde(rename_all = "camelCase")]
775pub struct VoiceFlowAssetsPage {
776    pub assets: Vec<VoiceFlowVersionAsset>,
777}
778
779// ---- System flows (public, unauthenticated) --------------------------------
780//
781// The platform's curated set of ready-made call flows, served to every
782// device — signed in or not — and cached locally for offline use. These
783// are distinct from a user's own authored flows (the published-flows
784// endpoint above) and are read-only to clients; the flows are authored
785// on the platform and have no upload direction. Keyed by language tier
786// and published schema version (spec §5, doc 48 amendment 2026-08-27).
787
788/// One system (ready-made) call-flow record as served by the unauthenticated
789/// `GET /api/voice/flows/system?language=…&schema_versions=…` endpoint.
790/// Public by design — a signed-out device lists the catalogue and may
791/// preview, cache, and arm from it (gated by entitlement at arming time).
792///
793/// `description`, `publishedAt`, and `systemTags` may be absent on older
794/// rows or when the platform withheld them; all are optional.
795///
796/// A consumer has to be able to *name* this type — to map a record into its
797/// own cache row, or to build one in a fixture — not merely receive it by
798/// inference from [`Client::system_flows`]. This example is that guarantee:
799/// a doctest compiles as a downstream crate, so it fails if the type ever
800/// stops being re-exported from the crate root. The unit tests below cannot
801/// catch that, because inside the crate the private `voice` module is always
802/// in scope — which is exactly how 0.0.26 through 0.0.28 shipped these two
803/// types unreachable.
804///
805/// ```
806/// use wavekat_platform_client::{VoiceSystemFlowRecord, VoiceSystemFlowsPage};
807///
808/// let page: VoiceSystemFlowsPage = serde_json::from_str(
809///     r#"{"flows":[{"id":"flow_voicemail","name":"Voicemail","description":"",
810///          "language":"en","version":2,"yaml":"schema_version: 1\n",
811///          "publishedAt":null,"access":"open","systemTags":["system"]}]}"#,
812/// )
813/// .unwrap();
814/// let first: &VoiceSystemFlowRecord = &page.flows[0];
815/// assert_eq!(first.access, "open");
816/// ```
817#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
818#[serde(rename_all = "camelCase")]
819pub struct VoiceSystemFlowRecord {
820    /// Platform-assigned flow id (`flow_…`), stable across versions.
821    pub id: String,
822    pub name: String,
823    /// Optional short description of what the flow does.
824    #[serde(default)]
825    pub description: String,
826    /// BCP-47-ish language tag — the tier this flow was selected in by
827    /// the device's language preference.
828    pub language: String,
829    /// Published version number (1-based).
830    pub version: u32,
831    /// The immutable published YAML document, verbatim.
832    pub yaml: String,
833    /// When this version was published, **verbatim from the platform's D1
834    /// column** — which defaults to SQLite `CURRENT_TIMESTAMP` and so is
835    /// `"YYYY-MM-DD HH:MM:SS"` in UTC, *not* RFC 3339 (space separator, no
836    /// offset). Some rows do carry RFC 3339. Consumers must accept **both**:
837    /// a strict RFC 3339 parse is how every pulled flow once rendered as
838    /// "Updated Jan 1, 1970" in the desktop client. Absent on older rows.
839    #[serde(default)]
840    pub published_at: Option<String>,
841    /// Platform-resolved arming rung. One of `"open"`, `"account"`, `"pro"`,
842    /// or an unknown value (forward-compat for new platform rungs). Unknown
843    /// values are treated as the strictest known rung at arm time.
844    pub access: String,
845    /// Raw platform tags, preserved verbatim so a future feature can read
846    /// a new tag without a daemon release.
847    #[serde(default)]
848    pub system_tags: Vec<String>,
849}
850
851/// One page of system flows as served by
852/// `GET /api/voice/flows/system?language=…&schema_versions=…`.
853#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
854#[serde(rename_all = "camelCase")]
855pub struct VoiceSystemFlowsPage {
856    pub flows: Vec<VoiceSystemFlowRecord>,
857}
858
859impl Client {
860    /// `GET /api/voice/flows/published` — one page of the caller's
861    /// published flow snapshots (latest version each). Strictly
862    /// creator-scoped server-side; never returns another user's flows.
863    pub async fn published_flows(&self, query: &VoiceFlowsQuery) -> Result<VoiceFlowsPage> {
864        self.get_json_query::<VoiceFlowsPage, _>("/api/voice/flows/published", query)
865            .await
866    }
867
868    /// `GET /api/voice/flows/{id}/versions/{version}/assets` — the frozen
869    /// audio manifest for one published version (docs 16/17). Flow-scoped
870    /// server-side: a version of a flow the caller doesn't own is a 404,
871    /// never another user's assets. An existing, visible version with no
872    /// generated audio returns an empty manifest.
873    pub async fn flow_version_assets(
874        &self,
875        flow_id: &str,
876        version: u32,
877    ) -> Result<VoiceFlowAssetsPage> {
878        let path = format!("/api/voice/flows/{flow_id}/versions/{version}/assets");
879        self.get_json::<VoiceFlowAssetsPage>(&path).await
880    }
881
882    /// `GET /api/voice/flows/{id}/versions/{version}/assets/{ref}/bytes` —
883    /// the immutable frozen copy of one clip, served from the version's own
884    /// asset set (never the mutable library). Returned in memory because a
885    /// clip is tens of KB and the daemon writes it atomically into its
886    /// on-disk cache; same flow-scoped 404 as the manifest.
887    pub async fn flow_version_asset_bytes(
888        &self,
889        flow_id: &str,
890        version: u32,
891        asset_ref: &str,
892    ) -> Result<Vec<u8>> {
893        let path =
894            format!("/api/voice/flows/{flow_id}/versions/{version}/assets/{asset_ref}/bytes");
895        self.get_bytes(&path).await
896    }
897
898    /// `GET /api/voice/flows/system?language=…&schema_versions=…` — the
899    /// curated system (ready-made) flow catalogue, tier-cut by language and
900    /// filterable by supported schema versions. Public by design — a
901    /// signed-out device lists and caches the catalogue. No bearer auth
902    /// on purpose; the endpoint is available before any sign-in.
903    ///
904    /// `language` is optional (the platform lists all when absent); pass
905    /// `None` to omit it. `schema_versions` is a comma-separated ascending
906    /// list (`"1,2"`) and is always sent — the platform reads silence as
907    /// "v1 only", same warning as [`VoiceFlowsQuery::schema_versions`].
908    pub async fn system_flows(
909        base_url: &str,
910        language: Option<&str>,
911        schema_versions: &str,
912    ) -> Result<VoiceSystemFlowsPage> {
913        let language_owned;
914        let mut query: Vec<(&str, &str)> = vec![("schema_versions", schema_versions)];
915        if let Some(lang) = language {
916            language_owned = lang.to_string();
917            query.push(("language", &language_owned));
918        }
919        Self::get_public_json::<VoiceSystemFlowsPage>(base_url, "/api/voice/flows/system", &query)
920            .await
921    }
922
923    /// `GET /api/voice/flows/system/{id}/versions/{version}/assets` — the
924    /// frozen audio manifest for one system flow version. Public by design.
925    /// Returns an empty manifest if the version has no generated audio.
926    ///
927    /// Reuses [`VoiceFlowAssetsPage`], which is the same wire shape as the
928    /// gated manifest for owned flows.
929    pub async fn system_flow_version_assets(
930        base_url: &str,
931        flow_id: &str,
932        version: u32,
933    ) -> Result<VoiceFlowAssetsPage> {
934        let path = format!("/api/voice/flows/system/{flow_id}/versions/{version}/assets");
935        Self::get_public_json::<VoiceFlowAssetsPage>(base_url, &path, &[]).await
936    }
937
938    /// `GET /api/voice/flows/system/{id}/versions/{version}/assets/{ref}/bytes`
939    /// — one clip from a
940    /// system flow's frozen asset set. Public by design — a signed-out
941    /// device fetches clips for offline preview and caching. Returned in
942    /// memory because a clip is tens of KB; same atomicity and offline-safe
943    /// guarantees as the gated owned-flow asset fetch.
944    pub async fn system_flow_version_asset_bytes(
945        base_url: &str,
946        flow_id: &str,
947        version: u32,
948        asset_ref: &str,
949    ) -> Result<Vec<u8>> {
950        let path = format!(
951            "/api/voice/flows/system/{flow_id}/versions/{version}/assets/{asset_ref}/bytes"
952        );
953        Self::get_public_bytes(base_url, &path).await
954    }
955}
956
957// ---- Booking (mid-call, synchronous) ---------------------------------------
958//
959// The action plane of wavekat-platform's docs/30: a `book` step asking
960// "when is this business free?" and then "put the caller in at this
961// time", with the caller on the line.
962//
963// Unlike every other endpoint in this file, these are **synchronous and
964// in-call**. Nothing here is queued, batched or retried: a person is
965// waiting, so the platform answers within seconds or answers
966// `unavailable`, and the flow takes its fallback exit. Callers should
967// give these a short timeout of their own and treat expiry the same way
968// they treat `unavailable`.
969//
970// The calendar credential never reaches this crate. The platform holds
971// the connection and answers in times and outcomes — which is what makes
972// booking a pair of platform calls rather than a Google client in every
973// daemon.
974//
975// Wire note: these routes use `snake_case` bodies, unlike the camelCase
976// sync resources above, so these types carry no `rename_all`.
977
978/// One open window in a business's week, `"HH:MM"` 24-hour local time —
979/// the same shape the flow document's `hours`/`book` steps carry.
980#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
981pub struct BookingTimeRange {
982    pub open: String,
983    pub close: String,
984}
985
986/// Open windows per weekday. A missing or empty day is closed.
987#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
988pub struct BookingSchedule {
989    #[serde(default, skip_serializing_if = "Vec::is_empty")]
990    pub mon: Vec<BookingTimeRange>,
991    #[serde(default, skip_serializing_if = "Vec::is_empty")]
992    pub tue: Vec<BookingTimeRange>,
993    #[serde(default, skip_serializing_if = "Vec::is_empty")]
994    pub wed: Vec<BookingTimeRange>,
995    #[serde(default, skip_serializing_if = "Vec::is_empty")]
996    pub thu: Vec<BookingTimeRange>,
997    #[serde(default, skip_serializing_if = "Vec::is_empty")]
998    pub fri: Vec<BookingTimeRange>,
999    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1000    pub sat: Vec<BookingTimeRange>,
1001    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1002    pub sun: Vec<BookingTimeRange>,
1003}
1004
1005/// A single-date override of the weekly schedule (a holiday, or special
1006/// hours).
1007#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1008pub struct BookingException {
1009    /// `"YYYY-MM-DD"` in the schedule's own timezone.
1010    pub date: String,
1011    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1012    pub closed: bool,
1013    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1014    pub ranges: Vec<BookingTimeRange>,
1015}
1016
1017/// Body of `POST /api/voice/booking/slots`.
1018///
1019/// Everything except `source_id` comes straight off the flow document's
1020/// `book` step; the platform holds no per-node configuration of its own.
1021#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1022pub struct BookingSlotsRequest {
1023    /// The call this offer belongs to (`voice_calls.source_id`). Slots
1024    /// are held against it, which is what stops a caller being blocked
1025    /// by their own offers — and what stops a second caller being
1026    /// offered the same time.
1027    pub source_id: String,
1028    pub duration_mins: u32,
1029    #[serde(default)]
1030    pub buffer_mins: u32,
1031    #[serde(default)]
1032    pub lead_mins: u32,
1033    #[serde(default)]
1034    pub horizon_days: u32,
1035    pub schedule: BookingSchedule,
1036    /// IANA zone the schedule is written in.
1037    pub timezone: String,
1038    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1039    pub exceptions: Vec<BookingException>,
1040    /// How many times to offer. The answer may be shorter, never longer.
1041    pub limit: u32,
1042}
1043
1044/// One offerable appointment, as absolute RFC 3339 instants.
1045#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1046pub struct BookingSlot {
1047    pub start: String,
1048    pub end: String,
1049}
1050
1051/// Answer to `POST /api/voice/booking/slots`.
1052///
1053/// `slots` empty is a real answer — the calendar is full, or the window
1054/// closed — and not an error: the flow takes its no-slots exit.
1055#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1056pub struct BookingSlotsResponse {
1057    #[serde(default)]
1058    pub slots: Vec<BookingSlot>,
1059    /// The zone the times should be *spoken* in — the business's, echoed
1060    /// back so the caller isn't told a time in the server's zone.
1061    #[serde(default)]
1062    pub timezone: String,
1063    /// Set when the platform could not read the calendar at all
1064    /// (`"unavailable"`); `slots` is then empty and the reason is for
1065    /// logs, never for a caller.
1066    #[serde(default, skip_serializing_if = "Option::is_none")]
1067    pub status: Option<String>,
1068    #[serde(default, skip_serializing_if = "Option::is_none")]
1069    pub reason: Option<String>,
1070}
1071
1072/// Body of `POST /api/voice/booking/book`.
1073///
1074/// Idempotent on `source_id`: a retried request for a call that already
1075/// has an appointment answers `booked` with the existing event's start,
1076/// without touching the calendar. A timed-out request is therefore safe
1077/// to repeat.
1078#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1079pub struct BookingBookRequest {
1080    pub source_id: String,
1081    /// One of the `start`s `/slots` handed back, verbatim.
1082    pub start: String,
1083    pub duration_mins: u32,
1084    pub timezone: String,
1085    /// Who is booking, for the calendar entry. Empty when the call
1086    /// carried no caller id.
1087    #[serde(default)]
1088    pub caller_number: String,
1089    #[serde(default, skip_serializing_if = "Option::is_none")]
1090    pub caller_name: Option<String>,
1091}
1092
1093/// Answer to `POST /api/voice/booking/book`.
1094///
1095/// Three outcomes, and the flow does something different with each:
1096/// `booked` continues, `slot_taken` can offer again, `unavailable` falls
1097/// back. Left as a string rather than an enum so a status added later
1098/// deserializes instead of failing the call.
1099#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1100pub struct BookingBookResponse {
1101    pub status: String,
1102    /// Present on `booked` — the instant the appointment actually
1103    /// starts, which on an idempotent retry is the *existing* event's
1104    /// start and not necessarily the one that was asked for.
1105    #[serde(default, skip_serializing_if = "Option::is_none")]
1106    pub start: Option<String>,
1107    #[serde(default, skip_serializing_if = "Option::is_none")]
1108    pub reason: Option<String>,
1109}
1110
1111impl Client {
1112    /// `POST /api/voice/booking/slots` — when is this business free?
1113    ///
1114    /// Writes as well as reads: every time it returns is held for
1115    /// `source_id` for a couple of minutes, so a second caller is not
1116    /// offered it while this one is still deciding. Re-offering the same
1117    /// call refreshes its own holds rather than colliding with them.
1118    pub async fn booking_slots(
1119        &self,
1120        request: &BookingSlotsRequest,
1121    ) -> Result<BookingSlotsResponse> {
1122        self.post_json::<BookingSlotsResponse, _>("/api/voice/booking/slots", request)
1123            .await
1124    }
1125
1126    /// `POST /api/voice/booking/book` — put the caller in at this time.
1127    pub async fn booking_book(&self, request: &BookingBookRequest) -> Result<BookingBookResponse> {
1128        self.post_json::<BookingBookResponse, _>("/api/voice/booking/book", request)
1129            .await
1130    }
1131}
1132
1133// ---- Anonymous install heartbeat ------------------------------------------
1134//
1135// A first-run / per-launch ping the desktop daemon fires *before* (and
1136// independently of) any platform sign-in, so the platform can count
1137// installs and track version / OS adoption for users who never sign in.
1138// It hits the public, unauthenticated `POST /api/voice/installs/heartbeat`
1139// and upserts a row keyed by `install_id` alone (no user) — distinct
1140// from the authenticated `voice_clients` heartbeat, which is keyed by
1141// `(user, install_id)`.
1142//
1143// The environment fields (os / os_version / arch / locale) are gathered
1144// *here*, inside the client crate, rather than on the consumer side:
1145// the daemon only owns the two values this crate genuinely cannot
1146// discover — the persisted `install_id` and its own app version.
1147
1148/// Best-effort snapshot of the host environment, detected at call time.
1149/// Every field is best-effort; a probe that fails contributes `None`
1150/// (or, for the always-available `os` / `arch`, the compile-time
1151/// target) rather than failing the heartbeat.
1152#[derive(Debug, Clone, PartialEq, Eq)]
1153pub struct SystemInfo {
1154    /// `std::env::consts::OS` — `"macos"`, `"windows"`, `"linux"`, …
1155    pub os: String,
1156    /// Human OS version, e.g. `"15.5.0"`. `None` when the OS probe
1157    /// can't determine it.
1158    pub os_version: Option<String>,
1159    /// `std::env::consts::ARCH` — `"aarch64"`, `"x86_64"`, …
1160    pub arch: String,
1161    /// BCP-47 system locale, e.g. `"en-NZ"`. `None` when unset /
1162    /// undetectable (common for GUI-launched apps on some platforms).
1163    pub locale: Option<String>,
1164}
1165
1166impl SystemInfo {
1167    /// Probe the current host. Cheap enough to call per heartbeat; we
1168    /// don't cache so a locale change between launches is reflected.
1169    pub fn detect() -> Self {
1170        let os_version = match LinuxSandbox::detect() {
1171            // Inside a sandbox `os_info` reads the sandbox's own release
1172            // file, not the host's — see [`host_os_version`].
1173            Some(sandbox) => host_os_version(sandbox, |path| std::fs::read_to_string(path).ok()),
1174            None => match os_info::get().version() {
1175                os_info::Version::Unknown => None,
1176                v => Some(v.to_string()),
1177            },
1178        };
1179        SystemInfo {
1180            os: std::env::consts::OS.to_string(),
1181            os_version,
1182            arch: std::env::consts::ARCH.to_string(),
1183            locale: sys_locale::get_locale(),
1184        }
1185    }
1186}
1187
1188/// A Linux app sandbox that hides the host's `/etc/os-release`.
1189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1190#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1191enum LinuxSandbox {
1192    Snap,
1193    Flatpak,
1194}
1195
1196impl LinuxSandbox {
1197    #[cfg(target_os = "linux")]
1198    fn detect() -> Option<Self> {
1199        if std::path::Path::new("/.flatpak-info").exists() {
1200            Some(LinuxSandbox::Flatpak)
1201        } else if std::env::var_os("SNAP").is_some() {
1202            Some(LinuxSandbox::Snap)
1203        } else {
1204            None
1205        }
1206    }
1207
1208    #[cfg(not(target_os = "linux"))]
1209    fn detect() -> Option<Self> {
1210        None
1211    }
1212}
1213
1214/// The *host's* OS version as seen from inside `sandbox`.
1215///
1216/// `os_info` resolves `/etc/os-release`, which a sandbox rewrites:
1217///
1218/// - **Flatpak** mounts its runtime there (`ID=org.freedesktop.platform`);
1219///   the host's copy is exposed at `/run/host/os-release`.
1220/// - **Snap** shares the host's `/etc`, but `/etc/os-release` is a
1221///   symlink into `/usr`, which comes from the base snap — so it reads
1222///   `ID=ubuntu-core`. The host copy under `/var/lib/snapd/hostfs` needs
1223///   the `system-observe` interface, which snapd never auto-connects.
1224///   `/etc/lsb-release` is a real file on Ubuntu hosts and readable under
1225///   the default confinement, so it's the one host source we can reach.
1226///
1227/// Returns `None` rather than falling back to `os_info`: inside a sandbox
1228/// that would report the runtime's version as if it were the host's.
1229fn host_os_version(sandbox: LinuxSandbox, read: impl Fn(&str) -> Option<String>) -> Option<String> {
1230    let sources: &[(&str, &str)] = match sandbox {
1231        LinuxSandbox::Flatpak => &[
1232            ("/run/host/os-release", "VERSION_ID"),
1233            ("/run/host/usr/lib/os-release", "VERSION_ID"),
1234            ("/run/host/etc/os-release", "VERSION_ID"),
1235        ],
1236        LinuxSandbox::Snap => &[("/etc/lsb-release", "DISTRIB_RELEASE")],
1237    };
1238    sources
1239        .iter()
1240        .find_map(|(path, key)| read(path).and_then(|contents| release_value(&contents, key)))
1241}
1242
1243/// The value of `key` in an os-release / lsb-release style `KEY=value`
1244/// file, with surrounding quotes stripped. `None` when absent or blank.
1245fn release_value(contents: &str, key: &str) -> Option<String> {
1246    contents.lines().find_map(|line| {
1247        let (k, v) = line.trim().split_once('=')?;
1248        if k.trim() != key {
1249            return None;
1250        }
1251        let v = v.trim().trim_matches(|c| c == '"' || c == '\'').trim();
1252        (!v.is_empty()).then(|| v.to_string())
1253    })
1254}
1255
1256/// Body of `POST /api/voice/installs/heartbeat`. The daemon supplies
1257/// `install_id` + `app_version`; [`Client::install_heartbeat`] fills the
1258/// environment fields from [`SystemInfo::detect`].
1259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1260#[serde(rename_all = "camelCase")]
1261pub struct InstallHeartbeatRequest {
1262    /// The daemon's persisted install UUID — the platform's upsert key.
1263    pub install_id: String,
1264    /// WaveKat Voice's own version (`env!("CARGO_PKG_VERSION")` on the
1265    /// daemon side) — *not* this crate's version.
1266    pub app_version: String,
1267    pub os: String,
1268    #[serde(default, skip_serializing_if = "Option::is_none")]
1269    pub os_version: Option<String>,
1270    #[serde(default, skip_serializing_if = "Option::is_none")]
1271    pub arch: Option<String>,
1272    #[serde(default, skip_serializing_if = "Option::is_none")]
1273    pub locale: Option<String>,
1274    /// How this copy was obtained — `"direct"` for a plain download,
1275    /// `"mas"` for the sandboxed Mac App Store build. Unlike every other
1276    /// field here it is **not** detectable: the two macOS builds share a
1277    /// bundle id and a version, and the binary is identical, so only the
1278    /// consumer knows which one it is shipping inside. Hence a caller
1279    /// argument rather than part of [`SystemInfo`].
1280    ///
1281    /// Free text by contract, not an enum: the platform stores whatever
1282    /// arrives so a new distribution can ship without a server release.
1283    /// `None` when the consumer has nothing meaningful to say (a source
1284    /// build, a package this crate has never heard of) — omitted from
1285    /// the body entirely rather than sent as null.
1286    #[serde(default, skip_serializing_if = "Option::is_none")]
1287    pub distribution: Option<String>,
1288    /// Fleet-admin fields (build provenance, update-channel and
1289    /// updater state, native arch, process start time). Flattened onto
1290    /// the wire so the body stays flat JSON even though the daemon
1291    /// builds one value; see [`InstallHeartbeatFleet`].
1292    #[serde(flatten, default)]
1293    pub fleet: InstallHeartbeatFleet,
1294}
1295
1296/// Optional fleet-admin fields on the install heartbeat, grouped so the
1297/// daemon can build (and the platform's fleet-admin view can read) one
1298/// value rather than ten loose arguments. Flattened onto
1299/// [`InstallHeartbeatRequest`] via `#[serde(flatten)]`, so on the wire
1300/// these fields sit alongside `installId` / `appVersion` / … with no
1301/// nesting.
1302///
1303/// Every field is optional and additive: an older daemon that never
1304/// sets them sends a body identical to the one before this struct
1305/// existed (an all-`None` `InstallHeartbeatFleet` serializes to no
1306/// extra keys), and a platform that hasn't deployed the corresponding
1307/// migration yet simply stores nulls. Read by `wavekat-platform`'s
1308/// fleet-admin view (docs/45) — keep every field optional so this
1309/// remains safe to send against an older platform and safe to omit
1310/// from an older daemon.
1311#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1312#[serde(rename_all = "camelCase")]
1313pub struct InstallHeartbeatFleet {
1314    /// The git SHA the daemon binary was built from.
1315    #[serde(default, skip_serializing_if = "Option::is_none")]
1316    pub build_sha: Option<String>,
1317    /// How this copy was obtained, at a finer grain than
1318    /// [`InstallHeartbeatRequest::distribution`] — e.g. `"mas"`,
1319    /// `"msstore"`, `"snap"`, `"appimage"`, `"deb"`, `"direct"`,
1320    /// `"dev"`. Free text by contract, not an enum: the platform
1321    /// stores whatever arrives so a new install source can ship
1322    /// without a server release.
1323    #[serde(default, skip_serializing_if = "Option::is_none")]
1324    pub install_source: Option<String>,
1325    /// Which release channel this build tracks — `"stable"` or
1326    /// `"beta"`.
1327    #[serde(default, skip_serializing_if = "Option::is_none")]
1328    pub update_channel: Option<String>,
1329    /// Whether the daemon's self-updater is active. `false` where the
1330    /// updater is inert because the platform's own store owns updates
1331    /// instead (Mac App Store, Microsoft Store) or the build is
1332    /// unpackaged.
1333    #[serde(default, skip_serializing_if = "Option::is_none")]
1334    pub updater_enabled: Option<bool>,
1335    /// The self-updater's current state machine status — `"idle"`,
1336    /// `"checking"`, `"available"`, `"downloading"`, `"downloaded"`,
1337    /// `"not-available"`, or `"error"`.
1338    #[serde(default, skip_serializing_if = "Option::is_none")]
1339    pub updater_status: Option<String>,
1340    /// The version the updater has staged or is offering, when there
1341    /// is one pending.
1342    #[serde(default, skip_serializing_if = "Option::is_none")]
1343    pub updater_version: Option<String>,
1344    /// ISO-8601 timestamp of the updater's last check.
1345    #[serde(default, skip_serializing_if = "Option::is_none")]
1346    pub updater_checked_at: Option<String>,
1347    /// The updater's last error message, if any. At most 256
1348    /// characters — the caller truncates before sending.
1349    #[serde(default, skip_serializing_if = "Option::is_none")]
1350    pub updater_error: Option<String>,
1351    /// `os.machine()` — the host's native architecture. Differs from
1352    /// `arch` when the process is running under translation (e.g.
1353    /// Rosetta on Apple Silicon).
1354    #[serde(default, skip_serializing_if = "Option::is_none")]
1355    pub native_arch: Option<String>,
1356    /// ISO-8601 timestamp of when the daemon process started.
1357    #[serde(default, skip_serializing_if = "Option::is_none")]
1358    pub started_at: Option<String>,
1359    /// Whether a call flow is armed on at least one line right now.
1360    ///
1361    /// The one activation fact that is *state* rather than a milestone,
1362    /// so it rides the periodic snapshot; "did this install ever add an
1363    /// account / register / connect a call" are milestones and go
1364    /// through [`Client::install_usage_events_with`] instead. A yes/no
1365    /// by design — the anonymous ping carries no identity. Absent means
1366    /// "not reported by this build", never "no": the platform stores
1367    /// NULL and reads it as unknown.
1368    #[serde(default, skip_serializing_if = "Option::is_none")]
1369    pub flow_armed: Option<bool>,
1370}
1371
1372/// The platform's view of an install row, echoed back from a heartbeat.
1373#[derive(Debug, Clone, Serialize, Deserialize)]
1374#[serde(rename_all = "camelCase")]
1375pub struct InstallHeartbeatResponse {
1376    pub id: String,
1377    pub install_id: String,
1378    pub app_version: String,
1379    pub os: String,
1380    pub os_version: Option<String>,
1381    pub arch: Option<String>,
1382    pub locale: Option<String>,
1383    /// Echoed back. `#[serde(default)]` because a platform deployed
1384    /// before this field existed omits the key rather than sending null,
1385    /// and a heartbeat must not fail to parse against an older server.
1386    #[serde(default)]
1387    pub distribution: Option<String>,
1388    pub first_seen_at: String,
1389    pub last_seen_at: String,
1390}
1391
1392impl Client {
1393    /// `POST /api/voice/installs/heartbeat` — the anonymous, no-auth
1394    /// first-run install ping. Detects the host environment internally
1395    /// and posts it alongside the caller-supplied `install_id` +
1396    /// `app_version`. Associated (not a method) because the endpoint is
1397    /// unauthenticated — there's no token, and at first run there's no
1398    /// signed-in `Client` to hang it off of.
1399    ///
1400    /// Though unauthenticated, the request is **signed** with the release
1401    /// credential `cred` (a per-version Ed25519 key + master-issued
1402    /// certificate the consumer bakes in at build time) so the platform
1403    /// can verify it came from a genuine release and reject forged or
1404    /// replayed pings — see [`Client::post_public_signed_json`] and
1405    /// [`crate::sign`]. The platform needs only the master *public* key to
1406    /// verify.
1407    ///
1408    /// `base_url` is the platform base (e.g. `https://platform.wavekat.com`).
1409    ///
1410    /// `distribution` says how this copy was obtained (`"direct"`,
1411    /// `"mas"`, …). It is the one field this call can't detect for
1412    /// itself — see [`InstallHeartbeatRequest::distribution`] — so pass
1413    /// `None` if the consumer has nothing meaningful to say.
1414    ///
1415    /// Sends no fleet-admin fields (see [`InstallHeartbeatFleet`]) — a
1416    /// thin wrapper over [`Client::install_heartbeat_with`] for
1417    /// callers that don't have them. Consumers that do should call
1418    /// [`Client::install_heartbeat_with`] directly instead.
1419    pub async fn install_heartbeat(
1420        base_url: &str,
1421        install_id: &str,
1422        app_version: &str,
1423        distribution: Option<&str>,
1424        cred: &ReleaseCredential,
1425    ) -> Result<InstallHeartbeatResponse> {
1426        Client::install_heartbeat_with(
1427            base_url,
1428            install_id,
1429            app_version,
1430            distribution,
1431            cred,
1432            InstallHeartbeatFleet::default(),
1433        )
1434        .await
1435    }
1436
1437    /// As [`Client::install_heartbeat`], but also takes the
1438    /// fleet-admin fields (build provenance, update-channel and
1439    /// updater state, native arch, process start time) that a
1440    /// fleet-aware daemon can supply — see [`InstallHeartbeatFleet`].
1441    /// Pass `InstallHeartbeatFleet::default()` for a caller with
1442    /// nothing to report; [`Client::install_heartbeat`] does exactly
1443    /// that.
1444    pub async fn install_heartbeat_with(
1445        base_url: &str,
1446        install_id: &str,
1447        app_version: &str,
1448        distribution: Option<&str>,
1449        cred: &ReleaseCredential,
1450        fleet: InstallHeartbeatFleet,
1451    ) -> Result<InstallHeartbeatResponse> {
1452        let sys = SystemInfo::detect();
1453        let body = InstallHeartbeatRequest {
1454            install_id: install_id.to_string(),
1455            app_version: app_version.to_string(),
1456            os: sys.os,
1457            os_version: sys.os_version,
1458            arch: Some(sys.arch),
1459            locale: sys.locale,
1460            distribution: distribution.map(str::to_string),
1461            fleet,
1462        };
1463        Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
1464            base_url,
1465            "/api/voice/installs/heartbeat",
1466            &body,
1467            cred,
1468        )
1469        .await
1470    }
1471
1472    /// `POST /api/voice/installs/events` — the anonymous usage-event
1473    /// batch that accompanies the install heartbeat. Same trust model
1474    /// as [`Client::install_heartbeat_with`]: no session, signed with
1475    /// the release credential, keyed by the persisted `install_id`,
1476    /// carrying no identity.
1477    ///
1478    /// Where the heartbeat is a *snapshot* (what the install looks like
1479    /// now), this is a *log* of milestones and actions (an account was
1480    /// added, a call connected, a flow answered) — the shape a setup
1481    /// funnel and a time-to-activate read need. Send at most
1482    /// [`USAGE_EVENTS_MAX_BATCH`] events per call; the platform rejects
1483    /// larger bodies. Retries are safe: each event carries a
1484    /// client-generated id the platform de-duplicates on, so a batch
1485    /// whose response was lost can be sent again verbatim.
1486    pub async fn install_usage_events_with(
1487        base_url: &str,
1488        install_id: &str,
1489        app_version: &str,
1490        cred: &ReleaseCredential,
1491        events: Vec<UsageEvent>,
1492    ) -> Result<UsageEventsResponse> {
1493        let body = UsageEventsRequest {
1494            install_id: install_id.to_string(),
1495            app_version: app_version.to_string(),
1496            events,
1497        };
1498        Client::post_public_signed_json::<UsageEventsResponse, _>(
1499            base_url,
1500            "/api/voice/installs/events",
1501            &body,
1502            cred,
1503        )
1504        .await
1505    }
1506}
1507
1508/// Upper bound on `events.len()` in one
1509/// [`Client::install_usage_events_with`] call. Mirrors the platform's
1510/// request validation; a caller with more pending events pages through
1511/// them.
1512pub const USAGE_EVENTS_MAX_BATCH: usize = 100;
1513
1514/// One anonymous usage event — a milestone or an action on an install,
1515/// stamped when it happened. See [`Client::install_usage_events_with`].
1516#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1517#[serde(rename_all = "camelCase")]
1518pub struct UsageEvent {
1519    /// Client-generated UUID; the platform's de-duplication key, so a
1520    /// retried batch never double-counts.
1521    pub id: String,
1522    /// Event name — `snake_case`, `^[a-z][a-z0-9_]{1,63}$`. Free text by
1523    /// contract, not an enum: the platform stores whatever arrives so a
1524    /// new event can ship in the daemon without a server release. The
1525    /// daemon owns the catalogue (and its privacy page lists it).
1526    pub name: String,
1527    /// Optional qualifier from the same small vocabulary — e.g.
1528    /// `"inbound"` / `"outbound"` on a connected-call event. Same
1529    /// pattern and length rule as `name`. Never free text, never a
1530    /// number, never anything the user typed.
1531    #[serde(default, skip_serializing_if = "Option::is_none")]
1532    pub detail: Option<String>,
1533    /// ISO-8601 timestamp of when the event happened on the client —
1534    /// not when it was sent, since batches are flushed later.
1535    pub occurred_at: String,
1536}
1537
1538/// Body of `POST /api/voice/installs/events`.
1539#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1540#[serde(rename_all = "camelCase")]
1541pub struct UsageEventsRequest {
1542    /// The daemon's persisted install UUID — the same one the heartbeat
1543    /// upserts on, so events join to `voice_installs`.
1544    pub install_id: String,
1545    /// WaveKat Voice's own version at send time.
1546    pub app_version: String,
1547    /// At most [`USAGE_EVENTS_MAX_BATCH`] entries.
1548    pub events: Vec<UsageEvent>,
1549}
1550
1551/// Response to `POST /api/voice/installs/events`.
1552#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1553#[serde(rename_all = "camelCase")]
1554pub struct UsageEventsResponse {
1555    /// How many of the submitted events were stored for the first time.
1556    /// Less than the batch size when some ids were already known (a
1557    /// retry) — that is success, not an error.
1558    pub accepted: u32,
1559}
1560
1561// ---- Client surface for recordings ----------------------------------------
1562//
1563// Recordings don't fit the generic `Client::sync` shape cleanly:
1564//
1565//   - the response carries per-item provenance (the platform-stamped
1566//     `r2Key`, plus whether bytes have already landed) that the
1567//     daemon needs in order to decide which rows still owe a PUT;
1568//   - the bytes upload is its own HTTP call (`PUT
1569//     /api/voice/recordings/{sourceId}/bytes`), not a JSON batch.
1570//
1571// Rather than overloading `SyncEndpoint` to carry these shapes, we
1572// expose two inherent methods on `Client` that compose the existing
1573// JSON / bytes-PUT primitives.
1574
1575impl Client {
1576    /// `POST /api/voice/recordings/sync` — idempotent batch upsert of
1577    /// recording metadata. Returns the per-item `r2Key` the daemon
1578    /// should target for the follow-up bytes PUT, and whether bytes
1579    /// have already landed for each row.
1580    ///
1581    /// Batch sizing rules match [`Client::sync`]: the platform rejects
1582    /// batches over 100 items; the daemon's uploader chunks at 50.
1583    pub async fn sync_recordings(
1584        &self,
1585        items: &[VoiceRecordingRecord],
1586    ) -> Result<VoiceRecordingsSyncResponse> {
1587        let stamped = stamp_schema_version::<VoiceRecordings>(items);
1588        let body = SyncRequest { items: stamped };
1589        self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
1590            .await
1591    }
1592
1593    /// `PUT /api/voice/recordings/{sourceId}/bytes` — upload the WAV
1594    /// bytes for a recording whose metadata was previously synced via
1595    /// [`Client::sync_recordings`]. The platform refuses (`HTTP 413`)
1596    /// if `bytes.len()` disagrees with the synced `sizeBytes`.
1597    ///
1598    /// `source_id` is path-segmented as-is; callers pass the
1599    /// daemon-side UUID they used for the metadata sync. Empty /
1600    /// path-traversal-shaped ids are not specifically guarded here —
1601    /// the platform's Zod schema rejects them server-side, so a
1602    /// malformed id surfaces as a 4xx via [`Error::Http`].
1603    pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
1604        if source_id.is_empty() {
1605            return Err(Error::BadRequest("source_id must not be empty".into()));
1606        }
1607        let path = format!("/api/voice/recordings/{source_id}/bytes");
1608        self.put_raw_bytes(&path, "audio/wav", bytes).await
1609    }
1610}
1611
1612// ---- Recording sharing ----------------------------------------------------
1613//
1614// Sharing is a *command* — mutate one recording's share state and get a
1615// result back — not the "batch upsert + cursor list" shape `SyncEndpoint`
1616// exists for (see wavekat-voice doc 38). So it's a typed method pair on
1617// `Client` (mirroring `whoami` rather than `sync::<E>()`), not a marker.
1618//
1619// The desktop daemon keeps only a *mirror* of what these return; the
1620// platform is authoritative for who may open a share. See
1621// `wavekat-voice/docs/38-share-a-recording.md`.
1622
1623/// Access tier for a shared recording, mirroring Loom's model. Wire-stable
1624/// snake_case strings — the platform's Zod schema validates against this
1625/// exact list, so a rename here would bounce every share command with a 400.
1626///
1627/// - `Private` — owner only (the default; "not shared").
1628/// - `Restricted` — owner + explicitly invited WaveKat accounts; the
1629///   recipient must be signed in as an invited identity ("protected by login").
1630/// - `Public` — anyone holding the capability link, no sign-in.
1631#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1632#[serde(rename_all = "snake_case")]
1633pub enum ShareVisibility {
1634    Private,
1635    Restricted,
1636    Public,
1637}
1638
1639/// How a shared recording's caller/callee identity (the call's `party`) is
1640/// exposed to a viewer. Wire-stable snake_case, matching the platform's Zod
1641/// enum, so a rename here bounces a share command with a 400.
1642///
1643/// - `Full` — hidden behind a neutral direction label ("Inbound call").
1644/// - `Partial` — best-effort redaction (keeps shape, drops the value).
1645/// - `None` — the raw `party` is shown.
1646///
1647/// Absent on the wire → the platform defaults to `Partial` (identity
1648/// masked) — privacy-forward without fully erasing the caller. See
1649/// `wavekat-platform` docs/14.
1650#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1651#[serde(rename_all = "snake_case")]
1652pub enum PartyMasking {
1653    Full,
1654    Partial,
1655    None,
1656}
1657
1658/// Body of `POST /api/voice/recordings/{id}/share` — create or update a
1659/// recording's share. The recording must already be synced (metadata +
1660/// bytes) or the platform returns 404.
1661#[derive(Debug, Clone, Serialize, Deserialize)]
1662#[serde(rename_all = "camelCase")]
1663pub struct ShareRecordingRequest {
1664    /// The artifact UUID, as synced (daemon-side `artifacts.id`). Goes in
1665    /// the URL path; carried in the struct so callers pass one value.
1666    pub recording_source_id: String,
1667    pub visibility: ShareVisibility,
1668    /// Restricted tier — the WaveKat-account emails allowed to open the
1669    /// share. Ignored (and omitted) for `Private` / `Public`.
1670    #[serde(default, skip_serializing_if = "Option::is_none")]
1671    pub invited_emails: Option<Vec<String>>,
1672    /// Per-share visibility controls (platform docs/14) — what a viewer may
1673    /// see. Each is omitted when unset; the platform then applies its
1674    /// privacy-forward default (identity masked, transcript hidden, audio
1675    /// shown, download off). NB the platform treats the request as the
1676    /// *full* desired state, so an omitted control is reset to its default,
1677    /// not preserved from a prior share — send all of them when editing an
1678    /// existing share's controls.
1679    #[serde(default, skip_serializing_if = "Option::is_none")]
1680    pub party_masking: Option<PartyMasking>,
1681    #[serde(default, skip_serializing_if = "Option::is_none")]
1682    pub show_transcript: Option<bool>,
1683    #[serde(default, skip_serializing_if = "Option::is_none")]
1684    pub show_audio: Option<bool>,
1685    /// Whether a viewer may *download* the WAV, distinct from hearing it.
1686    /// Off by default and only meaningful while `show_audio` is true — the
1687    /// platform forces it off otherwise (you can't save what you can't
1688    /// hear). A soft control: it hides the viewer's Download affordance,
1689    /// not the bytes a listener already fetches to play.
1690    #[serde(default, skip_serializing_if = "Option::is_none")]
1691    pub allow_download: Option<bool>,
1692    /// Per-channel playback defaults — which side is *audible by default*
1693    /// in the viewer's player (docs/14). A call has two channels: `local`
1694    /// (the owner's microphone, "your side") and `remote` (the other
1695    /// party, "their side"). `true` means that side starts muted; the
1696    /// viewer can still un-mute it, and the audio file is unchanged — this
1697    /// is only the player's starting state. Each is omitted when unset, in
1698    /// which case the platform defaults to audible (`false`). Only
1699    /// meaningful while `show_audio` is true; ignored when audio is hidden.
1700    #[serde(default, skip_serializing_if = "Option::is_none")]
1701    pub default_mute_local: Option<bool>,
1702    #[serde(default, skip_serializing_if = "Option::is_none")]
1703    pub default_mute_remote: Option<bool>,
1704    /// Phase 2 — out-of-band password gate. Omitted when unset.
1705    #[serde(default, skip_serializing_if = "Option::is_none")]
1706    pub password: Option<String>,
1707    /// Phase 2 — RFC 3339 auto-revoke time. Omitted when unset.
1708    #[serde(default, skip_serializing_if = "Option::is_none")]
1709    pub expires_at: Option<String>,
1710}
1711
1712/// The platform's response to a successful share command. `share_url` is
1713/// the full https link the user copies; `token` is the opaque capability
1714/// identifier embedded in it (returned separately so the daemon can store
1715/// it for display without re-parsing the URL).
1716#[derive(Debug, Clone, Serialize, Deserialize)]
1717#[serde(rename_all = "camelCase")]
1718pub struct ShareRecordingResponse {
1719    pub visibility: ShareVisibility,
1720    pub token: String,
1721    pub share_url: String,
1722    /// RFC 3339 — when the recording was first shared.
1723    pub shared_at: String,
1724    /// Effective visibility controls the platform stored (docs/14). Optional
1725    /// for tolerance — a platform predating the feature omits them, in which
1726    /// case the daemon should assume the defaults (identity masked, transcript
1727    /// hidden, audio shown, download off).
1728    #[serde(default, skip_serializing_if = "Option::is_none")]
1729    pub party_masking: Option<PartyMasking>,
1730    #[serde(default, skip_serializing_if = "Option::is_none")]
1731    pub show_transcript: Option<bool>,
1732    #[serde(default, skip_serializing_if = "Option::is_none")]
1733    pub show_audio: Option<bool>,
1734    /// Effective download permission — `show_audio && allow_download`, so
1735    /// it's never true when the audio is hidden. Absent on a platform
1736    /// predating the control (assume off).
1737    #[serde(default, skip_serializing_if = "Option::is_none")]
1738    pub allow_download: Option<bool>,
1739    /// Effective per-channel playback defaults the platform stored — which
1740    /// side starts muted in the viewer's player (docs/14). Absent on a
1741    /// platform predating the control (assume audible, `false`).
1742    #[serde(default, skip_serializing_if = "Option::is_none")]
1743    pub default_mute_local: Option<bool>,
1744    #[serde(default, skip_serializing_if = "Option::is_none")]
1745    pub default_mute_remote: Option<bool>,
1746}
1747
1748/// The platform's response to `GET /api/voice/recordings/{id}/share` — the
1749/// *authoritative* current share state for an owned recording. The POST
1750/// reply omits the invited-email list and a local mirror can't reflect a
1751/// share changed from another device, so the desktop "who can open this"
1752/// panel reads here.
1753///
1754/// A recording that was never shared (or whose share is revoked / expired)
1755/// comes back as [`ShareVisibility::Private`] with the optional fields
1756/// absent — the same "not shared" state DELETE leaves behind.
1757#[derive(Debug, Clone, Serialize, Deserialize)]
1758#[serde(rename_all = "camelCase")]
1759pub struct ShareStateResponse {
1760    pub visibility: ShareVisibility,
1761    /// Absent when `visibility == Private` (nothing is shared).
1762    #[serde(default, skip_serializing_if = "Option::is_none")]
1763    pub token: Option<String>,
1764    #[serde(default, skip_serializing_if = "Option::is_none")]
1765    pub share_url: Option<String>,
1766    /// RFC 3339 — when the recording was first shared. Absent when private.
1767    #[serde(default, skip_serializing_if = "Option::is_none")]
1768    pub shared_at: Option<String>,
1769    /// The restricted tier's audience (lowercased, de-duped). Present
1770    /// (possibly empty) only for [`ShareVisibility::Restricted`].
1771    #[serde(default, skip_serializing_if = "Option::is_none")]
1772    pub invited_emails: Option<Vec<String>>,
1773    /// Per-share visibility controls (docs/14). Present for a live share;
1774    /// absent when `Private` (nothing is shared, so no controls apply).
1775    #[serde(default, skip_serializing_if = "Option::is_none")]
1776    pub party_masking: Option<PartyMasking>,
1777    #[serde(default, skip_serializing_if = "Option::is_none")]
1778    pub show_transcript: Option<bool>,
1779    #[serde(default, skip_serializing_if = "Option::is_none")]
1780    pub show_audio: Option<bool>,
1781    /// Effective download permission — `show_audio && allow_download`, so
1782    /// never true when the audio is hidden. Absent when private.
1783    #[serde(default, skip_serializing_if = "Option::is_none")]
1784    pub allow_download: Option<bool>,
1785    /// Effective per-channel playback defaults — which side starts muted in
1786    /// the viewer's player (docs/14). Absent when private.
1787    #[serde(default, skip_serializing_if = "Option::is_none")]
1788    pub default_mute_local: Option<bool>,
1789    #[serde(default, skip_serializing_if = "Option::is_none")]
1790    pub default_mute_remote: Option<bool>,
1791}
1792
1793impl Client {
1794    /// `POST /api/voice/recordings/{id}/share` — create or update a share
1795    /// for an already-synced recording. Returns the capability link + token
1796    /// the desktop UI puts on the clipboard.
1797    ///
1798    /// Per the 404-not-403 ownership rule (doc 21 §"Authorization"), asking
1799    /// to share a recording the caller doesn't own surfaces as
1800    /// [`Error::Http`] with status 404 — existence doesn't leak.
1801    pub async fn share_recording(
1802        &self,
1803        req: &ShareRecordingRequest,
1804    ) -> Result<ShareRecordingResponse> {
1805        if req.recording_source_id.is_empty() {
1806            return Err(Error::BadRequest(
1807                "recording_source_id must not be empty".into(),
1808            ));
1809        }
1810        let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
1811        self.post_json::<ShareRecordingResponse, _>(&path, req)
1812            .await
1813    }
1814
1815    /// `GET /api/voice/recordings/{id}/share` — read the authoritative
1816    /// share state for an owned recording, including the restricted tier's
1817    /// invited emails (which the share command's reply omits). Like
1818    /// [`share_recording`](Self::share_recording), a recording the caller
1819    /// doesn't own surfaces as [`Error::Http`] with status 404.
1820    pub async fn get_recording_share(
1821        &self,
1822        recording_source_id: &str,
1823    ) -> Result<ShareStateResponse> {
1824        if recording_source_id.is_empty() {
1825            return Err(Error::BadRequest(
1826                "recording_source_id must not be empty".into(),
1827            ));
1828        }
1829        let path = format!("/api/voice/recordings/{recording_source_id}/share");
1830        self.get_json::<ShareStateResponse>(&path).await
1831    }
1832
1833    /// `DELETE /api/voice/recordings/{id}/share` — revoke the share. The
1834    /// recording reverts to Private and any outstanding link returns 410.
1835    pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
1836        if recording_source_id.is_empty() {
1837            return Err(Error::BadRequest(
1838                "recording_source_id must not be empty".into(),
1839            ));
1840        }
1841        let path = format!("/api/voice/recordings/{recording_source_id}/share");
1842        self.delete(&path).await
1843    }
1844}
1845
1846#[cfg(test)]
1847mod tests {
1848    use super::*;
1849
1850    #[test]
1851    fn share_visibility_types_are_reachable_from_the_crate_root() {
1852        // Regression for the 0.0.13 gap: `PartyMasking` was added to this
1853        // module but left out of the crate-root `pub use voice::{…}`, and the
1854        // module is private — so a consumer (`wavekat-voice`) couldn't name
1855        // the type to build a `ShareRecordingRequest`. Pin every share-control
1856        // type to the root path so dropping one fails to compile here, not in
1857        // a downstream crate. The body never runs; reachability is the test.
1858        #[allow(dead_code)]
1859        fn _reachable() {
1860            let _: Option<crate::PartyMasking> = Some(crate::PartyMasking::Partial);
1861            let _: Option<crate::ShareVisibility> = Some(crate::ShareVisibility::Public);
1862            let _: fn(&crate::ShareRecordingRequest) = |_| {};
1863            let _: fn(&crate::ShareRecordingResponse) = |_| {};
1864        }
1865    }
1866
1867    #[test]
1868    fn record_serializes_with_camel_case_keys() {
1869        let r = VoiceCallRecord {
1870            source_id: "11111111-1111-4111-8111-111111111111".into(),
1871            account_id: "22222222-2222-4222-8222-222222222222".into(),
1872            direction: VoiceCallDirection::Inbound,
1873            party: "+14155550123".into(),
1874            ring_at: "2026-05-16T10:00:00Z".into(),
1875            answer_at: Some("2026-05-16T10:00:05Z".into()),
1876            end_at: "2026-05-16T10:01:00Z".into(),
1877            duration_ms: Some(55_000),
1878            disposition: VoiceCallDisposition::Answered,
1879            end_reason: VoiceCallEndReason::HangupRemote,
1880            error: None,
1881            share_visibility: None,
1882            transfer_target: None,
1883            codec: None,
1884            flow_id: None,
1885            flow_name: None,
1886            flow_outcome: None,
1887            flow_steps: None,
1888            deleted_at: None,
1889            envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
1890        };
1891        let s = serde_json::to_string(&r).unwrap();
1892        assert!(s.contains("\"sourceId\":"), "{s}");
1893        assert!(s.contains("\"accountId\":"), "{s}");
1894        assert!(s.contains("\"ringAt\":"), "{s}");
1895        assert!(s.contains("\"endAt\":"), "{s}");
1896        assert!(s.contains("\"durationMs\":55000"), "{s}");
1897        // Optional `error` is None — should be omitted from the wire.
1898        assert!(!s.contains("\"error\""), "error should be omitted: {s}");
1899        // Optional `transferTarget` is None here — omitted from the wire,
1900        // exactly like a non-transferred call ships.
1901        assert!(
1902            !s.contains("\"transferTarget\""),
1903            "transferTarget should be omitted: {s}"
1904        );
1905        // Optional `codec` is None (never-answered call, or an older
1906        // daemon) — omitted from the wire, never `null`.
1907        assert!(!s.contains("\"codec\""), "codec should be omitted: {s}");
1908        // Envelope flattens to the top of the object — schemaVersion
1909        // sits next to the other fields rather than nested under
1910        // "envelope". Future resources rely on this layout.
1911        assert!(
1912            s.contains("\"schemaVersion\":1"),
1913            "schemaVersion should flatten: {s}"
1914        );
1915        // `extras` is None, so the envelope contributes no `extras`
1916        // key. Stays out of the row to keep the small/fast path.
1917        assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
1918        // A live call omits the tombstone entirely rather than sending
1919        // `null` — every ordinary sync is a live call, so this is the
1920        // common path and it should stay off the wire.
1921        assert!(
1922            !s.contains("\"deletedAt\""),
1923            "deletedAt should be omitted on a live call: {s}"
1924        );
1925    }
1926
1927    #[test]
1928    fn call_tombstone_serializes_deleted_at() {
1929        // The delete-propagation mechanism: a deleted call rides up as
1930        // an ordinary upsert with `deletedAt` set (platform docs/22),
1931        // the same shape the account tombstone uses.
1932        let mut r = VoiceCallRecord {
1933            source_id: "11111111-1111-4111-8111-111111111111".into(),
1934            account_id: "22222222-2222-4222-8222-222222222222".into(),
1935            direction: VoiceCallDirection::Inbound,
1936            party: "+14155550123".into(),
1937            ring_at: "2026-05-16T10:00:00Z".into(),
1938            answer_at: None,
1939            end_at: "2026-05-16T10:01:00Z".into(),
1940            duration_ms: None,
1941            disposition: VoiceCallDisposition::Missed,
1942            end_reason: VoiceCallEndReason::HangupRemote,
1943            error: None,
1944            share_visibility: None,
1945            transfer_target: None,
1946            codec: None,
1947            flow_id: None,
1948            flow_name: None,
1949            flow_outcome: None,
1950            flow_steps: None,
1951            deleted_at: None,
1952            envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
1953        };
1954        r.deleted_at = Some("2026-07-30T12:00:00Z".into());
1955        let s = serde_json::to_string(&r).unwrap();
1956        assert!(s.contains("\"deletedAt\":\"2026-07-30T12:00:00Z\""), "{s}");
1957    }
1958
1959    #[test]
1960    fn call_record_parses_without_deleted_at() {
1961        // Reading back a live call from `GET /api/voice/calls`: the
1962        // platform sends `deletedAt: null`, and a platform build
1963        // predating the field sends nothing at all. Both must land as
1964        // `None` rather than failing the whole page.
1965        let raw = r#"{
1966            "sourceId": "a",
1967            "accountId": "b",
1968            "direction": "outbound",
1969            "party": "+14155550123",
1970            "ringAt": "2026-05-16T10:00:00Z",
1971            "endAt": "2026-05-16T10:01:00Z",
1972            "disposition": "answered",
1973            "endReason": "hangup_local"
1974        }"#;
1975        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1976        assert!(parsed.deleted_at.is_none());
1977
1978        let with_null: VoiceCallRecord =
1979            serde_json::from_str(&raw.replace('}', r#", "deletedAt": null }"#)).unwrap();
1980        assert!(with_null.deleted_at.is_none());
1981    }
1982
1983    #[test]
1984    fn calls_query_serializes_include_deleted() {
1985        // The delta-pull flag a device sets to learn about deletes made
1986        // elsewhere. Omitted when unset, so an ordinary list request is
1987        // unchanged.
1988        let live = VoiceCallsQuery::default();
1989        assert_eq!(serde_json::to_string(&live).unwrap(), "{}");
1990
1991        let delta = VoiceCallsQuery {
1992            include_deleted: Some(true),
1993            ..Default::default()
1994        };
1995        let s = serde_json::to_string(&delta).unwrap();
1996        assert!(s.contains("\"includeDeleted\":true"), "{s}");
1997    }
1998
1999    #[test]
2000    fn record_round_trips_optional_fields() {
2001        // An unanswered call has answer_at/duration_ms/error all absent.
2002        let raw = r#"{
2003            "sourceId": "a",
2004            "accountId": "b",
2005            "direction": "inbound",
2006            "party": "anonymous",
2007            "ringAt": "2026-05-16T10:00:00Z",
2008            "endAt": "2026-05-16T10:00:30Z",
2009            "disposition": "missed",
2010            "endReason": "missed"
2011        }"#;
2012        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
2013        assert!(parsed.answer_at.is_none());
2014        assert!(parsed.duration_ms.is_none());
2015        assert!(parsed.error.is_none());
2016        assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
2017        assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
2018    }
2019
2020    #[test]
2021    fn query_omits_unset_fields() {
2022        let q = VoiceCallsQuery::default();
2023        let s = serde_json::to_string(&q).unwrap();
2024        // Empty object — every field skipped when None.
2025        assert_eq!(
2026            s, "{}",
2027            "default query should serialize to empty object: {s}"
2028        );
2029    }
2030
2031    #[test]
2032    fn enum_round_trip_via_json() {
2033        // The wire form for each direction/disposition/reason must
2034        // match what the daemon and platform expect — this guards
2035        // against accidental Rust-side renames.
2036        for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
2037            let s = serde_json::to_string(&d).unwrap();
2038            let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
2039            assert_eq!(d, back);
2040        }
2041        for d in [
2042            VoiceCallDisposition::Answered,
2043            VoiceCallDisposition::Missed,
2044            VoiceCallDisposition::Rejected,
2045            VoiceCallDisposition::Cancelled,
2046            VoiceCallDisposition::Failed,
2047        ] {
2048            let s = serde_json::to_string(&d).unwrap();
2049            let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
2050            assert_eq!(d, back);
2051        }
2052        for r in [
2053            VoiceCallEndReason::HangupLocal,
2054            VoiceCallEndReason::HangupRemote,
2055            VoiceCallEndReason::RejectedLocal,
2056            VoiceCallEndReason::RejectedRemote,
2057            VoiceCallEndReason::Missed,
2058            VoiceCallEndReason::CancelledLocal,
2059            VoiceCallEndReason::TransferredLocal,
2060            VoiceCallEndReason::ConnectionLost,
2061            VoiceCallEndReason::Failed,
2062        ] {
2063            let s = serde_json::to_string(&r).unwrap();
2064            let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
2065            assert_eq!(r, back);
2066        }
2067    }
2068
2069    #[test]
2070    fn connection_lost_pins_its_wire_string() {
2071        // The platform's sync endpoint validates end reasons against
2072        // an exact string list — a rename here would make every
2073        // upload from a session-timer teardown bounce with a 400.
2074        let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
2075        assert_eq!(s, "\"connection_lost\"");
2076    }
2077
2078    #[test]
2079    fn transferred_local_pins_its_wire_string() {
2080        // Same contract as `connection_lost`: the platform validates
2081        // against an exact string list, so a rename here would bounce
2082        // every transferred-call upload with a 400.
2083        let s = serde_json::to_string(&VoiceCallEndReason::TransferredLocal).unwrap();
2084        assert_eq!(s, "\"transferred_local\"");
2085    }
2086
2087    #[test]
2088    fn record_round_trips_transfer_target() {
2089        // A transferred call carries `transferTarget` both ways — the
2090        // daemon ships it (it's its own data, not read-only decoration),
2091        // and the platform echoes it back on read.
2092        let raw = r#"{
2093            "sourceId": "a",
2094            "accountId": "b",
2095            "direction": "inbound",
2096            "party": "Alice <sip:alice@example.com>",
2097            "ringAt": "2026-06-28T10:00:00Z",
2098            "answerAt": "2026-06-28T10:00:05Z",
2099            "endAt": "2026-06-28T10:00:30Z",
2100            "durationMs": 25000,
2101            "disposition": "answered",
2102            "endReason": "transferred_local",
2103            "transferTarget": "1002"
2104        }"#;
2105        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
2106        assert_eq!(parsed.end_reason, VoiceCallEndReason::TransferredLocal);
2107        assert_eq!(parsed.transfer_target.as_deref(), Some("1002"));
2108        // And it survives a re-serialize (daemon → platform direction).
2109        let s = serde_json::to_string(&parsed).unwrap();
2110        assert!(s.contains("\"transferTarget\":\"1002\""), "{s}");
2111    }
2112
2113    #[test]
2114    fn codec_pins_its_wire_strings() {
2115        // The platform's sync endpoint validates the codec against an
2116        // exact string list, and the daemon's `CallCodec::as_str` emits
2117        // these same strings — a rename here would bounce every upload
2118        // from an answered call with a 400.
2119        for (codec, wire) in [
2120            (VoiceCallCodec::Opus, "\"opus\""),
2121            (VoiceCallCodec::Pcmu, "\"pcmu\""),
2122            (VoiceCallCodec::Pcma, "\"pcma\""),
2123        ] {
2124            assert_eq!(serde_json::to_string(&codec).unwrap(), wire);
2125            let back: VoiceCallCodec = serde_json::from_str(wire).unwrap();
2126            assert_eq!(back, codec);
2127        }
2128    }
2129
2130    #[test]
2131    fn record_round_trips_codec() {
2132        // An answered call carries `codec` both ways — the daemon ships
2133        // it (its own data, like transferTarget), and the platform
2134        // echoes it back on read so the website can show the call's
2135        // audio quality.
2136        let raw = r#"{
2137            "sourceId": "a",
2138            "accountId": "b",
2139            "direction": "inbound",
2140            "party": "Alice <sip:alice@example.com>",
2141            "ringAt": "2026-07-03T10:00:00Z",
2142            "answerAt": "2026-07-03T10:00:05Z",
2143            "endAt": "2026-07-03T10:00:30Z",
2144            "durationMs": 25000,
2145            "disposition": "answered",
2146            "endReason": "hangup_remote",
2147            "codec": "opus"
2148        }"#;
2149        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
2150        assert_eq!(parsed.codec, Some(VoiceCallCodec::Opus));
2151        // And it survives a re-serialize (daemon → platform direction).
2152        let s = serde_json::to_string(&parsed).unwrap();
2153        assert!(s.contains("\"codec\":\"opus\""), "{s}");
2154
2155        // A row from an older daemon has no codec — reads as None.
2156        let legacy = raw.replace(",\n            \"codec\": \"opus\"", "");
2157        let parsed: VoiceCallRecord = serde_json::from_str(&legacy).unwrap();
2158        assert_eq!(parsed.codec, None);
2159    }
2160
2161    #[test]
2162    fn flow_outcome_pins_its_wire_strings() {
2163        // Three parties agree on these exact strings: the daemon's
2164        // `flow_outcome_to_str`, `wavekat_flow::trace::FlowOutcome`'s
2165        // snake_case serde, and the platform's zod enum. A rename here
2166        // 400s every flow-answered call's batch.
2167        for (outcome, wire) in [
2168            (VoiceCallFlowOutcome::Answered, "\"answered\""),
2169            (VoiceCallFlowOutcome::MessageLeft, "\"message_left\""),
2170            (VoiceCallFlowOutcome::Transferred, "\"transferred\""),
2171            (VoiceCallFlowOutcome::HungUp, "\"hung_up\""),
2172            (VoiceCallFlowOutcome::Aborted, "\"aborted\""),
2173            (VoiceCallFlowOutcome::Defect, "\"defect\""),
2174        ] {
2175            assert_eq!(serde_json::to_string(&outcome).unwrap(), wire);
2176            let back: VoiceCallFlowOutcome = serde_json::from_str(wire).unwrap();
2177            assert_eq!(back, outcome);
2178        }
2179    }
2180
2181    #[test]
2182    fn record_round_trips_flow_attribution() {
2183        // A flow-answered call carries which flow took it and how the
2184        // run ended, both ways: the daemon ships them, the platform
2185        // echoes them so the website can say "Answered by “X”" and show
2186        // the run's own outcome instead of the misleading SIP one.
2187        let raw = r#"{
2188            "sourceId": "a",
2189            "accountId": "b",
2190            "direction": "inbound",
2191            "party": "Alice <sip:alice@example.com>",
2192            "ringAt": "2026-07-03T10:00:00Z",
2193            "answerAt": "2026-07-03T10:00:05Z",
2194            "endAt": "2026-07-03T10:00:30Z",
2195            "durationMs": 25000,
2196            "disposition": "answered",
2197            "endReason": "hangup_local",
2198            "flowId": "flow_after_hours",
2199            "flowName": "After hours",
2200            "flowOutcome": "message_left"
2201        }"#;
2202        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
2203        assert_eq!(parsed.flow_id.as_deref(), Some("flow_after_hours"));
2204        assert_eq!(parsed.flow_name.as_deref(), Some("After hours"));
2205        assert_eq!(parsed.flow_outcome, Some(VoiceCallFlowOutcome::MessageLeft));
2206
2207        let s = serde_json::to_string(&parsed).unwrap();
2208        assert!(s.contains("\"flowId\":\"flow_after_hours\""), "{s}");
2209        assert!(s.contains("\"flowName\":\"After hours\""), "{s}");
2210        assert!(s.contains("\"flowOutcome\":\"message_left\""), "{s}");
2211    }
2212
2213    #[test]
2214    fn record_round_trips_a_flow_step_trace() {
2215        // Pins the per-step field names. These are consumed by the
2216        // platform's Zod schema on one side and produced by the daemon's
2217        // projection on the other; a silent rename here breaks both.
2218        let raw = r#"{
2219            "sourceId": "a",
2220            "accountId": "b",
2221            "direction": "inbound",
2222            "party": "sip:alice@example.com",
2223            "ringAt": "2026-07-03T10:00:00Z",
2224            "answerAt": "2026-07-03T10:00:05Z",
2225            "endAt": "2026-07-03T10:00:30Z",
2226            "disposition": "answered",
2227            "endReason": "hangup_local",
2228            "flowId": "f",
2229            "flowName": "F",
2230            "flowSteps": [
2231                { "atMs": 0, "kind": "spoke", "node": "greeting" },
2232                { "atMs": 4200, "kind": "menu_choice", "digit": "2" },
2233                { "atMs": 9100, "kind": "message_recorded", "secs": 31 }
2234            ]
2235        }"#;
2236        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
2237        let steps = parsed.flow_steps.as_deref().expect("steps present");
2238        assert_eq!(steps.len(), 3);
2239        assert_eq!(steps[1].kind, "menu_choice");
2240        assert_eq!(steps[1].digit.as_deref(), Some("2"));
2241        assert_eq!(steps[2].secs, Some(31));
2242        // Absent per-step fields stay absent rather than serializing as
2243        // nulls — same contract as the record's own optional fields.
2244        let s = serde_json::to_string(&steps[0]).unwrap();
2245        assert_eq!(s, r#"{"atMs":0,"kind":"spoke","node":"greeting"}"#);
2246    }
2247
2248    #[test]
2249    fn flow_step_accepts_a_kind_this_build_does_not_know() {
2250        // The whole reason `kind` is a String. A consumer pinned to an
2251        // older crate version must still deserialize a newer daemon's
2252        // trace — rejecting would fail the entire call record, not one
2253        // step.
2254        let step: VoiceCallFlowStep =
2255            serde_json::from_str(r#"{"atMs": 10, "kind": "consulted_the_oracle"}"#).unwrap();
2256        assert_eq!(step.kind, "consulted_the_oracle");
2257        assert_eq!(step.digit, None);
2258    }
2259
2260    #[test]
2261    fn record_omits_flow_steps_for_a_human_answered_call() {
2262        // A call the user took themselves has no trace. The field must
2263        // stay off the wire entirely rather than serializing as null.
2264        let raw = r#"{
2265            "sourceId": "a",
2266            "accountId": "b",
2267            "direction": "inbound",
2268            "party": "sip:alice@example.com",
2269            "ringAt": "2026-07-03T10:00:00Z",
2270            "endAt": "2026-07-03T10:00:30Z",
2271            "disposition": "answered",
2272            "endReason": "hangup_local"
2273        }"#;
2274        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
2275        assert!(parsed.flow_steps.is_none());
2276        let s = serde_json::to_string(&parsed).unwrap();
2277        assert!(!s.contains("flowSteps"), "{s}");
2278    }
2279
2280    #[test]
2281    fn record_omits_flow_fields_for_a_human_answered_call() {
2282        // Calls the user took themselves — and every row from a daemon
2283        // predating call flows — carry none of the three. They must
2284        // stay off the wire entirely, not serialize as nulls.
2285        let raw = r#"{
2286            "sourceId": "a",
2287            "accountId": "b",
2288            "direction": "inbound",
2289            "party": "sip:alice@example.com",
2290            "ringAt": "2026-07-03T10:00:00Z",
2291            "endAt": "2026-07-03T10:00:30Z",
2292            "disposition": "answered",
2293            "endReason": "hangup_remote"
2294        }"#;
2295        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
2296        assert_eq!(parsed.flow_id, None);
2297        assert_eq!(parsed.flow_name, None);
2298        assert_eq!(parsed.flow_outcome, None);
2299
2300        let s = serde_json::to_string(&parsed).unwrap();
2301        assert!(!s.contains("\"flowId\""), "flowId should be omitted: {s}");
2302        assert!(
2303            !s.contains("\"flowName\""),
2304            "flowName should be omitted: {s}"
2305        );
2306        assert!(
2307            !s.contains("\"flowOutcome\""),
2308            "flowOutcome should be omitted: {s}"
2309        );
2310    }
2311
2312    #[test]
2313    fn voice_calls_marker_resource_is_calls() {
2314        assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
2315    }
2316
2317    #[test]
2318    fn record_accepts_unknown_extras_for_forward_compat() {
2319        // A newer client shipping a `notes` field that this platform
2320        // version doesn't have a column for should round-trip via
2321        // the `extras` envelope. The platform persists the blob
2322        // verbatim; a future deploy can promote it to a typed
2323        // column without data loss.
2324        let raw = r#"{
2325            "sourceId": "a",
2326            "accountId": "b",
2327            "direction": "inbound",
2328            "party": "anon",
2329            "ringAt": "2026-05-16T10:00:00Z",
2330            "endAt": "2026-05-16T10:00:30Z",
2331            "disposition": "answered",
2332            "endReason": "hangup_remote",
2333            "schemaVersion": 2,
2334            "extras": { "notes": "from staging build" }
2335        }"#;
2336        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
2337        assert_eq!(parsed.envelope.schema_version, Some(2));
2338        let extras = parsed.envelope.extras.as_ref().expect("extras present");
2339        assert_eq!(extras["notes"], "from staging build");
2340    }
2341
2342    #[test]
2343    fn call_record_parses_share_visibility_from_list_response() {
2344        // The list / detail endpoints decorate a call with the tier of any
2345        // active share on its recording, so a consumer can badge the row.
2346        let raw = r#"{
2347            "sourceId": "a",
2348            "accountId": "b",
2349            "direction": "outbound",
2350            "party": "+14155550123",
2351            "ringAt": "2026-05-16T10:00:00Z",
2352            "endAt": "2026-05-16T10:00:30Z",
2353            "disposition": "answered",
2354            "endReason": "hangup_remote",
2355            "shareVisibility": "public"
2356        }"#;
2357        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
2358        assert_eq!(parsed.share_visibility, Some(ShareVisibility::Public));
2359
2360        let restricted = raw.replace("public", "restricted");
2361        let parsed: VoiceCallRecord = serde_json::from_str(&restricted).unwrap();
2362        assert_eq!(parsed.share_visibility, Some(ShareVisibility::Restricted));
2363    }
2364
2365    #[test]
2366    fn call_record_unshared_has_no_share_visibility() {
2367        // Absent (older platform, or an unshared call) and an explicit
2368        // `null` both read as "not shared" — never `Some(Private)`.
2369        let base = r#"{
2370            "sourceId": "a",
2371            "accountId": "b",
2372            "direction": "inbound",
2373            "party": "anon",
2374            "ringAt": "2026-05-16T10:00:00Z",
2375            "endAt": "2026-05-16T10:00:30Z",
2376            "disposition": "missed",
2377            "endReason": "missed"
2378        }"#;
2379        let parsed: VoiceCallRecord = serde_json::from_str(base).unwrap();
2380        assert_eq!(parsed.share_visibility, None);
2381
2382        let with_null = base.replace(
2383            r#""endReason": "missed""#,
2384            r#""endReason": "missed", "shareVisibility": null"#,
2385        );
2386        let parsed: VoiceCallRecord = serde_json::from_str(&with_null).unwrap();
2387        assert_eq!(parsed.share_visibility, None);
2388    }
2389
2390    #[test]
2391    fn synced_call_omits_share_visibility() {
2392        // `share_visibility` is read-only decoration: a call uploaded via
2393        // sync must not carry it on the wire (skip_serializing_if = None),
2394        // so the round trip from a sync-shaped record stays clean.
2395        let raw = r#"{
2396            "sourceId": "a",
2397            "accountId": "b",
2398            "direction": "inbound",
2399            "party": "anon",
2400            "ringAt": "2026-05-16T10:00:00Z",
2401            "endAt": "2026-05-16T10:00:30Z",
2402            "disposition": "answered",
2403            "endReason": "hangup_remote"
2404        }"#;
2405        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
2406        assert_eq!(parsed.share_visibility, None);
2407        let s = serde_json::to_string(&parsed).unwrap();
2408        assert!(
2409            !s.contains("shareVisibility"),
2410            "sync payload leaked share_visibility: {s}"
2411        );
2412    }
2413
2414    #[test]
2415    fn recording_marker_resource_is_recordings() {
2416        // Path constant drives the URL in `Client::sync_recordings`;
2417        // a rename here would silently 404 against the platform.
2418        assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
2419    }
2420
2421    #[test]
2422    fn recording_record_serializes_with_camel_case_and_envelope() {
2423        let r = VoiceRecordingRecord {
2424            source_id: "11111111-1111-4111-8111-111111111111".into(),
2425            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
2426            size_bytes: 44 + 64_000,
2427            duration_ms: 2_000,
2428            sample_rate: 8_000,
2429            channels: 2,
2430            created_at: "2026-05-16T10:01:05Z".into(),
2431            envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
2432        };
2433        let s = serde_json::to_string(&r).unwrap();
2434        // Field-by-field wire contract — these strings are also what
2435        // the platform's Zod schema expects.
2436        assert!(s.contains("\"sourceId\":"), "{s}");
2437        assert!(s.contains("\"callSourceId\":"), "{s}");
2438        assert!(s.contains("\"sizeBytes\":64044"), "{s}");
2439        assert!(s.contains("\"durationMs\":2000"), "{s}");
2440        assert!(s.contains("\"sampleRate\":8000"), "{s}");
2441        assert!(s.contains("\"channels\":2"), "{s}");
2442        assert!(s.contains("\"createdAt\":"), "{s}");
2443        // Envelope flattens to the top of the object, same as VoiceCallRecord.
2444        assert!(s.contains("\"schemaVersion\":1"), "{s}");
2445    }
2446
2447    #[test]
2448    fn recordings_sync_response_round_trips() {
2449        // The richer-than-generic response carries per-item provenance —
2450        // the daemon's uploader reads `r2Key` for the bytes follow-up
2451        // and `bytesUploaded` to short-circuit when the row already
2452        // landed on a previous cycle.
2453        let raw = r#"{
2454            "accepted": 2,
2455            "skipped": 0,
2456            "items": [
2457                {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
2458                {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
2459            ]
2460        }"#;
2461        let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
2462        assert_eq!(parsed.accepted, 2);
2463        assert_eq!(parsed.items.len(), 2);
2464        assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
2465        assert!(!parsed.items[0].bytes_uploaded);
2466        assert!(parsed.items[1].bytes_uploaded);
2467    }
2468
2469    #[test]
2470    fn install_heartbeat_request_serializes_with_camel_case_keys() {
2471        let req = InstallHeartbeatRequest {
2472            install_id: "11111111-1111-4111-8111-111111111111".into(),
2473            app_version: "0.0.21".into(),
2474            os: "macos".into(),
2475            os_version: Some("15.5.0".into()),
2476            arch: Some("aarch64".into()),
2477            locale: Some("en-NZ".into()),
2478            distribution: Some("mas".into()),
2479            fleet: InstallHeartbeatFleet::default(),
2480        };
2481        let s = serde_json::to_string(&req).unwrap();
2482        assert!(s.contains("\"installId\":"), "{s}");
2483        assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
2484        assert!(s.contains("\"os\":\"macos\""), "{s}");
2485        assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
2486        assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
2487        assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
2488        assert!(s.contains("\"distribution\":\"mas\""), "{s}");
2489    }
2490
2491    #[test]
2492    fn install_heartbeat_request_omits_absent_optional_fields() {
2493        // A host where the OS version / locale probe came up empty
2494        // shouldn't send `null` — keeping the keys out lets the
2495        // platform's Zod `.optional()` accept the body and the column
2496        // stay NULL rather than the string "null".
2497        let req = InstallHeartbeatRequest {
2498            install_id: "x".into(),
2499            app_version: "0.0.21".into(),
2500            os: "linux".into(),
2501            os_version: None,
2502            arch: None,
2503            locale: None,
2504            distribution: None,
2505            fleet: InstallHeartbeatFleet::default(),
2506        };
2507        let s = serde_json::to_string(&req).unwrap();
2508        assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
2509        assert!(!s.contains("arch"), "arch should be omitted: {s}");
2510        assert!(!s.contains("locale"), "locale should be omitted: {s}");
2511        assert!(
2512            !s.contains("distribution"),
2513            "distribution should be omitted: {s}"
2514        );
2515    }
2516
2517    #[test]
2518    fn install_heartbeat_request_with_default_fleet_matches_pre_fleet_key_set() {
2519        // `fleet: InstallHeartbeatFleet::default()` (all `None`) must
2520        // serialize to exactly the key set the platform saw before this
2521        // struct existed — flatten + `skip_serializing_if` must not
2522        // leak an empty-object marker or any of the ten new keys.
2523        let req = InstallHeartbeatRequest {
2524            install_id: "11111111-1111-4111-8111-111111111111".into(),
2525            app_version: "0.0.21".into(),
2526            os: "macos".into(),
2527            os_version: Some("15.5.0".into()),
2528            arch: Some("aarch64".into()),
2529            locale: Some("en-NZ".into()),
2530            distribution: Some("mas".into()),
2531            fleet: InstallHeartbeatFleet::default(),
2532        };
2533        let value: serde_json::Value = serde_json::to_value(&req).unwrap();
2534        let mut keys: Vec<&str> = value
2535            .as_object()
2536            .unwrap()
2537            .keys()
2538            .map(String::as_str)
2539            .collect();
2540        keys.sort_unstable();
2541        let mut expected = vec![
2542            "installId",
2543            "appVersion",
2544            "os",
2545            "osVersion",
2546            "arch",
2547            "locale",
2548            "distribution",
2549        ];
2550        expected.sort_unstable();
2551        assert_eq!(keys, expected, "unexpected key set: {value}");
2552    }
2553
2554    #[test]
2555    fn install_heartbeat_request_with_full_fleet_serializes_camel_case() {
2556        let req = InstallHeartbeatRequest {
2557            install_id: "11111111-1111-4111-8111-111111111111".into(),
2558            app_version: "0.0.21".into(),
2559            os: "macos".into(),
2560            os_version: Some("15.5.0".into()),
2561            arch: Some("aarch64".into()),
2562            locale: Some("en-NZ".into()),
2563            distribution: Some("mas".into()),
2564            fleet: InstallHeartbeatFleet {
2565                build_sha: Some("deadbeef".into()),
2566                install_source: Some("mas".into()),
2567                update_channel: Some("stable".into()),
2568                updater_enabled: Some(false),
2569                updater_status: Some("idle".into()),
2570                updater_version: Some("0.0.22".into()),
2571                updater_checked_at: Some("2026-09-07T10:00:00.000Z".into()),
2572                updater_error: Some("network timeout".into()),
2573                native_arch: Some("arm64".into()),
2574                started_at: Some("2026-09-07T09:00:00.000Z".into()),
2575                flow_armed: Some(true),
2576            },
2577        };
2578        let value: serde_json::Value = serde_json::to_value(&req).unwrap();
2579        assert_eq!(value["buildSha"], "deadbeef");
2580        assert_eq!(value["installSource"], "mas");
2581        assert_eq!(value["updateChannel"], "stable");
2582        assert_eq!(value["updaterEnabled"], serde_json::json!(false));
2583        assert!(value["updaterEnabled"].is_boolean(), "{value}");
2584        assert_eq!(value["updaterStatus"], "idle");
2585        assert_eq!(value["updaterVersion"], "0.0.22");
2586        assert_eq!(value["updaterCheckedAt"], "2026-09-07T10:00:00.000Z");
2587        assert_eq!(value["updaterError"], "network timeout");
2588        assert_eq!(value["nativeArch"], "arm64");
2589        assert_eq!(value["startedAt"], "2026-09-07T09:00:00.000Z");
2590        assert_eq!(value["flowArmed"], serde_json::json!(true));
2591        assert!(value["flowArmed"].is_boolean(), "{value}");
2592    }
2593
2594    #[test]
2595    fn install_heartbeat_request_full_fleet_round_trips() {
2596        let req = InstallHeartbeatRequest {
2597            install_id: "11111111-1111-4111-8111-111111111111".into(),
2598            app_version: "0.0.21".into(),
2599            os: "macos".into(),
2600            os_version: Some("15.5.0".into()),
2601            arch: Some("aarch64".into()),
2602            locale: Some("en-NZ".into()),
2603            distribution: Some("mas".into()),
2604            fleet: InstallHeartbeatFleet {
2605                build_sha: Some("deadbeef".into()),
2606                install_source: Some("mas".into()),
2607                update_channel: Some("stable".into()),
2608                updater_enabled: Some(false),
2609                updater_status: Some("idle".into()),
2610                updater_version: Some("0.0.22".into()),
2611                updater_checked_at: Some("2026-09-07T10:00:00.000Z".into()),
2612                updater_error: Some("network timeout".into()),
2613                native_arch: Some("arm64".into()),
2614                started_at: Some("2026-09-07T09:00:00.000Z".into()),
2615                flow_armed: Some(true),
2616            },
2617        };
2618        let s = serde_json::to_string(&req).unwrap();
2619        let round_tripped: InstallHeartbeatRequest = serde_json::from_str(&s).unwrap();
2620        assert_eq!(round_tripped, req);
2621    }
2622
2623    #[test]
2624    fn install_heartbeat_request_without_fleet_keys_deserializes_to_default_fleet() {
2625        // A body from a daemon that predates the fleet fields (or one
2626        // that simply has nothing to report) carries none of the eleven
2627        // fleet keys. It must still parse, with `fleet` coming back as
2628        // the all-`None` default.
2629        let raw = r#"{
2630            "installId": "11111111-1111-4111-8111-111111111111",
2631            "appVersion": "0.0.21",
2632            "os": "macos",
2633            "osVersion": "15.5.0",
2634            "arch": "aarch64",
2635            "locale": "en-NZ",
2636            "distribution": "mas"
2637        }"#;
2638        let parsed: InstallHeartbeatRequest = serde_json::from_str(raw).unwrap();
2639        assert_eq!(parsed.fleet, InstallHeartbeatFleet::default());
2640    }
2641
2642    #[test]
2643    fn usage_events_request_serializes_camel_case_and_omits_absent_detail() {
2644        let req = UsageEventsRequest {
2645            install_id: "11111111-1111-4111-8111-111111111111".into(),
2646            app_version: "0.0.53".into(),
2647            events: vec![
2648                UsageEvent {
2649                    id: "22222222-2222-4222-8222-222222222222".into(),
2650                    name: "account_added".into(),
2651                    detail: None,
2652                    occurred_at: "2026-09-08T09:00:00.000Z".into(),
2653                },
2654                UsageEvent {
2655                    id: "33333333-3333-4333-8333-333333333333".into(),
2656                    name: "call_connected".into(),
2657                    detail: Some("inbound".into()),
2658                    occurred_at: "2026-09-08T09:05:00.000Z".into(),
2659                },
2660            ],
2661        };
2662        let value: serde_json::Value = serde_json::to_value(&req).unwrap();
2663        assert_eq!(value["installId"], "11111111-1111-4111-8111-111111111111");
2664        assert_eq!(value["appVersion"], "0.0.53");
2665        let events = value["events"].as_array().unwrap();
2666        assert_eq!(events.len(), 2);
2667        assert_eq!(events[0]["name"], "account_added");
2668        assert_eq!(events[0]["occurredAt"], "2026-09-08T09:00:00.000Z");
2669        assert!(
2670            events[0].get("detail").is_none(),
2671            "absent detail must be omitted, not null: {value}"
2672        );
2673        assert_eq!(events[1]["detail"], "inbound");
2674    }
2675
2676    #[test]
2677    fn usage_events_request_round_trips() {
2678        let req = UsageEventsRequest {
2679            install_id: "11111111-1111-4111-8111-111111111111".into(),
2680            app_version: "0.0.53".into(),
2681            events: vec![UsageEvent {
2682                id: "22222222-2222-4222-8222-222222222222".into(),
2683                name: "flow_answered".into(),
2684                detail: Some("message_left".into()),
2685                occurred_at: "2026-09-08T09:00:00.000Z".into(),
2686            }],
2687        };
2688        let s = serde_json::to_string(&req).unwrap();
2689        let back: UsageEventsRequest = serde_json::from_str(&s).unwrap();
2690        assert_eq!(back, req);
2691    }
2692
2693    #[test]
2694    fn usage_events_response_parses_platform_shape() {
2695        let parsed: UsageEventsResponse = serde_json::from_str(r#"{"accepted":2}"#).unwrap();
2696        assert_eq!(parsed.accepted, 2);
2697    }
2698
2699    #[test]
2700    fn install_heartbeat_response_parses_platform_shape() {
2701        let raw = r#"{
2702            "id": "abc-123",
2703            "installId": "11111111-1111-4111-8111-111111111111",
2704            "appVersion": "0.0.21",
2705            "os": "macos",
2706            "osVersion": "15.5.0",
2707            "arch": "aarch64",
2708            "locale": null,
2709            "firstSeenAt": "2026-05-31T10:00:00.000Z",
2710            "lastSeenAt": "2026-05-31T10:00:00.000Z"
2711        }"#;
2712        let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
2713        assert_eq!(parsed.id, "abc-123");
2714        assert_eq!(parsed.app_version, "0.0.21");
2715        assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
2716        assert!(parsed.locale.is_none());
2717        // The fixture above carries no `distribution` key at all, which
2718        // is what a platform deployed before the field looks like. It
2719        // must parse, not error — hence `#[serde(default)]`.
2720        assert!(parsed.distribution.is_none());
2721    }
2722
2723    #[test]
2724    fn install_heartbeat_response_reads_the_distribution_back() {
2725        let raw = r#"{
2726            "id": "abc-123",
2727            "installId": "11111111-1111-4111-8111-111111111111",
2728            "appVersion": "0.0.48",
2729            "os": "macos",
2730            "osVersion": "15.5.0",
2731            "arch": "aarch64",
2732            "locale": "en-NZ",
2733            "distribution": "mas",
2734            "firstSeenAt": "2026-08-22T10:00:00.000Z",
2735            "lastSeenAt": "2026-08-22T10:00:00.000Z"
2736        }"#;
2737        let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
2738        assert_eq!(parsed.distribution.as_deref(), Some("mas"));
2739    }
2740
2741    #[test]
2742    fn install_heartbeat_response_accepts_an_unknown_distribution() {
2743        // Free text by contract: the platform stores whatever arrives so
2744        // a new distribution can ship without a server release. Parsing
2745        // it into an enum here would undo that on the client side.
2746        let raw = r#"{
2747            "id": "abc-123",
2748            "installId": "11111111-1111-4111-8111-111111111111",
2749            "appVersion": "0.1.0",
2750            "os": "windows",
2751            "osVersion": null,
2752            "arch": "x86_64",
2753            "locale": null,
2754            "distribution": "msstore",
2755            "firstSeenAt": "2026-08-22T10:00:00.000Z",
2756            "lastSeenAt": "2026-08-22T10:00:00.000Z"
2757        }"#;
2758        let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
2759        assert_eq!(parsed.distribution.as_deref(), Some("msstore"));
2760    }
2761
2762    #[test]
2763    fn system_info_detect_fills_os_and_arch() {
2764        // os / arch come from compile-time consts, so they're always
2765        // non-empty on every supported target. os_version / locale are
2766        // best-effort and intentionally not asserted.
2767        let sys = SystemInfo::detect();
2768        assert!(!sys.os.is_empty(), "os should be a non-empty target string");
2769        assert!(
2770            !sys.arch.is_empty(),
2771            "arch should be a non-empty target string"
2772        );
2773    }
2774
2775    fn files(entries: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
2776        let map: std::collections::HashMap<String, String> = entries
2777            .iter()
2778            .map(|(p, c)| (p.to_string(), c.to_string()))
2779            .collect();
2780        move |path| map.get(path).cloned()
2781    }
2782
2783    #[test]
2784    fn host_os_version_reads_flatpak_host_os_release() {
2785        let read = files(&[
2786            // The runtime's own file must not win.
2787            (
2788                "/etc/os-release",
2789                "ID=org.freedesktop.platform\nVERSION_ID=24.08\n",
2790            ),
2791            (
2792                "/run/host/os-release",
2793                "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\nID=ubuntu\n",
2794            ),
2795        ]);
2796        assert_eq!(
2797            host_os_version(LinuxSandbox::Flatpak, read).as_deref(),
2798            Some("24.04")
2799        );
2800    }
2801
2802    #[test]
2803    fn host_os_version_falls_through_flatpak_candidates() {
2804        let read = files(&[("/run/host/usr/lib/os-release", "ID=fedora\nVERSION_ID=41\n")]);
2805        assert_eq!(
2806            host_os_version(LinuxSandbox::Flatpak, read).as_deref(),
2807            Some("41")
2808        );
2809    }
2810
2811    #[test]
2812    fn host_os_version_reads_snap_host_lsb_release() {
2813        let read = files(&[
2814            // Regression: the base snap's os-release reads "Ubuntu Core 24",
2815            // which left the snap install's OS version blank.
2816            (
2817                "/etc/os-release",
2818                "NAME=\"Ubuntu Core\"\nID=ubuntu-core\nVERSION_ID=\"24\"\n",
2819            ),
2820            (
2821                "/etc/lsb-release",
2822                "DISTRIB_ID=Ubuntu\nDISTRIB_RELEASE=22.04\nDISTRIB_CODENAME=jammy\n",
2823            ),
2824        ]);
2825        assert_eq!(
2826            host_os_version(LinuxSandbox::Snap, read).as_deref(),
2827            Some("22.04")
2828        );
2829    }
2830
2831    #[test]
2832    fn host_os_version_is_none_when_host_file_is_unreachable() {
2833        // A snap on a host without /etc/lsb-release (e.g. Fedora) must not
2834        // report the base snap's version instead.
2835        let read = files(&[("/etc/os-release", "ID=ubuntu-core\nVERSION_ID=\"24\"\n")]);
2836        assert_eq!(host_os_version(LinuxSandbox::Snap, &read), None);
2837        assert_eq!(host_os_version(LinuxSandbox::Flatpak, &read), None);
2838    }
2839
2840    #[test]
2841    fn release_value_parses_quotes_and_blanks() {
2842        let contents = "# comment\nNAME='Arch Linux'\nVERSION_ID=\"\"\n  BUILD_ID = rolling \n";
2843        assert_eq!(
2844            release_value(contents, "NAME").as_deref(),
2845            Some("Arch Linux")
2846        );
2847        assert_eq!(
2848            release_value(contents, "BUILD_ID").as_deref(),
2849            Some("rolling")
2850        );
2851        assert_eq!(release_value(contents, "VERSION_ID"), None);
2852        assert_eq!(release_value(contents, "ID"), None);
2853    }
2854
2855    #[test]
2856    fn transcripts_marker_resource_is_transcripts() {
2857        assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
2858    }
2859
2860    #[test]
2861    fn transcript_record_serializes_with_camel_case_and_channel_enum() {
2862        let r = VoiceTranscriptRecord {
2863            source_id: "1".into(),
2864            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
2865            channel: VoiceTranscriptChannel::Remote,
2866            ts_ms: 100,
2867            end_ms: 1_500,
2868            text: "hello".into(),
2869            envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
2870        };
2871        let s = serde_json::to_string(&r).unwrap();
2872        assert!(s.contains("\"sourceId\":"), "{s}");
2873        assert!(s.contains("\"callSourceId\":"), "{s}");
2874        // The channel enum is wire-stable snake_case — matches the
2875        // platform's Zod `enum(VOICE_TRANSCRIPT_CHANNELS)`.
2876        assert!(s.contains("\"channel\":\"remote\""), "{s}");
2877        assert!(s.contains("\"tsMs\":100"), "{s}");
2878        assert!(s.contains("\"endMs\":1500"), "{s}");
2879        assert!(s.contains("\"text\":\"hello\""), "{s}");
2880        assert!(s.contains("\"schemaVersion\":1"), "{s}");
2881    }
2882
2883    #[test]
2884    fn share_visibility_pins_its_wire_strings() {
2885        // The platform validates these against an exact string list; a
2886        // rename would bounce every share command with a 400.
2887        assert_eq!(
2888            serde_json::to_string(&ShareVisibility::Private).unwrap(),
2889            "\"private\""
2890        );
2891        assert_eq!(
2892            serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
2893            "\"restricted\""
2894        );
2895        assert_eq!(
2896            serde_json::to_string(&ShareVisibility::Public).unwrap(),
2897            "\"public\""
2898        );
2899        for v in [
2900            ShareVisibility::Private,
2901            ShareVisibility::Restricted,
2902            ShareVisibility::Public,
2903        ] {
2904            let s = serde_json::to_string(&v).unwrap();
2905            let back: ShareVisibility = serde_json::from_str(&s).unwrap();
2906            assert_eq!(v, back);
2907        }
2908    }
2909
2910    #[test]
2911    fn share_request_serializes_with_camel_case_and_omits_unset() {
2912        let req = ShareRecordingRequest {
2913            recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
2914            visibility: ShareVisibility::Public,
2915            invited_emails: None,
2916            party_masking: None,
2917            show_transcript: None,
2918            show_audio: None,
2919            allow_download: None,
2920            default_mute_local: None,
2921            default_mute_remote: None,
2922            password: None,
2923            expires_at: None,
2924        };
2925        let s = serde_json::to_string(&req).unwrap();
2926        assert!(s.contains("\"recordingSourceId\":"), "{s}");
2927        assert!(s.contains("\"visibility\":\"public\""), "{s}");
2928        // Phase-2 / tier-specific / visibility-control fields stay off the
2929        // wire when unset so the platform's `.optional()` schema accepts the
2930        // body (and the omitted controls fall to the platform defaults).
2931        assert!(!s.contains("invitedEmails"), "{s}");
2932        assert!(!s.contains("partyMasking"), "{s}");
2933        assert!(!s.contains("showTranscript"), "{s}");
2934        assert!(!s.contains("showAudio"), "{s}");
2935        assert!(!s.contains("allowDownload"), "{s}");
2936        assert!(!s.contains("defaultMuteLocal"), "{s}");
2937        assert!(!s.contains("defaultMuteRemote"), "{s}");
2938        assert!(!s.contains("password"), "{s}");
2939        assert!(!s.contains("expiresAt"), "{s}");
2940    }
2941
2942    #[test]
2943    fn share_request_serializes_visibility_controls_camel_case() {
2944        let req = ShareRecordingRequest {
2945            recording_source_id: "a".into(),
2946            visibility: ShareVisibility::Public,
2947            invited_emails: None,
2948            party_masking: Some(PartyMasking::Partial),
2949            show_transcript: Some(false),
2950            show_audio: Some(true),
2951            allow_download: Some(true),
2952            default_mute_local: Some(false),
2953            default_mute_remote: Some(true),
2954            password: None,
2955            expires_at: None,
2956        };
2957        let s = serde_json::to_string(&req).unwrap();
2958        assert!(s.contains("\"partyMasking\":\"partial\""), "{s}");
2959        assert!(s.contains("\"showTranscript\":false"), "{s}");
2960        assert!(s.contains("\"showAudio\":true"), "{s}");
2961        assert!(s.contains("\"allowDownload\":true"), "{s}");
2962        // The owner muted their own side by default but left the other
2963        // party audible — both ride the wire as camelCase booleans.
2964        assert!(s.contains("\"defaultMuteLocal\":false"), "{s}");
2965        assert!(s.contains("\"defaultMuteRemote\":true"), "{s}");
2966    }
2967
2968    #[test]
2969    fn share_request_carries_invited_emails_for_restricted() {
2970        let req = ShareRecordingRequest {
2971            recording_source_id: "a".into(),
2972            visibility: ShareVisibility::Restricted,
2973            invited_emails: Some(vec!["alex@example.com".into()]),
2974            party_masking: None,
2975            show_transcript: None,
2976            show_audio: None,
2977            allow_download: None,
2978            default_mute_local: None,
2979            default_mute_remote: None,
2980            password: None,
2981            expires_at: None,
2982        };
2983        let s = serde_json::to_string(&req).unwrap();
2984        assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
2985        assert!(
2986            s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
2987            "{s}"
2988        );
2989    }
2990
2991    #[test]
2992    fn share_response_parses_platform_shape() {
2993        let raw = r#"{
2994            "visibility": "public",
2995            "token": "Zr7-x9F2k1QpLmN4sT8wYa",
2996            "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
2997            "sharedAt": "2026-06-19T10:00:00.000Z"
2998        }"#;
2999        let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
3000        assert_eq!(parsed.visibility, ShareVisibility::Public);
3001        assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
3002        assert!(parsed.share_url.ends_with(&parsed.token));
3003    }
3004
3005    #[test]
3006    fn share_state_parses_restricted_with_invited_emails() {
3007        // The GET read carries the audience back — this is the field the
3008        // POST reply omits and the desktop "who can open this" panel needs.
3009        let raw = r#"{
3010            "visibility": "restricted",
3011            "token": "Zr7-x9F2k1QpLmN4sT8wYa",
3012            "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
3013            "sharedAt": "2026-06-19T10:00:00.000Z",
3014            "invitedEmails": ["bob@example.com", "carol@example.com"],
3015            "partyMasking": "full",
3016            "showTranscript": true,
3017            "showAudio": false,
3018            "allowDownload": false,
3019            "defaultMuteLocal": false,
3020            "defaultMuteRemote": true
3021        }"#;
3022        let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
3023        assert_eq!(parsed.visibility, ShareVisibility::Restricted);
3024        assert_eq!(
3025            parsed.invited_emails.as_deref(),
3026            Some(
3027                [
3028                    "bob@example.com".to_string(),
3029                    "carol@example.com".to_string()
3030                ]
3031                .as_slice()
3032            )
3033        );
3034        // The visibility controls ride back on the live-share read.
3035        assert_eq!(parsed.party_masking, Some(PartyMasking::Full));
3036        assert_eq!(parsed.show_transcript, Some(true));
3037        assert_eq!(parsed.show_audio, Some(false));
3038        // Audio hidden here, so download comes back off (platform folds the two).
3039        assert_eq!(parsed.allow_download, Some(false));
3040        // Per-channel playback defaults ride back too.
3041        assert_eq!(parsed.default_mute_local, Some(false));
3042        assert_eq!(parsed.default_mute_remote, Some(true));
3043    }
3044
3045    #[test]
3046    fn share_state_parses_private_with_fields_absent() {
3047        // A never-shared (or revoked) recording reports private with no
3048        // token / url / emails — the optional fields stay None.
3049        let parsed: ShareStateResponse =
3050            serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
3051        assert_eq!(parsed.visibility, ShareVisibility::Private);
3052        assert!(parsed.token.is_none());
3053        assert!(parsed.share_url.is_none());
3054        assert!(parsed.shared_at.is_none());
3055        assert!(parsed.invited_emails.is_none());
3056    }
3057
3058    #[test]
3059    fn share_request_rejects_empty_source_id_before_hitting_network() {
3060        // Guarded client-side so an empty id can't produce a path like
3061        // `/api/voice/recordings//share` that 404s confusingly.
3062        let req = ShareRecordingRequest {
3063            recording_source_id: String::new(),
3064            visibility: ShareVisibility::Private,
3065            invited_emails: None,
3066            party_masking: None,
3067            show_transcript: None,
3068            show_audio: None,
3069            allow_download: None,
3070            default_mute_local: None,
3071            default_mute_remote: None,
3072            password: None,
3073            expires_at: None,
3074        };
3075        // We can't call the async method without a runtime here, but the
3076        // guard mirrors `upload_recording_bytes` — assert the precondition
3077        // shape the method checks.
3078        assert!(req.recording_source_id.is_empty());
3079    }
3080
3081    // ---- VoiceAccounts ----
3082
3083    fn sample_account() -> VoiceAccountRecord {
3084        VoiceAccountRecord {
3085            source_id: "11111111-1111-4111-8111-111111111111".into(),
3086            enabled: true,
3087            display_name: "Work line".into(),
3088            username: "alice".into(),
3089            domain: "sip.example.com".into(),
3090            auth_username: Some("alice-auth".into()),
3091            server: Some("sip.example.com".into()),
3092            port: Some(5060),
3093            transport: VoiceTransport::Udp,
3094            register_expires: 60,
3095            keepalive_secs: Some(50),
3096            disclosure_enabled: true,
3097            updated_at: "2026-06-20T10:00:00Z".into(),
3098            deleted_at: None,
3099            envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
3100        }
3101    }
3102
3103    #[test]
3104    fn accounts_marker_resource_is_accounts() {
3105        // Path constant drives the URL in `Client::sync` / `Client::list`;
3106        // a rename here would silently 404 against the platform.
3107        assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
3108    }
3109
3110    #[test]
3111    fn account_record_serializes_with_camel_case_and_envelope() {
3112        let s = serde_json::to_string(&sample_account()).unwrap();
3113        // Field-by-field wire contract — also what the platform's Zod
3114        // schema expects.
3115        assert!(s.contains("\"sourceId\":"), "{s}");
3116        assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
3117        assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
3118        assert!(s.contains("\"registerExpires\":60"), "{s}");
3119        assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
3120        assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
3121        assert!(s.contains("\"transport\":\"udp\""), "{s}");
3122        assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
3123        // A live line carries no tombstone.
3124        assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
3125        // The secret never crosses this wire, by construction.
3126        assert!(!s.contains("password"), "no password field: {s}");
3127        // Envelope flattens to the top, same as the other resources.
3128        assert!(s.contains("\"schemaVersion\":2"), "{s}");
3129    }
3130
3131    #[test]
3132    fn account_tombstone_serializes_deleted_at() {
3133        // A soft-delete rides as an upsert with deletedAt set — the
3134        // delete-propagation mechanism (doc 40).
3135        let mut r = sample_account();
3136        r.deleted_at = Some("2026-06-20T12:00:00Z".into());
3137        let s = serde_json::to_string(&r).unwrap();
3138        assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
3139    }
3140
3141    #[test]
3142    fn account_record_round_trips_optional_fields() {
3143        // A minimal line — no auth username, server, port, keepalive, or
3144        // tombstone — should parse with those all absent.
3145        let raw = r#"{
3146            "sourceId": "a",
3147            "enabled": false,
3148            "displayName": "Cheap trunk",
3149            "username": "u",
3150            "domain": "d",
3151            "transport": "tcp",
3152            "registerExpires": 120,
3153            "disclosureEnabled": false,
3154            "updatedAt": "2026-06-20T10:00:00Z"
3155        }"#;
3156        let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
3157        assert!(!parsed.enabled);
3158        assert!(parsed.auth_username.is_none());
3159        assert!(parsed.server.is_none());
3160        assert!(parsed.port.is_none());
3161        assert!(parsed.keepalive_secs.is_none());
3162        assert!(parsed.deleted_at.is_none());
3163        assert_eq!(parsed.transport, VoiceTransport::Tcp);
3164        assert_eq!(parsed.register_expires, 120);
3165    }
3166
3167    #[test]
3168    fn voice_transport_round_trips_via_json() {
3169        for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
3170            let s = serde_json::to_string(&t).unwrap();
3171            let back: VoiceTransport = serde_json::from_str(&s).unwrap();
3172            assert_eq!(t, back);
3173        }
3174        // Pin the wire strings — the daemon's `TransportKind` and the
3175        // platform's Zod enum both depend on these exact tokens.
3176        assert_eq!(
3177            serde_json::to_string(&VoiceTransport::Udp).unwrap(),
3178            "\"udp\""
3179        );
3180        assert_eq!(
3181            serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
3182            "\"tcp\""
3183        );
3184    }
3185
3186    #[test]
3187    fn voice_transport_tls_uses_its_wire_token() {
3188        assert_eq!(
3189            serde_json::to_string(&VoiceTransport::Tls).unwrap(),
3190            "\"tls\""
3191        );
3192        let back: VoiceTransport = serde_json::from_str("\"tls\"").unwrap();
3193        assert_eq!(back, VoiceTransport::Tls);
3194        for t in VoiceTransport::KNOWN {
3195            assert_eq!(
3196                serde_json::to_string(&t).unwrap(),
3197                format!("\"{}\"", t.as_str())
3198            );
3199        }
3200    }
3201
3202    #[test]
3203    fn an_unknown_transport_does_not_fail_the_page() {
3204        // The 0.0.32 failure mode: one line in a transport the build
3205        // didn't know failed the whole page. Now it lands as Unknown and
3206        // the other lines still parse.
3207        let page = r#"[
3208            {"sourceId":"a","enabled":true,"displayName":"Plain","username":"u",
3209             "domain":"d","transport":"udp","registerExpires":60,
3210             "disclosureEnabled":true,"updatedAt":"2026-06-20T10:00:00Z"},
3211            {"sourceId":"b","enabled":true,"displayName":"Future","username":"u",
3212             "domain":"d","transport":"ws","registerExpires":60,
3213             "disclosureEnabled":true,"updatedAt":"2026-06-20T10:00:00Z"}
3214        ]"#;
3215        let parsed: Vec<VoiceAccountRecord> = serde_json::from_str(page).unwrap();
3216        assert_eq!(parsed[0].transport, VoiceTransport::Udp);
3217        assert_eq!(parsed[1].transport, VoiceTransport::Unknown);
3218    }
3219
3220    #[test]
3221    fn accounts_records_are_stamped_as_tls_aware() {
3222        // The platform refuses a TLS-line overwrite from anything below 2.
3223        assert_eq!(<VoiceAccounts as SyncEndpoint>::CURRENT_SCHEMA_VERSION, 2);
3224        assert_eq!(
3225            SyncEnvelope::for_endpoint::<VoiceAccounts>().schema_version,
3226            Some(2)
3227        );
3228    }
3229
3230    #[test]
3231    fn accounts_query_asks_for_every_known_transport_by_default() {
3232        // Without it the platform treats us as a pre-TLS build and hides
3233        // TLS lines.
3234        let q = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
3235        assert_eq!(q, r#"{"transports":"udp,tcp,tls"}"#);
3236    }
3237
3238    #[test]
3239    fn accounts_query_omits_unset_and_serializes_include_deleted() {
3240        let legacy = serde_json::to_string(&VoiceAccountsQuery {
3241            include_deleted: None,
3242            transports: None,
3243        })
3244        .unwrap();
3245        assert_eq!(legacy, "{}", "an unset query should be empty: {legacy}");
3246        let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
3247            include_deleted: Some(true),
3248            ..Default::default()
3249        })
3250        .unwrap();
3251        assert!(
3252            with_deleted.contains("\"includeDeleted\":true"),
3253            "{with_deleted}"
3254        );
3255    }
3256
3257    // ---- VoiceFlows ----
3258
3259    #[test]
3260    fn flows_query_serializes_cursor_and_omits_absent_fields() {
3261        let empty = serde_json::to_string(&VoiceFlowsQuery::default()).unwrap();
3262        assert_eq!(empty, "{}");
3263        let cursored = serde_json::to_string(&VoiceFlowsQuery {
3264            after: Some("flow_abc".into()),
3265            limit: Some(100),
3266            schema_versions: None,
3267        })
3268        .unwrap();
3269        assert!(cursored.contains("\"after\":\"flow_abc\""), "{cursored}");
3270        assert!(cursored.contains("\"limit\":100"), "{cursored}");
3271    }
3272
3273    #[test]
3274    fn flows_query_sends_schema_versions_under_the_servers_name() {
3275        // The struct is camelCase; this parameter is not. A silently
3276        // camelCased key is ignored by the server, which reads exactly
3277        // like an account with no flows in that version — so pin it.
3278        let query = serde_json::to_string(&VoiceFlowsQuery {
3279            schema_versions: Some("1,2".into()),
3280            ..Default::default()
3281        })
3282        .unwrap();
3283        assert_eq!(query, r#"{"schema_versions":"1,2"}"#);
3284    }
3285
3286    // ---- Booking ----
3287
3288    #[test]
3289    fn booking_slots_request_uses_the_routes_snake_case_wire() {
3290        // Unlike the sync resources above, these routes speak snake_case.
3291        // A camelCased body is rejected as a validation error mid-call,
3292        // which the flow can only render as "unavailable".
3293        let body = serde_json::to_string(&BookingSlotsRequest {
3294            source_id: "call_1".into(),
3295            duration_mins: 30,
3296            buffer_mins: 10,
3297            lead_mins: 120,
3298            horizon_days: 14,
3299            schedule: BookingSchedule {
3300                tue: vec![BookingTimeRange {
3301                    open: "09:00".into(),
3302                    close: "17:00".into(),
3303                }],
3304                ..Default::default()
3305            },
3306            timezone: "Pacific/Auckland".into(),
3307            exceptions: Vec::new(),
3308            limit: 3,
3309        })
3310        .unwrap();
3311        assert!(body.contains(r#""source_id":"call_1""#), "{body}");
3312        assert!(body.contains(r#""duration_mins":30"#), "{body}");
3313        assert!(body.contains(r#""timezone":"Pacific/Auckland""#), "{body}");
3314        // Days with no hours, and an empty exception list, stay off the
3315        // wire entirely rather than shipping empty arrays.
3316        assert!(!body.contains("\"mon\""), "{body}");
3317        assert!(!body.contains("exceptions"), "{body}");
3318    }
3319
3320    #[test]
3321    fn booking_slots_response_parses_both_answers() {
3322        let offered: BookingSlotsResponse = serde_json::from_str(
3323            r#"{"slots":[{"start":"2026-08-11T21:00:00Z","end":"2026-08-11T21:30:00Z"}],"timezone":"Pacific/Auckland"}"#,
3324        )
3325        .unwrap();
3326        assert_eq!(offered.slots.len(), 1);
3327        assert_eq!(offered.timezone, "Pacific/Auckland");
3328        assert!(offered.status.is_none());
3329
3330        // The calendar could not be read. Not an error to the caller of
3331        // this crate — the flow has an exit for it.
3332        let down: BookingSlotsResponse =
3333            serde_json::from_str(r#"{"status":"unavailable","reason":"not_connected"}"#).unwrap();
3334        assert!(down.slots.is_empty());
3335        assert_eq!(down.status.as_deref(), Some("unavailable"));
3336        assert_eq!(down.reason.as_deref(), Some("not_connected"));
3337    }
3338
3339    #[test]
3340    fn booking_book_response_parses_every_outcome() {
3341        let booked: BookingBookResponse =
3342            serde_json::from_str(r#"{"status":"booked","start":"2026-08-11T21:00:00Z"}"#).unwrap();
3343        assert_eq!(booked.status, "booked");
3344        assert_eq!(booked.start.as_deref(), Some("2026-08-11T21:00:00Z"));
3345
3346        let taken: BookingBookResponse =
3347            serde_json::from_str(r#"{"status":"slot_taken"}"#).unwrap();
3348        assert_eq!(taken.status, "slot_taken");
3349        assert!(taken.start.is_none());
3350
3351        // A status this build has never heard of still parses: failing
3352        // here would drop a live call over an unknown string.
3353        let future: BookingBookResponse =
3354            serde_json::from_str(r#"{"status":"needs_deposit"}"#).unwrap();
3355        assert_eq!(future.status, "needs_deposit");
3356    }
3357
3358    #[test]
3359    fn flows_page_parses_platform_shape() {
3360        let raw = r#"{
3361            "items": [{
3362                "id": "flow_1",
3363                "name": "Luigi's — after hours",
3364                "version": 3,
3365                "yaml": "schema_version: 1\n",
3366                "publishedAt": "2026-07-13T10:00:00Z"
3367            }],
3368            "nextAfter": null
3369        }"#;
3370        let page: VoiceFlowsPage = serde_json::from_str(raw).unwrap();
3371        assert_eq!(page.items.len(), 1);
3372        let rec = &page.items[0];
3373        assert_eq!(rec.id, "flow_1");
3374        assert_eq!(rec.version, 3);
3375        assert_eq!(rec.published_at, "2026-07-13T10:00:00Z");
3376        assert!(page.next_after.is_none());
3377
3378        // A mid-walk page carries the cursor.
3379        let more: VoiceFlowsPage =
3380            serde_json::from_str(r#"{ "items": [], "nextAfter": "flow_1" }"#).unwrap();
3381        assert_eq!(more.next_after.as_deref(), Some("flow_1"));
3382    }
3383
3384    #[test]
3385    fn flow_assets_manifest_parses_platform_shape() {
3386        // `ref` (a reserved word) maps to `asset_ref`; a null duration is
3387        // accepted (the platform doesn't always know it).
3388        let raw = r#"{
3389            "assets": [{
3390                "ref": "vprompt_ab12cd34",
3391                "format": "ulaw_8000",
3392                "byteSize": 48044,
3393                "durationMs": null,
3394                "contentHash": "9f2c00aa"
3395            }]
3396        }"#;
3397        let page: VoiceFlowAssetsPage = serde_json::from_str(raw).unwrap();
3398        assert_eq!(page.assets.len(), 1);
3399        let asset = &page.assets[0];
3400        assert_eq!(asset.asset_ref, "vprompt_ab12cd34");
3401        assert_eq!(asset.format, "ulaw_8000");
3402        assert_eq!(asset.byte_size, 48044);
3403        assert!(asset.duration_ms.is_none());
3404        assert_eq!(asset.content_hash, "9f2c00aa");
3405
3406        // A text-only version legitimately has no frozen audio.
3407        let empty: VoiceFlowAssetsPage = serde_json::from_str(r#"{ "assets": [] }"#).unwrap();
3408        assert!(empty.assets.is_empty());
3409    }
3410
3411    #[test]
3412    fn account_record_accepts_unknown_extras_for_forward_compat() {
3413        // A newer client shipping a field this platform version lacks a
3414        // column for round-trips via the `extras` envelope.
3415        let raw = r#"{
3416            "sourceId": "a",
3417            "enabled": true,
3418            "displayName": "x",
3419            "username": "u",
3420            "domain": "d",
3421            "transport": "udp",
3422            "registerExpires": 60,
3423            "disclosureEnabled": true,
3424            "updatedAt": "2026-06-20T10:00:00Z",
3425            "schemaVersion": 2,
3426            "extras": { "ringtone": "classic" }
3427        }"#;
3428        let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
3429        assert_eq!(parsed.envelope.schema_version, Some(2));
3430        let extras = parsed.envelope.extras.as_ref().expect("extras present");
3431        assert_eq!(extras["ringtone"], "classic");
3432    }
3433
3434    #[test]
3435    fn system_flow_record_parses_the_platform_shape() {
3436        // The full wire shape as served by the platform's system flow
3437        // endpoint: all fields present including optionals.
3438        let json = r#"{
3439            "id": "flow_voicemail",
3440            "name": "Voicemail",
3441            "description": "A short greeting.",
3442            "language": "en",
3443            "version": 2,
3444            "yaml": "schema_version: 1\n",
3445            "publishedAt": "2026-08-27 01:02:03",
3446            "access": "open",
3447            "systemTags": ["system", "access:open"]
3448        }"#;
3449        let rec: VoiceSystemFlowRecord = serde_json::from_str(json).unwrap();
3450        assert_eq!(rec.id, "flow_voicemail");
3451        assert_eq!(rec.name, "Voicemail");
3452        assert_eq!(rec.description, "A short greeting.");
3453        assert_eq!(rec.language, "en");
3454        assert_eq!(rec.version, 2);
3455        assert_eq!(rec.yaml, "schema_version: 1\n");
3456        assert_eq!(rec.published_at, Some("2026-08-27 01:02:03".into()));
3457        assert_eq!(rec.access, "open");
3458        assert_eq!(rec.system_tags, vec!["system", "access:open"]);
3459    }
3460
3461    #[test]
3462    fn system_flow_record_tolerates_missing_optionals_and_unknown_fields() {
3463        // Older rows or newer platforms: description, publishedAt,
3464        // systemTags may be absent; unknown fields must be ignored
3465        // (forward compat).
3466        let json = r#"{"id":"f","name":"n","language":"en","version":1,"yaml":"y","access":"account","someFutureField":1}"#;
3467        let rec: VoiceSystemFlowRecord = serde_json::from_str(json).unwrap();
3468        assert_eq!(rec.id, "f");
3469        assert_eq!(rec.name, "n");
3470        assert_eq!(rec.description, "");
3471        assert_eq!(rec.language, "en");
3472        assert_eq!(rec.version, 1);
3473        assert_eq!(rec.yaml, "y");
3474        assert!(rec.published_at.is_none());
3475        assert_eq!(rec.access, "account");
3476        assert!(rec.system_tags.is_empty());
3477    }
3478}