feat: extensible search engine with person entities (FTS5/tsvector)

This commit is contained in:
2026-05-12 18:45:24 +02:00
parent 763d622601
commit c6770659c5
45 changed files with 2421 additions and 86 deletions

View File

@@ -0,0 +1,142 @@
use super::extractors::{make_test_state, Panic};
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
routing::get,
};
use domain::errors::DomainError;
use std::sync::Arc;
use tower::ServiceExt;
use uuid::Uuid;
// Custom stub for SearchPort that returns empty results instead of panicking
struct SearchPortStub;
#[async_trait::async_trait]
impl domain::ports::SearchPort for SearchPortStub {
async fn search(&self, _: &domain::models::SearchQuery) -> Result<domain::models::SearchResults, DomainError> {
Ok(domain::models::SearchResults {
movies: domain::models::collections::Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
},
people: domain::models::collections::Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
},
})
}
}
// Custom stub for PersonQuery that returns 404 instead of panicking
struct PersonQueryStub;
#[async_trait::async_trait]
impl domain::ports::PersonQuery for PersonQueryStub {
async fn get_by_id(&self, _: &domain::models::PersonId) -> Result<Option<domain::models::Person>, DomainError> {
Ok(None) // Return None to trigger 404
}
async fn get_by_external_id(&self, _: &domain::models::ExternalPersonId) -> Result<Option<domain::models::Person>, DomainError> {
Ok(None)
}
async fn get_credits(&self, _: &domain::models::PersonId) -> Result<domain::models::PersonCredits, DomainError> {
Err(DomainError::NotFound("Person not found".into()))
}
}
// --- Search endpoint tests ---
#[tokio::test]
async fn search_endpoint_returns_200_with_empty_results() {
let mut state = make_test_state(Arc::new(Panic));
// Override the search_port with our stub
state.app_ctx.search_port = Arc::new(SearchPortStub);
let app = Router::new()
.route("/api/v1/search", get(crate::handlers::api::get_search))
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/search?q=test&limit=10&offset=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn search_endpoint_with_no_query_returns_200() {
let mut state = make_test_state(Arc::new(Panic));
// Override the search_port with our stub
state.app_ctx.search_port = Arc::new(SearchPortStub);
let app = Router::new()
.route("/api/v1/search", get(crate::handlers::api::get_search))
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/search?q=&limit=5&offset=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
// --- Person endpoint tests ---
#[tokio::test]
async fn person_endpoint_returns_404_for_unknown_id() {
let mut state = make_test_state(Arc::new(Panic));
// Override the person_query with our stub
state.app_ctx.person_query = Arc::new(PersonQueryStub);
let app = Router::new()
.route("/api/v1/people/{id}", get(crate::handlers::api::get_person_handler))
.with_state(state);
let unknown_id = Uuid::new_v4();
let resp = app
.oneshot(
Request::builder()
.uri(&format!("/api/v1/people/{}", unknown_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn person_credits_endpoint_returns_404_for_unknown_id() {
let mut state = make_test_state(Arc::new(Panic));
// Override the person_query with our stub
state.app_ctx.person_query = Arc::new(PersonQueryStub);
let app = Router::new()
.route("/api/v1/people/{id}/credits", get(crate::handlers::api::get_person_credits_handler))
.with_state(state);
let unknown_id = Uuid::new_v4();
let resp = app
.oneshot(
Request::builder()
.uri(&format!("/api/v1/people/{}/credits", unknown_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

View File

@@ -13,11 +13,14 @@ use domain::{
DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats,
UserTrends,
collections::{PageParams, Paginated},
PersonId, EntityType, IndexableDocument, Person, PersonCredits,
SearchQuery, SearchResults,
},
ports::{
AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage,
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository,
StatsRepository, UserRepository,
PersonCommand, PersonQuery, SearchPort, SearchCommand,
},
value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl,
@@ -29,7 +32,7 @@ use tower::ServiceExt;
// --- Panic stubs (defined once) ---
struct Panic;
pub struct Panic;
#[async_trait::async_trait]
impl MovieRepository for Panic {
@@ -350,9 +353,29 @@ impl AuthService for RejectingAuth {
}
}
#[async_trait::async_trait]
impl PersonCommand for Panic {
async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { panic!() }
}
#[async_trait::async_trait]
impl PersonQuery for Panic {
async fn get_by_id(&self, _: &PersonId) -> Result<Option<Person>, DomainError> { panic!() }
async fn get_by_external_id(&self, _: &domain::models::ExternalPersonId) -> Result<Option<Person>, DomainError> { panic!() }
async fn get_credits(&self, _: &PersonId) -> Result<PersonCredits, DomainError> { panic!() }
}
#[async_trait::async_trait]
impl SearchPort for Panic {
async fn search(&self, _: &SearchQuery) -> Result<SearchResults, DomainError> { panic!() }
}
#[async_trait::async_trait]
impl SearchCommand for Panic {
async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { panic!() }
async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { panic!() }
}
// --- Single state factory — only auth_service varies ---
fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppState {
pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppState {
let repo = Arc::new(Panic);
crate::state::AppState {
app_ctx: AppContext {
@@ -371,6 +394,10 @@ fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppState
import_session_repository: Arc::clone(&repo) as _,
import_profile_repository: Arc::clone(&repo) as _,
movie_profile_repository: Arc::clone(&repo) as _,
person_command: Arc::clone(&repo) as _,
person_query: Arc::clone(&repo) as _,
search_port: Arc::clone(&repo) as _,
search_command: Arc::clone(&repo) as _,
auth_service,
config: AppConfig {
allow_registration: false,

View File

@@ -0,0 +1,45 @@
// Re-export imports needed by subtest modules
pub use application::{config::AppConfig, context::AppContext};
pub use axum::{
Router,
body::Body,
http::{Request, StatusCode},
routing::get,
};
pub use domain::{
errors::DomainError,
events::DomainEvent,
models::{
DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats,
UserTrends,
collections::{PageParams, Paginated},
PersonId, EntityType, IndexableDocument, Person, PersonCredits,
SearchQuery, SearchResults,
},
ports::{
AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage,
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository,
StatsRepository, UserRepository,
PersonCommand, PersonQuery, SearchPort, SearchCommand,
},
value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl,
ReleaseYear, ReviewId, UserId,
},
};
pub use std::sync::Arc;
pub use tower::ServiceExt;
// API types for tests
pub use api_types::{
LoginRequest, LogReviewRequest, DiaryQueryParams,
};
pub use crate::{
extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser},
forms::{LogReviewData, LogReviewForm, to_diary_query},
state::AppState,
};
mod extractors;
mod forms;
mod api_handlers;