feat: worker relays outbox to nats

This commit is contained in:
2026-10-26 00:06:09 +01:00
parent 32dfc84697
commit c06d3d9207
15 changed files with 678 additions and 102 deletions

View File

@@ -131,3 +131,194 @@ fn a_message_round_trips_its_event_id_and_decodes_without_one() {
let json = serde_json::to_string(&unit).unwrap();
assert_eq!(serde_json::from_str::<EventMessage>(&json).unwrap(), unit);
}
/// One sample per `DomainEvent` variant. `assert_covers_every_variant` is an
/// exhaustive match, so adding a variant breaks the build until it is added
/// here too.
fn every_event() -> Vec<DomainEvent> {
use domain::{
models::{ExternalPersonId, PersonId},
value_objects::{FollowTarget, GoalId, PosterPath, SocialIdentity, WrapUpId},
};
let user = || UserId::from_uuid(Uuid::new_v4());
let movie = || MovieId::from_uuid(Uuid::new_v4());
let local = || SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()));
let remote = || SocialIdentity::Remote {
actor_url: "https://remote.example/users/a".into(),
};
let date = |d| chrono::NaiveDate::from_ymd_opt(2026, 1, d).unwrap();
vec![
review_logged(),
review_updated(),
movie_discovered(),
DomainEvent::MovieDeleted {
movie_id: movie(),
poster_path: Some(PosterPath::new("p/x.jpg".into()).unwrap()),
},
DomainEvent::UserUpdated { user_id: user() },
DomainEvent::ReviewDeleted {
review_id: ReviewId::from_uuid(Uuid::new_v4()),
user_id: user(),
},
DomainEvent::MovieEnrichmentRequested {
movie_id: movie(),
external_metadata_id: ExternalMetadataId::new("tt7654321".into()).unwrap(),
},
DomainEvent::PersonEnrichmentRequested {
person_id: PersonId::from_uuid(Uuid::new_v4()),
external_person_id: ExternalPersonId::new("42"),
},
DomainEvent::ImageStored { key: "k".into() },
DomainEvent::WatchlistEntryAdded {
user_id: user(),
movie_id: movie(),
movie_title: "Heat".into(),
release_year: 1995,
external_metadata_id: Some("tt0113277".into()),
added_at: fixed_dt(),
},
DomainEvent::WatchlistEntryRemoved {
user_id: user(),
movie_id: movie(),
},
DomainEvent::FollowRequested {
follower: user(),
target: FollowTarget::Handle("a@b.example".into()),
},
DomainEvent::FollowAccepted {
owner: user(),
requester: remote(),
},
DomainEvent::FollowRejected {
owner: user(),
requester: local(),
},
DomainEvent::Unfollowed {
follower: user(),
target: remote(),
},
DomainEvent::FollowerRemoved {
owner: user(),
follower: local(),
},
DomainEvent::ActorBlocked {
blocker: user(),
target: remote(),
},
DomainEvent::ActorUnblocked {
blocker: user(),
target: remote(),
},
DomainEvent::BackfillFollower {
owner_user_id: user(),
follower_inbox_url: "https://remote.example/inbox".into(),
},
DomainEvent::FederationDeliveryRequested {
inbox_url: "https://remote.example/inbox".into(),
activity_json: serde_json::json!({"type": "Create"}),
signing_actor_id: Uuid::new_v4(),
},
DomainEvent::WatchEventIngested {
user_id: user(),
title: "Heat".into(),
source: "jellyfin".into(),
},
DomainEvent::WrapUpRequested {
wrapup_id: WrapUpId::from_uuid(Uuid::new_v4()),
user_id: Some(user()),
start_date: date(1),
end_date: date(2),
},
DomainEvent::WrapUpCompleted {
wrapup_id: WrapUpId::from_uuid(Uuid::new_v4()),
},
DomainEvent::SearchReindexRequested,
DomainEvent::PosterSynced { movie_id: movie() },
DomainEvent::GoalCreated {
goal_id: GoalId::from_uuid(Uuid::new_v4()),
user_id: user(),
year: 2026,
target_count: 50,
},
DomainEvent::GoalUpdated {
goal_id: GoalId::from_uuid(Uuid::new_v4()),
user_id: user(),
year: 2026,
target_count: 60,
},
DomainEvent::GoalDeleted {
goal_id: GoalId::from_uuid(Uuid::new_v4()),
user_id: user(),
year: 2026,
},
DomainEvent::UserDeleted { user_id: user() },
DomainEvent::UserAccountMoved {
user_id: user(),
new_actor_url: "https://new.example/users/a".into(),
},
]
}
fn assert_covers_every_variant(event: &DomainEvent) {
use DomainEvent::*;
match event {
ReviewLogged { .. }
| ReviewUpdated { .. }
| MovieDiscovered { .. }
| MovieDeleted { .. }
| UserUpdated { .. }
| ReviewDeleted { .. }
| MovieEnrichmentRequested { .. }
| PersonEnrichmentRequested { .. }
| ImageStored { .. }
| WatchlistEntryAdded { .. }
| WatchlistEntryRemoved { .. }
| FollowRequested { .. }
| FollowAccepted { .. }
| FollowRejected { .. }
| Unfollowed { .. }
| FollowerRemoved { .. }
| ActorBlocked { .. }
| ActorUnblocked { .. }
| BackfillFollower { .. }
| FederationDeliveryRequested { .. }
| WatchEventIngested { .. }
| WrapUpRequested { .. }
| WrapUpCompleted { .. }
| SearchReindexRequested
| PosterSynced { .. }
| GoalCreated { .. }
| GoalUpdated { .. }
| GoalDeleted { .. }
| UserDeleted { .. }
| UserAccountMoved { .. } => {}
}
}
#[test]
fn every_event_round_trips_through_an_event_message_with_and_without_an_id() {
use event_payload::EventMessage;
let events = every_event();
events.iter().for_each(assert_covers_every_variant);
let types: std::collections::HashSet<_> = events
.iter()
.map(|e| EventPayload::from(e).event_type())
.collect();
assert_eq!(types.len(), events.len(), "one sample per variant");
for event in &events {
for id in [Some(Uuid::new_v4().to_string()), None] {
let message = EventMessage {
event_id: id.clone(),
payload: EventPayload::from(event),
};
let json = serde_json::to_string(&message).unwrap();
let back: EventMessage = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("{} decode: {e}", message.payload.event_type()));
assert_eq!(back.event_id, id);
let recovered = DomainEvent::try_from(back.payload).unwrap();
assert_eq!(EventPayload::from(event), EventPayload::from(&recovered));
}
}
}