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

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

View File

@@ -0,0 +1,52 @@
[package]
name = "bootstrap"
version = "0.1.0"
edition.workspace = true
[features]
default = []
nats = ["dep:nats"]
federation = [
"application/federation",
"http-axum/federation",
"dep:sqlite-federation",
"dep:activitypub",
]
[dependencies]
config = { workspace = true }
domain = { workspace = true }
application = { workspace = true }
http-axum = { workspace = true, default-features = false }
auth = { workspace = true }
metadata = { workspace = true }
poster-fetcher = { workspace = true }
object-storage = { workspace = true }
tmdb-enrichment = { workspace = true }
image-converter = { workspace = true }
export = { workspace = true }
importer = { workspace = true }
rss-adapter = { workspace = true }
media-server = { workspace = true }
sqlite = { workspace = true }
sqlx = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
axum = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros", "sync", "time", "net"] }
futures = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "ansi", "tracing-log", "smallvec"] }
rustls = { workspace = true, features = ["ring", "std", "tls12", "logging"] }
nats = { workspace = true, optional = true }
activitypub = { workspace = true, optional = true }
sqlite-federation = { workspace = true, optional = true }
[dev-dependencies]
reqwest = { workspace = true }
chrono = { workspace = true, features = ["clock"] }
uuid = { workspace = true, features = ["v4"] }
serde_json = { workspace = true }
tower = { workspace = true, features = ["util"] }
domain = { workspace = true, features = ["test-helpers"] }

View File

@@ -0,0 +1,390 @@
use application::auth::deps::{
LoginDeps, LogoutDeps, RefreshDeps, RegisterAndLoginDeps, RegisterDeps,
};
use application::deps::{
AuthGroup, Deps, DiaryGroup, GoalsGroup, ImportGroup, InsightsGroup, IntegrationsGroup,
MoviesGroup, PersonGroup, SearchGroup, SocialGroup, UsersGroup, WatchlistGroup, WorkerDeps,
WrapupGroup,
};
use application::diary::deps::{
DeleteReviewDeps, EditReviewDeps, ExportDiaryDeps, GetActivityFeedDeps, GetDiaryDeps,
GetMovieSocialPageDeps, GetReviewHistoryDeps, GetUserFeedDeps,
};
use application::goals::deps::{GoalCommandDeps, GoalQueryDeps};
use application::import::deps::{
ApplyMappingDeps, ApplyProfileAndMapDeps, ApplyProfileDeps, CreateSessionDeps,
DeleteImportProfileDeps, ExecuteImportDeps, GetMappingStageDeps, GetPreviewStageDeps,
GetSessionStateDeps, ListImportProfilesDeps, SaveProfileDeps,
};
use application::insights::deps::GetInsightsDeps;
use application::integrations::deps::{
ConfirmWatchEventsDeps, DismissWatchEventsDeps, GenerateWebhookTokenDeps, GetWatchQueueDeps,
GetWebhookTokensDeps, IngestWatchEventDeps, RevokeWebhookTokenDeps,
};
use application::movies::deps::{
EnrichMovieDeps, GetMovieProfileDeps, GetMoviesDeps, ReindexSearchDeps, SyncPosterDeps,
};
use application::movies::merge_duplicates::MergeDuplicatesDeps;
use application::person::deps::{EnrichPersonDeps, GetPersonDeps};
use application::search::deps::SearchDeps;
use application::social::deps::{SocialCommandDeps, SocialQueryDeps};
use application::users::deps::{
AuthorizeAdminDeps, EnsureActiveDeps, GetCurrentProfileDeps, GetFederatedProfileDeps,
GetFederatedProfileStatsDeps, GetLocalProfileDeps, GetPageViewerDeps, GetProfileSettingsDeps,
GetSettingsDeps, GetUsersListDeps, RequestAccountDeletionDeps, ResolveUsernameDeps,
UpdateProfileDeps, UpdateProfileFieldsDeps, UpdateSettingsDeps,
};
use application::watchlist::deps::{
GetWatchlistDeps, GetWatchlistForOwnerDeps, IsOnWatchlistDeps, RemoveFromWatchlistDeps,
WatchlistAddDeps,
};
use application::wrapup::deps::{
DeleteWrapUpDeps, GenerateWrapUpDeps, GetReadyReportDeps, GetWrapUpDeps,
HandleWrapUpRequestedDeps, ListWrapUpsDeps,
};
use domain::value_objects::InstanceIdentity;
use crate::{Repositories, database::DatabaseOutput};
/// The composition root's single assembly point: every one of the server-facing
/// deps structs, built once from the already-constructed `Repositories` and
/// `application::Services`. Only `Arc` clones happen here — no adapters are built.
pub fn build_deps(
repos: &Repositories,
services: &application::Services,
config: &application::config::AppSettings,
instance: &InstanceIdentity,
) -> Deps {
Deps {
auth: AuthGroup {
login: LoginDeps {
user: repos.user.clone(),
password_hasher: services.password_hasher.clone(),
auth: services.auth.clone(),
refresh_session: repos.refresh_session.clone(),
config: config.clone(),
},
register: RegisterDeps {
user: repos.user.clone(),
password_hasher: services.password_hasher.clone(),
config: config.clone(),
},
refresh: RefreshDeps {
refresh_session: repos.refresh_session.clone(),
auth: services.auth.clone(),
config: config.clone(),
},
register_and_login: RegisterAndLoginDeps {
user: repos.user.clone(),
password_hasher: services.password_hasher.clone(),
auth: services.auth.clone(),
refresh_session: repos.refresh_session.clone(),
config: config.clone(),
},
logout: LogoutDeps {
refresh_session: repos.refresh_session.clone(),
},
},
diary: DiaryGroup {
delete_review: DeleteReviewDeps {
review: repos.review.clone(),
diary: repos.diary.clone(),
},
edit_review: EditReviewDeps {
review: repos.review.clone(),
},
get_movie_social_page: GetMovieSocialPageDeps {
movie_query: repos.movie_query.clone(),
diary: repos.diary.clone(),
movie_profile: repos.movie_profile.clone(),
},
get_activity_feed: GetActivityFeedDeps {
diary: repos.diary.clone(),
social_query: repos.follow_graph.clone(),
config: config.clone(),
},
get_user_feed: GetUserFeedDeps {
user: repos.user.clone(),
diary: repos.diary.clone(),
},
get_diary: GetDiaryDeps {
diary: repos.diary.clone(),
},
get_review_history: GetReviewHistoryDeps {
diary: repos.diary.clone(),
},
export_diary: ExportDiaryDeps {
diary: repos.diary.clone(),
diary_exporter: services.diary_exporter.clone(),
},
},
goals: GoalsGroup {
command: GoalCommandDeps {
goal_command: repos.goal_command.clone(),
goal_query: repos.goal_query.clone(),
stats: repos.stats.clone(),
},
query: GoalQueryDeps {
goal_query: repos.goal_query.clone(),
stats: repos.stats.clone(),
},
},
import: ImportGroup {
create_session: CreateSessionDeps {
import_session: repos.import_session.clone(),
document_parser: services.document_parser.clone(),
},
apply_mapping: ApplyMappingDeps {
import_session: repos.import_session.clone(),
document_parser: services.document_parser.clone(),
movie_query: repos.movie_query.clone(),
},
apply_profile: ApplyProfileDeps {
import_profile: repos.import_profile.clone(),
import_session: repos.import_session.clone(),
},
execute_import: ExecuteImportDeps {
import_session: repos.import_session.clone(),
review_logger: services.review_logger.clone(),
},
save_profile: SaveProfileDeps {
import_session: repos.import_session.clone(),
import_profile: repos.import_profile.clone(),
},
get_mapping_stage: GetMappingStageDeps {
import_session: repos.import_session.clone(),
},
get_preview_stage: GetPreviewStageDeps {
import_session: repos.import_session.clone(),
},
get_session_state: GetSessionStateDeps {
import_session: repos.import_session.clone(),
},
apply_profile_and_map: ApplyProfileAndMapDeps {
import_profile: repos.import_profile.clone(),
import_session: repos.import_session.clone(),
document_parser: services.document_parser.clone(),
movie_query: repos.movie_query.clone(),
},
delete_profile: DeleteImportProfileDeps {
import_profile: repos.import_profile.clone(),
},
list_profiles: ListImportProfilesDeps {
import_profile: repos.import_profile.clone(),
},
},
insights: InsightsGroup {
get: GetInsightsDeps {
insights: repos.insights.clone(),
},
},
integrations: IntegrationsGroup {
ingest_watch_event: IngestWatchEventDeps {
webhook_token: repos.webhook_token.clone(),
watch_event_command: repos.watch_event_command.clone(),
watch_event_query: repos.watch_event_query.clone(),
},
confirm_watch_events: ConfirmWatchEventsDeps {
watch_event_command: repos.watch_event_command.clone(),
watch_event_query: repos.watch_event_query.clone(),
review_logger: services.review_logger.clone(),
},
dismiss_watch_events: DismissWatchEventsDeps {
watch_event_command: repos.watch_event_command.clone(),
watch_event_query: repos.watch_event_query.clone(),
},
generate_webhook_token: GenerateWebhookTokenDeps {
webhook_token: repos.webhook_token.clone(),
},
get_watch_queue: GetWatchQueueDeps {
watch_event_query: repos.watch_event_query.clone(),
},
get_webhook_tokens: GetWebhookTokensDeps {
webhook_token: repos.webhook_token.clone(),
},
revoke_webhook_token: RevokeWebhookTokenDeps {
webhook_token: repos.webhook_token.clone(),
},
jellyfin_parser: std::sync::Arc::new(media_server::JellyfinParser),
plex_parser: std::sync::Arc::new(media_server::PlexParser),
},
movies: MoviesGroup {
sync_poster: SyncPosterDeps {
movie_command: repos.movie_command.clone(),
movie_query: repos.movie_query.clone(),
movie_profile: repos.movie_profile.clone(),
metadata: services.metadata.clone(),
poster_fetcher: services.poster_fetcher.clone(),
object_storage: services.object_storage.clone(),
event_publisher: services.event_publisher.clone(),
search_command: repos.search_command.clone(),
},
get_movie_profile: GetMovieProfileDeps {
movie_profile: repos.movie_profile.clone(),
},
get_movies: GetMoviesDeps {
movie: repos.movie_query.clone(),
},
},
person: PersonGroup {
get_person: GetPersonDeps {
person_query: repos.person_query.clone(),
event_publisher: services.event_publisher.clone(),
},
},
search: SearchGroup {
execute: SearchDeps {
search_port: repos.search_port.clone(),
},
},
social: SocialGroup {
command: SocialCommandDeps {
social_command: repos.social_command.clone(),
},
query: SocialQueryDeps {
follow_graph: repos.follow_graph.clone(),
block_query: repos.block_query.clone(),
},
},
users: UsersGroup {
get_local_profile: GetLocalProfileDeps {
stats: repos.stats.clone(),
diary: repos.diary.clone(),
social_query: repos.follow_graph.clone(),
user: repos.user.clone(),
instance: instance.clone(),
},
get_federated_profile_stats: GetFederatedProfileStatsDeps {
stats: repos.stats.clone(),
diary: repos.diary.clone(),
social_query: repos.follow_graph.clone(),
},
get_page_viewer: GetPageViewerDeps {
user: repos.user.clone(),
follow_graph: repos.follow_graph.clone(),
},
resolve_username: ResolveUsernameDeps {
user: repos.user.clone(),
},
get_profile_settings: GetProfileSettingsDeps {
user: repos.user.clone(),
profile_fields: repos.profile_fields.clone(),
instance: instance.clone(),
},
get_users_list: GetUsersListDeps {
user: repos.user.clone(),
federation_admin: repos.federation_admin.clone(),
},
update_profile: UpdateProfileDeps {
user: repos.user.clone(),
object_storage: services.object_storage.clone(),
event_publisher: services.event_publisher.clone(),
},
request_account_deletion: RequestAccountDeletionDeps {
user: repos.user.clone(),
password_hasher: services.password_hasher.clone(),
},
ensure_active: EnsureActiveDeps {
user: repos.user.clone(),
},
get_current_profile: GetCurrentProfileDeps {
user: repos.user.clone(),
},
update_profile_fields: UpdateProfileFieldsDeps {
profile_fields: repos.profile_fields.clone(),
},
get_settings: GetSettingsDeps {
user_settings: repos.user_settings.clone(),
},
update_settings: UpdateSettingsDeps {
user_settings: repos.user_settings.clone(),
},
authorize_admin: AuthorizeAdminDeps {
user: repos.user.clone(),
},
get_federated_profile: GetFederatedProfileDeps {
federated_profile: repos.federated_profile.clone(),
},
},
watchlist: WatchlistGroup {
add: WatchlistAddDeps {
movie_command: repos.movie_command.clone(),
movie_query: repos.movie_query.clone(),
metadata: services.metadata.clone(),
watchlist: repos.watchlist.clone(),
},
get_watchlist_for_owner: GetWatchlistForOwnerDeps {
user: repos.user.clone(),
watchlist: repos.watchlist.clone(),
remote_watchlist: repos.remote_watchlist.clone(),
},
get_watchlist: GetWatchlistDeps {
watchlist: repos.watchlist.clone(),
},
is_on_watchlist: IsOnWatchlistDeps {
watchlist: repos.watchlist.clone(),
},
remove_from_watchlist: RemoveFromWatchlistDeps {
watchlist: repos.watchlist.clone(),
},
},
wrapup: WrapupGroup {
get_ready_report: GetReadyReportDeps {
wrapup_repo: repos.wrapup_repo.clone(),
},
delete_wrapup: DeleteWrapUpDeps {
wrapup_repo: repos.wrapup_repo.clone(),
},
generate: GenerateWrapUpDeps {
wrapup_repo: repos.wrapup_repo.clone(),
},
get_wrapup: GetWrapUpDeps {
wrapup_repo: repos.wrapup_repo.clone(),
},
list_wrapups: ListWrapUpsDeps {
wrapup_repo: repos.wrapup_repo.clone(),
},
},
}
}
/// The worker binary's assembly point: the five deps structs that have no consumer
/// the server binary can ever reach, built straight from `DatabaseOutput` — the
/// worker cannot honestly construct a `Repositories` (it lacks the six
/// federation-sourced ports) but every field this function reads is on
/// `DatabaseOutput` — plus `application::WorkerServices`, the strict subset of
/// ports the worker actually constructs.
pub fn build_worker_deps(
db: &DatabaseOutput,
services: &application::WorkerServices,
) -> WorkerDeps {
WorkerDeps {
enrich_movie: EnrichMovieDeps {
movie_query: db.movie_query.clone(),
movie_profile: db.movie_profile.clone(),
person_command: db.person_command.clone(),
search_command: db.search_command.clone(),
},
reindex_search: ReindexSearchDeps {
movie_query: db.movie_query.clone(),
movie_profile: db.movie_profile.clone(),
search_command: db.search_command.clone(),
person_command: db.person_command.clone(),
person_query: db.person_query.clone(),
},
merge_duplicates: MergeDuplicatesDeps {
movie_query: db.movie_query.clone(),
deduplicator: db.deduplicator.clone(),
object_storage: services.object_storage.clone(),
},
enrich_person: EnrichPersonDeps {
person_query: db.person_query.clone(),
person_enrichment: services.person_enrichment.clone(),
person_command: db.person_command.clone(),
},
handle_requested: HandleWrapUpRequestedDeps {
wrapup_repo: db.wrapup_repo.clone(),
wrapup_stats: db.wrapup_stats.clone(),
},
}
}

View File

