test: move sqlite tests to tests/

No inline exceptions; migrate via sqlite::migrate, renamed diary/social tests.
This commit is contained in:
2026-10-25 22:40:53 +01:00
parent 8526b77f18
commit f6383d6c04
15 changed files with 39 additions and 41 deletions

View File

@@ -0,0 +1,207 @@
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);
}

View File

@@ -0,0 +1,120 @@
use domain::ports::{ImageRefCommand, ImageRefQuery};
use sqlite::SqliteImageRefAdapter;
use sqlx::SqlitePool;
async fn setup(pool: &SqlitePool) {
sqlx::query(
"CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'standard',
bio TEXT,
avatar_path TEXT
)",
)
.execute(pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE IF NOT EXISTS movies (
id TEXT PRIMARY KEY,
external_metadata_id TEXT,
title TEXT NOT NULL,
release_year INTEGER,
director TEXT,
poster_path TEXT
)",
)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn list_keys_returns_both_avatar_and_poster_paths() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
setup(&pool).await;
sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,'avatars/u1')")
.execute(&pool).await.unwrap();
sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')")
.execute(&pool)
.await
.unwrap();
let adapter = SqliteImageRefAdapter::new(pool);
let mut keys = adapter.list_keys().await.unwrap();
keys.sort();
assert_eq!(keys, vec!["avatars/u1", "posters/m1"]);
}
#[tokio::test]
async fn list_keys_excludes_nulls() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
setup(&pool).await;
sqlx::query(
"INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,NULL)",
)
.execute(&pool)
.await
.unwrap();
let adapter = SqliteImageRefAdapter::new(pool);
assert_eq!(adapter.list_keys().await.unwrap(), Vec::<String>::new());
}
#[tokio::test]
async fn swap_updates_avatar_path() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
setup(&pool).await;
sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,'avatars/u1')")
.execute(&pool).await.unwrap();
let adapter = SqliteImageRefAdapter::new(pool.clone());
adapter.swap("avatars/u1", "avatars/u1.avif").await.unwrap();
let row: (Option<String>,) = sqlx::query_as("SELECT avatar_path FROM users WHERE id='u1'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.0.as_deref(), Some("avatars/u1.avif"));
}
#[tokio::test]
async fn swap_updates_poster_path() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
setup(&pool).await;
sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')")
.execute(&pool)
.await
.unwrap();
let adapter = SqliteImageRefAdapter::new(pool.clone());
adapter.swap("posters/m1", "posters/m1.avif").await.unwrap();
let row: (Option<String>,) = sqlx::query_as("SELECT poster_path FROM movies WHERE id='m1'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.0.as_deref(), Some("posters/m1.avif"));
}
#[tokio::test]
async fn swap_noop_when_key_not_found() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
setup(&pool).await;
let adapter = SqliteImageRefAdapter::new(pool);
adapter
.swap("missing/key", "missing/key.avif")
.await
.unwrap();
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,253 @@
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)",
"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");
}

View File

@@ -0,0 +1,101 @@
use domain::models::UserProfile;
use domain::ports::UserRepository;
use domain::testing::test_user;
use domain::value_objects::UserId;
use sqlite::SqliteUserRepository;
use sqlx::SqlitePool;
async fn setup() -> (SqlitePool, SqliteUserRepository) {
let pool = SqlitePool::connect(":memory:").await.unwrap();
sqlx::query(
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', display_name TEXT, bio TEXT, avatar_path TEXT, banner_path TEXT, also_known_as TEXT)"
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE user_profile_fields (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, value TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0)"
)
.execute(&pool)
.await
.unwrap();
let repo = SqliteUserRepository::new(pool.clone());
(pool, repo)
}
#[tokio::test]
async fn find_by_id_returns_none_when_not_found() {
let (_, repo) = setup().await;
let result = repo
.find_by_id(&UserId::from_uuid(uuid::Uuid::new_v4()))
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn find_by_id_returns_user_when_found() {
let (pool, repo) = setup().await;
let id = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(id.to_string())
.bind("test@example.com")
.bind("test")
.bind("$argon2id$v=19$m=65536,t=2,p=1$fakesalt$fakehash")
.bind("2026-01-01T00:00:00Z")
.execute(&pool)
.await
.unwrap();
let result = repo.find_by_id(&UserId::from_uuid(id)).await.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().email().value(), "test@example.com");
}
#[tokio::test]
async fn update_profile_persists_bio_and_avatar() {
let (_, repo) = setup().await;
let user = 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);
}