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