@@ -0,0 +1,161 @@
use std::sync::Arc;
use anyhow::Context;
use domain::ports::{
AuthService, ImageRefCommand, ImageRefQuery, MetadataClient, MovieDeduplicator, ObjectStorage,
PasswordHasher, PosterFetcherClient, RefreshSessionRepository, UserProfileFieldsRepository,
WatchEventCommand, WatchEventQuery, WebhookTokenRepository,
};
pub struct DatabaseOutput {
pub movie_command: Arc<dyn domain::ports::MovieCommand>,
pub movie_query: Arc<dyn domain::ports::MovieQuery>,
pub review: Arc<dyn domain::ports::ReviewRepository>,
pub diary: Arc<dyn domain::ports::DiaryQuery>,
pub stats: Arc<dyn domain::ports::StatsRepository>,
pub user: Arc<dyn domain::ports::UserRepository>,
pub import_session: Arc<dyn domain::ports::ImportSessionRepository>,
pub import_profile: Arc<dyn domain::ports::ImportProfileRepository>,
pub movie_profile: Arc<dyn domain::ports::MovieProfileRepository>,
pub watchlist: Arc<dyn domain::ports::WatchlistRepository>,
pub watch_event_command: Arc<dyn WatchEventCommand>,
pub watch_event_query: Arc<dyn WatchEventQuery>,
pub webhook_token: Arc<dyn WebhookTokenRepository>,
pub person_command: Arc<dyn domain::ports::PersonCommand>,
pub person_query: Arc<dyn domain::ports::PersonQuery>,
pub search_port: Arc<dyn domain::ports::SearchPort>,
pub search_command: Arc<dyn domain::ports::SearchCommand>,
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
#[cfg(feature = "federation")]
pub ap_content: Arc<dyn domain::ports::LocalApContentQuery>,
pub wrapup_stats: Arc<dyn domain::ports::WrapUpStatsQuery>,
pub wrapup_repo: Arc<dyn domain::ports::WrapUpRepository>,
pub insights: Arc<dyn domain::ports::InsightsQuery>,
pub goal_command: Arc<dyn domain::ports::GoalCommand>,
pub goal_query: Arc<dyn domain::ports::GoalQuery>,
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
#[cfg(feature = "federation")]
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
pub refresh_session: Arc<dyn RefreshSessionRepository>,
pub processed_events: Arc<dyn domain::ports::ProcessedEventLedger>,
pub deduplicator: Arc<dyn MovieDeduplicator>,
pub account_purger: Arc<dyn domain::ports::AccountPurger>,
pub image_ref_command: Arc<dyn ImageRefCommand>,
pub image_ref_query: Arc<dyn ImageRefQuery>,
pub follow_command: Arc<dyn domain::ports::FollowCommand>,
pub follow_query: Arc<dyn domain::ports::FollowQuery>,
pub db_pool: sqlx::SqlitePool,
}
pub async fn build_database_adapters(
url: &str,
instance: &domain::value_objects::InstanceIdentity,
) -> anyhow::Result<DatabaseOutput> {
let w = sqlite::wire(url)
.await
.context("SQLite connection failed")?;
assemble(w, instance)
}
pub async fn build_database_adapters_on(
pool: sqlx::SqlitePool,
instance: &domain::value_objects::InstanceIdentity,
) -> anyhow::Result<DatabaseOutput> {
let w = sqlite::wire_pool(pool)
.await
.context("SQLite wiring failed")?;
assemble(w, instance)
}
fn assemble(
w: sqlite::SqliteWireOutput,
instance: &domain::value_objects::InstanceIdentity,
) -> anyhow::Result<DatabaseOutput> {
let (image_ref_command, image_ref_query) = sqlite::create_image_ref(w.pool.clone());
let (pc, pq) = sqlite::create_person_adapter(w.pool.clone());
let (sc, sp) = sqlite::create_search_adapter(w.pool.clone());
let pf = sqlite::create_profile_fields_repo(w.pool.clone());
let we = Arc::new(sqlite::SqliteWatchEventRepository::new(w.pool.clone()));
let wt: Arc<dyn WebhookTokenRepository> =
Arc::new(sqlite::SqliteWebhookTokenRepository::new(w.pool.clone()));
let social = Arc::new(sqlite::SqliteSocialRepository::new(
w.pool.clone(),
instance.clone(),
));
Ok(DatabaseOutput {
movie_command: w.movie_command,
movie_query: w.movie_query,
review: w.review,
diary: w.diary,
stats: w.stats,
user: w.user,
import_session: w.import_session,
import_profile: w.import_profile,
movie_profile: w.movie_profile,
watchlist: w.watchlist,
watch_event_command: we.clone() as _,
watch_event_query: we as _,
webhook_token: wt,
person_command: pc,
person_query: pq,
search_port: sp,
search_command: sc,
profile_fields: pf,
#[cfg(feature = "federation")]
ap_content: w.ap_content,
wrapup_stats: w.wrapup_stats,
wrapup_repo: w.wrapup_repo,
insights: w.insights,
goal_command: w.goal_command,
goal_query: w.goal_query,
user_settings: w.user_settings,
#[cfg(feature = "federation")]
federation_settings: w.federation_settings,
remote_goal: w.remote_goal,
refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone())) as _,
processed_events: Arc::new(sqlite::SqliteProcessedEventLedger::new(w.pool.clone())) as _,
deduplicator: w.deduplicator,
account_purger: Arc::new(sqlite::SqliteAccountPurger::new(
w.pool.clone(),
instance.clone(),
)) as _,
image_ref_command,
image_ref_query,
follow_command: Arc::clone(&social) as _,
follow_query: social as _,
db_pool: w.pool,
})
}
pub fn build_auth_adapters(
config: &config::AuthConfig,
) -> anyhow::Result<(Arc<dyn AuthService>, Arc<dyn PasswordHasher>)> {
auth::create(config)
}
pub fn build_metadata_client(
config: &config::MetadataConfig,
) -> anyhow::Result<Arc<dyn MetadataClient>> {
metadata::create(config)
}
pub fn build_poster_fetcher(
config: &config::MetadataConfig,
) -> anyhow::Result<Arc<dyn PosterFetcherClient>> {
poster_fetcher::create(config)
}
pub fn build_object_storage(
config: &config::StorageConfig,
) -> anyhow::Result<Arc<dyn ObjectStorage>> {
object_storage::create(config)
}
pub fn warn_if_database_backend_set() {
if let Ok(v) = std::env::var("DATABASE_BACKEND")
&& v != "sqlite"
{
tracing::warn!("DATABASE_BACKEND is ignored; SQLite is the only backend");
}
}

View File

@@ -0,0 +1,141 @@
use std::sync::Arc;
use crate::outbox_relay::OutboxRelay;
#[cfg(feature = "nats")]
use anyhow::Context;
use domain::ports::{EventConsumer, EventPublisher, EventRelaySink};
#[derive(Clone, Copy)]
pub enum EventBusBackend {
Db,
#[cfg(feature = "nats")]
Nats,
}
impl EventBusBackend {
pub fn from_config(config: &config::EventsConfig) -> anyhow::Result<Self> {
match config.bus_backend.as_str() {
"db" => Ok(Self::Db),
#[cfg(feature = "nats")]
"nats" => Ok(Self::Nats),
#[cfg(not(feature = "nats"))]
"nats" => {
anyhow::bail!("EVENT_BUS_BACKEND=nats requires the nats feature to be compiled in")
}
other => anyhow::bail!("unknown EVENT_BUS_BACKEND={other}, expected 'db' or 'nats'"),
}
}
}
pub async fn create_publisher(
db_pool: &sqlx::SqlitePool,
config: &config::AppConfig,
) -> anyhow::Result<Arc<dyn EventPublisher>> {
tracing::info!("event publisher: outbox (DB queue)");
sqlite::SqliteEventQueue::create_publisher(db_pool.clone(), &config.events).await
}
struct Wiring {
publisher: Arc<dyn EventPublisher>,
consumer: Arc<dyn EventConsumer>,
relay: Option<OutboxRelay>,
}
fn compose(
(publisher, outbox): (Arc<dyn EventPublisher>, Arc<dyn EventConsumer>),
bus: Option<(Arc<dyn EventConsumer>, Arc<dyn EventRelaySink>)>,
) -> Wiring {
match bus {
None => Wiring {
publisher,
consumer: outbox,
relay: None,
},
Some((consumer, sink)) => Wiring {
publisher,
consumer,
relay: Some(OutboxRelay::new(outbox, sink)),
},
}
}
pub async fn create_channel(
db_pool: &sqlx::SqlitePool,
config: &config::AppConfig,
) -> anyhow::Result<(
Arc<dyn EventPublisher>,
Arc<dyn EventConsumer>,
Option<OutboxRelay>,
)> {
let outbox = sqlite::SqliteEventQueue::create_channel(db_pool.clone(), &config.events).await?;
let wiring = match EventBusBackend::from_config(&config.events)? {
EventBusBackend::Db => {
tracing::info!("event bus: DB queue");
compose(outbox, None)
}
#[cfg(feature = "nats")]
EventBusBackend::Nats => {
let cfg = nats::NatsConfig::from_config(&config.nats, &config.events)
.context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?;
tracing::info!("event bus: NATS ({}), relaying the outbox", cfg.url);
compose(outbox, Some(nats::create_bus(cfg).await?))
}
};
Ok((wiring.publisher, wiring.consumer, wiring.relay))
}
#[cfg(test)]
mod tests {
use super::*;
use domain::{
errors::DomainError,
events::{DomainEvent, EventEnvelope, EventId},
};
use futures::stream::BoxStream;
struct Nop;
#[async_trait::async_trait]
impl EventPublisher for Nop {
async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> {
Ok(())
}
}
#[async_trait::async_trait]
impl EventRelaySink for Nop {
async fn relay(&self, _: Option<&EventId>, _: &DomainEvent) -> Result<(), DomainError> {
Ok(())
}
}
impl EventConsumer for Nop {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
Box::pin(futures::stream::empty())
}
}
fn outbox() -> (Arc<dyn EventPublisher>, Arc<dyn EventConsumer>) {
(Arc::new(Nop), Arc::new(Nop))
}
#[test]
fn db_mode_does_not_relay_and_consumes_the_outbox() {
let (publisher, queue) = outbox();
let queue_ptr = Arc::as_ptr(&queue);
let wiring = compose((publisher, queue), None);
assert!(wiring.relay.is_none());
assert!(std::ptr::addr_eq(Arc::as_ptr(&wiring.consumer), queue_ptr));
}
#[test]
fn bus_mode_relays_the_outbox_and_consumes_the_bus() {
let (publisher, queue) = outbox();
let bus_consumer: Arc<dyn EventConsumer> = Arc::new(Nop);
let bus_ptr = Arc::as_ptr(&bus_consumer);
let sink: Arc<dyn EventRelaySink> = Arc::new(Nop);
let wiring = compose((publisher, queue), Some((bus_consumer, sink)));
assert!(wiring.relay.is_some(), "relay built in bus mode");
assert!(std::ptr::addr_eq(Arc::as_ptr(&wiring.consumer), bus_ptr));
}
}

View File

@@ -0,0 +1,168 @@
use std::sync::Arc;
use domain::ports::{
BlockQuery, EventPublisher, FederatedProfileQuery, FederationAdminQuery, FollowGraphQuery,
RemoteWatchlistRepository, SocialCommand,
};
use domain::value_objects::InstanceIdentity;
use crate::database::DatabaseOutput;
pub struct ServerSocial {
pub ap_router: axum::Router,
#[cfg(feature = "federation")]
pub ap_document: Arc<dyn domain::ports::ApDocumentPort>,
#[cfg(feature = "federation")]
pub ap_blocklist: Arc<dyn domain::ports::InstanceBlocklistPort>,
pub social_command: Arc<dyn SocialCommand>,
pub follow_graph: Arc<dyn FollowGraphQuery>,
pub block_query: Arc<dyn BlockQuery>,
pub remote_watchlist: Arc<dyn RemoteWatchlistRepository>,
pub federation_admin: Arc<dyn FederationAdminQuery>,
pub federated_profile: Option<Arc<dyn FederatedProfileQuery>>,
}
#[cfg(not(feature = "federation"))]
pub async fn wire_server_social(
db: &DatabaseOutput,
instance: &InstanceIdentity,
_config: &config::AppConfig,
_event_publisher: &Arc<dyn EventPublisher>,
) -> anyhow::Result<ServerSocial> {
let local = Arc::new(application::social::local_service::LocalSocialService::new(
Arc::clone(&db.user),
Arc::clone(&db.follow_command),
Arc::clone(&db.follow_query),
instance.clone(),
));
Ok(ServerSocial {
ap_router: axum::Router::new(),
social_command: Arc::clone(&local) as Arc<dyn SocialCommand>,
follow_graph: Arc::clone(&local) as Arc<dyn FollowGraphQuery>,
block_query: local as Arc<dyn BlockQuery>,
remote_watchlist: Arc::new(domain::ports::noop::NoopRemoteWatchlistRepository),
// The empty list this noop returns is an invariant, not an approximation:
// federation-off, `LocalSocialService::follow_resolved` hard-errors on any
// non-Local target and block/unblock always error, so no remote follow can
// ever be persisted through this wiring. (Stale rows from an instance
// previously built federation-ON are the one exception — display staleness
// only; see ADR-0009.)
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery),
federated_profile: None,
})
}
#[cfg(feature = "federation")]
pub async fn wire_server_social(
db: &DatabaseOutput,
instance: &InstanceIdentity,
config: &config::AppConfig,
event_publisher: &Arc<dyn EventPublisher>,
) -> anyhow::Result<ServerSocial> {
let fed_repos = sqlite_federation::wire(db.db_pool.clone(), instance.clone());
let ap = activitypub::wire(activitypub::ActivityPubDeps {
activity_repo: fed_repos.activity,
follow_repo: fed_repos.follow,
actor_repo: fed_repos.actor,
blocklist_repo: fed_repos.blocklist,
review_store: fed_repos.review_store,
remote_watchlist_repo: fed_repos.remote_watchlist.clone(),
remote_goal_repo: Arc::clone(&db.remote_goal),
local_ap_content: Arc::clone(&db.ap_content),
movie_repo: Arc::clone(&db.movie_query),
review_repo: Arc::clone(&db.review),
diary_repo: Arc::clone(&db.diary),
goal_repo: Arc::clone(&db.goal_query),
stats_repo: Arc::clone(&db.stats),
user_repo: Arc::clone(&db.user),
federation_settings: Arc::clone(&db.federation_settings),
follow_command: Arc::clone(&fed_repos.follow_command),
follow_query: Arc::clone(&fed_repos.follow_query),
instance: instance.clone(),
allow_registration: config.server.allow_registration,
federation: config.federation.clone(),
event_publisher: Arc::clone(event_publisher),
})
.await?;
let local_social: Arc<dyn domain::ports::LocalSocial> =
Arc::new(application::social::local_service::LocalSocialService::new(
Arc::clone(&db.user),
Arc::clone(&db.follow_command),
Arc::clone(&db.follow_query),
instance.clone(),
));
let composite_social = Arc::new(activitypub::CompositeSocialAdapter::new(
local_social,
ap.service,
Arc::clone(&db.user),
instance.clone(),
Arc::clone(event_publisher),
));
Ok(ServerSocial {
ap_router: ap.router,
ap_document: ap.document,
ap_blocklist: ap.blocklist,
social_command: composite_social.clone() as Arc<dyn SocialCommand>,
follow_graph: composite_social.clone() as Arc<dyn FollowGraphQuery>,
block_query: composite_social as Arc<dyn BlockQuery>,
remote_watchlist: fed_repos.remote_watchlist,
federation_admin: fed_repos.admin_query,
federated_profile: Some(sqlite::create_federated_profile_query(
db.db_pool.clone(),
instance.clone(),
)),
})
}
#[cfg(feature = "federation")]
pub async fn wire_worker_handlers(
db: &DatabaseOutput,
instance: &InstanceIdentity,
config: &config::AppConfig,
event_publisher: &Arc<dyn EventPublisher>,
) -> anyhow::Result<(
Vec<Arc<dyn domain::ports::EventHandler>>,
Arc<dyn domain::ports::FederationDeletionNotifier>,
)> {
let fed_repos = sqlite_federation::wire(db.db_pool.clone(), instance.clone());
let ap_wire = activitypub::wire(activitypub::ActivityPubDeps {
activity_repo: fed_repos.activity,
follow_repo: fed_repos.follow,
actor_repo: fed_repos.actor,
blocklist_repo: fed_repos.blocklist,
review_store: fed_repos.review_store,
remote_watchlist_repo: fed_repos.remote_watchlist,
remote_goal_repo: Arc::clone(&db.remote_goal),
local_ap_content: Arc::clone(&db.ap_content),
movie_repo: Arc::clone(&db.movie_query),
review_repo: Arc::clone(&db.review),
diary_repo: Arc::clone(&db.diary),
goal_repo: Arc::clone(&db.goal_query),
stats_repo: Arc::clone(&db.stats),
user_repo: Arc::clone(&db.user),
follow_command: fed_repos.follow_command,
follow_query: fed_repos.follow_query,
instance: instance.clone(),
allow_registration: config.server.allow_registration,
federation: config.federation.clone(),
event_publisher: Arc::clone(event_publisher),
federation_settings: Arc::clone(&db.federation_settings),
})
.await?;
tracing::info!("federation event handler registered");
Ok((
vec![
ap_wire.event_handler,
Arc::new(crate::follow_backfill_handler::FollowBackfillHandler {
backfill: ap_wire.backfill,
}),
],
ap_wire.deletion_notifier,
))
}

View File

@@ -0,0 +1,126 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{ApBackfillPort, EventHandler},
value_objects::SocialIdentity,
};
pub struct FollowBackfillHandler {
pub backfill: Arc<dyn ApBackfillPort>,
}
#[async_trait]
impl EventHandler for FollowBackfillHandler {
fn name(&self) -> &'static str {
"follow_backfill"
}
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
match event {
DomainEvent::FollowAccepted {
owner,
requester: SocialIdentity::Remote { actor_url },
} => {
tracing::info!(actor = %actor_url, "follow accepted — looking up outbox for import");
let following = self.backfill.get_following(owner.value()).await?;
if let Some(actor) = following.iter().find(|a| a.url == *actor_url) {
if let Some(outbox_url) = &actor.outbox_url {
tracing::info!(outbox = %outbox_url, actor = %actor_url, "importing remote outbox");
self.backfill
.import_remote_outbox(outbox_url, actor_url)
.await?;
} else {
tracing::warn!(actor = %actor_url, "no outbox URL for accepted follow — skipping import");
}
}
Ok(())
}
DomainEvent::BackfillFollower {
owner_user_id,
follower_inbox_url,
} => {
tracing::info!(owner = %owner_user_id.value(), inbox = %follower_inbox_url, "backfilling local content to new follower");
self.backfill
.run_backfill_for_follower(owner_user_id.value(), follower_inbox_url.clone())
.await
}
_ => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use domain::models::FollowedActorInfo;
use domain::value_objects::UserId;
use uuid::Uuid;
use super::*;
#[derive(Default)]
struct CallLog(Mutex<Vec<String>>);
#[async_trait]
impl ApBackfillPort for CallLog {
async fn get_following(&self, _: Uuid) -> Result<Vec<FollowedActorInfo>, DomainError> {
Ok(vec![FollowedActorInfo {
url: "https://remote.example/users/a".into(),
outbox_url: Some("https://remote.example/users/a/outbox".into()),
}])
}
async fn import_remote_outbox(&self, outbox: &str, actor: &str) -> Result<(), DomainError> {
self.0
.lock()
.unwrap()
.push(format!("import {outbox} {actor}"));
Ok(())
}
async fn run_backfill_for_follower(
&self,
owner: Uuid,
inbox: String,
) -> Result<(), DomainError> {
self.0
.lock()
.unwrap()
.push(format!("backfill {owner} {inbox}"));
Ok(())
}
}
#[tokio::test]
async fn follow_backfill_replays_identical_port_calls_when_run_twice() {
let port = Arc::new(CallLog::default());
let handler = FollowBackfillHandler {
backfill: port.clone(),
};
let owner = UserId::from_uuid(Uuid::new_v4());
let events = [
DomainEvent::FollowAccepted {
owner: owner.clone(),
requester: SocialIdentity::Remote {
actor_url: "https://remote.example/users/a".into(),
},
},
DomainEvent::BackfillFollower {
owner_user_id: owner,
follower_inbox_url: "https://remote.example/inbox".into(),
},
];
for event in &events {
let start = port.0.lock().unwrap().len();
handler.handle(event).await.unwrap();
let mid = port.0.lock().unwrap().len();
handler.handle(event).await.unwrap();
let log = port.0.lock().unwrap().clone();
assert!(mid > start, "the event reaches the port");
assert_eq!(log[start..mid], log[mid..]);
}
}
}

