code refactor
Some checks failed
CI / Check / Test (push) Has been cancelled

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-10-26 09:36:23 +00:00
parent 2ce7db12c2
commit 017b58d6fa
804 changed files with 33423 additions and 28955 deletions

View File

@@ -0,0 +1,762 @@
use std::collections::BTreeSet;
use std::str::FromStr;
use chrono::Utc;
use domain::models::{ProfileField, UserProfile, UserSettings, WatchEvent, WatchEventSource};
use domain::models::{WatchlistEntry, WebhookToken};
use domain::ports::{
AccountPurger, FollowCommand, FollowWrite, UserProfileFieldsRepository, WatchEventCommand,
WebhookTokenRepository,
};
use domain::testing::{test_goal, test_movie, test_review, test_user, test_watched_at};
use domain::value_objects::{FollowStatus, InstanceIdentity, UserId};
use sqlite::{
PAYLOAD_KEYED_USER_COLUMNS, PURGED_USER_TABLES, RETAINED_USER_TABLES, SqliteAccountPurger,
SqliteProfileFieldsRepository, SqliteSocialRepository, SqliteWatchEventRepository,
SqliteWebhookTokenRepository, URL_KEYED_USER_COLUMNS,
};
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
const BASE: &str = "https://md.example";
const REMOTE_ACTOR: &str = "https://remote.example/users/ann";
async fn migrated_pool() -> SqlitePool {
let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await
.unwrap();
sqlite::migrate(&pool).await.unwrap();
pool
}
fn instance() -> InstanceIdentity {
InstanceIdentity::new(BASE)
}
fn purger(pool: &SqlitePool) -> SqliteAccountPurger {
SqliteAccountPurger::new(pool.clone(), instance())
}
async fn count(pool: &SqlitePool, sql: &str) -> i64 {
sqlx::query_scalar(sql).fetch_one(pool).await.unwrap()
}
async fn count_for(pool: &SqlitePool, table: &str, column: &str, user: &UserId) -> i64 {
sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table} WHERE {column} = ?"))
.bind(user.value().to_string())
.fetch_one(pool)
.await
.unwrap()
}
async fn exec(pool: &SqlitePool, sql: &str, binds: &[&str]) {
let mut q = sqlx::query(sql);
for b in binds {
q = q.bind(*b);
}
q.execute(pool).await.unwrap();
}
struct Seeded {
user: UserId,
other: UserId,
movie_id: String,
}
async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded {
let alice = test_user("alice");
let bob = test_user("bob");
let a = alice.id().clone();
let b = bob.id().clone();
let a_str = a.value().to_string();
let b_str = b.value().to_string();
parts.user.save(&alice).await.unwrap();
parts.user.save(&bob).await.unwrap();
let movie = test_movie("Heat");
parts.movie_command.upsert_movie(&movie, &[]).await.unwrap();
for u in [&a, &b] {
parts
.watchlist
.add(&WatchlistEntry::new(u.clone(), movie.id().clone()), &[])
.await
.unwrap();
parts
.goal_command
.save(&test_goal(u, 2024, 50), &[])
.await
.unwrap();
parts
.user_settings
.save(&UserSettings::new(u.clone()))
.await
.unwrap();
SqliteProfileFieldsRepository::new(pool.clone())
.set_fields(
u,
vec![ProfileField {
name: "Site".into(),
value: "https://example.com".into(),
}],
&[],
)
.await
.unwrap();
SqliteWebhookTokenRepository::new(pool.clone())
.save(&WebhookToken::new(
u.clone(),
format!("hash-{}", u.value()),
WatchEventSource::Jellyfin,
None,
))
.await
.unwrap();
SqliteWatchEventRepository::new(pool.clone())
.save(
&WatchEvent::new(
u.clone(),
"Heat".into(),
Some(1995),
None,
WatchEventSource::Jellyfin,
test_watched_at(),
None,
),
&[],
)
.await
.unwrap();
parts
.review
.save_review(&test_review(movie.id(), u, 4), &[])
.await
.unwrap();
exec(
pool,
"INSERT INTO import_sessions (id, user_id, parsed_data, created_at, expires_at) VALUES (?, ?, '{}', 'now', 'later')",
&[&uuid::Uuid::new_v4().to_string(), &u.value().to_string()],
)
.await;
exec(
pool,
"INSERT INTO import_profiles (id, user_id, name, field_mappings, created_at) VALUES (?, ?, 'p', '{}', 'now')",
&[&uuid::Uuid::new_v4().to_string(), &u.value().to_string()],
)
.await;
exec(
pool,
"INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, 'later', 'now')",
&[
&uuid::Uuid::new_v4().to_string(),
&u.value().to_string(),
&uuid::Uuid::new_v4().to_string(),
],
)
.await;
exec(
pool,
"INSERT INTO wrap_up_records (id, user_id, start_date, end_date) VALUES (?, ?, 'a', 'b')",
&[&uuid::Uuid::new_v4().to_string(), &u.value().to_string()],
)
.await;
exec(
pool,
"INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES (?, ?, 'now')",
&[&u.value().to_string(), REMOTE_ACTOR],
)
.await;
exec(
pool,
"INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) VALUES (?, 'pub', 'priv', 'now')",
&[&u.value().to_string()],
)
.await;
}
let social = SqliteSocialRepository::new(pool.clone(), instance());
let alice_actor = instance().actor_url_for(&a);
social
.apply(
&[
FollowWrite::AddFollow {
follower_id: a.value(),
target_actor_url: REMOTE_ACTOR.into(),
status: FollowStatus::Accepted,
},
FollowWrite::AddFollower {
local_user_id: a.value(),
follower_actor_url: REMOTE_ACTOR.into(),
status: FollowStatus::Accepted,
},
FollowWrite::AddFollow {
follower_id: b.value(),
target_actor_url: REMOTE_ACTOR.into(),
status: FollowStatus::Accepted,
},
FollowWrite::AddFollower {
local_user_id: b.value(),
follower_actor_url: REMOTE_ACTOR.into(),
status: FollowStatus::Accepted,
},
FollowWrite::AddFollow {
follower_id: b.value(),
target_actor_url: alice_actor.clone(),
status: FollowStatus::Accepted,
},
FollowWrite::AddFollower {
local_user_id: b.value(),
follower_actor_url: alice_actor,
status: FollowStatus::Accepted,
},
],
&[],
)
.await
.unwrap();
exec(
pool,
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
SELECT ?, movie_id, ?, rating, comment, watched_at, created_at, ? FROM reviews WHERE user_id = ? LIMIT 1",
&[
&uuid::Uuid::new_v4().to_string(),
&a_str,
REMOTE_ACTOR,
&a_str,
],
)
.await;
let alice_review: String =
sqlx::query_scalar("SELECT id FROM reviews WHERE user_id = ? AND remote_actor_url IS NULL")
.bind(&a_str)
.fetch_one(pool)
.await
.unwrap();
let bob_review: String = sqlx::query_scalar("SELECT id FROM reviews WHERE user_id = ?")
.bind(&b_str)
.fetch_one(pool)
.await
.unwrap();
for (id, object, actor) in [
(
"an1",
format!("{BASE}/reviews/{alice_review}"),
REMOTE_ACTOR,
),
(
"an2",
format!("{BASE}/users/{a_str}/goals/2024"),
REMOTE_ACTOR,
),
("an3", format!("{BASE}/reviews/{bob_review}"), REMOTE_ACTOR),
] {
exec(
pool,
"INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES (?, ?, ?, 'now')",
&[id, &object, actor],
)
.await;
}
for (event_type, status, payload) in [
(
"ReviewLogged",
"done",
format!(r#"{{"user_id":"{a_str}","comment":"secret"}}"#),
),
(
"FederationDeliveryRequested",
"done",
format!(
r#"{{"signing_actor_id":"{a_str}","activity_json":{{"type":"Create","content":"secret"}}}}"#
),
),
(
"FederationDeliveryRequested",
"pending",
format!(
r#"{{"signing_actor_id":"{a_str}","activity_json":{{"type":"Delete","id":"x"}}}}"#
),
),
(
"UserDeleted",
"processing",
format!(r#"{{"user_id":"{a_str}"}}"#),
),
(
"ReviewLogged",
"done",
format!(r#"{{"user_id":"{b_str}"}}"#),
),
(
"FollowRequested",
"pending",
format!(r#"{{"follower_id":"{b_str}","target":"{a_str}"}}"#),
),
(
"ActorBlocked",
"processing",
format!(r#"{{"user_id":"{b_str}","actor":"{a_str}"}}"#),
),
(
"Unfollowed",
"done",
format!(r#"{{"follower_id":"{b_str}","target":"{a_str}"}}"#),
),
] {
sqlx::query("INSERT INTO event_queue (event_type, payload, status) VALUES (?, ?, ?)")
.bind(event_type)
.bind(payload)
.bind(status)
.execute(pool)
.await
.unwrap();
}
Seeded {
user: a,
other: b,
movie_id: movie.id().value().to_string(),
}
}
async fn tombstone(pool: &SqlitePool, user: &UserId) {
exec(
pool,
"UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE id = ?",
&[&user.value().to_string()],
)
.await;
}
#[tokio::test]
async fn purge_removes_every_user_owned_row() {
let pool = migrated_pool().await;
let parts = sqlite::wire_pool(pool.clone()).await.unwrap();
let s = seed(&pool, &parts).await;
for (table, column) in PURGED_USER_TABLES {
assert!(
count_for(&pool, table, column, &s.user).await > 0,
"{table} should be seeded"
);
}
tombstone(&pool, &s.user).await;
purger(&pool).purge(&s.user).await.unwrap();
for (table, column) in PURGED_USER_TABLES {
let left = if *table == "reviews" { 1 } else { 0 };
assert_eq!(
count_for(&pool, table, column, &s.user).await,
left,
"{table} should be purged"
);
}
let alice_actor = instance().actor_url_for(&s.user);
for table in ["ap_followers", "ap_following"] {
let pointing: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {table} WHERE remote_actor_url = ?"
))
.bind(&alice_actor)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(pointing, 0, "{table} rows pointing at the user are gone");
}
let reviews: i64 = count(
&pool,
&format!(
"SELECT COUNT(*) FROM reviews WHERE user_id = '{}'",
s.user.value()
),
)
.await;
assert_eq!(reviews, 1, "the remote review is not the user's to purge");
let announces: Vec<String> = sqlx::query_scalar("SELECT id FROM ap_announces ORDER BY id")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(announces, vec!["an3"], "only bob's announce survives");
let queue: Vec<(String, String)> =
sqlx::query_as("SELECT event_type, status FROM event_queue ORDER BY id")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(
queue,
vec![
(
"FederationDeliveryRequested".to_string(),
"pending".to_string()
),
("UserDeleted".to_string(), "processing".to_string()),
("ReviewLogged".to_string(), "done".to_string()),
("FollowRequested".to_string(), "pending".to_string()),
("ActorBlocked".to_string(), "processing".to_string()),
],
"finished rows mentioning the user are gone; pending/processing rows (incl. other users' events about her) and bob's own rows stay"
);
for (table, column) in PURGED_USER_TABLES {
assert!(
count_for(&pool, table, column, &s.other).await > 0,
"{table} of the other user must survive"
);
}
for table in RETAINED_USER_TABLES {
assert_eq!(count_for(&pool, table, "user_id", &s.user).await, 1);
}
}
#[tokio::test]
async fn the_purge_table_lists_cover_every_user_referencing_column_in_the_schema() {
let pool = migrated_pool().await;
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT m.name, c.name FROM sqlite_master m, pragma_table_info(m.name) c
WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' AND m.name != 'users'
AND (c.name LIKE '%user_id%' OR c.name LIKE 'owner%')",
)
.fetch_all(&pool)
.await
.unwrap();
let fk_tables: Vec<String> = sqlx::query_scalar(
"SELECT m.name FROM sqlite_master m, pragma_foreign_key_list(m.name) f
WHERE m.type = 'table' AND f.\"table\" = 'users'",
)
.fetch_all(&pool)
.await
.unwrap();
let found: BTreeSet<(String, String)> = rows.into_iter().collect();
let mut covered: BTreeSet<(String, String)> = PURGED_USER_TABLES
.iter()
.map(|(t, c)| (t.to_string(), c.to_string()))
.collect();
covered.extend(
RETAINED_USER_TABLES
.iter()
.map(|t| (t.to_string(), "user_id".to_string())),
);
assert_eq!(
found, covered,
"a table references a user but the purge neither empties nor retains it"
);
for t in fk_tables {
assert!(
covered.iter().any(|(table, _)| *table == t),
"{t} has a foreign key to users but is not in the purge lists"
);
}
for (table, column) in URL_KEYED_USER_COLUMNS
.iter()
.chain(PAYLOAD_KEYED_USER_COLUMNS)
{
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?")
.bind(table)
.bind(column)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(n, 1, "{table}.{column} is matched by the purge but missing");
}
}
#[tokio::test]
async fn ap_activities_holds_nothing_attributable_to_a_local_user() {
let pool = migrated_pool().await;
let columns: Vec<String> =
sqlx::query_scalar("SELECT name FROM pragma_table_info('ap_activities') ORDER BY name")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(columns, vec!["id", "processed_at"]);
}
#[tokio::test]
async fn purge_keeps_movies_and_metadata() {
let pool = migrated_pool().await;
let parts = sqlite::wire_pool(pool.clone()).await.unwrap();
let s = seed(&pool, &parts).await;
let movies = count(&pool, "SELECT COUNT(*) FROM movies").await;
tombstone(&pool, &s.user).await;
purger(&pool).purge(&s.user).await.unwrap();
assert_eq!(count(&pool, "SELECT COUNT(*) FROM movies").await, movies);
assert_eq!(
count(
&pool,
&format!("SELECT COUNT(*) FROM movies WHERE id = '{}'", s.movie_id)
)
.await,
1
);
}
#[tokio::test]
async fn purge_strips_the_user_row_but_keeps_the_username_and_state() {
let pool = migrated_pool().await;
let parts = sqlite::wire_pool(pool.clone()).await.unwrap();
let s = seed(&pool, &parts).await;
parts
.user
.update_profile(
&s.user,
&UserProfile {
display_name: Some("Alice".into()),
bio: Some("bio".into()),
avatar_path: Some("avatars/a.webp".into()),
banner_path: Some("banners/a.webp".into()),
also_known_as: Some("https://x.example/a".into()),
..Default::default()
},
&[],
)
.await
.unwrap();
parts.user.mark_deleted(&s.user, &[]).await.unwrap();
let p = purger(&pool);
assert!(!p.is_purged(&s.user).await.unwrap());
p.purge(&s.user).await.unwrap();
assert!(p.is_purged(&s.user).await.unwrap());
let tomb = parts
.user
.find_tombstoned_by_id(&s.user)
.await
.unwrap()
.unwrap();
assert_eq!(tomb.username().value(), "alice");
assert_eq!(tomb.display_name(), None);
assert_eq!(tomb.bio(), None);
assert_eq!(tomb.also_known_as(), None);
assert_ne!(tomb.email().value(), "alice@example.com");
assert!(
parts
.user
.is_username_retired(tomb.username())
.await
.unwrap()
);
let mut keys = p.owned_image_keys(&s.user).await.unwrap();
keys.sort();
assert_eq!(keys, vec!["avatars/a.webp", "banners/a.webp"]);
p.forget_images(&s.user).await.unwrap();
assert!(p.owned_image_keys(&s.user).await.unwrap().is_empty());
}
#[tokio::test]
async fn purging_twice_is_a_no_op() {
let pool = migrated_pool().await;
let parts = sqlite::wire_pool(pool.clone()).await.unwrap();
let s = seed(&pool, &parts).await;
parts.user.mark_deleted(&s.user, &[]).await.unwrap();
let p = purger(&pool);
p.purge(&s.user).await.unwrap();
let before: i64 = count(&pool, "SELECT COUNT(*) FROM reviews").await;
let stamp: String = sqlx::query_scalar("SELECT purged_at FROM users WHERE id = ?")
.bind(s.user.value().to_string())
.fetch_one(&pool)
.await
.unwrap();
p.purge(&s.user).await.unwrap();
assert_eq!(count(&pool, "SELECT COUNT(*) FROM reviews").await, before);
let again: String = sqlx::query_scalar("SELECT purged_at FROM users WHERE id = ?")
.bind(s.user.value().to_string())
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(stamp, again);
p.purge(&UserId::generate()).await.unwrap();
}
#[tokio::test]
async fn purge_never_touches_a_live_account() {
let pool = migrated_pool().await;
let parts = sqlite::wire_pool(pool.clone()).await.unwrap();
let s = seed(&pool, &parts).await;
purger(&pool).purge(&s.user).await.unwrap();
for (table, column) in PURGED_USER_TABLES {
assert!(
count_for(&pool, table, column, &s.user).await > 0,
"{table}"
);
}
assert!(parts.user.find_by_id(&s.user).await.unwrap().is_some());
}
#[tokio::test]
async fn purge_frees_the_email_but_the_username_stays_retired() {
let pool = migrated_pool().await;
let parts = sqlite::wire_pool(pool.clone()).await.unwrap();
let s = seed(&pool, &parts).await;
let again = test_user("alice");
assert!(
parts.user.save(&again).await.is_err(),
"email is held while tombstoned"
);
parts.user.mark_deleted(&s.user, &[]).await.unwrap();
assert!(parts.user.save(&again).await.is_err());
purger(&pool).purge(&s.user).await.unwrap();
let reuse_email = domain::models::User::new(
domain::value_objects::Email::new("alice@example.com".into()).unwrap(),
domain::value_objects::Username::new("alice2".into()).unwrap(),
domain::value_objects::PasswordHash::new("h".into()).unwrap(),
domain::models::UserRole::Standard,
);
parts.user.save(&reuse_email).await.unwrap();
assert!(
parts
.user
.is_username_retired(&domain::value_objects::Username::new("alice".into()).unwrap())
.await
.unwrap()
);
assert!(
parts.user.save(&again).await.is_err(),
"the username is never reusable"
);
}
#[tokio::test]
async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_ones() {
let pool = migrated_pool().await;
let parts = sqlite::wire_pool(pool.clone()).await.unwrap();
let s = seed(&pool, &parts).await;
parts.user.mark_deleted(&s.user, &[]).await.unwrap();
let p = purger(&pool);
p.purge(&s.user).await.unwrap();
let now = Utc::now().naive_utc();
let stamp = |days: i64| {
(now - chrono::Duration::days(days))
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string()
};
exec(
&pool,
"UPDATE users SET purged_at = ? WHERE id = ?",
&[&stamp(8), &s.user.value().to_string()],
)
.await;
let removed = p
.purge_expired_tombstones(now - chrono::Duration::days(9))
.await
.unwrap();
assert_eq!(removed, 0);
assert_eq!(count_for(&pool, "users", "id", &s.user).await, 1);
let alice = s.user.value().to_string();
let unfinished = || async {
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM event_queue WHERE status IN ('pending', 'processing')",
)
.fetch_one(&pool)
.await
.unwrap()
};
let before = unfinished().await;
assert!(before >= 3);
let removed = p
.purge_expired_tombstones(now - chrono::Duration::days(7))
.await
.unwrap();
assert_eq!(removed, 0, "an unfinished event still names the user");
assert_eq!(count_for(&pool, "users", "id", &s.user).await, 1);
assert_eq!(unfinished().await, before);
exec(
&pool,
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at)
VALUES (?, 'https://late.example/users/zed', 'pending', '2026-01-01T00:00:00Z')",
&[&alice],
)
.await;
exec(&pool, "UPDATE event_queue SET status = 'done'", &[]).await;
let removed = p
.purge_expired_tombstones(now - chrono::Duration::days(7))
.await
.unwrap();
assert_eq!(removed, 1);
assert_eq!(count_for(&pool, "users", "id", &s.user).await, 0);
let left: Vec<String> = sqlx::query_scalar("SELECT payload FROM event_queue")
.fetch_all(&pool)
.await
.unwrap();
assert!(
left.iter().all(|p| !p.contains(&alice)),
"no history of the user remains: {left:?}"
);
assert_eq!(
count_for(&pool, "ap_followers", "local_user_id", &s.user).await,
0,
"late shared-inbox follower row"
);
assert_eq!(
count_for(&pool, "ap_local_actors", "user_id", &s.user).await,
0
);
assert_eq!(count_for(&pool, "users", "id", &s.other).await, 1);
assert_eq!(
count_for(&pool, "ap_local_actors", "user_id", &s.other).await,
1
);
let retired: i64 = count(
&pool,
"SELECT COUNT(*) FROM retired_usernames WHERE username = 'alice'",
)
.await;
assert_eq!(retired, 1, "the username stays retired for good");
}
#[tokio::test]
async fn purge_redacts_the_user_from_global_wrap_up_reports_only() {
let pool = migrated_pool().await;
let parts = sqlite::wire_pool(pool.clone()).await.unwrap();
let s = seed(&pool, &parts).await;
let (a, b) = (s.user.value().to_string(), s.other.value().to_string());
let report = |id: &str, name: &str| {
format!(
r#"{{"total_watched":3,"most_active_user":{{"user_id":"{id}","display_name":"{name}"}},"total_users_active":2}}"#
)
};
for (rid, id, name) in [("g-alice", &a, "Zelda Quux"), ("g-bob", &b, "Bobby Tables")] {
exec(
&pool,
"INSERT INTO wrap_up_records (id, user_id, start_date, end_date, status, report_json)
VALUES (?, NULL, '2024-01-01', '2024-12-31', 'completed', ?)",
&[rid, &report(id, name)],
)
.await;
}
tombstone(&pool, &s.user).await;
purger(&pool).purge(&s.user).await.unwrap();
let all: Vec<(String, String)> =
sqlx::query_as("SELECT id, report_json FROM wrap_up_records WHERE user_id IS NULL")
.fetch_all(&pool)
.await
.unwrap();
for (_, json) in &all {
assert!(!json.contains(&a), "user id left in {json}");
assert!(!json.contains("Zelda Quux"), "display name left in {json}");
}
let alice_row = all.iter().find(|(id, _)| id == "g-alice").unwrap();
let v: serde_json::Value = serde_json::from_str(&alice_row.1).unwrap();
assert!(v["most_active_user"].is_null(), "{v}");
assert_eq!(v["total_watched"], 3, "rest of the report is kept");
let bob_row = all.iter().find(|(id, _)| id == "g-bob").unwrap();
assert_eq!(
bob_row.1,
report(&b, "Bobby Tables"),
"other user untouched"
);
}

View File

@@ -0,0 +1,338 @@
use std::collections::BTreeSet;
use std::str::FromStr;
use chrono::Utc;
use domain::models::{
CastMember, CrewMember, Genre, Keyword, Movie, MovieProfile, ProfileField, UserSettings,
WatchEvent, WatchEventSource, WatchlistEntry, WebhookToken,
};
use domain::ports::{
FollowCommand, FollowWrite, UserProfileFieldsRepository, WatchEventCommand,
WebhookTokenRepository,
};
use domain::testing::{
MOVIE_CASCADE_TABLES, USER_CASCADE_TABLES, USER_CASCADE_TABLES_OUTSIDE_STORE, test_goal,
test_movie, test_review, test_user, test_watched_at,
};
use domain::value_objects::{FollowStatus, MovieId, UserId};
use sqlite::{
SqliteProfileFieldsRepository, SqliteSocialRepository, SqliteWatchEventRepository,
SqliteWebhookTokenRepository,
};
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
const REMOTE_ACTOR: &str = "https://remote.example/users/ann";
async fn migrated_pool() -> SqlitePool {
let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await
.unwrap();
let (foreign_keys,): (i64,) = sqlx::query_as("PRAGMA foreign_keys")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
foreign_keys, 1,
"tests must run with FKs enforced like production"
);
sqlite::migrate(&pool).await.unwrap();
pool
}
async fn count(pool: &SqlitePool, table: &str) -> i64 {
sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}"))
.fetch_one(pool)
.await
.unwrap()
}
async fn tables_cascading_from(pool: &SqlitePool, parent: &str) -> BTreeSet<String> {
sqlx::query_scalar::<_, String>(
"SELECT m.name FROM sqlite_master m, pragma_foreign_key_list(m.name) f
WHERE m.type = 'table' AND f.\"table\" = ? AND f.on_delete = 'CASCADE'",
)
.bind(parent)
.fetch_all(pool)
.await
.unwrap()
.into_iter()
.collect()
}
fn names(lists: &[&[&str]]) -> BTreeSet<String> {
lists
.iter()
.flat_map(|l| l.iter())
.map(|t| t.to_string())
.collect()
}
fn movie_profile(movie_id: &MovieId) -> MovieProfile {
MovieProfile {
movie_id: movie_id.clone(),
tmdb_id: 603,
imdb_id: None,
overview: None,
tagline: None,
runtime_minutes: None,
budget_usd: None,
revenue_usd: None,
vote_average: None,
vote_count: None,
original_language: None,
collection_name: None,
genres: vec![Genre {
tmdb_id: 1,
name: "Sci-Fi".into(),
}],
keywords: vec![Keyword {
tmdb_id: 2,
name: "simulation".into(),
}],
cast: vec![CastMember {
tmdb_person_id: 3,
name: "Actor".into(),
character: "Role".into(),
billing_order: 0,
profile_path: None,
}],
crew: vec![CrewMember {
tmdb_person_id: 4,
name: "Director".into(),
job: "Director".into(),
department: "Directing".into(),
profile_path: None,
}],
enriched_at: Utc::now(),
}
}
async fn seed_user_with_dependents(
pool: &SqlitePool,
parts: &sqlite::SqliteWireOutput,
) -> (UserId, Movie) {
let user = test_user("alice");
let user_id = user.id().clone();
let movie = test_movie("Heat");
parts.user.save(&user).await.unwrap();
parts.movie_command.upsert_movie(&movie, &[]).await.unwrap();
parts
.watchlist
.add(
&WatchlistEntry::new(user_id.clone(), movie.id().clone()),
&[],
)
.await
.unwrap();
parts
.goal_command
.save(&test_goal(&user_id, 2024, 50), &[])
.await
.unwrap();
parts
.user_settings
.save(&UserSettings::new(user_id.clone()))
.await
.unwrap();
SqliteProfileFieldsRepository::new(pool.clone())
.set_fields(
&user_id,
vec![ProfileField {
name: "Site".into(),
value: "https://example.com".into(),
}],
&[],
)
.await
.unwrap();
SqliteWebhookTokenRepository::new(pool.clone())
.save(&WebhookToken::new(
user_id.clone(),
"hash".into(),
WatchEventSource::Jellyfin,
None,
))
.await
.unwrap();
SqliteWatchEventRepository::new(pool.clone())
.save(
&WatchEvent::new(
user_id.clone(),
"Heat".into(),
Some(1995),
None,
WatchEventSource::Jellyfin,
test_watched_at(),
None,
),
&[],
)
.await
.unwrap();
let social = SqliteSocialRepository::new(
pool.clone(),
domain::value_objects::InstanceIdentity::new("https://md.example"),
);
social
.apply(
&[
FollowWrite::AddFollow {
follower_id: user_id.value(),
target_actor_url: REMOTE_ACTOR.into(),
status: FollowStatus::Accepted,
},
FollowWrite::AddFollower {
local_user_id: user_id.value(),
follower_actor_url: REMOTE_ACTOR.into(),
status: FollowStatus::Accepted,
},
],
&[],
)
.await
.unwrap();
sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES (?, ?, 'now')")
.bind(user_id.value().to_string())
.bind(REMOTE_ACTOR)
.execute(pool)
.await
.unwrap();
sqlx::query("INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) VALUES (?, 'pub', 'priv', 'now')")
.bind(user_id.value().to_string())
.execute(pool)
.await
.unwrap();
parts
.review
.save_review(&test_review(movie.id(), &user_id, 4), &[])
.await
.unwrap();
(user_id, movie)
}
#[tokio::test]
async fn the_cascade_lists_match_the_foreign_keys_declared_by_the_migrations() {
let pool = migrated_pool().await;
assert_eq!(
tables_cascading_from(&pool, "users").await,
names(&[USER_CASCADE_TABLES, USER_CASCADE_TABLES_OUTSIDE_STORE]),
);
assert_eq!(
tables_cascading_from(&pool, "movies").await,
names(&[MOVIE_CASCADE_TABLES]),
);
}
#[tokio::test]
async fn deleting_a_user_cascades_to_every_table_that_hangs_off_a_user() {
let pool = migrated_pool().await;
let parts = sqlite::wire_pool(pool.clone()).await.unwrap();
let (user_id, _) = seed_user_with_dependents(&pool, &parts).await;
for table in USER_CASCADE_TABLES
.iter()
.chain(USER_CASCADE_TABLES_OUTSIDE_STORE)
{
assert!(count(&pool, table).await > 0, "{table} should be seeded");
}
sqlx::query("DELETE FROM users WHERE id = ?")
.bind(user_id.value().to_string())
.execute(&pool)
.await
.unwrap();
for table in USER_CASCADE_TABLES
.iter()
.chain(USER_CASCADE_TABLES_OUTSIDE_STORE)
{
assert_eq!(count(&pool, table).await, 0, "{table} should cascade");
}
assert_eq!(count(&pool, "reviews").await, 1, "reviews have no user FK");
}
#[tokio::test]
async fn deleting_a_movie_cascades_to_watchlist_entries_and_every_enrichment_table() {
let pool = migrated_pool().await;
let parts = sqlite::wire_pool(pool.clone()).await.unwrap();
let user = test_user("alice");
let movie = test_movie("Heat");
parts.user.save(&user).await.unwrap();
parts.movie_command.upsert_movie(&movie, &[]).await.unwrap();
parts
.watchlist
.add(
&WatchlistEntry::new(user.id().clone(), movie.id().clone()),
&[],
)
.await
.unwrap();
parts
.movie_profile
.upsert(&movie_profile(movie.id()))
.await
.unwrap();
SqliteWatchEventRepository::new(pool.clone())
.save(
&WatchEvent::new(
user.id().clone(),
"Heat".into(),
Some(1995),
None,
WatchEventSource::Plex,
test_watched_at(),
Some(movie.id().clone()),
),
&[],
)
.await
.unwrap();
for table in MOVIE_CASCADE_TABLES {
assert!(count(&pool, table).await > 0, "{table} should be seeded");
}
parts
.movie_command
.delete_movie(movie.id(), &[])
.await
.unwrap();
for table in MOVIE_CASCADE_TABLES {
assert_eq!(count(&pool, table).await, 0, "{table} should cascade");
}
let (movie_id,): (Option<String>,) = sqlx::query_as("SELECT movie_id FROM watch_events")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(movie_id, None, "watch events are detached, not deleted");
}
#[tokio::test]
async fn deleting_a_movie_that_has_reviews_is_refused_because_reviews_do_not_cascade() {
let pool = migrated_pool().await;
let parts = sqlite::wire_pool(pool.clone()).await.unwrap();
let user = test_user("alice");
let movie = test_movie("Heat");
parts.user.save(&user).await.unwrap();
parts.movie_command.upsert_movie(&movie, &[]).await.unwrap();
parts
.review
.save_review(&test_review(movie.id(), user.id(), 5), &[])
.await
.unwrap();
let result = parts.movie_command.delete_movie(movie.id(), &[]).await;
assert!(matches!(
result,
Err(domain::errors::DomainError::InfrastructureError(_))
));
assert_eq!(count(&pool, "movies").await, 1);
assert_eq!(count(&pool, "reviews").await, 1);
}

