From 763d6226011f60bc4f1b3a1b39b7ec9e33b64b81 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 12 May 2026 16:39:58 +0200 Subject: [PATCH] refactor: move inline tests to separate files via #[path] --- .../adapters/activitypub-base/src/actors.rs | 37 +- .../adapters/activitypub-base/src/nodeinfo.rs | 41 +- .../adapters/activitypub-base/src/service.rs | 44 +- .../activitypub-base/src/tests/actors.rs | 33 ++ .../activitypub-base/src/tests/nodeinfo.rs | 37 ++ .../activitypub-base/src/tests/service.rs | 40 ++ crates/adapters/activitypub/src/objects.rs | 45 +- .../adapters/activitypub/src/tests/objects.rs | 41 ++ crates/adapters/event-payload/src/lib.rs | 90 +-- .../adapters/event-payload/src/tests/lib.rs | 86 +++ crates/adapters/event-publisher/src/lib.rs | 58 +- .../adapters/event-publisher/src/tests/lib.rs | 54 ++ crates/adapters/export/src/lib.rs | 150 +---- crates/adapters/export/src/tests/lib.rs | 146 +++++ .../adapters/image-converter/src/backfill.rs | 93 +-- crates/adapters/image-converter/src/config.rs | 43 +- .../adapters/image-converter/src/handler.rs | 129 +---- .../image-converter/src/tests/backfill.rs | 89 +++ .../image-converter/src/tests/config.rs | 39 ++ .../image-converter/src/tests/handler.rs | 125 ++++ crates/adapters/image-storage/src/config.rs | 21 +- crates/adapters/image-storage/src/lib.rs | 59 +- .../image-storage/src/tests/config.rs | 17 + .../adapters/image-storage/src/tests/lib.rs | 55 ++ crates/adapters/importer/src/mapper.rs | 118 +--- crates/adapters/importer/src/parsers/mod.rs | 41 +- crates/adapters/importer/src/parsers/tests.rs | 37 ++ crates/adapters/importer/src/tests/mapper.rs | 114 ++++ crates/adapters/nats/src/config.rs | 62 +- crates/adapters/nats/src/subject.rs | 62 +- crates/adapters/nats/src/tests/config.rs | 58 ++ crates/adapters/nats/src/tests/subject.rs | 58 ++ crates/adapters/rss/src/lib.rs | 19 +- crates/adapters/rss/src/tests/lib.rs | 15 + crates/adapters/sqlite-federation/src/lib.rs | 195 +------ .../src/tests/actor_block_tests.rs | 33 ++ .../src/tests/domain_block_tests.rs | 37 ++ .../sqlite-federation/src/tests/lib.rs | 113 ++++ crates/adapters/sqlite/src/image_ref.rs | 107 +--- crates/adapters/sqlite/src/tests/image_ref.rs | 103 ++++ crates/adapters/sqlite/src/tests/users.rs | 91 +++ crates/adapters/sqlite/src/users.rs | 95 +-- crates/application/src/movie_resolver.rs | 347 +---------- .../application/src/tests/movie_resolver.rs | 343 +++++++++++ crates/application/src/tests/worker.rs | 169 ++++++ crates/application/src/worker.rs | 173 +----- crates/domain/src/models/mod.rs | 35 +- crates/domain/src/models/tests.rs | 31 + crates/presentation/src/extractors.rs | 512 +--------------- crates/presentation/src/forms.rs | 116 +--- crates/presentation/src/tests/extractors.rs | 508 ++++++++++++++++ crates/presentation/src/tests/forms.rs | 112 ++++ crates/tui/src/app.rs | 546 +----------------- crates/tui/src/client.rs | 69 +-- crates/tui/src/config.rs | 20 +- crates/tui/src/tests/app.rs | 542 +++++++++++++++++ crates/tui/src/tests/client.rs | 65 +++ crates/tui/src/tests/config.rs | 16 + 58 files changed, 3267 insertions(+), 3267 deletions(-) create mode 100644 crates/adapters/activitypub-base/src/tests/actors.rs create mode 100644 crates/adapters/activitypub-base/src/tests/nodeinfo.rs create mode 100644 crates/adapters/activitypub-base/src/tests/service.rs create mode 100644 crates/adapters/activitypub/src/tests/objects.rs create mode 100644 crates/adapters/event-payload/src/tests/lib.rs create mode 100644 crates/adapters/event-publisher/src/tests/lib.rs create mode 100644 crates/adapters/export/src/tests/lib.rs create mode 100644 crates/adapters/image-converter/src/tests/backfill.rs create mode 100644 crates/adapters/image-converter/src/tests/config.rs create mode 100644 crates/adapters/image-converter/src/tests/handler.rs create mode 100644 crates/adapters/image-storage/src/tests/config.rs create mode 100644 crates/adapters/image-storage/src/tests/lib.rs create mode 100644 crates/adapters/importer/src/parsers/tests.rs create mode 100644 crates/adapters/importer/src/tests/mapper.rs create mode 100644 crates/adapters/nats/src/tests/config.rs create mode 100644 crates/adapters/nats/src/tests/subject.rs create mode 100644 crates/adapters/rss/src/tests/lib.rs create mode 100644 crates/adapters/sqlite-federation/src/tests/actor_block_tests.rs create mode 100644 crates/adapters/sqlite-federation/src/tests/domain_block_tests.rs create mode 100644 crates/adapters/sqlite-federation/src/tests/lib.rs create mode 100644 crates/adapters/sqlite/src/tests/image_ref.rs create mode 100644 crates/adapters/sqlite/src/tests/users.rs create mode 100644 crates/application/src/tests/movie_resolver.rs create mode 100644 crates/application/src/tests/worker.rs create mode 100644 crates/domain/src/models/tests.rs create mode 100644 crates/presentation/src/tests/extractors.rs create mode 100644 crates/presentation/src/tests/forms.rs create mode 100644 crates/tui/src/tests/app.rs create mode 100644 crates/tui/src/tests/client.rs create mode 100644 crates/tui/src/tests/config.rs diff --git a/crates/adapters/activitypub-base/src/actors.rs b/crates/adapters/activitypub-base/src/actors.rs index 5289bd4..da53591 100644 --- a/crates/adapters/activitypub-base/src/actors.rs +++ b/crates/adapters/activitypub-base/src/actors.rs @@ -263,38 +263,5 @@ impl Actor for DbActor { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn person_serializes_with_enriched_fields() { - let person = Person { - kind: Default::default(), - id: "https://example.com/users/1".parse::().unwrap().into(), - preferred_username: "alice".to_string(), - inbox: "https://example.com/users/1/inbox".parse().unwrap(), - outbox: "https://example.com/users/1/outbox".parse().unwrap(), - followers: "https://example.com/users/1/followers".parse().unwrap(), - following: "https://example.com/users/1/following".parse().unwrap(), - public_key: PublicKey { - id: "https://example.com/users/1#main-key".to_string(), - owner: "https://example.com/users/1".parse().unwrap(), - public_key_pem: "pem".to_string(), - }, - name: Some("Alice".to_string()), - summary: Some("Bio text".to_string()), - icon: Some(ApImageObject { - kind: "Image".to_string(), - url: "https://example.com/images/avatars/1".parse().unwrap(), - }), - url: Some("https://example.com/u/alice".parse().unwrap()), - discoverable: Some(true), - manually_approves_followers: false, - }; - let json = serde_json::to_value(&person).unwrap(); - assert_eq!(json["discoverable"], true); - assert_eq!(json["summary"], "Bio text"); - assert_eq!(json["icon"]["type"], "Image"); - assert!(json.get("manuallyApprovesFollowers").is_some()); - } -} +#[path = "tests/actors.rs"] +mod tests; diff --git a/crates/adapters/activitypub-base/src/nodeinfo.rs b/crates/adapters/activitypub-base/src/nodeinfo.rs index 1154237..9c0f621 100644 --- a/crates/adapters/activitypub-base/src/nodeinfo.rs +++ b/crates/adapters/activitypub-base/src/nodeinfo.rs @@ -78,42 +78,5 @@ pub async fn nodeinfo_handler( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn nodeinfo_well_known_serializes_correctly() { - let doc = NodeInfoWellKnown { - links: vec![NodeInfoLink { - rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(), - href: "https://example.com/nodeinfo/2.0".to_string(), - }], - }; - let json = serde_json::to_value(&doc).unwrap(); - assert_eq!(json["links"][0]["rel"], "http://nodeinfo.diaspora.software/ns/schema/2.0"); - assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0"); - } - - #[test] - fn nodeinfo_serializes_camel_case() { - let doc = NodeInfo { - version: "2.0".to_string(), - software: NodeInfoSoftware { - name: "my-app".to_string(), - version: "0.1.0".to_string(), - }, - protocols: vec!["activitypub".to_string()], - usage: NodeInfoUsage { - users: NodeInfoUsers { total: 3 }, - local_posts: 42, - }, - open_registrations: false, - }; - let json = serde_json::to_value(&doc).unwrap(); - assert_eq!(json["version"], "2.0"); - assert_eq!(json["software"]["name"], "my-app"); - assert_eq!(json["usage"]["users"]["total"], 3); - assert_eq!(json["usage"]["localPosts"], 42); - assert_eq!(json["openRegistrations"], false); - } -} +#[path = "tests/nodeinfo.rs"] +mod tests; diff --git a/crates/adapters/activitypub-base/src/service.rs b/crates/adapters/activitypub-base/src/service.rs index d031a32..6e75acb 100644 --- a/crates/adapters/activitypub-base/src/service.rs +++ b/crates/adapters/activitypub-base/src/service.rs @@ -931,45 +931,5 @@ impl ActivityPubService { } #[cfg(test)] -mod tests { - use super::*; - use crate::repository::{Follower, FollowerStatus, RemoteActor}; - - fn make_follower(inbox: &str, shared: Option<&str>) -> Follower { - Follower { - actor: RemoteActor { - url: format!("https://remote/{}", inbox), - handle: "user".to_string(), - inbox_url: inbox.to_string(), - shared_inbox_url: shared.map(|s| s.to_string()), - display_name: None, - avatar_url: None, - }, - status: FollowerStatus::Accepted, - } - } - - #[test] - fn collect_inboxes_deduplicates_shared() { - let followers = vec![ - make_follower("https://mastodon.social/users/a/inbox", Some("https://mastodon.social/inbox")), - make_follower("https://mastodon.social/users/b/inbox", Some("https://mastodon.social/inbox")), - make_follower("https://other.instance/users/c/inbox", None), - ]; - let inboxes = collect_inboxes(&followers); - assert_eq!(inboxes.len(), 2); - let strs: Vec<_> = inboxes.iter().map(|u| u.as_str()).collect(); - assert!(strs.contains(&"https://mastodon.social/inbox")); - assert!(strs.contains(&"https://other.instance/users/c/inbox")); - } - - #[test] - fn collect_inboxes_falls_back_to_individual_inbox() { - let followers = vec![ - make_follower("https://example.com/users/x/inbox", None), - ]; - let inboxes = collect_inboxes(&followers); - assert_eq!(inboxes.len(), 1); - assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox"); - } -} +#[path = "tests/service.rs"] +mod tests; diff --git a/crates/adapters/activitypub-base/src/tests/actors.rs b/crates/adapters/activitypub-base/src/tests/actors.rs new file mode 100644 index 0000000..2876ace --- /dev/null +++ b/crates/adapters/activitypub-base/src/tests/actors.rs @@ -0,0 +1,33 @@ +use super::*; + +#[test] +fn person_serializes_with_enriched_fields() { + let person = Person { + kind: Default::default(), + id: "https://example.com/users/1".parse::().unwrap().into(), + preferred_username: "alice".to_string(), + inbox: "https://example.com/users/1/inbox".parse().unwrap(), + outbox: "https://example.com/users/1/outbox".parse().unwrap(), + followers: "https://example.com/users/1/followers".parse().unwrap(), + following: "https://example.com/users/1/following".parse().unwrap(), + public_key: PublicKey { + id: "https://example.com/users/1#main-key".to_string(), + owner: "https://example.com/users/1".parse().unwrap(), + public_key_pem: "pem".to_string(), + }, + name: Some("Alice".to_string()), + summary: Some("Bio text".to_string()), + icon: Some(ApImageObject { + kind: "Image".to_string(), + url: "https://example.com/images/avatars/1".parse().unwrap(), + }), + url: Some("https://example.com/u/alice".parse().unwrap()), + discoverable: Some(true), + manually_approves_followers: false, + }; + let json = serde_json::to_value(&person).unwrap(); + assert_eq!(json["discoverable"], true); + assert_eq!(json["summary"], "Bio text"); + assert_eq!(json["icon"]["type"], "Image"); + assert!(json.get("manuallyApprovesFollowers").is_some()); +} diff --git a/crates/adapters/activitypub-base/src/tests/nodeinfo.rs b/crates/adapters/activitypub-base/src/tests/nodeinfo.rs new file mode 100644 index 0000000..4bb5791 --- /dev/null +++ b/crates/adapters/activitypub-base/src/tests/nodeinfo.rs @@ -0,0 +1,37 @@ +use super::*; + +#[test] +fn nodeinfo_well_known_serializes_correctly() { + let doc = NodeInfoWellKnown { + links: vec![NodeInfoLink { + rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(), + href: "https://example.com/nodeinfo/2.0".to_string(), + }], + }; + let json = serde_json::to_value(&doc).unwrap(); + assert_eq!(json["links"][0]["rel"], "http://nodeinfo.diaspora.software/ns/schema/2.0"); + assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0"); +} + +#[test] +fn nodeinfo_serializes_camel_case() { + let doc = NodeInfo { + version: "2.0".to_string(), + software: NodeInfoSoftware { + name: "my-app".to_string(), + version: "0.1.0".to_string(), + }, + protocols: vec!["activitypub".to_string()], + usage: NodeInfoUsage { + users: NodeInfoUsers { total: 3 }, + local_posts: 42, + }, + open_registrations: false, + }; + let json = serde_json::to_value(&doc).unwrap(); + assert_eq!(json["version"], "2.0"); + assert_eq!(json["software"]["name"], "my-app"); + assert_eq!(json["usage"]["users"]["total"], 3); + assert_eq!(json["usage"]["localPosts"], 42); + assert_eq!(json["openRegistrations"], false); +} diff --git a/crates/adapters/activitypub-base/src/tests/service.rs b/crates/adapters/activitypub-base/src/tests/service.rs new file mode 100644 index 0000000..7c42c48 --- /dev/null +++ b/crates/adapters/activitypub-base/src/tests/service.rs @@ -0,0 +1,40 @@ +use super::*; +use crate::repository::{Follower, FollowerStatus, RemoteActor}; + +fn make_follower(inbox: &str, shared: Option<&str>) -> Follower { + Follower { + actor: RemoteActor { + url: format!("https://remote/{}", inbox), + handle: "user".to_string(), + inbox_url: inbox.to_string(), + shared_inbox_url: shared.map(|s| s.to_string()), + display_name: None, + avatar_url: None, + }, + status: FollowerStatus::Accepted, + } +} + +#[test] +fn collect_inboxes_deduplicates_shared() { + let followers = vec![ + make_follower("https://mastodon.social/users/a/inbox", Some("https://mastodon.social/inbox")), + make_follower("https://mastodon.social/users/b/inbox", Some("https://mastodon.social/inbox")), + make_follower("https://other.instance/users/c/inbox", None), + ]; + let inboxes = collect_inboxes(&followers); + assert_eq!(inboxes.len(), 2); + let strs: Vec<_> = inboxes.iter().map(|u| u.as_str()).collect(); + assert!(strs.contains(&"https://mastodon.social/inbox")); + assert!(strs.contains(&"https://other.instance/users/c/inbox")); +} + +#[test] +fn collect_inboxes_falls_back_to_individual_inbox() { + let followers = vec![ + make_follower("https://example.com/users/x/inbox", None), + ]; + let inboxes = collect_inboxes(&followers); + assert_eq!(inboxes.len(), 1); + assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox"); +} diff --git a/crates/adapters/activitypub/src/objects.rs b/crates/adapters/activitypub/src/objects.rs index 3f04a53..81f3fa8 100644 --- a/crates/adapters/activitypub/src/objects.rs +++ b/crates/adapters/activitypub/src/objects.rs @@ -98,46 +98,5 @@ pub fn review_to_ap_object( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalize_hashtag_strips_non_alphanumeric() { - assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight"); - assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList"); - assert_eq!(normalize_hashtag("2001: A Space Odyssey"), "2001ASpaceOdyssey"); - } - - #[test] - fn review_to_ap_object_includes_two_hashtags() { - use chrono::NaiveDateTime; - use domain::{ - models::{Review, ReviewSource}, - value_objects::{MovieId, Rating, ReviewId, UserId}, - }; - - let review = Review::from_persistence( - ReviewId::generate(), - MovieId::from_uuid(uuid::Uuid::new_v4()), - UserId::from_uuid(uuid::Uuid::new_v4()), - Rating::new(4).unwrap(), - None, - NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), - NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), - ReviewSource::Local, - ); - let obj = review_to_ap_object( - &review, - "https://example.com/reviews/1".parse().unwrap(), - "https://example.com/users/1".parse().unwrap(), - "Dune".to_string(), - 2021, - None, - "https://example.com", - ); - assert_eq!(obj.tag.len(), 2); - let names: Vec<&str> = obj.tag.iter().map(|t| t.name.as_str()).collect(); - assert!(names.contains(&"#MoviesDiary")); - assert!(names.contains(&"#Dune")); - } -} +#[path = "tests/objects.rs"] +mod tests; diff --git a/crates/adapters/activitypub/src/tests/objects.rs b/crates/adapters/activitypub/src/tests/objects.rs new file mode 100644 index 0000000..9fda18a --- /dev/null +++ b/crates/adapters/activitypub/src/tests/objects.rs @@ -0,0 +1,41 @@ +use super::*; + +#[test] +fn normalize_hashtag_strips_non_alphanumeric() { + assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight"); + assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList"); + assert_eq!(normalize_hashtag("2001: A Space Odyssey"), "2001ASpaceOdyssey"); +} + +#[test] +fn review_to_ap_object_includes_two_hashtags() { + use chrono::NaiveDateTime; + use domain::{ + models::{Review, ReviewSource}, + value_objects::{MovieId, Rating, ReviewId, UserId}, + }; + + let review = Review::from_persistence( + ReviewId::generate(), + MovieId::from_uuid(uuid::Uuid::new_v4()), + UserId::from_uuid(uuid::Uuid::new_v4()), + Rating::new(4).unwrap(), + None, + NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), + NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), + ReviewSource::Local, + ); + let obj = review_to_ap_object( + &review, + "https://example.com/reviews/1".parse().unwrap(), + "https://example.com/users/1".parse().unwrap(), + "Dune".to_string(), + 2021, + None, + "https://example.com", + ); + assert_eq!(obj.tag.len(), 2); + let names: Vec<&str> = obj.tag.iter().map(|t| t.name.as_str()).collect(); + assert!(names.contains(&"#MoviesDiary")); + assert!(names.contains(&"#Dune")); +} diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/adapters/event-payload/src/lib.rs index 4327a14..056bf59 100644 --- a/crates/adapters/event-payload/src/lib.rs +++ b/crates/adapters/event-payload/src/lib.rs @@ -184,91 +184,5 @@ impl TryFrom for DomainEvent { } #[cfg(test)] -mod tests { - use super::*; - - fn fixed_dt() -> NaiveDateTime { - chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc() - } - - fn review_logged() -> DomainEvent { - DomainEvent::ReviewLogged { - review_id: ReviewId::from_uuid(Uuid::new_v4()), - movie_id: MovieId::from_uuid(Uuid::new_v4()), - user_id: UserId::from_uuid(Uuid::new_v4()), - rating: Rating::new(4).unwrap(), - watched_at: fixed_dt(), - } - } - - fn review_updated() -> DomainEvent { - DomainEvent::ReviewUpdated { - review_id: ReviewId::from_uuid(Uuid::new_v4()), - movie_id: MovieId::from_uuid(Uuid::new_v4()), - user_id: UserId::from_uuid(Uuid::new_v4()), - rating: Rating::new(3).unwrap(), - watched_at: fixed_dt(), - } - } - - fn movie_discovered() -> DomainEvent { - DomainEvent::MovieDiscovered { - movie_id: MovieId::from_uuid(Uuid::new_v4()), - external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(), - } - } - - fn round_trip(event: DomainEvent) { - let payload = EventPayload::from(&event); - let json = serde_json::to_string(&payload).expect("serialize"); - let back: EventPayload = serde_json::from_str(&json).expect("deserialize"); - let recovered = DomainEvent::try_from(back).expect("try_from"); - assert_eq!(EventPayload::from(&event), EventPayload::from(&recovered)); - } - - #[test] - fn round_trip_review_logged() { - round_trip(review_logged()); - } - - #[test] - fn round_trip_review_updated() { - round_trip(review_updated()); - } - - #[test] - fn round_trip_movie_discovered() { - round_trip(movie_discovered()); - } - - #[test] - fn serialized_format_is_tagged() { - let payload = EventPayload::from(&movie_discovered()); - let json = serde_json::to_string(&payload).unwrap(); - assert!(json.contains(r#""type":"MovieDiscovered""#)); - assert!(json.contains(r#""data":"#)); - } - - #[test] - fn event_type_strings() { - assert_eq!(EventPayload::from(&review_logged()).event_type(), "ReviewLogged"); - assert_eq!(EventPayload::from(&review_updated()).event_type(), "ReviewUpdated"); - assert_eq!(EventPayload::from(&movie_discovered()).event_type(), "MovieDiscovered"); - } - - #[test] - fn round_trip_image_stored() { - let event = DomainEvent::ImageStored { key: "avatars/abc123".into() }; - let payload = EventPayload::from(&event); - let json = serde_json::to_string(&payload).unwrap(); - let back: EventPayload = serde_json::from_str(&json).unwrap(); - let recovered = DomainEvent::try_from(back).unwrap(); - assert_eq!(EventPayload::from(&event), EventPayload::from(&recovered)); - } - - #[test] - fn image_stored_event_type() { - let payload = EventPayload::from(&DomainEvent::ImageStored { key: "posters/x".into() }); - assert_eq!(payload.event_type(), "ImageStored"); - } -} +#[path = "tests/lib.rs"] +mod tests; diff --git a/crates/adapters/event-payload/src/tests/lib.rs b/crates/adapters/event-payload/src/tests/lib.rs new file mode 100644 index 0000000..6dcb58a --- /dev/null +++ b/crates/adapters/event-payload/src/tests/lib.rs @@ -0,0 +1,86 @@ +use super::*; + +fn fixed_dt() -> NaiveDateTime { + chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc() +} + +fn review_logged() -> DomainEvent { + DomainEvent::ReviewLogged { + review_id: ReviewId::from_uuid(Uuid::new_v4()), + movie_id: MovieId::from_uuid(Uuid::new_v4()), + user_id: UserId::from_uuid(Uuid::new_v4()), + rating: Rating::new(4).unwrap(), + watched_at: fixed_dt(), + } +} + +fn review_updated() -> DomainEvent { + DomainEvent::ReviewUpdated { + review_id: ReviewId::from_uuid(Uuid::new_v4()), + movie_id: MovieId::from_uuid(Uuid::new_v4()), + user_id: UserId::from_uuid(Uuid::new_v4()), + rating: Rating::new(3).unwrap(), + watched_at: fixed_dt(), + } +} + +fn movie_discovered() -> DomainEvent { + DomainEvent::MovieDiscovered { + movie_id: MovieId::from_uuid(Uuid::new_v4()), + external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(), + } +} + +fn round_trip(event: DomainEvent) { + let payload = EventPayload::from(&event); + let json = serde_json::to_string(&payload).expect("serialize"); + let back: EventPayload = serde_json::from_str(&json).expect("deserialize"); + let recovered = DomainEvent::try_from(back).expect("try_from"); + assert_eq!(EventPayload::from(&event), EventPayload::from(&recovered)); +} + +#[test] +fn round_trip_review_logged() { + round_trip(review_logged()); +} + +#[test] +fn round_trip_review_updated() { + round_trip(review_updated()); +} + +#[test] +fn round_trip_movie_discovered() { + round_trip(movie_discovered()); +} + +#[test] +fn serialized_format_is_tagged() { + let payload = EventPayload::from(&movie_discovered()); + let json = serde_json::to_string(&payload).unwrap(); + assert!(json.contains(r#""type":"MovieDiscovered""#)); + assert!(json.contains(r#""data":"#)); +} + +#[test] +fn event_type_strings() { + assert_eq!(EventPayload::from(&review_logged()).event_type(), "ReviewLogged"); + assert_eq!(EventPayload::from(&review_updated()).event_type(), "ReviewUpdated"); + assert_eq!(EventPayload::from(&movie_discovered()).event_type(), "MovieDiscovered"); +} + +#[test] +fn round_trip_image_stored() { + let event = DomainEvent::ImageStored { key: "avatars/abc123".into() }; + let payload = EventPayload::from(&event); + let json = serde_json::to_string(&payload).unwrap(); + let back: EventPayload = serde_json::from_str(&json).unwrap(); + let recovered = DomainEvent::try_from(back).unwrap(); + assert_eq!(EventPayload::from(&event), EventPayload::from(&recovered)); +} + +#[test] +fn image_stored_event_type() { + let payload = EventPayload::from(&DomainEvent::ImageStored { key: "posters/x".into() }); + assert_eq!(payload.event_type(), "ImageStored"); +} diff --git a/crates/adapters/event-publisher/src/lib.rs b/crates/adapters/event-publisher/src/lib.rs index d38763b..8c2dd8a 100644 --- a/crates/adapters/event-publisher/src/lib.rs +++ b/crates/adapters/event-publisher/src/lib.rs @@ -84,59 +84,5 @@ pub fn create_event_channel( } #[cfg(test)] -mod tests { - use super::*; - use domain::{ - events::DomainEvent, - value_objects::{ExternalMetadataId, MovieId}, - }; - use futures::StreamExt; - - fn movie_discovered() -> DomainEvent { - DomainEvent::MovieDiscovered { - movie_id: MovieId::generate(), - external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(), - } - } - - #[tokio::test] - async fn consumer_yields_published_events() { - let config = EventPublisherConfig { channel_buffer: 8 }; - let (publisher, consumer) = create_event_channel(config); - - publisher.publish(&movie_discovered()).await.unwrap(); - drop(publisher); - - let mut stream = consumer.consume(); - let envelope = stream.next().await.unwrap().unwrap(); - assert!(matches!(envelope.event, DomainEvent::MovieDiscovered { .. })); - assert!(stream.next().await.is_none()); - } - - #[tokio::test] - async fn consumer_yields_multiple_events_in_order() { - let config = EventPublisherConfig { channel_buffer: 8 }; - let (publisher, consumer) = create_event_channel(config); - - publisher.publish(&movie_discovered()).await.unwrap(); - publisher.publish(&movie_discovered()).await.unwrap(); - drop(publisher); - - let mut stream = consumer.consume(); - let first = stream.next().await.unwrap().unwrap(); - let second = stream.next().await.unwrap().unwrap(); - assert!(matches!(first.event, DomainEvent::MovieDiscovered { .. })); - assert!(matches!(second.event, DomainEvent::MovieDiscovered { .. })); - assert!(stream.next().await.is_none()); - } - - #[tokio::test] - async fn stream_ends_when_publisher_dropped() { - let config = EventPublisherConfig { channel_buffer: 8 }; - let (publisher, consumer) = create_event_channel(config); - drop(publisher); - - let mut stream = consumer.consume(); - assert!(stream.next().await.is_none()); - } -} +#[path = "tests/lib.rs"] +mod tests; diff --git a/crates/adapters/event-publisher/src/tests/lib.rs b/crates/adapters/event-publisher/src/tests/lib.rs new file mode 100644 index 0000000..c843854 --- /dev/null +++ b/crates/adapters/event-publisher/src/tests/lib.rs @@ -0,0 +1,54 @@ +use super::*; +use domain::{ + events::DomainEvent, + value_objects::{ExternalMetadataId, MovieId}, +}; +use futures::StreamExt; + +fn movie_discovered() -> DomainEvent { + DomainEvent::MovieDiscovered { + movie_id: MovieId::generate(), + external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(), + } +} + +#[tokio::test] +async fn consumer_yields_published_events() { + let config = EventPublisherConfig { channel_buffer: 8 }; + let (publisher, consumer) = create_event_channel(config); + + publisher.publish(&movie_discovered()).await.unwrap(); + drop(publisher); + + let mut stream = consumer.consume(); + let envelope = stream.next().await.unwrap().unwrap(); + assert!(matches!(envelope.event, DomainEvent::MovieDiscovered { .. })); + assert!(stream.next().await.is_none()); +} + +#[tokio::test] +async fn consumer_yields_multiple_events_in_order() { + let config = EventPublisherConfig { channel_buffer: 8 }; + let (publisher, consumer) = create_event_channel(config); + + publisher.publish(&movie_discovered()).await.unwrap(); + publisher.publish(&movie_discovered()).await.unwrap(); + drop(publisher); + + let mut stream = consumer.consume(); + let first = stream.next().await.unwrap().unwrap(); + let second = stream.next().await.unwrap().unwrap(); + assert!(matches!(first.event, DomainEvent::MovieDiscovered { .. })); + assert!(matches!(second.event, DomainEvent::MovieDiscovered { .. })); + assert!(stream.next().await.is_none()); +} + +#[tokio::test] +async fn stream_ends_when_publisher_dropped() { + let config = EventPublisherConfig { channel_buffer: 8 }; + let (publisher, consumer) = create_event_channel(config); + drop(publisher); + + let mut stream = consumer.consume(); + assert!(stream.next().await.is_none()); +} diff --git a/crates/adapters/export/src/lib.rs b/crates/adapters/export/src/lib.rs index d712ac6..f2b22d8 100644 --- a/crates/adapters/export/src/lib.rs +++ b/crates/adapters/export/src/lib.rs @@ -75,151 +75,5 @@ fn serialize_json(entries: &[DiaryEntry]) -> Result, DomainError> { } #[cfg(test)] -mod tests { - use super::ExportAdapter; - use domain::{ - models::{DiaryEntry, ExportFormat, Movie, Review}, - ports::DiaryExporter, - value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear}, - }; - - fn make_entry( - title: &str, - year: u16, - director: Option<&str>, - rating: u8, - comment: Option<&str>, - ) -> DiaryEntry { - make_entry_full(title, year, director, rating, comment, None) - } - - fn make_entry_full( - title: &str, - year: u16, - director: Option<&str>, - rating: u8, - comment: Option<&str>, - external_id: Option<&str>, - ) -> DiaryEntry { - let movie = Movie::new( - external_id.map(|id| ExternalMetadataId::new(id.to_string()).unwrap()), - MovieTitle::new(title.to_string()).unwrap(), - ReleaseYear::new(year).unwrap(), - director.map(str::to_string), - None, - ); - let user_id = domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4()); - let review = Review::new( - movie.id().clone(), - user_id, - Rating::new(rating).unwrap(), - comment.map(|c| domain::value_objects::Comment::new(c.to_string()).unwrap()), - chrono::NaiveDate::from_ymd_opt(2024, 3, 15) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap(), - ) - .unwrap(); - DiaryEntry::new(movie, review) - } - - #[tokio::test] - async fn csv_has_header_and_one_row() { - let adapter = ExportAdapter; - let entry = make_entry( - "Inception", - 2010, - Some("Christopher Nolan"), - 5, - Some("great"), - ); - let bytes = adapter - .serialize_entries(&[entry], ExportFormat::Csv) - .await - .unwrap(); - let text = String::from_utf8(bytes).unwrap(); - assert!( - text.starts_with( - "title,year,director,rating,comment,watched_at,external_metadata_id\n" - ) - ); - assert!(text.contains("Inception")); - assert!(text.contains("2010")); - assert!(text.contains("Christopher Nolan")); - assert!(text.contains("5")); - assert!(text.contains("great")); - assert!(text.contains("2024-03-15")); - } - - #[tokio::test] - async fn csv_escapes_commas_in_title() { - let adapter = ExportAdapter; - let entry = make_entry("Tár, A Film", 2022, None, 4, None); - let bytes = adapter - .serialize_entries(&[entry], ExportFormat::Csv) - .await - .unwrap(); - let text = String::from_utf8(bytes).unwrap(); - assert!(text.contains("\"Tár, A Film\"")); - } - - #[tokio::test] - async fn json_is_valid_array() { - let adapter = ExportAdapter; - let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None); - let bytes = adapter - .serialize_entries(&[entry], ExportFormat::Json) - .await - .unwrap(); - let arr: Vec = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0]["title"], "Dune"); - assert_eq!(arr[0]["year"], 2021); - assert_eq!(arr[0]["rating"], 5); - assert_eq!(arr[0]["comment"], serde_json::Value::Null); - assert_eq!(arr[0]["external_metadata_id"], serde_json::Value::Null); - } - - #[tokio::test] - async fn external_metadata_id_included_when_present() { - let adapter = ExportAdapter; - let entry = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748")); - let bytes = adapter - .serialize_entries(&[entry], ExportFormat::Json) - .await - .unwrap(); - let arr: Vec = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(arr[0]["external_metadata_id"], "tt0078748"); - - let bytes = adapter - .serialize_entries( - &[make_entry_full( - "Alien", - 1979, - None, - 5, - None, - Some("tt0078748"), - )], - ExportFormat::Csv, - ) - .await - .unwrap(); - let text = String::from_utf8(bytes).unwrap(); - assert!(text.contains("tt0078748")); - } - - #[tokio::test] - async fn empty_entries_returns_csv_header_only() { - let adapter = ExportAdapter; - let bytes = adapter - .serialize_entries(&[], ExportFormat::Csv) - .await - .unwrap(); - let text = String::from_utf8(bytes).unwrap(); - assert_eq!( - text, - "title,year,director,rating,comment,watched_at,external_metadata_id\n" - ); - } -} +#[path = "tests/lib.rs"] +mod tests; diff --git a/crates/adapters/export/src/tests/lib.rs b/crates/adapters/export/src/tests/lib.rs new file mode 100644 index 0000000..9145ae3 --- /dev/null +++ b/crates/adapters/export/src/tests/lib.rs @@ -0,0 +1,146 @@ +use super::ExportAdapter; +use domain::{ + models::{DiaryEntry, ExportFormat, Movie, Review}, + ports::DiaryExporter, + value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear}, +}; + +fn make_entry( + title: &str, + year: u16, + director: Option<&str>, + rating: u8, + comment: Option<&str>, +) -> DiaryEntry { + make_entry_full(title, year, director, rating, comment, None) +} + +fn make_entry_full( + title: &str, + year: u16, + director: Option<&str>, + rating: u8, + comment: Option<&str>, + external_id: Option<&str>, +) -> DiaryEntry { + let movie = Movie::new( + external_id.map(|id| ExternalMetadataId::new(id.to_string()).unwrap()), + MovieTitle::new(title.to_string()).unwrap(), + ReleaseYear::new(year).unwrap(), + director.map(str::to_string), + None, + ); + let user_id = domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4()); + let review = Review::new( + movie.id().clone(), + user_id, + Rating::new(rating).unwrap(), + comment.map(|c| domain::value_objects::Comment::new(c.to_string()).unwrap()), + chrono::NaiveDate::from_ymd_opt(2024, 3, 15) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(), + ) + .unwrap(); + DiaryEntry::new(movie, review) +} + +#[tokio::test] +async fn csv_has_header_and_one_row() { + let adapter = ExportAdapter; + let entry = make_entry( + "Inception", + 2010, + Some("Christopher Nolan"), + 5, + Some("great"), + ); + let bytes = adapter + .serialize_entries(&[entry], ExportFormat::Csv) + .await + .unwrap(); + let text = String::from_utf8(bytes).unwrap(); + assert!( + text.starts_with( + "title,year,director,rating,comment,watched_at,external_metadata_id\n" + ) + ); + assert!(text.contains("Inception")); + assert!(text.contains("2010")); + assert!(text.contains("Christopher Nolan")); + assert!(text.contains("5")); + assert!(text.contains("great")); + assert!(text.contains("2024-03-15")); +} + +#[tokio::test] +async fn csv_escapes_commas_in_title() { + let adapter = ExportAdapter; + let entry = make_entry("Tár, A Film", 2022, None, 4, None); + let bytes = adapter + .serialize_entries(&[entry], ExportFormat::Csv) + .await + .unwrap(); + let text = String::from_utf8(bytes).unwrap(); + assert!(text.contains("\"Tár, A Film\"")); +} + +#[tokio::test] +async fn json_is_valid_array() { + let adapter = ExportAdapter; + let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None); + let bytes = adapter + .serialize_entries(&[entry], ExportFormat::Json) + .await + .unwrap(); + let arr: Vec = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["title"], "Dune"); + assert_eq!(arr[0]["year"], 2021); + assert_eq!(arr[0]["rating"], 5); + assert_eq!(arr[0]["comment"], serde_json::Value::Null); + assert_eq!(arr[0]["external_metadata_id"], serde_json::Value::Null); +} + +#[tokio::test] +async fn external_metadata_id_included_when_present() { + let adapter = ExportAdapter; + let entry = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748")); + let bytes = adapter + .serialize_entries(&[entry], ExportFormat::Json) + .await + .unwrap(); + let arr: Vec = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(arr[0]["external_metadata_id"], "tt0078748"); + + let bytes = adapter + .serialize_entries( + &[make_entry_full( + "Alien", + 1979, + None, + 5, + None, + Some("tt0078748"), + )], + ExportFormat::Csv, + ) + .await + .unwrap(); + let text = String::from_utf8(bytes).unwrap(); + assert!(text.contains("tt0078748")); +} + +#[tokio::test] +async fn empty_entries_returns_csv_header_only() { + let adapter = ExportAdapter; + let bytes = adapter + .serialize_entries(&[], ExportFormat::Csv) + .await + .unwrap(); + let text = String::from_utf8(bytes).unwrap(); + assert_eq!( + text, + "title,year,director,rating,comment,watched_at,external_metadata_id\n" + ); +} diff --git a/crates/adapters/image-converter/src/backfill.rs b/crates/adapters/image-converter/src/backfill.rs index e18a7de..7bf9a4f 100644 --- a/crates/adapters/image-converter/src/backfill.rs +++ b/crates/adapters/image-converter/src/backfill.rs @@ -47,94 +47,5 @@ impl PeriodicJob for ConversionBackfillJob { } #[cfg(test)] -mod tests { - use super::*; - use std::sync::Mutex; - - struct MockImageRef { - keys: Vec, - } - - #[async_trait::async_trait] - impl ImageRefQuery for MockImageRef { - async fn list_keys(&self) -> Result, DomainError> { - Ok(self.keys.clone()) - } - } - - struct MockPublisher { - emitted: Mutex>, - } - - impl MockPublisher { - fn new() -> Arc { - Arc::new(Self { emitted: Mutex::new(vec![]) }) - } - - fn emitted(&self) -> Vec { - self.emitted.lock().unwrap().clone() - } - } - - #[async_trait::async_trait] - impl EventPublisher for MockPublisher { - async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { - if let DomainEvent::ImageStored { key } = event { - self.emitted.lock().unwrap().push(key.clone()); - } - Ok(()) - } - } - - #[tokio::test] - async fn emits_image_stored_for_unconverted_keys() { - let image_ref = Arc::new(MockImageRef { - keys: vec!["avatars/u1".into(), "posters/m1".into()], - }); - let publisher = MockPublisher::new(); - let job = ConversionBackfillJob::new( - image_ref, - Arc::clone(&publisher) as Arc, - ); - - job.run().await.unwrap(); - - let mut emitted = publisher.emitted(); - emitted.sort(); - assert_eq!(emitted, vec!["avatars/u1", "posters/m1"]); - } - - #[tokio::test] - async fn skips_already_converted_keys() { - let image_ref = Arc::new(MockImageRef { - keys: vec![ - "avatars/u1.avif".into(), - "posters/m1".into(), - "avatars/u2.webp".into(), - ], - }); - let publisher = MockPublisher::new(); - let job = ConversionBackfillJob::new( - image_ref, - Arc::clone(&publisher) as Arc, - ); - - job.run().await.unwrap(); - - assert_eq!(publisher.emitted(), vec!["posters/m1"]); - } - - #[tokio::test] - async fn empty_keys_emits_nothing() { - let image_ref = Arc::new(MockImageRef { keys: vec![] }); - let publisher = MockPublisher::new(); - let job = ConversionBackfillJob::new( - image_ref, - Arc::clone(&publisher) as Arc, - ); - - job.run().await.unwrap(); - - assert!(publisher.emitted().is_empty()); - } -} +#[path = "tests/backfill.rs"] +mod tests; diff --git a/crates/adapters/image-converter/src/config.rs b/crates/adapters/image-converter/src/config.rs index d2502df..04bf3c6 100644 --- a/crates/adapters/image-converter/src/config.rs +++ b/crates/adapters/image-converter/src/config.rs @@ -47,44 +47,5 @@ impl ConversionConfig { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn disabled_by_default() { - assert!(ConversionConfig::from_vars(None, None).unwrap().is_none()); - assert!(ConversionConfig::from_vars(Some("false"), None).unwrap().is_none()); - } - - #[test] - fn enabled_avif() { - let cfg = ConversionConfig::from_vars(Some("true"), Some("avif")).unwrap().unwrap(); - assert_eq!(cfg.format, Format::Avif); - } - - #[test] - fn enabled_webp() { - let cfg = ConversionConfig::from_vars(Some("true"), Some("webp")).unwrap().unwrap(); - assert_eq!(cfg.format, Format::Webp); - } - - #[test] - fn unknown_format_is_error() { - assert!(ConversionConfig::from_vars(Some("true"), Some("gif")).is_err()); - } - - #[test] - fn missing_format_when_enabled_is_error() { - assert!(ConversionConfig::from_vars(Some("true"), None).is_err()); - } - - #[test] - fn avif_extension() { - assert_eq!(Format::Avif.extension(), ".avif"); - } - - #[test] - fn webp_extension() { - assert_eq!(Format::Webp.extension(), ".webp"); - } -} +#[path = "tests/config.rs"] +mod tests; diff --git a/crates/adapters/image-converter/src/handler.rs b/crates/adapters/image-converter/src/handler.rs index ac33a33..09ad187 100644 --- a/crates/adapters/image-converter/src/handler.rs +++ b/crates/adapters/image-converter/src/handler.rs @@ -92,130 +92,5 @@ fn convert(bytes: Vec, format: Format) -> Result, String> { } #[cfg(test)] -mod tests { - use super::*; - use std::sync::Mutex; - use object_store::memory::InMemory; - use image_storage::ImageStorageAdapter; - - struct MockImageRef { - swaps: Mutex>, - } - - impl MockImageRef { - fn new() -> Arc { - Arc::new(Self { swaps: Mutex::new(vec![]) }) - } - - fn swaps(&self) -> Vec<(String, String)> { - self.swaps.lock().unwrap().clone() - } - } - - #[async_trait::async_trait] - impl ImageRefCommand for MockImageRef { - async fn swap(&self, old: &str, new: &str) -> Result<(), DomainError> { - self.swaps.lock().unwrap().push((old.into(), new.into())); - Ok(()) - } - } - - fn in_memory_storage() -> Arc { - Arc::new(ImageStorageAdapter::new(Arc::new(InMemory::new()))) - } - - fn tiny_jpeg() -> Vec { - use image::{DynamicImage, ImageBuffer, Rgb}; - let img = DynamicImage::ImageRgb8( - ImageBuffer::from_pixel(4, 4, Rgb([200u8, 100, 50])), - ); - let mut buf = std::io::Cursor::new(Vec::new()); - img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap(); - buf.into_inner() - } - - #[tokio::test] - async fn ignores_non_image_stored_events() { - let storage = in_memory_storage(); - let image_ref = MockImageRef::new(); - let handler = ImageConversionHandler::new( - Arc::clone(&storage) as Arc, - Arc::clone(&image_ref) as Arc, - Format::Avif, - ); - - handler.handle(&DomainEvent::UserUpdated { - user_id: domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4()), - }).await.unwrap(); - - assert!(image_ref.swaps().is_empty()); - } - - #[tokio::test] - async fn skips_already_converted_avif_key() { - let storage = in_memory_storage(); - storage.store("avatars/u1.avif", &tiny_jpeg()).await.unwrap(); - let image_ref = MockImageRef::new(); - let handler = ImageConversionHandler::new( - Arc::clone(&storage) as Arc, - Arc::clone(&image_ref) as Arc, - Format::Avif, - ); - - handler.handle(&DomainEvent::ImageStored { key: "avatars/u1.avif".into() }).await.unwrap(); - - assert!(image_ref.swaps().is_empty()); - } - - #[tokio::test] - async fn skips_already_converted_webp_key() { - let storage = in_memory_storage(); - storage.store("posters/m1.webp", &tiny_jpeg()).await.unwrap(); - let image_ref = MockImageRef::new(); - let handler = ImageConversionHandler::new( - Arc::clone(&storage) as Arc, - Arc::clone(&image_ref) as Arc, - Format::Webp, - ); - - handler.handle(&DomainEvent::ImageStored { key: "posters/m1.webp".into() }).await.unwrap(); - - assert!(image_ref.swaps().is_empty()); - } - - #[tokio::test] - async fn converts_jpeg_to_avif_and_swaps_key() { - let storage = in_memory_storage(); - storage.store("avatars/u1", &tiny_jpeg()).await.unwrap(); - let image_ref = MockImageRef::new(); - let handler = ImageConversionHandler::new( - Arc::clone(&storage) as Arc, - Arc::clone(&image_ref) as Arc, - Format::Avif, - ); - - handler.handle(&DomainEvent::ImageStored { key: "avatars/u1".into() }).await.unwrap(); - - assert_eq!(image_ref.swaps(), vec![("avatars/u1".into(), "avatars/u1.avif".into())]); - assert!(storage.get("avatars/u1.avif").await.is_ok()); - assert!(storage.get("avatars/u1").await.is_err()); - } - - #[tokio::test] - async fn converts_jpeg_to_webp_and_swaps_key() { - let storage = in_memory_storage(); - storage.store("avatars/u1", &tiny_jpeg()).await.unwrap(); - let image_ref = MockImageRef::new(); - let handler = ImageConversionHandler::new( - Arc::clone(&storage) as Arc, - Arc::clone(&image_ref) as Arc, - Format::Webp, - ); - - handler.handle(&DomainEvent::ImageStored { key: "avatars/u1".into() }).await.unwrap(); - - assert_eq!(image_ref.swaps(), vec![("avatars/u1".into(), "avatars/u1.webp".into())]); - assert!(storage.get("avatars/u1.webp").await.is_ok()); - assert!(storage.get("avatars/u1").await.is_err()); - } -} +#[path = "tests/handler.rs"] +mod tests; diff --git a/crates/adapters/image-converter/src/tests/backfill.rs b/crates/adapters/image-converter/src/tests/backfill.rs new file mode 100644 index 0000000..80851c4 --- /dev/null +++ b/crates/adapters/image-converter/src/tests/backfill.rs @@ -0,0 +1,89 @@ +use super::*; +use std::sync::Mutex; + +struct MockImageRef { + keys: Vec, +} + +#[async_trait::async_trait] +impl ImageRefQuery for MockImageRef { + async fn list_keys(&self) -> Result, DomainError> { + Ok(self.keys.clone()) + } +} + +struct MockPublisher { + emitted: Mutex>, +} + +impl MockPublisher { + fn new() -> Arc { + Arc::new(Self { emitted: Mutex::new(vec![]) }) + } + + fn emitted(&self) -> Vec { + self.emitted.lock().unwrap().clone() + } +} + +#[async_trait::async_trait] +impl EventPublisher for MockPublisher { + async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { + if let DomainEvent::ImageStored { key } = event { + self.emitted.lock().unwrap().push(key.clone()); + } + Ok(()) + } +} + +#[tokio::test] +async fn emits_image_stored_for_unconverted_keys() { + let image_ref = Arc::new(MockImageRef { + keys: vec!["avatars/u1".into(), "posters/m1".into()], + }); + let publisher = MockPublisher::new(); + let job = ConversionBackfillJob::new( + image_ref, + Arc::clone(&publisher) as Arc, + ); + + job.run().await.unwrap(); + + let mut emitted = publisher.emitted(); + emitted.sort(); + assert_eq!(emitted, vec!["avatars/u1", "posters/m1"]); +} + +#[tokio::test] +async fn skips_already_converted_keys() { + let image_ref = Arc::new(MockImageRef { + keys: vec![ + "avatars/u1.avif".into(), + "posters/m1".into(), + "avatars/u2.webp".into(), + ], + }); + let publisher = MockPublisher::new(); + let job = ConversionBackfillJob::new( + image_ref, + Arc::clone(&publisher) as Arc, + ); + + job.run().await.unwrap(); + + assert_eq!(publisher.emitted(), vec!["posters/m1"]); +} + +#[tokio::test] +async fn empty_keys_emits_nothing() { + let image_ref = Arc::new(MockImageRef { keys: vec![] }); + let publisher = MockPublisher::new(); + let job = ConversionBackfillJob::new( + image_ref, + Arc::clone(&publisher) as Arc, + ); + + job.run().await.unwrap(); + + assert!(publisher.emitted().is_empty()); +} diff --git a/crates/adapters/image-converter/src/tests/config.rs b/crates/adapters/image-converter/src/tests/config.rs new file mode 100644 index 0000000..af8cd94 --- /dev/null +++ b/crates/adapters/image-converter/src/tests/config.rs @@ -0,0 +1,39 @@ +use super::*; + +#[test] +fn disabled_by_default() { + assert!(ConversionConfig::from_vars(None, None).unwrap().is_none()); + assert!(ConversionConfig::from_vars(Some("false"), None).unwrap().is_none()); +} + +#[test] +fn enabled_avif() { + let cfg = ConversionConfig::from_vars(Some("true"), Some("avif")).unwrap().unwrap(); + assert_eq!(cfg.format, Format::Avif); +} + +#[test] +fn enabled_webp() { + let cfg = ConversionConfig::from_vars(Some("true"), Some("webp")).unwrap().unwrap(); + assert_eq!(cfg.format, Format::Webp); +} + +#[test] +fn unknown_format_is_error() { + assert!(ConversionConfig::from_vars(Some("true"), Some("gif")).is_err()); +} + +#[test] +fn missing_format_when_enabled_is_error() { + assert!(ConversionConfig::from_vars(Some("true"), None).is_err()); +} + +#[test] +fn avif_extension() { + assert_eq!(Format::Avif.extension(), ".avif"); +} + +#[test] +fn webp_extension() { + assert_eq!(Format::Webp.extension(), ".webp"); +} diff --git a/crates/adapters/image-converter/src/tests/handler.rs b/crates/adapters/image-converter/src/tests/handler.rs new file mode 100644 index 0000000..d7c0e2f --- /dev/null +++ b/crates/adapters/image-converter/src/tests/handler.rs @@ -0,0 +1,125 @@ +use super::*; +use std::sync::Mutex; +use object_store::memory::InMemory; +use image_storage::ImageStorageAdapter; + +struct MockImageRef { + swaps: Mutex>, +} + +impl MockImageRef { + fn new() -> Arc { + Arc::new(Self { swaps: Mutex::new(vec![]) }) + } + + fn swaps(&self) -> Vec<(String, String)> { + self.swaps.lock().unwrap().clone() + } +} + +#[async_trait::async_trait] +impl ImageRefCommand for MockImageRef { + async fn swap(&self, old: &str, new: &str) -> Result<(), DomainError> { + self.swaps.lock().unwrap().push((old.into(), new.into())); + Ok(()) + } +} + +fn in_memory_storage() -> Arc { + Arc::new(ImageStorageAdapter::new(Arc::new(InMemory::new()))) +} + +fn tiny_jpeg() -> Vec { + use image::{DynamicImage, ImageBuffer, Rgb}; + let img = DynamicImage::ImageRgb8( + ImageBuffer::from_pixel(4, 4, Rgb([200u8, 100, 50])), + ); + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap(); + buf.into_inner() +} + +#[tokio::test] +async fn ignores_non_image_stored_events() { + let storage = in_memory_storage(); + let image_ref = MockImageRef::new(); + let handler = ImageConversionHandler::new( + Arc::clone(&storage) as Arc, + Arc::clone(&image_ref) as Arc, + Format::Avif, + ); + + handler.handle(&DomainEvent::UserUpdated { + user_id: domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4()), + }).await.unwrap(); + + assert!(image_ref.swaps().is_empty()); +} + +#[tokio::test] +async fn skips_already_converted_avif_key() { + let storage = in_memory_storage(); + storage.store("avatars/u1.avif", &tiny_jpeg()).await.unwrap(); + let image_ref = MockImageRef::new(); + let handler = ImageConversionHandler::new( + Arc::clone(&storage) as Arc, + Arc::clone(&image_ref) as Arc, + Format::Avif, + ); + + handler.handle(&DomainEvent::ImageStored { key: "avatars/u1.avif".into() }).await.unwrap(); + + assert!(image_ref.swaps().is_empty()); +} + +#[tokio::test] +async fn skips_already_converted_webp_key() { + let storage = in_memory_storage(); + storage.store("posters/m1.webp", &tiny_jpeg()).await.unwrap(); + let image_ref = MockImageRef::new(); + let handler = ImageConversionHandler::new( + Arc::clone(&storage) as Arc, + Arc::clone(&image_ref) as Arc, + Format::Webp, + ); + + handler.handle(&DomainEvent::ImageStored { key: "posters/m1.webp".into() }).await.unwrap(); + + assert!(image_ref.swaps().is_empty()); +} + +#[tokio::test] +async fn converts_jpeg_to_avif_and_swaps_key() { + let storage = in_memory_storage(); + storage.store("avatars/u1", &tiny_jpeg()).await.unwrap(); + let image_ref = MockImageRef::new(); + let handler = ImageConversionHandler::new( + Arc::clone(&storage) as Arc, + Arc::clone(&image_ref) as Arc, + Format::Avif, + ); + + handler.handle(&DomainEvent::ImageStored { key: "avatars/u1".into() }).await.unwrap(); + + assert_eq!(image_ref.swaps(), vec![("avatars/u1".into(), "avatars/u1.avif".into())]); + assert!(storage.get("avatars/u1.avif").await.is_ok()); + assert!(storage.get("avatars/u1").await.is_err()); +} + +#[tokio::test] +async fn converts_jpeg_to_webp_and_swaps_key() { + let storage = in_memory_storage(); + storage.store("avatars/u1", &tiny_jpeg()).await.unwrap(); + let image_ref = MockImageRef::new(); + let handler = ImageConversionHandler::new( + Arc::clone(&storage) as Arc, + Arc::clone(&image_ref) as Arc, + Format::Webp, + ); + + handler.handle(&DomainEvent::ImageStored { key: "avatars/u1".into() }).await.unwrap(); + + assert_eq!(image_ref.swaps(), vec![("avatars/u1".into(), "avatars/u1.webp".into())]); + assert!(storage.get("avatars/u1.webp").await.is_ok()); + assert!(storage.get("avatars/u1").await.is_err()); +} diff --git a/crates/adapters/image-storage/src/config.rs b/crates/adapters/image-storage/src/config.rs index 9503cef..4a8137b 100644 --- a/crates/adapters/image-storage/src/config.rs +++ b/crates/adapters/image-storage/src/config.rs @@ -62,22 +62,5 @@ fn build_local_store(path: &str) -> anyhow::Result> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn local_store_creates_dir_and_succeeds() { - let dir = std::env::temp_dir().join(format!("image_test_{}", uuid::Uuid::new_v4())); - let result = build_local_store(dir.to_str().unwrap()); - assert!(result.is_ok(), "expected Ok, got: {:?}", result.err()); - assert!(dir.exists(), "directory should have been created"); - } - - #[test] - fn local_store_succeeds_if_dir_already_exists() { - let dir = std::env::temp_dir().join(format!("image_test_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).unwrap(); - let result = build_local_store(dir.to_str().unwrap()); - assert!(result.is_ok()); - } -} +#[path = "tests/config.rs"] +mod tests; diff --git a/crates/adapters/image-storage/src/lib.rs b/crates/adapters/image-storage/src/lib.rs index a3e6a0a..37c8773 100644 --- a/crates/adapters/image-storage/src/lib.rs +++ b/crates/adapters/image-storage/src/lib.rs @@ -98,60 +98,5 @@ pub fn create() -> anyhow::Result> { } #[cfg(test)] -mod tests { - use super::*; - use object_store::memory::InMemory; - - fn adapter() -> ImageStorageAdapter { - ImageStorageAdapter::new(Arc::new(InMemory::new())) - } - - #[tokio::test] - async fn store_and_retrieve_round_trip() { - let adapter = adapter(); - let bytes = b"fake-image-bytes"; - let path = adapter.store("posters/abc123", bytes).await.unwrap(); - assert_eq!(path, "posters/abc123"); - let retrieved = adapter.get("posters/abc123").await.unwrap(); - assert_eq!(retrieved, bytes); - } - - #[tokio::test] - async fn get_missing_returns_not_found() { - let adapter = adapter(); - let result = adapter.get("nonexistent").await; - assert!(matches!(result, Err(DomainError::NotFound(_)))); - } - - #[tokio::test] - async fn delete_removes_key() { - let adapter = adapter(); - adapter.store("avatars/user1", b"img").await.unwrap(); - adapter.delete("avatars/user1").await.unwrap(); - let result = adapter.get("avatars/user1").await; - assert!(matches!(result, Err(DomainError::NotFound(_)))); - } - - #[tokio::test] - async fn delete_missing_returns_ok() { - let adapter = adapter(); - assert!(adapter.delete("does-not-exist").await.is_ok()); - } - - #[tokio::test] - async fn cleanup_handler_deletes_on_movie_deleted() { - use domain::{events::DomainEvent, value_objects::{MovieId, PosterPath}}; - let inner = Arc::new(adapter()); - inner.store("some-uuid", b"img").await.unwrap(); - let path = PosterPath::new("some-uuid".to_string()).unwrap(); - let handler = ImageCleanupHandler::new(Arc::clone(&inner) as Arc); - handler - .handle(&DomainEvent::MovieDeleted { - movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()), - poster_path: Some(path.clone()), - }) - .await - .unwrap(); - assert!(matches!(inner.get("some-uuid").await, Err(DomainError::NotFound(_)))); - } -} +#[path = "tests/lib.rs"] +mod tests; diff --git a/crates/adapters/image-storage/src/tests/config.rs b/crates/adapters/image-storage/src/tests/config.rs new file mode 100644 index 0000000..f0ee8cc --- /dev/null +++ b/crates/adapters/image-storage/src/tests/config.rs @@ -0,0 +1,17 @@ +use super::*; + +#[test] +fn local_store_creates_dir_and_succeeds() { + let dir = std::env::temp_dir().join(format!("image_test_{}", uuid::Uuid::new_v4())); + let result = build_local_store(dir.to_str().unwrap()); + assert!(result.is_ok(), "expected Ok, got: {:?}", result.err()); + assert!(dir.exists(), "directory should have been created"); +} + +#[test] +fn local_store_succeeds_if_dir_already_exists() { + let dir = std::env::temp_dir().join(format!("image_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let result = build_local_store(dir.to_str().unwrap()); + assert!(result.is_ok()); +} diff --git a/crates/adapters/image-storage/src/tests/lib.rs b/crates/adapters/image-storage/src/tests/lib.rs new file mode 100644 index 0000000..f6a4460 --- /dev/null +++ b/crates/adapters/image-storage/src/tests/lib.rs @@ -0,0 +1,55 @@ +use super::*; +use object_store::memory::InMemory; + +fn adapter() -> ImageStorageAdapter { + ImageStorageAdapter::new(Arc::new(InMemory::new())) +} + +#[tokio::test] +async fn store_and_retrieve_round_trip() { + let adapter = adapter(); + let bytes = b"fake-image-bytes"; + let path = adapter.store("posters/abc123", bytes).await.unwrap(); + assert_eq!(path, "posters/abc123"); + let retrieved = adapter.get("posters/abc123").await.unwrap(); + assert_eq!(retrieved, bytes); +} + +#[tokio::test] +async fn get_missing_returns_not_found() { + let adapter = adapter(); + let result = adapter.get("nonexistent").await; + assert!(matches!(result, Err(DomainError::NotFound(_)))); +} + +#[tokio::test] +async fn delete_removes_key() { + let adapter = adapter(); + adapter.store("avatars/user1", b"img").await.unwrap(); + adapter.delete("avatars/user1").await.unwrap(); + let result = adapter.get("avatars/user1").await; + assert!(matches!(result, Err(DomainError::NotFound(_)))); +} + +#[tokio::test] +async fn delete_missing_returns_ok() { + let adapter = adapter(); + assert!(adapter.delete("does-not-exist").await.is_ok()); +} + +#[tokio::test] +async fn cleanup_handler_deletes_on_movie_deleted() { + use domain::{events::DomainEvent, value_objects::{MovieId, PosterPath}}; + let inner = Arc::new(adapter()); + inner.store("some-uuid", b"img").await.unwrap(); + let path = PosterPath::new("some-uuid".to_string()).unwrap(); + let handler = ImageCleanupHandler::new(Arc::clone(&inner) as Arc); + handler + .handle(&DomainEvent::MovieDeleted { + movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()), + poster_path: Some(path.clone()), + }) + .await + .unwrap(); + assert!(matches!(inner.get("some-uuid").await, Err(DomainError::NotFound(_)))); +} diff --git a/crates/adapters/importer/src/mapper.rs b/crates/adapters/importer/src/mapper.rs index 8469b5a..a0cd221 100644 --- a/crates/adapters/importer/src/mapper.rs +++ b/crates/adapters/importer/src/mapper.rs @@ -76,119 +76,5 @@ fn set_field(row: &mut ImportRow, field: &DomainField, value: String) { } #[cfg(test)] -mod tests { - use super::*; - use domain::models::{DomainField, FieldMapping, ParsedFile, RowResult, Transform}; - - fn sample_file() -> ParsedFile { - ParsedFile { - columns: vec!["Name".into(), "Stars".into(), "Date".into()], - rows: vec![ - vec!["Inception".into(), "10".into(), "2024-01-15".into()], - vec!["Dune".into(), "8".into(), "2024-02-20".into()], - vec!["".into(), "3".into(), "2024-03-01".into()], // missing title → invalid - ], - } - } - - fn full_mappings() -> Vec { - vec![ - FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, - FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) }, - FieldMapping { source_column: "Date".into(), domain_field: DomainField::WatchedAt, transform: Transform::Identity }, - ] - } - - #[test] - fn maps_valid_rows() { - let results = apply_mapping(&sample_file(), &full_mappings()); - assert_eq!(results.len(), 3); - // First two rows are valid - assert!(matches!(results[0].result, RowResult::Valid(_))); - assert!(matches!(results[1].result, RowResult::Valid(_))); - // is_duplicate defaults to false - assert!(!results[0].is_duplicate); - } - - #[test] - fn applies_rating_scale_transform() { - let results = apply_mapping(&sample_file(), &full_mappings()); - if let RowResult::Valid(row) = &results[0].result { - // 10 * 0.5 = 5 - assert_eq!(row.rating.as_deref(), Some("5")); - } else { - panic!("expected Valid"); - } - } - - #[test] - fn marks_missing_required_fields_invalid() { - let results = apply_mapping(&sample_file(), &full_mappings()); - // Row 2 has empty title - assert!(matches!(results[2].result, RowResult::Invalid { .. })); - } - - #[test] - fn ignores_unmapped_columns() { - let mappings = vec![ - FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, - ]; - let file = ParsedFile { - columns: vec!["Name".into(), "Extra".into()], - rows: vec![vec!["Inception".into(), "ignored".into()]], - }; - let results = apply_mapping(&file, &mappings); - assert_eq!(results.len(), 1); - // Missing rating and watched_at → invalid - assert!(matches!(results[0].result, RowResult::Invalid { .. })); - } - - #[test] - fn nonexistent_source_column_skipped() { - let mappings = vec![ - FieldMapping { source_column: "DoesNotExist".into(), domain_field: DomainField::Title, transform: Transform::Identity }, - ]; - let file = ParsedFile { - columns: vec!["Name".into()], - rows: vec![vec!["Inception".into()]], - }; - let results = apply_mapping(&file, &mappings); - // Column not found → field not set → invalid (missing title, rating, watched_at) - assert!(matches!(results[0].result, RowResult::Invalid { .. })); - } - - #[test] - fn collects_all_errors_not_just_first() { - let mappings = vec![ - FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, - FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) }, - // no watched_at mapping - ]; - let file = ParsedFile { - columns: vec!["Name".into(), "Stars".into()], - rows: vec![vec!["Inception".into(), "notanumber".into()]], - }; - let results = apply_mapping(&file, &mappings); - if let RowResult::Invalid { errors, .. } = &results[0].result { - assert!(errors.iter().any(|e| e.contains("not a number")), "expected rating error, got: {:?}", errors); - assert!(errors.iter().any(|e| e.contains("watched_at")), "expected watched_at error, got: {:?}", errors); - } else { - panic!("expected Invalid"); - } - } - - #[test] - fn non_numeric_rating_produces_error_in_row() { - let mappings = vec![ - FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, - FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) }, - FieldMapping { source_column: "Date".into(), domain_field: DomainField::WatchedAt, transform: Transform::Identity }, - ]; - let file = ParsedFile { - columns: vec!["Name".into(), "Stars".into(), "Date".into()], - rows: vec![vec!["Inception".into(), "five".into(), "2024-01-15".into()]], - }; - let results = apply_mapping(&file, &mappings); - assert!(matches!(results[0].result, RowResult::Invalid { .. })); - } -} +#[path = "tests/mapper.rs"] +mod tests; diff --git a/crates/adapters/importer/src/parsers/mod.rs b/crates/adapters/importer/src/parsers/mod.rs index 83d297a..32daef7 100644 --- a/crates/adapters/importer/src/parsers/mod.rs +++ b/crates/adapters/importer/src/parsers/mod.rs @@ -9,42 +9,5 @@ pub use json::parse_json; pub use xlsx::parse_xlsx; #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn csv_parses_headers_and_rows() { - let data = b"title,rating,watched_at\nInception,5,2024-01-01\nDune,4,2024-02-15\n"; - let file = parse_csv(data).unwrap(); - assert_eq!(file.columns, vec!["title", "rating", "watched_at"]); - assert_eq!(file.rows.len(), 2); - assert_eq!(file.rows[0], vec!["Inception", "5", "2024-01-01"]); - } - - #[test] - fn csv_rejects_empty() { - assert!(parse_csv(b"").is_err()); - } - - #[test] - fn tsv_parses_correctly() { - let data = b"title\trating\nInception\t5\n"; - let file = parse_csv(data).unwrap(); - assert_eq!(file.columns, vec!["title", "rating"]); - assert_eq!(file.rows[0], vec!["Inception", "5"]); - } - - #[test] - fn json_array_of_objects() { - let data = br#"[{"title":"Inception","rating":"5"},{"title":"Dune","rating":"4"}]"#; - let file = parse_json(data).unwrap(); - assert_eq!(file.columns.len(), 2); - assert!(file.columns.contains(&"title".to_string())); - assert_eq!(file.rows.len(), 2); - } - - #[test] - fn json_empty_array_errors() { - assert!(parse_json(b"[]").is_err()); - } -} +#[path = "tests.rs"] +mod tests; diff --git a/crates/adapters/importer/src/parsers/tests.rs b/crates/adapters/importer/src/parsers/tests.rs new file mode 100644 index 0000000..0d44c4b --- /dev/null +++ b/crates/adapters/importer/src/parsers/tests.rs @@ -0,0 +1,37 @@ +use super::*; + +#[test] +fn csv_parses_headers_and_rows() { + let data = b"title,rating,watched_at\nInception,5,2024-01-01\nDune,4,2024-02-15\n"; + let file = parse_csv(data).unwrap(); + assert_eq!(file.columns, vec!["title", "rating", "watched_at"]); + assert_eq!(file.rows.len(), 2); + assert_eq!(file.rows[0], vec!["Inception", "5", "2024-01-01"]); +} + +#[test] +fn csv_rejects_empty() { + assert!(parse_csv(b"").is_err()); +} + +#[test] +fn tsv_parses_correctly() { + let data = b"title\trating\nInception\t5\n"; + let file = parse_csv(data).unwrap(); + assert_eq!(file.columns, vec!["title", "rating"]); + assert_eq!(file.rows[0], vec!["Inception", "5"]); +} + +#[test] +fn json_array_of_objects() { + let data = br#"[{"title":"Inception","rating":"5"},{"title":"Dune","rating":"4"}]"#; + let file = parse_json(data).unwrap(); + assert_eq!(file.columns.len(), 2); + assert!(file.columns.contains(&"title".to_string())); + assert_eq!(file.rows.len(), 2); +} + +#[test] +fn json_empty_array_errors() { + assert!(parse_json(b"[]").is_err()); +} diff --git a/crates/adapters/importer/src/tests/mapper.rs b/crates/adapters/importer/src/tests/mapper.rs new file mode 100644 index 0000000..05483d7 --- /dev/null +++ b/crates/adapters/importer/src/tests/mapper.rs @@ -0,0 +1,114 @@ +use super::*; +use domain::models::{DomainField, FieldMapping, ParsedFile, RowResult, Transform}; + +fn sample_file() -> ParsedFile { + ParsedFile { + columns: vec!["Name".into(), "Stars".into(), "Date".into()], + rows: vec![ + vec!["Inception".into(), "10".into(), "2024-01-15".into()], + vec!["Dune".into(), "8".into(), "2024-02-20".into()], + vec!["".into(), "3".into(), "2024-03-01".into()], // missing title → invalid + ], + } +} + +fn full_mappings() -> Vec { + vec![ + FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, + FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) }, + FieldMapping { source_column: "Date".into(), domain_field: DomainField::WatchedAt, transform: Transform::Identity }, + ] +} + +#[test] +fn maps_valid_rows() { + let results = apply_mapping(&sample_file(), &full_mappings()); + assert_eq!(results.len(), 3); + // First two rows are valid + assert!(matches!(results[0].result, RowResult::Valid(_))); + assert!(matches!(results[1].result, RowResult::Valid(_))); + // is_duplicate defaults to false + assert!(!results[0].is_duplicate); +} + +#[test] +fn applies_rating_scale_transform() { + let results = apply_mapping(&sample_file(), &full_mappings()); + if let RowResult::Valid(row) = &results[0].result { + // 10 * 0.5 = 5 + assert_eq!(row.rating.as_deref(), Some("5")); + } else { + panic!("expected Valid"); + } +} + +#[test] +fn marks_missing_required_fields_invalid() { + let results = apply_mapping(&sample_file(), &full_mappings()); + // Row 2 has empty title + assert!(matches!(results[2].result, RowResult::Invalid { .. })); +} + +#[test] +fn ignores_unmapped_columns() { + let mappings = vec![ + FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, + ]; + let file = ParsedFile { + columns: vec!["Name".into(), "Extra".into()], + rows: vec![vec!["Inception".into(), "ignored".into()]], + }; + let results = apply_mapping(&file, &mappings); + assert_eq!(results.len(), 1); + // Missing rating and watched_at → invalid + assert!(matches!(results[0].result, RowResult::Invalid { .. })); +} + +#[test] +fn nonexistent_source_column_skipped() { + let mappings = vec![ + FieldMapping { source_column: "DoesNotExist".into(), domain_field: DomainField::Title, transform: Transform::Identity }, + ]; + let file = ParsedFile { + columns: vec!["Name".into()], + rows: vec![vec!["Inception".into()]], + }; + let results = apply_mapping(&file, &mappings); + // Column not found → field not set → invalid (missing title, rating, watched_at) + assert!(matches!(results[0].result, RowResult::Invalid { .. })); +} + +#[test] +fn collects_all_errors_not_just_first() { + let mappings = vec![ + FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, + FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) }, + // no watched_at mapping + ]; + let file = ParsedFile { + columns: vec!["Name".into(), "Stars".into()], + rows: vec![vec!["Inception".into(), "notanumber".into()]], + }; + let results = apply_mapping(&file, &mappings); + if let RowResult::Invalid { errors, .. } = &results[0].result { + assert!(errors.iter().any(|e| e.contains("not a number")), "expected rating error, got: {:?}", errors); + assert!(errors.iter().any(|e| e.contains("watched_at")), "expected watched_at error, got: {:?}", errors); + } else { + panic!("expected Invalid"); + } +} + +#[test] +fn non_numeric_rating_produces_error_in_row() { + let mappings = vec![ + FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, + FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) }, + FieldMapping { source_column: "Date".into(), domain_field: DomainField::WatchedAt, transform: Transform::Identity }, + ]; + let file = ParsedFile { + columns: vec!["Name".into(), "Stars".into(), "Date".into()], + rows: vec![vec!["Inception".into(), "five".into(), "2024-01-15".into()]], + }; + let results = apply_mapping(&file, &mappings); + assert!(matches!(results[0].result, RowResult::Invalid { .. })); +} diff --git a/crates/adapters/nats/src/config.rs b/crates/adapters/nats/src/config.rs index 402e63d..4e1b3c7 100644 --- a/crates/adapters/nats/src/config.rs +++ b/crates/adapters/nats/src/config.rs @@ -39,63 +39,5 @@ impl NatsConfig { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn errors_without_nats_url() { - unsafe { std::env::remove_var("NATS_URL"); } - assert!(NatsConfig::from_env().is_err()); - } - - #[test] - fn defaults_with_only_url() { - unsafe { - std::env::set_var("NATS_URL", "nats://localhost:4222"); - std::env::remove_var("NATS_MODE"); - std::env::remove_var("NATS_SUBJECT_PREFIX"); - std::env::remove_var("NATS_STREAM_NAME"); - std::env::remove_var("NATS_CONSUMER_NAME"); - } - - let cfg = NatsConfig::from_env().unwrap(); - assert_eq!(cfg.url, "nats://localhost:4222"); - assert_eq!(cfg.mode, NatsMode::JetStream); - assert_eq!(cfg.subject_prefix, "movies-diary.events"); - assert_eq!(cfg.stream_name, "MOVIES_DIARY_EVENTS"); - assert_eq!(cfg.consumer_name, "worker"); - - unsafe { std::env::remove_var("NATS_URL"); } - } - - #[test] - fn core_mode_parsed() { - unsafe { - std::env::set_var("NATS_URL", "nats://test:4222"); - std::env::set_var("NATS_MODE", "core"); - } - - let cfg = NatsConfig::from_env().unwrap(); - assert_eq!(cfg.mode, NatsMode::Core); - - unsafe { - std::env::remove_var("NATS_URL"); - std::env::remove_var("NATS_MODE"); - } - } - - #[test] - fn invalid_mode_errors() { - unsafe { - std::env::set_var("NATS_URL", "nats://test:4222"); - std::env::set_var("NATS_MODE", "kafka"); - } - - assert!(NatsConfig::from_env().is_err()); - - unsafe { - std::env::remove_var("NATS_URL"); - std::env::remove_var("NATS_MODE"); - } - } -} +#[path = "tests/config.rs"] +mod tests; diff --git a/crates/adapters/nats/src/subject.rs b/crates/adapters/nats/src/subject.rs index de8b5b5..8c44f14 100644 --- a/crates/adapters/nats/src/subject.rs +++ b/crates/adapters/nats/src/subject.rs @@ -19,63 +19,5 @@ pub fn consumer_subject_filter(prefix: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - use chrono::NaiveDateTime; - use domain::value_objects::{ExternalMetadataId, MovieId, Rating, ReviewId, UserId}; - use uuid::Uuid; - - fn dt() -> NaiveDateTime { - chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc() - } - - #[test] - fn review_logged_subject() { - let event = DomainEvent::ReviewLogged { - review_id: ReviewId::from_uuid(Uuid::new_v4()), - movie_id: MovieId::from_uuid(Uuid::new_v4()), - user_id: UserId::from_uuid(Uuid::new_v4()), - rating: Rating::new(3).unwrap(), - watched_at: dt(), - }; - assert_eq!( - event_to_subject("movies-diary.events", &event), - "movies-diary.events.review.logged" - ); - } - - #[test] - fn review_updated_subject() { - let event = DomainEvent::ReviewUpdated { - review_id: ReviewId::from_uuid(Uuid::new_v4()), - movie_id: MovieId::from_uuid(Uuid::new_v4()), - user_id: UserId::from_uuid(Uuid::new_v4()), - rating: Rating::new(5).unwrap(), - watched_at: dt(), - }; - assert_eq!( - event_to_subject("movies-diary.events", &event), - "movies-diary.events.review.updated" - ); - } - - #[test] - fn movie_discovered_subject() { - let event = DomainEvent::MovieDiscovered { - movie_id: MovieId::from_uuid(Uuid::new_v4()), - external_metadata_id: ExternalMetadataId::new("tt0000001".into()).unwrap(), - }; - assert_eq!( - event_to_subject("movies-diary.events", &event), - "movies-diary.events.movie.discovered" - ); - } - - #[test] - fn consumer_subject_filter_appends_wildcard() { - assert_eq!( - consumer_subject_filter("movies-diary.events"), - "movies-diary.events.>" - ); - } -} +#[path = "tests/subject.rs"] +mod tests; diff --git a/crates/adapters/nats/src/tests/config.rs b/crates/adapters/nats/src/tests/config.rs new file mode 100644 index 0000000..4187074 --- /dev/null +++ b/crates/adapters/nats/src/tests/config.rs @@ -0,0 +1,58 @@ +use super::*; + +#[test] +fn errors_without_nats_url() { + unsafe { std::env::remove_var("NATS_URL"); } + assert!(NatsConfig::from_env().is_err()); +} + +#[test] +fn defaults_with_only_url() { + unsafe { + std::env::set_var("NATS_URL", "nats://localhost:4222"); + std::env::remove_var("NATS_MODE"); + std::env::remove_var("NATS_SUBJECT_PREFIX"); + std::env::remove_var("NATS_STREAM_NAME"); + std::env::remove_var("NATS_CONSUMER_NAME"); + } + + let cfg = NatsConfig::from_env().unwrap(); + assert_eq!(cfg.url, "nats://localhost:4222"); + assert_eq!(cfg.mode, NatsMode::JetStream); + assert_eq!(cfg.subject_prefix, "movies-diary.events"); + assert_eq!(cfg.stream_name, "MOVIES_DIARY_EVENTS"); + assert_eq!(cfg.consumer_name, "worker"); + + unsafe { std::env::remove_var("NATS_URL"); } +} + +#[test] +fn core_mode_parsed() { + unsafe { + std::env::set_var("NATS_URL", "nats://test:4222"); + std::env::set_var("NATS_MODE", "core"); + } + + let cfg = NatsConfig::from_env().unwrap(); + assert_eq!(cfg.mode, NatsMode::Core); + + unsafe { + std::env::remove_var("NATS_URL"); + std::env::remove_var("NATS_MODE"); + } +} + +#[test] +fn invalid_mode_errors() { + unsafe { + std::env::set_var("NATS_URL", "nats://test:4222"); + std::env::set_var("NATS_MODE", "kafka"); + } + + assert!(NatsConfig::from_env().is_err()); + + unsafe { + std::env::remove_var("NATS_URL"); + std::env::remove_var("NATS_MODE"); + } +} diff --git a/crates/adapters/nats/src/tests/subject.rs b/crates/adapters/nats/src/tests/subject.rs new file mode 100644 index 0000000..4bfd721 --- /dev/null +++ b/crates/adapters/nats/src/tests/subject.rs @@ -0,0 +1,58 @@ +use super::*; +use chrono::NaiveDateTime; +use domain::value_objects::{ExternalMetadataId, MovieId, Rating, ReviewId, UserId}; +use uuid::Uuid; + +fn dt() -> NaiveDateTime { + chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc() +} + +#[test] +fn review_logged_subject() { + let event = DomainEvent::ReviewLogged { + review_id: ReviewId::from_uuid(Uuid::new_v4()), + movie_id: MovieId::from_uuid(Uuid::new_v4()), + user_id: UserId::from_uuid(Uuid::new_v4()), + rating: Rating::new(3).unwrap(), + watched_at: dt(), + }; + assert_eq!( + event_to_subject("movies-diary.events", &event), + "movies-diary.events.review.logged" + ); +} + +#[test] +fn review_updated_subject() { + let event = DomainEvent::ReviewUpdated { + review_id: ReviewId::from_uuid(Uuid::new_v4()), + movie_id: MovieId::from_uuid(Uuid::new_v4()), + user_id: UserId::from_uuid(Uuid::new_v4()), + rating: Rating::new(5).unwrap(), + watched_at: dt(), + }; + assert_eq!( + event_to_subject("movies-diary.events", &event), + "movies-diary.events.review.updated" + ); +} + +#[test] +fn movie_discovered_subject() { + let event = DomainEvent::MovieDiscovered { + movie_id: MovieId::from_uuid(Uuid::new_v4()), + external_metadata_id: ExternalMetadataId::new("tt0000001".into()).unwrap(), + }; + assert_eq!( + event_to_subject("movies-diary.events", &event), + "movies-diary.events.movie.discovered" + ); +} + +#[test] +fn consumer_subject_filter_appends_wildcard() { + assert_eq!( + consumer_subject_filter("movies-diary.events"), + "movies-diary.events.>" + ); +} diff --git a/crates/adapters/rss/src/lib.rs b/crates/adapters/rss/src/lib.rs index f751ff7..6eb1aef 100644 --- a/crates/adapters/rss/src/lib.rs +++ b/crates/adapters/rss/src/lib.rs @@ -57,20 +57,5 @@ impl RssFeedRenderer for RssAdapter { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn render_feed_uses_provided_title() { - let adapter = RssAdapter::new("http://example.com".into()); - let xml = adapter.render_feed(&[], "Custom Title").unwrap(); - assert!(xml.contains("Custom Title")); - } - - #[test] - fn render_feed_empty_entries_produces_valid_xml() { - let adapter = RssAdapter::new("http://example.com".into()); - let xml = adapter.render_feed(&[], "My Feed").unwrap(); - assert!(xml.starts_with("Custom Title")); +} + +#[test] +fn render_feed_empty_entries_produces_valid_xml() { + let adapter = RssAdapter::new("http://example.com".into()); + let xml = adapter.render_feed(&[], "My Feed").unwrap(); + assert!(xml.starts_with(" ( } #[cfg(test)] -mod actor_block_tests { - use super::*; - use sqlx::SqlitePool; - - async fn test_pool() -> SqlitePool { - let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); - sqlx::query("CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, password_hash TEXT, created_at TEXT)") - .execute(&pool).await.unwrap(); - sqlx::query("CREATE TABLE blocked_actors (local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, remote_actor_url TEXT NOT NULL, blocked_at TEXT NOT NULL, PRIMARY KEY (local_user_id, remote_actor_url))") - .execute(&pool).await.unwrap(); - let uid = uuid::Uuid::new_v4().to_string(); - sqlx::query("INSERT INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)") - .bind(&uid).bind("a@b.com").bind("hash").bind("2024-01-01") - .execute(&pool).await.unwrap(); - pool - } - - #[tokio::test] - async fn block_and_check_actor() { - let pool = test_pool().await; - let user_id = uuid::Uuid::parse_str( - &sqlx::query_scalar::<_, String>("SELECT id FROM users LIMIT 1") - .fetch_one(&pool).await.unwrap() - ).unwrap(); - let repo = SqliteFederationRepository::new(pool); - let actor_url = "https://mastodon.social/users/alice"; - assert!(!repo.is_actor_blocked(user_id, actor_url).await.unwrap()); - repo.add_blocked_actor(user_id, actor_url).await.unwrap(); - assert!(repo.is_actor_blocked(user_id, actor_url).await.unwrap()); - let list = repo.get_blocked_actors(user_id).await.unwrap(); - assert_eq!(list, vec![actor_url.to_string()]); - repo.remove_blocked_actor(user_id, actor_url).await.unwrap(); - assert!(!repo.is_actor_blocked(user_id, actor_url).await.unwrap()); - } -} +#[path = "tests/actor_block_tests.rs"] +mod actor_block_tests; #[cfg(test)] -mod domain_block_tests { - use super::*; - use sqlx::SqlitePool; - - async fn test_pool() -> SqlitePool { - let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); - sqlx::query("CREATE TABLE blocked_domains (domain TEXT PRIMARY KEY, reason TEXT, blocked_at TEXT NOT NULL)") - .execute(&pool).await.unwrap(); - pool - } - - #[tokio::test] - async fn blocked_domain_is_detected() { - let pool = test_pool().await; - let repo = SqliteFederationRepository::new(pool); - assert!(!repo.is_domain_blocked("mastodon.social").await.unwrap()); - repo.add_blocked_domain("mastodon.social", Some("spam")).await.unwrap(); - assert!(repo.is_domain_blocked("mastodon.social").await.unwrap()); - } - - #[tokio::test] - async fn remove_unblocks_domain() { - let pool = test_pool().await; - let repo = SqliteFederationRepository::new(pool); - repo.add_blocked_domain("spam.xyz", None).await.unwrap(); - repo.remove_blocked_domain("spam.xyz").await.unwrap(); - assert!(!repo.is_domain_blocked("spam.xyz").await.unwrap()); - } - - #[tokio::test] - async fn get_blocked_domains_returns_all() { - let pool = test_pool().await; - let repo = SqliteFederationRepository::new(pool); - repo.add_blocked_domain("a.com", Some("reason a")).await.unwrap(); - repo.add_blocked_domain("b.com", None).await.unwrap(); - let domains = repo.get_blocked_domains().await.unwrap(); - assert_eq!(domains.len(), 2); - } -} +#[path = "tests/domain_block_tests.rs"] +mod domain_block_tests; #[cfg(test)] -mod tests { - use super::*; - use chrono::Utc; - use domain::ports::SocialQueryPort; - use sqlx::SqlitePool; - - async fn test_pool() -> SqlitePool { - let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); - sqlx::query("CREATE TABLE ap_announces (id TEXT PRIMARY KEY, object_url TEXT NOT NULL, actor_url TEXT NOT NULL, announced_at TEXT NOT NULL)") - .execute(&pool).await.unwrap(); - pool - } - - #[tokio::test] - async fn add_announce_stores_and_counts() { - let pool = test_pool().await; - let repo = SqliteFederationRepository::new(pool); - repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap(); - assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1); - } - - #[tokio::test] - async fn duplicate_announce_is_ignored() { - let pool = test_pool().await; - let repo = SqliteFederationRepository::new(pool); - repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap(); - repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap(); - assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1); - } - - async fn setup_db(pool: &SqlitePool) { - sqlx::query( - "CREATE TABLE IF NOT EXISTS ap_remote_actors ( - url TEXT PRIMARY KEY, - handle TEXT NOT NULL, - inbox_url TEXT NOT NULL, - shared_inbox_url TEXT, - display_name TEXT, - avatar_url TEXT, - fetched_at TEXT NOT NULL - )", - ) - .execute(pool) - .await - .unwrap(); - - sqlx::query( - "CREATE TABLE IF NOT EXISTS ap_following ( - local_user_id TEXT NOT NULL, - remote_actor_url TEXT NOT NULL, - follow_activity_id TEXT NOT NULL, - status TEXT NOT NULL, - PRIMARY KEY (local_user_id, remote_actor_url) - )", - ) - .execute(pool) - .await - .unwrap(); - } - - #[tokio::test] - async fn test_get_accepted_following_urls_returns_only_accepted() { - let pool = SqlitePool::connect(":memory:").await.unwrap(); - setup_db(&pool).await; - let repo = SqliteFederationRepository::new(pool.clone()); - let user_id = uuid::Uuid::new_v4(); - - sqlx::query( - "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status) - VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'), - (?, 'https://other.social/users/bob', 'act2', 'pending')", - ) - .bind(user_id.to_string()) - .bind(user_id.to_string()) - .execute(&pool) - .await - .unwrap(); - - let urls = repo.get_accepted_following_urls(user_id).await.unwrap(); - assert_eq!(urls.len(), 1); - assert_eq!(urls[0], "https://other.social/users/alice"); - } - - #[tokio::test] - async fn test_list_all_followed_remote_actors_deduplicates() { - let pool = SqlitePool::connect(":memory:").await.unwrap(); - setup_db(&pool).await; - let repo = SqliteFederationRepository::new(pool.clone()); - let user1 = uuid::Uuid::new_v4(); - let user2 = uuid::Uuid::new_v4(); - - sqlx::query( - "INSERT INTO ap_remote_actors (url, handle, inbox_url, fetched_at, display_name) - VALUES ('https://other.social/users/alice', 'alice@other.social', 'https://other.social/inbox', '2024-01-01', 'Alice')", - ) - .execute(&pool) - .await - .unwrap(); - - sqlx::query( - "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status) - VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'), - (?, 'https://other.social/users/alice', 'act2', 'accepted')", - ) - .bind(user1.to_string()) - .bind(user2.to_string()) - .execute(&pool) - .await - .unwrap(); - - let actors = repo.list_all_followed_remote_actors().await.unwrap(); - assert_eq!(actors.len(), 1); - assert_eq!(actors[0].handle, "alice@other.social"); - } -} +#[path = "tests/lib.rs"] +mod tests; diff --git a/crates/adapters/sqlite-federation/src/tests/actor_block_tests.rs b/crates/adapters/sqlite-federation/src/tests/actor_block_tests.rs new file mode 100644 index 0000000..02f7c5b --- /dev/null +++ b/crates/adapters/sqlite-federation/src/tests/actor_block_tests.rs @@ -0,0 +1,33 @@ +use super::*; +use sqlx::SqlitePool; + +async fn test_pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, password_hash TEXT, created_at TEXT)") + .execute(&pool).await.unwrap(); + sqlx::query("CREATE TABLE blocked_actors (local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, remote_actor_url TEXT NOT NULL, blocked_at TEXT NOT NULL, PRIMARY KEY (local_user_id, remote_actor_url))") + .execute(&pool).await.unwrap(); + let uid = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)") + .bind(&uid).bind("a@b.com").bind("hash").bind("2024-01-01") + .execute(&pool).await.unwrap(); + pool +} + +#[tokio::test] +async fn block_and_check_actor() { + let pool = test_pool().await; + let user_id = uuid::Uuid::parse_str( + &sqlx::query_scalar::<_, String>("SELECT id FROM users LIMIT 1") + .fetch_one(&pool).await.unwrap() + ).unwrap(); + let repo = SqliteFederationRepository::new(pool); + let actor_url = "https://mastodon.social/users/alice"; + assert!(!repo.is_actor_blocked(user_id, actor_url).await.unwrap()); + repo.add_blocked_actor(user_id, actor_url).await.unwrap(); + assert!(repo.is_actor_blocked(user_id, actor_url).await.unwrap()); + let list = repo.get_blocked_actors(user_id).await.unwrap(); + assert_eq!(list, vec![actor_url.to_string()]); + repo.remove_blocked_actor(user_id, actor_url).await.unwrap(); + assert!(!repo.is_actor_blocked(user_id, actor_url).await.unwrap()); +} diff --git a/crates/adapters/sqlite-federation/src/tests/domain_block_tests.rs b/crates/adapters/sqlite-federation/src/tests/domain_block_tests.rs new file mode 100644 index 0000000..c96c7af --- /dev/null +++ b/crates/adapters/sqlite-federation/src/tests/domain_block_tests.rs @@ -0,0 +1,37 @@ +use super::*; +use sqlx::SqlitePool; + +async fn test_pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE blocked_domains (domain TEXT PRIMARY KEY, reason TEXT, blocked_at TEXT NOT NULL)") + .execute(&pool).await.unwrap(); + pool +} + +#[tokio::test] +async fn blocked_domain_is_detected() { + let pool = test_pool().await; + let repo = SqliteFederationRepository::new(pool); + assert!(!repo.is_domain_blocked("mastodon.social").await.unwrap()); + repo.add_blocked_domain("mastodon.social", Some("spam")).await.unwrap(); + assert!(repo.is_domain_blocked("mastodon.social").await.unwrap()); +} + +#[tokio::test] +async fn remove_unblocks_domain() { + let pool = test_pool().await; + let repo = SqliteFederationRepository::new(pool); + repo.add_blocked_domain("spam.xyz", None).await.unwrap(); + repo.remove_blocked_domain("spam.xyz").await.unwrap(); + assert!(!repo.is_domain_blocked("spam.xyz").await.unwrap()); +} + +#[tokio::test] +async fn get_blocked_domains_returns_all() { + let pool = test_pool().await; + let repo = SqliteFederationRepository::new(pool); + repo.add_blocked_domain("a.com", Some("reason a")).await.unwrap(); + repo.add_blocked_domain("b.com", None).await.unwrap(); + let domains = repo.get_blocked_domains().await.unwrap(); + assert_eq!(domains.len(), 2); +} diff --git a/crates/adapters/sqlite-federation/src/tests/lib.rs b/crates/adapters/sqlite-federation/src/tests/lib.rs new file mode 100644 index 0000000..1455ef4 --- /dev/null +++ b/crates/adapters/sqlite-federation/src/tests/lib.rs @@ -0,0 +1,113 @@ +use super::*; +use chrono::Utc; +use domain::ports::SocialQueryPort; +use sqlx::SqlitePool; + +async fn test_pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE ap_announces (id TEXT PRIMARY KEY, object_url TEXT NOT NULL, actor_url TEXT NOT NULL, announced_at TEXT NOT NULL)") + .execute(&pool).await.unwrap(); + pool +} + +#[tokio::test] +async fn add_announce_stores_and_counts() { + let pool = test_pool().await; + let repo = SqliteFederationRepository::new(pool); + repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap(); + assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1); +} + +#[tokio::test] +async fn duplicate_announce_is_ignored() { + let pool = test_pool().await; + let repo = SqliteFederationRepository::new(pool); + repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap(); + repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap(); + assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1); +} + +async fn setup_db(pool: &SqlitePool) { + sqlx::query( + "CREATE TABLE IF NOT EXISTS ap_remote_actors ( + url TEXT PRIMARY KEY, + handle TEXT NOT NULL, + inbox_url TEXT NOT NULL, + shared_inbox_url TEXT, + display_name TEXT, + avatar_url TEXT, + fetched_at TEXT NOT NULL + )", + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query( + "CREATE TABLE IF NOT EXISTS ap_following ( + local_user_id TEXT NOT NULL, + remote_actor_url TEXT NOT NULL, + follow_activity_id TEXT NOT NULL, + status TEXT NOT NULL, + PRIMARY KEY (local_user_id, remote_actor_url) + )", + ) + .execute(pool) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_get_accepted_following_urls_returns_only_accepted() { + let pool = SqlitePool::connect(":memory:").await.unwrap(); + setup_db(&pool).await; + let repo = SqliteFederationRepository::new(pool.clone()); + let user_id = uuid::Uuid::new_v4(); + + sqlx::query( + "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status) + VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'), + (?, 'https://other.social/users/bob', 'act2', 'pending')", + ) + .bind(user_id.to_string()) + .bind(user_id.to_string()) + .execute(&pool) + .await + .unwrap(); + + let urls = repo.get_accepted_following_urls(user_id).await.unwrap(); + assert_eq!(urls.len(), 1); + assert_eq!(urls[0], "https://other.social/users/alice"); +} + +#[tokio::test] +async fn test_list_all_followed_remote_actors_deduplicates() { + let pool = SqlitePool::connect(":memory:").await.unwrap(); + setup_db(&pool).await; + let repo = SqliteFederationRepository::new(pool.clone()); + let user1 = uuid::Uuid::new_v4(); + let user2 = uuid::Uuid::new_v4(); + + sqlx::query( + "INSERT INTO ap_remote_actors (url, handle, inbox_url, fetched_at, display_name) + VALUES ('https://other.social/users/alice', 'alice@other.social', 'https://other.social/inbox', '2024-01-01', 'Alice')", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status) + VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'), + (?, 'https://other.social/users/alice', 'act2', 'accepted')", + ) + .bind(user1.to_string()) + .bind(user2.to_string()) + .execute(&pool) + .await + .unwrap(); + + let actors = repo.list_all_followed_remote_actors().await.unwrap(); + assert_eq!(actors.len(), 1); + assert_eq!(actors[0].handle, "alice@other.social"); +} diff --git a/crates/adapters/sqlite/src/image_ref.rs b/crates/adapters/sqlite/src/image_ref.rs index b1322cb..b747794 100644 --- a/crates/adapters/sqlite/src/image_ref.rs +++ b/crates/adapters/sqlite/src/image_ref.rs @@ -52,108 +52,5 @@ impl ImageRefQuery for SqliteImageRefAdapter { } #[cfg(test)] -mod tests { - use super::*; - - async fn setup(pool: &SqlitePool) { - sqlx::query( - "CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - created_at TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'standard', - bio TEXT, - avatar_path TEXT - )", - ) - .execute(pool) - .await - .unwrap(); - - sqlx::query( - "CREATE TABLE IF NOT EXISTS movies ( - id TEXT PRIMARY KEY, - external_metadata_id TEXT, - title TEXT NOT NULL, - release_year INTEGER, - director TEXT, - poster_path TEXT - )", - ) - .execute(pool) - .await - .unwrap(); - } - - #[tokio::test] - async fn list_keys_returns_both_avatar_and_poster_paths() { - let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); - setup(&pool).await; - - sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,'avatars/u1')") - .execute(&pool).await.unwrap(); - sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')") - .execute(&pool).await.unwrap(); - - let adapter = SqliteImageRefAdapter::new(pool); - let mut keys = adapter.list_keys().await.unwrap(); - keys.sort(); - - assert_eq!(keys, vec!["avatars/u1", "posters/m1"]); - } - - #[tokio::test] - async fn list_keys_excludes_nulls() { - let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); - setup(&pool).await; - - sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,NULL)") - .execute(&pool).await.unwrap(); - - let adapter = SqliteImageRefAdapter::new(pool); - assert_eq!(adapter.list_keys().await.unwrap(), Vec::::new()); - } - - #[tokio::test] - async fn swap_updates_avatar_path() { - let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); - setup(&pool).await; - - sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,'avatars/u1')") - .execute(&pool).await.unwrap(); - - let adapter = SqliteImageRefAdapter::new(pool.clone()); - adapter.swap("avatars/u1", "avatars/u1.avif").await.unwrap(); - - let row: (Option,) = sqlx::query_as("SELECT avatar_path FROM users WHERE id='u1'") - .fetch_one(&pool).await.unwrap(); - assert_eq!(row.0.as_deref(), Some("avatars/u1.avif")); - } - - #[tokio::test] - async fn swap_updates_poster_path() { - let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); - setup(&pool).await; - - sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')") - .execute(&pool).await.unwrap(); - - let adapter = SqliteImageRefAdapter::new(pool.clone()); - adapter.swap("posters/m1", "posters/m1.avif").await.unwrap(); - - let row: (Option,) = sqlx::query_as("SELECT poster_path FROM movies WHERE id='m1'") - .fetch_one(&pool).await.unwrap(); - assert_eq!(row.0.as_deref(), Some("posters/m1.avif")); - } - - #[tokio::test] - async fn swap_noop_when_key_not_found() { - let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); - setup(&pool).await; - - let adapter = SqliteImageRefAdapter::new(pool); - adapter.swap("missing/key", "missing/key.avif").await.unwrap(); - } -} +#[path = "tests/image_ref.rs"] +mod tests; diff --git a/crates/adapters/sqlite/src/tests/image_ref.rs b/crates/adapters/sqlite/src/tests/image_ref.rs new file mode 100644 index 0000000..2a12cd4 --- /dev/null +++ b/crates/adapters/sqlite/src/tests/image_ref.rs @@ -0,0 +1,103 @@ +use super::*; + +async fn setup(pool: &SqlitePool) { + sqlx::query( + "CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'standard', + bio TEXT, + avatar_path TEXT + )", + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query( + "CREATE TABLE IF NOT EXISTS movies ( + id TEXT PRIMARY KEY, + external_metadata_id TEXT, + title TEXT NOT NULL, + release_year INTEGER, + director TEXT, + poster_path TEXT + )", + ) + .execute(pool) + .await + .unwrap(); +} + +#[tokio::test] +async fn list_keys_returns_both_avatar_and_poster_paths() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + setup(&pool).await; + + sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,'avatars/u1')") + .execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')") + .execute(&pool).await.unwrap(); + + let adapter = SqliteImageRefAdapter::new(pool); + let mut keys = adapter.list_keys().await.unwrap(); + keys.sort(); + + assert_eq!(keys, vec!["avatars/u1", "posters/m1"]); +} + +#[tokio::test] +async fn list_keys_excludes_nulls() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + setup(&pool).await; + + sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,NULL)") + .execute(&pool).await.unwrap(); + + let adapter = SqliteImageRefAdapter::new(pool); + assert_eq!(adapter.list_keys().await.unwrap(), Vec::::new()); +} + +#[tokio::test] +async fn swap_updates_avatar_path() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + setup(&pool).await; + + sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,'avatars/u1')") + .execute(&pool).await.unwrap(); + + let adapter = SqliteImageRefAdapter::new(pool.clone()); + adapter.swap("avatars/u1", "avatars/u1.avif").await.unwrap(); + + let row: (Option,) = sqlx::query_as("SELECT avatar_path FROM users WHERE id='u1'") + .fetch_one(&pool).await.unwrap(); + assert_eq!(row.0.as_deref(), Some("avatars/u1.avif")); +} + +#[tokio::test] +async fn swap_updates_poster_path() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + setup(&pool).await; + + sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')") + .execute(&pool).await.unwrap(); + + let adapter = SqliteImageRefAdapter::new(pool.clone()); + adapter.swap("posters/m1", "posters/m1.avif").await.unwrap(); + + let row: (Option,) = sqlx::query_as("SELECT poster_path FROM movies WHERE id='m1'") + .fetch_one(&pool).await.unwrap(); + assert_eq!(row.0.as_deref(), Some("posters/m1.avif")); +} + +#[tokio::test] +async fn swap_noop_when_key_not_found() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + setup(&pool).await; + + let adapter = SqliteImageRefAdapter::new(pool); + adapter.swap("missing/key", "missing/key.avif").await.unwrap(); +} diff --git a/crates/adapters/sqlite/src/tests/users.rs b/crates/adapters/sqlite/src/tests/users.rs new file mode 100644 index 0000000..80c2efe --- /dev/null +++ b/crates/adapters/sqlite/src/tests/users.rs @@ -0,0 +1,91 @@ +use super::*; +use domain::models::UserRole; +use domain::value_objects::{Email, PasswordHash, Username}; +use sqlx::SqlitePool; + +async fn setup() -> (SqlitePool, SqliteUserRepository) { + let pool = SqlitePool::connect(":memory:").await.unwrap(); + sqlx::query( + "CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', bio TEXT, avatar_path TEXT)" + ) + .execute(&pool) + .await + .unwrap(); + let repo = SqliteUserRepository::new(pool.clone()); + (pool, repo) +} + +#[tokio::test] +async fn find_by_id_returns_none_when_not_found() { + let (_, repo) = setup().await; + let result = repo + .find_by_id(&UserId::from_uuid(uuid::Uuid::new_v4())) + .await + .unwrap(); + assert!(result.is_none()); +} + +#[tokio::test] +async fn find_by_id_returns_user_when_found() { + let (pool, repo) = setup().await; + let id = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)" + ) + .bind(id.to_string()) + .bind("test@example.com") + .bind("test") + .bind("$argon2id$v=19$m=65536,t=2,p=1$fakesalt$fakehash") + .bind("2026-01-01T00:00:00Z") + .execute(&pool) + .await + .unwrap(); + + let result = repo.find_by_id(&UserId::from_uuid(id)).await.unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().email().value(), "test@example.com"); +} + +#[tokio::test] +async fn update_profile_persists_bio_and_avatar() { + let (_, repo) = setup().await; + let user = domain::models::User::new( + Email::new("test@example.com".to_string()).unwrap(), + Username::new("testuser".to_string()).unwrap(), + PasswordHash::new("hash".to_string()).unwrap(), + UserRole::Standard, + ); + repo.save(&user).await.unwrap(); + + repo.update_profile( + user.id(), + Some("My biography".to_string()), + Some("avatars/user1".to_string()), + ) + .await + .unwrap(); + + let found = repo.find_by_id(user.id()).await.unwrap().unwrap(); + assert_eq!(found.bio(), Some("My biography")); + assert_eq!(found.avatar_path(), Some("avatars/user1")); +} + +#[tokio::test] +async fn update_profile_clears_fields_with_none() { + let (_, repo) = setup().await; + let user = domain::models::User::new( + Email::new("test2@example.com".to_string()).unwrap(), + Username::new("testuser2".to_string()).unwrap(), + PasswordHash::new("hash".to_string()).unwrap(), + UserRole::Standard, + ); + repo.save(&user).await.unwrap(); + repo.update_profile(user.id(), Some("bio".to_string()), Some("path".to_string())) + .await + .unwrap(); + repo.update_profile(user.id(), None, None).await.unwrap(); + + let found = repo.find_by_id(user.id()).await.unwrap().unwrap(); + assert_eq!(found.bio(), None); + assert_eq!(found.avatar_path(), None); +} diff --git a/crates/adapters/sqlite/src/users.rs b/crates/adapters/sqlite/src/users.rs index 3d3398c..05cde02 100644 --- a/crates/adapters/sqlite/src/users.rs +++ b/crates/adapters/sqlite/src/users.rs @@ -208,96 +208,5 @@ impl UserRepository for SqliteUserRepository { } #[cfg(test)] -mod tests { - use super::*; - use domain::models::UserRole; - use domain::value_objects::{Email, PasswordHash, Username}; - use sqlx::SqlitePool; - - async fn setup() -> (SqlitePool, SqliteUserRepository) { - let pool = SqlitePool::connect(":memory:").await.unwrap(); - sqlx::query( - "CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', bio TEXT, avatar_path TEXT)" - ) - .execute(&pool) - .await - .unwrap(); - let repo = SqliteUserRepository::new(pool.clone()); - (pool, repo) - } - - #[tokio::test] - async fn find_by_id_returns_none_when_not_found() { - let (_, repo) = setup().await; - let result = repo - .find_by_id(&UserId::from_uuid(uuid::Uuid::new_v4())) - .await - .unwrap(); - assert!(result.is_none()); - } - - #[tokio::test] - async fn find_by_id_returns_user_when_found() { - let (pool, repo) = setup().await; - let id = uuid::Uuid::new_v4(); - sqlx::query( - "INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)" - ) - .bind(id.to_string()) - .bind("test@example.com") - .bind("test") - .bind("$argon2id$v=19$m=65536,t=2,p=1$fakesalt$fakehash") - .bind("2026-01-01T00:00:00Z") - .execute(&pool) - .await - .unwrap(); - - let result = repo.find_by_id(&UserId::from_uuid(id)).await.unwrap(); - assert!(result.is_some()); - assert_eq!(result.unwrap().email().value(), "test@example.com"); - } - - #[tokio::test] - async fn update_profile_persists_bio_and_avatar() { - let (_, repo) = setup().await; - let user = domain::models::User::new( - Email::new("test@example.com".to_string()).unwrap(), - Username::new("testuser".to_string()).unwrap(), - PasswordHash::new("hash".to_string()).unwrap(), - UserRole::Standard, - ); - repo.save(&user).await.unwrap(); - - repo.update_profile( - user.id(), - Some("My biography".to_string()), - Some("avatars/user1".to_string()), - ) - .await - .unwrap(); - - let found = repo.find_by_id(user.id()).await.unwrap().unwrap(); - assert_eq!(found.bio(), Some("My biography")); - assert_eq!(found.avatar_path(), Some("avatars/user1")); - } - - #[tokio::test] - async fn update_profile_clears_fields_with_none() { - let (_, repo) = setup().await; - let user = domain::models::User::new( - Email::new("test2@example.com".to_string()).unwrap(), - Username::new("testuser2".to_string()).unwrap(), - PasswordHash::new("hash".to_string()).unwrap(), - UserRole::Standard, - ); - repo.save(&user).await.unwrap(); - repo.update_profile(user.id(), Some("bio".to_string()), Some("path".to_string())) - .await - .unwrap(); - repo.update_profile(user.id(), None, None).await.unwrap(); - - let found = repo.find_by_id(user.id()).await.unwrap().unwrap(); - assert_eq!(found.bio(), None); - assert_eq!(found.avatar_path(), None); - } -} +#[path = "tests/users.rs"] +mod tests; diff --git a/crates/application/src/movie_resolver.rs b/crates/application/src/movie_resolver.rs index fed1706..2cfbf95 100644 --- a/crates/application/src/movie_resolver.rs +++ b/crates/application/src/movie_resolver.rs @@ -165,348 +165,5 @@ impl ResolutionStrategy for ManualMovieStrategy { } #[cfg(test)] -mod tests { - use super::*; - use chrono::NaiveDate; - use domain::{ - errors::DomainError, - models::Movie, - ports::{MetadataSearchCriteria, MovieRepository}, - value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear}, - }; - - fn make_cmd(ext_id: Option<&str>, title: Option<&str>, year: Option) -> LogReviewCommand { - LogReviewCommand { - external_metadata_id: ext_id.map(String::from), - manual_title: title.map(String::from), - manual_release_year: year, - manual_director: None, - user_id: uuid::Uuid::new_v4(), - rating: 4, - comment: None, - watched_at: NaiveDate::from_ymd_opt(2024, 1, 1) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap(), - } - } - - fn make_movie() -> Movie { - Movie::new( - None, - MovieTitle::new("Inception".to_string()).unwrap(), - ReleaseYear::new(2010).unwrap(), - None, - None, - ) - } - - struct RepoWithExternalMovie(Movie); - struct RepoEmpty; - struct RepoWithTitleMatch(Movie); - - #[async_trait] - impl MovieRepository for RepoWithExternalMovie { - async fn get_movie_by_external_id( - &self, - _: &ExternalMetadataId, - ) -> Result, DomainError> { - Ok(Some(self.0.clone())) - } - async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { - panic!("unexpected") - } - async fn get_movies_by_title_and_year( - &self, - _: &MovieTitle, - _: &ReleaseYear, - ) -> Result, DomainError> { - panic!("unexpected") - } - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { - panic!("unexpected") - } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } - async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result, DomainError> { panic!("unexpected") } - } - - #[async_trait] - impl MovieRepository for RepoEmpty { - async fn get_movie_by_external_id( - &self, - _: &ExternalMetadataId, - ) -> Result, DomainError> { - Ok(None) - } - async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { - panic!("unexpected") - } - async fn get_movies_by_title_and_year( - &self, - _: &MovieTitle, - _: &ReleaseYear, - ) -> Result, DomainError> { - Ok(vec![]) - } - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } - async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result, DomainError> { panic!("unexpected") } - } - - #[async_trait] - impl MovieRepository for RepoWithTitleMatch { - async fn get_movie_by_external_id( - &self, - _: &ExternalMetadataId, - ) -> Result, DomainError> { - panic!("unexpected") - } - async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { - panic!("unexpected") - } - async fn get_movies_by_title_and_year( - &self, - _: &MovieTitle, - _: &ReleaseYear, - ) -> Result, DomainError> { - Ok(vec![self.0.clone()]) - } - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } - async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result, DomainError> { panic!("unexpected") } - } - - struct MetaReturnsMovie(Movie); - struct MetaErrors; - - #[async_trait] - impl MetadataClient for MetaReturnsMovie { - async fn fetch_movie_metadata( - &self, - _: &MetadataSearchCriteria, - ) -> Result { - Ok(self.0.clone()) - } - async fn get_poster_url( - &self, - _: &ExternalMetadataId, - ) -> Result, DomainError> { - panic!("unexpected") - } - } - - #[async_trait] - impl MetadataClient for MetaErrors { - async fn fetch_movie_metadata( - &self, - _: &MetadataSearchCriteria, - ) -> Result { - Err(DomainError::InfrastructureError( - "metadata unavailable".into(), - )) - } - async fn get_poster_url( - &self, - _: &ExternalMetadataId, - ) -> Result, DomainError> { - panic!("unexpected") - } - } - - // --- ExternalIdStrategy --- - - #[test] - fn external_id_strategy_can_handle_cmd_with_id() { - let cmd = make_cmd(Some("tt123"), None, None); - assert!(ExternalIdStrategy.can_handle(&cmd)); - } - - #[test] - fn external_id_strategy_cannot_handle_cmd_without_id() { - let cmd = make_cmd(None, Some("Inception"), Some(2010)); - assert!(!ExternalIdStrategy.can_handle(&cmd)); - } - - #[tokio::test] - async fn external_id_strategy_returns_cached_movie() { - let movie = make_movie(); - let repo = RepoWithExternalMovie(movie.clone()); - let meta = MetaErrors; - let deps = MovieResolverDeps { - repository: &repo, - metadata_client: &meta, - }; - let cmd = make_cmd(Some("tt123"), None, None); - let result = ExternalIdStrategy.resolve(&cmd, &deps).await.unwrap(); - assert!(matches!(result, Some((_, false)))); - } - - #[tokio::test] - async fn external_id_strategy_fetches_from_metadata_when_not_cached() { - let movie = make_movie(); - let repo = RepoEmpty; - let meta = MetaReturnsMovie(movie); - let deps = MovieResolverDeps { - repository: &repo, - metadata_client: &meta, - }; - let cmd = make_cmd(Some("tt123"), None, None); - let result = ExternalIdStrategy.resolve(&cmd, &deps).await.unwrap(); - assert!(matches!(result, Some((_, true)))); - } - - #[tokio::test] - async fn external_id_strategy_falls_through_on_metadata_error() { - let repo = RepoEmpty; - let meta = MetaErrors; - let deps = MovieResolverDeps { - repository: &repo, - metadata_client: &meta, - }; - let cmd = make_cmd(Some("tt123"), None, None); - let result = ExternalIdStrategy.resolve(&cmd, &deps).await.unwrap(); - assert!(result.is_none()); - } - - // --- TitleSearchStrategy --- - - #[test] - fn title_strategy_can_handle_cmd_with_title() { - let cmd = make_cmd(None, Some("Inception"), Some(2010)); - assert!(TitleSearchStrategy.can_handle(&cmd)); - } - - #[test] - fn title_strategy_cannot_handle_cmd_without_title() { - let cmd = make_cmd(Some("tt123"), None, None); - assert!(!TitleSearchStrategy.can_handle(&cmd)); - } - - #[tokio::test] - async fn title_strategy_fetches_from_metadata() { - let movie = make_movie(); - let repo = RepoEmpty; - let meta = MetaReturnsMovie(movie); - let deps = MovieResolverDeps { - repository: &repo, - metadata_client: &meta, - }; - let cmd = make_cmd(None, Some("Inception"), Some(2010)); - let result = TitleSearchStrategy.resolve(&cmd, &deps).await.unwrap(); - assert!(matches!(result, Some((_, true)))); - } - - #[tokio::test] - async fn title_strategy_falls_through_on_metadata_error() { - let repo = RepoEmpty; - let meta = MetaErrors; - let deps = MovieResolverDeps { - repository: &repo, - metadata_client: &meta, - }; - let cmd = make_cmd(None, Some("Inception"), Some(2010)); - let result = TitleSearchStrategy.resolve(&cmd, &deps).await.unwrap(); - assert!(result.is_none()); - } - - // --- ManualMovieStrategy --- - - #[test] - fn manual_strategy_can_handle_cmd_with_title() { - let cmd = make_cmd(None, Some("Inception"), Some(2010)); - assert!(ManualMovieStrategy.can_handle(&cmd)); - } - - #[test] - fn manual_strategy_cannot_handle_cmd_without_title() { - let cmd = make_cmd(Some("tt123"), None, None); - assert!(!ManualMovieStrategy.can_handle(&cmd)); - } - - #[tokio::test] - async fn manual_strategy_returns_existing_movie() { - let movie = make_movie(); - let repo = RepoWithTitleMatch(movie.clone()); - let meta = MetaErrors; - let deps = MovieResolverDeps { - repository: &repo, - metadata_client: &meta, - }; - let cmd = make_cmd(None, Some("Inception"), Some(2010)); - let result = ManualMovieStrategy.resolve(&cmd, &deps).await.unwrap(); - assert!(matches!(result, Some((_, false)))); - } - - #[tokio::test] - async fn manual_strategy_creates_new_movie_when_no_match() { - let repo = RepoEmpty; - let meta = MetaErrors; - let deps = MovieResolverDeps { - repository: &repo, - metadata_client: &meta, - }; - let cmd = make_cmd(None, Some("Inception"), Some(2010)); - let result = ManualMovieStrategy.resolve(&cmd, &deps).await.unwrap(); - assert!(matches!(result, Some((_, true)))); - } - - #[tokio::test] - async fn manual_strategy_errors_without_year() { - let repo = RepoEmpty; - let meta = MetaErrors; - let deps = MovieResolverDeps { - repository: &repo, - metadata_client: &meta, - }; - let cmd = make_cmd(None, Some("Inception"), None); - assert!(ManualMovieStrategy.resolve(&cmd, &deps).await.is_err()); - } - - // --- MovieResolver pipeline --- - - #[tokio::test] - async fn resolver_returns_error_when_no_strategy_matches() { - let repo = RepoEmpty; - let meta = MetaErrors; - let deps = MovieResolverDeps { - repository: &repo, - metadata_client: &meta, - }; - let cmd = make_cmd(None, None, None); - let result = MovieResolver::default_pipeline().resolve(&cmd, &deps).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn resolver_uses_cached_movie_when_external_id_matches() { - let movie = make_movie(); - let repo = RepoWithExternalMovie(movie.clone()); - let meta = MetaErrors; - let deps = MovieResolverDeps { - repository: &repo, - metadata_client: &meta, - }; - let cmd = make_cmd(Some("tt123"), None, None); - let (_, is_new) = MovieResolver::default_pipeline() - .resolve(&cmd, &deps) - .await - .unwrap(); - assert!(!is_new); - } - - #[tokio::test] - async fn resolver_falls_through_to_manual_when_external_and_title_both_fail() { - let repo = RepoEmpty; - let meta = MetaErrors; - let deps = MovieResolverDeps { - repository: &repo, - metadata_client: &meta, - }; - let cmd = make_cmd(Some("tt123"), Some("Inception"), Some(2010)); - let (_, is_new) = MovieResolver::default_pipeline() - .resolve(&cmd, &deps) - .await - .unwrap(); - assert!(is_new); - } -} +#[path = "tests/movie_resolver.rs"] +mod tests; diff --git a/crates/application/src/tests/movie_resolver.rs b/crates/application/src/tests/movie_resolver.rs new file mode 100644 index 0000000..1f64711 --- /dev/null +++ b/crates/application/src/tests/movie_resolver.rs @@ -0,0 +1,343 @@ +use super::*; +use chrono::NaiveDate; +use domain::{ + errors::DomainError, + models::Movie, + ports::{MetadataSearchCriteria, MovieRepository}, + value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear}, +}; + +fn make_cmd(ext_id: Option<&str>, title: Option<&str>, year: Option) -> LogReviewCommand { + LogReviewCommand { + external_metadata_id: ext_id.map(String::from), + manual_title: title.map(String::from), + manual_release_year: year, + manual_director: None, + user_id: uuid::Uuid::new_v4(), + rating: 4, + comment: None, + watched_at: NaiveDate::from_ymd_opt(2024, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(), + } +} + +fn make_movie() -> Movie { + Movie::new( + None, + MovieTitle::new("Inception".to_string()).unwrap(), + ReleaseYear::new(2010).unwrap(), + None, + None, + ) +} + +struct RepoWithExternalMovie(Movie); +struct RepoEmpty; +struct RepoWithTitleMatch(Movie); + +#[async_trait::async_trait] +impl MovieRepository for RepoWithExternalMovie { + async fn get_movie_by_external_id( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + Ok(Some(self.0.clone())) + } + async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { + panic!("unexpected") + } + async fn get_movies_by_title_and_year( + &self, + _: &MovieTitle, + _: &ReleaseYear, + ) -> Result, DomainError> { + panic!("unexpected") + } + async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { + panic!("unexpected") + } + async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } + async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result, DomainError> { panic!("unexpected") } +} + +#[async_trait::async_trait] +impl MovieRepository for RepoEmpty { + async fn get_movie_by_external_id( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + Ok(None) + } + async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { + panic!("unexpected") + } + async fn get_movies_by_title_and_year( + &self, + _: &MovieTitle, + _: &ReleaseYear, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") } + async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } + async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result, DomainError> { panic!("unexpected") } +} + +#[async_trait::async_trait] +impl MovieRepository for RepoWithTitleMatch { + async fn get_movie_by_external_id( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + panic!("unexpected") + } + async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { + panic!("unexpected") + } + async fn get_movies_by_title_and_year( + &self, + _: &MovieTitle, + _: &ReleaseYear, + ) -> Result, DomainError> { + Ok(vec![self.0.clone()]) + } + async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") } + async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } + async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result, DomainError> { panic!("unexpected") } +} + +struct MetaReturnsMovie(Movie); +struct MetaErrors; + +#[async_trait::async_trait] +impl MetadataClient for MetaReturnsMovie { + async fn fetch_movie_metadata( + &self, + _: &MetadataSearchCriteria, + ) -> Result { + Ok(self.0.clone()) + } + async fn get_poster_url( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + panic!("unexpected") + } +} + +#[async_trait::async_trait] +impl MetadataClient for MetaErrors { + async fn fetch_movie_metadata( + &self, + _: &MetadataSearchCriteria, + ) -> Result { + Err(DomainError::InfrastructureError( + "metadata unavailable".into(), + )) + } + async fn get_poster_url( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + panic!("unexpected") + } +} + +// --- ExternalIdStrategy --- + +#[test] +fn external_id_strategy_can_handle_cmd_with_id() { + let cmd = make_cmd(Some("tt123"), None, None); + assert!(ExternalIdStrategy.can_handle(&cmd)); +} + +#[test] +fn external_id_strategy_cannot_handle_cmd_without_id() { + let cmd = make_cmd(None, Some("Inception"), Some(2010)); + assert!(!ExternalIdStrategy.can_handle(&cmd)); +} + +#[tokio::test] +async fn external_id_strategy_returns_cached_movie() { + let movie = make_movie(); + let repo = RepoWithExternalMovie(movie.clone()); + let meta = MetaErrors; + let deps = MovieResolverDeps { + repository: &repo, + metadata_client: &meta, + }; + let cmd = make_cmd(Some("tt123"), None, None); + let result = ExternalIdStrategy.resolve(&cmd, &deps).await.unwrap(); + assert!(matches!(result, Some((_, false)))); +} + +#[tokio::test] +async fn external_id_strategy_fetches_from_metadata_when_not_cached() { + let movie = make_movie(); + let repo = RepoEmpty; + let meta = MetaReturnsMovie(movie); + let deps = MovieResolverDeps { + repository: &repo, + metadata_client: &meta, + }; + let cmd = make_cmd(Some("tt123"), None, None); + let result = ExternalIdStrategy.resolve(&cmd, &deps).await.unwrap(); + assert!(matches!(result, Some((_, true)))); +} + +#[tokio::test] +async fn external_id_strategy_falls_through_on_metadata_error() { + let repo = RepoEmpty; + let meta = MetaErrors; + let deps = MovieResolverDeps { + repository: &repo, + metadata_client: &meta, + }; + let cmd = make_cmd(Some("tt123"), None, None); + let result = ExternalIdStrategy.resolve(&cmd, &deps).await.unwrap(); + assert!(result.is_none()); +} + +// --- TitleSearchStrategy --- + +#[test] +fn title_strategy_can_handle_cmd_with_title() { + let cmd = make_cmd(None, Some("Inception"), Some(2010)); + assert!(TitleSearchStrategy.can_handle(&cmd)); +} + +#[test] +fn title_strategy_cannot_handle_cmd_without_title() { + let cmd = make_cmd(Some("tt123"), None, None); + assert!(!TitleSearchStrategy.can_handle(&cmd)); +} + +#[tokio::test] +async fn title_strategy_fetches_from_metadata() { + let movie = make_movie(); + let repo = RepoEmpty; + let meta = MetaReturnsMovie(movie); + let deps = MovieResolverDeps { + repository: &repo, + metadata_client: &meta, + }; + let cmd = make_cmd(None, Some("Inception"), Some(2010)); + let result = TitleSearchStrategy.resolve(&cmd, &deps).await.unwrap(); + assert!(matches!(result, Some((_, true)))); +} + +#[tokio::test] +async fn title_strategy_falls_through_on_metadata_error() { + let repo = RepoEmpty; + let meta = MetaErrors; + let deps = MovieResolverDeps { + repository: &repo, + metadata_client: &meta, + }; + let cmd = make_cmd(None, Some("Inception"), Some(2010)); + let result = TitleSearchStrategy.resolve(&cmd, &deps).await.unwrap(); + assert!(result.is_none()); +} + +// --- ManualMovieStrategy --- + +#[test] +fn manual_strategy_can_handle_cmd_with_title() { + let cmd = make_cmd(None, Some("Inception"), Some(2010)); + assert!(ManualMovieStrategy.can_handle(&cmd)); +} + +#[test] +fn manual_strategy_cannot_handle_cmd_without_title() { + let cmd = make_cmd(Some("tt123"), None, None); + assert!(!ManualMovieStrategy.can_handle(&cmd)); +} + +#[tokio::test] +async fn manual_strategy_returns_existing_movie() { + let movie = make_movie(); + let repo = RepoWithTitleMatch(movie.clone()); + let meta = MetaErrors; + let deps = MovieResolverDeps { + repository: &repo, + metadata_client: &meta, + }; + let cmd = make_cmd(None, Some("Inception"), Some(2010)); + let result = ManualMovieStrategy.resolve(&cmd, &deps).await.unwrap(); + assert!(matches!(result, Some((_, false)))); +} + +#[tokio::test] +async fn manual_strategy_creates_new_movie_when_no_match() { + let repo = RepoEmpty; + let meta = MetaErrors; + let deps = MovieResolverDeps { + repository: &repo, + metadata_client: &meta, + }; + let cmd = make_cmd(None, Some("Inception"), Some(2010)); + let result = ManualMovieStrategy.resolve(&cmd, &deps).await.unwrap(); + assert!(matches!(result, Some((_, true)))); +} + +#[tokio::test] +async fn manual_strategy_errors_without_year() { + let repo = RepoEmpty; + let meta = MetaErrors; + let deps = MovieResolverDeps { + repository: &repo, + metadata_client: &meta, + }; + let cmd = make_cmd(None, Some("Inception"), None); + assert!(ManualMovieStrategy.resolve(&cmd, &deps).await.is_err()); +} + +// --- MovieResolver pipeline --- + +#[tokio::test] +async fn resolver_returns_error_when_no_strategy_matches() { + let repo = RepoEmpty; + let meta = MetaErrors; + let deps = MovieResolverDeps { + repository: &repo, + metadata_client: &meta, + }; + let cmd = make_cmd(None, None, None); + let result = MovieResolver::default_pipeline().resolve(&cmd, &deps).await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn resolver_uses_cached_movie_when_external_id_matches() { + let movie = make_movie(); + let repo = RepoWithExternalMovie(movie.clone()); + let meta = MetaErrors; + let deps = MovieResolverDeps { + repository: &repo, + metadata_client: &meta, + }; + let cmd = make_cmd(Some("tt123"), None, None); + let (_, is_new) = MovieResolver::default_pipeline() + .resolve(&cmd, &deps) + .await + .unwrap(); + assert!(!is_new); +} + +#[tokio::test] +async fn resolver_falls_through_to_manual_when_external_and_title_both_fail() { + let repo = RepoEmpty; + let meta = MetaErrors; + let deps = MovieResolverDeps { + repository: &repo, + metadata_client: &meta, + }; + let cmd = make_cmd(Some("tt123"), Some("Inception"), Some(2010)); + let (_, is_new) = MovieResolver::default_pipeline() + .resolve(&cmd, &deps) + .await + .unwrap(); + assert!(is_new); +} diff --git a/crates/application/src/tests/worker.rs b/crates/application/src/tests/worker.rs new file mode 100644 index 0000000..2da1f5e --- /dev/null +++ b/crates/application/src/tests/worker.rs @@ -0,0 +1,169 @@ +use super::*; +use async_trait::async_trait; +use domain::{errors::DomainError, events::{AckHandle, DomainEvent}}; +use domain::value_objects::{ExternalMetadataId, MovieId}; +use futures::{stream, stream::BoxStream}; +use std::sync::{Arc, Mutex}; + +struct NoopAck; + +#[async_trait] +impl AckHandle for NoopAck { + async fn ack(&self) -> Result<(), DomainError> { Ok(()) } + async fn nack(&self) -> Result<(), DomainError> { Ok(()) } +} + +struct VecConsumer { + events: Vec, +} + +impl EventConsumer for VecConsumer { + fn consume(&self) -> BoxStream<'_, Result> { + let envelopes: Vec> = self + .events + .iter() + .cloned() + .map(|e| Ok(EventEnvelope::new(e, Box::new(NoopAck)))) + .collect(); + Box::pin(stream::iter(envelopes)) + } +} + +struct RecordingHandler { + calls: Arc>>, +} + +#[async_trait] +impl EventHandler for RecordingHandler { + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { + let label = match event { + DomainEvent::MovieDiscovered { .. } => "movie_discovered", + DomainEvent::ReviewLogged { .. } => "review_logged", + DomainEvent::ReviewUpdated { .. } => "review_updated", + DomainEvent::ReviewDeleted { .. } => "review_deleted", + DomainEvent::MovieDeleted { .. } => "movie_deleted", + DomainEvent::UserUpdated { .. } => "user_updated", + DomainEvent::MovieEnrichmentRequested { .. } => "movie_enrichment_requested", + DomainEvent::ImageStored { .. } => "image_stored", + }; + self.calls.lock().unwrap().push(label); + Ok(()) + } +} + +fn movie_discovered() -> DomainEvent { + DomainEvent::MovieDiscovered { + movie_id: MovieId::generate(), + external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(), + } +} + +#[tokio::test] +async fn dispatches_to_all_handlers() { + let calls = Arc::new(Mutex::new(vec![])); + let consumer = VecConsumer { events: vec![movie_discovered()] }; + let handler = RecordingHandler { calls: Arc::clone(&calls) }; + + WorkerService::new(Arc::new(consumer), vec![Arc::new(handler)]) + .run() + .await; + + assert_eq!(*calls.lock().unwrap(), vec!["movie_discovered"]); +} + +#[tokio::test] +async fn nacks_when_handler_fails() { + let nack_called = Arc::new(Mutex::new(false)); + + struct TrackingAck { + nack_called: Arc>, + } + + #[async_trait] + impl AckHandle for TrackingAck { + async fn ack(&self) -> Result<(), DomainError> { Ok(()) } + async fn nack(&self) -> Result<(), DomainError> { + *self.nack_called.lock().unwrap() = true; + Ok(()) + } + } + + struct TrackingConsumer { + event: DomainEvent, + nack_called: Arc>, + } + + impl EventConsumer for TrackingConsumer { + fn consume(&self) -> BoxStream<'_, Result> { + let envelope = EventEnvelope::new( + self.event.clone(), + Box::new(TrackingAck { nack_called: Arc::clone(&self.nack_called) }), + ); + Box::pin(stream::iter(vec![Ok(envelope)])) + } + } + + struct FailingHandler; + + #[async_trait] + impl EventHandler for FailingHandler { + async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> { + Err(DomainError::InfrastructureError("boom".into())) + } + } + + let consumer = TrackingConsumer { + event: movie_discovered(), + nack_called: Arc::clone(&nack_called), + }; + + WorkerService::new(Arc::new(consumer), vec![Arc::new(FailingHandler)]) + .run() + .await; + + assert!(*nack_called.lock().unwrap()); +} + +#[tokio::test] +async fn acks_when_all_handlers_succeed() { + let ack_called = Arc::new(Mutex::new(false)); + + struct TrackingAck { + ack_called: Arc>, + } + + #[async_trait] + impl AckHandle for TrackingAck { + async fn ack(&self) -> Result<(), DomainError> { + *self.ack_called.lock().unwrap() = true; + Ok(()) + } + async fn nack(&self) -> Result<(), DomainError> { Ok(()) } + } + + struct TrackingConsumer { + event: DomainEvent, + ack_called: Arc>, + } + + impl EventConsumer for TrackingConsumer { + fn consume(&self) -> BoxStream<'_, Result> { + let envelope = EventEnvelope::new( + self.event.clone(), + Box::new(TrackingAck { ack_called: Arc::clone(&self.ack_called) }), + ); + Box::pin(stream::iter(vec![Ok(envelope)])) + } + } + + let consumer = TrackingConsumer { + event: movie_discovered(), + ack_called: Arc::clone(&ack_called), + }; + + WorkerService::new(Arc::new(consumer), vec![]) + .run() + .await; + + assert!(*ack_called.lock().unwrap()); +} diff --git a/crates/application/src/worker.rs b/crates/application/src/worker.rs index d4d21fd..a051a30 100644 --- a/crates/application/src/worker.rs +++ b/crates/application/src/worker.rs @@ -50,174 +50,5 @@ impl WorkerService { } #[cfg(test)] -mod tests { - use super::*; - use async_trait::async_trait; - use domain::{errors::DomainError, events::{AckHandle, DomainEvent}}; - use domain::value_objects::{ExternalMetadataId, MovieId}; - use futures::{stream, stream::BoxStream}; - use std::sync::{Arc, Mutex}; - - struct NoopAck; - - #[async_trait] - impl AckHandle for NoopAck { - async fn ack(&self) -> Result<(), DomainError> { Ok(()) } - async fn nack(&self) -> Result<(), DomainError> { Ok(()) } - } - - struct VecConsumer { - events: Vec, - } - - impl EventConsumer for VecConsumer { - fn consume(&self) -> BoxStream<'_, Result> { - let envelopes: Vec> = self - .events - .iter() - .cloned() - .map(|e| Ok(EventEnvelope::new(e, Box::new(NoopAck)))) - .collect(); - Box::pin(stream::iter(envelopes)) - } - } - - struct RecordingHandler { - calls: Arc>>, - } - - #[async_trait] - impl EventHandler for RecordingHandler { - async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { - let label = match event { - DomainEvent::MovieDiscovered { .. } => "movie_discovered", - DomainEvent::ReviewLogged { .. } => "review_logged", - DomainEvent::ReviewUpdated { .. } => "review_updated", - DomainEvent::ReviewDeleted { .. } => "review_deleted", - DomainEvent::MovieDeleted { .. } => "movie_deleted", - DomainEvent::UserUpdated { .. } => "user_updated", - DomainEvent::MovieEnrichmentRequested { .. } => "movie_enrichment_requested", - DomainEvent::ImageStored { .. } => "image_stored", - }; - self.calls.lock().unwrap().push(label); - Ok(()) - } - } - - fn movie_discovered() -> DomainEvent { - DomainEvent::MovieDiscovered { - movie_id: MovieId::generate(), - external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(), - } - } - - #[tokio::test] - async fn dispatches_to_all_handlers() { - let calls = Arc::new(Mutex::new(vec![])); - let consumer = VecConsumer { events: vec![movie_discovered()] }; - let handler = RecordingHandler { calls: Arc::clone(&calls) }; - - WorkerService::new(Arc::new(consumer), vec![Arc::new(handler)]) - .run() - .await; - - assert_eq!(*calls.lock().unwrap(), vec!["movie_discovered"]); - } - - #[tokio::test] - async fn nacks_when_handler_fails() { - let nack_called = Arc::new(Mutex::new(false)); - - struct TrackingAck { - nack_called: Arc>, - } - - #[async_trait] - impl AckHandle for TrackingAck { - async fn ack(&self) -> Result<(), DomainError> { Ok(()) } - async fn nack(&self) -> Result<(), DomainError> { - *self.nack_called.lock().unwrap() = true; - Ok(()) - } - } - - struct TrackingConsumer { - event: DomainEvent, - nack_called: Arc>, - } - - impl EventConsumer for TrackingConsumer { - fn consume(&self) -> BoxStream<'_, Result> { - let envelope = EventEnvelope::new( - self.event.clone(), - Box::new(TrackingAck { nack_called: Arc::clone(&self.nack_called) }), - ); - Box::pin(stream::iter(vec![Ok(envelope)])) - } - } - - struct FailingHandler; - - #[async_trait] - impl EventHandler for FailingHandler { - async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> { - Err(DomainError::InfrastructureError("boom".into())) - } - } - - let consumer = TrackingConsumer { - event: movie_discovered(), - nack_called: Arc::clone(&nack_called), - }; - - WorkerService::new(Arc::new(consumer), vec![Arc::new(FailingHandler)]) - .run() - .await; - - assert!(*nack_called.lock().unwrap()); - } - - #[tokio::test] - async fn acks_when_all_handlers_succeed() { - let ack_called = Arc::new(Mutex::new(false)); - - struct TrackingAck { - ack_called: Arc>, - } - - #[async_trait] - impl AckHandle for TrackingAck { - async fn ack(&self) -> Result<(), DomainError> { - *self.ack_called.lock().unwrap() = true; - Ok(()) - } - async fn nack(&self) -> Result<(), DomainError> { Ok(()) } - } - - struct TrackingConsumer { - event: DomainEvent, - ack_called: Arc>, - } - - impl EventConsumer for TrackingConsumer { - fn consume(&self) -> BoxStream<'_, Result> { - let envelope = EventEnvelope::new( - self.event.clone(), - Box::new(TrackingAck { ack_called: Arc::clone(&self.ack_called) }), - ); - Box::pin(stream::iter(vec![Ok(envelope)])) - } - } - - let consumer = TrackingConsumer { - event: movie_discovered(), - ack_called: Arc::clone(&ack_called), - }; - - WorkerService::new(Arc::new(consumer), vec![]) - .run() - .await; - - assert!(*ack_called.lock().unwrap()); - } -} +#[path = "tests/worker.rs"] +mod tests; diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index 88045a9..ec5f403 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -457,39 +457,8 @@ pub enum ExportFormat { } #[cfg(test)] -mod tests { - use super::*; - use crate::value_objects::{Email, PasswordHash, UserId, Username}; - - fn make_user() -> User { - User::from_persistence( - UserId::generate(), - Email::new("a@b.com".to_string()).unwrap(), - Username::new("alice".to_string()).unwrap(), - PasswordHash::new("hash".to_string()).unwrap(), - UserRole::Standard, - None, - None, - ) - } - - #[test] - fn update_profile_sets_fields() { - let mut user = make_user(); - user.update_profile(Some("My bio".to_string()), Some("avatars/abc".to_string())); - assert_eq!(user.bio(), Some("My bio")); - assert_eq!(user.avatar_path(), Some("avatars/abc")); - } - - #[test] - fn update_profile_clears_with_none() { - let mut user = make_user(); - user.update_profile(Some("bio".to_string()), Some("path".to_string())); - user.update_profile(None, None); - assert_eq!(user.bio(), None); - assert_eq!(user.avatar_path(), None); - } -} +#[path = "tests.rs"] +mod tests; // ── Movie enrichment ─────────────────────────────────────────────────────────── diff --git a/crates/domain/src/models/tests.rs b/crates/domain/src/models/tests.rs new file mode 100644 index 0000000..4900a21 --- /dev/null +++ b/crates/domain/src/models/tests.rs @@ -0,0 +1,31 @@ +use super::*; +use crate::value_objects::{Email, PasswordHash, UserId, Username}; + +fn make_user() -> User { + User::from_persistence( + UserId::generate(), + Email::new("a@b.com".to_string()).unwrap(), + Username::new("alice".to_string()).unwrap(), + PasswordHash::new("hash".to_string()).unwrap(), + UserRole::Standard, + None, + None, + ) +} + +#[test] +fn update_profile_sets_fields() { + let mut user = make_user(); + user.update_profile(Some("My bio".to_string()), Some("avatars/abc".to_string())); + assert_eq!(user.bio(), Some("My bio")); + assert_eq!(user.avatar_path(), Some("avatars/abc")); +} + +#[test] +fn update_profile_clears_with_none() { + let mut user = make_user(); + user.update_profile(Some("bio".to_string()), Some("path".to_string())); + user.update_profile(None, None); + assert_eq!(user.bio(), None); + assert_eq!(user.avatar_path(), None); +} diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index 6e2d923..a411085 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -119,513 +119,5 @@ where } #[cfg(test)] -mod tests { - use super::*; - use application::{config::AppConfig, context::AppContext}; - use axum::{ - Router, - body::Body, - http::{Request, StatusCode}, - routing::get, - }; - use domain::{ - errors::DomainError, - events::DomainEvent, - models::{ - DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats, - UserTrends, - collections::{PageParams, Paginated}, - }, - ports::{ - AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage, - MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository, - StatsRepository, UserRepository, - }, - value_objects::{ - Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, - ReleaseYear, ReviewId, UserId, - }, - }; - use std::sync::Arc; - use tower::ServiceExt; - - // --- Panic stubs (defined once) --- - - struct Panic; - - #[async_trait::async_trait] - impl MovieRepository for Panic { - async fn get_movie_by_external_id( - &self, - _: &ExternalMetadataId, - ) -> Result, DomainError> { - panic!() - } - async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { - panic!() - } - async fn get_movies_by_title_and_year( - &self, - _: &MovieTitle, - _: &ReleaseYear, - ) -> Result, DomainError> { - panic!() - } - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { - panic!() - } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { - panic!() - } - async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result, DomainError> { - panic!() - } - } - #[async_trait::async_trait] - impl ReviewRepository for Panic { - async fn save_review(&self, _: &Review) -> Result { - panic!() - } - async fn get_review_by_id(&self, _: &ReviewId) -> Result, DomainError> { - panic!() - } - async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { - panic!() - } - async fn get_all_reviews_for_user(&self, _: &UserId) -> Result, DomainError> { - panic!() - } - } - #[async_trait::async_trait] - impl DiaryRepository for Panic { - async fn query_diary(&self, _: &DiaryFilter) -> Result, DomainError> { - panic!() - } - async fn query_activity_feed( - &self, - _: &PageParams, - ) -> Result, DomainError> { - panic!() - } - async fn query_activity_feed_filtered( - &self, - _: &PageParams, - _: &domain::ports::FeedSortBy, - _: Option<&str>, - _: Option<&domain::ports::FollowingFilter>, - ) -> Result, DomainError> { - panic!() - } - async fn get_review_history(&self, _: &MovieId) -> Result { - panic!() - } - async fn get_user_history(&self, _: &UserId) -> Result, DomainError> { - panic!() - } - async fn get_movie_stats( - &self, - _: &MovieId, - ) -> Result { - panic!() - } - async fn get_movie_social_feed( - &self, - _: &MovieId, - _: &PageParams, - ) -> Result, DomainError> { - panic!() - } - async fn count_local_posts(&self) -> Result { - panic!() - } - } - #[cfg(feature = "federation")] - #[async_trait::async_trait] - impl domain::ports::SocialQueryPort for Panic { - async fn get_accepted_following_urls( - &self, - _: uuid::Uuid, - ) -> Result, DomainError> { - panic!() - } - async fn list_all_followed_remote_actors( - &self, - ) -> Result, DomainError> { - panic!() - } - } - #[async_trait::async_trait] - impl StatsRepository for Panic { - async fn get_user_stats(&self, _: &UserId) -> Result { - panic!() - } - async fn get_user_trends(&self, _: &UserId) -> Result { - panic!() - } - } - #[async_trait::async_trait] - impl MetadataClient for Panic { - async fn fetch_movie_metadata( - &self, - _: &domain::ports::MetadataSearchCriteria, - ) -> Result { - panic!() - } - async fn get_poster_url( - &self, - _: &ExternalMetadataId, - ) -> Result, DomainError> { - panic!() - } - } - #[async_trait::async_trait] - impl PosterFetcherClient for Panic { - async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result, DomainError> { - panic!() - } - } - #[async_trait::async_trait] - impl ImageStorage for Panic { - async fn store(&self, _: &str, _: &[u8]) -> Result { panic!() } - async fn get(&self, _: &str) -> Result, DomainError> { panic!() } - async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() } - } - #[async_trait::async_trait] - impl AuthService for Panic { - async fn generate_token(&self, _: &UserId) -> Result { - panic!() - } - async fn validate_token(&self, _: &str) -> Result { - panic!() - } - } - #[async_trait::async_trait] - impl PasswordHasher for Panic { - async fn hash(&self, _: &str) -> Result { - panic!() - } - async fn verify(&self, _: &str, _: &PasswordHash) -> Result { - panic!() - } - } - #[async_trait::async_trait] - impl UserRepository for Panic { - async fn find_by_email( - &self, - _: &Email, - ) -> Result, DomainError> { - panic!() - } - async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> { - panic!() - } - async fn find_by_id( - &self, - _: &UserId, - ) -> Result, DomainError> { - panic!() - } - async fn find_by_username( - &self, - _: &domain::value_objects::Username, - ) -> Result, DomainError> { - panic!() - } - async fn list_with_stats(&self) -> Result, DomainError> { - panic!() - } - async fn update_profile(&self, _: &UserId, _: Option, _: Option) -> Result<(), DomainError> { - panic!() - } - } - #[async_trait::async_trait] - impl EventPublisher for Panic { - async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { - panic!() - } - } - #[async_trait::async_trait] - impl domain::ports::ImportSessionRepository for Panic { - async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } - async fn get(&self, _: &domain::value_objects::ImportSessionId, _: &UserId) -> Result, DomainError> { panic!() } - async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } - async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> { panic!() } - async fn delete_expired(&self) -> Result { panic!() } - async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { panic!() } - } - #[async_trait::async_trait] - impl domain::ports::ImportProfileRepository for Panic { - async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { panic!() } - async fn list_for_user(&self, _: &UserId) -> Result, DomainError> { panic!() } - async fn get(&self, _: &domain::value_objects::ImportProfileId, _: &UserId) -> Result, DomainError> { panic!() } - async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() } - } - #[async_trait::async_trait] - impl domain::ports::MovieProfileRepository for Panic { - async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { panic!() } - async fn get_by_movie_id(&self, _: &domain::value_objects::MovieId) -> Result, DomainError> { Ok(None) } - async fn list_stale(&self) -> Result, DomainError> { Ok(vec![]) } - } - #[async_trait::async_trait] - impl domain::ports::DiaryExporter for Panic { - async fn serialize_entries( - &self, - _: &[domain::models::DiaryEntry], - _: domain::models::ExportFormat, - ) -> Result, domain::errors::DomainError> { - panic!() - } - } - - impl domain::ports::DocumentParser for Panic { - fn parse(&self, _: &[u8], _: domain::models::FileFormat) -> Result { - panic!() - } - fn apply_mapping(&self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping]) -> Vec { - panic!() - } - } - - impl crate::ports::HtmlRenderer for Panic { - fn render_diary_page( - &self, - _: &Paginated, - _: application::ports::HtmlPageContext, - ) -> Result { - panic!() - } - fn render_login_page( - &self, - _: application::ports::LoginPageData<'_>, - ) -> Result { - panic!() - } - fn render_register_page( - &self, - _: application::ports::RegisterPageData<'_>, - ) -> Result { - panic!() - } - fn render_new_review_page( - &self, - _: application::ports::NewReviewPageData<'_>, - ) -> Result { - panic!() - } - fn render_activity_feed_page( - &self, - _: application::ports::ActivityFeedPageData, - ) -> Result { - panic!() - } - fn render_users_page( - &self, - _: application::ports::UsersPageData, - ) -> Result { - panic!() - } - fn render_profile_page( - &self, - _: application::ports::ProfilePageData, - ) -> Result { - panic!() - } - fn render_following_page( - &self, - _: application::ports::FollowingPageData, - ) -> Result { - panic!() - } - fn render_followers_page( - &self, - _: application::ports::FollowersPageData, - ) -> Result { - panic!() - } - fn render_movie_detail_page( - &self, - _: application::ports::MovieDetailPageData, - ) -> Result { - panic!() - } - fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result { panic!() } - fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result { panic!() } - fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result { panic!() } - fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result { panic!() } - fn render_blocked_domains_page(&self, _: application::ports::BlockedDomainsPageData) -> Result { panic!() } - fn render_blocked_actors_page(&self, _: application::ports::BlockedActorsPageData) -> Result { panic!() } - } - impl crate::ports::RssFeedRenderer for Panic { - fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result { - panic!() - } - } - - struct RejectingAuth; - #[async_trait::async_trait] - impl AuthService for RejectingAuth { - async fn generate_token(&self, _: &UserId) -> Result { - panic!() - } - async fn validate_token(&self, _: &str) -> Result { - Err(DomainError::Unauthorized("bad token".into())) - } - } - - // --- Single state factory — only auth_service varies --- - - fn make_test_state(auth_service: Arc) -> crate::state::AppState { - let repo = Arc::new(Panic); - crate::state::AppState { - app_ctx: AppContext { - movie_repository: Arc::clone(&repo) as _, - review_repository: Arc::clone(&repo) as _, - diary_repository: Arc::clone(&repo) as _, - diary_exporter: Arc::clone(&repo) as _, - document_parser: Arc::clone(&repo) as _, - stats_repository: Arc::clone(&repo) as _, - metadata_client: Arc::clone(&repo) as _, - poster_fetcher: Arc::clone(&repo) as _, - image_storage: Arc::clone(&repo) as _, - event_publisher: Arc::clone(&repo) as _, - password_hasher: Arc::clone(&repo) as _, - user_repository: Arc::clone(&repo) as _, - import_session_repository: Arc::clone(&repo) as _, - import_profile_repository: Arc::clone(&repo) as _, - movie_profile_repository: Arc::clone(&repo) as _, - auth_service, - config: AppConfig { - allow_registration: false, - base_url: "http://localhost:3000".to_string(), - rate_limit: 20, - }, - }, - html_renderer: Arc::new(Panic), - rss_renderer: Arc::new(Panic), - #[cfg(feature = "federation")] - ap_service: Arc::new(activitypub::NoopActivityPubService), - #[cfg(feature = "federation")] - social_query: Arc::new(Panic), - } - } - - // --- Routers --- - - async fn protected_handler(user: AuthenticatedUser) -> String { - user.0.value().to_string() - } - async fn optional_cookie_handler(user: OptionalCookieUser) -> String { - match user.0 { - Some(id) => id.value().to_string(), - None => "none".to_string(), - } - } - async fn required_cookie_handler(user: RequiredCookieUser) -> String { - user.0.value().to_string() - } - - fn router_protected(state: crate::state::AppState) -> Router { - Router::new() - .route("/protected", get(protected_handler)) - .with_state(state) - } - fn router_optional(state: crate::state::AppState) -> Router { - Router::new() - .route("/optional", get(optional_cookie_handler)) - .with_state(state) - } - fn router_required(state: crate::state::AppState) -> Router { - Router::new() - .route("/required", get(required_cookie_handler)) - .with_state(state) - } - - // --- Tests --- - - #[tokio::test] - async fn missing_auth_header_returns_401() { - let app = router_protected(make_test_state(Arc::new(Panic))); - let resp = app - .oneshot( - Request::builder() - .uri("/protected") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - } - - #[tokio::test] - async fn optional_cookie_user_returns_none_without_cookie() { - let app = router_optional(make_test_state(Arc::new(Panic))); - let resp = app - .oneshot( - Request::builder() - .uri("/optional") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - assert_eq!(&body[..], b"none"); - } - - #[tokio::test] - async fn optional_cookie_user_returns_none_with_invalid_token() { - let app = router_optional(make_test_state(Arc::new(RejectingAuth))); - let resp = app - .oneshot( - Request::builder() - .uri("/optional") - .header("cookie", "token=bad.token.here") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - assert_eq!(&body[..], b"none"); - } - - #[tokio::test] - async fn required_cookie_user_redirects_without_cookie() { - let app = router_required(make_test_state(Arc::new(Panic))); - let resp = app - .oneshot( - Request::builder() - .uri("/required") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::SEE_OTHER); - assert_eq!(resp.headers().get("location").unwrap(), "/login"); - } - - #[tokio::test] - async fn required_cookie_user_redirects_with_invalid_token() { - let app = router_required(make_test_state(Arc::new(RejectingAuth))); - let resp = app - .oneshot( - Request::builder() - .uri("/required") - .header("cookie", "token=bad.token.here") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::SEE_OTHER); - assert_eq!(resp.headers().get("location").unwrap(), "/login"); - } -} +#[path = "tests/extractors.rs"] +mod tests; diff --git a/crates/presentation/src/forms.rs b/crates/presentation/src/forms.rs index 1f36673..3cfb796 100644 --- a/crates/presentation/src/forms.rs +++ b/crates/presentation/src/forms.rs @@ -235,117 +235,5 @@ pub fn to_diary_query(p: DiaryQueryParams) -> GetDiaryQuery { } #[cfg(test)] -mod tests { - use super::*; - - fn make_form(watched_at: &str) -> LogReviewForm { - LogReviewForm { - external_metadata_id: None, - manual_title: None, - manual_release_year: None, - manual_director: None, - rating: 4, - comment: None, - watched_at: watched_at.to_string(), - csrf_token: String::new(), - } - } - - fn make_request(watched_at: &str) -> LogReviewRequest { - LogReviewRequest { - external_metadata_id: None, - manual_title: None, - manual_release_year: None, - manual_director: None, - rating: 4, - comment: None, - watched_at: watched_at.to_string(), - } - } - - #[test] - fn form_accepts_datetime_with_seconds() { - let data = LogReviewData::try_from(make_form("2024-03-15T20:30:00")).unwrap(); - assert_eq!(data.watched_at.format("%H:%M:%S").to_string(), "20:30:00"); - } - - #[test] - fn form_accepts_datetime_without_seconds() { - let data = LogReviewData::try_from(make_form("2024-03-15T20:30")).unwrap(); - assert_eq!(data.watched_at.format("%H:%M").to_string(), "20:30"); - } - - #[test] - fn form_rejects_invalid_datetime() { - assert!(LogReviewData::try_from(make_form("not-a-date")).is_err()); - } - - #[test] - fn api_accepts_datetime_with_seconds() { - let data = LogReviewData::try_from(make_request("2024-03-15T20:30:00")).unwrap(); - assert_eq!(data.watched_at.format("%H:%M:%S").to_string(), "20:30:00"); - } - - #[test] - fn api_rejects_datetime_without_seconds() { - assert!(LogReviewData::try_from(make_request("2024-03-15T20:30")).is_err()); - } - - #[test] - fn api_rejects_invalid_datetime() { - assert!(LogReviewData::try_from(make_request("garbage")).is_err()); - } - - #[test] - fn whitespace_external_id_becomes_none_in_form() { - let mut form = make_form("2024-03-15T20:30:00"); - form.external_metadata_id = Some(" ".to_string()); - let data = LogReviewData::try_from(form).unwrap(); - assert!(data.external_metadata_id.is_none()); - } - - #[test] - fn whitespace_external_id_becomes_none_in_request() { - let mut req = make_request("2024-03-15T20:30:00"); - req.external_metadata_id = Some(" ".to_string()); - let data = LogReviewData::try_from(req).unwrap(); - assert!(data.external_metadata_id.is_none()); - } - - #[test] - fn sort_by_asc_string_becomes_ascending() { - let params = DiaryQueryParams { - sort_by: Some("asc".to_string()), - limit: None, - offset: None, - movie_id: None, - }; - let query = to_diary_query(params); - assert!(matches!( - query.sort_by, - Some(domain::models::SortDirection::Ascending) - )); - } - - #[test] - fn sort_by_other_string_becomes_descending() { - let params = DiaryQueryParams { - sort_by: Some("desc".to_string()), - limit: None, - offset: None, - movie_id: None, - }; - let query = to_diary_query(params); - assert!(matches!( - query.sort_by, - Some(domain::models::SortDirection::Descending) - )); - } - - #[test] - fn form_accepts_date_only() { - let data = LogReviewData::try_from(make_form("2024-03-15")).unwrap(); - assert_eq!(data.watched_at.format("%H:%M:%S").to_string(), "00:00:00"); - assert_eq!(data.watched_at.format("%Y-%m-%d").to_string(), "2024-03-15"); - } -} +#[path = "tests/forms.rs"] +mod tests; diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs new file mode 100644 index 0000000..8c793eb --- /dev/null +++ b/crates/presentation/src/tests/extractors.rs @@ -0,0 +1,508 @@ +use super::*; +use application::{config::AppConfig, context::AppContext}; +use axum::{ + Router, + body::Body, + http::{Request, StatusCode}, + routing::get, +}; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::{ + DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats, + UserTrends, + collections::{PageParams, Paginated}, + }, + ports::{ + AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage, + MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository, + StatsRepository, UserRepository, + }, + value_objects::{ + Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, + ReleaseYear, ReviewId, UserId, + }, +}; +use std::sync::Arc; +use tower::ServiceExt; + +// --- Panic stubs (defined once) --- + +struct Panic; + +#[async_trait::async_trait] +impl MovieRepository for Panic { + async fn get_movie_by_external_id( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + panic!() + } + async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { + panic!() + } + async fn get_movies_by_title_and_year( + &self, + _: &MovieTitle, + _: &ReleaseYear, + ) -> Result, DomainError> { + panic!() + } + async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { + panic!() + } + async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { + panic!() + } + async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result, DomainError> { + panic!() + } +} +#[async_trait::async_trait] +impl ReviewRepository for Panic { + async fn save_review(&self, _: &Review) -> Result { + panic!() + } + async fn get_review_by_id(&self, _: &ReviewId) -> Result, DomainError> { + panic!() + } + async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { + panic!() + } + async fn get_all_reviews_for_user(&self, _: &UserId) -> Result, DomainError> { + panic!() + } +} +#[async_trait::async_trait] +impl DiaryRepository for Panic { + async fn query_diary(&self, _: &DiaryFilter) -> Result, DomainError> { + panic!() + } + async fn query_activity_feed( + &self, + _: &PageParams, + ) -> Result, DomainError> { + panic!() + } + async fn query_activity_feed_filtered( + &self, + _: &PageParams, + _: &domain::ports::FeedSortBy, + _: Option<&str>, + _: Option<&domain::ports::FollowingFilter>, + ) -> Result, DomainError> { + panic!() + } + async fn get_review_history(&self, _: &MovieId) -> Result { + panic!() + } + async fn get_user_history(&self, _: &UserId) -> Result, DomainError> { + panic!() + } + async fn get_movie_stats( + &self, + _: &MovieId, + ) -> Result { + panic!() + } + async fn get_movie_social_feed( + &self, + _: &MovieId, + _: &PageParams, + ) -> Result, DomainError> { + panic!() + } + async fn count_local_posts(&self) -> Result { + panic!() + } +} +#[cfg(feature = "federation")] +#[async_trait::async_trait] +impl domain::ports::SocialQueryPort for Panic { + async fn get_accepted_following_urls( + &self, + _: uuid::Uuid, + ) -> Result, DomainError> { + panic!() + } + async fn list_all_followed_remote_actors( + &self, + ) -> Result, DomainError> { + panic!() + } +} +#[async_trait::async_trait] +impl StatsRepository for Panic { + async fn get_user_stats(&self, _: &UserId) -> Result { + panic!() + } + async fn get_user_trends(&self, _: &UserId) -> Result { + panic!() + } +} +#[async_trait::async_trait] +impl MetadataClient for Panic { + async fn fetch_movie_metadata( + &self, + _: &domain::ports::MetadataSearchCriteria, + ) -> Result { + panic!() + } + async fn get_poster_url( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + panic!() + } +} +#[async_trait::async_trait] +impl PosterFetcherClient for Panic { + async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result, DomainError> { + panic!() + } +} +#[async_trait::async_trait] +impl ImageStorage for Panic { + async fn store(&self, _: &str, _: &[u8]) -> Result { panic!() } + async fn get(&self, _: &str) -> Result, DomainError> { panic!() } + async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() } +} +#[async_trait::async_trait] +impl AuthService for Panic { + async fn generate_token(&self, _: &UserId) -> Result { + panic!() + } + async fn validate_token(&self, _: &str) -> Result { + panic!() + } +} +#[async_trait::async_trait] +impl PasswordHasher for Panic { + async fn hash(&self, _: &str) -> Result { + panic!() + } + async fn verify(&self, _: &str, _: &PasswordHash) -> Result { + panic!() + } +} +#[async_trait::async_trait] +impl UserRepository for Panic { + async fn find_by_email( + &self, + _: &Email, + ) -> Result, DomainError> { + panic!() + } + async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> { + panic!() + } + async fn find_by_id( + &self, + _: &UserId, + ) -> Result, DomainError> { + panic!() + } + async fn find_by_username( + &self, + _: &domain::value_objects::Username, + ) -> Result, DomainError> { + panic!() + } + async fn list_with_stats(&self) -> Result, DomainError> { + panic!() + } + async fn update_profile(&self, _: &UserId, _: Option, _: Option) -> Result<(), DomainError> { + panic!() + } +} +#[async_trait::async_trait] +impl EventPublisher for Panic { + async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { + panic!() + } +} +#[async_trait::async_trait] +impl domain::ports::ImportSessionRepository for Panic { + async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } + async fn get(&self, _: &domain::value_objects::ImportSessionId, _: &UserId) -> Result, DomainError> { panic!() } + async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } + async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> { panic!() } + async fn delete_expired(&self) -> Result { panic!() } + async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { panic!() } +} +#[async_trait::async_trait] +impl domain::ports::ImportProfileRepository for Panic { + async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { panic!() } + async fn list_for_user(&self, _: &UserId) -> Result, DomainError> { panic!() } + async fn get(&self, _: &domain::value_objects::ImportProfileId, _: &UserId) -> Result, DomainError> { panic!() } + async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() } +} +#[async_trait::async_trait] +impl domain::ports::MovieProfileRepository for Panic { + async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { panic!() } + async fn get_by_movie_id(&self, _: &domain::value_objects::MovieId) -> Result, DomainError> { Ok(None) } + async fn list_stale(&self) -> Result, DomainError> { Ok(vec![]) } +} +#[async_trait::async_trait] +impl domain::ports::DiaryExporter for Panic { + async fn serialize_entries( + &self, + _: &[domain::models::DiaryEntry], + _: domain::models::ExportFormat, + ) -> Result, domain::errors::DomainError> { + panic!() + } +} + +impl domain::ports::DocumentParser for Panic { + fn parse(&self, _: &[u8], _: domain::models::FileFormat) -> Result { + panic!() + } + fn apply_mapping(&self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping]) -> Vec { + panic!() + } +} + +impl crate::ports::HtmlRenderer for Panic { + fn render_diary_page( + &self, + _: &Paginated, + _: application::ports::HtmlPageContext, + ) -> Result { + panic!() + } + fn render_login_page( + &self, + _: application::ports::LoginPageData<'_>, + ) -> Result { + panic!() + } + fn render_register_page( + &self, + _: application::ports::RegisterPageData<'_>, + ) -> Result { + panic!() + } + fn render_new_review_page( + &self, + _: application::ports::NewReviewPageData<'_>, + ) -> Result { + panic!() + } + fn render_activity_feed_page( + &self, + _: application::ports::ActivityFeedPageData, + ) -> Result { + panic!() + } + fn render_users_page( + &self, + _: application::ports::UsersPageData, + ) -> Result { + panic!() + } + fn render_profile_page( + &self, + _: application::ports::ProfilePageData, + ) -> Result { + panic!() + } + fn render_following_page( + &self, + _: application::ports::FollowingPageData, + ) -> Result { + panic!() + } + fn render_followers_page( + &self, + _: application::ports::FollowersPageData, + ) -> Result { + panic!() + } + fn render_movie_detail_page( + &self, + _: application::ports::MovieDetailPageData, + ) -> Result { + panic!() + } + fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result { panic!() } + fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result { panic!() } + fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result { panic!() } + fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result { panic!() } + fn render_blocked_domains_page(&self, _: application::ports::BlockedDomainsPageData) -> Result { panic!() } + fn render_blocked_actors_page(&self, _: application::ports::BlockedActorsPageData) -> Result { panic!() } +} +impl crate::ports::RssFeedRenderer for Panic { + fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result { + panic!() + } +} + +struct RejectingAuth; +#[async_trait::async_trait] +impl AuthService for RejectingAuth { + async fn generate_token(&self, _: &UserId) -> Result { + panic!() + } + async fn validate_token(&self, _: &str) -> Result { + Err(DomainError::Unauthorized("bad token".into())) + } +} + +// --- Single state factory — only auth_service varies --- + +fn make_test_state(auth_service: Arc) -> crate::state::AppState { + let repo = Arc::new(Panic); + crate::state::AppState { + app_ctx: AppContext { + movie_repository: Arc::clone(&repo) as _, + review_repository: Arc::clone(&repo) as _, + diary_repository: Arc::clone(&repo) as _, + diary_exporter: Arc::clone(&repo) as _, + document_parser: Arc::clone(&repo) as _, + stats_repository: Arc::clone(&repo) as _, + metadata_client: Arc::clone(&repo) as _, + poster_fetcher: Arc::clone(&repo) as _, + image_storage: Arc::clone(&repo) as _, + event_publisher: Arc::clone(&repo) as _, + password_hasher: Arc::clone(&repo) as _, + user_repository: Arc::clone(&repo) as _, + import_session_repository: Arc::clone(&repo) as _, + import_profile_repository: Arc::clone(&repo) as _, + movie_profile_repository: Arc::clone(&repo) as _, + auth_service, + config: AppConfig { + allow_registration: false, + base_url: "http://localhost:3000".to_string(), + rate_limit: 20, + }, + }, + html_renderer: Arc::new(Panic), + rss_renderer: Arc::new(Panic), + #[cfg(feature = "federation")] + ap_service: Arc::new(activitypub::NoopActivityPubService), + #[cfg(feature = "federation")] + social_query: Arc::new(Panic), + } +} + +// --- Routers --- + +async fn protected_handler(user: AuthenticatedUser) -> String { + user.0.value().to_string() +} +async fn optional_cookie_handler(user: OptionalCookieUser) -> String { + match user.0 { + Some(id) => id.value().to_string(), + None => "none".to_string(), + } +} +async fn required_cookie_handler(user: RequiredCookieUser) -> String { + user.0.value().to_string() +} + +fn router_protected(state: crate::state::AppState) -> Router { + Router::new() + .route("/protected", get(protected_handler)) + .with_state(state) +} +fn router_optional(state: crate::state::AppState) -> Router { + Router::new() + .route("/optional", get(optional_cookie_handler)) + .with_state(state) +} +fn router_required(state: crate::state::AppState) -> Router { + Router::new() + .route("/required", get(required_cookie_handler)) + .with_state(state) +} + +// --- Tests --- + +#[tokio::test] +async fn missing_auth_header_returns_401() { + let app = router_protected(make_test_state(Arc::new(Panic))); + let resp = app + .oneshot( + Request::builder() + .uri("/protected") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn optional_cookie_user_returns_none_without_cookie() { + let app = router_optional(make_test_state(Arc::new(Panic))); + let resp = app + .oneshot( + Request::builder() + .uri("/optional") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&body[..], b"none"); +} + +#[tokio::test] +async fn optional_cookie_user_returns_none_with_invalid_token() { + let app = router_optional(make_test_state(Arc::new(RejectingAuth))); + let resp = app + .oneshot( + Request::builder() + .uri("/optional") + .header("cookie", "token=bad.token.here") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&body[..], b"none"); +} + +#[tokio::test] +async fn required_cookie_user_redirects_without_cookie() { + let app = router_required(make_test_state(Arc::new(Panic))); + let resp = app + .oneshot( + Request::builder() + .uri("/required") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert_eq!(resp.headers().get("location").unwrap(), "/login"); +} + +#[tokio::test] +async fn required_cookie_user_redirects_with_invalid_token() { + let app = router_required(make_test_state(Arc::new(RejectingAuth))); + let resp = app + .oneshot( + Request::builder() + .uri("/required") + .header("cookie", "token=bad.token.here") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert_eq!(resp.headers().get("location").unwrap(), "/login"); +} diff --git a/crates/presentation/src/tests/forms.rs b/crates/presentation/src/tests/forms.rs new file mode 100644 index 0000000..eb17d4f --- /dev/null +++ b/crates/presentation/src/tests/forms.rs @@ -0,0 +1,112 @@ +use super::*; + +fn make_form(watched_at: &str) -> LogReviewForm { + LogReviewForm { + external_metadata_id: None, + manual_title: None, + manual_release_year: None, + manual_director: None, + rating: 4, + comment: None, + watched_at: watched_at.to_string(), + csrf_token: String::new(), + } +} + +fn make_request(watched_at: &str) -> LogReviewRequest { + LogReviewRequest { + external_metadata_id: None, + manual_title: None, + manual_release_year: None, + manual_director: None, + rating: 4, + comment: None, + watched_at: watched_at.to_string(), + } +} + +#[test] +fn form_accepts_datetime_with_seconds() { + let data = LogReviewData::try_from(make_form("2024-03-15T20:30:00")).unwrap(); + assert_eq!(data.watched_at.format("%H:%M:%S").to_string(), "20:30:00"); +} + +#[test] +fn form_accepts_datetime_without_seconds() { + let data = LogReviewData::try_from(make_form("2024-03-15T20:30")).unwrap(); + assert_eq!(data.watched_at.format("%H:%M").to_string(), "20:30"); +} + +#[test] +fn form_rejects_invalid_datetime() { + assert!(LogReviewData::try_from(make_form("not-a-date")).is_err()); +} + +#[test] +fn api_accepts_datetime_with_seconds() { + let data = LogReviewData::try_from(make_request("2024-03-15T20:30:00")).unwrap(); + assert_eq!(data.watched_at.format("%H:%M:%S").to_string(), "20:30:00"); +} + +#[test] +fn api_rejects_datetime_without_seconds() { + assert!(LogReviewData::try_from(make_request("2024-03-15T20:30")).is_err()); +} + +#[test] +fn api_rejects_invalid_datetime() { + assert!(LogReviewData::try_from(make_request("garbage")).is_err()); +} + +#[test] +fn whitespace_external_id_becomes_none_in_form() { + let mut form = make_form("2024-03-15T20:30:00"); + form.external_metadata_id = Some(" ".to_string()); + let data = LogReviewData::try_from(form).unwrap(); + assert!(data.external_metadata_id.is_none()); +} + +#[test] +fn whitespace_external_id_becomes_none_in_request() { + let mut req = make_request("2024-03-15T20:30:00"); + req.external_metadata_id = Some(" ".to_string()); + let data = LogReviewData::try_from(req).unwrap(); + assert!(data.external_metadata_id.is_none()); +} + +#[test] +fn sort_by_asc_string_becomes_ascending() { + let params = DiaryQueryParams { + sort_by: Some("asc".to_string()), + limit: None, + offset: None, + movie_id: None, + }; + let query = to_diary_query(params); + assert!(matches!( + query.sort_by, + Some(domain::models::SortDirection::Ascending) + )); +} + +#[test] +fn sort_by_other_string_becomes_descending() { + let params = DiaryQueryParams { + sort_by: Some("desc".to_string()), + limit: None, + offset: None, + movie_id: None, + }; + let query = to_diary_query(params); + assert!(matches!( + query.sort_by, + Some(domain::models::SortDirection::Descending) + )); +} + +#[test] +fn form_accepts_date_only() { + let data = LogReviewData::try_from(make_form("2024-03-15")).unwrap(); + assert_eq!(data.watched_at.format("%H:%M:%S").to_string(), "00:00:00"); + assert_eq!(data.watched_at.format("%Y-%m-%d").to_string(), "2024-03-15"); +} diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 6437f1f..281b8cc 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -993,547 +993,5 @@ pub fn update(app: &mut App, action: Action) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use api_types::{DiaryEntryDto, MovieDto, ReviewDto}; - use uuid::Uuid; - - fn setup_app() -> App { - App { - screen: Screen::Setup(SetupState { - api_url: String::new(), - error: None, - }), - token: None, - loading: false, - status: None, - api_url: String::new(), - } - } - - fn login_app() -> App { - App { - screen: Screen::Login(LoginState::default()), - token: None, - loading: false, - status: None, - api_url: String::new(), - } - } - - fn main_app() -> App { - App { - screen: Screen::Main(MainState::new("http://localhost:3000".into())), - token: Some("tok".into()), - loading: false, - status: None, - api_url: "http://localhost:3000".into(), - } - } - - fn diary_entry() -> DiaryEntryDto { - DiaryEntryDto { - movie: MovieDto { - id: Uuid::new_v4(), - title: "The Matrix".into(), - release_year: 1999, - director: None, - poster_path: None, - }, - review: ReviewDto { - id: Uuid::new_v4(), - rating: 5, - comment: None, - watched_at: "1999-03-31T00:00:00".into(), - }, - } - } - - // ── Setup screen ────────────────────────────────────────────────────────── - - #[test] - fn setup_input_char_appends_to_api_url() { - let mut app = setup_app(); - update(&mut app, Action::InputChar('h')); - update(&mut app, Action::InputChar('i')); - if let Screen::Setup(s) = &app.screen { - assert_eq!(s.api_url, "hi"); - } else { - panic!("expected Setup"); - } - } - - #[test] - fn setup_submit_with_empty_url_sets_error() { - let mut app = setup_app(); - let cmds = update(&mut app, Action::SetupSubmit); - assert!(cmds.is_empty()); - if let Screen::Setup(s) = &app.screen { - assert!(s.error.is_some()); - } else { - panic!("expected Setup"); - } - } - - #[test] - fn setup_submit_with_url_saves_config_and_transitions_to_login() { - let mut app = setup_app(); - update(&mut app, Action::InputChar('h')); - let cmds = update(&mut app, Action::SetupSubmit); - assert!(cmds.iter().any(|c| matches!(c, Command::SaveConfig(_)))); - assert!(matches!(app.screen, Screen::Login(_))); - } - - // ── Login screen ────────────────────────────────────────────────────────── - - #[test] - fn login_input_char_goes_to_email_by_default() { - let mut app = login_app(); - update(&mut app, Action::InputChar('a')); - if let Screen::Login(s) = &app.screen { - assert_eq!(s.email, "a"); - assert_eq!(s.password, ""); - } else { - panic!(); - } - } - - #[test] - fn login_focus_next_moves_to_password() { - let mut app = login_app(); - update(&mut app, Action::FocusNext); - if let Screen::Login(s) = &app.screen { - assert_eq!(s.focused, LoginField::Password); - } else { - panic!(); - } - } - - #[test] - fn login_input_after_focus_goes_to_password() { - let mut app = login_app(); - update(&mut app, Action::FocusNext); - update(&mut app, Action::InputChar('x')); - if let Screen::Login(s) = &app.screen { - assert_eq!(s.password, "x"); - } else { - panic!(); - } - } - - #[test] - fn login_submit_returns_login_command_and_sets_loading() { - let mut app = login_app(); - for c in "user@example.com".chars() { - update(&mut app, Action::InputChar(c)); - } - update(&mut app, Action::FocusNext); - for c in "pass123".chars() { - update(&mut app, Action::InputChar(c)); - } - let cmds = update(&mut app, Action::LoginSubmit); - assert!(cmds.iter().any(|c| matches!(c, Command::Login { .. }))); - assert!(app.loading); - } - - #[test] - fn login_submit_with_empty_fields_sets_error_status() { - let mut app = login_app(); - let cmds = update(&mut app, Action::LoginSubmit); - assert!(cmds.is_empty()); - assert!(app.status.as_ref().map_or(false, |s| s.is_error)); - } - - #[test] - fn auth_ok_sets_token_and_transitions_to_main() { - let mut app = login_app(); - let cmds = update(&mut app, Action::AuthOk("jwt-token".into())); - assert_eq!(app.token, Some("jwt-token".into())); - assert!(matches!(app.screen, Screen::Main(_))); - assert!(!app.loading); - assert!(cmds.iter().any(|c| matches!(c, Command::SaveToken(_)))); - assert!(cmds.iter().any(|c| matches!(c, Command::LoadDiary { .. }))); - } - - #[test] - fn auth_fail_sets_error_status_and_clears_loading() { - let mut app = login_app(); - app.loading = true; - update(&mut app, Action::AuthFail("bad creds".into())); - assert!(!app.loading); - assert!(app.status.as_ref().map_or(false, |s| s.is_error)); - } - - // ── Diary ───────────────────────────────────────────────────────────────── - - #[test] - fn diary_scroll_down_increments_selected() { - let mut app = main_app(); - update( - &mut app, - Action::DiaryLoaded { - entries: vec![diary_entry(), diary_entry(), diary_entry()], - total: 3, - }, - ); - update(&mut app, Action::ScrollDown); - if let Screen::Main(m) = &app.screen { - assert_eq!(m.diary.selected, 1); - } else { - panic!(); - } - } - - #[test] - fn diary_scroll_up_clamps_at_zero() { - let mut app = main_app(); - update( - &mut app, - Action::DiaryLoaded { - entries: vec![diary_entry()], - total: 1, - }, - ); - update(&mut app, Action::ScrollUp); - if let Screen::Main(m) = &app.screen { - assert_eq!(m.diary.selected, 0); - } else { - panic!(); - } - } - - #[test] - fn diary_scroll_down_clamps_at_last_entry() { - let mut app = main_app(); - update( - &mut app, - Action::DiaryLoaded { - entries: vec![diary_entry()], - total: 1, - }, - ); - update(&mut app, Action::ScrollDown); - if let Screen::Main(m) = &app.screen { - assert_eq!(m.diary.selected, 0); - } else { - panic!(); - } - } - - #[test] - fn delete_init_sets_delete_pending() { - let mut app = main_app(); - let entry = diary_entry(); - let review_id = entry.review.id; - update( - &mut app, - Action::DiaryLoaded { - entries: vec![entry], - total: 1, - }, - ); - update(&mut app, Action::DeleteInit); - if let Screen::Main(m) = &app.screen { - assert_eq!(m.diary.delete_pending, Some(review_id)); - } else { - panic!(); - } - } - - #[test] - fn delete_confirm_returns_delete_command() { - let mut app = main_app(); - let entry = diary_entry(); - let review_id = entry.review.id; - update( - &mut app, - Action::DiaryLoaded { - entries: vec![entry], - total: 1, - }, - ); - update(&mut app, Action::DeleteInit); - let cmds = update(&mut app, Action::DeleteConfirm); - assert!( - cmds.iter() - .any(|c| matches!(c, Command::DeleteReview(id) if *id == review_id)) - ); - } - - #[test] - fn delete_cancel_clears_pending() { - let mut app = main_app(); - let entry = diary_entry(); - update( - &mut app, - Action::DiaryLoaded { - entries: vec![entry], - total: 1, - }, - ); - update(&mut app, Action::DeleteInit); - update(&mut app, Action::DeleteCancel); - if let Screen::Main(m) = &app.screen { - assert!(m.diary.delete_pending.is_none()); - } else { - panic!(); - } - } - - #[test] - fn review_deleted_removes_entry_from_list() { - let mut app = main_app(); - let entry = diary_entry(); - let review_id = entry.review.id; - update( - &mut app, - Action::DiaryLoaded { - entries: vec![entry], - total: 1, - }, - ); - update(&mut app, Action::ReviewDeleted(review_id)); - if let Screen::Main(m) = &app.screen { - assert!(m.diary.entries.is_empty()); - assert_eq!(m.diary.total, 0); - } else { - panic!(); - } - } - - // ── Add Review ──────────────────────────────────────────────────────────── - - #[test] - fn rating_up_increments_rating() { - let mut app = main_app(); - if let Screen::Main(m) = &mut app.screen { - m.tab = Tab::AddReview; - m.add_review.rating = 3; - } - update(&mut app, Action::RatingUp); - if let Screen::Main(m) = &app.screen { - assert_eq!(m.add_review.rating, 4); - } - } - - #[test] - fn rating_clamps_at_5() { - let mut app = main_app(); - if let Screen::Main(m) = &mut app.screen { - m.tab = Tab::AddReview; - m.add_review.rating = 5; - } - update(&mut app, Action::RatingUp); - if let Screen::Main(m) = &app.screen { - assert_eq!(m.add_review.rating, 5); - } - } - - #[test] - fn review_submit_returns_create_review_command() { - let mut app = main_app(); - if let Screen::Main(m) = &mut app.screen { - m.tab = Tab::AddReview; - m.add_review.title = "The Matrix".into(); - m.add_review.watched_at = "1999-03-31T00:00:00".into(); - m.add_review.rating = 5; - } - let cmds = update(&mut app, Action::ReviewSubmit); - assert!(cmds.iter().any(|c| matches!(c, Command::CreateReview(_)))); - } - - #[test] - fn review_submit_with_missing_title_and_id_sets_error() { - let mut app = main_app(); - if let Screen::Main(m) = &mut app.screen { - m.tab = Tab::AddReview; - m.add_review.watched_at = "1999-03-31T00:00:00".into(); - } - let cmds = update(&mut app, Action::ReviewSubmit); - assert!(cmds.is_empty()); - assert!(app.status.as_ref().map_or(false, |s| s.is_error)); - } - - // ── Bulk Import ─────────────────────────────────────────────────────────── - - #[test] - fn bulk_import_all_with_valid_rows_returns_import_next_command() { - let mut app = main_app(); - if let Screen::Main(m) = &mut app.screen { - m.tab = Tab::BulkImport; - m.bulk_import.stage = BulkImportStage::Preview; - m.bulk_import.parsed = vec![ParsedRow { - row: 2, - result: Ok(LogReviewRequest { - external_metadata_id: None, - manual_title: Some("The Matrix".into()), - manual_release_year: None, - manual_director: None, - rating: 5, - comment: None, - watched_at: "1999-03-31T00:00:00".into(), - }), - }]; - } - let cmds = update(&mut app, Action::BulkImportAll); - assert!(cmds.iter().any(|c| matches!(c, Command::ImportNext(0)))); - } - - #[test] - fn bulk_item_done_advances_stage_and_returns_next_command() { - let mut app = main_app(); - if let Screen::Main(m) = &mut app.screen { - m.tab = Tab::BulkImport; - m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; - m.bulk_import.valid_requests = vec![ - LogReviewRequest { - external_metadata_id: None, - manual_title: Some("A".into()), - manual_release_year: None, - manual_director: None, - rating: 5, - comment: None, - watched_at: "2024-01-01T00:00:00".into(), - }, - LogReviewRequest { - external_metadata_id: None, - manual_title: Some("B".into()), - manual_release_year: None, - manual_director: None, - rating: 4, - comment: None, - watched_at: "2024-01-02T00:00:00".into(), - }, - ]; - m.bulk_import.results = vec![None, None]; - } - let cmds = update( - &mut app, - Action::BulkItemDone { - index: 0, - error: None, - }, - ); - assert!(cmds.iter().any(|c| matches!(c, Command::ImportNext(1)))); - } - - #[test] - fn bulk_item_done_last_item_transitions_to_done() { - let mut app = main_app(); - if let Screen::Main(m) = &mut app.screen { - m.tab = Tab::BulkImport; - m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; - m.bulk_import.valid_requests = vec![LogReviewRequest { - external_metadata_id: None, - manual_title: Some("A".into()), - manual_release_year: None, - manual_director: None, - rating: 5, - comment: None, - watched_at: "2024-01-01T00:00:00".into(), - }]; - m.bulk_import.results = vec![None]; - } - let cmds = update( - &mut app, - Action::BulkItemDone { - index: 0, - error: None, - }, - ); - assert!(cmds.is_empty()); - if let Screen::Main(m) = &app.screen { - assert!(matches!(m.bulk_import.stage, BulkImportStage::Done)); - } - assert!(app.status.is_some()); - } - - // ── Settings ────────────────────────────────────────────────────────────── - - #[test] - fn settings_save_returns_save_config_command() { - let mut app = main_app(); - if let Screen::Main(m) = &mut app.screen { - m.tab = Tab::Settings; - m.settings.api_url = "http://new-server:8080".into(); - } - let cmds = update(&mut app, Action::SettingsSave); - assert!( - cmds.iter() - .any(|c| matches!(c, Command::SaveConfig(url) if url.contains("8080"))) - ); - } - - #[test] - fn settings_logout_clears_token_and_goes_to_login() { - let mut app = main_app(); - let cmds = update(&mut app, Action::SettingsLogout); - assert!(app.token.is_none()); - assert!(matches!(app.screen, Screen::Login(_))); - assert!(cmds.iter().any(|c| matches!(c, Command::ClearToken))); - } - - #[test] - fn auth_ok_uses_app_api_url_for_main_state() { - let mut app = login_app(); - app.api_url = "http://test-server:9000".into(); - update(&mut app, Action::AuthOk("tok".into())); - if let Screen::Main(m) = &app.screen { - assert_eq!(m.settings.api_url, "http://test-server:9000"); - } else { - panic!("expected Main"); - } - } - - // ── parse_csv ───────────────────────────────────────────────────────────── - - // CSV column order matches the export format: - // title,year,director,rating,comment,watched_at,external_metadata_id - - #[test] - fn parse_csv_valid_row_with_title() { - let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\nThe Matrix,1999,Wachowski,5,,1999-03-31T00:00:00,\n"; - let rows = parse_csv(csv); - assert_eq!(rows.len(), 1); - assert!(rows[0].result.is_ok()); - let req = rows[0].result.as_ref().unwrap(); - assert_eq!(req.manual_title.as_deref(), Some("The Matrix")); - assert_eq!(req.manual_director.as_deref(), Some("Wachowski")); - assert_eq!(req.rating, 5); - } - - #[test] - fn parse_csv_row_missing_title_and_id_is_error() { - let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\n,,,5,,2024-01-01T00:00:00,\n"; - let rows = parse_csv(csv); - assert_eq!(rows.len(), 1); - assert!(rows[0].result.is_err()); - } - - #[test] - fn parse_csv_invalid_rating_is_error() { - let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\nThe Matrix,,,9,,2024-01-01T00:00:00,\n"; - let rows = parse_csv(csv); - assert!(rows[0].result.is_err()); - } - - #[test] - fn parse_csv_with_external_id_only() { - let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\n,,,5,,1999-03-31T00:00:00,tt0133093\n"; - let rows = parse_csv(csv); - assert!(rows[0].result.is_ok()); - let req = rows[0].result.as_ref().unwrap(); - assert_eq!(req.external_metadata_id.as_deref(), Some("tt0133093")); - assert!(req.manual_title.is_none()); - } - - #[test] - fn parse_csv_rating_zero_is_valid() { - let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\nThe Matrix,,,0,,2024-01-01T00:00:00,\n"; - let rows = parse_csv(csv); - assert_eq!(rows.len(), 1); - assert!(rows[0].result.is_ok()); - let req = rows[0].result.as_ref().unwrap(); - assert_eq!(req.rating, 0); - } -} +#[path = "tests/app.rs"] +mod tests; diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 51fe170..140f845 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -235,70 +235,5 @@ impl ApiClient { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn apierror_unauthorized_display() { - let err = ApiError::Unauthorized; - assert!(matches!(err, ApiError::Unauthorized)); - assert_eq!(err.to_string(), "unauthorized"); - } - - #[test] - fn apierror_validation_display() { - let err = ApiError::Validation("rating must be 0-5".into()); - assert!(err.to_string().contains("validation error")); - } - - #[test] - fn log_review_request_skips_none_fields() { - let req = LogReviewRequest { - external_metadata_id: None, - manual_title: Some("The Matrix".into()), - manual_release_year: None, - manual_director: None, - rating: 5, - comment: None, - watched_at: "2024-01-15T20:00:00".into(), - }; - let json = serde_json::to_string(&req).unwrap(); - assert!(!json.contains("external_metadata_id")); - assert!(!json.contains("manual_release_year")); - assert!(!json.contains("manual_director")); - assert!(json.contains("\"manual_title\":\"The Matrix\"")); - assert!(json.contains("\"rating\":5")); - } - - #[test] - fn log_review_request_includes_director_when_set() { - let req = LogReviewRequest { - external_metadata_id: None, - manual_title: Some("Dune".into()), - manual_release_year: Some(2021), - manual_director: Some("Denis Villeneuve".into()), - rating: 5, - comment: None, - watched_at: "2024-01-15T20:00:00".into(), - }; - let json = serde_json::to_string(&req).unwrap(); - assert!(json.contains("\"manual_director\":\"Denis Villeneuve\"")); - } - - #[test] - fn api_client_builds_versioned_urls() { - let client = ApiClient::new("http://localhost:3000"); - assert_eq!(client.api("/diary"), "http://localhost:3000/api/v1/diary"); - assert_eq!(client.api("/auth/login"), "http://localhost:3000/api/v1/auth/login"); - assert_eq!(client.api("/social/follow"), "http://localhost:3000/api/v1/social/follow"); - } - - #[test] - fn api_client_update_url() { - let client = ApiClient::new("http://localhost:3000"); - assert!(client.url().contains("3000")); - client.update_url("http://localhost:8080"); - assert!(client.url().contains("8080")); - assert_eq!(client.api("/diary"), "http://localhost:8080/api/v1/diary"); - } -} +#[path = "tests/client.rs"] +mod tests; diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index c3f614a..b387bd1 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -78,21 +78,5 @@ impl Config { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn config_roundtrip() { - let config = Config { - api_url: "http://localhost:3000".into(), - }; - let json = serde_json::to_string(&config).unwrap(); - let decoded: Config = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded.api_url, "http://localhost:3000"); - } - - #[test] - fn load_returns_none_when_no_file() { - let _ = Config::load(); - } -} +#[path = "tests/config.rs"] +mod tests; diff --git a/crates/tui/src/tests/app.rs b/crates/tui/src/tests/app.rs new file mode 100644 index 0000000..f8bc26b --- /dev/null +++ b/crates/tui/src/tests/app.rs @@ -0,0 +1,542 @@ +use super::*; +use api_types::{DiaryEntryDto, MovieDto, ReviewDto}; +use uuid::Uuid; + +fn setup_app() -> App { + App { + screen: Screen::Setup(SetupState { + api_url: String::new(), + error: None, + }), + token: None, + loading: false, + status: None, + api_url: String::new(), + } +} + +fn login_app() -> App { + App { + screen: Screen::Login(LoginState::default()), + token: None, + loading: false, + status: None, + api_url: String::new(), + } +} + +fn main_app() -> App { + App { + screen: Screen::Main(MainState::new("http://localhost:3000".into())), + token: Some("tok".into()), + loading: false, + status: None, + api_url: "http://localhost:3000".into(), + } +} + +fn diary_entry() -> DiaryEntryDto { + DiaryEntryDto { + movie: MovieDto { + id: Uuid::new_v4(), + title: "The Matrix".into(), + release_year: 1999, + director: None, + poster_path: None, + }, + review: ReviewDto { + id: Uuid::new_v4(), + rating: 5, + comment: None, + watched_at: "1999-03-31T00:00:00".into(), + }, + } +} + +// ── Setup screen ────────────────────────────────────────────────────────── + +#[test] +fn setup_input_char_appends_to_api_url() { + let mut app = setup_app(); + update(&mut app, Action::InputChar('h')); + update(&mut app, Action::InputChar('i')); + if let Screen::Setup(s) = &app.screen { + assert_eq!(s.api_url, "hi"); + } else { + panic!("expected Setup"); + } +} + +#[test] +fn setup_submit_with_empty_url_sets_error() { + let mut app = setup_app(); + let cmds = update(&mut app, Action::SetupSubmit); + assert!(cmds.is_empty()); + if let Screen::Setup(s) = &app.screen { + assert!(s.error.is_some()); + } else { + panic!("expected Setup"); + } +} + +#[test] +fn setup_submit_with_url_saves_config_and_transitions_to_login() { + let mut app = setup_app(); + update(&mut app, Action::InputChar('h')); + let cmds = update(&mut app, Action::SetupSubmit); + assert!(cmds.iter().any(|c| matches!(c, Command::SaveConfig(_)))); + assert!(matches!(app.screen, Screen::Login(_))); +} + +// ── Login screen ────────────────────────────────────────────────────────── + +#[test] +fn login_input_char_goes_to_email_by_default() { + let mut app = login_app(); + update(&mut app, Action::InputChar('a')); + if let Screen::Login(s) = &app.screen { + assert_eq!(s.email, "a"); + assert_eq!(s.password, ""); + } else { + panic!(); + } +} + +#[test] +fn login_focus_next_moves_to_password() { + let mut app = login_app(); + update(&mut app, Action::FocusNext); + if let Screen::Login(s) = &app.screen { + assert_eq!(s.focused, LoginField::Password); + } else { + panic!(); + } +} + +#[test] +fn login_input_after_focus_goes_to_password() { + let mut app = login_app(); + update(&mut app, Action::FocusNext); + update(&mut app, Action::InputChar('x')); + if let Screen::Login(s) = &app.screen { + assert_eq!(s.password, "x"); + } else { + panic!(); + } +} + +#[test] +fn login_submit_returns_login_command_and_sets_loading() { + let mut app = login_app(); + for c in "user@example.com".chars() { + update(&mut app, Action::InputChar(c)); + } + update(&mut app, Action::FocusNext); + for c in "pass123".chars() { + update(&mut app, Action::InputChar(c)); + } + let cmds = update(&mut app, Action::LoginSubmit); + assert!(cmds.iter().any(|c| matches!(c, Command::Login { .. }))); + assert!(app.loading); +} + +#[test] +fn login_submit_with_empty_fields_sets_error_status() { + let mut app = login_app(); + let cmds = update(&mut app, Action::LoginSubmit); + assert!(cmds.is_empty()); + assert!(app.status.as_ref().map_or(false, |s| s.is_error)); +} + +#[test] +fn auth_ok_sets_token_and_transitions_to_main() { + let mut app = login_app(); + let cmds = update(&mut app, Action::AuthOk("jwt-token".into())); + assert_eq!(app.token, Some("jwt-token".into())); + assert!(matches!(app.screen, Screen::Main(_))); + assert!(!app.loading); + assert!(cmds.iter().any(|c| matches!(c, Command::SaveToken(_)))); + assert!(cmds.iter().any(|c| matches!(c, Command::LoadDiary { .. }))); +} + +#[test] +fn auth_fail_sets_error_status_and_clears_loading() { + let mut app = login_app(); + app.loading = true; + update(&mut app, Action::AuthFail("bad creds".into())); + assert!(!app.loading); + assert!(app.status.as_ref().map_or(false, |s| s.is_error)); +} + +// ── Diary ───────────────────────────────────────────────────────────────── + +#[test] +fn diary_scroll_down_increments_selected() { + let mut app = main_app(); + update( + &mut app, + Action::DiaryLoaded { + entries: vec![diary_entry(), diary_entry(), diary_entry()], + total: 3, + }, + ); + update(&mut app, Action::ScrollDown); + if let Screen::Main(m) = &app.screen { + assert_eq!(m.diary.selected, 1); + } else { + panic!(); + } +} + +#[test] +fn diary_scroll_up_clamps_at_zero() { + let mut app = main_app(); + update( + &mut app, + Action::DiaryLoaded { + entries: vec![diary_entry()], + total: 1, + }, + ); + update(&mut app, Action::ScrollUp); + if let Screen::Main(m) = &app.screen { + assert_eq!(m.diary.selected, 0); + } else { + panic!(); + } +} + +#[test] +fn diary_scroll_down_clamps_at_last_entry() { + let mut app = main_app(); + update( + &mut app, + Action::DiaryLoaded { + entries: vec![diary_entry()], + total: 1, + }, + ); + update(&mut app, Action::ScrollDown); + if let Screen::Main(m) = &app.screen { + assert_eq!(m.diary.selected, 0); + } else { + panic!(); + } +} + +#[test] +fn delete_init_sets_delete_pending() { + let mut app = main_app(); + let entry = diary_entry(); + let review_id = entry.review.id; + update( + &mut app, + Action::DiaryLoaded { + entries: vec![entry], + total: 1, + }, + ); + update(&mut app, Action::DeleteInit); + if let Screen::Main(m) = &app.screen { + assert_eq!(m.diary.delete_pending, Some(review_id)); + } else { + panic!(); + } +} + +#[test] +fn delete_confirm_returns_delete_command() { + let mut app = main_app(); + let entry = diary_entry(); + let review_id = entry.review.id; + update( + &mut app, + Action::DiaryLoaded { + entries: vec![entry], + total: 1, + }, + ); + update(&mut app, Action::DeleteInit); + let cmds = update(&mut app, Action::DeleteConfirm); + assert!( + cmds.iter() + .any(|c| matches!(c, Command::DeleteReview(id) if *id == review_id)) + ); +} + +#[test] +fn delete_cancel_clears_pending() { + let mut app = main_app(); + let entry = diary_entry(); + update( + &mut app, + Action::DiaryLoaded { + entries: vec![entry], + total: 1, + }, + ); + update(&mut app, Action::DeleteInit); + update(&mut app, Action::DeleteCancel); + if let Screen::Main(m) = &app.screen { + assert!(m.diary.delete_pending.is_none()); + } else { + panic!(); + } +} + +#[test] +fn review_deleted_removes_entry_from_list() { + let mut app = main_app(); + let entry = diary_entry(); + let review_id = entry.review.id; + update( + &mut app, + Action::DiaryLoaded { + entries: vec![entry], + total: 1, + }, + ); + update(&mut app, Action::ReviewDeleted(review_id)); + if let Screen::Main(m) = &app.screen { + assert!(m.diary.entries.is_empty()); + assert_eq!(m.diary.total, 0); + } else { + panic!(); + } +} + +// ── Add Review ──────────────────────────────────────────────────────────── + +#[test] +fn rating_up_increments_rating() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::AddReview; + m.add_review.rating = 3; + } + update(&mut app, Action::RatingUp); + if let Screen::Main(m) = &app.screen { + assert_eq!(m.add_review.rating, 4); + } +} + +#[test] +fn rating_clamps_at_5() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::AddReview; + m.add_review.rating = 5; + } + update(&mut app, Action::RatingUp); + if let Screen::Main(m) = &app.screen { + assert_eq!(m.add_review.rating, 5); + } +} + +#[test] +fn review_submit_returns_create_review_command() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::AddReview; + m.add_review.title = "The Matrix".into(); + m.add_review.watched_at = "1999-03-31T00:00:00".into(); + m.add_review.rating = 5; + } + let cmds = update(&mut app, Action::ReviewSubmit); + assert!(cmds.iter().any(|c| matches!(c, Command::CreateReview(_)))); +} + +#[test] +fn review_submit_with_missing_title_and_id_sets_error() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::AddReview; + m.add_review.watched_at = "1999-03-31T00:00:00".into(); + } + let cmds = update(&mut app, Action::ReviewSubmit); + assert!(cmds.is_empty()); + assert!(app.status.as_ref().map_or(false, |s| s.is_error)); +} + +// ── Bulk Import ─────────────────────────────────────────────────────────── + +#[test] +fn bulk_import_all_with_valid_rows_returns_import_next_command() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::BulkImport; + m.bulk_import.stage = BulkImportStage::Preview; + m.bulk_import.parsed = vec![ParsedRow { + row: 2, + result: Ok(LogReviewRequest { + external_metadata_id: None, + manual_title: Some("The Matrix".into()), + manual_release_year: None, + manual_director: None, + rating: 5, + comment: None, + watched_at: "1999-03-31T00:00:00".into(), + }), + }]; + } + let cmds = update(&mut app, Action::BulkImportAll); + assert!(cmds.iter().any(|c| matches!(c, Command::ImportNext(0)))); +} + +#[test] +fn bulk_item_done_advances_stage_and_returns_next_command() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::BulkImport; + m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; + m.bulk_import.valid_requests = vec![ + LogReviewRequest { + external_metadata_id: None, + manual_title: Some("A".into()), + manual_release_year: None, + manual_director: None, + rating: 5, + comment: None, + watched_at: "2024-01-01T00:00:00".into(), + }, + LogReviewRequest { + external_metadata_id: None, + manual_title: Some("B".into()), + manual_release_year: None, + manual_director: None, + rating: 4, + comment: None, + watched_at: "2024-01-02T00:00:00".into(), + }, + ]; + m.bulk_import.results = vec![None, None]; + } + let cmds = update( + &mut app, + Action::BulkItemDone { + index: 0, + error: None, + }, + ); + assert!(cmds.iter().any(|c| matches!(c, Command::ImportNext(1)))); +} + +#[test] +fn bulk_item_done_last_item_transitions_to_done() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::BulkImport; + m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; + m.bulk_import.valid_requests = vec![LogReviewRequest { + external_metadata_id: None, + manual_title: Some("A".into()), + manual_release_year: None, + manual_director: None, + rating: 5, + comment: None, + watched_at: "2024-01-01T00:00:00".into(), + }]; + m.bulk_import.results = vec![None]; + } + let cmds = update( + &mut app, + Action::BulkItemDone { + index: 0, + error: None, + }, + ); + assert!(cmds.is_empty()); + if let Screen::Main(m) = &app.screen { + assert!(matches!(m.bulk_import.stage, BulkImportStage::Done)); + } + assert!(app.status.is_some()); +} + +// ── Settings ────────────────────────────────────────────────────────────── + +#[test] +fn settings_save_returns_save_config_command() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::Settings; + m.settings.api_url = "http://new-server:8080".into(); + } + let cmds = update(&mut app, Action::SettingsSave); + assert!( + cmds.iter() + .any(|c| matches!(c, Command::SaveConfig(url) if url.contains("8080"))) + ); +} + +#[test] +fn settings_logout_clears_token_and_goes_to_login() { + let mut app = main_app(); + let cmds = update(&mut app, Action::SettingsLogout); + assert!(app.token.is_none()); + assert!(matches!(app.screen, Screen::Login(_))); + assert!(cmds.iter().any(|c| matches!(c, Command::ClearToken))); +} + +#[test] +fn auth_ok_uses_app_api_url_for_main_state() { + let mut app = login_app(); + app.api_url = "http://test-server:9000".into(); + update(&mut app, Action::AuthOk("tok".into())); + if let Screen::Main(m) = &app.screen { + assert_eq!(m.settings.api_url, "http://test-server:9000"); + } else { + panic!("expected Main"); + } +} + +// ── parse_csv ───────────────────────────────────────────────────────────── + +// CSV column order matches the export format: +// title,year,director,rating,comment,watched_at,external_metadata_id + +#[test] +fn parse_csv_valid_row_with_title() { + let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\nThe Matrix,1999,Wachowski,5,,1999-03-31T00:00:00,\n"; + let rows = parse_csv(csv); + assert_eq!(rows.len(), 1); + assert!(rows[0].result.is_ok()); + let req = rows[0].result.as_ref().unwrap(); + assert_eq!(req.manual_title.as_deref(), Some("The Matrix")); + assert_eq!(req.manual_director.as_deref(), Some("Wachowski")); + assert_eq!(req.rating, 5); +} + +#[test] +fn parse_csv_row_missing_title_and_id_is_error() { + let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\n,,,5,,2024-01-01T00:00:00,\n"; + let rows = parse_csv(csv); + assert_eq!(rows.len(), 1); + assert!(rows[0].result.is_err()); +} + +#[test] +fn parse_csv_invalid_rating_is_error() { + let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\nThe Matrix,,,9,,2024-01-01T00:00:00,\n"; + let rows = parse_csv(csv); + assert!(rows[0].result.is_err()); +} + +#[test] +fn parse_csv_with_external_id_only() { + let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\n,,,5,,1999-03-31T00:00:00,tt0133093\n"; + let rows = parse_csv(csv); + assert!(rows[0].result.is_ok()); + let req = rows[0].result.as_ref().unwrap(); + assert_eq!(req.external_metadata_id.as_deref(), Some("tt0133093")); + assert!(req.manual_title.is_none()); +} + +#[test] +fn parse_csv_rating_zero_is_valid() { + let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\nThe Matrix,,,0,,2024-01-01T00:00:00,\n"; + let rows = parse_csv(csv); + assert_eq!(rows.len(), 1); + assert!(rows[0].result.is_ok()); + let req = rows[0].result.as_ref().unwrap(); + assert_eq!(req.rating, 0); +} diff --git a/crates/tui/src/tests/client.rs b/crates/tui/src/tests/client.rs new file mode 100644 index 0000000..02a7918 --- /dev/null +++ b/crates/tui/src/tests/client.rs @@ -0,0 +1,65 @@ +use super::*; + +#[test] +fn apierror_unauthorized_display() { + let err = ApiError::Unauthorized; + assert!(matches!(err, ApiError::Unauthorized)); + assert_eq!(err.to_string(), "unauthorized"); +} + +#[test] +fn apierror_validation_display() { + let err = ApiError::Validation("rating must be 0-5".into()); + assert!(err.to_string().contains("validation error")); +} + +#[test] +fn log_review_request_skips_none_fields() { + let req = LogReviewRequest { + external_metadata_id: None, + manual_title: Some("The Matrix".into()), + manual_release_year: None, + manual_director: None, + rating: 5, + comment: None, + watched_at: "2024-01-15T20:00:00".into(), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(!json.contains("external_metadata_id")); + assert!(!json.contains("manual_release_year")); + assert!(!json.contains("manual_director")); + assert!(json.contains("\"manual_title\":\"The Matrix\"")); + assert!(json.contains("\"rating\":5")); +} + +#[test] +fn log_review_request_includes_director_when_set() { + let req = LogReviewRequest { + external_metadata_id: None, + manual_title: Some("Dune".into()), + manual_release_year: Some(2021), + manual_director: Some("Denis Villeneuve".into()), + rating: 5, + comment: None, + watched_at: "2024-01-15T20:00:00".into(), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"manual_director\":\"Denis Villeneuve\"")); +} + +#[test] +fn api_client_builds_versioned_urls() { + let client = ApiClient::new("http://localhost:3000"); + assert_eq!(client.api("/diary"), "http://localhost:3000/api/v1/diary"); + assert_eq!(client.api("/auth/login"), "http://localhost:3000/api/v1/auth/login"); + assert_eq!(client.api("/social/follow"), "http://localhost:3000/api/v1/social/follow"); +} + +#[test] +fn api_client_update_url() { + let client = ApiClient::new("http://localhost:3000"); + assert!(client.url().contains("3000")); + client.update_url("http://localhost:8080"); + assert!(client.url().contains("8080")); + assert_eq!(client.api("/diary"), "http://localhost:8080/api/v1/diary"); +} diff --git a/crates/tui/src/tests/config.rs b/crates/tui/src/tests/config.rs new file mode 100644 index 0000000..00f8132 --- /dev/null +++ b/crates/tui/src/tests/config.rs @@ -0,0 +1,16 @@ +use super::*; + +#[test] +fn config_roundtrip() { + let config = Config { + api_url: "http://localhost:3000".into(), + }; + let json = serde_json::to_string(&config).unwrap(); + let decoded: Config = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.api_url, "http://localhost:3000"); +} + +#[test] +fn load_returns_none_when_no_file() { + let _ = Config::load(); +}