View File

@@ -0,0 +1,19 @@
mod build;
mod database;
mod event_bus;
mod federation;
#[cfg(feature = "federation")]
mod follow_backfill_handler;
pub mod outbox_relay;
pub mod repositories;
mod server;
mod tls;
mod tracing_setup;
mod worker;
pub use build::build_deps;
pub use repositories::Repositories;
pub use server::{ServerApp, build_server, build_server_on};
pub use tls::install_crypto_provider;
pub use tracing_setup::init_tracing;
pub use worker::{WorkerApp, build_worker, build_worker_on};

View File

@@ -0,0 +1,97 @@
use std::{sync::Arc, time::Duration};
use domain::{
events::{EventEnvelope, retry_backoff},
ports::{EventConsumer, EventRelaySink},
};
use futures::StreamExt;
use tokio::sync::watch;
pub struct OutboxRelay {
source: Arc<dyn EventConsumer>,
sink: Arc<dyn EventRelaySink>,
backoff: fn(u32) -> Duration,
}
impl OutboxRelay {
pub fn new(source: Arc<dyn EventConsumer>, sink: Arc<dyn EventRelaySink>) -> Self {
Self {
source,
sink,
backoff: retry_backoff,
}
}
pub fn with_backoff(mut self, backoff: fn(u32) -> Duration) -> Self {
self.backoff = backoff;
self
}
pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
let mut rows = self.source.consume();
let mut sink_failures: u32 = 0;
loop {
tokio::select! {
biased;
_ = shutdown.changed() => {
tracing::info!("outbox relay: shutdown signal received, stopping");
return;
}
next = rows.next() => match next {
Some(Ok(envelope)) => {
let pause = relay_envelope(
envelope,
self.sink.as_ref(),
&mut sink_failures,
self.backoff,
)
.await;
if let Some(pause) = pause {
tokio::select! {
_ = tokio::time::sleep(pause) => {}
_ = shutdown.changed() => {
tracing::info!("outbox relay: shutdown signal received, stopping");
return;
}
}
}
}
Some(Err(e)) => tracing::error!("outbox relay: skipped undecodable row: {e}"),
None => {
tracing::error!("outbox relay: source closed");
return;
}
},
}
}
}
}
async fn relay_envelope(
envelope: EventEnvelope,
sink: &dyn EventRelaySink,
sink_failures: &mut u32,
backoff: fn(u32) -> Duration,
) -> Option<Duration> {
match sink.relay(envelope.id(), &envelope.event).await {
Ok(()) => {
*sink_failures = 0;
if let Err(e) = envelope.ack().await {
tracing::error!("outbox relay: published but could not mark done: {e}");
}
None
}
Err(e) => {
*sink_failures = sink_failures.saturating_add(1);
let wait = backoff(*sink_failures);
tracing::warn!(
consecutive_failures = *sink_failures,
"outbox relay: bus unavailable, deferring row {wait:?} without spending an attempt: {e}"
);
if let Err(e) = envelope.defer(wait).await {
tracing::error!("outbox relay: could not defer: {e}");
}
Some(wait)
}
}
}

View File

@@ -0,0 +1,47 @@
use std::sync::Arc;
use domain::ports::{
BlockQuery, DiaryQuery, FederatedProfileQuery, FederationAdminQuery, FollowGraphQuery,
GoalCommand, GoalQuery, ImportProfileRepository, ImportSessionRepository, InsightsQuery,
MovieCommand, MovieProfileRepository, MovieQuery, PersonCommand, PersonQuery,
RefreshSessionRepository, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository,
SearchCommand, SearchPort, SocialCommand, StatsRepository, UserProfileFieldsRepository,
UserRepository, UserSettingsRepository, WatchEventCommand, WatchEventQuery,
WatchlistRepository, WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery,
};
#[derive(Clone)]
pub struct Repositories {
pub movie_command: Arc<dyn MovieCommand>,
pub movie_query: Arc<dyn MovieQuery>,
pub review: Arc<dyn ReviewRepository>,
pub diary: Arc<dyn DiaryQuery>,
pub stats: Arc<dyn StatsRepository>,
pub user: Arc<dyn UserRepository>,
pub import_session: Arc<dyn ImportSessionRepository>,
pub import_profile: Arc<dyn ImportProfileRepository>,
pub movie_profile: Arc<dyn MovieProfileRepository>,
pub watchlist: Arc<dyn WatchlistRepository>,
pub watch_event_command: Arc<dyn WatchEventCommand>,
pub watch_event_query: Arc<dyn WatchEventQuery>,
pub webhook_token: Arc<dyn WebhookTokenRepository>,
pub person_command: Arc<dyn PersonCommand>,
pub person_query: Arc<dyn PersonQuery>,
pub search_port: Arc<dyn SearchPort>,
pub search_command: Arc<dyn SearchCommand>,
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
pub remote_watchlist: Arc<dyn RemoteWatchlistRepository>,
pub social_command: Arc<dyn SocialCommand>,
pub follow_graph: Arc<dyn FollowGraphQuery>,
pub block_query: Arc<dyn BlockQuery>,
pub federation_admin: Arc<dyn FederationAdminQuery>,
pub wrapup_stats: Arc<dyn WrapUpStatsQuery>,
pub wrapup_repo: Arc<dyn WrapUpRepository>,
pub insights: Arc<dyn InsightsQuery>,
pub goal_command: Arc<dyn GoalCommand>,
pub goal_query: Arc<dyn GoalQuery>,
pub user_settings: Arc<dyn UserSettingsRepository>,
pub remote_goal: Arc<dyn RemoteGoalRepository>,
pub refresh_session: Arc<dyn RefreshSessionRepository>,
pub federated_profile: Option<Arc<dyn FederatedProfileQuery>>,
}

View File

@@ -0,0 +1,143 @@
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::Context;
use application::config::AppSettings;
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher};
use domain::value_objects::InstanceIdentity;
use export::ExportAdapter;
use http_axum::context::AppContext;
use http_axum::{openapi, router, state::AppState};
use importer::ImporterDocumentParser;
use rss_adapter::RssAdapter;
use crate::database::{self, DatabaseOutput};
use crate::{Repositories, event_bus, federation};
pub struct ServerApp {
pub router: axum::Router,
pub addr: SocketAddr,
}
pub async fn build_server(config: &config::AppConfig) -> anyhow::Result<ServerApp> {
database::warn_if_database_backend_set();
let instance = InstanceIdentity::new(config.server.base_url.clone());
let db = database::build_database_adapters(&config.database.url, &instance).await?;
let router = assemble(config, db, instance)
.await
.context("Failed to wire dependencies")?;
let bind = format!("{}:{}", config.server.host, config.server.port);
let addr = tokio::net::lookup_host(&bind)
.await
.with_context(|| format!("Invalid HOST/PORT {bind}"))?
.next()
.with_context(|| format!("HOST/PORT {bind} resolved to no address"))?;
Ok(ServerApp { router, addr })
}
pub async fn build_server_on(
config: &config::AppConfig,
pool: sqlx::SqlitePool,
) -> anyhow::Result<axum::Router> {
let instance = InstanceIdentity::new(config.server.base_url.clone());
let db = database::build_database_adapters_on(pool, &instance).await?;
assemble(config, db, instance).await
}
async fn assemble(
config: &config::AppConfig,
db: DatabaseOutput,
instance: InstanceIdentity,
) -> anyhow::Result<axum::Router> {
crate::tls::install_crypto_provider();
let app_config = AppSettings::from(config);
let (auth_service, password_hasher) = database::build_auth_adapters(&config.auth)?;
let metadata_client = database::build_metadata_client(&config.metadata)?;
let poster_fetcher = database::build_poster_fetcher(&config.metadata)?;
let object_storage = database::build_object_storage(&config.storage)?;
let event_publisher: Arc<dyn EventPublisher> =
event_bus::create_publisher(&db.db_pool, config).await?;
let social = federation::wire_server_social(&db, &instance, config, &event_publisher).await?;
let review_logger = Arc::new(application::diary::review_logger::DefaultReviewLogger::new(
Arc::clone(&db.movie_command),
Arc::clone(&db.movie_query),
Arc::clone(&db.review),
Arc::clone(&db.watchlist),
Arc::clone(&metadata_client),
));
let repos = Repositories {
movie_command: db.movie_command,
movie_query: db.movie_query,
review: db.review,
diary: db.diary,
stats: db.stats,
user: db.user,
import_session: db.import_session,
import_profile: db.import_profile,
movie_profile: db.movie_profile,
watchlist: db.watchlist,
watch_event_command: db.watch_event_command,
watch_event_query: db.watch_event_query,
webhook_token: db.webhook_token,
person_command: db.person_command,
person_query: db.person_query,
search_port: db.search_port,
search_command: db.search_command,
profile_fields: db.profile_fields,
remote_watchlist: social.remote_watchlist,
social_command: social.social_command,
follow_graph: social.follow_graph,
block_query: social.block_query,
federation_admin: social.federation_admin,
wrapup_stats: db.wrapup_stats,
wrapup_repo: db.wrapup_repo,
insights: db.insights,
goal_command: db.goal_command,
goal_query: db.goal_query,
user_settings: db.user_settings,
remote_goal: db.remote_goal,
refresh_session: db.refresh_session,
federated_profile: social.federated_profile,
};
let services = application::Services {
auth: auth_service,
password_hasher,
metadata: metadata_client,
poster_fetcher,
object_storage,
event_publisher,
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
review_logger,
person_enrichment: None,
};
let deps = Arc::new(crate::build_deps(&repos, &services, &app_config, &instance));
let app_ctx = AppContext {
deps,
services,
config: app_config,
instance,
#[cfg(feature = "federation")]
ap_document: social.ap_document,
#[cfg(feature = "federation")]
ap_blocklist: social.ap_blocklist,
};
let state = AppState {
app_ctx,
rss_renderer: Arc::new(RssAdapter::new(config.server.base_url.clone())),
};
Ok(openapi::serve(router::build_router(
state,
social.ap_router,
)))
}

View File

@@ -0,0 +1,23 @@
use std::sync::Once;
pub fn install_crypto_provider() {
static INSTALL: Once = Once::new();
INSTALL.call_once(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn https_client_builds_after_install() {
install_crypto_provider();
install_crypto_provider();
reqwest::Client::builder()
.build()
.expect("reqwest client builds");
assert!(rustls::crypto::CryptoProvider::get_default().is_some());
}
}

View File

@@ -0,0 +1,24 @@
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
pub fn init_tracing(default_directives: &str) {
tracing_subscriber::registry()
.with(log_filter(default_directives))
.with(tracing_subscriber::fmt::layer())
.init();
}
/// `RUST_LOG` names the crates to trace in detail; anything it doesn't name
/// still reports warnings and errors. Without a baseline, a filter like
/// `http_axum=debug` silently drops adapter errors (e.g. the sqlx detail
/// behind "Database operation failed").
fn log_filter(default: &str) -> tracing_subscriber::EnvFilter {
let directives = std::env::var("RUST_LOG").unwrap_or_else(|_| default.to_string());
let has_baseline = directives
.split(',')
.any(|d| !d.trim().is_empty() && !d.contains('='));
if has_baseline {
tracing_subscriber::EnvFilter::new(directives)
} else {
tracing_subscriber::EnvFilter::new(format!("warn,{directives}"))
}
}

View File

@@ -0,0 +1,317 @@
use std::sync::Arc;
use application::{
MovieDiscoveryIndexer, SearchCleanupHandler, SearchReindexHandler, worker::WorkerService,
};
use domain::ports::{EventHandler, MovieEnrichmentClient, PeriodicJob, PersonEnrichmentClient};
use domain::value_objects::InstanceIdentity;
use crate::database::{self, DatabaseOutput};
use crate::event_bus;
use crate::outbox_relay::OutboxRelay;
use tokio::sync::watch;
pub struct WorkerApp {
service: WorkerService,
relay: Option<OutboxRelay>,
}
impl WorkerApp {
pub fn handler_names(&self) -> Vec<&'static str> {
self.service.handler_names()
}
pub async fn run(self, mut shutdown: watch::Receiver<bool>) -> anyhow::Result<()> {
let (stop_tx, stop_rx) = watch::channel(false);
let mut relay = self.relay.map(|r| tokio::spawn(r.run(stop_rx.clone())));
let service = self.service.run(stop_rx);
tokio::pin!(service);
let mut stopping = false;
let mut relay_failure = None;
loop {
tokio::select! {
_ = &mut service => break,
_ = shutdown.changed(), if !stopping => {
stopping = true;
let _ = stop_tx.send(true);
}
result = async { relay.as_mut().expect("guarded").await }, if relay.is_some() => {
relay = None;
if !stopping {
relay_failure = Some(match result {
Ok(()) => "outbox relay stopped unexpectedly".to_owned(),
Err(e) => format!("outbox relay died: {e}"),
});
stopping = true;
let _ = stop_tx.send(true);
}
}
}
}
let _ = stop_tx.send(true);
if let Some(handle) = relay
&& let Err(e) = handle.await
{
relay_failure.get_or_insert(format!("outbox relay died: {e}"));
}
match relay_failure {
Some(message) => {
tracing::error!("{message}");
Err(anyhow::anyhow!(message))
}
None => Ok(()),
}
}
}
pub async fn build_worker(config: &config::AppConfig) -> anyhow::Result<WorkerApp> {
database::warn_if_database_backend_set();
let instance = InstanceIdentity::new(config.server.base_url.clone());
let db = database::build_database_adapters(&config.database.url, &instance).await?;
assemble(config, db, instance).await
}
pub async fn build_worker_on(
config: &config::AppConfig,
pool: sqlx::SqlitePool,
) -> anyhow::Result<WorkerApp> {
let instance = InstanceIdentity::new(config.server.base_url.clone());
let db = database::build_database_adapters_on(pool, &instance).await?;
assemble(config, db, instance).await
}
async fn assemble(
config: &config::AppConfig,
db: DatabaseOutput,
#[cfg_attr(not(feature = "federation"), allow(unused_variables))] instance: InstanceIdentity,
) -> anyhow::Result<WorkerApp> {
crate::tls::install_crypto_provider();
let metadata_client = metadata::create(&config.metadata)?;
let poster_fetcher = poster_fetcher::create(&config.metadata)?;
let object_storage = object_storage::create(&config.storage)?;
// TMDb client detection happens before `build_worker_deps` so the optional
// person-enrichment port can be handed to `WorkerServices` up front.
let tmdb_client = match tmdb_enrichment::TmdbEnrichmentClient::from_config(&config.metadata) {
Ok(client) => {
tracing::info!("TMDb enrichment enabled");
Some(Arc::new(client))
}
Err(e) => {
tracing::warn!("TMDb enrichment disabled: {e}");
None
}
};
let person_enrichment: Option<Arc<dyn PersonEnrichmentClient>> = tmdb_client
.clone()
.map(|c| c as Arc<dyn PersonEnrichmentClient>);
let (event_publisher_arc, consumer_arc, relay) =
event_bus::create_channel(&db.db_pool, config).await?;
let worker_services = application::WorkerServices {
object_storage: Arc::clone(&object_storage),
event_publisher: Arc::clone(&event_publisher_arc),
person_enrichment,
};
let worker_deps = crate::build::build_worker_deps(&db, &worker_services);
let image_ref_command = Arc::clone(&db.image_ref_command);
let image_ref_query = Arc::clone(&db.image_ref_query);
#[cfg(feature = "federation")]
let (federation_handlers, deletion_notifier) =
crate::federation::wire_worker_handlers(&db, &instance, config, &event_publisher_arc)
.await?;
#[cfg(not(feature = "federation"))]
let deletion_notifier: Arc<dyn domain::ports::FederationDeletionNotifier> =
Arc::new(domain::ports::noop::NoopFederationDeletionNotifier);
let movie_command = db.movie_command;
let movie_query = db.movie_query;
let user = db.user;
let import_session = db.import_session;
let movie_profile = db.movie_profile;
let watch_event_command = db.watch_event_command;
let person_query = db.person_query;
let search_command = db.search_command;
let wrapup_repo = db.wrapup_repo;
let refresh_session = db.refresh_session;
let processed_events = db.processed_events;
let account_purger = db.account_purger;
let event_publisher = event_publisher_arc;
let metadata = metadata_client;
// ── Enrichment ────────────────────────────────────────────────────────────
// Both the event handler and the staleness job are gated on TMDB_API_KEY.
// Without a key, no MovieEnrichmentRequested events are produced or handled.
type EnrichmentParts = (
Option<Arc<dyn EventHandler>>,
Option<Arc<dyn EventHandler>>,
Option<Arc<dyn PeriodicJob>>,
);
let (enrichment_handler, person_enrichment_handler, enrichment_job): EnrichmentParts =
match tmdb_client {
Some(client) => {
let image_fetcher = poster_fetcher::create_image_fetcher(&config.metadata)?;
let handler = Arc::new(application::movies::MovieEnrichmentHandler::new(
Arc::clone(&client) as Arc<dyn MovieEnrichmentClient>,
worker_deps.enrich_movie.movie_query.clone(),
worker_deps.enrich_movie.movie_profile.clone(),
worker_deps.enrich_movie.person_command.clone(),
worker_deps.enrich_movie.search_command.clone(),
Arc::clone(&object_storage),
image_fetcher,
)) as Arc<dyn EventHandler>;
let person_handler = Arc::new(application::person::PersonEnrichmentHandler::new(
worker_deps.enrich_person.person_query.clone(),
worker_deps.enrich_person.person_enrichment.clone(),
worker_deps.enrich_person.person_command.clone(),
)) as Arc<dyn EventHandler>;
let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(
Arc::clone(&movie_profile),
Arc::clone(&event_publisher),
)) as Arc<dyn PeriodicJob>;
(Some(handler), Some(person_handler), Some(job))
}
None => (None, None, None),
};
// ── Image conversion ──────────────────────────────────────────────────────
let conversion = image_converter::build(
&config.image_conversion,
Arc::clone(&object_storage),
image_ref_command,
image_ref_query,
Arc::clone(&event_publisher),
)?;
// ── Periodic jobs ─────────────────────────────────────────────────────────
let mut periodic_jobs: Vec<Arc<dyn PeriodicJob>> = vec![
Arc::new(application::jobs::MovieDeduplicationJob::new(
worker_deps.merge_duplicates.movie_query.clone(),
worker_deps.merge_duplicates.deduplicator.clone(),
worker_deps.merge_duplicates.object_storage.clone(),
)),
Arc::new(application::jobs::ImportSessionCleanupJob::new(
import_session.clone(),
)),
Arc::new(application::jobs::WatchEventCleanupJob::new(
watch_event_command.clone(),
)),
Arc::new(application::jobs::WrapUpAutoGenerateJob::new(
Arc::clone(&user),
Arc::clone(&wrapup_repo),
)),
Arc::new(application::jobs::WrapUpCleanupJob::new(Arc::clone(
&wrapup_repo,
))),
Arc::new(application::jobs::RefreshSessionCleanupJob::new(
Arc::clone(&refresh_session),
)),
Arc::new(application::jobs::ProcessedEventsCleanupJob::new(
Arc::clone(&processed_events),
)),
Arc::new(application::jobs::TombstoneCleanupJob::new(Arc::clone(
&account_purger,
))),
];
if let Some(job) = enrichment_job {
periodic_jobs.push(job);
}
if let Some((_, ref conv_job)) = conversion {
periodic_jobs.push(Arc::clone(conv_job));
}
for job in periodic_jobs {
tokio::spawn(async move {
let mut tick = tokio::time::interval(job.interval());
loop {
tick.tick().await;
if let Err(e) = job.run().await {
tracing::error!("periodic job failed: {e}");
}
}
});
}
// ── Event handlers ────────────────────────────────────────────────────────
let handlers: Vec<Arc<dyn EventHandler>> = {
let poster = Arc::new(
application::movies::poster_sync_handler::PosterSyncHandler::new(
Arc::clone(&movie_command),
Arc::clone(&movie_query),
Arc::clone(&metadata),
Arc::clone(&poster_fetcher),
Arc::clone(&object_storage),
Arc::clone(&event_publisher),
3,
),
) as Arc<dyn EventHandler>;
let cleanup = Arc::new(object_storage::ImageCleanupHandler::new(Arc::clone(
&object_storage,
))) as Arc<dyn EventHandler>;
let search_cleanup = Arc::new(SearchCleanupHandler::new(
Arc::clone(&search_command),
Arc::clone(&person_query),
)) as Arc<dyn EventHandler>;
let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new(
Arc::clone(&movie_query),
Arc::clone(&search_command),
)) as Arc<dyn EventHandler>;
let wrapup_handler = Arc::new(application::wrapup::event_handler::WrapUpEventHandler::new(
worker_deps.handle_requested.wrapup_repo.clone(),
worker_deps.handle_requested.wrapup_stats.clone(),
)) as Arc<dyn EventHandler>;
let reindex_handler = Arc::new(SearchReindexHandler::new(worker_deps.reindex_search))
as Arc<dyn EventHandler>;
let account_purge = Arc::new(application::users::purge_account::AccountPurgeHandler::new(
Arc::clone(&user),
deletion_notifier,
Arc::clone(&account_purger),
Arc::clone(&object_storage),
)) as Arc<dyn EventHandler>;
let mut h = vec![
account_purge,
poster,
cleanup,
search_cleanup,
discovery_indexer,
wrapup_handler,
reindex_handler,
];
#[cfg(feature = "federation")]
h.extend(federation_handlers);
if let Some(e) = enrichment_handler {
h.push(e);
}
if let Some(e) = person_enrichment_handler {
h.push(e);
}
if let Some((ref conv_handler, _)) = conversion {
h.push(Arc::clone(conv_handler));
}
h
};
Ok(WorkerApp {
service: WorkerService::new(consumer_arc, handlers, processed_events)?,
relay,
})
}

