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