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 Failed,
61}
62
63/// One historical call as it crosses the wire from the daemon up to the
64/// platform.
65///
66/// Mirrors the daemon's local `CallRecord` (see
67/// `wavekat-voice/crates/wavekat-voice/src/db.rs`) with one rename:
68/// the daemon's local primary key (`id`) is shipped as `source_id`
69/// because the platform allocates its own row id and treats the
70/// daemon-side UUID as the idempotency key.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct VoiceCallRecord {
74 /// Daemon-generated UUID. The platform's `(user_id, source_id)`
75 /// upsert key — re-syncing the same id is a no-op.
76 pub source_id: String,
77 /// SIP account UUID on the daemon side. Opaque to the platform.
78 pub account_id: String,
79 pub direction: VoiceCallDirection,
80 /// SIP `From:` (inbound) or `To:` (outbound). Free text — caller
81 /// IDs, display names, and SIP URIs all land here.
82 pub party: String,
83 /// RFC 3339. First ring (inbound) or first dial-out (outbound).
84 pub ring_at: String,
85 /// RFC 3339. Present only when the call reached the answered
86 /// state.
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub answer_at: Option<String>,
89 /// RFC 3339. Terminal timestamp; the platform uses this as the
90 /// list cursor.
91 pub end_at: String,
92 /// `answer_at` → `end_at` in milliseconds. `None` for calls that
93 /// were never answered.
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub duration_ms: Option<i64>,
96 pub disposition: VoiceCallDisposition,
97 pub end_reason: VoiceCallEndReason,
98 /// Free-text error, populated only when `disposition == Failed`.
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub error: Option<String>,
101 /// Version + forward-compat fields shared by every sync record.
102 /// Flattened so `schemaVersion` and `extras` sit at the top of
103 /// the JSON object alongside the other columns. See
104 /// [`SyncEnvelope`] and doc 21 §"Versioning and forward
105 /// compatibility".
106 #[serde(flatten, default)]
107 pub envelope: SyncEnvelope,
108}
109
110/// Query params for `GET /api/voice/calls`. All fields optional — the
111/// default returns the newest page.
112#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct VoiceCallsQuery {
115 /// RFC 3339 cursor; rows with `end_at < before` are returned.
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub before: Option<String>,
118 /// 1..=200. Server default is 50.
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub limit: Option<u32>,
121}
122
123/// Marker for the `/api/voice/calls/{sync,list}` endpoint pair.
124///
125/// Use as a type parameter, never construct: `client.sync::<VoiceCalls>(&items)`.
126pub struct VoiceCalls;
127
128impl SyncEndpoint for VoiceCalls {
129 const RESOURCE: &'static str = "calls";
130 type Record = VoiceCallRecord;
131 type Query = VoiceCallsQuery;
132}
133
134impl HasSyncEnvelope for VoiceCallRecord {
135 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
136 &mut self.envelope
137 }
138}
139
140// ---- VoiceRecordings ------------------------------------------------------
141
142/// One per-call recording's metadata as it crosses the wire from the
143/// daemon up to the platform. The WAV bytes ride on a separate
144/// follow-up call ([`Client::upload_recording_bytes`]) so the
145/// idempotent metadata sync stays small and a flaky bytes upload
146/// doesn't force the daemon to re-ship the row.
147///
148/// Mirrors the daemon's `RecordingArtifact` (see
149/// `wavekat-voice/crates/wavekat-voice/src/recording.rs`) with one
150/// rename: the daemon's local id (`id`) ships as `source_id` because
151/// the platform allocates its own row id and treats the daemon-side
152/// UUID as the idempotency key (same convention as
153/// [`VoiceCallRecord`]).
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct VoiceRecordingRecord {
157 /// Daemon-generated UUID for this recording artifact. Upsert key
158 /// on the platform side.
159 pub source_id: String,
160 /// Daemon's `calls.id` — the call this recording belongs to.
161 /// The platform stores both so the /voice/calls history page can
162 /// link a call to its recording without a separate join table.
163 pub call_source_id: String,
164 /// Byte length of the WAV file the daemon will PUT in the follow-
165 /// up bytes call. The platform refuses a PUT whose body length
166 /// disagrees.
167 pub size_bytes: u64,
168 pub duration_ms: u64,
169 pub sample_rate: u32,
170 pub channels: u16,
171 /// RFC 3339 timestamp the daemon stamped on the artifact at
172 /// finalize time. Drives the platform's `/voice/recordings` GET
173 /// cursor.
174 pub created_at: String,
175 #[serde(flatten, default)]
176 pub envelope: SyncEnvelope,
177}
178
179/// Query params for `GET /api/voice/recordings`.
180#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct VoiceRecordingsQuery {
183 /// RFC 3339 cursor; rows with `created_at < before` are returned.
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub before: Option<String>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub limit: Option<u32>,
188}
189
190/// Marker for the `/api/voice/recordings/{sync,list}` endpoint pair.
191///
192/// The corresponding bytes-upload endpoint
193/// (`PUT /api/voice/recordings/{sourceId}/bytes`) is invoked via
194/// [`Client::upload_recording_bytes`] — it doesn't fit the
195/// `SyncEndpoint` mold (no batch, no JSON body) so it has its own
196/// inherent method on `Client`.
197pub struct VoiceRecordings;
198
199impl SyncEndpoint for VoiceRecordings {
200 const RESOURCE: &'static str = "recordings";
201 type Record = VoiceRecordingRecord;
202 type Query = VoiceRecordingsQuery;
203}
204
205impl HasSyncEnvelope for VoiceRecordingRecord {
206 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
207 &mut self.envelope
208 }
209}
210
211/// One item in the platform's response to
212/// `POST /api/voice/recordings/sync`. Lets the daemon learn the R2
213/// key the platform stamped (so a subsequent bytes PUT can target it)
214/// without re-deriving it, and check whether bytes have already
215/// landed on a prior cycle (so the daemon can mark the local row
216/// synced without re-uploading the WAV).
217#[derive(Debug, Clone, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase")]
219pub struct VoiceRecordingSyncItem {
220 pub source_id: String,
221 pub r2_key: String,
222 pub bytes_uploaded: bool,
223}
224
225/// Full response from `POST /api/voice/recordings/sync`. Superset of
226/// the generic [`crate::SyncResponse`] — see [`Client::sync_recordings`].
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(rename_all = "camelCase")]
229pub struct VoiceRecordingsSyncResponse {
230 pub accepted: u32,
231 pub skipped: u32,
232 pub items: Vec<VoiceRecordingSyncItem>,
233}
234
235// ---- VoiceTranscripts -----------------------------------------------------
236
237/// Wire-stable transcript channel tag. Matches the daemon's
238/// `TranscriptChannelLabel` and `events::TranscriptChannel`.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241pub enum VoiceTranscriptChannel {
242 /// Local mic audio — what the user said.
243 Local,
244 /// Received RTP audio — what the remote party said.
245 Remote,
246}
247
248/// One ASR transcript segment ("final" in wavekat-asr parlance) as it
249/// crosses the wire. Each segment is a row on the daemon side
250/// (`transcripts` table); the daemon batches a slice of them per
251/// upload and the platform upserts per (user_id, source_id).
252#[derive(Debug, Clone, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct VoiceTranscriptRecord {
255 /// Daemon-side row id, formatted as text (the column is an
256 /// autoincrement integer on SQLite). Stable per (call, segment)
257 /// so re-shipping converges.
258 pub source_id: String,
259 /// Daemon's `calls.id` — the call this segment belongs to.
260 pub call_source_id: String,
261 pub channel: VoiceTranscriptChannel,
262 /// Start of the segment in milliseconds relative to the start of
263 /// the call's audio stream (not wall-clock).
264 pub ts_ms: i64,
265 /// End of the segment, same reference frame as `ts_ms`.
266 pub end_ms: i64,
267 /// Recognised text. Free-form; the platform stores it verbatim.
268 pub text: String,
269 #[serde(flatten, default)]
270 pub envelope: SyncEnvelope,
271}
272
273/// Query params for `GET /api/voice/transcripts` — required
274/// `call_source_id` (the endpoint refuses a flat list).
275#[derive(Debug, Clone, Default, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct VoiceTranscriptsQuery {
278 pub call_source_id: String,
279}
280
281/// Marker for the `/api/voice/transcripts/{sync,list}` endpoint pair.
282pub struct VoiceTranscripts;
283
284impl SyncEndpoint for VoiceTranscripts {
285 const RESOURCE: &'static str = "transcripts";
286 type Record = VoiceTranscriptRecord;
287 type Query = VoiceTranscriptsQuery;
288}
289
290impl HasSyncEnvelope for VoiceTranscriptRecord {
291 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
292 &mut self.envelope
293 }
294}
295
296// ---- Anonymous install heartbeat ------------------------------------------
297//
298// A first-run / per-launch ping the desktop daemon fires *before* (and
299// independently of) any platform sign-in, so the platform can count
300// installs and track version / OS adoption for users who never sign in.
301// It hits the public, unauthenticated `POST /api/voice/installs/heartbeat`
302// and upserts a row keyed by `install_id` alone (no user) — distinct
303// from the authenticated `voice_clients` heartbeat, which is keyed by
304// `(user, install_id)`.
305//
306// The environment fields (os / os_version / arch / locale) are gathered
307// *here*, inside the client crate, rather than on the consumer side:
308// the daemon only owns the two values this crate genuinely cannot
309// discover — the persisted `install_id` and its own app version.
310
311/// Best-effort snapshot of the host environment, detected at call time.
312/// Every field is best-effort; a probe that fails contributes `None`
313/// (or, for the always-available `os` / `arch`, the compile-time
314/// target) rather than failing the heartbeat.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct SystemInfo {
317 /// `std::env::consts::OS` — `"macos"`, `"windows"`, `"linux"`, …
318 pub os: String,
319 /// Human OS version, e.g. `"15.5.0"`. `None` when the OS probe
320 /// can't determine it.
321 pub os_version: Option<String>,
322 /// `std::env::consts::ARCH` — `"aarch64"`, `"x86_64"`, …
323 pub arch: String,
324 /// BCP-47 system locale, e.g. `"en-NZ"`. `None` when unset /
325 /// undetectable (common for GUI-launched apps on some platforms).
326 pub locale: Option<String>,
327}
328
329impl SystemInfo {
330 /// Probe the current host. Cheap enough to call per heartbeat; we
331 /// don't cache so a locale change between launches is reflected.
332 pub fn detect() -> Self {
333 let os_version = match os_info::get().version() {
334 os_info::Version::Unknown => None,
335 v => Some(v.to_string()),
336 };
337 SystemInfo {
338 os: std::env::consts::OS.to_string(),
339 os_version,
340 arch: std::env::consts::ARCH.to_string(),
341 locale: sys_locale::get_locale(),
342 }
343 }
344}
345
346/// Body of `POST /api/voice/installs/heartbeat`. The daemon supplies
347/// `install_id` + `app_version`; [`Client::install_heartbeat`] fills the
348/// environment fields from [`SystemInfo::detect`].
349#[derive(Debug, Clone, Serialize, Deserialize)]
350#[serde(rename_all = "camelCase")]
351pub struct InstallHeartbeatRequest {
352 /// The daemon's persisted install UUID — the platform's upsert key.
353 pub install_id: String,
354 /// WaveKat Voice's own version (`env!("CARGO_PKG_VERSION")` on the
355 /// daemon side) — *not* this crate's version.
356 pub app_version: String,
357 pub os: String,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub os_version: Option<String>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub arch: Option<String>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub locale: Option<String>,
364}
365
366/// The platform's view of an install row, echoed back from a heartbeat.
367#[derive(Debug, Clone, Serialize, Deserialize)]
368#[serde(rename_all = "camelCase")]
369pub struct InstallHeartbeatResponse {
370 pub id: String,
371 pub install_id: String,
372 pub app_version: String,
373 pub os: String,
374 pub os_version: Option<String>,
375 pub arch: Option<String>,
376 pub locale: Option<String>,
377 pub first_seen_at: String,
378 pub last_seen_at: String,
379}
380
381impl Client {
382 /// `POST /api/voice/installs/heartbeat` — the anonymous, no-auth
383 /// first-run install ping. Detects the host environment internally
384 /// and posts it alongside the caller-supplied `install_id` +
385 /// `app_version`. Associated (not a method) because the endpoint is
386 /// unauthenticated — there's no token, and at first run there's no
387 /// signed-in `Client` to hang it off of.
388 ///
389 /// Though unauthenticated, the request is **signed** with the release
390 /// credential `cred` (a per-version Ed25519 key + master-issued
391 /// certificate the consumer bakes in at build time) so the platform
392 /// can verify it came from a genuine release and reject forged or
393 /// replayed pings — see [`Client::post_public_signed_json`] and
394 /// [`crate::sign`]. The platform needs only the master *public* key to
395 /// verify.
396 ///
397 /// `base_url` is the platform base (e.g. `https://platform.wavekat.com`).
398 pub async fn install_heartbeat(
399 base_url: &str,
400 install_id: &str,
401 app_version: &str,
402 cred: &ReleaseCredential,
403 ) -> Result<InstallHeartbeatResponse> {
404 let sys = SystemInfo::detect();
405 let body = InstallHeartbeatRequest {
406 install_id: install_id.to_string(),
407 app_version: app_version.to_string(),
408 os: sys.os,
409 os_version: sys.os_version,
410 arch: Some(sys.arch),
411 locale: sys.locale,
412 };
413 Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
414 base_url,
415 "/api/voice/installs/heartbeat",
416 &body,
417 cred,
418 )
419 .await
420 }
421}
422
423// ---- Client surface for recordings ----------------------------------------
424//
425// Recordings don't fit the generic `Client::sync` shape cleanly:
426//
427// - the response carries per-item provenance (the platform-stamped
428// `r2Key`, plus whether bytes have already landed) that the
429// daemon needs in order to decide which rows still owe a PUT;
430// - the bytes upload is its own HTTP call (`PUT
431// /api/voice/recordings/{sourceId}/bytes`), not a JSON batch.
432//
433// Rather than overloading `SyncEndpoint` to carry these shapes, we
434// expose two inherent methods on `Client` that compose the existing
435// JSON / bytes-PUT primitives.
436
437impl Client {
438 /// `POST /api/voice/recordings/sync` — idempotent batch upsert of
439 /// recording metadata. Returns the per-item `r2Key` the daemon
440 /// should target for the follow-up bytes PUT, and whether bytes
441 /// have already landed for each row.
442 ///
443 /// Batch sizing rules match [`Client::sync`]: the platform rejects
444 /// batches over 100 items; the daemon's uploader chunks at 50.
445 pub async fn sync_recordings(
446 &self,
447 items: &[VoiceRecordingRecord],
448 ) -> Result<VoiceRecordingsSyncResponse> {
449 let stamped = stamp_schema_version::<VoiceRecordings>(items);
450 let body = SyncRequest { items: stamped };
451 self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
452 .await
453 }
454
455 /// `PUT /api/voice/recordings/{sourceId}/bytes` — upload the WAV
456 /// bytes for a recording whose metadata was previously synced via
457 /// [`Client::sync_recordings`]. The platform refuses (`HTTP 413`)
458 /// if `bytes.len()` disagrees with the synced `sizeBytes`.
459 ///
460 /// `source_id` is path-segmented as-is; callers pass the
461 /// daemon-side UUID they used for the metadata sync. Empty /
462 /// path-traversal-shaped ids are not specifically guarded here —
463 /// the platform's Zod schema rejects them server-side, so a
464 /// malformed id surfaces as a 4xx via [`Error::Http`].
465 pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
466 if source_id.is_empty() {
467 return Err(Error::BadRequest("source_id must not be empty".into()));
468 }
469 let path = format!("/api/voice/recordings/{source_id}/bytes");
470 self.put_raw_bytes(&path, "audio/wav", bytes).await
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn record_serializes_with_camel_case_keys() {
480 let r = VoiceCallRecord {
481 source_id: "11111111-1111-4111-8111-111111111111".into(),
482 account_id: "22222222-2222-4222-8222-222222222222".into(),
483 direction: VoiceCallDirection::Inbound,
484 party: "+14155550123".into(),
485 ring_at: "2026-05-16T10:00:00Z".into(),
486 answer_at: Some("2026-05-16T10:00:05Z".into()),
487 end_at: "2026-05-16T10:01:00Z".into(),
488 duration_ms: Some(55_000),
489 disposition: VoiceCallDisposition::Answered,
490 end_reason: VoiceCallEndReason::HangupRemote,
491 error: None,
492 envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
493 };
494 let s = serde_json::to_string(&r).unwrap();
495 assert!(s.contains("\"sourceId\":"), "{s}");
496 assert!(s.contains("\"accountId\":"), "{s}");
497 assert!(s.contains("\"ringAt\":"), "{s}");
498 assert!(s.contains("\"endAt\":"), "{s}");
499 assert!(s.contains("\"durationMs\":55000"), "{s}");
500 // Optional `error` is None — should be omitted from the wire.
501 assert!(!s.contains("\"error\""), "error should be omitted: {s}");
502 // Envelope flattens to the top of the object — schemaVersion
503 // sits next to the other fields rather than nested under
504 // "envelope". Future resources rely on this layout.
505 assert!(
506 s.contains("\"schemaVersion\":1"),
507 "schemaVersion should flatten: {s}"
508 );
509 // `extras` is None, so the envelope contributes no `extras`
510 // key. Stays out of the row to keep the small/fast path.
511 assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
512 }
513
514 #[test]
515 fn record_round_trips_optional_fields() {
516 // An unanswered call has answer_at/duration_ms/error all absent.
517 let raw = r#"{
518 "sourceId": "a",
519 "accountId": "b",
520 "direction": "inbound",
521 "party": "anonymous",
522 "ringAt": "2026-05-16T10:00:00Z",
523 "endAt": "2026-05-16T10:00:30Z",
524 "disposition": "missed",
525 "endReason": "missed"
526 }"#;
527 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
528 assert!(parsed.answer_at.is_none());
529 assert!(parsed.duration_ms.is_none());
530 assert!(parsed.error.is_none());
531 assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
532 assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
533 }
534
535 #[test]
536 fn query_omits_unset_fields() {
537 let q = VoiceCallsQuery::default();
538 let s = serde_json::to_string(&q).unwrap();
539 // Empty object — every field skipped when None.
540 assert_eq!(
541 s, "{}",
542 "default query should serialize to empty object: {s}"
543 );
544 }
545
546 #[test]
547 fn enum_round_trip_via_json() {
548 // The wire form for each direction/disposition/reason must
549 // match what the daemon and platform expect — this guards
550 // against accidental Rust-side renames.
551 for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
552 let s = serde_json::to_string(&d).unwrap();
553 let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
554 assert_eq!(d, back);
555 }
556 for d in [
557 VoiceCallDisposition::Answered,
558 VoiceCallDisposition::Missed,
559 VoiceCallDisposition::Rejected,
560 VoiceCallDisposition::Cancelled,
561 VoiceCallDisposition::Failed,
562 ] {
563 let s = serde_json::to_string(&d).unwrap();
564 let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
565 assert_eq!(d, back);
566 }
567 for r in [
568 VoiceCallEndReason::HangupLocal,
569 VoiceCallEndReason::HangupRemote,
570 VoiceCallEndReason::RejectedLocal,
571 VoiceCallEndReason::RejectedRemote,
572 VoiceCallEndReason::Missed,
573 VoiceCallEndReason::CancelledLocal,
574 VoiceCallEndReason::Failed,
575 ] {
576 let s = serde_json::to_string(&r).unwrap();
577 let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
578 assert_eq!(r, back);
579 }
580 }
581
582 #[test]
583 fn voice_calls_marker_resource_is_calls() {
584 assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
585 }
586
587 #[test]
588 fn record_accepts_unknown_extras_for_forward_compat() {
589 // A newer client shipping a `notes` field that this platform
590 // version doesn't have a column for should round-trip via
591 // the `extras` envelope. The platform persists the blob
592 // verbatim; a future deploy can promote it to a typed
593 // column without data loss.
594 let raw = r#"{
595 "sourceId": "a",
596 "accountId": "b",
597 "direction": "inbound",
598 "party": "anon",
599 "ringAt": "2026-05-16T10:00:00Z",
600 "endAt": "2026-05-16T10:00:30Z",
601 "disposition": "answered",
602 "endReason": "hangup_remote",
603 "schemaVersion": 2,
604 "extras": { "notes": "from staging build" }
605 }"#;
606 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
607 assert_eq!(parsed.envelope.schema_version, Some(2));
608 let extras = parsed.envelope.extras.as_ref().expect("extras present");
609 assert_eq!(extras["notes"], "from staging build");
610 }
611
612 #[test]
613 fn recording_marker_resource_is_recordings() {
614 // Path constant drives the URL in `Client::sync_recordings`;
615 // a rename here would silently 404 against the platform.
616 assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
617 }
618
619 #[test]
620 fn recording_record_serializes_with_camel_case_and_envelope() {
621 let r = VoiceRecordingRecord {
622 source_id: "11111111-1111-4111-8111-111111111111".into(),
623 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
624 size_bytes: 44 + 64_000,
625 duration_ms: 2_000,
626 sample_rate: 8_000,
627 channels: 2,
628 created_at: "2026-05-16T10:01:05Z".into(),
629 envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
630 };
631 let s = serde_json::to_string(&r).unwrap();
632 // Field-by-field wire contract — these strings are also what
633 // the platform's Zod schema expects.
634 assert!(s.contains("\"sourceId\":"), "{s}");
635 assert!(s.contains("\"callSourceId\":"), "{s}");
636 assert!(s.contains("\"sizeBytes\":64044"), "{s}");
637 assert!(s.contains("\"durationMs\":2000"), "{s}");
638 assert!(s.contains("\"sampleRate\":8000"), "{s}");
639 assert!(s.contains("\"channels\":2"), "{s}");
640 assert!(s.contains("\"createdAt\":"), "{s}");
641 // Envelope flattens to the top of the object, same as VoiceCallRecord.
642 assert!(s.contains("\"schemaVersion\":1"), "{s}");
643 }
644
645 #[test]
646 fn recordings_sync_response_round_trips() {
647 // The richer-than-generic response carries per-item provenance —
648 // the daemon's uploader reads `r2Key` for the bytes follow-up
649 // and `bytesUploaded` to short-circuit when the row already
650 // landed on a previous cycle.
651 let raw = r#"{
652 "accepted": 2,
653 "skipped": 0,
654 "items": [
655 {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
656 {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
657 ]
658 }"#;
659 let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
660 assert_eq!(parsed.accepted, 2);
661 assert_eq!(parsed.items.len(), 2);
662 assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
663 assert!(!parsed.items[0].bytes_uploaded);
664 assert!(parsed.items[1].bytes_uploaded);
665 }
666
667 #[test]
668 fn install_heartbeat_request_serializes_with_camel_case_keys() {
669 let req = InstallHeartbeatRequest {
670 install_id: "11111111-1111-4111-8111-111111111111".into(),
671 app_version: "0.0.21".into(),
672 os: "macos".into(),
673 os_version: Some("15.5.0".into()),
674 arch: Some("aarch64".into()),
675 locale: Some("en-NZ".into()),
676 };
677 let s = serde_json::to_string(&req).unwrap();
678 assert!(s.contains("\"installId\":"), "{s}");
679 assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
680 assert!(s.contains("\"os\":\"macos\""), "{s}");
681 assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
682 assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
683 assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
684 }
685
686 #[test]
687 fn install_heartbeat_request_omits_absent_optional_fields() {
688 // A host where the OS version / locale probe came up empty
689 // shouldn't send `null` — keeping the keys out lets the
690 // platform's Zod `.optional()` accept the body and the column
691 // stay NULL rather than the string "null".
692 let req = InstallHeartbeatRequest {
693 install_id: "x".into(),
694 app_version: "0.0.21".into(),
695 os: "linux".into(),
696 os_version: None,
697 arch: None,
698 locale: None,
699 };
700 let s = serde_json::to_string(&req).unwrap();
701 assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
702 assert!(!s.contains("arch"), "arch should be omitted: {s}");
703 assert!(!s.contains("locale"), "locale should be omitted: {s}");
704 }
705
706 #[test]
707 fn install_heartbeat_response_parses_platform_shape() {
708 let raw = r#"{
709 "id": "abc-123",
710 "installId": "11111111-1111-4111-8111-111111111111",
711 "appVersion": "0.0.21",
712 "os": "macos",
713 "osVersion": "15.5.0",
714 "arch": "aarch64",
715 "locale": null,
716 "firstSeenAt": "2026-05-31T10:00:00.000Z",
717 "lastSeenAt": "2026-05-31T10:00:00.000Z"
718 }"#;
719 let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
720 assert_eq!(parsed.id, "abc-123");
721 assert_eq!(parsed.app_version, "0.0.21");
722 assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
723 assert!(parsed.locale.is_none());
724 }
725
726 #[test]
727 fn system_info_detect_fills_os_and_arch() {
728 // os / arch come from compile-time consts, so they're always
729 // non-empty on every supported target. os_version / locale are
730 // best-effort and intentionally not asserted.
731 let sys = SystemInfo::detect();
732 assert!(!sys.os.is_empty(), "os should be a non-empty target string");
733 assert!(
734 !sys.arch.is_empty(),
735 "arch should be a non-empty target string"
736 );
737 }
738
739 #[test]
740 fn transcripts_marker_resource_is_transcripts() {
741 assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
742 }
743
744 #[test]
745 fn transcript_record_serializes_with_camel_case_and_channel_enum() {
746 let r = VoiceTranscriptRecord {
747 source_id: "1".into(),
748 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
749 channel: VoiceTranscriptChannel::Remote,
750 ts_ms: 100,
751 end_ms: 1_500,
752 text: "hello".into(),
753 envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
754 };
755 let s = serde_json::to_string(&r).unwrap();
756 assert!(s.contains("\"sourceId\":"), "{s}");
757 assert!(s.contains("\"callSourceId\":"), "{s}");
758 // The channel enum is wire-stable snake_case — matches the
759 // platform's Zod `enum(VOICE_TRANSCRIPT_CHANNELS)`.
760 assert!(s.contains("\"channel\":\"remote\""), "{s}");
761 assert!(s.contains("\"tsMs\":100"), "{s}");
762 assert!(s.contains("\"endMs\":1500"), "{s}");
763 assert!(s.contains("\"text\":\"hello\""), "{s}");
764 assert!(s.contains("\"schemaVersion\":1"), "{s}");
765 }
766}