test: shared factories and command builders

This commit is contained in:
2026-10-25 22:08:17 +01:00
parent 2ac1d4e0cf
commit 51d92e08a7
58 changed files with 784 additions and 1344 deletions

View File

@@ -13,5 +13,6 @@ bytes = { workspace = true }
async-stream = { workspace = true }
[dev-dependencies]
domain = { workspace = true, features = ["test-helpers"] }
uuid = { workspace = true }
tokio = { workspace = true }

View File

@@ -1,8 +1,11 @@
use super::ExportAdapter;
use domain::{
models::{DiaryEntry, ExportFormat, Movie, Review},
models::{DiaryEntry, ExportFormat, Movie, ReviewEdit},
ports::DiaryExporter,
value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear},
testing::{
test_movie_with_director, test_movie_with_external_id, test_movie_with_year, test_review,
},
value_objects::{Comment, ExternalMetadataId, UserId},
};
async fn collect_stream(
@@ -26,55 +29,19 @@ fn entry_stream(
Box::pin(futures::stream::iter(entries.into_iter().map(Ok)))
}
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(),
None,
false,
)
.unwrap();
fn entry_for(movie: Movie, rating: u8, comment: Option<&str>) -> DiaryEntry {
let review = test_review(movie.id(), &UserId::generate(), rating).apply_edit(ReviewEdit {
comment: Some(comment.map(|c| Comment::new(c.to_string()).unwrap())),
..Default::default()
});
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"),
let entry = entry_for(
test_movie_with_director("Inception", 2010, "Christopher Nolan"),
5,
Some("great"),
);
@@ -95,7 +62,7 @@ async fn csv_has_header_and_one_row() {
#[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 entry = entry_for(test_movie_with_year("Tár, A Film", 2022), 4, None);
let bytes =
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await;
let text = String::from_utf8(bytes).unwrap();
@@ -105,7 +72,11 @@ async fn csv_escapes_commas_in_title() {
#[tokio::test]
async fn json_is_valid_array() {
let adapter = ExportAdapter;
let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None);
let entry = entry_for(
test_movie_with_director("Dune", 2021, "Denis Villeneuve"),
5,
None,
);
let bytes =
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await;
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
@@ -120,20 +91,29 @@ async fn json_is_valid_array() {
#[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 entry = entry_for(
test_movie_with_external_id(
"Alien",
1979,
ExternalMetadataId::new("tt0078748".to_string()).unwrap(),
),
5,
None,
);
let bytes =
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await;
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(arr[0]["external_metadata_id"], "tt0078748");
let bytes = collect_stream(adapter.stream_entries(
entry_stream(vec![make_entry_full(
"Alien",
1979,
None,
entry_stream(vec![entry_for(
test_movie_with_external_id(
"Alien",
1979,
ExternalMetadataId::new("tt0078748".to_string()).unwrap(),
),
5,
None,
Some("tt0078748"),
)]),
ExportFormat::Csv,
))

View File

@@ -966,26 +966,6 @@ impl UserRepository for ErroringUserRepo {
}
}
/// Builds a bare `User` with the given role, for the admin/non-admin extractor
/// tests — only `id()` and `role()` are read by `authorize_admin::execute`.
fn make_user_with_role(id: UserId, role: domain::models::UserRole) -> domain::models::User {
domain::models::User::from_persistence(
id,
domain::value_objects::Email::new("extractor-test@example.com".into()).unwrap(),
domain::value_objects::Username::new("extractor_test_user".into()).unwrap(),
domain::value_objects::PasswordHash::new("hashed".into()).unwrap(),
role,
domain::models::UserProfile {
display_name: None,
bio: None,
avatar_path: None,
banner_path: None,
also_known_as: None,
profile_fields: vec![],
},
)
}
/// Auth stub that accepts any bearer token and resolves it to a fixed user id,
/// for tests that need to authenticate as a specific user rather than merely
/// exercise the unauthenticated-request path.
@@ -1154,7 +1134,7 @@ async fn admin_api_user_allows_an_admin_user() {
let user_repo = InMemoryUserRepository::new();
user_repo.store.lock().unwrap().insert(
id.value(),
make_user_with_role(id.clone(), domain::models::UserRole::Admin),
domain::testing::test_user_with_role(id.clone(), domain::models::UserRole::Admin),
);
let app = router_admin_api(test_app_state_with_user(
user_repo as _,
@@ -1179,7 +1159,7 @@ async fn admin_api_user_rejects_a_non_admin_user() {
let user_repo = InMemoryUserRepository::new();
user_repo.store.lock().unwrap().insert(
id.value(),
make_user_with_role(id.clone(), domain::models::UserRole::Standard),
domain::testing::test_user_with_role(id.clone(), domain::models::UserRole::Standard),
);
let app = router_admin_api(test_app_state_with_user(
user_repo as _,
@@ -1245,7 +1225,7 @@ async fn admin_user_allows_an_admin_user() {
let user_repo = InMemoryUserRepository::new();
user_repo.store.lock().unwrap().insert(
id.value(),
make_user_with_role(id.clone(), domain::models::UserRole::Admin),
domain::testing::test_user_with_role(id.clone(), domain::models::UserRole::Admin),
);
let app = router_admin_cookie(test_app_state_with_user(
user_repo as _,
@@ -1270,7 +1250,7 @@ async fn admin_user_rejects_a_non_admin_user() {
let user_repo = InMemoryUserRepository::new();
user_repo.store.lock().unwrap().insert(
id.value(),
make_user_with_role(id.clone(), domain::models::UserRole::Standard),
domain::testing::test_user_with_role(id.clone(), domain::models::UserRole::Standard),
);
let app = router_admin_cookie(test_app_state_with_user(
user_repo as _,

View File

@@ -8,4 +8,5 @@ rss-feed = { package = "rss", version = "2", features = ["atom"] }
domain = { workspace = true }
[dev-dependencies]
domain = { workspace = true, features = ["test-helpers"] }
chrono = { workspace = true }

View File

@@ -1,6 +1,7 @@
use super::*;
use domain::models::{Movie, Review};
use domain::value_objects::{Comment, MovieTitle, Rating, ReleaseYear, UserId};
use domain::models::ReviewEdit;
use domain::testing::{test_movie_with_year, test_review};
use domain::value_objects::{Comment, UserId};
fn channel<'a>(title: &'a str) -> FeedChannel<'a> {
FeedChannel {
@@ -11,23 +12,12 @@ fn channel<'a>(title: &'a str) -> FeedChannel<'a> {
}
fn entry(spoilers: bool) -> DiaryEntry {
let movie = Movie::new(
None,
MovieTitle::new("Blade Runner".into()).unwrap(),
ReleaseYear::new(1982).unwrap(),
None,
None,
);
let review = Review::new(
movie.id().clone(),
UserId::generate(),
Rating::new(4).unwrap(),
Some(Comment::new("replicants".into()).unwrap()),
chrono::Utc::now().naive_utc(),
None,
spoilers,
)
.unwrap();
let movie = test_movie_with_year("Blade Runner", 1982);
let review = test_review(movie.id(), &UserId::generate(), 4).apply_edit(ReviewEdit {
comment: Some(Some(Comment::new("replicants".into()).unwrap())),
spoilers: Some(spoilers),
..Default::default()
});
DiaryEntry::new(movie, review)
}

View File

@@ -22,4 +22,5 @@ bytes = { workspace = true }
async-stream = { workspace = true }
[dev-dependencies]
domain = { workspace = true, features = ["test-helpers"] }
tempfile = { workspace = true }

View File

@@ -1,6 +1,7 @@
use super::SqlitePersonAdapter;
use domain::testing::test_person_with_tmdb;
use domain::{
models::{ExternalPersonId, Person, PersonId},
models::{ExternalPersonId, PersonId},
ports::{PersonCommand, PersonQuery},
};
use sqlx::SqlitePool;
@@ -47,25 +48,14 @@ async fn pool_with_schema() -> SqlitePool {
pool
}
fn make_person(tmdb_id: i64, name: &str, dept: Option<&str>) -> Person {
let ext = ExternalPersonId::new(format!("tmdb:{tmdb_id}"));
Person::basic(
PersonId::from_external(&ext),
ext,
name.to_string(),
dept.map(str::to_string),
None,
)
}
#[tokio::test]
async fn upsert_batch_inserts_persons() {
let pool = pool_with_schema().await;
let adapter = SqlitePersonAdapter::new(pool.clone());
let persons = vec![
make_person(1, "Alice", Some("Acting")),
make_person(2, "Bob", Some("Directing")),
test_person_with_tmdb(1, "Alice", Some("Acting")),
test_person_with_tmdb(2, "Bob", Some("Directing")),
];
adapter.upsert_batch(&persons).await.unwrap();
@@ -81,7 +71,7 @@ async fn upsert_batch_is_idempotent() {
let pool = pool_with_schema().await;
let adapter = SqlitePersonAdapter::new(pool.clone());
let persons = vec![make_person(1, "Alice", Some("Acting"))];
let persons = vec![test_person_with_tmdb(1, "Alice", Some("Acting"))];
adapter.upsert_batch(&persons).await.unwrap();
adapter.upsert_batch(&persons).await.unwrap();
@@ -97,7 +87,7 @@ async fn get_by_id_returns_person() {
let pool = pool_with_schema().await;
let adapter = SqlitePersonAdapter::new(pool.clone());
let p = make_person(42, "Charlie", Some("Acting"));
let p = test_person_with_tmdb(42, "Charlie", Some("Acting"));
adapter
.upsert_batch(std::slice::from_ref(&p))
.await
@@ -122,7 +112,7 @@ async fn get_credits_returns_cast_and_crew() {
let pool = pool_with_schema().await;
let adapter = SqlitePersonAdapter::new(pool.clone());
let p = make_person(7, "Diana", Some("Acting"));
let p = test_person_with_tmdb(7, "Diana", Some("Acting"));
adapter
.upsert_batch(std::slice::from_ref(&p))
.await

View File

@@ -1,6 +1,5 @@
use super::*;
use domain::models::UserRole;
use domain::value_objects::{Email, PasswordHash, Username};
use domain::testing::test_user;
use sqlx::SqlitePool;
async fn setup() -> (SqlitePool, SqliteUserRepository) {
@@ -55,12 +54,7 @@ async fn find_by_id_returns_user_when_found() {
#[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,
);
let user = test_user("testuser");
repo.save(&user).await.unwrap();
repo.update_profile(
@@ -82,12 +76,7 @@ async fn update_profile_persists_bio_and_avatar() {
#[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,
);
let user = test_user("testuser2");
repo.save(&user).await.unwrap();
repo.update_profile(
user.id(),