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

@@ -14,8 +14,9 @@ pub async fn execute(deps: &EnrichMovieDeps, cmd: EnrichMovieCommand) -> Result<
}
let Some(movie) = deps.movie_query.get_movie_by_id(&cmd.movie_id).await? else {
tracing::warn!(movie_id = %cmd.movie_id.value(), "enrich_movie: movie not found, storing profile only");
deps.movie_profile.upsert(&cmd.profile).await?;
// The movie row is written with the event, so a missing row means it was
// deleted. Do not store the profile: it is the "enrichment done" marker.
tracing::warn!(movie_id = %cmd.movie_id.value(), "enrich_movie: movie not found, skipping");
return Ok(());
};

View File

@@ -110,15 +110,24 @@ impl EventHandler for PosterSyncHandler {
movie_id,
external_metadata_id,
} => {
// The poster is stored under the movie id and the movie row is
// updated atomically with `PosterSynced`; a poster already keyed
// by this movie means an earlier delivery completed.
// The poster is stored under the movie id (the converter later
// appends `.webp`/`.avif`) and the movie row is updated
// atomically with `PosterSynced`; exactly that path on the movie
// means an earlier delivery completed.
let id_str = movie_id.value().to_string();
let canonical = [
id_str.clone(),
format!("{id_str}.webp"),
format!("{id_str}.avif"),
];
let already_synced = self
.movie_query
.get_movie_by_id(&MovieId::from_uuid(movie_id.value()))
.await?
.and_then(|m| m.poster_path().map(|p| p.value().contains(&id_str)))
.and_then(|m| {
m.poster_path()
.map(|p| canonical.iter().any(|k| k == p.value()))
})
.unwrap_or(false);
if already_synced {
return Ok(());

View File

@@ -24,7 +24,7 @@ use domain::{
FakePosterFetcher, InMemoryStore, NoopEventPublisher, NoopObjectStorage, NoopPersonCommand,
test_movie_with_external_id,
},
value_objects::{ExternalMetadataId, MovieId, PosterUrl},
value_objects::{ExternalMetadataId, MovieId, PosterPath, PosterUrl},
};
use application::movies::{
@@ -239,6 +239,32 @@ impl domain::ports::ImageFetcher for NoImages {
}
}
/// Returns the key it was given, like the real adapter.
struct KeyStorage;
#[async_trait]
impl domain::ports::ObjectStorage for KeyStorage {
async fn store(&self, key: &str, _: &[u8]) -> Result<String, DomainError> {
Ok(key.to_string())
}
async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> {
Ok(vec![])
}
async fn get_stream(
&self,
_: &str,
) -> Result<futures::stream::BoxStream<'static, Result<bytes::Bytes, DomainError>>, DomainError>
{
Ok(Box::pin(futures::stream::empty()))
}
async fn delete(&self, _: &str) -> Result<(), DomainError> {
Ok(())
}
async fn exists(&self, _: &str) -> Result<bool, DomainError> {
Ok(true)
}
}
struct MetadataWithPoster;
#[async_trait]
@@ -409,7 +435,7 @@ async fn movie_discovery_indexer_is_idempotent_when_run_twice() {
#[tokio::test]
async fn search_reindex_is_idempotent_when_run_twice() {
let store = InMemoryStore::new();
seeded_movie(&store).await;
let (movie, _) = seeded_movie(&store).await;
let persons = FakePersons::new(person(3, "Someone"));
let search = RecordingSearch::new();
let handler = SearchReindexHandler::new(ReindexSearchDeps {
@@ -430,7 +456,11 @@ async fn search_reindex_is_idempotent_when_run_twice() {
.await
.unwrap();
assert!(!after_first.is_empty());
assert!(
after_first.contains(&("movie".to_string(), movie.id().value().to_string())),
"the movie doc is indexed"
);
assert!(after_first.iter().any(|(k, _)| k == "person"));
assert_eq!(search.snapshot(), after_first);
}
@@ -446,7 +476,7 @@ async fn poster_sync_is_idempotent_when_run_twice() {
store.clone(),
Arc::new(MetadataWithPoster),
Arc::new(FakePosterFetcher),
Arc::new(NoopObjectStorage),
Arc::new(KeyStorage),
publisher.clone(),
0,
);
@@ -466,3 +496,54 @@ async fn poster_sync_is_idempotent_when_run_twice() {
assert_eq!(synced, 1, "PosterSynced fans out to federation: emit once");
assert_eq!(publisher.published().len(), 1, "one ImageStored, not two");
}
#[tokio::test]
async fn poster_sync_skips_converted_poster_keys_but_not_lookalike_paths() {
let store = InMemoryStore::new();
let (mut movie, ext) = seeded_movie(&store).await;
let id = movie.id().value().to_string();
let publisher = NoopEventPublisher::new();
let handler = PosterSyncHandler::new(
store.clone(),
store.clone(),
Arc::new(MetadataWithPoster),
Arc::new(FakePosterFetcher),
Arc::new(KeyStorage),
publisher.clone(),
0,
);
let event = DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext,
};
// The converter's output is the canonical synced key: skipped.
movie.update_poster(PosterPath::new(format!("{id}.webp")).unwrap());
store.upsert_movie(&movie, &[]).await.unwrap();
handler.handle(&event).await.unwrap();
assert!(publisher.published().is_empty());
// A foreign path that merely contains the id is not a synced poster.
movie.update_poster(PosterPath::new(format!("https://cdn.example/{id}/p.jpg")).unwrap());
store.upsert_movie(&movie, &[]).await.unwrap();
handler.handle(&event).await.unwrap();
assert_eq!(publisher.published().len(), 1);
}
#[tokio::test]
async fn enrichment_of_a_deleted_movie_stores_no_profile() {
let store = InMemoryStore::new();
let ghost = MovieId::from_uuid(uuid::Uuid::new_v4());
let search = RecordingSearch::new();
let client = Arc::new(CountingMovieClient(AtomicUsize::new(0)));
let handler = enrichment_handler(&store, client, search.clone());
let event = DomainEvent::MovieEnrichmentRequested {
movie_id: ghost.clone(),
external_metadata_id: ExternalMetadataId::new("tmdb:1".into()).unwrap(),
};
handler.handle(&event).await.unwrap();
assert!(store.get_by_movie_id(&ghost).await.unwrap().is_none());
assert!(search.snapshot().is_empty());
}