View File

@@ -0,0 +1,33 @@
1 8E739CFF6E02FED257C86B7FE409EB07C10BA99475A638B4E19840D82D6841C73420C6C4E1328B5EC29DF785EAC3062F
2 BB37ADE7421737C25370B6332E3A1433CC26752A6362FFB3FC96366FC4BD93EF065E8C0167742F869B0CDBCD58F108FD
3 760C99DCD559ECBD4AD0FC0A9DAEDACA621FD46627C94F2A4F0D5EF9586AB00CBFE17DAFC062CFCDAB8475580E15B461
4 B91289D7629799BCF618B6034702468E914A29B25410D5C2A76FEBAE306CDFBAE0E257BA646EECB1F2E79B419758C996
5 15E7EF4EF0209C042DAB96C05C59B07E7B31013F7E66944C4B7668272381E79317E141BBD81AA5FBEBB976EAE967A0AC
6 39E160883C51D281E8DD1BA9EAA6AB10DA96E9F6F8C8FEC29C92A8AEBC51BD83132E3DB2D05603097D2C742EB8C5E41A
7 6B1AC746A418CE21C53F6DA4D6B6785B672F0022F5330D2A120C33932151B20F8F3BD8BB1B2A9F21F801D7091C926A3B
8 F14ED60C9D5921B92A7FBAC1576DF1F548FAB34A28326777C689ED32B6125FAC7E6192771DC67D3F036D299A7F6236E6
9 69E5AD1B6A5D24AF708C9BEA9F4B3695C60E14041DA2BB06EA445F40A24214B30C5771092FEA7BAC0D3335DA3CA797D0
10 7BA25975CB09BECAE0DE1F2645F089DE2204645C97A014A7C8C9BB915E6EBB40DF695AE076E2CBB1ECEFB26414A769B9
11 169AD32E5630D2D58E9EABFF22D70EFF97440D486BCEEC5993043E15A1D1C14D69DB1DC65F90CE069D2FBDF7485ABFAA
12 2356ED23B885EC86F96B2E5C1E2B9E7A580B742BC04F6355E2EBB19FEC58988557C8674A1BAB2E13BA2CFE4A9791AB27
13 595176135F7B5BF6F2304C985AD51FA4D102CCBA58CAA85A436A1C921869E43DEA9054F90ED3D0DC4CFCD640D52BB465
14 2871A6B456820EB45CAB85FEDBF9FF9E264D7F7E569CA6A114BE6D1FBFD3A42590650B9823AA00A7ABAE944B4FD8EBF6
15 CC66FD300934364A20866ADFACDCF4DEC65D7011F159A2C16FD6303F3DDBAB331684189EC4CC4FFC62CE6E85D4A42721
16 347B77FBF9DDFFB17950AF66C0B9DC485A38D4F9E6BFECAFB0F933036DDF0599381848C7635ADD29F4DCF3055C4ACFBE
17 BF5C1E3591A407CA8368AEE41D6CF7D842FE83310653B43747C12F2639D35C6F1E612FDD6739043A8FE226F0132D1B0D
18 7358CDB2DCBE1BDF1EF629C8A68990971B0A6B5D0FE0AC6672791478B0130C817B184F234572C325DE2565CD9C9C03BD
19 BAF7FA0F56E7757AC2F8D52B563902CB77126803304C3BAE35468F9B48B7F735DB2C8D4351ECDDC856916EBE393E1BD6
20 875E8181058BBB455C6D2D7E3FEA459DC24D500F5C16746EED46D696BBE3D2D07008FB03A29FA3827BF490496902B3CF
21 745DA33DBB2DD0D2E9121756D2D00D23DAE5307396063E300E52203C40A21FB51DEAD0047C901B8A6248098E685E45F3
22 947E5F3428691FF26E475825CBFE22976FBBA719C0F4547B7D525DCD042B108464A8A75F1F90392189520E94D7031DDA
23 947A36BFAD46B3068AB91FCC8A04A24D7ABCBCF78340DD3CB4B24134DB7C62550D2C9AD017A95C1FC12D362D93EA70AF
24 B5156F76DE0B4903521B19A3DE798EEB1F93193C21CCA69AC79B6945C78C9400AD1E2A4C94D16481C444D7D2579DF8D9
25 113BFB1C5D3194CCDE7B6C1EDE6A16D956DECFAC6BB3D5AA96D70F7E460A9AF579025373787FAB8482C9F0BDCD3D5AB8
26 60A5B8D024712B1F2FA0F31DBB4D4079A1BFE562F77E544F5877D5251B829B30FBDBC4B1F8D36B84CEA488B07F94DF3A
27 1E5069F7240DDA6D2BB8160E6997FF8FD6F6993D0D907AB30CC7DE5B774072B4FC46572CB83C038CA3A2BAA124048C84
28 1D3FE5DA59EBCBC098CB13E40364872906F4E0CCFF355AF3E2BCD3E0A21D88F0522CD8E4674AA075385965DBF719ACD8
29 56F4F7B41A09F07211D7543204F0EAC93B7BFB15AFD9BC90DF2654DAE5BE96B1E358B50E2190BF25739C5F0B1FD4B806
30 DAAA0AF35B4ACB271A9D8068B82F02A72EEC62F3AC07ED504C7A7887976C2C8BACB35FFF477B63A931DF15F79018390D
100 310ADC9ABF0CC2F8E9EDAB33168D009D64228FF5BD8D2A14185513699EBB782606A1EEA8FEE6EC6AF65D72396153F13F
101 19A2542D4343F3921A8BF0A10E195DA1915E3EA97A2BCF2D3BF9079504DB2053E4BD2175D94C95BCA363BC2FDF14AA88
102 A3995D574BDDDB634F9D7FEF5FAEE658F272C3E0CABA182080425ED5522E73DBF44EDEF28F35BDCF4669F722E4E2AA4B

