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}
645
646/// One page of published flow snapshots.
647#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
648#[serde(rename_all = "camelCase")]
649pub struct VoiceFlowsPage {
650 pub items: Vec<VoiceFlowRecord>,
651 /// Cursor for the next page; `None` = end of the set.
652 #[serde(default)]
653 pub next_after: Option<String>,
654}
655
656/// One frozen audio asset of a published flow version, as served by
657/// `GET /api/voice/flows/{id}/versions/{version}/assets` (wavekat-platform
658/// docs 16/17). The bytes were copied into a version-owned R2 object at
659/// publish time and never change, so `content_hash` identifies them
660/// exactly — the daemon diffs its local cache against it rather than
661/// trusting a bare filename, because the *same* `ref` can carry different
662/// bytes across two versions of the same flow.
663#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
664#[serde(rename_all = "camelCase")]
665pub struct VoiceFlowVersionAsset {
666 /// The `vprompt_…` reference exactly as it appears in the flow YAML.
667 #[serde(rename = "ref")]
668 pub asset_ref: String,
669 /// Source telephony format the clip was frozen as (`ulaw_8000`,
670 /// `pcm_16000`, `mp3`, …); the container is WAV unless `mp3`.
671 pub format: String,
672 /// Size of the frozen bytes.
673 pub byte_size: u64,
674 /// Clip duration if the platform knew it at freeze time.
675 #[serde(default)]
676 pub duration_ms: Option<u64>,
677 /// sha256 of the frozen bytes — the cache's content key.
678 pub content_hash: String,
679}
680
681/// The frozen-asset manifest for one published version. Not paginated:
682/// a flow's asset count is bounded by its node count (a phone tree is
683/// tens of clips), so the platform returns them all in one response.
684#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
685#[serde(rename_all = "camelCase")]
686pub struct VoiceFlowAssetsPage {
687 pub assets: Vec<VoiceFlowVersionAsset>,
688}
689
690impl Client {
691 /// `GET /api/voice/flows/published` — one page of the caller's
692 /// published flow snapshots (latest version each). Strictly
693 /// creator-scoped server-side; never returns another user's flows.
694 pub async fn published_flows(&self, query: &VoiceFlowsQuery) -> Result<VoiceFlowsPage> {
695 self.get_json_query::<VoiceFlowsPage, _>("/api/voice/flows/published", query)
696 .await
697 }
698
699 /// `GET /api/voice/flows/{id}/versions/{version}/assets` — the frozen
700 /// audio manifest for one published version (docs 16/17). Flow-scoped
701 /// server-side: a version of a flow the caller doesn't own is a 404,
702 /// never another user's assets. An existing, visible version with no
703 /// generated audio returns an empty manifest.
704 pub async fn flow_version_assets(
705 &self,
706 flow_id: &str,
707 version: u32,
708 ) -> Result<VoiceFlowAssetsPage> {
709 let path = format!("/api/voice/flows/{flow_id}/versions/{version}/assets");
710 self.get_json::<VoiceFlowAssetsPage>(&path).await
711 }
712
713 /// `GET /api/voice/flows/{id}/versions/{version}/assets/{ref}/bytes` —
714 /// the immutable frozen copy of one clip, served from the version's own
715 /// asset set (never the mutable library). Returned in memory because a
716 /// clip is tens of KB and the daemon writes it atomically into its
717 /// on-disk cache; same flow-scoped 404 as the manifest.
718 pub async fn flow_version_asset_bytes(
719 &self,
720 flow_id: &str,
721 version: u32,
722 asset_ref: &str,
723 ) -> Result<Vec<u8>> {
724 let path =
725 format!("/api/voice/flows/{flow_id}/versions/{version}/assets/{asset_ref}/bytes");
726 self.get_bytes(&path).await
727 }
728}
729
730// ---- Anonymous install heartbeat ------------------------------------------
731//
732// A first-run / per-launch ping the desktop daemon fires *before* (and
733// independently of) any platform sign-in, so the platform can count
734// installs and track version / OS adoption for users who never sign in.
735// It hits the public, unauthenticated `POST /api/voice/installs/heartbeat`
736// and upserts a row keyed by `install_id` alone (no user) — distinct
737// from the authenticated `voice_clients` heartbeat, which is keyed by
738// `(user, install_id)`.
739//
740// The environment fields (os / os_version / arch / locale) are gathered
741// *here*, inside the client crate, rather than on the consumer side:
742// the daemon only owns the two values this crate genuinely cannot
743// discover — the persisted `install_id` and its own app version.
744
745/// Best-effort snapshot of the host environment, detected at call time.
746/// Every field is best-effort; a probe that fails contributes `None`
747/// (or, for the always-available `os` / `arch`, the compile-time
748/// target) rather than failing the heartbeat.
749#[derive(Debug, Clone, PartialEq, Eq)]
750pub struct SystemInfo {
751 /// `std::env::consts::OS` — `"macos"`, `"windows"`, `"linux"`, …
752 pub os: String,
753 /// Human OS version, e.g. `"15.5.0"`. `None` when the OS probe
754 /// can't determine it.
755 pub os_version: Option<String>,
756 /// `std::env::consts::ARCH` — `"aarch64"`, `"x86_64"`, …
757 pub arch: String,
758 /// BCP-47 system locale, e.g. `"en-NZ"`. `None` when unset /
759 /// undetectable (common for GUI-launched apps on some platforms).
760 pub locale: Option<String>,
761}
762
763impl SystemInfo {
764 /// Probe the current host. Cheap enough to call per heartbeat; we
765 /// don't cache so a locale change between launches is reflected.
766 pub fn detect() -> Self {
767 let os_version = match os_info::get().version() {
768 os_info::Version::Unknown => None,
769 v => Some(v.to_string()),
770 };
771 SystemInfo {
772 os: std::env::consts::OS.to_string(),
773 os_version,
774 arch: std::env::consts::ARCH.to_string(),
775 locale: sys_locale::get_locale(),
776 }
777 }
778}
779
780/// Body of `POST /api/voice/installs/heartbeat`. The daemon supplies
781/// `install_id` + `app_version`; [`Client::install_heartbeat`] fills the
782/// environment fields from [`SystemInfo::detect`].
783#[derive(Debug, Clone, Serialize, Deserialize)]
784#[serde(rename_all = "camelCase")]
785pub struct InstallHeartbeatRequest {
786 /// The daemon's persisted install UUID — the platform's upsert key.
787 pub install_id: String,
788 /// WaveKat Voice's own version (`env!("CARGO_PKG_VERSION")` on the
789 /// daemon side) — *not* this crate's version.
790 pub app_version: String,
791 pub os: String,
792 #[serde(default, skip_serializing_if = "Option::is_none")]
793 pub os_version: Option<String>,
794 #[serde(default, skip_serializing_if = "Option::is_none")]
795 pub arch: Option<String>,
796 #[serde(default, skip_serializing_if = "Option::is_none")]
797 pub locale: Option<String>,
798}
799
800/// The platform's view of an install row, echoed back from a heartbeat.
801#[derive(Debug, Clone, Serialize, Deserialize)]
802#[serde(rename_all = "camelCase")]
803pub struct InstallHeartbeatResponse {
804 pub id: String,
805 pub install_id: String,
806 pub app_version: String,
807 pub os: String,
808 pub os_version: Option<String>,
809 pub arch: Option<String>,
810 pub locale: Option<String>,
811 pub first_seen_at: String,
812 pub last_seen_at: String,
813}
814
815impl Client {
816 /// `POST /api/voice/installs/heartbeat` — the anonymous, no-auth
817 /// first-run install ping. Detects the host environment internally
818 /// and posts it alongside the caller-supplied `install_id` +
819 /// `app_version`. Associated (not a method) because the endpoint is
820 /// unauthenticated — there's no token, and at first run there's no
821 /// signed-in `Client` to hang it off of.
822 ///
823 /// Though unauthenticated, the request is **signed** with the release
824 /// credential `cred` (a per-version Ed25519 key + master-issued
825 /// certificate the consumer bakes in at build time) so the platform
826 /// can verify it came from a genuine release and reject forged or
827 /// replayed pings — see [`Client::post_public_signed_json`] and
828 /// [`crate::sign`]. The platform needs only the master *public* key to
829 /// verify.
830 ///
831 /// `base_url` is the platform base (e.g. `https://platform.wavekat.com`).
832 pub async fn install_heartbeat(
833 base_url: &str,
834 install_id: &str,
835 app_version: &str,
836 cred: &ReleaseCredential,
837 ) -> Result<InstallHeartbeatResponse> {
838 let sys = SystemInfo::detect();
839 let body = InstallHeartbeatRequest {
840 install_id: install_id.to_string(),
841 app_version: app_version.to_string(),
842 os: sys.os,
843 os_version: sys.os_version,
844 arch: Some(sys.arch),
845 locale: sys.locale,
846 };
847 Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
848 base_url,
849 "/api/voice/installs/heartbeat",
850 &body,
851 cred,
852 )
853 .await
854 }
855}
856
857// ---- Client surface for recordings ----------------------------------------
858//
859// Recordings don't fit the generic `Client::sync` shape cleanly:
860//
861// - the response carries per-item provenance (the platform-stamped
862// `r2Key`, plus whether bytes have already landed) that the
863// daemon needs in order to decide which rows still owe a PUT;
864// - the bytes upload is its own HTTP call (`PUT
865// /api/voice/recordings/{sourceId}/bytes`), not a JSON batch.
866//
867// Rather than overloading `SyncEndpoint` to carry these shapes, we
868// expose two inherent methods on `Client` that compose the existing
869// JSON / bytes-PUT primitives.
870
871impl Client {
872 /// `POST /api/voice/recordings/sync` — idempotent batch upsert of
873 /// recording metadata. Returns the per-item `r2Key` the daemon
874 /// should target for the follow-up bytes PUT, and whether bytes
875 /// have already landed for each row.
876 ///
877 /// Batch sizing rules match [`Client::sync`]: the platform rejects
878 /// batches over 100 items; the daemon's uploader chunks at 50.
879 pub async fn sync_recordings(
880 &self,
881 items: &[VoiceRecordingRecord],
882 ) -> Result<VoiceRecordingsSyncResponse> {
883 let stamped = stamp_schema_version::<VoiceRecordings>(items);
884 let body = SyncRequest { items: stamped };
885 self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
886 .await
887 }
888
889 /// `PUT /api/voice/recordings/{sourceId}/bytes` — upload the WAV
890 /// bytes for a recording whose metadata was previously synced via
891 /// [`Client::sync_recordings`]. The platform refuses (`HTTP 413`)
892 /// if `bytes.len()` disagrees with the synced `sizeBytes`.
893 ///
894 /// `source_id` is path-segmented as-is; callers pass the
895 /// daemon-side UUID they used for the metadata sync. Empty /
896 /// path-traversal-shaped ids are not specifically guarded here —
897 /// the platform's Zod schema rejects them server-side, so a
898 /// malformed id surfaces as a 4xx via [`Error::Http`].
899 pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
900 if source_id.is_empty() {
901 return Err(Error::BadRequest("source_id must not be empty".into()));
902 }
903 let path = format!("/api/voice/recordings/{source_id}/bytes");
904 self.put_raw_bytes(&path, "audio/wav", bytes).await
905 }
906}
907
908// ---- Recording sharing ----------------------------------------------------
909//
910// Sharing is a *command* — mutate one recording's share state and get a
911// result back — not the "batch upsert + cursor list" shape `SyncEndpoint`
912// exists for (see wavekat-voice doc 38). So it's a typed method pair on
913// `Client` (mirroring `whoami` rather than `sync::<E>()`), not a marker.
914//
915// The desktop daemon keeps only a *mirror* of what these return; the
916// platform is authoritative for who may open a share. See
917// `wavekat-voice/docs/38-share-a-recording.md`.
918
919/// Access tier for a shared recording, mirroring Loom's model. Wire-stable
920/// snake_case strings — the platform's Zod schema validates against this
921/// exact list, so a rename here would bounce every share command with a 400.
922///
923/// - `Private` — owner only (the default; "not shared").
924/// - `Restricted` — owner + explicitly invited WaveKat accounts; the
925/// recipient must be signed in as an invited identity ("protected by login").
926/// - `Public` — anyone holding the capability link, no sign-in.
927#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
928#[serde(rename_all = "snake_case")]
929pub enum ShareVisibility {
930 Private,
931 Restricted,
932 Public,
933}
934
935/// How a shared recording's caller/callee identity (the call's `party`) is
936/// exposed to a viewer. Wire-stable snake_case, matching the platform's Zod
937/// enum, so a rename here bounces a share command with a 400.
938///
939/// - `Full` — hidden behind a neutral direction label ("Inbound call").
940/// - `Partial` — best-effort redaction (keeps shape, drops the value).
941/// - `None` — the raw `party` is shown.
942///
943/// Absent on the wire → the platform defaults to `Partial` (identity
944/// masked) — privacy-forward without fully erasing the caller. See
945/// `wavekat-platform` docs/14.
946#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
947#[serde(rename_all = "snake_case")]
948pub enum PartyMasking {
949 Full,
950 Partial,
951 None,
952}
953
954/// Body of `POST /api/voice/recordings/{id}/share` — create or update a
955/// recording's share. The recording must already be synced (metadata +
956/// bytes) or the platform returns 404.
957#[derive(Debug, Clone, Serialize, Deserialize)]
958#[serde(rename_all = "camelCase")]
959pub struct ShareRecordingRequest {
960 /// The artifact UUID, as synced (daemon-side `artifacts.id`). Goes in
961 /// the URL path; carried in the struct so callers pass one value.
962 pub recording_source_id: String,
963 pub visibility: ShareVisibility,
964 /// Restricted tier — the WaveKat-account emails allowed to open the
965 /// share. Ignored (and omitted) for `Private` / `Public`.
966 #[serde(default, skip_serializing_if = "Option::is_none")]
967 pub invited_emails: Option<Vec<String>>,
968 /// Per-share visibility controls (platform docs/14) — what a viewer may
969 /// see. Each is omitted when unset; the platform then applies its
970 /// privacy-forward default (identity masked, transcript hidden, audio
971 /// shown, download off). NB the platform treats the request as the
972 /// *full* desired state, so an omitted control is reset to its default,
973 /// not preserved from a prior share — send all of them when editing an
974 /// existing share's controls.
975 #[serde(default, skip_serializing_if = "Option::is_none")]
976 pub party_masking: Option<PartyMasking>,
977 #[serde(default, skip_serializing_if = "Option::is_none")]
978 pub show_transcript: Option<bool>,
979 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub show_audio: Option<bool>,
981 /// Whether a viewer may *download* the WAV, distinct from hearing it.
982 /// Off by default and only meaningful while `show_audio` is true — the
983 /// platform forces it off otherwise (you can't save what you can't
984 /// hear). A soft control: it hides the viewer's Download affordance,
985 /// not the bytes a listener already fetches to play.
986 #[serde(default, skip_serializing_if = "Option::is_none")]
987 pub allow_download: Option<bool>,
988 /// Per-channel playback defaults — which side is *audible by default*
989 /// in the viewer's player (docs/14). A call has two channels: `local`
990 /// (the owner's microphone, "your side") and `remote` (the other
991 /// party, "their side"). `true` means that side starts muted; the
992 /// viewer can still un-mute it, and the audio file is unchanged — this
993 /// is only the player's starting state. Each is omitted when unset, in
994 /// which case the platform defaults to audible (`false`). Only
995 /// meaningful while `show_audio` is true; ignored when audio is hidden.
996 #[serde(default, skip_serializing_if = "Option::is_none")]
997 pub default_mute_local: Option<bool>,
998 #[serde(default, skip_serializing_if = "Option::is_none")]
999 pub default_mute_remote: Option<bool>,
1000 /// Phase 2 — out-of-band password gate. Omitted when unset.
1001 #[serde(default, skip_serializing_if = "Option::is_none")]
1002 pub password: Option<String>,
1003 /// Phase 2 — RFC 3339 auto-revoke time. Omitted when unset.
1004 #[serde(default, skip_serializing_if = "Option::is_none")]
1005 pub expires_at: Option<String>,
1006}
1007
1008/// The platform's response to a successful share command. `share_url` is
1009/// the full https link the user copies; `token` is the opaque capability
1010/// identifier embedded in it (returned separately so the daemon can store
1011/// it for display without re-parsing the URL).
1012#[derive(Debug, Clone, Serialize, Deserialize)]
1013#[serde(rename_all = "camelCase")]
1014pub struct ShareRecordingResponse {
1015 pub visibility: ShareVisibility,
1016 pub token: String,
1017 pub share_url: String,
1018 /// RFC 3339 — when the recording was first shared.
1019 pub shared_at: String,
1020 /// Effective visibility controls the platform stored (docs/14). Optional
1021 /// for tolerance — a platform predating the feature omits them, in which
1022 /// case the daemon should assume the defaults (identity masked, transcript
1023 /// hidden, audio shown, download off).
1024 #[serde(default, skip_serializing_if = "Option::is_none")]
1025 pub party_masking: Option<PartyMasking>,
1026 #[serde(default, skip_serializing_if = "Option::is_none")]
1027 pub show_transcript: Option<bool>,
1028 #[serde(default, skip_serializing_if = "Option::is_none")]
1029 pub show_audio: Option<bool>,
1030 /// Effective download permission — `show_audio && allow_download`, so
1031 /// it's never true when the audio is hidden. Absent on a platform
1032 /// predating the control (assume off).
1033 #[serde(default, skip_serializing_if = "Option::is_none")]
1034 pub allow_download: Option<bool>,
1035 /// Effective per-channel playback defaults the platform stored — which
1036 /// side starts muted in the viewer's player (docs/14). Absent on a
1037 /// platform predating the control (assume audible, `false`).
1038 #[serde(default, skip_serializing_if = "Option::is_none")]
1039 pub default_mute_local: Option<bool>,
1040 #[serde(default, skip_serializing_if = "Option::is_none")]
1041 pub default_mute_remote: Option<bool>,
1042}
1043
1044/// The platform's response to `GET /api/voice/recordings/{id}/share` — the
1045/// *authoritative* current share state for an owned recording. The POST
1046/// reply omits the invited-email list and a local mirror can't reflect a
1047/// share changed from another device, so the desktop "who can open this"
1048/// panel reads here.
1049///
1050/// A recording that was never shared (or whose share is revoked / expired)
1051/// comes back as [`ShareVisibility::Private`] with the optional fields
1052/// absent — the same "not shared" state DELETE leaves behind.
1053#[derive(Debug, Clone, Serialize, Deserialize)]
1054#[serde(rename_all = "camelCase")]
1055pub struct ShareStateResponse {
1056 pub visibility: ShareVisibility,
1057 /// Absent when `visibility == Private` (nothing is shared).
1058 #[serde(default, skip_serializing_if = "Option::is_none")]
1059 pub token: Option<String>,
1060 #[serde(default, skip_serializing_if = "Option::is_none")]
1061 pub share_url: Option<String>,
1062 /// RFC 3339 — when the recording was first shared. Absent when private.
1063 #[serde(default, skip_serializing_if = "Option::is_none")]
1064 pub shared_at: Option<String>,
1065 /// The restricted tier's audience (lowercased, de-duped). Present
1066 /// (possibly empty) only for [`ShareVisibility::Restricted`].
1067 #[serde(default, skip_serializing_if = "Option::is_none")]
1068 pub invited_emails: Option<Vec<String>>,
1069 /// Per-share visibility controls (docs/14). Present for a live share;
1070 /// absent when `Private` (nothing is shared, so no controls apply).
1071 #[serde(default, skip_serializing_if = "Option::is_none")]
1072 pub party_masking: Option<PartyMasking>,
1073 #[serde(default, skip_serializing_if = "Option::is_none")]
1074 pub show_transcript: Option<bool>,
1075 #[serde(default, skip_serializing_if = "Option::is_none")]
1076 pub show_audio: Option<bool>,
1077 /// Effective download permission — `show_audio && allow_download`, so
1078 /// never true when the audio is hidden. Absent when private.
1079 #[serde(default, skip_serializing_if = "Option::is_none")]
1080 pub allow_download: Option<bool>,
1081 /// Effective per-channel playback defaults — which side starts muted in
1082 /// the viewer's player (docs/14). Absent when private.
1083 #[serde(default, skip_serializing_if = "Option::is_none")]
1084 pub default_mute_local: Option<bool>,
1085 #[serde(default, skip_serializing_if = "Option::is_none")]
1086 pub default_mute_remote: Option<bool>,
1087}
1088
1089impl Client {
1090 /// `POST /api/voice/recordings/{id}/share` — create or update a share
1091 /// for an already-synced recording. Returns the capability link + token
1092 /// the desktop UI puts on the clipboard.
1093 ///
1094 /// Per the 404-not-403 ownership rule (doc 21 §"Authorization"), asking
1095 /// to share a recording the caller doesn't own surfaces as
1096 /// [`Error::Http`] with status 404 — existence doesn't leak.
1097 pub async fn share_recording(
1098 &self,
1099 req: &ShareRecordingRequest,
1100 ) -> Result<ShareRecordingResponse> {
1101 if req.recording_source_id.is_empty() {
1102 return Err(Error::BadRequest(
1103 "recording_source_id must not be empty".into(),
1104 ));
1105 }
1106 let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
1107 self.post_json::<ShareRecordingResponse, _>(&path, req)
1108 .await
1109 }
1110
1111 /// `GET /api/voice/recordings/{id}/share` — read the authoritative
1112 /// share state for an owned recording, including the restricted tier's
1113 /// invited emails (which the share command's reply omits). Like
1114 /// [`share_recording`](Self::share_recording), a recording the caller
1115 /// doesn't own surfaces as [`Error::Http`] with status 404.
1116 pub async fn get_recording_share(
1117 &self,
1118 recording_source_id: &str,
1119 ) -> Result<ShareStateResponse> {
1120 if recording_source_id.is_empty() {
1121 return Err(Error::BadRequest(
1122 "recording_source_id must not be empty".into(),
1123 ));
1124 }
1125 let path = format!("/api/voice/recordings/{recording_source_id}/share");
1126 self.get_json::<ShareStateResponse>(&path).await
1127 }
1128
1129 /// `DELETE /api/voice/recordings/{id}/share` — revoke the share. The
1130 /// recording reverts to Private and any outstanding link returns 410.
1131 pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
1132 if recording_source_id.is_empty() {
1133 return Err(Error::BadRequest(
1134 "recording_source_id must not be empty".into(),
1135 ));
1136 }
1137 let path = format!("/api/voice/recordings/{recording_source_id}/share");
1138 self.delete(&path).await
1139 }
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144 use super::*;
1145
1146 #[test]
1147 fn share_visibility_types_are_reachable_from_the_crate_root() {
1148 // Regression for the 0.0.13 gap: `PartyMasking` was added to this
1149 // module but left out of the crate-root `pub use voice::{…}`, and the
1150 // module is private — so a consumer (`wavekat-voice`) couldn't name
1151 // the type to build a `ShareRecordingRequest`. Pin every share-control
1152 // type to the root path so dropping one fails to compile here, not in
1153 // a downstream crate. The body never runs; reachability is the test.
1154 #[allow(dead_code)]
1155 fn _reachable() {
1156 let _: Option<crate::PartyMasking> = Some(crate::PartyMasking::Partial);
1157 let _: Option<crate::ShareVisibility> = Some(crate::ShareVisibility::Public);
1158 let _: fn(&crate::ShareRecordingRequest) = |_| {};
1159 let _: fn(&crate::ShareRecordingResponse) = |_| {};
1160 }
1161 }
1162
1163 #[test]
1164 fn record_serializes_with_camel_case_keys() {
1165 let r = VoiceCallRecord {
1166 source_id: "11111111-1111-4111-8111-111111111111".into(),
1167 account_id: "22222222-2222-4222-8222-222222222222".into(),
1168 direction: VoiceCallDirection::Inbound,
1169 party: "+14155550123".into(),
1170 ring_at: "2026-05-16T10:00:00Z".into(),
1171 answer_at: Some("2026-05-16T10:00:05Z".into()),
1172 end_at: "2026-05-16T10:01:00Z".into(),
1173 duration_ms: Some(55_000),
1174 disposition: VoiceCallDisposition::Answered,
1175 end_reason: VoiceCallEndReason::HangupRemote,
1176 error: None,
1177 share_visibility: None,
1178 transfer_target: None,
1179 codec: None,
1180 flow_id: None,
1181 flow_name: None,
1182 flow_outcome: None,
1183 flow_steps: None,
1184 deleted_at: None,
1185 envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
1186 };
1187 let s = serde_json::to_string(&r).unwrap();
1188 assert!(s.contains("\"sourceId\":"), "{s}");
1189 assert!(s.contains("\"accountId\":"), "{s}");
1190 assert!(s.contains("\"ringAt\":"), "{s}");
1191 assert!(s.contains("\"endAt\":"), "{s}");
1192 assert!(s.contains("\"durationMs\":55000"), "{s}");
1193 // Optional `error` is None — should be omitted from the wire.
1194 assert!(!s.contains("\"error\""), "error should be omitted: {s}");
1195 // Optional `transferTarget` is None here — omitted from the wire,
1196 // exactly like a non-transferred call ships.
1197 assert!(
1198 !s.contains("\"transferTarget\""),
1199 "transferTarget should be omitted: {s}"
1200 );
1201 // Optional `codec` is None (never-answered call, or an older
1202 // daemon) — omitted from the wire, never `null`.
1203 assert!(!s.contains("\"codec\""), "codec should be omitted: {s}");
1204 // Envelope flattens to the top of the object — schemaVersion
1205 // sits next to the other fields rather than nested under
1206 // "envelope". Future resources rely on this layout.
1207 assert!(
1208 s.contains("\"schemaVersion\":1"),
1209 "schemaVersion should flatten: {s}"
1210 );
1211 // `extras` is None, so the envelope contributes no `extras`
1212 // key. Stays out of the row to keep the small/fast path.
1213 assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
1214 // A live call omits the tombstone entirely rather than sending
1215 // `null` — every ordinary sync is a live call, so this is the
1216 // common path and it should stay off the wire.
1217 assert!(
1218 !s.contains("\"deletedAt\""),
1219 "deletedAt should be omitted on a live call: {s}"
1220 );
1221 }
1222
1223 #[test]
1224 fn call_tombstone_serializes_deleted_at() {
1225 // The delete-propagation mechanism: a deleted call rides up as
1226 // an ordinary upsert with `deletedAt` set (platform docs/22),
1227 // the same shape the account tombstone uses.
1228 let mut r = VoiceCallRecord {
1229 source_id: "11111111-1111-4111-8111-111111111111".into(),
1230 account_id: "22222222-2222-4222-8222-222222222222".into(),
1231 direction: VoiceCallDirection::Inbound,
1232 party: "+14155550123".into(),
1233 ring_at: "2026-05-16T10:00:00Z".into(),
1234 answer_at: None,
1235 end_at: "2026-05-16T10:01:00Z".into(),
1236 duration_ms: None,
1237 disposition: VoiceCallDisposition::Missed,
1238 end_reason: VoiceCallEndReason::HangupRemote,
1239 error: None,
1240 share_visibility: None,
1241 transfer_target: None,
1242 codec: None,
1243 flow_id: None,
1244 flow_name: None,
1245 flow_outcome: None,
1246 flow_steps: None,
1247 deleted_at: None,
1248 envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
1249 };
1250 r.deleted_at = Some("2026-07-30T12:00:00Z".into());
1251 let s = serde_json::to_string(&r).unwrap();
1252 assert!(s.contains("\"deletedAt\":\"2026-07-30T12:00:00Z\""), "{s}");
1253 }
1254
1255 #[test]
1256 fn call_record_parses_without_deleted_at() {
1257 // Reading back a live call from `GET /api/voice/calls`: the
1258 // platform sends `deletedAt: null`, and a platform build
1259 // predating the field sends nothing at all. Both must land as
1260 // `None` rather than failing the whole page.
1261 let raw = r#"{
1262 "sourceId": "a",
1263 "accountId": "b",
1264 "direction": "outbound",
1265 "party": "+14155550123",
1266 "ringAt": "2026-05-16T10:00:00Z",
1267 "endAt": "2026-05-16T10:01:00Z",
1268 "disposition": "answered",
1269 "endReason": "hangup_local"
1270 }"#;
1271 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1272 assert!(parsed.deleted_at.is_none());
1273
1274 let with_null: VoiceCallRecord =
1275 serde_json::from_str(&raw.replace('}', r#", "deletedAt": null }"#)).unwrap();
1276 assert!(with_null.deleted_at.is_none());
1277 }
1278
1279 #[test]
1280 fn calls_query_serializes_include_deleted() {
1281 // The delta-pull flag a device sets to learn about deletes made
1282 // elsewhere. Omitted when unset, so an ordinary list request is
1283 // unchanged.
1284 let live = VoiceCallsQuery::default();
1285 assert_eq!(serde_json::to_string(&live).unwrap(), "{}");
1286
1287 let delta = VoiceCallsQuery {
1288 include_deleted: Some(true),
1289 ..Default::default()
1290 };
1291 let s = serde_json::to_string(&delta).unwrap();
1292 assert!(s.contains("\"includeDeleted\":true"), "{s}");
1293 }
1294
1295 #[test]
1296 fn record_round_trips_optional_fields() {
1297 // An unanswered call has answer_at/duration_ms/error all absent.
1298 let raw = r#"{
1299 "sourceId": "a",
1300 "accountId": "b",
1301 "direction": "inbound",
1302 "party": "anonymous",
1303 "ringAt": "2026-05-16T10:00:00Z",
1304 "endAt": "2026-05-16T10:00:30Z",
1305 "disposition": "missed",
1306 "endReason": "missed"
1307 }"#;
1308 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1309 assert!(parsed.answer_at.is_none());
1310 assert!(parsed.duration_ms.is_none());
1311 assert!(parsed.error.is_none());
1312 assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
1313 assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
1314 }
1315
1316 #[test]
1317 fn query_omits_unset_fields() {
1318 let q = VoiceCallsQuery::default();
1319 let s = serde_json::to_string(&q).unwrap();
1320 // Empty object — every field skipped when None.
1321 assert_eq!(
1322 s, "{}",
1323 "default query should serialize to empty object: {s}"
1324 );
1325 }
1326
1327 #[test]
1328 fn enum_round_trip_via_json() {
1329 // The wire form for each direction/disposition/reason must
1330 // match what the daemon and platform expect — this guards
1331 // against accidental Rust-side renames.
1332 for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
1333 let s = serde_json::to_string(&d).unwrap();
1334 let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
1335 assert_eq!(d, back);
1336 }
1337 for d in [
1338 VoiceCallDisposition::Answered,
1339 VoiceCallDisposition::Missed,
1340 VoiceCallDisposition::Rejected,
1341 VoiceCallDisposition::Cancelled,
1342 VoiceCallDisposition::Failed,
1343 ] {
1344 let s = serde_json::to_string(&d).unwrap();
1345 let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
1346 assert_eq!(d, back);
1347 }
1348 for r in [
1349 VoiceCallEndReason::HangupLocal,
1350 VoiceCallEndReason::HangupRemote,
1351 VoiceCallEndReason::RejectedLocal,
1352 VoiceCallEndReason::RejectedRemote,
1353 VoiceCallEndReason::Missed,
1354 VoiceCallEndReason::CancelledLocal,
1355 VoiceCallEndReason::TransferredLocal,
1356 VoiceCallEndReason::ConnectionLost,
1357 VoiceCallEndReason::Failed,
1358 ] {
1359 let s = serde_json::to_string(&r).unwrap();
1360 let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
1361 assert_eq!(r, back);
1362 }
1363 }
1364
1365 #[test]
1366 fn connection_lost_pins_its_wire_string() {
1367 // The platform's sync endpoint validates end reasons against
1368 // an exact string list — a rename here would make every
1369 // upload from a session-timer teardown bounce with a 400.
1370 let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
1371 assert_eq!(s, "\"connection_lost\"");
1372 }
1373
1374 #[test]
1375 fn transferred_local_pins_its_wire_string() {
1376 // Same contract as `connection_lost`: the platform validates
1377 // against an exact string list, so a rename here would bounce
1378 // every transferred-call upload with a 400.
1379 let s = serde_json::to_string(&VoiceCallEndReason::TransferredLocal).unwrap();
1380 assert_eq!(s, "\"transferred_local\"");
1381 }
1382
1383 #[test]
1384 fn record_round_trips_transfer_target() {
1385 // A transferred call carries `transferTarget` both ways — the
1386 // daemon ships it (it's its own data, not read-only decoration),
1387 // and the platform echoes it back on read.
1388 let raw = r#"{
1389 "sourceId": "a",
1390 "accountId": "b",
1391 "direction": "inbound",
1392 "party": "Alice <sip:alice@example.com>",
1393 "ringAt": "2026-06-28T10:00:00Z",
1394 "answerAt": "2026-06-28T10:00:05Z",
1395 "endAt": "2026-06-28T10:00:30Z",
1396 "durationMs": 25000,
1397 "disposition": "answered",
1398 "endReason": "transferred_local",
1399 "transferTarget": "1002"
1400 }"#;
1401 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1402 assert_eq!(parsed.end_reason, VoiceCallEndReason::TransferredLocal);
1403 assert_eq!(parsed.transfer_target.as_deref(), Some("1002"));
1404 // And it survives a re-serialize (daemon → platform direction).
1405 let s = serde_json::to_string(&parsed).unwrap();
1406 assert!(s.contains("\"transferTarget\":\"1002\""), "{s}");
1407 }
1408
1409 #[test]
1410 fn codec_pins_its_wire_strings() {
1411 // The platform's sync endpoint validates the codec against an
1412 // exact string list, and the daemon's `CallCodec::as_str` emits
1413 // these same strings — a rename here would bounce every upload
1414 // from an answered call with a 400.
1415 for (codec, wire) in [
1416 (VoiceCallCodec::Opus, "\"opus\""),
1417 (VoiceCallCodec::Pcmu, "\"pcmu\""),
1418 (VoiceCallCodec::Pcma, "\"pcma\""),
1419 ] {
1420 assert_eq!(serde_json::to_string(&codec).unwrap(), wire);
1421 let back: VoiceCallCodec = serde_json::from_str(wire).unwrap();
1422 assert_eq!(back, codec);
1423 }
1424 }
1425
1426 #[test]
1427 fn record_round_trips_codec() {
1428 // An answered call carries `codec` both ways — the daemon ships
1429 // it (its own data, like transferTarget), and the platform
1430 // echoes it back on read so the website can show the call's
1431 // audio quality.
1432 let raw = r#"{
1433 "sourceId": "a",
1434 "accountId": "b",
1435 "direction": "inbound",
1436 "party": "Alice <sip:alice@example.com>",
1437 "ringAt": "2026-07-03T10:00:00Z",
1438 "answerAt": "2026-07-03T10:00:05Z",
1439 "endAt": "2026-07-03T10:00:30Z",
1440 "durationMs": 25000,
1441 "disposition": "answered",
1442 "endReason": "hangup_remote",
1443 "codec": "opus"
1444 }"#;
1445 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1446 assert_eq!(parsed.codec, Some(VoiceCallCodec::Opus));
1447 // And it survives a re-serialize (daemon → platform direction).
1448 let s = serde_json::to_string(&parsed).unwrap();
1449 assert!(s.contains("\"codec\":\"opus\""), "{s}");
1450
1451 // A row from an older daemon has no codec — reads as None.
1452 let legacy = raw.replace(",\n \"codec\": \"opus\"", "");
1453 let parsed: VoiceCallRecord = serde_json::from_str(&legacy).unwrap();
1454 assert_eq!(parsed.codec, None);
1455 }
1456
1457 #[test]
1458 fn flow_outcome_pins_its_wire_strings() {
1459 // Three parties agree on these exact strings: the daemon's
1460 // `flow_outcome_to_str`, `wavekat_flow::trace::FlowOutcome`'s
1461 // snake_case serde, and the platform's zod enum. A rename here
1462 // 400s every flow-answered call's batch.
1463 for (outcome, wire) in [
1464 (VoiceCallFlowOutcome::Answered, "\"answered\""),
1465 (VoiceCallFlowOutcome::MessageLeft, "\"message_left\""),
1466 (VoiceCallFlowOutcome::Transferred, "\"transferred\""),
1467 (VoiceCallFlowOutcome::HungUp, "\"hung_up\""),
1468 (VoiceCallFlowOutcome::Aborted, "\"aborted\""),
1469 (VoiceCallFlowOutcome::Defect, "\"defect\""),
1470 ] {
1471 assert_eq!(serde_json::to_string(&outcome).unwrap(), wire);
1472 let back: VoiceCallFlowOutcome = serde_json::from_str(wire).unwrap();
1473 assert_eq!(back, outcome);
1474 }
1475 }
1476
1477 #[test]
1478 fn record_round_trips_flow_attribution() {
1479 // A flow-answered call carries which flow took it and how the
1480 // run ended, both ways: the daemon ships them, the platform
1481 // echoes them so the website can say "Answered by “X”" and show
1482 // the run's own outcome instead of the misleading SIP one.
1483 let raw = r#"{
1484 "sourceId": "a",
1485 "accountId": "b",
1486 "direction": "inbound",
1487 "party": "Alice <sip:alice@example.com>",
1488 "ringAt": "2026-07-03T10:00:00Z",
1489 "answerAt": "2026-07-03T10:00:05Z",
1490 "endAt": "2026-07-03T10:00:30Z",
1491 "durationMs": 25000,
1492 "disposition": "answered",
1493 "endReason": "hangup_local",
1494 "flowId": "flow_after_hours",
1495 "flowName": "After hours",
1496 "flowOutcome": "message_left"
1497 }"#;
1498 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1499 assert_eq!(parsed.flow_id.as_deref(), Some("flow_after_hours"));
1500 assert_eq!(parsed.flow_name.as_deref(), Some("After hours"));
1501 assert_eq!(parsed.flow_outcome, Some(VoiceCallFlowOutcome::MessageLeft));
1502
1503 let s = serde_json::to_string(&parsed).unwrap();
1504 assert!(s.contains("\"flowId\":\"flow_after_hours\""), "{s}");
1505 assert!(s.contains("\"flowName\":\"After hours\""), "{s}");
1506 assert!(s.contains("\"flowOutcome\":\"message_left\""), "{s}");
1507 }
1508
1509 #[test]
1510 fn record_round_trips_a_flow_step_trace() {
1511 // Pins the per-step field names. These are consumed by the
1512 // platform's Zod schema on one side and produced by the daemon's
1513 // projection on the other; a silent rename here breaks both.
1514 let raw = r#"{
1515 "sourceId": "a",
1516 "accountId": "b",
1517 "direction": "inbound",
1518 "party": "sip:alice@example.com",
1519 "ringAt": "2026-07-03T10:00:00Z",
1520 "answerAt": "2026-07-03T10:00:05Z",
1521 "endAt": "2026-07-03T10:00:30Z",
1522 "disposition": "answered",
1523 "endReason": "hangup_local",
1524 "flowId": "f",
1525 "flowName": "F",
1526 "flowSteps": [
1527 { "atMs": 0, "kind": "spoke", "node": "greeting" },
1528 { "atMs": 4200, "kind": "menu_choice", "digit": "2" },
1529 { "atMs": 9100, "kind": "message_recorded", "secs": 31 }
1530 ]
1531 }"#;
1532 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1533 let steps = parsed.flow_steps.as_deref().expect("steps present");
1534 assert_eq!(steps.len(), 3);
1535 assert_eq!(steps[1].kind, "menu_choice");
1536 assert_eq!(steps[1].digit.as_deref(), Some("2"));
1537 assert_eq!(steps[2].secs, Some(31));
1538 // Absent per-step fields stay absent rather than serializing as
1539 // nulls — same contract as the record's own optional fields.
1540 let s = serde_json::to_string(&steps[0]).unwrap();
1541 assert_eq!(s, r#"{"atMs":0,"kind":"spoke","node":"greeting"}"#);
1542 }
1543
1544 #[test]
1545 fn flow_step_accepts_a_kind_this_build_does_not_know() {
1546 // The whole reason `kind` is a String. A consumer pinned to an
1547 // older crate version must still deserialize a newer daemon's
1548 // trace — rejecting would fail the entire call record, not one
1549 // step.
1550 let step: VoiceCallFlowStep =
1551 serde_json::from_str(r#"{"atMs": 10, "kind": "consulted_the_oracle"}"#).unwrap();
1552 assert_eq!(step.kind, "consulted_the_oracle");
1553 assert_eq!(step.digit, None);
1554 }
1555
1556 #[test]
1557 fn record_omits_flow_steps_for_a_human_answered_call() {
1558 // A call the user took themselves has no trace. The field must
1559 // stay off the wire entirely rather than serializing as null.
1560 let raw = r#"{
1561 "sourceId": "a",
1562 "accountId": "b",
1563 "direction": "inbound",
1564 "party": "sip:alice@example.com",
1565 "ringAt": "2026-07-03T10:00:00Z",
1566 "endAt": "2026-07-03T10:00:30Z",
1567 "disposition": "answered",
1568 "endReason": "hangup_local"
1569 }"#;
1570 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1571 assert!(parsed.flow_steps.is_none());
1572 let s = serde_json::to_string(&parsed).unwrap();
1573 assert!(!s.contains("flowSteps"), "{s}");
1574 }
1575
1576 #[test]
1577 fn record_omits_flow_fields_for_a_human_answered_call() {
1578 // Calls the user took themselves — and every row from a daemon
1579 // predating call flows — carry none of the three. They must
1580 // stay off the wire entirely, not serialize as nulls.
1581 let raw = r#"{
1582 "sourceId": "a",
1583 "accountId": "b",
1584 "direction": "inbound",
1585 "party": "sip:alice@example.com",
1586 "ringAt": "2026-07-03T10:00:00Z",
1587 "endAt": "2026-07-03T10:00:30Z",
1588 "disposition": "answered",
1589 "endReason": "hangup_remote"
1590 }"#;
1591 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1592 assert_eq!(parsed.flow_id, None);
1593 assert_eq!(parsed.flow_name, None);
1594 assert_eq!(parsed.flow_outcome, None);
1595
1596 let s = serde_json::to_string(&parsed).unwrap();
1597 assert!(!s.contains("\"flowId\""), "flowId should be omitted: {s}");
1598 assert!(
1599 !s.contains("\"flowName\""),
1600 "flowName should be omitted: {s}"
1601 );
1602 assert!(
1603 !s.contains("\"flowOutcome\""),
1604 "flowOutcome should be omitted: {s}"
1605 );
1606 }
1607
1608 #[test]
1609 fn voice_calls_marker_resource_is_calls() {
1610 assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
1611 }
1612
1613 #[test]
1614 fn record_accepts_unknown_extras_for_forward_compat() {
1615 // A newer client shipping a `notes` field that this platform
1616 // version doesn't have a column for should round-trip via
1617 // the `extras` envelope. The platform persists the blob
1618 // verbatim; a future deploy can promote it to a typed
1619 // column without data loss.
1620 let raw = r#"{
1621 "sourceId": "a",
1622 "accountId": "b",
1623 "direction": "inbound",
1624 "party": "anon",
1625 "ringAt": "2026-05-16T10:00:00Z",
1626 "endAt": "2026-05-16T10:00:30Z",
1627 "disposition": "answered",
1628 "endReason": "hangup_remote",
1629 "schemaVersion": 2,
1630 "extras": { "notes": "from staging build" }
1631 }"#;
1632 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1633 assert_eq!(parsed.envelope.schema_version, Some(2));
1634 let extras = parsed.envelope.extras.as_ref().expect("extras present");
1635 assert_eq!(extras["notes"], "from staging build");
1636 }
1637
1638 #[test]
1639 fn call_record_parses_share_visibility_from_list_response() {
1640 // The list / detail endpoints decorate a call with the tier of any
1641 // active share on its recording, so a consumer can badge the row.
1642 let raw = r#"{
1643 "sourceId": "a",
1644 "accountId": "b",
1645 "direction": "outbound",
1646 "party": "+14155550123",
1647 "ringAt": "2026-05-16T10:00:00Z",
1648 "endAt": "2026-05-16T10:00:30Z",
1649 "disposition": "answered",
1650 "endReason": "hangup_remote",
1651 "shareVisibility": "public"
1652 }"#;
1653 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1654 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Public));
1655
1656 let restricted = raw.replace("public", "restricted");
1657 let parsed: VoiceCallRecord = serde_json::from_str(&restricted).unwrap();
1658 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Restricted));
1659 }
1660
1661 #[test]
1662 fn call_record_unshared_has_no_share_visibility() {
1663 // Absent (older platform, or an unshared call) and an explicit
1664 // `null` both read as "not shared" — never `Some(Private)`.
1665 let base = r#"{
1666 "sourceId": "a",
1667 "accountId": "b",
1668 "direction": "inbound",
1669 "party": "anon",
1670 "ringAt": "2026-05-16T10:00:00Z",
1671 "endAt": "2026-05-16T10:00:30Z",
1672 "disposition": "missed",
1673 "endReason": "missed"
1674 }"#;
1675 let parsed: VoiceCallRecord = serde_json::from_str(base).unwrap();
1676 assert_eq!(parsed.share_visibility, None);
1677
1678 let with_null = base.replace(
1679 r#""endReason": "missed""#,
1680 r#""endReason": "missed", "shareVisibility": null"#,
1681 );
1682 let parsed: VoiceCallRecord = serde_json::from_str(&with_null).unwrap();
1683 assert_eq!(parsed.share_visibility, None);
1684 }
1685
1686 #[test]
1687 fn synced_call_omits_share_visibility() {
1688 // `share_visibility` is read-only decoration: a call uploaded via
1689 // sync must not carry it on the wire (skip_serializing_if = None),
1690 // so the round trip from a sync-shaped record stays clean.
1691 let raw = r#"{
1692 "sourceId": "a",
1693 "accountId": "b",
1694 "direction": "inbound",
1695 "party": "anon",
1696 "ringAt": "2026-05-16T10:00:00Z",
1697 "endAt": "2026-05-16T10:00:30Z",
1698 "disposition": "answered",
1699 "endReason": "hangup_remote"
1700 }"#;
1701 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1702 assert_eq!(parsed.share_visibility, None);
1703 let s = serde_json::to_string(&parsed).unwrap();
1704 assert!(
1705 !s.contains("shareVisibility"),
1706 "sync payload leaked share_visibility: {s}"
1707 );
1708 }
1709
1710 #[test]
1711 fn recording_marker_resource_is_recordings() {
1712 // Path constant drives the URL in `Client::sync_recordings`;
1713 // a rename here would silently 404 against the platform.
1714 assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
1715 }
1716
1717 #[test]
1718 fn recording_record_serializes_with_camel_case_and_envelope() {
1719 let r = VoiceRecordingRecord {
1720 source_id: "11111111-1111-4111-8111-111111111111".into(),
1721 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1722 size_bytes: 44 + 64_000,
1723 duration_ms: 2_000,
1724 sample_rate: 8_000,
1725 channels: 2,
1726 created_at: "2026-05-16T10:01:05Z".into(),
1727 envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
1728 };
1729 let s = serde_json::to_string(&r).unwrap();
1730 // Field-by-field wire contract — these strings are also what
1731 // the platform's Zod schema expects.
1732 assert!(s.contains("\"sourceId\":"), "{s}");
1733 assert!(s.contains("\"callSourceId\":"), "{s}");
1734 assert!(s.contains("\"sizeBytes\":64044"), "{s}");
1735 assert!(s.contains("\"durationMs\":2000"), "{s}");
1736 assert!(s.contains("\"sampleRate\":8000"), "{s}");
1737 assert!(s.contains("\"channels\":2"), "{s}");
1738 assert!(s.contains("\"createdAt\":"), "{s}");
1739 // Envelope flattens to the top of the object, same as VoiceCallRecord.
1740 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1741 }
1742
1743 #[test]
1744 fn recordings_sync_response_round_trips() {
1745 // The richer-than-generic response carries per-item provenance —
1746 // the daemon's uploader reads `r2Key` for the bytes follow-up
1747 // and `bytesUploaded` to short-circuit when the row already
1748 // landed on a previous cycle.
1749 let raw = r#"{
1750 "accepted": 2,
1751 "skipped": 0,
1752 "items": [
1753 {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
1754 {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
1755 ]
1756 }"#;
1757 let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
1758 assert_eq!(parsed.accepted, 2);
1759 assert_eq!(parsed.items.len(), 2);
1760 assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
1761 assert!(!parsed.items[0].bytes_uploaded);
1762 assert!(parsed.items[1].bytes_uploaded);
1763 }
1764
1765 #[test]
1766 fn install_heartbeat_request_serializes_with_camel_case_keys() {
1767 let req = InstallHeartbeatRequest {
1768 install_id: "11111111-1111-4111-8111-111111111111".into(),
1769 app_version: "0.0.21".into(),
1770 os: "macos".into(),
1771 os_version: Some("15.5.0".into()),
1772 arch: Some("aarch64".into()),
1773 locale: Some("en-NZ".into()),
1774 };
1775 let s = serde_json::to_string(&req).unwrap();
1776 assert!(s.contains("\"installId\":"), "{s}");
1777 assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
1778 assert!(s.contains("\"os\":\"macos\""), "{s}");
1779 assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
1780 assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
1781 assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
1782 }
1783
1784 #[test]
1785 fn install_heartbeat_request_omits_absent_optional_fields() {
1786 // A host where the OS version / locale probe came up empty
1787 // shouldn't send `null` — keeping the keys out lets the
1788 // platform's Zod `.optional()` accept the body and the column
1789 // stay NULL rather than the string "null".
1790 let req = InstallHeartbeatRequest {
1791 install_id: "x".into(),
1792 app_version: "0.0.21".into(),
1793 os: "linux".into(),
1794 os_version: None,
1795 arch: None,
1796 locale: None,
1797 };
1798 let s = serde_json::to_string(&req).unwrap();
1799 assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
1800 assert!(!s.contains("arch"), "arch should be omitted: {s}");
1801 assert!(!s.contains("locale"), "locale should be omitted: {s}");
1802 }
1803
1804 #[test]
1805 fn install_heartbeat_response_parses_platform_shape() {
1806 let raw = r#"{
1807 "id": "abc-123",
1808 "installId": "11111111-1111-4111-8111-111111111111",
1809 "appVersion": "0.0.21",
1810 "os": "macos",
1811 "osVersion": "15.5.0",
1812 "arch": "aarch64",
1813 "locale": null,
1814 "firstSeenAt": "2026-05-31T10:00:00.000Z",
1815 "lastSeenAt": "2026-05-31T10:00:00.000Z"
1816 }"#;
1817 let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
1818 assert_eq!(parsed.id, "abc-123");
1819 assert_eq!(parsed.app_version, "0.0.21");
1820 assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
1821 assert!(parsed.locale.is_none());
1822 }
1823
1824 #[test]
1825 fn system_info_detect_fills_os_and_arch() {
1826 // os / arch come from compile-time consts, so they're always
1827 // non-empty on every supported target. os_version / locale are
1828 // best-effort and intentionally not asserted.
1829 let sys = SystemInfo::detect();
1830 assert!(!sys.os.is_empty(), "os should be a non-empty target string");
1831 assert!(
1832 !sys.arch.is_empty(),
1833 "arch should be a non-empty target string"
1834 );
1835 }
1836
1837 #[test]
1838 fn transcripts_marker_resource_is_transcripts() {
1839 assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
1840 }
1841
1842 #[test]
1843 fn transcript_record_serializes_with_camel_case_and_channel_enum() {
1844 let r = VoiceTranscriptRecord {
1845 source_id: "1".into(),
1846 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1847 channel: VoiceTranscriptChannel::Remote,
1848 ts_ms: 100,
1849 end_ms: 1_500,
1850 text: "hello".into(),
1851 envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
1852 };
1853 let s = serde_json::to_string(&r).unwrap();
1854 assert!(s.contains("\"sourceId\":"), "{s}");
1855 assert!(s.contains("\"callSourceId\":"), "{s}");
1856 // The channel enum is wire-stable snake_case — matches the
1857 // platform's Zod `enum(VOICE_TRANSCRIPT_CHANNELS)`.
1858 assert!(s.contains("\"channel\":\"remote\""), "{s}");
1859 assert!(s.contains("\"tsMs\":100"), "{s}");
1860 assert!(s.contains("\"endMs\":1500"), "{s}");
1861 assert!(s.contains("\"text\":\"hello\""), "{s}");
1862 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1863 }
1864
1865 #[test]
1866 fn share_visibility_pins_its_wire_strings() {
1867 // The platform validates these against an exact string list; a
1868 // rename would bounce every share command with a 400.
1869 assert_eq!(
1870 serde_json::to_string(&ShareVisibility::Private).unwrap(),
1871 "\"private\""
1872 );
1873 assert_eq!(
1874 serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
1875 "\"restricted\""
1876 );
1877 assert_eq!(
1878 serde_json::to_string(&ShareVisibility::Public).unwrap(),
1879 "\"public\""
1880 );
1881 for v in [
1882 ShareVisibility::Private,
1883 ShareVisibility::Restricted,
1884 ShareVisibility::Public,
1885 ] {
1886 let s = serde_json::to_string(&v).unwrap();
1887 let back: ShareVisibility = serde_json::from_str(&s).unwrap();
1888 assert_eq!(v, back);
1889 }
1890 }
1891
1892 #[test]
1893 fn share_request_serializes_with_camel_case_and_omits_unset() {
1894 let req = ShareRecordingRequest {
1895 recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
1896 visibility: ShareVisibility::Public,
1897 invited_emails: None,
1898 party_masking: None,
1899 show_transcript: None,
1900 show_audio: None,
1901 allow_download: None,
1902 default_mute_local: None,
1903 default_mute_remote: None,
1904 password: None,
1905 expires_at: None,
1906 };
1907 let s = serde_json::to_string(&req).unwrap();
1908 assert!(s.contains("\"recordingSourceId\":"), "{s}");
1909 assert!(s.contains("\"visibility\":\"public\""), "{s}");
1910 // Phase-2 / tier-specific / visibility-control fields stay off the
1911 // wire when unset so the platform's `.optional()` schema accepts the
1912 // body (and the omitted controls fall to the platform defaults).
1913 assert!(!s.contains("invitedEmails"), "{s}");
1914 assert!(!s.contains("partyMasking"), "{s}");
1915 assert!(!s.contains("showTranscript"), "{s}");
1916 assert!(!s.contains("showAudio"), "{s}");
1917 assert!(!s.contains("allowDownload"), "{s}");
1918 assert!(!s.contains("defaultMuteLocal"), "{s}");
1919 assert!(!s.contains("defaultMuteRemote"), "{s}");
1920 assert!(!s.contains("password"), "{s}");
1921 assert!(!s.contains("expiresAt"), "{s}");
1922 }
1923
1924 #[test]
1925 fn share_request_serializes_visibility_controls_camel_case() {
1926 let req = ShareRecordingRequest {
1927 recording_source_id: "a".into(),
1928 visibility: ShareVisibility::Public,
1929 invited_emails: None,
1930 party_masking: Some(PartyMasking::Partial),
1931 show_transcript: Some(false),
1932 show_audio: Some(true),
1933 allow_download: Some(true),
1934 default_mute_local: Some(false),
1935 default_mute_remote: Some(true),
1936 password: None,
1937 expires_at: None,
1938 };
1939 let s = serde_json::to_string(&req).unwrap();
1940 assert!(s.contains("\"partyMasking\":\"partial\""), "{s}");
1941 assert!(s.contains("\"showTranscript\":false"), "{s}");
1942 assert!(s.contains("\"showAudio\":true"), "{s}");
1943 assert!(s.contains("\"allowDownload\":true"), "{s}");
1944 // The owner muted their own side by default but left the other
1945 // party audible — both ride the wire as camelCase booleans.
1946 assert!(s.contains("\"defaultMuteLocal\":false"), "{s}");
1947 assert!(s.contains("\"defaultMuteRemote\":true"), "{s}");
1948 }
1949
1950 #[test]
1951 fn share_request_carries_invited_emails_for_restricted() {
1952 let req = ShareRecordingRequest {
1953 recording_source_id: "a".into(),
1954 visibility: ShareVisibility::Restricted,
1955 invited_emails: Some(vec!["alex@example.com".into()]),
1956 party_masking: None,
1957 show_transcript: None,
1958 show_audio: None,
1959 allow_download: None,
1960 default_mute_local: None,
1961 default_mute_remote: None,
1962 password: None,
1963 expires_at: None,
1964 };
1965 let s = serde_json::to_string(&req).unwrap();
1966 assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
1967 assert!(
1968 s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
1969 "{s}"
1970 );
1971 }
1972
1973 #[test]
1974 fn share_response_parses_platform_shape() {
1975 let raw = r#"{
1976 "visibility": "public",
1977 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1978 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1979 "sharedAt": "2026-06-19T10:00:00.000Z"
1980 }"#;
1981 let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
1982 assert_eq!(parsed.visibility, ShareVisibility::Public);
1983 assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
1984 assert!(parsed.share_url.ends_with(&parsed.token));
1985 }
1986
1987 #[test]
1988 fn share_state_parses_restricted_with_invited_emails() {
1989 // The GET read carries the audience back — this is the field the
1990 // POST reply omits and the desktop "who can open this" panel needs.
1991 let raw = r#"{
1992 "visibility": "restricted",
1993 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1994 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1995 "sharedAt": "2026-06-19T10:00:00.000Z",
1996 "invitedEmails": ["bob@example.com", "carol@example.com"],
1997 "partyMasking": "full",
1998 "showTranscript": true,
1999 "showAudio": false,
2000 "allowDownload": false,
2001 "defaultMuteLocal": false,
2002 "defaultMuteRemote": true
2003 }"#;
2004 let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
2005 assert_eq!(parsed.visibility, ShareVisibility::Restricted);
2006 assert_eq!(
2007 parsed.invited_emails.as_deref(),
2008 Some(
2009 [
2010 "bob@example.com".to_string(),
2011 "carol@example.com".to_string()
2012 ]
2013 .as_slice()
2014 )
2015 );
2016 // The visibility controls ride back on the live-share read.
2017 assert_eq!(parsed.party_masking, Some(PartyMasking::Full));
2018 assert_eq!(parsed.show_transcript, Some(true));
2019 assert_eq!(parsed.show_audio, Some(false));
2020 // Audio hidden here, so download comes back off (platform folds the two).
2021 assert_eq!(parsed.allow_download, Some(false));
2022 // Per-channel playback defaults ride back too.
2023 assert_eq!(parsed.default_mute_local, Some(false));
2024 assert_eq!(parsed.default_mute_remote, Some(true));
2025 }
2026
2027 #[test]
2028 fn share_state_parses_private_with_fields_absent() {
2029 // A never-shared (or revoked) recording reports private with no
2030 // token / url / emails — the optional fields stay None.
2031 let parsed: ShareStateResponse =
2032 serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
2033 assert_eq!(parsed.visibility, ShareVisibility::Private);
2034 assert!(parsed.token.is_none());
2035 assert!(parsed.share_url.is_none());
2036 assert!(parsed.shared_at.is_none());
2037 assert!(parsed.invited_emails.is_none());
2038 }
2039
2040 #[test]
2041 fn share_request_rejects_empty_source_id_before_hitting_network() {
2042 // Guarded client-side so an empty id can't produce a path like
2043 // `/api/voice/recordings//share` that 404s confusingly.
2044 let req = ShareRecordingRequest {
2045 recording_source_id: String::new(),
2046 visibility: ShareVisibility::Private,
2047 invited_emails: None,
2048 party_masking: None,
2049 show_transcript: None,
2050 show_audio: None,
2051 allow_download: None,
2052 default_mute_local: None,
2053 default_mute_remote: None,
2054 password: None,
2055 expires_at: None,
2056 };
2057 // We can't call the async method without a runtime here, but the
2058 // guard mirrors `upload_recording_bytes` — assert the precondition
2059 // shape the method checks.
2060 assert!(req.recording_source_id.is_empty());
2061 }
2062
2063 // ---- VoiceAccounts ----
2064
2065 fn sample_account() -> VoiceAccountRecord {
2066 VoiceAccountRecord {
2067 source_id: "11111111-1111-4111-8111-111111111111".into(),
2068 enabled: true,
2069 display_name: "Work line".into(),
2070 username: "alice".into(),
2071 domain: "sip.example.com".into(),
2072 auth_username: Some("alice-auth".into()),
2073 server: Some("sip.example.com".into()),
2074 port: Some(5060),
2075 transport: VoiceTransport::Udp,
2076 register_expires: 60,
2077 keepalive_secs: Some(50),
2078 disclosure_enabled: true,
2079 updated_at: "2026-06-20T10:00:00Z".into(),
2080 deleted_at: None,
2081 envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
2082 }
2083 }
2084
2085 #[test]
2086 fn accounts_marker_resource_is_accounts() {
2087 // Path constant drives the URL in `Client::sync` / `Client::list`;
2088 // a rename here would silently 404 against the platform.
2089 assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
2090 }
2091
2092 #[test]
2093 fn account_record_serializes_with_camel_case_and_envelope() {
2094 let s = serde_json::to_string(&sample_account()).unwrap();
2095 // Field-by-field wire contract — also what the platform's Zod
2096 // schema expects.
2097 assert!(s.contains("\"sourceId\":"), "{s}");
2098 assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
2099 assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
2100 assert!(s.contains("\"registerExpires\":60"), "{s}");
2101 assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
2102 assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
2103 assert!(s.contains("\"transport\":\"udp\""), "{s}");
2104 assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
2105 // A live line carries no tombstone.
2106 assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
2107 // The secret never crosses this wire, by construction.
2108 assert!(!s.contains("password"), "no password field: {s}");
2109 // Envelope flattens to the top, same as the other resources.
2110 assert!(s.contains("\"schemaVersion\":1"), "{s}");
2111 }
2112
2113 #[test]
2114 fn account_tombstone_serializes_deleted_at() {
2115 // A soft-delete rides as an upsert with deletedAt set — the
2116 // delete-propagation mechanism (doc 40).
2117 let mut r = sample_account();
2118 r.deleted_at = Some("2026-06-20T12:00:00Z".into());
2119 let s = serde_json::to_string(&r).unwrap();
2120 assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
2121 }
2122
2123 #[test]
2124 fn account_record_round_trips_optional_fields() {
2125 // A minimal line — no auth username, server, port, keepalive, or
2126 // tombstone — should parse with those all absent.
2127 let raw = r#"{
2128 "sourceId": "a",
2129 "enabled": false,
2130 "displayName": "Cheap trunk",
2131 "username": "u",
2132 "domain": "d",
2133 "transport": "tcp",
2134 "registerExpires": 120,
2135 "disclosureEnabled": false,
2136 "updatedAt": "2026-06-20T10:00:00Z"
2137 }"#;
2138 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
2139 assert!(!parsed.enabled);
2140 assert!(parsed.auth_username.is_none());
2141 assert!(parsed.server.is_none());
2142 assert!(parsed.port.is_none());
2143 assert!(parsed.keepalive_secs.is_none());
2144 assert!(parsed.deleted_at.is_none());
2145 assert_eq!(parsed.transport, VoiceTransport::Tcp);
2146 assert_eq!(parsed.register_expires, 120);
2147 }
2148
2149 #[test]
2150 fn voice_transport_round_trips_via_json() {
2151 for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
2152 let s = serde_json::to_string(&t).unwrap();
2153 let back: VoiceTransport = serde_json::from_str(&s).unwrap();
2154 assert_eq!(t, back);
2155 }
2156 // Pin the wire strings — the daemon's `TransportKind` and the
2157 // platform's Zod enum both depend on these exact tokens.
2158 assert_eq!(
2159 serde_json::to_string(&VoiceTransport::Udp).unwrap(),
2160 "\"udp\""
2161 );
2162 assert_eq!(
2163 serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
2164 "\"tcp\""
2165 );
2166 }
2167
2168 #[test]
2169 fn accounts_query_omits_unset_and_serializes_include_deleted() {
2170 let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
2171 assert_eq!(empty, "{}", "default query should be empty: {empty}");
2172 let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
2173 include_deleted: Some(true),
2174 })
2175 .unwrap();
2176 assert!(
2177 with_deleted.contains("\"includeDeleted\":true"),
2178 "{with_deleted}"
2179 );
2180 }
2181
2182 // ---- VoiceFlows ----
2183
2184 #[test]
2185 fn flows_query_serializes_cursor_and_omits_absent_fields() {
2186 let empty = serde_json::to_string(&VoiceFlowsQuery::default()).unwrap();
2187 assert_eq!(empty, "{}");
2188 let cursored = serde_json::to_string(&VoiceFlowsQuery {
2189 after: Some("flow_abc".into()),
2190 limit: Some(100),
2191 })
2192 .unwrap();
2193 assert!(cursored.contains("\"after\":\"flow_abc\""), "{cursored}");
2194 assert!(cursored.contains("\"limit\":100"), "{cursored}");
2195 }
2196
2197 #[test]
2198 fn flows_page_parses_platform_shape() {
2199 let raw = r#"{
2200 "items": [{
2201 "id": "flow_1",
2202 "name": "Luigi's — after hours",
2203 "version": 3,
2204 "yaml": "schema_version: 1\n",
2205 "publishedAt": "2026-07-13T10:00:00Z"
2206 }],
2207 "nextAfter": null
2208 }"#;
2209 let page: VoiceFlowsPage = serde_json::from_str(raw).unwrap();
2210 assert_eq!(page.items.len(), 1);
2211 let rec = &page.items[0];
2212 assert_eq!(rec.id, "flow_1");
2213 assert_eq!(rec.version, 3);
2214 assert_eq!(rec.published_at, "2026-07-13T10:00:00Z");
2215 assert!(page.next_after.is_none());
2216
2217 // A mid-walk page carries the cursor.
2218 let more: VoiceFlowsPage =
2219 serde_json::from_str(r#"{ "items": [], "nextAfter": "flow_1" }"#).unwrap();
2220 assert_eq!(more.next_after.as_deref(), Some("flow_1"));
2221 }
2222
2223 #[test]
2224 fn flow_assets_manifest_parses_platform_shape() {
2225 // `ref` (a reserved word) maps to `asset_ref`; a null duration is
2226 // accepted (the platform doesn't always know it).
2227 let raw = r#"{
2228 "assets": [{
2229 "ref": "vprompt_ab12cd34",
2230 "format": "ulaw_8000",
2231 "byteSize": 48044,
2232 "durationMs": null,
2233 "contentHash": "9f2c00aa"
2234 }]
2235 }"#;
2236 let page: VoiceFlowAssetsPage = serde_json::from_str(raw).unwrap();
2237 assert_eq!(page.assets.len(), 1);
2238 let asset = &page.assets[0];
2239 assert_eq!(asset.asset_ref, "vprompt_ab12cd34");
2240 assert_eq!(asset.format, "ulaw_8000");
2241 assert_eq!(asset.byte_size, 48044);
2242 assert!(asset.duration_ms.is_none());
2243 assert_eq!(asset.content_hash, "9f2c00aa");
2244
2245 // A text-only version legitimately has no frozen audio.
2246 let empty: VoiceFlowAssetsPage = serde_json::from_str(r#"{ "assets": [] }"#).unwrap();
2247 assert!(empty.assets.is_empty());
2248 }
2249
2250 #[test]
2251 fn account_record_accepts_unknown_extras_for_forward_compat() {
2252 // A newer client shipping a field this platform version lacks a
2253 // column for round-trips via the `extras` envelope.
2254 let raw = r#"{
2255 "sourceId": "a",
2256 "enabled": true,
2257 "displayName": "x",
2258 "username": "u",
2259 "domain": "d",
2260 "transport": "udp",
2261 "registerExpires": 60,
2262 "disclosureEnabled": true,
2263 "updatedAt": "2026-06-20T10:00:00Z",
2264 "schemaVersion": 2,
2265 "extras": { "ringtone": "classic" }
2266 }"#;
2267 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
2268 assert_eq!(parsed.envelope.schema_version, Some(2));
2269 let extras = parsed.envelope.extras.as_ref().expect("extras present");
2270 assert_eq!(extras["ringtone"], "classic");
2271 }
2272}