fix: real AP id tests, exact poster key check, exists port, list_movies fake

This commit is contained in:
2026-10-26 01:33:29 +01:00
parent 4cb2972f63
commit 818ab893a1
13 changed files with 395 additions and 80 deletions

View File

@@ -18,3 +18,7 @@ async-trait = { workspace = true }
url = { version = "2", features = ["serde"] }
ammonia = "4"
[dev-dependencies]
tokio = { workspace = true }
domain = { workspace = true, features = ["test-helpers"] }

View File

@@ -38,34 +38,3 @@ pub fn watchlist_entry_url(
))
.expect("base_url is always a valid URL prefix")
}
#[cfg(test)]
mod tests {
use super::*;
/// k-ap derives the Create activity id from the object id (UUIDv5), so
/// re-handling an event only re-sends the same activity id if our object
/// ids are pure functions of the entity. Update/Delete/Move ids are minted
/// randomly inside k-ap 0.5 with no override (see ADR-0015): those repeat
/// semantically idempotent activities under a fresh id.
#[test]
fn activitypub_object_ids_are_stable_across_event_redelivery() {
let instance = InstanceIdentity::new("https://diary.example");
let review = ReviewId::from_uuid(uuid::Uuid::new_v4());
let user = uuid::Uuid::new_v4();
let movie = uuid::Uuid::new_v4();
assert_eq!(
review_url(&instance, &review),
review_url(&instance, &review)
);
assert_eq!(
goal_url(&instance, user, 2026),
goal_url(&instance, user, 2026)
);
assert_eq!(
watchlist_entry_url(&instance, user, movie),
watchlist_entry_url(&instance, user, movie)
);
}
}

View File