View File

@@ -0,0 +1,404 @@
mod support;
use axum::{
body::Body,
http::{Method, Request, header},
};
use support::{PASSWORD, Session, get, json_request, send};
async fn promote(pool: &sqlx::SqlitePool, user_id: &str) {
sqlx::query("UPDATE users SET role = 'admin' WHERE id = ?")
.bind(user_id)
.execute(pool)
.await
.unwrap();
}
fn delete_json(uri: &str, token: &str, password: &str) -> Request<Body> {
json_request(
Method::DELETE,
uri,
Some(token),
serde_json::json!({ "password": password }),
)
}
fn classic_post(uri: &str, cookie: &str, password: &str) -> Request<Body> {
let csrf = cookie
.split(';')
.find_map(|c| c.trim().strip_prefix("csrf="))
.unwrap();
Request::builder()
.method(Method::POST)
.uri(uri)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, cookie)
.body(Body::from(format!("password={password}&_csrf={csrf}")))
.unwrap()
}
fn location(headers: &axum::http::HeaderMap) -> String {
headers
.get(header::LOCATION)
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default()
}
async fn two_users() -> (axum::Router, sqlx::SqlitePool, Session, Session) {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let bob = support::register(&router, "bob", "bob@example.com").await;
(router, pool, alice, bob)
}
#[tokio::test]
async fn self_delete_with_a_wrong_password_is_refused_and_keeps_the_account() {
let (router, _pool, alice, _bob) = two_users().await;
let (status, _, body) = send(
&router,
delete_json("/api/v1/users/me", &alice.token, "nope"),
)
.await;
assert_eq!(status, 401, "{body}");
assert!(body.contains("\"INVALID_CREDENTIALS\""), "{body}");
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 200);
}
#[tokio::test]
async fn self_delete_removes_the_account_and_revokes_the_token() {
let (router, _pool, alice, _bob) = two_users().await;
let (status, _, body) = send(
&router,
delete_json("/api/v1/users/me", &alice.token, PASSWORD),
)
.await;
assert_eq!(status, 204, "{body}");
let req = Request::builder()
.uri("/api/v1/profile")
.header(header::AUTHORIZATION, format!("Bearer {}", alice.token))
.body(Body::empty())
.unwrap();
let (status, _, _) = send(&router, req).await;
assert_eq!(status, 401, "token of a deleted account");
for uri in [
format!("/users/{}", alice.user_id),
format!("/users/{}?embed=true", alice.user_id),
] {
let (status, _, _) = send(&router, get(&uri, None)).await;
assert_eq!(status, 404, "{uri}");
}
}
#[tokio::test]
async fn a_standard_user_cannot_delete_someone_else() {
let (router, _pool, alice, bob) = two_users().await;
let uri = format!("/api/v1/users/{}", bob.user_id);
let (status, _, body) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await;
assert_eq!(status, 403, "{body}");
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
assert_eq!(status, 200);
}
#[tokio::test]
async fn an_admin_deletes_another_account() {
let (router, pool, alice, bob) = two_users().await;
promote(&pool, &alice.user_id).await;
let uri = format!("/api/v1/users/{}", bob.user_id);
let (status, _, body) = send(&router, delete_json(&uri, &alice.token, "nope")).await;
assert_eq!(status, 401, "wrong admin password");
assert!(body.contains("\"INVALID_CREDENTIALS\""), "{body}");
let (status, _, body) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await;
assert_eq!(status, 204, "{body}");
for uri in [
format!("/users/{}", bob.user_id),
format!("/users/{}?embed=true", bob.user_id),
] {
let (status, _, _) = send(&router, get(&uri, None)).await;
assert_eq!(status, 404, "{uri}");
}
let (status, _, _) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await;
assert_eq!(status, 404, "already deleted");
#[cfg(feature = "federation")]
{
let req = Request::builder()
.uri(format!("/users/{}", bob.user_id))
.header(header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap();
let (status, _, _) = send(&router, req).await;
assert_eq!(status, 410, "actor endpoint of a deleted account");
}
}
#[tokio::test]
async fn the_last_admin_cannot_be_deleted() {
let (router, pool, alice, _bob) = two_users().await;
promote(&pool, &alice.user_id).await;
let (status, _, body) = send(
&router,
delete_json("/api/v1/users/me", &alice.token, PASSWORD),
)
.await;
assert_eq!(status, 409, "{body}");
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 200);
}
#[tokio::test]
async fn classic_self_delete_logs_out_and_redirects_home() {
let (router, _pool, alice, _bob) = two_users().await;
let (status, headers, _) = send(
&router,
classic_post("/settings/account/delete", &alice.cookie, "nope"),
)
.await;
assert_eq!(status, 303);
assert!(
location(&headers).starts_with("/settings/profile?error="),
"{headers:?}"
);
assert!(
support::cookie_value(&headers, "token").is_none(),
"a wrong password must not log out"
);
let (status, headers, _) = send(
&router,
classic_post("/settings/account/delete", &alice.cookie, PASSWORD),
)
.await;
assert_eq!(status, 303);
assert_eq!(location(&headers), "/");
assert_eq!(
support::cookie_value(&headers, "token").as_deref(),
Some("")
);
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 404);
}
#[tokio::test]
async fn classic_self_delete_requires_the_csrf_token() {
let (router, _pool, alice, _bob) = two_users().await;
let req = Request::builder()
.method(Method::POST)
.uri("/settings/account/delete")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, &alice.cookie)
.body(Body::from(format!("password={PASSWORD}&_csrf=wrong")))
.unwrap();
let (status, _, _) = send(&router, req).await;
assert_eq!(status, 403);
}
#[tokio::test]
async fn classic_admin_delete_redirects_to_the_member_list() {
let (router, pool, alice, bob) = two_users().await;
let uri = format!("/users/{}/delete", bob.user_id);
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 403, "non-admin: {headers:?}");
promote(&pool, &alice.user_id).await;
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 303);
assert_eq!(location(&headers), "/users");
assert!(support::cookie_value(&headers, "token").is_none());
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
assert_eq!(status, 404);
}
#[tokio::test]
async fn classic_admin_delete_requires_the_csrf_token() {
let (router, pool, alice, bob) = two_users().await;
promote(&pool, &alice.user_id).await;
let req = Request::builder()
.method(Method::POST)
.uri(format!("/users/{}/delete", bob.user_id))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, &alice.cookie)
.body(Body::from(format!("password={PASSWORD}&_csrf=wrong")))
.unwrap();
let (status, _, _) = send(&router, req).await;
assert_eq!(status, 403);
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
assert_eq!(status, 200, "target must survive a CSRF rejection");
}
#[tokio::test]
async fn a_non_admin_classic_delete_of_someone_else_is_forbidden() {
let (router, _pool, alice, bob) = two_users().await;
let uri = format!("/users/{}/delete", bob.user_id);
let (status, _, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 403);
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
assert_eq!(status, 200);
}
#[tokio::test]
async fn classic_admin_self_delete_logs_out_unless_last_admin() {
let (router, pool, alice, bob) = two_users().await;
promote(&pool, &alice.user_id).await;
let uri = format!("/users/{}/delete", alice.user_id);
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 303);
assert!(location(&headers).contains("error="), "{headers:?}");
assert!(support::cookie_value(&headers, "token").is_none());
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 200);
promote(&pool, &bob.user_id).await;
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 303);
assert_eq!(location(&headers), "/");
assert_eq!(
support::cookie_value(&headers, "token").as_deref(),
Some("")
);
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 404);
}
#[tokio::test]
async fn delete_forms_render_only_where_they_apply() {
let (router, pool, alice, bob) = two_users().await;
let own_delete = "action=\"/settings/account/delete\"";
let user_delete = |id: &str| format!("action=\"/users/{id}/delete\"");
let (_, _, body) = send(&router, get("/settings/profile", Some(&alice.cookie))).await;
assert!(body.contains(own_delete), "owner settings page");
let bob_page = format!("/users/{}", bob.user_id);
let (_, _, body) = send(&router, get(&bob_page, None)).await;
assert!(!body.contains(&user_delete(&bob.user_id)), "anonymous");
let (_, _, body) = send(&router, get(&bob_page, Some(&alice.cookie))).await;
assert!(!body.contains(&user_delete(&bob.user_id)), "non-admin");
promote(&pool, &alice.user_id).await;
let (_, _, body) = send(&router, get(&bob_page, Some(&alice.cookie))).await;
assert!(body.contains(&user_delete(&bob.user_id)), "admin on other");
let (_, _, body) = send(
&router,
get(&format!("/users/{}", alice.user_id), Some(&alice.cookie)),
)
.await;
assert!(
!body.contains(&user_delete(&alice.user_id)),
"admin on self"
);
let (_, _, body) = send(
&router,
get(&format!("{bob_page}?embed=true"), Some(&alice.cookie)),
)
.await;
assert!(!body.contains("/delete"), "embed page");
}
const SERVICE_ACTOR: &str = "00000000-0000-4000-8000-000000000000";
#[tokio::test]
async fn the_service_actor_is_not_deletable_and_offers_no_delete_ui() {
let (router, pool, alice, bob) = two_users().await;
promote(&pool, &alice.user_id).await;
promote(&pool, &bob.user_id).await;
let page = format!("/users/{SERVICE_ACTOR}");
let (status, _, body) = send(&router, get(&page, Some(&alice.cookie))).await;
assert_eq!(status, 200, "{body}");
assert!(
!body.contains(&format!("action=\"/users/{SERVICE_ACTOR}/delete\"")),
"admin sees a delete form for the service actor"
);
let uri = format!("/api/v1/users/{SERVICE_ACTOR}");
let (status, _, body) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await;
assert_eq!(status, 403, "{body}");
let req = Request::builder()
.uri(&uri)
.header(header::AUTHORIZATION, format!("Bearer {}", alice.token))
.body(Body::empty())
.unwrap();
let (status, _, body) = send(&router, req).await;
assert_eq!(status, 200, "{body}");
assert!(body.contains("\"is_service_account\":true"), "{body}");
let req = Request::builder()
.uri(format!("/api/v1/users/{}", bob.user_id))
.header(header::AUTHORIZATION, format!("Bearer {}", alice.token))
.body(Body::empty())
.unwrap();
let (_, _, body) = send(&router, req).await;
assert!(!body.contains("is_service_account"), "{body}");
}
#[tokio::test]
async fn a_deleted_users_webhook_token_stops_ingesting_at_once() {
let (router, pool, alice, _bob) = two_users().await;
let (status, _, body) = send(
&router,
json_request(
Method::POST,
"/api/v1/settings/webhook-tokens",
Some(&alice.token),
serde_json::json!({ "provider": "jellyfin", "label": null }),
),
)
.await;
assert_eq!(status, 200, "{body}");
let token = serde_json::from_str::<serde_json::Value>(&body).unwrap()["token"]
.as_str()
.unwrap()
.to_string();
let hook = |token: &str| {
Request::builder()
.method(Method::POST)
.uri("/api/v1/webhooks/jellyfin")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::json!({
"NotificationType": "PlaybackStop", "ItemType": "Movie",
"Name": "Heat", "Year": 1995, "PlayedToCompletion": true,
"Provider_tmdb": "949"
})
.to_string(),
))
.unwrap()
};
let events = || async {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM watch_events")
.fetch_one(&pool)
.await
.unwrap()
};
let (status, _, body) = send(&router, hook(&token)).await;
assert_eq!(status, 200, "live token: {body}");
assert_eq!(events().await, 1);
let (status, _, _) = send(
&router,
delete_json("/api/v1/users/me", &alice.token, PASSWORD),
)
.await;
assert_eq!(status, 204);
let (status, _, body) = send(&router, hook(&token)).await;
assert_eq!(status, 401, "{body}");
assert_eq!(events().await, 1, "no event ingested after deletion");
}

View File