Binary file not shown.

View File

@@ -0,0 +1,102 @@
use std::path::Path;
use sqlx::{
SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
const LEDGER: &str = include_str!("fixtures/migration_ledger.txt");
const PRE_MERGE_DB: &str = "tests/fixtures/pre_merge.db";
async fn migrate_with_todays_entry_points(pool: &SqlitePool) {
sqlite::migrate(pool).await.unwrap();
}
async fn ledger(pool: &SqlitePool) -> String {
let rows: Vec<(i64, String)> =
sqlx::query_as("SELECT version, hex(checksum) FROM _sqlx_migrations ORDER BY version")
.fetch_all(pool)
.await
.unwrap();
rows.iter().map(|(v, c)| format!("{v} {c}\n")).collect()
}
async fn file_pool(path: &Path, create: bool) -> SqlitePool {
let opts = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(create);
SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await
.unwrap()
}
#[tokio::test]
async fn migrating_a_fresh_database_produces_the_recorded_ledger() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
migrate_with_todays_entry_points(&pool).await;
assert_eq!(ledger(&pool).await, LEDGER);
}
#[tokio::test]
async fn a_database_migrated_by_the_old_layout_migrates_again_cleanly() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pre_merge.db");
std::fs::copy(
Path::new(env!("CARGO_MANIFEST_DIR")).join(PRE_MERGE_DB),
&path,
)
.unwrap();
let pool = file_pool(&path, false).await;
sqlite::migrate(&pool).await.unwrap();
assert_eq!(ledger(&pool).await, LEDGER);
}
async fn wal_pool(path: &Path, create: bool) -> SqlitePool {
let opts = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(create)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(std::time::Duration::from_secs(5));
SqlitePoolOptions::new()
.max_connections(2)
.connect_with(opts)
.await
.unwrap()
}
async fn race_two_migrators(start: Option<&Path>) {
for _ in 0..10 {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("race.db");
if let Some(src) = start {
std::fs::copy(src, &path).unwrap();
}
let a = wal_pool(&path, start.is_none()).await;
let b = wal_pool(&path, false).await;
let (ra, rb) = tokio::join!(sqlite::migrate(&a), sqlite::migrate(&b));
ra.unwrap();
rb.unwrap();
assert_eq!(ledger(&a).await, LEDGER);
}
}
#[tokio::test]
async fn two_processes_migrating_the_same_fresh_database_both_succeed() {
race_two_migrators(None).await;
}
#[tokio::test]
async fn two_processes_migrating_a_database_at_the_old_version_both_succeed() {
race_two_migrators(Some(
&Path::new(env!("CARGO_MANIFEST_DIR")).join(PRE_MERGE_DB),
))
.await;
}

View File

