Skip to main content

maincopy_server/domain/publication/
mod.rs

1//! Publication models, persistence operations, and public HTTP handlers.
2
3pub(crate) mod activation;
4pub(crate) mod admin;
5pub(crate) mod assets;
6mod provenance;
7mod routes;
8pub(crate) mod scheduler;
9pub(crate) mod store;
10pub(crate) mod ui;
11mod visibility;
12pub(crate) mod web;
13
14pub use provenance::{SourceCommit, SourceCommitAlgorithm, SourceCommitParseError};
15pub use routes::CanonicalSiteUrl;
16pub(crate) use routes::MAX_PUBLIC_ROUTES;
17pub(crate) use routes::{PublicPagePath, ROBOTS_PATH, RSS_FEED_PATH, SITEMAP_PATH};
18pub use visibility::{PublicLedgerProjection, PublishedPostRevision};
19
20use std::marker::PhantomData;
21
22use markdown_compiler::{PostId, PostRevisionDigest};
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25use time::{OffsetDateTime, UtcOffset};
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum CanonicalState {
30    Scheduled,
31    Activating,
32    Blocked,
33    Published,
34    Superseded,
35    Cancelled,
36}
37
38#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum ActivationBlockReason {
41    RevisionUnavailable,
42    PreviewChanged,
43}
44
45macro_rules! state_markers {
46    ($(#[$meta:meta])* $module:ident { $($state:ident),+ $(,)? }) => {
47        $(#[$meta])*
48        pub mod $module {
49            $(
50                #[derive(Clone, Copy, Debug, Eq, PartialEq)]
51                pub struct $state;
52            )+
53        }
54    };
55}
56
57state_markers! {
58    /// Compile-time states for [`CanonicalPublication`].
59    canonical { Scheduled, Activating, Blocked, Published, Superseded, Cancelled }
60}
61
62/// A canonical publication whose legal operations are selected by `S`.
63///
64/// The state parameter prevents invalid transitions from compiling. For
65/// example, a scheduled publication cannot be committed as published:
66///
67/// ```compile_fail
68/// use maincopy_server::domain::publication::{CanonicalPublication, canonical};
69///
70/// fn publish_without_activation(publication: CanonicalPublication<canonical::Scheduled>) {
71///     let _ = publication.commit_published(1);
72/// }
73/// ```
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct CanonicalPublication<S = canonical::Scheduled> {
76    entity: CanonicalPublicationView,
77    marker: PhantomData<S>,
78}
79
80/// Owned, flat persistence and read representation of a canonical publication.
81///
82/// Its fields are deliberately public: callers that only need to inspect or
83/// persist an entity do not need a parallel getter API. Convert this view into
84/// [`CanonicalPublicationStatus`] before applying domain transitions.
85#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
86pub struct CanonicalPublicationView {
87    pub state: CanonicalState,
88    pub stable_post_id: PostId,
89    pub pinned_post_digest: PostRevisionDigest,
90    pub source_commit: Option<SourceCommit>,
91    #[serde(with = "time::serde::rfc3339")]
92    pub scheduled_at: OffsetDateTime,
93    #[serde(with = "time::serde::rfc3339::option")]
94    pub activation_started_at: Option<OffsetDateTime>,
95    #[serde(with = "time::serde::rfc3339::option")]
96    pub published_at: Option<OffsetDateTime>,
97    pub current_published_digest: Option<PostRevisionDigest>,
98    pub block_reason: Option<ActivationBlockReason>,
99    pub version: u64,
100}
101
102/// A validated canonical publication restored into its compile-time state.
103#[derive(Clone, Debug, Eq, PartialEq)]
104pub enum CanonicalPublicationStatus {
105    Scheduled(CanonicalPublication<canonical::Scheduled>),
106    Activating(CanonicalPublication<canonical::Activating>),
107    Blocked(CanonicalPublication<canonical::Blocked>),
108    Published(CanonicalPublication<canonical::Published>),
109    Superseded(CanonicalPublication<canonical::Superseded>),
110    Cancelled(CanonicalPublication<canonical::Cancelled>),
111}
112
113impl TryFrom<CanonicalPublicationView> for CanonicalPublicationStatus {
114    type Error = RehydrationError;
115
116    fn try_from(mut view: CanonicalPublicationView) -> Result<Self, Self::Error> {
117        view.validate()?;
118        view.normalize_timestamps();
119        let state = view.state;
120        Ok(match state {
121            CanonicalState::Scheduled => Self::Scheduled(canonical_from_view(view)),
122            CanonicalState::Activating => Self::Activating(canonical_from_view(view)),
123            CanonicalState::Blocked => Self::Blocked(canonical_from_view(view)),
124            CanonicalState::Published => Self::Published(canonical_from_view(view)),
125            CanonicalState::Superseded => Self::Superseded(canonical_from_view(view)),
126            CanonicalState::Cancelled => Self::Cancelled(canonical_from_view(view)),
127        })
128    }
129}
130
131fn canonical_from_view<S>(entity: CanonicalPublicationView) -> CanonicalPublication<S> {
132    CanonicalPublication {
133        entity,
134        marker: PhantomData,
135    }
136}
137
138impl CanonicalPublicationView {
139    fn validate(&self) -> Result<(), RehydrationError> {
140        let minimum_version = match self.state {
141            CanonicalState::Scheduled => 1,
142            CanonicalState::Activating => 2,
143            CanonicalState::Blocked | CanonicalState::Published => 3,
144            CanonicalState::Superseded => 4,
145            CanonicalState::Cancelled => 2,
146        };
147        if self.version < minimum_version {
148            return Err(RehydrationError::CanonicalVersion {
149                state: self.state,
150                version: self.version,
151                minimum: minimum_version,
152            });
153        }
154
155        let fields_are_valid = match (
156            self.activation_started_at,
157            self.published_at,
158            self.current_published_digest.as_ref(),
159            self.block_reason,
160        ) {
161            (None, None, None, None) => {
162                matches!(
163                    self.state,
164                    CanonicalState::Scheduled | CanonicalState::Cancelled
165                )
166            }
167            (Some(_), None, None, None) => self.state == CanonicalState::Activating,
168            (Some(_), None, None, Some(_)) => {
169                matches!(
170                    self.state,
171                    CanonicalState::Blocked | CanonicalState::Cancelled
172                )
173            }
174            (Some(started), Some(published), Some(_), None) => {
175                matches!(
176                    self.state,
177                    CanonicalState::Published | CanonicalState::Superseded
178                ) && started == published
179            }
180            _ => false,
181        };
182        fields_are_valid
183            .then_some(())
184            .ok_or(RehydrationError::CanonicalFields { state: self.state })
185    }
186
187    fn normalize_timestamps(&mut self) {
188        self.scheduled_at = self.scheduled_at.to_offset(UtcOffset::UTC);
189        self.activation_started_at = self
190            .activation_started_at
191            .map(|timestamp| timestamp.to_offset(UtcOffset::UTC));
192        self.published_at = self
193            .published_at
194            .map(|timestamp| timestamp.to_offset(UtcOffset::UTC));
195    }
196}
197
198impl CanonicalPublication<canonical::Scheduled> {
199    /// Changes the activation time without replacing the approved revision.
200    pub(crate) fn reschedule(
201        self,
202        expected_version: u64,
203        scheduled_at: OffsetDateTime,
204    ) -> Result<Self, TransitionFailure<Self>> {
205        let mut publication = self.require_version(expected_version)?;
206        publication.entity.scheduled_at = scheduled_at.to_offset(UtcOffset::UTC);
207        Ok(publication.transition(CanonicalState::Scheduled))
208    }
209
210    pub fn schedule(
211        stable_post_id: PostId,
212        pinned_post_digest: PostRevisionDigest,
213        source_commit: Option<SourceCommit>,
214        scheduled_at: OffsetDateTime,
215    ) -> Self {
216        Self {
217            entity: CanonicalPublicationView {
218                state: CanonicalState::Scheduled,
219                stable_post_id,
220                pinned_post_digest,
221                source_commit,
222                scheduled_at: scheduled_at.to_offset(UtcOffset::UTC),
223                activation_started_at: None,
224                published_at: None,
225                current_published_digest: None,
226                block_reason: None,
227                version: 1,
228            },
229            marker: PhantomData,
230        }
231    }
232
233    pub fn begin_activation(
234        self,
235        expected_version: u64,
236        now: OffsetDateTime,
237    ) -> Result<CanonicalPublication<canonical::Activating>, TransitionFailure<Self>> {
238        let publication = self.require_version(expected_version)?;
239        if now < publication.entity.scheduled_at {
240            let error = TransitionError::CanonicalNotDue {
241                scheduled_at: publication.entity.scheduled_at,
242                now,
243            };
244            return Err(TransitionFailure::new(publication, error));
245        }
246        Ok(publication.activate(now))
247    }
248
249    pub fn begin_activation_now(
250        self,
251        expected_version: u64,
252        now: OffsetDateTime,
253    ) -> Result<CanonicalPublication<canonical::Activating>, TransitionFailure<Self>> {
254        let publication = self.require_version(expected_version)?;
255        Ok(publication.activate(now))
256    }
257
258    fn activate(mut self, now: OffsetDateTime) -> CanonicalPublication<canonical::Activating> {
259        self.entity.activation_started_at = Some(now.to_offset(UtcOffset::UTC));
260        self.transition(CanonicalState::Activating)
261    }
262}
263
264impl CanonicalPublication<canonical::Activating> {
265    pub fn block_activation(
266        self,
267        expected_version: u64,
268        reason: ActivationBlockReason,
269    ) -> Result<CanonicalPublication<canonical::Blocked>, TransitionFailure<Self>> {
270        let mut publication = self.require_version(expected_version)?;
271        publication.entity.block_reason = Some(reason);
272        Ok(publication.transition(CanonicalState::Blocked))
273    }
274
275    /// Call only after the pinned revision is visible in the public snapshot.
276    pub fn commit_published(
277        self,
278        expected_version: u64,
279    ) -> Result<CanonicalPublication<canonical::Published>, TransitionFailure<Self>> {
280        let mut publication = self.require_version(expected_version)?;
281        let activation_started_at = publication
282            .entity
283            .activation_started_at
284            .expect("activating publications always have an activation timestamp");
285        publication.entity.published_at = Some(activation_started_at);
286        publication.entity.current_published_digest =
287            Some(publication.entity.pinned_post_digest.clone());
288        publication.entity.block_reason = None;
289        Ok(publication.transition(CanonicalState::Published))
290    }
291}
292
293impl CanonicalPublication<canonical::Published> {
294    /// Records that a later approved release replaced this public revision.
295    pub fn supersede(
296        self,
297        expected_version: u64,
298    ) -> Result<CanonicalPublication<canonical::Superseded>, TransitionFailure<Self>> {
299        let publication = self.require_version(expected_version)?;
300        Ok(publication.transition(CanonicalState::Superseded))
301    }
302}
303
304impl CanonicalPublication<canonical::Blocked> {
305    pub fn retry_blocked(
306        self,
307        expected_version: u64,
308        now: OffsetDateTime,
309    ) -> Result<CanonicalPublication<canonical::Activating>, TransitionFailure<Self>> {
310        let mut publication = self.require_version(expected_version)?;
311        publication.entity.activation_started_at = Some(now.to_offset(UtcOffset::UTC));
312        publication.entity.block_reason = None;
313        Ok(publication.transition(CanonicalState::Activating))
314    }
315}
316
317impl<S> CanonicalPublication<S> {
318    pub const fn view(&self) -> &CanonicalPublicationView {
319        &self.entity
320    }
321
322    pub fn into_view(self) -> CanonicalPublicationView {
323        self.entity
324    }
325
326    fn require_version(self, expected: u64) -> Result<Self, TransitionFailure<Self>> {
327        if self.entity.version == expected {
328            Ok(self)
329        } else {
330            let actual = self.entity.version;
331            Err(TransitionFailure::new(
332                self,
333                TransitionError::VersionConflict { expected, actual },
334            ))
335        }
336    }
337
338    fn transition<T>(mut self, state: CanonicalState) -> CanonicalPublication<T> {
339        self.entity.state = state;
340        self.entity.version += 1;
341        CanonicalPublication {
342            entity: self.entity,
343            marker: PhantomData,
344        }
345    }
346}
347
348macro_rules! cancellable_publications {
349    ($($state:ty),+ $(,)?) => {
350        $(
351            impl CanonicalPublication<$state> {
352                pub fn cancel(
353                    self,
354                    expected_version: u64,
355                ) -> Result<CanonicalPublication<canonical::Cancelled>, TransitionFailure<Self>> {
356                    let publication = self.require_version(expected_version)?;
357                    Ok(publication.transition(CanonicalState::Cancelled))
358                }
359            }
360        )+
361    };
362}
363
364cancellable_publications!(canonical::Scheduled, canonical::Blocked);
365
366/// A failed transition together with the unchanged machine that was supplied.
367#[derive(Clone, Debug, Eq, PartialEq, Error)]
368#[error("{error}")]
369pub struct TransitionFailure<M> {
370    pub machine: Box<M>,
371    #[source]
372    pub error: TransitionError,
373}
374
375impl<M> TransitionFailure<M> {
376    fn new(machine: M, error: TransitionError) -> Self {
377        Self {
378            machine: Box::new(machine),
379            error,
380        }
381    }
382}
383
384#[derive(Clone, Copy, Debug, Eq, PartialEq, Error)]
385pub enum RehydrationError {
386    #[error(
387        "canonical publication {state:?} requires version {minimum} or later, received {version}"
388    )]
389    CanonicalVersion {
390        state: CanonicalState,
391        version: u64,
392        minimum: u64,
393    },
394    #[error("canonical publication fields are inconsistent with {state:?} state")]
395    CanonicalFields { state: CanonicalState },
396}
397
398#[derive(Clone, Copy, Debug, Eq, PartialEq, Error)]
399pub enum TransitionError {
400    #[error("resource version conflict: expected {expected}, actual {actual}")]
401    VersionConflict { expected: u64, actual: u64 },
402    #[error("canonical publication is not due until {scheduled_at:?}; current time is {now:?}")]
403    CanonicalNotDue {
404        scheduled_at: OffsetDateTime,
405        now: OffsetDateTime,
406    },
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    const POST_A: &str = "11111111-1111-4111-8111-111111111111";
414    const DIGEST_A: &str =
415        "post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111";
416    const DIGEST_B: &str =
417        "post-b3-v1-2222222222222222222222222222222222222222222222222222222222222222";
418    const SOURCE_COMMIT_A: &str = "git-sha1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
419
420    fn at(seconds: i64) -> OffsetDateTime {
421        OffsetDateTime::from_unix_timestamp(seconds).unwrap()
422    }
423    fn post_id(value: &str) -> PostId {
424        PostId::parse(value).unwrap()
425    }
426    fn digest(value: &str) -> PostRevisionDigest {
427        PostRevisionDigest::parse(value).unwrap()
428    }
429    fn scheduled_publication(
430        scheduled_at: OffsetDateTime,
431    ) -> CanonicalPublication<canonical::Scheduled> {
432        CanonicalPublication::schedule(post_id(POST_A), digest(DIGEST_A), None, scheduled_at)
433    }
434    fn published_publication(
435        scheduled_at: OffsetDateTime,
436    ) -> CanonicalPublication<canonical::Published> {
437        scheduled_publication(scheduled_at)
438            .begin_activation(1, scheduled_at)
439            .unwrap()
440            .commit_published(2)
441            .unwrap()
442    }
443    fn restore_canonical(view: CanonicalPublicationView) -> CanonicalPublicationStatus {
444        let encoded = serde_json::to_string(&view).unwrap();
445        let decoded: CanonicalPublicationView = serde_json::from_str(&encoded).unwrap();
446        CanonicalPublicationStatus::try_from(decoded).unwrap()
447    }
448
449    #[test]
450    fn canonical_success_path_assigns_publication_time() {
451        let publication = CanonicalPublication::schedule(
452            post_id(POST_A),
453            digest(DIGEST_A),
454            Some(SourceCommit::parse(SOURCE_COMMIT_A).unwrap()),
455            at(10),
456        )
457        .begin_activation(1, at(11))
458        .unwrap()
459        .commit_published(2)
460        .unwrap();
461        let view = publication.view();
462
463        assert_eq!(view.state, CanonicalState::Published);
464        assert_eq!(view.published_at, Some(at(11)));
465        assert_eq!(
466            view.current_published_digest
467                .as_ref()
468                .map(PostRevisionDigest::as_str),
469            Some(DIGEST_A)
470        );
471        assert_eq!(view.version, 3);
472
473        fn requires_published(_: &CanonicalPublication<canonical::Published>) {}
474        requires_published(&publication);
475    }
476
477    #[test]
478    fn scheduled_activation_waits_until_due_but_publish_now_does_not() {
479        let scheduled = scheduled_publication(at(20));
480        let failure = scheduled.begin_activation(1, at(19)).unwrap_err();
481        assert_eq!(
482            failure.error,
483            TransitionError::CanonicalNotDue {
484                scheduled_at: at(20),
485                now: at(19),
486            }
487        );
488
489        let activating = (*failure.machine).begin_activation_now(1, at(19)).unwrap();
490        assert_eq!(activating.view().state, CanonicalState::Activating);
491        assert_eq!(activating.view().activation_started_at, Some(at(19)));
492    }
493
494    #[test]
495    fn blocked_retry_preserves_pinned_revision() {
496        let publication = scheduled_publication(at(10))
497            .begin_activation(1, at(10))
498            .unwrap()
499            .block_activation(2, ActivationBlockReason::RevisionUnavailable)
500            .unwrap();
501        assert_eq!(
502            publication.view().block_reason,
503            Some(ActivationBlockReason::RevisionUnavailable)
504        );
505        let publication = publication.retry_blocked(3, at(12)).unwrap();
506        let view = publication.view();
507        assert_eq!(view.state, CanonicalState::Activating);
508        assert_eq!(view.pinned_post_digest.as_str(), DIGEST_A);
509        assert_eq!(view.published_at, None);
510        assert_eq!(view.block_reason, None);
511        assert_eq!(view.activation_started_at, Some(at(12)));
512    }
513
514    #[test]
515    fn stale_version_is_rejected() {
516        let failure = scheduled_publication(at(10))
517            .begin_activation(2, at(10))
518            .unwrap_err();
519        assert_eq!(
520            failure.error,
521            TransitionError::VersionConflict {
522                expected: 2,
523                actual: 1
524            }
525        );
526        assert_eq!(failure.machine.view().version, 1);
527        assert_eq!(failure.machine.view().state, CanonicalState::Scheduled);
528    }
529
530    #[test]
531    fn state_names_are_stable() {
532        for (state, name) in [
533            (CanonicalState::Scheduled, "scheduled"),
534            (CanonicalState::Activating, "activating"),
535            (CanonicalState::Blocked, "blocked"),
536            (CanonicalState::Published, "published"),
537            (CanonicalState::Superseded, "superseded"),
538            (CanonicalState::Cancelled, "cancelled"),
539        ] {
540            assert_eq!(
541                serde_json::to_value(state).unwrap(),
542                serde_json::json!(name)
543            );
544        }
545        assert_eq!(
546            serde_json::to_value(ActivationBlockReason::RevisionUnavailable).unwrap(),
547            serde_json::json!("revision_unavailable")
548        );
549        assert_eq!(
550            serde_json::to_value(ActivationBlockReason::PreviewChanged).unwrap(),
551            serde_json::json!("preview_changed")
552        );
553    }
554
555    #[test]
556    fn canonical_view_preserves_the_exact_flat_json_contract() {
557        let publication = CanonicalPublication::schedule(
558            post_id(POST_A),
559            digest(DIGEST_A),
560            Some(SourceCommit::parse(SOURCE_COMMIT_A).unwrap()),
561            at(10),
562        );
563        assert_eq!(
564            serde_json::to_value(publication.view()).unwrap(),
565            serde_json::json!({
566                "state": "scheduled",
567                "stable_post_id": POST_A,
568                "pinned_post_digest": DIGEST_A,
569                "source_commit": SOURCE_COMMIT_A,
570                "scheduled_at": "1970-01-01T00:00:10Z",
571                "activation_started_at": null,
572                "published_at": null,
573                "current_published_digest": null,
574                "block_reason": null,
575                "version": 1,
576            })
577        );
578    }
579
580    #[test]
581    fn canonical_views_rehydrate_every_typed_state() {
582        let views = [
583            scheduled_publication(at(10)).into_view(),
584            scheduled_publication(at(10))
585                .begin_activation(1, at(10))
586                .unwrap()
587                .into_view(),
588            scheduled_publication(at(10))
589                .begin_activation(1, at(10))
590                .unwrap()
591                .block_activation(2, ActivationBlockReason::RevisionUnavailable)
592                .unwrap()
593                .into_view(),
594            published_publication(at(10)).into_view(),
595            published_publication(at(10))
596                .supersede(3)
597                .unwrap()
598                .into_view(),
599            scheduled_publication(at(10)).cancel(1).unwrap().into_view(),
600            scheduled_publication(at(10))
601                .begin_activation(1, at(10))
602                .unwrap()
603                .block_activation(2, ActivationBlockReason::RevisionUnavailable)
604                .unwrap()
605                .cancel(3)
606                .unwrap()
607                .into_view(),
608        ];
609
610        for expected in views {
611            let state = expected.state;
612            let actual = match restore_canonical(expected.clone()) {
613                CanonicalPublicationStatus::Scheduled(publication) => publication.into_view(),
614                CanonicalPublicationStatus::Activating(publication) => publication.into_view(),
615                CanonicalPublicationStatus::Blocked(publication) => publication.into_view(),
616                CanonicalPublicationStatus::Published(publication) => publication.into_view(),
617                CanonicalPublicationStatus::Superseded(publication) => publication.into_view(),
618                CanonicalPublicationStatus::Cancelled(publication) => publication.into_view(),
619            };
620            assert_eq!(actual.state, state);
621            assert_eq!(actual, expected);
622        }
623    }
624
625    #[test]
626    fn rehydration_rejects_unreachable_state_data() {
627        let mut canonical = scheduled_publication(at(10)).into_view();
628        canonical.state = CanonicalState::Published;
629        canonical.version = 3;
630        assert_eq!(
631            CanonicalPublicationStatus::try_from(canonical).unwrap_err(),
632            RehydrationError::CanonicalFields {
633                state: CanonicalState::Published
634            }
635        );
636
637        let mut canonical = scheduled_publication(at(10)).into_view();
638        canonical.published_at = Some(at(10));
639        assert_eq!(
640            CanonicalPublicationStatus::try_from(canonical).unwrap_err(),
641            RehydrationError::CanonicalFields {
642                state: CanonicalState::Scheduled
643            }
644        );
645
646        let mut reloaded = published_publication(at(10)).into_view();
647        reloaded.current_published_digest = Some(digest(DIGEST_B));
648        assert!(matches!(
649            CanonicalPublicationStatus::try_from(reloaded),
650            Ok(CanonicalPublicationStatus::Published(_))
651        ));
652
653        let mut canonical = scheduled_publication(at(10)).into_view();
654        canonical.version = 0;
655        assert_eq!(
656            CanonicalPublicationStatus::try_from(canonical).unwrap_err(),
657            RehydrationError::CanonicalVersion {
658                state: CanonicalState::Scheduled,
659                version: 0,
660                minimum: 1,
661            }
662        );
663    }
664
665    #[test]
666    fn stored_timestamps_are_normalized_to_utc() {
667        let east = UtcOffset::from_hms(2, 0, 0).unwrap();
668        let represented_with_offset = at(10).to_offset(east);
669        let publication = scheduled_publication(represented_with_offset);
670        assert_eq!(publication.view().scheduled_at.offset(), UtcOffset::UTC);
671
672        let publication = publication
673            .begin_activation(1, represented_with_offset)
674            .unwrap();
675        assert_eq!(
676            publication.view().activation_started_at.unwrap().offset(),
677            UtcOffset::UTC
678        );
679        let publication = publication.commit_published(2).unwrap();
680        assert_eq!(publication.view().published_at, Some(at(10)));
681
682        let mut persisted = scheduled_publication(at(10)).into_view();
683        persisted.scheduled_at = represented_with_offset;
684        let CanonicalPublicationStatus::Scheduled(restored) =
685            CanonicalPublicationStatus::try_from(persisted).unwrap()
686        else {
687            panic!("expected a scheduled publication");
688        };
689        assert_eq!(restored.view().scheduled_at.offset(), UtcOffset::UTC);
690    }
691
692    #[test]
693    fn all_cancellable_states_reach_typed_cancelled_states() {
694        let publication = scheduled_publication(at(10)).cancel(1).unwrap();
695        assert_eq!(publication.view().state, CanonicalState::Cancelled);
696        assert_eq!(publication.view().published_at, None);
697
698        let publication = scheduled_publication(at(10))
699            .begin_activation(1, at(10))
700            .unwrap()
701            .block_activation(2, ActivationBlockReason::RevisionUnavailable)
702            .unwrap()
703            .cancel(3)
704            .unwrap();
705        assert_eq!(publication.view().state, CanonicalState::Cancelled);
706    }
707}