fix: real AP id tests, exact poster key check, exists port, list_movies fake
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
193
crates/adapters/activitypub/tests/activity_ids.rs
Normal file
193
crates/adapters/activitypub/tests/activity_ids.rs
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user