Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
52
crates/bootstrap/Cargo.toml
Normal file
52
crates/bootstrap/Cargo.toml
Normal 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"] }
|
||||
390
crates/bootstrap/src/build.rs
Normal file
390
crates/bootstrap/src/build.rs
Normal 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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
161
crates/bootstrap/src/database.rs
Normal file
161
crates/bootstrap/src/database.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
141
crates/bootstrap/src/event_bus.rs
Normal file
141
crates/bootstrap/src/event_bus.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
168
crates/bootstrap/src/federation.rs
Normal file
168
crates/bootstrap/src/federation.rs
Normal 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,
|
||||
))
|
||||
}
|
||||
126
crates/bootstrap/src/follow_backfill_handler.rs
Normal file
126
crates/bootstrap/src/follow_backfill_handler.rs
Normal 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..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
crates/bootstrap/src/lib.rs
Normal file
19
crates/bootstrap/src/lib.rs
Normal 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};
|
||||
97
crates/bootstrap/src/outbox_relay.rs
Normal file
97
crates/bootstrap/src/outbox_relay.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
47
crates/bootstrap/src/repositories.rs
Normal file
47
crates/bootstrap/src/repositories.rs
Normal 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>>,
|
||||
}
|
||||
143
crates/bootstrap/src/server.rs
Normal file
143
crates/bootstrap/src/server.rs
Normal 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,
|
||||
)))
|
||||
}
|
||||
23
crates/bootstrap/src/tls.rs
Normal file
23
crates/bootstrap/src/tls.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
24
crates/bootstrap/src/tracing_setup.rs
Normal file
24
crates/bootstrap/src/tracing_setup.rs
Normal 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}"))
|
||||
}
|
||||
}
|
||||
317
crates/bootstrap/src/worker.rs
Normal file
317
crates/bootstrap/src/worker.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
404
crates/bootstrap/tests/account_deletion_test.rs
Normal file
404
crates/bootstrap/tests/account_deletion_test.rs
Normal 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");
|
||||
}
|
||||
123
crates/bootstrap/tests/account_purge_test.rs
Normal file
123
crates/bootstrap/tests/account_purge_test.rs
Normal 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");
|
||||
}
|
||||
386
crates/bootstrap/tests/api_test.rs
Normal file
386
crates/bootstrap/tests/api_test.rs
Normal 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);
|
||||
}
|
||||
307
crates/bootstrap/tests/bootstrap_test.rs
Normal file
307
crates/bootstrap/tests/bootstrap_test.rs
Normal 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}");
|
||||
}
|
||||
101
crates/bootstrap/tests/config_propagation_test.rs
Normal file
101
crates/bootstrap/tests/config_propagation_test.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
71
crates/bootstrap/tests/fixtures/openapi_paths.txt
vendored
Normal file
71
crates/bootstrap/tests/fixtures/openapi_paths.txt
vendored
Normal 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
|
||||
54
crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt
vendored
Normal file
54
crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt
vendored
Normal 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
|
||||
9
crates/bootstrap/tests/http_handlers.rs
Normal file
9
crates/bootstrap/tests/http_handlers.rs
Normal 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;
|
||||
553
crates/bootstrap/tests/http_handlers/api_handlers_test.rs
Normal file
553
crates/bootstrap/tests/http_handlers/api_handlers_test.rs
Normal 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");
|
||||
}
|
||||
24
crates/bootstrap/tests/http_handlers/app_context_test.rs
Normal file
24
crates/bootstrap/tests/http_handlers/app_context_test.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
408
crates/bootstrap/tests/http_handlers/extractors_test.rs
Normal file
408
crates/bootstrap/tests/http_handlers/extractors_test.rs
Normal 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");
|
||||
}
|
||||
259
crates/bootstrap/tests/http_handlers/support.rs
Normal file
259
crates/bootstrap/tests/http_handlers/support.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
99
crates/bootstrap/tests/ledger_recovery_test.rs
Normal file
99
crates/bootstrap/tests/ledger_recovery_test.rs
Normal 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");
|
||||
}
|
||||
209
crates/bootstrap/tests/outbox_relay_test.rs
Normal file
209
crates/bootstrap/tests/outbox_relay_test.rs
Normal 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");
|
||||
}
|
||||
298
crates/bootstrap/tests/route_smoke_test.rs
Normal file
298
crates/bootstrap/tests/route_smoke_test.rs
Normal 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");
|
||||
}
|
||||
153
crates/bootstrap/tests/support/mod.rs
Normal file
153
crates/bootstrap/tests/support/mod.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user