refactor: move inline tests to separate files via #[path]
This commit is contained in:
@@ -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::<url::Url>().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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
33
crates/adapters/activitypub-base/src/tests/actors.rs
Normal file
33
crates/adapters/activitypub-base/src/tests/actors.rs
Normal file
@@ -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::<url::Url>().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());
|
||||
}
|
||||
37
crates/adapters/activitypub-base/src/tests/nodeinfo.rs
Normal file
37
crates/adapters/activitypub-base/src/tests/nodeinfo.rs
Normal file
@@ -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);
|
||||
}
|
||||
40
crates/adapters/activitypub-base/src/tests/service.rs
Normal file
40
crates/adapters/activitypub-base/src/tests/service.rs
Normal file
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
41
crates/adapters/activitypub/src/tests/objects.rs
Normal file
41
crates/adapters/activitypub/src/tests/objects.rs
Normal file
@@ -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"));
|
||||
}
|
||||
@@ -184,91 +184,5 @@ impl TryFrom<EventPayload> 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;
|
||||
|
||||
86
crates/adapters/event-payload/src/tests/lib.rs
Normal file
86
crates/adapters/event-payload/src/tests/lib.rs
Normal file
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
54
crates/adapters/event-publisher/src/tests/lib.rs
Normal file
54
crates/adapters/event-publisher/src/tests/lib.rs
Normal file
@@ -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());
|
||||
}
|
||||
@@ -75,151 +75,5 @@ fn serialize_json(entries: &[DiaryEntry]) -> Result<Vec<u8>, 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::Value> = 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::Value> = 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;
|
||||
|
||||
146
crates/adapters/export/src/tests/lib.rs
Normal file
146
crates/adapters/export/src/tests/lib.rs
Normal file
@@ -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::Value> = 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::Value> = 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"
|
||||
);
|
||||
}
|
||||
@@ -47,94 +47,5 @@ impl PeriodicJob for ConversionBackfillJob {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct MockImageRef {
|
||||
keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ImageRefQuery for MockImageRef {
|
||||
async fn list_keys(&self) -> Result<Vec<String>, DomainError> {
|
||||
Ok(self.keys.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct MockPublisher {
|
||||
emitted: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl MockPublisher {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self { emitted: Mutex::new(vec![]) })
|
||||
}
|
||||
|
||||
fn emitted(&self) -> Vec<String> {
|
||||
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<dyn EventPublisher>,
|
||||
);
|
||||
|
||||
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<dyn EventPublisher>,
|
||||
);
|
||||
|
||||
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<dyn EventPublisher>,
|
||||
);
|
||||
|
||||
job.run().await.unwrap();
|
||||
|
||||
assert!(publisher.emitted().is_empty());
|
||||
}
|
||||
}
|
||||
#[path = "tests/backfill.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -92,130 +92,5 @@ fn convert(bytes: Vec<u8>, format: Format) -> Result<Vec<u8>, String> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
use object_store::memory::InMemory;
|
||||
use image_storage::ImageStorageAdapter;
|
||||
|
||||
struct MockImageRef {
|
||||
swaps: Mutex<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
impl MockImageRef {
|
||||
fn new() -> Arc<Self> {
|
||||
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<ImageStorageAdapter> {
|
||||
Arc::new(ImageStorageAdapter::new(Arc::new(InMemory::new())))
|
||||
}
|
||||
|
||||
fn tiny_jpeg() -> Vec<u8> {
|
||||
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<dyn ImageStorage>,
|
||||
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
|
||||
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<dyn ImageStorage>,
|
||||
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
|
||||
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<dyn ImageStorage>,
|
||||
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
|
||||
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<dyn ImageStorage>,
|
||||
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
|
||||
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<dyn ImageStorage>,
|
||||
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
|
||||
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;
|
||||
|
||||
89
crates/adapters/image-converter/src/tests/backfill.rs
Normal file
89
crates/adapters/image-converter/src/tests/backfill.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct MockImageRef {
|
||||
keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ImageRefQuery for MockImageRef {
|
||||
async fn list_keys(&self) -> Result<Vec<String>, DomainError> {
|
||||
Ok(self.keys.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct MockPublisher {
|
||||
emitted: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl MockPublisher {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self { emitted: Mutex::new(vec![]) })
|
||||
}
|
||||
|
||||
fn emitted(&self) -> Vec<String> {
|
||||
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<dyn EventPublisher>,
|
||||
);
|
||||
|
||||
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<dyn EventPublisher>,
|
||||
);
|
||||
|
||||
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<dyn EventPublisher>,
|
||||
);
|
||||
|
||||
job.run().await.unwrap();
|
||||
|
||||
assert!(publisher.emitted().is_empty());
|
||||
}
|
||||
39
crates/adapters/image-converter/src/tests/config.rs
Normal file
39
crates/adapters/image-converter/src/tests/config.rs
Normal file
@@ -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");
|
||||
}
|
||||
125
crates/adapters/image-converter/src/tests/handler.rs
Normal file
125
crates/adapters/image-converter/src/tests/handler.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
use object_store::memory::InMemory;
|
||||
use image_storage::ImageStorageAdapter;
|
||||
|
||||
struct MockImageRef {
|
||||
swaps: Mutex<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
impl MockImageRef {
|
||||
fn new() -> Arc<Self> {
|
||||
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<ImageStorageAdapter> {
|
||||
Arc::new(ImageStorageAdapter::new(Arc::new(InMemory::new())))
|
||||
}
|
||||
|
||||
fn tiny_jpeg() -> Vec<u8> {
|
||||
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<dyn ImageStorage>,
|
||||
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
|
||||
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<dyn ImageStorage>,
|
||||
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
|
||||
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<dyn ImageStorage>,
|
||||
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
|
||||
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<dyn ImageStorage>,
|
||||
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
|
||||
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<dyn ImageStorage>,
|
||||
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
|
||||
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());
|
||||
}
|
||||
@@ -62,22 +62,5 @@ fn build_local_store(path: &str) -> anyhow::Result<Arc<dyn ObjectStore>> {
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -98,60 +98,5 @@ pub fn create() -> anyhow::Result<Arc<dyn ImageStorage>> {
|
||||
}
|
||||
|
||||
#[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<dyn ImageStorage>);
|
||||
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;
|
||||
|
||||
17
crates/adapters/image-storage/src/tests/config.rs
Normal file
17
crates/adapters/image-storage/src/tests/config.rs
Normal file
@@ -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());
|
||||
}
|
||||
55
crates/adapters/image-storage/src/tests/lib.rs
Normal file
55
crates/adapters/image-storage/src/tests/lib.rs
Normal file
@@ -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<dyn ImageStorage>);
|
||||
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(_))));
|
||||
}
|
||||
@@ -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<FieldMapping> {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
37
crates/adapters/importer/src/parsers/tests.rs
Normal file
37
crates/adapters/importer/src/parsers/tests.rs
Normal file
@@ -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());
|
||||
}
|
||||
114
crates/adapters/importer/src/tests/mapper.rs
Normal file
114
crates/adapters/importer/src/tests/mapper.rs
Normal file
@@ -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<FieldMapping> {
|
||||
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 { .. }));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
58
crates/adapters/nats/src/tests/config.rs
Normal file
58
crates/adapters/nats/src/tests/config.rs
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
58
crates/adapters/nats/src/tests/subject.rs
Normal file
58
crates/adapters/nats/src/tests/subject.rs
Normal file
@@ -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.>"
|
||||
);
|
||||
}
|
||||
@@ -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("<title>Custom Title</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("<?xml") || xml.starts_with("<rss"));
|
||||
}
|
||||
}
|
||||
#[path = "tests/lib.rs"]
|
||||
mod tests;
|
||||
|
||||
15
crates/adapters/rss/src/tests/lib.rs
Normal file
15
crates/adapters/rss/src/tests/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
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("<title>Custom Title</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("<?xml") || xml.starts_with("<rss"));
|
||||
}
|
||||
@@ -686,196 +686,13 @@ pub fn wire(pool: sqlx::SqlitePool) -> (
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
113
crates/adapters/sqlite-federation/src/tests/lib.rs
Normal file
113
crates/adapters/sqlite-federation/src/tests/lib.rs
Normal file
@@ -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");
|
||||
}
|
||||
@@ -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::<String>::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<String>,) = 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<String>,) = 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;
|
||||
|
||||
103
crates/adapters/sqlite/src/tests/image_ref.rs
Normal file
103
crates/adapters/sqlite/src/tests/image_ref.rs
Normal file
@@ -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::<String>::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<String>,) = 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<String>,) = 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();
|
||||
}
|
||||
91
crates/adapters/sqlite/src/tests/users.rs
Normal file
91
crates/adapters/sqlite/src/tests/users.rs
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u16>) -> 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<Option<Movie>, DomainError> {
|
||||
Ok(Some(self.0.clone()))
|
||||
}
|
||||
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
_: &MovieTitle,
|
||||
_: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, 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<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MovieRepository for RepoEmpty {
|
||||
async fn get_movie_by_external_id(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
_: &MovieTitle,
|
||||
_: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, 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<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MovieRepository for RepoWithTitleMatch {
|
||||
async fn get_movie_by_external_id(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
_: &MovieTitle,
|
||||
_: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, 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<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
|
||||
}
|
||||
|
||||
struct MetaReturnsMovie(Movie);
|
||||
struct MetaErrors;
|
||||
|
||||
#[async_trait]
|
||||
impl MetadataClient for MetaReturnsMovie {
|
||||
async fn fetch_movie_metadata(
|
||||
&self,
|
||||
_: &MetadataSearchCriteria,
|
||||
) -> Result<Movie, DomainError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
async fn get_poster_url(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<PosterUrl>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MetadataClient for MetaErrors {
|
||||
async fn fetch_movie_metadata(
|
||||
&self,
|
||||
_: &MetadataSearchCriteria,
|
||||
) -> Result<Movie, DomainError> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"metadata unavailable".into(),
|
||||
))
|
||||
}
|
||||
async fn get_poster_url(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<PosterUrl>, 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;
|
||||
|
||||
343
crates/application/src/tests/movie_resolver.rs
Normal file
343
crates/application/src/tests/movie_resolver.rs
Normal file
@@ -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<u16>) -> 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<Option<Movie>, DomainError> {
|
||||
Ok(Some(self.0.clone()))
|
||||
}
|
||||
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
_: &MovieTitle,
|
||||
_: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, 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<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MovieRepository for RepoEmpty {
|
||||
async fn get_movie_by_external_id(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
_: &MovieTitle,
|
||||
_: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, 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<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MovieRepository for RepoWithTitleMatch {
|
||||
async fn get_movie_by_external_id(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
_: &MovieTitle,
|
||||
_: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, 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<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
|
||||
}
|
||||
|
||||
struct MetaReturnsMovie(Movie);
|
||||
struct MetaErrors;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MetadataClient for MetaReturnsMovie {
|
||||
async fn fetch_movie_metadata(
|
||||
&self,
|
||||
_: &MetadataSearchCriteria,
|
||||
) -> Result<Movie, DomainError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
async fn get_poster_url(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<PosterUrl>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MetadataClient for MetaErrors {
|
||||
async fn fetch_movie_metadata(
|
||||
&self,
|
||||
_: &MetadataSearchCriteria,
|
||||
) -> Result<Movie, DomainError> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"metadata unavailable".into(),
|
||||
))
|
||||
}
|
||||
async fn get_poster_url(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<PosterUrl>, 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);
|
||||
}
|
||||
169
crates/application/src/tests/worker.rs
Normal file
169
crates/application/src/tests/worker.rs
Normal file
@@ -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<DomainEvent>,
|
||||
}
|
||||
|
||||
impl EventConsumer for VecConsumer {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
let envelopes: Vec<Result<EventEnvelope, DomainError>> = self
|
||||
.events
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|e| Ok(EventEnvelope::new(e, Box::new(NoopAck))))
|
||||
.collect();
|
||||
Box::pin(stream::iter(envelopes))
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordingHandler {
|
||||
calls: Arc<Mutex<Vec<&'static str>>>,
|
||||
}
|
||||
|
||||
#[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<Mutex<bool>>,
|
||||
}
|
||||
|
||||
#[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<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl EventConsumer for TrackingConsumer {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
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<Mutex<bool>>,
|
||||
}
|
||||
|
||||
#[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<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl EventConsumer for TrackingConsumer {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
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());
|
||||
}
|
||||
@@ -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<DomainEvent>,
|
||||
}
|
||||
|
||||
impl EventConsumer for VecConsumer {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
let envelopes: Vec<Result<EventEnvelope, DomainError>> = self
|
||||
.events
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|e| Ok(EventEnvelope::new(e, Box::new(NoopAck))))
|
||||
.collect();
|
||||
Box::pin(stream::iter(envelopes))
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordingHandler {
|
||||
calls: Arc<Mutex<Vec<&'static str>>>,
|
||||
}
|
||||
|
||||
#[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<Mutex<bool>>,
|
||||
}
|
||||
|
||||
#[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<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl EventConsumer for TrackingConsumer {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
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<Mutex<bool>>,
|
||||
}
|
||||
|
||||
#[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<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl EventConsumer for TrackingConsumer {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
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;
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
31
crates/domain/src/models/tests.rs
Normal file
31
crates/domain/src/models/tests.rs
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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<Option<Movie>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
_: &MovieTitle,
|
||||
_: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, 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<domain::models::collections::Paginated<Movie>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl ReviewRepository for Panic {
|
||||
async fn save_review(&self, _: &Review) -> Result<DomainEvent, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_all_reviews_for_user(&self, _: &UserId) -> Result<Vec<Review>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl DiaryRepository for Panic {
|
||||
async fn query_diary(&self, _: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn query_activity_feed(
|
||||
&self,
|
||||
_: &PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn query_activity_feed_filtered(
|
||||
&self,
|
||||
_: &PageParams,
|
||||
_: &domain::ports::FeedSortBy,
|
||||
_: Option<&str>,
|
||||
_: Option<&domain::ports::FollowingFilter>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_movie_stats(
|
||||
&self,
|
||||
_: &MovieId,
|
||||
) -> Result<domain::models::MovieStats, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_movie_social_feed(
|
||||
&self,
|
||||
_: &MovieId,
|
||||
_: &PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "federation")]
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::SocialQueryPort for Panic {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
_: uuid::Uuid,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_all_followed_remote_actors(
|
||||
&self,
|
||||
) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl StatsRepository for Panic {
|
||||
async fn get_user_stats(&self, _: &UserId) -> Result<UserStats, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl MetadataClient for Panic {
|
||||
async fn fetch_movie_metadata(
|
||||
&self,
|
||||
_: &domain::ports::MetadataSearchCriteria,
|
||||
) -> Result<Movie, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_poster_url(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<PosterUrl>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl PosterFetcherClient for Panic {
|
||||
async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl ImageStorage for Panic {
|
||||
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> { panic!() }
|
||||
async fn get(&self, _: &str) -> Result<Vec<u8>, 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<GeneratedToken, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl PasswordHasher for Panic {
|
||||
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl UserRepository for Panic {
|
||||
async fn find_by_email(
|
||||
&self,
|
||||
_: &Email,
|
||||
) -> Result<Option<domain::models::User>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn find_by_id(
|
||||
&self,
|
||||
_: &UserId,
|
||||
) -> Result<Option<domain::models::User>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn find_by_username(
|
||||
&self,
|
||||
_: &domain::value_objects::Username,
|
||||
) -> Result<Option<domain::models::User>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn update_profile(&self, _: &UserId, _: Option<String>, _: Option<String>) -> 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<Option<domain::models::ImportSession>, 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<u64, DomainError> { 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<Vec<domain::models::ImportProfile>, DomainError> { panic!() }
|
||||
async fn get(&self, _: &domain::value_objects::ImportProfileId, _: &UserId) -> Result<Option<domain::models::ImportProfile>, 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<Option<domain::models::MovieProfile>, DomainError> { Ok(None) }
|
||||
async fn list_stale(&self) -> Result<Vec<(domain::value_objects::MovieId, String)>, 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<Vec<u8>, domain::errors::DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
impl domain::ports::DocumentParser for Panic {
|
||||
fn parse(&self, _: &[u8], _: domain::models::FileFormat) -> Result<domain::models::ParsedFile, domain::models::ImportError> {
|
||||
panic!()
|
||||
}
|
||||
fn apply_mapping(&self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping]) -> Vec<domain::models::AnnotatedRow> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::ports::HtmlRenderer for Panic {
|
||||
fn render_diary_page(
|
||||
&self,
|
||||
_: &Paginated<DiaryEntry>,
|
||||
_: application::ports::HtmlPageContext,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_login_page(
|
||||
&self,
|
||||
_: application::ports::LoginPageData<'_>,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_register_page(
|
||||
&self,
|
||||
_: application::ports::RegisterPageData<'_>,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_new_review_page(
|
||||
&self,
|
||||
_: application::ports::NewReviewPageData<'_>,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_activity_feed_page(
|
||||
&self,
|
||||
_: application::ports::ActivityFeedPageData,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_users_page(
|
||||
&self,
|
||||
_: application::ports::UsersPageData,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_profile_page(
|
||||
&self,
|
||||
_: application::ports::ProfilePageData,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_following_page(
|
||||
&self,
|
||||
_: application::ports::FollowingPageData,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_followers_page(
|
||||
&self,
|
||||
_: application::ports::FollowersPageData,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_movie_detail_page(
|
||||
&self,
|
||||
_: application::ports::MovieDetailPageData,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result<String, String> { panic!() }
|
||||
fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result<String, String> { panic!() }
|
||||
fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result<String, String> { panic!() }
|
||||
fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result<String, String> { panic!() }
|
||||
fn render_blocked_domains_page(&self, _: application::ports::BlockedDomainsPageData) -> Result<String, String> { panic!() }
|
||||
fn render_blocked_actors_page(&self, _: application::ports::BlockedActorsPageData) -> Result<String, String> { panic!() }
|
||||
}
|
||||
impl crate::ports::RssFeedRenderer for Panic {
|
||||
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct RejectingAuth;
|
||||
#[async_trait::async_trait]
|
||||
impl AuthService for RejectingAuth {
|
||||
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
|
||||
Err(DomainError::Unauthorized("bad token".into()))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Single state factory — only auth_service varies ---
|
||||
|
||||
fn make_test_state(auth_service: Arc<dyn AuthService>) -> 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
508
crates/presentation/src/tests/extractors.rs
Normal file
508
crates/presentation/src/tests/extractors.rs
Normal file
@@ -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<Option<Movie>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
_: &MovieTitle,
|
||||
_: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, 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<domain::models::collections::Paginated<Movie>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl ReviewRepository for Panic {
|
||||
async fn save_review(&self, _: &Review) -> Result<DomainEvent, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_all_reviews_for_user(&self, _: &UserId) -> Result<Vec<Review>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl DiaryRepository for Panic {
|
||||
async fn query_diary(&self, _: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn query_activity_feed(
|
||||
&self,
|
||||
_: &PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn query_activity_feed_filtered(
|
||||
&self,
|
||||
_: &PageParams,
|
||||
_: &domain::ports::FeedSortBy,
|
||||
_: Option<&str>,
|
||||
_: Option<&domain::ports::FollowingFilter>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_movie_stats(
|
||||
&self,
|
||||
_: &MovieId,
|
||||
) -> Result<domain::models::MovieStats, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_movie_social_feed(
|
||||
&self,
|
||||
_: &MovieId,
|
||||
_: &PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "federation")]
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::SocialQueryPort for Panic {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
_: uuid::Uuid,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_all_followed_remote_actors(
|
||||
&self,
|
||||
) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl StatsRepository for Panic {
|
||||
async fn get_user_stats(&self, _: &UserId) -> Result<UserStats, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl MetadataClient for Panic {
|
||||
async fn fetch_movie_metadata(
|
||||
&self,
|
||||
_: &domain::ports::MetadataSearchCriteria,
|
||||
) -> Result<Movie, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_poster_url(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<PosterUrl>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl PosterFetcherClient for Panic {
|
||||
async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl ImageStorage for Panic {
|
||||
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> { panic!() }
|
||||
async fn get(&self, _: &str) -> Result<Vec<u8>, 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<GeneratedToken, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl PasswordHasher for Panic {
|
||||
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl UserRepository for Panic {
|
||||
async fn find_by_email(
|
||||
&self,
|
||||
_: &Email,
|
||||
) -> Result<Option<domain::models::User>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn find_by_id(
|
||||
&self,
|
||||
_: &UserId,
|
||||
) -> Result<Option<domain::models::User>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn find_by_username(
|
||||
&self,
|
||||
_: &domain::value_objects::Username,
|
||||
) -> Result<Option<domain::models::User>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn update_profile(&self, _: &UserId, _: Option<String>, _: Option<String>) -> 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<Option<domain::models::ImportSession>, 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<u64, DomainError> { 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<Vec<domain::models::ImportProfile>, DomainError> { panic!() }
|
||||
async fn get(&self, _: &domain::value_objects::ImportProfileId, _: &UserId) -> Result<Option<domain::models::ImportProfile>, 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<Option<domain::models::MovieProfile>, DomainError> { Ok(None) }
|
||||
async fn list_stale(&self) -> Result<Vec<(domain::value_objects::MovieId, String)>, 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<Vec<u8>, domain::errors::DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
impl domain::ports::DocumentParser for Panic {
|
||||
fn parse(&self, _: &[u8], _: domain::models::FileFormat) -> Result<domain::models::ParsedFile, domain::models::ImportError> {
|
||||
panic!()
|
||||
}
|
||||
fn apply_mapping(&self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping]) -> Vec<domain::models::AnnotatedRow> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::ports::HtmlRenderer for Panic {
|
||||
fn render_diary_page(
|
||||
&self,
|
||||
_: &Paginated<DiaryEntry>,
|
||||
_: application::ports::HtmlPageContext,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_login_page(
|
||||
&self,
|
||||
_: application::ports::LoginPageData<'_>,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_register_page(
|
||||
&self,
|
||||
_: application::ports::RegisterPageData<'_>,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_new_review_page(
|
||||
&self,
|
||||
_: application::ports::NewReviewPageData<'_>,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_activity_feed_page(
|
||||
&self,
|
||||
_: application::ports::ActivityFeedPageData,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_users_page(
|
||||
&self,
|
||||
_: application::ports::UsersPageData,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_profile_page(
|
||||
&self,
|
||||
_: application::ports::ProfilePageData,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_following_page(
|
||||
&self,
|
||||
_: application::ports::FollowingPageData,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_followers_page(
|
||||
&self,
|
||||
_: application::ports::FollowersPageData,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_movie_detail_page(
|
||||
&self,
|
||||
_: application::ports::MovieDetailPageData,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result<String, String> { panic!() }
|
||||
fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result<String, String> { panic!() }
|
||||
fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result<String, String> { panic!() }
|
||||
fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result<String, String> { panic!() }
|
||||
fn render_blocked_domains_page(&self, _: application::ports::BlockedDomainsPageData) -> Result<String, String> { panic!() }
|
||||
fn render_blocked_actors_page(&self, _: application::ports::BlockedActorsPageData) -> Result<String, String> { panic!() }
|
||||
}
|
||||
impl crate::ports::RssFeedRenderer for Panic {
|
||||
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct RejectingAuth;
|
||||
#[async_trait::async_trait]
|
||||
impl AuthService for RejectingAuth {
|
||||
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
|
||||
Err(DomainError::Unauthorized("bad token".into()))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Single state factory — only auth_service varies ---
|
||||
|
||||
fn make_test_state(auth_service: Arc<dyn AuthService>) -> 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");
|
||||
}
|
||||
112
crates/presentation/src/tests/forms.rs
Normal file
112
crates/presentation/src/tests/forms.rs
Normal file
@@ -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");
|
||||
}
|
||||
@@ -993,547 +993,5 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
542
crates/tui/src/tests/app.rs
Normal file
542
crates/tui/src/tests/app.rs
Normal file
@@ -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);
|
||||
}
|
||||
65
crates/tui/src/tests/client.rs
Normal file
65
crates/tui/src/tests/client.rs
Normal file
@@ -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");
|
||||
}
|
||||
16
crates/tui/src/tests/config.rs
Normal file
16
crates/tui/src/tests/config.rs
Normal file
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user