@@ -0,0 +1,123 @@
mod support;
use std::time::Duration;
use domain::{events::DomainEvent, ports::UserRepository, value_objects::UserId};
async fn wait_for(pool: &sqlx::SqlitePool, sql: &str) -> bool {
for _ in 0..100 {
let n: i64 = sqlx::query_scalar(sql).fetch_one(pool).await.unwrap();
if n > 0 {
return true;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
false
}
#[tokio::test]
async fn the_worker_purges_a_deleted_account_and_frees_its_email() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let id = UserId::from_uuid(uuid::Uuid::parse_str(&alice.user_id).unwrap());
let users = sqlite::SqliteUserRepository::new(pool.clone());
users
.mark_deleted(
&id,
&[DomainEvent::UserDeleted {
user_id: id.clone(),
}],
)
.await
.unwrap();
let worker = bootstrap::build_worker_on(&support::test_config(), pool.clone())
.await
.unwrap();
let (stop, stopped) = tokio::sync::watch::channel(false);
let running = tokio::spawn(worker.run(stopped));
let purged = wait_for(
&pool,
"SELECT COUNT(*) FROM users WHERE username = 'alice' AND purged_at IS NOT NULL",
)
.await;
stop.send(true).unwrap();
running.await.unwrap().unwrap();
assert!(purged, "the worker should purge the tombstoned account");
let email: String = sqlx::query_scalar("SELECT email FROM users WHERE username = 'alice'")
.fetch_one(&pool)
.await
.unwrap();
assert_ne!(email, "alice@example.com");
let again = support::register(&router, "alice2", "alice@example.com").await;
assert!(!again.user_id.is_empty(), "the email can register again");
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn followers_are_told_before_the_purge_and_the_keypair_survives_it() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let id = UserId::from_uuid(uuid::Uuid::parse_str(&alice.user_id).unwrap());
for sql in [
"INSERT INTO ap_remote_actors (url, handle, inbox_url, fetched_at)
VALUES ('http://127.0.0.1:1/users/ann', 'ann@127.0.0.1', 'http://127.0.0.1:1/inbox', 'now')",
] {
sqlx::query(sql).execute(&pool).await.unwrap();
}
sqlx::query(
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at)
VALUES (?, 'http://127.0.0.1:1/users/ann', 'accepted', 'now')",
)
.bind(&alice.user_id)
.execute(&pool)
.await
.unwrap();
let users = sqlite::SqliteUserRepository::new(pool.clone());
users
.mark_deleted(
&id,
&[DomainEvent::UserDeleted {
user_id: id.clone(),
}],
)
.await
.unwrap();
let worker = bootstrap::build_worker_on(&support::test_config(), pool.clone())
.await
.unwrap();
let (stop, stopped) = tokio::sync::watch::channel(false);
let running = tokio::spawn(worker.run(stopped));
let purged = wait_for(
&pool,
"SELECT COUNT(*) FROM users WHERE username = 'alice' AND purged_at IS NOT NULL",
)
.await;
let enqueued = wait_for(
&pool,
"SELECT COUNT(*) FROM event_queue WHERE event_type = 'FederationDeliveryRequested'",
)
.await;
stop.send(true).unwrap();
running.await.unwrap().unwrap();
assert!(purged);
assert!(enqueued, "the Delete should be enqueued for the follower");
let deliveries: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM event_queue WHERE event_type = 'FederationDeliveryRequested'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(deliveries, 1, "the Delete was enqueued for the follower");
let keys: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_local_actors WHERE user_id = ?")
.bind(&alice.user_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(keys, 1, "the keypair stays until the tombstone cleanup");
}

View File

@@ -0,0 +1,386 @@
mod support;
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
};
use sqlx::SqlitePool;
use tower::ServiceExt;
use uuid::Uuid;
async fn test_app() -> Router {
support::server().await.0
}
async fn test_app_with_user() -> (Router, SqlitePool, Uuid, String) {
let (app, pool) = support::server().await;
let session = support::register(&app, "insights", "insights@example.com").await;
let user_id = session.user_id.parse().unwrap();
(app, pool, user_id, session.token)
}
/// Inject a fake peer IP so the GovernorLayer can extract ConnectInfo.
fn with_ip(req: Request<Body>) -> Request<Body> {
let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap();
let mut req = req;
req.extensions_mut()
.insert(axum::extract::ConnectInfo::<std::net::SocketAddr>(addr));
req
}
#[tokio::test]
async fn get_api_diary_returns_empty_list() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/api/v1/diary")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["total_count"], 0);
assert_eq!(json["items"], serde_json::json!([]));
assert_eq!(json["limit"], 5);
assert_eq!(json["offset"], 0);
}
#[tokio::test]
async fn post_api_reviews_without_auth_returns_401() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.method("POST")
.uri("/api/v1/reviews")
.header("content-type", "application/json")
.body(Body::from(
r#"{"rating":4,"watched_at":"2026-01-01T20:00:00","manual_title":"Dune","manual_release_year":2021}"#,
))
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn post_api_auth_login_unknown_user_returns_401() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.method("POST")
.uri("/api/v1/auth/login")
.header("content-type", "application/json")
.body(Body::from(r#"{"email":"a@b.com","password":"x"}"#))
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn get_api_movie_detail_returns_404_for_unknown_id() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/api/v1/movies/00000000-0000-0000-0000-000000000000")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn tags_moviesdiary_redirects_to_home() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/tags/moviesdiary")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
assert_eq!(response.headers().get("location").unwrap(), "/");
}
#[tokio::test]
async fn tags_other_redirects_to_search() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/tags/batman")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
assert_eq!(
response.headers().get("location").unwrap(),
"/?search=batman"
);
}
#[tokio::test]
async fn get_movie_detail_html_returns_404_for_unknown_id() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/movies/00000000-0000-0000-0000-000000000000")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
// ── Insights ─────────────────────────────────────────────────────────────────
async fn seed_insights_diary(pool: &SqlitePool, user_id: Uuid) {
// 12 thrillers rated 5 late at night, 12 comedies rated 2 — enough for a
// rating lift once genres come from movie_genres.
for i in 0..12u32 {
let movie_id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(format!("Thriller {i}"))
.bind(2020)
.execute(pool)
.await
.unwrap();
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(44400 + i as i64)
.bind("Thriller")
.execute(pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&movie_id)
.bind(user_id.to_string())
.bind(5)
.bind("2024-01-05 22:30:00")
.bind("2024-01-05 22:30:00")
.execute(pool)
.await
.unwrap();
let movie_id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(format!("Comedy {i}"))
.bind(2015)
.execute(pool)
.await
.unwrap();
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(55300 + i as i64)
.bind("Comedy")
.execute(pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&movie_id)
.bind(user_id.to_string())
.bind(2)
.bind("2024-03-10 13:00:00")
.bind("2024-03-10 13:00:00")
.execute(pool)
.await
.unwrap();
}
}
#[tokio::test]
async fn get_api_insights_returns_rating_lift_for_own_user() {
let (app, pool, user_id, token) = test_app_with_user().await;
seed_insights_diary(&pool, user_id).await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/api/v1/insights")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
let (status, bytes) = (
response.status(),
axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap(),
);
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8(bytes.to_vec()).unwrap()
);
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let rating_lifts: Vec<&serde_json::Value> = json["insights"]
.as_array()
.unwrap()
.iter()
.filter(|i| i["kind"] == "rating_lift")
.collect();
assert_eq!(rating_lifts.len(), 2, "thriller up, comedy down: {json}");
let thriller = rating_lifts
.iter()
.find(|i| i["genre"] == "Thriller")
.unwrap();
assert_eq!(thriller["delta"], 1.5);
assert_eq!(thriller["sample_count"], 12);
// Timestamps are real → rhythm fires. Hours split evenly between 22
// (thrillers) and 13 (comedies); every window covering either bucket ties,
// so the earliest one, 11:00–14:00, wins. Days tie too (Fri/Sun), sorted
// ascending.
let rhythm = json["insights"]
.as_array()
.unwrap()
.iter()
.find(|i| i["kind"] == "rhythm")
.expect("rhythm expected for real timestamps");
assert_eq!(rhythm["peak_hour_start"], 11);
assert_eq!(rhythm["peak_hour_end"], 14);
assert_eq!(rhythm["top_days"], serde_json::json!([5, 7]));
}
async fn get_insights_json(
app: Router,
token: &str,
query: &str,
) -> (StatusCode, serde_json::Value) {
let response = app
.oneshot(with_ip(
Request::builder()
.uri(format!("/api/v1/insights{query}"))
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
(status, json)
}
#[tokio::test]
async fn get_api_insights_ignores_remote_reviews() {
let (app, pool, user_id, token) = test_app_with_user().await;
seed_insights_diary(&pool, user_id).await;
// Federated rows stored under the same user_id are someone else's watches.
for i in 0..12u32 {
let movie_id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(format!("Horror {i}"))
.bind(2019)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(66600 + i as i64)
.bind("Horror")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at, remote_actor_url) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&movie_id)
.bind(user_id.to_string())
.bind(5)
.bind("2024-05-05 09:00:00")
.bind("2024-05-05 09:00:00")
.bind("https://remote.example/users/someone")
.execute(&pool)
.await
.unwrap();
}
let (status, json) = get_insights_json(app, &token, "").await;
assert_eq!(status, StatusCode::OK);
let insights = json["insights"].as_array().unwrap();
assert!(
!insights.iter().any(|i| i["genre"] == "Horror"),
"remote reviews must not feed insights: {json}"
);
let thriller = insights
.iter()
.find(|i| i["kind"] == "rating_lift" && i["genre"] == "Thriller")
.unwrap();
assert_eq!(thriller["delta"], 1.5);
}
#[tokio::test]
async fn get_api_insights_buckets_rhythm_in_requested_time_zone() {
let (app, pool, user_id, token) = test_app_with_user().await;
seed_insights_diary(&pool, user_id).await;
// January in Warsaw is UTC+1: 22:30 → 23:30 and 13:00 → 14:00, so the
// earliest tied window moves from 11–14 to 12–15.
let (status, json) = get_insights_json(app, &token, "?tz=Europe/Warsaw").await;
assert_eq!(status, StatusCode::OK);
let rhythm = json["insights"]
.as_array()
.unwrap()
.iter()
.find(|i| i["kind"] == "rhythm")
.expect("rhythm expected");
assert_eq!(rhythm["peak_hour_start"], 12);
assert_eq!(rhythm["peak_hour_end"], 15);
}
#[tokio::test]
async fn get_api_insights_rejects_unknown_time_zone() {
let (app, _pool, _user_id, token) = test_app_with_user().await;
let (status, _) = get_insights_json(app, &token, "?tz=Mars/Olympus").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}

View File

@@ -0,0 +1,307 @@
mod support;
#[tokio::test]
async fn server_builds_on_a_fresh_database() {
let pool = support::memory_pool().await;
let router = bootstrap::build_server_on(&support::test_config(), pool)
.await
.expect("server builds");
let (status, _, _) = support::send(&router, support::get("/health", None)).await;
assert_eq!(status, 200);
}
#[tokio::test]
async fn worker_builds_on_a_fresh_database() {
let pool = support::memory_pool().await;
bootstrap::build_worker_on(&support::test_config(), pool)
.await
.expect("worker builds");
}
#[cfg(not(feature = "federation"))]
#[tokio::test]
async fn federation_off_build_wires_local_social() {
use axum::http::Method;
let (router, _pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let bob = support::register(&router, "bob", "bob@example.com").await;
let (status, _, body) = support::send(
&router,
support::json_request(
Method::POST,
"/api/v1/social/follow",
Some(&alice.token),
serde_json::json!({ "handle": "bob@localhost:3000" }),
),
)
.await;
assert_eq!(status, 200, "follow: {body}");
let (status, _, body) = support::send(
&router,
axum::http::Request::builder()
.uri("/api/v1/social/followers/pending")
.header("authorization", format!("Bearer {}", bob.token))
.body(axum::body::Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, 200, "pending followers: {body}");
let followers: serde_json::Value = serde_json::from_str(&body).unwrap();
let actors = followers["actors"].as_array().unwrap();
assert_eq!(
actors.len(),
1,
"bob should have one pending follower: {body}"
);
assert!(
body.contains(&alice.user_id) || body.contains("alice"),
"follower should be alice: {body}"
);
}
#[tokio::test]
async fn server_publishes_to_the_outbox_even_when_bus_is_nats() {
let pool = support::memory_pool().await;
let mut config = support::test_config();
config.events.bus_backend = "nats".into();
config.nats.url = Some("nats://127.0.0.1:1".into());
let router = bootstrap::build_server_on(&config, pool.clone())
.await
.expect("server builds without a reachable NATS");
let session = support::register(&router, "alice", "alice@example.com").await;
let (status, _, body) = support::send(
&router,
support::json_request(
axum::http::Method::POST,
"/api/v1/reviews",
Some(&session.token),
serde_json::json!({
"manual_title": "Outbox",
"manual_release_year": 2001,
"rating": 4,
"comment": "queued",
"watched_at": "2024-01-01"
}),
),
)
.await;
assert_eq!(status, 201, "log review: {body}");
let queued: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM event_queue WHERE event_type = 'ReviewLogged' AND event_id IS NOT NULL",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(queued, 1);
}
#[tokio::test]
async fn handler_names_are_unique() {
let pool = support::memory_pool().await;
let worker = bootstrap::build_worker_on(&support::test_config(), pool)
.await
.expect("worker builds");
let names = worker.handler_names();
let unique: std::collections::HashSet<_> = names.iter().collect();
assert_eq!(
unique.len(),
names.len(),
"duplicate handler name in {names:?}"
);
let mut sorted = names.clone();
sorted.sort_unstable();
let mut expected = vec![
"account_purge",
"image_cleanup",
"movie_discovery_indexer",
"movie_enrichment",
"person_enrichment",
"poster_sync",
"search_cleanup",
"search_reindex",
"wrapup",
];
if cfg!(feature = "federation") {
expected.extend(["activitypub", "follow_backfill"]);
}
expected.sort_unstable();
assert_eq!(sorted, expected);
}
async fn queued(pool: &sqlx::SqlitePool, event_type: &str) -> i64 {
sqlx::query_scalar(
"SELECT COUNT(*) FROM event_queue WHERE event_type = ? AND event_id IS NOT NULL",
)
.bind(event_type)
.fetch_one(pool)
.await
.unwrap()
}
#[tokio::test]
async fn a_local_follow_lands_with_its_event_in_the_outbox() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let _bob = support::register(&router, "bob", "bob@example.com").await;
let (status, _, body) = support::send(
&router,
support::json_request(
axum::http::Method::POST,
"/api/v1/social/follow",
Some(&alice.token),
serde_json::json!({ "handle": "bob@localhost:3000" }),
),
)
.await;
assert_eq!(status, 200, "follow: {body}");
let following: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_following")
.fetch_one(&pool)
.await
.unwrap();
let followers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_followers")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!((following, followers), (1, 1));
assert_eq!(queued(&pool, "FollowRequested").await, 1);
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn a_remote_block_publishes_its_event_after_the_k_ap_write() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let (status, _, body) = support::send(
&router,
support::json_request(
axum::http::Method::POST,
"/api/v1/social/block",
Some(&alice.token),
serde_json::json!({ "actor_url": "https://remote.example/users/ann" }),
),
)
.await;
assert_eq!(status, 204, "block: {body}");
let blocked: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocked_actors")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(blocked, 1);
assert_eq!(queued(&pool, "ActorBlocked").await, 1);
}
#[tokio::test]
async fn a_deleted_users_token_is_rejected() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let bearer = |token: &str| {
axum::http::Request::builder()
.uri("/api/v1/diary/export?format=json")
.header("authorization", format!("Bearer {token}"))
.body(axum::body::Body::empty())
.unwrap()
};
let (status, _, body) = support::send(&router, bearer(&alice.token)).await;
assert_eq!(status, 200, "token works while the account is live: {body}");
let (status, _, _) =
support::send(&router, support::get("/diary/export", Some(&alice.cookie))).await;
assert_eq!(status, 200, "cookie works while the account is live");
sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE id = ?")
.bind(&alice.user_id)
.execute(&pool)
.await
.unwrap();
let (status, _, body) = support::send(&router, bearer(&alice.token)).await;
assert_eq!(status, 401, "json route: {body}");
let (anon_status, anon_headers, _) =
support::send(&router, support::get("/diary/export", None)).await;
let (status, headers, _) =
support::send(&router, support::get("/diary/export", Some(&alice.cookie))).await;
assert_eq!(status, anon_status, "classic owner page");
assert_eq!(headers.get("location"), anon_headers.get("location"));
assert_eq!(headers.get("location").unwrap(), "/login");
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn actor_document_for_a_tombstone_is_gone() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let ap_get = |path: String| {
axum::http::Request::builder()
.uri(path)
.header("accept", "application/activity+json")
.body(axum::body::Body::empty())
.unwrap()
};
let paths = |id: &str| {
[
format!("/users/{id}"),
format!("/users/{id}/followers"),
format!("/users/{id}/following"),
format!("/users/{id}/outbox"),
format!("/users/{id}/featured"),
]
};
let (status, _, body) =
support::send(&router, ap_get(format!("/users/{}", alice.user_id))).await;
assert_eq!(status, 200, "live actor: {body}");
sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE id = ?")
.bind(&alice.user_id)
.execute(&pool)
.await
.unwrap();
for path in paths(&alice.user_id) {
let (status, _, body) = support::send(&router, ap_get(path.clone())).await;
assert_eq!(status, 410, "{path}: {body}");
}
let (status, _, _) = support::send(
&router,
support::get(&format!("/users/{}", alice.user_id), None),
)
.await;
assert_eq!(status, 404, "the classic profile page stays 404");
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn posting_to_a_tombstones_inbox_is_gone() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let post = |path: String| {
axum::http::Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/activity+json")
.body(axum::body::Body::from("{}"))
.unwrap()
};
let path = format!("/users/{}/inbox", alice.user_id);
let (live, _, _) = support::send(&router, post(path.clone())).await;
assert_ne!(live, 410, "a live user's inbox is not gone");
sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE id = ?")
.bind(&alice.user_id)
.execute(&pool)
.await
.unwrap();
let (status, _, body) = support::send(&router, post(path)).await;
assert_eq!(status, 410, "{body}");
}

View File

