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::sync::{stamp_schema_version, HasSyncEnvelope, SyncEndpoint, SyncEnvelope, SyncRequest};
19
20/// Inbound vs. outbound. Wire-stable snake_case strings — never
21/// renumber or rename. New states (e.g. `internal`) would be a wire
22/// addition, not a replacement.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum VoiceCallDirection {
26 Inbound,
27 Outbound,
28}
29
30/// User-visible disposition. Derived from [`VoiceCallEndReason`] by the
31/// daemon; the platform stores both, so future UI surfaces can read
32/// either without re-deriving.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum VoiceCallDisposition {
36 Answered,
37 Missed,
38 Rejected,
39 Cancelled,
40 Failed,
41}
42
43/// Finer-grained terminal reason — kept distinct from
44/// [`VoiceCallDisposition`] because the disposition collapses
45/// `hangup_local` and `hangup_remote` to `Answered`, losing the
46/// "who hung up?" answer the row otherwise carries.
47///
48/// Wire-stable snake_case strings; the daemon's matching enum is the
49/// canonical source.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum VoiceCallEndReason {
53 HangupLocal,
54 HangupRemote,
55 RejectedLocal,
56 RejectedRemote,
57 Missed,
58 CancelledLocal,
59 Failed,
60}
61
62/// One historical call as it crosses the wire from the daemon up to the
63/// platform.
64///
65/// Mirrors the daemon's local `CallRecord` (see
66/// `wavekat-voice/crates/wavekat-voice/src/db.rs`) with one rename:
67/// the daemon's local primary key (`id`) is shipped as `source_id`
68/// because the platform allocates its own row id and treats the
69/// daemon-side UUID as the idempotency key.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct VoiceCallRecord {
73 /// Daemon-generated UUID. The platform's `(user_id, source_id)`
74 /// upsert key — re-syncing the same id is a no-op.
75 pub source_id: String,
76 /// SIP account UUID on the daemon side. Opaque to the platform.
77 pub account_id: String,
78 pub direction: VoiceCallDirection,
79 /// SIP `From:` (inbound) or `To:` (outbound). Free text — caller
80 /// IDs, display names, and SIP URIs all land here.
81 pub party: String,
82 /// RFC 3339. First ring (inbound) or first dial-out (outbound).
83 pub ring_at: String,
84 /// RFC 3339. Present only when the call reached the answered
85 /// state.
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub answer_at: Option<String>,
88 /// RFC 3339. Terminal timestamp; the platform uses this as the
89 /// list cursor.
90 pub end_at: String,
91 /// `answer_at` → `end_at` in milliseconds. `None` for calls that
92 /// were never answered.
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub duration_ms: Option<i64>,
95 pub disposition: VoiceCallDisposition,
96 pub end_reason: VoiceCallEndReason,
97 /// Free-text error, populated only when `disposition == Failed`.
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub error: Option<String>,
100 /// Version + forward-compat fields shared by every sync record.
101 /// Flattened so `schemaVersion` and `extras` sit at the top of
102 /// the JSON object alongside the other columns. See
103 /// [`SyncEnvelope`] and doc 21 §"Versioning and forward
104 /// compatibility".
105 #[serde(flatten, default)]
106 pub envelope: SyncEnvelope,
107}
108
109/// Query params for `GET /api/voice/calls`. All fields optional — the
110/// default returns the newest page.
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct VoiceCallsQuery {
114 /// RFC 3339 cursor; rows with `end_at < before` are returned.
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub before: Option<String>,
117 /// 1..=200. Server default is 50.
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub limit: Option<u32>,
120}
121
122/// Marker for the `/api/voice/calls/{sync,list}` endpoint pair.
123///
124/// Use as a type parameter, never construct: `client.sync::<VoiceCalls>(&items)`.
125pub struct VoiceCalls;
126
127impl SyncEndpoint for VoiceCalls {
128 const RESOURCE: &'static str = "calls";
129 type Record = VoiceCallRecord;
130 type Query = VoiceCallsQuery;
131}
132
133impl HasSyncEnvelope for VoiceCallRecord {
134 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
135 &mut self.envelope
136 }
137}
138
139// ---- VoiceRecordings ------------------------------------------------------
140
141/// One per-call recording's metadata as it crosses the wire from the
142/// daemon up to the platform. The WAV bytes ride on a separate
143/// follow-up call ([`Client::upload_recording_bytes`]) so the
144/// idempotent metadata sync stays small and a flaky bytes upload
145/// doesn't force the daemon to re-ship the row.
146///
147/// Mirrors the daemon's `RecordingArtifact` (see
148/// `wavekat-voice/crates/wavekat-voice/src/recording.rs`) with one
149/// rename: the daemon's local id (`id`) ships as `source_id` because
150/// the platform allocates its own row id and treats the daemon-side
151/// UUID as the idempotency key (same convention as
152/// [`VoiceCallRecord`]).
153#[derive(Debug, Clone, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct VoiceRecordingRecord {
156 /// Daemon-generated UUID for this recording artifact. Upsert key
157 /// on the platform side.
158 pub source_id: String,
159 /// Daemon's `calls.id` — the call this recording belongs to.
160 /// The platform stores both so the /voice/calls history page can
161 /// link a call to its recording without a separate join table.
162 pub call_source_id: String,
163 /// Byte length of the WAV file the daemon will PUT in the follow-
164 /// up bytes call. The platform refuses a PUT whose body length
165 /// disagrees.
166 pub size_bytes: u64,
167 pub duration_ms: u64,
168 pub sample_rate: u32,
169 pub channels: u16,
170 /// RFC 3339 timestamp the daemon stamped on the artifact at
171 /// finalize time. Drives the platform's `/voice/recordings` GET
172 /// cursor.
173 pub created_at: String,
174 #[serde(flatten, default)]
175 pub envelope: SyncEnvelope,
176}
177
178/// Query params for `GET /api/voice/recordings`.
179#[derive(Debug, Clone, Default, Serialize, Deserialize)]
180#[serde(rename_all = "camelCase")]
181pub struct VoiceRecordingsQuery {
182 /// RFC 3339 cursor; rows with `created_at < before` are returned.
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub before: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub limit: Option<u32>,
187}
188
189/// Marker for the `/api/voice/recordings/{sync,list}` endpoint pair.
190///
191/// The corresponding bytes-upload endpoint
192/// (`PUT /api/voice/recordings/{sourceId}/bytes`) is invoked via
193/// [`Client::upload_recording_bytes`] — it doesn't fit the
194/// `SyncEndpoint` mold (no batch, no JSON body) so it has its own
195/// inherent method on `Client`.
196pub struct VoiceRecordings;
197
198impl SyncEndpoint for VoiceRecordings {
199 const RESOURCE: &'static str = "recordings";
200 type Record = VoiceRecordingRecord;
201 type Query = VoiceRecordingsQuery;
202}
203
204impl HasSyncEnvelope for VoiceRecordingRecord {
205 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
206 &mut self.envelope
207 }
208}
209
210/// One item in the platform's response to
211/// `POST /api/voice/recordings/sync`. Lets the daemon learn the R2
212/// key the platform stamped (so a subsequent bytes PUT can target it)
213/// without re-deriving it, and check whether bytes have already
214/// landed on a prior cycle (so the daemon can mark the local row
215/// synced without re-uploading the WAV).
216#[derive(Debug, Clone, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct VoiceRecordingSyncItem {
219 pub source_id: String,
220 pub r2_key: String,
221 pub bytes_uploaded: bool,
222}
223
224/// Full response from `POST /api/voice/recordings/sync`. Superset of
225/// the generic [`crate::SyncResponse`] — see [`Client::sync_recordings`].
226#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(rename_all = "camelCase")]
228pub struct VoiceRecordingsSyncResponse {
229 pub accepted: u32,
230 pub skipped: u32,
231 pub items: Vec<VoiceRecordingSyncItem>,
232}
233
234// ---- VoiceTranscripts -----------------------------------------------------
235
236/// Wire-stable transcript channel tag. Matches the daemon's
237/// `TranscriptChannelLabel` and `events::TranscriptChannel`.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(rename_all = "snake_case")]
240pub enum VoiceTranscriptChannel {
241 /// Local mic audio — what the user said.
242 Local,
243 /// Received RTP audio — what the remote party said.
244 Remote,
245}
246
247/// One ASR transcript segment ("final" in wavekat-asr parlance) as it
248/// crosses the wire. Each segment is a row on the daemon side
249/// (`transcripts` table); the daemon batches a slice of them per
250/// upload and the platform upserts per (user_id, source_id).
251#[derive(Debug, Clone, Serialize, Deserialize)]
252#[serde(rename_all = "camelCase")]
253pub struct VoiceTranscriptRecord {
254 /// Daemon-side row id, formatted as text (the column is an
255 /// autoincrement integer on SQLite). Stable per (call, segment)
256 /// so re-shipping converges.
257 pub source_id: String,
258 /// Daemon's `calls.id` — the call this segment belongs to.
259 pub call_source_id: String,
260 pub channel: VoiceTranscriptChannel,
261 /// Start of the segment in milliseconds relative to the start of
262 /// the call's audio stream (not wall-clock).
263 pub ts_ms: i64,
264 /// End of the segment, same reference frame as `ts_ms`.
265 pub end_ms: i64,
266 /// Recognised text. Free-form; the platform stores it verbatim.
267 pub text: String,
268 #[serde(flatten, default)]
269 pub envelope: SyncEnvelope,
270}
271
272/// Query params for `GET /api/voice/transcripts` — required
273/// `call_source_id` (the endpoint refuses a flat list).
274#[derive(Debug, Clone, Default, Serialize, Deserialize)]
275#[serde(rename_all = "camelCase")]
276pub struct VoiceTranscriptsQuery {
277 pub call_source_id: String,
278}
279
280/// Marker for the `/api/voice/transcripts/{sync,list}` endpoint pair.
281pub struct VoiceTranscripts;
282
283impl SyncEndpoint for VoiceTranscripts {
284 const RESOURCE: &'static str = "transcripts";
285 type Record = VoiceTranscriptRecord;
286 type Query = VoiceTranscriptsQuery;
287}
288
289impl HasSyncEnvelope for VoiceTranscriptRecord {
290 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
291 &mut self.envelope
292 }
293}
294
295// ---- Client surface for recordings ----------------------------------------
296//
297// Recordings don't fit the generic `Client::sync` shape cleanly:
298//
299// - the response carries per-item provenance (the platform-stamped
300// `r2Key`, plus whether bytes have already landed) that the
301// daemon needs in order to decide which rows still owe a PUT;
302// - the bytes upload is its own HTTP call (`PUT
303// /api/voice/recordings/{sourceId}/bytes`), not a JSON batch.
304//
305// Rather than overloading `SyncEndpoint` to carry these shapes, we
306// expose two inherent methods on `Client` that compose the existing
307// JSON / bytes-PUT primitives.
308
309impl Client {
310 /// `POST /api/voice/recordings/sync` — idempotent batch upsert of
311 /// recording metadata. Returns the per-item `r2Key` the daemon
312 /// should target for the follow-up bytes PUT, and whether bytes
313 /// have already landed for each row.
314 ///
315 /// Batch sizing rules match [`Client::sync`]: the platform rejects
316 /// batches over 100 items; the daemon's uploader chunks at 50.
317 pub async fn sync_recordings(
318 &self,
319 items: &[VoiceRecordingRecord],
320 ) -> Result<VoiceRecordingsSyncResponse> {
321 let stamped = stamp_schema_version::<VoiceRecordings>(items);
322 let body = SyncRequest { items: stamped };
323 self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
324 .await
325 }
326
327 /// `PUT /api/voice/recordings/{sourceId}/bytes` — upload the WAV
328 /// bytes for a recording whose metadata was previously synced via
329 /// [`Client::sync_recordings`]. The platform refuses (`HTTP 413`)
330 /// if `bytes.len()` disagrees with the synced `sizeBytes`.
331 ///
332 /// `source_id` is path-segmented as-is; callers pass the
333 /// daemon-side UUID they used for the metadata sync. Empty /
334 /// path-traversal-shaped ids are not specifically guarded here —
335 /// the platform's Zod schema rejects them server-side, so a
336 /// malformed id surfaces as a 4xx via [`Error::Http`].
337 pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
338 if source_id.is_empty() {
339 return Err(Error::BadRequest("source_id must not be empty".into()));
340 }
341 let path = format!("/api/voice/recordings/{source_id}/bytes");
342 self.put_raw_bytes(&path, "audio/wav", bytes).await
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn record_serializes_with_camel_case_keys() {
352 let r = VoiceCallRecord {
353 source_id: "11111111-1111-4111-8111-111111111111".into(),
354 account_id: "22222222-2222-4222-8222-222222222222".into(),
355 direction: VoiceCallDirection::Inbound,
356 party: "+14155550123".into(),
357 ring_at: "2026-05-16T10:00:00Z".into(),
358 answer_at: Some("2026-05-16T10:00:05Z".into()),
359 end_at: "2026-05-16T10:01:00Z".into(),
360 duration_ms: Some(55_000),
361 disposition: VoiceCallDisposition::Answered,
362 end_reason: VoiceCallEndReason::HangupRemote,
363 error: None,
364 envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
365 };
366 let s = serde_json::to_string(&r).unwrap();
367 assert!(s.contains("\"sourceId\":"), "{s}");
368 assert!(s.contains("\"accountId\":"), "{s}");
369 assert!(s.contains("\"ringAt\":"), "{s}");
370 assert!(s.contains("\"endAt\":"), "{s}");
371 assert!(s.contains("\"durationMs\":55000"), "{s}");
372 // Optional `error` is None — should be omitted from the wire.
373 assert!(!s.contains("\"error\""), "error should be omitted: {s}");
374 // Envelope flattens to the top of the object — schemaVersion
375 // sits next to the other fields rather than nested under
376 // "envelope". Future resources rely on this layout.
377 assert!(
378 s.contains("\"schemaVersion\":1"),
379 "schemaVersion should flatten: {s}"
380 );
381 // `extras` is None, so the envelope contributes no `extras`
382 // key. Stays out of the row to keep the small/fast path.
383 assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
384 }
385
386 #[test]
387 fn record_round_trips_optional_fields() {
388 // An unanswered call has answer_at/duration_ms/error all absent.
389 let raw = r#"{
390 "sourceId": "a",
391 "accountId": "b",
392 "direction": "inbound",
393 "party": "anonymous",
394 "ringAt": "2026-05-16T10:00:00Z",
395 "endAt": "2026-05-16T10:00:30Z",
396 "disposition": "missed",
397 "endReason": "missed"
398 }"#;
399 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
400 assert!(parsed.answer_at.is_none());
401 assert!(parsed.duration_ms.is_none());
402 assert!(parsed.error.is_none());
403 assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
404 assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
405 }
406
407 #[test]
408 fn query_omits_unset_fields() {
409 let q = VoiceCallsQuery::default();
410 let s = serde_json::to_string(&q).unwrap();
411 // Empty object — every field skipped when None.
412 assert_eq!(
413 s, "{}",
414 "default query should serialize to empty object: {s}"
415 );
416 }
417
418 #[test]
419 fn enum_round_trip_via_json() {
420 // The wire form for each direction/disposition/reason must
421 // match what the daemon and platform expect — this guards
422 // against accidental Rust-side renames.
423 for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
424 let s = serde_json::to_string(&d).unwrap();
425 let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
426 assert_eq!(d, back);
427 }
428 for d in [
429 VoiceCallDisposition::Answered,
430 VoiceCallDisposition::Missed,
431 VoiceCallDisposition::Rejected,
432 VoiceCallDisposition::Cancelled,
433 VoiceCallDisposition::Failed,
434 ] {
435 let s = serde_json::to_string(&d).unwrap();
436 let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
437 assert_eq!(d, back);
438 }
439 for r in [
440 VoiceCallEndReason::HangupLocal,
441 VoiceCallEndReason::HangupRemote,
442 VoiceCallEndReason::RejectedLocal,
443 VoiceCallEndReason::RejectedRemote,
444 VoiceCallEndReason::Missed,
445 VoiceCallEndReason::CancelledLocal,
446 VoiceCallEndReason::Failed,
447 ] {
448 let s = serde_json::to_string(&r).unwrap();
449 let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
450 assert_eq!(r, back);
451 }
452 }
453
454 #[test]
455 fn voice_calls_marker_resource_is_calls() {
456 assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
457 }
458
459 #[test]
460 fn record_accepts_unknown_extras_for_forward_compat() {
461 // A newer client shipping a `notes` field that this platform
462 // version doesn't have a column for should round-trip via
463 // the `extras` envelope. The platform persists the blob
464 // verbatim; a future deploy can promote it to a typed
465 // column without data loss.
466 let raw = r#"{
467 "sourceId": "a",
468 "accountId": "b",
469 "direction": "inbound",
470 "party": "anon",
471 "ringAt": "2026-05-16T10:00:00Z",
472 "endAt": "2026-05-16T10:00:30Z",
473 "disposition": "answered",
474 "endReason": "hangup_remote",
475 "schemaVersion": 2,
476 "extras": { "notes": "from staging build" }
477 }"#;
478 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
479 assert_eq!(parsed.envelope.schema_version, Some(2));
480 let extras = parsed.envelope.extras.as_ref().expect("extras present");
481 assert_eq!(extras["notes"], "from staging build");
482 }
483
484 #[test]
485 fn recording_marker_resource_is_recordings() {
486 // Path constant drives the URL in `Client::sync_recordings`;
487 // a rename here would silently 404 against the platform.
488 assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
489 }
490
491 #[test]
492 fn recording_record_serializes_with_camel_case_and_envelope() {
493 let r = VoiceRecordingRecord {
494 source_id: "11111111-1111-4111-8111-111111111111".into(),
495 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
496 size_bytes: 44 + 64_000,
497 duration_ms: 2_000,
498 sample_rate: 8_000,
499 channels: 2,
500 created_at: "2026-05-16T10:01:05Z".into(),
501 envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
502 };
503 let s = serde_json::to_string(&r).unwrap();
504 // Field-by-field wire contract — these strings are also what
505 // the platform's Zod schema expects.
506 assert!(s.contains("\"sourceId\":"), "{s}");
507 assert!(s.contains("\"callSourceId\":"), "{s}");
508 assert!(s.contains("\"sizeBytes\":64044"), "{s}");
509 assert!(s.contains("\"durationMs\":2000"), "{s}");
510 assert!(s.contains("\"sampleRate\":8000"), "{s}");
511 assert!(s.contains("\"channels\":2"), "{s}");
512 assert!(s.contains("\"createdAt\":"), "{s}");
513 // Envelope flattens to the top of the object, same as VoiceCallRecord.
514 assert!(s.contains("\"schemaVersion\":1"), "{s}");
515 }
516
517 #[test]
518 fn recordings_sync_response_round_trips() {
519 // The richer-than-generic response carries per-item provenance —
520 // the daemon's uploader reads `r2Key` for the bytes follow-up
521 // and `bytesUploaded` to short-circuit when the row already
522 // landed on a previous cycle.
523 let raw = r#"{
524 "accepted": 2,
525 "skipped": 0,
526 "items": [
527 {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
528 {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
529 ]
530 }"#;
531 let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
532 assert_eq!(parsed.accepted, 2);
533 assert_eq!(parsed.items.len(), 2);
534 assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
535 assert!(!parsed.items[0].bytes_uploaded);
536 assert!(parsed.items[1].bytes_uploaded);
537 }
538
539 #[test]
540 fn transcripts_marker_resource_is_transcripts() {
541 assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
542 }
543
544 #[test]
545 fn transcript_record_serializes_with_camel_case_and_channel_enum() {
546 let r = VoiceTranscriptRecord {
547 source_id: "1".into(),
548 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
549 channel: VoiceTranscriptChannel::Remote,
550 ts_ms: 100,
551 end_ms: 1_500,
552 text: "hello".into(),
553 envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
554 };
555 let s = serde_json::to_string(&r).unwrap();
556 assert!(s.contains("\"sourceId\":"), "{s}");
557 assert!(s.contains("\"callSourceId\":"), "{s}");
558 // The channel enum is wire-stable snake_case — matches the
559 // platform's Zod `enum(VOICE_TRANSCRIPT_CHANNELS)`.
560 assert!(s.contains("\"channel\":\"remote\""), "{s}");
561 assert!(s.contains("\"tsMs\":100"), "{s}");
562 assert!(s.contains("\"endMs\":1500"), "{s}");
563 assert!(s.contains("\"text\":\"hello\""), "{s}");
564 assert!(s.contains("\"schemaVersion\":1"), "{s}");
565 }
566}