Skip to main content

maincopy_shared/
publication.rs

1//! Wire contracts for approving exact post revisions for publication.
2
3use std::{fmt, str::FromStr};
4
5use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
6use time::{OffsetDateTime, UtcOffset};
7use uuid::Uuid;
8
9/// Versioned path for approving a post revision for publication.
10pub const PUBLICATIONS_PATH: &str = "/api/admin/v1/publications";
11
12/// Header that identifies retries of the same publication command.
13pub const IDEMPOTENCY_KEY_HEADER: &str = "Idempotency-Key";
14
15/// Header carrying the exact rendered private-preview identity.
16pub const PREVIEW_DIGEST_HEADER: &str = "x-maincopy-preview-digest";
17
18/// Header carrying the post revision used to render a private preview.
19pub const POST_REVISION_HEADER: &str = "x-maincopy-post-revision";
20
21/// Header carrying the managed content-tree identity used by a private preview.
22pub 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/// Stable identity of one exact private post-preview representation.
28#[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    /// Parses one canonical versioned preview digest.
38    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/// Selects the exact post revision to publish immediately or at a scheduled time.
117#[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    /// Exact private preview reviewed by the operator.
123    pub preview_digest: PreviewDigest,
124    /// Exact revision precondition for the approval.
125    ///
126    /// A first publication can omit this precondition. Approval of an update
127    /// requires it.
128    pub expected_revision: Option<Box<str>>,
129    /// Requested publication time. Omission requests immediate publication.
130    #[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/// Durable state reached by a publication approval command.
140#[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/// Reports the exact pinned revision and durable approval state.
149#[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    /// Exact private preview accepted by this approval.
155    pub preview_digest: PreviewDigest,
156    /// Exact post revision pinned by this approval.
157    #[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    /// Whether the pinned revision is waiting for its time or is already public.
161    pub state: PublicationApprovalState,
162    /// Requested publication time when the approval was scheduled.
163    #[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    /// Actual canonical publication time, absent until a scheduled approval activates.
171    #[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
184/// Durable releases are independent of the currently synchronized candidate.
185pub 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/// Exact-version release controls. Idempotency-Key identifies the operation.
209#[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/// Current durable state and the exact identity approved for a release.
260#[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/// One bounded page, ordered by publication UUID.
279#[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/// Immutable accepted result, which can precede the release's current state.
287#[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}