@@ -0,0 +1,101 @@
mod support;
use axum::{
body::Body,
http::{Method, Request, header},
};
async fn server_with(config: config::AppConfig) -> axum::Router {
bootstrap::build_server_on(&config, support::memory_pool().await)
.await
.expect("server builds")
}
fn set_cookies(headers: &axum::http::HeaderMap) -> Vec<String> {
headers
.get_all(header::SET_COOKIE)
.iter()
.map(|v| v.to_str().unwrap().to_string())
.collect()
}
#[tokio::test]
async fn registration_is_refused_when_allow_registration_is_false() {
let mut config = support::test_config();
config.server.allow_registration = false;
let router = server_with(config).await;
let form = Request::builder()
.method(Method::POST)
.uri("/register")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(format!(
"email=a%40example.com&username=alice&password={}&_csrf=x",
support::PASSWORD
)))
.unwrap();
let (status, headers, _) = support::send(&router, form).await;
assert_eq!(status, 303, "classic form redirects away");
assert_eq!(headers.get(header::LOCATION).unwrap(), "/");
assert!(
support::cookie_value(&headers, "token").is_none(),
"no session issued"
);
let (status, _, _) = support::send(
&router,
support::json_request(
Method::POST,
"/api/v1/auth/register",
None,
serde_json::json!({
"email": "a@example.com",
"username": "alice",
"password": support::PASSWORD,
}),
),
)
.await;
assert_eq!(status, 401, "JSON register is refused");
}
#[tokio::test]
async fn secure_cookies_setting_marks_cookies_secure() {
for secure in [true, false] {
let mut config = support::test_config();
config.server.secure_cookies = secure;
let router = server_with(config).await;
let (status, headers, _) = support::send(&router, support::get("/register", None)).await;
assert_eq!(status, 200);
let cookies = set_cookies(&headers);
assert!(!cookies.is_empty(), "csrf cookie is set");
assert!(
cookies.iter().all(|c| c.contains("Secure") == secure),
"secure_cookies={secure}: {cookies:?}"
);
let csrf = support::cookie_value(&headers, "csrf").expect("csrf cookie");
let form = Request::builder()
.method(Method::POST)
.uri("/register")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, format!("csrf={csrf}"))
.body(Body::from(format!(
"email=a%40example.com&username=alice&password={}&_csrf={csrf}",
support::PASSWORD
)))
.unwrap();
let (status, headers, _) = support::send(&router, form).await;
assert_eq!(status, 303);
let token_cookie = set_cookies(&headers)
.into_iter()
.find(|c| c.starts_with("token="))
.expect("session cookie");
assert_eq!(
token_cookie.contains("Secure"),
secure,
"session cookie, secure_cookies={secure}: {token_cookie}"
);
}
}

View File

@@ -0,0 +1,71 @@
DELETE /api/v1/admin/blocked-domains/{domain}
DELETE /api/v1/goals/{year}
DELETE /api/v1/import/profiles/{id}
DELETE /api/v1/reviews/{id}
DELETE /api/v1/settings/webhook-tokens/{id}
DELETE /api/v1/users/me
DELETE /api/v1/users/{id}
DELETE /api/v1/watchlist/{movie_id}
DELETE /api/v1/wrapups/{id}
GET /api/v1/activity-feed
GET /api/v1/admin/blocked-domains
GET /api/v1/diary
GET /api/v1/diary/export
GET /api/v1/goals
GET /api/v1/import/profiles
GET /api/v1/import/sessions/{id}
GET /api/v1/insights
GET /api/v1/movies
GET /api/v1/movies/{id}/history
GET /api/v1/movies/{id}/profile
GET /api/v1/movies/{movie_id}
GET /api/v1/people/{id}
GET /api/v1/people/{id}/credits
GET /api/v1/profile
GET /api/v1/search
GET /api/v1/settings
GET /api/v1/settings/webhook-tokens
GET /api/v1/social/blocked
GET /api/v1/social/followers
GET /api/v1/social/followers/pending
GET /api/v1/social/followers/pending/count
GET /api/v1/social/following
GET /api/v1/social/following/pending
GET /api/v1/social/relationship
GET /api/v1/users
GET /api/v1/users/{id}
GET /api/v1/users/{id}/goals
GET /api/v1/watch-queue
GET /api/v1/watchlist
GET /api/v1/watchlist/{movie_id}
GET /api/v1/wrapups
GET /api/v1/wrapups/{id}
GET /api/v1/wrapups/{id}/report
POST /api/v1/admin/blocked-domains
POST /api/v1/auth/login
POST /api/v1/auth/register
POST /api/v1/goals
POST /api/v1/import/profiles
POST /api/v1/import/sessions
POST /api/v1/import/sessions/{id}/confirm
POST /api/v1/movies/{id}/sync-poster
POST /api/v1/reviews
POST /api/v1/settings/webhook-tokens
POST /api/v1/social/block
POST /api/v1/social/follow
POST /api/v1/social/followers/accept
POST /api/v1/social/followers/reject
POST /api/v1/social/followers/remove
POST /api/v1/social/unblock
POST /api/v1/social/unfollow
POST /api/v1/watch-queue/confirm
POST /api/v1/watch-queue/dismiss
POST /api/v1/watchlist
POST /api/v1/webhooks/jellyfin
POST /api/v1/webhooks/plex
POST /api/v1/wrapups/generate
PUT /api/v1/goals/{year}
PUT /api/v1/import/sessions/{id}/mapping
PUT /api/v1/profile
PUT /api/v1/profile/fields
PUT /api/v1/settings

View File

@@ -0,0 +1,54 @@
DELETE /api/v1/goals/{year}
DELETE /api/v1/import/profiles/{id}
DELETE /api/v1/reviews/{id}
DELETE /api/v1/settings/webhook-tokens/{id}
DELETE /api/v1/users/me
DELETE /api/v1/users/{id}
DELETE /api/v1/watchlist/{movie_id}
DELETE /api/v1/wrapups/{id}
GET /api/v1/activity-feed
GET /api/v1/diary
GET /api/v1/diary/export
GET /api/v1/goals
GET /api/v1/import/profiles
GET /api/v1/import/sessions/{id}
GET /api/v1/insights
GET /api/v1/movies
GET /api/v1/movies/{id}/history
GET /api/v1/movies/{id}/profile
GET /api/v1/movies/{movie_id}
GET /api/v1/people/{id}
GET /api/v1/people/{id}/credits
GET /api/v1/profile
GET /api/v1/search
GET /api/v1/settings
GET /api/v1/settings/webhook-tokens
GET /api/v1/users
GET /api/v1/users/{id}
GET /api/v1/users/{id}/goals
GET /api/v1/watch-queue
GET /api/v1/watchlist
GET /api/v1/watchlist/{movie_id}
GET /api/v1/wrapups
GET /api/v1/wrapups/{id}
GET /api/v1/wrapups/{id}/report
POST /api/v1/auth/login
POST /api/v1/auth/register
POST /api/v1/goals
POST /api/v1/import/profiles
POST /api/v1/import/sessions
POST /api/v1/import/sessions/{id}/confirm
POST /api/v1/movies/{id}/sync-poster
POST /api/v1/reviews
POST /api/v1/settings/webhook-tokens
POST /api/v1/watch-queue/confirm
POST /api/v1/watch-queue/dismiss
POST /api/v1/watchlist
POST /api/v1/webhooks/jellyfin
POST /api/v1/webhooks/plex
POST /api/v1/wrapups/generate
PUT /api/v1/goals/{year}
PUT /api/v1/import/sessions/{id}/mapping
PUT /api/v1/profile
PUT /api/v1/profile/fields
PUT /api/v1/settings

View File

@@ -0,0 +1,9 @@
#[path = "http_handlers/support.rs"]
mod support;
#[path = "http_handlers/api_handlers_test.rs"]
mod api_handlers_test;
#[path = "http_handlers/app_context_test.rs"]
mod app_context_test;
#[path = "http_handlers/extractors_test.rs"]
mod extractors_test;

View File