@@ -0,0 +1,22 @@
#[path = "repositories/diary_repository_test.rs"]
mod diary_repository_test;
#[path = "repositories/image_ref_adapter_test.rs"]
mod image_ref_adapter_test;
#[path = "repositories/movie_dedup_test.rs"]
mod movie_dedup_test;
#[path = "repositories/movie_profile_repository_test.rs"]
mod movie_profile_repository_test;
#[path = "repositories/outbox_support.rs"]
mod outbox_support;
#[path = "repositories/outbox_writes_test.rs"]
mod outbox_writes_test;
#[path = "repositories/person_adapter_test.rs"]
mod person_adapter_test;
#[path = "repositories/review_repository_test.rs"]
mod review_repository_test;
#[path = "repositories/search_adapter_test.rs"]
mod search_adapter_test;
#[path = "repositories/social_repository_test.rs"]
mod social_repository_test;
#[path = "repositories/user_repository_test.rs"]
mod user_repository_test;

View File

@@ -0,0 +1,236 @@
use domain::{
models::collections::PageParams,
models::{FeedSortBy, FollowingFilter},
ports::DiaryQuery,
};
use sqlite::SqliteDiaryRepository;
use sqlx::SqlitePool;
async fn setup(pool: &SqlitePool) {
sqlite::migrate(pool).await.unwrap();
sqlx::query(
"INSERT INTO users (id, email, username, password_hash, created_at) VALUES
('11111111-1111-1111-1111-111111111111', 'alice@example.com', 'alice', 'hash', '2024-01-01 00:00:00'),
('22222222-2222-2222-2222-222222222222', 'bob@example.com', 'bob', 'hash', '2024-01-01 00:00:00'),
('33333333-3333-3333-3333-333333333333', 'carol@remote.social', 'carol', 'hash', '2024-01-01 00:00:00')",
)
.execute(pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO movies (id, title, release_year) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Inception', 2010),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Interstellar', 2014),
('cccccccc-cccc-cccc-cccc-cccccccccccc', 'Dune', 2021)",
)
.execute(pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at, remote_actor_url) VALUES
('a1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 5, '2024-01-01 00:00:00', '2024-01-01 00:00:00', NULL),
('b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '22222222-2222-2222-2222-222222222222', 3, '2024-01-02 00:00:00', '2024-01-02 00:00:00', NULL),
('c3c3c3c3-c3c3-c3c3-c3c3-c3c3c3c3c3c3', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '33333333-3333-3333-3333-333333333333', 4, '2024-01-03 00:00:00', '2024-01-03 00:00:00', 'https://remote.social/users/carol')",
)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn activity_feed_sorts_by_rating_descending() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
let page = PageParams::new(Some(10), Some(0)).unwrap();
let result = repo
.query_activity_feed_filtered(&page, &FeedSortBy::Rating, None, None)
.await
.unwrap();
let ratings: Vec<u8> = result
.items
.iter()
.map(|e| e.review().rating().value())
.collect();
assert_eq!(ratings, vec![5, 4, 3]);
}
#[tokio::test]
async fn activity_feed_filters_by_title_search() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
let page = PageParams::new(Some(10), Some(0)).unwrap();
let result = repo
.query_activity_feed_filtered(&page, &FeedSortBy::Date, Some("Dune"), None)
.await
.unwrap();
assert_eq!(result.items.len(), 1);
assert_eq!(result.items[0].movie().title().value(), "Dune");
}
#[tokio::test]
async fn activity_feed_restricts_to_followed_local_and_remote_authors() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
let filter = FollowingFilter {
local_user_ids: vec![
uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(),
],
remote_actor_urls: vec!["https://remote.social/users/carol".to_string()],
};
let page = PageParams::new(Some(10), Some(0)).unwrap();
let result = repo
.query_activity_feed_filtered(&page, &FeedSortBy::Date, None, Some(&filter))
.await
.unwrap();
assert_eq!(result.items.len(), 2);
let titles: Vec<String> = result
.items
.iter()
.map(|e| e.movie().title().value().to_string())
.collect();
assert!(titles.contains(&"Inception".to_string()));
assert!(titles.contains(&"Dune".to_string()));
}
#[tokio::test]
async fn movie_stats_for_local_review() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
let movie_id = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
);
let stats = repo.get_movie_stats(&movie_id).await.unwrap();
assert_eq!(stats.total_count, 1);
assert_eq!(stats.federated_count, 0);
assert!((stats.avg_rating.unwrap() - 5.0).abs() < 0.001);
assert_eq!(stats.rating_histogram[4], 1);
assert_eq!(stats.rating_histogram[0], 0);
}
#[tokio::test]
async fn movie_social_feed_returns_reviews_for_movie() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
let movie_id = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
);
let page = PageParams::new(Some(10), Some(0)).unwrap();
let result = repo.get_movie_social_feed(&movie_id, &page).await.unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.items.len(), 1);
assert_eq!(result.items[0].movie().title().value(), "Inception");
assert_eq!(result.items[0].review().rating().value(), 5);
assert_eq!(result.items[0].user_display_name(), "alice");
assert!(!result.items[0].review().is_remote());
}
#[tokio::test]
async fn movie_social_feed_marks_federated_review_as_remote() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
let movie_id = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap(),
);
let page = PageParams::new(Some(10), Some(0)).unwrap();
let result = repo.get_movie_social_feed(&movie_id, &page).await.unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.items.len(), 1);
assert!(result.items[0].review().is_remote());
assert_eq!(
result.items[0].user_email(),
"https://remote.social/users/carol"
);
}
#[tokio::test]
async fn movie_social_feed_offset_past_end_returns_no_items() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
let movie_id = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
);
let page = PageParams::new(Some(10), Some(5)).unwrap();
let result = repo.get_movie_social_feed(&movie_id, &page).await.unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.items.len(), 0);
}
#[tokio::test]
async fn movie_stats_counts_federated_review() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
let movie_id = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap(),
);
let stats = repo.get_movie_stats(&movie_id).await.unwrap();
assert_eq!(stats.total_count, 1);
assert_eq!(stats.federated_count, 1);
assert_eq!(stats.rating_histogram[3], 1);
assert_eq!(stats.rating_histogram[4], 0);
}
#[tokio::test]
async fn count_local_posts_excludes_remote_reviews() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
let count = repo.count_local_posts().await.unwrap();
assert_eq!(count, 2);
}
#[tokio::test]
async fn feeds_hide_reviews_of_deleted_local_authors() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE username = 'alice'")
.execute(&pool)
.await
.unwrap();
let repo = SqliteDiaryRepository::new(pool);
let page = PageParams::new(Some(10), Some(0)).unwrap();
let feed = repo
.query_activity_feed_filtered(&page, &FeedSortBy::Date, None, None)
.await
.unwrap();
assert_eq!(feed.total_count, 2);
assert!(
feed.items
.iter()
.all(|e| e.movie().title().value() != "Inception")
);
let movie = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
);
let social = repo.get_movie_social_feed(&movie, &page).await.unwrap();
assert!(social.items.is_empty());
}

View File

@@ -0,0 +1,120 @@
use domain::ports::{ImageRefCommand, ImageRefQuery};
use sqlite::SqliteImageRefAdapter;
use sqlx::SqlitePool;
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();
}

View File

@@ -0,0 +1,161 @@
use std::str::FromStr;
use domain::{
models::Movie,
ports::MovieDeduplicator,
value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterPath, ReleaseYear},
};
use sqlite::SqliteMovieDeduplicator;
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
async fn migrated_pool() -> SqlitePool {
let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await
.unwrap();
sqlite::migrate(&pool).await.unwrap();
pool
}
async fn insert_movie(pool: &SqlitePool, id: &MovieId, ext: Option<&str>, poster: Option<&str>) {
sqlx::query(
"INSERT INTO movies (id, external_metadata_id, title, release_year, poster_path)
VALUES (?, ?, 'Heat', 1995, ?)",
)
.bind(id.value().to_string())
.bind(ext)
.bind(poster)
.execute(pool)
.await
.unwrap();
}
async fn insert_review(pool: &SqlitePool, movie: &MovieId) {
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at)
VALUES (?, ?, ?, 4, '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(movie.value().to_string())
.bind(uuid::Uuid::new_v4().to_string())
.execute(pool)
.await
.unwrap();
}
fn canonical_from(old: &Movie, canonical_id: MovieId) -> Movie {
Movie::from_persistence(
canonical_id,
old.external_metadata_id().cloned(),
old.title().clone(),
old.release_year().clone(),
old.director().map(str::to_string),
old.poster_path().cloned(),
)
}
fn old_movie(id: &MovieId, ext: &ExternalMetadataId, poster: Option<&str>) -> Movie {
Movie::from_persistence(
id.clone(),
Some(ext.clone()),
MovieTitle::new("Heat".into()).unwrap(),
ReleaseYear::new(1995).unwrap(),
None,
poster.map(|p| PosterPath::new(p.into()).unwrap()),
)
}
async fn scalar(pool: &SqlitePool, sql: &str, bind: &str) -> Option<String> {
sqlx::query_scalar(sql)
.bind(bind)
.fetch_optional(pool)
.await
.unwrap()
}
#[tokio::test]
async fn merging_into_a_canonical_movie_that_does_not_exist_yet_succeeds() {
let pool = migrated_pool().await;
let ext = ExternalMetadataId::new("tmdb:603".into()).unwrap();
let old_id = MovieId::generate();
insert_movie(&pool, &old_id, Some("tmdb:603"), None).await;
insert_review(&pool, &old_id).await;
let canonical_id = MovieId::from_external(&ext);
let canonical = canonical_from(&old_movie(&old_id, &ext, None), canonical_id.clone());
let repointed = SqliteMovieDeduplicator::new(pool.clone())
.merge_into_canonical(&old_id, &canonical)
.await
.unwrap();
assert_eq!(repointed, 1);
let ext_of_canonical = scalar(
&pool,
"SELECT external_metadata_id FROM movies WHERE id = ?",
&canonical_id.value().to_string(),
)
.await;
assert_eq!(ext_of_canonical.as_deref(), Some("tmdb:603"));
assert!(
scalar(
&pool,
"SELECT id FROM movies WHERE id = ?",
&old_id.value().to_string()
)
.await
.is_none()
);
let on_canonical: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE movie_id = ?")
.bind(canonical_id.value().to_string())
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(on_canonical, 1);
}
#[tokio::test]
async fn merging_into_an_existing_canonical_movie_moves_the_external_id_and_keeps_its_poster() {
let pool = migrated_pool().await;
let ext = ExternalMetadataId::new("tmdb:603".into()).unwrap();
let old_id = MovieId::generate();
let canonical_id = MovieId::from_external(&ext);
insert_movie(&pool, &old_id, Some("tmdb:603"), Some("old.jpg")).await;
insert_movie(&pool, &canonical_id, None, Some("canonical.jpg")).await;
insert_review(&pool, &old_id).await;
let canonical = Movie::from_persistence(
canonical_id.clone(),
Some(ext.clone()),
MovieTitle::new("Heat".into()).unwrap(),
ReleaseYear::new(1995).unwrap(),
None,
Some(PosterPath::new("canonical.jpg".into()).unwrap()),
);
SqliteMovieDeduplicator::new(pool.clone())
.merge_into_canonical(&old_id, &canonical)
.await
.unwrap();
let id = canonical_id.value().to_string();
assert_eq!(
scalar(&pool, "SELECT poster_path FROM movies WHERE id = ?", &id)
.await
.as_deref(),
Some("canonical.jpg")
);
assert_eq!(
scalar(
&pool,
"SELECT external_metadata_id FROM movies WHERE id = ?",
&id
)
.await
.as_deref(),
Some("tmdb:603")
);
}

View File

