Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
@@ -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