1use std::{fmt, str::FromStr};
4
5use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
6use time::{OffsetDateTime, UtcOffset};
7use uuid::Uuid;
8
9pub const PUBLICATIONS_PATH: &str = "/api/admin/v1/publications";
11
12pub const IDEMPOTENCY_KEY_HEADER: &str = "Idempotency-Key";
14
15pub const PREVIEW_DIGEST_HEADER: &str = "x-maincopy-preview-digest";
17
18pub const POST_REVISION_HEADER: &str = "x-maincopy-post-revision";
20
21pub const CONTENT_DIGEST_HEADER: &str = "x-maincopy-content-digest";
23
24const PREVIEW_DIGEST_PREFIX: &str = "preview-b3-v1-";
25const DIGEST_HEX_LENGTH: usize = 64;
26
27#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
30#[cfg_attr(
31 feature = "schema",
32 schema(value_type = String, pattern = r"^preview-b3-v1-[0-9a-f]{64}$")
33)]
34pub struct PreviewDigest(Box<str>);
35
36impl PreviewDigest {
37 pub fn parse(value: &str) -> Result<Self, PreviewDigestParseError> {
39 let encoded = value
40 .strip_prefix(PREVIEW_DIGEST_PREFIX)
41 .ok_or(PreviewDigestParseError::InvalidPrefix)?;
42 if encoded.len() != DIGEST_HEX_LENGTH {
43 return Err(PreviewDigestParseError::InvalidLength);
44 }
45 if !encoded
46 .bytes()
47 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
48 {
49 return Err(PreviewDigestParseError::InvalidEncoding);
50 }
51 Ok(Self(value.into()))
52 }
53
54 pub fn as_str(&self) -> &str {
55 &self.0
56 }
57}
58
59impl fmt::Display for PreviewDigest {
60 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61 formatter.write_str(self.as_str())
62 }
63}
64
65impl FromStr for PreviewDigest {
66 type Err = PreviewDigestParseError;
67
68 fn from_str(value: &str) -> Result<Self, Self::Err> {
69 Self::parse(value)
70 }
71}
72
73impl Serialize for PreviewDigest {
74 fn serialize<Serializer>(
75 &self,
76 serializer: Serializer,
77 ) -> Result<Serializer::Ok, Serializer::Error>
78 where
79 Serializer: serde::Serializer,
80 {
81 serializer.serialize_str(self.as_str())
82 }
83}
84
85impl<'de> Deserialize<'de> for PreviewDigest {
86 fn deserialize<DeserializerType>(
87 deserializer: DeserializerType,
88 ) -> Result<Self, DeserializerType::Error>
89 where
90 DeserializerType: Deserializer<'de>,
91 {
92 let value = Box::<str>::deserialize(deserializer)?;
93 Self::parse(&value).map_err(serde::de::Error::custom)
94 }
95}
96
97#[derive(Clone, Copy, Debug, Eq, PartialEq)]
98pub enum PreviewDigestParseError {
99 InvalidPrefix,
100 InvalidLength,
101 InvalidEncoding,
102}
103
104impl fmt::Display for PreviewDigestParseError {
105 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106 formatter.write_str(match self {
107 Self::InvalidPrefix => "preview_digest must start with preview-b3-v1-",
108 Self::InvalidLength => "preview_digest must contain exactly 32 encoded bytes",
109 Self::InvalidEncoding => "preview_digest must use lowercase hexadecimal",
110 })
111 }
112}
113
114impl std::error::Error for PreviewDigestParseError {}
115
116#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
118#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
119#[serde(deny_unknown_fields)]
120pub struct PublishNowRequest {
121 pub post_id: Uuid,
122 pub preview_digest: PreviewDigest,
124 pub expected_revision: Option<Box<str>>,
129 #[serde(
131 default,
132 skip_serializing_if = "Option::is_none",
133 serialize_with = "time::serde::rfc3339::option::serialize",
134 deserialize_with = "deserialize_optional_scheduled_for"
135 )]
136 pub scheduled_for: Option<OffsetDateTime>,
137}
138
139#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
141#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
142#[serde(rename_all = "snake_case")]
143pub enum PublicationApprovalState {
144 Scheduled,
145 Published,
146}
147
148#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
150#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
151pub struct PublishNowResponse {
152 pub publication_id: Uuid,
153 pub post_id: Uuid,
154 pub preview_digest: PreviewDigest,
156 #[serde(deserialize_with = "deserialize_post_revision")]
158 #[cfg_attr(feature = "schema", schema(pattern = r"^post-b3-v1-[0-9a-f]{64}$"))]
159 pub revision: Box<str>,
160 pub state: PublicationApprovalState,
162 #[serde(
164 default,
165 skip_serializing_if = "Option::is_none",
166 serialize_with = "time::serde::rfc3339::option::serialize",
167 deserialize_with = "deserialize_optional_scheduled_for"
168 )]
169 pub scheduled_for: Option<OffsetDateTime>,
170 #[serde(
172 serialize_with = "time::serde::rfc3339::option::serialize",
173 deserialize_with = "deserialize_optional_published_at"
174 )]
175 pub published_at: Option<OffsetDateTime>,
176 #[serde(deserialize_with = "deserialize_site_digest")]
177 #[cfg_attr(feature = "schema", schema(pattern = r"^site-b3-v1-[0-9a-f]{64}$"))]
178 pub site_digest: Box<str>,
179 #[serde(deserialize_with = "deserialize_site_version")]
180 #[cfg_attr(feature = "schema", schema(minimum = 1))]
181 pub site_version: u64,
182}
183
184pub const RELEASES_PATH: &str = "/api/admin/v1/releases";
186pub const RELEASE_OPERATIONS_PATH: &str = "/api/admin/v1/release-operations";
187
188#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
189#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
190#[serde(rename_all = "snake_case")]
191pub enum ReleaseState {
192 Scheduled,
193 Activating,
194 Blocked,
195 Published,
196 Superseded,
197 Cancelled,
198}
199
200#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
201#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
202#[serde(rename_all = "snake_case")]
203pub enum ReleaseBlockReason {
204 RevisionUnavailable,
205 PreviewChanged,
206}
207
208#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
210#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
211#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
212pub enum ChangeReleaseRequest {
213 Reschedule {
214 #[serde(deserialize_with = "deserialize_release_version")]
215 expected_version: u64,
216 #[serde(
217 serialize_with = "time::serde::rfc3339::serialize",
218 deserialize_with = "deserialize_release_time"
219 )]
220 scheduled_for: OffsetDateTime,
221 },
222 Cancel {
223 #[serde(deserialize_with = "deserialize_release_version")]
224 expected_version: u64,
225 },
226 Retry {
227 #[serde(deserialize_with = "deserialize_release_version")]
228 expected_version: u64,
229 },
230}
231
232fn deserialize_release_version<'de, D: Deserializer<'de>>(
233 deserializer: D,
234) -> Result<u64, D::Error> {
235 let version = u64::deserialize(deserializer)?;
236 if (1..i64::MAX as u64).contains(&version) {
237 Ok(version)
238 } else {
239 Err(D::Error::custom(
240 "expected_version must be positive and permit a stored version increment",
241 ))
242 }
243}
244
245fn deserialize_release_time<'de, D: Deserializer<'de>>(
246 deserializer: D,
247) -> Result<OffsetDateTime, D::Error> {
248 let timestamp = time::serde::rfc3339::deserialize(deserializer)?;
249 if timestamp.offset() != UtcOffset::UTC
250 || i64::try_from(timestamp.unix_timestamp_nanos()).is_err()
251 {
252 return Err(D::Error::custom(
253 "scheduled_for must be a UTC timestamp in the supported storage range",
254 ));
255 }
256 Ok(timestamp)
257}
258
259#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
261#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
262pub struct ReleaseResource {
263 pub publication_id: Uuid,
264 pub post_id: Uuid,
265 pub preview_digest: PreviewDigest,
266 #[serde(deserialize_with = "deserialize_post_revision")]
267 pub revision: Box<str>,
268 pub state: ReleaseState,
269 #[serde(deserialize_with = "deserialize_site_version")]
270 pub version: u64,
271 #[serde(with = "time::serde::rfc3339")]
272 pub scheduled_for: OffsetDateTime,
273 #[serde(with = "time::serde::rfc3339::option")]
274 pub published_at: Option<OffsetDateTime>,
275 pub block_reason: Option<ReleaseBlockReason>,
276}
277
278#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
280#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
281pub struct ListReleasesResponse {
282 pub releases: Vec<ReleaseResource>,
283 pub next_cursor: Option<Uuid>,
284}
285
286#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
288#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
289pub struct ReleaseOperationResource {
290 pub operation_id: Uuid,
291 pub publication_id: Uuid,
292 #[serde(deserialize_with = "deserialize_site_version")]
293 pub version: u64,
294 pub state: ReleaseState,
295}
296
297fn deserialize_post_revision<'de, D>(deserializer: D) -> Result<Box<str>, D::Error>
298where
299 D: Deserializer<'de>,
300{
301 deserialize_digest(deserializer, "post-b3-v1-", "revision")
302}
303
304fn deserialize_site_digest<'de, D>(deserializer: D) -> Result<Box<str>, D::Error>
305where
306 D: Deserializer<'de>,
307{
308 deserialize_digest(deserializer, "site-b3-v1-", "site_digest")
309}
310
311fn deserialize_digest<'de, D>(
312 deserializer: D,
313 prefix: &str,
314 field: &str,
315) -> Result<Box<str>, D::Error>
316where
317 D: Deserializer<'de>,
318{
319 let value = Box::<str>::deserialize(deserializer)?;
320 let valid = value.strip_prefix(prefix).is_some_and(|encoded| {
321 encoded.len() == 64
322 && encoded
323 .bytes()
324 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
325 });
326 if valid {
327 Ok(value)
328 } else {
329 Err(D::Error::custom(format_args!(
330 "{field} must be {prefix} followed by 64 lowercase hexadecimal characters"
331 )))
332 }
333}
334
335fn deserialize_optional_scheduled_for<'de, D>(
336 deserializer: D,
337) -> Result<Option<OffsetDateTime>, D::Error>
338where
339 D: Deserializer<'de>,
340{
341 deserialize_optional_utc_timestamp(deserializer, "scheduled_for")
342}
343
344fn deserialize_optional_published_at<'de, D>(
345 deserializer: D,
346) -> Result<Option<OffsetDateTime>, D::Error>
347where
348 D: Deserializer<'de>,
349{
350 deserialize_optional_utc_timestamp(deserializer, "published_at")
351}
352
353fn deserialize_optional_utc_timestamp<'de, D>(
354 deserializer: D,
355 field: &str,
356) -> Result<Option<OffsetDateTime>, D::Error>
357where
358 D: Deserializer<'de>,
359{
360 let timestamp = time::serde::rfc3339::option::deserialize(deserializer)?;
361 match timestamp {
362 Some(timestamp) if timestamp.offset() != UtcOffset::UTC => Err(D::Error::custom(
363 format_args!("{field} must use the UTC offset"),
364 )),
365 timestamp => Ok(timestamp),
366 }
367}
368
369fn deserialize_site_version<'de, D>(deserializer: D) -> Result<u64, D::Error>
370where
371 D: Deserializer<'de>,
372{
373 let version = u64::deserialize(deserializer)?;
374 if version > 0 {
375 Ok(version)
376 } else {
377 Err(D::Error::custom("site_version must be greater than zero"))
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use serde_json::json;
384
385 use super::*;
386
387 const REVISION: &str =
388 "post-b3-v1-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
389 const PREVIEW_DIGEST: &str =
390 "preview-b3-v1-123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0";
391 const SITE_DIGEST: &str =
392 "site-b3-v1-abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
393
394 fn published_response_value() -> serde_json::Value {
395 json!({
396 "publication_id": "018f2046-49b2-7c2a-9226-f81c87ab721d",
397 "post_id": "123e4567-e89b-12d3-a456-426614174000",
398 "preview_digest": PREVIEW_DIGEST,
399 "revision": REVISION,
400 "state": "published",
401 "published_at": "1970-01-01T00:00:00Z",
402 "site_digest": SITE_DIGEST,
403 "site_version": 7
404 })
405 }
406
407 fn scheduled_response_value() -> serde_json::Value {
408 json!({
409 "publication_id": "018f2046-49b2-7c2a-9226-f81c87ab721d",
410 "post_id": "123e4567-e89b-12d3-a456-426614174000",
411 "preview_digest": PREVIEW_DIGEST,
412 "revision": REVISION,
413 "state": "scheduled",
414 "scheduled_for": "1970-01-02T00:00:00Z",
415 "published_at": null,
416 "site_digest": SITE_DIGEST,
417 "site_version": 7
418 })
419 }
420
421 #[test]
422 fn release_controls_require_exact_versions_and_action_specific_fields() {
423 for value in [
424 json!({"action":"reschedule", "expected_version":1, "scheduled_for":"2026-09-06T12:00:00Z"}),
425 json!({"action":"cancel", "expected_version":2}),
426 json!({"action":"retry", "expected_version":3}),
427 ] {
428 let command: ChangeReleaseRequest = serde_json::from_value(value.clone()).unwrap();
429 assert_eq!(serde_json::to_value(command).unwrap(), value);
430 }
431 for value in [
432 json!({"action":"cancel"}),
433 json!({"action":"cancel", "expected_version":0}),
434 json!({"action":"cancel", "expected_version":i64::MAX}),
435 json!({"action":"cancel", "expected_version":1, "scheduled_for":"2026-09-06T12:00:00Z"}),
436 json!({"action":"cancel", "expected_version":1, "expected_vesion":2}),
437 json!({"action":"retry", "expected_version":-1}),
438 json!({"action":"reschedule", "expected_version":1}),
439 json!({"action":"reschedule", "expected_version":1, "scheduled_for":"2026-09-06T12:00:00+01:00"}),
440 json!({"action":"reschedule", "expected_version":1, "scheduled_for":"9999-09-06T12:00:00Z"}),
441 json!({"action":"publish", "expected_version":1}),
442 ] {
443 assert!(
444 serde_json::from_value::<ChangeReleaseRequest>(value.clone()).is_err(),
445 "{value}"
446 );
447 }
448 }
449
450 #[test]
451 fn preview_digest_is_a_strict_typed_string_contract() {
452 let digest = PreviewDigest::parse(PREVIEW_DIGEST).unwrap();
453 assert_eq!(digest.as_str(), PREVIEW_DIGEST);
454 assert_eq!(digest.to_string(), PREVIEW_DIGEST);
455 assert_eq!(
456 serde_json::to_value(&digest).unwrap(),
457 json!(PREVIEW_DIGEST)
458 );
459 assert_eq!(
460 serde_json::from_value::<PreviewDigest>(json!(PREVIEW_DIGEST)).unwrap(),
461 digest
462 );
463
464 for malformed in [
465 &PREVIEW_DIGEST[1..],
466 &PREVIEW_DIGEST[..PREVIEW_DIGEST.len() - 1],
467 "preview-b3-v1-123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef0",
468 "preview-b3-v2-123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0",
469 ] {
470 assert!(PreviewDigest::parse(malformed).is_err(), "{malformed}");
471 assert!(serde_json::from_value::<PreviewDigest>(json!(malformed)).is_err());
472 }
473 }
474
475 #[test]
476 fn publish_now_request_has_a_stable_bidirectional_wire_contract() {
477 let request = PublishNowRequest {
478 post_id: Uuid::from_u128(0x123e_4567_e89b_12d3_a456_4266_1417_4000),
479 preview_digest: PreviewDigest::parse(PREVIEW_DIGEST).unwrap(),
480 expected_revision: Some(REVISION.into()),
481 scheduled_for: None,
482 };
483
484 let value = serde_json::to_value(&request).unwrap();
485 assert_eq!(
486 value,
487 json!({
488 "post_id": "123e4567-e89b-12d3-a456-426614174000",
489 "preview_digest": PREVIEW_DIGEST,
490 "expected_revision": REVISION
491 })
492 );
493 assert_eq!(
494 serde_json::from_value::<PublishNowRequest>(value).unwrap(),
495 request
496 );
497 }
498
499 #[test]
500 fn scheduled_request_has_a_stable_utc_wire_contract() {
501 let scheduled_for = OffsetDateTime::from_unix_timestamp(86_400).unwrap();
502 let request = PublishNowRequest {
503 post_id: Uuid::from_u128(0x123e_4567_e89b_12d3_a456_4266_1417_4000),
504 preview_digest: PreviewDigest::parse(PREVIEW_DIGEST).unwrap(),
505 expected_revision: Some(REVISION.into()),
506 scheduled_for: Some(scheduled_for),
507 };
508
509 let value = serde_json::to_value(&request).unwrap();
510 assert_eq!(
511 value,
512 json!({
513 "post_id": "123e4567-e89b-12d3-a456-426614174000",
514 "preview_digest": PREVIEW_DIGEST,
515 "expected_revision": REVISION,
516 "scheduled_for": "1970-01-02T00:00:00Z"
517 })
518 );
519 assert_eq!(
520 serde_json::from_value::<PublishNowRequest>(value).unwrap(),
521 request
522 );
523 }
524
525 #[test]
526 fn request_rejects_a_non_utc_or_malformed_schedule() {
527 for scheduled_for in [json!("1970-01-02T01:00:00+01:00"), json!("not-a-timestamp")] {
528 let value = json!({
529 "post_id": "123e4567-e89b-12d3-a456-426614174000",
530 "preview_digest": PREVIEW_DIGEST,
531 "expected_revision": REVISION,
532 "scheduled_for": scheduled_for
533 });
534
535 assert!(serde_json::from_value::<PublishNowRequest>(value).is_err());
536 }
537 }
538
539 #[test]
540 fn request_revision_remains_server_validated() {
541 let request: PublishNowRequest = serde_json::from_value(json!({
542 "post_id": "123e4567-e89b-12d3-a456-426614174000",
543 "preview_digest": PREVIEW_DIGEST,
544 "expected_revision": "let-the-server-return-its-typed-error"
545 }))
546 .unwrap();
547
548 assert_eq!(
549 request.expected_revision.as_deref(),
550 Some("let-the-server-return-its-typed-error")
551 );
552 }
553
554 #[test]
555 fn request_rejects_unknown_fields_that_could_drop_a_precondition() {
556 let error = serde_json::from_value::<PublishNowRequest>(json!({
557 "post_id": "123e4567-e89b-12d3-a456-426614174000",
558 "preview_digest": PREVIEW_DIGEST,
559 "expected_revison": REVISION
560 }))
561 .unwrap_err();
562
563 assert!(error.to_string().contains("expected_revison"));
564 }
565
566 #[test]
567 fn request_requires_the_reviewed_preview_digest() {
568 let error = serde_json::from_value::<PublishNowRequest>(json!({
569 "post_id": "123e4567-e89b-12d3-a456-426614174000",
570 "expected_revision": REVISION
571 }))
572 .unwrap_err();
573
574 assert!(error.to_string().contains("preview_digest"));
575 }
576
577 #[test]
578 fn publish_now_response_has_a_stable_bidirectional_wire_contract() {
579 let response = PublishNowResponse {
580 publication_id: Uuid::from_u128(0x018f_2046_49b2_7c2a_9226_f81c_87ab_721d),
581 post_id: Uuid::from_u128(0x123e_4567_e89b_12d3_a456_4266_1417_4000),
582 preview_digest: PreviewDigest::parse(PREVIEW_DIGEST).unwrap(),
583 revision: REVISION.into(),
584 state: PublicationApprovalState::Published,
585 scheduled_for: None,
586 published_at: Some(OffsetDateTime::UNIX_EPOCH),
587 site_digest: SITE_DIGEST.into(),
588 site_version: 7,
589 };
590
591 let value = serde_json::to_value(&response).unwrap();
592 assert_eq!(value, published_response_value());
593 assert_eq!(
594 serde_json::from_value::<PublishNowResponse>(value).unwrap(),
595 response
596 );
597 }
598
599 #[test]
600 fn scheduled_response_exposes_the_pinned_revision_state_and_time() {
601 let response = PublishNowResponse {
602 publication_id: Uuid::from_u128(0x018f_2046_49b2_7c2a_9226_f81c_87ab_721d),
603 post_id: Uuid::from_u128(0x123e_4567_e89b_12d3_a456_4266_1417_4000),
604 preview_digest: PreviewDigest::parse(PREVIEW_DIGEST).unwrap(),
605 revision: REVISION.into(),
606 state: PublicationApprovalState::Scheduled,
607 scheduled_for: Some(OffsetDateTime::from_unix_timestamp(86_400).unwrap()),
608 published_at: None,
609 site_digest: SITE_DIGEST.into(),
610 site_version: 7,
611 };
612
613 let value = serde_json::to_value(&response).unwrap();
614 assert_eq!(value, scheduled_response_value());
615 assert_eq!(
616 serde_json::from_value::<PublishNowResponse>(value).unwrap(),
617 response
618 );
619 }
620
621 #[test]
622 fn approval_states_have_stable_wire_names() {
623 for (state, name) in [
624 (PublicationApprovalState::Scheduled, "scheduled"),
625 (PublicationApprovalState::Published, "published"),
626 ] {
627 assert_eq!(serde_json::to_value(state).unwrap(), json!(name));
628 assert_eq!(
629 serde_json::from_value::<PublicationApprovalState>(json!(name)).unwrap(),
630 state
631 );
632 }
633
634 assert!(serde_json::from_value::<PublicationApprovalState>(json!("unknown")).is_err());
635 }
636
637 #[test]
638 fn publish_now_response_rejects_malformed_success_fields() {
639 let cases = [
640 (
641 "preview_digest",
642 json!(PREVIEW_DIGEST.replacen("1234", "ABCD", 1)),
643 ),
644 ("revision", json!(&REVISION[..REVISION.len() - 1])),
645 ("revision", json!(REVISION.replacen("abcdef", "ABCDEF", 1))),
646 ("site_digest", json!(REVISION)),
647 ("scheduled_for", json!("1970-01-02T01:00:00+01:00")),
648 ("published_at", json!("1970-01-01T01:00:00+01:00")),
649 ("site_version", json!(0)),
650 ];
651
652 for (field, malformed) in cases {
653 let mut value = published_response_value();
654 value[field] = malformed;
655
656 let error = serde_json::from_value::<PublishNowResponse>(value).unwrap_err();
657 assert!(error.to_string().contains(field), "{field}: {error}");
658 }
659
660 let mut missing_state = published_response_value();
661 missing_state.as_object_mut().unwrap().remove("state");
662 let error = serde_json::from_value::<PublishNowResponse>(missing_state).unwrap_err();
663 assert!(error.to_string().contains("state"), "{error}");
664 }
665}