@@ -0,0 +1,193 @@
//! Outbound activity ids across a redelivered event, through the real k-ap
//! `ActivityPubService` (delivery captured via its `EventPublisher` seam).
//!
//! k-ap 0.5.0 derives Create ids from the object id (UUIDv5,
//! `service/broadcast.rs:285-288`) but mints Update/Delete/Move/actor-update ids
//! randomly (`broadcast.rs:81,129,157,210,321,343,377` -> `url_scheme.rs:53`,
//! `Uuid::new_v4`) with no override.
use std::sync::Arc;
use std::time::Duration;
use activitypub::ActivityPubEventHandler;
use async_trait::async_trait;
use chrono::NaiveDate;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{DiaryEntry, WatchlistWithMovie},
ports::{EventHandler, LocalApContentQuery, MovieCommand},
testing::{
InMemoryStore, PanicGoalQuery, PanicReviewRepository, PanicStatsRepository, test_movie,
},
value_objects::{InstanceIdentity, MovieId, UserId},
};
use k_ap::{
ActivityPubService, ApUser, FederationEvent,
testing::{MockActorRepoBuilder, MockFollowRepoBuilder, MockUserRepoBuilder},
};
use tokio::sync::mpsc;
struct NoContent;
#[async_trait]
impl LocalApContentQuery for NoContent {
async fn get_local_watchlist_for_user(
&self,
_: &UserId,
) -> Result<Vec<WatchlistWithMovie>, DomainError> {
Ok(vec![])
}
async fn get_local_reviews_for_movie(
&self,
_: &MovieId,
) -> Result<Vec<DiaryEntry>, DomainError> {
Ok(vec![])
}
async fn get_local_reviews_page(
&self,
_: &UserId,
_: Option<chrono::NaiveDateTime>,
_: usize,
) -> Result<Vec<DiaryEntry>, DomainError> {
Ok(vec![])
}
}
struct Capture(mpsc::UnboundedSender<serde_json::Value>);
#[async_trait]
impl k_ap::EventPublisher for Capture {
async fn publish(&self, event: FederationEvent) -> anyhow::Result<()> {
if let FederationEvent::DeliveryRequested { activity, .. } = event {
let _ = self.0.send(activity);
}
Ok(())
}
}
struct Harness {
handler: ActivityPubEventHandler,
delivered: mpsc::UnboundedReceiver<serde_json::Value>,
user: UserId,
movie_id: MovieId,
}
async fn harness() -> Harness {
let user = UserId::from_uuid(uuid::Uuid::new_v4());
let uid = user.value();
let (tx, rx) = mpsc::unbounded_channel();
let service = ActivityPubService::builder("https://diary.example")
.activity_repo(k_ap::testing::MockActivityRepoBuilder::new().build())
.follow_repo(
MockFollowRepoBuilder::new()
.on_get_accepted_follower_inboxes(|_| {
Ok(vec!["https://remote.example/inbox".to_string()])
})
.build(),
)
.actor_repo(MockActorRepoBuilder::new().build())
.blocklist_repo(k_ap::testing::MockBlocklistRepoBuilder::new().build())
.user_repo(
MockUserRepoBuilder::new()
.on_find_by_id(move |id| {
Ok((id == uid).then(|| ApUser {
id,
username: "alice".into(),
display_name: None,
bio: None,
avatar_url: None,
banner_url: None,
also_known_as: vec![],
profile_url: None,
attachment: vec![],
manually_approves_followers: false,
discoverable: true,
actor_type: Default::default(),
featured_url: None,
}))
})
.build(),
)
.content_reader(k_ap::testing::MockContentReaderBuilder::new().build())
.object_handler(k_ap::testing::MockObjectHandlerBuilder::new().build())
.event_publisher(Arc::new(Capture(tx)))
.build()
.await
.expect("service builds");
let store = InMemoryStore::new();
let movie = test_movie("Heat");
store.upsert_movie(&movie, &[]).await.unwrap();
let handler = ActivityPubEventHandler::new(
Arc::new(service),
Arc::new(NoContent),
Arc::new(PanicReviewRepository),
store.clone(),
Arc::new(PanicGoalQuery),
Arc::new(PanicStatsRepository),
store,
InstanceIdentity::new("https://diary.example"),
);
Harness {
handler,
delivered: rx,
user,
movie_id: movie.id().clone(),
}
}
impl Harness {
async fn next_activity_id(&mut self) -> String {
let activity = tokio::time::timeout(Duration::from_secs(5), self.delivered.recv())
.await
.expect("an activity was delivered")
.expect("channel open");
activity["id"]
.as_str()
.expect("activity has an id")
.to_string()
}
}
#[tokio::test]
async fn activitypub_is_idempotent_when_run_twice() {
let mut h = harness().await;
let event = DomainEvent::WatchlistEntryAdded {
user_id: h.user.clone(),
movie_id: h.movie_id.clone(),
movie_title: "Heat".into(),
release_year: 1995,
external_metadata_id: None,
added_at: NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
};
h.handler.handle(&event).await.unwrap();
let first = h.next_activity_id().await;
h.handler.handle(&event).await.unwrap();
let second = h.next_activity_id().await;
assert!(first.starts_with("https://diary.example/activities/create/"));
assert_eq!(first, second, "a redelivered Create keeps its activity id");
}
/// Characterises the k-ap 0.5.0 limitation recorded in ADR-0015. If k-ap starts
/// minting deterministic Delete ids this fails: upgrade the ADR and flip it.
#[tokio::test]
async fn redelivered_delete_gets_a_fresh_activity_id_known_k_ap_limitation() {
let mut h = harness().await;
let event = DomainEvent::WatchlistEntryRemoved {
user_id: h.user.clone(),
movie_id: h.movie_id.clone(),
};
h.handler.handle(&event).await.unwrap();
let first = h.next_activity_id().await;
h.handler.handle(&event).await.unwrap();
let second = h.next_activity_id().await;
assert_ne!(first, second);
}

View File

@@ -48,13 +48,9 @@ impl EventHandler for ImageConversionHandler {
// `get` falls back to `{key}.webp` / `{key}.avif`, which would re-encode
// an already converted image on redelivery. Probe the exact key first:
// once the original is gone, an earlier delivery finished the conversion.
match self.storage.get_stream(&key).await {
Ok(_) => {}
Err(DomainError::NotFound(_)) => {
tracing::debug!("original {key} already converted, skipping");
return Ok(());
}
Err(e) => return Err(e),
if !self.storage.exists(&key).await? {
tracing::warn!(%key, "original image already gone, treating conversion as done");
return Ok(());
}
let bytes = self.storage.get(&key).await?;
let format = self.format;

View File

@@ -82,6 +82,14 @@ impl ObjectStorage for ObjectStorageAdapter {
Ok(Box::pin(stream))
}
async fn exists(&self, key: &str) -> Result<bool, DomainError> {
match self.store.head(&Path::from(key)).await {
Ok(_) => Ok(true),
Err(object_store::Error::NotFound { .. }) => Ok(false),
Err(e) => Err(DomainError::InfrastructureError(e.to_string())),
}
}
async fn delete(&self, key: &str) -> Result<(), DomainError> {
let path = Path::from(key);
match self.store.delete(&path).await {