@@ -0,0 +1,553 @@
use crate::support::{AcceptingAuth, test_app_state, test_app_state_with_repos};
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
routing::get,
};
use domain::errors::DomainError;
use domain::ports::UserRepository;
use domain::testing::InMemoryStore;
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()))
}
async fn list_orphaned_persons(&self) -> Result<Vec<domain::models::PersonId>, DomainError> {
Ok(vec![])
}
async fn list_page(
&self,
_limit: u32,
_offset: u32,
) -> Result<Vec<domain::models::Person>, DomainError> {
Ok(vec![])
}
}
// --- Search endpoint tests ---
#[tokio::test]
async fn search_endpoint_returns_200_with_empty_results() {
let state = crate::support::test_app_state_with_search_port(Arc::new(SearchPortStub));
let app = Router::new()
.route("/api/v1/search", get(http_axum::api::search::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 state = crate::support::test_app_state_with_search_port(Arc::new(SearchPortStub));
let app = Router::new()
.route("/api/v1/search", get(http_axum::api::search::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 state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub));
let app = Router::new()
.route(
"/api/v1/people/{id}",
get(http_axum::api::search::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 state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub));
let app = Router::new()
.route(
"/api/v1/people/{id}/credits",
get(http_axum::api::search::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);
}
#[test]
fn injected_stub_reaches_prebuilt_deps() {
let stub: Arc<dyn domain::ports::PersonQuery> = Arc::new(PersonQueryStub);
let state = crate::support::test_app_state_with_person_query(Arc::clone(&stub));
assert!(
Arc::ptr_eq(&state.app_ctx.deps.person.get_person.person_query, &stub),
"prebuilt deps must hold the injected stub too, or handlers reading deps \
will silently use the real dependency"
);
}
// --- Watchlist endpoint tests ---
#[tokio::test]
async fn get_watchlist_requires_auth() {
let state = test_app_state();
let app = Router::new()
.route(
"/api/v1/watchlist",
get(http_axum::api::watchlist::get_watchlist_handler),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/watchlist")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// --- Pending-follower-count endpoint tests ---
struct FixedPendingCount(usize);
#[async_trait::async_trait]
impl domain::ports::FollowGraphQuery for FixedPendingCount {
async fn get_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn get_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn get_pending_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn get_pending_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn count_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
unreachable!()
}
async fn count_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
unreachable!()
}
async fn count_pending_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
Ok(self.0)
}
async fn get_relation(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
) -> Result<domain::value_objects::FollowRelation, DomainError> {
unreachable!()
}
}
#[tokio::test]
async fn pending_count_endpoint_reports_the_use_case_result() {
let uid = domain::value_objects::UserId::from_uuid(Uuid::new_v4());
let users = InMemoryStore::new();
users
.save(&domain::testing::test_user_with_role(
uid.clone(),
domain::models::UserRole::Standard,
))
.await
.unwrap();
let state = test_app_state_with_repos(
|repos| {
repos.follow_graph = Arc::new(FixedPendingCount(3));
repos.user = users as _;
},
Arc::new(AcceptingAuth(uid)),
);
let app = Router::new()
.route(
"/api/v1/social/followers/pending/count",
get(http_axum::api::social::get_pending_follower_count),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/social/followers/pending/count")
.header("authorization", "Bearer test-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["count"], 3);
}
#[tokio::test]
async fn get_watchlist_status_requires_auth() {
let state = test_app_state();
let app = Router::new()
.route(
"/api/v1/watchlist/{movie_id}",
get(http_axum::api::watchlist::get_watchlist_status),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/watchlist/00000000-0000-0000-0000-000000000001")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// --- Federation collection error-handling tests ---
#[cfg(feature = "federation")]
struct FailingApDocument;
#[cfg(feature = "federation")]
#[async_trait::async_trait]
impl domain::ports::ApDocumentPort for FailingApDocument {
async fn actor_json(&self, _: &str) -> Result<String, DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
async fn followers_collection_json(
&self,
_: Uuid,
_: Option<u32>,
) -> Result<String, DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
async fn following_collection_json(
&self,
_: Uuid,
_: Option<u32>,
) -> Result<String, DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
}
#[cfg(feature = "federation")]
fn state_with_failing_ap_document() -> http_axum::state::AppState {
let mut state = test_app_state();
state.app_ctx.ap_document = Arc::new(FailingApDocument);
state
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn followers_collection_returns_500_on_federation_error() {
let app = http_axum::federation::html_routes().with_state(state_with_failing_ap_document());
let resp = app
.oneshot(
Request::builder()
.uri(format!("/users/{}/followers", Uuid::nil()))
.header(axum::http::header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn following_collection_returns_500_on_federation_error() {
let app = http_axum::federation::html_routes().with_state(state_with_failing_ap_document());
let resp = app
.oneshot(
Request::builder()
.uri(format!("/users/{}/following", Uuid::nil()))
.header(axum::http::header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
async fn refusal_body(resp: axum::response::Response) -> serde_json::Value {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&bytes).expect("refusal body is JSON")
}
#[tokio::test]
async fn a_missing_resource_is_refused_with_not_found_code() {
let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub));
let app = Router::new()
.route(
"/api/v1/people/{id}",
get(http_axum::api::search::get_person_handler),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/v1/people/{}", Uuid::new_v4()))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let body = refusal_body(resp).await;
assert_eq!(body["code"], "NOT_FOUND");
assert!(body["error"].is_string());
}
#[tokio::test]
async fn a_missing_credential_is_refused_with_unauthorized_code() {
let app = Router::new()
.route(
"/api/v1/watchlist",
get(http_axum::api::watchlist::get_watchlist_handler),
)
.with_state(test_app_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/watchlist")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(refusal_body(resp).await["code"], "UNAUTHORIZED");
}
#[tokio::test]
async fn a_malformed_path_parameter_is_refused_with_invalid_input_code() {
let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub));
let app = Router::new()
.route(
"/api/v1/people/{id}",
get(http_axum::api::search::get_person_handler),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/people/not-a-uuid")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT");
}
#[tokio::test]
async fn a_malformed_json_body_is_refused_with_invalid_input_code() {
let app = Router::new()
.route(
"/api/v1/auth/login",
axum::routing::post(http_axum::api::auth::login),
)
.with_state(test_app_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/auth/login")
.header("content-type", "application/json")
.body(Body::from("{not json"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = refusal_body(resp).await;
assert_eq!(body["code"], "INVALID_INPUT");
assert!(body["error"].is_string());
}
#[tokio::test]
async fn a_json_body_of_the_wrong_shape_is_refused_as_unprocessable() {
let app = Router::new()
.route(
"/api/v1/auth/login",
axum::routing::post(http_axum::api::auth::login),
)
.with_state(test_app_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/auth/login")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT");
}
#[tokio::test]
async fn a_non_multipart_upload_is_refused_with_invalid_input_code() {
let app = Router::new()
.route(
"/api/v1/webhooks/plex",
axum::routing::post(http_axum::api::webhook::post_plex_webhook),
)
.with_state(test_app_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/webhooks/plex?token=t")
.header("content-type", "text/plain")
.body(Body::from("hello"))
.unwrap(),
)
.await
.unwrap();
assert!(resp.status().is_client_error());
assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT");
}

View File

@@ -0,0 +1,24 @@
#[test]
fn prebuilt_deps_share_port_instances_with_repos() {
use std::sync::Arc;
let repos = crate::support::test_repositories();
let user = Arc::clone(&repos.user);
let follow_graph = Arc::clone(&repos.follow_graph);
let state =
crate::support::build_test_state_from(repos, Arc::new(domain::testing::PanicAuthService));
assert!(
Arc::ptr_eq(&state.app_ctx.deps.users.get_local_profile.user, &user),
"deps.users.get_local_profile.user must be the same Arc as the Repositories it was built from"
);
assert!(
Arc::ptr_eq(&state.app_ctx.deps.social.query.follow_graph, &follow_graph),
"deps.social.query.follow_graph must be the same Arc as the Repositories it was built from"
);
assert_eq!(
state.app_ctx.deps.users.get_local_profile.instance, state.app_ctx.instance,
"prebuilt deps must carry the context's InstanceIdentity"
);
}

View File

@@ -0,0 +1,408 @@
use std::sync::Arc;
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
routing::get,
};
use domain::{
errors::DomainError, models::GeneratedToken, ports::AuthService, testing::InMemoryStore,
value_objects::UserId,
};
use http_axum::extractors::{
AdminApiUser, AdminUser, AuthenticatedUser, OptionalCookieUser, RequiredCookieUser,
};
use tower::ServiceExt;
use crate::support::{
AcceptingAuth, ErroringUserRepo, make_test_state, test_app_state, test_app_state_with_user,
};
struct RejectingAuth;
#[async_trait::async_trait]
impl AuthService for RejectingAuth {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
panic!()
}
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
Err(DomainError::Unauthorized("bad token".into()))
}
}
// --- Routers ---
async fn protected_handler(user: AuthenticatedUser) -> String {
user.0.value().to_string()
}
async fn optional_cookie_handler(user: OptionalCookieUser) -> String {
match user.0 {
Some(id) => id.value().to_string(),
None => "none".to_string(),
}
}
async fn required_cookie_handler(user: RequiredCookieUser) -> String {
user.0.value().to_string()
}
async fn admin_api_handler(user: AdminApiUser) -> String {
user.0.value().to_string()
}
async fn admin_cookie_handler(user: AdminUser) -> String {
user.0.value().to_string()
}
fn router_protected(state: http_axum::state::AppState) -> Router {
Router::new()
.route("/protected", get(protected_handler))
.with_state(state)
}
fn router_optional(state: http_axum::state::AppState) -> Router {
Router::new()
.route("/optional", get(optional_cookie_handler))
.with_state(state)
}
fn router_admin_api(state: http_axum::state::AppState) -> Router {
Router::new()
.route("/admin-api", get(admin_api_handler))
.with_state(state)
}
fn router_admin_cookie(state: http_axum::state::AppState) -> Router {
Router::new()
.route("/admin-cookie", get(admin_cookie_handler))
.with_state(state)
}
fn router_required(state: http_axum::state::AppState) -> Router {
Router::new()
.route("/required", get(required_cookie_handler))
.with_state(state)
}
// --- Tests ---
#[tokio::test]
async fn missing_auth_header_returns_401() {
let app = router_protected(test_app_state());
let resp = app
.oneshot(
Request::builder()
.uri("/protected")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn optional_cookie_user_returns_none_without_cookie() {
let app = router_optional(test_app_state());
let resp = app
.oneshot(
Request::builder()
.uri("/optional")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"none");
}
#[tokio::test]
async fn optional_cookie_user_returns_none_with_invalid_token() {
let app = router_optional(make_test_state(Arc::new(RejectingAuth)));
let resp = app
.oneshot(
Request::builder()
.uri("/optional")
.header("cookie", "token=bad.token.here")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"none");
}
#[tokio::test]
async fn required_cookie_user_redirects_without_cookie() {
let app = router_required(test_app_state());
let resp = app
.oneshot(
Request::builder()
.uri("/required")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn required_cookie_user_redirects_with_invalid_token() {
let app = router_required(make_test_state(Arc::new(RejectingAuth)));
let resp = app
.oneshot(
Request::builder()
.uri("/required")
.header("cookie", "token=bad.token.here")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get("location").unwrap(), "/login");
}
// --- AdminApiUser / AdminUser: per-path rejection pinning ---
//
// These pin the exact rejection for every path `authorize_admin::execute` can
// produce, now that both extractors go through it instead of calling
// `repos.user.find_by_id` directly. Each test name states the path; the
// asserted status is the byte-identical-to-before-this-task value recorded in
// the task-4 report's before/after table.
fn admin_id() -> UserId {
UserId::generate()
}
#[tokio::test]
async fn admin_api_user_allows_an_admin_user() {
let id = admin_id();
let user_repo = InMemoryStore::new();
user_repo.seed_user(domain::testing::test_user_with_role(
id.clone(),
domain::models::UserRole::Admin,
));
let app = router_admin_api(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-api")
.header("authorization", "Bearer anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn admin_api_user_rejects_a_non_admin_user() {
let id = admin_id();
let user_repo = InMemoryStore::new();
user_repo.seed_user(domain::testing::test_user_with_role(
id.clone(),
domain::models::UserRole::Standard,
));
let app = router_admin_api(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-api")
.header("authorization", "Bearer anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_api_user_rejects_an_unknown_user() {
let id = admin_id();
let user_repo = InMemoryStore::new(); // empty — id is not in the store
let app = router_admin_api(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-api")
.header("authorization", "Bearer anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn admin_api_user_surfaces_a_repository_error_as_500() {
let id = admin_id();
let app = router_admin_api(test_app_state_with_user(
Arc::new(ErroringUserRepo),
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-api")
.header("authorization", "Bearer anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn admin_user_allows_an_admin_user() {
let id = admin_id();
let user_repo = InMemoryStore::new();
user_repo.seed_user(domain::testing::test_user_with_role(
id.clone(),
domain::models::UserRole::Admin,
));
let app = router_admin_cookie(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-cookie")
.header("cookie", "token=anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn admin_user_rejects_a_non_admin_user() {
let id = admin_id();
let user_repo = InMemoryStore::new();
user_repo.seed_user(domain::testing::test_user_with_role(
id.clone(),
domain::models::UserRole::Standard,
));
let app = router_admin_cookie(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-cookie")
.header("cookie", "token=anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_user_rejects_an_unknown_user() {
let id = admin_id();
let user_repo = InMemoryStore::new(); // empty — id is not in the store
let app = router_admin_cookie(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-cookie")
.header("cookie", "token=anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn admin_user_surfaces_a_repository_error_as_500() {
let id = admin_id();
let app = router_admin_cookie(test_app_state_with_user(
Arc::new(ErroringUserRepo),
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-cookie")
.header("cookie", "token=anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
async fn path_id_handler(
http_axum::extractors::PathId(id): http_axum::extractors::PathId<u32>,
) -> String {
id.to_string()
}
async fn refusal_code(resp: axum::response::Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("refusal body is JSON");
v["code"].as_str().unwrap().to_string()
}
#[tokio::test]
async fn path_extractor_on_a_route_without_params_is_refused_as_internal() {
let app = Router::new().route("/no-params", get(path_id_handler));
let resp = app
.oneshot(
Request::builder()
.uri("/no-params")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(refusal_code(resp).await, "INTERNAL");
}
#[tokio::test]
async fn path_extractor_with_an_unparseable_param_is_refused_as_invalid_input() {
let app = Router::new().route("/items/{id}", get(path_id_handler));
let resp = app
.oneshot(
Request::builder()
.uri("/items/abc")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert_eq!(refusal_code(resp).await, "INVALID_INPUT");
}

View File

@@ -0,0 +1,259 @@
#![allow(dead_code)]
use application::config::AppSettings;
use bootstrap::Repositories;
use domain::{
errors::DomainError,
models::GeneratedToken,
ports::{AuthService, PersonQuery, SearchPort, UserRepository},
testing::{
PanicAuthService, PanicDiaryExporter, PanicDiaryQuery, PanicDocumentParser,
PanicEventPublisher, PanicGoalCommand, PanicGoalQuery, PanicImportProfileRepository,
PanicImportSessionRepository, PanicInsightsQuery, PanicMetadataClient, PanicMovieCommand,
PanicMovieProfileRepository, PanicMovieQuery, PanicObjectStorage, PanicPasswordHasher,
PanicPersonCommand, PanicPersonQuery, PanicPosterFetcher, PanicProfileFieldsRepo,
PanicRefreshSessionRepository, PanicRemoteGoalRepository, PanicRemoteWatchlistRepository,
PanicReviewRepository, PanicRssFeedRenderer, PanicSearchCommand, PanicSearchPort,
PanicStatsRepository, PanicUserRepository, PanicUserSettingsRepository,
PanicWatchEventCommand, PanicWatchEventQuery, PanicWatchlistRepository,
PanicWebhookTokenRepository, PanicWrapUpRepository, PanicWrapUpStatsQuery,
},
value_objects::UserId,
};
use http_axum::context::AppContext;
use std::sync::Arc;
struct PanicReviewLogger;
#[async_trait::async_trait]
impl application::ports::ReviewLogger for PanicReviewLogger {
async fn log_review(
&self,
_: application::diary::commands::LogReviewCommand,
) -> Result<(), DomainError> {
panic!()
}
}
// --- Repositories factory — `Panic*` stubs everywhere ---
pub fn test_repositories() -> Repositories {
Repositories {
movie_command: Arc::new(PanicMovieCommand) as _,
movie_query: Arc::new(PanicMovieQuery) as _,
review: Arc::new(PanicReviewRepository) as _,
diary: Arc::new(PanicDiaryQuery) as _,
stats: Arc::new(PanicStatsRepository) as _,
user: Arc::new(PanicUserRepository) as _,
import_session: Arc::new(PanicImportSessionRepository) as _,
import_profile: Arc::new(PanicImportProfileRepository) as _,
movie_profile: Arc::new(PanicMovieProfileRepository) as _,
watchlist: Arc::new(PanicWatchlistRepository) as _,
watch_event_command: Arc::new(PanicWatchEventCommand) as _,
watch_event_query: Arc::new(PanicWatchEventQuery) as _,
webhook_token: Arc::new(PanicWebhookTokenRepository) as _,
profile_fields: Arc::new(PanicProfileFieldsRepo) as _,
person_command: Arc::new(PanicPersonCommand) as _,
person_query: Arc::new(PanicPersonQuery) as _,
search_port: Arc::new(PanicSearchPort) as _,
search_command: Arc::new(PanicSearchCommand) as _,
remote_watchlist: Arc::new(PanicRemoteWatchlistRepository) as _,
social_command: Arc::new(domain::ports::noop::NoopSocialCommand) as _,
follow_graph: Arc::new(domain::ports::noop::NoopSocialQuery) as _,
block_query: Arc::new(domain::ports::noop::NoopSocialQuery) as _,
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _,
wrapup_stats: Arc::new(PanicWrapUpStatsQuery) as _,
wrapup_repo: Arc::new(PanicWrapUpRepository) as _,
insights: Arc::new(PanicInsightsQuery) as _,
goal_command: Arc::new(PanicGoalCommand) as _,
goal_query: Arc::new(PanicGoalQuery) as _,
user_settings: Arc::new(PanicUserSettingsRepository) as _,
remote_goal: Arc::new(PanicRemoteGoalRepository) as _,
refresh_session: Arc::new(PanicRefreshSessionRepository) as _,
federated_profile: None,
}
}
// --- Single state factory — builds `deps` from whatever `repos` it is given ---
/// Builds an `AppState` from caller-supplied `repos`, running `bootstrap::build_deps`
/// AFTER any overrides the caller already applied to `repos`. This is the only place
/// `deps` gets built for tests, so `repos` and `deps` can never disagree.
pub fn build_test_state_from(
repos: Repositories,
auth_service: Arc<dyn AuthService>,
) -> http_axum::state::AppState {
let services = application::Services {
auth: auth_service,
password_hasher: Arc::new(PanicPasswordHasher) as _,
metadata: Arc::new(PanicMetadataClient) as _,
poster_fetcher: Arc::new(PanicPosterFetcher) as _,
object_storage: Arc::new(PanicObjectStorage) as _,
event_publisher: Arc::new(PanicEventPublisher) as _,
diary_exporter: Arc::new(PanicDiaryExporter) as _,
document_parser: Arc::new(PanicDocumentParser) as _,
review_logger: Arc::new(PanicReviewLogger) as _,
person_enrichment: None,
};
let config = AppSettings {
allow_registration: false,
base_url: "http://localhost:3000".to_string(),
rate_limit: 20,
refresh_ttl_seconds: 2_592_000,
secure_cookies: false,
cors_origins: String::new(),
wrapup: application::config::WrapUpSettings {
font_path: None,
logo_path: None,
bg_dir: None,
},
};
let instance = domain::value_objects::InstanceIdentity::new("http://localhost:3000");
let deps = Arc::new(bootstrap::build_deps(&repos, &services, &config, &instance));
http_axum::state::AppState {
app_ctx: AppContext {
deps,
services,
config,
instance,
#[cfg(feature = "federation")]
ap_document: Arc::new(domain::ports::noop::NoopApDocument),
#[cfg(feature = "federation")]
ap_blocklist: Arc::new(domain::ports::noop::NoopInstanceBlocklist),
},
rss_renderer: Arc::new(PanicRssFeedRenderer),
}
}
pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> http_axum::state::AppState {
build_test_state_from(test_repositories(), auth_service)
}
/// Reusable default test `AppState`: `Panic*` stubs everywhere, real prebuilt `deps`.
/// Callers that need a specific stub swapped in should use one of the
/// `test_app_state_with_*` constructors below, which inject BEFORE `build_deps`
/// runs — mutating the returned `AppState` afterwards would only reach `repos`,
/// not the prebuilt `deps`.
pub fn test_app_state() -> http_axum::state::AppState {
make_test_state(Arc::new(PanicAuthService))
}
/// Test state with `person_query` overridden BEFORE `build_deps` runs, so the
/// stub is visible from both `app_ctx.repos.person_query` and every prebuilt
/// deps struct that carries a `person_query` (e.g. `deps.person.get_person`).
pub fn test_app_state_with_person_query(
person_query: Arc<dyn PersonQuery>,
) -> http_axum::state::AppState {
let mut repos = test_repositories();
repos.person_query = person_query;
build_test_state_from(repos, Arc::new(PanicAuthService))
}
/// Test state with `search_port` overridden BEFORE `build_deps` runs, so the
/// stub is visible from both `app_ctx.repos.search_port` and any prebuilt deps
/// struct that carries a `search_port`.
pub fn test_app_state_with_search_port(
search_port: Arc<dyn SearchPort>,
) -> http_axum::state::AppState {
let mut repos = test_repositories();
repos.search_port = search_port;
build_test_state_from(repos, Arc::new(PanicAuthService))
}
pub fn test_app_state_with_repos(
adjust: impl FnOnce(&mut Repositories),
auth_service: Arc<dyn AuthService>,
) -> http_axum::state::AppState {
let mut repos = test_repositories();
adjust(&mut repos);
build_test_state_from(repos, auth_service)
}
/// Test state with `user` overridden BEFORE `build_deps` runs, so the stub is
/// visible from both `app_ctx.repos.user` and `deps.users.authorize_admin.user`
/// — the field the `AdminApiUser`/`AdminUser` extractors now read through.
pub fn test_app_state_with_user(
user: Arc<dyn UserRepository>,
auth_service: Arc<dyn AuthService>,
) -> http_axum::state::AppState {
let mut repos = test_repositories();
repos.user = user;
build_test_state_from(repos, auth_service)
}
/// A `UserRepository` whose `find_by_id` always fails, for exercising the
/// repository-error rejection path of the admin extractors. Every other
/// method panics — the admin extractors only ever call `find_by_id`.
pub struct ErroringUserRepo;
#[async_trait::async_trait]
impl UserRepository for ErroringUserRepo {
async fn find_by_email(
&self,
_: &domain::value_objects::Email,
) -> Result<Option<domain::models::User>, DomainError> {
panic!()
}
async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> {
panic!()
}
async fn find_by_id(&self, _: &UserId) -> Result<Option<domain::models::User>, DomainError> {
Err(DomainError::InfrastructureError("db down".into()))
}
async fn find_by_username(
&self,
_: &domain::value_objects::Username,
) -> Result<Option<domain::models::User>, DomainError> {
panic!()
}
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
panic!()
}
async fn update_profile(
&self,
_: &UserId,
_: &domain::models::UserProfile,
_: &[domain::events::DomainEvent],
) -> Result<(), DomainError> {
panic!()
}
async fn mark_deleted(
&self,
_: &UserId,
_: &[domain::events::DomainEvent],
) -> Result<(), DomainError> {
panic!()
}
async fn find_tombstoned_by_id(
&self,
_: &UserId,
) -> Result<Option<domain::models::User>, DomainError> {
panic!()
}
async fn is_username_retired(
&self,
_: &domain::value_objects::Username,
) -> Result<bool, DomainError> {
panic!()
}
async fn count_active_admins(&self) -> Result<u64, DomainError> {
panic!()
}
}
/// Auth stub that accepts any bearer token and resolves it to a fixed user id,
/// for tests that need to authenticate as a specific user rather than merely
/// exercise the unauthenticated-request path.
pub struct AcceptingAuth(pub UserId);
#[async_trait::async_trait]
impl AuthService for AcceptingAuth {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
panic!()
}
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
Ok(self.0.clone())
}
}

View File

@@ -0,0 +1,99 @@
mod support;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use application::worker::WorkerService;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::{AckHandle, DomainEvent, EventEnvelope, EventId},
ports::{Begin, EventConsumer, EventHandler, ProcessedEventLedger},
};
use futures::stream::BoxStream;
use sqlite::SqliteProcessedEventLedger;
struct Counting(Arc<AtomicU32>);
#[async_trait]
impl EventHandler for Counting {
fn name(&self) -> &'static str {
"crashy"
}
async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
struct Outcome(Arc<Mutex<Vec<&'static str>>>);
#[async_trait]
impl AckHandle for Outcome {
async fn ack(&self) -> Result<(), DomainError> {
self.0.lock().unwrap().push("ack");
Ok(())
}
async fn nack(&self) -> Result<(), DomainError> {
self.0.lock().unwrap().push("nack");
Ok(())
}
async fn defer(&self, _: Duration) -> Result<(), DomainError> {
self.0.lock().unwrap().push("defer");
Ok(())
}
}
struct Once(Mutex<Option<EventEnvelope>>);
impl EventConsumer for Once {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
let item = self.0.lock().unwrap().take().map(Ok);
Box::pin(futures::stream::iter(item))
}
}
#[tokio::test]
async fn an_abandoned_claim_defers_deliveries_until_the_lease_lapses_then_runs_once() {
let pool = support::memory_pool().await;
sqlite::migrate(&pool).await.unwrap();
let ledger = Arc::new(SqliteProcessedEventLedger::with_lease(
pool,
Duration::from_secs(1),
));
let id = EventId::new();
assert!(matches!(
ledger.begin(&id, "crashy").await.unwrap(),
Begin::Run(_)
));
let runs = Arc::new(AtomicU32::new(0));
let outcomes = Arc::new(Mutex::new(Vec::new()));
let deliver = || {
let envelope = EventEnvelope::new(
Some(id),
DomainEvent::SearchReindexRequested,
Box::new(Outcome(outcomes.clone())),
);
let service = WorkerService::new(
Arc::new(Once(Mutex::new(Some(envelope)))),
vec![Arc::new(Counting(runs.clone()))],
ledger.clone(),
)
.unwrap();
async move { service.run(tokio::sync::watch::channel(false).1).await }
};
deliver().await;
assert_eq!(*outcomes.lock().unwrap(), ["defer"]);
assert_eq!(runs.load(Ordering::SeqCst), 0);
tokio::time::sleep(Duration::from_millis(2100)).await;
deliver().await;
assert_eq!(*outcomes.lock().unwrap(), ["defer", "ack"]);
assert_eq!(runs.load(Ordering::SeqCst), 1);
deliver().await;
assert_eq!(runs.load(Ordering::SeqCst), 1, "done claims never re-run");
}

View File

@@ -0,0 +1,209 @@
mod support;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::Notify;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::{DomainEvent, EventId},
ports::{EventPublisher, EventRelaySink},
value_objects::UserId,
};
use sqlite::SqliteEventQueue;
use uuid::Uuid;
#[derive(Default)]
struct FakeSink {
fail: bool,
fail_first: std::sync::atomic::AtomicUsize,
slow: Option<Duration>,
started: Notify,
seen: Mutex<Vec<Option<EventId>>>,
}
#[async_trait]
impl EventRelaySink for FakeSink {
async fn relay(&self, id: Option<&EventId>, _event: &DomainEvent) -> Result<(), DomainError> {
self.seen.lock().unwrap().push(id.cloned());
if let Some(delay) = self.slow {
self.started.notify_one();
tokio::time::sleep(delay).await;
}
let outage = self
.fail_first
.try_update(
std::sync::atomic::Ordering::SeqCst,
std::sync::atomic::Ordering::SeqCst,
|n| n.checked_sub(1),
)
.is_ok();
if self.fail || outage {
Err(DomainError::InfrastructureError("bus down".into()))
} else {
Ok(())
}
}
}
async fn outbox() -> (sqlx::SqlitePool, Arc<dyn EventPublisher>, SqliteEventQueue) {
outbox_with_attempts(config::EventsConfig::default().queue_max_attempts).await
}
async fn outbox_with_attempts(
max_attempts: i32,
) -> (sqlx::SqlitePool, Arc<dyn EventPublisher>, SqliteEventQueue) {
let pool = support::memory_pool().await;
sqlite::migrate(&pool).await.unwrap();
let config = config::EventsConfig {
queue_poll_interval_ms: 10,
queue_max_attempts: max_attempts,
..Default::default()
};
let queue = SqliteEventQueue::create(
pool.clone(),
sqlite::DbEventQueueConfig::from_config(&config),
);
(pool.clone(), Arc::new(queue.clone()), queue)
}
async fn row(pool: &sqlx::SqlitePool) -> (String, i32, Option<String>) {
sqlx::query_as("SELECT status, attempts, event_id FROM event_queue")
.fetch_one(pool)
.await
.unwrap()
}
async fn wait_for(pool: &sqlx::SqlitePool, status: &str) {
for _ in 0..800 {
if row(pool).await.0 == status {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("row never reached {status}: {:?}", row(pool).await);
}
fn start(
queue: SqliteEventQueue,
sink: Arc<FakeSink>,
) -> (
tokio::sync::watch::Sender<bool>,
tokio::task::JoinHandle<()>,
) {
let (stop, rx) = tokio::sync::watch::channel(false);
let relay = bootstrap::outbox_relay::OutboxRelay::new(Arc::new(queue), sink)
.with_backoff(|_| Duration::from_millis(10));
(stop, tokio::spawn(relay.run(rx)))
}
fn event() -> DomainEvent {
DomainEvent::UserUpdated {
user_id: UserId::from_uuid(Uuid::new_v4()),
}
}
#[tokio::test]
async fn relay_marks_rows_done_only_after_publish_succeeds() {
let (pool, publisher, queue) = outbox().await;
publisher.publish(&event()).await.unwrap();
let sink = Arc::new(FakeSink::default());
let (stop, relay) = start(queue, sink.clone());
wait_for(&pool, "done").await;
relay.abort();
drop(stop);
let (_, attempts, event_id) = row(&pool).await;
assert_eq!(attempts, 0);
let seen = sink.seen.lock().unwrap();
assert_eq!(seen.len(), 1);
assert_eq!(
seen[0].as_ref().map(|id| id.value().to_string()),
event_id,
"the sink gets the row's own event id"
);
}
#[tokio::test]
async fn a_failing_bus_never_spends_the_rows_attempts_and_the_row_relays_on_recovery() {
let (pool, publisher, queue) = outbox_with_attempts(2).await;
publisher.publish(&event()).await.unwrap();
let sink = Arc::new(FakeSink {
fail_first: std::sync::atomic::AtomicUsize::new(3),
..Default::default()
});
let (stop, relay) = start(queue, sink.clone());
wait_for(&pool, "done").await;
relay.abort();
drop(stop);
let (status, attempts, _) = row(&pool).await;
assert_eq!((status.as_str(), attempts), ("done", 0));
assert_eq!(
sink.seen.lock().unwrap().len(),
4,
"3 failures then success"
);
}
#[tokio::test]
async fn a_failed_publish_defers_the_row_without_counting_an_attempt() {
let (pool, publisher, queue) = outbox().await;
publisher.publish(&event()).await.unwrap();
let sink = Arc::new(FakeSink {
fail: true,
..Default::default()
});
let (stop, relay) = start(queue, sink.clone());
for _ in 0..200 {
let last_error: Option<String> = sqlx::query_scalar("SELECT last_error FROM event_queue")
.fetch_one(&pool)
.await
.unwrap();
if last_error.as_deref() == Some("deferred") {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
relay.abort();
drop(stop);
let (status, attempts, _) = row(&pool).await;
assert_eq!((status.as_str(), attempts), ("pending", 0));
let last_error: Option<String> = sqlx::query_scalar("SELECT last_error FROM event_queue")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(last_error.as_deref(), Some("deferred"));
}
#[tokio::test]
async fn shutdown_lets_the_in_flight_row_finish_then_stops_the_relay() {
let (pool, publisher, queue) = outbox().await;
publisher.publish(&event()).await.unwrap();
publisher.publish(&event()).await.unwrap();
let sink = Arc::new(FakeSink {
slow: Some(Duration::from_millis(200)),
..Default::default()
});
let (stop, relay) = start(queue, sink.clone());
sink.started.notified().await;
stop.send(true).unwrap();
tokio::time::timeout(Duration::from_secs(5), relay)
.await
.expect("relay stops on shutdown")
.unwrap();
let statuses: Vec<String> = sqlx::query_scalar("SELECT status FROM event_queue ORDER BY id")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(statuses[0], "done", "in-flight row was completed");
assert_ne!(statuses[0], "processing");
assert_eq!(sink.seen.lock().unwrap().len(), 1, "no new row after stop");
}

View File

@@ -0,0 +1,298 @@
mod support;
use axum::{
Router,
body::Body,
http::{Method, Request, header},
};
use support::{get, json_request, send};
const EMAIL: &str = "owner@example.com";
const USERNAME: &str = "owner";
const CLASSIC_VISITOR_PAGES: &[(&str, u16)] = &[
("/", 200),
("/users", 200),
("/u/{username}", 308),
("/users/{id}", 200),
("/users/{id}?view=ratings", 200),
("/users/{id}?view=history", 200),
("/movies/{movie}", 200),
("/login", 200),
("/register", 200),
("/logout", 303),
("/tags/drama", 307),
("/users/{id}/watchlist", 200),
("/wrapups/{id}/2025", 200),
("/wrapups/global/2025", 200),
("/users/{id}/following-list", 303),
("/users/{id}/followers-list", 303),
("/posters/abc.jpg", 308),
("/images/missing.jpg", 404),
("/health", 200),
];
const CLASSIC_OWNER_PAGES: &[(&str, u16)] = &[
("/reviews/new", 200),
("/diary/export", 200),
("/import", 200),
("/import/done", 200),
("/import/00000000-0000-0000-0000-000000000000/mapping", 303),
("/import/00000000-0000-0000-0000-000000000000/preview", 303),
("/settings/profile", 200),
("/settings/integrations", 200),
("/watch-queue", 200),
("/users/{id}/watchlist", 200),
("/users/{id}/following-list", 200),
("/users/{id}/followers-list", 200),
("/movies/{movie}", 200),
("/users/{id}?embed=true", 200),
];
#[cfg(feature = "federation")]
const FEDERATION_VISITOR_PAGES: &[(&str, u16)] = &[
("/users/{id}/followers", 303),
("/users/{id}/following", 303),
];
#[cfg(feature = "federation")]
const FEDERATION_OWNER_PAGES: &[(&str, u16)] = &[
("/users/{id}/followers", 303),
("/users/{id}/following", 303),
("/admin/blocked-domains", 403),
("/social/blocked", 200),
];
#[cfg(not(feature = "federation"))]
const FEDERATION_OFF_PAGES: &[(&str, u16)] = &[
("/users/{id}/followers", 308),
("/users/{id}/following", 308),
];
struct Seed {
router: Router,
user_id: String,
movie_id: String,
owner_cookie: String,
}
async fn seed() -> Seed {
let (router, pool) = support::server().await;
let session = support::register(&router, USERNAME, EMAIL).await;
let (token, user_id, owner_cookie) = (session.token, session.user_id, session.cookie);
let (status, _, body) = send(
&router,
json_request(
Method::POST,
"/api/v1/reviews",
Some(&token),
serde_json::json!({
"manual_title": "Seeded Movie",
"manual_release_year": 1999,
"manual_director": "Some Director",
"rating": 4,
"comment": "the butler did it",
"watched_at": "2025-03-01",
"spoilers": true,
}),
),
)
.await;
assert_eq!(status, 201, "log review: {body}");
let (status, _, body) = send(
&router,
json_request(
Method::POST,
"/api/v1/goals",
Some(&token),
serde_json::json!({ "year": 2025, "target_count": 12 }),
),
)
.await;
assert_eq!(status, 200, "create goal: {body}");
let (status, _, body) = send(
&router,
Request::builder()
.uri("/api/v1/movies")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, 200, "list movies: {body}");
let movies: serde_json::Value = serde_json::from_str(&body).unwrap();
let movie_id = movies["items"][0]["id"]
.as_str()
.unwrap_or_else(|| panic!("no movie id in {body}"))
.to_string();
seed_ready_wrapup(&pool, Some(&user_id)).await;
seed_ready_wrapup(&pool, None).await;
Seed {
router,
user_id,
movie_id,
owner_cookie,
}
}
async fn seed_ready_wrapup(pool: &sqlx::SqlitePool, user_id: Option<&str>) {
use chrono::NaiveDate;
use domain::models::wrapup::{DateRange, WrapUpScope};
let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let scope = match user_id {
Some(id) => WrapUpScope::User(id.parse().unwrap()),
None => WrapUpScope::Global,
};
let report = domain::services::wrapup_analyzer::build_report(
scope,
DateRange::new(start, end).unwrap(),
&[],
);
sqlx::query(
"INSERT INTO wrap_up_records (id, user_id, start_date, end_date, status, report_json) \
VALUES (?, ?, '2025-01-01', '2026-01-01', 'ready', ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(user_id)
.bind(serde_json::to_string(&report).unwrap())
.execute(pool)
.await
.unwrap();
}
fn expand(seed: &Seed, template: &str) -> String {
template
.replace("{id}", &seed.user_id)
.replace("{username}", USERNAME)
.replace("{movie}", &seed.movie_id)
}
async fn check_table(seed: &Seed, table: &[(&str, u16)], cookie: Option<&str>) {
let mut failures = Vec::new();
for (template, expected) in table {
let uri = expand(seed, template);
let (status, _, _) = send(&seed.router, get(&uri, cookie)).await;
if status != *expected {
failures.push(format!("{uri}: expected {expected}, got {status}"));
}
}
assert!(
failures.is_empty(),
"route status drift:\n{}",
failures.join("\n")
);
}
#[tokio::test]
async fn every_classic_page_renders_for_a_visitor() {
let seed = seed().await;
check_table(&seed, CLASSIC_VISITOR_PAGES, None).await;
#[cfg(feature = "federation")]
check_table(&seed, FEDERATION_VISITOR_PAGES, None).await;
#[cfg(not(feature = "federation"))]
check_table(&seed, FEDERATION_OFF_PAGES, None).await;
for template in ["/wrapups/{id}/2025", "/wrapups/global/2025"] {
let (status, _, body) = send(&seed.router, get(&expand(&seed, template), None)).await;
assert_eq!(status, 200, "{template}");
assert!(
body.contains("class=\"wu-container\""),
"wrapup template not rendered for {template}"
);
}
let (status, _, body) = send(
&seed.router,
get(&expand(&seed, "/users/{id}?embed=true"), None),
)
.await;
assert_eq!(status, 200);
assert!(
body.contains("class=\"embed-profile\""),
"embed template not rendered: {body}"
);
}
#[tokio::test]
async fn every_classic_page_renders_for_the_signed_in_owner() {
let seed = seed().await;
let cookie = seed.owner_cookie.clone();
check_table(&seed, CLASSIC_OWNER_PAGES, Some(&cookie)).await;
#[cfg(feature = "federation")]
check_table(&seed, FEDERATION_OWNER_PAGES, Some(&cookie)).await;
}
#[tokio::test]
async fn feeds_and_openapi_are_served() {
let seed = seed().await;
for (template, expected) in [
("/feed.rss", 200),
("/users/{id}/feed.rss", 200),
("/openapi.json", 200),
("/docs", 303),
("/docs/", 200),
] {
let uri = expand(&seed, template);
let (status, _, _) = send(&seed.router, get(&uri, None)).await;
assert_eq!(status, expected, "{uri}");
}
}
#[tokio::test]
async fn openapi_paths_match_the_recorded_list() {
let seed = seed().await;
let (status, _, body) = send(&seed.router, get("/openapi.json", None)).await;
assert_eq!(status, 200);
let spec: serde_json::Value = serde_json::from_str(&body).unwrap();
let mut lines = Vec::new();
for (path, item) in spec["paths"].as_object().unwrap() {
for method in item.as_object().unwrap().keys() {
if ["get", "put", "post", "delete", "patch", "head", "options"]
.contains(&method.as_str())
{
lines.push(format!("{} {path}", method.to_uppercase()));
}
}
}
lines.sort();
let actual = lines.join("\n") + "\n";
let fixture = if cfg!(feature = "federation") {
"tests/fixtures/openapi_paths.txt"
} else {
"tests/fixtures/openapi_paths_federation_off.txt"
};
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(fixture);
if std::env::var("UPDATE_OPENAPI_FIXTURE").is_ok() {
std::fs::write(&path, &actual).unwrap();
}
let expected = std::fs::read_to_string(&path).unwrap();
assert_eq!(actual, expected, "OpenAPI paths drifted from {fixture}");
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn profile_page_serves_the_actor_document_to_activitypub_clients() {
let seed = seed().await;
let req = Request::builder()
.method(Method::GET)
.uri(format!("/users/{}", seed.user_id))
.header(header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap();
let (status, headers, body) = send(&seed.router, req).await;
assert_eq!(status, 200, "{body}");
assert_eq!(
headers.get(header::CONTENT_TYPE).unwrap(),
"application/activity+json"
);
let actor: serde_json::Value = serde_json::from_str(&body).expect("actor JSON");
assert_eq!(actor["type"], "Person");
}

View File

@@ -0,0 +1,153 @@
#![allow(dead_code)]
use axum::{
Router,
body::Body,
http::{Method, Request, header},
};
use tower::ServiceExt;
pub const PASSWORD: &str = "correct-horse-battery";
pub fn test_config() -> config::AppConfig {
config::AppConfig {
server: config::ServerConfig {
allow_registration: true,
base_url: "http://localhost:3000".into(),
..Default::default()
},
database: config::DatabaseConfig {
url: "sqlite::memory:".into(),
},
auth: config::AuthConfig {
jwt_secret: Some("route-smoke-test-secret-0123456789abcdef".into()),
..Default::default()
},
events: Default::default(),
nats: Default::default(),
storage: Default::default(),
image_conversion: Default::default(),
metadata: config::MetadataConfig {
tmdb_api_key: Some("test-key".into()),
..Default::default()
},
federation: Default::default(),
wrapup: Default::default(),
}
}
pub async fn send(router: &Router, mut req: Request<Body>) -> (u16, axum::http::HeaderMap, String) {
let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap();
req.extensions_mut()
.insert(axum::extract::ConnectInfo::<std::net::SocketAddr>(addr));
let resp = router.clone().oneshot(req).await.unwrap();
let status = resp.status().as_u16();
let headers = resp.headers().clone();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
(
status,
headers,
String::from_utf8_lossy(&bytes).into_owned(),
)
}
pub fn get(uri: &str, cookie: Option<&str>) -> Request<Body> {
let mut b = Request::builder().method(Method::GET).uri(uri);
if let Some(c) = cookie {
b = b.header(header::COOKIE, c);
}
b.body(Body::empty()).unwrap()
}
pub fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option<String> {
headers
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.find_map(|c| {
c.split(';')
.next()?
.trim()
.strip_prefix(&format!("{name}="))
.map(str::to_string)
})
}
pub fn json_request(
method: Method,
uri: &str,
token: Option<&str>,
body: serde_json::Value,
) -> Request<Body> {
let mut b = Request::builder()
.method(method)
.uri(uri)
.header(header::CONTENT_TYPE, "application/json");
if let Some(t) = token {
b = b.header(header::AUTHORIZATION, format!("Bearer {t}"));
}
b.body(Body::from(body.to_string())).unwrap()
}
pub async fn memory_pool() -> sqlx::SqlitePool {
sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap()
}
pub async fn server() -> (Router, sqlx::SqlitePool) {
let pool = memory_pool().await;
let router = bootstrap::build_server_on(&test_config(), pool.clone())
.await
.unwrap();
(router, pool)
}
pub struct Session {
pub user_id: String,
pub token: String,
pub cookie: String,
}
pub async fn register(router: &Router, username: &str, email: &str) -> Session {
let (status, headers, _) = send(router, get("/register", None)).await;
assert_eq!(status, 200, "GET /register");
let csrf = cookie_value(&headers, "csrf").expect("csrf cookie");
let form = format!(
"email={}&username={username}&password={PASSWORD}&_csrf={csrf}",
email.replace('@', "%40")
);
let req = Request::builder()
.method(Method::POST)
.uri("/register")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, format!("csrf={csrf}"))
.body(Body::from(form))
.unwrap();
let (status, headers, _) = send(router, req).await;
assert_eq!(status, 303, "POST /register");
let session = cookie_value(&headers, "token").expect("session cookie after register");
let cookie = format!("csrf={csrf}; token={session}");
let (status, _, body) = send(
router,
json_request(
Method::POST,
"/api/v1/auth/login",
None,
serde_json::json!({ "email": email, "password": PASSWORD }),
),
)
.await;
assert_eq!(status, 200, "login: {body}");
let login: serde_json::Value = serde_json::from_str(&body).unwrap();
Session {
user_id: login["user_id"].as_str().unwrap().to_string(),
token: login["token"].as_str().unwrap().to_string(),
cookie,
}
}