@@ -0,0 +1,95 @@
use domain::{ports::MovieProfileRepository, value_objects::MovieId};
use sqlite::SqliteMovieProfileRepository;
use sqlx::SqlitePool;
async fn pool_with_schema() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query(
"CREATE TABLE movie_profiles (
movie_id TEXT PRIMARY KEY, tmdb_id INTEGER, imdb_id TEXT,
overview TEXT, tagline TEXT, runtime_minutes INTEGER,
budget_usd INTEGER, revenue_usd INTEGER, vote_average REAL,
vote_count INTEGER, original_language TEXT, collection_name TEXT,
enriched_at TEXT NOT NULL
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("CREATE TABLE movie_genres (movie_id TEXT, tmdb_id INTEGER, name TEXT)")
.execute(&pool)
.await
.unwrap();
sqlx::query("CREATE TABLE movie_keywords (movie_id TEXT, tmdb_id INTEGER, name TEXT)")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER,
name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE movie_crew (movie_id TEXT, tmdb_person_id INTEGER,
name TEXT, job TEXT, department TEXT, profile_path TEXT)",
)
.execute(&pool)
.await
.unwrap();
pool
}
async fn insert_bare_profile(pool: &SqlitePool, movie_id: &str) {
sqlx::query("INSERT INTO movie_profiles (movie_id, tmdb_id, enriched_at) VALUES (?, 1, ?)")
.bind(movie_id)
.bind(chrono::Utc::now().to_rfc3339())
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn null_cast_profile_path_becomes_none_not_empty_string() {
let pool = pool_with_schema().await;
let movie_id = MovieId::generate();
let movie_id_str = movie_id.value().to_string();
insert_bare_profile(&pool, &movie_id_str).await;
sqlx::query(
"INSERT INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path)
VALUES (?, 1, 'Alice', 'Hero', 0, NULL)",
)
.bind(&movie_id_str)
.execute(&pool)
.await
.unwrap();
let adapter = SqliteMovieProfileRepository::new(pool);
let profile = adapter.get_by_movie_id(&movie_id).await.unwrap().unwrap();
assert_eq!(profile.cast.len(), 1);
assert_eq!(
profile.cast[0].profile_path, None,
"NULL profile_path must decode to None, not Some(\"\")"
);
}
#[tokio::test]
async fn null_budget_usd_becomes_none_not_some_zero() {
let pool = pool_with_schema().await;
let movie_id = MovieId::generate();
let movie_id_str = movie_id.value().to_string();
insert_bare_profile(&pool, &movie_id_str).await;
let adapter = SqliteMovieProfileRepository::new(pool);
let profile = adapter.get_by_movie_id(&movie_id).await.unwrap().unwrap();
assert_eq!(
profile.budget_usd, None,
"NULL budget_usd must decode to None, not Some(0)"
);
}

View File

@@ -0,0 +1,34 @@
use std::str::FromStr;
use sqlx::{
SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
pub async fn migrated_pool() -> SqlitePool {
let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await
.unwrap();
sqlite::migrate(&pool).await.unwrap();
pool
}
pub async fn count(pool: &SqlitePool, table: &str) -> i64 {
sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}"))
.fetch_one(pool)
.await
.unwrap()
}
pub async fn fail_event_inserts(pool: &SqlitePool) {
sqlx::query(
"CREATE TRIGGER fail_event_insert BEFORE INSERT ON event_queue \
BEGIN SELECT RAISE(ABORT, 'enqueue failed'); END",
)
.execute(pool)
.await
.unwrap();
}

View File

@@ -0,0 +1,810 @@
use domain::{
events::DomainEvent,
models::{ProfileField, UserProfile, WatchlistEntry},
ports::{
GoalCommand, MovieCommand, UserProfileFieldsRepository, UserRepository, WatchEventCommand,
WatchlistRepository, WrapUpRepository,
},
testing::{test_goal, test_movie, test_user},
value_objects::UserId,
};
use sqlite::{
SqliteGoalRepository, SqliteMovieRepository, SqliteProfileFieldsRepository,
SqliteUserRepository, SqliteWatchlistRepository,
};
use sqlx::SqlitePool;
use super::outbox_support::{count, fail_event_inserts, migrated_pool};
async fn pool_with_user() -> (SqlitePool, domain::models::User) {
let pool = migrated_pool().await;
let user = test_user("alice");
SqliteUserRepository::new(pool.clone())
.save(&user)
.await
.unwrap();
(pool, user)
}
fn goal_created(goal: &domain::models::Goal) -> DomainEvent {
DomainEvent::GoalCreated {
goal_id: goal.id().clone(),
user_id: goal.user_id().clone(),
year: goal.year(),
target_count: goal.target_count(),
}
}
fn goal_updated(goal: &domain::models::Goal) -> DomainEvent {
DomainEvent::GoalUpdated {
goal_id: goal.id().clone(),
user_id: goal.user_id().clone(),
year: goal.year(),
target_count: goal.target_count(),
}
}
fn goal_deleted(goal: &domain::models::Goal) -> DomainEvent {
DomainEvent::GoalDeleted {
goal_id: goal.id().clone(),
user_id: goal.user_id().clone(),
year: goal.year(),
}
}
#[tokio::test]
async fn saving_a_goal_writes_the_goal_and_its_event_in_one_commit() {
let (pool, user) = pool_with_user().await;
let goal = test_goal(user.id(), 2025, 50);
SqliteGoalRepository::new(pool.clone())
.save(&goal, &[goal_created(&goal)])
.await
.unwrap();
assert_eq!(count(&pool, "goals").await, 1);
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_the_goal_save() {
let (pool, user) = pool_with_user().await;
let goal = test_goal(user.id(), 2025, 50);
fail_event_inserts(&pool).await;
let result = SqliteGoalRepository::new(pool.clone())
.save(&goal, &[goal_created(&goal)])
.await;
assert!(result.is_err());
assert_eq!(count(&pool, "goals").await, 0);
}
#[tokio::test]
async fn updating_a_goal_writes_the_change_and_its_event_in_one_commit() {
let (pool, user) = pool_with_user().await;
let repo = SqliteGoalRepository::new(pool.clone());
let mut goal = test_goal(user.id(), 2025, 50);
repo.save(&goal, &[]).await.unwrap();
goal.update_target(80).unwrap();
repo.update(&goal, &[goal_updated(&goal)]).await.unwrap();
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_the_goal_update() {
let (pool, user) = pool_with_user().await;
let repo = SqliteGoalRepository::new(pool.clone());
let mut goal = test_goal(user.id(), 2025, 50);
repo.save(&goal, &[]).await.unwrap();
goal.update_target(80).unwrap();
fail_event_inserts(&pool).await;
let result = repo.update(&goal, &[goal_updated(&goal)]).await;
assert!(result.is_err());
let target: i64 = sqlx::query_scalar("SELECT target_count FROM goals")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(target, 50, "update rolled back");
}
#[tokio::test]
async fn updating_a_missing_goal_enqueues_no_event() {
let (pool, user) = pool_with_user().await;
let goal = test_goal(user.id(), 2025, 50);
let result = SqliteGoalRepository::new(pool.clone())
.update(&goal, &[goal_updated(&goal)])
.await;
assert!(result.is_err());
assert_eq!(count(&pool, "event_queue").await, 0);
}
#[tokio::test]
async fn deleting_a_goal_writes_the_removal_and_its_event_in_one_commit() {
let (pool, user) = pool_with_user().await;
let repo = SqliteGoalRepository::new(pool.clone());
let goal = test_goal(user.id(), 2025, 50);
repo.save(&goal, &[]).await.unwrap();
repo.delete(goal.id(), user.id(), &[goal_deleted(&goal)])
.await
.unwrap();
assert_eq!(count(&pool, "goals").await, 0);
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_the_goal_delete() {
let (pool, user) = pool_with_user().await;
let repo = SqliteGoalRepository::new(pool.clone());
let goal = test_goal(user.id(), 2025, 50);
repo.save(&goal, &[]).await.unwrap();
fail_event_inserts(&pool).await;
let result = repo
.delete(goal.id(), user.id(), &[goal_deleted(&goal)])
.await;
assert!(result.is_err());
assert_eq!(count(&pool, "goals").await, 1, "delete rolled back");
}
async fn watchlist_fixture() -> (SqlitePool, UserId, domain::models::Movie) {
let (pool, user) = pool_with_user().await;
let movie = test_movie("Heat");
SqliteMovieRepository::new(pool.clone())
.upsert_movie(&movie, &[])
.await
.unwrap();
(pool, user.id().clone(), movie)
}
fn watchlist_added(entry: &WatchlistEntry, movie: &domain::models::Movie) -> DomainEvent {
DomainEvent::WatchlistEntryAdded {
user_id: entry.user_id.clone(),
movie_id: movie.id().clone(),
movie_title: movie.title().value().to_string(),
release_year: movie.release_year().value(),
external_metadata_id: None,
added_at: entry.added_at,
}
}
fn watchlist_removed(user_id: &UserId, movie: &domain::models::Movie) -> DomainEvent {
DomainEvent::WatchlistEntryRemoved {
user_id: user_id.clone(),
movie_id: movie.id().clone(),
}
}
#[tokio::test]
async fn adding_a_watchlist_entry_writes_the_entry_and_its_event_in_one_commit() {
let (pool, user_id, movie) = watchlist_fixture().await;
let entry = WatchlistEntry::new(user_id, movie.id().clone());
SqliteWatchlistRepository::new(pool.clone())
.add(&entry, &[watchlist_added(&entry, &movie)])
.await
.unwrap();
assert_eq!(count(&pool, "watchlist_entries").await, 1);
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn re_adding_a_watchlist_entry_enqueues_no_second_event() {
let (pool, user_id, movie) = watchlist_fixture().await;
let repo = SqliteWatchlistRepository::new(pool.clone());
let first = WatchlistEntry::new(user_id.clone(), movie.id().clone());
repo.add(&first, &[watchlist_added(&first, &movie)])
.await
.unwrap();
let again = WatchlistEntry::new(user_id, movie.id().clone());
repo.add(&again, &[watchlist_added(&again, &movie)])
.await
.unwrap();
assert_eq!(count(&pool, "watchlist_entries").await, 1);
assert_eq!(count(&pool, "event_queue").await, 1, "no phantom event");
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_the_watchlist_add() {
let (pool, user_id, movie) = watchlist_fixture().await;
let entry = WatchlistEntry::new(user_id, movie.id().clone());
fail_event_inserts(&pool).await;
let result = SqliteWatchlistRepository::new(pool.clone())
.add(&entry, &[watchlist_added(&entry, &movie)])
.await;
assert!(result.is_err());
assert_eq!(count(&pool, "watchlist_entries").await, 0);
}
#[tokio::test]
async fn removing_a_watchlist_entry_writes_the_removal_and_its_event_in_one_commit() {
let (pool, user_id, movie) = watchlist_fixture().await;
let repo = SqliteWatchlistRepository::new(pool.clone());
repo.add(
&WatchlistEntry::new(user_id.clone(), movie.id().clone()),
&[],
)
.await
.unwrap();
repo.remove(&user_id, movie.id(), &[watchlist_removed(&user_id, &movie)])
.await
.unwrap();
assert_eq!(count(&pool, "watchlist_entries").await, 0);
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_the_watchlist_remove() {
let (pool, user_id, movie) = watchlist_fixture().await;
let repo = SqliteWatchlistRepository::new(pool.clone());
repo.add(
&WatchlistEntry::new(user_id.clone(), movie.id().clone()),
&[],
)
.await
.unwrap();
fail_event_inserts(&pool).await;
let result = repo
.remove(&user_id, movie.id(), &[watchlist_removed(&user_id, &movie)])
.await;
assert!(result.is_err());
assert_eq!(
count(&pool, "watchlist_entries").await,
1,
"remove rolled back"
);
}
#[tokio::test]
async fn removing_an_absent_watchlist_entry_enqueues_no_event() {
let (pool, user_id, movie) = watchlist_fixture().await;
let result = SqliteWatchlistRepository::new(pool.clone())
.remove(&user_id, movie.id(), &[watchlist_removed(&user_id, &movie)])
.await;
assert!(result.is_err());
assert_eq!(count(&pool, "event_queue").await, 0);
}
#[tokio::test]
async fn remove_if_present_enqueues_its_event_only_when_a_row_was_removed() {
let (pool, user_id, movie) = watchlist_fixture().await;
let repo = SqliteWatchlistRepository::new(pool.clone());
let event = watchlist_removed(&user_id, &movie);
let absent = repo
.remove_if_present(&user_id, movie.id(), std::slice::from_ref(&event))
.await
.unwrap();
assert!(!absent);
assert_eq!(count(&pool, "event_queue").await, 0);
repo.add(
&WatchlistEntry::new(user_id.clone(), movie.id().clone()),
&[],
)
.await
.unwrap();
let present = repo
.remove_if_present(&user_id, movie.id(), std::slice::from_ref(&event))
.await
.unwrap();
assert!(present);
assert_eq!(count(&pool, "watchlist_entries").await, 0);
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_remove_if_present() {
let (pool, user_id, movie) = watchlist_fixture().await;
let repo = SqliteWatchlistRepository::new(pool.clone());
repo.add(
&WatchlistEntry::new(user_id.clone(), movie.id().clone()),
&[],
)
.await
.unwrap();
fail_event_inserts(&pool).await;
let result = repo
.remove_if_present(&user_id, movie.id(), &[watchlist_removed(&user_id, &movie)])
.await;
assert!(result.is_err());
assert_eq!(
count(&pool, "watchlist_entries").await,
1,
"remove rolled back"
);
}
#[tokio::test]
async fn updating_a_profile_writes_the_change_and_its_event_in_one_commit() {
let (pool, user) = pool_with_user().await;
let repo = SqliteUserRepository::new(pool.clone());
let profile = UserProfile {
bio: Some("hello".into()),
..Default::default()
};
repo.update_profile(
user.id(),
&profile,
&[DomainEvent::UserUpdated {
user_id: user.id().clone(),
}],
)
.await
.unwrap();
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
assert_eq!(found.bio(), Some("hello"));
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_the_profile_update() {
let (pool, user) = pool_with_user().await;
let repo = SqliteUserRepository::new(pool.clone());
fail_event_inserts(&pool).await;
let result = repo
.update_profile(
user.id(),
&UserProfile {
bio: Some("hello".into()),
..Default::default()
},
&[DomainEvent::UserUpdated {
user_id: user.id().clone(),
}],
)
.await;
assert!(result.is_err());
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
assert_eq!(found.bio(), None, "profile update rolled back");
}
fn fields() -> Vec<ProfileField> {
vec![ProfileField {
name: "Site".into(),
value: "https://example.com".into(),
}]
}
#[tokio::test]
async fn setting_profile_fields_writes_them_and_the_event_in_one_commit() {
let (pool, user) = pool_with_user().await;
SqliteProfileFieldsRepository::new(pool.clone())
.set_fields(
user.id(),
fields(),
&[DomainEvent::UserUpdated {
user_id: user.id().clone(),
}],
)
.await
.unwrap();
assert_eq!(count(&pool, "user_profile_fields").await, 1);
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_the_profile_fields_replacement() {
let (pool, user) = pool_with_user().await;
let repo = SqliteProfileFieldsRepository::new(pool.clone());
repo.set_fields(user.id(), fields(), &[]).await.unwrap();
fail_event_inserts(&pool).await;
let result = repo
.set_fields(
user.id(),
vec![],
&[DomainEvent::UserUpdated {
user_id: user.id().clone(),
}],
)
.await;
assert!(result.is_err());
assert_eq!(
repo.get_fields(user.id()).await.unwrap().len(),
1,
"delete of the old fields rolled back"
);
}
fn movie_discovered(movie: &domain::models::Movie) -> DomainEvent {
DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: domain::value_objects::ExternalMetadataId::new("tmdb:1".into())
.unwrap(),
}
}
fn movie_deleted(movie: &domain::models::Movie) -> DomainEvent {
DomainEvent::MovieDeleted {
movie_id: movie.id().clone(),
poster_path: None,
}
}
#[tokio::test]
async fn upserting_a_movie_writes_the_movie_and_its_event_in_one_commit() {
let pool = migrated_pool().await;
let movie = test_movie("Heat");
SqliteMovieRepository::new(pool.clone())
.upsert_movie(&movie, &[movie_discovered(&movie)])
.await
.unwrap();
assert_eq!(count(&pool, "movies").await, 1);
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_the_movie_upsert_of_a_new_movie() {
let pool = migrated_pool().await;
let movie = test_movie("Heat");
fail_event_inserts(&pool).await;
let result = SqliteMovieRepository::new(pool.clone())
.upsert_movie(&movie, &[movie_discovered(&movie)])
.await;
assert!(result.is_err());
assert_eq!(count(&pool, "movies").await, 0);
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_a_movie_update() {
let pool = migrated_pool().await;
let repo = SqliteMovieRepository::new(pool.clone());
let mut movie = test_movie("Heat");
repo.upsert_movie(&movie, &[]).await.unwrap();
movie.update_poster(domain::value_objects::PosterPath::new("posters/new.jpg".into()).unwrap());
fail_event_inserts(&pool).await;
let result = repo.upsert_movie(&movie, &[movie_discovered(&movie)]).await;
assert!(result.is_err());
let poster: Option<String> = sqlx::query_scalar("SELECT poster_path FROM movies")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(poster, None, "poster update rolled back");
}
#[tokio::test]
async fn deleting_a_movie_writes_the_removal_and_its_event_in_one_commit() {
let pool = migrated_pool().await;
let repo = SqliteMovieRepository::new(pool.clone());
let movie = test_movie("Heat");
repo.upsert_movie(&movie, &[]).await.unwrap();
repo.delete_movie(movie.id(), &[movie_deleted(&movie)])
.await
.unwrap();
assert_eq!(count(&pool, "movies").await, 0);
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_the_movie_delete() {
let pool = migrated_pool().await;
let repo = SqliteMovieRepository::new(pool.clone());
let movie = test_movie("Heat");
repo.upsert_movie(&movie, &[]).await.unwrap();
fail_event_inserts(&pool).await;
let result = repo
.delete_movie(movie.id(), &[movie_deleted(&movie)])
.await;
assert!(result.is_err());
assert_eq!(count(&pool, "movies").await, 1, "delete rolled back");
}
#[tokio::test]
async fn deleting_an_absent_movie_enqueues_no_event() {
let pool = migrated_pool().await;
let movie = test_movie("Heat");
SqliteMovieRepository::new(pool.clone())
.delete_movie(movie.id(), &[movie_deleted(&movie)])
.await
.unwrap();
assert_eq!(count(&pool, "event_queue").await, 0);
}
fn wrapup_record(user_id: Option<uuid::Uuid>) -> domain::models::wrapup::WrapUpRecord {
use domain::models::wrapup::WrapUpStatus;
domain::models::wrapup::WrapUpRecord {
id: domain::value_objects::WrapUpId::generate(),
user_id,
start_date: chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
status: WrapUpStatus::Pending,
report: None,
error_message: None,
created_at: chrono::Utc::now().naive_utc(),
completed_at: None,
}
}
fn wrapup_requested(record: &domain::models::wrapup::WrapUpRecord) -> DomainEvent {
DomainEvent::WrapUpRequested {
wrapup_id: record.id.clone(),
user_id: None,
start_date: record.start_date,
end_date: record.end_date,
}
}
fn wrapup_completed(record: &domain::models::wrapup::WrapUpRecord) -> DomainEvent {
DomainEvent::WrapUpCompleted {
wrapup_id: record.id.clone(),
}
}
fn empty_report() -> domain::models::wrapup::WrapUpReport {
serde_json::from_value(serde_json::json!({
"scope": "Global",
"date_range": { "start": "2024-01-01", "end": "2024-12-31" },
"generated_at": "2025-01-01T00:00:00Z",
"total_movies": 0,
"total_watch_time_minutes": 0,
"movies_per_month": [],
"rating_distribution": [0, 0, 0, 0, 0],
"top_directors": [],
"top_actors": [],
"director_diversity": 0,
"actor_diversity": 0,
"top_genres": [],
"genre_diversity": 0,
"top_keywords": [],
"language_distribution": [],
"watch_medium_distribution": [],
"total_rewatches": 0,
"poster_paths": [],
"top_cast_profile_paths": [],
}))
.expect("minimal wrap-up report")
}
#[tokio::test]
async fn creating_a_wrapup_writes_the_record_and_its_event_in_one_commit() {
let pool = migrated_pool().await;
let record = wrapup_record(None);
sqlite::SqliteWrapUpRepository::new(pool.clone())
.create(&record, &[wrapup_requested(&record)])
.await
.unwrap();
assert_eq!(count(&pool, "wrap_up_records").await, 1);
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_the_wrapup_create() {
let pool = migrated_pool().await;
let record = wrapup_record(None);
fail_event_inserts(&pool).await;
let result = sqlite::SqliteWrapUpRepository::new(pool.clone())
.create(&record, &[wrapup_requested(&record)])
.await;
assert!(result.is_err());
assert_eq!(count(&pool, "wrap_up_records").await, 0);
}
#[tokio::test]
async fn completing_a_wrapup_writes_the_report_and_its_event_in_one_commit() {
let pool = migrated_pool().await;
let repo = sqlite::SqliteWrapUpRepository::new(pool.clone());
let record = wrapup_record(None);
repo.create(&record, &[]).await.unwrap();
repo.set_complete(&record.id, &empty_report(), &[wrapup_completed(&record)])
.await
.unwrap();
let status: String = sqlx::query_scalar("SELECT status FROM wrap_up_records")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(status, "ready");
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_the_wrapup_completion() {
let pool = migrated_pool().await;
let repo = sqlite::SqliteWrapUpRepository::new(pool.clone());
let record = wrapup_record(None);
repo.create(&record, &[]).await.unwrap();
fail_event_inserts(&pool).await;
let result = repo
.set_complete(&record.id, &empty_report(), &[wrapup_completed(&record)])
.await;
assert!(result.is_err());
let status: String = sqlx::query_scalar("SELECT status FROM wrap_up_records")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(status, "pending", "completion rolled back");
}
#[tokio::test]
async fn completing_a_missing_wrapup_is_not_found_and_enqueues_no_event() {
let pool = migrated_pool().await;
let record = wrapup_record(None);
let result = sqlite::SqliteWrapUpRepository::new(pool.clone())
.set_complete(&record.id, &empty_report(), &[wrapup_completed(&record)])
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::NotFound(_))
));
assert_eq!(count(&pool, "event_queue").await, 0);
}
fn watch_event_for(user_id: &UserId) -> domain::models::WatchEvent {
domain::models::WatchEvent::new(
user_id.clone(),
"Heat".into(),
Some(1995),
None,
domain::models::WatchEventSource::Jellyfin,
chrono::Utc::now().naive_utc(),
None,
)
}
fn watch_event_ingested(user_id: &UserId) -> DomainEvent {
DomainEvent::WatchEventIngested {
user_id: user_id.clone(),
title: "Heat".into(),
source: "jellyfin".into(),
}
}
#[tokio::test]
async fn saving_a_watch_event_writes_the_row_and_its_event_in_one_commit() {
let (pool, user) = pool_with_user().await;
let event = watch_event_for(user.id());
sqlite::SqliteWatchEventRepository::new(pool.clone())
.save(&event, &[watch_event_ingested(user.id())])
.await
.unwrap();
assert_eq!(count(&pool, "watch_events").await, 1);
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_the_watch_event_save() {
let (pool, user) = pool_with_user().await;
let event = watch_event_for(user.id());
fail_event_inserts(&pool).await;
let result = sqlite::SqliteWatchEventRepository::new(pool.clone())
.save(&event, &[watch_event_ingested(user.id())])
.await;
assert!(result.is_err());
assert_eq!(count(&pool, "watch_events").await, 0);
}
fn follow_writes(user_id: &UserId) -> Vec<domain::ports::FollowWrite> {
use domain::{ports::FollowWrite, value_objects::FollowStatus};
vec![
FollowWrite::AddFollower {
local_user_id: user_id.value(),
follower_actor_url: "https://md.example/users/other".into(),
status: FollowStatus::Pending,
},
FollowWrite::AddFollow {
follower_id: user_id.value(),
target_actor_url: "https://md.example/users/other".into(),
status: FollowStatus::Pending,
},
]
}
fn follow_requested(user_id: &UserId) -> DomainEvent {
DomainEvent::FollowRequested {
follower: user_id.clone(),
target: domain::value_objects::FollowTarget::Handle("@other@md.example".into()),
}
}
fn social_repo(pool: &SqlitePool) -> sqlite::SqliteSocialRepository {
sqlite::SqliteSocialRepository::new(
pool.clone(),
domain::value_objects::InstanceIdentity::new("https://md.example"),
)
}
#[tokio::test]
async fn a_local_follow_writes_both_rows_and_its_event_in_one_commit() {
use domain::ports::FollowCommand;
let (pool, user) = pool_with_user().await;
social_repo(&pool)
.apply(&follow_writes(user.id()), &[follow_requested(user.id())])
.await
.unwrap();
assert_eq!(count(&pool, "ap_followers").await, 1);
assert_eq!(count(&pool, "ap_following").await, 1);
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_both_follow_rows() {
use domain::ports::FollowCommand;
let (pool, user) = pool_with_user().await;
fail_event_inserts(&pool).await;
let result = social_repo(&pool)
.apply(&follow_writes(user.id()), &[follow_requested(user.id())])
.await;
assert!(result.is_err());
assert_eq!(count(&pool, "ap_followers").await, 0);
assert_eq!(count(&pool, "ap_following").await, 0);
}
#[tokio::test]
async fn a_failing_follow_write_rolls_back_the_rows_already_applied() {
use domain::ports::{FollowCommand, FollowWrite};
let (pool, user) = pool_with_user().await;
let mut writes = follow_writes(user.id());
writes.push(FollowWrite::AddFollow {
follower_id: uuid::Uuid::new_v4(),
target_actor_url: "https://md.example/users/other".into(),
status: domain::value_objects::FollowStatus::Pending,
});
let result = social_repo(&pool)
.apply(&writes, &[follow_requested(user.id())])
.await;
assert!(result.is_err());
assert_eq!(count(&pool, "ap_followers").await, 0);
assert_eq!(count(&pool, "event_queue").await, 0);
}

View File

@@ -0,0 +1,135 @@
use domain::testing::test_person_with_tmdb;
use domain::{
models::{ExternalPersonId, PersonId},
ports::{PersonCommand, PersonQuery},
};
use sqlite::SqlitePersonAdapter;
use sqlx::SqlitePool;
async fn pool_with_schema() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query(
"CREATE TABLE persons (
id TEXT PRIMARY KEY, external_id TEXT NOT NULL UNIQUE,
tmdb_person_id INTEGER UNIQUE, name TEXT NOT NULL,
known_for_department TEXT, profile_path TEXT,
biography TEXT, birthday TEXT, deathday TEXT,
place_of_birth TEXT, also_known_as TEXT,
homepage TEXT, imdb_id TEXT, enriched_at TEXT
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE movies (id TEXT PRIMARY KEY, title TEXT NOT NULL,
release_year INTEGER, director TEXT, poster_path TEXT,
external_metadata_id TEXT)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER,
name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT,
PRIMARY KEY (movie_id, tmdb_person_id))",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE movie_crew (movie_id TEXT, tmdb_person_id INTEGER,
name TEXT, job TEXT, department TEXT, profile_path TEXT,
PRIMARY KEY (movie_id, tmdb_person_id, job))",
)
.execute(&pool)
.await
.unwrap();
pool
}
#[tokio::test]
async fn upsert_batch_inserts_persons() {
let pool = pool_with_schema().await;
let adapter = SqlitePersonAdapter::new(pool.clone());
let persons = vec![
test_person_with_tmdb(1, "Alice", Some("Acting")),
test_person_with_tmdb(2, "Bob", Some("Directing")),
];
adapter.upsert_batch(&persons).await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM persons")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 2);
}
#[tokio::test]
async fn upsert_batch_is_idempotent() {
let pool = pool_with_schema().await;
let adapter = SqlitePersonAdapter::new(pool.clone());
let persons = vec![test_person_with_tmdb(1, "Alice", Some("Acting"))];
adapter.upsert_batch(&persons).await.unwrap();
adapter.upsert_batch(&persons).await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM persons")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 1);
}
#[tokio::test]
async fn get_by_id_returns_person() {
let pool = pool_with_schema().await;
let adapter = SqlitePersonAdapter::new(pool.clone());
let p = test_person_with_tmdb(42, "Charlie", Some("Acting"));
adapter
.upsert_batch(std::slice::from_ref(&p))
.await
.unwrap();
let found = adapter.get_by_id(p.id()).await.unwrap().unwrap();
assert_eq!(found.name(), "Charlie");
assert_eq!(found.external_id().value(), "tmdb:42");
}
#[tokio::test]
async fn get_by_id_returns_none_for_unknown() {
let pool = pool_with_schema().await;
let adapter = SqlitePersonAdapter::new(pool);
let ext = ExternalPersonId::new("tmdb:999");
let id = PersonId::from_external(&ext);
assert!(adapter.get_by_id(&id).await.unwrap().is_none());
}
#[tokio::test]
async fn get_credits_returns_cast_and_crew() {
let pool = pool_with_schema().await;
let adapter = SqlitePersonAdapter::new(pool.clone());
let p = test_person_with_tmdb(7, "Diana", Some("Acting"));
adapter
.upsert_batch(std::slice::from_ref(&p))
.await
.unwrap();
sqlx::query("INSERT INTO movies VALUES ('m1', 'The Film', 2020, 'Dir', NULL, NULL)")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO movie_cast VALUES ('m1', 7, 'Diana', 'Hero', 1, NULL)")
.execute(&pool)
.await
.unwrap();
let credits = adapter.get_credits(p.id()).await.unwrap();
assert_eq!(credits.person.name(), "Diana");
assert_eq!(credits.cast.len(), 1);
assert_eq!(credits.cast[0].character, "Hero");
assert!(credits.crew.is_empty());
}

View File

@@ -0,0 +1,219 @@
use domain::{
events::DomainEvent,
ports::{MovieCommand, ReviewRepository, UserRepository},
testing::{test_movie, test_review, test_user},
};
use sqlite::{SqliteMovieRepository, SqliteReviewRepository, SqliteUserRepository};
use sqlx::SqlitePool;
use super::outbox_support::{count, fail_event_inserts, migrated_pool};
fn logged(review: &domain::models::Review) -> DomainEvent {
DomainEvent::ReviewLogged {
review_id: review.id().clone(),
movie_id: review.movie_id().clone(),
user_id: review.user_id().clone(),
rating: review.rating().clone(),
watched_at: *review.watched_at(),
}
}
#[tokio::test]
async fn saving_a_review_writes_the_review_and_its_event_in_one_commit() {
let pool = migrated_pool().await;
let movie = test_movie("Heat");
let user = test_user("alice");
SqliteUserRepository::new(pool.clone())
.save(&user)
.await
.unwrap();
SqliteMovieRepository::new(pool.clone())
.upsert_movie(&movie, &[])
.await
.unwrap();
let review = test_review(movie.id(), user.id(), 4);
SqliteReviewRepository::new(pool.clone())
.save_review(&review, &[logged(&review)])
.await
.unwrap();
assert_eq!(count(&pool, "reviews").await, 1);
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failed_review_save_leaves_no_event_row() {
let pool = migrated_pool().await;
let review = test_review(
&domain::value_objects::MovieId::generate(),
&domain::value_objects::UserId::generate(),
4,
);
let result = SqliteReviewRepository::new(pool.clone())
.save_review(&review, &[logged(&review)])
.await;
assert!(result.is_err(), "FK violation must fail the save");
assert_eq!(count(&pool, "reviews").await, 0);
assert_eq!(count(&pool, "event_queue").await, 0);
}
fn updated(review: &domain::models::Review) -> DomainEvent {
DomainEvent::ReviewUpdated {
review_id: review.id().clone(),
movie_id: review.movie_id().clone(),
user_id: review.user_id().clone(),
rating: review.rating().clone(),
watched_at: *review.watched_at(),
}
}
async fn seeded() -> (SqlitePool, domain::models::Review) {
let pool = migrated_pool().await;
let movie = test_movie("Heat");
let user = test_user("alice");
SqliteUserRepository::new(pool.clone())
.save(&user)
.await
.unwrap();
SqliteMovieRepository::new(pool.clone())
.upsert_movie(&movie, &[])
.await
.unwrap();
let review = test_review(movie.id(), user.id(), 4);
(pool, review)
}
#[tokio::test]
async fn a_failing_enqueue_after_the_review_insert_rolls_the_review_back() {
let (pool, review) = seeded().await;
fail_event_inserts(&pool).await;
let result = SqliteReviewRepository::new(pool.clone())
.save_review(&review, &[logged(&review)])
.await;
assert!(result.is_err());
assert_eq!(count(&pool, "reviews").await, 0);
assert_eq!(count(&pool, "event_queue").await, 0);
}
#[tokio::test]
async fn updating_a_review_writes_the_change_and_its_event_in_one_commit() {
let (pool, review) = seeded().await;
let repo = SqliteReviewRepository::new(pool.clone());
repo.save_review(&review, &[]).await.unwrap();
let event = updated(&review);
repo.update_review(&review, &[event]).await.unwrap();
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failing_enqueue_after_the_review_update_rolls_the_update_back() {
let (pool, review) = seeded().await;
let repo = SqliteReviewRepository::new(pool.clone());
repo.save_review(&review, &[]).await.unwrap();
fail_event_inserts(&pool).await;
let edited = review.apply_edit(domain::models::ReviewEdit {
rating: Some(domain::value_objects::Rating::new(1).unwrap()),
..Default::default()
});
let event = updated(&edited);
let result = repo.update_review(&edited, &[event]).await;
assert!(result.is_err());
let persisted: i64 = sqlx::query_scalar("SELECT rating FROM reviews")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(persisted, 4, "rating update rolled back");
assert_eq!(count(&pool, "event_queue").await, 0);
}
fn deleted(review: &domain::models::Review) -> DomainEvent {
DomainEvent::ReviewDeleted {
review_id: review.id().clone(),
user_id: review.user_id().clone(),
}
}
fn movie_deleted(review: &domain::models::Review) -> DomainEvent {
DomainEvent::MovieDeleted {
movie_id: review.movie_id().clone(),
poster_path: None,
}
}
#[tokio::test]
async fn deleting_the_last_review_removes_the_movie_and_enqueues_both_events_in_one_commit() {
let (pool, review) = seeded().await;
let repo = SqliteReviewRepository::new(pool.clone());
repo.save_review(&review, &[]).await.unwrap();
repo.delete_review_and_orphaned_movie(
review.id(),
review.movie_id(),
&[deleted(&review)],
&[movie_deleted(&review)],
)
.await
.unwrap();
assert_eq!(count(&pool, "reviews").await, 0);
assert_eq!(count(&pool, "movies").await, 0);
assert_eq!(count(&pool, "event_queue").await, 2);
}
#[tokio::test]
async fn deleting_a_review_while_another_remains_keeps_the_movie_and_enqueues_only_review_deleted()
{
let (pool, review) = seeded().await;
let repo = SqliteReviewRepository::new(pool.clone());
repo.save_review(&review, &[]).await.unwrap();
let other = test_review(review.movie_id(), review.user_id(), 3);
repo.save_review(&other, &[]).await.unwrap();
repo.delete_review_and_orphaned_movie(
review.id(),
review.movie_id(),
&[deleted(&review)],
&[movie_deleted(&review)],
)
.await
.unwrap();
assert_eq!(count(&pool, "reviews").await, 1);
assert_eq!(count(&pool, "movies").await, 1);
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_the_review_and_movie_delete() {
let (pool, review) = seeded().await;
let repo = SqliteReviewRepository::new(pool.clone());
repo.save_review(&review, &[]).await.unwrap();
fail_event_inserts(&pool).await;
let result = repo
.delete_review_and_orphaned_movie(
review.id(),
review.movie_id(),
&[deleted(&review)],
&[movie_deleted(&review)],
)
.await;
assert!(result.is_err());
assert_eq!(
count(&pool, "reviews").await,
1,
"review delete rolled back"
);
assert_eq!(count(&pool, "movies").await, 1, "movie delete rolled back");
assert_eq!(count(&pool, "event_queue").await, 0);
}

View File

@@ -0,0 +1,213 @@
use domain::{
models::{
EntityType, IndexableDocument, Movie, SearchFilters, SearchQuery, collections::PageParams,
},
value_objects::{MovieId, MovieTitle, ReleaseYear},
};
use sqlite::create_search_adapter;
use sqlx::SqlitePool;
async fn pool_with_schema() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query(
"CREATE TABLE movies (id TEXT PRIMARY KEY, title TEXT NOT NULL,
release_year INTEGER, director TEXT, poster_path TEXT, external_metadata_id TEXT)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE persons (id TEXT PRIMARY KEY, external_id TEXT UNIQUE,
tmdb_person_id INTEGER UNIQUE, name TEXT NOT NULL,
known_for_department TEXT, profile_path TEXT)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER,
name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("CREATE TABLE movie_genres (movie_id TEXT, tmdb_id INTEGER, name TEXT)")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE VIRTUAL TABLE movies_fts USING fts5(
movie_id UNINDEXED, title, director, overview, genres, keywords,
cast_names, crew_names, release_year UNINDEXED, language UNINDEXED)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE VIRTUAL TABLE people_fts USING fts5(
person_id UNINDEXED, name, known_for_department UNINDEXED)",
)
.execute(&pool)
.await
.unwrap();
pool
}
fn test_movie(id: &str, title: &str, year: u16) -> Movie {
Movie::from_persistence(
MovieId::from_uuid(uuid::Uuid::parse_str(id).unwrap()),
None,
MovieTitle::new(title.into()).unwrap(),
ReleaseYear::new(year).unwrap(),
Some("Test Director".to_string()),
None,
)
}
fn default_page() -> PageParams {
PageParams::new(Some(10), Some(0)).unwrap()
}
#[tokio::test]
async fn index_and_search_movie_by_title() {
let pool = pool_with_schema().await;
let (cmd, query) = create_search_adapter(pool.clone());
let id_str = "00000000-0000-0000-0000-000000000001";
let movie = test_movie(id_str, "Interstellar", 2014);
let movie_id = movie.id().clone();
sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)")
.bind(id_str)
.bind("Interstellar")
.bind(2014i32)
.bind("Christopher Nolan")
.bind::<Option<String>>(None)
.bind::<Option<String>>(None)
.execute(&pool)
.await
.unwrap();
cmd.index(IndexableDocument::Movie {
id: movie_id.clone(),
movie: Box::new(movie),
profile: None,
})
.await
.unwrap();
let results = query
.search(&SearchQuery {
text: Some("Interstellar".to_string()),
filters: SearchFilters::default(),
page: default_page(),
})
.await
.unwrap();
assert_eq!(results.movies.items.len(), 1);
assert_eq!(results.movies.items[0].title, "Interstellar");
}
#[tokio::test]
async fn remove_movie_clears_from_index() {
let pool = pool_with_schema().await;
let (cmd, query) = create_search_adapter(pool.clone());
let id_str = "00000000-0000-0000-0000-000000000002";
let movie = test_movie(id_str, "Inception", 2010);
let movie_id = movie.id().clone();
sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)")
.bind(id_str)
.bind("Inception")
.bind(2010i32)
.bind("Christopher Nolan")
.bind::<Option<String>>(None)
.bind::<Option<String>>(None)
.execute(&pool)
.await
.unwrap();
cmd.index(IndexableDocument::Movie {
id: movie_id.clone(),
movie: Box::new(movie),
profile: None,
})
.await
.unwrap();
cmd.remove(EntityType::Movie, id_str).await.unwrap();
let results = query
.search(&SearchQuery {
text: Some("Inception".to_string()),
filters: SearchFilters::default(),
page: default_page(),
})
.await
.unwrap();
assert!(results.movies.items.is_empty());
}
#[tokio::test]
async fn search_with_genre_filter() {
let pool = pool_with_schema().await;
let (cmd, query) = create_search_adapter(pool.clone());
let id_str = "00000000-0000-0000-0000-000000000003";
let movie = test_movie(id_str, "The Dark Knight", 2008);
let movie_id = movie.id().clone();
sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)")
.bind(id_str)
.bind("The Dark Knight")
.bind(2008i32)
.bind("Christopher Nolan")
.bind::<Option<String>>(None)
.bind::<Option<String>>(None)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO movie_genres VALUES (?, 1, 'Action')")
.bind(id_str)
.execute(&pool)
.await
.unwrap();
cmd.index(IndexableDocument::Movie {
id: movie_id.clone(),
movie: Box::new(movie),
profile: None,
})
.await
.unwrap();
// Matching genre — no text filter
let results = query
.search(&SearchQuery {
text: None,
filters: SearchFilters {
genre: Some("Action".to_string()),
..Default::default()
},
page: default_page(),
})
.await
.unwrap();
assert_eq!(results.movies.items.len(), 1);
// Non-matching genre
let results = query
.search(&SearchQuery {
text: None,
filters: SearchFilters {
genre: Some("Comedy".to_string()),
..Default::default()
},
page: default_page(),
})
.await
.unwrap();
assert!(results.movies.items.is_empty());
}

View File

@@ -0,0 +1,327 @@
use domain::ports::{FederationAdminQuery, FollowQuery};
use domain::value_objects::{FollowStatus, InstanceIdentity, SocialIdentity};
use sqlite::SqliteSocialRepository;
use sqlx::SqlitePool;
async fn test_pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
for ddl in [
"CREATE TABLE ap_following (local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL,
PRIMARY KEY (local_user_id, remote_actor_url))",
"CREATE TABLE ap_followers (local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL,
PRIMARY KEY (local_user_id, remote_actor_url))",
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL,
display_name TEXT, avatar_path TEXT, deleted_at TEXT)",
"CREATE TABLE ap_remote_actors (url TEXT PRIMARY KEY, handle TEXT NOT NULL,
display_name TEXT, avatar_url TEXT)",
] {
sqlx::query(ddl).execute(&pool).await.unwrap();
}
pool
}
fn repo(pool: SqlitePool) -> SqliteSocialRepository {
SqliteSocialRepository::new(pool, InstanceIdentity::new("https://md.example"))
}
#[tokio::test]
async fn get_relation_returns_no_edges_for_strangers() {
let r = repo(test_pool().await);
let rel = r
.get_relation(uuid::Uuid::new_v4(), "https://other.example/users/bob")
.await
.unwrap();
assert_eq!(rel.following, None);
assert_eq!(rel.followed_by, None);
}
#[tokio::test]
async fn get_relation_reads_following_direction_only_from_ap_following() {
let pool = test_pool().await;
let viewer = uuid::Uuid::new_v4();
let target = "https://other.example/users/bob";
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, ?2, '', 'pending')",
)
.bind(viewer.to_string())
.bind(target)
.execute(&pool)
.await
.unwrap();
let rel = repo(pool).get_relation(viewer, target).await.unwrap();
assert_eq!(rel.following, Some(FollowStatus::Pending));
assert_eq!(
rel.followed_by, None,
"an ap_following row must not populate followed_by"
);
}
#[tokio::test]
async fn get_relation_reads_followed_by_from_ap_followers_including_rejected() {
let pool = test_pool().await;
let owner = uuid::Uuid::new_v4();
let requester = "https://other.example/users/carol";
sqlx::query(
"INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, ?2, '', 'rejected')",
)
.bind(owner.to_string())
.bind(requester)
.execute(&pool)
.await
.unwrap();
let rel = repo(pool).get_relation(owner, requester).await.unwrap();
assert_eq!(rel.followed_by, Some(FollowStatus::Rejected));
assert_eq!(rel.following, None);
}
#[tokio::test]
async fn get_relation_treats_unknown_status_as_no_edge() {
let pool = test_pool().await;
let viewer = uuid::Uuid::new_v4();
let target = "https://other.example/users/dave";
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, ?2, '', 'not-a-real-status')",
)
.bind(viewer.to_string())
.bind(target)
.execute(&pool)
.await
.unwrap();
let rel = repo(pool).get_relation(viewer, target).await.unwrap();
assert_eq!(rel.following, None);
}
#[tokio::test]
async fn get_pending_following_returns_only_pending_rows() {
let pool = test_pool().await;
let viewer = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, 'https://other.example/users/pending', '', 'pending'),
(?1, 'https://other.example/users/accepted', '', 'accepted')",
)
.bind(viewer.to_string())
.execute(&pool)
.await
.unwrap();
let actors = FollowQuery::get_pending_following(&repo(pool), viewer)
.await
.unwrap();
assert_eq!(
actors.len(),
1,
"accepted rows must not appear in pending_following"
);
// The handle-fallback-on-join-miss behavior is covered by
// `remote_actor_with_no_cached_row_falls_back_to_its_actor_url`; this test
// only needs to check pending-row filtering, so it asserts on identity.
assert_eq!(
actors[0].identity,
SocialIdentity::Remote {
actor_url: "https://other.example/users/pending".to_string()
}
);
}
#[tokio::test]
async fn remote_actor_with_no_cached_row_falls_back_to_its_actor_url() {
let pool = test_pool().await;
let viewer = uuid::Uuid::new_v4();
let orphan = "https://other.example/users/uncached";
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, ?2, '', 'pending')",
)
.bind(viewer.to_string())
.bind(orphan)
.execute(&pool)
.await
.unwrap();
// deliberately NO ap_remote_actors row for `orphan`
let actors = FollowQuery::get_pending_following(&repo(pool), viewer)
.await
.unwrap();
assert_eq!(actors.len(), 1);
assert_eq!(
actors[0].handle, orphan,
"with no cached actor, handle must fall back to the actor url, not render empty"
);
}
#[tokio::test]
async fn count_pending_followers_counts_only_pending() {
let pool = test_pool().await;
let owner = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, 'https://other.example/users/a', '', 'pending'),
(?1, 'https://other.example/users/b', '', 'pending'),
(?1, 'https://other.example/users/c', '', 'accepted'),
(?1, 'https://other.example/users/d', '', 'rejected')",
)
.bind(owner.to_string())
.execute(&pool)
.await
.unwrap();
let n = FollowQuery::count_pending_followers(&repo(pool), owner)
.await
.unwrap();
assert_eq!(
n, 2,
"only pending rows count; accepted and rejected must not"
);
}
async fn setup_admin_query_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 list_all_followed_remote_actors_deduplicates_across_users() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup_admin_query_db(&pool).await;
let repo =
SqliteSocialRepository::new(pool.clone(), InstanceIdentity::new("https://localhost"));
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");
}
#[tokio::test]
async fn follow_lists_omit_deleted_local_users() {
let pool = test_pool().await;
let viewer = uuid::Uuid::new_v4();
let gone = uuid::Uuid::new_v4();
let live = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO users (id, username, deleted_at) VALUES (?1, 'gone', '2026-01-01T00:00:00Z'), (?2, 'live', NULL)",
)
.bind(gone.to_string())
.bind(live.to_string())
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, 'https://md.example/users/' || ?2, '', 'accepted'),
(?1, 'https://md.example/users/' || ?3, '', 'accepted'),
(?1, 'https://other.example/users/remote', '', 'accepted')",
)
.bind(viewer.to_string())
.bind(gone.to_string())
.bind(live.to_string())
.execute(&pool)
.await
.unwrap();
let actors = FollowQuery::get_following(&repo(pool), viewer)
.await
.unwrap();
assert_eq!(actors.len(), 2, "deleted local user must not be listed");
assert!(!actors.iter().any(
|a| a.identity == SocialIdentity::Local(domain::value_objects::UserId::from_uuid(gone))
));
}
#[tokio::test]
async fn follower_lists_omit_deleted_local_users() {
let pool = test_pool().await;
let viewer = uuid::Uuid::new_v4();
let gone = uuid::Uuid::new_v4();
let live = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO users (id, username, deleted_at) VALUES (?1, 'gone', '2026-01-01T00:00:00Z'), (?2, 'live', NULL)",
)
.bind(gone.to_string())
.bind(live.to_string())
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, 'https://md.example/users/' || ?2, '', 'accepted'),
(?1, 'https://md.example/users/' || ?3, '', 'accepted'),
(?1, 'https://other.example/users/remote', '', 'accepted')",
)
.bind(viewer.to_string())
.bind(gone.to_string())
.bind(live.to_string())
.execute(&pool)
.await
.unwrap();
let actors = FollowQuery::get_followers(&repo(pool), viewer)
.await
.unwrap();
assert_eq!(actors.len(), 2, "deleted local follower must not be listed");
assert!(!actors.iter().any(
|a| a.identity == SocialIdentity::Local(domain::value_objects::UserId::from_uuid(gone))
));
}

