Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
762
crates/adapters/sqlite/tests/account_purge_test.rs
Normal file
762
crates/adapters/sqlite/tests/account_purge_test.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
338
crates/adapters/sqlite/tests/cascade_test.rs
Normal file
338
crates/adapters/sqlite/tests/cascade_test.rs
Normal 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);
|
||||
}
|
||||
33
crates/adapters/sqlite/tests/fixtures/migration_ledger.txt
vendored
Normal file
33
crates/adapters/sqlite/tests/fixtures/migration_ledger.txt
vendored
Normal 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
|
||||
BIN
crates/adapters/sqlite/tests/fixtures/pre_merge.db
vendored
Normal file
BIN
crates/adapters/sqlite/tests/fixtures/pre_merge.db
vendored
Normal file
Binary file not shown.
102
crates/adapters/sqlite/tests/migration_ledger_test.rs
Normal file
102
crates/adapters/sqlite/tests/migration_ledger_test.rs
Normal 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;
|
||||
}
|
||||
22
crates/adapters/sqlite/tests/repositories.rs
Normal file
22
crates/adapters/sqlite/tests/repositories.rs
Normal 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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
161
crates/adapters/sqlite/tests/repositories/movie_dedup_test.rs
Normal file
161
crates/adapters/sqlite/tests/repositories/movie_dedup_test.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
34
crates/adapters/sqlite/tests/repositories/outbox_support.rs
Normal file
34
crates/adapters/sqlite/tests/repositories/outbox_support.rs
Normal 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();
|
||||
}
|
||||
810
crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs
Normal file
810
crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs
Normal 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);
|
||||
}
|
||||
135
crates/adapters/sqlite/tests/repositories/person_adapter_test.rs
Normal file
135
crates/adapters/sqlite/tests/repositories/person_adapter_test.rs
Normal 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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
213
crates/adapters/sqlite/tests/repositories/search_adapter_test.rs
Normal file
213
crates/adapters/sqlite/tests/repositories/search_adapter_test.rs
Normal 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());
|
||||
}
|
||||
@@ -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))
|
||||
));
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user