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