View File

@@ -0,0 +1,275 @@
use domain::models::UserProfile;
use domain::ports::UserRepository;
use domain::testing::test_user;
use domain::value_objects::UserId;
use sqlite::SqliteUserRepository;
use sqlx::SqlitePool;
use super::outbox_support::{count, fail_event_inserts, migrated_pool};
async fn setup() -> (SqlitePool, SqliteUserRepository) {
let pool = migrated_pool().await;
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 = test_user("testuser");
repo.save(&user).await.unwrap();
repo.update_profile(
user.id(),
&UserProfile {
bio: Some("My biography".to_string()),
avatar_path: Some("avatars/user1".to_string()),
..Default::default()
},
&[],
)
.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 = test_user("testuser2");
repo.save(&user).await.unwrap();
repo.update_profile(
user.id(),
&UserProfile {
bio: Some("bio".to_string()),
avatar_path: Some("path".to_string()),
..Default::default()
},
&[],
)
.await
.unwrap();
repo.update_profile(user.id(), &UserProfile::default(), &[])
.await
.unwrap();
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
assert_eq!(found.bio(), None);
assert_eq!(found.avatar_path(), None);
}
fn deleted_event(user: &domain::models::User) -> domain::events::DomainEvent {
domain::events::DomainEvent::UserDeleted {
user_id: user.id().clone(),
}
}
async fn add_session(pool: &SqlitePool, user_id: &UserId, token: &str) {
sqlx::query(
"INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(user_id.value().to_string())
.bind(token)
.bind("2099-01-01T00:00:00+00:00")
.bind("2026-01-01T00:00:00+00:00")
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn marking_a_user_deleted_hides_them_from_every_lookup() {
let (pool, repo) = setup().await;
let admin =
domain::testing::test_user_with_role(UserId::generate(), domain::models::UserRole::Admin);
repo.save(&admin).await.unwrap();
let other = test_user("other");
repo.save(&other).await.unwrap();
sqlx::query("UPDATE users SET role = 'admin' WHERE id = ?")
.bind(other.id().value().to_string())
.execute(&pool)
.await
.unwrap();
assert_eq!(repo.count_active_admins().await.unwrap(), 2);
repo.mark_deleted(admin.id(), &[deleted_event(&admin)])
.await
.unwrap();
assert!(repo.find_by_id(admin.id()).await.unwrap().is_none());
assert!(repo.find_by_email(admin.email()).await.unwrap().is_none());
assert!(
repo.find_by_username(admin.username())
.await
.unwrap()
.is_none()
);
let listed = repo.list_with_stats().await.unwrap();
let emails: Vec<_> = listed.iter().map(|u| u.email()).collect();
assert!(emails.contains(&other.email().value()));
assert!(!emails.contains(&admin.email().value()));
assert_eq!(repo.count_active_admins().await.unwrap(), 1);
assert!(
repo.find_tombstoned_by_id(admin.id())
.await
.unwrap()
.is_some()
);
assert!(
repo.find_tombstoned_by_id(other.id())
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn marking_deleted_retires_the_username_and_drops_sessions_atomically() {
let (pool, repo) = setup().await;
let user = test_user("leaver");
let keeper = test_user("keeper");
repo.save(&user).await.unwrap();
repo.save(&keeper).await.unwrap();
add_session(&pool, user.id(), "t1").await;
add_session(&pool, user.id(), "t2").await;
add_session(&pool, keeper.id(), "t3").await;
repo.mark_deleted(user.id(), &[deleted_event(&user)])
.await
.unwrap();
assert!(repo.is_username_retired(user.username()).await.unwrap());
assert!(!repo.is_username_retired(keeper.username()).await.unwrap());
assert_eq!(count(&pool, "refresh_sessions").await, 1, "only keeper's");
assert_eq!(count(&pool, "event_queue").await, 1);
let deleted_at: Option<String> =
sqlx::query_scalar("SELECT deleted_at FROM users WHERE id = ?")
.bind(user.id().value().to_string())
.fetch_one(&pool)
.await
.unwrap();
assert!(deleted_at.is_some());
}
#[tokio::test]
async fn a_failed_enqueue_rolls_back_tombstone_retirement_and_session_drop() {
let (pool, repo) = setup().await;
let user = test_user("stayer");
repo.save(&user).await.unwrap();
add_session(&pool, user.id(), "t1").await;
fail_event_inserts(&pool).await;
let result = repo.mark_deleted(user.id(), &[deleted_event(&user)]).await;
assert!(result.is_err());
assert!(repo.find_by_id(user.id()).await.unwrap().is_some());
assert!(!repo.is_username_retired(user.username()).await.unwrap());
assert_eq!(count(&pool, "retired_usernames").await, 0);
assert_eq!(count(&pool, "refresh_sessions").await, 1);
assert_eq!(count(&pool, "event_queue").await, 0);
}
#[tokio::test]
async fn retired_names_match_case_insensitively() {
let (_, repo) = setup().await;
let user = test_user("MixedCase");
repo.save(&user).await.unwrap();
repo.mark_deleted(user.id(), &[deleted_event(&user)])
.await
.unwrap();
let lower = domain::value_objects::Username::new("mixedcase".to_string()).unwrap();
let upper = domain::value_objects::Username::new("MIXEDCASE".to_string()).unwrap();
assert!(repo.is_username_retired(&lower).await.unwrap());
assert!(repo.is_username_retired(&upper).await.unwrap());
}
#[tokio::test]
async fn marking_the_last_admin_deleted_is_refused_atomically() {
let dir = tempfile::tempdir().unwrap();
let opts = sqlx::sqlite::SqliteConnectOptions::new()
.filename(dir.path().join("u.db"))
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(std::time::Duration::from_secs(5));
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(4)
.connect_with(opts)
.await
.unwrap();
sqlite::migrate(&pool).await.unwrap();
let repo = std::sync::Arc::new(SqliteUserRepository::new(pool.clone()));
let a = test_user("admin_a");
let b = test_user("admin_b");
repo.save(&a).await.unwrap();
repo.save(&b).await.unwrap();
sqlx::query("UPDATE users SET role = 'admin' WHERE id IN (?, ?)")
.bind(a.id().value().to_string())
.bind(b.id().value().to_string())
.execute(&pool)
.await
.unwrap();
assert_eq!(repo.count_active_admins().await.unwrap(), 2);
let (ra, rb) = (repo.clone(), repo.clone());
let (ida, idb) = (a.id().clone(), b.id().clone());
let (ea, eb) = (deleted_event(&a), deleted_event(&b));
let (x, y) = tokio::join!(
tokio::spawn(async move { ra.mark_deleted(&ida, &[ea]).await }),
tokio::spawn(async move { rb.mark_deleted(&idb, &[eb]).await }),
);
let results = [x.unwrap(), y.unwrap()];
assert_eq!(
results.iter().filter(|r| r.is_ok()).count(),
1,
"{results:?}"
);
assert!(
results
.iter()
.any(|r| matches!(r, Err(domain::errors::DomainError::Conflict(_)))),
"{results:?}"
);
assert_eq!(repo.count_active_admins().await.unwrap(), 1);
assert_eq!(
count(&pool, "event_queue").await,
1,
"loser enqueued nothing"
);
}