From 18c179e14c50240deb1fc3b1a86db19ecfdc499b Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 20:26:50 +0100 Subject: [PATCH 001/104] chore: gate test helpers, code style, stricter clippy --- .gitea/workflows/ci.yml | 2 +- .github/workflows/ci.yml | 2 +- CODE_STYLE.md | 283 ++++++++++++++++++ Cargo.toml | 3 + Makefile | 4 +- crates/adapters/activitypub/Cargo.toml | 2 +- crates/adapters/adapter-common/Cargo.toml | 2 +- crates/adapters/auth/Cargo.toml | 4 +- crates/adapters/event-payload/Cargo.toml | 2 +- crates/adapters/event-publisher/Cargo.toml | 2 +- crates/adapters/export/Cargo.toml | 2 +- crates/adapters/image-converter/Cargo.toml | 2 +- crates/adapters/importer/Cargo.toml | 2 +- crates/adapters/jellyfin/Cargo.toml | 2 +- crates/adapters/metadata/Cargo.toml | 2 +- crates/adapters/nats/Cargo.toml | 2 +- crates/adapters/object-storage/Cargo.toml | 2 +- crates/adapters/plex/Cargo.toml | 2 +- crates/adapters/poster-sync/Cargo.toml | 2 +- .../adapters/postgres-event-queue/Cargo.toml | 2 +- .../adapters/postgres-federation/Cargo.toml | 2 +- crates/adapters/postgres-social/Cargo.toml | 2 +- crates/adapters/postgres/Cargo.toml | 2 +- crates/adapters/rss/Cargo.toml | 2 +- crates/adapters/rss/src/tests/lib.rs | 4 +- crates/adapters/sqlite-event-queue/Cargo.toml | 4 +- crates/adapters/sqlite-federation/Cargo.toml | 2 +- crates/adapters/sqlite-social/Cargo.toml | 2 +- crates/adapters/sqlite/Cargo.toml | 9 +- crates/adapters/template-askama/Cargo.toml | 2 +- crates/adapters/tmdb-enrichment/Cargo.toml | 2 +- crates/api-types/Cargo.toml | 4 +- crates/application/Cargo.toml | 2 +- crates/composition/Cargo.toml | 2 +- crates/domain/Cargo.toml | 2 +- crates/domain/src/lib.rs | 2 +- crates/presentation/Cargo.toml | 2 +- crates/server/Cargo.toml | 2 +- crates/tui/Cargo.toml | 4 +- crates/worker/Cargo.toml | 2 +- 40 files changed, 332 insertions(+), 49 deletions(-) create mode 100644 CODE_STYLE.md diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d649966d..dade483e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: run: cargo fmt --all -- --check - name: clippy - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets -- -D warnings - name: test run: cargo test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7291dc9c..5b423adf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: run: cargo fmt --all -- --check - name: clippy - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets -- -D warnings - name: test run: cargo test diff --git a/CODE_STYLE.md b/CODE_STYLE.md new file mode 100644 index 00000000..12d18456 --- /dev/null +++ b/CODE_STYLE.md @@ -0,0 +1,283 @@ +# Code Style Guide + +This document defines the coding standards for this project. All contributors (human and agent) must follow these rules. When in doubt, prefer clarity and correctness over cleverness and speed. + +## Philosophy + +- Code reads like prose +- Do things properly — no shortcuts +- SOLID principles — always, no exceptions +- KISS — simplest solution that solves the problem **correctly and properly**. KISS means no unnecessary complexity, NOT skipping proper architecture. If the design calls for a trait, a newtype, or a pattern — that IS the simple solution. When in doubt, follow the architecture (CONTEXT.md, ADRs) over perceived simplicity. +- DRY — one source of truth for every piece of knowledge +- Use design patterns where they fit (Strategy, Observer, Builder, etc.) — name them in code so intent is clear +- Single responsibility everywhere: files, functions, types, modules +- Single point of entry, single control flow — no parallel data/control paths +- Open/Closed — extend via new types, don't modify existing ones +- Depend on abstractions (traits), not concretions +- Keep things as private as possible +- Zero magic numbers, strings, or unnamed constants +- Newtypes for self-documenting code +- Descriptive variable names — `review_rating`, not `rr`. `watched_on`, not `wd`. `poster_url`, not `pu`. No abbreviations unless universally understood (e.g., `id`, `url`, `i` for a trivial loop index). + +## Project Structure + +### Module Organization + +Deep module hierarchy, grouped by concern. No flat file dumps. Each file is its own small, enclosed thing. + +``` +crates/domain/src/ +├── models/ +│ ├── review.rs +│ ├── diary_entry.rs +│ └── movie.rs +├── value_objects/ +│ ├── movie_id.rs +│ ├── rating.rs +│ └── watched_on.rs +├── ports/ +│ ├── review_repository.rs +│ └── event_publisher.rs +└── ... +``` + +### File Size + +Small files with a single concern. If a file grows past ~100-150 lines, it's probably doing too much — split it. + +### Tests + +Never in the same file as production code. Use cases keep their tests in a sibling `tests/` module, one file per use case: + +``` +crates/application/src/diary/log_review.rs +crates/application/src/diary/tests/log_review.rs +``` + +## Naming + +### Conventions + +- Files and modules: `snake_case` +- Types: `PascalCase` with semantic suffixes for clarity and navigation +- Functions and methods: `snake_case`, verb-first, descriptive + +### Type Suffixes + +| Suffix | Usage | Example | +|---|---|---| +| (none) | Value objects, entities | `Review`, `Movie`, `DiaryEntry` | +| `Id` | Identity newtypes | `MovieId`, `ReviewId`, `UserId` | +| `Config` | Configuration data | `ServerConfig`, `StorageConfig` | +| `Service` | Domain services | `LocalSocialService` | +| `Error` | Error types | `DomainError`, `AuthError` | +| `UseCase` | Application use cases | `LogReviewUseCase`, `GetDiaryUseCase` | +| `Strategy` | Strategy trait/impls | `RatingAggregationStrategy` | + +Ports are named for the capability they provide (`ReviewRepository`, `EventPublisher`); their implementations are named for the technology (`SqliteReviewRepository`). + +### Newtypes + +Use newtypes everywhere a primitive carries domain meaning: + +```rust +// YES +struct MovieId(Uuid); +struct ReviewId(Uuid); +struct Rating(u8); +struct PosterUrl(Url); + +// NO +type MovieId = String; +fn log_review(movie: &str, rating: u8, watched_on: &str) -> ... +``` + +Newtypes are self-documenting and prevent accidental misuse (can't pass a `MovieId` where a `ReviewId` is expected). + +## Functions and Methods + +### Single Responsibility + +Each function does ONE thing. Compose small functions into larger behaviors. + +```rust +// YES +fn resolve_movie(...) -> Movie { ... } +fn build_review(movie: &Movie, draft: ReviewDraft) -> Review { ... } +fn record_diary_entry(review: &Review) -> DiaryEntry { ... } + +// NO +fn log_review(...) -> DiaryEntry { + // 80 lines doing movie resolution, review construction and diary recording +} +``` + +### Visibility + +Default to private. Only `pub` what the module's consumers actually need. Use `pub(crate)` for intra-crate sharing that shouldn't leak outside. + +### impl Blocks + +Split by concern: + +```rust +// Construction +impl Review { + pub fn new(movie: MovieId, rating: Rating, watched_on: WatchedOn) -> Self { ... } +} + +// Queries +impl Review { + pub fn is_rewatch(&self) -> bool { ... } + pub fn rating(&self) -> Rating { ... } +} + +// Mutations +impl Review { + pub fn revise_rating(&mut self, rating: Rating) { ... } +} +``` + +## Error Handling + +### Domain and Application: `thiserror` + +Typed, matchable error enums. Every error variant is meaningful. + +```rust +#[derive(Debug, thiserror::Error)] +pub enum DiaryError { + #[error("movie {0} not found")] + MovieNotFound(MovieId), + #[error("review rejected: {0}")] + ReviewRejected(String), +} +``` + +### Adapters and Bootstrap: `anyhow` + +Pragmatic error handling. Context strings for debugging. + +```rust +fn fetch_poster_blob(poster_url: &PosterUrl) -> anyhow::Result { + let data = http_client.get(poster_url) + .context("failed to fetch poster")?; + // ... +} +``` + +### Never `unwrap` or `expect` in production code + +Errors propagate with `?` and bubble up to a single handler. No scattered panic points. + +`unwrap` is allowed **only** in test code. + +## Configuration + +Secrets (API keys, JWT secrets) and deployment settings live in environment variables, read once at startup into a typed config struct. + +Zero magic numbers or strings. Everything named, everything explained by its name. + +```rust +// YES +const MAX_REVIEW_BODY_LENGTH: usize = 10_000; + +// NO +if body.len() > 10_000 { ... } +``` + +Constants are only for true invariants that never change. Tunable values belong in config. + +## Comments + +Comments only where a name, test or ADR cannot carry the reason. A comment is noise that drifts out of date while the code moves on, so prefer to put the reason somewhere that cannot rot silently: + +- **A name.** Rename the function, the variable, or the type until the reason is visible in the code. +- **A test.** A constraint worth a comment is worth a test that fails when someone breaks it. `media_is_resolved_before_the_cascade_runs` outlives any note explaining why the order matters. +- **An ADR.** Architectural reasoning belongs in `docs/adr/`, where it is versioned and discoverable. + +When none of those can carry it, a short comment stating the reason (never restating the code) is acceptable. + +## Dependencies + +- Use good crates when they exist — don't reinvent +- Prefer crates with minimal transitive dependencies +- Aim for small memory footprint and executable size +- Every dependency that has a `[workspace.dependencies]` entry is declared `workspace = true`; crates inherit `edition` from `[workspace.package]` +- Domain crate: zero external dependencies beyond `thiserror`, `serde` and small value-type crates +- Application crate: depends only on domain +- Adapters: free to pull in platform crates (`axum`, `sqlx`, `nats`, etc.) +- Bootstrap: wiring only + +## Trait Objects for Ports + +Ports are `dyn` behind `Arc`. Use cases hold their dependencies as trait objects: + +```rust +pub struct Deps { + pub reviews: Arc, + pub events: Arc, +} +``` + +Prefer a closed enum over `dyn` only where exhaustiveness is the point — when adding a variant must force every consumer to handle it. That is a decision about compiler-enforced coverage, not about dispatch cost. + +## Testing + +### Test Helpers Are Gated + +`domain::testing` (in-memory fakes and noops) compiles only with the `test-helpers` feature, which crates enable in their `[dev-dependencies]`. Production builds must never contain fakes. `application::test_helpers` is `#[cfg(test)]`. + +### Coverage Targets + +| Crate | Approach | Target | +|---|---|---| +| Domain | TDD (red-green-refactor) | 100% | +| Application | TDD | High coverage | +| Adapters | Integration tests where practical | Best effort | +| Bootstrap | No tests | — | + +### Fakes Must Not Be Kinder Than Production + +A fake that is more forgiving than the real adapter turns a test into a lie: it passes whichever way the code is written, which is worse than having no test. Two rules follow. + +**A fake must reproduce the constraints the database enforces.** SQLite removes dependent rows through `ON DELETE CASCADE`, so an in-memory store must cascade the same way, and enforce the same uniqueness constraints. Without that, an ordering requirement such as "resolve the poster before deleting the `DiaryEntry`" could not be tested at all. + +**A fake must be able to fail.** Every port whose failure is meant to degrade rather than propagate needs a fake that refuses (a poster fetcher that errors, an event publisher that rejects). A best-effort path with no failing test is a path that has never run. + +When a claim cannot be observed through the fake, move the test to where the real thing runs rather than asserting it against the fake's own behaviour. + +### Test Naming + +Tests read as specifications: + +```rust +#[tokio::test] +async fn logging_review_creates_diary_entry() { ... } + +#[tokio::test] +async fn rewatch_review_keeps_original_entry() { ... } +``` + +## Control Flow + +### Single Point of Entry + +One path through the code. No parallel flows that do similar things in different places. + +```rust +// YES — one function handles movie resolution +let movie = movie_resolver.resolve(movie_id, requester); + +// NO — movie resolution duplicated in two places +// diary_handler.rs: resolves movie +// import_handler.rs: also resolves movie with slightly different logic +``` + +### Single Call, Cascading Changes + +When something changes, it should cascade from one point. Don't require updating multiple files for a single logical change. + +### No God Objects + +If a struct has more than 5-6 fields, question whether it's doing too much. If a function takes more than 4-5 parameters, consider grouping them into a dedicated type. diff --git a/Cargo.toml b/Cargo.toml index 59e44179..4c4c940a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,9 @@ members = [ ] resolver = "2" +[workspace.package] +edition = "2024" + [workspace.dependencies] tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] } bytes = "1" diff --git a/Makefile b/Makefile index 435ea944..caef6984 100644 --- a/Makefile +++ b/Makefile @@ -74,10 +74,10 @@ fmt-check: cargo fmt --check clippy: - cargo clippy -- -D warnings + cargo clippy --workspace --all-targets -- -D warnings test: - cargo test + cargo test --workspace fix: cargo fmt diff --git a/crates/adapters/activitypub/Cargo.toml b/crates/adapters/activitypub/Cargo.toml index 4fad9e32..261eb766 100644 --- a/crates/adapters/activitypub/Cargo.toml +++ b/crates/adapters/activitypub/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "activitypub" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] k-ap = { version = "0.5.0", registry = "gitea" } diff --git a/crates/adapters/adapter-common/Cargo.toml b/crates/adapters/adapter-common/Cargo.toml index 0951f37c..fe6f793c 100644 --- a/crates/adapters/adapter-common/Cargo.toml +++ b/crates/adapters/adapter-common/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "adapter-common" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] domain = { workspace = true } diff --git a/crates/adapters/auth/Cargo.toml b/crates/adapters/auth/Cargo.toml index 437ffc67..89a9f7b3 100644 --- a/crates/adapters/auth/Cargo.toml +++ b/crates/adapters/auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "auth" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] async-trait = { workspace = true } @@ -9,7 +9,7 @@ domain = { workspace = true } anyhow = { workspace = true } chrono = { workspace = true } uuid = { workspace = true } -serde = { version = "1.0", features = ["derive"] } +serde = { workspace = true } jsonwebtoken = "9" argon2 = { version = "0.5", features = ["std"] } rand_core = { version = "0.6", features = ["getrandom"] } diff --git a/crates/adapters/event-payload/Cargo.toml b/crates/adapters/event-payload/Cargo.toml index f06e5364..4418cba6 100644 --- a/crates/adapters/event-payload/Cargo.toml +++ b/crates/adapters/event-payload/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "event-payload" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] domain = { workspace = true } diff --git a/crates/adapters/event-publisher/Cargo.toml b/crates/adapters/event-publisher/Cargo.toml index 33f40e12..bb925b3a 100644 --- a/crates/adapters/event-publisher/Cargo.toml +++ b/crates/adapters/event-publisher/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "event-publisher" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] domain = { workspace = true } diff --git a/crates/adapters/export/Cargo.toml b/crates/adapters/export/Cargo.toml index 2b10cf48..edcdcfc3 100644 --- a/crates/adapters/export/Cargo.toml +++ b/crates/adapters/export/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "export" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] domain = { workspace = true } diff --git a/crates/adapters/image-converter/Cargo.toml b/crates/adapters/image-converter/Cargo.toml index 28a1a2dc..3e39230c 100644 --- a/crates/adapters/image-converter/Cargo.toml +++ b/crates/adapters/image-converter/Cargo.toml @@ -15,5 +15,5 @@ webp = "0.3" [dev-dependencies] object-storage = { workspace = true } -object_store = "0.11" +object_store = { workspace = true } uuid = { workspace = true } diff --git a/crates/adapters/importer/Cargo.toml b/crates/adapters/importer/Cargo.toml index f4d0bc94..e80f6e75 100644 --- a/crates/adapters/importer/Cargo.toml +++ b/crates/adapters/importer/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "importer" version = "0.1.0" -edition = "2024" +edition.workspace = true [features] xlsx = ["dep:calamine"] diff --git a/crates/adapters/jellyfin/Cargo.toml b/crates/adapters/jellyfin/Cargo.toml index 78368268..cd50e5e4 100644 --- a/crates/adapters/jellyfin/Cargo.toml +++ b/crates/adapters/jellyfin/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "jellyfin" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] domain = { workspace = true } diff --git a/crates/adapters/metadata/Cargo.toml b/crates/adapters/metadata/Cargo.toml index 3ae18512..efbb2a6e 100644 --- a/crates/adapters/metadata/Cargo.toml +++ b/crates/adapters/metadata/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "metadata" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] anyhow = { workspace = true } diff --git a/crates/adapters/nats/Cargo.toml b/crates/adapters/nats/Cargo.toml index 0e5fd467..c2318dc2 100644 --- a/crates/adapters/nats/Cargo.toml +++ b/crates/adapters/nats/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "nats" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] async-nats = "0.48.0" diff --git a/crates/adapters/object-storage/Cargo.toml b/crates/adapters/object-storage/Cargo.toml index fa791461..fcf61cfc 100644 --- a/crates/adapters/object-storage/Cargo.toml +++ b/crates/adapters/object-storage/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "object-storage" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] domain = { workspace = true } diff --git a/crates/adapters/plex/Cargo.toml b/crates/adapters/plex/Cargo.toml index 1dbb842b..408dfd19 100644 --- a/crates/adapters/plex/Cargo.toml +++ b/crates/adapters/plex/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "plex" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] domain = { workspace = true } diff --git a/crates/adapters/poster-sync/Cargo.toml b/crates/adapters/poster-sync/Cargo.toml index 4ad9be58..39e41276 100644 --- a/crates/adapters/poster-sync/Cargo.toml +++ b/crates/adapters/poster-sync/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "poster-sync" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] domain = { workspace = true } diff --git a/crates/adapters/postgres-event-queue/Cargo.toml b/crates/adapters/postgres-event-queue/Cargo.toml index e9cd111d..83f23d11 100644 --- a/crates/adapters/postgres-event-queue/Cargo.toml +++ b/crates/adapters/postgres-event-queue/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "postgres-event-queue" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "macros", "chrono"] } diff --git a/crates/adapters/postgres-federation/Cargo.toml b/crates/adapters/postgres-federation/Cargo.toml index d130e25a..a7bd6358 100644 --- a/crates/adapters/postgres-federation/Cargo.toml +++ b/crates/adapters/postgres-federation/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "postgres-federation" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] sqlx = { version = "0.8.6", features = [ diff --git a/crates/adapters/postgres-social/Cargo.toml b/crates/adapters/postgres-social/Cargo.toml index d90e9bf8..9354c526 100644 --- a/crates/adapters/postgres-social/Cargo.toml +++ b/crates/adapters/postgres-social/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "postgres-social" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] sqlx = { version = "0.8.6", features = [ diff --git a/crates/adapters/postgres/Cargo.toml b/crates/adapters/postgres/Cargo.toml index 38402ba4..30627837 100644 --- a/crates/adapters/postgres/Cargo.toml +++ b/crates/adapters/postgres/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "postgres" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] sqlx = { version = "0.8.6", features = [ diff --git a/crates/adapters/rss/Cargo.toml b/crates/adapters/rss/Cargo.toml index 2de1ac45..73207cc5 100644 --- a/crates/adapters/rss/Cargo.toml +++ b/crates/adapters/rss/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "rss" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] rss-feed = { package = "rss", version = "2", features = ["atom"] } diff --git a/crates/adapters/rss/src/tests/lib.rs b/crates/adapters/rss/src/tests/lib.rs index 0fe98bf4..3b282f5e 100644 --- a/crates/adapters/rss/src/tests/lib.rs +++ b/crates/adapters/rss/src/tests/lib.rs @@ -60,7 +60,9 @@ fn channel_links_are_absolute_with_atom_self() { fn item_links_to_absolute_movie_page() { let adapter = RssAdapter::new("http://example.com".into()); let e = entry(false); - let xml = adapter.render_feed(&[e.clone()], &channel("Feed")).unwrap(); + let xml = adapter + .render_feed(std::slice::from_ref(&e), &channel("Feed")) + .unwrap(); let expected = format!( "http://example.com/movies/{}", e.movie().id().value() diff --git a/crates/adapters/sqlite-event-queue/Cargo.toml b/crates/adapters/sqlite-event-queue/Cargo.toml index 30604ac8..df685810 100644 --- a/crates/adapters/sqlite-event-queue/Cargo.toml +++ b/crates/adapters/sqlite-event-queue/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "sqlite-event-queue" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] -sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "macros", "chrono"] } +sqlx = { workspace = true, features = ["macros", "chrono"] } domain = { workspace = true } event-payload = { workspace = true } anyhow = { workspace = true } diff --git a/crates/adapters/sqlite-federation/Cargo.toml b/crates/adapters/sqlite-federation/Cargo.toml index df18d5e8..573f19ad 100644 --- a/crates/adapters/sqlite-federation/Cargo.toml +++ b/crates/adapters/sqlite-federation/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "sqlite-federation" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] sqlx = { workspace = true } diff --git a/crates/adapters/sqlite-social/Cargo.toml b/crates/adapters/sqlite-social/Cargo.toml index d853a233..2fd4adf0 100644 --- a/crates/adapters/sqlite-social/Cargo.toml +++ b/crates/adapters/sqlite-social/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "sqlite-social" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] sqlx = { workspace = true } diff --git a/crates/adapters/sqlite/Cargo.toml b/crates/adapters/sqlite/Cargo.toml index 74079173..b53c5054 100644 --- a/crates/adapters/sqlite/Cargo.toml +++ b/crates/adapters/sqlite/Cargo.toml @@ -1,15 +1,10 @@ [package] name = "sqlite" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] -sqlx = { version = "0.8.6", features = [ - "runtime-tokio-rustls", - "sqlite", - "uuid", - "macros", -] } +sqlx = { workspace = true, features = ["macros"] } adapter-common = { workspace = true } domain = { workspace = true } diff --git a/crates/adapters/template-askama/Cargo.toml b/crates/adapters/template-askama/Cargo.toml index 9b037548..eea631b7 100644 --- a/crates/adapters/template-askama/Cargo.toml +++ b/crates/adapters/template-askama/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "template-askama" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] askama = { version = "0.16.0" } diff --git a/crates/adapters/tmdb-enrichment/Cargo.toml b/crates/adapters/tmdb-enrichment/Cargo.toml index b6d28d49..11fa35c3 100644 --- a/crates/adapters/tmdb-enrichment/Cargo.toml +++ b/crates/adapters/tmdb-enrichment/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tmdb-enrichment" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] domain = { workspace = true } diff --git a/crates/api-types/Cargo.toml b/crates/api-types/Cargo.toml index 322565af..b1185136 100644 --- a/crates/api-types/Cargo.toml +++ b/crates/api-types/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "api-types" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] serde = { workspace = true } uuid = { workspace = true } utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] } -domain = { path = "../domain" } +domain = { workspace = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 50d8e29e..2ff9fe03 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "application" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] async-trait = { workspace = true } diff --git a/crates/composition/Cargo.toml b/crates/composition/Cargo.toml index e4dab622..a9ad821d 100644 --- a/crates/composition/Cargo.toml +++ b/crates/composition/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "composition" version = "0.1.0" -edition = "2024" +edition.workspace = true [features] default = [] diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index 1e2cc03e..c16d2d0f 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "domain" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] uuid = { workspace = true } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 42b7ebc1..30bb5eb8 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -5,5 +5,5 @@ pub mod ports; pub mod services; pub mod value_objects; -#[cfg(any(test, feature = "test-helpers"))] +#[cfg(feature = "test-helpers")] pub mod testing; diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index 56d14455..246c415c 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "presentation" version = "0.1.0" -edition = "2024" +edition.workspace = true description = "Self-hosted movie diary with REST API and ActivityPub federation" license = "MIT" diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 04be03c3..4f5fdacf 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "server" version = "0.1.0" -edition = "2024" +edition.workspace = true description = "Self-hosted movie diary with REST API and ActivityPub federation" license = "MIT" diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 1afb8dcb..af761643 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tui" version = "0.1.0" -edition = "2024" +edition.workspace = true [features] default = [] @@ -16,7 +16,7 @@ windows = ["dep:windows-native-keyring-store"] ratatui = "0.30.0" keyring-core = "1.0.0" directories = "6" -csv = "1" +csv = { workspace = true } apple-native-keyring-store = { version = "1.0.0", optional = true, features = [ "keychain", diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 54809513..ce02a2d5 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "worker" version = "0.1.0" -edition = "2024" +edition.workspace = true [features] default = ["sqlite", "sqlite-federation"] -- 2.49.1 From 59238a36d8d58a28c5b4b5b046d933741551f5c1 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 20:30:28 +0100 Subject: [PATCH 002/104] refactor: typed errors in rss/import ports --- crates/adapters/jellyfin/src/lib.rs | 72 +----------- crates/adapters/jellyfin/src/tests/lib.rs | 68 ++++++++++++ crates/adapters/plex/src/lib.rs | 99 +---------------- crates/adapters/plex/src/tests/lib.rs | 95 ++++++++++++++++ crates/adapters/rss/src/lib.rs | 3 +- crates/api-types/src/social.rs | 26 +---- crates/api-types/src/tests/social.rs | 21 ++++ crates/application/src/import/execute.rs | 35 ++++-- crates/application/src/users/diary_filter.rs | 42 +------ .../src/users/tests/diary_filter.rs | 38 +++++++ crates/domain/src/ports/federation.rs | 46 +------- crates/domain/src/ports/rss.rs | 3 +- crates/domain/src/ports/tests/federation.rs | 42 +++++++ crates/presentation/src/handlers/rss.rs | 6 +- crates/presentation/src/handlers/social.rs | 104 +----------------- .../presentation/src/handlers/tests/social.rs | 100 +++++++++++++++++ crates/presentation/src/tests/extractors.rs | 2 +- 17 files changed, 410 insertions(+), 392 deletions(-) create mode 100644 crates/adapters/jellyfin/src/tests/lib.rs create mode 100644 crates/adapters/plex/src/tests/lib.rs create mode 100644 crates/api-types/src/tests/social.rs create mode 100644 crates/application/src/users/tests/diary_filter.rs create mode 100644 crates/domain/src/ports/tests/federation.rs create mode 100644 crates/presentation/src/handlers/tests/social.rs diff --git a/crates/adapters/jellyfin/src/lib.rs b/crates/adapters/jellyfin/src/lib.rs index 1ee790bf..3ff9cc91 100644 --- a/crates/adapters/jellyfin/src/lib.rs +++ b/crates/adapters/jellyfin/src/lib.rs @@ -60,73 +60,5 @@ struct JellyfinPayload { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_valid_playback_stop() { - let body = serde_json::json!({ - "NotificationType": "PlaybackStop", - "ItemType": "Movie", - "Name": "Blade Runner", - "Year": 1982, - "PlayedToCompletion": true, - "Provider_tmdb": "78", - "Provider_imdb": "tt0083658" - }); - let parser = JellyfinParser; - let result = parser - .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) - .unwrap(); - let event = result.expect("should parse"); - assert_eq!(event.title, "Blade Runner"); - assert_eq!(event.year, Some(1982)); - assert_eq!(event.tmdb_id, Some("tmdb:78".into())); - assert_eq!(event.imdb_id, Some("tt0083658".into())); - } - - #[test] - fn ignores_non_movie() { - let body = serde_json::json!({ - "NotificationType": "PlaybackStop", - "ItemType": "Episode", - "Name": "Some Episode", - "PlayedToCompletion": true - }); - let parser = JellyfinParser; - let result = parser - .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) - .unwrap(); - assert!(result.is_none()); - } - - #[test] - fn ignores_incomplete_playback() { - let body = serde_json::json!({ - "NotificationType": "PlaybackStop", - "ItemType": "Movie", - "Name": "Blade Runner", - "PlayedToCompletion": false - }); - let parser = JellyfinParser; - let result = parser - .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) - .unwrap(); - assert!(result.is_none()); - } - - #[test] - fn ignores_playback_start() { - let body = serde_json::json!({ - "NotificationType": "PlaybackStart", - "ItemType": "Movie", - "Name": "Blade Runner", - "PlayedToCompletion": false - }); - let parser = JellyfinParser; - let result = parser - .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) - .unwrap(); - assert!(result.is_none()); - } -} +#[path = "tests/lib.rs"] +mod tests; diff --git a/crates/adapters/jellyfin/src/tests/lib.rs b/crates/adapters/jellyfin/src/tests/lib.rs new file mode 100644 index 00000000..9511eb78 --- /dev/null +++ b/crates/adapters/jellyfin/src/tests/lib.rs @@ -0,0 +1,68 @@ +use super::*; + +#[test] +fn parses_valid_playback_stop() { + let body = serde_json::json!({ + "NotificationType": "PlaybackStop", + "ItemType": "Movie", + "Name": "Blade Runner", + "Year": 1982, + "PlayedToCompletion": true, + "Provider_tmdb": "78", + "Provider_imdb": "tt0083658" + }); + let parser = JellyfinParser; + let result = parser + .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) + .unwrap(); + let event = result.expect("should parse"); + assert_eq!(event.title, "Blade Runner"); + assert_eq!(event.year, Some(1982)); + assert_eq!(event.tmdb_id, Some("tmdb:78".into())); + assert_eq!(event.imdb_id, Some("tt0083658".into())); +} + +#[test] +fn ignores_non_movie() { + let body = serde_json::json!({ + "NotificationType": "PlaybackStop", + "ItemType": "Episode", + "Name": "Some Episode", + "PlayedToCompletion": true + }); + let parser = JellyfinParser; + let result = parser + .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) + .unwrap(); + assert!(result.is_none()); +} + +#[test] +fn ignores_incomplete_playback() { + let body = serde_json::json!({ + "NotificationType": "PlaybackStop", + "ItemType": "Movie", + "Name": "Blade Runner", + "PlayedToCompletion": false + }); + let parser = JellyfinParser; + let result = parser + .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) + .unwrap(); + assert!(result.is_none()); +} + +#[test] +fn ignores_playback_start() { + let body = serde_json::json!({ + "NotificationType": "PlaybackStart", + "ItemType": "Movie", + "Name": "Blade Runner", + "PlayedToCompletion": false + }); + let parser = JellyfinParser; + let result = parser + .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) + .unwrap(); + assert!(result.is_none()); +} diff --git a/crates/adapters/plex/src/lib.rs b/crates/adapters/plex/src/lib.rs index 685b4108..2c6dfc05 100644 --- a/crates/adapters/plex/src/lib.rs +++ b/crates/adapters/plex/src/lib.rs @@ -70,100 +70,5 @@ struct PlexGuid { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_movie_scrobble() { - let body = serde_json::json!({ - "event": "media.scrobble", - "Metadata": { - "type": "movie", - "title": "Blade Runner", - "year": 1982, - "Guid": [ - {"id": "tmdb://78"}, - {"id": "imdb://tt0083658"} - ] - } - }); - let parser = PlexParser; - let result = parser - .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) - .unwrap(); - let event = result.expect("should parse"); - assert_eq!(event.title, "Blade Runner"); - assert_eq!(event.year, Some(1982)); - assert_eq!(event.tmdb_id, Some("tmdb:78".into())); - assert_eq!(event.imdb_id, Some("tt0083658".into())); - } - - #[test] - fn ignores_tv_episode() { - let body = serde_json::json!({ - "event": "media.scrobble", - "Metadata": { - "type": "episode", - "title": "Pilot", - "grandparentTitle": "Breaking Bad", - "year": 2008, - "Guid": [] - } - }); - let parser = PlexParser; - let result = parser - .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) - .unwrap(); - assert!(result.is_none()); - } - - #[test] - fn ignores_play_event() { - let body = serde_json::json!({ - "event": "media.play", - "Metadata": { - "type": "movie", - "title": "Blade Runner", - "year": 1982, - "Guid": [] - } - }); - let parser = PlexParser; - let result = parser - .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) - .unwrap(); - assert!(result.is_none()); - } - - #[test] - fn handles_no_guids() { - let body = serde_json::json!({ - "event": "media.scrobble", - "Metadata": { - "type": "movie", - "title": "Some Indie Film", - "year": 2023 - } - }); - let parser = PlexParser; - let result = parser - .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) - .unwrap(); - let event = result.expect("should parse"); - assert_eq!(event.title, "Some Indie Film"); - assert!(event.tmdb_id.is_none()); - assert!(event.imdb_id.is_none()); - } - - #[test] - fn handles_missing_metadata() { - let body = serde_json::json!({ - "event": "media.scrobble" - }); - let parser = PlexParser; - let result = parser - .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) - .unwrap(); - assert!(result.is_none()); - } -} +#[path = "tests/lib.rs"] +mod tests; diff --git a/crates/adapters/plex/src/tests/lib.rs b/crates/adapters/plex/src/tests/lib.rs new file mode 100644 index 00000000..2459f256 --- /dev/null +++ b/crates/adapters/plex/src/tests/lib.rs @@ -0,0 +1,95 @@ +use super::*; + +#[test] +fn parses_movie_scrobble() { + let body = serde_json::json!({ + "event": "media.scrobble", + "Metadata": { + "type": "movie", + "title": "Blade Runner", + "year": 1982, + "Guid": [ + {"id": "tmdb://78"}, + {"id": "imdb://tt0083658"} + ] + } + }); + let parser = PlexParser; + let result = parser + .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) + .unwrap(); + let event = result.expect("should parse"); + assert_eq!(event.title, "Blade Runner"); + assert_eq!(event.year, Some(1982)); + assert_eq!(event.tmdb_id, Some("tmdb:78".into())); + assert_eq!(event.imdb_id, Some("tt0083658".into())); +} + +#[test] +fn ignores_tv_episode() { + let body = serde_json::json!({ + "event": "media.scrobble", + "Metadata": { + "type": "episode", + "title": "Pilot", + "grandparentTitle": "Breaking Bad", + "year": 2008, + "Guid": [] + } + }); + let parser = PlexParser; + let result = parser + .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) + .unwrap(); + assert!(result.is_none()); +} + +#[test] +fn ignores_play_event() { + let body = serde_json::json!({ + "event": "media.play", + "Metadata": { + "type": "movie", + "title": "Blade Runner", + "year": 1982, + "Guid": [] + } + }); + let parser = PlexParser; + let result = parser + .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) + .unwrap(); + assert!(result.is_none()); +} + +#[test] +fn handles_no_guids() { + let body = serde_json::json!({ + "event": "media.scrobble", + "Metadata": { + "type": "movie", + "title": "Some Indie Film", + "year": 2023 + } + }); + let parser = PlexParser; + let result = parser + .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) + .unwrap(); + let event = result.expect("should parse"); + assert_eq!(event.title, "Some Indie Film"); + assert!(event.tmdb_id.is_none()); + assert!(event.imdb_id.is_none()); +} + +#[test] +fn handles_missing_metadata() { + let body = serde_json::json!({ + "event": "media.scrobble" + }); + let parser = PlexParser; + let result = parser + .parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice()) + .unwrap(); + assert!(result.is_none()); +} diff --git a/crates/adapters/rss/src/lib.rs b/crates/adapters/rss/src/lib.rs index d11d5ff8..87057c4e 100644 --- a/crates/adapters/rss/src/lib.rs +++ b/crates/adapters/rss/src/lib.rs @@ -1,3 +1,4 @@ +use domain::errors::DomainError; use domain::models::DiaryEntry; use domain::ports::{FeedChannel, RssFeedRenderer}; use rss_feed::extension::atom::{self, AtomExtensionBuilder, Link}; @@ -24,7 +25,7 @@ impl RssFeedRenderer for RssAdapter { &self, entries: &[DiaryEntry], channel: &FeedChannel<'_>, - ) -> Result { + ) -> Result { let items = entries .iter() .map(|e| { diff --git a/crates/api-types/src/social.rs b/crates/api-types/src/social.rs index 94af24e0..f7172b78 100644 --- a/crates/api-types/src/social.rs +++ b/crates/api-types/src/social.rs @@ -78,28 +78,6 @@ pub struct PendingCountResponse { pub count: usize, } -//todo: move out to separate file #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn follow_state_dto_serializes_to_lowercase_strings() { - assert_eq!( - serde_json::to_string(&FollowStateDto::None).unwrap(), - "\"none\"" - ); - assert_eq!( - serde_json::to_string(&FollowStateDto::Pending).unwrap(), - "\"pending\"" - ); - assert_eq!( - serde_json::to_string(&FollowStateDto::Accepted).unwrap(), - "\"accepted\"" - ); - assert_eq!( - serde_json::to_string(&FollowStateDto::Rejected).unwrap(), - "\"rejected\"" - ); - } -} +#[path = "tests/social.rs"] +mod tests; diff --git a/crates/api-types/src/tests/social.rs b/crates/api-types/src/tests/social.rs new file mode 100644 index 00000000..2e09621b --- /dev/null +++ b/crates/api-types/src/tests/social.rs @@ -0,0 +1,21 @@ +use super::*; + +#[test] +fn follow_state_dto_serializes_to_lowercase_strings() { + assert_eq!( + serde_json::to_string(&FollowStateDto::None).unwrap(), + "\"none\"" + ); + assert_eq!( + serde_json::to_string(&FollowStateDto::Pending).unwrap(), + "\"pending\"" + ); + assert_eq!( + serde_json::to_string(&FollowStateDto::Accepted).unwrap(), + "\"accepted\"" + ); + assert_eq!( + serde_json::to_string(&FollowStateDto::Rejected).unwrap(), + "\"rejected\"" + ); +} diff --git a/crates/application/src/import/execute.rs b/crates/application/src/import/execute.rs index 6480cde3..73df7b7a 100644 --- a/crates/application/src/import/execute.rs +++ b/crates/application/src/import/execute.rs @@ -44,7 +44,8 @@ pub async fn execute( let mut failed = Vec::new(); let semaphore = Arc::new(tokio::sync::Semaphore::new(CONCURRENCY_LIMIT)); - let mut tasks: tokio::task::JoinSet<(usize, Result<(), String>)> = tokio::task::JoinSet::new(); + let mut tasks: tokio::task::JoinSet<(usize, Result<(), DomainError>)> = + tokio::task::JoinSet::new(); for (idx, annotated) in row_results.into_iter().enumerate() { if !confirmed_set.contains(&idx) { @@ -56,12 +57,12 @@ pub async fn execute( failed.push((idx, errors.join("; "))); } RowResult::Valid(row) => match row_to_command(&row, user_id.value()) { - Err(e) => failed.push((idx, e)), + Err(e) => failed.push((idx, import_error_message(e))), Ok(log_cmd) => { let permit = Arc::clone(&semaphore).acquire_owned().await.unwrap(); let logger = deps.review_logger.clone(); tasks.spawn(async move { - let result = logger.log_review(log_cmd).await.map_err(|e| e.to_string()); + let result = logger.log_review(log_cmd).await; drop(permit); (idx, result) }); @@ -74,7 +75,7 @@ pub async fn execute( let (idx, outcome) = res.expect("import task panicked"); match outcome { Ok(()) => imported += 1, - Err(e) => failed.push((idx, e)), + Err(e) => failed.push((idx, e.to_string())), } } @@ -91,20 +92,36 @@ pub async fn execute( #[path = "tests/execute.rs"] mod tests; -fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result { +/// Row-level validation failures keep their bare message in the summary. +fn import_error_message(e: DomainError) -> String { + match e { + DomainError::ValidationError(msg) => msg, + other => other.to_string(), + } +} + +fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result { let rating = row .rating .as_deref() - .ok_or("missing rating")? + .ok_or_else(|| DomainError::ValidationError("missing rating".into()))? .parse::() - .map_err(|_| "rating is not a valid u8".to_string())?; + .map_err(|_| DomainError::ValidationError("rating is not a valid u8".into()))?; - let watched_at_str = row.watched_at.as_deref().ok_or("missing watched_at")?; + let watched_at_str = row + .watched_at + .as_deref() + .ok_or_else(|| DomainError::ValidationError("missing watched_at".into()))?; let watched_at = NaiveDateTime::parse_from_str(&format!("{} 00:00:00", watched_at_str), "%Y-%m-%d %H:%M:%S") .or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%d %H:%M:%S")) .or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%dT%H:%M:%S")) - .map_err(|_| format!("cannot parse watched_at: '{}'", watched_at_str))?; + .map_err(|_| { + DomainError::ValidationError(format!( + "cannot parse watched_at: '{}'", + watched_at_str + )) + })?; Ok(LogReviewCommand { user_id, diff --git a/crates/application/src/users/diary_filter.rs b/crates/application/src/users/diary_filter.rs index 326b9cf2..e52744e8 100644 --- a/crates/application/src/users/diary_filter.rs +++ b/crates/application/src/users/diary_filter.rs @@ -38,43 +38,5 @@ pub(super) fn paged_user_filter( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn feed_sort_to_direction_all_variants() { - assert!(matches!( - feed_sort_to_direction(FeedSortBy::Date), - ReviewSortBy::Descending - )); - assert!(matches!( - feed_sort_to_direction(FeedSortBy::DateAsc), - ReviewSortBy::Ascending - )); - assert!(matches!( - feed_sort_to_direction(FeedSortBy::Rating), - ReviewSortBy::ByRatingDesc - )); - assert!(matches!( - feed_sort_to_direction(FeedSortBy::RatingAsc), - ReviewSortBy::ByRatingAsc - )); - } - - #[test] - fn paged_user_filter_builds_correctly() { - let uid = UserId::from_uuid(uuid::Uuid::new_v4()); - let filter = paged_user_filter( - uid.clone(), - ReviewSortBy::Descending, - Some(20), - Some(5), - Some("blade".into()), - false, - ) - .unwrap(); - - assert_eq!(filter.user_id.unwrap().value(), uid.value()); - assert_eq!(filter.search.as_deref(), Some("blade")); - } -} +#[path = "tests/diary_filter.rs"] +mod tests; diff --git a/crates/application/src/users/tests/diary_filter.rs b/crates/application/src/users/tests/diary_filter.rs new file mode 100644 index 00000000..1bdbe433 --- /dev/null +++ b/crates/application/src/users/tests/diary_filter.rs @@ -0,0 +1,38 @@ +use super::*; + +#[test] +fn feed_sort_to_direction_all_variants() { + assert!(matches!( + feed_sort_to_direction(FeedSortBy::Date), + ReviewSortBy::Descending + )); + assert!(matches!( + feed_sort_to_direction(FeedSortBy::DateAsc), + ReviewSortBy::Ascending + )); + assert!(matches!( + feed_sort_to_direction(FeedSortBy::Rating), + ReviewSortBy::ByRatingDesc + )); + assert!(matches!( + feed_sort_to_direction(FeedSortBy::RatingAsc), + ReviewSortBy::ByRatingAsc + )); +} + +#[test] +fn paged_user_filter_builds_correctly() { + let uid = UserId::from_uuid(uuid::Uuid::new_v4()); + let filter = paged_user_filter( + uid.clone(), + ReviewSortBy::Descending, + Some(20), + Some(5), + Some("blade".into()), + false, + ) + .unwrap(); + + assert_eq!(filter.user_id.unwrap().value(), uid.value()); + assert_eq!(filter.search.as_deref(), Some("blade")); +} diff --git a/crates/domain/src/ports/federation.rs b/crates/domain/src/ports/federation.rs index 6ec92eb4..9c944d8a 100644 --- a/crates/domain/src/ports/federation.rs +++ b/crates/domain/src/ports/federation.rs @@ -51,47 +51,5 @@ pub trait ApBackfillPort: Send + Sync { } #[cfg(test)] -mod tests { - use super::*; - use crate::ports::noop::{NoopApBackfill, NoopApDocument, NoopInstanceBlocklist}; - - #[tokio::test] - async fn noop_document_returns_empty_strings() { - let p = NoopApDocument; - assert_eq!(p.actor_json("anything").await.unwrap(), ""); - assert_eq!( - p.followers_collection_json(uuid::Uuid::nil(), None) - .await - .unwrap(), - "" - ); - assert_eq!( - p.following_collection_json(uuid::Uuid::nil(), Some(2)) - .await - .unwrap(), - "" - ); - } - - #[tokio::test] - async fn noop_blocklist_returns_empty_and_ok() { - let p = NoopInstanceBlocklist; - assert!(p.get_blocked_domains().await.unwrap().is_empty()); - p.add_blocked_domain("evil.example", Some("spam")) - .await - .unwrap(); - p.remove_blocked_domain("evil.example").await.unwrap(); - } - - #[tokio::test] - async fn noop_backfill_returns_empty_and_ok() { - let p = NoopApBackfill; - assert!(p.get_following(uuid::Uuid::nil()).await.unwrap().is_empty()); - p.import_remote_outbox("https://a/outbox", "https://a") - .await - .unwrap(); - p.run_backfill_for_follower(uuid::Uuid::nil(), "https://a/inbox".into()) - .await - .unwrap(); - } -} +#[path = "tests/federation.rs"] +mod tests; diff --git a/crates/domain/src/ports/rss.rs b/crates/domain/src/ports/rss.rs index 3062a934..1e4a37f0 100644 --- a/crates/domain/src/ports/rss.rs +++ b/crates/domain/src/ports/rss.rs @@ -1,3 +1,4 @@ +use crate::errors::DomainError; use crate::models::DiaryEntry; /// Channel-level metadata for a rendered feed. Paths are site-relative @@ -15,5 +16,5 @@ pub trait RssFeedRenderer: Send + Sync { &self, entries: &[DiaryEntry], channel: &FeedChannel<'_>, - ) -> Result; + ) -> Result; } diff --git a/crates/domain/src/ports/tests/federation.rs b/crates/domain/src/ports/tests/federation.rs new file mode 100644 index 00000000..052cf14b --- /dev/null +++ b/crates/domain/src/ports/tests/federation.rs @@ -0,0 +1,42 @@ +use super::*; +use crate::ports::noop::{NoopApBackfill, NoopApDocument, NoopInstanceBlocklist}; + +#[tokio::test] +async fn noop_document_returns_empty_strings() { + let p = NoopApDocument; + assert_eq!(p.actor_json("anything").await.unwrap(), ""); + assert_eq!( + p.followers_collection_json(uuid::Uuid::nil(), None) + .await + .unwrap(), + "" + ); + assert_eq!( + p.following_collection_json(uuid::Uuid::nil(), Some(2)) + .await + .unwrap(), + "" + ); +} + +#[tokio::test] +async fn noop_blocklist_returns_empty_and_ok() { + let p = NoopInstanceBlocklist; + assert!(p.get_blocked_domains().await.unwrap().is_empty()); + p.add_blocked_domain("evil.example", Some("spam")) + .await + .unwrap(); + p.remove_blocked_domain("evil.example").await.unwrap(); +} + +#[tokio::test] +async fn noop_backfill_returns_empty_and_ok() { + let p = NoopApBackfill; + assert!(p.get_following(uuid::Uuid::nil()).await.unwrap().is_empty()); + p.import_remote_outbox("https://a/outbox", "https://a") + .await + .unwrap(); + p.run_backfill_for_follower(uuid::Uuid::nil(), "https://a/inbox".into()) + .await + .unwrap(); +} diff --git a/crates/presentation/src/handlers/rss.rs b/crates/presentation/src/handlers/rss.rs index ed4d13e4..847b73ff 100644 --- a/crates/presentation/src/handlers/rss.rs +++ b/crates/presentation/src/handlers/rss.rs @@ -6,7 +6,7 @@ use axum::{ use uuid::Uuid; use application::{diary::get_diary, diary::get_user_feed, diary::queries::GetDiaryQuery}; -use domain::{errors::DomainError, models::ReviewSortBy, ports::FeedChannel}; +use domain::{models::ReviewSortBy, ports::FeedChannel}; use crate::{errors::ApiError, state::AppState}; @@ -29,7 +29,7 @@ pub async fn get_feed(State(state): State) -> Result SocialActor { - SocialActor { - identity: SocialIdentity::Local(uid.clone()), - handle: "@gabriel@md.example".into(), - display_name: None, - avatar_url: None, - } - } - - #[test] - fn local_actor_dto_url_round_trips_back_to_the_same_identity() { - let instance = InstanceIdentity::new("https://md.example"); - let uid = UserId::from_uuid(uuid::Uuid::new_v4()); - - let dto = social_actor_to_dto(local_actor(&uid), &instance); - - assert!(!dto.url.starts_with("local:"), "url was {}", dto.url); - assert_eq!( - instance.identify(&dto.url), - SocialIdentity::Local(uid), - "a local actor's dto url must parse back as Local" - ); - } - - #[cfg(feature = "federation")] - #[test] - fn local_blocked_dto_url_round_trips_back_to_the_same_identity() { - let instance = InstanceIdentity::new("https://md.example"); - let uid = UserId::from_uuid(uuid::Uuid::new_v4()); - - let dto = social_actor_to_blocked_dto(local_actor(&uid), &instance); - - assert!(!dto.url.starts_with("local:"), "url was {}", dto.url); - assert_eq!(instance.identify(&dto.url), SocialIdentity::Local(uid)); - } - - #[test] - fn local_template_url_round_trips_back_to_the_same_identity() { - let instance = InstanceIdentity::new("https://md.example"); - let uid = UserId::from_uuid(uuid::Uuid::new_v4()); - - let data = social_actor_to_template(local_actor(&uid), &instance); - - assert!(!data.url.starts_with("local:"), "url was {}", data.url); - assert_eq!(instance.identify(&data.url), SocialIdentity::Local(uid)); - } - - #[test] - fn remote_actor_dto_url_is_passed_through_unchanged() { - let instance = InstanceIdentity::new("https://md.example"); - let actor = SocialActor { - identity: SocialIdentity::Remote { - actor_url: "https://other.example/users/bob".into(), - }, - handle: "@bob@other.example".into(), - display_name: None, - avatar_url: None, - }; - - let dto = social_actor_to_dto(actor, &instance); - - assert_eq!(dto.url, "https://other.example/users/bob"); - } - - #[test] - fn local_actor_dto_carries_user_id_and_avatar() { - let instance = InstanceIdentity::new("https://md.example"); - let uid = UserId::from_uuid(uuid::Uuid::new_v4()); - let mut actor = local_actor(&uid); - actor.avatar_url = Some("https://md.example/images/a.webp".into()); - - let dto = social_actor_to_dto(actor, &instance); - - assert_eq!( - dto.user_id, - Some(uid.value()), - "local actors must carry user_id for internal links" - ); - assert_eq!( - dto.avatar_url.as_deref(), - Some("https://md.example/images/a.webp") - ); - } - - #[test] - fn remote_actor_dto_has_no_user_id() { - let instance = InstanceIdentity::new("https://md.example"); - let actor = SocialActor { - identity: SocialIdentity::Remote { - actor_url: "https://other.example/users/bob".into(), - }, - handle: "@bob@other.example".into(), - display_name: None, - avatar_url: None, - }; - assert_eq!(social_actor_to_dto(actor, &instance).user_id, None); - } -} +#[path = "tests/social.rs"] +mod tests; diff --git a/crates/presentation/src/handlers/tests/social.rs b/crates/presentation/src/handlers/tests/social.rs new file mode 100644 index 00000000..5c4999bf --- /dev/null +++ b/crates/presentation/src/handlers/tests/social.rs @@ -0,0 +1,100 @@ +use super::*; +use domain::value_objects::{InstanceIdentity, SocialActor, SocialIdentity, UserId}; + +fn local_actor(uid: &UserId) -> SocialActor { + SocialActor { + identity: SocialIdentity::Local(uid.clone()), + handle: "@gabriel@md.example".into(), + display_name: None, + avatar_url: None, + } +} + +#[test] +fn local_actor_dto_url_round_trips_back_to_the_same_identity() { + let instance = InstanceIdentity::new("https://md.example"); + let uid = UserId::from_uuid(uuid::Uuid::new_v4()); + + let dto = social_actor_to_dto(local_actor(&uid), &instance); + + assert!(!dto.url.starts_with("local:"), "url was {}", dto.url); + assert_eq!( + instance.identify(&dto.url), + SocialIdentity::Local(uid), + "a local actor's dto url must parse back as Local" + ); +} + +#[cfg(feature = "federation")] +#[test] +fn local_blocked_dto_url_round_trips_back_to_the_same_identity() { + let instance = InstanceIdentity::new("https://md.example"); + let uid = UserId::from_uuid(uuid::Uuid::new_v4()); + + let dto = social_actor_to_blocked_dto(local_actor(&uid), &instance); + + assert!(!dto.url.starts_with("local:"), "url was {}", dto.url); + assert_eq!(instance.identify(&dto.url), SocialIdentity::Local(uid)); +} + +#[test] +fn local_template_url_round_trips_back_to_the_same_identity() { + let instance = InstanceIdentity::new("https://md.example"); + let uid = UserId::from_uuid(uuid::Uuid::new_v4()); + + let data = social_actor_to_template(local_actor(&uid), &instance); + + assert!(!data.url.starts_with("local:"), "url was {}", data.url); + assert_eq!(instance.identify(&data.url), SocialIdentity::Local(uid)); +} + +#[test] +fn remote_actor_dto_url_is_passed_through_unchanged() { + let instance = InstanceIdentity::new("https://md.example"); + let actor = SocialActor { + identity: SocialIdentity::Remote { + actor_url: "https://other.example/users/bob".into(), + }, + handle: "@bob@other.example".into(), + display_name: None, + avatar_url: None, + }; + + let dto = social_actor_to_dto(actor, &instance); + + assert_eq!(dto.url, "https://other.example/users/bob"); +} + +#[test] +fn local_actor_dto_carries_user_id_and_avatar() { + let instance = InstanceIdentity::new("https://md.example"); + let uid = UserId::from_uuid(uuid::Uuid::new_v4()); + let mut actor = local_actor(&uid); + actor.avatar_url = Some("https://md.example/images/a.webp".into()); + + let dto = social_actor_to_dto(actor, &instance); + + assert_eq!( + dto.user_id, + Some(uid.value()), + "local actors must carry user_id for internal links" + ); + assert_eq!( + dto.avatar_url.as_deref(), + Some("https://md.example/images/a.webp") + ); +} + +#[test] +fn remote_actor_dto_has_no_user_id() { + let instance = InstanceIdentity::new("https://md.example"); + let actor = SocialActor { + identity: SocialIdentity::Remote { + actor_url: "https://other.example/users/bob".into(), + }, + handle: "@bob@other.example".into(), + display_name: None, + avatar_url: None, + }; + assert_eq!(social_actor_to_dto(actor, &instance).user_id, None); +} diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index 8a2cd575..58ae5c3a 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -420,7 +420,7 @@ impl domain::ports::RssFeedRenderer for Panic { &self, _: &[DiaryEntry], _: &domain::ports::FeedChannel<'_>, - ) -> Result { + ) -> Result { panic!() } } -- 2.49.1 From 933400e1c2920b69cb2c240764c4b292ce31cfe1 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 20:34:07 +0100 Subject: [PATCH 003/104] refactor: drop postgres backend --- Cargo.lock | 91 --- Cargo.toml | 10 - .../adapters/postgres-event-queue/Cargo.toml | 15 - .../migrations/0100_event_queue.sql | 13 - .../adapters/postgres-event-queue/src/lib.rs | 233 ------- .../postgres-event-queue/src/migrations.rs | 7 - .../postgres-event-queue/src/payload.rs | 1 - .../adapters/postgres-federation/Cargo.toml | 24 - .../postgres-federation/src/activity.rs | 30 - .../adapters/postgres-federation/src/actor.rs | 102 ---- .../postgres-federation/src/blocklist.rs | 91 --- .../src/follow/followers.rs | 197 ------ .../src/follow/following.rs | 106 ---- .../src/follow/migration.rs | 27 - .../postgres-federation/src/follow/mod.rs | 3 - .../adapters/postgres-federation/src/lib.rs | 85 --- .../postgres-federation/src/review.rs | 112 ---- crates/adapters/postgres-search/Cargo.toml | 11 - crates/adapters/postgres-search/src/lib.rs | 353 ----------- crates/adapters/postgres-social/Cargo.toml | 18 - .../postgres-social/src/ap_content.rs | 301 --------- .../postgres-social/src/federated_profile.rs | 58 -- .../postgres-social/src/follow_repository.rs | 354 ----------- crates/adapters/postgres-social/src/lib.rs | 30 - .../postgres-social/src/remote_goals.rs | 114 ---- crates/adapters/postgres-social/src/social.rs | 21 - .../adapters/postgres-social/src/watchlist.rs | 91 --- crates/adapters/postgres/Cargo.toml | 27 - .../postgres/migrations/0001_initial.sql | 69 --- .../postgres/migrations/0002_import.sql | 21 - .../postgres/migrations/0009_user_profile.sql | 2 - .../0010_ap_remote_actor_avatar.sql | 1 - .../postgres/migrations/0011_ap_announces.sql | 8 - .../migrations/0012_blocked_domains.sql | 5 - .../migrations/0013_blocked_actors.sql | 6 - .../migrations/0014_movie_profiles.sql | 54 -- .../postgres/migrations/0015_search.sql | 25 - .../postgres/migrations/0016_watchlist.sql | 9 - .../migrations/0017_ap_remote_watchlist.sql | 12 - .../0018_remote_actor_outbox_url.sql | 1 - .../migrations/0019_profile_extended.sql | 13 - .../postgres/migrations/0020_kap_v03.sql | 12 - .../migrations/0021_user_display_name.sql | 1 - .../migrations/0022_instance_actor.sql | 10 - .../postgres/migrations/0023_watch_events.sql | 28 - .../postgres/migrations/0024_wrap_up.sql | 12 - .../postgres/migrations/0025_goals.sql | 25 - .../migrations/0026_person_enrichment.sql | 8 - .../migrations/0027_refresh_sessions.sql | 10 - .../migrations/0028_federation_privacy.sql | 2 - .../postgres/migrations/0029_watch_medium.sql | 1 - .../migrations/0030_review_spoilers.sql | 1 - crates/adapters/postgres/src/diary.rs | 570 ------------------ crates/adapters/postgres/src/goals.rs | 189 ------ crates/adapters/postgres/src/image_ref.rs | 66 -- .../adapters/postgres/src/import_profile.rs | 212 ------- .../adapters/postgres/src/import_session.rs | 387 ------------ crates/adapters/postgres/src/insights.rs | 81 --- crates/adapters/postgres/src/lib.rs | 127 ---- crates/adapters/postgres/src/models.rs | 292 --------- crates/adapters/postgres/src/movie.rs | 253 -------- crates/adapters/postgres/src/movie_dedup.rs | 147 ----- crates/adapters/postgres/src/persons.rs | 339 ----------- crates/adapters/postgres/src/profile.rs | 284 --------- .../adapters/postgres/src/profile_fields.rs | 74 --- .../adapters/postgres/src/refresh_sessions.rs | 108 ---- crates/adapters/postgres/src/review.rs | 130 ---- crates/adapters/postgres/src/stats.rs | 239 -------- crates/adapters/postgres/src/user_settings.rs | 99 --- crates/adapters/postgres/src/users.rs | 200 ------ crates/adapters/postgres/src/watch_event.rs | 384 ------------ crates/adapters/postgres/src/watchlist.rs | 189 ------ crates/adapters/postgres/src/wrapup.rs | 508 ---------------- crates/composition/Cargo.toml | 7 - crates/composition/src/factory.rs | 169 ++---- crates/infra-wiring/Cargo.toml | 1 - crates/infra-wiring/src/lib.rs | 2 - crates/server/Cargo.toml | 14 - crates/server/src/main.rs | 41 +- crates/worker/Cargo.toml | 12 - crates/worker/src/event_bus.rs | 4 - crates/worker/src/main.rs | 25 +- 82 files changed, 77 insertions(+), 7937 deletions(-) delete mode 100644 crates/adapters/postgres-event-queue/Cargo.toml delete mode 100644 crates/adapters/postgres-event-queue/migrations/0100_event_queue.sql delete mode 100644 crates/adapters/postgres-event-queue/src/lib.rs delete mode 100644 crates/adapters/postgres-event-queue/src/migrations.rs delete mode 100644 crates/adapters/postgres-event-queue/src/payload.rs delete mode 100644 crates/adapters/postgres-federation/Cargo.toml delete mode 100644 crates/adapters/postgres-federation/src/activity.rs delete mode 100644 crates/adapters/postgres-federation/src/actor.rs delete mode 100644 crates/adapters/postgres-federation/src/blocklist.rs delete mode 100644 crates/adapters/postgres-federation/src/follow/followers.rs delete mode 100644 crates/adapters/postgres-federation/src/follow/following.rs delete mode 100644 crates/adapters/postgres-federation/src/follow/migration.rs delete mode 100644 crates/adapters/postgres-federation/src/follow/mod.rs delete mode 100644 crates/adapters/postgres-federation/src/lib.rs delete mode 100644 crates/adapters/postgres-federation/src/review.rs delete mode 100644 crates/adapters/postgres-search/Cargo.toml delete mode 100644 crates/adapters/postgres-search/src/lib.rs delete mode 100644 crates/adapters/postgres-social/Cargo.toml delete mode 100644 crates/adapters/postgres-social/src/ap_content.rs delete mode 100644 crates/adapters/postgres-social/src/federated_profile.rs delete mode 100644 crates/adapters/postgres-social/src/follow_repository.rs delete mode 100644 crates/adapters/postgres-social/src/lib.rs delete mode 100644 crates/adapters/postgres-social/src/remote_goals.rs delete mode 100644 crates/adapters/postgres-social/src/social.rs delete mode 100644 crates/adapters/postgres-social/src/watchlist.rs delete mode 100644 crates/adapters/postgres/Cargo.toml delete mode 100644 crates/adapters/postgres/migrations/0001_initial.sql delete mode 100644 crates/adapters/postgres/migrations/0002_import.sql delete mode 100644 crates/adapters/postgres/migrations/0009_user_profile.sql delete mode 100644 crates/adapters/postgres/migrations/0010_ap_remote_actor_avatar.sql delete mode 100644 crates/adapters/postgres/migrations/0011_ap_announces.sql delete mode 100644 crates/adapters/postgres/migrations/0012_blocked_domains.sql delete mode 100644 crates/adapters/postgres/migrations/0013_blocked_actors.sql delete mode 100644 crates/adapters/postgres/migrations/0014_movie_profiles.sql delete mode 100644 crates/adapters/postgres/migrations/0015_search.sql delete mode 100644 crates/adapters/postgres/migrations/0016_watchlist.sql delete mode 100644 crates/adapters/postgres/migrations/0017_ap_remote_watchlist.sql delete mode 100644 crates/adapters/postgres/migrations/0018_remote_actor_outbox_url.sql delete mode 100644 crates/adapters/postgres/migrations/0019_profile_extended.sql delete mode 100644 crates/adapters/postgres/migrations/0020_kap_v03.sql delete mode 100644 crates/adapters/postgres/migrations/0021_user_display_name.sql delete mode 100644 crates/adapters/postgres/migrations/0022_instance_actor.sql delete mode 100644 crates/adapters/postgres/migrations/0023_watch_events.sql delete mode 100644 crates/adapters/postgres/migrations/0024_wrap_up.sql delete mode 100644 crates/adapters/postgres/migrations/0025_goals.sql delete mode 100644 crates/adapters/postgres/migrations/0026_person_enrichment.sql delete mode 100644 crates/adapters/postgres/migrations/0027_refresh_sessions.sql delete mode 100644 crates/adapters/postgres/migrations/0028_federation_privacy.sql delete mode 100644 crates/adapters/postgres/migrations/0029_watch_medium.sql delete mode 100644 crates/adapters/postgres/migrations/0030_review_spoilers.sql delete mode 100644 crates/adapters/postgres/src/diary.rs delete mode 100644 crates/adapters/postgres/src/goals.rs delete mode 100644 crates/adapters/postgres/src/image_ref.rs delete mode 100644 crates/adapters/postgres/src/import_profile.rs delete mode 100644 crates/adapters/postgres/src/import_session.rs delete mode 100644 crates/adapters/postgres/src/insights.rs delete mode 100644 crates/adapters/postgres/src/lib.rs delete mode 100644 crates/adapters/postgres/src/models.rs delete mode 100644 crates/adapters/postgres/src/movie.rs delete mode 100644 crates/adapters/postgres/src/movie_dedup.rs delete mode 100644 crates/adapters/postgres/src/persons.rs delete mode 100644 crates/adapters/postgres/src/profile.rs delete mode 100644 crates/adapters/postgres/src/profile_fields.rs delete mode 100644 crates/adapters/postgres/src/refresh_sessions.rs delete mode 100644 crates/adapters/postgres/src/review.rs delete mode 100644 crates/adapters/postgres/src/stats.rs delete mode 100644 crates/adapters/postgres/src/user_settings.rs delete mode 100644 crates/adapters/postgres/src/users.rs delete mode 100644 crates/adapters/postgres/src/watch_event.rs delete mode 100644 crates/adapters/postgres/src/watchlist.rs delete mode 100644 crates/adapters/postgres/src/wrapup.rs diff --git a/Cargo.lock b/Cargo.lock index b4736427..839c63fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1147,11 +1147,6 @@ dependencies = [ "object-storage", "plex", "poster-fetcher", - "postgres", - "postgres-event-queue", - "postgres-federation", - "postgres-search", - "postgres-social", "sqlite", "sqlite-event-queue", "sqlite-federation", @@ -3956,83 +3951,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "postgres" -version = "0.1.0" -dependencies = [ - "adapter-common", - "anyhow", - "async-stream", - "async-trait", - "bytes", - "chrono", - "domain", - "futures", - "postgres-social", - "serde", - "serde_json", - "sqlx", - "tokio", - "tracing", - "uuid", -] - -[[package]] -name = "postgres-event-queue" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "domain", - "event-payload", - "futures", - "serde_json", - "sqlx", - "tokio", - "tracing", -] - -[[package]] -name = "postgres-federation" -version = "0.1.0" -dependencies = [ - "activitypub", - "adapter-common", - "anyhow", - "async-trait", - "chrono", - "domain", - "k-ap", - "postgres-social", - "serde_json", - "sqlx", - "tracing", - "uuid", -] - -[[package]] -name = "postgres-search" -version = "0.1.0" -dependencies = [ - "adapter-common", - "async-trait", - "domain", - "sqlx", - "uuid", -] - -[[package]] -name = "postgres-social" -version = "0.1.0" -dependencies = [ - "adapter-common", - "async-trait", - "chrono", - "domain", - "sqlx", - "uuid", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -5027,11 +4945,6 @@ dependencies = [ "nats", "object-storage", "poster-fetcher", - "postgres", - "postgres-event-queue", - "postgres-federation", - "postgres-search", - "postgres-social", "presentation", "rss 0.1.0", "serde_json", @@ -7289,10 +7202,6 @@ dependencies = [ "object-storage", "poster-fetcher", "poster-sync", - "postgres", - "postgres-event-queue", - "postgres-federation", - "postgres-search", "sqlite", "sqlite-event-queue", "sqlite-federation", diff --git a/Cargo.toml b/Cargo.toml index 4c4c940a..a718d83e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,13 +8,9 @@ members = [ "crates/adapters/poster-sync", "crates/adapters/rss", "crates/adapters/sqlite", - "crates/adapters/postgres", "crates/adapters/sqlite-federation", "crates/adapters/sqlite-social", - "crates/adapters/postgres-federation", - "crates/adapters/postgres-social", "crates/adapters/sqlite-event-queue", - "crates/adapters/postgres-event-queue", "crates/adapters/template-askama", "crates/adapters/activitypub", "crates/adapters/export", @@ -34,7 +30,6 @@ members = [ "crates/adapters/jellyfin", "crates/adapters/plex", "crates/adapters/sqlite-search", - "crates/adapters/postgres-search", "crates/adapters/adapter-common", "crates/infra-wiring", ] @@ -89,21 +84,16 @@ export = { path = "crates/adapters/export" } sqlite = { path = "crates/adapters/sqlite" } sqlite-federation = { path = "crates/adapters/sqlite-federation" } sqlite-social = { path = "crates/adapters/sqlite-social" } -postgres = { path = "crates/adapters/postgres" } -postgres-federation = { path = "crates/adapters/postgres-federation" } -postgres-social = { path = "crates/adapters/postgres-social" } template-askama = { path = "crates/adapters/template-askama" } activitypub = { path = "crates/adapters/activitypub" } event-payload = { path = "crates/adapters/event-payload" } nats = { path = "crates/adapters/nats" } sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" } -postgres-event-queue = { path = "crates/adapters/postgres-event-queue" } importer = { path = "crates/adapters/importer" } jellyfin = { path = "crates/adapters/jellyfin" } plex = { path = "crates/adapters/plex" } image-converter = { path = "crates/adapters/image-converter" } sqlite-search = { path = "crates/adapters/sqlite-search" } -postgres-search = { path = "crates/adapters/postgres-search" } adapter-common = { path = "crates/adapters/adapter-common" } infra-wiring = { path = "crates/infra-wiring" } diff --git a/crates/adapters/postgres-event-queue/Cargo.toml b/crates/adapters/postgres-event-queue/Cargo.toml deleted file mode 100644 index 83f23d11..00000000 --- a/crates/adapters/postgres-event-queue/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "postgres-event-queue" -version = "0.1.0" -edition.workspace = true - -[dependencies] -sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "macros", "chrono"] } -domain = { workspace = true } -event-payload = { workspace = true } -anyhow = { workspace = true } -async-trait = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true } -futures = { workspace = true } -tracing = { workspace = true } diff --git a/crates/adapters/postgres-event-queue/migrations/0100_event_queue.sql b/crates/adapters/postgres-event-queue/migrations/0100_event_queue.sql deleted file mode 100644 index 33dc8580..00000000 --- a/crates/adapters/postgres-event-queue/migrations/0100_event_queue.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE IF NOT EXISTS event_queue ( - id BIGSERIAL PRIMARY KEY, - event_type TEXT NOT NULL, - payload TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - attempts INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_error TEXT -); - -CREATE INDEX IF NOT EXISTS idx_event_queue_poll - ON event_queue (status, next_attempt_at); diff --git a/crates/adapters/postgres-event-queue/src/lib.rs b/crates/adapters/postgres-event-queue/src/lib.rs deleted file mode 100644 index b2e62d7b..00000000 --- a/crates/adapters/postgres-event-queue/src/lib.rs +++ /dev/null @@ -1,233 +0,0 @@ -mod migrations; -mod payload; - -use std::sync::Arc; -use std::time::Duration; - -use async_trait::async_trait; -use domain::{ - errors::DomainError, - events::{AckHandle, DomainEvent, EventEnvelope}, - ports::{EventConsumer, EventPublisher}, -}; -use futures::stream::{self, BoxStream}; -use sqlx::PgPool; -use tokio::sync::{Mutex, mpsc}; - -use payload::DbEventPayload; - -pub struct DbEventQueueConfig { - pub poll_interval_ms: u64, - pub batch_size: i64, - pub max_attempts: i32, -} - -impl DbEventQueueConfig { - pub fn from_env() -> Self { - Self { - poll_interval_ms: std::env::var("EVENT_QUEUE_POLL_INTERVAL_MS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(500), - batch_size: std::env::var("EVENT_QUEUE_BATCH_SIZE") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(10), - max_attempts: std::env::var("EVENT_QUEUE_MAX_ATTEMPTS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(5), - } - } -} - -#[derive(Clone)] -pub struct PostgresEventQueue { - pool: PgPool, - config: Arc, -} - -impl PostgresEventQueue { - pub async fn create(pool: PgPool, config: DbEventQueueConfig) -> anyhow::Result { - migrations::run(&pool).await?; - Ok(Self { - pool, - config: Arc::new(config), - }) - } - - pub async fn create_publisher(pool: PgPool) -> anyhow::Result> { - let q = Self::create(pool, DbEventQueueConfig::from_env()).await?; - Ok(Arc::new(q)) - } - - pub async fn create_channel( - pool: PgPool, - ) -> anyhow::Result<(Arc, Arc)> { - let q = Self::create(pool, DbEventQueueConfig::from_env()).await?; - Ok((Arc::new(q.clone()), Arc::new(q))) - } -} - -#[async_trait] -impl EventPublisher for PostgresEventQueue { - async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { - let db_payload = DbEventPayload::from(event); - let event_type = db_payload.event_type(); - let payload_json = serde_json::to_string(&db_payload) - .map_err(|e| DomainError::InfrastructureError(format!("serialize: {e}")))?; - - sqlx::query("INSERT INTO event_queue (event_type, payload) VALUES ($1, $2)") - .bind(event_type) - .bind(payload_json) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?; - - Ok(()) - } -} - -impl EventConsumer for PostgresEventQueue { - fn consume(&self) -> BoxStream<'_, Result> { - let pool = self.pool.clone(); - let config = Arc::clone(&self.config); - let (tx, rx) = mpsc::channel(128); - let rx = Arc::new(Mutex::new(rx)); - - tokio::spawn(async move { - let poll_interval = Duration::from_millis(config.poll_interval_ms); - loop { - match claim_batch(&pool, &config).await { - Err(e) => { - tracing::error!("postgres event queue claim error: {e}"); - tokio::time::sleep(poll_interval).await; - } - Ok(rows) if rows.is_empty() => { - tokio::time::sleep(poll_interval).await; - } - Ok(rows) => { - for row in rows { - let envelope = decode_row(&pool, row, config.max_attempts); - if tx.send(envelope).await.is_err() { - tracing::info!("postgres event queue consumer closed"); - return; - } - } - } - } - } - }); - - Box::pin(stream::unfold(rx, |rx| async move { - let item = rx.lock().await.recv().await?; - Some((item, rx)) - })) - } -} - -// ── Internal types ──────────────────────────────────────────────────────────── - -#[derive(sqlx::FromRow)] -struct QueueRow { - id: i64, - payload: String, - attempts: i32, -} - -async fn claim_batch( - pool: &PgPool, - config: &DbEventQueueConfig, -) -> Result, DomainError> { - let rows = sqlx::query_as::<_, QueueRow>( - r#" - WITH claimed AS ( - SELECT id FROM event_queue - WHERE status = 'pending' AND next_attempt_at <= NOW() - ORDER BY next_attempt_at ASC - LIMIT $1 - FOR UPDATE SKIP LOCKED - ) - UPDATE event_queue q - SET status = 'processing' - FROM claimed - WHERE q.id = claimed.id - RETURNING q.id, q.payload, q.attempts - "#, - ) - .bind(config.batch_size) - .fetch_all(pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("claim batch: {e}")))?; - - Ok(rows) -} - -fn decode_row( - pool: &PgPool, - row: QueueRow, - max_attempts: i32, -) -> Result { - let db_payload: DbEventPayload = serde_json::from_str(&row.payload) - .map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?; - let event = DomainEvent::try_from(db_payload)?; - Ok(EventEnvelope::new( - event, - Box::new(DbAckHandle { - pool: pool.clone(), - row_id: row.id, - attempts: row.attempts, - max_attempts, - }), - )) -} - -struct DbAckHandle { - pool: PgPool, - row_id: i64, - attempts: i32, - max_attempts: i32, -} - -#[async_trait] -impl AckHandle for DbAckHandle { - async fn ack(&self) -> Result<(), DomainError> { - sqlx::query("UPDATE event_queue SET status = 'done' WHERE id = $1") - .bind(self.row_id) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("ack: {e}")))?; - Ok(()) - } - - async fn nack(&self) -> Result<(), DomainError> { - let new_attempts = self.attempts + 1; - if new_attempts >= self.max_attempts { - sqlx::query( - "UPDATE event_queue SET status = 'dead_lettered', attempts = $1, last_error = 'max attempts reached' WHERE id = $2" - ) - .bind(new_attempts) - .bind(self.row_id) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("nack dead-letter: {e}")))?; - } else { - let backoff = backoff_seconds(new_attempts).to_string(); - sqlx::query( - "UPDATE event_queue SET status = 'pending', attempts = $1, next_attempt_at = NOW() + ($2 || ' seconds')::interval, last_error = 'nack' WHERE id = $3" - ) - .bind(new_attempts) - .bind(backoff) - .bind(self.row_id) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("nack retry: {e}")))?; - } - Ok(()) - } -} - -fn backoff_seconds(attempts: i32) -> i64 { - let base: i64 = 5 * (1i64 << attempts.min(6)); - base.min(300) -} diff --git a/crates/adapters/postgres-event-queue/src/migrations.rs b/crates/adapters/postgres-event-queue/src/migrations.rs deleted file mode 100644 index ef49936f..00000000 --- a/crates/adapters/postgres-event-queue/src/migrations.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub(crate) async fn run(pool: &sqlx::PgPool) -> anyhow::Result<()> { - sqlx::migrate!("./migrations") - .set_ignore_missing(true) - .run(pool) - .await - .map_err(|e| anyhow::anyhow!("postgres-event-queue migration failed: {e}")) -} diff --git a/crates/adapters/postgres-event-queue/src/payload.rs b/crates/adapters/postgres-event-queue/src/payload.rs deleted file mode 100644 index 6df07ad8..00000000 --- a/crates/adapters/postgres-event-queue/src/payload.rs +++ /dev/null @@ -1 +0,0 @@ -pub use event_payload::EventPayload as DbEventPayload; diff --git a/crates/adapters/postgres-federation/Cargo.toml b/crates/adapters/postgres-federation/Cargo.toml deleted file mode 100644 index a7bd6358..00000000 --- a/crates/adapters/postgres-federation/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "postgres-federation" -version = "0.1.0" -edition.workspace = true - -[dependencies] -sqlx = { version = "0.8.6", features = [ - "runtime-tokio-rustls", - "postgres", - "uuid", - "macros", - "chrono", -] } -activitypub = { workspace = true } -adapter-common = { workspace = true } -k-ap = { version = "0.5.0", registry = "gitea" } -postgres-social = { workspace = true } -domain = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -tracing = { workspace = true } -async-trait = { workspace = true } -anyhow = { workspace = true } -serde_json = { workspace = true } diff --git a/crates/adapters/postgres-federation/src/activity.rs b/crates/adapters/postgres-federation/src/activity.rs deleted file mode 100644 index a7437e32..00000000 --- a/crates/adapters/postgres-federation/src/activity.rs +++ /dev/null @@ -1,30 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use chrono::Utc; -use k_ap::ActivityRepository; - -use super::PostgresFederationRepository; -use adapter_common::datetime_to_str; - -#[async_trait] -impl ActivityRepository for PostgresFederationRepository { - async fn is_activity_processed(&self, activity_id: &str) -> Result { - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_activities WHERE id = $1") - .bind(activity_id) - .fetch_one(&self.pool) - .await?; - Ok(count > 0) - } - - async fn mark_activity_processed(&self, activity_id: &str) -> Result<()> { - let ts = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query( - "INSERT INTO ap_activities (id, processed_at) VALUES ($1, $2) ON CONFLICT DO NOTHING", - ) - .bind(activity_id) - .bind(&ts) - .execute(&self.pool) - .await?; - Ok(()) - } -} diff --git a/crates/adapters/postgres-federation/src/actor.rs b/crates/adapters/postgres-federation/src/actor.rs deleted file mode 100644 index 41a0e289..00000000 --- a/crates/adapters/postgres-federation/src/actor.rs +++ /dev/null @@ -1,102 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use chrono::Utc; -use k_ap::{AnnounceRepository, Keypair, KeypairRepository, RemoteActor, RemoteActorCache}; -use sqlx::Row; - -use super::{PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor}; -use adapter_common::datetime_to_str; - -#[async_trait] -impl KeypairRepository for PostgresFederationRepository { - async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result> { - let uid = user_id.to_string(); - let row = - sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1") - .bind(&uid) - .fetch_optional(&self.pool) - .await?; - Ok(row.map(|r| Keypair { - public_key: r.get("public_key"), - private_key: r.get("private_key"), - })) - } - - async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, keypair: Keypair) -> Result<()> { - let uid = user_id.to_string(); - let now = Utc::now().naive_utc(); - let created_at = datetime_to_str(&now); - sqlx::query( - "INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) VALUES ($1, $2, $3, $4::timestamptz) - ON CONFLICT(user_id) DO UPDATE SET public_key = EXCLUDED.public_key, private_key = EXCLUDED.private_key", - ).bind(&uid).bind(&keypair.public_key).bind(&keypair.private_key).bind(&created_at).execute(&self.pool).await?; - Ok(()) - } -} - -#[async_trait] -impl RemoteActorCache for PostgresFederationRepository { - async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { - let now = Utc::now().naive_utc(); - let fetched_at = datetime_to_str(&now); - let aka_json = serde_json::to_string(&actor.also_known_as).unwrap_or_default(); - sqlx::query( - "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, bio, banner_url, followers_url, following_url, also_known_as, fetched_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::timestamptz) - ON CONFLICT(url) DO UPDATE SET - handle=EXCLUDED.handle, inbox_url=EXCLUDED.inbox_url, shared_inbox_url=EXCLUDED.shared_inbox_url, - display_name=EXCLUDED.display_name, avatar_url=EXCLUDED.avatar_url, - outbox_url=COALESCE(EXCLUDED.outbox_url, ap_remote_actors.outbox_url), - bio=EXCLUDED.bio, banner_url=EXCLUDED.banner_url, followers_url=EXCLUDED.followers_url, - following_url=EXCLUDED.following_url, also_known_as=EXCLUDED.also_known_as, fetched_at=EXCLUDED.fetched_at", - ) - .bind(&actor.url).bind(&actor.handle).bind(&actor.inbox_url).bind(&actor.shared_inbox_url) - .bind(&actor.display_name).bind(&actor.avatar_url).bind(&actor.outbox_url) - .bind(&actor.bio).bind(&actor.banner_url).bind(&actor.followers_url).bind(&actor.following_url) - .bind(&aka_json).bind(&fetched_at) - .execute(&self.pool).await?; - Ok(()) - } - - async fn get_remote_actor(&self, actor_url: &str) -> Result> { - let q = format!("SELECT url, {PG_ACTOR_COLS} FROM ap_remote_actors a WHERE url = $1"); - let row = sqlx::query(&q) - .bind(actor_url) - .fetch_optional(&self.pool) - .await?; - Ok(row.as_ref().map(|r| pg_remote_actor(r, "url"))) - } -} - -#[async_trait] -impl AnnounceRepository for PostgresFederationRepository { - async fn add_announce( - &self, - activity_id: &str, - object_url: &str, - actor_url: &str, - announced_at: chrono::DateTime, - ) -> Result<()> { - let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string(); - sqlx::query("INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING") - .bind(activity_id).bind(object_url).bind(actor_url).bind(&ts).execute(&self.pool).await?; - Ok(()) - } - - async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM ap_announces WHERE id = $1 AND actor_url = $2") - .bind(activity_id) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn count_announces(&self, object_url: &str) -> Result { - let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = $1") - .bind(object_url) - .fetch_one(&self.pool) - .await?; - Ok(row.get::("cnt") as usize) - } -} diff --git a/crates/adapters/postgres-federation/src/blocklist.rs b/crates/adapters/postgres-federation/src/blocklist.rs deleted file mode 100644 index 0bb22a3e..00000000 --- a/crates/adapters/postgres-federation/src/blocklist.rs +++ /dev/null @@ -1,91 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use chrono::Utc; -use k_ap::{ActorBlocklist, BlockedDomain, DomainBlocklist}; -use sqlx::Row; - -use super::PostgresFederationRepository; -use adapter_common::datetime_to_str; - -#[async_trait] -impl DomainBlocklist for PostgresFederationRepository { - async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> { - let ts = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query("INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3) ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason") - .bind(domain).bind(reason).bind(&ts).execute(&self.pool).await?; - Ok(()) - } - - async fn remove_blocked_domain(&self, domain: &str) -> Result<()> { - sqlx::query("DELETE FROM blocked_domains WHERE domain = $1") - .bind(domain) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn get_blocked_domains(&self) -> Result> { - let rows = sqlx::query( - "SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC", - ) - .fetch_all(&self.pool) - .await?; - Ok(rows - .iter() - .map(|r| BlockedDomain { - domain: r.get("domain"), - reason: r.get("reason"), - blocked_at: r.get("blocked_at"), - }) - .collect()) - } - - async fn is_domain_blocked(&self, domain: &str) -> Result { - let count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = $1") - .bind(domain) - .fetch_one(&self.pool) - .await?; - Ok(count > 0) - } -} - -#[async_trait] -impl ActorBlocklist for PostgresFederationRepository { - async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { - let uid = local_user_id.to_string(); - let ts = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING") - .bind(&uid).bind(actor_url).bind(&ts).execute(&self.pool).await?; - Ok(()) - } - - async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { - let uid = local_user_id.to_string(); - sqlx::query( - "DELETE FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2", - ) - .bind(&uid) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - let rows = sqlx::query("SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 ORDER BY blocked_at DESC") - .bind(&uid).fetch_all(&self.pool).await?; - Ok(rows - .iter() - .map(|r| r.get::("remote_actor_url")) - .collect()) - } - - async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2") - .bind(&uid).bind(actor_url).fetch_one(&self.pool).await?; - Ok(count > 0) - } -} diff --git a/crates/adapters/postgres-federation/src/follow/followers.rs b/crates/adapters/postgres-federation/src/follow/followers.rs deleted file mode 100644 index 0a1e6548..00000000 --- a/crates/adapters/postgres-federation/src/follow/followers.rs +++ /dev/null @@ -1,197 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use chrono::Utc; -use k_ap::{Follower, FollowerReader, FollowerStatus, FollowerWriter, RemoteActor}; -use sqlx::Row; - -use crate::{ - PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor, status_to_str, str_to_status, -}; -use adapter_common::datetime_to_str; - -#[async_trait] -impl FollowerWriter for PostgresFederationRepository { - async fn add_follower( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowerStatus, - follow_activity_id: &str, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let status_str = status_to_str(&status); - let now = Utc::now().naive_utc(); - let created_at = datetime_to_str(&now); - sqlx::query( - "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) - VALUES ($1, $2, $3, $4::timestamptz, $5) - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET - status = EXCLUDED.status, follow_activity_id = EXCLUDED.follow_activity_id", - ).bind(&uid).bind(remote_actor_url).bind(status_str).bind(&created_at).bind(follow_activity_id).execute(&self.pool).await?; - Ok(()) - } - - async fn get_follower_follow_activity_id( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> Result> { - let uid = local_user_id.to_string(); - let row: Option = sqlx::query_scalar( - "SELECT follow_activity_id FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2", - ).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?; - Ok(row) - } - - async fn remove_follower( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> Result<()> { - let uid = local_user_id.to_string(); - sqlx::query("DELETE FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2") - .bind(&uid) - .bind(remote_actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn update_follower_status( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowerStatus, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let status_str = status_to_str(&status); - let result = sqlx::query("UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3") - .bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?; - if result.rows_affected() == 0 { - tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found"); - } - Ok(()) - } -} - -#[async_trait] -impl FollowerReader for PostgresFederationRepository { - async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1" - ); - let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?; - Ok(rows - .iter() - .map(|row| { - let status_str: String = row.get("status"); - Follower { - actor: pg_remote_actor(row, "remote_actor_url"), - status: str_to_status(&status_str), - } - }) - .collect()) - } - - async fn get_followers_page( - &self, - local_user_id: uuid::Uuid, - offset: u32, - limit: usize, - ) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3" - ); - let rows = sqlx::query(&q) - .bind(&uid) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(&self.pool) - .await?; - Ok(rows - .iter() - .map(|row| { - let status_str: String = row.get("status"); - Follower { - actor: pg_remote_actor(row, "remote_actor_url"), - status: str_to_status(&status_str), - } - }) - .collect()) - } - - async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await?; - Ok(count as usize) - } - - async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT f.remote_actor_url, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'pending'" - ); - let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?; - Ok(rows - .iter() - .map(|row| pg_remote_actor(row, "remote_actor_url")) - .collect()) - } - - async fn get_accepted_follower_inboxes( - &self, - local_user_id: uuid::Uuid, - ) -> Result> { - let uid = local_user_id.to_string(); - let rows = sqlx::query( - "SELECT DISTINCT COALESCE(a.shared_inbox_url, a.inbox_url) as inbox - FROM ap_followers f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $1 AND f.status = 'accepted' - AND f.remote_actor_url NOT IN (SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1)", - ).bind(&uid).fetch_all(&self.pool).await?; - Ok(rows - .iter() - .filter_map(|r| r.try_get::("inbox").ok()) - .collect()) - } - - async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await?; - Ok(count as usize) - } - - async fn get_accepted_followers_page( - &self, - local_user_id: uuid::Uuid, - offset: u32, - limit: usize, - ) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT f.remote_actor_url, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3" - ); - let rows = sqlx::query(&q) - .bind(&uid) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(&self.pool) - .await?; - Ok(rows - .iter() - .map(|row| pg_remote_actor(row, "remote_actor_url")) - .collect()) - } -} diff --git a/crates/adapters/postgres-federation/src/follow/following.rs b/crates/adapters/postgres-federation/src/follow/following.rs deleted file mode 100644 index 795487b5..00000000 --- a/crates/adapters/postgres-federation/src/follow/following.rs +++ /dev/null @@ -1,106 +0,0 @@ -use crate::{PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor}; -use adapter_common::datetime_to_str; -use anyhow::Result; -use async_trait::async_trait; -use chrono::Utc; -use k_ap::{FollowingReader, FollowingStatus, FollowingWriter, RemoteActor, RemoteActorCache}; - -#[async_trait] -impl FollowingWriter for PostgresFederationRepository { - async fn add_following( - &self, - local_user_id: uuid::Uuid, - actor: RemoteActor, - follow_activity_id: &str, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let now = Utc::now().naive_utc(); - let created_at = datetime_to_str(&now); - RemoteActorCache::upsert_remote_actor(self, actor.clone()).await?; - sqlx::query("INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at) VALUES ($1, $2, $3, $4::timestamptz) ON CONFLICT DO NOTHING") - .bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?; - Ok(()) - } - - async fn get_follow_activity_id( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> Result> { - let uid = local_user_id.to_string(); - let row: Option = sqlx::query_scalar("SELECT follow_activity_id FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2") - .bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?; - Ok(row) - } - - async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { - let uid = local_user_id.to_string(); - sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2") - .bind(&uid) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn update_following_status( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowingStatus, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let status_str = match status { - FollowingStatus::Pending => "pending", - FollowingStatus::Accepted => "accepted", - }; - let result = sqlx::query("UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3") - .bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?; - if result.rows_affected() == 0 { - tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found"); - } - Ok(()) - } -} - -#[async_trait] -impl FollowingReader for PostgresFederationRepository { - async fn get_following(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted'" - ); - let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?; - Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect()) - } - - async fn count_following(&self, local_user_id: uuid::Uuid) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await?; - Ok(count as usize) - } - - async fn get_following_page( - &self, - local_user_id: uuid::Uuid, - offset: u32, - limit: usize, - ) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3" - ); - let rows = sqlx::query(&q) - .bind(&uid) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(&self.pool) - .await?; - Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect()) - } -} diff --git a/crates/adapters/postgres-federation/src/follow/migration.rs b/crates/adapters/postgres-federation/src/follow/migration.rs deleted file mode 100644 index 7c990e07..00000000 --- a/crates/adapters/postgres-federation/src/follow/migration.rs +++ /dev/null @@ -1,27 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use k_ap::FollowMigration; - -use crate::PostgresFederationRepository; - -#[async_trait] -impl FollowMigration for PostgresFederationRepository { - async fn migrate_follower_actor( - &self, - old_actor_url: &str, - new_actor_url: &str, - ) -> Result> { - let candidates: Vec = sqlx::query_scalar( - "SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $2)", - ).bind(old_actor_url).bind(new_actor_url).fetch_all(&self.pool).await?; - if candidates.is_empty() { - return Ok(vec![]); - } - sqlx::query("UPDATE ap_following SET remote_actor_url = $1 WHERE remote_actor_url = $2 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1)") - .bind(new_actor_url).bind(old_actor_url).execute(&self.pool).await?; - candidates - .into_iter() - .map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e))) - .collect() - } -} diff --git a/crates/adapters/postgres-federation/src/follow/mod.rs b/crates/adapters/postgres-federation/src/follow/mod.rs deleted file mode 100644 index 414bc5cf..00000000 --- a/crates/adapters/postgres-federation/src/follow/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod followers; -mod following; -mod migration; diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs deleted file mode 100644 index e5c6eeb5..00000000 --- a/crates/adapters/postgres-federation/src/lib.rs +++ /dev/null @@ -1,85 +0,0 @@ -mod activity; -mod actor; -mod blocklist; -mod follow; -mod review; - -use k_ap::{FollowerStatus, RemoteActor}; -use sqlx::{PgPool, Row}; - -pub(crate) fn status_to_str(status: &FollowerStatus) -> &'static str { - match status { - FollowerStatus::Pending => "pending", - FollowerStatus::Accepted => "accepted", - FollowerStatus::Rejected => "rejected", - } -} - -pub(crate) fn str_to_status(s: &str) -> FollowerStatus { - match s { - "accepted" => FollowerStatus::Accepted, - "rejected" => FollowerStatus::Rejected, - _ => FollowerStatus::Pending, - } -} - -pub(crate) fn pg_remote_actor(row: &sqlx::postgres::PgRow, url_col: &str) -> RemoteActor { - RemoteActor { - url: row.get(url_col), - handle: row.try_get("handle").unwrap_or_default(), - inbox_url: row.try_get("inbox_url").unwrap_or_default(), - shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), - display_name: row.try_get("display_name").ok().flatten(), - avatar_url: row.try_get("avatar_url").ok().flatten(), - outbox_url: row.try_get("outbox_url").ok().flatten(), - bio: row.try_get("bio").ok().flatten(), - banner_url: row.try_get("banner_url").ok().flatten(), - followers_url: row.try_get("followers_url").ok().flatten(), - following_url: row.try_get("following_url").ok().flatten(), - also_known_as: row - .try_get::, _>("also_known_as") - .ok() - .flatten() - .map(|s| { - serde_json::from_str::>(&s).unwrap_or_else(|e| { - tracing::warn!(raw = %s, error = %e, "failed to parse also_known_as JSON"); - vec![s] - }) - }) - .unwrap_or_default(), - fetched_at: row.try_get("fetched_at").ok(), - } -} - -pub(crate) const PG_ACTOR_COLS: &str = "a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at"; - -pub struct PostgresFederationRepository { - pub(crate) pool: PgPool, -} - -impl PostgresFederationRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -pub fn wire( - pool: PgPool, - instance: domain::value_objects::InstanceIdentity, -) -> activitypub::FederationRepos { - let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool.clone())); - let social = std::sync::Arc::new(postgres_social::PostgresSocialRepository::new( - pool, instance, - )); - activitypub::FederationRepos { - activity: std::sync::Arc::clone(&fed) as _, - follow: std::sync::Arc::clone(&fed) as _, - actor: std::sync::Arc::clone(&fed) as _, - blocklist: std::sync::Arc::clone(&fed) as _, - review_store: fed as _, - admin_query: std::sync::Arc::clone(&social) as _, - remote_watchlist: std::sync::Arc::clone(&social) as _, - follow_command: std::sync::Arc::clone(&social) as _, - follow_query: social as _, - } -} diff --git a/crates/adapters/postgres-federation/src/review.rs b/crates/adapters/postgres-federation/src/review.rs deleted file mode 100644 index 9772be39..00000000 --- a/crates/adapters/postgres-federation/src/review.rs +++ /dev/null @@ -1,112 +0,0 @@ -use activitypub::{RemoteReviewRepository, RemoteReviewUpdate}; -use anyhow::{Result, anyhow}; -use async_trait::async_trait; -use domain::models::{Review, ReviewSource}; - -use super::PostgresFederationRepository; -use adapter_common::datetime_to_str; - -#[async_trait] -impl RemoteReviewRepository for PostgresFederationRepository { - async fn save_remote_review( - &self, - review: &Review, - ap_id: &str, - movie_title: &str, - release_year: u16, - external_metadata_id: Option<&str>, - poster_url: Option<&str>, - ) -> Result<()> { - let actor_url = match review.source() { - ReviewSource::Remote { actor_url } => actor_url.clone(), - ReviewSource::Local => { - return Err(anyhow!("save_remote_review called with a local review")); - } - }; - let movie_id = review.movie_id().value().to_string(); - sqlx::query( - "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) - VALUES ($1, $2, $3, $4, NULL, $5) - ON CONFLICT(id) DO UPDATE SET - external_metadata_id = COALESCE(EXCLUDED.external_metadata_id, movies.external_metadata_id), - poster_path = COALESCE(EXCLUDED.poster_path, movies.poster_path)", - ) - .bind(&movie_id) - .bind(external_metadata_id) - .bind(movie_title) - .bind(release_year.max(1888) as i64) - .bind(poster_url) - .execute(&self.pool) - .await?; - - let id = review.id().value().to_string(); - let user_id = review.user_id().value().to_string(); - let rating = review.rating().value() as i64; - let comment = review.comment().map(|c| c.value().to_string()); - let watched_at = datetime_to_str(review.watched_at()); - let created_at = datetime_to_str(review.created_at()); - sqlx::query( - "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id, spoilers) - VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9, $10) ON CONFLICT DO NOTHING", - ) - .bind(&id) - .bind(&movie_id) - .bind(&user_id) - .bind(rating) - .bind(&comment) - .bind(&watched_at) - .bind(&created_at) - .bind(&actor_url) - .bind(ap_id) - .bind(review.spoilers()) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM reviews WHERE ap_id = $1 AND remote_actor_url = $2") - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> { - let watched_at_str = datetime_to_str(&u.watched_at); - sqlx::query( - "UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4, spoilers = $5 - WHERE ap_id = $6 AND remote_actor_url = $7", - ) - .bind(u.rating as i64) - .bind(u.comment) - .bind(&watched_at_str) - .bind(u.watch_medium) - .bind(u.spoilers) - .bind(u.ap_id) - .bind(u.actor_url) - .execute(&self.pool) - .await?; - if let Some(url) = u.poster_url { - sqlx::query( - "UPDATE movies SET poster_path = $1 - WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = $2 AND remote_actor_url = $3)", - ) - .bind(url) - .bind(u.ap_id) - .bind(u.actor_url) - .execute(&self.pool) - .await?; - } - Ok(()) - } - - async fn delete_by_actor(&self, actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM reviews WHERE remote_actor_url = $1") - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } -} diff --git a/crates/adapters/postgres-search/Cargo.toml b/crates/adapters/postgres-search/Cargo.toml deleted file mode 100644 index d3c7ca60..00000000 --- a/crates/adapters/postgres-search/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "postgres-search" -version = "0.1.0" -edition = "2021" - -[dependencies] -domain = { workspace = true } -adapter-common = { workspace = true } -async-trait = { workspace = true } -sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "macros"] } -uuid = { workspace = true } diff --git a/crates/adapters/postgres-search/src/lib.rs b/crates/adapters/postgres-search/src/lib.rs deleted file mode 100644 index 06c00433..00000000 --- a/crates/adapters/postgres-search/src/lib.rs +++ /dev/null @@ -1,353 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::PersonId, - models::{ - collections::Paginated, EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, - SearchQuery, SearchResults, - }, - ports::{SearchCommand, SearchPort}, - value_objects::MovieId, -}; -use sqlx::PgPool; - -pub struct PostgresSearchAdapter { - pool: PgPool, -} - -impl PostgresSearchAdapter { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -pub fn create_search_adapter(pool: PgPool) -> (Arc, Arc) { - let adapter = Arc::new(PostgresSearchAdapter::new(pool)); - ( - Arc::clone(&adapter) as Arc, - adapter as Arc, - ) -} - -#[async_trait] -impl SearchCommand for PostgresSearchAdapter { - async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> { - match doc { - IndexableDocument::Movie { id, movie, profile } => { - let movie_id = id.value().to_string(); - let title = movie.title().value().to_string(); - let director = movie.director().unwrap_or("").to_string(); - let (overview, genres, keywords, cast_names, crew_names) = match profile.as_deref() - { - Some(p) => ( - p.overview.clone().unwrap_or_default(), - p.genres - .iter() - .map(|g| g.name.as_str()) - .collect::>() - .join(" "), - p.keywords - .iter() - .map(|k| k.name.as_str()) - .collect::>() - .join(" "), - p.cast - .iter() - .map(|c| c.name.as_str()) - .collect::>() - .join(" "), - p.crew - .iter() - .map(|c| c.name.as_str()) - .collect::>() - .join(" "), - ), - None => ( - String::new(), - String::new(), - String::new(), - String::new(), - String::new(), - ), - }; - - let fts_input = format!( - "{} {} {} {} {} {} {}", - title, director, overview, genres, keywords, cast_names, crew_names - ); - - sqlx::query( - "INSERT INTO movies_search (movie_id, fts) - VALUES ($1, to_tsvector('english', $2)) - ON CONFLICT (movie_id) DO UPDATE SET fts = EXCLUDED.fts", - ) - .bind(&movie_id) - .bind(&fts_input) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - IndexableDocument::Person { id, person } => { - let person_id = id.value().to_string(); - let fts_input = format!( - "{} {}", - person.name(), - person.known_for_department().unwrap_or("") - ); - - sqlx::query( - "INSERT INTO people_search (person_id, fts) - VALUES ($1, to_tsvector('english', $2)) - ON CONFLICT (person_id) DO UPDATE SET fts = EXCLUDED.fts", - ) - .bind(&person_id) - .bind(&fts_input) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - } - } - - async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError> { - match entity_type { - EntityType::Movie => { - sqlx::query("DELETE FROM movies_search WHERE movie_id = $1") - .bind(id) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - } - EntityType::Person => { - sqlx::query("DELETE FROM people_search WHERE person_id = $1") - .bind(id) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - } - } - Ok(()) - } -} - -#[async_trait] -impl SearchPort for PostgresSearchAdapter { - async fn search(&self, query: &SearchQuery) -> Result { - let movies = self.search_movies(query).await?; - let people = self.search_people(query).await?; - Ok(SearchResults { movies, people }) - } -} - -impl PostgresSearchAdapter { - async fn search_movies( - &self, - query: &SearchQuery, - ) -> Result, DomainError> { - let limit = query.page.limit as i64; - let offset = query.page.offset as i64; - - #[derive(sqlx::FromRow)] - struct Row { - id: String, - title: String, - release_year: Option, - director: Option, - poster_path: Option, - genres: Option, - } - - let total: u64 = if let Some(text) = &query.text { - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(DISTINCT m.id) - FROM movies_search ms - JOIN movies m ON m.id = ms.movie_id - LEFT JOIN movie_genres mg ON mg.movie_id = m.id - WHERE ms.fts @@ plainto_tsquery('english', $1) - AND ($2::TEXT IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = $2)) - AND ($3::INT IS NULL OR m.release_year = $3)", - ) - .bind(text) - .bind(&query.filters.genre) - .bind(query.filters.year.map(|y| y as i32)) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - count as u64 - } else { - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(DISTINCT m.id) FROM movies m - LEFT JOIN movie_genres mg ON mg.movie_id = m.id - WHERE ($1::TEXT IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = $1)) - AND ($2::INT IS NULL OR m.release_year = $2)", - ) - .bind(&query.filters.genre) - .bind(query.filters.year.map(|y| y as i32)) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - count as u64 - }; - - let rows: Vec = if let Some(text) = &query.text { - sqlx::query_as::<_, Row>( - "SELECT m.id, m.title, m.release_year, m.director, m.poster_path, - STRING_AGG(DISTINCT mg.name, ',' ORDER BY mg.name) AS genres - FROM movies_search ms - JOIN movies m ON m.id = ms.movie_id - LEFT JOIN movie_genres mg ON mg.movie_id = m.id - WHERE ms.fts @@ plainto_tsquery('english', $1) - AND ($2::TEXT IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = $2)) - AND ($3::INT IS NULL OR m.release_year = $3) - GROUP BY m.id, m.title, m.release_year, m.director, m.poster_path, ms.fts - ORDER BY ts_rank(ms.fts, plainto_tsquery('english', $1)) DESC - LIMIT $4 OFFSET $5", - ) - .bind(text) - .bind(&query.filters.genre) - .bind(query.filters.year.map(|y| y as i32)) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - } else { - sqlx::query_as::<_, Row>( - "SELECT m.id, m.title, m.release_year, m.director, m.poster_path, - STRING_AGG(DISTINCT mg.name, ',' ORDER BY mg.name) AS genres - FROM movies m - LEFT JOIN movie_genres mg ON mg.movie_id = m.id - WHERE ($1::TEXT IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = $1)) - AND ($2::INT IS NULL OR m.release_year = $2) - GROUP BY m.id ORDER BY m.title LIMIT $3 OFFSET $4", - ) - .bind(&query.filters.genre) - .bind(query.filters.year.map(|y| y as i32)) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - }; - - let items = rows - .into_iter() - .map(|r| MovieSearchHit { - movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), - title: r.title, - release_year: r.release_year.map(|y| y as u16), - director: r.director, - poster_path: r.poster_path, - genres: r - .genres - .unwrap_or_default() - .split(',') - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(), - }) - .collect::>(); - - Ok(Paginated { - items, - total_count: total, - limit: query.page.limit, - offset: query.page.offset, - }) - } - - async fn search_people( - &self, - query: &SearchQuery, - ) -> Result, DomainError> { - let Some(text) = &query.text else { - return Ok(Paginated { - items: vec![], - total_count: 0, - limit: query.page.limit, - offset: query.page.offset, - }); - }; - - let limit = query.page.limit as i64; - let offset = query.page.offset as i64; - - let total: u64 = { - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM people_search WHERE fts @@ plainto_tsquery('english', $1)", - ) - .bind(text) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - count as u64 - }; - - #[derive(sqlx::FromRow)] - struct Row { - person_id: String, - name: String, - known_for_department: Option, - profile_path: Option, - tmdb_person_id: Option, - } - - let rows = sqlx::query_as::<_, Row>( - "SELECT ps.person_id, p.name, p.known_for_department, p.profile_path, p.tmdb_person_id - FROM people_search ps - JOIN persons p ON p.id = ps.person_id - WHERE ps.fts @@ plainto_tsquery('english', $1) - ORDER BY ts_rank(ps.fts, plainto_tsquery('english', $1)) DESC - LIMIT $2 OFFSET $3", - ) - .bind(text) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let mut items = Vec::with_capacity(rows.len()); - for row in rows { - let known_for_titles = if let Some(tid) = row.tmdb_person_id { - sqlx::query_scalar::<_, String>( - "SELECT m.title FROM movie_cast mc - JOIN movies m ON m.id = mc.movie_id - WHERE mc.tmdb_person_id = $1 - ORDER BY mc.billing_order - LIMIT 3", - ) - .bind(tid) - .fetch_all(&self.pool) - .await - .unwrap_or_default() - } else { - vec![] - }; - - items.push(PersonSearchHit { - person_id: PersonId::from_uuid( - uuid::Uuid::parse_str(&row.person_id).unwrap_or_default(), - ), - name: row.name, - known_for_department: row.known_for_department, - profile_path: row.profile_path, - known_for_titles, - }); - } - - Ok(Paginated { - items, - total_count: total, - limit: query.page.limit, - offset: query.page.offset, - }) - } -} diff --git a/crates/adapters/postgres-social/Cargo.toml b/crates/adapters/postgres-social/Cargo.toml deleted file mode 100644 index 9354c526..00000000 --- a/crates/adapters/postgres-social/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "postgres-social" -version = "0.1.0" -edition.workspace = true - -[dependencies] -sqlx = { version = "0.8.6", features = [ - "runtime-tokio-rustls", - "postgres", - "uuid", - "macros", - "chrono", -] } -adapter-common = { workspace = true } -domain = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -async-trait = { workspace = true } diff --git a/crates/adapters/postgres-social/src/ap_content.rs b/crates/adapters/postgres-social/src/ap_content.rs deleted file mode 100644 index 03444553..00000000 --- a/crates/adapters/postgres-social/src/ap_content.rs +++ /dev/null @@ -1,301 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{ - DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry, - WatchlistWithMovie, - }, - ports::LocalApContentQuery, - value_objects::{ - Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, - ReviewId, UserId, WatchlistEntryId, - }, -}; -use sqlx::{PgPool, Row}; - -pub struct PostgresApContentQuery { - pool: PgPool, -} - -impl PostgresApContentQuery { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -// ── Local row types ────────────────────────────────────────────────────────── - -use adapter_common::{parse_datetime, parse_uuid}; - -#[derive(sqlx::FromRow)] -struct MovieRow { - id: String, - external_metadata_id: Option, - title: String, - release_year: i64, - director: Option, - poster_path: Option, -} - -impl MovieRow { - fn into_domain(self) -> Result { - let id = MovieId::from_uuid(parse_uuid(&self.id)?); - let external_metadata_id = self - .external_metadata_id - .map(ExternalMetadataId::new) - .transpose()?; - let title = MovieTitle::new(self.title)?; - let release_year = ReleaseYear::new(self.release_year as u16)?; - let poster_path = self.poster_path.map(PosterPath::new).transpose()?; - Ok(Movie::from_persistence( - id, - external_metadata_id, - title, - release_year, - self.director, - poster_path, - )) - } -} - -#[derive(sqlx::FromRow)] -struct ReviewRow { - id: String, - movie_id: String, - user_id: String, - rating: i64, - comment: Option, - watched_at: String, - created_at: String, - remote_actor_url: Option, - watch_medium: Option, - spoilers: bool, -} - -impl ReviewRow { - fn into_domain(self) -> Result { - let id = ReviewId::from_uuid(parse_uuid(&self.id)?); - let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?); - let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?); - let rating = Rating::new(self.rating as u8)?; - let comment = self.comment.map(Comment::new).transpose()?; - let watched_at = parse_datetime(&self.watched_at)?; - let created_at = parse_datetime(&self.created_at)?; - let source = match self.remote_actor_url { - None => ReviewSource::Local, - Some(url) => ReviewSource::Remote { actor_url: url }, - }; - let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?; - Ok(Review::from_persistence(PersistedReview { - id, - movie_id, - user_id, - rating, - comment, - watched_at, - created_at, - source, - watch_medium, - spoilers: self.spoilers, - })) - } -} - -#[derive(sqlx::FromRow)] -struct DiaryRow { - id: String, - external_metadata_id: Option, - title: String, - release_year: i64, - director: Option, - poster_path: Option, - review_id: String, - movie_id: String, - user_id: String, - rating: i64, - comment: Option, - watched_at: String, - created_at: String, - remote_actor_url: Option, - watch_medium: Option, - spoilers: bool, -} - -impl DiaryRow { - fn into_domain(self) -> Result { - let movie = MovieRow { - id: self.id, - external_metadata_id: self.external_metadata_id, - title: self.title, - release_year: self.release_year, - director: self.director, - poster_path: self.poster_path, - } - .into_domain()?; - let review = ReviewRow { - id: self.review_id, - movie_id: self.movie_id, - user_id: self.user_id, - rating: self.rating, - comment: self.comment, - watched_at: self.watched_at, - created_at: self.created_at, - remote_actor_url: self.remote_actor_url, - watch_medium: self.watch_medium, - spoilers: self.spoilers, - } - .into_domain()?; - Ok(DiaryEntry::new(movie, review)) - } -} - -// ── LocalApContentQuery impl ───────────────────────────────────────────────── - -#[async_trait] -impl LocalApContentQuery for PostgresApContentQuery { - async fn get_local_watchlist_for_user( - &self, - user_id: &UserId, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let rows = sqlx::query( - "SELECT w.id, w.user_id, w.movie_id, - to_char(w.added_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS added_at, - m.id AS m_id, m.external_metadata_id, m.title, m.release_year, - m.director, m.poster_path - FROM watchlist_entries w - JOIN movies m ON m.id = w.movie_id - WHERE w.user_id = $1 - ORDER BY w.added_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.into_iter() - .map(|row| { - let entry = WatchlistEntry { - id: WatchlistEntryId::from_uuid(parse_uuid( - &row.try_get::("id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?), - user_id: UserId::from_uuid(parse_uuid( - &row.try_get::("user_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?), - movie_id: MovieId::from_uuid(parse_uuid( - &row.try_get::("movie_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?), - added_at: parse_datetime( - &row.try_get::("added_at") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?, - }; - let movie = MovieRow { - id: row - .try_get("m_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - external_metadata_id: row - .try_get("external_metadata_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - title: row - .try_get("title") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - release_year: row - .try_get("release_year") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - director: row - .try_get("director") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - poster_path: row - .try_get("poster_path") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - } - .into_domain()?; - Ok(WatchlistWithMovie { entry, movie }) - }) - .collect() - } - - async fn get_local_reviews_for_movie( - &self, - movie_id: &MovieId, - ) -> Result, DomainError> { - let mid = movie_id.value().to_string(); - let rows = sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL - ORDER BY r.created_at DESC", - ) - .bind(&mid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - rows.into_iter().map(DiaryRow::into_domain).collect() - } - - async fn get_local_reviews_page( - &self, - user_id: &UserId, - before: Option, - limit: usize, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let limit_i64 = limit as i64; - - let rows = if let Some(before_ts) = before { - let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string(); - sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1 AND r.remote_actor_url IS NULL AND r.watched_at < $2::timestamptz - ORDER BY r.watched_at DESC - LIMIT $3", - ) - .bind(&uid) - .bind(&ts) - .bind(limit_i64) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - } else { - sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1 AND r.remote_actor_url IS NULL - ORDER BY r.watched_at DESC - LIMIT $2", - ) - .bind(&uid) - .bind(limit_i64) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - }; - rows.into_iter().map(DiaryRow::into_domain).collect() - } -} diff --git a/crates/adapters/postgres-social/src/federated_profile.rs b/crates/adapters/postgres-social/src/federated_profile.rs deleted file mode 100644 index c32f3602..00000000 --- a/crates/adapters/postgres-social/src/federated_profile.rs +++ /dev/null @@ -1,58 +0,0 @@ -use async_trait::async_trait; -use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery}; -use sqlx::Row; - -use super::PostgresSocialRepository; - -#[async_trait] -impl FederatedProfileQuery for PostgresSocialRepository { - async fn get_federated_profile( - &self, - synthetic_user_id: uuid::Uuid, - ) -> Result, DomainError> { - let uid = synthetic_user_id.to_string(); - - let actor_url: Option = sqlx::query_scalar( - "SELECT remote_actor_url FROM reviews - WHERE user_id = $1 AND remote_actor_url IS NOT NULL - LIMIT 1", - ) - .bind(&uid) - .fetch_optional(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - let actor_url = match actor_url { - Some(url) => url, - None => return Ok(None), - }; - - let row = sqlx::query( - "SELECT handle, display_name, bio, avatar_url, banner_url - FROM ap_remote_actors WHERE url = $1", - ) - .bind(&actor_url) - .fetch_optional(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - match row { - Some(r) => Ok(Some(FederatedProfile { - actor_url, - handle: r.get("handle"), - display_name: r.try_get("display_name").ok().flatten(), - bio: r.try_get("bio").ok().flatten(), - avatar_url: r.try_get("avatar_url").ok().flatten(), - banner_url: r.try_get("banner_url").ok().flatten(), - })), - None => Ok(Some(FederatedProfile { - handle: actor_url.clone(), - actor_url, - display_name: None, - bio: None, - avatar_url: None, - banner_url: None, - })), - } - } -} diff --git a/crates/adapters/postgres-social/src/follow_repository.rs b/crates/adapters/postgres-social/src/follow_repository.rs deleted file mode 100644 index 2c241157..00000000 --- a/crates/adapters/postgres-social/src/follow_repository.rs +++ /dev/null @@ -1,354 +0,0 @@ -use async_trait::async_trait; -use chrono::Utc; -use domain::{ - errors::DomainError, - value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity}, -}; -use sqlx::Row; - -use crate::PostgresSocialRepository; -use adapter_common::datetime_to_str; - -fn follow_status_to_str(status: &FollowStatus) -> &'static str { - match status { - FollowStatus::Pending => "pending", - FollowStatus::Accepted => "accepted", - FollowStatus::Rejected => "rejected", - } -} - -fn infra_err(e: impl std::fmt::Display) -> DomainError { - DomainError::InfrastructureError(e.to_string()) -} - -fn follow_status_from_str(status: &str) -> Option { - match status { - "pending" => Some(FollowStatus::Pending), - "accepted" => Some(FollowStatus::Accepted), - "rejected" => Some(FollowStatus::Rejected), - _ => None, - } -} - -#[async_trait] -impl domain::ports::FollowCommand for PostgresSocialRepository { - async fn add_follow( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = follower_id.to_string(); - let status_str = follow_status_to_str(&status); - let now = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query( - "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status) - VALUES ($1, $2, '', $3::timestamptz, $4) - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = EXCLUDED.status", - ) - .bind(&uid) - .bind(target_actor_url) - .bind(&now) - .bind(status_str) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn update_follow_status( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = follower_id.to_string(); - let status_str = follow_status_to_str(&status); - sqlx::query( - "UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3", - ) - .bind(status_str) - .bind(&uid) - .bind(target_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn remove_follow( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - ) -> Result<(), DomainError> { - let uid = follower_id.to_string(); - sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2") - .bind(&uid) - .bind(target_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn add_follower( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = local_user_id.to_string(); - let status_str = follow_status_to_str(&status); - let now = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query( - "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) - VALUES ($1, $2, $3, $4::timestamptz, '') - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = EXCLUDED.status", - ) - .bind(&uid) - .bind(follower_actor_url) - .bind(status_str) - .bind(&now) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn update_follower_status( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = local_user_id.to_string(); - let status_str = follow_status_to_str(&status); - sqlx::query( - "UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3", - ) - .bind(status_str) - .bind(&uid) - .bind(follower_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn remove_follower_record( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - ) -> Result<(), DomainError> { - let uid = local_user_id.to_string(); - sqlx::query("DELETE FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2") - .bind(&uid) - .bind(follower_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } -} - -fn social_actor_from_row(row: &sqlx::postgres::PgRow, instance: &InstanceIdentity) -> SocialActor { - let actor_url: String = row.get("remote_actor_url"); - let identity = instance.identify(&actor_url); - - let (handle, display_name, avatar_url) = match &identity { - SocialIdentity::Local(_) => { - let username: Option = row.try_get("local_username").ok().flatten(); - let display: Option = row.try_get("local_display").ok().flatten(); - let avatar: Option = row - .try_get::, _>("local_avatar_path") - .ok() - .flatten() - .map(|p| instance.image_url_for(&p)); - let handle = username - .as_deref() - .map(|u| instance.handle_for(u)) - .unwrap_or_else(|| actor_url.clone()); - (handle, display, avatar) - } - SocialIdentity::Remote { .. } => { - let handle: String = row - .try_get::, _>("remote_handle") - .ok() - .flatten() - .unwrap_or_else(|| actor_url.clone()); - let display: Option = row.try_get("remote_display").ok().flatten(); - let avatar: Option = row.try_get("remote_avatar").ok().flatten(); - (handle, display, avatar) - } - }; - - SocialActor { - identity, - handle, - display_name, - avatar_url, - } -} - -#[async_trait] -impl domain::ports::FollowQuery for PostgresSocialRepository { - async fn get_following(&self, user_id: uuid::Uuid) -> Result, DomainError> { - let uid = user_id.to_string(); - let rows = sqlx::query( - "SELECT f.remote_actor_url, - u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path, - a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar - FROM ap_following f - LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id - LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $2 AND f.status = 'accepted'", - ) - .bind(self.instance.base_url()) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(infra_err)?; - Ok(rows - .iter() - .map(|r| social_actor_from_row(r, &self.instance)) - .collect()) - } - - async fn get_followers(&self, user_id: uuid::Uuid) -> Result, DomainError> { - let uid = user_id.to_string(); - let rows = sqlx::query( - "SELECT f.remote_actor_url, - u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path, - a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar - FROM ap_followers f - LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id - LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $2 AND f.status = 'accepted'", - ) - .bind(self.instance.base_url()) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(infra_err)?; - Ok(rows - .iter() - .map(|r| social_actor_from_row(r, &self.instance)) - .collect()) - } - - async fn get_pending_followers( - &self, - user_id: uuid::Uuid, - ) -> Result, DomainError> { - let uid = user_id.to_string(); - let rows = sqlx::query( - "SELECT f.remote_actor_url, - u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path, - a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar - FROM ap_followers f - LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id - LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $2 AND f.status = 'pending'", - ) - .bind(self.instance.base_url()) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(infra_err)?; - Ok(rows - .iter() - .map(|r| social_actor_from_row(r, &self.instance)) - .collect()) - } - - async fn get_pending_following( - &self, - user_id: uuid::Uuid, - ) -> Result, DomainError> { - let uid = user_id.to_string(); - let rows = sqlx::query( - "SELECT f.remote_actor_url, - u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path, - a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar - FROM ap_following f - LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id - LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $2 AND f.status = 'pending'", - ) - .bind(self.instance.base_url()) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(infra_err)?; - Ok(rows - .iter() - .map(|r| social_actor_from_row(r, &self.instance)) - .collect()) - } - - async fn count_following(&self, user_id: uuid::Uuid) -> Result { - let uid = user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await - .map_err(infra_err)?; - Ok(count as usize) - } - - async fn count_followers(&self, user_id: uuid::Uuid) -> Result { - let uid = user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await - .map_err(infra_err)?; - Ok(count as usize) - } - - async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result { - let uid = user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'pending'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await - .map_err(infra_err)?; - Ok(count as usize) - } - - async fn get_relation( - &self, - viewer_id: uuid::Uuid, - target_actor_url: &str, - ) -> Result { - let uid = viewer_id.to_string(); - let row = sqlx::query( - "SELECT (SELECT status FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2) AS following, - (SELECT status FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2) AS followed_by", - ) - .bind(&uid) - .bind(target_actor_url) - .fetch_one(&self.pool) - .await - .map_err(infra_err)?; - - Ok(FollowRelation { - following: row - .try_get::, _>("following") - .map_err(infra_err)? - .as_deref() - .and_then(follow_status_from_str), - followed_by: row - .try_get::, _>("followed_by") - .map_err(infra_err)? - .as_deref() - .and_then(follow_status_from_str), - }) - } -} diff --git a/crates/adapters/postgres-social/src/lib.rs b/crates/adapters/postgres-social/src/lib.rs deleted file mode 100644 index 02753589..00000000 --- a/crates/adapters/postgres-social/src/lib.rs +++ /dev/null @@ -1,30 +0,0 @@ -mod federated_profile; -mod follow_repository; -mod social; -mod watchlist; - -pub mod ap_content; -pub mod remote_goals; - -pub use ap_content::PostgresApContentQuery; -pub use remote_goals::PostgresRemoteGoalRepository; - -use sqlx::PgPool; - -pub struct PostgresSocialRepository { - pub(crate) pool: PgPool, - pub(crate) instance: domain::value_objects::InstanceIdentity, -} - -impl PostgresSocialRepository { - pub fn new(pool: PgPool, instance: domain::value_objects::InstanceIdentity) -> Self { - Self { pool, instance } - } -} - -pub fn create_federated_profile_query( - pool: PgPool, - instance: domain::value_objects::InstanceIdentity, -) -> std::sync::Arc { - std::sync::Arc::new(PostgresSocialRepository::new(pool, instance)) -} diff --git a/crates/adapters/postgres-social/src/remote_goals.rs b/crates/adapters/postgres-social/src/remote_goals.rs deleted file mode 100644 index 21a6d08d..00000000 --- a/crates/adapters/postgres-social/src/remote_goals.rs +++ /dev/null @@ -1,114 +0,0 @@ -use async_trait::async_trait; -use chrono::TimeZone; -use domain::{errors::DomainError, models::RemoteGoalEntry, ports::RemoteGoalRepository}; -use sqlx::{PgPool, Row}; - -pub struct PostgresRemoteGoalRepository { - pool: PgPool, -} - -impl PostgresRemoteGoalRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl RemoteGoalRepository for PostgresRemoteGoalRepository { - async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError> { - let received = entry.received_at.format("%Y-%m-%d %H:%M:%S").to_string(); - - sqlx::query( - "INSERT INTO remote_goals \ - (ap_id, actor_url, year, target_count, current_count, received_at) \ - VALUES ($1, $2, $3, $4, $5, $6::timestamptz) \ - ON CONFLICT (ap_id) DO UPDATE SET \ - target_count = $4, current_count = $5", - ) - .bind(&entry.ap_id) - .bind(&entry.actor_url) - .bind(entry.year as i64) - .bind(entry.target_count as i64) - .bind(entry.current_count as i64) - .bind(&received) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn update_by_ap_id( - &self, - ap_id: &str, - target: u32, - current: u32, - ) -> Result<(), DomainError> { - sqlx::query( - "UPDATE remote_goals SET target_count = $1, current_count = $2 WHERE ap_id = $3", - ) - .bind(target as i64) - .bind(current as i64) - .bind(ap_id) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> { - sqlx::query("DELETE FROM remote_goals WHERE ap_id = $1 AND actor_url = $2") - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> { - sqlx::query("DELETE FROM remote_goals WHERE actor_url = $1") - .bind(actor_url) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn get_by_actor_url(&self, actor_url: &str) -> Result, DomainError> { - let rows = sqlx::query( - "SELECT ap_id, actor_url, year, target_count, current_count, \ - to_char(received_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS received_at \ - FROM remote_goals WHERE actor_url = $1 ORDER BY year DESC", - ) - .bind(actor_url) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.iter() - .map(|r| { - let year: i64 = r.try_get("year").unwrap_or(0); - let target: i64 = r.try_get("target_count").unwrap_or(0); - let current: i64 = r.try_get("current_count").unwrap_or(0); - let received_str: String = r.try_get("received_at").unwrap_or_default(); - let received_at = - chrono::NaiveDateTime::parse_from_str(&received_str, "%Y-%m-%d %H:%M:%S") - .map(|ndt| chrono::Utc.from_utc_datetime(&ndt)) - .unwrap_or_else(|_| chrono::Utc::now()); - - Ok(RemoteGoalEntry { - ap_id: r.try_get("ap_id").unwrap_or_default(), - actor_url: r.try_get("actor_url").unwrap_or_default(), - year: year as u16, - target_count: target as u32, - current_count: current as u32, - received_at, - }) - }) - .collect() - } -} diff --git a/crates/adapters/postgres-social/src/social.rs b/crates/adapters/postgres-social/src/social.rs deleted file mode 100644 index 8f6dac42..00000000 --- a/crates/adapters/postgres-social/src/social.rs +++ /dev/null @@ -1,21 +0,0 @@ -use async_trait::async_trait; -use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery}; - -use super::PostgresSocialRepository; - -#[async_trait] -impl FederationAdminQuery for PostgresSocialRepository { - async fn list_all_followed_remote_actors(&self) -> Result, DomainError> { - let rows = sqlx::query_as::<_, (String, String, Option)>( - "SELECT DISTINCT ar.url, ar.handle, ar.display_name FROM ap_remote_actors ar JOIN ap_following f ON f.remote_actor_url = ar.url WHERE f.status = 'accepted'", - ).fetch_all(&self.pool).await.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(rows - .into_iter() - .map(|(url, handle, display_name)| RemoteActorInfo { - url, - handle, - display_name, - }) - .collect()) - } -} diff --git a/crates/adapters/postgres-social/src/watchlist.rs b/crates/adapters/postgres-social/src/watchlist.rs deleted file mode 100644 index 903dde11..00000000 --- a/crates/adapters/postgres-social/src/watchlist.rs +++ /dev/null @@ -1,91 +0,0 @@ -use async_trait::async_trait; -use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository}; -use sqlx::Row; - -use super::PostgresSocialRepository; - -#[async_trait] -impl RemoteWatchlistRepository for PostgresSocialRepository { - async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> { - sqlx::query( - "INSERT INTO ap_remote_watchlist_entries \ - (ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7) \ - ON CONFLICT(ap_id) DO UPDATE SET \ - movie_title=excluded.movie_title, release_year=excluded.release_year, \ - external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url", - ) - .bind(&entry.ap_id).bind(&entry.actor_url).bind(&entry.movie_title) - .bind(entry.release_year as i32).bind(&entry.external_metadata_id).bind(&entry.poster_url) - .bind(entry.added_at) - .execute(&self.pool).await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(()) - } - - async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> { - sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE ap_id = $1 AND actor_url = $2") - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(()) - } - - async fn get_by_actor_url( - &self, - actor_url: &str, - ) -> Result, DomainError> { - let rows = sqlx::query( - "SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \ - FROM ap_remote_watchlist_entries WHERE actor_url = $1 ORDER BY added_at DESC", - ).bind(actor_url).fetch_all(&self.pool).await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - rows.into_iter() - .map(|row| { - Ok(RemoteWatchlistEntry { - ap_id: row.try_get("ap_id").unwrap_or_default(), - actor_url: row.try_get("actor_url").unwrap_or_default(), - movie_title: row.try_get("movie_title").unwrap_or_default(), - release_year: row.try_get::("release_year").unwrap_or(0) as u16, - external_metadata_id: row.try_get("external_metadata_id").ok().flatten(), - poster_url: row.try_get("poster_url").ok().flatten(), - added_at: row - .try_get::, _>("added_at") - .unwrap_or_else(|_| chrono::Utc::now()), - }) - }) - .collect() - } - - async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> { - sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = $1") - .bind(actor_url) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(()) - } - - async fn get_by_derived_uuid( - &self, - uuid: uuid::Uuid, - ) -> Result, DomainError> { - let actors: Vec = - sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries") - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))? - .into_iter() - .filter_map(|row| row.try_get::("actor_url").ok()) - .collect(); - let target = actors - .into_iter() - .find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid); - match target { - None => Ok(vec![]), - Some(actor_url) => self.get_by_actor_url(&actor_url).await, - } - } -} diff --git a/crates/adapters/postgres/Cargo.toml b/crates/adapters/postgres/Cargo.toml deleted file mode 100644 index 30627837..00000000 --- a/crates/adapters/postgres/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "postgres" -version = "0.1.0" -edition.workspace = true - -[dependencies] -sqlx = { version = "0.8.6", features = [ - "runtime-tokio-rustls", - "postgres", - "uuid", - "macros", - "chrono", -] } -adapter-common = { workspace = true } -domain = { workspace = true } -postgres-social = { workspace = true } -anyhow = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -tracing = { workspace = true } -async-trait = { workspace = true } -tokio = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -futures = { workspace = true } -bytes = { workspace = true } -async-stream = { workspace = true } diff --git a/crates/adapters/postgres/migrations/0001_initial.sql b/crates/adapters/postgres/migrations/0001_initial.sql deleted file mode 100644 index 63a2ed0f..00000000 --- a/crates/adapters/postgres/migrations/0001_initial.sql +++ /dev/null @@ -1,69 +0,0 @@ -CREATE TABLE IF NOT EXISTS movies ( - id TEXT PRIMARY KEY NOT NULL, - external_metadata_id TEXT UNIQUE, - title TEXT NOT NULL, - release_year BIGINT NOT NULL, - director TEXT, - poster_path TEXT -); - -CREATE INDEX IF NOT EXISTS idx_movies_title_year ON movies (title, release_year); - -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY NOT NULL, - email TEXT UNIQUE NOT NULL, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - role TEXT NOT NULL DEFAULT 'standard' -); - -CREATE TABLE IF NOT EXISTS reviews ( - id TEXT PRIMARY KEY NOT NULL, - movie_id TEXT NOT NULL REFERENCES movies(id), - user_id TEXT NOT NULL, - rating BIGINT NOT NULL, - comment TEXT, - watched_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - remote_actor_url TEXT, - ap_id TEXT -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_ap_id ON reviews (ap_id) WHERE ap_id IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_reviews_movie_id ON reviews (movie_id); -CREATE INDEX IF NOT EXISTS idx_reviews_watched_at ON reviews (watched_at); - -CREATE TABLE IF NOT EXISTS ap_followers ( - local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - remote_actor_url TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - follow_activity_id TEXT, - created_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (local_user_id, remote_actor_url) -); - -CREATE TABLE IF NOT EXISTS ap_following ( - local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - remote_actor_url TEXT NOT NULL, - follow_activity_id TEXT, - status TEXT NOT NULL DEFAULT 'pending', - created_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (local_user_id, remote_actor_url) -); - -CREATE TABLE IF NOT EXISTS ap_remote_actors ( - url TEXT PRIMARY KEY, - handle TEXT NOT NULL, - inbox_url TEXT NOT NULL, - shared_inbox_url TEXT, - display_name TEXT, - fetched_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS ap_local_actors ( - user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - public_key TEXT NOT NULL, - private_key TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL -); diff --git a/crates/adapters/postgres/migrations/0002_import.sql b/crates/adapters/postgres/migrations/0002_import.sql deleted file mode 100644 index 97c0edd9..00000000 --- a/crates/adapters/postgres/migrations/0002_import.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE IF NOT EXISTS import_sessions ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL, - parsed_data TEXT NOT NULL, - field_mappings TEXT, - row_results TEXT, - created_at TIMESTAMPTZ NOT NULL, - expires_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS import_profiles ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - field_mappings TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_import_sessions_user_id ON import_sessions (user_id); -CREATE INDEX IF NOT EXISTS idx_import_sessions_expires_at ON import_sessions (expires_at); -CREATE INDEX IF NOT EXISTS idx_import_profiles_user_id ON import_profiles (user_id); diff --git a/crates/adapters/postgres/migrations/0009_user_profile.sql b/crates/adapters/postgres/migrations/0009_user_profile.sql deleted file mode 100644 index 2d7dcfb9..00000000 --- a/crates/adapters/postgres/migrations/0009_user_profile.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users ADD COLUMN bio TEXT; -ALTER TABLE users ADD COLUMN avatar_path TEXT; diff --git a/crates/adapters/postgres/migrations/0010_ap_remote_actor_avatar.sql b/crates/adapters/postgres/migrations/0010_ap_remote_actor_avatar.sql deleted file mode 100644 index 36624432..00000000 --- a/crates/adapters/postgres/migrations/0010_ap_remote_actor_avatar.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE ap_remote_actors ADD COLUMN avatar_url TEXT; diff --git a/crates/adapters/postgres/migrations/0011_ap_announces.sql b/crates/adapters/postgres/migrations/0011_ap_announces.sql deleted file mode 100644 index 2ab1467f..00000000 --- a/crates/adapters/postgres/migrations/0011_ap_announces.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE ap_announces ( - id TEXT PRIMARY KEY, - object_url TEXT NOT NULL, - actor_url TEXT NOT NULL, - announced_at TEXT NOT NULL -); - -CREATE INDEX idx_ap_announces_object ON ap_announces (object_url); diff --git a/crates/adapters/postgres/migrations/0012_blocked_domains.sql b/crates/adapters/postgres/migrations/0012_blocked_domains.sql deleted file mode 100644 index 4680c892..00000000 --- a/crates/adapters/postgres/migrations/0012_blocked_domains.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE blocked_domains ( - domain TEXT PRIMARY KEY, - reason TEXT, - blocked_at TEXT NOT NULL -); diff --git a/crates/adapters/postgres/migrations/0013_blocked_actors.sql b/crates/adapters/postgres/migrations/0013_blocked_actors.sql deleted file mode 100644 index 0b1245ed..00000000 --- a/crates/adapters/postgres/migrations/0013_blocked_actors.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE blocked_actors ( - local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - remote_actor_url TEXT NOT NULL, - blocked_at TEXT NOT NULL, - PRIMARY KEY (local_user_id, remote_actor_url) -); diff --git a/crates/adapters/postgres/migrations/0014_movie_profiles.sql b/crates/adapters/postgres/migrations/0014_movie_profiles.sql deleted file mode 100644 index c3bdfc83..00000000 --- a/crates/adapters/postgres/migrations/0014_movie_profiles.sql +++ /dev/null @@ -1,54 +0,0 @@ -CREATE TABLE IF NOT EXISTS movie_profiles ( - movie_id TEXT PRIMARY KEY NOT NULL REFERENCES movies(id) ON DELETE CASCADE, - tmdb_id BIGINT NOT NULL, - imdb_id TEXT, - overview TEXT, - tagline TEXT, - runtime_minutes INTEGER, - budget_usd BIGINT, - revenue_usd BIGINT, - vote_average DOUBLE PRECISION, - vote_count INTEGER, - original_language TEXT, - collection_name TEXT, - enriched_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS movie_genres ( - movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE, - tmdb_id INTEGER NOT NULL, - name TEXT NOT NULL, - PRIMARY KEY (movie_id, tmdb_id) -); - -CREATE TABLE IF NOT EXISTS movie_keywords ( - movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE, - tmdb_id INTEGER NOT NULL, - name TEXT NOT NULL, - PRIMARY KEY (movie_id, tmdb_id) -); - -CREATE TABLE IF NOT EXISTS movie_cast ( - movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE, - tmdb_person_id BIGINT NOT NULL, - name TEXT NOT NULL, - character TEXT NOT NULL, - billing_order INTEGER NOT NULL, - profile_path TEXT, - PRIMARY KEY (movie_id, tmdb_person_id) -); - -CREATE TABLE IF NOT EXISTS movie_crew ( - movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE, - tmdb_person_id BIGINT NOT NULL, - name TEXT NOT NULL, - job TEXT NOT NULL, - department TEXT NOT NULL, - profile_path TEXT, - PRIMARY KEY (movie_id, tmdb_person_id, job) -); - -CREATE INDEX IF NOT EXISTS idx_movie_cast_person ON movie_cast (tmdb_person_id); -CREATE INDEX IF NOT EXISTS idx_movie_crew_person ON movie_crew (tmdb_person_id); -CREATE INDEX IF NOT EXISTS idx_movie_genres_name ON movie_genres (name); -CREATE INDEX IF NOT EXISTS idx_movie_keywords_name ON movie_keywords (name); diff --git a/crates/adapters/postgres/migrations/0015_search.sql b/crates/adapters/postgres/migrations/0015_search.sql deleted file mode 100644 index 0a57b4bc..00000000 --- a/crates/adapters/postgres/migrations/0015_search.sql +++ /dev/null @@ -1,25 +0,0 @@ -CREATE TABLE IF NOT EXISTS persons ( - id TEXT PRIMARY KEY, - external_id TEXT NOT NULL UNIQUE, - tmdb_person_id BIGINT UNIQUE, - name TEXT NOT NULL, - known_for_department TEXT, - profile_path TEXT -); - -CREATE INDEX IF NOT EXISTS idx_persons_external ON persons (external_id); -CREATE INDEX IF NOT EXISTS idx_persons_tmdb_id ON persons (tmdb_person_id); - --- tsvector-based search for movies (equivalent of SQLite FTS5) -CREATE TABLE IF NOT EXISTS movies_search ( - movie_id TEXT PRIMARY KEY REFERENCES movies(id) ON DELETE CASCADE, - fts TSVECTOR NOT NULL DEFAULT '' -); -CREATE INDEX IF NOT EXISTS idx_movies_search_fts ON movies_search USING GIN(fts); - --- tsvector-based search for people -CREATE TABLE IF NOT EXISTS people_search ( - person_id TEXT PRIMARY KEY REFERENCES persons(id) ON DELETE CASCADE, - fts TSVECTOR NOT NULL DEFAULT '' -); -CREATE INDEX IF NOT EXISTS idx_people_search_fts ON people_search USING GIN(fts); diff --git a/crates/adapters/postgres/migrations/0016_watchlist.sql b/crates/adapters/postgres/migrations/0016_watchlist.sql deleted file mode 100644 index dcbcef17..00000000 --- a/crates/adapters/postgres/migrations/0016_watchlist.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS watchlist_entries ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE, - added_at TIMESTAMPTZ NOT NULL, - UNIQUE(user_id, movie_id) -); - -CREATE INDEX IF NOT EXISTS idx_watchlist_user ON watchlist_entries(user_id, added_at DESC); diff --git a/crates/adapters/postgres/migrations/0017_ap_remote_watchlist.sql b/crates/adapters/postgres/migrations/0017_ap_remote_watchlist.sql deleted file mode 100644 index cd38bf11..00000000 --- a/crates/adapters/postgres/migrations/0017_ap_remote_watchlist.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE IF NOT EXISTS ap_remote_watchlist_entries ( - ap_id TEXT PRIMARY KEY NOT NULL, - actor_url TEXT NOT NULL, - movie_title TEXT NOT NULL, - release_year INTEGER NOT NULL, - external_metadata_id TEXT, - poster_url TEXT, - added_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_remote_watchlist_actor - ON ap_remote_watchlist_entries(actor_url); diff --git a/crates/adapters/postgres/migrations/0018_remote_actor_outbox_url.sql b/crates/adapters/postgres/migrations/0018_remote_actor_outbox_url.sql deleted file mode 100644 index a767d7d3..00000000 --- a/crates/adapters/postgres/migrations/0018_remote_actor_outbox_url.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE ap_remote_actors ADD COLUMN outbox_url TEXT; diff --git a/crates/adapters/postgres/migrations/0019_profile_extended.sql b/crates/adapters/postgres/migrations/0019_profile_extended.sql deleted file mode 100644 index b2fa2b71..00000000 --- a/crates/adapters/postgres/migrations/0019_profile_extended.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER TABLE users ADD COLUMN IF NOT EXISTS banner_path TEXT; -ALTER TABLE users ADD COLUMN IF NOT EXISTS also_known_as TEXT; - -CREATE TABLE IF NOT EXISTS user_profile_fields ( - id TEXT NOT NULL PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - value TEXT NOT NULL, - position INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_user_profile_fields_user_id - ON user_profile_fields(user_id); diff --git a/crates/adapters/postgres/migrations/0020_kap_v03.sql b/crates/adapters/postgres/migrations/0020_kap_v03.sql deleted file mode 100644 index fbbb4233..00000000 --- a/crates/adapters/postgres/migrations/0020_kap_v03.sql +++ /dev/null @@ -1,12 +0,0 @@ --- k-ap 0.3: new RemoteActor fields + activity dedup table - -ALTER TABLE ap_remote_actors ADD COLUMN IF NOT EXISTS bio TEXT; -ALTER TABLE ap_remote_actors ADD COLUMN IF NOT EXISTS banner_url TEXT; -ALTER TABLE ap_remote_actors ADD COLUMN IF NOT EXISTS followers_url TEXT; -ALTER TABLE ap_remote_actors ADD COLUMN IF NOT EXISTS following_url TEXT; -ALTER TABLE ap_remote_actors ADD COLUMN IF NOT EXISTS also_known_as TEXT; -- JSON array - -CREATE TABLE IF NOT EXISTS ap_activities ( - id TEXT PRIMARY KEY, - processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); diff --git a/crates/adapters/postgres/migrations/0021_user_display_name.sql b/crates/adapters/postgres/migrations/0021_user_display_name.sql deleted file mode 100644 index 00efce82..00000000 --- a/crates/adapters/postgres/migrations/0021_user_display_name.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name TEXT; diff --git a/crates/adapters/postgres/migrations/0022_instance_actor.sql b/crates/adapters/postgres/migrations/0022_instance_actor.sql deleted file mode 100644 index 35774ff3..00000000 --- a/crates/adapters/postgres/migrations/0022_instance_actor.sql +++ /dev/null @@ -1,10 +0,0 @@ -INSERT INTO users (id, username, email, password_hash, created_at, role) -VALUES ( - '00000000-0000-4000-8000-000000000000', - 'instance', - 'noreply@instance.invalid', - '!service-actor-no-login', - NOW(), - 'standard' -) -ON CONFLICT (id) DO NOTHING; diff --git a/crates/adapters/postgres/migrations/0023_watch_events.sql b/crates/adapters/postgres/migrations/0023_watch_events.sql deleted file mode 100644 index eaf0f268..00000000 --- a/crates/adapters/postgres/migrations/0023_watch_events.sql +++ /dev/null @@ -1,28 +0,0 @@ -CREATE TABLE IF NOT EXISTS webhook_tokens ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash TEXT NOT NULL, - provider TEXT NOT NULL, - label TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_used_at TIMESTAMPTZ -); - -CREATE INDEX IF NOT EXISTS idx_webhook_tokens_hash ON webhook_tokens(token_hash); -CREATE INDEX IF NOT EXISTS idx_webhook_tokens_user ON webhook_tokens(user_id); - -CREATE TABLE IF NOT EXISTS watch_events ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - movie_id TEXT REFERENCES movies(id) ON DELETE SET NULL, - title TEXT NOT NULL, - year INTEGER, - external_metadata_id TEXT, - source TEXT NOT NULL, - watched_at TIMESTAMPTZ NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_watch_events_user_status ON watch_events(user_id, status); -CREATE INDEX IF NOT EXISTS idx_watch_events_dedup ON watch_events(user_id, external_metadata_id, created_at); diff --git a/crates/adapters/postgres/migrations/0024_wrap_up.sql b/crates/adapters/postgres/migrations/0024_wrap_up.sql deleted file mode 100644 index fbc0b19e..00000000 --- a/crates/adapters/postgres/migrations/0024_wrap_up.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE IF NOT EXISTS wrap_up_records ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT, - start_date DATE NOT NULL, - end_date DATE NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - report_json TEXT, - error_message TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - completed_at TIMESTAMPTZ -); -CREATE INDEX IF NOT EXISTS idx_wrap_up_user ON wrap_up_records (user_id, start_date, end_date); diff --git a/crates/adapters/postgres/migrations/0025_goals.sql b/crates/adapters/postgres/migrations/0025_goals.sql deleted file mode 100644 index dd124513..00000000 --- a/crates/adapters/postgres/migrations/0025_goals.sql +++ /dev/null @@ -1,25 +0,0 @@ -CREATE TABLE IF NOT EXISTS goals ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - year BIGINT NOT NULL, - target_count BIGINT NOT NULL, - goal_type TEXT NOT NULL DEFAULT 'movies', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(user_id, year) -); -CREATE INDEX IF NOT EXISTS idx_goals_user ON goals(user_id); - -CREATE TABLE IF NOT EXISTS user_settings ( - user_id TEXT PRIMARY KEY NOT NULL REFERENCES users(id) ON DELETE CASCADE, - federate_goals BIGINT NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS remote_goals ( - ap_id TEXT PRIMARY KEY NOT NULL, - actor_url TEXT NOT NULL, - year BIGINT NOT NULL, - target_count BIGINT NOT NULL, - current_count BIGINT NOT NULL DEFAULT 0, - received_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX IF NOT EXISTS idx_remote_goals_actor ON remote_goals(actor_url); diff --git a/crates/adapters/postgres/migrations/0026_person_enrichment.sql b/crates/adapters/postgres/migrations/0026_person_enrichment.sql deleted file mode 100644 index 4d46f6d6..00000000 --- a/crates/adapters/postgres/migrations/0026_person_enrichment.sql +++ /dev/null @@ -1,8 +0,0 @@ -ALTER TABLE persons ADD COLUMN biography TEXT; -ALTER TABLE persons ADD COLUMN birthday TEXT; -ALTER TABLE persons ADD COLUMN deathday TEXT; -ALTER TABLE persons ADD COLUMN place_of_birth TEXT; -ALTER TABLE persons ADD COLUMN also_known_as TEXT; -ALTER TABLE persons ADD COLUMN homepage TEXT; -ALTER TABLE persons ADD COLUMN imdb_id TEXT; -ALTER TABLE persons ADD COLUMN enriched_at TIMESTAMPTZ; diff --git a/crates/adapters/postgres/migrations/0027_refresh_sessions.sql b/crates/adapters/postgres/migrations/0027_refresh_sessions.sql deleted file mode 100644 index 26e97a95..00000000 --- a/crates/adapters/postgres/migrations/0027_refresh_sessions.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE IF NOT EXISTS refresh_sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - token TEXT NOT NULL UNIQUE, - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token); -CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user_id ON refresh_sessions(user_id); -CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires_at ON refresh_sessions(expires_at); diff --git a/crates/adapters/postgres/migrations/0028_federation_privacy.sql b/crates/adapters/postgres/migrations/0028_federation_privacy.sql deleted file mode 100644 index 4e8ec86d..00000000 --- a/crates/adapters/postgres/migrations/0028_federation_privacy.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE user_settings ADD COLUMN federate_reviews BOOLEAN NOT NULL DEFAULT TRUE; -ALTER TABLE user_settings ADD COLUMN federate_watchlist BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/crates/adapters/postgres/migrations/0029_watch_medium.sql b/crates/adapters/postgres/migrations/0029_watch_medium.sql deleted file mode 100644 index 64923703..00000000 --- a/crates/adapters/postgres/migrations/0029_watch_medium.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE reviews ADD COLUMN watch_medium TEXT; diff --git a/crates/adapters/postgres/migrations/0030_review_spoilers.sql b/crates/adapters/postgres/migrations/0030_review_spoilers.sql deleted file mode 100644 index 41cd6e71..00000000 --- a/crates/adapters/postgres/migrations/0030_review_spoilers.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE reviews ADD COLUMN spoilers BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/crates/adapters/postgres/src/diary.rs b/crates/adapters/postgres/src/diary.rs deleted file mode 100644 index 9c14e8e1..00000000 --- a/crates/adapters/postgres/src/diary.rs +++ /dev/null @@ -1,570 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{ - DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, ReviewSortBy, - collections::{PageParams, Paginated}, - }, - ports::DiaryQuery, - value_objects::{MovieId, UserId}, -}; -use futures::stream::BoxStream; -use sqlx::PgPool; - -use crate::models::{DiaryRow, FeedRow, MovieRow, MovieStatsRow, ReviewRow}; - -pub struct PostgresDiaryRepository { - pool: PgPool, -} - -impl PostgresDiaryRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result { - match movie_id { - None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews") - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error), - Some(id) => { - sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = $1") - .bind(id) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } - } - } - - async fn fetch_all_diary_rows( - &self, - sort: &ReviewSortBy, - limit: i64, - offset: i64, - ) -> Result, DomainError> { - let order = match sort { - ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", - ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", - ReviewSortBy::Ascending => "r.watched_at ASC", - ReviewSortBy::Descending => "r.watched_at DESC", - }; - let sql = format!( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - ORDER BY {} - LIMIT $1 OFFSET $2", - order - ); - sqlx::query_as::<_, DiaryRow>(&sql) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } - - async fn fetch_movie_diary_rows( - &self, - movie_id: &str, - sort: &ReviewSortBy, - limit: i64, - offset: i64, - ) -> Result, DomainError> { - let order = match sort { - ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", - ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", - ReviewSortBy::Ascending => "r.watched_at ASC", - ReviewSortBy::Descending => "r.watched_at DESC", - }; - let sql = format!( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.movie_id = $1 - ORDER BY {} - LIMIT $2 OFFSET $3", - order - ); - sqlx::query_as::<_, DiaryRow>(&sql) - .bind(movie_id) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } - - async fn count_user_diary_entries( - &self, - user_id: &str, - search: Option<&str>, - include_remote: bool, - ) -> Result { - let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); - let remote_clause = if include_remote { - "" - } else { - " AND r.remote_actor_url IS NULL" - }; - let search_clause = if has_search { - " AND m.title ILIKE '%' || $2 || '%'" - } else { - "" - }; - let sql = format!( - "SELECT COUNT(*) FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1{remote_clause}{search_clause}" - ); - let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id); - if has_search { - q = q.bind(search.unwrap()); - } - q.fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } - - async fn fetch_user_diary_rows( - &self, - user_id: &str, - sort: &ReviewSortBy, - search: Option<&str>, - include_remote: bool, - limit: i64, - offset: i64, - ) -> Result, DomainError> { - let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); - let order_clause = match sort { - ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", - ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", - ReviewSortBy::Ascending => "r.watched_at ASC", - ReviewSortBy::Descending => "r.watched_at DESC", - }; - let remote_clause = if include_remote { - "" - } else { - " AND r.remote_actor_url IS NULL" - }; - - let mut p: i32 = 1; - let search_clause = if has_search { - p += 1; - format!(" AND m.title ILIKE '%' || ${} || '%'", p) - } else { - String::new() - }; - p += 1; - let limit_param = format!("${}", p); - p += 1; - let offset_param = format!("${}", p); - - let sql = format!( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1{remote_clause}{search_clause} - ORDER BY {order_clause} - LIMIT {limit_param} OFFSET {offset_param}", - ); - - let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id); - if has_search { - q = q.bind(search.unwrap()); - } - q.bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } -} - -#[async_trait] -impl DiaryQuery for PostgresDiaryRepository { - async fn query_diary( - &self, - filter: &DiaryFilter, - ) -> Result, DomainError> { - let limit = filter.page.limit as i64; - let offset = filter.page.offset as i64; - - let (total, rows) = match (&filter.movie_id, &filter.user_id) { - (None, None) => tokio::try_join!( - self.count_diary_entries(None), - self.fetch_all_diary_rows(&filter.sort_by, limit, offset) - )?, - (Some(id), None) => { - let id_str = id.value().to_string(); - tokio::try_join!( - self.count_diary_entries(Some(id_str.as_str())), - self.fetch_movie_diary_rows(&id_str, &filter.sort_by, limit, offset) - )? - } - (None, Some(uid)) => { - let uid_str = uid.value().to_string(); - let search = filter.search.as_deref(); - let inc = filter.include_remote; - tokio::try_join!( - self.count_user_diary_entries(&uid_str, search, inc), - self.fetch_user_diary_rows( - &uid_str, - &filter.sort_by, - search, - inc, - limit, - offset - ) - )? - } - (Some(_), Some(_)) => { - return Err(DomainError::ValidationError( - "Combined movie_id + user_id filter not supported".into(), - )); - } - }; - - let items = rows - .into_iter() - .map(DiaryRow::into_domain) - .collect::, _>>()?; - - Ok(Paginated { - items, - total_count: total as u64, - limit: filter.page.limit, - offset: filter.page.offset, - }) - } - - async fn query_activity_feed( - &self, - page: &PageParams, - ) -> Result, DomainError> { - self.query_activity_feed_filtered(page, &domain::models::FeedSortBy::Date, None, None) - .await - } - - async fn query_activity_feed_filtered( - &self, - page: &PageParams, - sort_by: &domain::models::FeedSortBy, - search: Option<&str>, - following: Option<&domain::models::FollowingFilter>, - ) -> Result, DomainError> { - use domain::models::FeedSortBy; - - let limit = page.limit as i64; - let offset = page.offset as i64; - let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); - - let mut p: i32 = 0; - let mut next_param = || { - p += 1; - format!("${}", p) - }; - - let mut where_parts = vec!["1=1".to_string()]; - - if has_search { - let pn = next_param(); - where_parts.push(format!("m.title ILIKE '%' || {} || '%'", pn)); - } - - if let Some(f) = following { - let local_params: Vec = f.local_user_ids.iter().map(|_| next_param()).collect(); - let remote_params: Vec = - f.remote_actor_urls.iter().map(|_| next_param()).collect(); - - let local_in = if local_params.is_empty() { - "(SELECT NULL::text WHERE false)".to_string() - } else { - local_params.join(", ") - }; - let remote_in = if remote_params.is_empty() { - "(SELECT NULL::text WHERE false)".to_string() - } else { - remote_params.join(", ") - }; - where_parts.push(format!( - "(r.user_id IN ({}) OR r.remote_actor_url IN ({}))", - local_in, remote_in - )); - } - - let limit_param = next_param(); - let offset_param = next_param(); - - let order_clause = match sort_by { - FeedSortBy::Date => "r.watched_at DESC", - FeedSortBy::DateAsc => "r.watched_at ASC", - FeedSortBy::Rating => "r.rating DESC, r.watched_at DESC", - FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC", - }; - - let where_clause = where_parts.join(" AND "); - - let count_sql = format!( - "SELECT COUNT(*) FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE {}", - where_clause - ); - - let select_sql = format!( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers, - CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url) - ELSE COALESCE(u.email, r.user_id) END AS user_email - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - LEFT JOIN users u ON u.id = r.user_id - LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url - WHERE {} - ORDER BY {} - LIMIT {} OFFSET {}", - where_clause, order_clause, limit_param, offset_param - ); - - macro_rules! bind_filter_params { - ($q:expr) => {{ - let mut q = $q; - if has_search { - q = q.bind(search.unwrap()); - } - if let Some(f) = following { - for uid in &f.local_user_ids { - q = q.bind(uid.to_string()); - } - for url in &f.remote_actor_urls { - q = q.bind(url.as_str()); - } - } - q - }}; - } - - let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql)); - let total = count_q - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql)); - let rows = rows_q - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let items = rows - .into_iter() - .map(FeedRow::into_domain) - .collect::, _>>()?; - - Ok(Paginated { - items, - total_count: total as u64, - limit: page.limit, - offset: page.offset, - }) - } - - async fn get_review_history(&self, movie_id: &MovieId) -> Result { - let id_str = movie_id.value().to_string(); - - let movie = sqlx::query_as::<_, MovieRow>( - "SELECT id, external_metadata_id, title, release_year, director, poster_path - FROM movies WHERE id = $1", - ) - .bind(&id_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))? - .into_domain()?; - - let viewings = sqlx::query_as::<_, ReviewRow>( - "SELECT id, movie_id, user_id, rating, comment, - to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - remote_actor_url, - watch_medium, - spoilers - FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC", - ) - .bind(&id_str) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(ReviewRow::into_domain) - .collect::, _>>()?; - - Ok(ReviewHistory::new(movie, viewings)) - } - - async fn get_user_history(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let rows = sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1 - ORDER BY r.watched_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.into_iter().map(DiaryRow::into_domain).collect() - } - - fn stream_user_history( - &self, - user_id: UserId, - ) -> BoxStream<'static, Result> { - let pool = self.pool.clone(); - let uid = user_id.value().to_string(); - Box::pin(async_stream::stream! { - let mut rows = sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1 - ORDER BY r.watched_at DESC", - ) - .bind(&uid) - .fetch(&pool); - while let Some(row) = futures::StreamExt::next(&mut rows).await { - yield match row { - Ok(r) => r.into_domain(), - Err(e) => Err(adapter_common::map_sqlx_error(e)), - }; - } - }) - } - - async fn get_movie_stats(&self, movie_id: &MovieId) -> Result { - let id_str = movie_id.value().to_string(); - sqlx::query_as::<_, MovieStatsRow>( - "SELECT - COUNT(*) AS total_count, - AVG(CAST(rating AS FLOAT)) AS avg_rating, - COUNT(CASE WHEN remote_actor_url IS NOT NULL THEN 1 END) AS federated_count, - COUNT(CASE WHEN rating = 1 THEN 1 END) AS rating_1, - COUNT(CASE WHEN rating = 2 THEN 1 END) AS rating_2, - COUNT(CASE WHEN rating = 3 THEN 1 END) AS rating_3, - COUNT(CASE WHEN rating = 4 THEN 1 END) AS rating_4, - COUNT(CASE WHEN rating = 5 THEN 1 END) AS rating_5 - FROM reviews WHERE movie_id = $1", - ) - .bind(id_str) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - .map(MovieStatsRow::into_domain) - } - - async fn get_movie_social_feed( - &self, - movie_id: &MovieId, - page: &PageParams, - ) -> Result, DomainError> { - let id_str = movie_id.value().to_string(); - let limit = page.limit as i64; - let offset = page.offset as i64; - - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE movie_id = $1") - .bind(&id_str) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let rows = sqlx::query_as::<_, FeedRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers, - CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url) - ELSE COALESCE(u.email, r.user_id) END AS user_email - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - LEFT JOIN users u ON u.id = r.user_id - LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url - WHERE r.movie_id = $1 - ORDER BY r.watched_at DESC - LIMIT $2 OFFSET $3", - ) - .bind(&id_str) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let items = rows - .into_iter() - .map(FeedRow::into_domain) - .collect::, _>>()?; - - Ok(Paginated { - items, - total_count: total as u64, - limit: page.limit, - offset: page.offset, - }) - } - - async fn count_local_posts(&self) -> Result { - let count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL") - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(count as u64) - } -} diff --git a/crates/adapters/postgres/src/goals.rs b/crates/adapters/postgres/src/goals.rs deleted file mode 100644 index 2547e1e5..00000000 --- a/crates/adapters/postgres/src/goals.rs +++ /dev/null @@ -1,189 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{Goal, GoalType}, - ports::{GoalCommand, GoalQuery}, - value_objects::{GoalId, UserId}, -}; -use sqlx::{PgPool, Row}; - -use adapter_common::{datetime_to_str, parse_datetime, parse_uuid}; - -pub struct PostgresGoalRepository { - pool: PgPool, -} - -impl PostgresGoalRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl GoalCommand for PostgresGoalRepository { - async fn save(&self, goal: &Goal) -> Result<(), DomainError> { - let id = goal.id().value().to_string(); - let user_id = goal.user_id().value().to_string(); - let year = goal.year() as i64; - let target = goal.target_count() as i64; - let goal_type = goal.goal_type().as_str(); - let created_at = datetime_to_str(goal.created_at()); - - sqlx::query( - "INSERT INTO goals (id, user_id, year, target_count, goal_type, created_at) \ - VALUES ($1, $2, $3, $4, $5, $6::timestamptz)", - ) - .bind(&id) - .bind(&user_id) - .bind(year) - .bind(target) - .bind(goal_type) - .bind(&created_at) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn update(&self, goal: &Goal) -> Result<(), DomainError> { - let id = goal.id().value().to_string(); - let target = goal.target_count() as i64; - - let result = sqlx::query("UPDATE goals SET target_count = $1 WHERE id = $2") - .bind(target) - .bind(&id) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - if result.rows_affected() == 0 { - return Err(DomainError::NotFound("Goal not found".into())); - } - Ok(()) - } - - async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError> { - let gid = id.value().to_string(); - let uid = user_id.value().to_string(); - - let result = sqlx::query("DELETE FROM goals WHERE id = $1 AND user_id = $2") - .bind(&gid) - .bind(&uid) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - if result.rows_affected() == 0 { - return Err(DomainError::NotFound("Goal not found".into())); - } - Ok(()) - } -} - -#[async_trait] -impl GoalQuery for PostgresGoalRepository { - async fn find_by_user_and_year( - &self, - user_id: &UserId, - year: u16, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let y = year as i64; - - let row = sqlx::query( - "SELECT id, user_id, year, target_count, goal_type, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \ - FROM goals WHERE user_id = $1 AND year = $2", - ) - .bind(&uid) - .bind(y) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.map(|r| row_to_goal(&r)).transpose() - } - - async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - - let rows = sqlx::query( - "SELECT id, user_id, year, target_count, goal_type, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \ - FROM goals WHERE user_id = $1 ORDER BY year DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.iter().map(row_to_goal).collect() - } -} - -pub(crate) async fn count_reviews_in_year( - pool: &PgPool, - user_id: &UserId, - year: u16, -) -> Result { - let uid = user_id.value().to_string(); - let start = format!("{year}-01-01 00:00:00"); - let end = format!("{}-01-01 00:00:00", year + 1); - - let count: i64 = sqlx::query( - "SELECT COUNT(*) FROM reviews \ - WHERE user_id = $1 \ - AND watched_at >= $2::timestamptz \ - AND watched_at < $3::timestamptz \ - AND remote_actor_url IS NULL", - ) - .bind(&uid) - .bind(&start) - .bind(&end) - .fetch_one(pool) - .await - .map_err(|e| { - tracing::error!("Database error: {:?}", e); - DomainError::InfrastructureError("Database operation failed".into()) - })? - .try_get(0) - .unwrap_or(0); - - Ok(count as u32) -} - -pub(crate) fn row_to_goal(r: &sqlx::postgres::PgRow) -> Result { - let id_str: String = r - .try_get("id") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?; - let user_id_str: String = r - .try_get("user_id") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?; - let year: i64 = r - .try_get("year") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?; - let target: i64 = r.try_get("target_count").map_err(|e| { - DomainError::InfrastructureError(format!("Failed to read target_count: {e}")) - })?; - let goal_type_str: String = r - .try_get("goal_type") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?; - let created_at_str: String = r - .try_get("created_at") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?; - - let id = GoalId::from_uuid(parse_uuid(&id_str)?); - let user_id = UserId::from_uuid(parse_uuid(&user_id_str)?); - let goal_type: GoalType = goal_type_str.parse()?; - let created_at = parse_datetime(&created_at_str)?; - - Ok(Goal::from_persistence( - id, - user_id, - year as u16, - target as u32, - goal_type, - created_at, - )) -} diff --git a/crates/adapters/postgres/src/image_ref.rs b/crates/adapters/postgres/src/image_ref.rs deleted file mode 100644 index d532cfb5..00000000 --- a/crates/adapters/postgres/src/image_ref.rs +++ /dev/null @@ -1,66 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - ports::{ImageRefCommand, ImageRefQuery}, -}; -use sqlx::PgPool; -use std::sync::Arc; - -pub struct PostgresImageRefAdapter { - pool: PgPool, -} - -impl PostgresImageRefAdapter { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -pub fn create_image_ref(pool: PgPool) -> (Arc, Arc) { - let adapter = Arc::new(PostgresImageRefAdapter::new(pool)); - ( - Arc::clone(&adapter) as Arc, - adapter as Arc, - ) -} - -#[async_trait] -impl ImageRefCommand for PostgresImageRefAdapter { - async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> { - let mut tx = self - .pool - .begin() - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - sqlx::query("UPDATE users SET avatar_path = $1 WHERE avatar_path = $2") - .bind(new_key) - .bind(old_key) - .execute(&mut *tx) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - sqlx::query("UPDATE movies SET poster_path = $1 WHERE poster_path = $2") - .bind(new_key) - .bind(old_key) - .execute(&mut *tx) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - tx.commit() - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string())) - } -} - -#[async_trait] -impl ImageRefQuery for PostgresImageRefAdapter { - async fn list_keys(&self) -> Result, DomainError> { - let rows: Vec<(String,)> = sqlx::query_as( - "SELECT avatar_path FROM users WHERE avatar_path IS NOT NULL - UNION - SELECT poster_path FROM movies WHERE poster_path IS NOT NULL", - ) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(rows.into_iter().map(|(k,)| k).collect()) - } -} diff --git a/crates/adapters/postgres/src/import_profile.rs b/crates/adapters/postgres/src/import_profile.rs deleted file mode 100644 index 3d033c84..00000000 --- a/crates/adapters/postgres/src/import_profile.rs +++ /dev/null @@ -1,212 +0,0 @@ -use async_trait::async_trait; -use chrono::NaiveDateTime; -use domain::{ - errors::DomainError, - models::{ - FieldMapping, ImportProfile, - import::{DomainField, Transform}, - }, - ports::ImportProfileRepository, - value_objects::{ImportProfileId, UserId}, -}; -use serde::{Deserialize, Serialize}; -use sqlx::PgPool; - -#[derive(Serialize, Deserialize)] -enum DomainFieldJson { - Title, - ReleaseYear, - Director, - Rating, - WatchedAt, - Comment, - ExternalMetadataId, -} - -#[derive(Serialize, Deserialize)] -enum TransformJson { - RatingScale(f64), - DateFormat(String), - Identity, -} - -#[derive(Serialize, Deserialize)] -struct FieldMappingJson { - source_column: String, - domain_field: DomainFieldJson, - transform: TransformJson, -} - -fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson { - FieldMappingJson { - source_column: m.source_column.clone(), - domain_field: match &m.domain_field { - DomainField::Title => DomainFieldJson::Title, - DomainField::ReleaseYear => DomainFieldJson::ReleaseYear, - DomainField::Director => DomainFieldJson::Director, - DomainField::Rating => DomainFieldJson::Rating, - DomainField::WatchedAt => DomainFieldJson::WatchedAt, - DomainField::Comment => DomainFieldJson::Comment, - DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId, - }, - transform: match &m.transform { - Transform::RatingScale(f) => TransformJson::RatingScale(*f), - Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()), - Transform::Identity => TransformJson::Identity, - }, - } -} - -fn mapping_from_json(j: FieldMappingJson) -> FieldMapping { - FieldMapping { - source_column: j.source_column, - domain_field: match j.domain_field { - DomainFieldJson::Title => DomainField::Title, - DomainFieldJson::ReleaseYear => DomainField::ReleaseYear, - DomainFieldJson::Director => DomainField::Director, - DomainFieldJson::Rating => DomainField::Rating, - DomainFieldJson::WatchedAt => DomainField::WatchedAt, - DomainFieldJson::Comment => DomainField::Comment, - DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId, - }, - transform: match j.transform { - TransformJson::RatingScale(f) => Transform::RatingScale(f), - TransformJson::DateFormat(s) => Transform::DateFormat(s), - TransformJson::Identity => Transform::Identity, - }, - } -} - -fn serialize_mappings(ms: &[FieldMapping]) -> Result { - serde_json::to_string(&ms.iter().map(mapping_to_json).collect::>()) - .map_err(|e| DomainError::InfrastructureError(e.to_string())) -} - -fn deserialize_mappings(s: &str) -> Result, DomainError> { - let js: Vec = - serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(js.into_iter().map(mapping_from_json).collect()) -} - -pub struct PostgresImportProfileRepository { - pool: PgPool, -} - -impl PostgresImportProfileRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl ImportProfileRepository for PostgresImportProfileRepository { - async fn save(&self, p: &ImportProfile) -> Result<(), DomainError> { - let id = p.id.value().to_string(); - let user_id = p.user_id.value().to_string(); - let field_mappings = serialize_mappings(&p.field_mappings)?; - sqlx::query( - "INSERT INTO import_profiles (id, user_id, name, field_mappings, created_at) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, field_mappings = EXCLUDED.field_mappings", - ) - .bind(&id).bind(&user_id).bind(&p.name).bind(&field_mappings).bind(p.created_at) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) - } - - async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - - #[derive(sqlx::FromRow)] - struct Row { - id: String, - user_id: String, - name: String, - field_mappings: String, - created_at: NaiveDateTime, - } - - let rows = sqlx::query_as::<_, Row>( - "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = $1 ORDER BY created_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.into_iter() - .map(|r| { - Ok(ImportProfile { - id: ImportProfileId::from_uuid( - r.id.parse::() - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - ), - user_id: UserId::from_uuid( - r.user_id - .parse::() - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - ), - name: r.name, - field_mappings: deserialize_mappings(&r.field_mappings)?, - created_at: r.created_at, - }) - }) - .collect() - } - - async fn get( - &self, - id: &ImportProfileId, - user_id: &UserId, - ) -> Result, DomainError> { - let id_str = id.value().to_string(); - let uid_str = user_id.value().to_string(); - - #[derive(sqlx::FromRow)] - struct Row { - id: String, - user_id: String, - name: String, - field_mappings: String, - created_at: NaiveDateTime, - } - - let row = sqlx::query_as::<_, Row>( - "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = $1 AND user_id = $2", - ) - .bind(&id_str).bind(&uid_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.map(|r| { - Ok(ImportProfile { - id: ImportProfileId::from_uuid( - r.id.parse::() - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - ), - user_id: UserId::from_uuid( - r.user_id - .parse::() - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - ), - name: r.name, - field_mappings: deserialize_mappings(&r.field_mappings)?, - created_at: r.created_at, - }) - }) - .transpose() - } - - async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - sqlx::query("DELETE FROM import_profiles WHERE id = $1") - .bind(&id_str) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) - } -} diff --git a/crates/adapters/postgres/src/import_session.rs b/crates/adapters/postgres/src/import_session.rs deleted file mode 100644 index 17733e58..00000000 --- a/crates/adapters/postgres/src/import_session.rs +++ /dev/null @@ -1,387 +0,0 @@ -use async_trait::async_trait; -use chrono::NaiveDateTime; -use domain::{ - errors::DomainError, - models::{ - AnnotatedRow, FieldMapping, ImportSession, ParsedFile, - import::{DomainField, ImportRow, RowResult, Transform}, - }, - ports::ImportSessionRepository, - value_objects::{ImportSessionId, UserId}, -}; -use serde::{Deserialize, Serialize}; -use sqlx::PgPool; - -// ── serde mirror structs ── - -#[derive(Serialize, Deserialize, Default)] -struct ParsedFileJson { - columns: Vec, - rows: Vec>, -} - -#[derive(Serialize, Deserialize)] -enum DomainFieldJson { - Title, - ReleaseYear, - Director, - Rating, - WatchedAt, - Comment, - ExternalMetadataId, -} - -#[derive(Serialize, Deserialize)] -enum TransformJson { - RatingScale(f64), - DateFormat(String), - Identity, -} - -#[derive(Serialize, Deserialize)] -struct FieldMappingJson { - source_column: String, - domain_field: DomainFieldJson, - transform: TransformJson, -} - -#[derive(Serialize, Deserialize, Default)] -struct ImportRowJson { - #[serde(skip_serializing_if = "Option::is_none")] - title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - release_year: Option, - #[serde(skip_serializing_if = "Option::is_none")] - director: Option, - #[serde(skip_serializing_if = "Option::is_none")] - rating: Option, - #[serde(skip_serializing_if = "Option::is_none")] - watched_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - comment: Option, - #[serde(skip_serializing_if = "Option::is_none")] - external_metadata_id: Option, -} - -#[derive(Serialize, Deserialize)] -enum RowResultJson { - Valid(ImportRowJson), - Invalid { - errors: Vec, - raw: Vec<(String, String)>, - }, -} - -#[derive(Serialize, Deserialize)] -struct AnnotatedRowJson { - result: RowResultJson, - is_duplicate: bool, -} - -// ── conversion helpers ── - -fn domain_field_to_json(f: &DomainField) -> DomainFieldJson { - match f { - DomainField::Title => DomainFieldJson::Title, - DomainField::ReleaseYear => DomainFieldJson::ReleaseYear, - DomainField::Director => DomainFieldJson::Director, - DomainField::Rating => DomainFieldJson::Rating, - DomainField::WatchedAt => DomainFieldJson::WatchedAt, - DomainField::Comment => DomainFieldJson::Comment, - DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId, - } -} - -fn domain_field_from_json(j: DomainFieldJson) -> DomainField { - match j { - DomainFieldJson::Title => DomainField::Title, - DomainFieldJson::ReleaseYear => DomainField::ReleaseYear, - DomainFieldJson::Director => DomainField::Director, - DomainFieldJson::Rating => DomainField::Rating, - DomainFieldJson::WatchedAt => DomainField::WatchedAt, - DomainFieldJson::Comment => DomainField::Comment, - DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId, - } -} - -fn transform_to_json(t: &Transform) -> TransformJson { - match t { - Transform::RatingScale(f) => TransformJson::RatingScale(*f), - Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()), - Transform::Identity => TransformJson::Identity, - } -} - -fn transform_from_json(j: TransformJson) -> Transform { - match j { - TransformJson::RatingScale(f) => Transform::RatingScale(f), - TransformJson::DateFormat(s) => Transform::DateFormat(s), - TransformJson::Identity => Transform::Identity, - } -} - -fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson { - FieldMappingJson { - source_column: m.source_column.clone(), - domain_field: domain_field_to_json(&m.domain_field), - transform: transform_to_json(&m.transform), - } -} - -fn mapping_from_json(j: FieldMappingJson) -> FieldMapping { - FieldMapping { - source_column: j.source_column, - domain_field: domain_field_from_json(j.domain_field), - transform: transform_from_json(j.transform), - } -} - -fn import_row_to_json(r: &ImportRow) -> ImportRowJson { - ImportRowJson { - title: r.title.clone(), - release_year: r.release_year.clone(), - director: r.director.clone(), - rating: r.rating.clone(), - watched_at: r.watched_at.clone(), - comment: r.comment.clone(), - external_metadata_id: r.external_metadata_id.clone(), - } -} - -fn import_row_from_json(j: ImportRowJson) -> ImportRow { - ImportRow { - title: j.title, - release_year: j.release_year, - director: j.director, - rating: j.rating, - watched_at: j.watched_at, - comment: j.comment, - external_metadata_id: j.external_metadata_id, - } -} - -fn annotated_to_json(a: &AnnotatedRow) -> AnnotatedRowJson { - AnnotatedRowJson { - result: match &a.result { - RowResult::Valid(row) => RowResultJson::Valid(import_row_to_json(row)), - RowResult::Invalid { errors, raw } => RowResultJson::Invalid { - errors: errors.clone(), - raw: raw.clone(), - }, - }, - is_duplicate: a.is_duplicate, - } -} - -fn annotated_from_json(j: AnnotatedRowJson) -> AnnotatedRow { - AnnotatedRow { - result: match j.result { - RowResultJson::Valid(row) => RowResult::Valid(import_row_from_json(row)), - RowResultJson::Invalid { errors, raw } => RowResult::Invalid { errors, raw }, - }, - is_duplicate: j.is_duplicate, - } -} - -fn ser(v: &T) -> Result { - serde_json::to_string(v).map_err(|e| DomainError::InfrastructureError(e.to_string())) -} - -fn de Deserialize<'de>>(s: &str) -> Result { - serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string())) -} - -// ── repository ── - -pub struct PostgresImportSessionRepository { - pool: PgPool, -} - -impl PostgresImportSessionRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - fn serialize_session( - s: &ImportSession, - ) -> Result<(String, Option, Option), DomainError> { - let parsed = s - .parsed_file - .as_ref() - .map(|f| { - ser(&ParsedFileJson { - columns: f.columns.clone(), - rows: f.rows.clone(), - }) - }) - .transpose()? - .unwrap_or_default(); - let mappings = s - .field_mappings - .as_ref() - .map(|ms| ser(&ms.iter().map(mapping_to_json).collect::>())) - .transpose()?; - let results = s - .row_results - .as_ref() - .map(|rs| ser(&rs.iter().map(annotated_to_json).collect::>())) - .transpose()?; - Ok((parsed, mappings, results)) - } - - fn deserialize_session( - id: String, - user_id: String, - parsed_data: String, - field_mappings: Option, - row_results: Option, - created_at: NaiveDateTime, - expires_at: NaiveDateTime, - ) -> Result { - let parsed_file = if parsed_data.is_empty() { - None - } else { - let j: ParsedFileJson = de(&parsed_data)?; - Some(ParsedFile { - columns: j.columns, - rows: j.rows, - }) - }; - let field_mappings = field_mappings - .as_deref() - .map(|s| -> Result, DomainError> { - let js: Vec = de(s)?; - Ok(js.into_iter().map(mapping_from_json).collect()) - }) - .transpose()?; - let row_results = row_results - .as_deref() - .map(|s| -> Result, DomainError> { - let js: Vec = de(s)?; - Ok(js.into_iter().map(annotated_from_json).collect()) - }) - .transpose()?; - Ok(ImportSession { - id: ImportSessionId::from_uuid( - id.parse::() - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - ), - user_id: UserId::from_uuid( - user_id - .parse::() - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - ), - parsed_file, - field_mappings, - row_results, - created_at, - expires_at, - }) - } -} - -#[async_trait] -impl ImportSessionRepository for PostgresImportSessionRepository { - async fn create(&self, s: &ImportSession) -> Result<(), DomainError> { - let id = s.id.value().to_string(); - let user_id = s.user_id.value().to_string(); - let (parsed_data, field_mappings, row_results) = Self::serialize_session(s)?; - sqlx::query( - "INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7)", - ) - .bind(&id).bind(&user_id).bind(&parsed_data) - .bind(&field_mappings).bind(&row_results) - .bind(s.created_at).bind(s.expires_at) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) - } - - async fn get( - &self, - id: &ImportSessionId, - user_id: &UserId, - ) -> Result, DomainError> { - let id_str = id.value().to_string(); - let uid_str = user_id.value().to_string(); - - #[derive(sqlx::FromRow)] - struct Row { - id: String, - user_id: String, - parsed_data: String, - field_mappings: Option, - row_results: Option, - created_at: NaiveDateTime, - expires_at: NaiveDateTime, - } - - let row = sqlx::query_as::<_, Row>( - "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at - FROM import_sessions WHERE id = $1 AND user_id = $2", - ) - .bind(&id_str) - .bind(&uid_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.map(|r| { - Self::deserialize_session( - r.id, - r.user_id, - r.parsed_data, - r.field_mappings, - r.row_results, - r.created_at, - r.expires_at, - ) - }) - .transpose() - } - - async fn update(&self, s: &ImportSession) -> Result<(), DomainError> { - let id = s.id.value().to_string(); - let (_, field_mappings, row_results) = Self::serialize_session(s)?; - sqlx::query( - "UPDATE import_sessions SET field_mappings = $1, row_results = $2 WHERE id = $3", - ) - .bind(&field_mappings) - .bind(&row_results) - .bind(&id) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) - } - - async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - sqlx::query("DELETE FROM import_sessions WHERE id = $1") - .bind(&id_str) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) - } - - async fn delete_expired(&self) -> Result { - let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < NOW()") - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(result.rows_affected()) - } - - async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { - let uid = user_id.value().to_string(); - sqlx::query("DELETE FROM import_sessions WHERE user_id = $1 AND expires_at < NOW()") - .bind(&uid) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) - } -} diff --git a/crates/adapters/postgres/src/insights.rs b/crates/adapters/postgres/src/insights.rs deleted file mode 100644 index 09bfe971..00000000 --- a/crates/adapters/postgres/src/insights.rs +++ /dev/null @@ -1,81 +0,0 @@ -use std::collections::HashMap; - -use async_trait::async_trait; -use domain::{ - errors::DomainError, models::insights::WatchRow, ports::InsightsQuery, value_objects::UserId, -}; -use sqlx::{PgPool, Row}; - -use adapter_common::{parse_datetime, parse_uuid}; - -use crate::wrapup::fetch_genres_pg; - -/// Postgres twin of `SqliteInsightsQuery` — same shape, `$n` binds, and the -/// same thin "rows in, rows out" contract (ADR-0010). -pub struct PostgresInsightsQuery { - pool: PgPool, -} - -impl PostgresInsightsQuery { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl InsightsQuery for PostgresInsightsQuery { - async fn get_watch_rows(&self, user_id: &UserId) -> Result, DomainError> { - let rows = sqlx::query( - "SELECT movie_id, rating, \ - to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \ - watch_medium \ - FROM reviews \ - WHERE user_id = $1 AND remote_actor_url IS NULL \ - ORDER BY watched_at ASC", - ) - .bind(user_id.value().to_string()) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let mut movie_ids: Vec = Vec::new(); - let mut seen = std::collections::HashSet::new(); - for row in &rows { - let mid: String = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - if seen.insert(mid.clone()) { - movie_ids.push(mid); - } - } - - let genres_map: HashMap> = - fetch_genres_pg(&self.pool, &movie_ids).await?; - - let mut result = Vec::with_capacity(rows.len()); - for row in &rows { - let movie_id: String = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let rating: i64 = row - .try_get("rating") - .map_err(adapter_common::map_sqlx_error)?; - let watched_at: String = row - .try_get("watched_at") - .map_err(adapter_common::map_sqlx_error)?; - let watch_medium: Option = row - .try_get("watch_medium") - .map_err(adapter_common::map_sqlx_error)?; - - result.push(WatchRow { - movie_id: parse_uuid(&movie_id)?, - rating: rating as u8, - watched_at: parse_datetime(&watched_at)?, - watch_medium, - genres: genres_map.get(&movie_id).cloned().unwrap_or_default(), - }); - } - - Ok(result) - } -} diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs deleted file mode 100644 index 28c2438e..00000000 --- a/crates/adapters/postgres/src/lib.rs +++ /dev/null @@ -1,127 +0,0 @@ -use domain::errors::DomainError; -use sqlx::PgPool; - -mod diary; -mod goals; -mod image_ref; -mod import_profile; -mod import_session; -mod insights; -mod models; -mod movie; -mod movie_dedup; -mod persons; -mod profile; -mod profile_fields; -mod refresh_sessions; -mod review; -mod stats; -mod user_settings; -mod users; -mod watch_event; -mod watchlist; -mod wrapup; - -pub use diary::PostgresDiaryRepository; -pub use image_ref::{PostgresImageRefAdapter, create_image_ref}; -pub use import_profile::PostgresImportProfileRepository; -pub use import_session::PostgresImportSessionRepository; -pub use insights::PostgresInsightsQuery; -pub use movie::PostgresMovieRepository; -pub use movie_dedup::PostgresMovieDeduplicator; -pub use persons::{PostgresPersonAdapter, create_person_adapter}; -pub use postgres_social::PostgresApContentQuery; -pub use profile::PostgresMovieProfileRepository; -pub use profile_fields::PostgresProfileFieldsRepository; -pub use refresh_sessions::PostgresRefreshSessionAdapter; -pub use review::PostgresReviewRepository; -pub use stats::PostgresStatsRepository; -pub use users::PostgresUserRepository; -pub use watch_event::{PostgresWatchEventRepository, PostgresWebhookTokenRepository}; -pub use watchlist::PostgresWatchlistRepository; -pub use wrapup::{PostgresWrapUpRepository, PostgresWrapUpStatsQuery}; - -pub async fn migrate(pool: &PgPool) -> Result<(), DomainError> { - sqlx::migrate!("./migrations") - .set_ignore_missing(true) - .run(pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string())) -} - -pub fn create_profile_fields_repo( - pool: sqlx::PgPool, -) -> std::sync::Arc { - std::sync::Arc::new(profile_fields::PostgresProfileFieldsRepository::new(pool)) -} - -pub struct PostgresWireOutput { - pub pool: PgPool, - pub movie_command: std::sync::Arc, - pub movie_query: std::sync::Arc, - pub review: std::sync::Arc, - pub diary: std::sync::Arc, - pub stats: std::sync::Arc, - pub user: std::sync::Arc, - pub import_session: std::sync::Arc, - pub import_profile: std::sync::Arc, - pub movie_profile: std::sync::Arc, - pub watchlist: std::sync::Arc, - pub ap_content: std::sync::Arc, - pub wrapup_repo: std::sync::Arc, - pub wrapup_stats: std::sync::Arc, - pub insights: std::sync::Arc, - pub goal_command: std::sync::Arc, - pub goal_query: std::sync::Arc, - pub user_settings: std::sync::Arc, - pub federation_settings: std::sync::Arc, - pub remote_goal: std::sync::Arc, - pub deduplicator: std::sync::Arc, -} - -pub async fn wire(database_url: &str) -> anyhow::Result { - use anyhow::Context; - - let pool = sqlx::PgPool::connect(database_url) - .await - .context("Failed to connect to PostgreSQL database")?; - - migrate(&pool) - .await - .map_err(|e| anyhow::anyhow!("{e}")) - .context("Database migration failed")?; - - let user_settings_repo = std::sync::Arc::new( - user_settings::PostgresUserSettingsRepository::new(pool.clone()), - ); - - let movie_repo = std::sync::Arc::new(PostgresMovieRepository::new(pool.clone())); - - Ok(PostgresWireOutput { - pool: pool.clone(), - movie_command: movie_repo.clone() as _, - movie_query: movie_repo as _, - review: std::sync::Arc::new(PostgresReviewRepository::new(pool.clone())) as _, - diary: std::sync::Arc::new(PostgresDiaryRepository::new(pool.clone())) as _, - stats: std::sync::Arc::new(PostgresStatsRepository::new(pool.clone())) as _, - user: std::sync::Arc::new(PostgresUserRepository::new(pool.clone())) as _, - import_session: std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone())) - as _, - import_profile: std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone())) - as _, - movie_profile: std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone())) as _, - watchlist: std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone())) as _, - ap_content: std::sync::Arc::new(PostgresApContentQuery::new(pool.clone())) as _, - wrapup_repo: std::sync::Arc::new(PostgresWrapUpRepository::new(pool.clone())) as _, - wrapup_stats: std::sync::Arc::new(PostgresWrapUpStatsQuery::new(pool.clone())) as _, - insights: std::sync::Arc::new(PostgresInsightsQuery::new(pool.clone())) as _, - goal_command: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _, - goal_query: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _, - user_settings: std::sync::Arc::clone(&user_settings_repo) as _, - federation_settings: user_settings_repo as _, - remote_goal: std::sync::Arc::new(postgres_social::PostgresRemoteGoalRepository::new( - pool.clone(), - )) as _, - deduplicator: std::sync::Arc::new(PostgresMovieDeduplicator::new(pool)) as _, - }) -} diff --git a/crates/adapters/postgres/src/models.rs b/crates/adapters/postgres/src/models.rs deleted file mode 100644 index fd098df8..00000000 --- a/crates/adapters/postgres/src/models.rs +++ /dev/null @@ -1,292 +0,0 @@ -use adapter_common::{ - movie_row_to_domain, movie_stats_to_domain, movie_summary_to_domain, review_row_to_domain, - user_summary_to_domain, -}; -use domain::{ - errors::DomainError, - models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review}, -}; - -#[derive(sqlx::FromRow)] -pub(crate) struct MovieRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, -} - -impl MovieRow { - pub fn into_domain(self) -> Result { - movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct MovieSummaryRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, - pub genres: Option>, - pub runtime_minutes: Option, - pub original_language: Option, - pub overview: Option, - pub collection_name: Option, -} - -impl MovieSummaryRow { - pub fn into_domain(self) -> Result { - let movie = movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - )?; - Ok(movie_summary_to_domain( - movie, - self.genres.unwrap_or_default(), - self.runtime_minutes, - self.original_language, - self.overview, - self.collection_name, - )) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct ReviewRow { - pub id: String, - pub movie_id: String, - pub user_id: String, - pub rating: i64, - pub comment: Option, - pub watched_at: String, - pub created_at: String, - pub remote_actor_url: Option, - pub watch_medium: Option, - pub spoilers: bool, -} - -impl ReviewRow { - pub fn into_domain(self) -> Result { - review_row_to_domain( - self.id, - self.movie_id, - self.user_id, - self.rating, - self.comment, - self.watched_at, - self.created_at, - self.remote_actor_url, - self.watch_medium, - self.spoilers, - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct DiaryRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, - pub review_id: String, - pub movie_id: String, - pub user_id: String, - pub rating: i64, - pub comment: Option, - pub watched_at: String, - pub created_at: String, - pub remote_actor_url: Option, - pub watch_medium: Option, - pub spoilers: bool, -} - -impl DiaryRow { - pub fn into_domain(self) -> Result { - let movie = movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - )?; - let review = review_row_to_domain( - self.review_id, - self.movie_id, - self.user_id, - self.rating, - self.comment, - self.watched_at, - self.created_at, - self.remote_actor_url, - self.watch_medium, - self.spoilers, - )?; - Ok(DiaryEntry::new(movie, review)) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct FeedRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, - pub review_id: String, - pub movie_id: String, - pub user_id: String, - pub rating: i64, - pub comment: Option, - pub watched_at: String, - pub created_at: String, - pub remote_actor_url: Option, - pub watch_medium: Option, - pub spoilers: bool, - pub user_email: String, -} - -impl FeedRow { - pub fn into_domain(self) -> Result { - let movie = movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - )?; - let review = review_row_to_domain( - self.review_id, - self.movie_id, - self.user_id, - self.rating, - self.comment, - self.watched_at, - self.created_at, - self.remote_actor_url, - self.watch_medium, - self.spoilers, - )?; - let diary = DiaryEntry::new(movie, review); - Ok(FeedEntry::new(diary, self.user_email)) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct MovieStatsRow { - pub total_count: i64, - pub avg_rating: Option, - pub federated_count: i64, - pub rating_1: i64, - pub rating_2: i64, - pub rating_3: i64, - pub rating_4: i64, - pub rating_5: i64, -} - -impl MovieStatsRow { - pub fn into_domain(self) -> domain::models::MovieStats { - movie_stats_to_domain( - self.total_count, - self.avg_rating, - self.federated_count, - [ - self.rating_1, - self.rating_2, - self.rating_3, - self.rating_4, - self.rating_5, - ], - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct UserSummaryRow { - pub id: String, - pub email: String, - pub username: String, - pub display_name: Option, - pub total_movies: i64, - pub avg_rating: Option, - pub avatar_path: Option, -} - -impl UserSummaryRow { - pub fn into_domain(self) -> Result { - user_summary_to_domain( - self.id, - self.email, - self.username, - self.display_name, - self.total_movies, - self.avg_rating, - self.avatar_path, - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct UserTotalsRow { - pub total: i64, - pub avg_rating: Option, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct DirectorCountRow { - pub director: String, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct MonthlyRatingRow { - pub month: String, - pub avg_rating: f64, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct GenreCountRow { - pub genre: String, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct RatingDistRow { - pub rating: i64, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct WatchMediumCountRow { - pub watch_medium: String, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct ActorAppearanceRow { - pub tmdb_person_id: i64, - pub name: String, - pub profile_path: Option, - pub billing_order: i32, - pub movie_id: String, -} diff --git a/crates/adapters/postgres/src/movie.rs b/crates/adapters/postgres/src/movie.rs deleted file mode 100644 index 8c75fada..00000000 --- a/crates/adapters/postgres/src/movie.rs +++ /dev/null @@ -1,253 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{ - Movie, MovieFilter, MovieSummary, - collections::{PageParams, Paginated}, - }, - ports::{MovieCommand, MovieQuery}, - value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear}, -}; -use sqlx::PgPool; - -use crate::models::{MovieRow, MovieSummaryRow}; - -pub struct PostgresMovieRepository { - pool: PgPool, -} - -impl PostgresMovieRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl MovieCommand for PostgresMovieRepository { - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { - let id = movie.id().value().to_string(); - let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string()); - let title = movie.title().value(); - let release_year = movie.release_year().value() as i64; - let director = movie.director(); - let poster_path = movie.poster_path().map(|p| p.value().to_string()); - - sqlx::query( - "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT(id) DO UPDATE SET - external_metadata_id = excluded.external_metadata_id, - title = excluded.title, - release_year = excluded.release_year, - director = excluded.director, - poster_path = excluded.poster_path", - ) - .bind(&id) - .bind(&external_metadata_id) - .bind(title) - .bind(release_year) - .bind(director) - .bind(&poster_path) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { - let id = movie_id.value().to_string(); - sqlx::query("DELETE FROM movies WHERE id = $1") - .bind(&id) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } -} - -#[async_trait] -impl MovieQuery for PostgresMovieRepository { - async fn get_movie_by_external_id( - &self, - external_metadata_id: &ExternalMetadataId, - ) -> Result, DomainError> { - let id = external_metadata_id.value(); - sqlx::query_as::<_, MovieRow>( - "SELECT id, external_metadata_id, title, release_year, director, poster_path - FROM movies WHERE external_metadata_id = $1", - ) - .bind(id) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .map(MovieRow::into_domain) - .transpose() - } - - async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError> { - let id = movie_id.value().to_string(); - sqlx::query_as::<_, MovieRow>( - "SELECT id, external_metadata_id, title, release_year, director, poster_path - FROM movies WHERE id = $1", - ) - .bind(&id) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .map(MovieRow::into_domain) - .transpose() - } - - async fn get_movies_by_title_and_year( - &self, - title: &MovieTitle, - year: &ReleaseYear, - ) -> Result, DomainError> { - let title = title.value(); - let year = year.value() as i64; - sqlx::query_as::<_, MovieRow>( - "SELECT id, external_metadata_id, title, release_year, director, poster_path - FROM movies WHERE title = $1 AND release_year = $2", - ) - .bind(title) - .bind(year) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(MovieRow::into_domain) - .collect() - } - - async fn existing_external_ids( - &self, - ids: &[ExternalMetadataId], - ) -> Result, DomainError> { - if ids.is_empty() { - return Ok(Default::default()); - } - let vals: Vec = ids.iter().map(|id| id.value().to_string()).collect(); - let rows: Vec<(String,)> = sqlx::query_as( - "SELECT external_metadata_id FROM movies WHERE external_metadata_id = ANY($1)", - ) - .bind(&vals) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(rows.into_iter().map(|(id,)| id).collect()) - } - - async fn existing_title_year_pairs( - &self, - pairs: &[(MovieTitle, ReleaseYear)], - ) -> Result, DomainError> { - if pairs.is_empty() { - return Ok(Default::default()); - } - let titles: Vec<&str> = pairs.iter().map(|(t, _)| t.value()).collect(); - let years: Vec = pairs.iter().map(|(_, y)| y.value() as i64).collect(); - use sqlx::Row; - let rows = sqlx::query( - "SELECT DISTINCT m.title, m.release_year FROM movies m \ - INNER JOIN unnest($1::text[], $2::bigint[]) AS p(title, release_year) \ - ON m.title = p.title AND m.release_year = p.release_year", - ) - .bind(&titles) - .bind(&years) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(rows - .into_iter() - .map(|r| { - let t: String = r.get("title"); - let y: i64 = r.get("release_year"); - (t, y as u16) - }) - .collect()) - } - - async fn list_movies( - &self, - page: &PageParams, - filter: &MovieFilter, - ) -> Result, DomainError> { - use sqlx::Row; - let limit = page.limit as i64; - let offset = page.offset as i64; - let pattern = filter - .search - .as_deref() - .map(|s| format!("%{}%", s.to_lowercase())); - let genre = filter.genre.as_deref(); - let language = filter.language.as_deref(); - - let rows: Vec = sqlx::query_as( - "SELECT \ - m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \ - p.overview, p.runtime_minutes, p.original_language, p.collection_name, \ - array_agg(g.name) FILTER (WHERE g.name IS NOT NULL) AS genres \ - FROM movies m \ - LEFT JOIN movie_profiles p ON p.movie_id = m.id \ - LEFT JOIN movie_genres g ON g.movie_id = m.id \ - WHERE ($1::text IS NULL OR LOWER(m.title) LIKE $1) \ - AND ($2::text IS NULL OR p.original_language = $2) \ - AND ($3::text IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER($3))) \ - GROUP BY m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \ - p.overview, p.runtime_minutes, p.original_language, p.collection_name \ - ORDER BY m.title ASC \ - LIMIT $4 OFFSET $5", - ) - .bind(&pattern) - .bind(language) - .bind(genre) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let total: i64 = sqlx::query( - "SELECT COUNT(DISTINCT m.id) \ - FROM movies m \ - LEFT JOIN movie_profiles p ON p.movie_id = m.id \ - WHERE ($1::text IS NULL OR LOWER(m.title) LIKE $1) \ - AND ($2::text IS NULL OR p.original_language = $2) \ - AND ($3::text IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER($3)))", - ) - .bind(&pattern) - .bind(language) - .bind(genre) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .try_get(0) - .unwrap_or(0); - - let items = rows - .into_iter() - .map(|r| r.into_domain()) - .collect::, _>>()?; - - Ok(Paginated { - items, - total_count: total as u64, - limit: page.limit, - offset: page.offset, - }) - } - - async fn list_movies_with_external_id(&self) -> Result, DomainError> { - sqlx::query_as::<_, MovieRow>( - "SELECT id, external_metadata_id, title, release_year, director, poster_path - FROM movies WHERE external_metadata_id IS NOT NULL", - ) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(|r| r.into_domain()) - .collect() - } -} diff --git a/crates/adapters/postgres/src/movie_dedup.rs b/crates/adapters/postgres/src/movie_dedup.rs deleted file mode 100644 index 8d0d2e89..00000000 --- a/crates/adapters/postgres/src/movie_dedup.rs +++ /dev/null @@ -1,147 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, models::Movie, ports::MovieDeduplicator, value_objects::MovieId, -}; -use sqlx::PgPool; - -pub struct PostgresMovieDeduplicator { - pool: PgPool, -} - -impl PostgresMovieDeduplicator { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl MovieDeduplicator for PostgresMovieDeduplicator { - async fn merge_into_canonical( - &self, - old_id: &MovieId, - canonical: &Movie, - ) -> Result { - let old = old_id.value().to_string(); - let new = canonical.id().value().to_string(); - let ext_id = canonical - .external_metadata_id() - .map(|id| id.value().to_string()); - let title = canonical.title().value().to_string(); - let year = canonical.release_year().value() as i64; - let director = canonical.director().map(str::to_string); - let poster = canonical.poster_path().map(|p| p.value().to_string()); - - let mut tx = self - .pool - .begin() - .await - .map_err(adapter_common::map_sqlx_error)?; - - sqlx::query( - "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT(id) DO UPDATE SET - external_metadata_id = COALESCE(EXCLUDED.external_metadata_id, movies.external_metadata_id), - poster_path = COALESCE(EXCLUDED.poster_path, movies.poster_path)", - ) - .bind(&new).bind(&ext_id).bind(&title).bind(year).bind(&director).bind(&poster) - .execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?; - - let reviews = sqlx::query("UPDATE reviews SET movie_id = $1 WHERE movie_id = $2") - .bind(&new) - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)? - .rows_affected(); - - let watchlist = - sqlx::query("UPDATE watchlist_entries SET movie_id = $1 WHERE movie_id = $2") - .bind(&new) - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)? - .rows_affected(); - - let watch_events = sqlx::query("UPDATE watch_events SET movie_id = $1 WHERE movie_id = $2") - .bind(&new) - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)? - .rows_affected(); - - let profiles = sqlx::query("UPDATE movie_profiles SET movie_id = $1 WHERE movie_id = $2") - .bind(&new) - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)? - .rows_affected(); - - sqlx::query( - "INSERT INTO movie_genres (movie_id, tmdb_id, name) - SELECT $1, tmdb_id, name FROM movie_genres WHERE movie_id = $2 - ON CONFLICT DO NOTHING", - ) - .bind(&new) - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1") - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - - sqlx::query( - "INSERT INTO movie_keywords (movie_id, tmdb_id, name) - SELECT $1, tmdb_id, name FROM movie_keywords WHERE movie_id = $2 - ON CONFLICT DO NOTHING", - ) - .bind(&new) - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1") - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - - sqlx::query( - "INSERT INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path) - SELECT $1, tmdb_person_id, name, character, billing_order, profile_path FROM movie_cast WHERE movie_id = $2 - ON CONFLICT DO NOTHING", - ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?; - sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1") - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - - sqlx::query( - "INSERT INTO movie_crew (movie_id, tmdb_person_id, name, job, department, profile_path) - SELECT $1, tmdb_person_id, name, job, department, profile_path FROM movie_crew WHERE movie_id = $2 - ON CONFLICT DO NOTHING", - ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?; - sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1") - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - - sqlx::query("DELETE FROM movies WHERE id = $1") - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - - tx.commit().await.map_err(adapter_common::map_sqlx_error)?; - - Ok(reviews + watchlist + watch_events + profiles) - } -} diff --git a/crates/adapters/postgres/src/persons.rs b/crates/adapters/postgres/src/persons.rs deleted file mode 100644 index 03b9a42b..00000000 --- a/crates/adapters/postgres/src/persons.rs +++ /dev/null @@ -1,339 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{ - CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData, - PersonId, - }, - ports::{PersonCommand, PersonQuery}, - value_objects::MovieId, -}; -use sqlx::PgPool; -use std::sync::Arc; - -pub struct PostgresPersonAdapter { - pool: PgPool, -} - -impl PostgresPersonAdapter { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -pub fn create_person_adapter(pool: PgPool) -> (Arc, Arc) { - let adapter = Arc::new(PostgresPersonAdapter::new(pool)); - ( - Arc::clone(&adapter) as Arc, - adapter as Arc, - ) -} - -#[async_trait] -impl PersonCommand for PostgresPersonAdapter { - async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> { - for person in persons { - let tmdb_id = person.external_id().tmdb_id(); - sqlx::query( - "INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT(id) DO UPDATE SET - external_id = EXCLUDED.external_id, - tmdb_person_id = EXCLUDED.tmdb_person_id, - name = EXCLUDED.name, - known_for_department = EXCLUDED.known_for_department, - profile_path = EXCLUDED.profile_path", - ) - .bind(person.id().value().to_string()) - .bind(person.external_id().value()) - .bind(tmdb_id) - .bind(person.name()) - .bind(person.known_for_department()) - .bind(person.profile_path()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - } - Ok(()) - } - - async fn backfill_from_credits_batch( - &self, - batch_size: u32, - ) -> Result<(u64, bool), DomainError> { - #[derive(sqlx::FromRow)] - struct MissingPerson { - tmdb_person_id: i64, - name: String, - department: Option, - profile_path: Option, - } - - let rows = sqlx::query_as::<_, MissingPerson>( - "SELECT mc.tmdb_person_id, mc.name, 'Acting' AS department, mc.profile_path - FROM movie_cast mc - WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id) - GROUP BY mc.tmdb_person_id, mc.name, mc.profile_path - UNION ALL - SELECT mc.tmdb_person_id, mc.name, mc.department, mc.profile_path - FROM movie_crew mc - WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id) - AND NOT EXISTS (SELECT 1 FROM movie_cast c2 WHERE c2.tmdb_person_id = mc.tmdb_person_id) - GROUP BY mc.tmdb_person_id, mc.name, mc.department, mc.profile_path - LIMIT $1", - ) - .bind(batch_size as i64) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let has_more = rows.len() as u32 >= batch_size; - let mut count = 0u64; - for row in &rows { - let ext = ExternalPersonId::new(format!("tmdb:{}", row.tmdb_person_id)); - let pid = PersonId::from_external(&ext); - sqlx::query( - "INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT(tmdb_person_id) DO NOTHING", - ) - .bind(pid.value().to_string()) - .bind(ext.value()) - .bind(row.tmdb_person_id) - .bind(&row.name) - .bind(&row.department) - .bind(&row.profile_path) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - count += 1; - } - Ok((count, has_more)) - } - - async fn update_enrichment( - &self, - id: &PersonId, - data: &PersonEnrichmentData, - ) -> Result<(), DomainError> { - let also_known_as_json = - serde_json::to_string(&data.also_known_as).unwrap_or_else(|_| "[]".into()); - let now = chrono::Utc::now(); - sqlx::query( - "UPDATE persons SET biography = $1, birthday = $2, deathday = $3, place_of_birth = $4, also_known_as = $5, homepage = $6, imdb_id = $7, enriched_at = $8 WHERE id = $9", - ) - .bind(&data.biography) - .bind(data.birthday.map(|d| d.to_string())) - .bind(data.deathday.map(|d| d.to_string())) - .bind(&data.place_of_birth) - .bind(&also_known_as_json) - .bind(&data.homepage) - .bind(&data.imdb_id) - .bind(now) - .bind(id.value().to_string()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } -} - -#[async_trait] -impl PersonQuery for PostgresPersonAdapter { - async fn get_by_id(&self, id: &PersonId) -> Result, DomainError> { - let row = sqlx::query_as::<_, PersonRow>( - "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE id = $1", - ) - .bind(id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(row.map(PersonRow::into_person)) - } - - async fn get_by_external_id( - &self, - id: &ExternalPersonId, - ) -> Result, DomainError> { - let row = sqlx::query_as::<_, PersonRow>( - "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE external_id = $1", - ) - .bind(id.value()) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(row.map(PersonRow::into_person)) - } - - async fn get_credits(&self, id: &PersonId) -> Result { - let person = self - .get_by_id(id) - .await? - .ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?; - - let tmdb_id: Option = - sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = $1") - .bind(id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .flatten(); - - let Some(tmdb_id) = tmdb_id else { - return Ok(PersonCredits { - person, - cast: vec![], - crew: vec![], - }); - }; - - #[derive(sqlx::FromRow)] - struct CastRow { - id: String, - title: String, - release_year: Option, - character: String, - poster_path: Option, - } - #[derive(sqlx::FromRow)] - struct CrewRow { - id: String, - title: String, - release_year: Option, - job: String, - department: String, - poster_path: Option, - } - - let cast = sqlx::query_as::<_, CastRow>( - "SELECT m.id, m.title, m.release_year, mc.character, m.poster_path - FROM movie_cast mc JOIN movies m ON m.id = mc.movie_id - WHERE mc.tmdb_person_id = $1 ORDER BY mc.billing_order", - ) - .bind(tmdb_id) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(|r| CastCredit { - movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), - title: r.title, - release_year: r.release_year.map(|y| y as u16), - character: r.character, - poster_path: r.poster_path, - }) - .collect(); - - let crew = sqlx::query_as::<_, CrewRow>( - "SELECT m.id, m.title, m.release_year, mc.job, mc.department, m.poster_path - FROM movie_crew mc JOIN movies m ON m.id = mc.movie_id - WHERE mc.tmdb_person_id = $1 ORDER BY m.title", - ) - .bind(tmdb_id) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(|r| CrewCredit { - movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), - title: r.title, - release_year: r.release_year.map(|y| y as u16), - job: r.job, - department: r.department, - poster_path: r.poster_path, - }) - .collect(); - - Ok(PersonCredits { person, cast, crew }) - } - - async fn list_page(&self, limit: u32, offset: u32) -> Result, DomainError> { - let rows = sqlx::query_as::<_, PersonRow>( - "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons ORDER BY id LIMIT $1 OFFSET $2", - ) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(rows.into_iter().map(PersonRow::into_person).collect()) - } - - async fn list_orphaned_persons(&self) -> Result, DomainError> { - let rows: Vec<(String,)> = sqlx::query_as( - "SELECT id FROM persons - WHERE NOT EXISTS ( - SELECT 1 FROM movie_cast WHERE movie_cast.tmdb_person_id = persons.tmdb_person_id - ) - AND NOT EXISTS ( - SELECT 1 FROM movie_crew WHERE movie_crew.tmdb_person_id = persons.tmdb_person_id - ) - LIMIT 500", - ) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(rows - .into_iter() - .filter_map(|(id,)| uuid::Uuid::parse_str(&id).ok().map(PersonId::from_uuid)) - .collect()) - } -} - -// ── Row types ──────────────────────────────────────────────────────────────── - -#[derive(sqlx::FromRow)] -struct PersonRow { - id: String, - external_id: String, - name: String, - known_for_department: Option, - profile_path: Option, - biography: Option, - birthday: Option, - deathday: Option, - place_of_birth: Option, - also_known_as: Option, - homepage: Option, - imdb_id: Option, - enriched_at: Option, -} - -impl PersonRow { - fn into_person(self) -> Person { - let ext = ExternalPersonId::new(self.external_id); - let also_known_as = self - .also_known_as - .and_then(|s| serde_json::from_str::>(&s).ok()) - .unwrap_or_default(); - let birthday = self - .birthday - .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); - let deathday = self - .deathday - .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); - let enriched_at = self - .enriched_at - .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) - .map(|d| d.with_timezone(&chrono::Utc)); - Person::new( - PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()), - ext, - self.name, - self.known_for_department, - self.profile_path, - self.biography, - birthday, - deathday, - self.place_of_birth, - also_known_as, - self.homepage, - self.imdb_id, - enriched_at, - ) - } -} diff --git a/crates/adapters/postgres/src/profile.rs b/crates/adapters/postgres/src/profile.rs deleted file mode 100644 index d713b2d7..00000000 --- a/crates/adapters/postgres/src/profile.rs +++ /dev/null @@ -1,284 +0,0 @@ -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use domain::{ - errors::DomainError, - models::{CastMember, CrewMember, Genre, Keyword, MovieProfile}, - ports::MovieProfileRepository, - value_objects::MovieId, -}; -use sqlx::{PgPool, Row}; - -pub struct PostgresMovieProfileRepository { - pool: PgPool, -} - -impl PostgresMovieProfileRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl MovieProfileRepository for PostgresMovieProfileRepository { - async fn upsert(&self, p: &MovieProfile) -> Result<(), DomainError> { - let movie_id = p.movie_id.value().to_string(); - - let mut tx = self - .pool - .begin() - .await - .map_err(adapter_common::map_sqlx_error)?; - - sqlx::query( - r#"INSERT INTO movie_profiles - (movie_id, tmdb_id, imdb_id, overview, tagline, runtime_minutes, - budget_usd, revenue_usd, vote_average, vote_count, - original_language, collection_name, enriched_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) - ON CONFLICT(movie_id) DO UPDATE SET - tmdb_id=EXCLUDED.tmdb_id, imdb_id=EXCLUDED.imdb_id, - overview=EXCLUDED.overview, tagline=EXCLUDED.tagline, - runtime_minutes=EXCLUDED.runtime_minutes, - budget_usd=EXCLUDED.budget_usd, revenue_usd=EXCLUDED.revenue_usd, - vote_average=EXCLUDED.vote_average, vote_count=EXCLUDED.vote_count, - original_language=EXCLUDED.original_language, - collection_name=EXCLUDED.collection_name, - enriched_at=EXCLUDED.enriched_at"#, - ) - .bind(&movie_id) - .bind(p.tmdb_id as i64) - .bind(&p.imdb_id) - .bind(&p.overview) - .bind(&p.tagline) - .bind(p.runtime_minutes.map(|v| v as i32)) - .bind(p.budget_usd) - .bind(p.revenue_usd) - .bind(p.vote_average) - .bind(p.vote_count.map(|v| v as i32)) - .bind(&p.original_language) - .bind(&p.collection_name) - .bind(p.enriched_at) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - - sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1") - .bind(&movie_id) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - for g in &p.genres { - sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING") - .bind(&movie_id).bind(g.tmdb_id as i32).bind(&g.name) - .execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?; - } - - sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1") - .bind(&movie_id) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - for k in &p.keywords { - sqlx::query("INSERT INTO movie_keywords (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING") - .bind(&movie_id).bind(k.tmdb_id as i32).bind(&k.name) - .execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?; - } - - sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1") - .bind(&movie_id) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - for c in &p.cast { - sqlx::query( - "INSERT INTO movie_cast \ - (movie_id, tmdb_person_id, name, character, billing_order, profile_path) \ - VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING", - ) - .bind(&movie_id) - .bind(c.tmdb_person_id as i64) - .bind(&c.name) - .bind(&c.character) - .bind(c.billing_order as i32) - .bind(&c.profile_path) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - } - - sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1") - .bind(&movie_id) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - for cr in &p.crew { - sqlx::query( - "INSERT INTO movie_crew \ - (movie_id, tmdb_person_id, name, job, department, profile_path) \ - VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING", - ) - .bind(&movie_id) - .bind(cr.tmdb_person_id as i64) - .bind(&cr.name) - .bind(&cr.job) - .bind(&cr.department) - .bind(&cr.profile_path) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - } - - tx.commit().await.map_err(adapter_common::map_sqlx_error) - } - - async fn get_by_movie_id(&self, id: &MovieId) -> Result, DomainError> { - let movie_id = id.value().to_string(); - - let row = sqlx::query( - "SELECT tmdb_id, imdb_id, overview, tagline, runtime_minutes, budget_usd, - revenue_usd, vote_average, vote_count, original_language, - collection_name, enriched_at - FROM movie_profiles WHERE movie_id = $1", - ) - .bind(&movie_id) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let row = match row { - Some(r) => r, - None => return Ok(None), - }; - - let enriched_at: DateTime = row - .try_get("enriched_at") - .map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?; - - let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = $1") - .bind(&movie_id) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(|r| Genre { - tmdb_id: r.try_get::("tmdb_id").unwrap_or(0) as u32, - name: r.try_get("name").unwrap_or_default(), - }) - .collect(); - - let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = $1") - .bind(&movie_id) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(|r| Keyword { - tmdb_id: r.try_get::("tmdb_id").unwrap_or(0) as u32, - name: r.try_get("name").unwrap_or_default(), - }) - .collect(); - - let cast = sqlx::query( - "SELECT tmdb_person_id, name, character, billing_order, profile_path \ - FROM movie_cast WHERE movie_id = $1 ORDER BY billing_order", - ) - .bind(&movie_id) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(|r| CastMember { - tmdb_person_id: r.try_get::("tmdb_person_id").unwrap_or(0) as u64, - name: r.try_get("name").unwrap_or_default(), - character: r.try_get("character").unwrap_or_default(), - billing_order: r.try_get::("billing_order").unwrap_or(0) as u32, - profile_path: r - .try_get::, _>("profile_path") - .ok() - .flatten(), - }) - .collect(); - - let crew = sqlx::query( - "SELECT tmdb_person_id, name, job, department, profile_path \ - FROM movie_crew WHERE movie_id = $1", - ) - .bind(&movie_id) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(|r| CrewMember { - tmdb_person_id: r.try_get::("tmdb_person_id").unwrap_or(0) as u64, - name: r.try_get("name").unwrap_or_default(), - job: r.try_get("job").unwrap_or_default(), - department: r.try_get("department").unwrap_or_default(), - profile_path: r - .try_get::, _>("profile_path") - .ok() - .flatten(), - }) - .collect(); - - Ok(Some(MovieProfile { - movie_id: id.clone(), - tmdb_id: row.try_get::("tmdb_id").unwrap_or(0) as u64, - imdb_id: row.try_get::, _>("imdb_id").ok().flatten(), - overview: row.try_get::, _>("overview").ok().flatten(), - tagline: row.try_get::, _>("tagline").ok().flatten(), - runtime_minutes: row - .try_get::, _>("runtime_minutes") - .ok() - .flatten() - .map(|v| v as u32), - budget_usd: row.try_get::, _>("budget_usd").ok().flatten(), - revenue_usd: row.try_get::, _>("revenue_usd").ok().flatten(), - vote_average: row.try_get::, _>("vote_average").ok().flatten(), - vote_count: row - .try_get::, _>("vote_count") - .ok() - .flatten() - .map(|v| v as u32), - original_language: row - .try_get::, _>("original_language") - .ok() - .flatten(), - collection_name: row - .try_get::, _>("collection_name") - .ok() - .flatten(), - genres, - keywords, - cast, - crew, - enriched_at, - })) - } - - async fn list_stale(&self) -> Result, DomainError> { - let threshold = Utc::now() - chrono::Duration::days(30); - let rows = sqlx::query( - r#"SELECT m.id, m.external_metadata_id - FROM movies m - LEFT JOIN movie_profiles p ON p.movie_id = m.id - WHERE m.external_metadata_id IS NOT NULL - AND (p.movie_id IS NULL OR p.enriched_at < $1) - ORDER BY p.enriched_at ASC NULLS FIRST"#, - ) - .bind(threshold) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(rows - .into_iter() - .filter_map(|r| { - let ext_id: Option = r.try_get("external_metadata_id").ok()?; - let ext_id = ext_id?; - let id_str: String = r.try_get("id").ok()?; - let movie_id = id_str.parse::().ok().map(MovieId::from_uuid)?; - Some((movie_id, ext_id)) - }) - .collect()) - } -} diff --git a/crates/adapters/postgres/src/profile_fields.rs b/crates/adapters/postgres/src/profile_fields.rs deleted file mode 100644 index 3b2b0fc5..00000000 --- a/crates/adapters/postgres/src/profile_fields.rs +++ /dev/null @@ -1,74 +0,0 @@ -use async_trait::async_trait; -use sqlx::PgPool; - -use domain::{ - errors::DomainError, models::ProfileField, ports::UserProfileFieldsRepository, - value_objects::UserId, -}; - -pub struct PostgresProfileFieldsRepository { - pool: PgPool, -} - -impl PostgresProfileFieldsRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl UserProfileFieldsRepository for PostgresProfileFieldsRepository { - async fn get_fields(&self, user_id: &UserId) -> Result, DomainError> { - let id_str = user_id.value().to_string(); - #[derive(sqlx::FromRow)] - struct Row { - name: String, - value: String, - } - let rows = sqlx::query_as::<_, Row>( - "SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC", - ) - .bind(&id_str) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(rows - .into_iter() - .map(|r| ProfileField { - name: r.name, - value: r.value, - }) - .collect()) - } - - async fn set_fields( - &self, - user_id: &UserId, - fields: Vec, - ) -> Result<(), DomainError> { - let id_str = user_id.value().to_string(); - - sqlx::query("DELETE FROM user_profile_fields WHERE user_id = $1") - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - for (i, field) in fields.into_iter().enumerate() { - let id = uuid::Uuid::new_v4().to_string(); - let position = i as i64; - sqlx::query( - "INSERT INTO user_profile_fields (id, user_id, name, value, position) VALUES ($1, $2, $3, $4, $5)", - ) - .bind(&id) - .bind(&id_str) - .bind(&field.name) - .bind(&field.value) - .bind(position) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - } - Ok(()) - } -} diff --git a/crates/adapters/postgres/src/refresh_sessions.rs b/crates/adapters/postgres/src/refresh_sessions.rs deleted file mode 100644 index dce011a9..00000000 --- a/crates/adapters/postgres/src/refresh_sessions.rs +++ /dev/null @@ -1,108 +0,0 @@ -use async_trait::async_trait; -use chrono::DateTime; -use domain::{ - errors::DomainError, models::RefreshSession, ports::RefreshSessionRepository, - value_objects::UserId, -}; -use sqlx::PgPool; - -pub struct PostgresRefreshSessionAdapter { - pool: PgPool, -} - -impl PostgresRefreshSessionAdapter { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl RefreshSessionRepository for PostgresRefreshSessionAdapter { - async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> { - sqlx::query( - "INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) - VALUES ($1, $2, $3, $4, $5)", - ) - .bind(session.id.to_string()) - .bind(session.user_id.value().to_string()) - .bind(&session.token) - .bind(session.expires_at) - .bind(session.created_at) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } - - async fn get_by_token(&self, token: &str) -> Result, DomainError> { - let row = sqlx::query_as::<_, RefreshSessionRow>( - "SELECT id, user_id, token, - to_char(expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS expires_at, - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS created_at - FROM refresh_sessions WHERE token = $1", - ) - .bind(token) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.map(RefreshSessionRow::into_domain).transpose() - } - - async fn revoke(&self, token: &str) -> Result<(), DomainError> { - sqlx::query("DELETE FROM refresh_sessions WHERE token = $1") - .bind(token) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } - - async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { - sqlx::query("DELETE FROM refresh_sessions WHERE user_id = $1") - .bind(user_id.value().to_string()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } - - async fn delete_expired(&self) -> Result { - let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < NOW()") - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(result.rows_affected()) - } -} - -#[derive(sqlx::FromRow)] -struct RefreshSessionRow { - id: String, - user_id: String, - token: String, - expires_at: String, - created_at: String, -} - -impl RefreshSessionRow { - fn into_domain(self) -> Result { - let id = uuid::Uuid::parse_str(&self.id) - .map_err(|e| DomainError::InfrastructureError(format!("invalid uuid: {e}")))?; - let user_id = uuid::Uuid::parse_str(&self.user_id) - .map_err(|e| DomainError::InfrastructureError(format!("invalid user_id: {e}")))?; - let expires_at = DateTime::parse_from_rfc3339(&self.expires_at) - .map_err(|e| DomainError::InfrastructureError(format!("invalid expires_at: {e}")))? - .with_timezone(&chrono::Utc); - let created_at = DateTime::parse_from_rfc3339(&self.created_at) - .map_err(|e| DomainError::InfrastructureError(format!("invalid created_at: {e}")))? - .with_timezone(&chrono::Utc); - Ok(RefreshSession { - id, - user_id: UserId::from_uuid(user_id), - token: self.token, - expires_at, - created_at, - }) - } -} diff --git a/crates/adapters/postgres/src/review.rs b/crates/adapters/postgres/src/review.rs deleted file mode 100644 index 050187a2..00000000 --- a/crates/adapters/postgres/src/review.rs +++ /dev/null @@ -1,130 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{Review, ReviewSource}, - ports::ReviewRepository, - value_objects::{ReviewId, UserId}, -}; -use sqlx::PgPool; - -use crate::models::ReviewRow; -use adapter_common::datetime_to_str; - -pub struct PostgresReviewRepository { - pool: PgPool, -} - -impl PostgresReviewRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl ReviewRepository for PostgresReviewRepository { - async fn save_review(&self, review: &Review) -> Result<(), DomainError> { - let id = review.id().value().to_string(); - let movie_id = review.movie_id().value().to_string(); - let user_id = review.user_id().value().to_string(); - let rating = review.rating().value() as i64; - let comment = review.comment().map(|c| c.value().to_string()); - let watched_at = datetime_to_str(review.watched_at()); - let created_at = datetime_to_str(review.created_at()); - let remote_actor_url = match review.source() { - ReviewSource::Local => None, - ReviewSource::Remote { actor_url } => Some(actor_url.clone()), - }; - - sqlx::query( - "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers) - VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9, $10)", - ) - .bind(&id) - .bind(&movie_id) - .bind(&user_id) - .bind(rating) - .bind(&comment) - .bind(&watched_at) - .bind(&created_at) - .bind(&remote_actor_url) - .bind(review.watch_medium().map(|wm| wm.to_string())) - .bind(review.spoilers()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { - let id = review_id.value().to_string(); - sqlx::query_as::<_, ReviewRow>( - "SELECT id, movie_id, user_id, rating, comment, - to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - remote_actor_url, - watch_medium, - spoilers - FROM reviews WHERE id = $1", - ) - .bind(&id) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .map(ReviewRow::into_domain) - .transpose() - } - - async fn update_review(&self, review: &Review) -> Result<(), DomainError> { - let id = review.id().value().to_string(); - let rating = review.rating().value() as i64; - let comment = review.comment().map(|c| c.value().to_string()); - let watched_at = datetime_to_str(review.watched_at()); - let watch_medium = review.watch_medium().map(|wm| wm.to_string()); - - sqlx::query( - "UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4, spoilers = $5 WHERE id = $6", - ) - .bind(rating) - .bind(&comment) - .bind(&watched_at) - .bind(&watch_medium) - .bind(review.spoilers()) - .bind(&id) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> { - let id = review_id.value().to_string(); - sqlx::query("DELETE FROM reviews WHERE id = $1") - .bind(&id) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } - - async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - sqlx::query_as::<_, ReviewRow>( - "SELECT id, movie_id, user_id, rating, comment, - to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - remote_actor_url, - watch_medium, - spoilers - FROM reviews WHERE user_id = $1 ORDER BY watched_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(ReviewRow::into_domain) - .collect() - } -} diff --git a/crates/adapters/postgres/src/stats.rs b/crates/adapters/postgres/src/stats.rs deleted file mode 100644 index 5b190680..00000000 --- a/crates/adapters/postgres/src/stats.rs +++ /dev/null @@ -1,239 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{ - ActorAppearance, DirectorStat, MonthlyRating, UserStats, UserTrends, compute_top_actors, - }, - ports::StatsRepository, - value_objects::UserId, -}; -use sqlx::PgPool; - -use crate::models::{ - ActorAppearanceRow, DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, - UserTotalsRow, WatchMediumCountRow, -}; -use adapter_common::format_year_month; - -pub struct PostgresStatsRepository { - pool: PgPool, -} - -impl PostgresStatsRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - async fn fetch_user_totals(&self, user_id: &str) -> Result { - sqlx::query_as::<_, UserTotalsRow>( - r#"SELECT COUNT(DISTINCT movie_id) AS total, - AVG(rating::float) AS avg_rating - FROM reviews WHERE user_id = $1"#, - ) - .bind(user_id) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } - - async fn fetch_user_favorite_director( - &self, - user_id: &str, - ) -> Result, DomainError> { - sqlx::query_scalar::<_, String>( - "SELECT m.director - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1 AND m.director IS NOT NULL - GROUP BY m.director - ORDER BY COUNT(*) DESC - LIMIT 1", - ) - .bind(user_id) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } - - async fn fetch_user_most_active_month( - &self, - user_id: &str, - ) -> Result, DomainError> { - sqlx::query_scalar::<_, String>( - "SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month - FROM reviews - WHERE user_id = $1 - GROUP BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') - ORDER BY COUNT(*) DESC - LIMIT 1", - ) - .bind(user_id) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } -} - -#[async_trait] -impl StatsRepository for PostgresStatsRepository { - async fn get_user_stats(&self, user_id: &UserId) -> Result { - let uid = user_id.value().to_string(); - - let (totals, fav_director, most_active) = tokio::try_join!( - self.fetch_user_totals(&uid), - self.fetch_user_favorite_director(&uid), - self.fetch_user_most_active_month(&uid) - )?; - - let most_active_month = most_active.map(|ym| format_year_month(&ym)); - - Ok(UserStats { - total_movies: totals.total, - avg_rating: totals.avg_rating, - favorite_director: fav_director, - most_active_month, - }) - } - - async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result { - crate::goals::count_reviews_in_year(&self.pool, user_id, year).await - } - - async fn get_user_trends(&self, user_id: &UserId) -> Result { - let uid = user_id.value().to_string(); - - let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows, actor_rows) = - tokio::try_join!( - sqlx::query_as::<_, MonthlyRatingRow>( - "SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month, - AVG(rating::float) AS avg_rating, - COUNT(*) AS count - FROM reviews - WHERE user_id = $1 AND watched_at >= NOW() - INTERVAL '12 months' - GROUP BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') - ORDER BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') ASC" - ) - .bind(&uid) - .fetch_all(&self.pool), - sqlx::query_as::<_, DirectorCountRow>( - "SELECT m.director AS director, COUNT(*) AS count - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1 AND m.director IS NOT NULL - GROUP BY m.director - ORDER BY COUNT(*) DESC - LIMIT 5" - ) - .bind(&uid) - .fetch_all(&self.pool), - sqlx::query_as::<_, GenreCountRow>( - "SELECT mg.name AS genre, COUNT(*) AS count - FROM reviews r - INNER JOIN movie_genres mg ON mg.movie_id = r.movie_id - WHERE r.user_id = $1 - GROUP BY mg.name - ORDER BY COUNT(*) DESC - LIMIT 5" - ) - .bind(&uid) - .fetch_all(&self.pool), - sqlx::query_as::<_, RatingDistRow>( - "SELECT rating, COUNT(*) AS count - FROM reviews - WHERE user_id = $1 - GROUP BY rating - ORDER BY rating ASC" - ) - .bind(&uid) - .fetch_all(&self.pool), - sqlx::query_as::<_, WatchMediumCountRow>( - "SELECT watch_medium, COUNT(*) AS count - FROM reviews - WHERE user_id = $1 AND watch_medium IS NOT NULL - GROUP BY watch_medium - ORDER BY COUNT(*) DESC" - ) - .bind(&uid) - .fetch_all(&self.pool), - sqlx::query_as::<_, ActorAppearanceRow>( - "SELECT mc.tmdb_person_id, mc.name, mc.profile_path, - mc.billing_order, mc.movie_id - FROM reviews r - INNER JOIN movie_cast mc ON mc.movie_id = r.movie_id - WHERE r.user_id = $1 - GROUP BY mc.tmdb_person_id, mc.movie_id, mc.name, mc.profile_path, mc.billing_order" - ) - .bind(&uid) - .fetch_all(&self.pool) - ) - .map_err(adapter_common::map_sqlx_error)?; - - let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1); - - let monthly_ratings = rating_rows - .into_iter() - .map(|r| MonthlyRating { - month_label: format_year_month(&r.month), - year_month: r.month, - avg_rating: r.avg_rating, - count: r.count, - }) - .collect(); - - let top_directors = director_rows - .into_iter() - .map(|d| DirectorStat { - director: d.director, - count: d.count, - }) - .collect(); - - let top_genres = genre_rows - .into_iter() - .map(|g| domain::models::stats::GenreStat { - genre: g.genre, - count: g.count, - }) - .collect(); - - let rating_distribution = { - let mut dist = [0i64; 5]; - for r in &rating_dist_rows { - let idx = (r.rating as usize).saturating_sub(1).min(4); - dist[idx] = r.count; - } - dist - }; - - let watch_medium_distribution = medium_rows - .into_iter() - .map(|m| domain::models::stats::WatchMediumStat { - medium: m.watch_medium, - count: m.count, - }) - .collect(); - - let top_actors = compute_top_actors( - actor_rows - .into_iter() - .map(|r| ActorAppearance { - tmdb_person_id: r.tmdb_person_id as u64, - name: r.name, - profile_path: r.profile_path, - billing_order: r.billing_order as u32, - movie_id: r.movie_id, - }) - .collect(), - ); - - Ok(UserTrends { - monthly_ratings, - top_directors, - max_director_count, - top_genres, - rating_distribution, - watch_medium_distribution, - top_actors, - }) - } -} diff --git a/crates/adapters/postgres/src/user_settings.rs b/crates/adapters/postgres/src/user_settings.rs deleted file mode 100644 index 37112076..00000000 --- a/crates/adapters/postgres/src/user_settings.rs +++ /dev/null @@ -1,99 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{FederationFlags, UserSettings}, - ports::{UserFederationSettingsQuery, UserSettingsRepository}, - value_objects::UserId, -}; -use sqlx::{PgPool, Row}; - -pub struct PostgresUserSettingsRepository { - pool: PgPool, -} - -impl PostgresUserSettingsRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl UserSettingsRepository for PostgresUserSettingsRepository { - async fn get(&self, user_id: &UserId) -> Result { - let uid = user_id.value().to_string(); - let row = sqlx::query( - "SELECT federate_goals, federate_reviews, federate_watchlist \ - FROM user_settings WHERE user_id = $1", - ) - .bind(&uid) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - match row { - Some(r) => { - let goals: bool = r.try_get("federate_goals").unwrap_or(true); - let reviews: bool = r.try_get("federate_reviews").unwrap_or(true); - let watchlist: bool = r.try_get("federate_watchlist").unwrap_or(true); - Ok(UserSettings::from_persistence( - user_id.clone(), - goals, - reviews, - watchlist, - )) - } - None => Ok(UserSettings::new(user_id.clone())), - } - } - - async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> { - let uid = settings.user_id().value().to_string(); - sqlx::query( - "INSERT INTO user_settings (user_id, federate_goals, federate_reviews, federate_watchlist) \ - VALUES ($1, $2, $3, $4) \ - ON CONFLICT (user_id) DO UPDATE \ - SET federate_goals = $2, federate_reviews = $3, federate_watchlist = $4", - ) - .bind(&uid) - .bind(settings.federate_goals()) - .bind(settings.federate_reviews()) - .bind(settings.federate_watchlist()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } -} - -#[async_trait] -impl UserFederationSettingsQuery for PostgresUserSettingsRepository { - async fn get_federation_flags(&self, user_id: &UserId) -> Result { - let uid = user_id.value().to_string(); - let row = sqlx::query( - "SELECT federate_goals, federate_reviews, federate_watchlist \ - FROM user_settings WHERE user_id = $1", - ) - .bind(&uid) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - match row { - Some(r) => { - let goals: bool = r.try_get("federate_goals").unwrap_or(true); - let reviews: bool = r.try_get("federate_reviews").unwrap_or(true); - let watchlist: bool = r.try_get("federate_watchlist").unwrap_or(true); - Ok(FederationFlags { - goals, - reviews, - watchlist, - }) - } - None => Ok(FederationFlags { - goals: true, - reviews: true, - watchlist: true, - }), - } - } -} diff --git a/crates/adapters/postgres/src/users.rs b/crates/adapters/postgres/src/users.rs deleted file mode 100644 index b11dcd0e..00000000 --- a/crates/adapters/postgres/src/users.rs +++ /dev/null @@ -1,200 +0,0 @@ -use async_trait::async_trait; -use chrono::Utc; -use sqlx::{PgPool, Row}; - -use domain::{ - errors::DomainError, - models::{ProfileField, User, UserRole}, - ports::UserRepository, - value_objects::{Email, PasswordHash, UserId, Username}, -}; - -use super::models::UserSummaryRow; - -pub struct PostgresUserRepository { - pool: PgPool, -} - -impl PostgresUserRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - fn parse_role(s: &str) -> UserRole { - match s { - "admin" => UserRole::Admin, - _ => UserRole::Standard, - } - } - - fn row_to_user( - row: &sqlx::postgres::PgRow, - profile_fields: Vec, - ) -> Result { - let id_str: String = row.get("id"); - let id = uuid::Uuid::parse_str(&id_str) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let email = Email::new(row.get::("email")) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let username = Username::new(row.get::("username")) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let hash = PasswordHash::new(row.get::("password_hash")) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let role_str: String = row.get("role"); - Ok(User::from_persistence( - UserId::from_uuid(id), - email, - username, - hash, - Self::parse_role(&role_str), - domain::models::UserProfile { - display_name: row.try_get("display_name").ok().flatten(), - bio: row.try_get("bio").ok().flatten(), - avatar_path: row.try_get("avatar_path").ok().flatten(), - banner_path: row.try_get("banner_path").ok().flatten(), - also_known_as: row.try_get("also_known_as").ok().flatten(), - profile_fields, - }, - )) - } -} - -const PG_USER_COLS: &str = "id, email, username, password_hash, role, display_name, bio, avatar_path, banner_path, also_known_as"; - -#[async_trait] -impl UserRepository for PostgresUserRepository { - async fn find_by_email(&self, email: &Email) -> Result, DomainError> { - let email_str = email.value(); - let row = sqlx::query(&format!( - "SELECT {PG_USER_COLS} FROM users WHERE email = $1" - )) - .bind(email_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - row.as_ref() - .map(|r| Self::row_to_user(r, vec![])) - .transpose() - } - - async fn find_by_username(&self, username: &Username) -> Result, DomainError> { - let username_str = username.value(); - let row = sqlx::query(&format!( - "SELECT {PG_USER_COLS} FROM users WHERE username = $1" - )) - .bind(username_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - row.as_ref() - .map(|r| Self::row_to_user(r, vec![])) - .transpose() - } - - async fn save(&self, user: &User) -> Result<(), DomainError> { - if self.find_by_email(user.email()).await?.is_some() { - return Err(DomainError::ValidationError( - "Email already registered".into(), - )); - } - if self.find_by_username(user.username()).await?.is_some() { - return Err(DomainError::ValidationError( - "Username already taken".into(), - )); - } - let id = user.id().value().to_string(); - let email = user.email().value(); - let username = user.username().value(); - let hash = user.password_hash().value(); - let created_at = Utc::now() - .naive_utc() - .format("%Y-%m-%d %H:%M:%S") - .to_string(); - let role = match user.role() { - UserRole::Admin => "admin", - UserRole::Standard => "standard", - }; - sqlx::query( - "INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES ($1, $2, $3, $4, $5::timestamptz, $6)", - ) - .bind(&id) - .bind(email) - .bind(username) - .bind(hash) - .bind(&created_at) - .bind(role) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } - - async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { - let id_str = id.value().to_string(); - let row = sqlx::query(&format!("SELECT {PG_USER_COLS} FROM users WHERE id = $1")) - .bind(&id_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let Some(r) = row else { return Ok(None) }; - - let field_rows = sqlx::query( - "SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC", - ) - .bind(&id_str) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - let profile_fields = field_rows - .iter() - .map(|f| ProfileField { - name: f.get("name"), - value: f.get("value"), - }) - .collect(); - - Self::row_to_user(&r, profile_fields).map(Some) - } - - async fn update_profile( - &self, - user_id: &UserId, - profile: &domain::models::UserProfile, - ) -> Result<(), DomainError> { - let id_str = user_id.value().to_string(); - sqlx::query( - "UPDATE users SET display_name = $1, bio = $2, avatar_path = $3, banner_path = $4, also_known_as = $5 WHERE id = $6", - ) - .bind(&profile.display_name) - .bind(&profile.bio) - .bind(&profile.avatar_path) - .bind(&profile.banner_path) - .bind(&profile.also_known_as) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(()) - } - - async fn list_with_stats(&self) -> Result, DomainError> { - sqlx::query_as::<_, UserSummaryRow>( - r#"SELECT u.id, u.email, u.username, u.display_name, - COUNT(DISTINCT r.movie_id) AS total_movies, - AVG(r.rating::float) AS avg_rating, - u.avatar_path - FROM users u - LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL - GROUP BY u.id, u.email, u.username, u.display_name, u.avatar_path - ORDER BY u.email ASC"#, - ) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(UserSummaryRow::into_domain) - .collect() - } -} diff --git a/crates/adapters/postgres/src/watch_event.rs b/crates/adapters/postgres/src/watch_event.rs deleted file mode 100644 index 310e2747..00000000 --- a/crates/adapters/postgres/src/watch_event.rs +++ /dev/null @@ -1,384 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken}, - ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository}, - value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId}, -}; -use sqlx::{PgPool, Row}; - -use adapter_common::{parse_datetime, parse_uuid}; - -// ── WatchEventRepository ────────────────────────────────────────────────────── - -pub struct PostgresWatchEventRepository { - pool: PgPool, -} - -impl PostgresWatchEventRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WatchEventCommand for PostgresWatchEventRepository { - async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { - let id = event.id().value().to_string(); - let user_id = event.user_id().value().to_string(); - let movie_id = event.movie_id().map(|m| m.value().to_string()); - let source = event.source().to_string(); - let status = event.status().to_string(); - - sqlx::query( - "INSERT INTO watch_events \ - (id, user_id, movie_id, title, year, external_metadata_id, source, watched_at, status, created_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", - ) - .bind(&id) - .bind(&user_id) - .bind(&movie_id) - .bind(event.title()) - .bind(event.year().map(|y| y as i32)) - .bind(event.external_metadata_id()) - .bind(&source) - .bind(event.watched_at()) - .bind(&status) - .bind(event.created_at()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn update_status( - &self, - id: &WatchEventId, - status: WatchEventStatus, - ) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let status_str = status.to_string(); - - sqlx::query("UPDATE watch_events SET status = $1 WHERE id = $2") - .bind(&status_str) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn update_status_batch( - &self, - ids: &[WatchEventId], - status: WatchEventStatus, - ) -> Result { - if ids.is_empty() { - return Ok(0); - } - let id_strs: Vec = ids.iter().map(|id| id.value().to_string()).collect(); - let status_str = status.to_string(); - let result = sqlx::query("UPDATE watch_events SET status = $1 WHERE id = ANY($2)") - .bind(&status_str) - .bind(&id_strs) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(result.rows_affected()) - } - - async fn delete_non_pending_older_than( - &self, - before: chrono::NaiveDateTime, - ) -> Result { - let result = - sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < $1") - .bind(before) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(result.rows_affected()) - } -} - -#[async_trait] -impl WatchEventQuery for PostgresWatchEventRepository { - async fn list_pending(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - - let rows = sqlx::query( - "SELECT id, user_id, movie_id, title, year, external_metadata_id, \ - source, \ - to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \ - status, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \ - FROM watch_events \ - WHERE user_id = $1 AND status = 'pending' \ - ORDER BY watched_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.iter().map(row_to_watch_event).collect() - } - - async fn get_by_id(&self, id: &WatchEventId) -> Result, DomainError> { - let id_str = id.value().to_string(); - - let row = sqlx::query( - "SELECT id, user_id, movie_id, title, year, external_metadata_id, \ - source, \ - to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \ - status, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \ - FROM watch_events WHERE id = $1", - ) - .bind(&id_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.as_ref().map(row_to_watch_event).transpose() - } - - async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result, DomainError> { - if ids.is_empty() { - return Ok(vec![]); - } - let id_strs: Vec = ids.iter().map(|id| id.value().to_string()).collect(); - let rows = sqlx::query( - "SELECT id, user_id, movie_id, title, year, external_metadata_id, \ - source, \ - to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \ - status, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \ - FROM watch_events WHERE id = ANY($1)", - ) - .bind(&id_strs) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - rows.iter().map(row_to_watch_event).collect() - } - - async fn find_duplicate( - &self, - user_id: &UserId, - external_id: &str, - after: chrono::NaiveDateTime, - ) -> Result { - let uid = user_id.value().to_string(); - - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM watch_events \ - WHERE user_id = $1 AND external_metadata_id = $2 AND created_at > $3", - ) - .bind(&uid) - .bind(external_id) - .bind(after) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(count > 0) - } -} - -fn row_to_watch_event(row: &sqlx::postgres::PgRow) -> Result { - let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?; - let user_id_str: String = row - .try_get("user_id") - .map_err(adapter_common::map_sqlx_error)?; - let movie_id_str: Option = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let title: String = row - .try_get("title") - .map_err(adapter_common::map_sqlx_error)?; - let year: Option = row - .try_get("year") - .map_err(adapter_common::map_sqlx_error)?; - let ext_id: Option = row - .try_get("external_metadata_id") - .map_err(adapter_common::map_sqlx_error)?; - let source_str: String = row - .try_get("source") - .map_err(adapter_common::map_sqlx_error)?; - let watched_at_str: String = row - .try_get("watched_at") - .map_err(adapter_common::map_sqlx_error)?; - let status_str: String = row - .try_get("status") - .map_err(adapter_common::map_sqlx_error)?; - let created_at_str: String = row - .try_get("created_at") - .map_err(adapter_common::map_sqlx_error)?; - - let source: WatchEventSource = source_str - .parse() - .map_err(|e: String| DomainError::InfrastructureError(e))?; - let status: WatchEventStatus = status_str - .parse() - .map_err(|e: String| DomainError::InfrastructureError(e))?; - - let movie_id = movie_id_str - .as_deref() - .map(parse_uuid) - .transpose()? - .map(MovieId::from_uuid); - - Ok(WatchEvent::from_persistence(PersistedWatchEvent { - id: WatchEventId::from_uuid(parse_uuid(&id_str)?), - user_id: UserId::from_uuid(parse_uuid(&user_id_str)?), - movie_id, - title, - year: year.map(|y| y as u16), - external_metadata_id: ext_id, - source, - watched_at: parse_datetime(&watched_at_str)?, - status, - created_at: parse_datetime(&created_at_str)?, - })) -} - -// ── WebhookTokenRepository ──────────────────────────────────────────────────── - -pub struct PostgresWebhookTokenRepository { - pool: PgPool, -} - -impl PostgresWebhookTokenRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WebhookTokenRepository for PostgresWebhookTokenRepository { - async fn save(&self, token: &WebhookToken) -> Result<(), DomainError> { - let id = token.id().value().to_string(); - let user_id = token.user_id().value().to_string(); - let provider = token.provider().to_string(); - - sqlx::query( - "INSERT INTO webhook_tokens \ - (id, user_id, token_hash, provider, label, created_at, last_used_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7)", - ) - .bind(&id) - .bind(&user_id) - .bind(token.token_hash()) - .bind(&provider) - .bind(token.label()) - .bind(token.created_at()) - .bind(token.last_used_at()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn find_by_token_hash(&self, hash: &str) -> Result, DomainError> { - let row = sqlx::query( - "SELECT id, user_id, token_hash, provider, label, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, \ - to_char(last_used_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS last_used_at \ - FROM webhook_tokens WHERE token_hash = $1", - ) - .bind(hash) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.as_ref().map(row_to_webhook_token).transpose() - } - - async fn list_by_user(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - - let rows = sqlx::query( - "SELECT id, user_id, token_hash, provider, label, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, \ - to_char(last_used_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS last_used_at \ - FROM webhook_tokens WHERE user_id = $1 ORDER BY created_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.iter().map(row_to_webhook_token).collect() - } - - async fn delete(&self, id: &WebhookTokenId, user_id: &UserId) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let uid = user_id.value().to_string(); - - let result = sqlx::query("DELETE FROM webhook_tokens WHERE id = $1 AND user_id = $2") - .bind(&id_str) - .bind(&uid) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - if result.rows_affected() == 0 { - return Err(DomainError::NotFound(format!("Webhook token {id_str}"))); - } - Ok(()) - } - - async fn touch_last_used(&self, id: &WebhookTokenId) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - - sqlx::query("UPDATE webhook_tokens SET last_used_at = NOW() WHERE id = $1") - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } -} - -fn row_to_webhook_token(row: &sqlx::postgres::PgRow) -> Result { - let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?; - let user_id_str: String = row - .try_get("user_id") - .map_err(adapter_common::map_sqlx_error)?; - let token_hash: String = row - .try_get("token_hash") - .map_err(adapter_common::map_sqlx_error)?; - let provider_str: String = row - .try_get("provider") - .map_err(adapter_common::map_sqlx_error)?; - let label: Option = row - .try_get("label") - .map_err(adapter_common::map_sqlx_error)?; - let created_at_str: String = row - .try_get("created_at") - .map_err(adapter_common::map_sqlx_error)?; - let last_used_str: Option = row - .try_get("last_used_at") - .map_err(adapter_common::map_sqlx_error)?; - - let provider: WatchEventSource = provider_str - .parse() - .map_err(|e: String| DomainError::InfrastructureError(e))?; - - let last_used = last_used_str.as_deref().map(parse_datetime).transpose()?; - - Ok(WebhookToken::from_persistence( - WebhookTokenId::from_uuid(parse_uuid(&id_str)?), - UserId::from_uuid(parse_uuid(&user_id_str)?), - token_hash, - provider, - label, - parse_datetime(&created_at_str)?, - last_used, - )) -} diff --git a/crates/adapters/postgres/src/watchlist.rs b/crates/adapters/postgres/src/watchlist.rs deleted file mode 100644 index 5b2953f9..00000000 --- a/crates/adapters/postgres/src/watchlist.rs +++ /dev/null @@ -1,189 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{ - WatchlistEntry, WatchlistWithMovie, - collections::{PageParams, Paginated}, - }, - ports::WatchlistRepository, - value_objects::{MovieId, UserId, WatchlistEntryId}, -}; -use sqlx::{PgPool, Row}; - -use crate::models::MovieRow; -use adapter_common::{parse_datetime, parse_uuid}; - -pub struct PostgresWatchlistRepository { - pool: PgPool, -} - -impl PostgresWatchlistRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WatchlistRepository for PostgresWatchlistRepository { - async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> { - let id = entry.id.value().to_string(); - let user_id = entry.user_id.value().to_string(); - let movie_id = entry.movie_id.value().to_string(); - let added_at = entry.added_at; - - sqlx::query( - "INSERT INTO watchlist_entries (id, user_id, movie_id, added_at) \ - VALUES ($1, $2, $3, $4) \ - ON CONFLICT (user_id, movie_id) DO NOTHING", - ) - .bind(&id) - .bind(&user_id) - .bind(&movie_id) - .bind(added_at) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> { - let uid = user_id.value().to_string(); - let mid = movie_id.value().to_string(); - - let result = - sqlx::query("DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2") - .bind(&uid) - .bind(&mid) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - if result.rows_affected() == 0 { - return Err(DomainError::NotFound(format!( - "Watchlist entry for movie {} not found", - mid - ))); - } - Ok(()) - } - - async fn remove_if_present( - &self, - user_id: &UserId, - movie_id: &MovieId, - ) -> Result { - let uid = user_id.value().to_string(); - let mid = movie_id.value().to_string(); - let result = - sqlx::query("DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2") - .bind(&uid) - .bind(&mid) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(result.rows_affected() > 0) - } - - async fn get_for_user( - &self, - user_id: &UserId, - page: &PageParams, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let limit = page.limit as i64; - let offset = page.offset as i64; - - let rows = sqlx::query( - "SELECT w.id, w.user_id, w.movie_id, \ - to_char(w.added_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS added_at, \ - m.id AS m_id, m.external_metadata_id, m.title, m.release_year, \ - m.director, m.poster_path \ - FROM watchlist_entries w \ - JOIN movies m ON m.id = w.movie_id \ - WHERE w.user_id = $1 \ - ORDER BY w.added_at DESC \ - LIMIT $2 OFFSET $3", - ) - .bind(&uid) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let total: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1") - .bind(&uid) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let items = rows - .into_iter() - .map(|row| { - let entry = WatchlistEntry { - id: WatchlistEntryId::from_uuid(parse_uuid( - &row.try_get::("id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?), - user_id: UserId::from_uuid(parse_uuid( - &row.try_get::("user_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?), - movie_id: MovieId::from_uuid(parse_uuid( - &row.try_get::("movie_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?), - added_at: parse_datetime( - &row.try_get::("added_at") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?, - }; - let movie = MovieRow { - id: row - .try_get("m_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - external_metadata_id: row - .try_get("external_metadata_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - title: row - .try_get("title") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - release_year: row - .try_get("release_year") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - director: row - .try_get("director") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - poster_path: row - .try_get("poster_path") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - } - .into_domain()?; - Ok(WatchlistWithMovie { entry, movie }) - }) - .collect::, DomainError>>()?; - - Ok(Paginated { - items, - total_count: total as u64, - limit: page.limit, - offset: page.offset, - }) - } - - async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result { - let uid = user_id.value().to_string(); - let mid = movie_id.value().to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2", - ) - .bind(&uid) - .bind(&mid) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(count > 0) - } -} diff --git a/crates/adapters/postgres/src/wrapup.rs b/crates/adapters/postgres/src/wrapup.rs deleted file mode 100644 index ffffda6b..00000000 --- a/crates/adapters/postgres/src/wrapup.rs +++ /dev/null @@ -1,508 +0,0 @@ -use std::collections::HashMap; - -use async_trait::async_trait; -use chrono::NaiveDate; -use domain::{ - errors::DomainError, - models::wrapup::{ - DateRange, WrapUpMovieRow, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus, - }, - ports::{WrapUpRepository, WrapUpStatsQuery}, - value_objects::WrapUpId, -}; -use sqlx::{PgPool, Row}; -use uuid::Uuid; - -use adapter_common::{parse_datetime, parse_uuid}; - -fn status_to_str(s: &WrapUpStatus) -> &'static str { - match s { - WrapUpStatus::Pending => "pending", - WrapUpStatus::Generating => "generating", - WrapUpStatus::Ready => "ready", - WrapUpStatus::Failed => "failed", - } -} - -fn parse_status(s: &str) -> Result { - match s { - "pending" => Ok(WrapUpStatus::Pending), - "generating" => Ok(WrapUpStatus::Generating), - "ready" => Ok(WrapUpStatus::Ready), - "failed" => Ok(WrapUpStatus::Failed), - other => Err(DomainError::InfrastructureError(format!( - "Unknown wrap-up status: {other}" - ))), - } -} - -// ── WrapUpRepository ───────────────────────────────────────────────────────── - -pub struct PostgresWrapUpRepository { - pool: PgPool, -} - -impl PostgresWrapUpRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WrapUpRepository for PostgresWrapUpRepository { - async fn create(&self, record: &WrapUpRecord) -> Result<(), DomainError> { - let id = record.id.value().to_string(); - let user_id = record.user_id.map(|u| u.to_string()); - let status = status_to_str(&record.status); - - sqlx::query( - "INSERT INTO wrap_up_records \ - (id, user_id, start_date, end_date, status, report_json, error_message, created_at, completed_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", - ) - .bind(&id) - .bind(&user_id) - .bind(record.start_date) - .bind(record.end_date) - .bind(status) - .bind(record.report.as_ref().and_then(|r| serde_json::to_string(r).ok())) - .bind(&record.error_message) - .bind(record.created_at) - .bind(record.completed_at) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn update_status( - &self, - id: &WrapUpId, - status: &WrapUpStatus, - error: Option<&str>, - ) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let status_str = status_to_str(status); - - sqlx::query("UPDATE wrap_up_records SET status = $1, error_message = $2 WHERE id = $3") - .bind(status_str) - .bind(error) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn set_complete(&self, id: &WrapUpId, report: &WrapUpReport) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let json = serde_json::to_string(report) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - sqlx::query( - "UPDATE wrap_up_records \ - SET status = 'ready', report_json = $1, completed_at = NOW() \ - WHERE id = $2", - ) - .bind(&json) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn get_by_id(&self, id: &WrapUpId) -> Result, DomainError> { - let id_str = id.value().to_string(); - - let row = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, \ - to_char(completed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS completed_at \ - FROM wrap_up_records WHERE id = $1", - ) - .bind(&id_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.as_ref().map(row_to_record).transpose() - } - - async fn list_for_user(&self, user_id: Uuid) -> Result, DomainError> { - let uid = user_id.to_string(); - - let rows = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, \ - to_char(completed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS completed_at \ - FROM wrap_up_records WHERE user_id = $1 ORDER BY created_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.iter().map(row_to_record).collect() - } - - async fn list_global(&self) -> Result, DomainError> { - let rows = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, \ - to_char(completed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS completed_at \ - FROM wrap_up_records WHERE user_id IS NULL ORDER BY created_at DESC", - ) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.iter().map(row_to_record).collect() - } - - async fn find_existing( - &self, - user_id: Option, - start: NaiveDate, - end: NaiveDate, - ) -> Result, DomainError> { - let uid = user_id.map(|u| u.to_string()); - - let row = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, \ - to_char(completed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS completed_at \ - FROM wrap_up_records \ - WHERE (($1::text IS NULL AND user_id IS NULL) OR user_id = $1) \ - AND start_date = $2 AND end_date = $3 \ - LIMIT 1", - ) - .bind(&uid) - .bind(start) - .bind(end) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.as_ref().map(row_to_record).transpose() - } - - async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError> { - sqlx::query("DELETE FROM wrap_up_records WHERE id = $1") - .bind(id.value().to_string()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } - - async fn delete_failed_older_than( - &self, - before: chrono::NaiveDateTime, - ) -> Result { - let result = - sqlx::query("DELETE FROM wrap_up_records WHERE status = 'failed' AND created_at < $1") - .bind(before) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(result.rows_affected()) - } -} - -fn row_to_record(row: &sqlx::postgres::PgRow) -> Result { - let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?; - let user_id_str: Option = row - .try_get("user_id") - .map_err(adapter_common::map_sqlx_error)?; - let start_date: NaiveDate = row - .try_get("start_date") - .map_err(adapter_common::map_sqlx_error)?; - let end_date: NaiveDate = row - .try_get("end_date") - .map_err(adapter_common::map_sqlx_error)?; - let status_str: String = row - .try_get("status") - .map_err(adapter_common::map_sqlx_error)?; - let report_json: Option = row - .try_get("report_json") - .map_err(adapter_common::map_sqlx_error)?; - let error_message: Option = row - .try_get("error_message") - .map_err(adapter_common::map_sqlx_error)?; - let created_at_str: String = row - .try_get("created_at") - .map_err(adapter_common::map_sqlx_error)?; - let completed_at_str: Option = row - .try_get("completed_at") - .map_err(adapter_common::map_sqlx_error)?; - - let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?; - - let report = report_json.and_then(|j| serde_json::from_str(&j).ok()); - - Ok(WrapUpRecord { - id: WrapUpId::from_uuid(parse_uuid(&id_str)?), - user_id, - start_date, - end_date, - status: parse_status(&status_str)?, - report, - error_message, - created_at: parse_datetime(&created_at_str)?, - completed_at: completed_at_str - .as_deref() - .map(parse_datetime) - .transpose()?, - }) -} - -// ── WrapUpStatsQuery ───────────────────────────────────────────────────────── - -pub struct PostgresWrapUpStatsQuery { - pool: PgPool, -} - -impl PostgresWrapUpStatsQuery { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WrapUpStatsQuery for PostgresWrapUpStatsQuery { - async fn get_reviews_with_profiles( - &self, - scope: &WrapUpScope, - range: &DateRange, - ) -> Result, DomainError> { - // 1) Main query: reviews + movies + movie_profiles - let (scope_clause, scope_bind) = match scope { - WrapUpScope::User(uid) => ("AND r.user_id = $3", Some(uid.to_string())), - WrapUpScope::Global => ("", None), - }; - - let sql = format!( - "SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \ - r.rating, \ - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \ - r.user_id, r.watch_medium, \ - p.runtime_minutes, p.budget_usd, p.original_language \ - FROM reviews r \ - INNER JOIN movies m ON m.id = r.movie_id \ - LEFT JOIN movie_profiles p ON p.movie_id = m.id \ - WHERE r.watched_at >= $1 AND r.watched_at < $2 {scope_clause} \ - ORDER BY r.watched_at ASC" - ); - - let mut q = sqlx::query(&sql).bind(range.start()).bind(range.end()); - if let Some(ref uid) = scope_bind { - q = q.bind(uid); - } - - let rows = q - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - if rows.is_empty() { - return Ok(vec![]); - } - - // Collect unique movie IDs - let mut movie_ids: Vec = Vec::new(); - let mut seen = std::collections::HashSet::new(); - for row in &rows { - let mid: String = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - if seen.insert(mid.clone()) { - movie_ids.push(mid); - } - } - - // 2) Batch-fetch genres, keywords, cast - let (genres_map, keywords_map, cast_map) = tokio::try_join!( - fetch_genres_pg(&self.pool, &movie_ids), - fetch_keywords_pg(&self.pool, &movie_ids), - fetch_cast_pg(&self.pool, &movie_ids), - )?; - - // 3) Build result - let mut result = Vec::with_capacity(rows.len()); - for row in &rows { - let movie_id_str: String = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let title: String = row - .try_get("title") - .map_err(adapter_common::map_sqlx_error)?; - let release_year: i64 = row - .try_get("release_year") - .map_err(adapter_common::map_sqlx_error)?; - let director: Option = row - .try_get("director") - .map_err(adapter_common::map_sqlx_error)?; - let poster_path: Option = row - .try_get("poster_path") - .map_err(adapter_common::map_sqlx_error)?; - let rating: i64 = row - .try_get("rating") - .map_err(adapter_common::map_sqlx_error)?; - let watched_at_str: String = row - .try_get("watched_at") - .map_err(adapter_common::map_sqlx_error)?; - let user_id_str: String = row - .try_get("user_id") - .map_err(adapter_common::map_sqlx_error)?; - let runtime_minutes: Option = row - .try_get("runtime_minutes") - .map_err(adapter_common::map_sqlx_error)?; - let budget_usd: Option = row - .try_get("budget_usd") - .map_err(adapter_common::map_sqlx_error)?; - let original_language: Option = row - .try_get("original_language") - .map_err(adapter_common::map_sqlx_error)?; - let watch_medium: Option = row - .try_get("watch_medium") - .map_err(adapter_common::map_sqlx_error)?; - - let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default(); - let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default(); - let cast = cast_map.get(&movie_id_str).cloned().unwrap_or_default(); - - let cast_names: Vec<(String, u32, i64)> = cast - .iter() - .map(|c| (c.name.clone(), c.billing_order, c.tmdb_person_id)) - .collect(); - let cast_profile_paths: Vec> = - cast.iter().map(|c| c.profile_path.clone()).collect(); - - result.push(WrapUpMovieRow { - movie_id: parse_uuid(&movie_id_str)?, - title, - release_year: release_year as u16, - director, - poster_path, - rating: rating as u8, - watched_at: parse_datetime(&watched_at_str)?, - user_id: parse_uuid(&user_id_str)?, - runtime_minutes: runtime_minutes.map(|v| v as u32), - budget_usd, - original_language, - watch_medium, - genres, - keywords, - cast_names, - cast_profile_paths, - }); - } - - Ok(result) - } -} - -#[derive(Clone)] -struct CastEntry { - name: String, - billing_order: u32, - tmdb_person_id: i64, - profile_path: Option, -} - -/// Shared by WrapUp and Insights: movie-id → genre names. -pub(crate) async fn fetch_genres_pg( - pool: &PgPool, - movie_ids: &[String], -) -> Result>, DomainError> { - let rows = sqlx::query( - "SELECT movie_id, name FROM movie_genres WHERE movie_id = ANY($1) ORDER BY name", - ) - .bind(movie_ids) - .fetch_all(pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let mut map: HashMap> = HashMap::new(); - for row in rows { - let mid: String = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let name: String = row - .try_get("name") - .map_err(adapter_common::map_sqlx_error)?; - map.entry(mid).or_default().push(name); - } - Ok(map) -} - -async fn fetch_keywords_pg( - pool: &PgPool, - movie_ids: &[String], -) -> Result>, DomainError> { - let rows = sqlx::query( - "SELECT movie_id, name FROM movie_keywords WHERE movie_id = ANY($1) ORDER BY name", - ) - .bind(movie_ids) - .fetch_all(pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let mut map: HashMap> = HashMap::new(); - for row in rows { - let mid: String = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let name: String = row - .try_get("name") - .map_err(adapter_common::map_sqlx_error)?; - map.entry(mid).or_default().push(name); - } - Ok(map) -} - -async fn fetch_cast_pg( - pool: &PgPool, - movie_ids: &[String], -) -> Result>, DomainError> { - let rows = sqlx::query( - "SELECT movie_id, name, billing_order, tmdb_person_id, profile_path \ - FROM movie_cast \ - WHERE movie_id = ANY($1) AND billing_order <= 3 \ - ORDER BY billing_order ASC", - ) - .bind(movie_ids) - .fetch_all(pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let mut map: HashMap> = HashMap::new(); - for row in rows { - let mid: String = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let name: String = row - .try_get("name") - .map_err(adapter_common::map_sqlx_error)?; - let billing_order: i32 = row - .try_get("billing_order") - .map_err(adapter_common::map_sqlx_error)?; - let tmdb_person_id: i64 = row - .try_get("tmdb_person_id") - .map_err(adapter_common::map_sqlx_error)?; - let profile_path: Option = row - .try_get("profile_path") - .map_err(adapter_common::map_sqlx_error)?; - map.entry(mid).or_default().push(CastEntry { - name, - billing_order: billing_order as u32, - tmdb_person_id, - profile_path, - }); - } - Ok(map) -} diff --git a/crates/composition/Cargo.toml b/crates/composition/Cargo.toml index a9ad821d..de444330 100644 --- a/crates/composition/Cargo.toml +++ b/crates/composition/Cargo.toml @@ -6,11 +6,9 @@ edition.workspace = true [features] default = [] sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "dep:sqlite-social", "infra-wiring/sqlite"] -postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search", "dep:postgres-social", "infra-wiring/postgres"] nats = ["dep:nats", "infra-wiring/nats"] federation = ["application/federation"] sqlite-federation = ["sqlite", "dep:sqlite-federation", "dep:activitypub", "federation"] -postgres-federation = ["postgres", "dep:postgres-federation", "dep:activitypub", "federation"] [dependencies] domain = { workspace = true } @@ -25,17 +23,12 @@ plex = { workspace = true } anyhow = { workspace = true } sqlite = { workspace = true, optional = true } -postgres = { workspace = true, optional = true } sqlite-event-queue = { workspace = true, optional = true } -postgres-event-queue = { workspace = true, optional = true } sqlite-search = { workspace = true, optional = true } -postgres-search = { workspace = true, optional = true } sqlite-social = { workspace = true, optional = true } -postgres-social = { workspace = true, optional = true } nats = { workspace = true, optional = true } activitypub = { workspace = true, optional = true } sqlite-federation = { workspace = true, optional = true } -postgres-federation = { workspace = true, optional = true } [dev-dependencies] async-trait = { workspace = true } diff --git a/crates/composition/src/factory.rs b/crates/composition/src/factory.rs index 6e36146e..efe5fa16 100644 --- a/crates/composition/src/factory.rs +++ b/crates/composition/src/factory.rs @@ -48,126 +48,59 @@ pub struct DatabaseOutput { } pub async fn build_database_adapters( - backend: &str, url: &str, instance: &domain::value_objects::InstanceIdentity, ) -> anyhow::Result { - match backend { - #[cfg(feature = "postgres")] - "postgres" => { - let w = postgres::wire(url) - .await - .context("PostgreSQL connection failed")?; - let (image_ref_command, image_ref_query) = postgres::create_image_ref(w.pool.clone()); - let (pc, pq) = postgres::create_person_adapter(w.pool.clone()); - let (sc, sp) = postgres_search::create_search_adapter(w.pool.clone()); - let pf = postgres::create_profile_fields_repo(w.pool.clone()); - let we = Arc::new(postgres::PostgresWatchEventRepository::new(w.pool.clone())); - let wt: Arc = Arc::new( - postgres::PostgresWebhookTokenRepository::new(w.pool.clone()), - ); - let social = Arc::new(postgres_social::PostgresSocialRepository::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, - 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, - federation_settings: w.federation_settings, - remote_goal: w.remote_goal, - refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new( - w.pool.clone(), - )) as _, - deduplicator: w.deduplicator, - image_ref_command, - image_ref_query, - follow_command: Arc::clone(&social) as _, - follow_query: social as _, - db_pool: DbPool::Postgres(w.pool), - }) - } - #[cfg(feature = "sqlite")] - _ => { - let w = sqlite::wire(url) - .await - .context("SQLite connection failed")?; - 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_search::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 = - Arc::new(sqlite::SqliteWebhookTokenRepository::new(w.pool.clone())); - let social = Arc::new(sqlite_social::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, - 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, - federation_settings: w.federation_settings, - remote_goal: w.remote_goal, - refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone())) - as _, - deduplicator: w.deduplicator, - image_ref_command, - image_ref_query, - follow_command: Arc::clone(&social) as _, - follow_query: social as _, - db_pool: DbPool::Sqlite(w.pool), - }) - } - #[cfg(not(feature = "sqlite"))] - _ => anyhow::bail!( - "DATABASE_BACKEND={backend} is not supported by this build (enable sqlite or postgres feature)" - ), - } + let w = sqlite::wire(url) + .await + .context("SQLite connection failed")?; + 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_search::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 = + Arc::new(sqlite::SqliteWebhookTokenRepository::new(w.pool.clone())); + let social = Arc::new(sqlite_social::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, + 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, + federation_settings: w.federation_settings, + remote_goal: w.remote_goal, + refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone())) as _, + deduplicator: w.deduplicator, + image_ref_command, + image_ref_query, + follow_command: Arc::clone(&social) as _, + follow_query: social as _, + db_pool: DbPool::Sqlite(w.pool), + }) } pub fn build_auth_adapters() -> anyhow::Result<(Arc, Arc)> { @@ -190,8 +123,6 @@ pub fn build_profile_fields_repo( pool: &DbPool, ) -> anyhow::Result> { match pool { - #[cfg(feature = "postgres")] - DbPool::Postgres(pool) => Ok(postgres::create_profile_fields_repo(pool.clone())), #[cfg(feature = "sqlite")] DbPool::Sqlite(pool) => Ok(sqlite::create_profile_fields_repo(pool.clone())), #[cfg(not(feature = "sqlite"))] diff --git a/crates/infra-wiring/Cargo.toml b/crates/infra-wiring/Cargo.toml index fff101eb..fcacb81f 100644 --- a/crates/infra-wiring/Cargo.toml +++ b/crates/infra-wiring/Cargo.toml @@ -10,5 +10,4 @@ anyhow = { workspace = true } [features] default = [] sqlite = [] -postgres = ["sqlx/postgres"] nats = [] diff --git a/crates/infra-wiring/src/lib.rs b/crates/infra-wiring/src/lib.rs index dae26a9e..bb9dc566 100644 --- a/crates/infra-wiring/src/lib.rs +++ b/crates/infra-wiring/src/lib.rs @@ -5,8 +5,6 @@ pub use config::{AppConfig, WrapUpConfig}; pub enum DbPool { #[cfg(feature = "sqlite")] Sqlite(sqlx::SqlitePool), - #[cfg(feature = "postgres")] - Postgres(sqlx::PgPool), } #[derive(Clone, Copy)] diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 4f5fdacf..bc333407 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -8,7 +8,6 @@ license = "MIT" [features] default = ["sqlite", "sqlite-federation"] sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "infra-wiring/sqlite", "composition/sqlite"] -postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search", "infra-wiring/postgres", "composition/postgres"] nats = ["dep:nats", "infra-wiring/nats", "composition/nats"] # Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working federation = ["application/federation", "presentation/federation"] @@ -20,14 +19,6 @@ sqlite-federation = [ "federation", "composition/sqlite-federation", ] -postgres-federation = [ - "postgres", - "dep:postgres-federation", - "dep:postgres-social", - "dep:activitypub", - "federation", - "composition/postgres-federation", -] [dependencies] axum = { workspace = true } @@ -55,18 +46,13 @@ async-trait = { workspace = true } # Optional — database backends sqlite = { workspace = true, optional = true } -postgres = { workspace = true, optional = true } sqlite-event-queue = { workspace = true, optional = true } -postgres-event-queue = { workspace = true, optional = true } sqlite-search = { workspace = true, optional = true } -postgres-search = { workspace = true, optional = true } # Optional — federation activitypub = { workspace = true, optional = true } sqlite-federation = { workspace = true, optional = true } sqlite-social = { workspace = true, optional = true } -postgres-federation = { workspace = true, optional = true } -postgres-social = { workspace = true, optional = true } [dev-dependencies] bytes = { workspace = true } diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 5d73a0f7..8d14a952 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -16,13 +16,8 @@ use rss::RssAdapter; use domain::ports::{DiaryExporter, DocumentParser, EventPublisher}; use infra_wiring::EventBusBackend; -#[cfg(feature = "postgres")] -use postgres_search; - -#[cfg(not(any(feature = "sqlite", feature = "postgres")))] -compile_error!( - "At least one database backend must be enabled. Use --features sqlite or --features postgres" -); +#[cfg(not(feature = "sqlite"))] +compile_error!("The sqlite feature must be enabled. Use --features sqlite"); #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -53,15 +48,15 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let app_config = AppConfig::from_env(); let instance = domain::value_objects::InstanceIdentity::new(app_config.base_url.clone()); let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; - let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string()); + warn_if_database_backend_set(); let (auth_service, password_hasher) = composition::factory::build_auth_adapters()?; let metadata_client = composition::factory::build_metadata_client()?; let poster_fetcher = composition::factory::build_poster_fetcher()?; let object_storage = composition::factory::build_object_storage()?; - let db = - composition::factory::build_database_adapters(&backend, &database_url, &instance).await?; + let db = composition::factory::build_database_adapters(&database_url, &instance).await?; + #[cfg(feature = "federation")] let ap_content_repo = db.ap_content; let db_pool = db.db_pool; @@ -81,18 +76,10 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { block_query_arc, ) = { let fed_repos = match &db_pool { - #[cfg(feature = "postgres-federation")] - composition::DbPool::Postgres(pool) => { - postgres_federation::wire(pool.clone(), instance.clone()) - } #[cfg(feature = "sqlite-federation")] composition::DbPool::Sqlite(pool) => { sqlite_federation::wire(pool.clone(), instance.clone()) } - #[cfg(not(feature = "sqlite-federation"))] - _ => anyhow::bail!( - "DATABASE_BACKEND={backend} federation is not supported by this build" - ), }; let ep = create_event_publisher(event_bus, &db_pool).await?; @@ -231,12 +218,6 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { composition::DbPool::Sqlite(pool) => { sqlite_social::create_federated_profile_query(pool.clone(), instance.clone()) } - #[cfg(feature = "postgres-federation")] - composition::DbPool::Postgres(pool) => { - postgres_social::create_federated_profile_query(pool.clone(), instance.clone()) - } - #[cfg(not(feature = "sqlite-federation"))] - _ => unreachable!(), } }), #[cfg(not(feature = "federation"))] @@ -291,10 +272,6 @@ async fn create_event_publisher( EventBusBackend::Db => { tracing::info!("event bus: DB queue"); Ok(match db_pool { - #[cfg(feature = "postgres")] - composition::DbPool::Postgres(pool) => { - postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await? - } #[cfg(feature = "sqlite")] composition::DbPool::Sqlite(pool) => { sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await? @@ -311,6 +288,14 @@ async fn create_event_publisher( } } +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"); + } +} + fn init_tracing() { tracing_subscriber::registry() .with(log_filter("server=debug,tower_http=debug")) diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index ce02a2d5..d81add48 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -6,7 +6,6 @@ edition.workspace = true [features] default = ["sqlite", "sqlite-federation"] sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "infra-wiring/sqlite", "composition/sqlite"] -postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search", "infra-wiring/postgres", "composition/postgres"] nats = ["dep:nats", "infra-wiring/nats", "composition/nats"] federation = ["application/federation"] sqlite-federation = [ @@ -16,13 +15,6 @@ sqlite-federation = [ "federation", "composition/sqlite-federation", ] -postgres-federation = [ - "postgres", - "dep:postgres-federation", - "dep:activitypub", - "federation", - "composition/postgres-federation", -] [dependencies] domain = { workspace = true } @@ -49,13 +41,9 @@ async-trait = { workspace = true } # Optional — database backends sqlite = { workspace = true, optional = true } -postgres = { workspace = true, optional = true } sqlite-event-queue = { workspace = true, optional = true } -postgres-event-queue = { workspace = true, optional = true } sqlite-search = { workspace = true, optional = true } -postgres-search = { workspace = true, optional = true } # Optional — federation activitypub = { workspace = true, optional = true } sqlite-federation = { workspace = true, optional = true } -postgres-federation = { workspace = true, optional = true } diff --git a/crates/worker/src/event_bus.rs b/crates/worker/src/event_bus.rs index cc0c49ae..24bfd543 100644 --- a/crates/worker/src/event_bus.rs +++ b/crates/worker/src/event_bus.rs @@ -13,10 +13,6 @@ pub async fn create( EventBusBackend::Db => { tracing::info!("event bus: DB queue"); match db_pool { - #[cfg(feature = "postgres")] - DbPool::Postgres(pool) => Ok( - postgres_event_queue::PostgresEventQueue::create_channel(pool.clone()).await?, - ), #[cfg(feature = "sqlite")] DbPool::Sqlite(pool) => { Ok(sqlite_event_queue::SqliteEventQueue::create_channel(pool.clone()).await?) diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 867b309f..f2505713 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -1,4 +1,5 @@ mod event_bus; +#[cfg(feature = "federation")] mod follow_backfill_handler; use std::sync::Arc; @@ -12,10 +13,8 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use domain::ports::{EventHandler, MovieEnrichmentClient, PeriodicJob, PersonEnrichmentClient}; -#[cfg(not(any(feature = "sqlite", feature = "postgres")))] -compile_error!( - "At least one database backend must be enabled. Use --features sqlite or --features postgres" -); +#[cfg(not(feature = "sqlite"))] +compile_error!("The sqlite feature must be enabled. Use --features sqlite"); #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -23,7 +22,7 @@ async fn main() -> anyhow::Result<()> { init_tracing(); let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; - let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string()); + warn_if_database_backend_set(); let app_config = AppConfig::from_env(); let instance = domain::value_objects::InstanceIdentity::new(app_config.base_url.clone()); @@ -47,8 +46,7 @@ async fn main() -> anyhow::Result<()> { .clone() .map(|c| c as Arc); - let db = - composition::factory::build_database_adapters(&backend, &database_url, &instance).await?; + let db = composition::factory::build_database_adapters(&database_url, &instance).await?; let (event_publisher_arc, consumer_arc) = event_bus::create(&db.db_pool).await?; let worker_services = application::WorkerServices { @@ -88,10 +86,6 @@ async fn main() -> anyhow::Result<()> { composition::DbPool::Sqlite(pool) => { sqlite_federation::wire(pool.clone(), instance.clone()) } - #[cfg(feature = "postgres-federation")] - composition::DbPool::Postgres(pool) => { - postgres_federation::wire(pool.clone(), instance.clone()) - } }; let movie_command = db.movie_command; @@ -103,6 +97,7 @@ async fn main() -> anyhow::Result<()> { let person_query = db.person_query; let search_command = db.search_command; let wrapup_repo = db.wrapup_repo; + #[cfg(feature = "federation")] let remote_goal = db.remote_goal; let refresh_session = db.refresh_session; @@ -306,6 +301,14 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +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"); + } +} + fn init_tracing() { tracing_subscriber::registry() .with(log_filter("worker=info,application=info,k_ap=info")) -- 2.49.1 From 1b7384386a00e3af596bd6cdf9b3c2f14066e151 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 20:37:37 +0100 Subject: [PATCH 004/104] refactor: collapse feature flags to federation+nats --- .github/workflows/ci.yml | 2 +- Cargo.lock | 1 + Dockerfile | 9 +++--- crates/composition/Cargo.toml | 13 ++++----- crates/composition/src/factory.rs | 11 +++++-- crates/composition/src/tests/build.rs | 14 +-------- crates/infra-wiring/Cargo.toml | 1 - crates/infra-wiring/src/lib.rs | 1 - crates/presentation/src/tests/extractors.rs | 1 - crates/server/Cargo.toml | 32 ++++++++++----------- crates/server/src/main.rs | 16 +---------- crates/server/tests/api_test.rs | 2 -- crates/worker/Cargo.toml | 27 +++++++++-------- crates/worker/src/event_bus.rs | 1 - crates/worker/src/main.rs | 14 +-------- deploy.sh | 2 +- docker-compose.yml | 4 +-- 17 files changed, 54 insertions(+), 97 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b423adf..f6dee4da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,4 +83,4 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - build-args: FEATURES=sqlite,sqlite-federation + build-args: FEATURES=federation diff --git a/Cargo.lock b/Cargo.lock index 839c63fc..2aeadf3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1154,6 +1154,7 @@ dependencies = [ "sqlite-social", "sqlx", "tokio", + "tracing", "uuid", ] diff --git a/Dockerfile b/Dockerfile index d136920e..886a2f86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,11 +74,10 @@ COPY crates ./crates # All sqlx queries use the runtime API (no query! macros), so no database # or .sqlx cache is needed at compile time. # -# To build with PostgreSQL backend instead: -# --build-arg FEATURES=postgres,postgres-federation -# To add NATS support (EVENT_BUS_BACKEND=nats): -# --build-arg FEATURES=sqlite,sqlite-federation,nats -ARG FEATURES=sqlite,sqlite-federation +# Features: federation (ActivityPub), nats (EVENT_BUS_BACKEND=nats). +# Build without federation: --build-arg FEATURES= +# To add NATS support: --build-arg FEATURES=federation,nats +ARG FEATURES=federation RUN cargo build --release -p server -p worker --no-default-features --features "${FEATURES}" # ----- runtime ----- diff --git a/crates/composition/Cargo.toml b/crates/composition/Cargo.toml index de444330..0599e0de 100644 --- a/crates/composition/Cargo.toml +++ b/crates/composition/Cargo.toml @@ -5,10 +5,8 @@ edition.workspace = true [features] default = [] -sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "dep:sqlite-social", "infra-wiring/sqlite"] nats = ["dep:nats", "infra-wiring/nats"] -federation = ["application/federation"] -sqlite-federation = ["sqlite", "dep:sqlite-federation", "dep:activitypub", "federation"] +federation = ["application/federation", "dep:sqlite-federation", "dep:activitypub"] [dependencies] domain = { workspace = true } @@ -21,11 +19,12 @@ object-storage = { workspace = true } jellyfin = { workspace = true } plex = { workspace = true } anyhow = { workspace = true } +tracing = { workspace = true } -sqlite = { workspace = true, optional = true } -sqlite-event-queue = { workspace = true, optional = true } -sqlite-search = { workspace = true, optional = true } -sqlite-social = { workspace = true, optional = true } +sqlite = { workspace = true } +sqlite-event-queue = { workspace = true } +sqlite-search = { workspace = true } +sqlite-social = { workspace = true } nats = { workspace = true, optional = true } activitypub = { workspace = true, optional = true } sqlite-federation = { workspace = true, optional = true } diff --git a/crates/composition/src/factory.rs b/crates/composition/src/factory.rs index efe5fa16..34580381 100644 --- a/crates/composition/src/factory.rs +++ b/crates/composition/src/factory.rs @@ -123,9 +123,14 @@ pub fn build_profile_fields_repo( pool: &DbPool, ) -> anyhow::Result> { match pool { - #[cfg(feature = "sqlite")] DbPool::Sqlite(pool) => Ok(sqlite::create_profile_fields_repo(pool.clone())), - #[cfg(not(feature = "sqlite"))] - _ => anyhow::bail!("no profile fields repo for this backend"), + } +} + +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"); } } diff --git a/crates/composition/src/tests/build.rs b/crates/composition/src/tests/build.rs index 6a506570..e0746cf4 100644 --- a/crates/composition/src/tests/build.rs +++ b/crates/composition/src/tests/build.rs @@ -265,11 +265,7 @@ fn test_repositories() -> Repositories { } /// A lazily-connected in-memory sqlite pool — never actually opened in this test, -/// just a value to satisfy `DatabaseOutput::db_pool`. Requires the `sqlite` feature, -/// which `cargo test --workspace` enables transitively (presentation/worker both -/// default to it), matching how `DbPool::Sqlite` is only constructible under that -/// feature everywhere else in the workspace. -#[cfg(feature = "sqlite")] +/// just a value to satisfy `DatabaseOutput::db_pool`. fn test_db_pool() -> infra_wiring::DbPool { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") @@ -277,14 +273,6 @@ fn test_db_pool() -> infra_wiring::DbPool { infra_wiring::DbPool::Sqlite(pool) } -#[cfg(not(feature = "sqlite"))] -fn test_db_pool() -> infra_wiring::DbPool { - unreachable!( - "worker_deps_wiring needs the sqlite feature; cargo test --workspace enables it \ - transitively via presentation/worker's default features" - ) -} - fn test_database_output() -> DatabaseOutput { let movies = InMemoryMovieRepository::new(); let watch_events = InMemoryWatchEventRepository::new(); diff --git a/crates/infra-wiring/Cargo.toml b/crates/infra-wiring/Cargo.toml index fcacb81f..228f8686 100644 --- a/crates/infra-wiring/Cargo.toml +++ b/crates/infra-wiring/Cargo.toml @@ -9,5 +9,4 @@ anyhow = { workspace = true } [features] default = [] -sqlite = [] nats = [] diff --git a/crates/infra-wiring/src/lib.rs b/crates/infra-wiring/src/lib.rs index bb9dc566..b4320c09 100644 --- a/crates/infra-wiring/src/lib.rs +++ b/crates/infra-wiring/src/lib.rs @@ -3,7 +3,6 @@ pub mod config; pub use config::{AppConfig, WrapUpConfig}; pub enum DbPool { - #[cfg(feature = "sqlite")] Sqlite(sqlx::SqlitePool), } diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index 58ae5c3a..d6fc267b 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -495,7 +495,6 @@ impl SearchCommand for Panic { panic!() } } -#[cfg(feature = "federation")] #[async_trait::async_trait] impl domain::ports::RemoteWatchlistRepository for Panic { async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> { diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index bc333407..ca4fe627 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -6,19 +6,18 @@ description = "Self-hosted movie diary with REST API and ActivityPub federation" license = "MIT" [features] -default = ["sqlite", "sqlite-federation"] -sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "infra-wiring/sqlite", "composition/sqlite"] -nats = ["dep:nats", "infra-wiring/nats", "composition/nats"] -# Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working -federation = ["application/federation", "presentation/federation"] -sqlite-federation = [ - "sqlite", - "dep:sqlite-federation", - "dep:sqlite-social", +default = ["federation"] +federation = [ "dep:activitypub", - "federation", - "composition/sqlite-federation", + "dep:sqlite-federation", + "application/federation", + "presentation/federation", + "composition/federation", ] +nats = ["dep:nats", "infra-wiring/nats", "composition/nats"] +# deprecated aliases kept so existing deploy commands keep working +sqlite = [] +sqlite-federation = ["federation"] [dependencies] axum = { workspace = true } @@ -44,15 +43,14 @@ sqlx = { workspace = true } infra-wiring = { workspace = true } async-trait = { workspace = true } -# Optional — database backends -sqlite = { workspace = true, optional = true } -sqlite-event-queue = { workspace = true, optional = true } -sqlite-search = { workspace = true, optional = true } +sqlite = { workspace = true } +sqlite-event-queue = { workspace = true } +sqlite-search = { workspace = true } +sqlite-social = { workspace = true } # Optional — federation -activitypub = { workspace = true, optional = true } +activitypub = { workspace = true, optional = true } sqlite-federation = { workspace = true, optional = true } -sqlite-social = { workspace = true, optional = true } [dev-dependencies] bytes = { workspace = true } diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 8d14a952..72c47a4a 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -16,9 +16,6 @@ use rss::RssAdapter; use domain::ports::{DiaryExporter, DocumentParser, EventPublisher}; use infra_wiring::EventBusBackend; -#[cfg(not(feature = "sqlite"))] -compile_error!("The sqlite feature must be enabled. Use --features sqlite"); - #[tokio::main] async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); @@ -48,7 +45,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let app_config = AppConfig::from_env(); let instance = domain::value_objects::InstanceIdentity::new(app_config.base_url.clone()); let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; - warn_if_database_backend_set(); + composition::factory::warn_if_database_backend_set(); let (auth_service, password_hasher) = composition::factory::build_auth_adapters()?; let metadata_client = composition::factory::build_metadata_client()?; @@ -76,7 +73,6 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { block_query_arc, ) = { let fed_repos = match &db_pool { - #[cfg(feature = "sqlite-federation")] composition::DbPool::Sqlite(pool) => { sqlite_federation::wire(pool.clone(), instance.clone()) } @@ -214,7 +210,6 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { #[cfg(feature = "federation")] federated_profile: Some({ match &db_pool { - #[cfg(feature = "sqlite-federation")] composition::DbPool::Sqlite(pool) => { sqlite_social::create_federated_profile_query(pool.clone(), instance.clone()) } @@ -272,7 +267,6 @@ async fn create_event_publisher( EventBusBackend::Db => { tracing::info!("event bus: DB queue"); Ok(match db_pool { - #[cfg(feature = "sqlite")] composition::DbPool::Sqlite(pool) => { sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await? } @@ -288,14 +282,6 @@ async fn create_event_publisher( } } -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"); - } -} - fn init_tracing() { tracing_subscriber::registry() .with(log_filter("server=debug,tower_http=debug")) diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs index a8698c22..0ff2a4f9 100644 --- a/crates/server/tests/api_test.rs +++ b/crates/server/tests/api_test.rs @@ -362,9 +362,7 @@ impl SearchCommand for PanicSearchCommand { } } -#[cfg(feature = "federation")] struct PanicRemoteWatchlist; -#[cfg(feature = "federation")] #[async_trait::async_trait] impl domain::ports::RemoteWatchlistRepository for PanicRemoteWatchlist { async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> { diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index d81add48..de04f955 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -4,17 +4,17 @@ version = "0.1.0" edition.workspace = true [features] -default = ["sqlite", "sqlite-federation"] -sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "infra-wiring/sqlite", "composition/sqlite"] -nats = ["dep:nats", "infra-wiring/nats", "composition/nats"] -federation = ["application/federation"] -sqlite-federation = [ - "sqlite", - "dep:sqlite-federation", +default = ["federation"] +federation = [ "dep:activitypub", - "federation", - "composition/sqlite-federation", + "dep:sqlite-federation", + "application/federation", + "composition/federation", ] +nats = ["dep:nats", "infra-wiring/nats", "composition/nats"] +# deprecated aliases kept so existing deploy commands keep working +sqlite = [] +sqlite-federation = ["federation"] [dependencies] domain = { workspace = true } @@ -39,11 +39,10 @@ sqlx = { workspace = true } infra-wiring = { workspace = true } async-trait = { workspace = true } -# Optional — database backends -sqlite = { workspace = true, optional = true } -sqlite-event-queue = { workspace = true, optional = true } -sqlite-search = { workspace = true, optional = true } +sqlite = { workspace = true } +sqlite-event-queue = { workspace = true } +sqlite-search = { workspace = true } # Optional — federation -activitypub = { workspace = true, optional = true } +activitypub = { workspace = true, optional = true } sqlite-federation = { workspace = true, optional = true } diff --git a/crates/worker/src/event_bus.rs b/crates/worker/src/event_bus.rs index 24bfd543..556e302d 100644 --- a/crates/worker/src/event_bus.rs +++ b/crates/worker/src/event_bus.rs @@ -13,7 +13,6 @@ pub async fn create( EventBusBackend::Db => { tracing::info!("event bus: DB queue"); match db_pool { - #[cfg(feature = "sqlite")] DbPool::Sqlite(pool) => { Ok(sqlite_event_queue::SqliteEventQueue::create_channel(pool.clone()).await?) } diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index f2505713..b20cea59 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -13,16 +13,13 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use domain::ports::{EventHandler, MovieEnrichmentClient, PeriodicJob, PersonEnrichmentClient}; -#[cfg(not(feature = "sqlite"))] -compile_error!("The sqlite feature must be enabled. Use --features sqlite"); - #[tokio::main] async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); init_tracing(); let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; - warn_if_database_backend_set(); + composition::factory::warn_if_database_backend_set(); let app_config = AppConfig::from_env(); let instance = domain::value_objects::InstanceIdentity::new(app_config.base_url.clone()); @@ -82,7 +79,6 @@ async fn main() -> anyhow::Result<()> { // Wire federation repos early to get remote_watchlist_repo for AppContext. #[cfg(feature = "federation")] let fed_repos = match &db.db_pool { - #[cfg(feature = "sqlite-federation")] composition::DbPool::Sqlite(pool) => { sqlite_federation::wire(pool.clone(), instance.clone()) } @@ -301,14 +297,6 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -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"); - } -} - fn init_tracing() { tracing_subscriber::registry() .with(log_filter("worker=info,application=info,k_ap=info")) diff --git a/deploy.sh b/deploy.sh index 5e6f8ee5..8801669d 100755 --- a/deploy.sh +++ b/deploy.sh @@ -3,7 +3,7 @@ set -euo pipefail REGISTRY="registry.gabrielkaszewski.dev" REPO="movies-diary" -DEFAULT_FEATURES="sqlite,sqlite-federation,nats" +DEFAULT_FEATURES="federation,nats" features="${FEATURES:-$DEFAULT_FEATURES}" tag="latest" diff --git a/docker-compose.yml b/docker-compose.yml index 1c8a4994..c67f2596 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: # build: # context: . # args: - # FEATURES: sqlite,sqlite-federation + # FEATURES: federation ports: - "3000:3000" env_file: .env @@ -18,7 +18,7 @@ services: # build: # context: . # args: - # FEATURES: sqlite,sqlite-federation + # FEATURES: federation entrypoint: ["./worker"] env_file: .env volumes: -- 2.49.1 From 3a05b83320c2385cdaa9cc70ca4599ffafbb627d Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 20:39:47 +0100 Subject: [PATCH 005/104] docs: adr 0012 sqlite only --- CONTRIBUTING.md | 6 +++--- Dockerfile | 5 ----- README.md | 16 ++++++---------- architecture.mmd | 4 ---- crates/presentation/Cargo.toml | 2 +- docs/adr/0006-handlers-call-use-cases-only.md | 4 ++++ ...esentation-names-renderers-not-reachers.md | 4 ++++ ...ion-is-optional-at-the-dependency-level.md | 4 ++++ docs/adr/0012-sqlite-is-the-only-backend.md | 19 +++++++++++++++++++ 9 files changed, 41 insertions(+), 23 deletions(-) create mode 100644 docs/adr/0012-sqlite-is-the-only-backend.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75fb9d1e..2aa9e755 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,14 +51,14 @@ domain → pure types, traits (ports), zero deps application → use cases, orchestration presentation → Axum handlers, routes, OpenAPI worker → event consumer, background jobs -adapters/* → implements domain ports (sqlite, postgres, AP, etc.) +adapters/* → implements domain ports (sqlite, AP, etc.) spa/ → React SPA (TanStack Router + shadcn/ui) ``` ### Adding a new feature 1. **Domain first** — models in `domain/src/models/`, ports in `ports.rs`, events in `events.rs` -2. **Adapters** — implement ports in both `sqlite` and `postgres` adapters, add migration +2. **Adapters** — implement ports in the `sqlite*` adapters, add migration 3. **Application** — use cases in `application/src//`, wire into `context.rs` 4. **API types** — DTOs in `api-types/src/` 5. **Presentation** — handler file in `handlers/.rs`, routes in `routes.rs` @@ -67,7 +67,7 @@ spa/ → React SPA (TanStack Router + shadcn/ui) ### Database adapters -Both SQLite and PostgreSQL are supported. If you add a migration or repository, implement it for both. The postgres adapter uses `$1, $2` params and `TIMESTAMPTZ`; SQLite uses `?` and text datetimes. +SQLite is the only supported backend (see [ADR-0012](docs/adr/0012-sqlite-is-the-only-backend.md)). Add migrations under the sqlite adapter; SQLite uses `?` params and text datetimes. ### Federation (ActivityPub) diff --git a/Dockerfile b/Dockerfile index 886a2f86..aed95f52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,10 +36,6 @@ COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo COPY crates/adapters/sqlite-federation/Cargo.toml crates/adapters/sqlite-federation/Cargo.toml COPY crates/adapters/sqlite-social/Cargo.toml crates/adapters/sqlite-social/Cargo.toml COPY crates/adapters/sqlite-event-queue/Cargo.toml crates/adapters/sqlite-event-queue/Cargo.toml -COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml -COPY crates/adapters/postgres-federation/Cargo.toml crates/adapters/postgres-federation/Cargo.toml -COPY crates/adapters/postgres-social/Cargo.toml crates/adapters/postgres-social/Cargo.toml -COPY crates/adapters/postgres-event-queue/Cargo.toml crates/adapters/postgres-event-queue/Cargo.toml COPY crates/adapters/template-askama/Cargo.toml crates/adapters/template-askama/Cargo.toml COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml COPY crates/application/Cargo.toml crates/application/Cargo.toml @@ -51,7 +47,6 @@ COPY crates/server/Cargo.toml crates/server/Cargo.toml COPY crates/tui/Cargo.toml crates/tui/Cargo.toml COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/Cargo.toml COPY crates/adapters/sqlite-search/Cargo.toml crates/adapters/sqlite-search/Cargo.toml -COPY crates/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml COPY crates/adapters/adapter-common/Cargo.toml crates/adapters/adapter-common/Cargo.toml COPY crates/worker/Cargo.toml crates/worker/Cargo.toml COPY crates/infra-wiring/Cargo.toml crates/infra-wiring/Cargo.toml diff --git a/README.md b/README.md index d09ccdaa..a143277e 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Movies Diary -A self-hosted movie diary built in Rust. Ships a classic server-rendered HTML interface (no JavaScript) alongside a full React SPA, both backed by the same REST API. Federates over ActivityPub so reviews reach the Fediverse. Supports Jellyfin and Plex auto-import, full-text search, annual wrap-ups, goals, and bulk import from Letterboxd, IMDb, and other sources. Runs on SQLite or PostgreSQL. +A self-hosted movie diary built in Rust. Ships a classic server-rendered HTML interface (no JavaScript) alongside a full React SPA, both backed by the same REST API. Federates over ActivityPub so reviews reach the Fediverse. Supports Jellyfin and Plex auto-import, full-text search, annual wrap-ups, goals, and bulk import from Letterboxd, IMDb, and other sources. Runs on SQLite. [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Built with Rust](https://img.shields.io/badge/built_with-Rust-orange.svg?logo=rust)](https://www.rust-lang.org/) [![Docker](https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/) [![ActivityPub](https://img.shields.io/badge/ActivityPub-federated-5b5ea6)](https://activitypub.rocks/) -[![SQLite](https://img.shields.io/badge/database-SQLite%20%7C%20PostgreSQL-003B57?logo=sqlite&logoColor=white)](https://www.sqlite.org/) +[![SQLite](https://img.shields.io/badge/database-SQLite-003B57?logo=sqlite&logoColor=white)](https://www.sqlite.org/) --- @@ -52,7 +52,7 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth - Spoiler Flag — authors mark a review as containing spoilers; the comment is veiled (blur + reveal) for every viewer except the author, withheld from RSS, and federates as `containsSpoilers` + an AP `summary` content warning so Mastodon-style clients also collapse it - Background poster fetching and storage (local filesystem or S3-compatible) - Movie enrichment via TMDb — full cast, crew, genres, keywords, runtime, budget/revenue, ratings; fetched automatically on movie discovery and refreshed every 30 days; exposed via `GET /api/v1/movies/{id}/profile` -- Full-text search across movies and people via `GET /api/v1/search` — free-text query plus structured filters (genre, year, person, department, language); backed by SQLite FTS5 or PostgreSQL tsvector + GIN indexes +- Full-text search across movies and people via `GET /api/v1/search` — free-text query plus structured filters (genre, year, person, department, language); backed by SQLite FTS5 - People as first-class entities — browse by person via `GET /api/v1/people/{id}` and full credit history via `GET /api/v1/people/{id}/credits`; index populated automatically during TMDb enrichment - RSS/Atom feed for public subscription (global and per-user) - JWT authentication via cookie (HTML) or Bearer token (REST API) @@ -95,13 +95,12 @@ infra-wiring — shared infrastructure types (DbPool, EventBusBackend, Ap domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, SocialCommand/SocialQuery, ImageFetcher, RssFeedRenderer), no external deps except serde application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic; modules: auth, diary, goals, import, integrations, movies, person, search, social, users, watchlist, wrapup presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving; library crate, no binary of its own -server — owns the HTTP binary, backend-selection features (sqlite/postgres, federation), boots presentation's router +server — owns the HTTP binary, features (federation, nats), boots presentation's router worker — standalone worker binary (event consumer, poster sync, federation) adapters/ adapter-common — shared row-to-domain conversions, sqlx error mapping, date/uuid parsing utils auth — JWT issuance and validation (Argon2 passwords) sqlite — SQLite repository + connection factory - postgres — PostgreSQL repository + connection factory metadata — TMDB / OMDb HTTP client poster-fetcher — downloads poster images object-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage @@ -116,15 +115,12 @@ adapters/ plex — Plex webhook payload parser (MediaServerParser adapter; requires Plex Pass) event-payload — shared event serialization DTOs (used by all event bus adapters) sqlite-event-queue — durable polling event queue backed by SQLite - postgres-event-queue — durable polling event queue backed by PostgreSQL event-publisher — in-memory event channel (used in tests) nats — NATS Core / JetStream event publisher and consumer event-publisher — in-memory event channel (used in tests) activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals sqlite-search — SQLite FTS5 implementation of SearchPort + SearchCommand - postgres-search — PostgreSQL tsvector + GIN implementation of SearchPort + SearchCommand sqlite-federation — SQLite-backed federation repository - postgres-federation — PostgreSQL-backed federation repository tui — terminal UI client (ratatui); shares api-types with presentation for typed API access spa/ — React SPA (TanStack Router + shadcn/ui + Vite); served at /app/ by the backend ``` @@ -142,7 +138,7 @@ Copy `.env.example` to `.env` and set the values below. Required fields must be | Variable | Default | Required | Description | |---|---|---|---| -| `DATABASE_URL` | `sqlite://movies.db` | Yes | SQLite or PostgreSQL connection string | +| `DATABASE_URL` | `sqlite://movies.db` | Yes | SQLite connection string | | `JWT_SECRET` | — | Yes | Secret for JWT signing — use a long random string | | `OMDB_API_KEY` | — | Yes | [OMDb](https://www.omdbapi.com/apikey.aspx) key for movie metadata | | `TMDB_API_KEY` | — | No | [TMDb](https://www.themoviedb.org/settings/api) key — enables cast, crew, genres, enrichment | @@ -260,7 +256,7 @@ docker run -p 3000:3000 --env-file .env -v movies-diary-data:/data movies-diary docker run --env-file .env -v movies-diary-data:/data --entrypoint ./worker movies-diary ``` -Build for PostgreSQL: `--build-arg FEATURES=postgres,postgres-federation` +Default build enables `federation`; add NATS with `--build-arg FEATURES=federation,nats`. SQLite is the only database backend (see [ADR-0012](docs/adr/0012-sqlite-is-the-only-backend.md)). ## Media Server Integration diff --git a/architecture.mmd b/architecture.mmd index 70a7d5f2..3e6a8c04 100644 --- a/architecture.mmd +++ b/architecture.mmd @@ -86,15 +86,12 @@ graph TB A_COMMON["adapter-common
Shared row conversions,
error mapping, date utils
"] subgraph Storage["Storage"] A_SQLITE["sqlite
SQLite repos"] - A_PG["postgres
PostgreSQL repos"] A_SQLITE_SEARCH["sqlite-search
FTS5"] - A_PG_SEARCH["postgres-search
tsvector/GIN"] A_OBJ["object-storage
S3 / filesystem"] end subgraph Messaging["Messaging"] A_NATS["nats
JetStream / Core"] A_SQLITE_QUEUE["sqlite-event-queue
Polling, dead-letter"] - A_PG_QUEUE["postgres-event-queue
Polling, dead-letter"] A_EVT_PUB["event-publisher
In-memory (tests)"] A_PAYLOAD["event-payload
Serde (de)serialization"] end @@ -107,7 +104,6 @@ graph TB subgraph Federation["Federation (feature-gated)"] A_AP["activitypub
k_ap library"] A_SQLITE_FED["sqlite-federation"] - A_PG_FED["postgres-federation"] end subgraph Media["Media Processing"] A_IMG["image-converter
AVIF/WebP"] diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index 246c415c..8c0aed20 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT" [features] default = ["federation"] -# Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working +# Enables ActivityPub federation (pulls in sqlite-federation + activitypub); keeps all #[cfg(feature = "federation")] gates working federation = ["application/federation"] [dependencies] diff --git a/docs/adr/0006-handlers-call-use-cases-only.md b/docs/adr/0006-handlers-call-use-cases-only.md index 87bc76d3..290ba87d 100644 --- a/docs/adr/0006-handlers-call-use-cases-only.md +++ b/docs/adr/0006-handlers-call-use-cases-only.md @@ -150,3 +150,7 @@ Removing `repos` now would break every one of those 40 sites for no gain this pl it transitively via presentation/worker's default features` — pre-existing, not introduced or fixed by this plan, and invisible to `make check`'s workspace-wide `cargo test`, which enables the feature transitively. + +--- + +Superseded in part by [ADR-0012](0012-sqlite-is-the-only-backend.md): the postgres backends, crates and features mentioned above no longer exist; SQLite is the only backend. diff --git a/docs/adr/0008-presentation-names-renderers-not-reachers.md b/docs/adr/0008-presentation-names-renderers-not-reachers.md index 2c190b3e..572dfae2 100644 --- a/docs/adr/0008-presentation-names-renderers-not-reachers.md +++ b/docs/adr/0008-presentation-names-renderers-not-reachers.md @@ -259,3 +259,7 @@ recorded under Known follow-ups below, which is also pre-existing. established as legitimate — test helpers build a real `Repositories` and call `composition::build_deps`. `check-presentation-adapters` intentionally does not scan that section, and it does not need to: `composition` is not an adapter crate. + +--- + +Superseded in part by [ADR-0012](0012-sqlite-is-the-only-backend.md): the postgres backends, crates and features mentioned above no longer exist; SQLite is the only backend. diff --git a/docs/adr/0009-federation-is-optional-at-the-dependency-level.md b/docs/adr/0009-federation-is-optional-at-the-dependency-level.md index 2eae79e9..f85f5f85 100644 --- a/docs/adr/0009-federation-is-optional-at-the-dependency-level.md +++ b/docs/adr/0009-federation-is-optional-at-the-dependency-level.md @@ -365,3 +365,7 @@ still fails with the same two pre-existing errors. This branch neither fixed nor leaked into `presentation`. ADR-0008's caveat carries over unchanged: the guard assumes directory name equals package name, true for all 28 today but not enforced. - The deferred items in ADR-0005/0006/0007/0008 are otherwise unaffected by this plan. + +--- + +Superseded in part by [ADR-0012](0012-sqlite-is-the-only-backend.md): the postgres backends, crates and features mentioned above no longer exist; SQLite is the only backend. diff --git a/docs/adr/0012-sqlite-is-the-only-backend.md b/docs/adr/0012-sqlite-is-the-only-backend.md new file mode 100644 index 00000000..da50e1ee --- /dev/null +++ b/docs/adr/0012-sqlite-is-the-only-backend.md @@ -0,0 +1,19 @@ +# SQLite is the only database backend + +Movies Diary supports exactly one database: SQLite. The PostgreSQL adapters and the `DATABASE_BACKEND` runtime switch are removed. + +## Context + +The repository carried two adapter families, `sqlite*` and `postgres*`, across five crate pairs (base repositories, social, federation, search, event queue). They were roughly 80% copy-pasted, about 6,000 lines, differing mainly in placeholder syntax (`?` vs `$1`), datetime types and FTS (FTS5 vs tsvector). Every port change had to be made twice. The postgres family had no tests, so it was never verified to work, and the owner has only ever run SQLite. It was dead weight that doubled the maintenance cost of every storage change and the number of feature combinations to build. + +## Decision + +Delete the `postgres*` crates and the backend switch. SQLite is the sole implementation of the storage ports. If a second backend is ever needed, it is added back deliberately, with tests, behind the existing ports rather than kept alive speculatively. + +## Consequences + +- One adapter family to change per port; roughly 6,000 fewer lines and five fewer crates to build and review. +- `DATABASE_BACKEND` is ignored. If it is set to anything other than `sqlite`, startup logs one warning ("DATABASE_BACKEND is ignored; SQLite is the only backend") and continues. +- Cargo features collapse to `federation` (default) and `nats`. `sqlite` and `sqlite-federation` remain as deprecated aliases so existing deploy commands (`FEATURES=sqlite,sqlite-federation,nats`) still build. They may be removed in a future release once deploy scripts no longer pass them. +- Existing PostgreSQL deployments are not supported and have no migration path from this repository. +- ADR-0006, ADR-0008 and ADR-0009 mention postgres features and crates; they carry a note that they are superseded in part by this ADR and are otherwise left as written. -- 2.49.1 From 79cce2a7402c09db4a00730c009db176203bb5c7 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 20:42:08 +0100 Subject: [PATCH 006/104] docs: fix presentation feature comment, dedupe readme --- README.md | 1 - crates/presentation/Cargo.toml | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index a143277e..df522568 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,6 @@ adapters/ sqlite-event-queue — durable polling event queue backed by SQLite event-publisher — in-memory event channel (used in tests) nats — NATS Core / JetStream event publisher and consumer - event-publisher — in-memory event channel (used in tests) activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals sqlite-search — SQLite FTS5 implementation of SearchPort + SearchCommand sqlite-federation — SQLite-backed federation repository diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index 8c0aed20..cc0952f0 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT" [features] default = ["federation"] -# Enables ActivityPub federation (pulls in sqlite-federation + activitypub); keeps all #[cfg(feature = "federation")] gates working +# Enables federation-gated handlers; forwards to application/federation federation = ["application/federation"] [dependencies] -- 2.49.1 From f8fe49f7394da266f0af84f583054e0e731f9e1d Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 20:44:10 +0100 Subject: [PATCH 007/104] test: pin migration ledger --- Cargo.lock | 2 + Cargo.toml | 1 + crates/adapters/sqlite/Cargo.toml | 4 ++ .../tests/fixtures/migration_ledger.txt | 31 ++++++++++ .../sqlite/tests/migration_ledger_test.rs | 61 +++++++++++++++++++ 5 files changed, 99 insertions(+) create mode 100644 crates/adapters/sqlite/tests/fixtures/migration_ledger.txt create mode 100644 crates/adapters/sqlite/tests/migration_ledger_test.rs diff --git a/Cargo.lock b/Cargo.lock index 2aeadf3a..f719e86f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5205,8 +5205,10 @@ dependencies = [ "futures", "serde", "serde_json", + "sqlite-event-queue", "sqlite-social", "sqlx", + "tempfile", "tokio", "tracing", "uuid", diff --git a/Cargo.toml b/Cargo.toml index a718d83e..ca6a2d76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ sqlx = { version = "0.8.6", features = [ "uuid", ] } rand = "0.9" +tempfile = "3" reqwest = { version = "0.13", features = ["json", "query"] } sha2 = "0.10" hex = "0.4" diff --git a/crates/adapters/sqlite/Cargo.toml b/crates/adapters/sqlite/Cargo.toml index b53c5054..d46f49c5 100644 --- a/crates/adapters/sqlite/Cargo.toml +++ b/crates/adapters/sqlite/Cargo.toml @@ -20,3 +20,7 @@ tokio = { workspace = true } futures = { workspace = true } bytes = { workspace = true } async-stream = { workspace = true } + +[dev-dependencies] +sqlite-event-queue = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt new file mode 100644 index 00000000..98d343ed --- /dev/null +++ b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt @@ -0,0 +1,31 @@ +1 8E739CFF6E02FED257C86B7FE409EB07C10BA99475A638B4E19840D82D6841C73420C6C4E1328B5EC29DF785EAC3062F +2 BB37ADE7421737C25370B6332E3A1433CC26752A6362FFB3FC96366FC4BD93EF065E8C0167742F869B0CDBCD58F108FD +3 760C99DCD559ECBD4AD0FC0A9DAEDACA621FD46627C94F2A4F0D5EF9586AB00CBFE17DAFC062CFCDAB8475580E15B461 +4 B91289D7629799BCF618B6034702468E914A29B25410D5C2A76FEBAE306CDFBAE0E257BA646EECB1F2E79B419758C996 +5 15E7EF4EF0209C042DAB96C05C59B07E7B31013F7E66944C4B7668272381E79317E141BBD81AA5FBEBB976EAE967A0AC +6 39E160883C51D281E8DD1BA9EAA6AB10DA96E9F6F8C8FEC29C92A8AEBC51BD83132E3DB2D05603097D2C742EB8C5E41A +7 6B1AC746A418CE21C53F6DA4D6B6785B672F0022F5330D2A120C33932151B20F8F3BD8BB1B2A9F21F801D7091C926A3B +8 F14ED60C9D5921B92A7FBAC1576DF1F548FAB34A28326777C689ED32B6125FAC7E6192771DC67D3F036D299A7F6236E6 +9 69E5AD1B6A5D24AF708C9BEA9F4B3695C60E14041DA2BB06EA445F40A24214B30C5771092FEA7BAC0D3335DA3CA797D0 +10 7BA25975CB09BECAE0DE1F2645F089DE2204645C97A014A7C8C9BB915E6EBB40DF695AE076E2CBB1ECEFB26414A769B9 +11 169AD32E5630D2D58E9EABFF22D70EFF97440D486BCEEC5993043E15A1D1C14D69DB1DC65F90CE069D2FBDF7485ABFAA +12 2356ED23B885EC86F96B2E5C1E2B9E7A580B742BC04F6355E2EBB19FEC58988557C8674A1BAB2E13BA2CFE4A9791AB27 +13 595176135F7B5BF6F2304C985AD51FA4D102CCBA58CAA85A436A1C921869E43DEA9054F90ED3D0DC4CFCD640D52BB465 +14 2871A6B456820EB45CAB85FEDBF9FF9E264D7F7E569CA6A114BE6D1FBFD3A42590650B9823AA00A7ABAE944B4FD8EBF6 +15 CC66FD300934364A20866ADFACDCF4DEC65D7011F159A2C16FD6303F3DDBAB331684189EC4CC4FFC62CE6E85D4A42721 +16 347B77FBF9DDFFB17950AF66C0B9DC485A38D4F9E6BFECAFB0F933036DDF0599381848C7635ADD29F4DCF3055C4ACFBE +17 BF5C1E3591A407CA8368AEE41D6CF7D842FE83310653B43747C12F2639D35C6F1E612FDD6739043A8FE226F0132D1B0D +18 7358CDB2DCBE1BDF1EF629C8A68990971B0A6B5D0FE0AC6672791478B0130C817B184F234572C325DE2565CD9C9C03BD +19 BAF7FA0F56E7757AC2F8D52B563902CB77126803304C3BAE35468F9B48B7F735DB2C8D4351ECDDC856916EBE393E1BD6 +20 875E8181058BBB455C6D2D7E3FEA459DC24D500F5C16746EED46D696BBE3D2D07008FB03A29FA3827BF490496902B3CF +21 745DA33DBB2DD0D2E9121756D2D00D23DAE5307396063E300E52203C40A21FB51DEAD0047C901B8A6248098E685E45F3 +22 947E5F3428691FF26E475825CBFE22976FBBA719C0F4547B7D525DCD042B108464A8A75F1F90392189520E94D7031DDA +23 947A36BFAD46B3068AB91FCC8A04A24D7ABCBCF78340DD3CB4B24134DB7C62550D2C9AD017A95C1FC12D362D93EA70AF +24 B5156F76DE0B4903521B19A3DE798EEB1F93193C21CCA69AC79B6945C78C9400AD1E2A4C94D16481C444D7D2579DF8D9 +25 113BFB1C5D3194CCDE7B6C1EDE6A16D956DECFAC6BB3D5AA96D70F7E460A9AF579025373787FAB8482C9F0BDCD3D5AB8 +26 60A5B8D024712B1F2FA0F31DBB4D4079A1BFE562F77E544F5877D5251B829B30FBDBC4B1F8D36B84CEA488B07F94DF3A +27 1E5069F7240DDA6D2BB8160E6997FF8FD6F6993D0D907AB30CC7DE5B774072B4FC46572CB83C038CA3A2BAA124048C84 +28 1D3FE5DA59EBCBC098CB13E40364872906F4E0CCFF355AF3E2BCD3E0A21D88F0522CD8E4674AA075385965DBF719ACD8 +29 56F4F7B41A09F07211D7543204F0EAC93B7BFB15AFD9BC90DF2654DAE5BE96B1E358B50E2190BF25739C5F0B1FD4B806 +30 DAAA0AF35B4ACB271A9D8068B82F02A72EEC62F3AC07ED504C7A7887976C2C8BACB35FFF477B63A931DF15F79018390D +100 310ADC9ABF0CC2F8E9EDAB33168D009D64228FF5BD8D2A14185513699EBB782606A1EEA8FEE6EC6AF65D72396153F13F diff --git a/crates/adapters/sqlite/tests/migration_ledger_test.rs b/crates/adapters/sqlite/tests/migration_ledger_test.rs new file mode 100644 index 00000000..6c5fbe4b --- /dev/null +++ b/crates/adapters/sqlite/tests/migration_ledger_test.rs @@ -0,0 +1,61 @@ +use std::path::Path; + +use sqlx::{ + SqlitePool, + sqlite::{SqliteConnectOptions, SqlitePoolOptions}, +}; + +const LEDGER: &str = include_str!("fixtures/migration_ledger.txt"); +const PRE_MERGE_DB: &str = "tests/fixtures/pre_merge.db"; + +async fn migrate_with_todays_entry_points(pool: &SqlitePool) { + sqlite::migrate(pool).await.unwrap(); + sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()) + .await + .unwrap(); +} + +async fn ledger(pool: &SqlitePool) -> String { + let rows: Vec<(i64, String)> = + sqlx::query_as("SELECT version, hex(checksum) FROM _sqlx_migrations ORDER BY version") + .fetch_all(pool) + .await + .unwrap(); + rows.iter().map(|(v, c)| format!("{v} {c}\n")).collect() +} + +async fn file_pool(path: &Path, create: bool) -> SqlitePool { + let opts = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(create); + SqlitePoolOptions::new() + .max_connections(1) + .connect_with(opts) + .await + .unwrap() +} + +#[tokio::test] +async fn migrating_a_fresh_database_produces_the_recorded_ledger() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + + migrate_with_todays_entry_points(&pool).await; + + assert_eq!(ledger(&pool).await, LEDGER); +} + +#[tokio::test] +async fn a_database_migrated_by_the_old_layout_migrates_again_cleanly() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pre_merge.db"); + std::fs::copy( + Path::new(env!("CARGO_MANIFEST_DIR")).join(PRE_MERGE_DB), + &path, + ) + .unwrap(); + let pool = file_pool(&path, false).await; + + sqlite::migrate(&pool).await.unwrap(); + + assert_eq!(ledger(&pool).await, LEDGER); +} -- 2.49.1 From 1ece8032ff29d3e7400afd5e4b4535aba6417687 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 20:44:46 +0100 Subject: [PATCH 008/104] test: track pre_merge.db fixture --- .gitignore | 3 ++- .../sqlite/tests/fixtures/pre_merge.db | Bin 0 -> 466944 bytes 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 crates/adapters/sqlite/tests/fixtures/pre_merge.db diff --git a/.gitignore b/.gitignore index 6e4f5223..7538665a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ docs/* !docs/adr/ imgs/ -.sqlx/ \ No newline at end of file +.sqlx/ +!crates/adapters/sqlite/tests/fixtures/*.db diff --git a/crates/adapters/sqlite/tests/fixtures/pre_merge.db b/crates/adapters/sqlite/tests/fixtures/pre_merge.db new file mode 100644 index 0000000000000000000000000000000000000000..50ee3952ff5bef4a3a43ae61240f6a9d74b44a7c GIT binary patch literal 466944 zcmeI*349yXfj@A|hh)o#$T>+&l1YNGh2_MKbC86PIF6E-ICf&oNgR?4V`*$pENPUP zk?jx)YbPl!J)ng@v|Mc|EwoT*3)`iHd%GNkwk$2ja?^$OU!ecOmJ%o}{NI~Hni)ws zcKLh?+MkNYnt5;D@qNdF!8<5jk~;G_bVt6(+D78De$COt zQf%&!-!9AdTwhlg%s)3#LPCpuZ_+q>d>{DU^}X$Tv*NX~SITQk`^#>0A1G2>279YjjKS6M}$#jZy}EukPAYFX1BWF3KQ zotGsaF+#VgPbj!O#BT0t+tkw4%We$zvK^fvwxg%LJ)p%-DuT?VPtjuAW5oHGkV=hZ zv&85Zg=mC}@ri^OA5gNz2+IPm3K5PpF#Xul@cLIWZe~k;E;~mpxhBVfY2$&(n&3g7 zRZtNWMU>*Ept(CGo1ht(Rq%uWZ8g({6TGaDsz-R0cUE1GkcCeVkfjk$7L;UERg5$B z<95v~Sqh^Gk(_97+aeI{DaWC0ilns715?L$p5$Q|#&w9%v}ylQw9N#Y>|E{G+-#yw zP`y$@j2EM{bI^~i$ZStX+m@c79*riO>XEslC3r;{k>m(B$SZ^PMS;`2CaI%t|KLSkO?pX6@Hx|HNZQU8qH!r^m>3!*VQhr1)1=x|!~VT*HDil#NnrDcg~%@l{?AQ&ZzQ*kU$SDra(b>U+7f ziBcg(^XTnw>(eGTdmNx&(FLZ?PxibA3f&&2y4rQi(QhDBjSF@AY$=*B_AJH0A2 zF38pro1I?oN->EfYAhpdQ-*a+Xps~;%j+kZgDB&IuLsM#O8Tm{9AMizLc#UHF6a1= zVlg3}UQ+7kjqs`2E8Dfg(pvUldfj*QdMyH(^b+X-rlrosgov1o8No_IB1?7BO`WAo z4ZKEwgU8LZROgz%40o={_)2BsyDtF5Xqbglc zMM;%Zo~}{Ns@hgvs;H7HkKzH?>!(x{8tnDgm$;c#({owsBHTj#x4XXhI3hRckxRz?F70}LutXaw9J!AhMEgLaK|k_H>c8RESGO(N9;BVRFlY42R) z2opwyaFUjaQ$=zEXcXTZ_Fn(0!lPZ0ofuq`c7)tacXX4@k`}T#IY2hmQ!B}KcJV_6 zzVG{bs{UGaUDevkmn$!=?|a_sylrK_E4!d@_cbB}3?|yq#WZQRk&GNYFY0|Uw^#N|$6 z$&&WgcaGdXqwFnt!qcIJ#c#d-$cp_ROu6+x?z-fqQ$x?bcF*NM`p@nETs7yCdux=d zx$@b+xx?M};pfgd+2d-WX`kFLLI_cG-$c29Q zJ1;->(c&F{{_S1Ae)+nYpL?q-@XV_NXMHnydav-@k?A)-v;Eq)c2|2`4K#;pS?DJZ zy#`tHQB{`oXKfaboKpF0tp1LvRpJ#NUHsI$Utd{y$+@>xJoMt(Bdg`x|F&*h;Epqj zzWD9y?)=s%#a}#l_T=T~%<#Atko1dZB*>MVT!e+VEQSZ^!ZS+-=X~dm$4}W?KYPJ9 z|2*^Xrq%ntb?<9^N6rs*ZXbEFrh5FU#t)wR*^l1)_0YjzUjMltUU1n<%L^_nn%h0y z<6>wHrVmKu);CM46IOh1@~)=K9(!?rbk>V|rvAs)7k_p0&}~nC|MI7O=dbf#`Imi# z-OA_%Hys>*9u~~2SO!K&^X`<6cWIn+r6Iqh|=PA1WJ&?7LS!bd?x<=!=&X{9x8;?U(&~Wb9{apMGN7HShh$-UnXU!5Dpo(ff+l z*RvJ*vU8W*`-642xi9+Z?8h!DzHZaGcina5!0H#yxc$d1i=I2LuctC_<>SovcFw-P zVM)WKuP1Kpo&J-ZQ#~#}E%H=t(L$lfD`r+Iwq{BB% zzwe#U{?Byn`bBa5Ne3f0-oVYiWZ9h0cU4{XbkWRb&)N7GH$`v9qAB#g!+i3_(kE|O zGEjGYd*$JV$DjDaL}u#~k5|rn_O(Xka`%~yRbA{EtFN2=&of_ty!gCJsxRu>Q1<1) z-N8TiO}@NmvLQZsh=30_HfKrp!rfzIC0{rk+!<>)f7R>3YaZCN*|&458vo z2i|+Ke(SQrM@J?O7B#l7xMlIWi?7=7FGm(X7x+@^mN#E`cY}^*_WpsN;Bht6Y$gyKBPXp3MMWjsK)QG5ti@ls-t+SV zgSWgoGkWBiH6I>ma(Capbp8DcPF?ovozg6R!3zV+ikDyf+f(0~)KFa+;osPHlE*ch zW^vNd5?-`_864UMl(e>C-km6yEWI z|DEUmT(a}5U)_^<>-><<W(cP=GyALue>aTpVq+Yl8 zySHy0ueyEbO@}^w?){IhJ$2Is=Wo6GyWg68Pi*$RzrMca5~2KS^S)nj!%eqbwz2b> zH{KoMHt(!7Qm^bIk9LNHh~BQ*hHCGY;Yr;7v)}!%#`dr6cyjjj|6cgmo!ftY+kt)p8BNBOjAJ3NjysdwncgLYO zKl}dB{GGGvuRV8rxmtf;XSnCLZ~W*RZ*KqEp=}QjoK=0({7H8PK0ND+uiRA2y|-aU zxiQO>lg^TQah3&U>XpBqzqRp+C%=5{O&`sDy6U4V)>dv9`sJIwmlmHnQT=rkrn|!qFo}a(`;_Op{ zZ+-s7xy6y4SA28w4G%wZ_$#~PhgZKO?s$3lw~zkl%|HD(GWiDar=hau9c4xh%CyGV)K%d``jMa zVhZjypGncKs6DqN?<%w8vFw#|K6l2JC&vEo3)SCy{ku1v^z7w-c=x@BKYZZouikje zU0d!xFlqkEA3wgesO&QVb^aay_UeqTS-0&hF?xH6nKXG}zz#3WlKPb2|Ec3G_Uht> zn=Zd^XYUWs-{0g~b$oYYABRqQ@_`$qH*RT+zI5lyzuk6c zB>v`){_FQwwOq0Nk<0nMaBq>ZWGu3jI!lp942`28AY z=>y$~pES>3KDqg^Qp8Li2w@d%tzViN% z%TuPUDa5#_ihQ;!!;LLru=nv7ykx~b+qa>>_c!uh{w?3@zE^!O`kwJU;d{*Ypzl84 zoxaT0^3F@tWWPp8{y)999A=yvK|0jRwA0G%n00Izz00bZa0SG_<0uX=z1oBvbzW>Ml ze;zk5ss;fFKmY;|fB*y_009U<00Iyo0=WN2H-G>HAOHafKmY;|fB*y_009W(zX1OH zfBv^IY6t-cKmY;|fB*y_009U<00I!e`+xKS2tWV=5P$##AOHafKmY;|fI$8W;QfF8 zw=rr60SG_<0uX=z1Rwwb2tWV=5WxF?^Z^J!00Izz00bZa0SG_<0uX>e{tMv!fBv^I zY6t-cKmY;|fB*y_009U<00I!e`+xKS2tWV=5P$##AOHafKmY;|fI$8W;QfF8w=rr6 z0SG_<0uX=z1Rwwb2tWV=5WxF?^Z^J!00Izz00bZa0SG_<0uX>e{tMv!fBv^IY6t-c zKmY;|fB*y_009U<00I!e`+xKS2tWV=5P$##AOHafKmY;|fI$8W(D(l?-~IyfA3hL( z00bZa0SG_<0uX=z1Rwwb2z>kkV}-7Yo|Q9KG|$x1ZZtj(^moG|x;dS0=&t#ROR|uN z?prO!6_t;Nh52H9n2(APbAOH^$ire-XyC)DBsavRhNv_k#uqj&T+-0g*syRh+q7)? zqUPm`8tM5FUXIZB|1RG%1>`?`AOHafKmY;|fB*y_009U<00I#BlnK-p^^}=U4UYCi zLBIchwZQl4r>ue~E(9O|0SG_<0uX=z1Rwwb2tWV=Hi7D*m1Vj950uX=z1Rwwb2tWV= z5P$##jza+d{{L|xAqoT_009U<00Izz00bZa0SG|gQ!aq_|DW;(MDZa20SG_<0uX=z z1Rwwb2tWV=$030C|HpxZC=h@E1Rwwb2tWV=5P$##AOL|+xd7h(f65yW#fJa{AOHaf zKmY;|fB*y_009UbhXCIH9|sbmKmY;|fB*y_009U<00Izz00che0+qh^3Z}ZwEU5Z| z?_Vk>SM9E9DnF;rYfJmfZgd|mttt6s$;HKu#U(}07TsF-PlYWc@<#=ykFA~N zVQOkz;~Q1JFDmc}j*rKsWIQY=*6-6>yMisDARB5~(;j54A#9zOB_AFQ-S273cq6sd2I5LUUQ93|1TC>@<4wxg%LT@UBOsw5l1S;LKjML1ryhkN}i z88@?~-c=yRBf_Y%H!7+E$0t=u+vmhsLXuT3Axr&YR8Y7k$AM|%1yemteZA|zQcb}T z?AT>&7LLGd97b&|AgsOSNkx#kv|tvSI3E*C-Rye(q7aR6F+PzH;{%G_NB0fO0UxAMe0qQ^jc~G{B%`WgoS98COP0cDLL?{B&g#t4>-SG`Gb@=~{gw6y zg=^M+P4-VbcGQJh)Jgsc$Bx>hMV&al^CS<$Fs?(4)?V5{Z8O2vHgmOObF;OHvq^Lo zA;ybzd}|+@3ft4swxuViN2AH6`gHDS30_e~Bsszj^2(sS$7X2-TRPqVD@#$qeiFMj zxUQwAJ;c^3Dj$#Va-=rE_KA`fE5P!@q<_d-s-NEn$7$=I|U;iHPg4aKFAILGV5 zEWk!YB@yNKaaxUNDf}yaM;lFK{)|dtKFVoR9vMxJ11F8`snX}8a&uO;#&^W_*;>pI zXtW&lrZbF2WSxk*g6o1^!H(8oH>)*uofz@6ogDe@oh`jU%` zv#xqE2T8j-yM-Pi#`~mE?fPRYpdP3U@-mq~>~Zv5-0U^fP)T zq(K2zN~+ept$otyf$6yYQZy=!&e{BqEqey@LNxtrNobCf1XQEB|C zy^8VHQk}ldPER^Qik^Hg%T7;1{sHtS;&|fCq%A5>l)vn_`041Pld{fF_PhrQ-5#d8 z+I8q`jb9dqMPWoSetB%@#zDh7y{tRtGB75QM2&TzZOX8Y2`!RBr)jf{TA+9Y_^w~&r9pO{6SGHs=tz{3Umt9A%*Hfi84aNm1 zz-p;;F(D!*V@9x&kjUDcbW>+3QvC;`tnpBt5 zx5c&=(~o6oFx}0Rwe~f0y3&<}QI)P|qNGYHPZybHRc(tNRaD89$MMP5W&*hpQv`0G zz{{!FP8L1m2w7s%t7Iw~{i0n{v{p<=3MrVrd`U&2!CrrTiJMt9J(r~}!Y$N)yX%XO zBXW}-xn$h$(yr$ROSGZFk=wCRE+!7pu1N149Q%c~rf`NDjWSGn2058s)3%;KWZqjw z1SKqs37UbuKk7`{|4UvpO0LE##Z7g-F2xKtGVbR`)k_rnUi zrn9p>*wT?YPY)$m`S6fJI`W$K&NYrOVN?hwX{k6>BzJH|@y*WT^{*;C+7;P}!8JLL z6p_akrS#DS{qz4*U9S`rB@1uyJ>)yJ>i3mTS6*5E+445;Zf{N5qS7Ot%RPL_hb4~` z{b$kbg%=cVcD>@-Rq${@ciElve9On(=+^d>1s7jHk%hLVtj&y2OXjWA0JjV$a>+ecC81O#Z@v-SrRplH08|Ih>$zi zaASZCH<6!(@nwF)X~z?Np%J|SPo2nPsA&!U<3VdO&;kd- zb=nf-!kJnxG!7fT>TOMK9L#=2)2=wS!Dn0=8XDN1p0>4Y-Da(M+aiALB9gke=}Q^g z1>lzoPtj6MJvq=5u$4%kll2>Paw-=QNW!YN!lCi>VB<-kd10ghWZ^;{1dxkV zVo<-mrTelv7~##`2zh*F*(G=UVS$tSIW3yG$jS0_U{H#YG7RXCA_A;PNm45zAufy7 z+eCkJt(#dt=cw0Zj;y{$IQ?>~eLrpdoKro_oH?#T`tt&9I?zJW-~6@?l0I(q5&eMP z!c^5}ntlD5>oI|&%~OqTUiI`QaI~R4Lp{nKsZVcUV{0pu9>vnSTBIyd9Br*Pl-7+b zV}PwQ0m$TR>$3sU@A}A%TXW5Ev&ADVqPL4Dec|B3B*gG1-)gh93pxG? zZkI557D8^C1#{Jz1-(y=_Pv@ctz@Q?(NZ>#|HjzSMqzfb^r*3Aq7R0n^=ERwNFF}S zIojQ?UgH5WBhgnR*1c2423hUl!+}Ybeyqo|#+#Q#NssmEF#bTbtP&PygLsW+t4yx0#nJGnVz%QX;ox^chID zFqI!5qu+X~OAD@dQaKr?w?gEGFrHKe`cB2jTMzC_Mg|1(l2A##PqYP-TYDj%6fEJk z;9>2tF)10n+-e{4+R}KvsD*{et4;k;hcjbI76(Ln3m@g<1Ig6oTDwq$$s<>Cn@w-y zi~*RgkM#nsXK5RmWQv-3v<{^$x#?44?O8Y1WZ!4UD^J#^s2;6D*+cDnvuz#99%ghX z+v7=ESX)u8edw6)0rqSKWlN|EQ!NfK?Ila%y_G-Yt zbo$X|Xz~ax^>$u+KAGB^fxrK6d4hqj5P$##AOHafKmY;|fB*y_0D(Lez~BGR(}qR0 zAOHafKmY;|fB*y_009U<00I^Py#Kdg;41_m009U<00Izz00bZa0SG`KPX+M)KTjJL z)q(&7AOHafKmY;|fB*y_009VC1n~aff`P9PfB*y_009U<00Izz00bZafjkw!`~N&` zSX2uF5P$##AOHafKmY;|fB*y_U=hIke+ve_LI45~fB*y_009U<00Izz00i<>0Pp|v zv|&*#2tWV=5P$##AOHafKmY;|fPh5+@Bb|r_zD3CKmY;|fB*y_009U<00I!mQvtmH z&(nrQwIBci2tWV=5P$##AOHafKmY<30lfdWVBjkRAOHafKmY;|fB*y_009UY)^R!`6EeJpW0uX=z1Rwwb2tWV=5P*P10QdhE419$E1Rwwb2tWV=5P$##AOHaf zCA+tEiv?@Bj0mSy2ZFKmY;|fB*y_009U<00Izzz|jTp z@Bbg&2cZyv00bZa0SG_<0uX=z1Rwx`d=S9>e?GJ#>Hq-`?`AOHafKmY;|fB*y_009U<00I!mBZ2awYS*wJE20!H#=rlUM-7Xr zKmY;|fB*y_009U<00Izz00eXa{QH0C2@rq)1Rwwb2tWV=5P$##AOL~?g#z^b|CGX^ zf}&*M%Y{R}i+ya>)m5id-dcHP`QOX$EFbaCDf@Zp-KE2xBc8k5d)y60?-y(;xQzUd zem-z5*M6Iu0|h}3!!WKxoXYn_g#n3=D%z&4wJX>X3bLV=HSIxGJH*y`S@IDhbZdM< z!R;Y-b64A@mablQW3ZR)=nSzPJ?-rQEpAc~WX?Q6i))6nUBPw1u3$%Nu$!f^lsYlu zXFEICwZZlv$+xwoyR~I)P)}x`z{_TiY+FYtxIWnB$Vuhp0YT-$QZlaQh(asDsrwQF zP0C)FwZU~QJ?$a3HYN>=f>Ntz8HZG0S`u%!xJsoXZdV+O^Nr0ub@_YR&*SeW4^{xUj9uY>By-`sWI6kRL z+CDcT^9e4Q;A9~z$q|KXava!jV1BEIsjqi^c9PbDj$p^`dRwbH0yEoJS>R!snp|TO zw4`+#u3uHSh!j@DbA`+!3vC$~oYqrnNjs<7$I@{Eta2{9roD5Ge{AA>J=acWu3;&z z3USqvt8t_$XRbzs+_{Du18lg7{469t&E#hh`B_YUmXM#N~B(Szl5eCFafK>%K7GM(taVcOOKh|KspWW6L+R)h(V!JxGwXHqa z)Z$@kYFwYI(Oa5V!-JeKOhl9s%lDuye``o)1E&U+F(51r;uv4Hk(@fsK8j9cNNZ-P zsliN8OV5&wF6)>Yx`Wm!MitelHlocmwBNRZWtl`$k&W3zk0OkcW{HzoA||MOgjad3 zfHW;VT#=G;I3u$xd1>nA%#q3~OGHKG)ufV&VSKSgBr~EhIVFU6M2rt)o1IcA*iQBO zSFCn3o&KDYi_kYHNkg0}4GD2Nx7haAj(4uoI{m?3y~Ei;?QvV}oz50BYq!(NdA!}t zR;d%Za%eT<26<(W3fa=pn=~Pl89yQzmzOLNqI{nawejjF+Aa>+QjYS9%8_BCUq5Kd zUjMQ)-OQ%iT+KmddOoa*!=g&AL0pqSt2+pmRe(E)KEK@T!3tqJE0C0=VOBXblq;4k0!GQBqW8jrxZ_J^vu$E{XNUw zjM$pXlr)`8uCQb^pv@*xk#r|A*UM(#$Q5|zc=s|7)7t7f*kE>*Tv3mb#Y&s`Q6d`l zB!;=pJLa{7baF}NlCn0eY)zqC>emKiV$TvT3sHer1WsEPW?v3woyloZMo*U%GSeGH zNFU}Sk@Tc(%h>C0Io-|lEXdVZnN74X(3jGwv0!z@_fI#{ny$@IE! z?HL(oEVs2|MsRxM7*m{9YsqB!~fwj zIo71z4;eQtmQ3k_&at~FXCY|h+|GSbDLka#u1Jm64yAWoONYuh&}`2#Bm9^h3jh9} z`S1e2ApijgKmY;|fB*y_009U<00Q|dfcyXaZClg~0uX=z1Rwwb2tWV=5P$##AYcmM z-~Try@EZaUfB*y_009U<00Izz00bbAUjoj5|G(g*{Hh;n0|5v?00Izz00bZa0SG_< z0uX?JU4Xv-$Nj(E1bYyG00bZa0SG_<0uX=z1Rwx`d=tR^f4;RS>H`4?KmY;|fB*y_ z009U<00I!m6u`g#muZ3{5P$##AOHafKmY;|fB*y_0D*iH!2ADvYf;n(0uX=z1Rwwb z2tWV=5P$##Ado45_y3tDI069(KmY;|fB*y_009U<00I!mHvzo=&$kvueINh<2tWV= z5P$##AOHafKmY=n0(k$QX@VmVfB*y_009U<00Izz00bZafqWCd`~Q4vQPc+l5P$## zAOHafKmY;|fB*y_kST!o|CuH@0s#m>00Izz00bZa0SG_<0uabI0lfduw-!ZxAOHaf zKmY;|fB*y_009U<00Nl;c>kYif+G-s00bZa0SG_<0uX=z1Rwx`d=tR?|9opv)CU3( zfB*y_009U<00Izz00bbADS-F?nI<>_0SG_<0uX=z1Rwwb2tWV=5Xd(Hy#LR)7Das^ z009U<00Izz00bZa0SG_<0+|AM|DS1sBM^W91Rwwb2tWV=5P$##AOL}U6Ttibd}~qE z2Lcd)00bZa0SG_<0uX=z1R#(pfcyVU6C8m61Rwwb2tWV=5P$##AOHafr0Ry9>V zQn}srJy&bNzg65*5g?~`*-iF_JsTAy&gM9U?d)Lfr`6GvI5FZskMl513tVG0=`49QJQx)fRnIN8C&X1* z6qHD2hN&ntQ7y035#uB?y8z2aB0_}YRd#K#yVW1r?O~d$U1Oe9?g>FwBvMl~7U?79 zGj_unnL6UJG+W~sTc@3@@6kH;?eZ{-NiMF8T!c|okmG#RII}NjHfBUyHs(Q^$5_c$ z57Sof8k?8OM2saQS>+T#QAAoLVKgDifn2djpB$yN|UqiOEECFntR%$Uq z%bxzUb%cWJgI(>c7y`jX?jBHzJ#Ek8VYU?(Qjra z(PJg}eNl-|=a4;?qVj4|Nf*Hxi_Bi@T6)?;Y;8h_N5uF*t)4osszNNGrp`2rZk8c+ zUZWmAEDOA<&xcxOIrFJgRJotb8$w-eO>aX@tf3|ns@br-X4CST?j5xOwl*$})cXB; zs&O(gky%D73Y}^{o>Zeexg-d(EXleHP1Wlk+tu!28X8>VF|EaAp`T1~gQ-c+xxdBM z_Ri3B2hfUaX>h$_<`krbnx=Y?c7fDuY6h_S{Gx}ar$~DwJ=D?;wzR$eRUK}oyTMhU z4N0Tl^GQ|G_Bm$-xP{Im-K#d8Py$WP1iHq1&hjudHLioEa7-fObU@;xiY%nRcRFO# zA%?0xYnpx93G5gqd{~uaE-6RT-O^;$#pq;Yrp1!ghdxB@aa3L&5L7NKCF5$YIN_u$ z(@8>))<49wAcb|JMFgCv;`T>sU1sCl{wp!?Fz0V z7tM~=U^h$8QR>KQg>J-P*D?sJCjr5D{cDBkA2MqghkNjotv8ifdk^ z*K~Ha2U|K)Jt-AC)YTKT#MiEt?pp z=jwJNGMPt{2{QACC7HBXljFdK1M^!wbXE0PeTX}P9lPsot?CHO*1n{rPo#;B=z4w0 zqK4d#M9A>g`-_cSk3tqRmIw~yR4SQEBXizQwS!|_Bn#TD&>nJYr{ztvHgcp33NA+O zk@x{xu5^g4wT+O>x#dI}JS@c$Q6atFqIr?)z?!372eiqG)7F->vFYuEv9}I?{@?!e z0(%gE00bZa0SG_<0uX=z1Rwx`d=$W+|If!(MV%l30SG_<0uX=z1Rwwb2tWV=b^*Nq zw|ig@0uX=z1Rwwb2tWV=5P$##Adrs&c>kY|t%^E900Izz00bZa0SG_<0uX=z1ndI1 z|F?T!4+0Q?00bZa0SG_<0uX=z1R#))0{HX)`Pizc69gat0SG_<0uX=z1Rwwb2tdFt zfct;D2lgNU0SG_<0uX=z1Rwwb2tWV=`6z%t|DTVoiaJ360uX=z1Rwwb2tWV=5P$## z>;ky|w|ig@0uX=z1Rwwb2tWV=5P$##Adrs&xc|?`Rz;m4009U<00Izz00bZa0SG_< z0(Jr1|JyyV2LT8`00Izz00bZa0SG_<0uabY0o?!RW2>T05P$##AOHafKmY;|fB*y_ z00FxI?*Hu`*nCsszug0S5P$##AOHafKmY;|fB*y_0D*iI!2N$dwkqlb0SG_<0uX=z1Rwwb z2tWV=5U>m2{@?C_JqSPm0uX=z1Rwwb2tWV=5P(2F3gG@fA6pf5f&c^{009U<00Izz z00bZa0SMRyaQ|=jz#ar3009U<00Izz00bZa0SG`K9|ds#pO3AIIza#e5P$##AOHaf zKmY;|fB*#S0=WOTdteU&5P$##AOHafKmY;|fB*y_kdFem|If!(MV%l30SG_<0uX=z z1Rwwb2tWV=b^+Y~+dZ%c0SG_<0uX=z1Rwwb2tWV=5XeV?N>Zun!ix%gpZAql9jGd) z{A%UAirXvN%bzPBDWBlIwCw$|fzr22&-eV@6LbH`{Vn&xl7A^#QhZbKf}(nI8a@#C zm;}aV#XZcNIi=%eD&H3s5`vV73S7Ucq`$Rx1zSQvc57Q#sHdf!4YjOk53=cCwx_$T zV?9ewSzPC3$tNMmiWKL>2;DJ1JsoWwYlGW^YXe%GI3E-AZ$oiuB+m6qG8YjNysXBA zxXPwtd;R0Cm{!08omv57(y%Bfw1DYvSqqpB&M2T3WR@?Lm0lcGRHH`8BBCsWRY}$Z zq+vlOS&!&@143LD6r%>hz7a`|7<*w}Q8`*Yb6*xltoyPM6?jG9_6fY4uBV<-l#dT2 z`2j&gOBKxPKR4=O7SD5y`Neod7!@OvXS;r(*;07c)NB!*sZpP2J22#1{*-!}WTP!2_bOf5LwpVAmo!9SS8tPqR4XNZt zWIn+q$$un4^E(-BbQnc5k~SmI$TV?t-#CFa^9it|6U!EhD^C|d=o^%zAx`USiry-= zLxY*Q+rqU|w2WYwRKb^ z7Y}U|lHrjNK&E_|T>ONLLS@9%k}%72l%y;p{yfgZG%c`B^XU@N8m0G)Vni~Ek&Z%7 zNOeQ4QpQOpM1bWZWZaSAz^)B;xB4TyJxp`8Ys`~k(N_@)r^X_E1lQONXJqP#$I@(# zV{Dyvvc5;s@b~Q^*HMy-DCm<<;=#6Xv@YtNb`_(>ODPO6MeZcWULajwSfH) zpOKqATswtkXg|!>(Pf6e|7j=U_<(>dt}jeKG|Jj~KL zt}&k@BQmp%aJrkGvvwdyHkQcgZ0Ny^wu<%U$d0bBY}t_myK`lij%>+}9L&gW?{?B` zWKvGojtm<<9+#5wFj?71eS64S(0XWBMwZr4;{=+i^)OqfC!}3t&)eoh?9X5{RMU`|LhcSlg_TInAv+3I21NcQub*=JeS8L5~dXXh$`8Nsor zbDYjv7j@_Lc{r@MpaZoe}KmY;|fB*y_009U<00Izzz{ezj`~Qy# z63IXS0uX=z1Rwwb2tWV=5P$##KEVRG|NjIx4vGc=2tWV=5P$##AOHafKmY;|_?QH6 z|Nk*TA{huk00Izz00bZa0SG_<0uX?}Cs+XY|DWK-LD3)p0SG_<0uX=z1Rwwb2tWV= zACmy?|34;3Bm)5mKmY;|fB*y_009U<00I#B1PkE){}bFeC>jJH009U<00Izz00bZa L0SG|gV-ooPJ$y11 literal 0 HcmV?d00001 -- 2.49.1 From 67597ec9a6aad54fdfb68302e24b05e5467b0e34 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 20:48:30 +0100 Subject: [PATCH 009/104] refactor: merge sqlite adapter family into one crate --- .gitea/workflows/ci.yml | 2 +- .github/workflows/ci.yml | 2 +- Cargo.lock | 66 +------------ Cargo.toml | 8 -- Dockerfile | 4 - Makefile | 14 +-- README.md | 5 +- crates/adapters/adapter-common/Cargo.toml | 11 --- crates/adapters/sqlite-event-queue/Cargo.toml | 15 --- .../sqlite-event-queue/src/migrations.rs | 7 -- crates/adapters/sqlite-federation/Cargo.toml | 3 +- .../sqlite-federation/src/activity.rs | 2 +- .../adapters/sqlite-federation/src/actor.rs | 2 +- .../sqlite-federation/src/blocklist.rs | 2 +- .../sqlite-federation/src/follow/followers.rs | 2 +- .../sqlite-federation/src/follow/following.rs | 2 +- crates/adapters/sqlite-federation/src/lib.rs | 2 +- .../adapters/sqlite-federation/src/review.rs | 2 +- crates/adapters/sqlite-search/Cargo.toml | 14 --- crates/adapters/sqlite-social/Cargo.toml | 15 --- crates/adapters/sqlite/Cargo.toml | 6 +- .../migrations/0100_event_queue.sql | 0 crates/adapters/sqlite/src/diary.rs | 32 +++---- .../lib.rs => sqlite/src/event_queue/mod.rs} | 2 - .../src => sqlite/src/event_queue}/payload.rs | 0 crates/adapters/sqlite/src/goals.rs | 10 +- crates/adapters/sqlite/src/import_profile.rs | 8 +- crates/adapters/sqlite/src/import_session.rs | 12 +-- crates/adapters/sqlite/src/insights.rs | 20 ++-- crates/adapters/sqlite/src/lib.rs | 21 +++- crates/adapters/sqlite/src/models.rs | 2 +- crates/adapters/sqlite/src/movie.rs | 20 ++-- crates/adapters/sqlite/src/movie_dedup.rs | 32 +++---- crates/adapters/sqlite/src/persons.rs | 22 ++--- crates/adapters/sqlite/src/profile.rs | 34 +++---- crates/adapters/sqlite/src/profile_fields.rs | 6 +- .../adapters/sqlite/src/refresh_sessions.rs | 10 +- crates/adapters/sqlite/src/review.rs | 12 +-- .../src/lib.rs => sqlite/src/search.rs} | 31 +++--- .../lib.rs => sqlite/src/search/tests.rs} | 2 +- .../src/lib.rs => sqlite/src/shared.rs} | 25 ++--- .../src => sqlite/src/social}/ap_content.rs | 10 +- .../src/social}/federated_profile.rs | 0 .../src/social}/follow_repository.rs | 2 +- .../src/social/local_social.rs} | 0 .../src/lib.rs => sqlite/src/social/mod.rs} | 2 +- .../src => sqlite/src/social}/remote_goals.rs | 10 +- .../social}/tests/follow_relation_tests.rs | 0 .../src => sqlite/src/social}/watchlist.rs | 0 crates/adapters/sqlite/src/stats.rs | 12 +-- crates/adapters/sqlite/src/user_settings.rs | 6 +- crates/adapters/sqlite/src/users.rs | 10 +- crates/adapters/sqlite/src/watch_event.rs | 64 ++++++------- crates/adapters/sqlite/src/watchlist.rs | 14 +-- crates/adapters/sqlite/src/wrapup.rs | 96 +++++++++---------- .../sqlite/tests/migration_ledger_test.rs | 3 - crates/composition/Cargo.toml | 3 - crates/composition/src/factory.rs | 4 +- crates/server/Cargo.toml | 3 - crates/server/src/main.rs | 4 +- crates/worker/Cargo.toml | 2 - crates/worker/src/event_bus.rs | 2 +- 62 files changed, 292 insertions(+), 442 deletions(-) delete mode 100644 crates/adapters/adapter-common/Cargo.toml delete mode 100644 crates/adapters/sqlite-event-queue/Cargo.toml delete mode 100644 crates/adapters/sqlite-event-queue/src/migrations.rs delete mode 100644 crates/adapters/sqlite-search/Cargo.toml delete mode 100644 crates/adapters/sqlite-social/Cargo.toml rename crates/adapters/{sqlite-event-queue => sqlite}/migrations/0100_event_queue.sql (100%) rename crates/adapters/{sqlite-event-queue/src/lib.rs => sqlite/src/event_queue/mod.rs} (99%) rename crates/adapters/{sqlite-event-queue/src => sqlite/src/event_queue}/payload.rs (100%) rename crates/adapters/{sqlite-search/src/lib.rs => sqlite/src/search.rs} (93%) rename crates/adapters/{sqlite-search/src/tests/lib.rs => sqlite/src/search/tests.rs} (98%) rename crates/adapters/{adapter-common/src/lib.rs => sqlite/src/shared.rs} (89%) rename crates/adapters/{sqlite-social/src => sqlite/src/social}/ap_content.rs (97%) rename crates/adapters/{sqlite-social/src => sqlite/src/social}/federated_profile.rs (100%) rename crates/adapters/{sqlite-social/src => sqlite/src/social}/follow_repository.rs (99%) rename crates/adapters/{sqlite-social/src/social.rs => sqlite/src/social/local_social.rs} (100%) rename crates/adapters/{sqlite-social/src/lib.rs => sqlite/src/social/mod.rs} (97%) rename crates/adapters/{sqlite-social/src => sqlite/src/social}/remote_goals.rs (92%) rename crates/adapters/{sqlite-social/src => sqlite/src/social}/tests/follow_relation_tests.rs (100%) rename crates/adapters/{sqlite-social/src => sqlite/src/social}/watchlist.rs (100%) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index dade483e..96fdd1f0 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -44,4 +44,4 @@ jobs: # Layering guards. These live in the Makefile and were previously local-only, # so a PR could go green in CI while violating them. - name: guards - run: make check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free + run: make check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-sqlite-is-ap-free diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6dee4da..91f1eef6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: # Layering guards. These live in the Makefile and were previously local-only, # so a PR could go green in CI while violating them. - name: guards - run: make check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free + run: make check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-sqlite-is-ap-free docker: name: Build & Push Docker Image diff --git a/Cargo.lock b/Cargo.lock index f719e86f..378715aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,17 +220,6 @@ dependencies = [ "url", ] -[[package]] -name = "adapter-common" -version = "0.1.0" -dependencies = [ - "chrono", - "domain", - "sqlx", - "tracing", - "uuid", -] - [[package]] name = "adler2" version = "2.0.1" @@ -1148,10 +1137,7 @@ dependencies = [ "plex", "poster-fetcher", "sqlite", - "sqlite-event-queue", "sqlite-federation", - "sqlite-search", - "sqlite-social", "sqlx", "tokio", "tracing", @@ -4950,10 +4936,7 @@ dependencies = [ "rss 0.1.0", "serde_json", "sqlite", - "sqlite-event-queue", "sqlite-federation", - "sqlite-search", - "sqlite-social", "sqlx", "tokio", "tower", @@ -5195,18 +5178,16 @@ dependencies = [ name = "sqlite" version = "0.1.0" dependencies = [ - "adapter-common", "anyhow", "async-stream", "async-trait", "bytes", "chrono", "domain", + "event-payload", "futures", "serde", "serde_json", - "sqlite-event-queue", - "sqlite-social", "sqlx", "tempfile", "tokio", @@ -5214,65 +5195,24 @@ dependencies = [ "uuid", ] -[[package]] -name = "sqlite-event-queue" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "domain", - "event-payload", - "futures", - "serde_json", - "sqlx", - "tokio", - "tracing", -] - [[package]] name = "sqlite-federation" version = "0.1.0" dependencies = [ "activitypub", - "adapter-common", "anyhow", "async-trait", "chrono", "domain", "k-ap", "serde_json", - "sqlite-social", + "sqlite", "sqlx", "tokio", "tracing", "uuid", ] -[[package]] -name = "sqlite-search" -version = "0.1.0" -dependencies = [ - "adapter-common", - "async-trait", - "domain", - "sqlx", - "tokio", - "uuid", -] - -[[package]] -name = "sqlite-social" -version = "0.1.0" -dependencies = [ - "adapter-common", - "async-trait", - "chrono", - "domain", - "sqlx", - "tokio", - "uuid", -] - [[package]] name = "sqlx" version = "0.8.6" @@ -7206,9 +7146,7 @@ dependencies = [ "poster-fetcher", "poster-sync", "sqlite", - "sqlite-event-queue", "sqlite-federation", - "sqlite-search", "sqlx", "tmdb-enrichment", "tokio", diff --git a/Cargo.toml b/Cargo.toml index ca6a2d76..e38ee522 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,6 @@ members = [ "crates/adapters/rss", "crates/adapters/sqlite", "crates/adapters/sqlite-federation", - "crates/adapters/sqlite-social", - "crates/adapters/sqlite-event-queue", "crates/adapters/template-askama", "crates/adapters/activitypub", "crates/adapters/export", @@ -29,8 +27,6 @@ members = [ "crates/adapters/importer", "crates/adapters/jellyfin", "crates/adapters/plex", - "crates/adapters/sqlite-search", - "crates/adapters/adapter-common", "crates/infra-wiring", ] resolver = "2" @@ -84,18 +80,14 @@ rss = { path = "crates/adapters/rss" } export = { path = "crates/adapters/export" } sqlite = { path = "crates/adapters/sqlite" } sqlite-federation = { path = "crates/adapters/sqlite-federation" } -sqlite-social = { path = "crates/adapters/sqlite-social" } template-askama = { path = "crates/adapters/template-askama" } activitypub = { path = "crates/adapters/activitypub" } event-payload = { path = "crates/adapters/event-payload" } nats = { path = "crates/adapters/nats" } -sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" } importer = { path = "crates/adapters/importer" } jellyfin = { path = "crates/adapters/jellyfin" } plex = { path = "crates/adapters/plex" } image-converter = { path = "crates/adapters/image-converter" } -sqlite-search = { path = "crates/adapters/sqlite-search" } -adapter-common = { path = "crates/adapters/adapter-common" } infra-wiring = { path = "crates/infra-wiring" } [profile.dev] diff --git a/Dockerfile b/Dockerfile index aed95f52..1b4c67bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,8 +34,6 @@ COPY crates/adapters/plex/Cargo.toml crates/adapters/plex/Cargo.tom COPY crates/adapters/rss/Cargo.toml crates/adapters/rss/Cargo.toml COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml COPY crates/adapters/sqlite-federation/Cargo.toml crates/adapters/sqlite-federation/Cargo.toml -COPY crates/adapters/sqlite-social/Cargo.toml crates/adapters/sqlite-social/Cargo.toml -COPY crates/adapters/sqlite-event-queue/Cargo.toml crates/adapters/sqlite-event-queue/Cargo.toml COPY crates/adapters/template-askama/Cargo.toml crates/adapters/template-askama/Cargo.toml COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml COPY crates/application/Cargo.toml crates/application/Cargo.toml @@ -46,8 +44,6 @@ COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml COPY crates/server/Cargo.toml crates/server/Cargo.toml COPY crates/tui/Cargo.toml crates/tui/Cargo.toml COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/Cargo.toml -COPY crates/adapters/sqlite-search/Cargo.toml crates/adapters/sqlite-search/Cargo.toml -COPY crates/adapters/adapter-common/Cargo.toml crates/adapters/adapter-common/Cargo.toml COPY crates/worker/Cargo.toml crates/worker/Cargo.toml COPY crates/infra-wiring/Cargo.toml crates/infra-wiring/Cargo.toml diff --git a/Makefile b/Makefile index caef6984..82b878ba 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := check -check: fmt-check clippy test check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free +check: fmt-check clippy test check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-sqlite-is-ap-free @echo "✅ All checks passed" check-appcontext: @@ -59,13 +59,13 @@ check-federation-off-social: @echo "✅ server wires real social behavior in every configuration" -check-social-crates-are-ap-free: - @if grep -nE '^[[:space:]]*(k-ap|activitypub[A-Za-z0-9_-]*|[A-Za-z0-9_-]+-federation)[[:space:]]*=' crates/adapters/*-social/Cargo.toml | grep -q .; then \ - echo "❌ social crate depends on ActivityPub:"; \ - grep -nE '^[[:space:]]*(k-ap|activitypub[A-Za-z0-9_-]*|[A-Za-z0-9_-]+-federation)[[:space:]]*=' crates/adapters/*-social/Cargo.toml; \ +check-sqlite-is-ap-free: + @if grep -nE '^[[:space:]]*(k-ap|activitypub[A-Za-z0-9_-]*|[A-Za-z0-9_-]+-federation)[[:space:]]*=' crates/adapters/sqlite/Cargo.toml | grep -q .; then \ + echo "❌ sqlite crate depends on ActivityPub:"; \ + grep -nE '^[[:space:]]*(k-ap|activitypub[A-Za-z0-9_-]*|[A-Za-z0-9_-]+-federation)[[:space:]]*=' crates/adapters/sqlite/Cargo.toml; \ exit 1; \ fi - @echo "✅ social crates depend on no k-ap, activitypub, or *-federation crate" + @echo "✅ sqlite crate depends on no k-ap, activitypub, or *-federation crate" fmt: cargo fmt @@ -106,4 +106,4 @@ dev-spa: spa-dev: @npm --prefix spa run dev -.PHONY: check fmt fmt-check clippy test fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free +.PHONY: check fmt fmt-check clippy test fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-sqlite-is-ap-free diff --git a/README.md b/README.md index df522568..9a316e2c 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,8 @@ presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Sc server — owns the HTTP binary, features (federation, nats), boots presentation's router worker — standalone worker binary (event consumer, poster sync, federation) adapters/ - adapter-common — shared row-to-domain conversions, sqlx error mapping, date/uuid parsing utils auth — JWT issuance and validation (Argon2 passwords) - sqlite — SQLite repository + connection factory + sqlite — SQLite repositories, search (FTS5), social graph, durable event queue, connection factory + migrations metadata — TMDB / OMDb HTTP client poster-fetcher — downloads poster images object-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage @@ -114,11 +113,9 @@ adapters/ jellyfin — Jellyfin webhook payload parser (MediaServerParser adapter) plex — Plex webhook payload parser (MediaServerParser adapter; requires Plex Pass) event-payload — shared event serialization DTOs (used by all event bus adapters) - sqlite-event-queue — durable polling event queue backed by SQLite event-publisher — in-memory event channel (used in tests) nats — NATS Core / JetStream event publisher and consumer activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals - sqlite-search — SQLite FTS5 implementation of SearchPort + SearchCommand sqlite-federation — SQLite-backed federation repository tui — terminal UI client (ratatui); shares api-types with presentation for typed API access spa/ — React SPA (TanStack Router + shadcn/ui + Vite); served at /app/ by the backend diff --git a/crates/adapters/adapter-common/Cargo.toml b/crates/adapters/adapter-common/Cargo.toml deleted file mode 100644 index fe6f793c..00000000 --- a/crates/adapters/adapter-common/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "adapter-common" -version = "0.1.0" -edition.workspace = true - -[dependencies] -domain = { workspace = true } -sqlx = { workspace = true } -tracing = { workspace = true } -chrono = { workspace = true } -uuid = { workspace = true } diff --git a/crates/adapters/sqlite-event-queue/Cargo.toml b/crates/adapters/sqlite-event-queue/Cargo.toml deleted file mode 100644 index df685810..00000000 --- a/crates/adapters/sqlite-event-queue/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "sqlite-event-queue" -version = "0.1.0" -edition.workspace = true - -[dependencies] -sqlx = { workspace = true, features = ["macros", "chrono"] } -domain = { workspace = true } -event-payload = { workspace = true } -anyhow = { workspace = true } -async-trait = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true } -futures = { workspace = true } -tracing = { workspace = true } diff --git a/crates/adapters/sqlite-event-queue/src/migrations.rs b/crates/adapters/sqlite-event-queue/src/migrations.rs deleted file mode 100644 index 56c89219..00000000 --- a/crates/adapters/sqlite-event-queue/src/migrations.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub(crate) async fn run(pool: &sqlx::SqlitePool) -> anyhow::Result<()> { - sqlx::migrate!("./migrations") - .set_ignore_missing(true) - .run(pool) - .await - .map_err(|e| anyhow::anyhow!("sqlite-event-queue migration failed: {e}")) -} diff --git a/crates/adapters/sqlite-federation/Cargo.toml b/crates/adapters/sqlite-federation/Cargo.toml index 573f19ad..0f7549f1 100644 --- a/crates/adapters/sqlite-federation/Cargo.toml +++ b/crates/adapters/sqlite-federation/Cargo.toml @@ -6,9 +6,8 @@ edition.workspace = true [dependencies] sqlx = { workspace = true } activitypub = { workspace = true } -adapter-common = { workspace = true } k-ap = { version = "0.5.0", registry = "gitea" } -sqlite-social = { workspace = true } +sqlite = { workspace = true } domain = { workspace = true } anyhow = { workspace = true } serde_json = { workspace = true } diff --git a/crates/adapters/sqlite-federation/src/activity.rs b/crates/adapters/sqlite-federation/src/activity.rs index 779b707e..a16b84e8 100644 --- a/crates/adapters/sqlite-federation/src/activity.rs +++ b/crates/adapters/sqlite-federation/src/activity.rs @@ -4,7 +4,7 @@ use chrono::Utc; use k_ap::ActivityRepository; use super::SqliteFederationRepository; -use adapter_common::datetime_to_str; +use sqlite::persistence::datetime_to_str; #[async_trait] impl ActivityRepository for SqliteFederationRepository { diff --git a/crates/adapters/sqlite-federation/src/actor.rs b/crates/adapters/sqlite-federation/src/actor.rs index 7ed39616..d25d50cd 100644 --- a/crates/adapters/sqlite-federation/src/actor.rs +++ b/crates/adapters/sqlite-federation/src/actor.rs @@ -5,7 +5,7 @@ use k_ap::{AnnounceRepository, Keypair, KeypairRepository, RemoteActor, RemoteAc use sqlx::Row; use super::{SqliteFederationRepository, remote_actor_from_row}; -use adapter_common::datetime_to_str; +use sqlite::persistence::datetime_to_str; #[async_trait] impl KeypairRepository for SqliteFederationRepository { diff --git a/crates/adapters/sqlite-federation/src/blocklist.rs b/crates/adapters/sqlite-federation/src/blocklist.rs index 2785c9f8..9f5daff1 100644 --- a/crates/adapters/sqlite-federation/src/blocklist.rs +++ b/crates/adapters/sqlite-federation/src/blocklist.rs @@ -5,7 +5,7 @@ use k_ap::{ActorBlocklist, BlockedDomain, DomainBlocklist}; use sqlx::Row; use super::SqliteFederationRepository; -use adapter_common::datetime_to_str; +use sqlite::persistence::datetime_to_str; #[async_trait] impl DomainBlocklist for SqliteFederationRepository { diff --git a/crates/adapters/sqlite-federation/src/follow/followers.rs b/crates/adapters/sqlite-federation/src/follow/followers.rs index 598ff9d9..459bcf60 100644 --- a/crates/adapters/sqlite-federation/src/follow/followers.rs +++ b/crates/adapters/sqlite-federation/src/follow/followers.rs @@ -5,7 +5,7 @@ use k_ap::{Follower, FollowerReader, FollowerStatus, FollowerWriter, RemoteActor use sqlx::Row; use crate::{SqliteFederationRepository, remote_actor_from_row, status_to_str, str_to_status}; -use adapter_common::datetime_to_str; +use sqlite::persistence::datetime_to_str; #[async_trait] impl FollowerWriter for SqliteFederationRepository { diff --git a/crates/adapters/sqlite-federation/src/follow/following.rs b/crates/adapters/sqlite-federation/src/follow/following.rs index 2b69e348..e22575f2 100644 --- a/crates/adapters/sqlite-federation/src/follow/following.rs +++ b/crates/adapters/sqlite-federation/src/follow/following.rs @@ -4,7 +4,7 @@ use chrono::Utc; use k_ap::{FollowingReader, FollowingStatus, FollowingWriter, RemoteActor, RemoteActorCache}; use crate::{SqliteFederationRepository, remote_actor_from_row}; -use adapter_common::datetime_to_str; +use sqlite::persistence::datetime_to_str; #[async_trait] impl FollowingWriter for SqliteFederationRepository { diff --git a/crates/adapters/sqlite-federation/src/lib.rs b/crates/adapters/sqlite-federation/src/lib.rs index 84a2aab1..32e6a9c4 100644 --- a/crates/adapters/sqlite-federation/src/lib.rs +++ b/crates/adapters/sqlite-federation/src/lib.rs @@ -79,7 +79,7 @@ pub fn wire( instance: domain::value_objects::InstanceIdentity, ) -> activitypub::FederationRepos { let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool.clone())); - let social = std::sync::Arc::new(sqlite_social::SqliteSocialRepository::new(pool, instance)); + let social = std::sync::Arc::new(sqlite::SqliteSocialRepository::new(pool, instance)); activitypub::FederationRepos { activity: std::sync::Arc::clone(&fed) as _, follow: std::sync::Arc::clone(&fed) as _, diff --git a/crates/adapters/sqlite-federation/src/review.rs b/crates/adapters/sqlite-federation/src/review.rs index ebbfd300..c648c684 100644 --- a/crates/adapters/sqlite-federation/src/review.rs +++ b/crates/adapters/sqlite-federation/src/review.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use domain::models::{Review, ReviewSource}; use super::SqliteFederationRepository; -use adapter_common::datetime_to_str; +use sqlite::persistence::datetime_to_str; #[async_trait] impl RemoteReviewRepository for SqliteFederationRepository { diff --git a/crates/adapters/sqlite-search/Cargo.toml b/crates/adapters/sqlite-search/Cargo.toml deleted file mode 100644 index b6da39d2..00000000 --- a/crates/adapters/sqlite-search/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "sqlite-search" -version = "0.1.0" -edition = "2021" - -[dependencies] -domain = { workspace = true } -adapter-common = { workspace = true } -async-trait = { workspace = true } -sqlx = { workspace = true } -uuid = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true } diff --git a/crates/adapters/sqlite-social/Cargo.toml b/crates/adapters/sqlite-social/Cargo.toml deleted file mode 100644 index 2fd4adf0..00000000 --- a/crates/adapters/sqlite-social/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "sqlite-social" -version = "0.1.0" -edition.workspace = true - -[dependencies] -sqlx = { workspace = true } -adapter-common = { workspace = true } -domain = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -async-trait = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true } diff --git a/crates/adapters/sqlite/Cargo.toml b/crates/adapters/sqlite/Cargo.toml index d46f49c5..4f207f4d 100644 --- a/crates/adapters/sqlite/Cargo.toml +++ b/crates/adapters/sqlite/Cargo.toml @@ -4,11 +4,10 @@ version = "0.1.0" edition.workspace = true [dependencies] -sqlx = { workspace = true, features = ["macros"] } +sqlx = { workspace = true, features = ["macros", "chrono"] } -adapter-common = { workspace = true } domain = { workspace = true } -sqlite-social = { workspace = true } +event-payload = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } anyhow = { workspace = true } @@ -22,5 +21,4 @@ bytes = { workspace = true } async-stream = { workspace = true } [dev-dependencies] -sqlite-event-queue = { workspace = true } tempfile = { workspace = true } diff --git a/crates/adapters/sqlite-event-queue/migrations/0100_event_queue.sql b/crates/adapters/sqlite/migrations/0100_event_queue.sql similarity index 100% rename from crates/adapters/sqlite-event-queue/migrations/0100_event_queue.sql rename to crates/adapters/sqlite/migrations/0100_event_queue.sql diff --git a/crates/adapters/sqlite/src/diary.rs b/crates/adapters/sqlite/src/diary.rs index 26788b96..0ca6bb62 100644 --- a/crates/adapters/sqlite/src/diary.rs +++ b/crates/adapters/sqlite/src/diary.rs @@ -27,13 +27,13 @@ impl SqliteDiaryRepository { None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews") .fetch_one(&self.pool) .await - .map_err(adapter_common::map_sqlx_error), + .map_err(crate::shared::map_sqlx_error), Some(id) => { sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = ?") .bind(id) .fetch_one(&self.pool) .await - .map_err(adapter_common::map_sqlx_error) + .map_err(crate::shared::map_sqlx_error) } } } @@ -64,7 +64,7 @@ impl SqliteDiaryRepository { .bind(offset) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error) + .map_err(crate::shared::map_sqlx_error) } async fn fetch_movie_diary_rows( @@ -96,7 +96,7 @@ impl SqliteDiaryRepository { .bind(offset) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error) + .map_err(crate::shared::map_sqlx_error) } async fn count_user_diary_entries( @@ -127,7 +127,7 @@ impl SqliteDiaryRepository { } q.fetch_one(&self.pool) .await - .map_err(adapter_common::map_sqlx_error) + .map_err(crate::shared::map_sqlx_error) } async fn fetch_user_diary_rows( @@ -173,7 +173,7 @@ impl SqliteDiaryRepository { .bind(offset) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error) + .map_err(crate::shared::map_sqlx_error) } } @@ -340,7 +340,7 @@ impl DiaryQuery for SqliteDiaryRepository { let total = count_q .fetch_one(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql)); let rows = rows_q @@ -348,7 +348,7 @@ impl DiaryQuery for SqliteDiaryRepository { .bind(offset) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let items = rows .into_iter() @@ -373,7 +373,7 @@ impl DiaryQuery for SqliteDiaryRepository { .bind(&id_str) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))? .into_domain()?; @@ -384,7 +384,7 @@ impl DiaryQuery for SqliteDiaryRepository { .bind(&id_str) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .into_iter() .map(ReviewRow::into_domain) .collect::, _>>()?; @@ -405,7 +405,7 @@ impl DiaryQuery for SqliteDiaryRepository { .bind(&uid) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; rows.into_iter().map(DiaryRow::into_domain).collect() } @@ -430,7 +430,7 @@ impl DiaryQuery for SqliteDiaryRepository { while let Some(row) = futures::StreamExt::next(&mut rows).await { yield match row { Ok(r) => r.into_domain(), - Err(e) => Err(adapter_common::map_sqlx_error(e)), + Err(e) => Err(crate::shared::map_sqlx_error(e)), }; } }) @@ -453,7 +453,7 @@ impl DiaryQuery for SqliteDiaryRepository { .bind(id_str) .fetch_one(&self.pool) .await - .map_err(adapter_common::map_sqlx_error) + .map_err(crate::shared::map_sqlx_error) .map(MovieStatsRow::into_domain) } @@ -471,7 +471,7 @@ impl DiaryQuery for SqliteDiaryRepository { .bind(&id_str) .fetch_one(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let rows = sqlx::query_as::<_, FeedRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, @@ -492,7 +492,7 @@ impl DiaryQuery for SqliteDiaryRepository { .bind(offset) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let items = rows .into_iter() @@ -512,7 +512,7 @@ impl DiaryQuery for SqliteDiaryRepository { sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL") .fetch_one(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(count as u64) } } diff --git a/crates/adapters/sqlite-event-queue/src/lib.rs b/crates/adapters/sqlite/src/event_queue/mod.rs similarity index 99% rename from crates/adapters/sqlite-event-queue/src/lib.rs rename to crates/adapters/sqlite/src/event_queue/mod.rs index 5cb3cfd7..7798d060 100644 --- a/crates/adapters/sqlite-event-queue/src/lib.rs +++ b/crates/adapters/sqlite/src/event_queue/mod.rs @@ -1,4 +1,3 @@ -mod migrations; mod payload; use std::sync::Arc; @@ -49,7 +48,6 @@ pub struct SqliteEventQueue { impl SqliteEventQueue { pub async fn create(pool: SqlitePool, config: DbEventQueueConfig) -> anyhow::Result { - migrations::run(&pool).await?; Ok(Self { pool, config: Arc::new(config), diff --git a/crates/adapters/sqlite-event-queue/src/payload.rs b/crates/adapters/sqlite/src/event_queue/payload.rs similarity index 100% rename from crates/adapters/sqlite-event-queue/src/payload.rs rename to crates/adapters/sqlite/src/event_queue/payload.rs diff --git a/crates/adapters/sqlite/src/goals.rs b/crates/adapters/sqlite/src/goals.rs index 8ceff8e5..a8934cae 100644 --- a/crates/adapters/sqlite/src/goals.rs +++ b/crates/adapters/sqlite/src/goals.rs @@ -39,7 +39,7 @@ impl GoalCommand for SqliteGoalRepository { .bind(&created_at) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -53,7 +53,7 @@ impl GoalCommand for SqliteGoalRepository { .bind(&id) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; if result.rows_affected() == 0 { return Err(DomainError::NotFound("Goal not found".into())); @@ -70,7 +70,7 @@ impl GoalCommand for SqliteGoalRepository { .bind(&uid) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; if result.rows_affected() == 0 { return Err(DomainError::NotFound("Goal not found".into())); @@ -97,7 +97,7 @@ impl GoalQuery for SqliteGoalRepository { .bind(y) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; row.map(|r| row_to_goal(&r)).transpose() } @@ -112,7 +112,7 @@ impl GoalQuery for SqliteGoalRepository { .bind(&uid) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; rows.iter().map(row_to_goal).collect() } diff --git a/crates/adapters/sqlite/src/import_profile.rs b/crates/adapters/sqlite/src/import_profile.rs index 535f01cd..86bf680d 100644 --- a/crates/adapters/sqlite/src/import_profile.rs +++ b/crates/adapters/sqlite/src/import_profile.rs @@ -125,7 +125,7 @@ impl ImportProfileRepository for SqliteImportProfileRepository { .execute(&self.pool) .await .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) + .map_err(crate::shared::map_sqlx_error) } async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { @@ -136,7 +136,7 @@ impl ImportProfileRepository for SqliteImportProfileRepository { .bind(&uid) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; rows.iter() .map(|r| { @@ -178,7 +178,7 @@ impl ImportProfileRepository for SqliteImportProfileRepository { .bind(&uid_str) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; row.map(|r| { use sqlx::Row; @@ -210,6 +210,6 @@ impl ImportProfileRepository for SqliteImportProfileRepository { .execute(&self.pool) .await .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) + .map_err(crate::shared::map_sqlx_error) } } diff --git a/crates/adapters/sqlite/src/import_session.rs b/crates/adapters/sqlite/src/import_session.rs index f978ece3..fd58a00a 100644 --- a/crates/adapters/sqlite/src/import_session.rs +++ b/crates/adapters/sqlite/src/import_session.rs @@ -311,7 +311,7 @@ impl ImportSessionRepository for SqliteImportSessionRepository { .execute(&self.pool) .await .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) + .map_err(crate::shared::map_sqlx_error) } async fn get( @@ -329,7 +329,7 @@ impl ImportSessionRepository for SqliteImportSessionRepository { .bind(&uid_str) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; row.map(|r| { use sqlx::Row; @@ -356,7 +356,7 @@ impl ImportSessionRepository for SqliteImportSessionRepository { .execute(&self.pool) .await .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) + .map_err(crate::shared::map_sqlx_error) } async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> { @@ -366,14 +366,14 @@ impl ImportSessionRepository for SqliteImportSessionRepository { .execute(&self.pool) .await .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) + .map_err(crate::shared::map_sqlx_error) } async fn delete_expired(&self) -> Result { let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < datetime('now')") .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(result.rows_affected()) } @@ -386,6 +386,6 @@ impl ImportSessionRepository for SqliteImportSessionRepository { .execute(&self.pool) .await .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) + .map_err(crate::shared::map_sqlx_error) } } diff --git a/crates/adapters/sqlite/src/insights.rs b/crates/adapters/sqlite/src/insights.rs index 2224901f..752a7153 100644 --- a/crates/adapters/sqlite/src/insights.rs +++ b/crates/adapters/sqlite/src/insights.rs @@ -6,7 +6,7 @@ use domain::{ }; use sqlx::{Row, SqlitePool}; -use adapter_common::{parse_datetime, parse_uuid}; +use crate::shared::{parse_datetime, parse_uuid}; /// SQLite read side of Insights (ADR-0010). Deliberately thin: one query for /// review basics, one for genres — every threshold and comparison lives in @@ -33,7 +33,7 @@ impl InsightsQuery for SqliteInsightsQuery { .bind(user_id.value().to_string()) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; // Subquery rather than an `IN (?, …)` bind list: a whole diary can // exceed SQLite's bound-parameter limit. @@ -48,16 +48,14 @@ impl InsightsQuery for SqliteInsightsQuery { .bind(user_id.value().to_string()) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let mut genres_map: HashMap> = HashMap::new(); for row in genre_rows { let mid: String = row .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let name: String = row - .try_get("name") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; + let name: String = row.try_get("name").map_err(crate::shared::map_sqlx_error)?; genres_map.entry(mid).or_default().push(name); } @@ -65,16 +63,16 @@ impl InsightsQuery for SqliteInsightsQuery { for row in &rows { let movie_id: String = row .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let rating: i64 = row .try_get("rating") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let watched_at: String = row .try_get("watched_at") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let watch_medium: Option = row .try_get("watch_medium") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; result.push(WatchRow { movie_id: parse_uuid(&movie_id)?, diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 05a59f60..87589544 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -1,6 +1,7 @@ use sqlx::SqlitePool; mod diary; +mod event_queue; mod goals; mod image_ref; mod import_profile; @@ -15,6 +16,9 @@ mod profile; mod profile_fields; mod refresh_sessions; mod review; +mod search; +mod shared; +mod social; mod stats; mod user_settings; mod users; @@ -23,6 +27,7 @@ mod watchlist; mod wrapup; pub use diary::SqliteDiaryRepository; +pub use event_queue::{DbEventQueueConfig, SqliteEventQueue}; pub use image_ref::{SqliteImageRefAdapter, create_image_ref}; pub use import_profile::SqliteImportProfileRepository; pub use import_session::SqliteImportSessionRepository; @@ -34,13 +39,23 @@ pub use profile::SqliteMovieProfileRepository; pub use profile_fields::SqliteProfileFieldsRepository; pub use refresh_sessions::SqliteRefreshSessionAdapter; pub use review::SqliteReviewRepository; -pub use sqlite_social::SqliteApContentQuery; +pub use search::create_search_adapter; +pub use social::{ + SqliteApContentQuery, SqliteRemoteGoalRepository, SqliteSocialRepository, + create_federated_profile_query, +}; pub use stats::SqliteStatsRepository; pub use users::SqliteUserRepository; pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository}; pub use watchlist::SqliteWatchlistRepository; pub use wrapup::{SqliteWrapUpRepository, SqliteWrapUpStatsQuery}; +/// Row helpers shared with the `sqlite-federation` crate (which cannot live +/// here because it depends on `activitypub`/`k-ap`). +pub mod persistence { + pub use crate::shared::datetime_to_str; +} + pub fn create_profile_fields_repo( pool: sqlx::SqlitePool, ) -> std::sync::Arc { @@ -122,9 +137,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result { goal_query: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _, user_settings: std::sync::Arc::clone(&user_settings_repo) as _, federation_settings: user_settings_repo as _, - remote_goal: std::sync::Arc::new(sqlite_social::SqliteRemoteGoalRepository::new( - pool.clone(), - )) as _, + remote_goal: std::sync::Arc::new(SqliteRemoteGoalRepository::new(pool.clone())) as _, deduplicator: std::sync::Arc::new(SqliteMovieDeduplicator::new(pool)) as _, }) } diff --git a/crates/adapters/sqlite/src/models.rs b/crates/adapters/sqlite/src/models.rs index e4b8a5da..250a26da 100644 --- a/crates/adapters/sqlite/src/models.rs +++ b/crates/adapters/sqlite/src/models.rs @@ -1,4 +1,4 @@ -use adapter_common::{ +use crate::shared::{ movie_row_to_domain, movie_stats_to_domain, movie_summary_to_domain, review_row_to_domain, user_summary_to_domain, watchlist_entry_to_domain, watchlist_with_movie_to_domain, }; diff --git a/crates/adapters/sqlite/src/movie.rs b/crates/adapters/sqlite/src/movie.rs index 84f2352b..e60029e6 100644 --- a/crates/adapters/sqlite/src/movie.rs +++ b/crates/adapters/sqlite/src/movie.rs @@ -50,7 +50,7 @@ impl MovieCommand for SqliteMovieRepository { .bind(&poster_path) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -61,7 +61,7 @@ impl MovieCommand for SqliteMovieRepository { .bind(&id) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } } @@ -80,7 +80,7 @@ impl MovieQuery for SqliteMovieRepository { .bind(id) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .map(MovieRow::into_domain) .transpose() } @@ -94,7 +94,7 @@ impl MovieQuery for SqliteMovieRepository { .bind(&id) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .map(MovieRow::into_domain) .transpose() } @@ -114,7 +114,7 @@ impl MovieQuery for SqliteMovieRepository { .bind(y) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .into_iter() .map(MovieRow::into_domain) .collect() @@ -139,7 +139,7 @@ impl MovieQuery for SqliteMovieRepository { let rows = q .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(rows.into_iter().collect()) } @@ -166,7 +166,7 @@ impl MovieQuery for SqliteMovieRepository { let rows = q .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(rows .into_iter() .map(|r| { @@ -218,7 +218,7 @@ impl MovieQuery for SqliteMovieRepository { .bind(offset) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let total: i64 = sqlx::query( "SELECT COUNT(DISTINCT m.id) \ @@ -236,7 +236,7 @@ impl MovieQuery for SqliteMovieRepository { .bind(genre) .fetch_one(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .try_get(0) .unwrap_or(0); @@ -260,7 +260,7 @@ impl MovieQuery for SqliteMovieRepository { ) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .into_iter() .map(MovieRow::into_domain) .collect() diff --git a/crates/adapters/sqlite/src/movie_dedup.rs b/crates/adapters/sqlite/src/movie_dedup.rs index 689ca920..32440e3b 100644 --- a/crates/adapters/sqlite/src/movie_dedup.rs +++ b/crates/adapters/sqlite/src/movie_dedup.rs @@ -35,7 +35,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator { .pool .begin() .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; sqlx::query( "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) @@ -45,14 +45,14 @@ impl MovieDeduplicator for SqliteMovieDeduplicator { poster_path = COALESCE(excluded.poster_path, movies.poster_path)", ) .bind(&new).bind(&ext_id).bind(&title).bind(year).bind(&director).bind(&poster) - .execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?; + .execute(&mut *tx).await.map_err(crate::shared::map_sqlx_error)?; let reviews = sqlx::query("UPDATE reviews SET movie_id = ? WHERE movie_id = ?") .bind(&new) .bind(&old) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .rows_affected(); let watchlist = sqlx::query("UPDATE watchlist_entries SET movie_id = ? WHERE movie_id = ?") @@ -60,7 +60,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator { .bind(&old) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .rows_affected(); let watch_events = sqlx::query("UPDATE watch_events SET movie_id = ? WHERE movie_id = ?") @@ -68,7 +68,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator { .bind(&old) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .rows_affected(); let profiles = sqlx::query("UPDATE movie_profiles SET movie_id = ? WHERE movie_id = ?") @@ -76,7 +76,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator { .bind(&old) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .rows_affected(); sqlx::query( @@ -87,12 +87,12 @@ impl MovieDeduplicator for SqliteMovieDeduplicator { .bind(&old) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?") .bind(&old) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; sqlx::query( "INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) @@ -102,40 +102,40 @@ impl MovieDeduplicator for SqliteMovieDeduplicator { .bind(&old) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?") .bind(&old) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; sqlx::query( "INSERT OR IGNORE INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path) SELECT ?, tmdb_person_id, name, character, billing_order, profile_path FROM movie_cast WHERE movie_id = ?", - ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?; + ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(crate::shared::map_sqlx_error)?; sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?") .bind(&old) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; sqlx::query( "INSERT OR IGNORE INTO movie_crew (movie_id, tmdb_person_id, name, job, department, profile_path) SELECT ?, tmdb_person_id, name, job, department, profile_path FROM movie_crew WHERE movie_id = ?", - ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?; + ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(crate::shared::map_sqlx_error)?; sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?") .bind(&old) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; sqlx::query("DELETE FROM movies WHERE id = ?") .bind(&old) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; - tx.commit().await.map_err(adapter_common::map_sqlx_error)?; + tx.commit().await.map_err(crate::shared::map_sqlx_error)?; Ok(reviews + watchlist + watch_events + profiles) } diff --git a/crates/adapters/sqlite/src/persons.rs b/crates/adapters/sqlite/src/persons.rs index e4cf30c3..110e2e45 100644 --- a/crates/adapters/sqlite/src/persons.rs +++ b/crates/adapters/sqlite/src/persons.rs @@ -52,7 +52,7 @@ impl PersonCommand for SqlitePersonAdapter { .bind(person.profile_path()) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; } Ok(()) } @@ -85,7 +85,7 @@ impl PersonCommand for SqlitePersonAdapter { .bind(batch_size) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let has_more = rows.len() as u32 >= batch_size; let mut count = 0u64; @@ -105,7 +105,7 @@ impl PersonCommand for SqlitePersonAdapter { .bind(&row.profile_path) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; count += 1; } Ok((count, has_more)) @@ -133,7 +133,7 @@ impl PersonCommand for SqlitePersonAdapter { .bind(id.value().to_string()) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } } @@ -147,7 +147,7 @@ impl PersonQuery for SqlitePersonAdapter { .bind(id.value().to_string()) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(row.map(PersonRow::into_person)) } @@ -162,7 +162,7 @@ impl PersonQuery for SqlitePersonAdapter { .bind(id.value()) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(row.map(PersonRow::into_person)) } @@ -178,7 +178,7 @@ impl PersonQuery for SqlitePersonAdapter { .bind(id.value().to_string()) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .flatten(); let Some(tmdb_id) = tmdb_id else { @@ -199,7 +199,7 @@ impl PersonQuery for SqlitePersonAdapter { .bind(tmdb_id) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .into_iter() .map(|r| CastCredit { movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), @@ -220,7 +220,7 @@ impl PersonQuery for SqlitePersonAdapter { .bind(tmdb_id) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .into_iter() .map(|r| CrewCredit { movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), @@ -243,7 +243,7 @@ impl PersonQuery for SqlitePersonAdapter { .bind(offset) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(rows.into_iter().map(PersonRow::into_person).collect()) } @@ -261,7 +261,7 @@ impl PersonQuery for SqlitePersonAdapter { ) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(rows .into_iter() diff --git a/crates/adapters/sqlite/src/profile.rs b/crates/adapters/sqlite/src/profile.rs index f6437efa..e518d3ef 100644 --- a/crates/adapters/sqlite/src/profile.rs +++ b/crates/adapters/sqlite/src/profile.rs @@ -28,7 +28,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { .pool .begin() .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; sqlx::query( r#"INSERT INTO movie_profiles @@ -61,13 +61,13 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { .bind(&enriched_at) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?") .bind(&movie_id) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; for g in &p.genres { sqlx::query( "INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name) VALUES (?,?,?)", @@ -77,14 +77,14 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { .bind(&g.name) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; } sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?") .bind(&movie_id) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; for k in &p.keywords { sqlx::query( "INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) VALUES (?,?,?)", @@ -94,14 +94,14 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { .bind(&k.name) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; } sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?") .bind(&movie_id) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; for c in &p.cast { sqlx::query( "INSERT OR IGNORE INTO movie_cast \ @@ -116,14 +116,14 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { .bind(&c.profile_path) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; } sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?") .bind(&movie_id) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; for cr in &p.crew { sqlx::query( "INSERT OR IGNORE INTO movie_crew \ @@ -138,10 +138,10 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { .bind(&cr.profile_path) .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; } - tx.commit().await.map_err(adapter_common::map_sqlx_error) + tx.commit().await.map_err(crate::shared::map_sqlx_error) } async fn get_by_movie_id(&self, id: &MovieId) -> Result, DomainError> { @@ -156,7 +156,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { .bind(&movie_id) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let row = match row { Some(r) => r, @@ -174,7 +174,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { .bind(&movie_id) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .into_iter() .map(|r| Genre { tmdb_id: r.try_get::("tmdb_id").unwrap_or(0) as u32, @@ -186,7 +186,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { .bind(&movie_id) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .into_iter() .map(|r| Keyword { tmdb_id: r.try_get::("tmdb_id").unwrap_or(0) as u32, @@ -201,7 +201,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { .bind(&movie_id) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .into_iter() .map(|r| CastMember { tmdb_person_id: r.try_get::("tmdb_person_id").unwrap_or(0) as u64, @@ -222,7 +222,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { .bind(&movie_id) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .into_iter() .map(|r| CrewMember { tmdb_person_id: r.try_get::("tmdb_person_id").unwrap_or(0) as u64, @@ -284,7 +284,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { .bind(&threshold) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(rows .into_iter() diff --git a/crates/adapters/sqlite/src/profile_fields.rs b/crates/adapters/sqlite/src/profile_fields.rs index 6d5ce907..9f11241f 100644 --- a/crates/adapters/sqlite/src/profile_fields.rs +++ b/crates/adapters/sqlite/src/profile_fields.rs @@ -26,7 +26,7 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository { .bind(&id_str) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(rows .iter() @@ -48,7 +48,7 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository { .bind(&id_str) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; for (i, field) in fields.into_iter().enumerate() { let id = uuid::Uuid::new_v4().to_string(); @@ -63,7 +63,7 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository { .bind(position) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; } Ok(()) diff --git a/crates/adapters/sqlite/src/refresh_sessions.rs b/crates/adapters/sqlite/src/refresh_sessions.rs index c2124ebd..27ac5790 100644 --- a/crates/adapters/sqlite/src/refresh_sessions.rs +++ b/crates/adapters/sqlite/src/refresh_sessions.rs @@ -30,7 +30,7 @@ impl RefreshSessionRepository for SqliteRefreshSessionAdapter { .bind(session.created_at.to_rfc3339()) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -41,7 +41,7 @@ impl RefreshSessionRepository for SqliteRefreshSessionAdapter { .bind(token) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; row.map(RefreshSessionRow::into_domain).transpose() } @@ -51,7 +51,7 @@ impl RefreshSessionRepository for SqliteRefreshSessionAdapter { .bind(token) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -60,7 +60,7 @@ impl RefreshSessionRepository for SqliteRefreshSessionAdapter { .bind(user_id.value().to_string()) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -70,7 +70,7 @@ impl RefreshSessionRepository for SqliteRefreshSessionAdapter { .bind(&now) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(result.rows_affected()) } } diff --git a/crates/adapters/sqlite/src/review.rs b/crates/adapters/sqlite/src/review.rs index 4768a496..9fc2c6b5 100644 --- a/crates/adapters/sqlite/src/review.rs +++ b/crates/adapters/sqlite/src/review.rs @@ -8,7 +8,7 @@ use domain::{ use sqlx::SqlitePool; use crate::models::ReviewRow; -use adapter_common::datetime_to_str; +use crate::shared::datetime_to_str; pub struct SqliteReviewRepository { pool: SqlitePool, @@ -51,7 +51,7 @@ impl ReviewRepository for SqliteReviewRepository { .bind(review.spoilers()) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -65,7 +65,7 @@ impl ReviewRepository for SqliteReviewRepository { .bind(&id) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .map(ReviewRow::into_domain) .transpose() } @@ -88,7 +88,7 @@ impl ReviewRepository for SqliteReviewRepository { .bind(&id) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -99,7 +99,7 @@ impl ReviewRepository for SqliteReviewRepository { .bind(&id) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -112,7 +112,7 @@ impl ReviewRepository for SqliteReviewRepository { .bind(&uid) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .into_iter() .map(ReviewRow::into_domain) .collect() diff --git a/crates/adapters/sqlite-search/src/lib.rs b/crates/adapters/sqlite/src/search.rs similarity index 93% rename from crates/adapters/sqlite-search/src/lib.rs rename to crates/adapters/sqlite/src/search.rs index 36e95e5d..528f5520 100644 --- a/crates/adapters/sqlite-search/src/lib.rs +++ b/crates/adapters/sqlite/src/search.rs @@ -5,8 +5,8 @@ use domain::{ errors::DomainError, models::PersonId, models::{ - collections::Paginated, EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, - SearchQuery, SearchResults, + EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchQuery, SearchResults, + collections::Paginated, }, ports::{SearchCommand, SearchPort}, value_objects::MovieId, @@ -82,7 +82,7 @@ impl SearchCommand for SqliteSearchAdapter { .bind(&movie_id) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; sqlx::query( "INSERT INTO movies_fts(movie_id, title, director, overview, genres, keywords, cast_names, crew_names, release_year, language) @@ -100,7 +100,7 @@ impl SearchCommand for SqliteSearchAdapter { .bind(&language) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -114,7 +114,7 @@ impl SearchCommand for SqliteSearchAdapter { .bind(&person_id) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; sqlx::query( "INSERT INTO people_fts(person_id, name, known_for_department) VALUES (?, ?, ?)", @@ -124,7 +124,7 @@ impl SearchCommand for SqliteSearchAdapter { .bind(person.known_for_department()) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -140,7 +140,7 @@ impl SearchCommand for SqliteSearchAdapter { .bind(id) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; } EntityType::Person => { sqlx::query( @@ -149,7 +149,7 @@ impl SearchCommand for SqliteSearchAdapter { .bind(id) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; } } Ok(()) @@ -201,7 +201,7 @@ impl SqliteSearchAdapter { .bind(query.filters.year.map(|y| y as i64)) .fetch_one(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; count as u64 } else { let count: i64 = sqlx::query_scalar( @@ -217,7 +217,7 @@ impl SqliteSearchAdapter { .bind(query.filters.year.map(|y| y as i64)) .fetch_one(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; count as u64 }; @@ -245,7 +245,7 @@ impl SqliteSearchAdapter { .bind(offset) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? } else { sqlx::query_as::<_, Row>( "SELECT m.id, m.title, m.release_year, m.director, m.poster_path, @@ -266,7 +266,7 @@ impl SqliteSearchAdapter { .bind(offset) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? }; let items = rows .into_iter() @@ -317,7 +317,7 @@ impl SqliteSearchAdapter { .bind(&fts_query) .fetch_one(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; count as u64 }; @@ -342,7 +342,7 @@ impl SqliteSearchAdapter { .bind(offset) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let mut items = Vec::with_capacity(rows.len()); for row in rows { @@ -351,7 +351,7 @@ impl SqliteSearchAdapter { .bind(&row.person_id) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .flatten(); let known_for_titles = if let Some(tid) = tmdb_id { @@ -391,5 +391,4 @@ impl SqliteSearchAdapter { } #[cfg(test)] -#[path = "tests/lib.rs"] mod tests; diff --git a/crates/adapters/sqlite-search/src/tests/lib.rs b/crates/adapters/sqlite/src/search/tests.rs similarity index 98% rename from crates/adapters/sqlite-search/src/tests/lib.rs rename to crates/adapters/sqlite/src/search/tests.rs index 25771dc9..80f0faf6 100644 --- a/crates/adapters/sqlite-search/src/tests/lib.rs +++ b/crates/adapters/sqlite/src/search/tests.rs @@ -1,7 +1,7 @@ use super::create_search_adapter; use domain::{ models::{ - collections::PageParams, EntityType, IndexableDocument, Movie, SearchFilters, SearchQuery, + EntityType, IndexableDocument, Movie, SearchFilters, SearchQuery, collections::PageParams, }, value_objects::{MovieId, MovieTitle, ReleaseYear}, }; diff --git a/crates/adapters/adapter-common/src/lib.rs b/crates/adapters/sqlite/src/shared.rs similarity index 89% rename from crates/adapters/adapter-common/src/lib.rs rename to crates/adapters/sqlite/src/shared.rs index f0b75266..13ae4078 100644 --- a/crates/adapters/adapter-common/src/lib.rs +++ b/crates/adapters/sqlite/src/shared.rs @@ -10,17 +10,17 @@ use domain::{ }, }; -pub fn map_sqlx_error(e: sqlx::Error) -> DomainError { +pub(crate) fn map_sqlx_error(e: sqlx::Error) -> DomainError { tracing::error!("Database error: {:?}", e); DomainError::InfrastructureError("Database operation failed".into()) } -pub fn parse_uuid(s: &str) -> Result { +pub(crate) fn parse_uuid(s: &str) -> Result { uuid::Uuid::parse_str(s) .map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e))) } -pub fn parse_datetime(s: &str) -> Result { +pub(crate) fn parse_datetime(s: &str) -> Result { chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") .map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e))) } @@ -29,7 +29,7 @@ pub fn datetime_to_str(dt: &chrono::NaiveDateTime) -> String { dt.format("%Y-%m-%d %H:%M:%S").to_string() } -pub fn format_year_month(ym: &str) -> String { +pub(crate) fn format_year_month(ym: &str) -> String { let parts: Vec<&str> = ym.splitn(2, '-').collect(); if parts.len() != 2 { return ym.to_string(); @@ -53,7 +53,7 @@ pub fn format_year_month(ym: &str) -> String { format!("{} '{}", month, year) } -pub fn movie_row_to_domain( +pub(crate) fn movie_row_to_domain( id: String, external_metadata_id: Option, title: String, @@ -79,7 +79,7 @@ pub fn movie_row_to_domain( } #[allow(clippy::too_many_arguments)] -pub fn review_row_to_domain( +pub(crate) fn review_row_to_domain( id: String, movie_id: String, user_id: String, @@ -117,7 +117,7 @@ pub fn review_row_to_domain( })) } -pub fn movie_summary_to_domain( +pub(crate) fn movie_summary_to_domain( movie: Movie, genres: Vec, runtime_minutes: Option, @@ -135,7 +135,7 @@ pub fn movie_summary_to_domain( } } -pub fn movie_stats_to_domain( +pub(crate) fn movie_stats_to_domain( total_count: i64, avg_rating: Option, federated_count: i64, @@ -156,7 +156,7 @@ pub fn movie_stats_to_domain( } #[allow(clippy::too_many_arguments)] -pub fn user_summary_to_domain( +pub(crate) fn user_summary_to_domain( id: String, email: String, username: String, @@ -176,7 +176,7 @@ pub fn user_summary_to_domain( )) } -pub fn watchlist_entry_to_domain( +pub(crate) fn watchlist_entry_to_domain( id: String, user_id: String, movie_id: String, @@ -190,6 +190,9 @@ pub fn watchlist_entry_to_domain( }) } -pub fn watchlist_with_movie_to_domain(entry: WatchlistEntry, movie: Movie) -> WatchlistWithMovie { +pub(crate) fn watchlist_with_movie_to_domain( + entry: WatchlistEntry, + movie: Movie, +) -> WatchlistWithMovie { WatchlistWithMovie { entry, movie } } diff --git a/crates/adapters/sqlite-social/src/ap_content.rs b/crates/adapters/sqlite/src/social/ap_content.rs similarity index 97% rename from crates/adapters/sqlite-social/src/ap_content.rs rename to crates/adapters/sqlite/src/social/ap_content.rs index aeb412f9..b441b467 100644 --- a/crates/adapters/sqlite-social/src/ap_content.rs +++ b/crates/adapters/sqlite/src/social/ap_content.rs @@ -25,7 +25,7 @@ impl SqliteApContentQuery { // ── Local row types ────────────────────────────────────────────────────────── -use adapter_common::{parse_datetime, parse_uuid}; +use crate::shared::{parse_datetime, parse_uuid}; #[derive(sqlx::FromRow)] struct MovieRow { @@ -205,7 +205,7 @@ impl LocalApContentQuery for SqliteApContentQuery { .bind(&uid) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; rows.into_iter().map(WatchlistRow::into_domain).collect() } @@ -225,7 +225,7 @@ impl LocalApContentQuery for SqliteApContentQuery { .bind(&mid) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; rows.into_iter().map(DiaryRow::into_domain).collect() } @@ -254,7 +254,7 @@ impl LocalApContentQuery for SqliteApContentQuery { .bind(limit_i64) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? } else { sqlx::query_as::<_, DiaryRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, @@ -269,7 +269,7 @@ impl LocalApContentQuery for SqliteApContentQuery { .bind(limit_i64) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? }; rows.into_iter().map(DiaryRow::into_domain).collect() } diff --git a/crates/adapters/sqlite-social/src/federated_profile.rs b/crates/adapters/sqlite/src/social/federated_profile.rs similarity index 100% rename from crates/adapters/sqlite-social/src/federated_profile.rs rename to crates/adapters/sqlite/src/social/federated_profile.rs diff --git a/crates/adapters/sqlite-social/src/follow_repository.rs b/crates/adapters/sqlite/src/social/follow_repository.rs similarity index 99% rename from crates/adapters/sqlite-social/src/follow_repository.rs rename to crates/adapters/sqlite/src/social/follow_repository.rs index 51dde13d..506c9c7c 100644 --- a/crates/adapters/sqlite-social/src/follow_repository.rs +++ b/crates/adapters/sqlite/src/social/follow_repository.rs @@ -7,7 +7,7 @@ use domain::{ use sqlx::Row; use crate::SqliteSocialRepository; -use adapter_common::datetime_to_str; +use crate::shared::datetime_to_str; fn follow_status_to_str(status: &FollowStatus) -> &'static str { match status { diff --git a/crates/adapters/sqlite-social/src/social.rs b/crates/adapters/sqlite/src/social/local_social.rs similarity index 100% rename from crates/adapters/sqlite-social/src/social.rs rename to crates/adapters/sqlite/src/social/local_social.rs diff --git a/crates/adapters/sqlite-social/src/lib.rs b/crates/adapters/sqlite/src/social/mod.rs similarity index 97% rename from crates/adapters/sqlite-social/src/lib.rs rename to crates/adapters/sqlite/src/social/mod.rs index 06f3e466..1f447813 100644 --- a/crates/adapters/sqlite-social/src/lib.rs +++ b/crates/adapters/sqlite/src/social/mod.rs @@ -1,6 +1,6 @@ mod federated_profile; mod follow_repository; -mod social; +mod local_social; mod watchlist; pub mod ap_content; diff --git a/crates/adapters/sqlite-social/src/remote_goals.rs b/crates/adapters/sqlite/src/social/remote_goals.rs similarity index 92% rename from crates/adapters/sqlite-social/src/remote_goals.rs rename to crates/adapters/sqlite/src/social/remote_goals.rs index 73252009..cfc2fc22 100644 --- a/crates/adapters/sqlite-social/src/remote_goals.rs +++ b/crates/adapters/sqlite/src/social/remote_goals.rs @@ -31,7 +31,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository { .bind(&received) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -48,7 +48,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository { .bind(ap_id) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -59,7 +59,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository { .bind(actor_url) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -69,7 +69,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository { .bind(actor_url) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -82,7 +82,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository { .bind(actor_url) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; rows.iter() .map(|r| { diff --git a/crates/adapters/sqlite-social/src/tests/follow_relation_tests.rs b/crates/adapters/sqlite/src/social/tests/follow_relation_tests.rs similarity index 100% rename from crates/adapters/sqlite-social/src/tests/follow_relation_tests.rs rename to crates/adapters/sqlite/src/social/tests/follow_relation_tests.rs diff --git a/crates/adapters/sqlite-social/src/watchlist.rs b/crates/adapters/sqlite/src/social/watchlist.rs similarity index 100% rename from crates/adapters/sqlite-social/src/watchlist.rs rename to crates/adapters/sqlite/src/social/watchlist.rs diff --git a/crates/adapters/sqlite/src/stats.rs b/crates/adapters/sqlite/src/stats.rs index 937ebd03..75d39b4f 100644 --- a/crates/adapters/sqlite/src/stats.rs +++ b/crates/adapters/sqlite/src/stats.rs @@ -32,7 +32,7 @@ impl SqliteStatsRepository { .bind(user_id) .fetch_one(&self.pool) .await - .map_err(adapter_common::map_sqlx_error) + .map_err(crate::shared::map_sqlx_error) } async fn fetch_user_favorite_director( @@ -51,7 +51,7 @@ impl SqliteStatsRepository { .bind(user_id) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(row) } @@ -70,7 +70,7 @@ impl SqliteStatsRepository { .bind(user_id) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(row) } } @@ -86,7 +86,7 @@ impl StatsRepository for SqliteStatsRepository { self.fetch_user_most_active_month(&uid) )?; - let most_active_month = most_active.map(|ym| adapter_common::format_year_month(&ym)); + let most_active_month = most_active.map(|ym| crate::shared::format_year_month(&ym)); Ok(UserStats { total_movies: totals.total, @@ -168,14 +168,14 @@ impl StatsRepository for SqliteStatsRepository { .bind(&uid) .fetch_all(&self.pool) ) - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1); let monthly_ratings = rating_rows .into_iter() .map(|r| MonthlyRating { - month_label: adapter_common::format_year_month(&r.month), + month_label: crate::shared::format_year_month(&r.month), year_month: r.month, avg_rating: r.avg_rating, count: r.count, diff --git a/crates/adapters/sqlite/src/user_settings.rs b/crates/adapters/sqlite/src/user_settings.rs index 63868141..73af4174 100644 --- a/crates/adapters/sqlite/src/user_settings.rs +++ b/crates/adapters/sqlite/src/user_settings.rs @@ -28,7 +28,7 @@ impl UserSettingsRepository for SqliteUserSettingsRepository { .bind(&uid) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; match row { Some(r) => { @@ -63,7 +63,7 @@ impl UserSettingsRepository for SqliteUserSettingsRepository { }) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } } @@ -79,7 +79,7 @@ impl UserFederationSettingsQuery for SqliteUserSettingsRepository { .bind(&uid) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; match row { Some(r) => { diff --git a/crates/adapters/sqlite/src/users.rs b/crates/adapters/sqlite/src/users.rs index 9c6abd6a..96793d0d 100644 --- a/crates/adapters/sqlite/src/users.rs +++ b/crates/adapters/sqlite/src/users.rs @@ -68,7 +68,7 @@ impl UserRepository for SqliteUserRepository { .bind(email_str) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; row.as_ref() .map(|r| Self::row_to_user(r, vec![])) @@ -81,7 +81,7 @@ impl UserRepository for SqliteUserRepository { .bind(username_str) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; row.as_ref() .map(|r| Self::row_to_user(r, vec![])) @@ -121,7 +121,7 @@ impl UserRepository for SqliteUserRepository { .bind(role) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -132,7 +132,7 @@ impl UserRepository for SqliteUserRepository { .bind(&id_str) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let Some(r) = row else { return Ok(None) }; @@ -189,7 +189,7 @@ impl UserRepository for SqliteUserRepository { ) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .into_iter() .map(UserSummaryRow::into_domain) .collect() diff --git a/crates/adapters/sqlite/src/watch_event.rs b/crates/adapters/sqlite/src/watch_event.rs index 8864aa91..ff821c82 100644 --- a/crates/adapters/sqlite/src/watch_event.rs +++ b/crates/adapters/sqlite/src/watch_event.rs @@ -7,7 +7,7 @@ use domain::{ }; use sqlx::{Row, SqlitePool}; -use adapter_common::datetime_to_str; +use crate::shared::datetime_to_str; fn parse_uuid(s: &str) -> Result { s.parse() @@ -60,7 +60,7 @@ impl WatchEventCommand for SqliteWatchEventRepository { .bind(&created_at) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -78,7 +78,7 @@ impl WatchEventCommand for SqliteWatchEventRepository { .bind(&id_str) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -103,7 +103,7 @@ impl WatchEventCommand for SqliteWatchEventRepository { let result = q .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(result.rows_affected()) } @@ -117,7 +117,7 @@ impl WatchEventCommand for SqliteWatchEventRepository { .bind(&before_str) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(result.rows_affected()) } } @@ -137,7 +137,7 @@ impl WatchEventQuery for SqliteWatchEventRepository { .bind(&uid) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; rows.iter().map(row_to_watch_event).collect() } @@ -153,7 +153,7 @@ impl WatchEventQuery for SqliteWatchEventRepository { .bind(&id_str) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; row.as_ref().map(row_to_watch_event).transpose() } @@ -176,7 +176,7 @@ impl WatchEventQuery for SqliteWatchEventRepository { let rows = q .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; rows.iter().map(row_to_watch_event).collect() } @@ -198,7 +198,7 @@ impl WatchEventQuery for SqliteWatchEventRepository { .bind(&after_str) .fetch_one(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .try_get(0) .unwrap_or(0); @@ -207,34 +207,32 @@ impl WatchEventQuery for SqliteWatchEventRepository { } fn row_to_watch_event(row: &sqlx::sqlite::SqliteRow) -> Result { - let id_str: &str = row.try_get("id").map_err(adapter_common::map_sqlx_error)?; + let id_str: &str = row.try_get("id").map_err(crate::shared::map_sqlx_error)?; let user_id_str: &str = row .try_get("user_id") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let movie_id_str: Option<&str> = row .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let title: String = row .try_get("title") - .map_err(adapter_common::map_sqlx_error)?; - let year: Option = row - .try_get("year") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; + let year: Option = row.try_get("year").map_err(crate::shared::map_sqlx_error)?; let ext_id: Option = row .try_get("external_metadata_id") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let source_str: String = row .try_get("source") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let watched_at_str: String = row .try_get("watched_at") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let status_str: String = row .try_get("status") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let created_at_str: String = row .try_get("created_at") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let source: WatchEventSource = source_str .parse() @@ -297,7 +295,7 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository { .bind(&last_used) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -310,7 +308,7 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository { .bind(hash) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; row.as_ref().map(row_to_webhook_token).transpose() } @@ -325,7 +323,7 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository { .bind(&uid) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; rows.iter().map(row_to_webhook_token).collect() } @@ -339,7 +337,7 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository { .bind(&uid) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; if result.rows_affected() == 0 { return Err(DomainError::NotFound(format!("Webhook token {id_str}"))); @@ -356,32 +354,32 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository { .bind(&id_str) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } } fn row_to_webhook_token(row: &sqlx::sqlite::SqliteRow) -> Result { - let id_str: &str = row.try_get("id").map_err(adapter_common::map_sqlx_error)?; + let id_str: &str = row.try_get("id").map_err(crate::shared::map_sqlx_error)?; let user_id_str: &str = row .try_get("user_id") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let token_hash: String = row .try_get("token_hash") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let provider_str: String = row .try_get("provider") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let label: Option = row .try_get("label") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let created_at_str: String = row .try_get("created_at") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let last_used_str: Option = row .try_get("last_used_at") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let provider: WatchEventSource = provider_str .parse() diff --git a/crates/adapters/sqlite/src/watchlist.rs b/crates/adapters/sqlite/src/watchlist.rs index 934717c7..ae78e83c 100644 --- a/crates/adapters/sqlite/src/watchlist.rs +++ b/crates/adapters/sqlite/src/watchlist.rs @@ -11,7 +11,7 @@ use domain::{ use sqlx::{Row, SqlitePool}; use crate::models::WatchlistRow; -use adapter_common::datetime_to_str; +use crate::shared::datetime_to_str; pub struct SqliteWatchlistRepository { pool: SqlitePool, @@ -41,7 +41,7 @@ impl WatchlistRepository for SqliteWatchlistRepository { .bind(&added_at) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -56,7 +56,7 @@ impl WatchlistRepository for SqliteWatchlistRepository { .bind(&mid) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; if result.rows_affected() == 0 { return Err(DomainError::NotFound(format!( @@ -80,7 +80,7 @@ impl WatchlistRepository for SqliteWatchlistRepository { .bind(&mid) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(result.rows_affected() > 0) } @@ -108,13 +108,13 @@ impl WatchlistRepository for SqliteWatchlistRepository { .bind(offset) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let total: i64 = sqlx::query("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ?") .bind(&uid) .fetch_one(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .try_get(0) .unwrap_or(0); @@ -141,7 +141,7 @@ impl WatchlistRepository for SqliteWatchlistRepository { .bind(&mid) .fetch_one(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)? + .map_err(crate::shared::map_sqlx_error)? .try_get(0) .unwrap_or(0); Ok(count > 0) diff --git a/crates/adapters/sqlite/src/wrapup.rs b/crates/adapters/sqlite/src/wrapup.rs index 521c3d1f..1348a0c2 100644 --- a/crates/adapters/sqlite/src/wrapup.rs +++ b/crates/adapters/sqlite/src/wrapup.rs @@ -13,7 +13,7 @@ use domain::{ use sqlx::{Row, SqlitePool}; use uuid::Uuid; -use adapter_common::{parse_datetime, parse_uuid}; +use crate::shared::{parse_datetime, parse_uuid}; fn status_to_str(s: &WrapUpStatus) -> &'static str { match s { @@ -82,7 +82,7 @@ impl WrapUpRepository for SqliteWrapUpRepository { .bind(&completed) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -102,7 +102,7 @@ impl WrapUpRepository for SqliteWrapUpRepository { .bind(&id_str) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -121,7 +121,7 @@ impl WrapUpRepository for SqliteWrapUpRepository { .bind(&id_str) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -137,7 +137,7 @@ impl WrapUpRepository for SqliteWrapUpRepository { .bind(&id_str) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; row.as_ref().map(row_to_record).transpose() } @@ -153,7 +153,7 @@ impl WrapUpRepository for SqliteWrapUpRepository { .bind(&uid) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; rows.iter().map(row_to_record).collect() } @@ -166,7 +166,7 @@ impl WrapUpRepository for SqliteWrapUpRepository { ) .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; rows.iter().map(row_to_record).collect() } @@ -195,7 +195,7 @@ impl WrapUpRepository for SqliteWrapUpRepository { .bind(&end_str) .fetch_optional(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; row.as_ref().map(row_to_record).transpose() } @@ -205,7 +205,7 @@ impl WrapUpRepository for SqliteWrapUpRepository { .bind(id.value().to_string()) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } @@ -219,37 +219,37 @@ impl WrapUpRepository for SqliteWrapUpRepository { .bind(&before_str) .execute(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; Ok(result.rows_affected()) } } fn row_to_record(row: &sqlx::sqlite::SqliteRow) -> Result { - let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?; + let id_str: String = row.try_get("id").map_err(crate::shared::map_sqlx_error)?; let user_id_str: Option = row .try_get("user_id") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let start_date_str: String = row .try_get("start_date") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let end_date_str: String = row .try_get("end_date") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let status_str: String = row .try_get("status") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let report_json: Option = row .try_get("report_json") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let error_message: Option = row .try_get("error_message") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let created_at_str: String = row .try_get("created_at") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let completed_at_str: Option = row .try_get("completed_at") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?; @@ -318,7 +318,7 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery { let rows = q .fetch_all(&self.pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; if rows.is_empty() { return Ok(vec![]); @@ -330,7 +330,7 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery { for row in &rows { let mid: String = row .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; if seen.insert(mid.clone()) { movie_ids.push(mid); } @@ -348,40 +348,40 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery { for row in &rows { let movie_id_str: String = row .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let title: String = row .try_get("title") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let release_year: i64 = row .try_get("release_year") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let director: Option = row .try_get("director") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let poster_path: Option = row .try_get("poster_path") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let rating: i64 = row .try_get("rating") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let watched_at_str: String = row .try_get("watched_at") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let user_id_str: String = row .try_get("user_id") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let runtime_minutes: Option = row .try_get("runtime_minutes") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let budget_usd: Option = row .try_get("budget_usd") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let original_language: Option = row .try_get("original_language") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let watch_medium: Option = row .try_get("watch_medium") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default(); let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default(); @@ -456,16 +456,14 @@ pub(crate) async fn fetch_genres_sqlite( let rows = q .fetch_all(pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let mut map: HashMap> = HashMap::new(); for row in rows { let mid: String = row .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let name: String = row - .try_get("name") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; + let name: String = row.try_get("name").map_err(crate::shared::map_sqlx_error)?; map.entry(mid).or_default().push(name); } Ok(map) @@ -489,16 +487,14 @@ async fn fetch_keywords_sqlite( let rows = q .fetch_all(pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let mut map: HashMap> = HashMap::new(); for row in rows { let mid: String = row .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let name: String = row - .try_get("name") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; + let name: String = row.try_get("name").map_err(crate::shared::map_sqlx_error)?; map.entry(mid).or_default().push(name); } Ok(map) @@ -525,25 +521,23 @@ async fn fetch_cast_sqlite( let rows = q .fetch_all(pool) .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let mut map: HashMap> = HashMap::new(); for row in rows { let mid: String = row .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let name: String = row - .try_get("name") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; + let name: String = row.try_get("name").map_err(crate::shared::map_sqlx_error)?; let billing_order: i32 = row .try_get("billing_order") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let tmdb_person_id: i64 = row .try_get("tmdb_person_id") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; let profile_path: Option = row .try_get("profile_path") - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; map.entry(mid).or_default().push(CastEntry { name, billing_order: billing_order as u32, diff --git a/crates/adapters/sqlite/tests/migration_ledger_test.rs b/crates/adapters/sqlite/tests/migration_ledger_test.rs index 6c5fbe4b..b226c58a 100644 --- a/crates/adapters/sqlite/tests/migration_ledger_test.rs +++ b/crates/adapters/sqlite/tests/migration_ledger_test.rs @@ -10,9 +10,6 @@ const PRE_MERGE_DB: &str = "tests/fixtures/pre_merge.db"; async fn migrate_with_todays_entry_points(pool: &SqlitePool) { sqlite::migrate(pool).await.unwrap(); - sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()) - .await - .unwrap(); } async fn ledger(pool: &SqlitePool) -> String { diff --git a/crates/composition/Cargo.toml b/crates/composition/Cargo.toml index 0599e0de..c0c4bdcd 100644 --- a/crates/composition/Cargo.toml +++ b/crates/composition/Cargo.toml @@ -22,9 +22,6 @@ anyhow = { workspace = true } tracing = { workspace = true } sqlite = { workspace = true } -sqlite-event-queue = { workspace = true } -sqlite-search = { workspace = true } -sqlite-social = { workspace = true } nats = { workspace = true, optional = true } activitypub = { workspace = true, optional = true } sqlite-federation = { workspace = true, optional = true } diff --git a/crates/composition/src/factory.rs b/crates/composition/src/factory.rs index 34580381..f1009836 100644 --- a/crates/composition/src/factory.rs +++ b/crates/composition/src/factory.rs @@ -56,12 +56,12 @@ pub async fn build_database_adapters( .context("SQLite connection failed")?; 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_search::create_search_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 = Arc::new(sqlite::SqliteWebhookTokenRepository::new(w.pool.clone())); - let social = Arc::new(sqlite_social::SqliteSocialRepository::new( + let social = Arc::new(sqlite::SqliteSocialRepository::new( w.pool.clone(), instance.clone(), )); diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index ca4fe627..89010c28 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -44,9 +44,6 @@ infra-wiring = { workspace = true } async-trait = { workspace = true } sqlite = { workspace = true } -sqlite-event-queue = { workspace = true } -sqlite-search = { workspace = true } -sqlite-social = { workspace = true } # Optional — federation activitypub = { workspace = true, optional = true } diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 72c47a4a..4d7138d0 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -211,7 +211,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { federated_profile: Some({ match &db_pool { composition::DbPool::Sqlite(pool) => { - sqlite_social::create_federated_profile_query(pool.clone(), instance.clone()) + sqlite::create_federated_profile_query(pool.clone(), instance.clone()) } } }), @@ -268,7 +268,7 @@ async fn create_event_publisher( tracing::info!("event bus: DB queue"); Ok(match db_pool { composition::DbPool::Sqlite(pool) => { - sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await? + sqlite::SqliteEventQueue::create_publisher(pool.clone()).await? } }) } diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index de04f955..002647b6 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -40,8 +40,6 @@ infra-wiring = { workspace = true } async-trait = { workspace = true } sqlite = { workspace = true } -sqlite-event-queue = { workspace = true } -sqlite-search = { workspace = true } # Optional — federation activitypub = { workspace = true, optional = true } diff --git a/crates/worker/src/event_bus.rs b/crates/worker/src/event_bus.rs index 556e302d..35814ee6 100644 --- a/crates/worker/src/event_bus.rs +++ b/crates/worker/src/event_bus.rs @@ -14,7 +14,7 @@ pub async fn create( tracing::info!("event bus: DB queue"); match db_pool { DbPool::Sqlite(pool) => { - Ok(sqlite_event_queue::SqliteEventQueue::create_channel(pool.clone()).await?) + Ok(sqlite::SqliteEventQueue::create_channel(pool.clone()).await?) } } } -- 2.49.1 From ab4e4c31382e4b12ec13c02d7f1b7546d8073293 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 20:55:33 +0100 Subject: [PATCH 010/104] refactor: sqlite repositories per aggregate --- crates/adapters/sqlite/src/lib.rs | 63 +-- crates/adapters/sqlite/src/models.rs | 325 -------------- crates/adapters/sqlite/src/persons.rs | 348 --------------- .../sqlite/src/repositories/ap_content/mod.rs | 110 +++++ .../ap_content/rows.rs} | 120 +----- .../sqlite/src/repositories/diary/mod.rs | 174 ++++++++ .../{diary.rs => repositories/diary/query.rs} | 176 +------- .../sqlite/src/repositories/diary/rows.rs | 128 ++++++ .../diary.rs => repositories/diary/tests.rs} | 0 .../{goals.rs => repositories/goals/mod.rs} | 0 .../image_ref/mod.rs} | 1 - .../image_ref/tests.rs} | 0 .../import_profile/mod.rs} | 83 +--- .../src/repositories/import_profile/rows.rs | 84 ++++ .../import_session/mod.rs} | 186 +------- .../src/repositories/import_session/rows.rs | 187 ++++++++ .../insights/mod.rs} | 0 .../adapters/sqlite/src/repositories/mod.rs | 47 +++ .../sqlite/src/repositories/movie/command.rs | 48 +++ .../sqlite/src/repositories/movie/mod.rs | 15 + .../{movie.rs => repositories/movie/query.rs} | 60 +-- .../sqlite/src/repositories/movie/rows.rs | 68 +++ .../movie_dedup/mod.rs} | 0 .../src/repositories/persons/command.rs | 117 +++++ .../sqlite/src/repositories/persons/mod.rs | 28 ++ .../sqlite/src/repositories/persons/query.rs | 142 +++++++ .../sqlite/src/repositories/persons/rows.rs | 72 ++++ .../persons/tests.rs} | 2 +- .../profile/mod.rs} | 1 - .../profile/tests.rs} | 2 +- .../profile_fields/mod.rs} | 0 .../refresh_sessions/mod.rs} | 0 .../remote_goals/mod.rs} | 0 .../{review.rs => repositories/review/mod.rs} | 4 +- .../sqlite/src/repositories/review/rows.rs | 33 ++ .../sqlite/src/repositories/search/command.rs | 133 ++++++ .../sqlite/src/repositories/search/mod.rs | 27 ++ .../search/query.rs} | 156 +------ .../src/{ => repositories}/search/tests.rs | 0 .../social/federated_profile.rs | 0 .../src/repositories/social/follow/command.rs | 130 ++++++ .../src/repositories/social/follow/mod.rs | 9 + .../social/follow/query.rs} | 192 +-------- .../src/repositories/social/follow/rows.rs | 61 +++ .../{ => repositories}/social/local_social.rs | 0 .../src/{ => repositories}/social/mod.rs | 11 +- .../social/tests.rs} | 0 .../{ => repositories}/social/watchlist.rs | 0 .../{stats.rs => repositories/stats/mod.rs} | 6 +- .../sqlite/src/repositories/stats/rows.rs | 45 ++ .../user_settings/mod.rs} | 0 .../{users.rs => repositories/users/mod.rs} | 5 +- .../sqlite/src/repositories/users/rows.rs | 27 ++ .../users.rs => repositories/users/tests.rs} | 0 .../src/repositories/watch_event/command.rs | 101 +++++ .../src/repositories/watch_event/mod.rs | 18 + .../src/repositories/watch_event/query.rs | 97 +++++ .../src/repositories/watch_event/rows.rs | 111 +++++ .../repositories/watch_event/webhook_token.rs | 110 +++++ .../watchlist/mod.rs} | 4 +- .../sqlite/src/repositories/watchlist/rows.rs | 33 ++ .../sqlite/src/repositories/wrapup/mod.rs | 200 +++++++++ .../wrapup/query.rs} | 265 +----------- .../sqlite/src/repositories/wrapup/rows.rs | 82 ++++ crates/adapters/sqlite/src/watch_event.rs | 399 ------------------ 65 files changed, 2500 insertions(+), 2346 deletions(-) delete mode 100644 crates/adapters/sqlite/src/models.rs delete mode 100644 crates/adapters/sqlite/src/persons.rs create mode 100644 crates/adapters/sqlite/src/repositories/ap_content/mod.rs rename crates/adapters/sqlite/src/{social/ap_content.rs => repositories/ap_content/rows.rs} (52%) create mode 100644 crates/adapters/sqlite/src/repositories/diary/mod.rs rename crates/adapters/sqlite/src/{diary.rs => repositories/diary/query.rs} (67%) create mode 100644 crates/adapters/sqlite/src/repositories/diary/rows.rs rename crates/adapters/sqlite/src/{tests/diary.rs => repositories/diary/tests.rs} (100%) rename crates/adapters/sqlite/src/{goals.rs => repositories/goals/mod.rs} (100%) rename crates/adapters/sqlite/src/{image_ref.rs => repositories/image_ref/mod.rs} (98%) rename crates/adapters/sqlite/src/{tests/image_ref.rs => repositories/image_ref/tests.rs} (100%) rename crates/adapters/sqlite/src/{import_profile.rs => repositories/import_profile/mod.rs} (62%) create mode 100644 crates/adapters/sqlite/src/repositories/import_profile/rows.rs rename crates/adapters/sqlite/src/{import_session.rs => repositories/import_session/mod.rs} (55%) create mode 100644 crates/adapters/sqlite/src/repositories/import_session/rows.rs rename crates/adapters/sqlite/src/{insights.rs => repositories/insights/mod.rs} (100%) create mode 100644 crates/adapters/sqlite/src/repositories/mod.rs create mode 100644 crates/adapters/sqlite/src/repositories/movie/command.rs create mode 100644 crates/adapters/sqlite/src/repositories/movie/mod.rs rename crates/adapters/sqlite/src/{movie.rs => repositories/movie/query.rs} (78%) create mode 100644 crates/adapters/sqlite/src/repositories/movie/rows.rs rename crates/adapters/sqlite/src/{movie_dedup.rs => repositories/movie_dedup/mod.rs} (100%) create mode 100644 crates/adapters/sqlite/src/repositories/persons/command.rs create mode 100644 crates/adapters/sqlite/src/repositories/persons/mod.rs create mode 100644 crates/adapters/sqlite/src/repositories/persons/query.rs create mode 100644 crates/adapters/sqlite/src/repositories/persons/rows.rs rename crates/adapters/sqlite/src/{tests/persons.rs => repositories/persons/tests.rs} (98%) rename crates/adapters/sqlite/src/{profile.rs => repositories/profile/mod.rs} (99%) rename crates/adapters/sqlite/src/{tests/profile.rs => repositories/profile/tests.rs} (98%) rename crates/adapters/sqlite/src/{profile_fields.rs => repositories/profile_fields/mod.rs} (100%) rename crates/adapters/sqlite/src/{refresh_sessions.rs => repositories/refresh_sessions/mod.rs} (100%) rename crates/adapters/sqlite/src/{social/remote_goals.rs => repositories/remote_goals/mod.rs} (100%) rename crates/adapters/sqlite/src/{review.rs => repositories/review/mod.rs} (98%) create mode 100644 crates/adapters/sqlite/src/repositories/review/rows.rs create mode 100644 crates/adapters/sqlite/src/repositories/search/command.rs create mode 100644 crates/adapters/sqlite/src/repositories/search/mod.rs rename crates/adapters/sqlite/src/{search.rs => repositories/search/query.rs} (60%) rename crates/adapters/sqlite/src/{ => repositories}/search/tests.rs (100%) rename crates/adapters/sqlite/src/{ => repositories}/social/federated_profile.rs (100%) create mode 100644 crates/adapters/sqlite/src/repositories/social/follow/command.rs create mode 100644 crates/adapters/sqlite/src/repositories/social/follow/mod.rs rename crates/adapters/sqlite/src/{social/follow_repository.rs => repositories/social/follow/query.rs} (51%) create mode 100644 crates/adapters/sqlite/src/repositories/social/follow/rows.rs rename crates/adapters/sqlite/src/{ => repositories}/social/local_social.rs (100%) rename crates/adapters/sqlite/src/{ => repositories}/social/mod.rs (73%) rename crates/adapters/sqlite/src/{social/tests/follow_relation_tests.rs => repositories/social/tests.rs} (100%) rename crates/adapters/sqlite/src/{ => repositories}/social/watchlist.rs (100%) rename crates/adapters/sqlite/src/{stats.rs => repositories/stats/mod.rs} (98%) create mode 100644 crates/adapters/sqlite/src/repositories/stats/rows.rs rename crates/adapters/sqlite/src/{user_settings.rs => repositories/user_settings/mod.rs} (100%) rename crates/adapters/sqlite/src/{users.rs => repositories/users/mod.rs} (99%) create mode 100644 crates/adapters/sqlite/src/repositories/users/rows.rs rename crates/adapters/sqlite/src/{tests/users.rs => repositories/users/tests.rs} (100%) create mode 100644 crates/adapters/sqlite/src/repositories/watch_event/command.rs create mode 100644 crates/adapters/sqlite/src/repositories/watch_event/mod.rs create mode 100644 crates/adapters/sqlite/src/repositories/watch_event/query.rs create mode 100644 crates/adapters/sqlite/src/repositories/watch_event/rows.rs create mode 100644 crates/adapters/sqlite/src/repositories/watch_event/webhook_token.rs rename crates/adapters/sqlite/src/{watchlist.rs => repositories/watchlist/mod.rs} (99%) create mode 100644 crates/adapters/sqlite/src/repositories/watchlist/rows.rs create mode 100644 crates/adapters/sqlite/src/repositories/wrapup/mod.rs rename crates/adapters/sqlite/src/{wrapup.rs => repositories/wrapup/query.rs} (51%) create mode 100644 crates/adapters/sqlite/src/repositories/wrapup/rows.rs delete mode 100644 crates/adapters/sqlite/src/watch_event.rs diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 87589544..f25126a2 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -1,54 +1,23 @@ use sqlx::SqlitePool; -mod diary; mod event_queue; -mod goals; -mod image_ref; -mod import_profile; -mod import_session; -mod insights; mod migrations; -mod models; -mod movie; -mod movie_dedup; -mod persons; -mod profile; -mod profile_fields; -mod refresh_sessions; -mod review; -mod search; +mod repositories; mod shared; -mod social; -mod stats; -mod user_settings; -mod users; -mod watch_event; -mod watchlist; -mod wrapup; -pub use diary::SqliteDiaryRepository; pub use event_queue::{DbEventQueueConfig, SqliteEventQueue}; -pub use image_ref::{SqliteImageRefAdapter, create_image_ref}; -pub use import_profile::SqliteImportProfileRepository; -pub use import_session::SqliteImportSessionRepository; -pub use insights::SqliteInsightsQuery; -pub use movie::SqliteMovieRepository; -pub use movie_dedup::SqliteMovieDeduplicator; -pub use persons::{SqlitePersonAdapter, create_person_adapter}; -pub use profile::SqliteMovieProfileRepository; -pub use profile_fields::SqliteProfileFieldsRepository; -pub use refresh_sessions::SqliteRefreshSessionAdapter; -pub use review::SqliteReviewRepository; -pub use search::create_search_adapter; -pub use social::{ - SqliteApContentQuery, SqliteRemoteGoalRepository, SqliteSocialRepository, - create_federated_profile_query, +pub use repositories::{ + SqliteApContentQuery, SqliteDiaryRepository, SqliteImageRefAdapter, + SqliteImportProfileRepository, SqliteImportSessionRepository, SqliteInsightsQuery, + SqliteMovieDeduplicator, SqliteMovieProfileRepository, SqliteMovieRepository, + SqlitePersonAdapter, SqliteProfileFieldsRepository, SqliteRefreshSessionAdapter, + SqliteRemoteGoalRepository, SqliteReviewRepository, SqliteSocialRepository, + SqliteStatsRepository, SqliteUserRepository, SqliteWatchEventRepository, + SqliteWatchlistRepository, SqliteWebhookTokenRepository, SqliteWrapUpRepository, + SqliteWrapUpStatsQuery, create_federated_profile_query, create_image_ref, + create_person_adapter, create_search_adapter, }; -pub use stats::SqliteStatsRepository; -pub use users::SqliteUserRepository; -pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository}; -pub use watchlist::SqliteWatchlistRepository; -pub use wrapup::{SqliteWrapUpRepository, SqliteWrapUpStatsQuery}; +use repositories::{SqliteGoalRepository, SqliteUserSettingsRepository}; /// Row helpers shared with the `sqlite-federation` crate (which cannot live /// here because it depends on `activitypub`/`k-ap`). @@ -111,9 +80,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result { .map_err(|e| anyhow::anyhow!("{e}")) .context("Database migration failed")?; - let user_settings_repo = std::sync::Arc::new(user_settings::SqliteUserSettingsRepository::new( - pool.clone(), - )); + let user_settings_repo = std::sync::Arc::new(SqliteUserSettingsRepository::new(pool.clone())); let movie_repo = std::sync::Arc::new(SqliteMovieRepository::new(pool.clone())); @@ -133,8 +100,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result { wrapup_repo: std::sync::Arc::new(SqliteWrapUpRepository::new(pool.clone())) as _, wrapup_stats: std::sync::Arc::new(SqliteWrapUpStatsQuery::new(pool.clone())) as _, insights: std::sync::Arc::new(SqliteInsightsQuery::new(pool.clone())) as _, - goal_command: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _, - goal_query: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _, + goal_command: std::sync::Arc::new(SqliteGoalRepository::new(pool.clone())) as _, + goal_query: std::sync::Arc::new(SqliteGoalRepository::new(pool.clone())) as _, user_settings: std::sync::Arc::clone(&user_settings_repo) as _, federation_settings: user_settings_repo as _, remote_goal: std::sync::Arc::new(SqliteRemoteGoalRepository::new(pool.clone())) as _, diff --git a/crates/adapters/sqlite/src/models.rs b/crates/adapters/sqlite/src/models.rs deleted file mode 100644 index 250a26da..00000000 --- a/crates/adapters/sqlite/src/models.rs +++ /dev/null @@ -1,325 +0,0 @@ -use crate::shared::{ - movie_row_to_domain, movie_stats_to_domain, movie_summary_to_domain, review_row_to_domain, - user_summary_to_domain, watchlist_entry_to_domain, watchlist_with_movie_to_domain, -}; -use domain::{ - errors::DomainError, - models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, WatchlistWithMovie}, -}; - -#[derive(sqlx::FromRow)] -pub(crate) struct MovieRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, -} - -impl MovieRow { - pub fn into_domain(self) -> Result { - movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct MovieSummaryRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, - pub genres: Option, - pub runtime_minutes: Option, - pub original_language: Option, - pub overview: Option, - pub collection_name: Option, -} - -impl MovieSummaryRow { - pub fn into_domain(self) -> Result { - let movie = movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - )?; - let genres = self - .genres - .map(|g| g.split(',').map(str::to_string).collect()) - .unwrap_or_default(); - Ok(movie_summary_to_domain( - movie, - genres, - self.runtime_minutes, - self.original_language, - self.overview, - self.collection_name, - )) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct ReviewRow { - pub id: String, - pub movie_id: String, - pub user_id: String, - pub rating: i64, - pub comment: Option, - pub watched_at: String, - pub created_at: String, - pub remote_actor_url: Option, - pub watch_medium: Option, - pub spoilers: bool, -} - -impl ReviewRow { - pub fn into_domain(self) -> Result { - review_row_to_domain( - self.id, - self.movie_id, - self.user_id, - self.rating, - self.comment, - self.watched_at, - self.created_at, - self.remote_actor_url, - self.watch_medium, - self.spoilers, - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct DiaryRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, - pub review_id: String, - pub movie_id: String, - pub user_id: String, - pub rating: i64, - pub comment: Option, - pub watched_at: String, - pub created_at: String, - pub remote_actor_url: Option, - pub watch_medium: Option, - pub spoilers: bool, -} - -impl DiaryRow { - pub fn into_domain(self) -> Result { - let movie = movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - )?; - let review = review_row_to_domain( - self.review_id, - self.movie_id, - self.user_id, - self.rating, - self.comment, - self.watched_at, - self.created_at, - self.remote_actor_url, - self.watch_medium, - self.spoilers, - )?; - Ok(DiaryEntry::new(movie, review)) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct MovieStatsRow { - pub total_count: i64, - pub avg_rating: Option, - pub federated_count: i64, - pub rating_1: i64, - pub rating_2: i64, - pub rating_3: i64, - pub rating_4: i64, - pub rating_5: i64, -} - -impl MovieStatsRow { - pub fn into_domain(self) -> domain::models::MovieStats { - movie_stats_to_domain( - self.total_count, - self.avg_rating, - self.federated_count, - [ - self.rating_1, - self.rating_2, - self.rating_3, - self.rating_4, - self.rating_5, - ], - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct FeedRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, - pub review_id: String, - pub movie_id: String, - pub user_id: String, - pub rating: i64, - pub comment: Option, - pub watched_at: String, - pub created_at: String, - pub remote_actor_url: Option, - pub watch_medium: Option, - pub spoilers: bool, - pub user_email: String, -} - -impl FeedRow { - pub fn into_domain(self) -> Result { - let movie = movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - )?; - let review = review_row_to_domain( - self.review_id, - self.movie_id, - self.user_id, - self.rating, - self.comment, - self.watched_at, - self.created_at, - self.remote_actor_url, - self.watch_medium, - self.spoilers, - )?; - let diary = DiaryEntry::new(movie, review); - Ok(FeedEntry::new(diary, self.user_email)) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct UserSummaryRow { - pub id: String, - pub email: String, - pub username: String, - pub display_name: Option, - pub total_movies: i64, - pub avg_rating: Option, - pub avatar_path: Option, -} - -impl UserSummaryRow { - pub fn into_domain(self) -> Result { - user_summary_to_domain( - self.id, - self.email, - self.username, - self.display_name, - self.total_movies, - self.avg_rating, - self.avatar_path, - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct UserTotalsRow { - pub total: i64, - pub avg_rating: Option, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct DirectorCountRow { - pub director: String, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct MonthlyRatingRow { - pub month: String, - pub avg_rating: f64, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct GenreCountRow { - pub genre: String, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct RatingDistRow { - pub rating: i64, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct WatchMediumCountRow { - pub watch_medium: String, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct ActorAppearanceRow { - pub tmdb_person_id: i64, - pub name: String, - pub profile_path: Option, - pub billing_order: i32, - pub movie_id: String, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct WatchlistRow { - pub id: String, - pub user_id: String, - pub movie_id: String, - pub added_at: String, - pub m_id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, -} - -impl WatchlistRow { - pub fn into_domain(self) -> Result { - let entry = watchlist_entry_to_domain(self.id, self.user_id, self.movie_id, self.added_at)?; - let movie = movie_row_to_domain( - self.m_id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - )?; - Ok(watchlist_with_movie_to_domain(entry, movie)) - } -} diff --git a/crates/adapters/sqlite/src/persons.rs b/crates/adapters/sqlite/src/persons.rs deleted file mode 100644 index 110e2e45..00000000 --- a/crates/adapters/sqlite/src/persons.rs +++ /dev/null @@ -1,348 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{ - CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData, - PersonId, - }, - ports::{PersonCommand, PersonQuery}, - value_objects::MovieId, -}; -use sqlx::SqlitePool; -use std::sync::Arc; - -pub struct SqlitePersonAdapter { - pool: SqlitePool, -} - -impl SqlitePersonAdapter { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -pub fn create_person_adapter(pool: SqlitePool) -> (Arc, Arc) { - let adapter = Arc::new(SqlitePersonAdapter::new(pool)); - ( - Arc::clone(&adapter) as Arc, - adapter as Arc, - ) -} - -#[async_trait] -impl PersonCommand for SqlitePersonAdapter { - async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> { - for person in persons { - let tmdb_id = person.external_id().tmdb_id(); - sqlx::query( - "INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - external_id = excluded.external_id, - tmdb_person_id = excluded.tmdb_person_id, - name = excluded.name, - known_for_department = excluded.known_for_department, - profile_path = excluded.profile_path", - ) - .bind(person.id().value().to_string()) - .bind(person.external_id().value()) - .bind(tmdb_id) - .bind(person.name()) - .bind(person.known_for_department()) - .bind(person.profile_path()) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - } - Ok(()) - } - - async fn backfill_from_credits_batch( - &self, - batch_size: u32, - ) -> Result<(u64, bool), DomainError> { - #[derive(sqlx::FromRow)] - struct MissingPerson { - tmdb_person_id: i64, - name: String, - department: Option, - profile_path: Option, - } - - let rows = sqlx::query_as::<_, MissingPerson>( - "SELECT mc.tmdb_person_id, mc.name, 'Acting' AS department, mc.profile_path - FROM movie_cast mc - WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id) - GROUP BY mc.tmdb_person_id - UNION ALL - SELECT mc.tmdb_person_id, mc.name, mc.department, mc.profile_path - FROM movie_crew mc - WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id) - AND NOT EXISTS (SELECT 1 FROM movie_cast c2 WHERE c2.tmdb_person_id = mc.tmdb_person_id) - GROUP BY mc.tmdb_person_id - LIMIT ?", - ) - .bind(batch_size) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - let has_more = rows.len() as u32 >= batch_size; - let mut count = 0u64; - for row in &rows { - let ext = ExternalPersonId::new(format!("tmdb:{}", row.tmdb_person_id)); - let pid = PersonId::from_external(&ext); - sqlx::query( - "INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(tmdb_person_id) DO NOTHING", - ) - .bind(pid.value().to_string()) - .bind(ext.value()) - .bind(row.tmdb_person_id) - .bind(&row.name) - .bind(&row.department) - .bind(&row.profile_path) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - count += 1; - } - Ok((count, has_more)) - } - - async fn update_enrichment( - &self, - id: &PersonId, - data: &PersonEnrichmentData, - ) -> Result<(), DomainError> { - let also_known_as_json = - serde_json::to_string(&data.also_known_as).unwrap_or_else(|_| "[]".into()); - let now = chrono::Utc::now().to_rfc3339(); - sqlx::query( - "UPDATE persons SET biography = ?, birthday = ?, deathday = ?, place_of_birth = ?, also_known_as = ?, homepage = ?, imdb_id = ?, enriched_at = ? WHERE id = ?", - ) - .bind(&data.biography) - .bind(data.birthday.map(|d| d.to_string())) - .bind(data.deathday.map(|d| d.to_string())) - .bind(&data.place_of_birth) - .bind(&also_known_as_json) - .bind(&data.homepage) - .bind(&data.imdb_id) - .bind(&now) - .bind(id.value().to_string()) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - Ok(()) - } -} - -#[async_trait] -impl PersonQuery for SqlitePersonAdapter { - async fn get_by_id(&self, id: &PersonId) -> Result, DomainError> { - let row = sqlx::query_as::<_, PersonRow>( - "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE id = ?", - ) - .bind(id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(row.map(PersonRow::into_person)) - } - - async fn get_by_external_id( - &self, - id: &ExternalPersonId, - ) -> Result, DomainError> { - let row = sqlx::query_as::<_, PersonRow>( - "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE external_id = ?", - ) - .bind(id.value()) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(row.map(PersonRow::into_person)) - } - - async fn get_credits(&self, id: &PersonId) -> Result { - let person = self - .get_by_id(id) - .await? - .ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?; - - let tmdb_id: Option = - sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = ?") - .bind(id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)? - .flatten(); - - let Some(tmdb_id) = tmdb_id else { - return Ok(PersonCredits { - person, - cast: vec![], - crew: vec![], - }); - }; - - let cast = sqlx::query_as::<_, CastRow>( - "SELECT m.id, m.title, m.release_year, mc.character, m.poster_path - FROM movie_cast mc - JOIN movies m ON m.id = mc.movie_id - WHERE mc.tmdb_person_id = ? - ORDER BY mc.billing_order", - ) - .bind(tmdb_id) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)? - .into_iter() - .map(|r| CastCredit { - movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), - title: r.title, - release_year: r.release_year.map(|y| y as u16), - character: r.character, - poster_path: r.poster_path, - }) - .collect(); - - let crew = sqlx::query_as::<_, CrewRow>( - "SELECT m.id, m.title, m.release_year, mc.job, mc.department, m.poster_path - FROM movie_crew mc - JOIN movies m ON m.id = mc.movie_id - WHERE mc.tmdb_person_id = ? - ORDER BY m.title", - ) - .bind(tmdb_id) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)? - .into_iter() - .map(|r| CrewCredit { - movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), - title: r.title, - release_year: r.release_year.map(|y| y as u16), - job: r.job, - department: r.department, - poster_path: r.poster_path, - }) - .collect(); - - Ok(PersonCredits { person, cast, crew }) - } - - async fn list_page(&self, limit: u32, offset: u32) -> Result, DomainError> { - let rows = sqlx::query_as::<_, PersonRow>( - "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons ORDER BY id LIMIT ? OFFSET ?", - ) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(rows.into_iter().map(PersonRow::into_person).collect()) - } - - async fn list_orphaned_persons(&self) -> Result, DomainError> { - let rows: Vec<(String,)> = sqlx::query_as( - "SELECT id FROM persons - WHERE NOT EXISTS ( - SELECT 1 FROM movie_cast WHERE movie_cast.tmdb_person_id = persons.tmdb_person_id - ) - AND NOT EXISTS ( - SELECT 1 FROM movie_crew WHERE movie_crew.tmdb_person_id = persons.tmdb_person_id - ) - LIMIT 500", - ) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(rows - .into_iter() - .filter_map(|(id,)| uuid::Uuid::parse_str(&id).ok().map(PersonId::from_uuid)) - .collect()) - } -} - -// ── Row types ──────────────────────────────────────────────────────────────── - -#[derive(sqlx::FromRow)] -struct PersonRow { - id: String, - external_id: String, - name: String, - known_for_department: Option, - profile_path: Option, - biography: Option, - birthday: Option, - deathday: Option, - place_of_birth: Option, - also_known_as: Option, - homepage: Option, - imdb_id: Option, - enriched_at: Option, -} - -impl PersonRow { - fn into_person(self) -> Person { - let ext = ExternalPersonId::new(self.external_id); - let also_known_as = self - .also_known_as - .and_then(|s| serde_json::from_str::>(&s).ok()) - .unwrap_or_default(); - let birthday = self - .birthday - .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); - let deathday = self - .deathday - .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); - let enriched_at = self - .enriched_at - .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) - .map(|d| d.with_timezone(&chrono::Utc)); - Person::new( - PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()), - ext, - self.name, - self.known_for_department, - self.profile_path, - self.biography, - birthday, - deathday, - self.place_of_birth, - also_known_as, - self.homepage, - self.imdb_id, - enriched_at, - ) - } -} - -#[derive(sqlx::FromRow)] -struct CastRow { - id: String, - title: String, - release_year: Option, - character: String, - poster_path: Option, -} - -#[derive(sqlx::FromRow)] -struct CrewRow { - id: String, - title: String, - release_year: Option, - job: String, - department: String, - poster_path: Option, -} - -#[cfg(test)] -#[path = "tests/persons.rs"] -mod tests; diff --git a/crates/adapters/sqlite/src/repositories/ap_content/mod.rs b/crates/adapters/sqlite/src/repositories/ap_content/mod.rs new file mode 100644 index 00000000..0887476b --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/ap_content/mod.rs @@ -0,0 +1,110 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{DiaryEntry, WatchlistWithMovie}, + ports::LocalApContentQuery, + value_objects::{MovieId, UserId}, +}; +use sqlx::SqlitePool; + +pub struct SqliteApContentQuery { + pool: SqlitePool, +} + +impl SqliteApContentQuery { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +mod rows; +use rows::{DiaryRow, WatchlistRow}; + +#[async_trait] +impl LocalApContentQuery for SqliteApContentQuery { + async fn get_local_watchlist_for_user( + &self, + user_id: &UserId, + ) -> Result, DomainError> { + let uid = user_id.value().to_string(); + let rows: Vec = sqlx::query_as( + "SELECT w.id, w.user_id, w.movie_id, w.added_at, + m.id AS m_id, m.external_metadata_id, m.title, m.release_year, + m.director, m.poster_path + FROM watchlist_entries w + JOIN movies m ON m.id = w.movie_id + WHERE w.user_id = ? + ORDER BY w.added_at DESC", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + rows.into_iter().map(WatchlistRow::into_domain).collect() + } + + async fn get_local_reviews_for_movie( + &self, + movie_id: &MovieId, + ) -> Result, DomainError> { + let mid = movie_id.value().to_string(); + let rows = sqlx::query_as::<_, DiaryRow>( + "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.movie_id = ? AND r.remote_actor_url IS NULL + ORDER BY r.created_at DESC", + ) + .bind(&mid) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + rows.into_iter().map(DiaryRow::into_domain).collect() + } + + async fn get_local_reviews_page( + &self, + user_id: &UserId, + before: Option, + limit: usize, + ) -> Result, DomainError> { + let uid = user_id.value().to_string(); + let limit_i64 = limit as i64; + + let rows = if let Some(before_ts) = before { + let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string(); + sqlx::query_as::<_, DiaryRow>( + "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ? + ORDER BY r.watched_at DESC + LIMIT ?", + ) + .bind(&uid) + .bind(&ts) + .bind(limit_i64) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)? + } else { + sqlx::query_as::<_, DiaryRow>( + "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ? AND r.remote_actor_url IS NULL + ORDER BY r.watched_at DESC + LIMIT ?", + ) + .bind(&uid) + .bind(limit_i64) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)? + }; + rows.into_iter().map(DiaryRow::into_domain).collect() + } +} diff --git a/crates/adapters/sqlite/src/social/ap_content.rs b/crates/adapters/sqlite/src/repositories/ap_content/rows.rs similarity index 52% rename from crates/adapters/sqlite/src/social/ap_content.rs rename to crates/adapters/sqlite/src/repositories/ap_content/rows.rs index b441b467..6880e659 100644 --- a/crates/adapters/sqlite/src/social/ap_content.rs +++ b/crates/adapters/sqlite/src/repositories/ap_content/rows.rs @@ -1,34 +1,19 @@ -use async_trait::async_trait; use domain::{ errors::DomainError, models::{ DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry, WatchlistWithMovie, }, - ports::LocalApContentQuery, value_objects::{ Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, ReviewId, UserId, WatchlistEntryId, }, }; -use sqlx::SqlitePool; - -pub struct SqliteApContentQuery { - pool: SqlitePool, -} - -impl SqliteApContentQuery { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -// ── Local row types ────────────────────────────────────────────────────────── use crate::shared::{parse_datetime, parse_uuid}; #[derive(sqlx::FromRow)] -struct MovieRow { +pub(super) struct MovieRow { id: String, external_metadata_id: Option, title: String, @@ -38,7 +23,7 @@ struct MovieRow { } impl MovieRow { - fn into_domain(self) -> Result { + pub(super) fn into_domain(self) -> Result { let id = MovieId::from_uuid(parse_uuid(&self.id)?); let external_metadata_id = self .external_metadata_id @@ -59,7 +44,7 @@ impl MovieRow { } #[derive(sqlx::FromRow)] -struct ReviewRow { +pub(super) struct ReviewRow { id: String, movie_id: String, user_id: String, @@ -73,7 +58,7 @@ struct ReviewRow { } impl ReviewRow { - fn into_domain(self) -> Result { + pub(super) fn into_domain(self) -> Result { let id = ReviewId::from_uuid(parse_uuid(&self.id)?); let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?); let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?); @@ -102,7 +87,7 @@ impl ReviewRow { } #[derive(sqlx::FromRow)] -struct DiaryRow { +pub(super) struct DiaryRow { id: String, external_metadata_id: Option, title: String, @@ -122,7 +107,7 @@ struct DiaryRow { } impl DiaryRow { - fn into_domain(self) -> Result { + pub(super) fn into_domain(self) -> Result { let movie = MovieRow { id: self.id, external_metadata_id: self.external_metadata_id, @@ -150,7 +135,7 @@ impl DiaryRow { } #[derive(sqlx::FromRow)] -struct WatchlistRow { +pub(super) struct WatchlistRow { id: String, user_id: String, movie_id: String, @@ -164,7 +149,7 @@ struct WatchlistRow { } impl WatchlistRow { - fn into_domain(self) -> Result { + pub(super) fn into_domain(self) -> Result { let entry = WatchlistEntry { id: WatchlistEntryId::from_uuid(parse_uuid(&self.id)?), user_id: UserId::from_uuid(parse_uuid(&self.user_id)?), @@ -185,92 +170,3 @@ impl WatchlistRow { } // ── LocalApContentQuery impl ───────────────────────────────────────────────── - -#[async_trait] -impl LocalApContentQuery for SqliteApContentQuery { - async fn get_local_watchlist_for_user( - &self, - user_id: &UserId, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let rows: Vec = sqlx::query_as( - "SELECT w.id, w.user_id, w.movie_id, w.added_at, - m.id AS m_id, m.external_metadata_id, m.title, m.release_year, - m.director, m.poster_path - FROM watchlist_entries w - JOIN movies m ON m.id = w.movie_id - WHERE w.user_id = ? - ORDER BY w.added_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - rows.into_iter().map(WatchlistRow::into_domain).collect() - } - - async fn get_local_reviews_for_movie( - &self, - movie_id: &MovieId, - ) -> Result, DomainError> { - let mid = movie_id.value().to_string(); - let rows = sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.movie_id = ? AND r.remote_actor_url IS NULL - ORDER BY r.created_at DESC", - ) - .bind(&mid) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - rows.into_iter().map(DiaryRow::into_domain).collect() - } - - async fn get_local_reviews_page( - &self, - user_id: &UserId, - before: Option, - limit: usize, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let limit_i64 = limit as i64; - - let rows = if let Some(before_ts) = before { - let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string(); - sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ? - ORDER BY r.watched_at DESC - LIMIT ?", - ) - .bind(&uid) - .bind(&ts) - .bind(limit_i64) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)? - } else { - sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = ? AND r.remote_actor_url IS NULL - ORDER BY r.watched_at DESC - LIMIT ?", - ) - .bind(&uid) - .bind(limit_i64) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)? - }; - rows.into_iter().map(DiaryRow::into_domain).collect() - } -} diff --git a/crates/adapters/sqlite/src/repositories/diary/mod.rs b/crates/adapters/sqlite/src/repositories/diary/mod.rs new file mode 100644 index 00000000..c1a211d7 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/diary/mod.rs @@ -0,0 +1,174 @@ +use domain::{errors::DomainError, models::ReviewSortBy}; +use sqlx::SqlitePool; + +use rows::DiaryRow; + +mod query; +mod rows; + +pub struct SqliteDiaryRepository { + pool: SqlitePool, +} + +impl SqliteDiaryRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result { + match movie_id { + None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews") + .fetch_one(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error), + Some(id) => { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error) + } + } + } + + async fn fetch_all_diary_rows( + &self, + sort: &ReviewSortBy, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + let order_clause = match sort { + ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", + ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", + ReviewSortBy::Ascending => "r.watched_at ASC", + ReviewSortBy::Descending => "r.watched_at DESC", + }; + let sql = format!( + "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + ORDER BY {} + LIMIT ? OFFSET ?", + order_clause + ); + sqlx::query_as::<_, DiaryRow>(&sql) + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error) + } + + async fn fetch_movie_diary_rows( + &self, + movie_id: &str, + sort: &ReviewSortBy, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + let order_clause = match sort { + ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", + ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", + ReviewSortBy::Ascending => "r.watched_at ASC", + ReviewSortBy::Descending => "r.watched_at DESC", + }; + let sql = format!( + "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.movie_id = ? + ORDER BY {} + LIMIT ? OFFSET ?", + order_clause + ); + sqlx::query_as::<_, DiaryRow>(&sql) + .bind(movie_id) + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error) + } + + async fn count_user_diary_entries( + &self, + user_id: &str, + search: Option<&str>, + include_remote: bool, + ) -> Result { + let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); + let remote_clause = if include_remote { + "" + } else { + " AND r.remote_actor_url IS NULL" + }; + let search_clause = if has_search { + " AND m.title LIKE '%' || ? || '%'" + } else { + "" + }; + let sql = format!( + "SELECT COUNT(*) FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ?{remote_clause}{search_clause}" + ); + let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id); + if has_search { + q = q.bind(search.unwrap()); + } + q.fetch_one(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error) + } + + async fn fetch_user_diary_rows( + &self, + user_id: &str, + sort: &ReviewSortBy, + search: Option<&str>, + include_remote: bool, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); + let remote_clause = if include_remote { + "" + } else { + " AND r.remote_actor_url IS NULL" + }; + let search_clause = if has_search { + " AND m.title LIKE '%' || ? || '%'" + } else { + "" + }; + let order_clause = match sort { + ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", + ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", + ReviewSortBy::Ascending => "r.watched_at ASC", + ReviewSortBy::Descending => "r.watched_at DESC", + }; + let sql = format!( + "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ?{remote_clause}{search_clause} + ORDER BY {order_clause} + LIMIT ? OFFSET ?", + ); + let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id); + if has_search { + q = q.bind(search.unwrap()); + } + q.bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/adapters/sqlite/src/diary.rs b/crates/adapters/sqlite/src/repositories/diary/query.rs similarity index 67% rename from crates/adapters/sqlite/src/diary.rs rename to crates/adapters/sqlite/src/repositories/diary/query.rs index 0ca6bb62..1ce028c8 100644 --- a/crates/adapters/sqlite/src/diary.rs +++ b/crates/adapters/sqlite/src/repositories/diary/query.rs @@ -2,180 +2,18 @@ use async_trait::async_trait; use domain::{ errors::DomainError, models::{ - DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, ReviewSortBy, + DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, collections::{PageParams, Paginated}, }, ports::DiaryQuery, value_objects::{MovieId, UserId}, }; use futures::stream::BoxStream; -use sqlx::SqlitePool; -use crate::models::{DiaryRow, FeedRow, MovieRow, MovieStatsRow, ReviewRow}; - -pub struct SqliteDiaryRepository { - pool: SqlitePool, -} - -impl SqliteDiaryRepository { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } - - async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result { - match movie_id { - None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews") - .fetch_one(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error), - Some(id) => { - sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = ?") - .bind(id) - .fetch_one(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error) - } - } - } - - async fn fetch_all_diary_rows( - &self, - sort: &ReviewSortBy, - limit: i64, - offset: i64, - ) -> Result, DomainError> { - let order_clause = match sort { - ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", - ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", - ReviewSortBy::Ascending => "r.watched_at ASC", - ReviewSortBy::Descending => "r.watched_at DESC", - }; - let sql = format!( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - ORDER BY {} - LIMIT ? OFFSET ?", - order_clause - ); - sqlx::query_as::<_, DiaryRow>(&sql) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error) - } - - async fn fetch_movie_diary_rows( - &self, - movie_id: &str, - sort: &ReviewSortBy, - limit: i64, - offset: i64, - ) -> Result, DomainError> { - let order_clause = match sort { - ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", - ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", - ReviewSortBy::Ascending => "r.watched_at ASC", - ReviewSortBy::Descending => "r.watched_at DESC", - }; - let sql = format!( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.movie_id = ? - ORDER BY {} - LIMIT ? OFFSET ?", - order_clause - ); - sqlx::query_as::<_, DiaryRow>(&sql) - .bind(movie_id) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error) - } - - async fn count_user_diary_entries( - &self, - user_id: &str, - search: Option<&str>, - include_remote: bool, - ) -> Result { - let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); - let remote_clause = if include_remote { - "" - } else { - " AND r.remote_actor_url IS NULL" - }; - let search_clause = if has_search { - " AND m.title LIKE '%' || ? || '%'" - } else { - "" - }; - let sql = format!( - "SELECT COUNT(*) FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = ?{remote_clause}{search_clause}" - ); - let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id); - if has_search { - q = q.bind(search.unwrap()); - } - q.fetch_one(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error) - } - - async fn fetch_user_diary_rows( - &self, - user_id: &str, - sort: &ReviewSortBy, - search: Option<&str>, - include_remote: bool, - limit: i64, - offset: i64, - ) -> Result, DomainError> { - let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); - let remote_clause = if include_remote { - "" - } else { - " AND r.remote_actor_url IS NULL" - }; - let search_clause = if has_search { - " AND m.title LIKE '%' || ? || '%'" - } else { - "" - }; - let order_clause = match sort { - ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", - ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", - ReviewSortBy::Ascending => "r.watched_at ASC", - ReviewSortBy::Descending => "r.watched_at DESC", - }; - let sql = format!( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = ?{remote_clause}{search_clause} - ORDER BY {order_clause} - LIMIT ? OFFSET ?", - ); - let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id); - if has_search { - q = q.bind(search.unwrap()); - } - q.bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error) - } -} +use super::SqliteDiaryRepository; +use super::rows::{DiaryRow, FeedRow, MovieStatsRow}; +use crate::repositories::movie::rows::MovieRow; +use crate::repositories::review::rows::ReviewRow; #[async_trait] impl DiaryQuery for SqliteDiaryRepository { @@ -516,7 +354,3 @@ impl DiaryQuery for SqliteDiaryRepository { Ok(count as u64) } } - -#[cfg(test)] -#[path = "tests/diary.rs"] -mod tests; diff --git a/crates/adapters/sqlite/src/repositories/diary/rows.rs b/crates/adapters/sqlite/src/repositories/diary/rows.rs new file mode 100644 index 00000000..1150c8ea --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/diary/rows.rs @@ -0,0 +1,128 @@ +use crate::shared::{movie_row_to_domain, movie_stats_to_domain, review_row_to_domain}; +use domain::{ + errors::DomainError, + models::{DiaryEntry, FeedEntry}, +}; + +#[derive(sqlx::FromRow)] +pub(crate) struct DiaryRow { + pub id: String, + pub external_metadata_id: Option, + pub title: String, + pub release_year: i64, + pub director: Option, + pub poster_path: Option, + pub review_id: String, + pub movie_id: String, + pub user_id: String, + pub rating: i64, + pub comment: Option, + pub watched_at: String, + pub created_at: String, + pub remote_actor_url: Option, + pub watch_medium: Option, + pub spoilers: bool, +} + +impl DiaryRow { + pub fn into_domain(self) -> Result { + let movie = movie_row_to_domain( + self.id, + self.external_metadata_id, + self.title, + self.release_year, + self.director, + self.poster_path, + )?; + let review = review_row_to_domain( + self.review_id, + self.movie_id, + self.user_id, + self.rating, + self.comment, + self.watched_at, + self.created_at, + self.remote_actor_url, + self.watch_medium, + self.spoilers, + )?; + Ok(DiaryEntry::new(movie, review)) + } +} + +#[derive(sqlx::FromRow)] +pub(crate) struct FeedRow { + pub id: String, + pub external_metadata_id: Option, + pub title: String, + pub release_year: i64, + pub director: Option, + pub poster_path: Option, + pub review_id: String, + pub movie_id: String, + pub user_id: String, + pub rating: i64, + pub comment: Option, + pub watched_at: String, + pub created_at: String, + pub remote_actor_url: Option, + pub watch_medium: Option, + pub spoilers: bool, + pub user_email: String, +} + +impl FeedRow { + pub fn into_domain(self) -> Result { + let movie = movie_row_to_domain( + self.id, + self.external_metadata_id, + self.title, + self.release_year, + self.director, + self.poster_path, + )?; + let review = review_row_to_domain( + self.review_id, + self.movie_id, + self.user_id, + self.rating, + self.comment, + self.watched_at, + self.created_at, + self.remote_actor_url, + self.watch_medium, + self.spoilers, + )?; + let diary = DiaryEntry::new(movie, review); + Ok(FeedEntry::new(diary, self.user_email)) + } +} + +#[derive(sqlx::FromRow)] +pub(crate) struct MovieStatsRow { + pub total_count: i64, + pub avg_rating: Option, + pub federated_count: i64, + pub rating_1: i64, + pub rating_2: i64, + pub rating_3: i64, + pub rating_4: i64, + pub rating_5: i64, +} + +impl MovieStatsRow { + pub fn into_domain(self) -> domain::models::MovieStats { + movie_stats_to_domain( + self.total_count, + self.avg_rating, + self.federated_count, + [ + self.rating_1, + self.rating_2, + self.rating_3, + self.rating_4, + self.rating_5, + ], + ) + } +} diff --git a/crates/adapters/sqlite/src/tests/diary.rs b/crates/adapters/sqlite/src/repositories/diary/tests.rs similarity index 100% rename from crates/adapters/sqlite/src/tests/diary.rs rename to crates/adapters/sqlite/src/repositories/diary/tests.rs diff --git a/crates/adapters/sqlite/src/goals.rs b/crates/adapters/sqlite/src/repositories/goals/mod.rs similarity index 100% rename from crates/adapters/sqlite/src/goals.rs rename to crates/adapters/sqlite/src/repositories/goals/mod.rs diff --git a/crates/adapters/sqlite/src/image_ref.rs b/crates/adapters/sqlite/src/repositories/image_ref/mod.rs similarity index 98% rename from crates/adapters/sqlite/src/image_ref.rs rename to crates/adapters/sqlite/src/repositories/image_ref/mod.rs index 0da8a0b2..322d1b90 100644 --- a/crates/adapters/sqlite/src/image_ref.rs +++ b/crates/adapters/sqlite/src/repositories/image_ref/mod.rs @@ -66,5 +66,4 @@ impl ImageRefQuery for SqliteImageRefAdapter { } #[cfg(test)] -#[path = "tests/image_ref.rs"] mod tests; diff --git a/crates/adapters/sqlite/src/tests/image_ref.rs b/crates/adapters/sqlite/src/repositories/image_ref/tests.rs similarity index 100% rename from crates/adapters/sqlite/src/tests/image_ref.rs rename to crates/adapters/sqlite/src/repositories/image_ref/tests.rs diff --git a/crates/adapters/sqlite/src/import_profile.rs b/crates/adapters/sqlite/src/repositories/import_profile/mod.rs similarity index 62% rename from crates/adapters/sqlite/src/import_profile.rs rename to crates/adapters/sqlite/src/repositories/import_profile/mod.rs index 86bf680d..c8324617 100644 --- a/crates/adapters/sqlite/src/import_profile.rs +++ b/crates/adapters/sqlite/src/repositories/import_profile/mod.rs @@ -2,91 +2,14 @@ use async_trait::async_trait; use chrono::NaiveDateTime; use domain::{ errors::DomainError, - models::{ - FieldMapping, ImportProfile, - import::{DomainField, Transform}, - }, + models::ImportProfile, ports::ImportProfileRepository, value_objects::{ImportProfileId, UserId}, }; -use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; -#[derive(Serialize, Deserialize)] -enum DomainFieldJson { - Title, - ReleaseYear, - Director, - Rating, - WatchedAt, - Comment, - ExternalMetadataId, -} - -#[derive(Serialize, Deserialize)] -enum TransformJson { - RatingScale(f64), - DateFormat(String), - Identity, -} - -#[derive(Serialize, Deserialize)] -struct FieldMappingJson { - source_column: String, - domain_field: DomainFieldJson, - transform: TransformJson, -} - -fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson { - FieldMappingJson { - source_column: m.source_column.clone(), - domain_field: match &m.domain_field { - DomainField::Title => DomainFieldJson::Title, - DomainField::ReleaseYear => DomainFieldJson::ReleaseYear, - DomainField::Director => DomainFieldJson::Director, - DomainField::Rating => DomainFieldJson::Rating, - DomainField::WatchedAt => DomainFieldJson::WatchedAt, - DomainField::Comment => DomainFieldJson::Comment, - DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId, - }, - transform: match &m.transform { - Transform::RatingScale(f) => TransformJson::RatingScale(*f), - Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()), - Transform::Identity => TransformJson::Identity, - }, - } -} - -fn mapping_from_json(j: FieldMappingJson) -> FieldMapping { - FieldMapping { - source_column: j.source_column, - domain_field: match j.domain_field { - DomainFieldJson::Title => DomainField::Title, - DomainFieldJson::ReleaseYear => DomainField::ReleaseYear, - DomainFieldJson::Director => DomainField::Director, - DomainFieldJson::Rating => DomainField::Rating, - DomainFieldJson::WatchedAt => DomainField::WatchedAt, - DomainFieldJson::Comment => DomainField::Comment, - DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId, - }, - transform: match j.transform { - TransformJson::RatingScale(f) => Transform::RatingScale(f), - TransformJson::DateFormat(s) => Transform::DateFormat(s), - TransformJson::Identity => Transform::Identity, - }, - } -} - -fn serialize_mappings(ms: &[FieldMapping]) -> Result { - serde_json::to_string(&ms.iter().map(mapping_to_json).collect::>()) - .map_err(|e| DomainError::InfrastructureError(e.to_string())) -} - -fn deserialize_mappings(s: &str) -> Result, DomainError> { - let js: Vec = - serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(js.into_iter().map(mapping_from_json).collect()) -} +mod rows; +use rows::*; pub struct SqliteImportProfileRepository { pool: SqlitePool, diff --git a/crates/adapters/sqlite/src/repositories/import_profile/rows.rs b/crates/adapters/sqlite/src/repositories/import_profile/rows.rs new file mode 100644 index 00000000..3e87dc15 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/import_profile/rows.rs @@ -0,0 +1,84 @@ +use domain::{ + errors::DomainError, + models::{ + FieldMapping, + import::{DomainField, Transform}, + }, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub(super) enum DomainFieldJson { + Title, + ReleaseYear, + Director, + Rating, + WatchedAt, + Comment, + ExternalMetadataId, +} + +#[derive(Serialize, Deserialize)] +pub(super) enum TransformJson { + RatingScale(f64), + DateFormat(String), + Identity, +} + +#[derive(Serialize, Deserialize)] +pub(super) struct FieldMappingJson { + source_column: String, + domain_field: DomainFieldJson, + transform: TransformJson, +} + +pub(super) fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson { + FieldMappingJson { + source_column: m.source_column.clone(), + domain_field: match &m.domain_field { + DomainField::Title => DomainFieldJson::Title, + DomainField::ReleaseYear => DomainFieldJson::ReleaseYear, + DomainField::Director => DomainFieldJson::Director, + DomainField::Rating => DomainFieldJson::Rating, + DomainField::WatchedAt => DomainFieldJson::WatchedAt, + DomainField::Comment => DomainFieldJson::Comment, + DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId, + }, + transform: match &m.transform { + Transform::RatingScale(f) => TransformJson::RatingScale(*f), + Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()), + Transform::Identity => TransformJson::Identity, + }, + } +} + +pub(super) fn mapping_from_json(j: FieldMappingJson) -> FieldMapping { + FieldMapping { + source_column: j.source_column, + domain_field: match j.domain_field { + DomainFieldJson::Title => DomainField::Title, + DomainFieldJson::ReleaseYear => DomainField::ReleaseYear, + DomainFieldJson::Director => DomainField::Director, + DomainFieldJson::Rating => DomainField::Rating, + DomainFieldJson::WatchedAt => DomainField::WatchedAt, + DomainFieldJson::Comment => DomainField::Comment, + DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId, + }, + transform: match j.transform { + TransformJson::RatingScale(f) => Transform::RatingScale(f), + TransformJson::DateFormat(s) => Transform::DateFormat(s), + TransformJson::Identity => Transform::Identity, + }, + } +} + +pub(super) fn serialize_mappings(ms: &[FieldMapping]) -> Result { + serde_json::to_string(&ms.iter().map(mapping_to_json).collect::>()) + .map_err(|e| DomainError::InfrastructureError(e.to_string())) +} + +pub(super) fn deserialize_mappings(s: &str) -> Result, DomainError> { + let js: Vec = + serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(js.into_iter().map(mapping_from_json).collect()) +} diff --git a/crates/adapters/sqlite/src/import_session.rs b/crates/adapters/sqlite/src/repositories/import_session/mod.rs similarity index 55% rename from crates/adapters/sqlite/src/import_session.rs rename to crates/adapters/sqlite/src/repositories/import_session/mod.rs index fd58a00a..7029ce6b 100644 --- a/crates/adapters/sqlite/src/import_session.rs +++ b/crates/adapters/sqlite/src/repositories/import_session/mod.rs @@ -2,194 +2,14 @@ use async_trait::async_trait; use chrono::NaiveDateTime; use domain::{ errors::DomainError, - models::{ - AnnotatedRow, FieldMapping, ImportSession, ParsedFile, - import::{DomainField, ImportRow, RowResult, Transform}, - }, + models::{AnnotatedRow, FieldMapping, ImportSession, ParsedFile}, ports::ImportSessionRepository, value_objects::{ImportSessionId, UserId}, }; -use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; -// ── serde mirror structs (match the JSON format from the old importer types) ── - -#[derive(Serialize, Deserialize, Default)] -struct ParsedFileJson { - columns: Vec, - rows: Vec>, -} - -#[derive(Serialize, Deserialize)] -enum DomainFieldJson { - Title, - ReleaseYear, - Director, - Rating, - WatchedAt, - Comment, - ExternalMetadataId, -} - -#[derive(Serialize, Deserialize)] -enum TransformJson { - RatingScale(f64), - DateFormat(String), - Identity, -} - -#[derive(Serialize, Deserialize)] -struct FieldMappingJson { - source_column: String, - domain_field: DomainFieldJson, - transform: TransformJson, -} - -#[derive(Serialize, Deserialize, Default)] -struct ImportRowJson { - #[serde(skip_serializing_if = "Option::is_none")] - title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - release_year: Option, - #[serde(skip_serializing_if = "Option::is_none")] - director: Option, - #[serde(skip_serializing_if = "Option::is_none")] - rating: Option, - #[serde(skip_serializing_if = "Option::is_none")] - watched_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - comment: Option, - #[serde(skip_serializing_if = "Option::is_none")] - external_metadata_id: Option, -} - -#[derive(Serialize, Deserialize)] -enum RowResultJson { - Valid(ImportRowJson), - Invalid { - errors: Vec, - raw: Vec<(String, String)>, - }, -} - -#[derive(Serialize, Deserialize)] -struct AnnotatedRowJson { - result: RowResultJson, - is_duplicate: bool, -} - -// ── conversion helpers ── - -fn domain_field_to_json(f: &DomainField) -> DomainFieldJson { - match f { - DomainField::Title => DomainFieldJson::Title, - DomainField::ReleaseYear => DomainFieldJson::ReleaseYear, - DomainField::Director => DomainFieldJson::Director, - DomainField::Rating => DomainFieldJson::Rating, - DomainField::WatchedAt => DomainFieldJson::WatchedAt, - DomainField::Comment => DomainFieldJson::Comment, - DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId, - } -} - -fn domain_field_from_json(j: DomainFieldJson) -> DomainField { - match j { - DomainFieldJson::Title => DomainField::Title, - DomainFieldJson::ReleaseYear => DomainField::ReleaseYear, - DomainFieldJson::Director => DomainField::Director, - DomainFieldJson::Rating => DomainField::Rating, - DomainFieldJson::WatchedAt => DomainField::WatchedAt, - DomainFieldJson::Comment => DomainField::Comment, - DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId, - } -} - -fn transform_to_json(t: &Transform) -> TransformJson { - match t { - Transform::RatingScale(f) => TransformJson::RatingScale(*f), - Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()), - Transform::Identity => TransformJson::Identity, - } -} - -fn transform_from_json(j: TransformJson) -> Transform { - match j { - TransformJson::RatingScale(f) => Transform::RatingScale(f), - TransformJson::DateFormat(s) => Transform::DateFormat(s), - TransformJson::Identity => Transform::Identity, - } -} - -fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson { - FieldMappingJson { - source_column: m.source_column.clone(), - domain_field: domain_field_to_json(&m.domain_field), - transform: transform_to_json(&m.transform), - } -} - -fn mapping_from_json(j: FieldMappingJson) -> FieldMapping { - FieldMapping { - source_column: j.source_column, - domain_field: domain_field_from_json(j.domain_field), - transform: transform_from_json(j.transform), - } -} - -fn import_row_to_json(r: &ImportRow) -> ImportRowJson { - ImportRowJson { - title: r.title.clone(), - release_year: r.release_year.clone(), - director: r.director.clone(), - rating: r.rating.clone(), - watched_at: r.watched_at.clone(), - comment: r.comment.clone(), - external_metadata_id: r.external_metadata_id.clone(), - } -} - -fn import_row_from_json(j: ImportRowJson) -> ImportRow { - ImportRow { - title: j.title, - release_year: j.release_year, - director: j.director, - rating: j.rating, - watched_at: j.watched_at, - comment: j.comment, - external_metadata_id: j.external_metadata_id, - } -} - -fn annotated_to_json(a: &AnnotatedRow) -> AnnotatedRowJson { - AnnotatedRowJson { - result: match &a.result { - RowResult::Valid(row) => RowResultJson::Valid(import_row_to_json(row)), - RowResult::Invalid { errors, raw } => RowResultJson::Invalid { - errors: errors.clone(), - raw: raw.clone(), - }, - }, - is_duplicate: a.is_duplicate, - } -} - -fn annotated_from_json(j: AnnotatedRowJson) -> AnnotatedRow { - AnnotatedRow { - result: match j.result { - RowResultJson::Valid(row) => RowResult::Valid(import_row_from_json(row)), - RowResultJson::Invalid { errors, raw } => RowResult::Invalid { errors, raw }, - }, - is_duplicate: j.is_duplicate, - } -} - -fn ser(v: &T) -> Result { - serde_json::to_string(v).map_err(|e| DomainError::InfrastructureError(e.to_string())) -} - -fn de Deserialize<'de>>(s: &str) -> Result { - serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string())) -} +mod rows; +use rows::*; // ── repository ── diff --git a/crates/adapters/sqlite/src/repositories/import_session/rows.rs b/crates/adapters/sqlite/src/repositories/import_session/rows.rs new file mode 100644 index 00000000..82945559 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/import_session/rows.rs @@ -0,0 +1,187 @@ +use domain::{ + errors::DomainError, + models::{ + AnnotatedRow, FieldMapping, + import::{DomainField, ImportRow, RowResult, Transform}, + }, +}; +use serde::{Deserialize, Serialize}; + +// ── serde mirror structs (match the JSON format from the old importer types) ── + +#[derive(Serialize, Deserialize, Default)] +pub(super) struct ParsedFileJson { + pub(super) columns: Vec, + pub(super) rows: Vec>, +} + +#[derive(Serialize, Deserialize)] +pub(super) enum DomainFieldJson { + Title, + ReleaseYear, + Director, + Rating, + WatchedAt, + Comment, + ExternalMetadataId, +} + +#[derive(Serialize, Deserialize)] +pub(super) enum TransformJson { + RatingScale(f64), + DateFormat(String), + Identity, +} + +#[derive(Serialize, Deserialize)] +pub(super) struct FieldMappingJson { + source_column: String, + domain_field: DomainFieldJson, + transform: TransformJson, +} + +#[derive(Serialize, Deserialize, Default)] +pub(super) struct ImportRowJson { + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + release_year: Option, + #[serde(skip_serializing_if = "Option::is_none")] + director: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rating: Option, + #[serde(skip_serializing_if = "Option::is_none")] + watched_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + comment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + external_metadata_id: Option, +} + +#[derive(Serialize, Deserialize)] +pub(super) enum RowResultJson { + Valid(ImportRowJson), + Invalid { + errors: Vec, + raw: Vec<(String, String)>, + }, +} + +#[derive(Serialize, Deserialize)] +pub(super) struct AnnotatedRowJson { + result: RowResultJson, + is_duplicate: bool, +} + +// ── conversion helpers ── + +pub(super) fn domain_field_to_json(f: &DomainField) -> DomainFieldJson { + match f { + DomainField::Title => DomainFieldJson::Title, + DomainField::ReleaseYear => DomainFieldJson::ReleaseYear, + DomainField::Director => DomainFieldJson::Director, + DomainField::Rating => DomainFieldJson::Rating, + DomainField::WatchedAt => DomainFieldJson::WatchedAt, + DomainField::Comment => DomainFieldJson::Comment, + DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId, + } +} + +pub(super) fn domain_field_from_json(j: DomainFieldJson) -> DomainField { + match j { + DomainFieldJson::Title => DomainField::Title, + DomainFieldJson::ReleaseYear => DomainField::ReleaseYear, + DomainFieldJson::Director => DomainField::Director, + DomainFieldJson::Rating => DomainField::Rating, + DomainFieldJson::WatchedAt => DomainField::WatchedAt, + DomainFieldJson::Comment => DomainField::Comment, + DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId, + } +} + +pub(super) fn transform_to_json(t: &Transform) -> TransformJson { + match t { + Transform::RatingScale(f) => TransformJson::RatingScale(*f), + Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()), + Transform::Identity => TransformJson::Identity, + } +} + +pub(super) fn transform_from_json(j: TransformJson) -> Transform { + match j { + TransformJson::RatingScale(f) => Transform::RatingScale(f), + TransformJson::DateFormat(s) => Transform::DateFormat(s), + TransformJson::Identity => Transform::Identity, + } +} + +pub(super) fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson { + FieldMappingJson { + source_column: m.source_column.clone(), + domain_field: domain_field_to_json(&m.domain_field), + transform: transform_to_json(&m.transform), + } +} + +pub(super) fn mapping_from_json(j: FieldMappingJson) -> FieldMapping { + FieldMapping { + source_column: j.source_column, + domain_field: domain_field_from_json(j.domain_field), + transform: transform_from_json(j.transform), + } +} + +pub(super) fn import_row_to_json(r: &ImportRow) -> ImportRowJson { + ImportRowJson { + title: r.title.clone(), + release_year: r.release_year.clone(), + director: r.director.clone(), + rating: r.rating.clone(), + watched_at: r.watched_at.clone(), + comment: r.comment.clone(), + external_metadata_id: r.external_metadata_id.clone(), + } +} + +pub(super) fn import_row_from_json(j: ImportRowJson) -> ImportRow { + ImportRow { + title: j.title, + release_year: j.release_year, + director: j.director, + rating: j.rating, + watched_at: j.watched_at, + comment: j.comment, + external_metadata_id: j.external_metadata_id, + } +} + +pub(super) fn annotated_to_json(a: &AnnotatedRow) -> AnnotatedRowJson { + AnnotatedRowJson { + result: match &a.result { + RowResult::Valid(row) => RowResultJson::Valid(import_row_to_json(row)), + RowResult::Invalid { errors, raw } => RowResultJson::Invalid { + errors: errors.clone(), + raw: raw.clone(), + }, + }, + is_duplicate: a.is_duplicate, + } +} + +pub(super) fn annotated_from_json(j: AnnotatedRowJson) -> AnnotatedRow { + AnnotatedRow { + result: match j.result { + RowResultJson::Valid(row) => RowResult::Valid(import_row_from_json(row)), + RowResultJson::Invalid { errors, raw } => RowResult::Invalid { errors, raw }, + }, + is_duplicate: j.is_duplicate, + } +} + +pub(super) fn ser(v: &T) -> Result { + serde_json::to_string(v).map_err(|e| DomainError::InfrastructureError(e.to_string())) +} + +pub(super) fn de Deserialize<'de>>(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string())) +} diff --git a/crates/adapters/sqlite/src/insights.rs b/crates/adapters/sqlite/src/repositories/insights/mod.rs similarity index 100% rename from crates/adapters/sqlite/src/insights.rs rename to crates/adapters/sqlite/src/repositories/insights/mod.rs diff --git a/crates/adapters/sqlite/src/repositories/mod.rs b/crates/adapters/sqlite/src/repositories/mod.rs new file mode 100644 index 00000000..53803c82 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/mod.rs @@ -0,0 +1,47 @@ +mod ap_content; +mod diary; +mod goals; +mod image_ref; +mod import_profile; +mod import_session; +mod insights; +mod movie; +mod movie_dedup; +mod persons; +mod profile; +mod profile_fields; +mod refresh_sessions; +mod remote_goals; +mod review; +mod search; +mod social; +mod stats; +mod user_settings; +mod users; +mod watch_event; +mod watchlist; +mod wrapup; + +pub use ap_content::SqliteApContentQuery; +pub use diary::SqliteDiaryRepository; +pub use goals::SqliteGoalRepository; +pub use image_ref::{SqliteImageRefAdapter, create_image_ref}; +pub use import_profile::SqliteImportProfileRepository; +pub use import_session::SqliteImportSessionRepository; +pub use insights::SqliteInsightsQuery; +pub use movie::SqliteMovieRepository; +pub use movie_dedup::SqliteMovieDeduplicator; +pub use persons::{SqlitePersonAdapter, create_person_adapter}; +pub use profile::SqliteMovieProfileRepository; +pub use profile_fields::SqliteProfileFieldsRepository; +pub use refresh_sessions::SqliteRefreshSessionAdapter; +pub use remote_goals::SqliteRemoteGoalRepository; +pub use review::SqliteReviewRepository; +pub use search::create_search_adapter; +pub use social::{SqliteSocialRepository, create_federated_profile_query}; +pub use stats::SqliteStatsRepository; +pub use user_settings::SqliteUserSettingsRepository; +pub use users::SqliteUserRepository; +pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository}; +pub use watchlist::SqliteWatchlistRepository; +pub use wrapup::{SqliteWrapUpRepository, SqliteWrapUpStatsQuery}; diff --git a/crates/adapters/sqlite/src/repositories/movie/command.rs b/crates/adapters/sqlite/src/repositories/movie/command.rs new file mode 100644 index 00000000..79946f26 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/movie/command.rs @@ -0,0 +1,48 @@ +use async_trait::async_trait; +use domain::{errors::DomainError, models::Movie, ports::MovieCommand, value_objects::MovieId}; + +use super::SqliteMovieRepository; + +#[async_trait] +impl MovieCommand for SqliteMovieRepository { + async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { + let id = movie.id().value().to_string(); + let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string()); + let title = movie.title().value(); + let release_year = movie.release_year().value() as i64; + let director = movie.director(); + let poster_path = movie.poster_path().map(|p| p.value().to_string()); + + sqlx::query( + "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + external_metadata_id = excluded.external_metadata_id, + title = excluded.title, + release_year = excluded.release_year, + director = excluded.director, + poster_path = excluded.poster_path", + ) + .bind(&id) + .bind(&external_metadata_id) + .bind(title) + .bind(release_year) + .bind(director) + .bind(&poster_path) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { + let id = movie_id.value().to_string(); + sqlx::query("DELETE FROM movies WHERE id = ?") + .bind(&id) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(()) + } +} diff --git a/crates/adapters/sqlite/src/repositories/movie/mod.rs b/crates/adapters/sqlite/src/repositories/movie/mod.rs new file mode 100644 index 00000000..2e4af344 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/movie/mod.rs @@ -0,0 +1,15 @@ +use sqlx::SqlitePool; + +mod command; +mod query; +pub(crate) mod rows; + +pub struct SqliteMovieRepository { + pool: SqlitePool, +} + +impl SqliteMovieRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} diff --git a/crates/adapters/sqlite/src/movie.rs b/crates/adapters/sqlite/src/repositories/movie/query.rs similarity index 78% rename from crates/adapters/sqlite/src/movie.rs rename to crates/adapters/sqlite/src/repositories/movie/query.rs index e60029e6..1c61473f 100644 --- a/crates/adapters/sqlite/src/movie.rs +++ b/crates/adapters/sqlite/src/repositories/movie/query.rs @@ -5,66 +5,12 @@ use domain::{ Movie, MovieFilter, MovieSummary, collections::{PageParams, Paginated}, }, - ports::{MovieCommand, MovieQuery}, + ports::MovieQuery, value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear}, }; -use sqlx::SqlitePool; -use crate::models::{MovieRow, MovieSummaryRow}; - -pub struct SqliteMovieRepository { - pool: SqlitePool, -} - -impl SqliteMovieRepository { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl MovieCommand for SqliteMovieRepository { - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { - let id = movie.id().value().to_string(); - let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string()); - let title = movie.title().value(); - let release_year = movie.release_year().value() as i64; - let director = movie.director(); - let poster_path = movie.poster_path().map(|p| p.value().to_string()); - - sqlx::query( - "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - external_metadata_id = excluded.external_metadata_id, - title = excluded.title, - release_year = excluded.release_year, - director = excluded.director, - poster_path = excluded.poster_path", - ) - .bind(&id) - .bind(&external_metadata_id) - .bind(title) - .bind(release_year) - .bind(director) - .bind(&poster_path) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { - let id = movie_id.value().to_string(); - sqlx::query("DELETE FROM movies WHERE id = ?") - .bind(&id) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - Ok(()) - } -} +use super::SqliteMovieRepository; +use super::rows::{MovieRow, MovieSummaryRow}; #[async_trait] impl MovieQuery for SqliteMovieRepository { diff --git a/crates/adapters/sqlite/src/repositories/movie/rows.rs b/crates/adapters/sqlite/src/repositories/movie/rows.rs new file mode 100644 index 00000000..ff3eb4da --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/movie/rows.rs @@ -0,0 +1,68 @@ +use crate::shared::{movie_row_to_domain, movie_summary_to_domain}; +use domain::{ + errors::DomainError, + models::{Movie, MovieSummary}, +}; + +#[derive(sqlx::FromRow)] +pub(crate) struct MovieRow { + pub id: String, + pub external_metadata_id: Option, + pub title: String, + pub release_year: i64, + pub director: Option, + pub poster_path: Option, +} + +impl MovieRow { + pub fn into_domain(self) -> Result { + movie_row_to_domain( + self.id, + self.external_metadata_id, + self.title, + self.release_year, + self.director, + self.poster_path, + ) + } +} + +#[derive(sqlx::FromRow)] +pub(crate) struct MovieSummaryRow { + pub id: String, + pub external_metadata_id: Option, + pub title: String, + pub release_year: i64, + pub director: Option, + pub poster_path: Option, + pub genres: Option, + pub runtime_minutes: Option, + pub original_language: Option, + pub overview: Option, + pub collection_name: Option, +} + +impl MovieSummaryRow { + pub fn into_domain(self) -> Result { + let movie = movie_row_to_domain( + self.id, + self.external_metadata_id, + self.title, + self.release_year, + self.director, + self.poster_path, + )?; + let genres = self + .genres + .map(|g| g.split(',').map(str::to_string).collect()) + .unwrap_or_default(); + Ok(movie_summary_to_domain( + movie, + genres, + self.runtime_minutes, + self.original_language, + self.overview, + self.collection_name, + )) + } +} diff --git a/crates/adapters/sqlite/src/movie_dedup.rs b/crates/adapters/sqlite/src/repositories/movie_dedup/mod.rs similarity index 100% rename from crates/adapters/sqlite/src/movie_dedup.rs rename to crates/adapters/sqlite/src/repositories/movie_dedup/mod.rs diff --git a/crates/adapters/sqlite/src/repositories/persons/command.rs b/crates/adapters/sqlite/src/repositories/persons/command.rs new file mode 100644 index 00000000..ddbb490c --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/persons/command.rs @@ -0,0 +1,117 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{ExternalPersonId, Person, PersonEnrichmentData, PersonId}, + ports::PersonCommand, +}; + +use super::SqlitePersonAdapter; + +#[async_trait] +impl PersonCommand for SqlitePersonAdapter { + async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> { + for person in persons { + let tmdb_id = person.external_id().tmdb_id(); + sqlx::query( + "INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + external_id = excluded.external_id, + tmdb_person_id = excluded.tmdb_person_id, + name = excluded.name, + known_for_department = excluded.known_for_department, + profile_path = excluded.profile_path", + ) + .bind(person.id().value().to_string()) + .bind(person.external_id().value()) + .bind(tmdb_id) + .bind(person.name()) + .bind(person.known_for_department()) + .bind(person.profile_path()) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + } + Ok(()) + } + + async fn backfill_from_credits_batch( + &self, + batch_size: u32, + ) -> Result<(u64, bool), DomainError> { + #[derive(sqlx::FromRow)] + struct MissingPerson { + tmdb_person_id: i64, + name: String, + department: Option, + profile_path: Option, + } + + let rows = sqlx::query_as::<_, MissingPerson>( + "SELECT mc.tmdb_person_id, mc.name, 'Acting' AS department, mc.profile_path + FROM movie_cast mc + WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id) + GROUP BY mc.tmdb_person_id + UNION ALL + SELECT mc.tmdb_person_id, mc.name, mc.department, mc.profile_path + FROM movie_crew mc + WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id) + AND NOT EXISTS (SELECT 1 FROM movie_cast c2 WHERE c2.tmdb_person_id = mc.tmdb_person_id) + GROUP BY mc.tmdb_person_id + LIMIT ?", + ) + .bind(batch_size) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + let has_more = rows.len() as u32 >= batch_size; + let mut count = 0u64; + for row in &rows { + let ext = ExternalPersonId::new(format!("tmdb:{}", row.tmdb_person_id)); + let pid = PersonId::from_external(&ext); + sqlx::query( + "INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(tmdb_person_id) DO NOTHING", + ) + .bind(pid.value().to_string()) + .bind(ext.value()) + .bind(row.tmdb_person_id) + .bind(&row.name) + .bind(&row.department) + .bind(&row.profile_path) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + count += 1; + } + Ok((count, has_more)) + } + + async fn update_enrichment( + &self, + id: &PersonId, + data: &PersonEnrichmentData, + ) -> Result<(), DomainError> { + let also_known_as_json = + serde_json::to_string(&data.also_known_as).unwrap_or_else(|_| "[]".into()); + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "UPDATE persons SET biography = ?, birthday = ?, deathday = ?, place_of_birth = ?, also_known_as = ?, homepage = ?, imdb_id = ?, enriched_at = ? WHERE id = ?", + ) + .bind(&data.biography) + .bind(data.birthday.map(|d| d.to_string())) + .bind(data.deathday.map(|d| d.to_string())) + .bind(&data.place_of_birth) + .bind(&also_known_as_json) + .bind(&data.homepage) + .bind(&data.imdb_id) + .bind(&now) + .bind(id.value().to_string()) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(()) + } +} diff --git a/crates/adapters/sqlite/src/repositories/persons/mod.rs b/crates/adapters/sqlite/src/repositories/persons/mod.rs new file mode 100644 index 00000000..19118f8f --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/persons/mod.rs @@ -0,0 +1,28 @@ +use domain::ports::{PersonCommand, PersonQuery}; +use sqlx::SqlitePool; +use std::sync::Arc; + +mod command; +mod query; +mod rows; + +pub struct SqlitePersonAdapter { + pool: SqlitePool, +} + +impl SqlitePersonAdapter { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +pub fn create_person_adapter(pool: SqlitePool) -> (Arc, Arc) { + let adapter = Arc::new(SqlitePersonAdapter::new(pool)); + ( + Arc::clone(&adapter) as Arc, + adapter as Arc, + ) +} + +#[cfg(test)] +mod tests; diff --git a/crates/adapters/sqlite/src/repositories/persons/query.rs b/crates/adapters/sqlite/src/repositories/persons/query.rs new file mode 100644 index 00000000..386f3b92 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/persons/query.rs @@ -0,0 +1,142 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId}, + ports::PersonQuery, + value_objects::MovieId, +}; + +use super::SqlitePersonAdapter; +use super::rows::{CastRow, CrewRow, PersonRow}; + +#[async_trait] +impl PersonQuery for SqlitePersonAdapter { + async fn get_by_id(&self, id: &PersonId) -> Result, DomainError> { + let row = sqlx::query_as::<_, PersonRow>( + "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE id = ?", + ) + .bind(id.value().to_string()) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(row.map(PersonRow::into_person)) + } + + async fn get_by_external_id( + &self, + id: &ExternalPersonId, + ) -> Result, DomainError> { + let row = sqlx::query_as::<_, PersonRow>( + "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE external_id = ?", + ) + .bind(id.value()) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(row.map(PersonRow::into_person)) + } + + async fn get_credits(&self, id: &PersonId) -> Result { + let person = self + .get_by_id(id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?; + + let tmdb_id: Option = + sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = ?") + .bind(id.value().to_string()) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)? + .flatten(); + + let Some(tmdb_id) = tmdb_id else { + return Ok(PersonCredits { + person, + cast: vec![], + crew: vec![], + }); + }; + + let cast = sqlx::query_as::<_, CastRow>( + "SELECT m.id, m.title, m.release_year, mc.character, m.poster_path + FROM movie_cast mc + JOIN movies m ON m.id = mc.movie_id + WHERE mc.tmdb_person_id = ? + ORDER BY mc.billing_order", + ) + .bind(tmdb_id) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)? + .into_iter() + .map(|r| CastCredit { + movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), + title: r.title, + release_year: r.release_year.map(|y| y as u16), + character: r.character, + poster_path: r.poster_path, + }) + .collect(); + + let crew = sqlx::query_as::<_, CrewRow>( + "SELECT m.id, m.title, m.release_year, mc.job, mc.department, m.poster_path + FROM movie_crew mc + JOIN movies m ON m.id = mc.movie_id + WHERE mc.tmdb_person_id = ? + ORDER BY m.title", + ) + .bind(tmdb_id) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)? + .into_iter() + .map(|r| CrewCredit { + movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), + title: r.title, + release_year: r.release_year.map(|y| y as u16), + job: r.job, + department: r.department, + poster_path: r.poster_path, + }) + .collect(); + + Ok(PersonCredits { person, cast, crew }) + } + + async fn list_page(&self, limit: u32, offset: u32) -> Result, DomainError> { + let rows = sqlx::query_as::<_, PersonRow>( + "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons ORDER BY id LIMIT ? OFFSET ?", + ) + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(rows.into_iter().map(PersonRow::into_person).collect()) + } + + async fn list_orphaned_persons(&self) -> Result, DomainError> { + let rows: Vec<(String,)> = sqlx::query_as( + "SELECT id FROM persons + WHERE NOT EXISTS ( + SELECT 1 FROM movie_cast WHERE movie_cast.tmdb_person_id = persons.tmdb_person_id + ) + AND NOT EXISTS ( + SELECT 1 FROM movie_crew WHERE movie_crew.tmdb_person_id = persons.tmdb_person_id + ) + LIMIT 500", + ) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(rows + .into_iter() + .filter_map(|(id,)| uuid::Uuid::parse_str(&id).ok().map(PersonId::from_uuid)) + .collect()) + } +} diff --git a/crates/adapters/sqlite/src/repositories/persons/rows.rs b/crates/adapters/sqlite/src/repositories/persons/rows.rs new file mode 100644 index 00000000..903705c6 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/persons/rows.rs @@ -0,0 +1,72 @@ +use domain::models::{ExternalPersonId, Person, PersonId}; + +#[derive(sqlx::FromRow)] +pub(super) struct PersonRow { + pub(super) id: String, + pub(super) external_id: String, + pub(super) name: String, + pub(super) known_for_department: Option, + pub(super) profile_path: Option, + pub(super) biography: Option, + pub(super) birthday: Option, + pub(super) deathday: Option, + pub(super) place_of_birth: Option, + pub(super) also_known_as: Option, + pub(super) homepage: Option, + pub(super) imdb_id: Option, + pub(super) enriched_at: Option, +} + +impl PersonRow { + pub(super) fn into_person(self) -> Person { + let ext = ExternalPersonId::new(self.external_id); + let also_known_as = self + .also_known_as + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default(); + let birthday = self + .birthday + .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); + let deathday = self + .deathday + .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); + let enriched_at = self + .enriched_at + .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) + .map(|d| d.with_timezone(&chrono::Utc)); + Person::new( + PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()), + ext, + self.name, + self.known_for_department, + self.profile_path, + self.biography, + birthday, + deathday, + self.place_of_birth, + also_known_as, + self.homepage, + self.imdb_id, + enriched_at, + ) + } +} + +#[derive(sqlx::FromRow)] +pub(super) struct CastRow { + pub(super) id: String, + pub(super) title: String, + pub(super) release_year: Option, + pub(super) character: String, + pub(super) poster_path: Option, +} + +#[derive(sqlx::FromRow)] +pub(super) struct CrewRow { + pub(super) id: String, + pub(super) title: String, + pub(super) release_year: Option, + pub(super) job: String, + pub(super) department: String, + pub(super) poster_path: Option, +} diff --git a/crates/adapters/sqlite/src/tests/persons.rs b/crates/adapters/sqlite/src/repositories/persons/tests.rs similarity index 98% rename from crates/adapters/sqlite/src/tests/persons.rs rename to crates/adapters/sqlite/src/repositories/persons/tests.rs index 1589b1d2..3dff2026 100644 --- a/crates/adapters/sqlite/src/tests/persons.rs +++ b/crates/adapters/sqlite/src/repositories/persons/tests.rs @@ -1,4 +1,4 @@ -use super::super::persons::SqlitePersonAdapter; +use super::SqlitePersonAdapter; use domain::{ models::{ExternalPersonId, Person, PersonId}, ports::{PersonCommand, PersonQuery}, diff --git a/crates/adapters/sqlite/src/profile.rs b/crates/adapters/sqlite/src/repositories/profile/mod.rs similarity index 99% rename from crates/adapters/sqlite/src/profile.rs rename to crates/adapters/sqlite/src/repositories/profile/mod.rs index e518d3ef..9b827dd2 100644 --- a/crates/adapters/sqlite/src/profile.rs +++ b/crates/adapters/sqlite/src/repositories/profile/mod.rs @@ -300,5 +300,4 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { } #[cfg(test)] -#[path = "tests/profile.rs"] mod tests; diff --git a/crates/adapters/sqlite/src/tests/profile.rs b/crates/adapters/sqlite/src/repositories/profile/tests.rs similarity index 98% rename from crates/adapters/sqlite/src/tests/profile.rs rename to crates/adapters/sqlite/src/repositories/profile/tests.rs index bf19abc0..2a85a1c0 100644 --- a/crates/adapters/sqlite/src/tests/profile.rs +++ b/crates/adapters/sqlite/src/repositories/profile/tests.rs @@ -1,4 +1,4 @@ -use super::super::profile::SqliteMovieProfileRepository; +use super::SqliteMovieProfileRepository; use domain::{ports::MovieProfileRepository, value_objects::MovieId}; use sqlx::SqlitePool; diff --git a/crates/adapters/sqlite/src/profile_fields.rs b/crates/adapters/sqlite/src/repositories/profile_fields/mod.rs similarity index 100% rename from crates/adapters/sqlite/src/profile_fields.rs rename to crates/adapters/sqlite/src/repositories/profile_fields/mod.rs diff --git a/crates/adapters/sqlite/src/refresh_sessions.rs b/crates/adapters/sqlite/src/repositories/refresh_sessions/mod.rs similarity index 100% rename from crates/adapters/sqlite/src/refresh_sessions.rs rename to crates/adapters/sqlite/src/repositories/refresh_sessions/mod.rs diff --git a/crates/adapters/sqlite/src/social/remote_goals.rs b/crates/adapters/sqlite/src/repositories/remote_goals/mod.rs similarity index 100% rename from crates/adapters/sqlite/src/social/remote_goals.rs rename to crates/adapters/sqlite/src/repositories/remote_goals/mod.rs diff --git a/crates/adapters/sqlite/src/review.rs b/crates/adapters/sqlite/src/repositories/review/mod.rs similarity index 98% rename from crates/adapters/sqlite/src/review.rs rename to crates/adapters/sqlite/src/repositories/review/mod.rs index 9fc2c6b5..ca1f8e69 100644 --- a/crates/adapters/sqlite/src/review.rs +++ b/crates/adapters/sqlite/src/repositories/review/mod.rs @@ -7,7 +7,9 @@ use domain::{ }; use sqlx::SqlitePool; -use crate::models::ReviewRow; +use rows::ReviewRow; + +pub(crate) mod rows; use crate::shared::datetime_to_str; pub struct SqliteReviewRepository { diff --git a/crates/adapters/sqlite/src/repositories/review/rows.rs b/crates/adapters/sqlite/src/repositories/review/rows.rs new file mode 100644 index 00000000..b6943e7a --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/review/rows.rs @@ -0,0 +1,33 @@ +use crate::shared::review_row_to_domain; +use domain::{errors::DomainError, models::Review}; + +#[derive(sqlx::FromRow)] +pub(crate) struct ReviewRow { + pub id: String, + pub movie_id: String, + pub user_id: String, + pub rating: i64, + pub comment: Option, + pub watched_at: String, + pub created_at: String, + pub remote_actor_url: Option, + pub watch_medium: Option, + pub spoilers: bool, +} + +impl ReviewRow { + pub fn into_domain(self) -> Result { + review_row_to_domain( + self.id, + self.movie_id, + self.user_id, + self.rating, + self.comment, + self.watched_at, + self.created_at, + self.remote_actor_url, + self.watch_medium, + self.spoilers, + ) + } +} diff --git a/crates/adapters/sqlite/src/repositories/search/command.rs b/crates/adapters/sqlite/src/repositories/search/command.rs new file mode 100644 index 00000000..78753479 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/search/command.rs @@ -0,0 +1,133 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{EntityType, IndexableDocument}, + ports::SearchCommand, +}; + +use super::SqliteSearchAdapter; + +#[async_trait] +impl SearchCommand for SqliteSearchAdapter { + async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> { + match doc { + IndexableDocument::Movie { id, movie, profile } => { + let movie_id = id.value().to_string(); + let title = movie.title().value().to_string(); + let director = movie.director().unwrap_or("").to_string(); + let release_year = movie.release_year().value() as i64; + let (overview, genres, keywords, cast_names, crew_names, language) = + match profile.as_deref() { + Some(p) => ( + p.overview.clone().unwrap_or_default(), + p.genres + .iter() + .map(|g| g.name.as_str()) + .collect::>() + .join(" "), + p.keywords + .iter() + .map(|k| k.name.as_str()) + .collect::>() + .join(" "), + p.cast + .iter() + .map(|c| c.name.as_str()) + .collect::>() + .join(" "), + p.crew + .iter() + .map(|c| c.name.as_str()) + .collect::>() + .join(" "), + p.original_language.clone().unwrap_or_default(), + ), + None => ( + String::new(), + String::new(), + String::new(), + String::new(), + String::new(), + String::new(), + ), + }; + + sqlx::query( + "DELETE FROM movies_fts WHERE rowid = (SELECT rowid FROM movies_fts WHERE movie_id = ? LIMIT 1)", + ) + .bind(&movie_id) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + sqlx::query( + "INSERT INTO movies_fts(movie_id, title, director, overview, genres, keywords, cast_names, crew_names, release_year, language) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&movie_id) + .bind(&title) + .bind(&director) + .bind(&overview) + .bind(&genres) + .bind(&keywords) + .bind(&cast_names) + .bind(&crew_names) + .bind(release_year) + .bind(&language) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + IndexableDocument::Person { id, person } => { + let person_id = id.value().to_string(); + + sqlx::query( + "DELETE FROM people_fts WHERE rowid = (SELECT rowid FROM people_fts WHERE person_id = ? LIMIT 1)", + ) + .bind(&person_id) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + sqlx::query( + "INSERT INTO people_fts(person_id, name, known_for_department) VALUES (?, ?, ?)", + ) + .bind(&person_id) + .bind(person.name()) + .bind(person.known_for_department()) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + } + } + + async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError> { + match entity_type { + EntityType::Movie => { + sqlx::query( + "DELETE FROM movies_fts WHERE rowid = (SELECT rowid FROM movies_fts WHERE movie_id = ? LIMIT 1)", + ) + .bind(id) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + } + EntityType::Person => { + sqlx::query( + "DELETE FROM people_fts WHERE rowid = (SELECT rowid FROM people_fts WHERE person_id = ? LIMIT 1)", + ) + .bind(id) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + } + } + Ok(()) + } +} diff --git a/crates/adapters/sqlite/src/repositories/search/mod.rs b/crates/adapters/sqlite/src/repositories/search/mod.rs new file mode 100644 index 00000000..9dd37f91 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/search/mod.rs @@ -0,0 +1,27 @@ +use domain::ports::{SearchCommand, SearchPort}; +use sqlx::SqlitePool; +use std::sync::Arc; + +mod command; +mod query; + +pub struct SqliteSearchAdapter { + pool: SqlitePool, +} + +impl SqliteSearchAdapter { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +pub fn create_search_adapter(pool: SqlitePool) -> (Arc, Arc) { + let adapter = Arc::new(SqliteSearchAdapter::new(pool)); + ( + Arc::clone(&adapter) as Arc, + adapter as Arc, + ) +} + +#[cfg(test)] +mod tests; diff --git a/crates/adapters/sqlite/src/search.rs b/crates/adapters/sqlite/src/repositories/search/query.rs similarity index 60% rename from crates/adapters/sqlite/src/search.rs rename to crates/adapters/sqlite/src/repositories/search/query.rs index 528f5520..989a6f5c 100644 --- a/crates/adapters/sqlite/src/search.rs +++ b/crates/adapters/sqlite/src/repositories/search/query.rs @@ -1,160 +1,13 @@ -use std::sync::Arc; - use async_trait::async_trait; use domain::{ errors::DomainError, models::PersonId, - models::{ - EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchQuery, SearchResults, - collections::Paginated, - }, - ports::{SearchCommand, SearchPort}, + models::{MovieSearchHit, PersonSearchHit, SearchQuery, SearchResults, collections::Paginated}, + ports::SearchPort, value_objects::MovieId, }; -use sqlx::SqlitePool; -pub struct SqliteSearchAdapter { - pool: SqlitePool, -} - -impl SqliteSearchAdapter { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -pub fn create_search_adapter(pool: SqlitePool) -> (Arc, Arc) { - let adapter = Arc::new(SqliteSearchAdapter::new(pool)); - ( - Arc::clone(&adapter) as Arc, - adapter as Arc, - ) -} - -#[async_trait] -impl SearchCommand for SqliteSearchAdapter { - async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> { - match doc { - IndexableDocument::Movie { id, movie, profile } => { - let movie_id = id.value().to_string(); - let title = movie.title().value().to_string(); - let director = movie.director().unwrap_or("").to_string(); - let release_year = movie.release_year().value() as i64; - let (overview, genres, keywords, cast_names, crew_names, language) = - match profile.as_deref() { - Some(p) => ( - p.overview.clone().unwrap_or_default(), - p.genres - .iter() - .map(|g| g.name.as_str()) - .collect::>() - .join(" "), - p.keywords - .iter() - .map(|k| k.name.as_str()) - .collect::>() - .join(" "), - p.cast - .iter() - .map(|c| c.name.as_str()) - .collect::>() - .join(" "), - p.crew - .iter() - .map(|c| c.name.as_str()) - .collect::>() - .join(" "), - p.original_language.clone().unwrap_or_default(), - ), - None => ( - String::new(), - String::new(), - String::new(), - String::new(), - String::new(), - String::new(), - ), - }; - - sqlx::query( - "DELETE FROM movies_fts WHERE rowid = (SELECT rowid FROM movies_fts WHERE movie_id = ? LIMIT 1)", - ) - .bind(&movie_id) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - sqlx::query( - "INSERT INTO movies_fts(movie_id, title, director, overview, genres, keywords, cast_names, crew_names, release_year, language) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(&movie_id) - .bind(&title) - .bind(&director) - .bind(&overview) - .bind(&genres) - .bind(&keywords) - .bind(&cast_names) - .bind(&crew_names) - .bind(release_year) - .bind(&language) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - IndexableDocument::Person { id, person } => { - let person_id = id.value().to_string(); - - sqlx::query( - "DELETE FROM people_fts WHERE rowid = (SELECT rowid FROM people_fts WHERE person_id = ? LIMIT 1)", - ) - .bind(&person_id) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - sqlx::query( - "INSERT INTO people_fts(person_id, name, known_for_department) VALUES (?, ?, ?)", - ) - .bind(&person_id) - .bind(person.name()) - .bind(person.known_for_department()) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - } - } - - async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError> { - match entity_type { - EntityType::Movie => { - sqlx::query( - "DELETE FROM movies_fts WHERE rowid = (SELECT rowid FROM movies_fts WHERE movie_id = ? LIMIT 1)", - ) - .bind(id) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - } - EntityType::Person => { - sqlx::query( - "DELETE FROM people_fts WHERE rowid = (SELECT rowid FROM people_fts WHERE person_id = ? LIMIT 1)", - ) - .bind(id) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - } - } - Ok(()) - } -} +use super::SqliteSearchAdapter; #[async_trait] impl SearchPort for SqliteSearchAdapter { @@ -389,6 +242,3 @@ impl SqliteSearchAdapter { }) } } - -#[cfg(test)] -mod tests; diff --git a/crates/adapters/sqlite/src/search/tests.rs b/crates/adapters/sqlite/src/repositories/search/tests.rs similarity index 100% rename from crates/adapters/sqlite/src/search/tests.rs rename to crates/adapters/sqlite/src/repositories/search/tests.rs diff --git a/crates/adapters/sqlite/src/social/federated_profile.rs b/crates/adapters/sqlite/src/repositories/social/federated_profile.rs similarity index 100% rename from crates/adapters/sqlite/src/social/federated_profile.rs rename to crates/adapters/sqlite/src/repositories/social/federated_profile.rs diff --git a/crates/adapters/sqlite/src/repositories/social/follow/command.rs b/crates/adapters/sqlite/src/repositories/social/follow/command.rs new file mode 100644 index 00000000..7424c2a7 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/social/follow/command.rs @@ -0,0 +1,130 @@ +use async_trait::async_trait; +use chrono::Utc; +use domain::{errors::DomainError, value_objects::FollowStatus}; + +use crate::shared::datetime_to_str; + +use super::infra_err; +use super::rows::follow_status_to_str; +use crate::repositories::social::SqliteSocialRepository; + +#[async_trait] +impl domain::ports::FollowCommand for SqliteSocialRepository { + async fn add_follow( + &self, + follower_id: uuid::Uuid, + target_actor_url: &str, + status: FollowStatus, + ) -> Result<(), DomainError> { + let uid = follower_id.to_string(); + let status_str = follow_status_to_str(&status); + let now = datetime_to_str(&Utc::now().naive_utc()); + sqlx::query( + "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status) + VALUES (?1, ?2, '', ?3, ?4) + ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status", + ) + .bind(&uid) + .bind(target_actor_url) + .bind(&now) + .bind(status_str) + .execute(&self.pool) + .await + .map_err(infra_err)?; + Ok(()) + } + + async fn update_follow_status( + &self, + follower_id: uuid::Uuid, + target_actor_url: &str, + status: FollowStatus, + ) -> Result<(), DomainError> { + let uid = follower_id.to_string(); + let status_str = follow_status_to_str(&status); + sqlx::query( + "UPDATE ap_following SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3", + ) + .bind(status_str) + .bind(&uid) + .bind(target_actor_url) + .execute(&self.pool) + .await + .map_err(infra_err)?; + Ok(()) + } + + async fn remove_follow( + &self, + follower_id: uuid::Uuid, + target_actor_url: &str, + ) -> Result<(), DomainError> { + let uid = follower_id.to_string(); + sqlx::query("DELETE FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2") + .bind(&uid) + .bind(target_actor_url) + .execute(&self.pool) + .await + .map_err(infra_err)?; + Ok(()) + } + + async fn add_follower( + &self, + local_user_id: uuid::Uuid, + follower_actor_url: &str, + status: FollowStatus, + ) -> Result<(), DomainError> { + let uid = local_user_id.to_string(); + let status_str = follow_status_to_str(&status); + let now = datetime_to_str(&Utc::now().naive_utc()); + sqlx::query( + "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) + VALUES (?1, ?2, ?3, ?4, '') + ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status", + ) + .bind(&uid) + .bind(follower_actor_url) + .bind(status_str) + .bind(&now) + .execute(&self.pool) + .await + .map_err(infra_err)?; + Ok(()) + } + + async fn update_follower_status( + &self, + local_user_id: uuid::Uuid, + follower_actor_url: &str, + status: FollowStatus, + ) -> Result<(), DomainError> { + let uid = local_user_id.to_string(); + let status_str = follow_status_to_str(&status); + sqlx::query( + "UPDATE ap_followers SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3", + ) + .bind(status_str) + .bind(&uid) + .bind(follower_actor_url) + .execute(&self.pool) + .await + .map_err(infra_err)?; + Ok(()) + } + + async fn remove_follower_record( + &self, + local_user_id: uuid::Uuid, + follower_actor_url: &str, + ) -> Result<(), DomainError> { + let uid = local_user_id.to_string(); + sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2") + .bind(&uid) + .bind(follower_actor_url) + .execute(&self.pool) + .await + .map_err(infra_err)?; + Ok(()) + } +} diff --git a/crates/adapters/sqlite/src/repositories/social/follow/mod.rs b/crates/adapters/sqlite/src/repositories/social/follow/mod.rs new file mode 100644 index 00000000..5a4e1d74 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/social/follow/mod.rs @@ -0,0 +1,9 @@ +use domain::errors::DomainError; + +mod command; +mod query; +mod rows; + +fn infra_err(e: impl std::fmt::Display) -> DomainError { + DomainError::InfrastructureError(e.to_string()) +} diff --git a/crates/adapters/sqlite/src/social/follow_repository.rs b/crates/adapters/sqlite/src/repositories/social/follow/query.rs similarity index 51% rename from crates/adapters/sqlite/src/social/follow_repository.rs rename to crates/adapters/sqlite/src/repositories/social/follow/query.rs index 506c9c7c..05e7c3ea 100644 --- a/crates/adapters/sqlite/src/social/follow_repository.rs +++ b/crates/adapters/sqlite/src/repositories/social/follow/query.rs @@ -1,197 +1,13 @@ use async_trait::async_trait; -use chrono::Utc; use domain::{ errors::DomainError, - value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity}, + value_objects::{FollowRelation, SocialActor}, }; use sqlx::Row; -use crate::SqliteSocialRepository; -use crate::shared::datetime_to_str; - -fn follow_status_to_str(status: &FollowStatus) -> &'static str { - match status { - FollowStatus::Pending => "pending", - FollowStatus::Accepted => "accepted", - FollowStatus::Rejected => "rejected", - } -} - -fn infra_err(e: impl std::fmt::Display) -> DomainError { - DomainError::InfrastructureError(e.to_string()) -} - -fn follow_status_from_str(status: &str) -> Option { - match status { - "pending" => Some(FollowStatus::Pending), - "accepted" => Some(FollowStatus::Accepted), - "rejected" => Some(FollowStatus::Rejected), - _ => None, - } -} - -#[async_trait] -impl domain::ports::FollowCommand for SqliteSocialRepository { - async fn add_follow( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = follower_id.to_string(); - let status_str = follow_status_to_str(&status); - let now = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query( - "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status) - VALUES (?1, ?2, '', ?3, ?4) - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status", - ) - .bind(&uid) - .bind(target_actor_url) - .bind(&now) - .bind(status_str) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn update_follow_status( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = follower_id.to_string(); - let status_str = follow_status_to_str(&status); - sqlx::query( - "UPDATE ap_following SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3", - ) - .bind(status_str) - .bind(&uid) - .bind(target_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn remove_follow( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - ) -> Result<(), DomainError> { - let uid = follower_id.to_string(); - sqlx::query("DELETE FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2") - .bind(&uid) - .bind(target_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn add_follower( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = local_user_id.to_string(); - let status_str = follow_status_to_str(&status); - let now = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query( - "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) - VALUES (?1, ?2, ?3, ?4, '') - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status", - ) - .bind(&uid) - .bind(follower_actor_url) - .bind(status_str) - .bind(&now) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn update_follower_status( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = local_user_id.to_string(); - let status_str = follow_status_to_str(&status); - sqlx::query( - "UPDATE ap_followers SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3", - ) - .bind(status_str) - .bind(&uid) - .bind(follower_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn remove_follower_record( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - ) -> Result<(), DomainError> { - let uid = local_user_id.to_string(); - sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2") - .bind(&uid) - .bind(follower_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } -} - -fn social_actor_from_row( - row: &sqlx::sqlite::SqliteRow, - instance: &InstanceIdentity, -) -> SocialActor { - let actor_url: String = row.get("remote_actor_url"); - let identity = instance.identify(&actor_url); - - let (handle, display_name, avatar_url) = match &identity { - SocialIdentity::Local(_) => { - let username: Option = row.try_get("local_username").ok().flatten(); - let display: Option = row.try_get("local_display").ok().flatten(); - let avatar: Option = row - .try_get::, _>("local_avatar_path") - .ok() - .flatten() - .map(|p| instance.image_url_for(&p)); - let handle = username - .as_deref() - .map(|u| instance.handle_for(u)) - .unwrap_or_else(|| actor_url.clone()); - (handle, display, avatar) - } - SocialIdentity::Remote { .. } => { - let handle: String = row - .try_get::, _>("remote_handle") - .ok() - .flatten() - .unwrap_or_else(|| actor_url.clone()); - let display: Option = row.try_get("remote_display").ok().flatten(); - let avatar: Option = row.try_get("remote_avatar").ok().flatten(); - (handle, display, avatar) - } - }; - - SocialActor { - identity, - handle, - display_name, - avatar_url, - } -} +use super::infra_err; +use super::rows::{follow_status_from_str, social_actor_from_row}; +use crate::repositories::social::SqliteSocialRepository; #[async_trait] impl domain::ports::FollowQuery for SqliteSocialRepository { diff --git a/crates/adapters/sqlite/src/repositories/social/follow/rows.rs b/crates/adapters/sqlite/src/repositories/social/follow/rows.rs new file mode 100644 index 00000000..d13bd1a9 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/social/follow/rows.rs @@ -0,0 +1,61 @@ +use domain::value_objects::{FollowStatus, InstanceIdentity, SocialActor, SocialIdentity}; +use sqlx::Row; + +pub(super) fn follow_status_to_str(status: &FollowStatus) -> &'static str { + match status { + FollowStatus::Pending => "pending", + FollowStatus::Accepted => "accepted", + FollowStatus::Rejected => "rejected", + } +} + +pub(super) fn follow_status_from_str(status: &str) -> Option { + match status { + "pending" => Some(FollowStatus::Pending), + "accepted" => Some(FollowStatus::Accepted), + "rejected" => Some(FollowStatus::Rejected), + _ => None, + } +} + +pub(super) fn social_actor_from_row( + row: &sqlx::sqlite::SqliteRow, + instance: &InstanceIdentity, +) -> SocialActor { + let actor_url: String = row.get("remote_actor_url"); + let identity = instance.identify(&actor_url); + + let (handle, display_name, avatar_url) = match &identity { + SocialIdentity::Local(_) => { + let username: Option = row.try_get("local_username").ok().flatten(); + let display: Option = row.try_get("local_display").ok().flatten(); + let avatar: Option = row + .try_get::, _>("local_avatar_path") + .ok() + .flatten() + .map(|p| instance.image_url_for(&p)); + let handle = username + .as_deref() + .map(|u| instance.handle_for(u)) + .unwrap_or_else(|| actor_url.clone()); + (handle, display, avatar) + } + SocialIdentity::Remote { .. } => { + let handle: String = row + .try_get::, _>("remote_handle") + .ok() + .flatten() + .unwrap_or_else(|| actor_url.clone()); + let display: Option = row.try_get("remote_display").ok().flatten(); + let avatar: Option = row.try_get("remote_avatar").ok().flatten(); + (handle, display, avatar) + } + }; + + SocialActor { + identity, + handle, + display_name, + avatar_url, + } +} diff --git a/crates/adapters/sqlite/src/social/local_social.rs b/crates/adapters/sqlite/src/repositories/social/local_social.rs similarity index 100% rename from crates/adapters/sqlite/src/social/local_social.rs rename to crates/adapters/sqlite/src/repositories/social/local_social.rs diff --git a/crates/adapters/sqlite/src/social/mod.rs b/crates/adapters/sqlite/src/repositories/social/mod.rs similarity index 73% rename from crates/adapters/sqlite/src/social/mod.rs rename to crates/adapters/sqlite/src/repositories/social/mod.rs index 1f447813..1c003a59 100644 --- a/crates/adapters/sqlite/src/social/mod.rs +++ b/crates/adapters/sqlite/src/repositories/social/mod.rs @@ -1,14 +1,8 @@ mod federated_profile; -mod follow_repository; +mod follow; mod local_social; mod watchlist; -pub mod ap_content; -pub mod remote_goals; - -pub use ap_content::SqliteApContentQuery; -pub use remote_goals::SqliteRemoteGoalRepository; - use sqlx::SqlitePool; pub struct SqliteSocialRepository { @@ -30,5 +24,4 @@ pub fn create_federated_profile_query( } #[cfg(test)] -#[path = "tests/follow_relation_tests.rs"] -mod follow_relation_tests; +mod tests; diff --git a/crates/adapters/sqlite/src/social/tests/follow_relation_tests.rs b/crates/adapters/sqlite/src/repositories/social/tests.rs similarity index 100% rename from crates/adapters/sqlite/src/social/tests/follow_relation_tests.rs rename to crates/adapters/sqlite/src/repositories/social/tests.rs diff --git a/crates/adapters/sqlite/src/social/watchlist.rs b/crates/adapters/sqlite/src/repositories/social/watchlist.rs similarity index 100% rename from crates/adapters/sqlite/src/social/watchlist.rs rename to crates/adapters/sqlite/src/repositories/social/watchlist.rs diff --git a/crates/adapters/sqlite/src/stats.rs b/crates/adapters/sqlite/src/repositories/stats/mod.rs similarity index 98% rename from crates/adapters/sqlite/src/stats.rs rename to crates/adapters/sqlite/src/repositories/stats/mod.rs index 75d39b4f..a45ba6c2 100644 --- a/crates/adapters/sqlite/src/stats.rs +++ b/crates/adapters/sqlite/src/repositories/stats/mod.rs @@ -9,11 +9,13 @@ use domain::{ }; use sqlx::SqlitePool; -use crate::models::{ +use rows::{ ActorAppearanceRow, DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, UserTotalsRow, WatchMediumCountRow, }; +mod rows; + pub struct SqliteStatsRepository { pool: SqlitePool, } @@ -97,7 +99,7 @@ impl StatsRepository for SqliteStatsRepository { } async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result { - crate::goals::count_reviews_in_year(&self.pool, user_id, year).await + crate::repositories::goals::count_reviews_in_year(&self.pool, user_id, year).await } async fn get_user_trends(&self, user_id: &UserId) -> Result { diff --git a/crates/adapters/sqlite/src/repositories/stats/rows.rs b/crates/adapters/sqlite/src/repositories/stats/rows.rs new file mode 100644 index 00000000..38ea237f --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/stats/rows.rs @@ -0,0 +1,45 @@ +#[derive(sqlx::FromRow)] +pub(crate) struct UserTotalsRow { + pub total: i64, + pub avg_rating: Option, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct DirectorCountRow { + pub director: String, + pub count: i64, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct MonthlyRatingRow { + pub month: String, + pub avg_rating: f64, + pub count: i64, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct GenreCountRow { + pub genre: String, + pub count: i64, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct RatingDistRow { + pub rating: i64, + pub count: i64, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct WatchMediumCountRow { + pub watch_medium: String, + pub count: i64, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct ActorAppearanceRow { + pub tmdb_person_id: i64, + pub name: String, + pub profile_path: Option, + pub billing_order: i32, + pub movie_id: String, +} diff --git a/crates/adapters/sqlite/src/user_settings.rs b/crates/adapters/sqlite/src/repositories/user_settings/mod.rs similarity index 100% rename from crates/adapters/sqlite/src/user_settings.rs rename to crates/adapters/sqlite/src/repositories/user_settings/mod.rs diff --git a/crates/adapters/sqlite/src/users.rs b/crates/adapters/sqlite/src/repositories/users/mod.rs similarity index 99% rename from crates/adapters/sqlite/src/users.rs rename to crates/adapters/sqlite/src/repositories/users/mod.rs index 96793d0d..d69b8be8 100644 --- a/crates/adapters/sqlite/src/users.rs +++ b/crates/adapters/sqlite/src/repositories/users/mod.rs @@ -2,7 +2,9 @@ use async_trait::async_trait; use chrono::Utc; use sqlx::{Row, SqlitePool}; -use super::models::UserSummaryRow; +use rows::UserSummaryRow; + +mod rows; use domain::{ errors::DomainError, models::{ProfileField, User, UserRole}, @@ -197,5 +199,4 @@ impl UserRepository for SqliteUserRepository { } #[cfg(test)] -#[path = "tests/users.rs"] mod tests; diff --git a/crates/adapters/sqlite/src/repositories/users/rows.rs b/crates/adapters/sqlite/src/repositories/users/rows.rs new file mode 100644 index 00000000..6a36cba6 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/users/rows.rs @@ -0,0 +1,27 @@ +use crate::shared::user_summary_to_domain; +use domain::errors::DomainError; + +#[derive(sqlx::FromRow)] +pub(crate) struct UserSummaryRow { + pub id: String, + pub email: String, + pub username: String, + pub display_name: Option, + pub total_movies: i64, + pub avg_rating: Option, + pub avatar_path: Option, +} + +impl UserSummaryRow { + pub fn into_domain(self) -> Result { + user_summary_to_domain( + self.id, + self.email, + self.username, + self.display_name, + self.total_movies, + self.avg_rating, + self.avatar_path, + ) + } +} diff --git a/crates/adapters/sqlite/src/tests/users.rs b/crates/adapters/sqlite/src/repositories/users/tests.rs similarity index 100% rename from crates/adapters/sqlite/src/tests/users.rs rename to crates/adapters/sqlite/src/repositories/users/tests.rs diff --git a/crates/adapters/sqlite/src/repositories/watch_event/command.rs b/crates/adapters/sqlite/src/repositories/watch_event/command.rs new file mode 100644 index 00000000..4d20916d --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/watch_event/command.rs @@ -0,0 +1,101 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{WatchEvent, WatchEventStatus}, + ports::WatchEventCommand, + value_objects::WatchEventId, +}; + +use crate::shared::datetime_to_str; + +use super::SqliteWatchEventRepository; + +#[async_trait] +impl WatchEventCommand for SqliteWatchEventRepository { + async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { + let id = event.id().value().to_string(); + let user_id = event.user_id().value().to_string(); + let movie_id = event.movie_id().map(|m| m.value().to_string()); + let source = event.source().to_string(); + let watched_at = datetime_to_str(event.watched_at()); + let status = event.status().to_string(); + let created_at = datetime_to_str(event.created_at()); + + sqlx::query( + "INSERT INTO watch_events \ + (id, user_id, movie_id, title, year, external_metadata_id, source, watched_at, status, created_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(&user_id) + .bind(&movie_id) + .bind(event.title()) + .bind(event.year().map(|y| y as i64)) + .bind(event.external_metadata_id()) + .bind(&source) + .bind(&watched_at) + .bind(&status) + .bind(&created_at) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + async fn update_status( + &self, + id: &WatchEventId, + status: WatchEventStatus, + ) -> Result<(), DomainError> { + let id_str = id.value().to_string(); + let status_str = status.to_string(); + + sqlx::query("UPDATE watch_events SET status = ? WHERE id = ?") + .bind(&status_str) + .bind(&id_str) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + async fn update_status_batch( + &self, + ids: &[WatchEventId], + status: WatchEventStatus, + ) -> Result { + if ids.is_empty() { + return Ok(0); + } + let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect(); + let sql = format!( + "UPDATE watch_events SET status = ? WHERE id IN ({})", + placeholders.join(",") + ); + let mut q = sqlx::query(&sql).bind(status.to_string()); + for id in ids { + q = q.bind(id.value().to_string()); + } + let result = q + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(result.rows_affected()) + } + + async fn delete_non_pending_older_than( + &self, + before: chrono::NaiveDateTime, + ) -> Result { + let before_str = datetime_to_str(&before); + let result = + sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < ?") + .bind(&before_str) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(result.rows_affected()) + } +} diff --git a/crates/adapters/sqlite/src/repositories/watch_event/mod.rs b/crates/adapters/sqlite/src/repositories/watch_event/mod.rs new file mode 100644 index 00000000..c86b2da2 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/watch_event/mod.rs @@ -0,0 +1,18 @@ +use sqlx::SqlitePool; + +mod command; +mod query; +mod rows; +mod webhook_token; + +pub use webhook_token::SqliteWebhookTokenRepository; + +pub struct SqliteWatchEventRepository { + pool: SqlitePool, +} + +impl SqliteWatchEventRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} diff --git a/crates/adapters/sqlite/src/repositories/watch_event/query.rs b/crates/adapters/sqlite/src/repositories/watch_event/query.rs new file mode 100644 index 00000000..c8331053 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/watch_event/query.rs @@ -0,0 +1,97 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::WatchEvent, + ports::WatchEventQuery, + value_objects::{UserId, WatchEventId}, +}; +use sqlx::Row; + +use crate::shared::datetime_to_str; + +use super::SqliteWatchEventRepository; +use super::rows::row_to_watch_event; + +#[async_trait] +impl WatchEventQuery for SqliteWatchEventRepository { + async fn list_pending(&self, user_id: &UserId) -> Result, DomainError> { + let uid = user_id.value().to_string(); + + let rows = sqlx::query( + "SELECT id, user_id, movie_id, title, year, external_metadata_id, \ + source, watched_at, status, created_at \ + FROM watch_events \ + WHERE user_id = ? AND status = 'pending' \ + ORDER BY watched_at DESC", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + rows.iter().map(row_to_watch_event).collect() + } + + async fn get_by_id(&self, id: &WatchEventId) -> Result, DomainError> { + let id_str = id.value().to_string(); + + let row = sqlx::query( + "SELECT id, user_id, movie_id, title, year, external_metadata_id, \ + source, watched_at, status, created_at \ + FROM watch_events WHERE id = ?", + ) + .bind(&id_str) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + row.as_ref().map(row_to_watch_event).transpose() + } + + async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result, DomainError> { + if ids.is_empty() { + return Ok(vec![]); + } + let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect(); + let sql = format!( + "SELECT id, user_id, movie_id, title, year, external_metadata_id, \ + source, watched_at, status, created_at \ + FROM watch_events WHERE id IN ({})", + placeholders.join(",") + ); + let mut q = sqlx::query(&sql); + for id in ids { + q = q.bind(id.value().to_string()); + } + let rows = q + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + rows.iter().map(row_to_watch_event).collect() + } + + async fn find_duplicate( + &self, + user_id: &UserId, + external_id: &str, + after: chrono::NaiveDateTime, + ) -> Result { + let uid = user_id.value().to_string(); + let after_str = datetime_to_str(&after); + + let count: i64 = sqlx::query( + "SELECT COUNT(*) FROM watch_events \ + WHERE user_id = ? AND external_metadata_id = ? AND created_at > ?", + ) + .bind(&uid) + .bind(external_id) + .bind(&after_str) + .fetch_one(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)? + .try_get(0) + .unwrap_or(0); + + Ok(count > 0) + } +} diff --git a/crates/adapters/sqlite/src/repositories/watch_event/rows.rs b/crates/adapters/sqlite/src/repositories/watch_event/rows.rs new file mode 100644 index 00000000..39dd101c --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/watch_event/rows.rs @@ -0,0 +1,111 @@ +use domain::{ + errors::DomainError, + models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken}, + value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId}, +}; +use sqlx::Row; + +pub(super) fn parse_uuid(s: &str) -> Result { + s.parse() + .map_err(|_| DomainError::InfrastructureError(format!("invalid UUID: {s}"))) +} + +pub(super) fn parse_datetime(s: &str) -> Result { + chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") + .or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")) + .map_err(|_| DomainError::InfrastructureError(format!("invalid datetime: {s}"))) +} + +pub(super) fn row_to_watch_event(row: &sqlx::sqlite::SqliteRow) -> Result { + let id_str: &str = row.try_get("id").map_err(crate::shared::map_sqlx_error)?; + let user_id_str: &str = row + .try_get("user_id") + .map_err(crate::shared::map_sqlx_error)?; + let movie_id_str: Option<&str> = row + .try_get("movie_id") + .map_err(crate::shared::map_sqlx_error)?; + let title: String = row + .try_get("title") + .map_err(crate::shared::map_sqlx_error)?; + let year: Option = row.try_get("year").map_err(crate::shared::map_sqlx_error)?; + let ext_id: Option = row + .try_get("external_metadata_id") + .map_err(crate::shared::map_sqlx_error)?; + let source_str: String = row + .try_get("source") + .map_err(crate::shared::map_sqlx_error)?; + let watched_at_str: String = row + .try_get("watched_at") + .map_err(crate::shared::map_sqlx_error)?; + let status_str: String = row + .try_get("status") + .map_err(crate::shared::map_sqlx_error)?; + let created_at_str: String = row + .try_get("created_at") + .map_err(crate::shared::map_sqlx_error)?; + + let source: WatchEventSource = source_str + .parse() + .map_err(|e: String| DomainError::InfrastructureError(e))?; + let status: WatchEventStatus = status_str + .parse() + .map_err(|e: String| DomainError::InfrastructureError(e))?; + + let movie_id = movie_id_str + .map(parse_uuid) + .transpose()? + .map(MovieId::from_uuid); + + Ok(WatchEvent::from_persistence(PersistedWatchEvent { + id: WatchEventId::from_uuid(parse_uuid(id_str)?), + user_id: UserId::from_uuid(parse_uuid(user_id_str)?), + movie_id, + title, + year: year.map(|y| y as u16), + external_metadata_id: ext_id, + source, + watched_at: parse_datetime(&watched_at_str)?, + status, + created_at: parse_datetime(&created_at_str)?, + })) +} + +pub(super) fn row_to_webhook_token( + row: &sqlx::sqlite::SqliteRow, +) -> Result { + let id_str: &str = row.try_get("id").map_err(crate::shared::map_sqlx_error)?; + let user_id_str: &str = row + .try_get("user_id") + .map_err(crate::shared::map_sqlx_error)?; + let token_hash: String = row + .try_get("token_hash") + .map_err(crate::shared::map_sqlx_error)?; + let provider_str: String = row + .try_get("provider") + .map_err(crate::shared::map_sqlx_error)?; + let label: Option = row + .try_get("label") + .map_err(crate::shared::map_sqlx_error)?; + let created_at_str: String = row + .try_get("created_at") + .map_err(crate::shared::map_sqlx_error)?; + let last_used_str: Option = row + .try_get("last_used_at") + .map_err(crate::shared::map_sqlx_error)?; + + let provider: WatchEventSource = provider_str + .parse() + .map_err(|e: String| DomainError::InfrastructureError(e))?; + + let last_used = last_used_str.map(|s| parse_datetime(&s)).transpose()?; + + Ok(WebhookToken::from_persistence( + WebhookTokenId::from_uuid(parse_uuid(id_str)?), + UserId::from_uuid(parse_uuid(user_id_str)?), + token_hash, + provider, + label, + parse_datetime(&created_at_str)?, + last_used, + )) +} diff --git a/crates/adapters/sqlite/src/repositories/watch_event/webhook_token.rs b/crates/adapters/sqlite/src/repositories/watch_event/webhook_token.rs new file mode 100644 index 00000000..ccf772ae --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/watch_event/webhook_token.rs @@ -0,0 +1,110 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::WebhookToken, + ports::WebhookTokenRepository, + value_objects::{UserId, WebhookTokenId}, +}; +use sqlx::SqlitePool; + +use crate::shared::datetime_to_str; + +use super::rows::row_to_webhook_token; + +pub struct SqliteWebhookTokenRepository { + pool: SqlitePool, +} + +impl SqliteWebhookTokenRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl WebhookTokenRepository for SqliteWebhookTokenRepository { + async fn save(&self, token: &WebhookToken) -> Result<(), DomainError> { + let id = token.id().value().to_string(); + let user_id = token.user_id().value().to_string(); + let provider = token.provider().to_string(); + let created_at = datetime_to_str(token.created_at()); + let last_used = token.last_used_at().map(datetime_to_str); + + sqlx::query( + "INSERT INTO webhook_tokens \ + (id, user_id, token_hash, provider, label, created_at, last_used_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(&user_id) + .bind(token.token_hash()) + .bind(&provider) + .bind(token.label()) + .bind(&created_at) + .bind(&last_used) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + async fn find_by_token_hash(&self, hash: &str) -> Result, DomainError> { + let row = sqlx::query( + "SELECT id, user_id, token_hash, provider, label, created_at, last_used_at \ + FROM webhook_tokens WHERE token_hash = ?", + ) + .bind(hash) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + row.as_ref().map(row_to_webhook_token).transpose() + } + + async fn list_by_user(&self, user_id: &UserId) -> Result, DomainError> { + let uid = user_id.value().to_string(); + + let rows = sqlx::query( + "SELECT id, user_id, token_hash, provider, label, created_at, last_used_at \ + FROM webhook_tokens WHERE user_id = ? ORDER BY created_at DESC", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + rows.iter().map(row_to_webhook_token).collect() + } + + async fn delete(&self, id: &WebhookTokenId, user_id: &UserId) -> Result<(), DomainError> { + let id_str = id.value().to_string(); + let uid = user_id.value().to_string(); + + let result = sqlx::query("DELETE FROM webhook_tokens WHERE id = ? AND user_id = ?") + .bind(&id_str) + .bind(&uid) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + if result.rows_affected() == 0 { + return Err(DomainError::NotFound(format!("Webhook token {id_str}"))); + } + Ok(()) + } + + async fn touch_last_used(&self, id: &WebhookTokenId) -> Result<(), DomainError> { + let id_str = id.value().to_string(); + let now = datetime_to_str(&chrono::Utc::now().naive_utc()); + + sqlx::query("UPDATE webhook_tokens SET last_used_at = ? WHERE id = ?") + .bind(&now) + .bind(&id_str) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } +} diff --git a/crates/adapters/sqlite/src/watchlist.rs b/crates/adapters/sqlite/src/repositories/watchlist/mod.rs similarity index 99% rename from crates/adapters/sqlite/src/watchlist.rs rename to crates/adapters/sqlite/src/repositories/watchlist/mod.rs index ae78e83c..2485dd53 100644 --- a/crates/adapters/sqlite/src/watchlist.rs +++ b/crates/adapters/sqlite/src/repositories/watchlist/mod.rs @@ -10,7 +10,9 @@ use domain::{ }; use sqlx::{Row, SqlitePool}; -use crate::models::WatchlistRow; +use rows::WatchlistRow; + +mod rows; use crate::shared::datetime_to_str; pub struct SqliteWatchlistRepository { diff --git a/crates/adapters/sqlite/src/repositories/watchlist/rows.rs b/crates/adapters/sqlite/src/repositories/watchlist/rows.rs new file mode 100644 index 00000000..992f0460 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/watchlist/rows.rs @@ -0,0 +1,33 @@ +use crate::shared::{ + movie_row_to_domain, watchlist_entry_to_domain, watchlist_with_movie_to_domain, +}; +use domain::{errors::DomainError, models::WatchlistWithMovie}; + +#[derive(sqlx::FromRow)] +pub(crate) struct WatchlistRow { + pub id: String, + pub user_id: String, + pub movie_id: String, + pub added_at: String, + pub m_id: String, + pub external_metadata_id: Option, + pub title: String, + pub release_year: i64, + pub director: Option, + pub poster_path: Option, +} + +impl WatchlistRow { + pub fn into_domain(self) -> Result { + let entry = watchlist_entry_to_domain(self.id, self.user_id, self.movie_id, self.added_at)?; + let movie = movie_row_to_domain( + self.m_id, + self.external_metadata_id, + self.title, + self.release_year, + self.director, + self.poster_path, + )?; + Ok(watchlist_with_movie_to_domain(entry, movie)) + } +} diff --git a/crates/adapters/sqlite/src/repositories/wrapup/mod.rs b/crates/adapters/sqlite/src/repositories/wrapup/mod.rs new file mode 100644 index 00000000..b694630e --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/wrapup/mod.rs @@ -0,0 +1,200 @@ +use async_trait::async_trait; +use chrono::NaiveDate; +use domain::{ + errors::DomainError, + models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpStatus}, + ports::WrapUpRepository, + value_objects::WrapUpId, +}; +use sqlx::SqlitePool; +use uuid::Uuid; + +use rows::{row_to_record, status_to_str}; + +mod query; +mod rows; + +pub use query::SqliteWrapUpStatsQuery; + +// ── WrapUpRepository ───────────────────────────────────────────────────────── + +pub struct SqliteWrapUpRepository { + pool: SqlitePool, +} + +impl SqliteWrapUpRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl WrapUpRepository for SqliteWrapUpRepository { + async fn create(&self, record: &WrapUpRecord) -> Result<(), DomainError> { + let id = record.id.value().to_string(); + let user_id = record.user_id.map(|u| u.to_string()); + let status = status_to_str(&record.status); + let start = record.start_date.format("%Y-%m-%d").to_string(); + let end = record.end_date.format("%Y-%m-%d").to_string(); + let created = record.created_at.format("%Y-%m-%d %H:%M:%S").to_string(); + let completed = record + .completed_at + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()); + + sqlx::query( + "INSERT INTO wrap_up_records \ + (id, user_id, start_date, end_date, status, report_json, error_message, created_at, completed_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(&user_id) + .bind(&start) + .bind(&end) + .bind(status) + .bind(record.report.as_ref().and_then(|r| serde_json::to_string(r).ok())) + .bind(&record.error_message) + .bind(&created) + .bind(&completed) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + async fn update_status( + &self, + id: &WrapUpId, + status: &WrapUpStatus, + error: Option<&str>, + ) -> Result<(), DomainError> { + let id_str = id.value().to_string(); + let status_str = status_to_str(status); + + sqlx::query("UPDATE wrap_up_records SET status = ?, error_message = ? WHERE id = ?") + .bind(status_str) + .bind(error) + .bind(&id_str) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + async fn set_complete(&self, id: &WrapUpId, report: &WrapUpReport) -> Result<(), DomainError> { + let id_str = id.value().to_string(); + let json = serde_json::to_string(report) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + sqlx::query( + "UPDATE wrap_up_records \ + SET status = 'ready', report_json = ?, completed_at = strftime('%Y-%m-%d %H:%M:%S', 'now') \ + WHERE id = ?", + ) + .bind(&json) + .bind(&id_str) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + async fn get_by_id(&self, id: &WrapUpId) -> Result, DomainError> { + let id_str = id.value().to_string(); + + let row = sqlx::query( + "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ + created_at, completed_at \ + FROM wrap_up_records WHERE id = ?", + ) + .bind(&id_str) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + row.as_ref().map(row_to_record).transpose() + } + + async fn list_for_user(&self, user_id: Uuid) -> Result, DomainError> { + let uid = user_id.to_string(); + + let rows = sqlx::query( + "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ + created_at, completed_at \ + FROM wrap_up_records WHERE user_id = ? ORDER BY created_at DESC", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + rows.iter().map(row_to_record).collect() + } + + async fn list_global(&self) -> Result, DomainError> { + let rows = sqlx::query( + "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ + created_at, completed_at \ + FROM wrap_up_records WHERE user_id IS NULL ORDER BY created_at DESC", + ) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + rows.iter().map(row_to_record).collect() + } + + async fn find_existing( + &self, + user_id: Option, + start: NaiveDate, + end: NaiveDate, + ) -> Result, DomainError> { + let uid = user_id.map(|u| u.to_string()); + let start_str = start.format("%Y-%m-%d").to_string(); + let end_str = end.format("%Y-%m-%d").to_string(); + + let row = sqlx::query( + "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ + created_at, completed_at \ + FROM wrap_up_records \ + WHERE ((? IS NULL AND user_id IS NULL) OR user_id = ?) \ + AND start_date = ? AND end_date = ? \ + LIMIT 1", + ) + .bind(&uid) + .bind(&uid) + .bind(&start_str) + .bind(&end_str) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + row.as_ref().map(row_to_record).transpose() + } + + async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError> { + sqlx::query("DELETE FROM wrap_up_records WHERE id = ?") + .bind(id.value().to_string()) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(()) + } + + async fn delete_failed_older_than( + &self, + before: chrono::NaiveDateTime, + ) -> Result { + let before_str = before.format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let result = + sqlx::query("DELETE FROM wrap_up_records WHERE status = 'failed' AND created_at < ?") + .bind(&before_str) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(result.rows_affected()) + } +} diff --git a/crates/adapters/sqlite/src/wrapup.rs b/crates/adapters/sqlite/src/repositories/wrapup/query.rs similarity index 51% rename from crates/adapters/sqlite/src/wrapup.rs rename to crates/adapters/sqlite/src/repositories/wrapup/query.rs index 1348a0c2..95416ae9 100644 --- a/crates/adapters/sqlite/src/wrapup.rs +++ b/crates/adapters/sqlite/src/repositories/wrapup/query.rs @@ -1,276 +1,15 @@ use std::collections::HashMap; use async_trait::async_trait; -use chrono::NaiveDate; use domain::{ errors::DomainError, - models::wrapup::{ - DateRange, WrapUpMovieRow, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus, - }, - ports::{WrapUpRepository, WrapUpStatsQuery}, - value_objects::WrapUpId, + models::wrapup::{DateRange, WrapUpMovieRow, WrapUpScope}, + ports::WrapUpStatsQuery, }; use sqlx::{Row, SqlitePool}; -use uuid::Uuid; use crate::shared::{parse_datetime, parse_uuid}; -fn status_to_str(s: &WrapUpStatus) -> &'static str { - match s { - WrapUpStatus::Pending => "pending", - WrapUpStatus::Generating => "generating", - WrapUpStatus::Ready => "ready", - WrapUpStatus::Failed => "failed", - } -} - -fn parse_status(s: &str) -> Result { - match s { - "pending" => Ok(WrapUpStatus::Pending), - "generating" => Ok(WrapUpStatus::Generating), - "ready" => Ok(WrapUpStatus::Ready), - "failed" => Ok(WrapUpStatus::Failed), - other => Err(DomainError::InfrastructureError(format!( - "Unknown wrap-up status: {other}" - ))), - } -} - -fn parse_date(s: &str) -> Result { - NaiveDate::parse_from_str(s, "%Y-%m-%d") - .map_err(|e| DomainError::InfrastructureError(format!("Invalid date '{s}': {e}"))) -} - -// ── WrapUpRepository ───────────────────────────────────────────────────────── - -pub struct SqliteWrapUpRepository { - pool: SqlitePool, -} - -impl SqliteWrapUpRepository { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WrapUpRepository for SqliteWrapUpRepository { - async fn create(&self, record: &WrapUpRecord) -> Result<(), DomainError> { - let id = record.id.value().to_string(); - let user_id = record.user_id.map(|u| u.to_string()); - let status = status_to_str(&record.status); - let start = record.start_date.format("%Y-%m-%d").to_string(); - let end = record.end_date.format("%Y-%m-%d").to_string(); - let created = record.created_at.format("%Y-%m-%d %H:%M:%S").to_string(); - let completed = record - .completed_at - .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()); - - sqlx::query( - "INSERT INTO wrap_up_records \ - (id, user_id, start_date, end_date, status, report_json, error_message, created_at, completed_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(&id) - .bind(&user_id) - .bind(&start) - .bind(&end) - .bind(status) - .bind(record.report.as_ref().and_then(|r| serde_json::to_string(r).ok())) - .bind(&record.error_message) - .bind(&created) - .bind(&completed) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - async fn update_status( - &self, - id: &WrapUpId, - status: &WrapUpStatus, - error: Option<&str>, - ) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let status_str = status_to_str(status); - - sqlx::query("UPDATE wrap_up_records SET status = ?, error_message = ? WHERE id = ?") - .bind(status_str) - .bind(error) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - async fn set_complete(&self, id: &WrapUpId, report: &WrapUpReport) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let json = serde_json::to_string(report) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - sqlx::query( - "UPDATE wrap_up_records \ - SET status = 'ready', report_json = ?, completed_at = strftime('%Y-%m-%d %H:%M:%S', 'now') \ - WHERE id = ?", - ) - .bind(&json) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - async fn get_by_id(&self, id: &WrapUpId) -> Result, DomainError> { - let id_str = id.value().to_string(); - - let row = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - created_at, completed_at \ - FROM wrap_up_records WHERE id = ?", - ) - .bind(&id_str) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - row.as_ref().map(row_to_record).transpose() - } - - async fn list_for_user(&self, user_id: Uuid) -> Result, DomainError> { - let uid = user_id.to_string(); - - let rows = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - created_at, completed_at \ - FROM wrap_up_records WHERE user_id = ? ORDER BY created_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - rows.iter().map(row_to_record).collect() - } - - async fn list_global(&self) -> Result, DomainError> { - let rows = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - created_at, completed_at \ - FROM wrap_up_records WHERE user_id IS NULL ORDER BY created_at DESC", - ) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - rows.iter().map(row_to_record).collect() - } - - async fn find_existing( - &self, - user_id: Option, - start: NaiveDate, - end: NaiveDate, - ) -> Result, DomainError> { - let uid = user_id.map(|u| u.to_string()); - let start_str = start.format("%Y-%m-%d").to_string(); - let end_str = end.format("%Y-%m-%d").to_string(); - - let row = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - created_at, completed_at \ - FROM wrap_up_records \ - WHERE ((? IS NULL AND user_id IS NULL) OR user_id = ?) \ - AND start_date = ? AND end_date = ? \ - LIMIT 1", - ) - .bind(&uid) - .bind(&uid) - .bind(&start_str) - .bind(&end_str) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - row.as_ref().map(row_to_record).transpose() - } - - async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError> { - sqlx::query("DELETE FROM wrap_up_records WHERE id = ?") - .bind(id.value().to_string()) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - Ok(()) - } - - async fn delete_failed_older_than( - &self, - before: chrono::NaiveDateTime, - ) -> Result { - let before_str = before.format("%Y-%m-%dT%H:%M:%SZ").to_string(); - let result = - sqlx::query("DELETE FROM wrap_up_records WHERE status = 'failed' AND created_at < ?") - .bind(&before_str) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - Ok(result.rows_affected()) - } -} - -fn row_to_record(row: &sqlx::sqlite::SqliteRow) -> Result { - let id_str: String = row.try_get("id").map_err(crate::shared::map_sqlx_error)?; - let user_id_str: Option = row - .try_get("user_id") - .map_err(crate::shared::map_sqlx_error)?; - let start_date_str: String = row - .try_get("start_date") - .map_err(crate::shared::map_sqlx_error)?; - let end_date_str: String = row - .try_get("end_date") - .map_err(crate::shared::map_sqlx_error)?; - let status_str: String = row - .try_get("status") - .map_err(crate::shared::map_sqlx_error)?; - let report_json: Option = row - .try_get("report_json") - .map_err(crate::shared::map_sqlx_error)?; - let error_message: Option = row - .try_get("error_message") - .map_err(crate::shared::map_sqlx_error)?; - let created_at_str: String = row - .try_get("created_at") - .map_err(crate::shared::map_sqlx_error)?; - let completed_at_str: Option = row - .try_get("completed_at") - .map_err(crate::shared::map_sqlx_error)?; - - let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?; - - let report = report_json.and_then(|j| serde_json::from_str(&j).ok()); - - Ok(WrapUpRecord { - id: WrapUpId::from_uuid(parse_uuid(&id_str)?), - user_id, - start_date: parse_date(&start_date_str)?, - end_date: parse_date(&end_date_str)?, - status: parse_status(&status_str)?, - report, - error_message, - created_at: parse_datetime(&created_at_str)?, - completed_at: completed_at_str - .as_deref() - .map(parse_datetime) - .transpose()?, - }) -} - // ── WrapUpStatsQuery ───────────────────────────────────────────────────────── pub struct SqliteWrapUpStatsQuery { diff --git a/crates/adapters/sqlite/src/repositories/wrapup/rows.rs b/crates/adapters/sqlite/src/repositories/wrapup/rows.rs new file mode 100644 index 00000000..fc189041 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/wrapup/rows.rs @@ -0,0 +1,82 @@ +use chrono::NaiveDate; +use domain::{ + errors::DomainError, + models::wrapup::{WrapUpRecord, WrapUpStatus}, + value_objects::WrapUpId, +}; +use sqlx::Row; + +use crate::shared::{parse_datetime, parse_uuid}; + +pub(super) fn status_to_str(s: &WrapUpStatus) -> &'static str { + match s { + WrapUpStatus::Pending => "pending", + WrapUpStatus::Generating => "generating", + WrapUpStatus::Ready => "ready", + WrapUpStatus::Failed => "failed", + } +} + +pub(super) fn parse_status(s: &str) -> Result { + match s { + "pending" => Ok(WrapUpStatus::Pending), + "generating" => Ok(WrapUpStatus::Generating), + "ready" => Ok(WrapUpStatus::Ready), + "failed" => Ok(WrapUpStatus::Failed), + other => Err(DomainError::InfrastructureError(format!( + "Unknown wrap-up status: {other}" + ))), + } +} + +pub(super) fn parse_date(s: &str) -> Result { + NaiveDate::parse_from_str(s, "%Y-%m-%d") + .map_err(|e| DomainError::InfrastructureError(format!("Invalid date '{s}': {e}"))) +} + +pub(super) fn row_to_record(row: &sqlx::sqlite::SqliteRow) -> Result { + let id_str: String = row.try_get("id").map_err(crate::shared::map_sqlx_error)?; + let user_id_str: Option = row + .try_get("user_id") + .map_err(crate::shared::map_sqlx_error)?; + let start_date_str: String = row + .try_get("start_date") + .map_err(crate::shared::map_sqlx_error)?; + let end_date_str: String = row + .try_get("end_date") + .map_err(crate::shared::map_sqlx_error)?; + let status_str: String = row + .try_get("status") + .map_err(crate::shared::map_sqlx_error)?; + let report_json: Option = row + .try_get("report_json") + .map_err(crate::shared::map_sqlx_error)?; + let error_message: Option = row + .try_get("error_message") + .map_err(crate::shared::map_sqlx_error)?; + let created_at_str: String = row + .try_get("created_at") + .map_err(crate::shared::map_sqlx_error)?; + let completed_at_str: Option = row + .try_get("completed_at") + .map_err(crate::shared::map_sqlx_error)?; + + let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?; + + let report = report_json.and_then(|j| serde_json::from_str(&j).ok()); + + Ok(WrapUpRecord { + id: WrapUpId::from_uuid(parse_uuid(&id_str)?), + user_id, + start_date: parse_date(&start_date_str)?, + end_date: parse_date(&end_date_str)?, + status: parse_status(&status_str)?, + report, + error_message, + created_at: parse_datetime(&created_at_str)?, + completed_at: completed_at_str + .as_deref() + .map(parse_datetime) + .transpose()?, + }) +} diff --git a/crates/adapters/sqlite/src/watch_event.rs b/crates/adapters/sqlite/src/watch_event.rs deleted file mode 100644 index ff821c82..00000000 --- a/crates/adapters/sqlite/src/watch_event.rs +++ /dev/null @@ -1,399 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken}, - ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository}, - value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId}, -}; -use sqlx::{Row, SqlitePool}; - -use crate::shared::datetime_to_str; - -fn parse_uuid(s: &str) -> Result { - s.parse() - .map_err(|_| DomainError::InfrastructureError(format!("invalid UUID: {s}"))) -} - -fn parse_datetime(s: &str) -> Result { - chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") - .or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")) - .map_err(|_| DomainError::InfrastructureError(format!("invalid datetime: {s}"))) -} - -// ── WatchEventRepository ────────────────────────────────────────────────────── - -pub struct SqliteWatchEventRepository { - pool: SqlitePool, -} - -impl SqliteWatchEventRepository { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WatchEventCommand for SqliteWatchEventRepository { - async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { - let id = event.id().value().to_string(); - let user_id = event.user_id().value().to_string(); - let movie_id = event.movie_id().map(|m| m.value().to_string()); - let source = event.source().to_string(); - let watched_at = datetime_to_str(event.watched_at()); - let status = event.status().to_string(); - let created_at = datetime_to_str(event.created_at()); - - sqlx::query( - "INSERT INTO watch_events \ - (id, user_id, movie_id, title, year, external_metadata_id, source, watched_at, status, created_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(&id) - .bind(&user_id) - .bind(&movie_id) - .bind(event.title()) - .bind(event.year().map(|y| y as i64)) - .bind(event.external_metadata_id()) - .bind(&source) - .bind(&watched_at) - .bind(&status) - .bind(&created_at) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - async fn update_status( - &self, - id: &WatchEventId, - status: WatchEventStatus, - ) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let status_str = status.to_string(); - - sqlx::query("UPDATE watch_events SET status = ? WHERE id = ?") - .bind(&status_str) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - async fn update_status_batch( - &self, - ids: &[WatchEventId], - status: WatchEventStatus, - ) -> Result { - if ids.is_empty() { - return Ok(0); - } - let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect(); - let sql = format!( - "UPDATE watch_events SET status = ? WHERE id IN ({})", - placeholders.join(",") - ); - let mut q = sqlx::query(&sql).bind(status.to_string()); - for id in ids { - q = q.bind(id.value().to_string()); - } - let result = q - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - Ok(result.rows_affected()) - } - - async fn delete_non_pending_older_than( - &self, - before: chrono::NaiveDateTime, - ) -> Result { - let before_str = datetime_to_str(&before); - let result = - sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < ?") - .bind(&before_str) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - Ok(result.rows_affected()) - } -} - -#[async_trait] -impl WatchEventQuery for SqliteWatchEventRepository { - async fn list_pending(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - - let rows = sqlx::query( - "SELECT id, user_id, movie_id, title, year, external_metadata_id, \ - source, watched_at, status, created_at \ - FROM watch_events \ - WHERE user_id = ? AND status = 'pending' \ - ORDER BY watched_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - rows.iter().map(row_to_watch_event).collect() - } - - async fn get_by_id(&self, id: &WatchEventId) -> Result, DomainError> { - let id_str = id.value().to_string(); - - let row = sqlx::query( - "SELECT id, user_id, movie_id, title, year, external_metadata_id, \ - source, watched_at, status, created_at \ - FROM watch_events WHERE id = ?", - ) - .bind(&id_str) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - row.as_ref().map(row_to_watch_event).transpose() - } - - async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result, DomainError> { - if ids.is_empty() { - return Ok(vec![]); - } - let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect(); - let sql = format!( - "SELECT id, user_id, movie_id, title, year, external_metadata_id, \ - source, watched_at, status, created_at \ - FROM watch_events WHERE id IN ({})", - placeholders.join(",") - ); - let mut q = sqlx::query(&sql); - for id in ids { - q = q.bind(id.value().to_string()); - } - let rows = q - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - rows.iter().map(row_to_watch_event).collect() - } - - async fn find_duplicate( - &self, - user_id: &UserId, - external_id: &str, - after: chrono::NaiveDateTime, - ) -> Result { - let uid = user_id.value().to_string(); - let after_str = datetime_to_str(&after); - - let count: i64 = sqlx::query( - "SELECT COUNT(*) FROM watch_events \ - WHERE user_id = ? AND external_metadata_id = ? AND created_at > ?", - ) - .bind(&uid) - .bind(external_id) - .bind(&after_str) - .fetch_one(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)? - .try_get(0) - .unwrap_or(0); - - Ok(count > 0) - } -} - -fn row_to_watch_event(row: &sqlx::sqlite::SqliteRow) -> Result { - let id_str: &str = row.try_get("id").map_err(crate::shared::map_sqlx_error)?; - let user_id_str: &str = row - .try_get("user_id") - .map_err(crate::shared::map_sqlx_error)?; - let movie_id_str: Option<&str> = row - .try_get("movie_id") - .map_err(crate::shared::map_sqlx_error)?; - let title: String = row - .try_get("title") - .map_err(crate::shared::map_sqlx_error)?; - let year: Option = row.try_get("year").map_err(crate::shared::map_sqlx_error)?; - let ext_id: Option = row - .try_get("external_metadata_id") - .map_err(crate::shared::map_sqlx_error)?; - let source_str: String = row - .try_get("source") - .map_err(crate::shared::map_sqlx_error)?; - let watched_at_str: String = row - .try_get("watched_at") - .map_err(crate::shared::map_sqlx_error)?; - let status_str: String = row - .try_get("status") - .map_err(crate::shared::map_sqlx_error)?; - let created_at_str: String = row - .try_get("created_at") - .map_err(crate::shared::map_sqlx_error)?; - - let source: WatchEventSource = source_str - .parse() - .map_err(|e: String| DomainError::InfrastructureError(e))?; - let status: WatchEventStatus = status_str - .parse() - .map_err(|e: String| DomainError::InfrastructureError(e))?; - - let movie_id = movie_id_str - .map(parse_uuid) - .transpose()? - .map(MovieId::from_uuid); - - Ok(WatchEvent::from_persistence(PersistedWatchEvent { - id: WatchEventId::from_uuid(parse_uuid(id_str)?), - user_id: UserId::from_uuid(parse_uuid(user_id_str)?), - movie_id, - title, - year: year.map(|y| y as u16), - external_metadata_id: ext_id, - source, - watched_at: parse_datetime(&watched_at_str)?, - status, - created_at: parse_datetime(&created_at_str)?, - })) -} - -// ── WebhookTokenRepository ──────────────────────────────────────────────────── - -pub struct SqliteWebhookTokenRepository { - pool: SqlitePool, -} - -impl SqliteWebhookTokenRepository { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WebhookTokenRepository for SqliteWebhookTokenRepository { - async fn save(&self, token: &WebhookToken) -> Result<(), DomainError> { - let id = token.id().value().to_string(); - let user_id = token.user_id().value().to_string(); - let provider = token.provider().to_string(); - let created_at = datetime_to_str(token.created_at()); - let last_used = token.last_used_at().map(datetime_to_str); - - sqlx::query( - "INSERT INTO webhook_tokens \ - (id, user_id, token_hash, provider, label, created_at, last_used_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?)", - ) - .bind(&id) - .bind(&user_id) - .bind(token.token_hash()) - .bind(&provider) - .bind(token.label()) - .bind(&created_at) - .bind(&last_used) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - async fn find_by_token_hash(&self, hash: &str) -> Result, DomainError> { - let row = sqlx::query( - "SELECT id, user_id, token_hash, provider, label, created_at, last_used_at \ - FROM webhook_tokens WHERE token_hash = ?", - ) - .bind(hash) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - row.as_ref().map(row_to_webhook_token).transpose() - } - - async fn list_by_user(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - - let rows = sqlx::query( - "SELECT id, user_id, token_hash, provider, label, created_at, last_used_at \ - FROM webhook_tokens WHERE user_id = ? ORDER BY created_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - rows.iter().map(row_to_webhook_token).collect() - } - - async fn delete(&self, id: &WebhookTokenId, user_id: &UserId) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let uid = user_id.value().to_string(); - - let result = sqlx::query("DELETE FROM webhook_tokens WHERE id = ? AND user_id = ?") - .bind(&id_str) - .bind(&uid) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - if result.rows_affected() == 0 { - return Err(DomainError::NotFound(format!("Webhook token {id_str}"))); - } - Ok(()) - } - - async fn touch_last_used(&self, id: &WebhookTokenId) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let now = datetime_to_str(&chrono::Utc::now().naive_utc()); - - sqlx::query("UPDATE webhook_tokens SET last_used_at = ? WHERE id = ?") - .bind(&now) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } -} - -fn row_to_webhook_token(row: &sqlx::sqlite::SqliteRow) -> Result { - let id_str: &str = row.try_get("id").map_err(crate::shared::map_sqlx_error)?; - let user_id_str: &str = row - .try_get("user_id") - .map_err(crate::shared::map_sqlx_error)?; - let token_hash: String = row - .try_get("token_hash") - .map_err(crate::shared::map_sqlx_error)?; - let provider_str: String = row - .try_get("provider") - .map_err(crate::shared::map_sqlx_error)?; - let label: Option = row - .try_get("label") - .map_err(crate::shared::map_sqlx_error)?; - let created_at_str: String = row - .try_get("created_at") - .map_err(crate::shared::map_sqlx_error)?; - let last_used_str: Option = row - .try_get("last_used_at") - .map_err(crate::shared::map_sqlx_error)?; - - let provider: WatchEventSource = provider_str - .parse() - .map_err(|e: String| DomainError::InfrastructureError(e))?; - - let last_used = last_used_str.map(|s| parse_datetime(&s)).transpose()?; - - Ok(WebhookToken::from_persistence( - WebhookTokenId::from_uuid(parse_uuid(id_str)?), - UserId::from_uuid(parse_uuid(user_id_str)?), - token_hash, - provider, - label, - parse_datetime(&created_at_str)?, - last_used, - )) -} -- 2.49.1 From 8bdf55cfe1bfcab35b084167bff82d7e6adc1c8f Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:00:42 +0100 Subject: [PATCH 011/104] feat: config crate with legacy env names --- Cargo.lock | 90 +++++ Cargo.toml | 3 + Dockerfile | 1 + crates/config/Cargo.toml | 13 + crates/config/src/app_config.rs | 29 ++ crates/config/src/auth_config.rs | 38 ++ crates/config/src/database_config.rs | 16 + crates/config/src/errors.rs | 11 + crates/config/src/events_config.rs | 29 ++ crates/config/src/federation_config.rs | 10 + crates/config/src/image_conversion_config.rs | 11 + crates/config/src/lenient.rs | 79 ++++ crates/config/src/lib.rs | 28 ++ crates/config/src/loader.rs | 139 +++++++ crates/config/src/metadata_config.rs | 43 +++ crates/config/src/nats_config.rs | 23 ++ crates/config/src/server_config.rs | 33 ++ crates/config/src/storage_config.rs | 41 ++ crates/config/src/wrapup_config.rs | 9 + crates/config/tests/loader_test.rs | 379 +++++++++++++++++++ 20 files changed, 1025 insertions(+) create mode 100644 crates/config/Cargo.toml create mode 100644 crates/config/src/app_config.rs create mode 100644 crates/config/src/auth_config.rs create mode 100644 crates/config/src/database_config.rs create mode 100644 crates/config/src/errors.rs create mode 100644 crates/config/src/events_config.rs create mode 100644 crates/config/src/federation_config.rs create mode 100644 crates/config/src/image_conversion_config.rs create mode 100644 crates/config/src/lenient.rs create mode 100644 crates/config/src/lib.rs create mode 100644 crates/config/src/loader.rs create mode 100644 crates/config/src/metadata_config.rs create mode 100644 crates/config/src/nats_config.rs create mode 100644 crates/config/src/server_config.rs create mode 100644 crates/config/src/storage_config.rs create mode 100644 crates/config/src/wrapup_config.rs create mode 100644 crates/config/tests/loader_test.rs diff --git a/Cargo.lock b/Cargo.lock index 378715aa..1370ca03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1153,6 +1153,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "config" +version = "0.1.0" +dependencies = [ + "figment", + "serde", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1934,6 +1944,22 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "parking_lot", + "pear", + "serde", + "tempfile", + "toml", + "uncased", + "version_check", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -2749,6 +2775,12 @@ dependencies = [ "sqlx", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + [[package]] name = "inout" version = "0.1.4" @@ -3668,6 +3700,29 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", +] + [[package]] name = "pem" version = "3.0.6" @@ -4029,6 +4084,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", + "yansi", +] + [[package]] name = "profiling" version = "1.0.18" @@ -5934,6 +6002,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime 0.6.11", + "toml_write", "winnow 0.7.15", ] @@ -5958,6 +6027,12 @@ dependencies = [ "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -6142,6 +6217,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicase" version = "2.9.0" @@ -7160,6 +7244,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index e38ee522..7bbd3f0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "crates/adapters/jellyfin", "crates/adapters/plex", "crates/infra-wiring", + "crates/config", ] resolver = "2" @@ -63,6 +64,7 @@ hex = "0.4" object_store = { version = "0.11", features = ["aws"] } axum = { version = "0.8.8", features = ["macros", "multipart"] } csv = "1" +figment = { version = "0.10", features = ["toml", "env"] } api-types = { path = "crates/api-types" } domain = { path = "crates/domain" } @@ -89,6 +91,7 @@ jellyfin = { path = "crates/adapters/jellyfin" } plex = { path = "crates/adapters/plex" } image-converter = { path = "crates/adapters/image-converter" } infra-wiring = { path = "crates/infra-wiring" } +config = { path = "crates/config" } [profile.dev] debug = 1 # line tables only — still debuggable, much faster linking diff --git a/Dockerfile b/Dockerfile index 1b4c67bd..a737d7be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,7 @@ COPY crates/tui/Cargo.toml crates/tui/Cargo.toml COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/Cargo.toml COPY crates/worker/Cargo.toml crates/worker/Cargo.toml COPY crates/infra-wiring/Cargo.toml crates/infra-wiring/Cargo.toml +COPY crates/config/Cargo.toml crates/config/Cargo.toml # Stub every crate so cargo can resolve and fetch deps RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \ diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml new file mode 100644 index 00000000..01a3c64b --- /dev/null +++ b/crates/config/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "config" +edition.workspace = true +version = "0.1.0" + +[dependencies] +thiserror.workspace = true +serde.workspace = true +figment.workspace = true +tracing.workspace = true + +[dev-dependencies] +figment = { workspace = true, features = ["test"] } diff --git a/crates/config/src/app_config.rs b/crates/config/src/app_config.rs new file mode 100644 index 00000000..cf291739 --- /dev/null +++ b/crates/config/src/app_config.rs @@ -0,0 +1,29 @@ +use serde::Deserialize; + +use crate::{ + AuthConfig, DatabaseConfig, EventsConfig, FederationConfig, ImageConversionConfig, + MetadataConfig, NatsConfig, ServerConfig, StorageConfig, WrapUpConfig, +}; + +#[derive(Debug, Clone, Deserialize)] +pub struct AppConfig { + #[serde(default)] + pub server: ServerConfig, + pub database: DatabaseConfig, + #[serde(default)] + pub auth: AuthConfig, + #[serde(default)] + pub events: EventsConfig, + #[serde(default)] + pub nats: NatsConfig, + #[serde(default)] + pub storage: StorageConfig, + #[serde(default)] + pub image_conversion: ImageConversionConfig, + #[serde(default)] + pub metadata: MetadataConfig, + #[serde(default)] + pub federation: FederationConfig, + #[serde(default)] + pub wrapup: WrapUpConfig, +} diff --git a/crates/config/src/auth_config.rs b/crates/config/src/auth_config.rs new file mode 100644 index 00000000..0005948a --- /dev/null +++ b/crates/config/src/auth_config.rs @@ -0,0 +1,38 @@ +use std::fmt; + +use serde::Deserialize; + +use crate::lenient; + +#[derive(Clone, Deserialize)] +#[serde(default)] +pub struct AuthConfig { + pub jwt_secret: Option, + #[serde(deserialize_with = "lenient::number")] + pub jwt_ttl_seconds: u64, + #[serde(deserialize_with = "lenient::number")] + pub refresh_ttl_seconds: u64, +} + +impl Default for AuthConfig { + fn default() -> Self { + Self { + jwt_secret: None, + jwt_ttl_seconds: 900, + refresh_ttl_seconds: 2_592_000, + } + } +} + +impl fmt::Debug for AuthConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AuthConfig") + .field( + "jwt_secret", + &self.jwt_secret.as_ref().map(|_| ""), + ) + .field("jwt_ttl_seconds", &self.jwt_ttl_seconds) + .field("refresh_ttl_seconds", &self.refresh_ttl_seconds) + .finish() + } +} diff --git a/crates/config/src/database_config.rs b/crates/config/src/database_config.rs new file mode 100644 index 00000000..1f301f12 --- /dev/null +++ b/crates/config/src/database_config.rs @@ -0,0 +1,16 @@ +use std::fmt; + +use serde::Deserialize; + +#[derive(Clone, Deserialize)] +pub struct DatabaseConfig { + pub url: String, +} + +impl fmt::Debug for DatabaseConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DatabaseConfig") + .field("url", &"") + .finish() + } +} diff --git a/crates/config/src/errors.rs b/crates/config/src/errors.rs new file mode 100644 index 00000000..5d8577e1 --- /dev/null +++ b/crates/config/src/errors.rs @@ -0,0 +1,11 @@ +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("configuration file {0} was named by CONFIG_FILE but does not exist")] + NamedFileMissing(String), + #[error("{0} is required but not set")] + Missing(&'static str), + #[error("invalid value for {variable}: {reason}")] + Invalid { variable: String, reason: String }, + #[error("could not read configuration: {0}")] + Unreadable(#[from] Box), +} diff --git a/crates/config/src/events_config.rs b/crates/config/src/events_config.rs new file mode 100644 index 00000000..d1ec745e --- /dev/null +++ b/crates/config/src/events_config.rs @@ -0,0 +1,29 @@ +use serde::Deserialize; + +use crate::lenient; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct EventsConfig { + pub bus_backend: String, + #[serde(deserialize_with = "lenient::number")] + pub channel_buffer: usize, + #[serde(deserialize_with = "lenient::number")] + pub queue_poll_interval_ms: u64, + #[serde(deserialize_with = "lenient::number")] + pub queue_batch_size: i64, + #[serde(deserialize_with = "lenient::number")] + pub queue_max_attempts: i32, +} + +impl Default for EventsConfig { + fn default() -> Self { + Self { + bus_backend: "db".into(), + channel_buffer: 128, + queue_poll_interval_ms: 500, + queue_batch_size: 10, + queue_max_attempts: 5, + } + } +} diff --git a/crates/config/src/federation_config.rs b/crates/config/src/federation_config.rs new file mode 100644 index 00000000..5e8df522 --- /dev/null +++ b/crates/config/src/federation_config.rs @@ -0,0 +1,10 @@ +use serde::Deserialize; + +use crate::lenient; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct FederationConfig { + #[serde(deserialize_with = "lenient::true_or_one")] + pub debug: bool, +} diff --git a/crates/config/src/image_conversion_config.rs b/crates/config/src/image_conversion_config.rs new file mode 100644 index 00000000..066a0524 --- /dev/null +++ b/crates/config/src/image_conversion_config.rs @@ -0,0 +1,11 @@ +use serde::Deserialize; + +use crate::lenient; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct ImageConversionConfig { + #[serde(deserialize_with = "lenient::true_only")] + pub enabled: bool, + pub format: Option, +} diff --git a/crates/config/src/lenient.rs b/crates/config/src/lenient.rs new file mode 100644 index 00000000..74377255 --- /dev/null +++ b/crates/config/src/lenient.rs @@ -0,0 +1,79 @@ +//! Deserializers for values that arrive as strings from the environment but +//! may be native numbers/bools in a TOML file. + +use std::fmt; +use std::marker::PhantomData; +use std::str::FromStr; + +use serde::Deserializer; +use serde::de::{self, Visitor}; + +struct Number(PhantomData); + +impl Visitor<'_> for Number { + type Value = T; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a number") + } + + fn visit_str(self, v: &str) -> Result { + v.trim() + .parse() + .map_err(|_| E::custom("expected a number in range")) + } + + fn visit_u64(self, v: u64) -> Result { + self.visit_str(&v.to_string()) + } + + fn visit_i64(self, v: i64) -> Result { + self.visit_str(&v.to_string()) + } +} + +pub fn number<'de, D, T>(d: D) -> Result +where + D: Deserializer<'de>, + T: FromStr, +{ + d.deserialize_any(Number(PhantomData)) +} + +struct Flag { + accepts_one: bool, +} + +impl Visitor<'_> for Flag { + type Value = bool; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a boolean") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(v) + } + + fn visit_str(self, v: &str) -> Result { + Ok(v == "true" || (self.accepts_one && v == "1")) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(self.accepts_one && v == 1) + } + + fn visit_i64(self, v: i64) -> Result { + Ok(self.accepts_one && v == 1) + } +} + +/// `true` only for `true` or `1` (anything else is false, like the old readers). +pub fn true_or_one<'de, D: Deserializer<'de>>(d: D) -> Result { + d.deserialize_any(Flag { accepts_one: true }) +} + +/// `true` only for `true`. +pub fn true_only<'de, D: Deserializer<'de>>(d: D) -> Result { + d.deserialize_any(Flag { accepts_one: false }) +} diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs new file mode 100644 index 00000000..4de68b35 --- /dev/null +++ b/crates/config/src/lib.rs @@ -0,0 +1,28 @@ +mod app_config; +mod auth_config; +mod database_config; +mod errors; +mod events_config; +mod federation_config; +mod image_conversion_config; +mod lenient; +mod loader; +mod metadata_config; +mod nats_config; +mod server_config; +mod storage_config; +mod wrapup_config; + +pub use app_config::AppConfig; +pub use auth_config::AuthConfig; +pub use database_config::DatabaseConfig; +pub use errors::ConfigError; +pub use events_config::EventsConfig; +pub use federation_config::FederationConfig; +pub use image_conversion_config::ImageConversionConfig; +pub use loader::load; +pub use metadata_config::MetadataConfig; +pub use nats_config::NatsConfig; +pub use server_config::ServerConfig; +pub use storage_config::StorageConfig; +pub use wrapup_config::WrapUpConfig; diff --git a/crates/config/src/loader.rs b/crates/config/src/loader.rs new file mode 100644 index 00000000..e0fc448b --- /dev/null +++ b/crates/config/src/loader.rs @@ -0,0 +1,139 @@ +use std::path::Path; + +use figment::providers::{Format, Toml}; +use figment::value::{Dict, Map, Value}; +use figment::{Figment, Metadata, Profile, Provider}; + +use crate::AppConfig; +use crate::errors::ConfigError; + +const DEFAULT_CONFIG_FILE: &str = "config.toml"; +const CONFIG_FILE_VARIABLE: &str = "CONFIG_FILE"; + +/// Every environment variable the application has always honoured, mapped to +/// its `section.field` key. No prefix: the names are the public contract. +const LEGACY_ENV: &[(&str, &str)] = &[ + ("ALLOW_REGISTRATION", "server.allow_registration"), + ("BASE_URL", "server.base_url"), + ("CORS_ORIGINS", "server.cors_origins"), + ("DATABASE_URL", "database.url"), + ("EVENT_BUS_BACKEND", "events.bus_backend"), + ("EVENT_CHANNEL_BUFFER", "events.channel_buffer"), + ("EVENT_QUEUE_BATCH_SIZE", "events.queue_batch_size"), + ("EVENT_QUEUE_MAX_ATTEMPTS", "events.queue_max_attempts"), + ( + "EVENT_QUEUE_POLL_INTERVAL_MS", + "events.queue_poll_interval_ms", + ), + ("FEDERATION_DEBUG", "federation.debug"), + ("HOST", "server.host"), + ("PORT", "server.port"), + ("IMAGE_CONVERSION_ENABLED", "image_conversion.enabled"), + ("IMAGE_CONVERSION_FORMAT", "image_conversion.format"), + ("IMAGE_STORAGE_BACKEND", "storage.backend"), + ("IMAGE_STORAGE_PATH", "storage.path"), + ("JWT_SECRET", "auth.jwt_secret"), + ("JWT_TTL_SECONDS", "auth.jwt_ttl_seconds"), + ("MINIO_ACCESS_KEY_ID", "storage.access_key_id"), + ("MINIO_BUCKET", "storage.bucket"), + ("MINIO_ENDPOINT", "storage.endpoint"), + ("MINIO_REGION", "storage.region"), + ("MINIO_SECRET_ACCESS_KEY", "storage.secret_access_key"), + ("NATS_CONSUMER_NAME", "nats.consumer_name"), + ("NATS_MODE", "nats.mode"), + ("NATS_STREAM_NAME", "nats.stream_name"), + ("NATS_SUBJECT_PREFIX", "nats.subject_prefix"), + ("NATS_URL", "nats.url"), + ("OMDB_API_KEY", "metadata.omdb_api_key"), + ( + "POSTER_FETCH_TIMEOUT_SECONDS", + "metadata.poster_fetch_timeout_seconds", + ), + ("RATE_LIMIT", "server.rate_limit"), + ("REFRESH_TTL_SECONDS", "auth.refresh_ttl_seconds"), + ("SECURE_COOKIES", "server.secure_cookies"), + ("TMDB_API_KEY", "metadata.tmdb_api_key"), + ("WRAPUP_BG_DIR", "wrapup.bg_dir"), + ("WRAPUP_FONT_PATH", "wrapup.font_path"), + ("WRAPUP_LOGO_PATH", "wrapup.logo_path"), +]; + +/// Reads the legacy variables as plain strings; typing is left to the config +/// structs so that e.g. `JWT_SECRET=12345` stays a string. +struct LegacyEnv; + +impl Provider for LegacyEnv { + fn metadata(&self) -> Metadata { + Metadata::named("environment variables") + } + + fn data(&self) -> Result, figment::Error> { + let mut sections: Dict = Dict::new(); + for (variable, key) in LEGACY_ENV { + let Some(raw) = std::env::var_os(variable) else { + continue; + }; + let value = raw + .into_string() + .map_err(|_| figment::Error::from(format!("{variable} is not valid unicode")))?; + let (section, field) = key.split_once('.').expect("legacy keys are section.field"); + let entry = sections + .entry(section.to_owned()) + .or_insert_with(|| Value::from(Dict::new())); + if let Value::Dict(_, dict) = entry { + dict.insert(field.to_owned(), Value::from(value)); + } + } + Ok(Profile::Default.collect(sections)) + } +} + +pub fn load() -> Result { + let named_file = std::env::var(CONFIG_FILE_VARIABLE).ok(); + let path = named_file.as_deref().unwrap_or(DEFAULT_CONFIG_FILE); + + if named_file.is_some() && !Path::new(path).exists() { + return Err(ConfigError::NamedFileMissing(path.to_owned())); + } + + let figment = Figment::new().merge(Toml::file(path)).merge(LegacyEnv); + + if figment.find_value("database.url").is_err() { + return Err(ConfigError::Missing("DATABASE_URL")); + } + + let mut config: AppConfig = figment.extract().map_err(into_config_error)?; + config.server.base_url = config.server.base_url.trim_end_matches('/').to_owned(); + + announce(path); + + Ok(config) +} + +fn into_config_error(error: figment::Error) -> ConfigError { + let key = error.path.join("."); + match LEGACY_ENV.iter().find(|(_, k)| *k == key) { + Some((variable, _)) if std::env::var_os(variable).is_some() => ConfigError::Invalid { + variable: (*variable).to_owned(), + reason: error.kind.to_string(), + }, + _ => ConfigError::Unreadable(Box::new(error)), + } +} + +fn announce(path: &str) { + if Path::new(path).exists() { + tracing::info!(path, "loaded configuration from file"); + } else { + tracing::info!("no configuration file found, using defaults"); + } + + let overriding: Vec<&str> = LEGACY_ENV + .iter() + .map(|(variable, _)| *variable) + .filter(|variable| std::env::var_os(variable).is_some()) + .collect(); + if !overriding.is_empty() { + tracing::info!(variables = ?overriding, "configuration overridden from the environment"); + } +} diff --git a/crates/config/src/metadata_config.rs b/crates/config/src/metadata_config.rs new file mode 100644 index 00000000..b1d8589a --- /dev/null +++ b/crates/config/src/metadata_config.rs @@ -0,0 +1,43 @@ +use std::fmt; + +use serde::Deserialize; + +use crate::lenient; + +#[derive(Clone, Deserialize)] +#[serde(default)] +pub struct MetadataConfig { + pub tmdb_api_key: Option, + pub omdb_api_key: Option, + #[serde(deserialize_with = "lenient::number")] + pub poster_fetch_timeout_seconds: u64, +} + +impl Default for MetadataConfig { + fn default() -> Self { + Self { + tmdb_api_key: None, + omdb_api_key: None, + poster_fetch_timeout_seconds: 30, + } + } +} + +impl fmt::Debug for MetadataConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MetadataConfig") + .field( + "tmdb_api_key", + &self.tmdb_api_key.as_ref().map(|_| ""), + ) + .field( + "omdb_api_key", + &self.omdb_api_key.as_ref().map(|_| ""), + ) + .field( + "poster_fetch_timeout_seconds", + &self.poster_fetch_timeout_seconds, + ) + .finish() + } +} diff --git a/crates/config/src/nats_config.rs b/crates/config/src/nats_config.rs new file mode 100644 index 00000000..cb878bb3 --- /dev/null +++ b/crates/config/src/nats_config.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct NatsConfig { + pub url: Option, + pub mode: String, + pub subject_prefix: String, + pub stream_name: String, + pub consumer_name: String, +} + +impl Default for NatsConfig { + fn default() -> Self { + Self { + url: None, + mode: "jetstream".into(), + subject_prefix: "movies-diary.events".into(), + stream_name: "MOVIES_DIARY_EVENTS".into(), + consumer_name: "worker".into(), + } + } +} diff --git a/crates/config/src/server_config.rs b/crates/config/src/server_config.rs new file mode 100644 index 00000000..140066ae --- /dev/null +++ b/crates/config/src/server_config.rs @@ -0,0 +1,33 @@ +use serde::Deserialize; + +use crate::lenient; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct ServerConfig { + pub host: String, + #[serde(deserialize_with = "lenient::number")] + pub port: u16, + pub base_url: String, + pub cors_origins: String, + #[serde(deserialize_with = "lenient::true_or_one")] + pub allow_registration: bool, + #[serde(deserialize_with = "lenient::true_only")] + pub secure_cookies: bool, + #[serde(deserialize_with = "lenient::number")] + pub rate_limit: u64, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + host: "0.0.0.0".into(), + port: 3000, + base_url: "http://localhost:3000".into(), + cors_origins: String::new(), + allow_registration: false, + secure_cookies: false, + rate_limit: 60, + } + } +} diff --git a/crates/config/src/storage_config.rs b/crates/config/src/storage_config.rs new file mode 100644 index 00000000..ef58f1d0 --- /dev/null +++ b/crates/config/src/storage_config.rs @@ -0,0 +1,41 @@ +use std::fmt; + +use serde::Deserialize; + +#[derive(Clone, Deserialize)] +#[serde(default)] +pub struct StorageConfig { + pub backend: String, + pub path: String, + pub endpoint: Option, + pub access_key_id: Option, + pub secret_access_key: Option, + pub bucket: Option, + pub region: String, +} + +impl Default for StorageConfig { + fn default() -> Self { + Self { + backend: "local".into(), + path: "./images".into(), + endpoint: None, + access_key_id: None, + secret_access_key: None, + bucket: None, + region: "minio".into(), + } + } +} + +impl fmt::Debug for StorageConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("StorageConfig") + .field("backend", &self.backend) + .field("path", &self.path) + .field("endpoint", &self.endpoint) + .field("bucket", &self.bucket) + .field("region", &self.region) + .finish_non_exhaustive() + } +} diff --git a/crates/config/src/wrapup_config.rs b/crates/config/src/wrapup_config.rs new file mode 100644 index 00000000..89b896e7 --- /dev/null +++ b/crates/config/src/wrapup_config.rs @@ -0,0 +1,9 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct WrapUpConfig { + pub font_path: Option, + pub logo_path: Option, + pub bg_dir: Option, +} diff --git a/crates/config/tests/loader_test.rs b/crates/config/tests/loader_test.rs new file mode 100644 index 00000000..88d984ad --- /dev/null +++ b/crates/config/tests/loader_test.rs @@ -0,0 +1,379 @@ +#![allow(clippy::result_large_err)] + +use config::{ConfigError, load}; +use figment::Jail; + +fn jail_with(jail: &mut Jail, vars: &[(&str, &str)]) { + jail.clear_env(); + jail.set_env("DATABASE_URL", "sqlite://test.db"); + for (name, value) in vars { + jail.set_env(name, value); + } +} + +macro_rules! env_sets { + ($test:ident, $var:literal, $value:literal, |$c:ident| $assert:expr) => { + #[test] + fn $test() { + Jail::expect_with(|jail| { + jail_with(jail, &[($var, $value)]); + let $c = load().expect("config loads"); + assert!($assert, "{} not applied", $var); + Ok(()) + }); + } + }; +} + +env_sets!( + allow_registration_env_sets_allow_registration, + "ALLOW_REGISTRATION", + "true", + |c| c.server.allow_registration +); +env_sets!( + base_url_env_sets_base_url, + "BASE_URL", + "https://x.example/", + |c| c.server.base_url == "https://x.example" +); +env_sets!( + cors_origins_env_sets_cors_origins, + "CORS_ORIGINS", + "https://a.example,https://b.example", + |c| c.server.cors_origins == "https://a.example,https://b.example" +); +env_sets!( + database_url_env_sets_url, + "DATABASE_URL", + "sqlite://other.db", + |c| c.database.url == "sqlite://other.db" +); +env_sets!( + event_bus_backend_env_sets_bus_backend, + "EVENT_BUS_BACKEND", + "nats", + |c| c.events.bus_backend == "nats" +); +env_sets!( + event_channel_buffer_env_sets_channel_buffer, + "EVENT_CHANNEL_BUFFER", + "7", + |c| c.events.channel_buffer == 7 +); +env_sets!( + event_queue_batch_size_env_sets_queue_batch_size, + "EVENT_QUEUE_BATCH_SIZE", + "11", + |c| c.events.queue_batch_size == 11 +); +env_sets!( + event_queue_max_attempts_env_sets_queue_max_attempts, + "EVENT_QUEUE_MAX_ATTEMPTS", + "9", + |c| c.events.queue_max_attempts == 9 +); +env_sets!( + event_queue_poll_interval_ms_env_sets_queue_poll_interval_ms, + "EVENT_QUEUE_POLL_INTERVAL_MS", + "250", + |c| c.events.queue_poll_interval_ms == 250 +); +env_sets!( + federation_debug_env_sets_debug, + "FEDERATION_DEBUG", + "1", + |c| c.federation.debug +); +env_sets!(host_env_sets_host, "HOST", "127.0.0.1", |c| c.server.host + == "127.0.0.1"); +env_sets!(port_env_sets_port, "PORT", "8080", |c| c.server.port + == 8080); +env_sets!( + image_conversion_enabled_env_sets_enabled, + "IMAGE_CONVERSION_ENABLED", + "true", + |c| c.image_conversion.enabled +); +env_sets!( + image_conversion_format_env_sets_format, + "IMAGE_CONVERSION_FORMAT", + "webp", + |c| c.image_conversion.format.as_deref() == Some("webp") +); +env_sets!( + image_storage_backend_env_sets_backend, + "IMAGE_STORAGE_BACKEND", + "s3", + |c| c.storage.backend == "s3" +); +env_sets!( + image_storage_path_env_sets_path, + "IMAGE_STORAGE_PATH", + "/data/img", + |c| c.storage.path == "/data/img" +); +env_sets!( + jwt_secret_env_sets_jwt_secret, + "JWT_SECRET", + "s3cret", + |c| c.auth.jwt_secret.as_deref() == Some("s3cret") +); +env_sets!( + jwt_ttl_seconds_env_sets_jwt_ttl_seconds, + "JWT_TTL_SECONDS", + "60", + |c| c.auth.jwt_ttl_seconds == 60 +); +env_sets!( + minio_access_key_id_env_sets_access_key_id, + "MINIO_ACCESS_KEY_ID", + "ak", + |c| c.storage.access_key_id.as_deref() == Some("ak") +); +env_sets!(minio_bucket_env_sets_bucket, "MINIO_BUCKET", "b", |c| c + .storage + .bucket + .as_deref() + == Some("b")); +env_sets!( + minio_endpoint_env_sets_endpoint, + "MINIO_ENDPOINT", + "http://minio:9000", + |c| c.storage.endpoint.as_deref() == Some("http://minio:9000") +); +env_sets!(minio_region_env_sets_region, "MINIO_REGION", "eu", |c| c + .storage + .region + == "eu"); +env_sets!( + minio_secret_access_key_env_sets_secret_access_key, + "MINIO_SECRET_ACCESS_KEY", + "sk", + |c| c.storage.secret_access_key.as_deref() == Some("sk") +); +env_sets!( + nats_consumer_name_env_sets_consumer_name, + "NATS_CONSUMER_NAME", + "w2", + |c| c.nats.consumer_name == "w2" +); +env_sets!(nats_mode_env_sets_mode, "NATS_MODE", "core", |c| c + .nats + .mode + == "core"); +env_sets!( + nats_stream_name_env_sets_stream_name, + "NATS_STREAM_NAME", + "S", + |c| c.nats.stream_name == "S" +); +env_sets!( + nats_subject_prefix_env_sets_subject_prefix, + "NATS_SUBJECT_PREFIX", + "p.e", + |c| c.nats.subject_prefix == "p.e" +); +env_sets!(nats_url_env_sets_url, "NATS_URL", "nats://n:4222", |c| c + .nats + .url + .as_deref() + == Some("nats://n:4222")); +env_sets!( + omdb_api_key_env_sets_omdb_api_key, + "OMDB_API_KEY", + "ok", + |c| c.metadata.omdb_api_key.as_deref() == Some("ok") +); +env_sets!( + poster_fetch_timeout_seconds_env_sets_poster_fetch_timeout_seconds, + "POSTER_FETCH_TIMEOUT_SECONDS", + "5", + |c| c.metadata.poster_fetch_timeout_seconds == 5 +); +env_sets!(rate_limit_env_sets_rate_limit, "RATE_LIMIT", "120", |c| c + .server + .rate_limit + == 120); +env_sets!( + refresh_ttl_seconds_env_sets_refresh_ttl_seconds, + "REFRESH_TTL_SECONDS", + "99", + |c| c.auth.refresh_ttl_seconds == 99 +); +env_sets!( + secure_cookies_env_sets_secure_cookies, + "SECURE_COOKIES", + "true", + |c| c.server.secure_cookies +); +env_sets!( + tmdb_api_key_env_sets_tmdb_api_key, + "TMDB_API_KEY", + "tk", + |c| c.metadata.tmdb_api_key.as_deref() == Some("tk") +); +env_sets!(wrapup_bg_dir_env_sets_bg_dir, "WRAPUP_BG_DIR", "/bg", |c| c + .wrapup + .bg_dir + .as_deref() + == Some("/bg")); +env_sets!( + wrapup_font_path_env_sets_font_path, + "WRAPUP_FONT_PATH", + "/f.ttf", + |c| c.wrapup.font_path.as_deref() == Some("/f.ttf") +); +env_sets!( + wrapup_logo_path_env_sets_logo_path, + "WRAPUP_LOGO_PATH", + "/l.png", + |c| c.wrapup.logo_path.as_deref() == Some("/l.png") +); + +#[test] +fn numeric_looking_string_values_stay_strings() { + Jail::expect_with(|jail| { + jail_with(jail, &[("JWT_SECRET", "12345"), ("TMDB_API_KEY", "1")]); + let c = load().expect("config loads"); + assert_eq!(c.auth.jwt_secret.as_deref(), Some("12345")); + assert_eq!(c.metadata.tmdb_api_key.as_deref(), Some("1")); + Ok(()) + }); +} + +#[test] +fn defaults_match_the_previous_env_readers() { + Jail::expect_with(|jail| { + jail_with(jail, &[]); + let c = load().expect("config loads"); + assert!(!c.server.allow_registration); + assert_eq!(c.server.base_url, "http://localhost:3000"); + assert_eq!(c.server.cors_origins, ""); + assert_eq!(c.server.host, "0.0.0.0"); + assert_eq!(c.server.port, 3000); + assert_eq!(c.server.rate_limit, 60); + assert!(!c.server.secure_cookies); + assert_eq!(c.database.url, "sqlite://test.db"); + assert_eq!(c.auth.jwt_secret, None); + assert_eq!(c.auth.jwt_ttl_seconds, 900); + assert_eq!(c.auth.refresh_ttl_seconds, 2_592_000); + assert_eq!(c.events.bus_backend, "db"); + assert_eq!(c.events.channel_buffer, 128); + assert_eq!(c.events.queue_poll_interval_ms, 500); + assert_eq!(c.events.queue_batch_size, 10); + assert_eq!(c.events.queue_max_attempts, 5); + assert_eq!(c.nats.url, None); + assert_eq!(c.nats.mode, "jetstream"); + assert_eq!(c.nats.subject_prefix, "movies-diary.events"); + assert_eq!(c.nats.stream_name, "MOVIES_DIARY_EVENTS"); + assert_eq!(c.nats.consumer_name, "worker"); + assert_eq!(c.storage.backend, "local"); + assert_eq!(c.storage.path, "./images"); + assert_eq!(c.storage.endpoint, None); + assert_eq!(c.storage.access_key_id, None); + assert_eq!(c.storage.secret_access_key, None); + assert_eq!(c.storage.bucket, None); + assert_eq!(c.storage.region, "minio"); + assert!(!c.image_conversion.enabled); + assert_eq!(c.image_conversion.format, None); + assert_eq!(c.metadata.tmdb_api_key, None); + assert_eq!(c.metadata.omdb_api_key, None); + assert_eq!(c.metadata.poster_fetch_timeout_seconds, 30); + assert!(!c.federation.debug); + assert_eq!(c.wrapup.font_path, None); + assert_eq!(c.wrapup.logo_path, None); + assert_eq!(c.wrapup.bg_dir, None); + Ok(()) + }); +} + +#[test] +fn toml_file_is_overridden_by_env() { + Jail::expect_with(|jail| { + jail_with(jail, &[("PORT", "9000")]); + jail.create_file( + "config.toml", + "[server]\nport = 1234\nhost = \"10.0.0.1\"\n[auth]\njwt_secret = \"from-file\"\n", + )?; + let c = load().expect("config loads"); + assert_eq!(c.server.port, 9000, "env wins over file"); + assert_eq!(c.server.host, "10.0.0.1", "file wins over default"); + assert_eq!(c.auth.jwt_secret.as_deref(), Some("from-file")); + Ok(()) + }); +} + +#[test] +fn config_file_env_names_an_alternative_file() { + Jail::expect_with(|jail| { + jail_with(jail, &[("CONFIG_FILE", "alt.toml")]); + jail.create_file("config.toml", "[server]\nport = 1\n")?; + jail.create_file("alt.toml", "[server]\nport = 2\n")?; + assert_eq!(load().expect("config loads").server.port, 2); + Ok(()) + }); +} + +#[test] +fn missing_named_config_file_is_an_error() { + Jail::expect_with(|jail| { + jail_with(jail, &[("CONFIG_FILE", "nope.toml")]); + let err = load().expect_err("named file is missing"); + assert!(matches!(err, ConfigError::NamedFileMissing(_)), "{err}"); + Ok(()) + }); +} + +#[test] +fn missing_database_url_is_an_error_naming_the_variable() { + Jail::expect_with(|jail| { + jail.clear_env(); + let err = load().expect_err("DATABASE_URL is required"); + assert!(err.to_string().contains("DATABASE_URL"), "{err}"); + Ok(()) + }); +} + +#[test] +fn malformed_numeric_env_value_fails_naming_the_variable() { + Jail::expect_with(|jail| { + jail_with(jail, &[("RATE_LIMIT", "abc")]); + let err = load().expect_err("RATE_LIMIT=abc is malformed"); + assert!(err.to_string().contains("RATE_LIMIT"), "{err}"); + Ok(()) + }); +} + +#[test] +fn allow_registration_accepts_true_and_1_only() { + for (value, expected) in [ + ("true", true), + ("1", true), + ("false", false), + ("0", false), + ("yes", false), + ("TRUE", false), + ] { + Jail::expect_with(|jail| { + jail_with(jail, &[("ALLOW_REGISTRATION", value)]); + let c = load().expect("config loads"); + assert_eq!(c.server.allow_registration, expected, "value {value:?}"); + Ok(()) + }); + } +} + +#[test] +fn secure_cookies_and_image_conversion_accept_true_only() { + Jail::expect_with(|jail| { + jail_with( + jail, + &[("SECURE_COOKIES", "1"), ("IMAGE_CONVERSION_ENABLED", "1")], + ); + let c = load().expect("config loads"); + assert!(!c.server.secure_cookies); + assert!(!c.image_conversion.enabled); + Ok(()) + }); +} -- 2.49.1 From 1f724bb064d7478a9cf2e36d1960b9591bc6286d Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:02:35 +0100 Subject: [PATCH 012/104] fix: config file parse errors not reported as missing DATABASE_URL --- Cargo.lock | 49 ------------------------------ Cargo.toml | 2 +- crates/config/src/loader.rs | 3 ++ crates/config/tests/loader_test.rs | 12 ++++++++ 4 files changed, 16 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1370ca03..10331fbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1952,7 +1952,6 @@ checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" dependencies = [ "atomic", "parking_lot", - "pear", "serde", "tempfile", "toml", @@ -2775,12 +2774,6 @@ dependencies = [ "sqlx", ] -[[package]] -name = "inlinable_string" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" - [[package]] name = "inout" version = "0.1.4" @@ -3700,29 +3693,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pear" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" -dependencies = [ - "inlinable_string", - "pear_codegen", - "yansi", -] - -[[package]] -name = "pear_codegen" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" -dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn 2.0.117", -] - [[package]] name = "pem" version = "3.0.6" @@ -4084,19 +4054,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "version_check", - "yansi", -] - [[package]] name = "profiling" version = "1.0.18" @@ -7244,12 +7201,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 7bbd3f0c..49ec33ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,7 +64,7 @@ hex = "0.4" object_store = { version = "0.11", features = ["aws"] } axum = { version = "0.8.8", features = ["macros", "multipart"] } csv = "1" -figment = { version = "0.10", features = ["toml", "env"] } +figment = { version = "0.10", features = ["toml"] } api-types = { path = "crates/api-types" } domain = { path = "crates/domain" } diff --git a/crates/config/src/loader.rs b/crates/config/src/loader.rs index e0fc448b..250404f6 100644 --- a/crates/config/src/loader.rs +++ b/crates/config/src/loader.rs @@ -98,6 +98,9 @@ pub fn load() -> Result { let figment = Figment::new().merge(Toml::file(path)).merge(LegacyEnv); + figment + .data() + .map_err(|e| ConfigError::Unreadable(Box::new(e)))?; if figment.find_value("database.url").is_err() { return Err(ConfigError::Missing("DATABASE_URL")); } diff --git a/crates/config/tests/loader_test.rs b/crates/config/tests/loader_test.rs index 88d984ad..58f8d11f 100644 --- a/crates/config/tests/loader_test.rs +++ b/crates/config/tests/loader_test.rs @@ -377,3 +377,15 @@ fn secure_cookies_and_image_conversion_accept_true_only() { Ok(()) }); } + +#[test] +fn malformed_config_file_reports_the_file_not_a_missing_database_url() { + Jail::expect_with(|jail| { + jail_with(jail, &[]); + jail.create_file("config.toml", "[server\nport = = 1\n")?; + let err = load().expect_err("config.toml is malformed"); + assert!(matches!(err, ConfigError::Unreadable(_)), "{err}"); + assert!(!err.to_string().contains("DATABASE_URL"), "{err}"); + Ok(()) + }); +} -- 2.49.1 From 04f9c136258b83bc59365c4596b6403ad4169347 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:07:19 +0100 Subject: [PATCH 013/104] refactor: adapters configured from config crate --- Cargo.lock | 14 ++++++ crates/adapters/activitypub/Cargo.toml | 1 + crates/adapters/activitypub/src/lib.rs | 6 +-- crates/adapters/auth/Cargo.toml | 1 + crates/adapters/auth/src/lib.rs | 20 ++++----- crates/adapters/event-publisher/Cargo.toml | 1 + crates/adapters/event-publisher/src/lib.rs | 10 ++--- crates/adapters/image-converter/Cargo.toml | 1 + crates/adapters/image-converter/src/config.rs | 7 +-- crates/adapters/image-converter/src/lib.rs | 3 +- .../image-converter/src/tests/config.rs | 12 ++++++ crates/adapters/metadata/Cargo.toml | 1 + crates/adapters/metadata/src/lib.rs | 10 +++-- crates/adapters/nats/Cargo.toml | 1 + crates/adapters/nats/src/config.rs | 12 +++--- crates/adapters/nats/src/tests/config.rs | 17 ++++++++ crates/adapters/object-storage/Cargo.toml | 1 + crates/adapters/object-storage/src/config.rs | 28 ++++++------ crates/adapters/object-storage/src/lib.rs | 4 +- .../object-storage/src/tests/config.rs | 19 ++++++++ crates/adapters/poster-fetcher/Cargo.toml | 1 + crates/adapters/poster-fetcher/src/config.rs | 10 ++--- crates/adapters/poster-fetcher/src/lib.rs | 12 ++++-- crates/adapters/sqlite/Cargo.toml | 1 + crates/adapters/sqlite/src/event_queue/mod.rs | 27 +++++------- crates/adapters/tmdb-enrichment/Cargo.toml | 1 + crates/adapters/tmdb-enrichment/src/client.rs | 8 ++-- .../src/diary/tests/get_activity_feed.rs | 2 + crates/application/src/test_helpers.rs | 2 + crates/composition/Cargo.toml | 1 + crates/composition/src/factory.rs | 24 +++++++---- crates/composition/src/tests/build.rs | 2 + crates/infra-wiring/Cargo.toml | 1 + crates/infra-wiring/src/config.rs | 43 +++++++------------ crates/infra-wiring/src/lib.rs | 7 +-- crates/presentation/src/csrf.rs | 22 ++++++---- crates/presentation/src/handlers/auth.rs | 18 +++++--- crates/presentation/src/routes.rs | 20 +++++---- crates/presentation/src/tests/extractors.rs | 2 + crates/server/Cargo.toml | 1 + crates/server/src/main.rs | 41 +++++++++--------- crates/server/tests/api_test.rs | 2 + crates/worker/Cargo.toml | 1 + crates/worker/src/event_bus.rs | 13 +++--- crates/worker/src/main.rs | 23 +++++----- 45 files changed, 277 insertions(+), 177 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 10331fbf..d4de1912 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,7 @@ dependencies = [ "async-trait", "axum", "chrono", + "config", "domain", "k-ap", "serde", @@ -663,6 +664,7 @@ dependencies = [ "argon2", "async-trait", "chrono", + "config", "domain", "jsonwebtoken", "rand_core 0.6.4", @@ -1128,6 +1130,7 @@ dependencies = [ "async-trait", "auth", "chrono", + "config", "domain", "infra-wiring", "jellyfin", @@ -1887,6 +1890,7 @@ name = "event-publisher" version = "0.1.0" dependencies = [ "async-trait", + "config", "domain", "futures", "tokio", @@ -2693,6 +2697,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "config", "domain", "image", "object-storage", @@ -2771,6 +2776,7 @@ name = "infra-wiring" version = "0.1.0" dependencies = [ "anyhow", + "config", "sqlx", ] @@ -3246,6 +3252,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "config", "domain", "reqwest 0.13.3", "serde", @@ -3350,6 +3357,7 @@ dependencies = [ "async-nats", "async-trait", "chrono", + "config", "domain", "event-payload", "futures", @@ -3571,6 +3579,7 @@ dependencies = [ "anyhow", "async-trait", "bytes", + "config", "domain", "futures", "infer", @@ -3949,6 +3958,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "config", "domain", "reqwest 0.13.3", ] @@ -4946,6 +4956,7 @@ dependencies = [ "axum", "bytes", "composition", + "config", "domain", "dotenvy", "export", @@ -5208,6 +5219,7 @@ dependencies = [ "async-trait", "bytes", "chrono", + "config", "domain", "event-payload", "futures", @@ -5828,6 +5840,7 @@ dependencies = [ "anyhow", "async-trait", "chrono", + "config", "domain", "reqwest 0.13.3", "serde", @@ -7175,6 +7188,7 @@ dependencies = [ "async-trait", "auth", "composition", + "config", "domain", "dotenvy", "export", diff --git a/crates/adapters/activitypub/Cargo.toml b/crates/adapters/activitypub/Cargo.toml index 261eb766..c00dcf72 100644 --- a/crates/adapters/activitypub/Cargo.toml +++ b/crates/adapters/activitypub/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition.workspace = true [dependencies] +config = { workspace = true } k-ap = { version = "0.5.0", registry = "gitea" } domain = { workspace = true } axum = { workspace = true } diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index ee8e3531..3fa93716 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -68,6 +68,7 @@ pub struct ActivityPubDeps { pub follow_query: std::sync::Arc, pub instance: domain::value_objects::InstanceIdentity, pub allow_registration: bool, + pub federation: config::FederationConfig, pub event_publisher: std::sync::Arc, } @@ -92,6 +93,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { follow_query: _, instance, allow_registration, + federation, event_publisher, } = deps; let review_handler = std::sync::Arc::new(ReviewObjectHandler { @@ -118,9 +120,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { goal: goal_handler, }); - let federation_debug = std::env::var("FEDERATION_DEBUG") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); + let federation_debug = federation.debug; if federation_debug { tracing::warn!( diff --git a/crates/adapters/auth/Cargo.toml b/crates/adapters/auth/Cargo.toml index 89a9f7b3..a55b773d 100644 --- a/crates/adapters/auth/Cargo.toml +++ b/crates/adapters/auth/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition.workspace = true [dependencies] +config = { workspace = true } async-trait = { workspace = true } domain = { workspace = true } anyhow = { workspace = true } diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs index 6271e4b0..7cf767bf 100644 --- a/crates/adapters/auth/src/lib.rs +++ b/crates/adapters/auth/src/lib.rs @@ -22,19 +22,17 @@ pub struct AuthConfig { } impl AuthConfig { - pub fn from_env() -> anyhow::Result { - let secret = std::env::var("JWT_SECRET") - .map_err(|_| anyhow::anyhow!("JWT_SECRET env var is required"))?; + pub fn from_config(config: &config::AuthConfig) -> anyhow::Result { + let secret = config + .jwt_secret + .clone() + .ok_or_else(|| anyhow::anyhow!("JWT_SECRET env var is required"))?; if secret.is_empty() { anyhow::bail!("JWT_SECRET must not be empty"); } - let ttl_seconds = std::env::var("JWT_TTL_SECONDS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(900u64); Ok(Self { secret, - ttl_seconds, + ttl_seconds: config.jwt_ttl_seconds, }) } } @@ -107,11 +105,13 @@ impl PasswordHasher for Argon2PasswordHasher { } } -pub fn create() -> anyhow::Result<( +pub fn create( + config: &config::AuthConfig, +) -> anyhow::Result<( std::sync::Arc, std::sync::Arc, )> { - let config = AuthConfig::from_env()?; + let config = AuthConfig::from_config(config)?; Ok(( std::sync::Arc::new(JwtAuthService::new(config)), std::sync::Arc::new(Argon2PasswordHasher), diff --git a/crates/adapters/event-publisher/Cargo.toml b/crates/adapters/event-publisher/Cargo.toml index bb925b3a..09aaee99 100644 --- a/crates/adapters/event-publisher/Cargo.toml +++ b/crates/adapters/event-publisher/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition.workspace = true [dependencies] +config = { workspace = true } domain = { workspace = true } async-trait = { workspace = true } tokio = { workspace = true } diff --git a/crates/adapters/event-publisher/src/lib.rs b/crates/adapters/event-publisher/src/lib.rs index 302ed8f5..aec30ee4 100644 --- a/crates/adapters/event-publisher/src/lib.rs +++ b/crates/adapters/event-publisher/src/lib.rs @@ -16,12 +16,10 @@ pub struct EventPublisherConfig { } impl EventPublisherConfig { - pub fn from_env() -> Self { - let channel_buffer = std::env::var("EVENT_CHANNEL_BUFFER") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(128); - Self { channel_buffer } + pub fn from_config(config: &config::EventsConfig) -> Self { + Self { + channel_buffer: config.channel_buffer, + } } } diff --git a/crates/adapters/image-converter/Cargo.toml b/crates/adapters/image-converter/Cargo.toml index 3e39230c..27053717 100644 --- a/crates/adapters/image-converter/Cargo.toml +++ b/crates/adapters/image-converter/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +config = { workspace = true } domain = { workspace = true } async-trait = { workspace = true } anyhow = { workspace = true } diff --git a/crates/adapters/image-converter/src/config.rs b/crates/adapters/image-converter/src/config.rs index 04bf3c69..4745e970 100644 --- a/crates/adapters/image-converter/src/config.rs +++ b/crates/adapters/image-converter/src/config.rs @@ -18,11 +18,8 @@ pub struct ConversionConfig { } impl ConversionConfig { - pub fn from_env() -> anyhow::Result> { - Self::from_vars( - std::env::var("IMAGE_CONVERSION_ENABLED").ok().as_deref(), - std::env::var("IMAGE_CONVERSION_FORMAT").ok().as_deref(), - ) + pub fn from_config(config: &::config::ImageConversionConfig) -> anyhow::Result> { + Self::from_vars(config.enabled.then_some("true"), config.format.as_deref()) } fn from_vars(enabled: Option<&str>, format: Option<&str>) -> anyhow::Result> { diff --git a/crates/adapters/image-converter/src/lib.rs b/crates/adapters/image-converter/src/lib.rs index d34822e6..a9dba8f2 100644 --- a/crates/adapters/image-converter/src/lib.rs +++ b/crates/adapters/image-converter/src/lib.rs @@ -14,12 +14,13 @@ use std::sync::Arc; type ConversionPair = (Arc, Arc); pub fn build( + config: &::config::ImageConversionConfig, object_storage: Arc, image_ref_command: Arc, image_ref_query: Arc, event_publisher: Arc, ) -> anyhow::Result> { - let config = match ConversionConfig::from_env()? { + let config = match ConversionConfig::from_config(config)? { Some(c) => c, None => return Ok(None), }; diff --git a/crates/adapters/image-converter/src/tests/config.rs b/crates/adapters/image-converter/src/tests/config.rs index 65941a71..9747b481 100644 --- a/crates/adapters/image-converter/src/tests/config.rs +++ b/crates/adapters/image-converter/src/tests/config.rs @@ -43,3 +43,15 @@ fn avif_extension() { fn webp_extension() { assert_eq!(Format::Webp.extension(), ".webp"); } + +#[test] +fn from_config_follows_section() { + let disabled = ::config::ImageConversionConfig::default(); + assert!(ConversionConfig::from_config(&disabled).unwrap().is_none()); + let enabled = ::config::ImageConversionConfig { + enabled: true, + format: Some("webp".into()), + }; + let cfg = ConversionConfig::from_config(&enabled).unwrap().unwrap(); + assert_eq!(cfg.format, Format::Webp); +} diff --git a/crates/adapters/metadata/Cargo.toml b/crates/adapters/metadata/Cargo.toml index efbb2a6e..2cecca90 100644 --- a/crates/adapters/metadata/Cargo.toml +++ b/crates/adapters/metadata/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition.workspace = true [dependencies] +config = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } reqwest = { workspace = true } diff --git a/crates/adapters/metadata/src/lib.rs b/crates/adapters/metadata/src/lib.rs index 6bd8d029..078ec401 100644 --- a/crates/adapters/metadata/src/lib.rs +++ b/crates/adapters/metadata/src/lib.rs @@ -68,12 +68,16 @@ impl MetadataClient for MetadataClientImpl { } } -pub fn create() -> anyhow::Result> { +pub fn create( + config: &config::MetadataConfig, +) -> anyhow::Result> { use anyhow::Context; - if let Ok(key) = std::env::var("TMDB_API_KEY") { + if let Some(key) = config.tmdb_api_key.clone() { Ok(std::sync::Arc::new(MetadataClientImpl::new_tmdb(key))) } else { - let key = std::env::var("OMDB_API_KEY") + let key = config + .omdb_api_key + .clone() .context("either TMDB_API_KEY or OMDB_API_KEY must be set")?; Ok(std::sync::Arc::new(MetadataClientImpl::new_omdb(key))) } diff --git a/crates/adapters/nats/Cargo.toml b/crates/adapters/nats/Cargo.toml index c2318dc2..ecaacd80 100644 --- a/crates/adapters/nats/Cargo.toml +++ b/crates/adapters/nats/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition.workspace = true [dependencies] +config = { workspace = true } async-nats = "0.48.0" domain = { workspace = true } diff --git a/crates/adapters/nats/src/config.rs b/crates/adapters/nats/src/config.rs index 6395af52..31d5b393 100644 --- a/crates/adapters/nats/src/config.rs +++ b/crates/adapters/nats/src/config.rs @@ -14,13 +14,13 @@ pub struct NatsConfig { } impl NatsConfig { - pub fn from_env() -> anyhow::Result { + pub fn from_config(config: &::config::NatsConfig) -> anyhow::Result { Self::from_vars( - std::env::var("NATS_URL").ok().as_deref(), - std::env::var("NATS_MODE").ok().as_deref(), - std::env::var("NATS_SUBJECT_PREFIX").ok().as_deref(), - std::env::var("NATS_STREAM_NAME").ok().as_deref(), - std::env::var("NATS_CONSUMER_NAME").ok().as_deref(), + config.url.as_deref(), + Some(&config.mode), + Some(&config.subject_prefix), + Some(&config.stream_name), + Some(&config.consumer_name), ) } diff --git a/crates/adapters/nats/src/tests/config.rs b/crates/adapters/nats/src/tests/config.rs index af9ed729..d81453e8 100644 --- a/crates/adapters/nats/src/tests/config.rs +++ b/crates/adapters/nats/src/tests/config.rs @@ -28,3 +28,20 @@ fn invalid_mode_errors() { NatsConfig::from_vars(Some("nats://test:4222"), Some("kafka"), None, None, None).is_err() ); } + +#[test] +fn from_config_requires_url() { + assert!(NatsConfig::from_config(&::config::NatsConfig::default()).is_err()); +} + +#[test] +fn from_config_uses_section_values() { + let section = ::config::NatsConfig { + url: Some("nats://test:4222".into()), + mode: "core".into(), + ..Default::default() + }; + let cfg = NatsConfig::from_config(§ion).unwrap(); + assert_eq!(cfg.url, "nats://test:4222"); + assert_eq!(cfg.mode, NatsMode::Core); +} diff --git a/crates/adapters/object-storage/Cargo.toml b/crates/adapters/object-storage/Cargo.toml index fcf61cfc..ec36d0c8 100644 --- a/crates/adapters/object-storage/Cargo.toml +++ b/crates/adapters/object-storage/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition.workspace = true [dependencies] +config = { workspace = true } domain = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } diff --git a/crates/adapters/object-storage/src/config.rs b/crates/adapters/object-storage/src/config.rs index 73dc5467..a6f66855 100644 --- a/crates/adapters/object-storage/src/config.rs +++ b/crates/adapters/object-storage/src/config.rs @@ -5,21 +5,25 @@ use std::sync::Arc; pub struct StorageConfig(Arc); impl StorageConfig { - pub fn from_env() -> anyhow::Result { - let backend = std::env::var("IMAGE_STORAGE_BACKEND").unwrap_or_else(|_| "local".into()); - - let store: Arc = match backend.as_str() { + pub fn from_config(config: &::config::StorageConfig) -> anyhow::Result { + let store: Arc = match config.backend.as_str() { "s3" => build_s3_store( - &std::env::var("MINIO_ENDPOINT").context("MINIO_ENDPOINT required")?, - &std::env::var("MINIO_ACCESS_KEY_ID").context("MINIO_ACCESS_KEY_ID required")?, - &std::env::var("MINIO_SECRET_ACCESS_KEY") + config + .endpoint + .as_deref() + .context("MINIO_ENDPOINT required")?, + config + .access_key_id + .as_deref() + .context("MINIO_ACCESS_KEY_ID required")?, + config + .secret_access_key + .as_deref() .context("MINIO_SECRET_ACCESS_KEY required")?, - &std::env::var("MINIO_BUCKET").context("MINIO_BUCKET required")?, - &std::env::var("MINIO_REGION").unwrap_or_else(|_| "minio".to_string()), - )?, - "local" => build_local_store( - &std::env::var("IMAGE_STORAGE_PATH").unwrap_or_else(|_| "./images".into()), + config.bucket.as_deref().context("MINIO_BUCKET required")?, + &config.region, )?, + "local" => build_local_store(&config.path)?, other => { anyhow::bail!("Unknown IMAGE_STORAGE_BACKEND: {other:?}. Valid values: s3, local") } diff --git a/crates/adapters/object-storage/src/lib.rs b/crates/adapters/object-storage/src/lib.rs index c183ba10..f66c339a 100644 --- a/crates/adapters/object-storage/src/lib.rs +++ b/crates/adapters/object-storage/src/lib.rs @@ -127,9 +127,9 @@ impl EventHandler for ImageCleanupHandler { } } -pub fn create() -> anyhow::Result> { +pub fn create(config: &::config::StorageConfig) -> anyhow::Result> { Ok(Arc::new(ObjectStorageAdapter::from_config( - StorageConfig::from_env()?, + StorageConfig::from_config(config)?, ))) } diff --git a/crates/adapters/object-storage/src/tests/config.rs b/crates/adapters/object-storage/src/tests/config.rs index f0ee8cc1..26fcd862 100644 --- a/crates/adapters/object-storage/src/tests/config.rs +++ b/crates/adapters/object-storage/src/tests/config.rs @@ -15,3 +15,22 @@ fn local_store_succeeds_if_dir_already_exists() { let result = build_local_store(dir.to_str().unwrap()); assert!(result.is_ok()); } + +#[test] +fn s3_backend_requires_endpoint() { + let section = ::config::StorageConfig { + backend: "s3".into(), + ..Default::default() + }; + let err = StorageConfig::from_config(§ion).err().unwrap(); + assert!(err.to_string().contains("MINIO_ENDPOINT required")); +} + +#[test] +fn unknown_backend_is_error() { + let section = ::config::StorageConfig { + backend: "ftp".into(), + ..Default::default() + }; + assert!(StorageConfig::from_config(§ion).is_err()); +} diff --git a/crates/adapters/poster-fetcher/Cargo.toml b/crates/adapters/poster-fetcher/Cargo.toml index 4d8ab67b..dad29290 100644 --- a/crates/adapters/poster-fetcher/Cargo.toml +++ b/crates/adapters/poster-fetcher/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +config = { workspace = true } domain = { workspace = true } async-trait = { workspace = true } reqwest = { workspace = true } diff --git a/crates/adapters/poster-fetcher/src/config.rs b/crates/adapters/poster-fetcher/src/config.rs index def31db3..52d82d53 100644 --- a/crates/adapters/poster-fetcher/src/config.rs +++ b/crates/adapters/poster-fetcher/src/config.rs @@ -3,11 +3,9 @@ pub struct PosterFetcherConfig { } impl PosterFetcherConfig { - pub fn from_env() -> Self { - let timeout_seconds = std::env::var("POSTER_FETCH_TIMEOUT_SECONDS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(30); - Self { timeout_seconds } + pub fn from_config(config: &::config::MetadataConfig) -> Self { + Self { + timeout_seconds: config.poster_fetch_timeout_seconds, + } } } diff --git a/crates/adapters/poster-fetcher/src/lib.rs b/crates/adapters/poster-fetcher/src/lib.rs index 187e4d76..504eae70 100644 --- a/crates/adapters/poster-fetcher/src/lib.rs +++ b/crates/adapters/poster-fetcher/src/lib.rs @@ -59,14 +59,18 @@ impl ImageFetcher for ReqwestPosterFetcher { } } -pub fn create() -> anyhow::Result> { +pub fn create( + config: &::config::MetadataConfig, +) -> anyhow::Result> { Ok(std::sync::Arc::new(ReqwestPosterFetcher::new( - PosterFetcherConfig::from_env(), + PosterFetcherConfig::from_config(config), )?)) } -pub fn create_image_fetcher() -> anyhow::Result> { +pub fn create_image_fetcher( + config: &::config::MetadataConfig, +) -> anyhow::Result> { Ok(std::sync::Arc::new(ReqwestPosterFetcher::new( - PosterFetcherConfig::from_env(), + PosterFetcherConfig::from_config(config), )?)) } diff --git a/crates/adapters/sqlite/Cargo.toml b/crates/adapters/sqlite/Cargo.toml index 4f207f4d..373715e7 100644 --- a/crates/adapters/sqlite/Cargo.toml +++ b/crates/adapters/sqlite/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition.workspace = true [dependencies] +config = { workspace = true } sqlx = { workspace = true, features = ["macros", "chrono"] } domain = { workspace = true } diff --git a/crates/adapters/sqlite/src/event_queue/mod.rs b/crates/adapters/sqlite/src/event_queue/mod.rs index 7798d060..8479ac8a 100644 --- a/crates/adapters/sqlite/src/event_queue/mod.rs +++ b/crates/adapters/sqlite/src/event_queue/mod.rs @@ -22,20 +22,11 @@ pub struct DbEventQueueConfig { } impl DbEventQueueConfig { - pub fn from_env() -> Self { + pub fn from_config(config: &config::EventsConfig) -> Self { Self { - poll_interval_ms: std::env::var("EVENT_QUEUE_POLL_INTERVAL_MS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(500), - batch_size: std::env::var("EVENT_QUEUE_BATCH_SIZE") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(10), - max_attempts: std::env::var("EVENT_QUEUE_MAX_ATTEMPTS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(5), + poll_interval_ms: config.queue_poll_interval_ms, + batch_size: config.queue_batch_size, + max_attempts: config.queue_max_attempts, } } } @@ -54,15 +45,19 @@ impl SqliteEventQueue { }) } - pub async fn create_publisher(pool: SqlitePool) -> anyhow::Result> { - let q = Self::create(pool, DbEventQueueConfig::from_env()).await?; + pub async fn create_publisher( + pool: SqlitePool, + config: &config::EventsConfig, + ) -> anyhow::Result> { + let q = Self::create(pool, DbEventQueueConfig::from_config(config)).await?; Ok(Arc::new(q)) } pub async fn create_channel( pool: SqlitePool, + config: &config::EventsConfig, ) -> anyhow::Result<(Arc, Arc)> { - let q = Self::create(pool, DbEventQueueConfig::from_env()).await?; + let q = Self::create(pool, DbEventQueueConfig::from_config(config)).await?; Ok((Arc::new(q.clone()), Arc::new(q))) } } diff --git a/crates/adapters/tmdb-enrichment/Cargo.toml b/crates/adapters/tmdb-enrichment/Cargo.toml index 11fa35c3..7e640900 100644 --- a/crates/adapters/tmdb-enrichment/Cargo.toml +++ b/crates/adapters/tmdb-enrichment/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition.workspace = true [dependencies] +config = { workspace = true } domain = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } diff --git a/crates/adapters/tmdb-enrichment/src/client.rs b/crates/adapters/tmdb-enrichment/src/client.rs index 3fc468ef..09c111c5 100644 --- a/crates/adapters/tmdb-enrichment/src/client.rs +++ b/crates/adapters/tmdb-enrichment/src/client.rs @@ -7,9 +7,11 @@ pub struct TmdbEnrichmentClient { } impl TmdbEnrichmentClient { - pub fn from_env() -> Result { - let api_key = std::env::var("TMDB_API_KEY") - .map_err(|_| DomainError::InfrastructureError("TMDB_API_KEY is not set".into()))?; + pub fn from_config(config: &config::MetadataConfig) -> Result { + let api_key = config + .tmdb_api_key + .clone() + .ok_or_else(|| DomainError::InfrastructureError("TMDB_API_KEY is not set".into()))?; Ok(Self { api_key, http: reqwest::Client::new(), diff --git a/crates/application/src/diary/tests/get_activity_feed.rs b/crates/application/src/diary/tests/get_activity_feed.rs index 89471eea..0780aee8 100644 --- a/crates/application/src/diary/tests/get_activity_feed.rs +++ b/crates/application/src/diary/tests/get_activity_feed.rs @@ -129,6 +129,8 @@ async fn following_filter_separates_local_and_remote() { base_url: "http://localhost:3000".into(), rate_limit: 20, refresh_ttl_seconds: 2_592_000, + secure_cookies: false, + cors_origins: String::new(), wrapup: crate::config::WrapUpConfig { font_path: None, logo_path: None, diff --git a/crates/application/src/test_helpers.rs b/crates/application/src/test_helpers.rs index aa18b5ea..51872a51 100644 --- a/crates/application/src/test_helpers.rs +++ b/crates/application/src/test_helpers.rs @@ -136,6 +136,8 @@ impl TestContextBuilder { base_url: "http://localhost:3000".into(), rate_limit: 20, refresh_ttl_seconds: 2_592_000, + secure_cookies: false, + cors_origins: String::new(), wrapup: crate::config::WrapUpConfig { font_path: None, logo_path: None, diff --git a/crates/composition/Cargo.toml b/crates/composition/Cargo.toml index c0c4bdcd..292ae32d 100644 --- a/crates/composition/Cargo.toml +++ b/crates/composition/Cargo.toml @@ -9,6 +9,7 @@ nats = ["dep:nats", "infra-wiring/nats"] federation = ["application/federation", "dep:sqlite-federation", "dep:activitypub"] [dependencies] +config = { workspace = true } domain = { workspace = true } application = { workspace = true } infra-wiring = { workspace = true } diff --git a/crates/composition/src/factory.rs b/crates/composition/src/factory.rs index f1009836..1a15c430 100644 --- a/crates/composition/src/factory.rs +++ b/crates/composition/src/factory.rs @@ -103,20 +103,28 @@ pub async fn build_database_adapters( }) } -pub fn build_auth_adapters() -> anyhow::Result<(Arc, Arc)> { - auth::create() +pub fn build_auth_adapters( + config: &config::AuthConfig, +) -> anyhow::Result<(Arc, Arc)> { + auth::create(config) } -pub fn build_metadata_client() -> anyhow::Result> { - metadata::create() +pub fn build_metadata_client( + config: &config::MetadataConfig, +) -> anyhow::Result> { + metadata::create(config) } -pub fn build_poster_fetcher() -> anyhow::Result> { - poster_fetcher::create() +pub fn build_poster_fetcher( + config: &config::MetadataConfig, +) -> anyhow::Result> { + poster_fetcher::create(config) } -pub fn build_object_storage() -> anyhow::Result> { - object_storage::create() +pub fn build_object_storage( + config: &config::StorageConfig, +) -> anyhow::Result> { + object_storage::create(config) } pub fn build_profile_fields_repo( diff --git a/crates/composition/src/tests/build.rs b/crates/composition/src/tests/build.rs index e0746cf4..4cfc62d3 100644 --- a/crates/composition/src/tests/build.rs +++ b/crates/composition/src/tests/build.rs @@ -346,6 +346,8 @@ fn test_config() -> AppConfig { base_url: "http://localhost:3000".into(), rate_limit: 20, refresh_ttl_seconds: 2_592_000, + secure_cookies: false, + cors_origins: String::new(), wrapup: infra_wiring::WrapUpConfig { font_path: None, logo_path: None, diff --git a/crates/infra-wiring/Cargo.toml b/crates/infra-wiring/Cargo.toml index 228f8686..8a96e9b5 100644 --- a/crates/infra-wiring/Cargo.toml +++ b/crates/infra-wiring/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +config = { workspace = true } sqlx = { workspace = true } anyhow = { workspace = true } diff --git a/crates/infra-wiring/src/config.rs b/crates/infra-wiring/src/config.rs index 75d1eb96..101cad4b 100644 --- a/crates/infra-wiring/src/config.rs +++ b/crates/infra-wiring/src/config.rs @@ -4,6 +4,8 @@ pub struct AppConfig { pub base_url: String, pub rate_limit: u64, pub refresh_ttl_seconds: u64, + pub secure_cookies: bool, + pub cors_origins: String, pub wrapup: WrapUpConfig, } @@ -14,39 +16,26 @@ pub struct WrapUpConfig { pub bg_dir: Option, } -impl AppConfig { - pub fn from_env() -> Self { - let allow_registration = std::env::var("ALLOW_REGISTRATION") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); - let base_url = std::env::var("BASE_URL") - .unwrap_or_else(|_| "http://localhost:3000".to_string()) - .trim_end_matches('/') - .to_string(); - let rate_limit = std::env::var("RATE_LIMIT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(60); - let refresh_ttl_seconds = std::env::var("REFRESH_TTL_SECONDS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(2_592_000u64); +impl From<&::config::AppConfig> for AppConfig { + fn from(config: &::config::AppConfig) -> Self { Self { - allow_registration, - base_url, - rate_limit, - refresh_ttl_seconds, - wrapup: WrapUpConfig::from_env(), + allow_registration: config.server.allow_registration, + base_url: config.server.base_url.clone(), + rate_limit: config.server.rate_limit, + refresh_ttl_seconds: config.auth.refresh_ttl_seconds, + secure_cookies: config.server.secure_cookies, + cors_origins: config.server.cors_origins.clone(), + wrapup: WrapUpConfig::from(&config.wrapup), } } } -impl WrapUpConfig { - pub fn from_env() -> Self { +impl From<&::config::WrapUpConfig> for WrapUpConfig { + fn from(config: &::config::WrapUpConfig) -> Self { Self { - font_path: std::env::var("WRAPUP_FONT_PATH").ok(), - logo_path: std::env::var("WRAPUP_LOGO_PATH").ok(), - bg_dir: std::env::var("WRAPUP_BG_DIR").ok(), + font_path: config.font_path.clone(), + logo_path: config.logo_path.clone(), + bg_dir: config.bg_dir.clone(), } } } diff --git a/crates/infra-wiring/src/lib.rs b/crates/infra-wiring/src/lib.rs index b4320c09..a0205078 100644 --- a/crates/infra-wiring/src/lib.rs +++ b/crates/infra-wiring/src/lib.rs @@ -14,11 +14,8 @@ pub enum EventBusBackend { } impl EventBusBackend { - pub fn from_env() -> anyhow::Result { - match std::env::var("EVENT_BUS_BACKEND") - .unwrap_or_else(|_| "db".to_string()) - .as_str() - { + pub fn from_config(config: &::config::EventsConfig) -> anyhow::Result { + match config.bus_backend.as_str() { "db" => Ok(Self::Db), #[cfg(feature = "nats")] "nats" => Ok(Self::Nats), diff --git a/crates/presentation/src/csrf.rs b/crates/presentation/src/csrf.rs index 70464fd4..aa607835 100644 --- a/crates/presentation/src/csrf.rs +++ b/crates/presentation/src/csrf.rs @@ -1,5 +1,5 @@ use axum::{ - extract::Request, + extract::{Request, State}, http::{HeaderValue, header}, middleware::Next, response::Response, @@ -19,15 +19,19 @@ pub fn extract_from_cookie(headers: &axum::http::HeaderMap) -> Option { }) } -pub(crate) fn secure_flag() -> &'static str { - if std::env::var("SECURE_COOKIES").as_deref() == Ok("true") { - "; Secure" - } else { - "" - } +/// Router state for [`csrf_middleware`]: whether cookies carry the `Secure` attribute. +#[derive(Clone, Copy)] +pub struct SecureCookies(pub bool); + +pub(crate) fn secure_flag(secure_cookies: bool) -> &'static str { + if secure_cookies { "; Secure" } else { "" } } -pub async fn csrf_middleware(mut req: Request, next: Next) -> Response { +pub async fn csrf_middleware( + State(SecureCookies(secure_cookies)): State, + mut req: Request, + next: Next, +) -> Response { let existing = extract_from_cookie(req.headers()); let (token, needs_set) = match existing { Some(t) => (t, false), @@ -42,7 +46,7 @@ pub async fn csrf_middleware(mut req: Request, next: Next) -> Response { let cookie = format!( "csrf={}; HttpOnly; Path=/; SameSite=Strict{}", token, - secure_flag() + secure_flag(secure_cookies) ); if let Ok(val) = HeaderValue::from_str(&cookie) { response.headers_mut().append(header::SET_COOKIE, val); diff --git a/crates/presentation/src/handlers/auth.rs b/crates/presentation/src/handlers/auth.rs index 89127aaa..0cc2a4e4 100644 --- a/crates/presentation/src/handlers/auth.rs +++ b/crates/presentation/src/handlers/auth.rs @@ -25,12 +25,16 @@ use template_askama::{LoginTemplate, RegisterTemplate}; // ── HTML helpers ───────────────────────────────────────────────────────────── -fn set_cookie_header(token: &str, max_age: i64) -> (axum::http::HeaderName, HeaderValue) { +fn set_cookie_header( + token: &str, + max_age: i64, + secure_cookies: bool, +) -> (axum::http::HeaderName, HeaderValue) { let val = format!( "token={}; HttpOnly; Path=/; SameSite=Strict; Max-Age={}{}", token, max_age, - secure_flag() + secure_flag(secure_cookies) ); ( SET_COOKIE, @@ -177,17 +181,18 @@ pub async fn post_login( { Ok(result) => { let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); - let cookie = set_cookie_header(&result.token, max_age); + let cookie = + set_cookie_header(&result.token, max_age, state.app_ctx.config.secure_cookies); ([cookie], Redirect::to("/")).into_response() } Err(_) => Redirect::to("/login?error=Invalid+credentials").into_response(), } } -pub async fn get_logout() -> impl IntoResponse { +pub async fn get_logout(State(state): State) -> impl IntoResponse { let val = format!( "token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0{}", - secure_flag() + secure_flag(state.app_ctx.config.secure_cookies) ); let cookie = ( SET_COOKIE, @@ -246,7 +251,8 @@ pub async fn post_register( { Ok(result) => { let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); - let cookie = set_cookie_header(&result.token, max_age); + let cookie = + set_cookie_header(&result.token, max_age, state.app_ctx.config.secure_cookies); ([cookie], Redirect::to("/")).into_response() } Err(_) => { diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 53d7295f..77e08d53 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -12,6 +12,8 @@ use crate::{handlers, state::AppState}; pub fn build_router(state: AppState, ap_router: Router) -> Router { let rate_limit = state.app_ctx.config.rate_limit; + let secure_cookies = state.app_ctx.config.secure_cookies; + let cors_origins = state.app_ctx.config.cors_origins.clone(); let ap_cfg = GovernorConfigBuilder::default() .with_extractor(PeerIp::default()) @@ -23,8 +25,8 @@ pub fn build_router(state: AppState, ap_router: Router) -> Router { Router::new() .route("/health", routing::get(health_handler)) - .merge(html_routes(rate_limit)) - .merge(api_routes(rate_limit)) + .merge(html_routes(rate_limit, secure_cookies)) + .merge(api_routes(rate_limit, &cors_origins)) .nest_service("/static", ServeDir::new("static")) .nest_service( "/app", @@ -44,7 +46,7 @@ fn per_minute(n: u64) -> Quota { Quota::requests_per_minute(n) } -fn html_routes(rate_limit: u64) -> Router { +fn html_routes(rate_limit: u64, secure_cookies: bool) -> Router { let auth = Router::new() .route( "/login", @@ -188,7 +190,10 @@ fn html_routes(rate_limit: u64) -> Router { #[cfg(feature = "federation")] let base = base.merge(federation_html_routes()); - base.layer(axum::middleware::from_fn(crate::csrf::csrf_middleware)) + base.layer(axum::middleware::from_fn_with_state( + crate::csrf::SecureCookies(secure_cookies), + crate::csrf::csrf_middleware, + )) } fn social_html_routes() -> Router { @@ -278,11 +283,10 @@ fn federation_html_routes() -> Router { ) } -fn cors_layer() -> CorsLayer { +fn cors_layer(origins: &str) -> CorsLayer { use axum::http::{HeaderName, Method}; use tower_http::cors::AllowOrigin; - let origins = std::env::var("CORS_ORIGINS").unwrap_or_default(); let layer = CorsLayer::new() .allow_methods([ Method::GET, @@ -319,7 +323,7 @@ fn cors_layer() -> CorsLayer { } } -fn api_routes(rate_limit: u64) -> Router { +fn api_routes(rate_limit: u64, cors_origins: &str) -> Router { let cfg = GovernorConfigBuilder::default() .with_extractor(PeerIp::default()) .expect_connect_info() @@ -506,7 +510,7 @@ fn api_routes(rate_limit: u64) -> Router { Router::new() .nest("/api/v1", base.layer(GovernorLayer::new(cfg))) .nest("/api/v1", webhook_routes) - .layer(cors_layer()) + .layer(cors_layer(cors_origins)) } fn social_api_routes() -> Router { diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index d6fc267b..152c72f3 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -843,6 +843,8 @@ pub(crate) fn build_test_state_from( 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::WrapUpConfig { font_path: None, logo_path: None, diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 89010c28..3004e56b 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -20,6 +20,7 @@ sqlite = [] sqlite-federation = ["federation"] [dependencies] +config = { workspace = true } axum = { workspace = true } tokio = { workspace = true } anyhow = { workspace = true } diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 4d7138d0..fc39b4bc 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -5,7 +5,6 @@ use anyhow::Context; use tokio::net::TcpListener; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use application::config::AppConfig; use composition::Repositories; use export::ExportAdapter; use importer::ImporterDocumentParser; @@ -21,15 +20,15 @@ async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); init_tracing(); - let (state, ap_router) = wire_dependencies() + let config = config::load().context("Failed to load configuration")?; + + let (state, ap_router) = wire_dependencies(&config) .await .context("Failed to wire dependencies")?; let app = openapi::serve(routes::build_router(state, ap_router)); - let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); - let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string()); - let addr = format!("{}:{}", host, port); + let addr = format!("{}:{}", config.server.host, config.server.port); let listener = TcpListener::bind(&addr).await?; tracing::info!("Listening on {}", addr); axum::serve( @@ -41,24 +40,24 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { - let app_config = AppConfig::from_env(); +async fn wire_dependencies(config: &config::AppConfig) -> anyhow::Result<(AppState, axum::Router)> { + let app_config = application::config::AppConfig::from(config); let instance = domain::value_objects::InstanceIdentity::new(app_config.base_url.clone()); - let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; + let database_url = config.database.url.as_str(); composition::factory::warn_if_database_backend_set(); - let (auth_service, password_hasher) = composition::factory::build_auth_adapters()?; - let metadata_client = composition::factory::build_metadata_client()?; - let poster_fetcher = composition::factory::build_poster_fetcher()?; - let object_storage = composition::factory::build_object_storage()?; + let (auth_service, password_hasher) = composition::factory::build_auth_adapters(&config.auth)?; + let metadata_client = composition::factory::build_metadata_client(&config.metadata)?; + let poster_fetcher = composition::factory::build_poster_fetcher(&config.metadata)?; + let object_storage = composition::factory::build_object_storage(&config.storage)?; - let db = composition::factory::build_database_adapters(&database_url, &instance).await?; + let db = composition::factory::build_database_adapters(database_url, &instance).await?; #[cfg(feature = "federation")] let ap_content_repo = db.ap_content; let db_pool = db.db_pool; // Wire up event channel, federation service, and ap_router - let event_bus = EventBusBackend::from_env()?; + let event_bus = EventBusBackend::from_config(&config.events)?; #[cfg(feature = "federation")] let ( @@ -78,7 +77,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { } }; - let ep = create_event_publisher(event_bus, &db_pool).await?; + let ep = create_event_publisher(event_bus, &db_pool, config).await?; let ap = activitypub::wire(activitypub::ActivityPubDeps { activity_repo: fed_repos.activity, @@ -100,6 +99,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { follow_query: Arc::clone(&fed_repos.follow_query), instance: instance.clone(), allow_registration: app_config.allow_registration, + federation: config.federation.clone(), event_publisher: Arc::clone(&ep), }) .await?; @@ -136,7 +136,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { }; #[cfg(not(feature = "federation"))] - let event_publisher_arc = create_event_publisher(event_bus, &db_pool).await?; + let event_publisher_arc = create_event_publisher(event_bus, &db_pool, config).await?; #[cfg(not(feature = "federation"))] let ap_router = axum::Router::new(); #[cfg(not(feature = "federation"))] @@ -252,9 +252,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let state = AppState { app_ctx, - rss_renderer: Arc::new(RssAdapter::new( - std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()), - )), + rss_renderer: Arc::new(RssAdapter::new(config.server.base_url.clone())), }; Ok((state, ap_router)) } @@ -262,19 +260,20 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { async fn create_event_publisher( event_bus: EventBusBackend, db_pool: &composition::DbPool, + config: &config::AppConfig, ) -> anyhow::Result> { match event_bus { EventBusBackend::Db => { tracing::info!("event bus: DB queue"); Ok(match db_pool { composition::DbPool::Sqlite(pool) => { - sqlite::SqliteEventQueue::create_publisher(pool.clone()).await? + sqlite::SqliteEventQueue::create_publisher(pool.clone(), &config.events).await? } }) } #[cfg(feature = "nats")] EventBusBackend::Nats => { - let cfg = nats::NatsConfig::from_env() + let cfg = nats::NatsConfig::from_config(&config.nats) .context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?; tracing::info!("event bus: NATS ({})", cfg.url); Ok(nats::create_publisher(cfg).await?) diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs index 0ff2a4f9..de982935 100644 --- a/crates/server/tests/api_test.rs +++ b/crates/server/tests/api_test.rs @@ -450,6 +450,8 @@ async fn test_app_with(auth: impl AuthService + 'static) -> (Router, SqlitePool) 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::WrapUpConfig { font_path: None, logo_path: None, diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 002647b6..6a1f7ab7 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -17,6 +17,7 @@ sqlite = [] sqlite-federation = ["federation"] [dependencies] +config = { workspace = true } domain = { workspace = true } application = { workspace = true } composition = { workspace = true } diff --git a/crates/worker/src/event_bus.rs b/crates/worker/src/event_bus.rs index 35814ee6..244c5cd3 100644 --- a/crates/worker/src/event_bus.rs +++ b/crates/worker/src/event_bus.rs @@ -8,19 +8,22 @@ use infra_wiring::{DbPool, EventBusBackend}; pub async fn create( db_pool: &DbPool, + config: &config::AppConfig, ) -> anyhow::Result<(Arc, Arc)> { - match EventBusBackend::from_env()? { + match EventBusBackend::from_config(&config.events)? { EventBusBackend::Db => { tracing::info!("event bus: DB queue"); match db_pool { - DbPool::Sqlite(pool) => { - Ok(sqlite::SqliteEventQueue::create_channel(pool.clone()).await?) - } + DbPool::Sqlite(pool) => Ok(sqlite::SqliteEventQueue::create_channel( + pool.clone(), + &config.events, + ) + .await?), } } #[cfg(feature = "nats")] EventBusBackend::Nats => { - let cfg = nats::NatsConfig::from_env() + let cfg = nats::NatsConfig::from_config(&config.nats) .context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?; tracing::info!("event bus: NATS ({})", cfg.url); Ok(nats::create_channel(cfg).await?) diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index b20cea59..f1027431 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -6,8 +6,7 @@ use std::sync::Arc; use anyhow::Context; use application::{ - MovieDiscoveryIndexer, SearchCleanupHandler, SearchReindexHandler, config::AppConfig, - worker::WorkerService, + MovieDiscoveryIndexer, SearchCleanupHandler, SearchReindexHandler, worker::WorkerService, }; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -18,18 +17,18 @@ async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); init_tracing(); - let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; + let config = config::load().context("Failed to load configuration")?; composition::factory::warn_if_database_backend_set(); - let app_config = AppConfig::from_env(); + let app_config = application::config::AppConfig::from(&config); let instance = domain::value_objects::InstanceIdentity::new(app_config.base_url.clone()); - let metadata_client = metadata::create()?; - let poster_fetcher = poster_fetcher::create()?; - let object_storage = object_storage::create()?; + 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_env() { + let tmdb_client = match tmdb_enrichment::TmdbEnrichmentClient::from_config(&config.metadata) { Ok(client) => { tracing::info!("TMDb enrichment enabled"); Some(Arc::new(client)) @@ -43,8 +42,8 @@ async fn main() -> anyhow::Result<()> { .clone() .map(|c| c as Arc); - let db = composition::factory::build_database_adapters(&database_url, &instance).await?; - let (event_publisher_arc, consumer_arc) = event_bus::create(&db.db_pool).await?; + let db = composition::factory::build_database_adapters(&config.database.url, &instance).await?; + let (event_publisher_arc, consumer_arc) = event_bus::create(&db.db_pool, &config).await?; let worker_services = application::WorkerServices { object_storage: Arc::clone(&object_storage), @@ -113,7 +112,7 @@ async fn main() -> anyhow::Result<()> { let (enrichment_handler, person_enrichment_handler, enrichment_job): EnrichmentParts = match tmdb_client { Some(client) => { - let image_fetcher = poster_fetcher::create_image_fetcher()?; + let image_fetcher = poster_fetcher::create_image_fetcher(&config.metadata)?; let handler = Arc::new(application::movies::MovieEnrichmentHandler::new( Arc::clone(&client) as Arc, worker_deps.enrich_movie.movie_query.clone(), @@ -140,6 +139,7 @@ async fn main() -> anyhow::Result<()> { // ── Image conversion ────────────────────────────────────────────────────── let conversion = image_converter::build( + &config.image_conversion, Arc::clone(&object_storage), image_ref_command, image_ref_query, @@ -257,6 +257,7 @@ async fn main() -> anyhow::Result<()> { follow_query: fed_repos.follow_query, instance: instance.clone(), allow_registration, + federation: config.federation.clone(), event_publisher: Arc::clone(&event_publisher), federation_settings: std::sync::Arc::clone(&db.federation_settings), }) -- 2.49.1 From 9bf9342fc01377ad670b75aa3cadecb8cc8f0992 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:16:38 +0100 Subject: [PATCH 014/104] refactor: bootstrap crate, thin server/worker binaries --- .gitea/workflows/ci.yml | 2 +- .github/workflows/ci.yml | 2 +- CONTRIBUTING.md | 2 +- Cargo.lock | 109 +++---- Cargo.toml | 6 +- Dockerfile | 3 +- Makefile | 36 +- README.md | 7 +- architecture.mmd | 4 +- crates/adapters/sqlite/src/lib.rs | 7 + crates/application/Cargo.toml | 2 +- crates/application/src/config.rs | 42 ++- crates/application/src/deps.rs | 8 +- crates/{composition => bootstrap}/Cargo.toml | 33 +- .../{composition => bootstrap}/src/build.rs | 0 .../factory.rs => bootstrap/src/database.rs} | 23 +- crates/bootstrap/src/event_bus.rs | 77 +++++ crates/bootstrap/src/federation.rs | 170 ++++++++++ .../src/follow_backfill_handler.rs | 0 crates/bootstrap/src/lib.rs | 28 ++ .../src/repositories.rs | 0 crates/bootstrap/src/server.rs | 147 +++++++++ .../src/tests/build.rs | 6 +- crates/bootstrap/src/tracing_setup.rs | 25 ++ crates/bootstrap/src/worker.rs | 231 +++++++++++++ crates/composition/src/lib.rs | 11 - crates/infra-wiring/Cargo.toml | 13 - crates/infra-wiring/src/config.rs | 41 --- crates/infra-wiring/src/lib.rs | 29 -- crates/presentation/Cargo.toml | 2 +- crates/presentation/src/tests/extractors.rs | 8 +- crates/server/Cargo.toml | 44 +-- crates/server/src/main.rs | 295 +---------------- crates/server/tests/api_test.rs | 6 +- crates/worker/Cargo.toml | 35 +- crates/worker/src/event_bus.rs | 32 -- crates/worker/src/main.rs | 307 +----------------- docs/adr/0005-single-composition-root.md | 7 + docs/adr/0013-config-and-bootstrap.md | 44 +++ 39 files changed, 939 insertions(+), 905 deletions(-) rename crates/{composition => bootstrap}/Cargo.toml (62%) rename crates/{composition => bootstrap}/src/build.rs (100%) rename crates/{composition/src/factory.rs => bootstrap/src/database.rs} (89%) create mode 100644 crates/bootstrap/src/event_bus.rs create mode 100644 crates/bootstrap/src/federation.rs rename crates/{worker => bootstrap}/src/follow_backfill_handler.rs (100%) create mode 100644 crates/bootstrap/src/lib.rs rename crates/{composition => bootstrap}/src/repositories.rs (100%) create mode 100644 crates/bootstrap/src/server.rs rename crates/{composition => bootstrap}/src/tests/build.rs (99%) create mode 100644 crates/bootstrap/src/tracing_setup.rs create mode 100644 crates/bootstrap/src/worker.rs delete mode 100644 crates/composition/src/lib.rs delete mode 100644 crates/infra-wiring/Cargo.toml delete mode 100644 crates/infra-wiring/src/config.rs delete mode 100644 crates/infra-wiring/src/lib.rs delete mode 100644 crates/worker/src/event_bus.rs create mode 100644 docs/adr/0013-config-and-bootstrap.md diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 96fdd1f0..fa63712f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -44,4 +44,4 @@ jobs: # Layering guards. These live in the Makefile and were previously local-only, # so a PR could go green in CI while violating them. - name: guards - run: make check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-sqlite-is-ap-free + run: make check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91f1eef6..1716751e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: # Layering guards. These live in the Makefile and were previously local-only, # so a PR could go green in CI while violating them. - name: guards - run: make check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-sqlite-is-ap-free + run: make check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free docker: name: Build & Push Docker Image diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2aa9e755..47d7df88 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,7 @@ The project follows hexagonal (ports & adapters) architecture. See `architecture **Key rules:** - Presentation handlers never touch repositories directly — all domain logic goes through use cases in the `application` crate - Application use cases return raw domain data — URL formatting, date display, and view model assembly belong in presentation mappers (`presentation/src/mappers/`) -- Use cases called from presentation handlers take a `&FooDeps` struct (registered in `application::Deps`, built by `composition::build_deps`) — never `&AppContext` itself, which `application` cannot even depend on. A few keep individual `Arc` params instead: `enrich_movie` and `request_enrichment` because they are called from adapters rather than handlers, and `diary::log_review` because its one extra dependency comes from `Services` rather than a repository. `wrapup::compute`, `import::cleanup` and `integrations::cleanup` also take individual params, but they are only ever called from jobs. See ADR-0007 for the exact list and why each is legitimate +- Use cases called from presentation handlers take a `&FooDeps` struct (registered in `application::Deps`, built by `bootstrap::build_deps`) — never `&AppContext` itself, which `application` cannot even depend on. A few keep individual `Arc` params instead: `enrich_movie` and `request_enrichment` because they are called from adapters rather than handlers, and `diary::log_review` because its one extra dependency comes from `Services` rather than a repository. `wrapup::compute`, `import::cleanup` and `integrations::cleanup` also take individual params, but they are only ever called from jobs. See ADR-0007 for the exact list and why each is legitimate ``` domain → pure types, traits (ports), zero deps diff --git a/Cargo.lock b/Cargo.lock index d4de1912..f6d8cc76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -319,10 +319,10 @@ dependencies = [ "async-trait", "bytes", "chrono", + "config", "domain", "futures", "hex", - "infra-wiring", "rand 0.9.4", "serde_json", "sha2", @@ -905,6 +905,41 @@ dependencies = [ "piper", ] +[[package]] +name = "bootstrap" +version = "0.1.0" +dependencies = [ + "activitypub", + "anyhow", + "application", + "async-trait", + "auth", + "axum", + "chrono", + "config", + "domain", + "export", + "image-converter", + "importer", + "jellyfin", + "metadata", + "nats", + "object-storage", + "plex", + "poster-fetcher", + "poster-sync", + "presentation", + "rss 0.1.0", + "sqlite", + "sqlite-federation", + "sqlx", + "tmdb-enrichment", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "built" version = "0.7.7" @@ -1120,33 +1155,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "composition" -version = "0.1.0" -dependencies = [ - "activitypub", - "anyhow", - "application", - "async-trait", - "auth", - "chrono", - "config", - "domain", - "infra-wiring", - "jellyfin", - "metadata", - "nats", - "object-storage", - "plex", - "poster-fetcher", - "sqlite", - "sqlite-federation", - "sqlx", - "tokio", - "tracing", - "uuid", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2771,15 +2779,6 @@ dependencies = [ "cfb", ] -[[package]] -name = "infra-wiring" -version = "0.1.0" -dependencies = [ - "anyhow", - "config", - "sqlx", -] - [[package]] name = "inout" version = "0.1.4" @@ -4013,10 +4012,10 @@ dependencies = [ "async-trait", "axum", "axum-governor", + "bootstrap", "bytes", "chrono", "chrono-tz", - "composition", "domain", "dotenvy", "futures", @@ -4948,36 +4947,25 @@ dependencies = [ name = "server" version = "0.1.0" dependencies = [ - "activitypub", "anyhow", "application", "async-trait", - "auth", "axum", + "bootstrap", "bytes", - "composition", "config", "domain", "dotenvy", - "export", "futures", "http-body-util", - "importer", - "infra-wiring", - "metadata", - "nats", - "object-storage", - "poster-fetcher", "presentation", "rss 0.1.0", "serde_json", "sqlite", - "sqlite-federation", "sqlx", "tokio", "tower", "tracing", - "tracing-subscriber", "uuid", ] @@ -7182,31 +7170,12 @@ dependencies = [ name = "worker" version = "0.1.0" dependencies = [ - "activitypub", "anyhow", - "application", - "async-trait", - "auth", - "composition", + "bootstrap", "config", - "domain", "dotenvy", - "export", - "image-converter", - "importer", - "infra-wiring", - "metadata", - "nats", - "object-storage", - "poster-fetcher", - "poster-sync", - "sqlite", - "sqlite-federation", - "sqlx", - "tmdb-enrichment", "tokio", "tracing", - "tracing-subscriber", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 49ec33ba..68eec11e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ members = [ "crates/adapters/tmdb-enrichment", "crates/adapters/image-converter", "crates/domain", - "crates/composition", + "crates/bootstrap", "crates/presentation", "crates/server", "crates/tui", @@ -27,7 +27,6 @@ members = [ "crates/adapters/importer", "crates/adapters/jellyfin", "crates/adapters/plex", - "crates/infra-wiring", "crates/config", ] resolver = "2" @@ -70,7 +69,7 @@ api-types = { path = "crates/api-types" } domain = { path = "crates/domain" } tmdb-enrichment = { path = "crates/adapters/tmdb-enrichment" } application = { path = "crates/application" } -composition = { path = "crates/composition" } +bootstrap = { path = "crates/bootstrap" } presentation = { path = "crates/presentation" } auth = { path = "crates/adapters/auth" } metadata = { path = "crates/adapters/metadata" } @@ -90,7 +89,6 @@ importer = { path = "crates/adapters/importer" } jellyfin = { path = "crates/adapters/jellyfin" } plex = { path = "crates/adapters/plex" } image-converter = { path = "crates/adapters/image-converter" } -infra-wiring = { path = "crates/infra-wiring" } config = { path = "crates/config" } [profile.dev] diff --git a/Dockerfile b/Dockerfile index a737d7be..27437609 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,13 +39,12 @@ COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml COPY crates/application/Cargo.toml crates/application/Cargo.toml COPY crates/adapters/tmdb-enrichment/Cargo.toml crates/adapters/tmdb-enrichment/Cargo.toml COPY crates/domain/Cargo.toml crates/domain/Cargo.toml -COPY crates/composition/Cargo.toml crates/composition/Cargo.toml +COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml COPY crates/server/Cargo.toml crates/server/Cargo.toml COPY crates/tui/Cargo.toml crates/tui/Cargo.toml COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/Cargo.toml COPY crates/worker/Cargo.toml crates/worker/Cargo.toml -COPY crates/infra-wiring/Cargo.toml crates/infra-wiring/Cargo.toml COPY crates/config/Cargo.toml crates/config/Cargo.toml # Stub every crate so cargo can resolve and fetch deps diff --git a/Makefile b/Makefile index 82b878ba..d5126704 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := check -check: fmt-check clippy test check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-sqlite-is-ap-free +check: fmt-check clippy test check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free @echo "✅ All checks passed" check-appcontext: @@ -47,16 +47,36 @@ check-presentation-adapters: check-federation-off-social: - @if grep -n "NoopSocialCommand\|NoopSocialQuery" crates/server/src/main.rs | grep -q .; then \ - echo "❌ server wires social noops — federation-off would report an empty follow graph:"; \ - grep -n "NoopSocialCommand\|NoopSocialQuery" crates/server/src/main.rs; \ + @if grep -n "NoopSocialCommand\|NoopSocialQuery" crates/bootstrap/src/federation.rs | grep -q .; then \ + echo "❌ bootstrap wires social noops — federation-off would report an empty follow graph:"; \ + grep -n "NoopSocialCommand\|NoopSocialQuery" crates/bootstrap/src/federation.rs; \ exit 1; \ fi - @if ! awk '/cfg\(not\(feature = "federation"\)\)/ && !started {started=1} started && /cfg\(feature = "federation"\)/ {exit} started {print}' crates/server/src/main.rs | grep -q "LocalSocialService"; then \ - echo "❌ LocalSocialService not wired in the federation-off region of crates/server/src/main.rs — federation-off has no social behavior wired at all, not even noops"; \ + @if ! awk '/cfg\(not\(feature = "federation"\)\)/ && !started {started=1} started && /cfg\(feature = "federation"\)/ {exit} started {print}' crates/bootstrap/src/federation.rs | grep -q "LocalSocialService"; then \ + echo "❌ LocalSocialService not wired in the federation-off region of crates/bootstrap/src/federation.rs — federation-off has no social behavior wired at all, not even noops"; \ exit 1; \ fi - @echo "✅ server wires real social behavior in every configuration" + @echo "✅ bootstrap wires real social behavior in every configuration" + + +BINARIES_ALLOWED_DEPS := bootstrap config tokio axum anyhow dotenvy tracing + +check-binaries-are-thin: + @bad=""; \ + for c in server worker; do \ + deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/$$c/Cargo.toml \ + | sed -n 's/^\([A-Za-z0-9_-]\{1,\}\)[[:space:]]*=.*/\1/p'); \ + for d in $$deps; do \ + case " $(BINARIES_ALLOWED_DEPS) " in *" $$d "*) continue;; esac; \ + bad="$$bad $$c:$$d"; \ + done; \ + done; \ + if [ -n "$$bad" ]; then \ + echo "❌ binary [dependencies] outside {$(BINARIES_ALLOWED_DEPS)}:$$bad"; \ + echo " wiring belongs in crates/bootstrap — see ADR-0013."; \ + exit 1; \ + fi; \ + echo "✅ server and worker depend only on {$(BINARIES_ALLOWED_DEPS)}" check-sqlite-is-ap-free: @@ -106,4 +126,4 @@ dev-spa: spa-dev: @npm --prefix spa run dev -.PHONY: check fmt fmt-check clippy test fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-sqlite-is-ap-free +.PHONY: check fmt fmt-check clippy test fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free diff --git a/README.md b/README.md index 9a316e2c..12be0709 100644 --- a/README.md +++ b/README.md @@ -91,12 +91,13 @@ Hexagonal (Ports & Adapters) with Domain-Driven Design: ``` api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by presentation, tui, and template adapters -infra-wiring — shared infrastructure types (DbPool, EventBusBackend, AppConfig) used by both server and worker binaries +config — typed configuration (defaults, config.toml, legacy env names); loaded once by the binaries +bootstrap — composition root: the only crate that names adapters; builds the server router and the worker service domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, SocialCommand/SocialQuery, ImageFetcher, RssFeedRenderer), no external deps except serde application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic; modules: auth, diary, goals, import, integrations, movies, person, search, social, users, watchlist, wrapup presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving; library crate, no binary of its own -server — owns the HTTP binary, features (federation, nats), boots presentation's router -worker — standalone worker binary (event consumer, poster sync, federation) +server — thin HTTP binary (dotenv, config, bootstrap::build_server, axum::serve); features (federation, nats) forward to bootstrap +worker — thin worker binary (bootstrap::build_worker, run until ctrl-c) adapters/ auth — JWT issuance and validation (Argon2 passwords) sqlite — SQLite repositories, search (FTS5), social graph, durable event queue, connection factory + migrations diff --git a/architecture.mmd b/architecture.mmd index 3e6a8c04..4e7cc9bd 100644 --- a/architecture.mmd +++ b/architecture.mmd @@ -77,8 +77,8 @@ graph TB DTO["DTOs
MovieDto, ReviewDto,
FeedEntryDto, UserSummaryDto,
HtmlPageContext, ...
"] end - subgraph InfraWiring["infra-wiring"] - IW["DbPool, EventBusBackend,
AppConfig
Shared infra types"] + subgraph Bootstrap["bootstrap"] + IW["build_server, build_worker,
DbPool, EventBusBackend
Composition root"] end subgraph Adapters["Adapters (implement Port Traits)"] diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index f25126a2..34bb266b 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -75,6 +75,13 @@ pub async fn wire(database_url: &str) -> anyhow::Result { .await .context("Failed to connect to SQLite database")?; + wire_pool(pool).await +} + +/// Migrates an already-connected pool and builds every repository on it. +pub async fn wire_pool(pool: SqlitePool) -> anyhow::Result { + use anyhow::Context; + migrate(&pool) .await .map_err(|e| anyhow::anyhow!("{e}")) diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 2ff9fe03..f6366262 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -6,7 +6,7 @@ edition.workspace = true [dependencies] async-trait = { workspace = true } domain = { workspace = true } -infra-wiring = { workspace = true } +config = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } diff --git a/crates/application/src/config.rs b/crates/application/src/config.rs index 38eb0b15..101cad4b 100644 --- a/crates/application/src/config.rs +++ b/crates/application/src/config.rs @@ -1 +1,41 @@ -pub use infra_wiring::{AppConfig, WrapUpConfig}; +#[derive(Clone)] +pub struct AppConfig { + pub allow_registration: bool, + pub base_url: String, + pub rate_limit: u64, + pub refresh_ttl_seconds: u64, + pub secure_cookies: bool, + pub cors_origins: String, + pub wrapup: WrapUpConfig, +} + +#[derive(Clone)] +pub struct WrapUpConfig { + pub font_path: Option, + pub logo_path: Option, + pub bg_dir: Option, +} + +impl From<&::config::AppConfig> for AppConfig { + fn from(config: &::config::AppConfig) -> Self { + Self { + allow_registration: config.server.allow_registration, + base_url: config.server.base_url.clone(), + rate_limit: config.server.rate_limit, + refresh_ttl_seconds: config.auth.refresh_ttl_seconds, + secure_cookies: config.server.secure_cookies, + cors_origins: config.server.cors_origins.clone(), + wrapup: WrapUpConfig::from(&config.wrapup), + } + } +} + +impl From<&::config::WrapUpConfig> for WrapUpConfig { + fn from(config: &::config::WrapUpConfig) -> Self { + Self { + font_path: config.font_path.clone(), + logo_path: config.logo_path.clone(), + bg_dir: config.bg_dir.clone(), + } + } +} diff --git a/crates/application/src/deps.rs b/crates/application/src/deps.rs index ae943fd6..969e3f8c 100644 --- a/crates/application/src/deps.rs +++ b/crates/application/src/deps.rs @@ -153,10 +153,10 @@ pub struct WrapupGroup { /// Every deps struct a handler can need, built once by the composition root. /// Use cases still receive only their own narrow struct — nothing takes `&Deps`. /// -/// `composition::build_deps` is called only from `crates/presentation/src/main.rs`. +/// `bootstrap::build_deps` is called only from `bootstrap::build_server`. /// The worker-only groups this used to also carry now live in `WorkerDeps`, built by -/// `composition::build_worker_deps` and consumed by `crates/worker/src/main.rs` — -/// `crates/worker` no longer wires its own deps (its former `db.rs` is gone). Nothing +/// `bootstrap::build_worker_deps` and consumed by `bootstrap::build_worker` — +/// the worker binary no longer wires anything itself. Nothing /// in `Deps` below is worker-only anymore, except one field with no consumer anywhere /// in the workspace — see its comment. pub struct Deps { @@ -188,7 +188,7 @@ pub struct WorkerServices { pub person_enrichment: Option>, } -/// The deps structs the worker binary needs, built by `composition::build_worker_deps`. +/// The deps structs the worker binary needs, built by `bootstrap::build_worker_deps`. /// These are the five groups that moved out of `Deps` during worker unification — /// they have no consumer the server binary can ever reach. pub struct WorkerDeps { diff --git a/crates/composition/Cargo.toml b/crates/bootstrap/Cargo.toml similarity index 62% rename from crates/composition/Cargo.toml rename to crates/bootstrap/Cargo.toml index 292ae32d..3632f8cd 100644 --- a/crates/composition/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -1,36 +1,49 @@ [package] -name = "composition" +name = "bootstrap" version = "0.1.0" edition.workspace = true [features] default = [] -nats = ["dep:nats", "infra-wiring/nats"] -federation = ["application/federation", "dep:sqlite-federation", "dep:activitypub"] +nats = ["dep:nats"] +federation = [ + "application/federation", + "presentation/federation", + "dep:sqlite-federation", + "dep:activitypub", +] [dependencies] config = { workspace = true } domain = { workspace = true } application = { workspace = true } -infra-wiring = { workspace = true } +presentation = { path = "../presentation", default-features = false } auth = { workspace = true } metadata = { workspace = true } poster-fetcher = { workspace = true } object-storage = { workspace = true } +poster-sync = { workspace = true } +tmdb-enrichment = { workspace = true } +image-converter = { workspace = true } +export = { workspace = true } +importer = { workspace = true } +rss = { workspace = true } jellyfin = { workspace = true } plex = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } - sqlite = { workspace = true } +sqlx = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } +axum = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + nats = { workspace = true, optional = true } activitypub = { workspace = true, optional = true } sqlite-federation = { workspace = true, optional = true } [dev-dependencies] -async-trait = { workspace = true } chrono = { workspace = true } domain = { workspace = true, features = ["test-helpers"] } -sqlx = { workspace = true } -tokio = { workspace = true } uuid = { workspace = true } diff --git a/crates/composition/src/build.rs b/crates/bootstrap/src/build.rs similarity index 100% rename from crates/composition/src/build.rs rename to crates/bootstrap/src/build.rs diff --git a/crates/composition/src/factory.rs b/crates/bootstrap/src/database.rs similarity index 89% rename from crates/composition/src/factory.rs rename to crates/bootstrap/src/database.rs index 1a15c430..bad5da5e 100644 --- a/crates/composition/src/factory.rs +++ b/crates/bootstrap/src/database.rs @@ -8,7 +8,9 @@ use domain::ports::{ WebhookTokenRepository, }; -pub use infra_wiring::DbPool; +pub enum DbPool { + Sqlite(sqlx::SqlitePool), +} pub struct DatabaseOutput { pub movie_command: Arc, @@ -54,6 +56,25 @@ pub async fn build_database_adapters( let w = sqlite::wire(url) .await .context("SQLite connection failed")?; + assemble(w, instance) +} + +/// Like [`build_database_adapters`], on a pool the caller already connected +/// (migrations still run). Lets tests drive the real wiring on an in-memory pool. +pub async fn build_database_adapters_on( + pool: sqlx::SqlitePool, + instance: &domain::value_objects::InstanceIdentity, +) -> anyhow::Result { + 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 { 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()); diff --git a/crates/bootstrap/src/event_bus.rs b/crates/bootstrap/src/event_bus.rs new file mode 100644 index 00000000..b4ace78a --- /dev/null +++ b/crates/bootstrap/src/event_bus.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; + +#[cfg(feature = "nats")] +use anyhow::Context; +use domain::ports::{EventConsumer, EventPublisher}; + +use crate::database::DbPool; + +#[derive(Clone, Copy)] +pub enum EventBusBackend { + Db, + #[cfg(feature = "nats")] + Nats, +} + +impl EventBusBackend { + pub fn from_config(config: &config::EventsConfig) -> anyhow::Result { + 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: &DbPool, + config: &config::AppConfig, +) -> anyhow::Result> { + match EventBusBackend::from_config(&config.events)? { + EventBusBackend::Db => { + tracing::info!("event bus: DB queue"); + Ok(match db_pool { + DbPool::Sqlite(pool) => { + sqlite::SqliteEventQueue::create_publisher(pool.clone(), &config.events).await? + } + }) + } + #[cfg(feature = "nats")] + EventBusBackend::Nats => { + let cfg = nats::NatsConfig::from_config(&config.nats) + .context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?; + tracing::info!("event bus: NATS ({})", cfg.url); + Ok(nats::create_publisher(cfg).await?) + } + } +} + +pub async fn create_channel( + db_pool: &DbPool, + config: &config::AppConfig, +) -> anyhow::Result<(Arc, Arc)> { + match EventBusBackend::from_config(&config.events)? { + EventBusBackend::Db => { + tracing::info!("event bus: DB queue"); + match db_pool { + DbPool::Sqlite(pool) => Ok(sqlite::SqliteEventQueue::create_channel( + pool.clone(), + &config.events, + ) + .await?), + } + } + #[cfg(feature = "nats")] + EventBusBackend::Nats => { + let cfg = nats::NatsConfig::from_config(&config.nats) + .context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?; + tracing::info!("event bus: NATS ({})", cfg.url); + Ok(nats::create_channel(cfg).await?) + } + } +} diff --git a/crates/bootstrap/src/federation.rs b/crates/bootstrap/src/federation.rs new file mode 100644 index 00000000..941b9c9d --- /dev/null +++ b/crates/bootstrap/src/federation.rs @@ -0,0 +1,170 @@ +//! Social and federation wiring for the server and worker. +//! +//! Federation off still wires real local social behaviour (`LocalSocialService`): +//! the follow graph and block queries must never silently report empty. + +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, + #[cfg(feature = "federation")] + pub ap_blocklist: Arc, + pub social_command: Arc, + pub follow_graph: Arc, + pub block_query: Arc, + pub remote_watchlist: Arc, + pub federation_admin: Arc, + pub federated_profile: Option>, +} + +#[cfg(not(feature = "federation"))] +pub async fn wire_server_social( + db: &DatabaseOutput, + instance: &InstanceIdentity, + _config: &config::AppConfig, + _event_publisher: &Arc, +) -> anyhow::Result { + 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, + follow_graph: Arc::clone(&local) as Arc, + block_query: local as Arc, + 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, +) -> anyhow::Result { + let crate::database::DbPool::Sqlite(pool) = &db.db_pool; + let fed_repos = sqlite_federation::wire(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 = + 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(), + )); + + Ok(ServerSocial { + ap_router: ap.router, + ap_document: ap.document, + ap_blocklist: ap.blocklist, + social_command: composite_social.clone() as Arc, + follow_graph: composite_social.clone() as Arc, + block_query: composite_social as Arc, + remote_watchlist: fed_repos.remote_watchlist, + federation_admin: fed_repos.admin_query, + federated_profile: Some(sqlite::create_federated_profile_query( + pool.clone(), + instance.clone(), + )), + }) +} + +/// The worker's federation event handlers: the ActivityPub delivery handler and +/// the follow-accepted outbox backfill. +#[cfg(feature = "federation")] +pub async fn wire_worker_handlers( + db: &DatabaseOutput, + instance: &InstanceIdentity, + config: &config::AppConfig, + event_publisher: &Arc, +) -> anyhow::Result>> { + let crate::database::DbPool::Sqlite(pool) = &db.db_pool; + let fed_repos = sqlite_federation::wire(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, + }), + ]) +} diff --git a/crates/worker/src/follow_backfill_handler.rs b/crates/bootstrap/src/follow_backfill_handler.rs similarity index 100% rename from crates/worker/src/follow_backfill_handler.rs rename to crates/bootstrap/src/follow_backfill_handler.rs diff --git a/crates/bootstrap/src/lib.rs b/crates/bootstrap/src/lib.rs new file mode 100644 index 00000000..12533284 --- /dev/null +++ b/crates/bootstrap/src/lib.rs @@ -0,0 +1,28 @@ +//! The composition root: the only crate that names concrete adapters. +//! +//! `server` and `worker` are thin binaries over [`build_server`] and +//! [`build_worker`]; everything between configuration and a running service is +//! assembled here (ADR-0005, ADR-0013). + +pub mod build; +pub mod database; +mod event_bus; +mod federation; +#[cfg(feature = "federation")] +mod follow_backfill_handler; +pub mod repositories; +mod server; +mod tracing_setup; +mod worker; + +pub use build::{build_deps, build_worker_deps}; +pub use database::{DatabaseOutput, DbPool}; +pub use event_bus::EventBusBackend; +pub use repositories::Repositories; +pub use server::{ServerApp, build_server, build_server_on}; +pub use tracing_setup::init_tracing; +pub use worker::{WorkerApp, build_worker}; + +#[cfg(test)] +#[path = "tests/build.rs"] +mod build_tests; diff --git a/crates/composition/src/repositories.rs b/crates/bootstrap/src/repositories.rs similarity index 100% rename from crates/composition/src/repositories.rs rename to crates/bootstrap/src/repositories.rs diff --git a/crates/bootstrap/src/server.rs b/crates/bootstrap/src/server.rs new file mode 100644 index 00000000..f430fd7a --- /dev/null +++ b/crates/bootstrap/src/server.rs @@ -0,0 +1,147 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::Context; +use application::config::AppConfig as AppSettings; +use domain::ports::{DiaryExporter, DocumentParser, EventPublisher}; +use domain::value_objects::InstanceIdentity; +use export::ExportAdapter; +use importer::ImporterDocumentParser; +use presentation::context::AppContext; +use presentation::{openapi, routes, state::AppState}; +use rss::RssAdapter; + +use crate::database::{self, DatabaseOutput}; +use crate::{Repositories, event_bus, federation}; + +pub struct ServerApp { + pub router: axum::Router, + pub addr: SocketAddr, +} + +/// Wires the whole HTTP application from configuration. +pub async fn build_server(config: &config::AppConfig) -> anyhow::Result { + 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")?; + + // Resolved like `TcpListener::bind(&str)` would, so a hostname HOST keeps working. + 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 }) +} + +/// Builds the same router as [`build_server`] on a caller-provided pool. +/// Migrations run on it exactly as they do for the normal path. +pub async fn build_server_on( + config: &config::AppConfig, + pool: sqlx::SqlitePool, +) -> anyhow::Result { + 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 { + 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 = + 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), + Arc::clone(&event_publisher), + )); + + 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, + document_parser: Arc::new(ImporterDocumentParser) as Arc, + 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(routes::build_router( + state, + social.ap_router, + ))) +} diff --git a/crates/composition/src/tests/build.rs b/crates/bootstrap/src/tests/build.rs similarity index 99% rename from crates/composition/src/tests/build.rs rename to crates/bootstrap/src/tests/build.rs index 4cfc62d3..c3c506f9 100644 --- a/crates/composition/src/tests/build.rs +++ b/crates/bootstrap/src/tests/build.rs @@ -266,11 +266,11 @@ fn test_repositories() -> Repositories { /// A lazily-connected in-memory sqlite pool — never actually opened in this test, /// just a value to satisfy `DatabaseOutput::db_pool`. -fn test_db_pool() -> infra_wiring::DbPool { +fn test_db_pool() -> crate::DbPool { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .expect("lazy sqlite pool"); - infra_wiring::DbPool::Sqlite(pool) + crate::DbPool::Sqlite(pool) } fn test_database_output() -> DatabaseOutput { @@ -348,7 +348,7 @@ fn test_config() -> AppConfig { refresh_ttl_seconds: 2_592_000, secure_cookies: false, cors_origins: String::new(), - wrapup: infra_wiring::WrapUpConfig { + wrapup: application::config::WrapUpConfig { font_path: None, logo_path: None, bg_dir: None, diff --git a/crates/bootstrap/src/tracing_setup.rs b/crates/bootstrap/src/tracing_setup.rs new file mode 100644 index 00000000..786a3e4d --- /dev/null +++ b/crates/bootstrap/src/tracing_setup.rs @@ -0,0 +1,25 @@ +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +/// Installs the global subscriber. `RUST_LOG` overrides `default_directives`. +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 +/// `presentation=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}")) + } +} diff --git a/crates/bootstrap/src/worker.rs b/crates/bootstrap/src/worker.rs new file mode 100644 index 00000000..5bcf7e60 --- /dev/null +++ b/crates/bootstrap/src/worker.rs @@ -0,0 +1,231 @@ +use std::sync::Arc; + +use application::config::AppConfig as AppSettings; +use application::{ + MovieDiscoveryIndexer, SearchCleanupHandler, SearchReindexHandler, worker::WorkerService, +}; +use domain::ports::{EventHandler, MovieEnrichmentClient, PeriodicJob, PersonEnrichmentClient}; +use domain::value_objects::InstanceIdentity; + +use crate::{database, event_bus}; + +pub struct WorkerApp { + pub service: WorkerService, +} + +/// Wires the background worker: event handlers, periodic jobs (already +/// spawned) and the consumer. Run it with `service.run(shutdown)`. +pub async fn build_worker(config: &config::AppConfig) -> anyhow::Result { + database::warn_if_database_backend_set(); + let app_config = AppSettings::from(config); + let instance = InstanceIdentity::new(app_config.base_url.clone()); + + 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> = tmdb_client + .clone() + .map(|c| c as Arc); + + let db = database::build_database_adapters(&config.database.url, &instance).await?; + let (event_publisher_arc, consumer_arc) = + 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_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 = + crate::federation::wire_worker_handlers(&db, &instance, config, &event_publisher_arc) + .await?; + + 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 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>, + Option>, + Option>, + ); + 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, + 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; + 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; + let job = Arc::new(application::jobs::EnrichmentStalenessJob::new( + Arc::clone(&movie_profile), + Arc::clone(&event_publisher), + )) as Arc; + (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> = 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::clone(&event_publisher), + )), + Arc::new(application::jobs::WrapUpCleanupJob::new(Arc::clone( + &wrapup_repo, + ))), + Arc::new(application::jobs::RefreshSessionCleanupJob::new( + Arc::clone(&refresh_session), + )), + ]; + 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> = { + let poster = Arc::new(poster_sync::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; + + let cleanup = Arc::new(object_storage::ImageCleanupHandler::new(Arc::clone( + &object_storage, + ))) as Arc; + + let search_cleanup = Arc::new(SearchCleanupHandler::new( + Arc::clone(&search_command), + Arc::clone(&person_query), + )) as Arc; + + let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new( + Arc::clone(&movie_query), + Arc::clone(&search_command), + )) as Arc; + + let wrapup_handler = Arc::new(application::wrapup::event_handler::WrapUpEventHandler::new( + worker_deps.handle_requested.wrapup_repo.clone(), + worker_deps.handle_requested.event_publisher.clone(), + worker_deps.handle_requested.wrapup_stats.clone(), + )) as Arc; + + let reindex_handler = Arc::new(SearchReindexHandler::new(worker_deps.reindex_search)) + as Arc; + + let mut h = vec![ + 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), + }) +} diff --git a/crates/composition/src/lib.rs b/crates/composition/src/lib.rs deleted file mode 100644 index 88e56cb4..00000000 --- a/crates/composition/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -pub mod build; -pub mod factory; -pub mod repositories; - -pub use build::{build_deps, build_worker_deps}; -pub use factory::{DatabaseOutput, DbPool}; -pub use repositories::Repositories; - -#[cfg(test)] -#[path = "tests/build.rs"] -mod build_tests; diff --git a/crates/infra-wiring/Cargo.toml b/crates/infra-wiring/Cargo.toml deleted file mode 100644 index 8a96e9b5..00000000 --- a/crates/infra-wiring/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "infra-wiring" -version = "0.1.0" -edition = "2021" - -[dependencies] -config = { workspace = true } -sqlx = { workspace = true } -anyhow = { workspace = true } - -[features] -default = [] -nats = [] diff --git a/crates/infra-wiring/src/config.rs b/crates/infra-wiring/src/config.rs deleted file mode 100644 index 101cad4b..00000000 --- a/crates/infra-wiring/src/config.rs +++ /dev/null @@ -1,41 +0,0 @@ -#[derive(Clone)] -pub struct AppConfig { - pub allow_registration: bool, - pub base_url: String, - pub rate_limit: u64, - pub refresh_ttl_seconds: u64, - pub secure_cookies: bool, - pub cors_origins: String, - pub wrapup: WrapUpConfig, -} - -#[derive(Clone)] -pub struct WrapUpConfig { - pub font_path: Option, - pub logo_path: Option, - pub bg_dir: Option, -} - -impl From<&::config::AppConfig> for AppConfig { - fn from(config: &::config::AppConfig) -> Self { - Self { - allow_registration: config.server.allow_registration, - base_url: config.server.base_url.clone(), - rate_limit: config.server.rate_limit, - refresh_ttl_seconds: config.auth.refresh_ttl_seconds, - secure_cookies: config.server.secure_cookies, - cors_origins: config.server.cors_origins.clone(), - wrapup: WrapUpConfig::from(&config.wrapup), - } - } -} - -impl From<&::config::WrapUpConfig> for WrapUpConfig { - fn from(config: &::config::WrapUpConfig) -> Self { - Self { - font_path: config.font_path.clone(), - logo_path: config.logo_path.clone(), - bg_dir: config.bg_dir.clone(), - } - } -} diff --git a/crates/infra-wiring/src/lib.rs b/crates/infra-wiring/src/lib.rs deleted file mode 100644 index a0205078..00000000 --- a/crates/infra-wiring/src/lib.rs +++ /dev/null @@ -1,29 +0,0 @@ -pub mod config; - -pub use config::{AppConfig, WrapUpConfig}; - -pub enum DbPool { - Sqlite(sqlx::SqlitePool), -} - -#[derive(Clone, Copy)] -pub enum EventBusBackend { - Db, - #[cfg(feature = "nats")] - Nats, -} - -impl EventBusBackend { - pub fn from_config(config: &::config::EventsConfig) -> anyhow::Result { - 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'"), - } - } -} diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index cc0952f0..bd25a45a 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -43,4 +43,4 @@ bytes = { workspace = true } futures = { workspace = true } tower = { version = "0.5", features = ["util"] } domain = { workspace = true, features = ["test-helpers"] } -composition = { workspace = true } +bootstrap = { workspace = true } diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index 152c72f3..71dd3670 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -7,7 +7,7 @@ use axum::{ http::{Request, StatusCode}, routing::get, }; -use composition::Repositories; +use bootstrap::Repositories; use domain::{ errors::DomainError, events::DomainEvent, @@ -816,7 +816,7 @@ pub(crate) fn test_repositories() -> Repositories { // --- Single state factory — builds `deps` from whatever `repos` it is given --- -/// Builds an `AppState` from caller-supplied `repos`, running `composition::build_deps` +/// 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(crate) fn build_test_state_from( @@ -854,9 +854,7 @@ pub(crate) fn build_test_state_from( let instance = domain::value_objects::InstanceIdentity::new("http://localhost:3000"); - let deps = Arc::new(composition::build_deps( - &repos, &services, &config, &instance, - )); + let deps = Arc::new(bootstrap::build_deps(&repos, &services, &config, &instance)); crate::state::AppState { app_ctx: AppContext { diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 3004e56b..eff91cd0 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -7,54 +7,32 @@ license = "MIT" [features] default = ["federation"] -federation = [ - "dep:activitypub", - "dep:sqlite-federation", - "application/federation", - "presentation/federation", - "composition/federation", -] -nats = ["dep:nats", "infra-wiring/nats", "composition/nats"] +federation = ["bootstrap/federation"] +nats = ["bootstrap/nats"] # deprecated aliases kept so existing deploy commands keep working sqlite = [] sqlite-federation = ["federation"] [dependencies] +bootstrap = { workspace = true } config = { workspace = true } axum = { workspace = true } tokio = { workspace = true } anyhow = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } dotenvy = { workspace = true } - -domain = { workspace = true } -application = { workspace = true } -composition = { workspace = true } -presentation = { path = "../presentation", default-features = false } -auth = { workspace = true } -metadata = { workspace = true } -poster-fetcher = { workspace = true } -object-storage = { workspace = true } -rss = { workspace = true } -export = { workspace = true } -importer = { workspace = true } -nats = { workspace = true, optional = true } -sqlx = { workspace = true } -infra-wiring = { workspace = true } -async-trait = { workspace = true } - -sqlite = { workspace = true } - -# Optional — federation -activitypub = { workspace = true, optional = true } -sqlite-federation = { workspace = true, optional = true } +tracing = { workspace = true } [dev-dependencies] +domain = { workspace = true, features = ["test-helpers"] } +application = { workspace = true } +presentation = { path = "../presentation", default-features = false } +rss = { workspace = true } +sqlite = { workspace = true } +sqlx = { workspace = true } +async-trait = { workspace = true } bytes = { workspace = true } futures = { workspace = true } serde_json = { workspace = true } uuid = { workspace = true } tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" -domain = { workspace = true, features = ["test-helpers"] } diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index fc39b4bc..09fe19f2 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,305 +1,22 @@ -use std::sync::Arc; - use anyhow::Context; - use tokio::net::TcpListener; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -use composition::Repositories; -use export::ExportAdapter; -use importer::ImporterDocumentParser; -use presentation::context::AppContext; -use presentation::{openapi, routes, state::AppState}; -use rss::RssAdapter; - -use domain::ports::{DiaryExporter, DocumentParser, EventPublisher}; -use infra_wiring::EventBusBackend; #[tokio::main] async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); - init_tracing(); + bootstrap::init_tracing("server=debug,tower_http=debug"); let config = config::load().context("Failed to load configuration")?; + let app = bootstrap::build_server(&config).await?; - let (state, ap_router) = wire_dependencies(&config) - .await - .context("Failed to wire dependencies")?; - - let app = openapi::serve(routes::build_router(state, ap_router)); - - let addr = format!("{}:{}", config.server.host, config.server.port); - let listener = TcpListener::bind(&addr).await?; - tracing::info!("Listening on {}", addr); + let listener = TcpListener::bind(app.addr).await?; + tracing::info!("Listening on {}", app.addr); axum::serve( listener, - app.into_make_service_with_connect_info::(), + app.router + .into_make_service_with_connect_info::(), ) .await?; Ok(()) } - -async fn wire_dependencies(config: &config::AppConfig) -> anyhow::Result<(AppState, axum::Router)> { - let app_config = application::config::AppConfig::from(config); - let instance = domain::value_objects::InstanceIdentity::new(app_config.base_url.clone()); - let database_url = config.database.url.as_str(); - composition::factory::warn_if_database_backend_set(); - - let (auth_service, password_hasher) = composition::factory::build_auth_adapters(&config.auth)?; - let metadata_client = composition::factory::build_metadata_client(&config.metadata)?; - let poster_fetcher = composition::factory::build_poster_fetcher(&config.metadata)?; - let object_storage = composition::factory::build_object_storage(&config.storage)?; - - let db = composition::factory::build_database_adapters(database_url, &instance).await?; - #[cfg(feature = "federation")] - let ap_content_repo = db.ap_content; - let db_pool = db.db_pool; - - // Wire up event channel, federation service, and ap_router - let event_bus = EventBusBackend::from_config(&config.events)?; - - #[cfg(feature = "federation")] - let ( - event_publisher_arc, - ap_router, - ap_document, - ap_blocklist, - social_query, - remote_watchlist_repo, - social_command_arc, - follow_graph_arc, - block_query_arc, - ) = { - let fed_repos = match &db_pool { - composition::DbPool::Sqlite(pool) => { - sqlite_federation::wire(pool.clone(), instance.clone()) - } - }; - - let ep = create_event_publisher(event_bus, &db_pool, config).await?; - - 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(&ap_content_repo), - 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: std::sync::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: app_config.allow_registration, - federation: config.federation.clone(), - event_publisher: Arc::clone(&ep), - }) - .await?; - let ap_router = ap.router; - let ap_document = ap.document; - let ap_blocklist = ap.blocklist; - - let local_social: Arc = - 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(), - )); - - ( - ep, - ap_router, - ap_document, - ap_blocklist, - fed_repos.admin_query, - fed_repos.remote_watchlist, - composite_social.clone() as Arc, - composite_social.clone() as Arc, - composite_social as Arc, - ) - }; - - #[cfg(not(feature = "federation"))] - let event_publisher_arc = create_event_publisher(event_bus, &db_pool, config).await?; - #[cfg(not(feature = "federation"))] - let ap_router = axum::Router::new(); - #[cfg(not(feature = "federation"))] - let (social_command_arc, follow_graph_arc, block_query_arc) = { - 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(), - )); - ( - Arc::clone(&local) as Arc, - Arc::clone(&local) as Arc, - local as Arc, - ) - }; - - 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), - Arc::clone(&event_publisher_arc), - )); - - 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, - #[cfg(feature = "federation")] - remote_watchlist: remote_watchlist_repo, - #[cfg(not(feature = "federation"))] - remote_watchlist: Arc::new(domain::ports::noop::NoopRemoteWatchlistRepository), - social_command: social_command_arc, - follow_graph: follow_graph_arc, - block_query: block_query_arc, - #[cfg(feature = "federation")] - federation_admin: social_query.clone(), - // 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.) - #[cfg(not(feature = "federation"))] - federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery), - 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, - #[cfg(feature = "federation")] - federated_profile: Some({ - match &db_pool { - composition::DbPool::Sqlite(pool) => { - sqlite::create_federated_profile_query(pool.clone(), instance.clone()) - } - } - }), - #[cfg(not(feature = "federation"))] - federated_profile: None, - }; - - let services = application::Services { - auth: auth_service, - password_hasher, - metadata: metadata_client, - poster_fetcher, - object_storage, - event_publisher: event_publisher_arc, - diary_exporter: Arc::new(ExportAdapter) as Arc, - document_parser: Arc::new(ImporterDocumentParser) as Arc, - review_logger, - person_enrichment: None, - }; - - let deps = Arc::new(composition::build_deps( - &repos, - &services, - &app_config, - &instance, - )); - - let app_ctx = AppContext { - deps, - services, - config: app_config, - instance, - #[cfg(feature = "federation")] - ap_document, - #[cfg(feature = "federation")] - ap_blocklist, - }; - - let state = AppState { - app_ctx, - rss_renderer: Arc::new(RssAdapter::new(config.server.base_url.clone())), - }; - Ok((state, ap_router)) -} - -async fn create_event_publisher( - event_bus: EventBusBackend, - db_pool: &composition::DbPool, - config: &config::AppConfig, -) -> anyhow::Result> { - match event_bus { - EventBusBackend::Db => { - tracing::info!("event bus: DB queue"); - Ok(match db_pool { - composition::DbPool::Sqlite(pool) => { - sqlite::SqliteEventQueue::create_publisher(pool.clone(), &config.events).await? - } - }) - } - #[cfg(feature = "nats")] - EventBusBackend::Nats => { - let cfg = nats::NatsConfig::from_config(&config.nats) - .context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?; - tracing::info!("event bus: NATS ({})", cfg.url); - Ok(nats::create_publisher(cfg).await?) - } - } -} - -fn init_tracing() { - tracing_subscriber::registry() - .with(log_filter("server=debug,tower_http=debug")) - .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 -/// `presentation=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}")) - } -} diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs index de982935..68c4c41e 100644 --- a/crates/server/tests/api_test.rs +++ b/crates/server/tests/api_test.rs @@ -7,7 +7,7 @@ use axum::{ body::Body, http::{Request, StatusCode}, }; -use composition::Repositories; +use bootstrap::Repositories; use domain::{ errors::DomainError, events::DomainEvent, @@ -461,9 +461,7 @@ async fn test_app_with(auth: impl AuthService + 'static) -> (Router, SqlitePool) let instance = domain::value_objects::InstanceIdentity::new("http://localhost:3000"); - let deps = Arc::new(composition::build_deps( - &repos, &services, &config, &instance, - )); + let deps = Arc::new(bootstrap::build_deps(&repos, &services, &config, &instance)); let state = AppState { app_ctx: AppContext { diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 6a1f7ab7..de32a83c 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -5,43 +5,16 @@ edition.workspace = true [features] default = ["federation"] -federation = [ - "dep:activitypub", - "dep:sqlite-federation", - "application/federation", - "composition/federation", -] -nats = ["dep:nats", "infra-wiring/nats", "composition/nats"] +federation = ["bootstrap/federation"] +nats = ["bootstrap/nats"] # deprecated aliases kept so existing deploy commands keep working sqlite = [] sqlite-federation = ["federation"] [dependencies] +bootstrap = { workspace = true } config = { workspace = true } -domain = { workspace = true } -application = { workspace = true } -composition = { workspace = true } tokio = { workspace = true, features = ["signal"] } anyhow = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } dotenvy = { workspace = true } -auth = { workspace = true } -metadata = { workspace = true } -poster-fetcher = { workspace = true } -object-storage = { workspace = true } -poster-sync = { workspace = true } -export = { workspace = true } -tmdb-enrichment = { workspace = true } -importer = { workspace = true } -image-converter = { workspace = true } -nats = { workspace = true, optional = true } -sqlx = { workspace = true } -infra-wiring = { workspace = true } -async-trait = { workspace = true } - -sqlite = { workspace = true } - -# Optional — federation -activitypub = { workspace = true, optional = true } -sqlite-federation = { workspace = true, optional = true } +tracing = { workspace = true } diff --git a/crates/worker/src/event_bus.rs b/crates/worker/src/event_bus.rs deleted file mode 100644 index 244c5cd3..00000000 --- a/crates/worker/src/event_bus.rs +++ /dev/null @@ -1,32 +0,0 @@ -use std::sync::Arc; - -#[cfg(feature = "nats")] -use anyhow::Context; -use domain::ports::{EventConsumer, EventPublisher}; - -use infra_wiring::{DbPool, EventBusBackend}; - -pub async fn create( - db_pool: &DbPool, - config: &config::AppConfig, -) -> anyhow::Result<(Arc, Arc)> { - match EventBusBackend::from_config(&config.events)? { - EventBusBackend::Db => { - tracing::info!("event bus: DB queue"); - match db_pool { - DbPool::Sqlite(pool) => Ok(sqlite::SqliteEventQueue::create_channel( - pool.clone(), - &config.events, - ) - .await?), - } - } - #[cfg(feature = "nats")] - EventBusBackend::Nats => { - let cfg = nats::NatsConfig::from_config(&config.nats) - .context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?; - tracing::info!("event bus: NATS ({})", cfg.url); - Ok(nats::create_channel(cfg).await?) - } - } -} diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index f1027431..908c9623 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -1,289 +1,12 @@ -mod event_bus; -#[cfg(feature = "federation")] -mod follow_backfill_handler; - -use std::sync::Arc; - use anyhow::Context; -use application::{ - MovieDiscoveryIndexer, SearchCleanupHandler, SearchReindexHandler, worker::WorkerService, -}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -use domain::ports::{EventHandler, MovieEnrichmentClient, PeriodicJob, PersonEnrichmentClient}; #[tokio::main] async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); - init_tracing(); + bootstrap::init_tracing("worker=info,application=info,k_ap=info"); let config = config::load().context("Failed to load configuration")?; - composition::factory::warn_if_database_backend_set(); - let app_config = application::config::AppConfig::from(&config); - let instance = domain::value_objects::InstanceIdentity::new(app_config.base_url.clone()); - - 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> = tmdb_client - .clone() - .map(|c| c as Arc); - - let db = composition::factory::build_database_adapters(&config.database.url, &instance).await?; - let (event_publisher_arc, consumer_arc) = event_bus::create(&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 = composition::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 ( - fed_ap_content, - fed_movie_repo, - fed_review_repo, - fed_diary_repo, - fed_goal_repo, - fed_stats_repo, - fed_user_repo, - allow_registration, - ) = ( - Arc::clone(&db.ap_content), - Arc::clone(&db.movie_query), - Arc::clone(&db.review), - Arc::clone(&db.diary), - Arc::clone(&db.goal_query), - Arc::clone(&db.stats), - Arc::clone(&db.user), - app_config.allow_registration, - ); - // Wire federation repos early to get remote_watchlist_repo for AppContext. - #[cfg(feature = "federation")] - let fed_repos = match &db.db_pool { - composition::DbPool::Sqlite(pool) => { - sqlite_federation::wire(pool.clone(), instance.clone()) - } - }; - - 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; - #[cfg(feature = "federation")] - let remote_goal = db.remote_goal; - let refresh_session = db.refresh_session; - - let event_publisher = event_publisher_arc; - let object_storage = object_storage; - 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>, - Option>, - Option>, - ); - 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, - 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; - 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; - let job = Arc::new(application::jobs::EnrichmentStalenessJob::new( - Arc::clone(&movie_profile), - Arc::clone(&event_publisher), - )) as Arc; - (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> = 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::clone(&event_publisher), - )), - Arc::new(application::jobs::WrapUpCleanupJob::new(Arc::clone( - &wrapup_repo, - ))), - Arc::new(application::jobs::RefreshSessionCleanupJob::new( - Arc::clone(&refresh_session), - )), - ]; - 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> = { - let poster = Arc::new(poster_sync::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; - - let cleanup = Arc::new(object_storage::ImageCleanupHandler::new(Arc::clone( - &object_storage, - ))) as Arc; - - let search_cleanup = Arc::new(SearchCleanupHandler::new( - Arc::clone(&search_command), - Arc::clone(&person_query), - )) as Arc; - - let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new( - Arc::clone(&movie_query), - Arc::clone(&search_command), - )) as Arc; - - let wrapup_handler = Arc::new(application::wrapup::event_handler::WrapUpEventHandler::new( - worker_deps.handle_requested.wrapup_repo.clone(), - worker_deps.handle_requested.event_publisher.clone(), - worker_deps.handle_requested.wrapup_stats.clone(), - )) as Arc; - - let reindex_handler = Arc::new(SearchReindexHandler::new(worker_deps.reindex_search)) - as Arc; - - let mut h = vec![ - poster, - cleanup, - search_cleanup, - discovery_indexer, - wrapup_handler, - reindex_handler, - ]; - - #[cfg(feature = "federation")] - { - 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(&remote_goal), - local_ap_content: fed_ap_content, - movie_repo: fed_movie_repo, - review_repo: fed_review_repo, - diary_repo: fed_diary_repo, - goal_repo: fed_goal_repo, - stats_repo: fed_stats_repo, - user_repo: fed_user_repo, - follow_command: fed_repos.follow_command, - follow_query: fed_repos.follow_query, - instance: instance.clone(), - allow_registration, - federation: config.federation.clone(), - event_publisher: Arc::clone(&event_publisher), - federation_settings: std::sync::Arc::clone(&db.federation_settings), - }) - .await?; - - tracing::info!("federation event handler registered"); - h.push(ap_wire.event_handler); - h.push(Arc::new(follow_backfill_handler::FollowBackfillHandler { - backfill: ap_wire.backfill, - }) as Arc); - } - - 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 - }; - - // ── Run ─────────────────────────────────────────────────────────────────── + let app = bootstrap::build_worker(&config).await?; let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); tokio::spawn(async move { @@ -291,32 +14,8 @@ async fn main() -> anyhow::Result<()> { let _ = shutdown_tx.send(true); }); - let worker = WorkerService::new(consumer_arc, handlers); tracing::info!("worker started"); - worker.run(shutdown_rx).await; + app.service.run(shutdown_rx).await; Ok(()) } - -fn init_tracing() { - tracing_subscriber::registry() - .with(log_filter("worker=info,application=info,k_ap=info")) - .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 -/// `presentation=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}")) - } -} diff --git a/docs/adr/0005-single-composition-root.md b/docs/adr/0005-single-composition-root.md index c7fb962c..d76f1948 100644 --- a/docs/adr/0005-single-composition-root.md +++ b/docs/adr/0005-single-composition-root.md @@ -75,3 +75,10 @@ unconditionally, with no `#[cfg(feature = "federation")]` gate. `cargo build -p --no-default-features --features sqlite` (federation off) fails to compile because of it. This predates worker unification — confirmed byte-identical at the commit before this plan started — and is unrelated to the composition-root split; it needs its own fix. + +## Realised by `bootstrap` (ADR-0013) + +`crates/composition` is now `crates/bootstrap`, and `crates/infra-wiring` is absorbed into it. The +server and worker `main.rs` files no longer wire anything: `bootstrap::build_server` and +`bootstrap::build_worker` own the whole assembly, so the single composition root is now the only +place adapters are named. See ADR-0013. diff --git a/docs/adr/0013-config-and-bootstrap.md b/docs/adr/0013-config-and-bootstrap.md new file mode 100644 index 00000000..2af5842a --- /dev/null +++ b/docs/adr/0013-config-and-bootstrap.md @@ -0,0 +1,44 @@ +# Typed configuration crate and a bootstrap crate that owns all wiring + +Configuration was read ad hoc from `std::env` in many adapters, and the two binaries each carried +hundreds of lines of adapter wiring (`wire_dependencies`, `create_event_publisher`, the worker's +handler and job assembly). ADR-0005 made `composition` the single root for deps, but the binaries +still named adapters themselves, and `infra-wiring` existed only so `application` could re-export an +`AppConfig`. + +## Decision + +**`config` crate.** One typed `config::AppConfig` with sections (`server`, `database`, `auth`, +`events`, `nats`, `storage`, `image_conversion`, `metadata`, `federation`, `wrapup`), loaded once by +the binary via `config::load()`. Layering, lowest to highest precedence: + +1. built-in defaults; +2. `config.toml`, or the file named by `CONFIG_FILE` (a named file that is missing is an error); +3. environment variables, using the legacy names (`DATABASE_URL`, `PORT`, `JWT_SECRET`, ...) with no + prefix. Every variable that ever worked keeps its name, meaning and default. + +A malformed value fails startup with an error naming the variable (`PORT=abc`). An empty or +whitespace-only value is treated as unset, so the default applies (or `None` for optional fields) +rather than being an error. Adapters take their section (or a typed config built from it), never +raw environment strings. + +**`bootstrap` crate.** Replaces `composition` and `infra-wiring`. It is the only crate that names +concrete adapters. It exposes `build_server(&AppConfig) -> ServerApp { router, addr }`, +`build_worker(&AppConfig) -> WorkerApp { service }`, `build_server_on(&AppConfig, SqlitePool)` (the +same router on a caller-provided pool, for tests) and `init_tracing`. `DbPool` and +`EventBusBackend` live here. `application` keeps a small application-owned `AppConfig` / +`WrapUpConfig` built from the `config` sections, so use-case deps structs and their test fixtures +are unchanged, and `application` no longer pulls `sqlx` or `anyhow`. + +**Binaries are thin.** `server` and `worker` do dotenv, `config::load`, `init_tracing`, one +`bootstrap::build_*` call and `axum::serve` / `service.run`. Their `[dependencies]` are limited to +`bootstrap`, `config`, `tokio`, `axum`, `anyhow`, `dotenvy` and `tracing`; `make +check-binaries-are-thin` enforces it. The `federation` and `nats` features forward to bootstrap. + +## Consequences + +- Adding or changing an adapter touches `bootstrap` only; the binaries do not change. +- The wiring is reachable from tests (`build_server_on`) without a process or a port. +- `check-federation-off-social` now inspects `crates/bootstrap/src/federation.rs`: federation-off + must still wire `LocalSocialService`, never social noops (ADR-0009). +- Operators can move settings into `config.toml` incrementally; existing `.env` files keep working. -- 2.49.1 From a00a5dc65589265dadc203b881f269b5bb0a5883 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:18:16 +0100 Subject: [PATCH 015/104] fix: config empty values unset, drop adapter from_vars --- crates/adapters/image-converter/src/config.rs | 21 ++++----- .../image-converter/src/tests/config.rs | 45 +++++++++---------- crates/adapters/nats/src/config.rs | 35 ++++----------- crates/adapters/nats/src/tests/config.rs | 41 +++++++---------- crates/config/src/loader.rs | 8 +++- crates/config/tests/loader_test.rs | 21 +++++++++ 6 files changed, 83 insertions(+), 88 deletions(-) diff --git a/crates/adapters/image-converter/src/config.rs b/crates/adapters/image-converter/src/config.rs index 4745e970..2bf3934a 100644 --- a/crates/adapters/image-converter/src/config.rs +++ b/crates/adapters/image-converter/src/config.rs @@ -19,24 +19,19 @@ pub struct ConversionConfig { impl ConversionConfig { pub fn from_config(config: &::config::ImageConversionConfig) -> anyhow::Result> { - Self::from_vars(config.enabled.then_some("true"), config.format.as_deref()) - } - - fn from_vars(enabled: Option<&str>, format: Option<&str>) -> anyhow::Result> { - if enabled != Some("true") { + if !config.enabled { return Ok(None); } - let format_str = format.ok_or_else(|| { - anyhow::anyhow!("IMAGE_CONVERSION_FORMAT required when IMAGE_CONVERSION_ENABLED=true") - })?; - - let format = match format_str { - "avif" => Format::Avif, - "webp" => Format::Webp, - other => anyhow::bail!( + let format = match config.format.as_deref() { + Some("avif") => Format::Avif, + Some("webp") => Format::Webp, + Some(other) => anyhow::bail!( "Unknown IMAGE_CONVERSION_FORMAT: {other:?}. Valid values: avif, webp" ), + None => { + anyhow::bail!("IMAGE_CONVERSION_FORMAT required when IMAGE_CONVERSION_ENABLED=true") + } }; Ok(Some(Self { format })) diff --git a/crates/adapters/image-converter/src/tests/config.rs b/crates/adapters/image-converter/src/tests/config.rs index 9747b481..63a302c3 100644 --- a/crates/adapters/image-converter/src/tests/config.rs +++ b/crates/adapters/image-converter/src/tests/config.rs @@ -1,37 +1,46 @@ use super::*; +fn section(enabled: bool, format: Option<&str>) -> ::config::ImageConversionConfig { + ::config::ImageConversionConfig { + enabled, + format: format.map(str::to_owned), + } +} + +fn convert(enabled: bool, format: Option<&str>) -> anyhow::Result> { + ConversionConfig::from_config(§ion(enabled, format)) +} + #[test] fn disabled_by_default() { - assert!(ConversionConfig::from_vars(None, None).unwrap().is_none()); - assert!(ConversionConfig::from_vars(Some("false"), None) - .unwrap() - .is_none()); + assert!( + ConversionConfig::from_config(&::config::ImageConversionConfig::default()) + .unwrap() + .is_none() + ); + assert!(convert(false, Some("webp")).unwrap().is_none()); } #[test] fn enabled_avif() { - let cfg = ConversionConfig::from_vars(Some("true"), Some("avif")) - .unwrap() - .unwrap(); + let cfg = convert(true, Some("avif")).unwrap().unwrap(); assert_eq!(cfg.format, Format::Avif); } #[test] fn enabled_webp() { - let cfg = ConversionConfig::from_vars(Some("true"), Some("webp")) - .unwrap() - .unwrap(); + let cfg = convert(true, Some("webp")).unwrap().unwrap(); assert_eq!(cfg.format, Format::Webp); } #[test] fn unknown_format_is_error() { - assert!(ConversionConfig::from_vars(Some("true"), Some("gif")).is_err()); + assert!(convert(true, Some("gif")).is_err()); } #[test] fn missing_format_when_enabled_is_error() { - assert!(ConversionConfig::from_vars(Some("true"), None).is_err()); + assert!(convert(true, None).is_err()); } #[test] @@ -43,15 +52,3 @@ fn avif_extension() { fn webp_extension() { assert_eq!(Format::Webp.extension(), ".webp"); } - -#[test] -fn from_config_follows_section() { - let disabled = ::config::ImageConversionConfig::default(); - assert!(ConversionConfig::from_config(&disabled).unwrap().is_none()); - let enabled = ::config::ImageConversionConfig { - enabled: true, - format: Some("webp".into()), - }; - let cfg = ConversionConfig::from_config(&enabled).unwrap().unwrap(); - assert_eq!(cfg.format, Format::Webp); -} diff --git a/crates/adapters/nats/src/config.rs b/crates/adapters/nats/src/config.rs index 31d5b393..d7ed4381 100644 --- a/crates/adapters/nats/src/config.rs +++ b/crates/adapters/nats/src/config.rs @@ -15,40 +15,23 @@ pub struct NatsConfig { impl NatsConfig { pub fn from_config(config: &::config::NatsConfig) -> anyhow::Result { - Self::from_vars( - config.url.as_deref(), - Some(&config.mode), - Some(&config.subject_prefix), - Some(&config.stream_name), - Some(&config.consumer_name), - ) - } + let url = config + .url + .clone() + .ok_or_else(|| anyhow::anyhow!("NATS_URL is not set"))?; - pub(crate) fn from_vars( - url: Option<&str>, - mode: Option<&str>, - subject_prefix: Option<&str>, - stream_name: Option<&str>, - consumer_name: Option<&str>, - ) -> anyhow::Result { - let url = url.ok_or_else(|| anyhow::anyhow!("NATS_URL is not set"))?; - - let mode = match mode.unwrap_or("jetstream") { + let mode = match config.mode.as_str() { "core" => NatsMode::Core, "jetstream" => NatsMode::JetStream, other => anyhow::bail!("unknown NATS_MODE: {other}"), }; - let subject_prefix = subject_prefix.unwrap_or("movies-diary.events").to_string(); - let stream_name = stream_name.unwrap_or("MOVIES_DIARY_EVENTS").to_string(); - let consumer_name = consumer_name.unwrap_or("worker").to_string(); - Ok(Self { - url: url.to_string(), + url, mode, - subject_prefix, - stream_name, - consumer_name, + subject_prefix: config.subject_prefix.clone(), + stream_name: config.stream_name.clone(), + consumer_name: config.consumer_name.clone(), }) } } diff --git a/crates/adapters/nats/src/tests/config.rs b/crates/adapters/nats/src/tests/config.rs index d81453e8..8d749d06 100644 --- a/crates/adapters/nats/src/tests/config.rs +++ b/crates/adapters/nats/src/tests/config.rs @@ -1,13 +1,25 @@ use super::*; +fn section(url: Option<&str>, mode: &str) -> ::config::NatsConfig { + ::config::NatsConfig { + url: url.map(str::to_owned), + mode: mode.into(), + ..Default::default() + } +} + #[test] fn errors_without_nats_url() { - assert!(NatsConfig::from_vars(None, None, None, None, None).is_err()); + assert!(NatsConfig::from_config(&::config::NatsConfig::default()).is_err()); } #[test] fn defaults_with_only_url() { - let cfg = NatsConfig::from_vars(Some("nats://localhost:4222"), None, None, None, None).unwrap(); + let cfg = NatsConfig::from_config(&::config::NatsConfig { + url: Some("nats://localhost:4222".into()), + ..Default::default() + }) + .unwrap(); assert_eq!(cfg.url, "nats://localhost:4222"); assert_eq!(cfg.mode, NatsMode::JetStream); assert_eq!(cfg.subject_prefix, "movies-diary.events"); @@ -17,31 +29,12 @@ fn defaults_with_only_url() { #[test] fn core_mode_parsed() { - let cfg = - NatsConfig::from_vars(Some("nats://test:4222"), Some("core"), None, None, None).unwrap(); + let cfg = NatsConfig::from_config(§ion(Some("nats://test:4222"), "core")).unwrap(); + assert_eq!(cfg.url, "nats://test:4222"); assert_eq!(cfg.mode, NatsMode::Core); } #[test] fn invalid_mode_errors() { - assert!( - NatsConfig::from_vars(Some("nats://test:4222"), Some("kafka"), None, None, None).is_err() - ); -} - -#[test] -fn from_config_requires_url() { - assert!(NatsConfig::from_config(&::config::NatsConfig::default()).is_err()); -} - -#[test] -fn from_config_uses_section_values() { - let section = ::config::NatsConfig { - url: Some("nats://test:4222".into()), - mode: "core".into(), - ..Default::default() - }; - let cfg = NatsConfig::from_config(§ion).unwrap(); - assert_eq!(cfg.url, "nats://test:4222"); - assert_eq!(cfg.mode, NatsMode::Core); + assert!(NatsConfig::from_config(§ion(Some("nats://test:4222"), "kafka")).is_err()); } diff --git a/crates/config/src/loader.rs b/crates/config/src/loader.rs index 250404f6..5d5e6b83 100644 --- a/crates/config/src/loader.rs +++ b/crates/config/src/loader.rs @@ -59,7 +59,8 @@ const LEGACY_ENV: &[(&str, &str)] = &[ ]; /// Reads the legacy variables as plain strings; typing is left to the config -/// structs so that e.g. `JWT_SECRET=12345` stays a string. +/// structs so that e.g. `JWT_SECRET=12345` stays a string. Empty or +/// whitespace-only values are skipped as unset. struct LegacyEnv; impl Provider for LegacyEnv { @@ -76,6 +77,11 @@ impl Provider for LegacyEnv { let value = raw .into_string() .map_err(|_| figment::Error::from(format!("{variable} is not valid unicode")))?; + // An empty value (`PORT=` in a .env template) means "not set": the + // default applies, or the optional field stays `None`. + if value.trim().is_empty() { + continue; + } let (section, field) = key.split_once('.').expect("legacy keys are section.field"); let entry = sections .entry(section.to_owned()) diff --git a/crates/config/tests/loader_test.rs b/crates/config/tests/loader_test.rs index 58f8d11f..77e80b54 100644 --- a/crates/config/tests/loader_test.rs +++ b/crates/config/tests/loader_test.rs @@ -345,6 +345,27 @@ fn malformed_numeric_env_value_fails_naming_the_variable() { }); } +#[test] +fn empty_env_value_falls_back_to_the_default() { + Jail::expect_with(|jail| { + jail_with( + jail, + &[ + ("RATE_LIMIT", ""), + ("PORT", " "), + ("TMDB_API_KEY", ""), + ("NATS_URL", ""), + ], + ); + let c = load().expect("empty values are unset, not errors"); + assert_eq!(c.server.rate_limit, 60); + assert_eq!(c.server.port, 3000); + assert!(c.metadata.tmdb_api_key.is_none()); + assert!(c.nats.url.is_none()); + Ok(()) + }); +} + #[test] fn allow_registration_accepts_true_and_1_only() { for (value, expected) in [ -- 2.49.1 From 57c70afeadb156d86560f1208a6784fc100d48e5 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:20:37 +0100 Subject: [PATCH 016/104] fix: bootstrap log directives, empty DATABASE_URL test --- crates/config/tests/loader_test.rs | 10 ++++++++++ crates/server/src/main.rs | 2 +- crates/worker/src/main.rs | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/config/tests/loader_test.rs b/crates/config/tests/loader_test.rs index 77e80b54..1192ace4 100644 --- a/crates/config/tests/loader_test.rs +++ b/crates/config/tests/loader_test.rs @@ -410,3 +410,13 @@ fn malformed_config_file_reports_the_file_not_a_missing_database_url() { Ok(()) }); } + +#[test] +fn empty_database_url_is_reported_missing() { + Jail::expect_with(|jail| { + jail_with(jail, &[("DATABASE_URL", "")]); + let err = load().expect_err("empty DATABASE_URL is unset"); + assert!(matches!(err, ConfigError::Missing("DATABASE_URL")), "{err}"); + Ok(()) + }); +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 09fe19f2..2b111c26 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -4,7 +4,7 @@ use tokio::net::TcpListener; #[tokio::main] async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); - bootstrap::init_tracing("server=debug,tower_http=debug"); + bootstrap::init_tracing("server=debug,bootstrap=debug,tower_http=debug"); let config = config::load().context("Failed to load configuration")?; let app = bootstrap::build_server(&config).await?; diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 908c9623..05c679fa 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -3,7 +3,7 @@ use anyhow::Context; #[tokio::main] async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); - bootstrap::init_tracing("worker=info,application=info,k_ap=info"); + bootstrap::init_tracing("worker=info,bootstrap=info,application=info,k_ap=info"); let config = config::load().context("Failed to load configuration")?; let app = bootstrap::build_worker(&config).await?; -- 2.49.1 From b5bfdadcd9115b2bff927d0a90b34db75899d735 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:22:59 +0100 Subject: [PATCH 017/104] refactor: merge media-server parsers, move poster sync and event payload --- Cargo.lock | 41 +++++-------------- Cargo.toml | 12 ++---- Dockerfile | 6 +-- README.md | 5 +-- crates/adapters/image-converter/Cargo.toml | 2 +- .../{jellyfin => media-server}/Cargo.toml | 2 +- .../lib.rs => media-server/src/jellyfin.rs} | 2 +- crates/adapters/media-server/src/lib.rs | 5 +++ .../src/lib.rs => media-server/src/plex.rs} | 2 +- .../src/tests/jellyfin.rs} | 0 .../lib.rs => media-server/src/tests/plex.rs} | 0 crates/adapters/plex/Cargo.toml | 9 ---- crates/adapters/poster-fetcher/Cargo.toml | 2 +- crates/adapters/poster-sync/Cargo.toml | 10 ----- crates/application/src/movies/mod.rs | 1 + .../src/movies/poster_sync.rs} | 0 crates/bootstrap/Cargo.toml | 4 +- crates/bootstrap/src/build.rs | 4 +- crates/bootstrap/src/worker.rs | 2 +- .../{adapters => }/event-payload/Cargo.toml | 0 .../{adapters => }/event-payload/src/lib.rs | 0 .../event-payload/src/tests/lib.rs | 0 22 files changed, 32 insertions(+), 77 deletions(-) rename crates/adapters/{jellyfin => media-server}/Cargo.toml (88%) rename crates/adapters/{jellyfin/src/lib.rs => media-server/src/jellyfin.rs} (98%) create mode 100644 crates/adapters/media-server/src/lib.rs rename crates/adapters/{plex/src/lib.rs => media-server/src/plex.rs} (98%) rename crates/adapters/{jellyfin/src/tests/lib.rs => media-server/src/tests/jellyfin.rs} (100%) rename crates/adapters/{plex/src/tests/lib.rs => media-server/src/tests/plex.rs} (100%) delete mode 100644 crates/adapters/plex/Cargo.toml delete mode 100644 crates/adapters/poster-sync/Cargo.toml rename crates/{adapters/poster-sync/src/lib.rs => application/src/movies/poster_sync.rs} (100%) rename crates/{adapters => }/event-payload/Cargo.toml (100%) rename crates/{adapters => }/event-payload/src/lib.rs (100%) rename crates/{adapters => }/event-payload/src/tests/lib.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index f6d8cc76..8010cb58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -921,13 +921,11 @@ dependencies = [ "export", "image-converter", "importer", - "jellyfin", + "media-server", "metadata", "nats", "object-storage", - "plex", "poster-fetcher", - "poster-sync", "presentation", "rss 0.1.0", "sqlite", @@ -2852,15 +2850,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jellyfin" -version = "0.1.0" -dependencies = [ - "domain", - "serde", - "serde_json", -] - [[package]] name = "jni" version = "0.22.4" @@ -3224,6 +3213,15 @@ dependencies = [ "digest", ] +[[package]] +name = "media-server" +version = "0.1.0" +dependencies = [ + "domain", + "serde", + "serde_json", +] + [[package]] name = "memchr" version = "2.8.0" @@ -3909,15 +3907,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" -[[package]] -name = "plex" -version = "0.1.0" -dependencies = [ - "domain", - "serde", - "serde_json", -] - [[package]] name = "png" version = "0.18.1" @@ -3962,16 +3951,6 @@ dependencies = [ "reqwest 0.13.3", ] -[[package]] -name = "poster-sync" -version = "0.1.0" -dependencies = [ - "async-trait", - "domain", - "tokio", - "tracing", -] - [[package]] name = "potential_utf" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index 68eec11e..8c73152e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,14 +5,13 @@ members = [ "crates/adapters/metadata", "crates/adapters/poster-fetcher", "crates/adapters/object-storage", - "crates/adapters/poster-sync", "crates/adapters/rss", "crates/adapters/sqlite", "crates/adapters/sqlite-federation", "crates/adapters/template-askama", "crates/adapters/activitypub", "crates/adapters/export", - "crates/adapters/event-payload", + "crates/event-payload", "crates/adapters/nats", "crates/api-types", "crates/application", @@ -25,8 +24,7 @@ members = [ "crates/tui", "crates/worker", "crates/adapters/importer", - "crates/adapters/jellyfin", - "crates/adapters/plex", + "crates/adapters/media-server", "crates/config", ] resolver = "2" @@ -75,7 +73,6 @@ auth = { path = "crates/adapters/auth" } metadata = { path = "crates/adapters/metadata" } poster-fetcher = { path = "crates/adapters/poster-fetcher" } object-storage = { path = "crates/adapters/object-storage" } -poster-sync = { path = "crates/adapters/poster-sync" } event-publisher = { path = "crates/adapters/event-publisher" } rss = { path = "crates/adapters/rss" } export = { path = "crates/adapters/export" } @@ -83,11 +80,10 @@ sqlite = { path = "crates/adapters/sqlite" } sqlite-federation = { path = "crates/adapters/sqlite-federation" } template-askama = { path = "crates/adapters/template-askama" } activitypub = { path = "crates/adapters/activitypub" } -event-payload = { path = "crates/adapters/event-payload" } +event-payload = { path = "crates/event-payload" } +media-server = { path = "crates/adapters/media-server" } nats = { path = "crates/adapters/nats" } importer = { path = "crates/adapters/importer" } -jellyfin = { path = "crates/adapters/jellyfin" } -plex = { path = "crates/adapters/plex" } image-converter = { path = "crates/adapters/image-converter" } config = { path = "crates/config" } diff --git a/Dockerfile b/Dockerfile index 27437609..75560c74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,17 +20,15 @@ COPY Cargo.toml Cargo.lock ./ COPY .cargo ./.cargo COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml -COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml +COPY crates/event-payload/Cargo.toml crates/event-payload/Cargo.toml COPY crates/adapters/event-publisher/Cargo.toml crates/adapters/event-publisher/Cargo.toml COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml COPY crates/adapters/metadata/Cargo.toml crates/adapters/metadata/Cargo.toml COPY crates/adapters/poster-fetcher/Cargo.toml crates/adapters/poster-fetcher/Cargo.toml COPY crates/adapters/object-storage/Cargo.toml crates/adapters/object-storage/Cargo.toml -COPY crates/adapters/poster-sync/Cargo.toml crates/adapters/poster-sync/Cargo.toml COPY crates/adapters/export/Cargo.toml crates/adapters/export/Cargo.toml COPY crates/adapters/importer/Cargo.toml crates/adapters/importer/Cargo.toml -COPY crates/adapters/jellyfin/Cargo.toml crates/adapters/jellyfin/Cargo.toml -COPY crates/adapters/plex/Cargo.toml crates/adapters/plex/Cargo.toml +COPY crates/adapters/media-server/Cargo.toml crates/adapters/media-server/Cargo.toml COPY crates/adapters/rss/Cargo.toml crates/adapters/rss/Cargo.toml COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml COPY crates/adapters/sqlite-federation/Cargo.toml crates/adapters/sqlite-federation/Cargo.toml diff --git a/README.md b/README.md index 12be0709..e57bd204 100644 --- a/README.md +++ b/README.md @@ -104,16 +104,13 @@ adapters/ metadata — TMDB / OMDb HTTP client poster-fetcher — downloads poster images object-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage - poster-sync — event handler: triggers poster fetch+store on MovieDiscovered image-converter — optional background worker: converts stored images to AVIF or WebP; backfills existing images via a 24h periodic job tmdb-enrichment — TMDb HTTP client implementing MovieEnrichmentClient and PersonEnrichmentClient; event handlers (MovieEnrichmentHandler, PersonEnrichmentHandler) live in the application layer template-askama — Askama HTML rendering rss — RSS/Atom feed generation export — CSV and JSON diary serialization importer — CSV/TSV/JSON/XLSX parser and column mapper for bulk import - jellyfin — Jellyfin webhook payload parser (MediaServerParser adapter) - plex — Plex webhook payload parser (MediaServerParser adapter; requires Plex Pass) - event-payload — shared event serialization DTOs (used by all event bus adapters) + media-server — Jellyfin and Plex webhook payload parsers (MediaServerParser adapter; Plex requires Plex Pass) event-publisher — in-memory event channel (used in tests) nats — NATS Core / JetStream event publisher and consumer activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals diff --git a/crates/adapters/image-converter/Cargo.toml b/crates/adapters/image-converter/Cargo.toml index 27053717..b65976a0 100644 --- a/crates/adapters/image-converter/Cargo.toml +++ b/crates/adapters/image-converter/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "image-converter" version = "0.1.0" -edition = "2021" +edition.workspace = true [dependencies] config = { workspace = true } diff --git a/crates/adapters/jellyfin/Cargo.toml b/crates/adapters/media-server/Cargo.toml similarity index 88% rename from crates/adapters/jellyfin/Cargo.toml rename to crates/adapters/media-server/Cargo.toml index cd50e5e4..12e13465 100644 --- a/crates/adapters/jellyfin/Cargo.toml +++ b/crates/adapters/media-server/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "jellyfin" +name = "media-server" version = "0.1.0" edition.workspace = true diff --git a/crates/adapters/jellyfin/src/lib.rs b/crates/adapters/media-server/src/jellyfin.rs similarity index 98% rename from crates/adapters/jellyfin/src/lib.rs rename to crates/adapters/media-server/src/jellyfin.rs index 3ff9cc91..aaa565d8 100644 --- a/crates/adapters/jellyfin/src/lib.rs +++ b/crates/adapters/media-server/src/jellyfin.rs @@ -60,5 +60,5 @@ struct JellyfinPayload { } #[cfg(test)] -#[path = "tests/lib.rs"] +#[path = "tests/jellyfin.rs"] mod tests; diff --git a/crates/adapters/media-server/src/lib.rs b/crates/adapters/media-server/src/lib.rs new file mode 100644 index 00000000..7776ea70 --- /dev/null +++ b/crates/adapters/media-server/src/lib.rs @@ -0,0 +1,5 @@ +mod jellyfin; +mod plex; + +pub use jellyfin::JellyfinParser; +pub use plex::PlexParser; diff --git a/crates/adapters/plex/src/lib.rs b/crates/adapters/media-server/src/plex.rs similarity index 98% rename from crates/adapters/plex/src/lib.rs rename to crates/adapters/media-server/src/plex.rs index 2c6dfc05..16187867 100644 --- a/crates/adapters/plex/src/lib.rs +++ b/crates/adapters/media-server/src/plex.rs @@ -70,5 +70,5 @@ struct PlexGuid { } #[cfg(test)] -#[path = "tests/lib.rs"] +#[path = "tests/plex.rs"] mod tests; diff --git a/crates/adapters/jellyfin/src/tests/lib.rs b/crates/adapters/media-server/src/tests/jellyfin.rs similarity index 100% rename from crates/adapters/jellyfin/src/tests/lib.rs rename to crates/adapters/media-server/src/tests/jellyfin.rs diff --git a/crates/adapters/plex/src/tests/lib.rs b/crates/adapters/media-server/src/tests/plex.rs similarity index 100% rename from crates/adapters/plex/src/tests/lib.rs rename to crates/adapters/media-server/src/tests/plex.rs diff --git a/crates/adapters/plex/Cargo.toml b/crates/adapters/plex/Cargo.toml deleted file mode 100644 index 408dfd19..00000000 --- a/crates/adapters/plex/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "plex" -version = "0.1.0" -edition.workspace = true - -[dependencies] -domain = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } diff --git a/crates/adapters/poster-fetcher/Cargo.toml b/crates/adapters/poster-fetcher/Cargo.toml index dad29290..efbdb43f 100644 --- a/crates/adapters/poster-fetcher/Cargo.toml +++ b/crates/adapters/poster-fetcher/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "poster-fetcher" version = "0.1.0" -edition = "2021" +edition.workspace = true [dependencies] config = { workspace = true } diff --git a/crates/adapters/poster-sync/Cargo.toml b/crates/adapters/poster-sync/Cargo.toml deleted file mode 100644 index 39e41276..00000000 --- a/crates/adapters/poster-sync/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "poster-sync" -version = "0.1.0" -edition.workspace = true - -[dependencies] -domain = { workspace = true } -async-trait = { workspace = true } -tracing = { workspace = true } -tokio = { workspace = true } diff --git a/crates/application/src/movies/mod.rs b/crates/application/src/movies/mod.rs index 6ca52485..14104074 100644 --- a/crates/application/src/movies/mod.rs +++ b/crates/application/src/movies/mod.rs @@ -6,6 +6,7 @@ pub mod event_handler; pub mod get_movie_profile; pub mod get_movies; pub mod merge_duplicates; +pub mod poster_sync; pub mod queries; pub mod reindex_search; pub mod request_enrichment; diff --git a/crates/adapters/poster-sync/src/lib.rs b/crates/application/src/movies/poster_sync.rs similarity index 100% rename from crates/adapters/poster-sync/src/lib.rs rename to crates/application/src/movies/poster_sync.rs diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index 3632f8cd..4690586d 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -22,14 +22,12 @@ auth = { workspace = true } metadata = { workspace = true } poster-fetcher = { workspace = true } object-storage = { workspace = true } -poster-sync = { workspace = true } tmdb-enrichment = { workspace = true } image-converter = { workspace = true } export = { workspace = true } importer = { workspace = true } rss = { workspace = true } -jellyfin = { workspace = true } -plex = { workspace = true } +media-server = { workspace = true } sqlite = { workspace = true } sqlx = { workspace = true } anyhow = { workspace = true } diff --git a/crates/bootstrap/src/build.rs b/crates/bootstrap/src/build.rs index 98f534cd..eb7b0e85 100644 --- a/crates/bootstrap/src/build.rs +++ b/crates/bootstrap/src/build.rs @@ -210,8 +210,8 @@ pub fn build_deps( revoke_webhook_token: RevokeWebhookTokenDeps { webhook_token: repos.webhook_token.clone(), }, - jellyfin_parser: std::sync::Arc::new(jellyfin::JellyfinParser), - plex_parser: std::sync::Arc::new(plex::PlexParser), + jellyfin_parser: std::sync::Arc::new(media_server::JellyfinParser), + plex_parser: std::sync::Arc::new(media_server::PlexParser), }, movies: MoviesGroup { sync_poster: SyncPosterDeps { diff --git a/crates/bootstrap/src/worker.rs b/crates/bootstrap/src/worker.rs index 5bcf7e60..de35f6dd 100644 --- a/crates/bootstrap/src/worker.rs +++ b/crates/bootstrap/src/worker.rs @@ -167,7 +167,7 @@ pub async fn build_worker(config: &config::AppConfig) -> anyhow::Result> = { - let poster = Arc::new(poster_sync::PosterSyncHandler::new( + let poster = Arc::new(application::movies::poster_sync::PosterSyncHandler::new( Arc::clone(&movie_command), Arc::clone(&movie_query), Arc::clone(&metadata), diff --git a/crates/adapters/event-payload/Cargo.toml b/crates/event-payload/Cargo.toml similarity index 100% rename from crates/adapters/event-payload/Cargo.toml rename to crates/event-payload/Cargo.toml diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/event-payload/src/lib.rs similarity index 100% rename from crates/adapters/event-payload/src/lib.rs rename to crates/event-payload/src/lib.rs diff --git a/crates/adapters/event-payload/src/tests/lib.rs b/crates/event-payload/src/tests/lib.rs similarity index 100% rename from crates/adapters/event-payload/src/tests/lib.rs rename to crates/event-payload/src/tests/lib.rs -- 2.49.1 From d7bac4aec347f9fe3b24bf47662d78db4e43505c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:27:13 +0100 Subject: [PATCH 018/104] test: classic route and openapi characterization --- Cargo.lock | 2 + crates/bootstrap/Cargo.toml | 2 + .../tests/fixtures/openapi_paths.txt | 69 ++++ .../fixtures/openapi_paths_federation_off.txt | 52 +++ crates/bootstrap/tests/route_smoke_test.rs | 385 ++++++++++++++++++ 5 files changed, 510 insertions(+) create mode 100644 crates/bootstrap/tests/fixtures/openapi_paths.txt create mode 100644 crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt create mode 100644 crates/bootstrap/tests/route_smoke_test.rs diff --git a/Cargo.lock b/Cargo.lock index 8010cb58..d4d6721a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -928,11 +928,13 @@ dependencies = [ "poster-fetcher", "presentation", "rss 0.1.0", + "serde_json", "sqlite", "sqlite-federation", "sqlx", "tmdb-enrichment", "tokio", + "tower", "tracing", "tracing-subscriber", "uuid", diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index 4690586d..95458630 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -45,3 +45,5 @@ sqlite-federation = { workspace = true, optional = true } chrono = { workspace = true } domain = { workspace = true, features = ["test-helpers"] } uuid = { workspace = true } +serde_json = { workspace = true } +tower = { version = "0.5", features = ["util"] } diff --git a/crates/bootstrap/tests/fixtures/openapi_paths.txt b/crates/bootstrap/tests/fixtures/openapi_paths.txt new file mode 100644 index 00000000..851c39a0 --- /dev/null +++ b/crates/bootstrap/tests/fixtures/openapi_paths.txt @@ -0,0 +1,69 @@ +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/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 diff --git a/crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt b/crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt new file mode 100644 index 00000000..ea1b1b54 --- /dev/null +++ b/crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt @@ -0,0 +1,52 @@ +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/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 diff --git a/crates/bootstrap/tests/route_smoke_test.rs b/crates/bootstrap/tests/route_smoke_test.rs new file mode 100644 index 00000000..44d49609 --- /dev/null +++ b/crates/bootstrap/tests/route_smoke_test.rs @@ -0,0 +1,385 @@ +//! Characterization of the classic HTML routes, feeds and the OpenAPI surface, +//! recorded before the HTTP layer moves. Statuses are what the app returns today. + +use axum::{ + Router, + body::Body, + http::{Method, Request, header}, +}; +use tower::ServiceExt; + +const PASSWORD: &str = "correct-horse-battery"; +const EMAIL: &str = "owner@example.com"; +const USERNAME: &str = "owner"; + +/// Public classic pages. `{id}`, `{username}`, `{movie}` are replaced by seeded values. +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", 404), + ("/wrapups/global/2025", 404), + ("/users/{id}/following-list", 303), + ("/users/{id}/followers-list", 303), + ("/posters/abc.jpg", 308), + ("/images/missing.jpg", 404), + ("/health", 200), +]; + +/// Pages that need the signed-in owner. +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), + // The registered owner is not an admin. + ("/admin/blocked-domains", 403), + ("/social/blocked", 200), +]; + +/// Without federation the collection URLs permanently redirect to the HTML lists. +#[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, + /// Cookie header for the signed-in owner (csrf + token). + owner_cookie: String, +} + +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(), + // Required to build; never called (seeding logs reviews with manual titles). + metadata: config::MetadataConfig { + tmdb_api_key: Some("test-key".into()), + ..Default::default() + }, + federation: Default::default(), + wrapup: Default::default(), + } +} + +async fn send(router: &Router, mut req: Request) -> (u16, axum::http::HeaderMap, String) { + let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap(); + req.extensions_mut() + .insert(axum::extract::ConnectInfo::(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(), + ) +} + +fn get(uri: &str, cookie: Option<&str>) -> Request { + 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() +} + +fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option { + 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) + }) +} + +fn json_request( + method: Method, + uri: &str, + token: Option<&str>, + body: serde_json::Value, +) -> Request { + 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() +} + +/// One user (registered through the classic form), one movie with a spoiler review, one goal. +async fn seed() -> Seed { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + let router = bootstrap::build_server_on(&test_config(), pool) + .await + .unwrap(); + + // Register through the form: GET for the csrf cookie, then POST with the token. + 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 owner_cookie = format!("csrf={csrf}; token={session}"); + + // Bearer token for the JSON API. + 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(); + let token = login["token"].as_str().unwrap().to_string(); + let user_id = login["user_id"].as_str().unwrap().to_string(); + + 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!( + status == 200 || status == 201, + "log review {status}: {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!( + status == 200 || status == 201, + "create goal {status}: {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 first = movies + .get("movies") + .and_then(|m| m.get(0)) + .or_else(|| movies.get(0)) + .or_else(|| movies.get("items").and_then(|m| m.get(0))) + .unwrap_or_else(|| panic!("no movie in {body}")); + let movie_id = first["id"] + .as_str() + .or_else(|| first["movie"]["id"].as_str()) + .unwrap_or_else(|| panic!("no movie id in {body}")) + .to_string(); + + Seed { + router, + user_id, + movie_id, + owner_cookie, + } +} + +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; + + // The owner embeds this widget in an iframe on their personal website. + 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 in [ + "/feed.rss", + "/users/{id}/feed.rss", + "/openapi.json", + "/docs/", + "/scalar", + ] { + let uri = expand(&seed, template); + let (status, _, _) = send(&seed.router, get(&uri, None)).await; + assert_eq!(status, 200, "{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}"); +} -- 2.49.1 From 2875594ed93b38f945c457dd94291cc5265c7664 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:30:01 +0100 Subject: [PATCH 019/104] test: seed ready wrapups, federation-off gate target --- .gitea/workflows/ci.yml | 3 + .github/workflows/ci.yml | 3 + Makefile | 7 +- crates/bootstrap/tests/route_smoke_test.rs | 83 +++++++++++++++------- 4 files changed, 68 insertions(+), 28 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index fa63712f..a8a1c209 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: - name: test run: cargo test + - name: test (federation off) + run: cargo test --workspace --no-default-features + # Layering guards. These live in the Makefile and were previously local-only, # so a PR could go green in CI while violating them. - name: guards diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1716751e..d0a39ecd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: - name: test run: cargo test + - name: test (federation off) + run: cargo test --workspace --no-default-features + # Layering guards. These live in the Makefile and were previously local-only, # so a PR could go green in CI while violating them. - name: guards diff --git a/Makefile b/Makefile index d5126704..293759fb 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := check -check: fmt-check clippy test check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free +check: fmt-check clippy test test-federation-off check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free @echo "✅ All checks passed" check-appcontext: @@ -99,6 +99,9 @@ clippy: test: cargo test --workspace +test-federation-off: + cargo test --workspace --no-default-features + fix: cargo fmt cargo clippy --fix --allow-dirty --allow-staged @@ -126,4 +129,4 @@ dev-spa: spa-dev: @npm --prefix spa run dev -.PHONY: check fmt fmt-check clippy test fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free +.PHONY: check fmt fmt-check clippy test test-federation-off fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free diff --git a/crates/bootstrap/tests/route_smoke_test.rs b/crates/bootstrap/tests/route_smoke_test.rs index 44d49609..de892ab4 100644 --- a/crates/bootstrap/tests/route_smoke_test.rs +++ b/crates/bootstrap/tests/route_smoke_test.rs @@ -26,8 +26,8 @@ const CLASSIC_VISITOR_PAGES: &[(&str, u16)] = &[ ("/logout", 303), ("/tags/drama", 307), ("/users/{id}/watchlist", 200), - ("/wrapups/{id}/2025", 404), - ("/wrapups/global/2025", 404), + ("/wrapups/{id}/2025", 200), + ("/wrapups/global/2025", 200), ("/users/{id}/following-list", 303), ("/users/{id}/followers-list", 303), ("/posters/abc.jpg", 308), @@ -173,7 +173,7 @@ async fn seed() -> Seed { .connect("sqlite::memory:") .await .unwrap(); - let router = bootstrap::build_server_on(&test_config(), pool) + let router = bootstrap::build_server_on(&test_config(), pool.clone()) .await .unwrap(); @@ -231,10 +231,7 @@ async fn seed() -> Seed { ), ) .await; - assert!( - status == 200 || status == 201, - "log review {status}: {body}" - ); + assert_eq!(status, 201, "log review: {body}"); let (status, _, body) = send( &router, @@ -246,10 +243,7 @@ async fn seed() -> Seed { ), ) .await; - assert!( - status == 200 || status == 201, - "create goal {status}: {body}" - ); + assert_eq!(status, 200, "create goal: {body}"); let (status, _, body) = send( &router, @@ -262,18 +256,14 @@ async fn seed() -> Seed { .await; assert_eq!(status, 200, "list movies: {body}"); let movies: serde_json::Value = serde_json::from_str(&body).unwrap(); - let first = movies - .get("movies") - .and_then(|m| m.get(0)) - .or_else(|| movies.get(0)) - .or_else(|| movies.get("items").and_then(|m| m.get(0))) - .unwrap_or_else(|| panic!("no movie in {body}")); - let movie_id = first["id"] + let movie_id = movies["items"][0]["id"] .as_str() - .or_else(|| first["movie"]["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, @@ -282,6 +272,35 @@ async fn seed() -> Seed { } } +/// Inserts a READY 2025 wrap-up record (user scope, or global when `user_id` is None), +/// the state the worker leaves behind after generating a report. +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) @@ -314,6 +333,16 @@ async fn every_classic_page_renders_for_a_visitor() { #[cfg(not(feature = "federation"))] check_table(&seed, FEDERATION_OFF_PAGES, None).await; + // A dropped wrapup route would also 404, so require the template itself. + 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}" + ); + } + // The owner embeds this widget in an iframe on their personal website. let (status, _, body) = send( &seed.router, @@ -339,16 +368,18 @@ async fn every_classic_page_renders_for_the_signed_in_owner() { #[tokio::test] async fn feeds_and_openapi_are_served() { let seed = seed().await; - for template in [ - "/feed.rss", - "/users/{id}/feed.rss", - "/openapi.json", - "/docs/", - "/scalar", + // `/docs` (what users type) redirects to the Swagger UI at `/docs/`. + for (template, expected) in [ + ("/feed.rss", 200), + ("/users/{id}/feed.rss", 200), + ("/openapi.json", 200), + ("/docs", 303), + ("/docs/", 200), + ("/scalar", 200), ] { let uri = expand(&seed, template); let (status, _, _) = send(&seed.router, get(&uri, None)).await; - assert_eq!(status, 200, "{uri}"); + assert_eq!(status, expected, "{uri}"); } } -- 2.49.1 From a931fb548f3025dc92f2b8477184526476f9f987 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:34:09 +0100 Subject: [PATCH 020/104] refactor: presentation becomes http-axum adapter, absorbs templates --- .env.example | 6 +- CONTRIBUTING.md | 4 +- Cargo.lock | 81 ++++++++----------- Cargo.toml | 6 +- Dockerfile | 3 +- Makefile | 14 ++-- README.md | 7 +- architecture.mmd | 3 +- .../http-axum}/Cargo.toml | 4 +- .../http-axum}/src/context.rs | 0 .../http-axum}/src/csrf.rs | 0 .../http-axum}/src/errors.rs | 0 .../http-axum}/src/extractors.rs | 0 .../http-axum}/src/forms.rs | 0 .../http-axum}/src/handlers/auth.rs | 2 +- .../http-axum}/src/handlers/diary.rs | 2 +- .../http-axum}/src/handlers/goals.rs | 0 .../http-axum}/src/handlers/helpers.rs | 0 .../http-axum}/src/handlers/images.rs | 0 .../http-axum}/src/handlers/import.rs | 8 +- .../http-axum}/src/handlers/insights.rs | 0 .../http-axum}/src/handlers/integrations.rs | 6 +- .../http-axum}/src/handlers/mod.rs | 0 .../http-axum}/src/handlers/movies.rs | 2 +- .../http-axum}/src/handlers/rss.rs | 0 .../http-axum}/src/handlers/search.rs | 0 .../http-axum}/src/handlers/social.rs | 14 ++-- .../http-axum}/src/handlers/tests/social.rs | 0 .../http-axum}/src/handlers/users.rs | 16 ++-- .../http-axum}/src/handlers/watchlist.rs | 2 +- .../http-axum}/src/handlers/webhook.rs | 0 .../http-axum}/src/handlers/wrapup.rs | 2 +- crates/adapters/http-axum/src/html/mod.rs | 1 + .../src/html/templates.rs} | 2 +- .../http-axum}/src/lib.rs | 1 + .../http-axum}/src/mappers/diary.rs | 0 .../http-axum}/src/mappers/import.rs | 2 +- .../http-axum}/src/mappers/integrations.rs | 2 +- .../http-axum}/src/mappers/mod.rs | 0 .../http-axum}/src/mappers/movies.rs | 0 .../http-axum}/src/mappers/search.rs | 0 .../http-axum}/src/mappers/users.rs | 2 +- .../http-axum}/src/mappers/watchlist.rs | 2 +- .../http-axum}/src/openapi/auth.rs | 0 .../http-axum}/src/openapi/diary.rs | 0 .../http-axum}/src/openapi/goals.rs | 0 .../http-axum}/src/openapi/import.rs | 0 .../http-axum}/src/openapi/insights.rs | 0 .../http-axum}/src/openapi/mod.rs | 0 .../http-axum}/src/openapi/movies.rs | 0 .../http-axum}/src/openapi/search.rs | 0 .../http-axum}/src/openapi/social.rs | 0 .../http-axum}/src/openapi/users.rs | 0 .../http-axum}/src/openapi/watchlist.rs | 0 .../http-axum}/src/openapi/webhook.rs | 0 .../http-axum}/src/openapi/wrapup.rs | 0 .../http-axum}/src/render.rs | 2 +- .../http-axum}/src/routes.rs | 0 .../http-axum}/src/state.rs | 0 .../http-axum}/src/tests/api_handlers.rs | 0 .../http-axum}/src/tests/context.rs | 0 .../http-axum}/src/tests/extractors.rs | 0 .../http-axum}/src/tests/forms.rs | 0 .../http-axum}/src/tests/mod.rs | 0 .../templates/_filter_controls.html | 0 .../templates/activity_feed.html | 0 .../templates/base.html | 0 .../templates/blocked_actors.html | 0 .../templates/blocked_domains.html | 0 .../templates/diary.html | 0 .../templates/embed_base.html | 0 .../templates/embed_profile.html | 0 .../templates/followers.html | 0 .../templates/following.html | 0 .../templates/import_mapping.html | 0 .../templates/import_preview.html | 0 .../templates/import_upload.html | 0 .../templates/integrations.html | 0 .../templates/login.html | 0 .../templates/movie_detail.html | 0 .../templates/new_review.html | 0 .../templates/profile.html | 0 .../templates/profile_settings.html | 0 .../templates/register.html | 0 .../templates/users.html | 0 .../templates/watch_queue.html | 0 .../templates/watchlist.html | 0 .../templates/wrapup.html | 0 crates/adapters/template-askama/Cargo.toml | 13 --- crates/bootstrap/Cargo.toml | 4 +- crates/bootstrap/src/server.rs | 4 +- crates/bootstrap/src/tracing_setup.rs | 2 +- crates/server/Cargo.toml | 2 +- crates/server/tests/api_test.rs | 4 +- 94 files changed, 99 insertions(+), 126 deletions(-) rename crates/{presentation => adapters/http-axum}/Cargo.toml (95%) rename crates/{presentation => adapters/http-axum}/src/context.rs (100%) rename crates/{presentation => adapters/http-axum}/src/csrf.rs (100%) rename crates/{presentation => adapters/http-axum}/src/errors.rs (100%) rename crates/{presentation => adapters/http-axum}/src/extractors.rs (100%) rename crates/{presentation => adapters/http-axum}/src/forms.rs (100%) rename crates/{presentation => adapters/http-axum}/src/handlers/auth.rs (99%) rename crates/{presentation => adapters/http-axum}/src/handlers/diary.rs (99%) rename crates/{presentation => adapters/http-axum}/src/handlers/goals.rs (100%) rename crates/{presentation => adapters/http-axum}/src/handlers/helpers.rs (100%) rename crates/{presentation => adapters/http-axum}/src/handlers/images.rs (100%) rename crates/{presentation => adapters/http-axum}/src/handlers/import.rs (99%) rename crates/{presentation => adapters/http-axum}/src/handlers/insights.rs (100%) rename crates/{presentation => adapters/http-axum}/src/handlers/integrations.rs (96%) rename crates/{presentation => adapters/http-axum}/src/handlers/mod.rs (100%) rename crates/{presentation => adapters/http-axum}/src/handlers/movies.rs (99%) rename crates/{presentation => adapters/http-axum}/src/handlers/rss.rs (100%) rename crates/{presentation => adapters/http-axum}/src/handlers/search.rs (100%) rename crates/{presentation => adapters/http-axum}/src/handlers/social.rs (98%) rename crates/{presentation => adapters/http-axum}/src/handlers/tests/social.rs (100%) rename crates/{presentation => adapters/http-axum}/src/handlers/users.rs (99%) rename crates/{presentation => adapters/http-axum}/src/handlers/watchlist.rs (99%) rename crates/{presentation => adapters/http-axum}/src/handlers/webhook.rs (100%) rename crates/{presentation => adapters/http-axum}/src/handlers/wrapup.rs (99%) create mode 100644 crates/adapters/http-axum/src/html/mod.rs rename crates/adapters/{template-askama/src/lib.rs => http-axum/src/html/templates.rs} (99%) rename crates/{presentation => adapters/http-axum}/src/lib.rs (93%) rename crates/{presentation => adapters/http-axum}/src/mappers/diary.rs (100%) rename crates/{presentation => adapters/http-axum}/src/mappers/import.rs (94%) rename crates/{presentation => adapters/http-axum}/src/mappers/integrations.rs (92%) rename crates/{presentation => adapters/http-axum}/src/mappers/mod.rs (100%) rename crates/{presentation => adapters/http-axum}/src/mappers/movies.rs (100%) rename crates/{presentation => adapters/http-axum}/src/mappers/search.rs (100%) rename crates/{presentation => adapters/http-axum}/src/mappers/users.rs (94%) rename crates/{presentation => adapters/http-axum}/src/mappers/watchlist.rs (97%) rename crates/{presentation => adapters/http-axum}/src/openapi/auth.rs (100%) rename crates/{presentation => adapters/http-axum}/src/openapi/diary.rs (100%) rename crates/{presentation => adapters/http-axum}/src/openapi/goals.rs (100%) rename crates/{presentation => adapters/http-axum}/src/openapi/import.rs (100%) rename crates/{presentation => adapters/http-axum}/src/openapi/insights.rs (100%) rename crates/{presentation => adapters/http-axum}/src/openapi/mod.rs (100%) rename crates/{presentation => adapters/http-axum}/src/openapi/movies.rs (100%) rename crates/{presentation => adapters/http-axum}/src/openapi/search.rs (100%) rename crates/{presentation => adapters/http-axum}/src/openapi/social.rs (100%) rename crates/{presentation => adapters/http-axum}/src/openapi/users.rs (100%) rename crates/{presentation => adapters/http-axum}/src/openapi/watchlist.rs (100%) rename crates/{presentation => adapters/http-axum}/src/openapi/webhook.rs (100%) rename crates/{presentation => adapters/http-axum}/src/openapi/wrapup.rs (100%) rename crates/{presentation => adapters/http-axum}/src/render.rs (79%) rename crates/{presentation => adapters/http-axum}/src/routes.rs (100%) rename crates/{presentation => adapters/http-axum}/src/state.rs (100%) rename crates/{presentation => adapters/http-axum}/src/tests/api_handlers.rs (100%) rename crates/{presentation => adapters/http-axum}/src/tests/context.rs (100%) rename crates/{presentation => adapters/http-axum}/src/tests/extractors.rs (100%) rename crates/{presentation => adapters/http-axum}/src/tests/forms.rs (100%) rename crates/{presentation => adapters/http-axum}/src/tests/mod.rs (100%) rename crates/adapters/{template-askama => http-axum}/templates/_filter_controls.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/activity_feed.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/base.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/blocked_actors.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/blocked_domains.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/diary.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/embed_base.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/embed_profile.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/followers.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/following.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/import_mapping.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/import_preview.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/import_upload.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/integrations.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/login.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/movie_detail.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/new_review.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/profile.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/profile_settings.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/register.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/users.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/watch_queue.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/watchlist.html (100%) rename crates/adapters/{template-askama => http-axum}/templates/wrapup.html (100%) delete mode 100644 crates/adapters/template-askama/Cargo.toml diff --git a/.env.example b/.env.example index 0fdad9f4..a859bf8b 100644 --- a/.env.example +++ b/.env.example @@ -45,11 +45,11 @@ ALLOW_REGISTRATION=true # PORT=3000 # RATE_LIMIT=60 # SECURE_COOKIES=true -# Handler-level logs come from the `presentation` crate; startup/wiring logs come +# Handler-level logs come from the `http_axum` crate; startup/wiring logs come # from `server`. Include both — `server=info` alone silently drops handler logs. # Crates not named here (adapters, etc.) still log warnings and errors unless -# you set a bare level yourself (e.g. `info,presentation=debug`). -# RUST_LOG=server=info,presentation=info,tower_http=info,worker=info +# you set a bare level yourself (e.g. `info,http_axum=debug`). +# RUST_LOG=server=info,http_axum=info,tower_http=info,worker=info # CORS (for SPA development only) # CORS_ORIGINS=http://localhost:5173 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47d7df88..33661d10 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,13 +43,13 @@ The project follows hexagonal (ports & adapters) architecture. See `architecture **Key rules:** - Presentation handlers never touch repositories directly — all domain logic goes through use cases in the `application` crate -- Application use cases return raw domain data — URL formatting, date display, and view model assembly belong in presentation mappers (`presentation/src/mappers/`) +- Application use cases return raw domain data — URL formatting, date display, and view model assembly belong in presentation mappers (`crates/adapters/http-axum/src/mappers/`) - Use cases called from presentation handlers take a `&FooDeps` struct (registered in `application::Deps`, built by `bootstrap::build_deps`) — never `&AppContext` itself, which `application` cannot even depend on. A few keep individual `Arc` params instead: `enrich_movie` and `request_enrichment` because they are called from adapters rather than handlers, and `diary::log_review` because its one extra dependency comes from `Services` rather than a repository. `wrapup::compute`, `import::cleanup` and `integrations::cleanup` also take individual params, but they are only ever called from jobs. See ADR-0007 for the exact list and why each is legitimate ``` domain → pure types, traits (ports), zero deps application → use cases, orchestration -presentation → Axum handlers, routes, OpenAPI +http-axum → Axum handlers, routes, OpenAPI worker → event consumer, background jobs adapters/* → implements domain ports (sqlite, AP, etc.) spa/ → React SPA (TanStack Router + shadcn/ui) diff --git a/Cargo.lock b/Cargo.lock index d4d6721a..754ef1e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -919,6 +919,7 @@ dependencies = [ "config", "domain", "export", + "http-axum", "image-converter", "importer", "media-server", @@ -926,7 +927,6 @@ dependencies = [ "nats", "object-storage", "poster-fetcher", - "presentation", "rss 0.1.0", "serde_json", "sqlite", @@ -2409,6 +2409,39 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-axum" +version = "0.1.0" +dependencies = [ + "anyhow", + "api-types", + "application", + "askama", + "async-trait", + "axum", + "axum-governor", + "bootstrap", + "bytes", + "chrono", + "chrono-tz", + "domain", + "dotenvy", + "futures", + "infer", + "percent-encoding", + "serde", + "serde_json", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "utoipa", + "utoipa-scalar", + "utoipa-swagger-ui", + "uuid", +] + [[package]] name = "http-body" version = "1.0.1" @@ -3983,39 +4016,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "presentation" -version = "0.1.0" -dependencies = [ - "anyhow", - "api-types", - "application", - "async-trait", - "axum", - "axum-governor", - "bootstrap", - "bytes", - "chrono", - "chrono-tz", - "domain", - "dotenvy", - "futures", - "infer", - "percent-encoding", - "serde", - "serde_json", - "template-askama", - "tokio", - "tower", - "tower-http", - "tracing", - "tracing-subscriber", - "utoipa", - "utoipa-scalar", - "utoipa-swagger-ui", - "uuid", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -4938,8 +4938,8 @@ dependencies = [ "domain", "dotenvy", "futures", + "http-axum", "http-body-util", - "presentation", "rss 0.1.0", "serde_json", "sqlite", @@ -5610,17 +5610,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "template-askama" -version = "0.1.0" -dependencies = [ - "api-types", - "askama", - "chrono", - "domain", - "uuid", -] - [[package]] name = "tendril" version = "0.4.3" diff --git a/Cargo.toml b/Cargo.toml index 8c73152e..ead06304 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,6 @@ members = [ "crates/adapters/rss", "crates/adapters/sqlite", "crates/adapters/sqlite-federation", - "crates/adapters/template-askama", "crates/adapters/activitypub", "crates/adapters/export", "crates/event-payload", @@ -19,7 +18,7 @@ members = [ "crates/adapters/image-converter", "crates/domain", "crates/bootstrap", - "crates/presentation", + "crates/adapters/http-axum", "crates/server", "crates/tui", "crates/worker", @@ -68,7 +67,7 @@ domain = { path = "crates/domain" } tmdb-enrichment = { path = "crates/adapters/tmdb-enrichment" } application = { path = "crates/application" } bootstrap = { path = "crates/bootstrap" } -presentation = { path = "crates/presentation" } +http-axum = { path = "crates/adapters/http-axum" } auth = { path = "crates/adapters/auth" } metadata = { path = "crates/adapters/metadata" } poster-fetcher = { path = "crates/adapters/poster-fetcher" } @@ -78,7 +77,6 @@ rss = { path = "crates/adapters/rss" } export = { path = "crates/adapters/export" } sqlite = { path = "crates/adapters/sqlite" } sqlite-federation = { path = "crates/adapters/sqlite-federation" } -template-askama = { path = "crates/adapters/template-askama" } activitypub = { path = "crates/adapters/activitypub" } event-payload = { path = "crates/event-payload" } media-server = { path = "crates/adapters/media-server" } diff --git a/Dockerfile b/Dockerfile index 75560c74..1b8d79b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,13 +32,12 @@ COPY crates/adapters/media-server/Cargo.toml crates/adapters/media-server/C COPY crates/adapters/rss/Cargo.toml crates/adapters/rss/Cargo.toml COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml COPY crates/adapters/sqlite-federation/Cargo.toml crates/adapters/sqlite-federation/Cargo.toml -COPY crates/adapters/template-askama/Cargo.toml crates/adapters/template-askama/Cargo.toml COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml COPY crates/application/Cargo.toml crates/application/Cargo.toml COPY crates/adapters/tmdb-enrichment/Cargo.toml crates/adapters/tmdb-enrichment/Cargo.toml COPY crates/domain/Cargo.toml crates/domain/Cargo.toml COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml -COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml +COPY crates/adapters/http-axum/Cargo.toml crates/adapters/http-axum/Cargo.toml COPY crates/server/Cargo.toml crates/server/Cargo.toml COPY crates/tui/Cargo.toml crates/tui/Cargo.toml COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/Cargo.toml diff --git a/Makefile b/Makefile index 293759fb..ba3f5966 100644 --- a/Makefile +++ b/Makefile @@ -12,26 +12,26 @@ check-appcontext: @echo "✅ No AppContext in application crate" check-handler-deps: - @if grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/presentation/src --include="*.rs" --exclude-dir=tests | grep -q .; then \ + @if grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests | grep -q .; then \ echo "❌ hand-built deps struct found in presentation:"; \ - grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/presentation/src --include="*.rs" --exclude-dir=tests; \ + grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests; \ exit 1; \ fi @echo "✅ No hand-built deps structs in presentation" check-handler-repos: - @if grep -rn "app_ctx\.repos" crates/presentation/src --include="*.rs" --exclude-dir=tests | grep -q .; then \ + @if grep -rn "app_ctx\.repos" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests | grep -q .; then \ echo "❌ app_ctx.repos referenced in presentation:"; \ - grep -rn "app_ctx\.repos" crates/presentation/src --include="*.rs" --exclude-dir=tests; \ + grep -rn "app_ctx\.repos" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests; \ exit 1; \ fi @echo "✅ No app_ctx.repos references in presentation" -PRESENTATION_ALLOWED_ADAPTERS := template-askama +PRESENTATION_ALLOWED_ADAPTERS := check-presentation-adapters: - @deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/presentation/Cargo.toml \ + @deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/adapters/http-axum/Cargo.toml \ | sed -n 's/^\([A-Za-z0-9_-]\{1,\}\)[[:space:]]*=.*/\1/p'); \ bad=""; \ for a in $$(ls crates/adapters); do \ @@ -43,7 +43,7 @@ check-presentation-adapters: echo " presentation may name renderers, not reachers — see ADR-0008."; \ exit 1; \ fi; \ - echo "✅ presentation depends on no adapter crate but $(PRESENTATION_ALLOWED_ADAPTERS)" + echo "✅ http-axum depends on no other adapter crate" check-federation-off-social: diff --git a/README.md b/README.md index e57bd204..bc3bf316 100644 --- a/README.md +++ b/README.md @@ -90,12 +90,12 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth Hexagonal (Ports & Adapters) with Domain-Driven Design: ``` -api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by presentation, tui, and template adapters +api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by http-axum and tui config — typed configuration (defaults, config.toml, legacy env names); loaded once by the binaries bootstrap — composition root: the only crate that names adapters; builds the server router and the worker service domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, SocialCommand/SocialQuery, ImageFetcher, RssFeedRenderer), no external deps except serde application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic; modules: auth, diary, goals, import, integrations, movies, person, search, social, users, watchlist, wrapup -presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving; library crate, no binary of its own +adapters/http-axum — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving; library crate, no binary of its own server — thin HTTP binary (dotenv, config, bootstrap::build_server, axum::serve); features (federation, nats) forward to bootstrap worker — thin worker binary (bootstrap::build_worker, run until ctrl-c) adapters/ @@ -106,7 +106,6 @@ adapters/ object-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage image-converter — optional background worker: converts stored images to AVIF or WebP; backfills existing images via a 24h periodic job tmdb-enrichment — TMDb HTTP client implementing MovieEnrichmentClient and PersonEnrichmentClient; event handlers (MovieEnrichmentHandler, PersonEnrichmentHandler) live in the application layer - template-askama — Askama HTML rendering rss — RSS/Atom feed generation export — CSV and JSON diary serialization importer — CSV/TSV/JSON/XLSX parser and column mapper for bulk import @@ -115,7 +114,7 @@ adapters/ nats — NATS Core / JetStream event publisher and consumer activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals sqlite-federation — SQLite-backed federation repository -tui — terminal UI client (ratatui); shares api-types with presentation for typed API access +tui — terminal UI client (ratatui); shares api-types with http-axum for typed API access spa/ — React SPA (TanStack Router + shadcn/ui + Vite); served at /app/ by the backend ``` diff --git a/architecture.mmd b/architecture.mmd index 4e7cc9bd..d8ef6cb0 100644 --- a/architecture.mmd +++ b/architecture.mmd @@ -3,7 +3,7 @@ title: Movies Diary — Hexagonal Architecture --- graph TB subgraph Binaries["Binaries (Composition Root)"] - WEB["presentation
Axum web server
Routes, Handlers, Mappers"] + WEB["http-axum
Axum web server + HTML templates
Routes, Handlers, Mappers"] WORKER["worker
Event consumer
Concurrent dispatch, graceful shutdown"] TUI["tui
Terminal UI"] end @@ -110,7 +110,6 @@ graph TB A_POSTER_SYNC["poster-sync"] end subgraph Presentation["Presentation Helpers"] - A_TEMPLATE["template-askama
HTML templates"] A_RSS["rss
Feed generation"] A_EXPORT["export
CSV/JSON diary"] A_IMPORT["importer
CSV/JSON/XLSX parser"] diff --git a/crates/presentation/Cargo.toml b/crates/adapters/http-axum/Cargo.toml similarity index 95% rename from crates/presentation/Cargo.toml rename to crates/adapters/http-axum/Cargo.toml index bd25a45a..77e1df8e 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/adapters/http-axum/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "presentation" +name = "http-axum" version = "0.1.0" edition.workspace = true description = "Self-hosted movie diary with REST API and ActivityPub federation" @@ -33,7 +33,7 @@ futures = { workspace = true } api-types = { workspace = true } domain = { workspace = true } application = { workspace = true } -template-askama = { workspace = true } +askama = { version = "0.16.0" } utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] } utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false } utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } diff --git a/crates/presentation/src/context.rs b/crates/adapters/http-axum/src/context.rs similarity index 100% rename from crates/presentation/src/context.rs rename to crates/adapters/http-axum/src/context.rs diff --git a/crates/presentation/src/csrf.rs b/crates/adapters/http-axum/src/csrf.rs similarity index 100% rename from crates/presentation/src/csrf.rs rename to crates/adapters/http-axum/src/csrf.rs diff --git a/crates/presentation/src/errors.rs b/crates/adapters/http-axum/src/errors.rs similarity index 100% rename from crates/presentation/src/errors.rs rename to crates/adapters/http-axum/src/errors.rs diff --git a/crates/presentation/src/extractors.rs b/crates/adapters/http-axum/src/extractors.rs similarity index 100% rename from crates/presentation/src/extractors.rs rename to crates/adapters/http-axum/src/extractors.rs diff --git a/crates/presentation/src/forms.rs b/crates/adapters/http-axum/src/forms.rs similarity index 100% rename from crates/presentation/src/forms.rs rename to crates/adapters/http-axum/src/forms.rs diff --git a/crates/presentation/src/handlers/auth.rs b/crates/adapters/http-axum/src/handlers/auth.rs similarity index 99% rename from crates/presentation/src/handlers/auth.rs rename to crates/adapters/http-axum/src/handlers/auth.rs index 0cc2a4e4..e6754dfb 100644 --- a/crates/presentation/src/handlers/auth.rs +++ b/crates/adapters/http-axum/src/handlers/auth.rs @@ -10,6 +10,7 @@ use application::auth::{ commands::RegisterCommand, login as login_uc, queries::LoginCommand, register as register_uc, }; +use crate::html::templates::{LoginTemplate, RegisterTemplate}; use crate::{ csrf::{CsrfToken, secure_flag}, errors::ApiError, @@ -21,7 +22,6 @@ use api_types::HtmlPageContext; use api_types::{ LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest, }; -use template_askama::{LoginTemplate, RegisterTemplate}; // ── HTML helpers ───────────────────────────────────────────────────────────── diff --git a/crates/presentation/src/handlers/diary.rs b/crates/adapters/http-axum/src/handlers/diary.rs similarity index 99% rename from crates/presentation/src/handlers/diary.rs rename to crates/adapters/http-axum/src/handlers/diary.rs index 1327b3cf..f374b7ba 100644 --- a/crates/presentation/src/handlers/diary.rs +++ b/crates/adapters/http-axum/src/handlers/diary.rs @@ -12,6 +12,7 @@ use application::diary::{ queries::GetActivityFeedQuery, }; +use crate::html::templates::{ActivityFeedTemplate, NewReviewTemplate, build_page_items}; use crate::{ csrf::CsrfToken, errors::ApiError, @@ -24,7 +25,6 @@ use api_types::{ ActivityFeedQueryParams, ActivityFeedResponse, DiaryQueryParams, DiaryResponse, EditReviewRequest, ExportQueryParams, LogReviewRequest, }; -use template_askama::{ActivityFeedTemplate, NewReviewTemplate, build_page_items}; use super::helpers::{build_export_response, build_page_context, encode_domain_error}; diff --git a/crates/presentation/src/handlers/goals.rs b/crates/adapters/http-axum/src/handlers/goals.rs similarity index 100% rename from crates/presentation/src/handlers/goals.rs rename to crates/adapters/http-axum/src/handlers/goals.rs diff --git a/crates/presentation/src/handlers/helpers.rs b/crates/adapters/http-axum/src/handlers/helpers.rs similarity index 100% rename from crates/presentation/src/handlers/helpers.rs rename to crates/adapters/http-axum/src/handlers/helpers.rs diff --git a/crates/presentation/src/handlers/images.rs b/crates/adapters/http-axum/src/handlers/images.rs similarity index 100% rename from crates/presentation/src/handlers/images.rs rename to crates/adapters/http-axum/src/handlers/images.rs diff --git a/crates/presentation/src/handlers/import.rs b/crates/adapters/http-axum/src/handlers/import.rs similarity index 99% rename from crates/presentation/src/handlers/import.rs rename to crates/adapters/http-axum/src/handlers/import.rs index 33061e0f..af372f84 100644 --- a/crates/presentation/src/handlers/import.rs +++ b/crates/adapters/http-axum/src/handlers/import.rs @@ -11,6 +11,10 @@ use axum::{ use serde::Deserialize; use std::collections::HashMap; +use crate::html::templates::{ + ImportMappingTemplate, ImportPreviewRow, ImportPreviewTemplate, ImportProfileView, + ImportUploadTemplate, +}; use crate::render::render_page; use application::import::{ apply_mapping as apply_import_mapping, apply_profile_and_map, @@ -28,10 +32,6 @@ use domain::models::{ import::{DomainField, Transform}, }; use domain::value_objects::ImportSessionId; -use template_askama::{ - ImportMappingTemplate, ImportPreviewRow, ImportPreviewTemplate, ImportProfileView, - ImportUploadTemplate, -}; use crate::{ csrf::CsrfToken, diff --git a/crates/presentation/src/handlers/insights.rs b/crates/adapters/http-axum/src/handlers/insights.rs similarity index 100% rename from crates/presentation/src/handlers/insights.rs rename to crates/adapters/http-axum/src/handlers/insights.rs diff --git a/crates/presentation/src/handlers/integrations.rs b/crates/adapters/http-axum/src/handlers/integrations.rs similarity index 96% rename from crates/presentation/src/handlers/integrations.rs rename to crates/adapters/http-axum/src/handlers/integrations.rs index 6325461b..2d6aab44 100644 --- a/crates/presentation/src/handlers/integrations.rs +++ b/crates/adapters/http-axum/src/handlers/integrations.rs @@ -18,11 +18,11 @@ use application::integrations::{ revoke_token as revoke_webhook_token, }; +use crate::html::templates::{IntegrationsTemplate, WatchQueueTemplate}; use crate::{ csrf::CsrfToken, extractors::RequiredCookieUser, forms::ErrorQuery, render::render_page, state::AppState, }; -use template_askama::{IntegrationsTemplate, WatchQueueTemplate}; use super::helpers::{build_page_context, encode_domain_error}; @@ -46,7 +46,7 @@ pub async fn get_integrations_page( .await .unwrap_or_default(); - let token_views: Vec = tokens + let token_views: Vec = tokens .iter() .map(crate::mappers::integrations::webhook_token_view) .collect(); @@ -146,7 +146,7 @@ pub async fn get_watch_queue_page( .await .unwrap_or_default(); - let entries: Vec = events + let entries: Vec = events .iter() .map(crate::mappers::integrations::watch_queue_entry) .collect(); diff --git a/crates/presentation/src/handlers/mod.rs b/crates/adapters/http-axum/src/handlers/mod.rs similarity index 100% rename from crates/presentation/src/handlers/mod.rs rename to crates/adapters/http-axum/src/handlers/mod.rs diff --git a/crates/presentation/src/handlers/movies.rs b/crates/adapters/http-axum/src/handlers/movies.rs similarity index 99% rename from crates/presentation/src/handlers/movies.rs rename to crates/adapters/http-axum/src/handlers/movies.rs index ab54d045..89f74ba0 100644 --- a/crates/presentation/src/handlers/movies.rs +++ b/crates/adapters/http-axum/src/handlers/movies.rs @@ -17,6 +17,7 @@ use application::{ }; use domain::services::review_history::Trend; +use crate::html::templates::MovieDetailTemplate; use crate::{ csrf::CsrfToken, errors::ApiError, @@ -29,7 +30,6 @@ use api_types::{ MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, }; -use template_askama::MovieDetailTemplate; use super::helpers::build_page_context; diff --git a/crates/presentation/src/handlers/rss.rs b/crates/adapters/http-axum/src/handlers/rss.rs similarity index 100% rename from crates/presentation/src/handlers/rss.rs rename to crates/adapters/http-axum/src/handlers/rss.rs diff --git a/crates/presentation/src/handlers/search.rs b/crates/adapters/http-axum/src/handlers/search.rs similarity index 100% rename from crates/presentation/src/handlers/search.rs rename to crates/adapters/http-axum/src/handlers/search.rs diff --git a/crates/presentation/src/handlers/social.rs b/crates/adapters/http-axum/src/handlers/social.rs similarity index 98% rename from crates/presentation/src/handlers/social.rs rename to crates/adapters/http-axum/src/handlers/social.rs index 4256d292..b311b686 100644 --- a/crates/presentation/src/handlers/social.rs +++ b/crates/adapters/http-axum/src/handlers/social.rs @@ -6,6 +6,9 @@ use axum::{ }; use uuid::Uuid; +#[cfg(feature = "federation")] +use crate::html::templates::{BlockedActorsTemplate, BlockedDomainsTemplate}; +use crate::html::templates::{FollowersTemplate, FollowingTemplate, RemoteActorData}; use crate::{ csrf::CsrfToken, errors::ApiError, @@ -26,9 +29,6 @@ use api_types::{ #[cfg(feature = "federation")] use api_types::{AddBlockedDomainRequest, BlockedActorResponse, BlockedDomainResponse}; use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor, SocialIdentity}; -#[cfg(feature = "federation")] -use template_askama::{BlockedActorsTemplate, BlockedDomainsTemplate}; -use template_askama::{FollowersTemplate, FollowingTemplate, RemoteActorData}; use super::helpers::{build_page_context, encode_domain_error}; @@ -917,9 +917,9 @@ pub async fn get_blocked_domains_page( ctx.canonical_url = format!("{}/admin/blocked-domains", state.app_ctx.config.base_url); match state.app_ctx.ap_blocklist.get_blocked_domains().await { Ok(domains) => { - let entries: Vec = domains + let entries: Vec = domains .into_iter() - .map(|d| template_askama::BlockedDomainEntry { + .map(|d| crate::html::templates::BlockedDomainEntry { domain: d.domain, reason: d.reason, blocked_at: d.blocked_at, @@ -1008,9 +1008,9 @@ pub async fn get_blocked_actors_page( .await { Ok(blocked) => { - let entries: Vec = blocked + let entries: Vec = blocked .into_iter() - .map(|a| template_askama::BlockedActorEntry { + .map(|a| crate::html::templates::BlockedActorEntry { url: instance.actor_url_of(&a.identity), handle: a.handle, display_name: a.display_name, diff --git a/crates/presentation/src/handlers/tests/social.rs b/crates/adapters/http-axum/src/handlers/tests/social.rs similarity index 100% rename from crates/presentation/src/handlers/tests/social.rs rename to crates/adapters/http-axum/src/handlers/tests/social.rs diff --git a/crates/presentation/src/handlers/users.rs b/crates/adapters/http-axum/src/handlers/users.rs similarity index 99% rename from crates/presentation/src/handlers/users.rs rename to crates/adapters/http-axum/src/handlers/users.rs index ba5cc638..c6f8034d 100644 --- a/crates/presentation/src/handlers/users.rs +++ b/crates/adapters/http-axum/src/handlers/users.rs @@ -16,6 +16,11 @@ use application::users::{ }; use domain::{errors::DomainError, value_objects::Username}; +use crate::html::templates::{ + EmbedProfileTemplate, MonthlyRatingRow, ProfileSettingsTemplate, ProfileTemplate, + RemoteActorData, RemoteActorDisplay, UserSummaryView, UsersTemplate, bar_height_px, + build_heatmap, build_page_items, +}; use crate::{ csrf::CsrfToken, errors::ApiError, @@ -28,11 +33,6 @@ use api_types::{ ProfileResponse, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, WatchMediumStatDto, }; -use template_askama::{ - EmbedProfileTemplate, MonthlyRatingRow, ProfileSettingsTemplate, ProfileTemplate, - RemoteActorData, RemoteActorDisplay, UserSummaryView, UsersTemplate, bar_height_px, - build_heatmap, build_page_items, -}; use super::goals::goal_with_progress_to_dto; use super::helpers::build_page_context; @@ -502,7 +502,7 @@ struct PaginationInfo { offset: u32, has_more: bool, limit: u32, - page_items: Vec, + page_items: Vec, } fn compute_pagination( @@ -571,7 +571,7 @@ fn build_monthly_rating_rows<'a>( async fn fetch_profile_goals( state: &AppState, user_id: Uuid, -) -> Vec { +) -> Vec { let goals_list = application::goals::list::execute( &state.app_ctx.deps.goals.query, application::goals::queries::ListGoalsQuery { user_id }, @@ -580,7 +580,7 @@ async fn fetch_profile_goals( .unwrap_or_default(); goals_list .iter() - .map(|g| template_askama::GoalViewData { + .map(|g| crate::html::templates::GoalViewData { year: g.goal.year(), target_count: g.goal.target_count(), current_count: g.current_count, diff --git a/crates/presentation/src/handlers/watchlist.rs b/crates/adapters/http-axum/src/handlers/watchlist.rs similarity index 99% rename from crates/presentation/src/handlers/watchlist.rs rename to crates/adapters/http-axum/src/handlers/watchlist.rs index 0f360a41..9854e60f 100644 --- a/crates/presentation/src/handlers/watchlist.rs +++ b/crates/adapters/http-axum/src/handlers/watchlist.rs @@ -18,6 +18,7 @@ use application::{ }; use domain::errors::DomainError; +use crate::html::templates::WatchlistTemplate; use crate::{ csrf::CsrfToken, errors::ApiError, @@ -29,7 +30,6 @@ use api_types::{ AddToWatchlistRequest, PaginationQueryParams, WatchlistEntryDto, WatchlistResponse, WatchlistStatusResponse, }; -use template_askama::WatchlistTemplate; use super::helpers::{build_page_context, encode_error}; diff --git a/crates/presentation/src/handlers/webhook.rs b/crates/adapters/http-axum/src/handlers/webhook.rs similarity index 100% rename from crates/presentation/src/handlers/webhook.rs rename to crates/adapters/http-axum/src/handlers/webhook.rs diff --git a/crates/presentation/src/handlers/wrapup.rs b/crates/adapters/http-axum/src/handlers/wrapup.rs similarity index 99% rename from crates/presentation/src/handlers/wrapup.rs rename to crates/adapters/http-axum/src/handlers/wrapup.rs index 0bb6545a..026dc598 100644 --- a/crates/presentation/src/handlers/wrapup.rs +++ b/crates/adapters/http-axum/src/handlers/wrapup.rs @@ -219,7 +219,7 @@ fn render_wrapup( .take(8) .map(|g| g.count as f64 / genre_max as f64 * 100.0) .collect(); - let tmpl = template_askama::WrapUpPageTemplate { + let tmpl = crate::html::templates::WrapUpPageTemplate { ctx, report, year_label: year.to_string(), diff --git a/crates/adapters/http-axum/src/html/mod.rs b/crates/adapters/http-axum/src/html/mod.rs new file mode 100644 index 00000000..b9a16841 --- /dev/null +++ b/crates/adapters/http-axum/src/html/mod.rs @@ -0,0 +1 @@ +pub mod templates; diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/http-axum/src/html/templates.rs similarity index 99% rename from crates/adapters/template-askama/src/lib.rs rename to crates/adapters/http-axum/src/html/templates.rs index 5a2b37b6..77e140d6 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/http-axum/src/html/templates.rs @@ -136,7 +136,7 @@ pub struct MovieDetailTemplate<'a> { pub struct WatchlistTemplate<'a> { pub ctx: &'a HtmlPageContext, pub owner_id: uuid::Uuid, - pub display_entries: &'a [crate::WatchlistDisplayEntry], + pub display_entries: &'a [WatchlistDisplayEntry], pub current_offset: u32, pub has_more: bool, pub limit: u32, diff --git a/crates/presentation/src/lib.rs b/crates/adapters/http-axum/src/lib.rs similarity index 93% rename from crates/presentation/src/lib.rs rename to crates/adapters/http-axum/src/lib.rs index 79ba5c97..f7858366 100644 --- a/crates/presentation/src/lib.rs +++ b/crates/adapters/http-axum/src/lib.rs @@ -4,6 +4,7 @@ pub mod errors; pub mod extractors; pub mod forms; pub mod handlers; +pub mod html; pub mod mappers; pub mod openapi; pub mod render; diff --git a/crates/presentation/src/mappers/diary.rs b/crates/adapters/http-axum/src/mappers/diary.rs similarity index 100% rename from crates/presentation/src/mappers/diary.rs rename to crates/adapters/http-axum/src/mappers/diary.rs diff --git a/crates/presentation/src/mappers/import.rs b/crates/adapters/http-axum/src/mappers/import.rs similarity index 94% rename from crates/presentation/src/mappers/import.rs rename to crates/adapters/http-axum/src/mappers/import.rs index f5ce7022..e1230b46 100644 --- a/crates/presentation/src/mappers/import.rs +++ b/crates/adapters/http-axum/src/mappers/import.rs @@ -1,6 +1,6 @@ +use crate::html::templates::{ImportPreviewRow, ImportRowStatus}; use domain::models::AnnotatedRow; use domain::models::import::RowResult; -use template_askama::{ImportPreviewRow, ImportRowStatus}; pub fn annotated_to_preview_row(idx: usize, annotated: &AnnotatedRow) -> ImportPreviewRow { match &annotated.result { diff --git a/crates/presentation/src/mappers/integrations.rs b/crates/adapters/http-axum/src/mappers/integrations.rs similarity index 92% rename from crates/presentation/src/mappers/integrations.rs rename to crates/adapters/http-axum/src/mappers/integrations.rs index f2477fb7..4282eb86 100644 --- a/crates/presentation/src/mappers/integrations.rs +++ b/crates/adapters/http-axum/src/mappers/integrations.rs @@ -1,5 +1,5 @@ +use crate::html::templates::{WatchQueueDisplayEntry, WebhookTokenView}; use domain::models::{WatchEvent, WebhookToken}; -use template_askama::{WatchQueueDisplayEntry, WebhookTokenView}; pub fn webhook_token_view(t: &WebhookToken) -> WebhookTokenView { WebhookTokenView { diff --git a/crates/presentation/src/mappers/mod.rs b/crates/adapters/http-axum/src/mappers/mod.rs similarity index 100% rename from crates/presentation/src/mappers/mod.rs rename to crates/adapters/http-axum/src/mappers/mod.rs diff --git a/crates/presentation/src/mappers/movies.rs b/crates/adapters/http-axum/src/mappers/movies.rs similarity index 100% rename from crates/presentation/src/mappers/movies.rs rename to crates/adapters/http-axum/src/mappers/movies.rs diff --git a/crates/presentation/src/mappers/search.rs b/crates/adapters/http-axum/src/mappers/search.rs similarity index 100% rename from crates/presentation/src/mappers/search.rs rename to crates/adapters/http-axum/src/mappers/search.rs diff --git a/crates/presentation/src/mappers/users.rs b/crates/adapters/http-axum/src/mappers/users.rs similarity index 94% rename from crates/presentation/src/mappers/users.rs rename to crates/adapters/http-axum/src/mappers/users.rs index ee3c0fd8..b9952a9c 100644 --- a/crates/presentation/src/mappers/users.rs +++ b/crates/adapters/http-axum/src/mappers/users.rs @@ -1,7 +1,7 @@ +use crate::html::templates::{RemoteActorData, RemoteActorDisplay, UserSummaryView}; use application::users::get_local_profile::PendingFollowerView; use domain::models::RemoteActorInfo; use domain::models::UserSummary; -use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView}; pub fn user_summary_view(u: &UserSummary) -> UserSummaryView { let name = u diff --git a/crates/presentation/src/mappers/watchlist.rs b/crates/adapters/http-axum/src/mappers/watchlist.rs similarity index 97% rename from crates/presentation/src/mappers/watchlist.rs rename to crates/adapters/http-axum/src/mappers/watchlist.rs index 9ff5b830..00752b47 100644 --- a/crates/presentation/src/mappers/watchlist.rs +++ b/crates/adapters/http-axum/src/mappers/watchlist.rs @@ -1,5 +1,5 @@ +use crate::html::templates::WatchlistDisplayEntry; use domain::models::{RemoteWatchlistEntry, WatchlistWithMovie, collections::Paginated}; -use template_askama::WatchlistDisplayEntry; pub struct WatchlistPageResult { pub display_entries: Vec, diff --git a/crates/presentation/src/openapi/auth.rs b/crates/adapters/http-axum/src/openapi/auth.rs similarity index 100% rename from crates/presentation/src/openapi/auth.rs rename to crates/adapters/http-axum/src/openapi/auth.rs diff --git a/crates/presentation/src/openapi/diary.rs b/crates/adapters/http-axum/src/openapi/diary.rs similarity index 100% rename from crates/presentation/src/openapi/diary.rs rename to crates/adapters/http-axum/src/openapi/diary.rs diff --git a/crates/presentation/src/openapi/goals.rs b/crates/adapters/http-axum/src/openapi/goals.rs similarity index 100% rename from crates/presentation/src/openapi/goals.rs rename to crates/adapters/http-axum/src/openapi/goals.rs diff --git a/crates/presentation/src/openapi/import.rs b/crates/adapters/http-axum/src/openapi/import.rs similarity index 100% rename from crates/presentation/src/openapi/import.rs rename to crates/adapters/http-axum/src/openapi/import.rs diff --git a/crates/presentation/src/openapi/insights.rs b/crates/adapters/http-axum/src/openapi/insights.rs similarity index 100% rename from crates/presentation/src/openapi/insights.rs rename to crates/adapters/http-axum/src/openapi/insights.rs diff --git a/crates/presentation/src/openapi/mod.rs b/crates/adapters/http-axum/src/openapi/mod.rs similarity index 100% rename from crates/presentation/src/openapi/mod.rs rename to crates/adapters/http-axum/src/openapi/mod.rs diff --git a/crates/presentation/src/openapi/movies.rs b/crates/adapters/http-axum/src/openapi/movies.rs similarity index 100% rename from crates/presentation/src/openapi/movies.rs rename to crates/adapters/http-axum/src/openapi/movies.rs diff --git a/crates/presentation/src/openapi/search.rs b/crates/adapters/http-axum/src/openapi/search.rs similarity index 100% rename from crates/presentation/src/openapi/search.rs rename to crates/adapters/http-axum/src/openapi/search.rs diff --git a/crates/presentation/src/openapi/social.rs b/crates/adapters/http-axum/src/openapi/social.rs similarity index 100% rename from crates/presentation/src/openapi/social.rs rename to crates/adapters/http-axum/src/openapi/social.rs diff --git a/crates/presentation/src/openapi/users.rs b/crates/adapters/http-axum/src/openapi/users.rs similarity index 100% rename from crates/presentation/src/openapi/users.rs rename to crates/adapters/http-axum/src/openapi/users.rs diff --git a/crates/presentation/src/openapi/watchlist.rs b/crates/adapters/http-axum/src/openapi/watchlist.rs similarity index 100% rename from crates/presentation/src/openapi/watchlist.rs rename to crates/adapters/http-axum/src/openapi/watchlist.rs diff --git a/crates/presentation/src/openapi/webhook.rs b/crates/adapters/http-axum/src/openapi/webhook.rs similarity index 100% rename from crates/presentation/src/openapi/webhook.rs rename to crates/adapters/http-axum/src/openapi/webhook.rs diff --git a/crates/presentation/src/openapi/wrapup.rs b/crates/adapters/http-axum/src/openapi/wrapup.rs similarity index 100% rename from crates/presentation/src/openapi/wrapup.rs rename to crates/adapters/http-axum/src/openapi/wrapup.rs diff --git a/crates/presentation/src/render.rs b/crates/adapters/http-axum/src/render.rs similarity index 79% rename from crates/presentation/src/render.rs rename to crates/adapters/http-axum/src/render.rs index b54e5220..6cc026eb 100644 --- a/crates/presentation/src/render.rs +++ b/crates/adapters/http-axum/src/render.rs @@ -3,7 +3,7 @@ use axum::{ response::{Html, IntoResponse, Response}, }; -pub fn render_page(template: impl template_askama::askama::Template) -> Response { +pub fn render_page(template: impl askama::Template) -> Response { match template.render() { Ok(html) => Html(html).into_response(), Err(e) => { diff --git a/crates/presentation/src/routes.rs b/crates/adapters/http-axum/src/routes.rs similarity index 100% rename from crates/presentation/src/routes.rs rename to crates/adapters/http-axum/src/routes.rs diff --git a/crates/presentation/src/state.rs b/crates/adapters/http-axum/src/state.rs similarity index 100% rename from crates/presentation/src/state.rs rename to crates/adapters/http-axum/src/state.rs diff --git a/crates/presentation/src/tests/api_handlers.rs b/crates/adapters/http-axum/src/tests/api_handlers.rs similarity index 100% rename from crates/presentation/src/tests/api_handlers.rs rename to crates/adapters/http-axum/src/tests/api_handlers.rs diff --git a/crates/presentation/src/tests/context.rs b/crates/adapters/http-axum/src/tests/context.rs similarity index 100% rename from crates/presentation/src/tests/context.rs rename to crates/adapters/http-axum/src/tests/context.rs diff --git a/crates/presentation/src/tests/extractors.rs b/crates/adapters/http-axum/src/tests/extractors.rs similarity index 100% rename from crates/presentation/src/tests/extractors.rs rename to crates/adapters/http-axum/src/tests/extractors.rs diff --git a/crates/presentation/src/tests/forms.rs b/crates/adapters/http-axum/src/tests/forms.rs similarity index 100% rename from crates/presentation/src/tests/forms.rs rename to crates/adapters/http-axum/src/tests/forms.rs diff --git a/crates/presentation/src/tests/mod.rs b/crates/adapters/http-axum/src/tests/mod.rs similarity index 100% rename from crates/presentation/src/tests/mod.rs rename to crates/adapters/http-axum/src/tests/mod.rs diff --git a/crates/adapters/template-askama/templates/_filter_controls.html b/crates/adapters/http-axum/templates/_filter_controls.html similarity index 100% rename from crates/adapters/template-askama/templates/_filter_controls.html rename to crates/adapters/http-axum/templates/_filter_controls.html diff --git a/crates/adapters/template-askama/templates/activity_feed.html b/crates/adapters/http-axum/templates/activity_feed.html similarity index 100% rename from crates/adapters/template-askama/templates/activity_feed.html rename to crates/adapters/http-axum/templates/activity_feed.html diff --git a/crates/adapters/template-askama/templates/base.html b/crates/adapters/http-axum/templates/base.html similarity index 100% rename from crates/adapters/template-askama/templates/base.html rename to crates/adapters/http-axum/templates/base.html diff --git a/crates/adapters/template-askama/templates/blocked_actors.html b/crates/adapters/http-axum/templates/blocked_actors.html similarity index 100% rename from crates/adapters/template-askama/templates/blocked_actors.html rename to crates/adapters/http-axum/templates/blocked_actors.html diff --git a/crates/adapters/template-askama/templates/blocked_domains.html b/crates/adapters/http-axum/templates/blocked_domains.html similarity index 100% rename from crates/adapters/template-askama/templates/blocked_domains.html rename to crates/adapters/http-axum/templates/blocked_domains.html diff --git a/crates/adapters/template-askama/templates/diary.html b/crates/adapters/http-axum/templates/diary.html similarity index 100% rename from crates/adapters/template-askama/templates/diary.html rename to crates/adapters/http-axum/templates/diary.html diff --git a/crates/adapters/template-askama/templates/embed_base.html b/crates/adapters/http-axum/templates/embed_base.html similarity index 100% rename from crates/adapters/template-askama/templates/embed_base.html rename to crates/adapters/http-axum/templates/embed_base.html diff --git a/crates/adapters/template-askama/templates/embed_profile.html b/crates/adapters/http-axum/templates/embed_profile.html similarity index 100% rename from crates/adapters/template-askama/templates/embed_profile.html rename to crates/adapters/http-axum/templates/embed_profile.html diff --git a/crates/adapters/template-askama/templates/followers.html b/crates/adapters/http-axum/templates/followers.html similarity index 100% rename from crates/adapters/template-askama/templates/followers.html rename to crates/adapters/http-axum/templates/followers.html diff --git a/crates/adapters/template-askama/templates/following.html b/crates/adapters/http-axum/templates/following.html similarity index 100% rename from crates/adapters/template-askama/templates/following.html rename to crates/adapters/http-axum/templates/following.html diff --git a/crates/adapters/template-askama/templates/import_mapping.html b/crates/adapters/http-axum/templates/import_mapping.html similarity index 100% rename from crates/adapters/template-askama/templates/import_mapping.html rename to crates/adapters/http-axum/templates/import_mapping.html diff --git a/crates/adapters/template-askama/templates/import_preview.html b/crates/adapters/http-axum/templates/import_preview.html similarity index 100% rename from crates/adapters/template-askama/templates/import_preview.html rename to crates/adapters/http-axum/templates/import_preview.html diff --git a/crates/adapters/template-askama/templates/import_upload.html b/crates/adapters/http-axum/templates/import_upload.html similarity index 100% rename from crates/adapters/template-askama/templates/import_upload.html rename to crates/adapters/http-axum/templates/import_upload.html diff --git a/crates/adapters/template-askama/templates/integrations.html b/crates/adapters/http-axum/templates/integrations.html similarity index 100% rename from crates/adapters/template-askama/templates/integrations.html rename to crates/adapters/http-axum/templates/integrations.html diff --git a/crates/adapters/template-askama/templates/login.html b/crates/adapters/http-axum/templates/login.html similarity index 100% rename from crates/adapters/template-askama/templates/login.html rename to crates/adapters/http-axum/templates/login.html diff --git a/crates/adapters/template-askama/templates/movie_detail.html b/crates/adapters/http-axum/templates/movie_detail.html similarity index 100% rename from crates/adapters/template-askama/templates/movie_detail.html rename to crates/adapters/http-axum/templates/movie_detail.html diff --git a/crates/adapters/template-askama/templates/new_review.html b/crates/adapters/http-axum/templates/new_review.html similarity index 100% rename from crates/adapters/template-askama/templates/new_review.html rename to crates/adapters/http-axum/templates/new_review.html diff --git a/crates/adapters/template-askama/templates/profile.html b/crates/adapters/http-axum/templates/profile.html similarity index 100% rename from crates/adapters/template-askama/templates/profile.html rename to crates/adapters/http-axum/templates/profile.html diff --git a/crates/adapters/template-askama/templates/profile_settings.html b/crates/adapters/http-axum/templates/profile_settings.html similarity index 100% rename from crates/adapters/template-askama/templates/profile_settings.html rename to crates/adapters/http-axum/templates/profile_settings.html diff --git a/crates/adapters/template-askama/templates/register.html b/crates/adapters/http-axum/templates/register.html similarity index 100% rename from crates/adapters/template-askama/templates/register.html rename to crates/adapters/http-axum/templates/register.html diff --git a/crates/adapters/template-askama/templates/users.html b/crates/adapters/http-axum/templates/users.html similarity index 100% rename from crates/adapters/template-askama/templates/users.html rename to crates/adapters/http-axum/templates/users.html diff --git a/crates/adapters/template-askama/templates/watch_queue.html b/crates/adapters/http-axum/templates/watch_queue.html similarity index 100% rename from crates/adapters/template-askama/templates/watch_queue.html rename to crates/adapters/http-axum/templates/watch_queue.html diff --git a/crates/adapters/template-askama/templates/watchlist.html b/crates/adapters/http-axum/templates/watchlist.html similarity index 100% rename from crates/adapters/template-askama/templates/watchlist.html rename to crates/adapters/http-axum/templates/watchlist.html diff --git a/crates/adapters/template-askama/templates/wrapup.html b/crates/adapters/http-axum/templates/wrapup.html similarity index 100% rename from crates/adapters/template-askama/templates/wrapup.html rename to crates/adapters/http-axum/templates/wrapup.html diff --git a/crates/adapters/template-askama/Cargo.toml b/crates/adapters/template-askama/Cargo.toml deleted file mode 100644 index eea631b7..00000000 --- a/crates/adapters/template-askama/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "template-askama" -version = "0.1.0" -edition.workspace = true - -[dependencies] -askama = { version = "0.16.0" } - -chrono = { workspace = true } -uuid = { workspace = true } - -domain = { workspace = true } -api-types = { workspace = true } diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index 95458630..c9e21232 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -8,7 +8,7 @@ default = [] nats = ["dep:nats"] federation = [ "application/federation", - "presentation/federation", + "http-axum/federation", "dep:sqlite-federation", "dep:activitypub", ] @@ -17,7 +17,7 @@ federation = [ config = { workspace = true } domain = { workspace = true } application = { workspace = true } -presentation = { path = "../presentation", default-features = false } +http-axum = { path = "../adapters/http-axum", default-features = false } auth = { workspace = true } metadata = { workspace = true } poster-fetcher = { workspace = true } diff --git a/crates/bootstrap/src/server.rs b/crates/bootstrap/src/server.rs index f430fd7a..76302609 100644 --- a/crates/bootstrap/src/server.rs +++ b/crates/bootstrap/src/server.rs @@ -6,9 +6,9 @@ use application::config::AppConfig as AppSettings; use domain::ports::{DiaryExporter, DocumentParser, EventPublisher}; use domain::value_objects::InstanceIdentity; use export::ExportAdapter; +use http_axum::context::AppContext; +use http_axum::{openapi, routes, state::AppState}; use importer::ImporterDocumentParser; -use presentation::context::AppContext; -use presentation::{openapi, routes, state::AppState}; use rss::RssAdapter; use crate::database::{self, DatabaseOutput}; diff --git a/crates/bootstrap/src/tracing_setup.rs b/crates/bootstrap/src/tracing_setup.rs index 786a3e4d..26147833 100644 --- a/crates/bootstrap/src/tracing_setup.rs +++ b/crates/bootstrap/src/tracing_setup.rs @@ -10,7 +10,7 @@ pub fn init_tracing(default_directives: &str) { /// `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 -/// `presentation=debug` silently drops adapter errors (e.g. the sqlx detail +/// `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()); diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index eff91cd0..ccbdb448 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -25,7 +25,7 @@ tracing = { workspace = true } [dev-dependencies] domain = { workspace = true, features = ["test-helpers"] } application = { workspace = true } -presentation = { path = "../presentation", default-features = false } +http-axum = { path = "../adapters/http-axum", default-features = false } rss = { workspace = true } sqlite = { workspace = true } sqlx = { workspace = true } diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs index 68c4c41e..ef4c96b1 100644 --- a/crates/server/tests/api_test.rs +++ b/crates/server/tests/api_test.rs @@ -21,9 +21,9 @@ use domain::{ }, value_objects::{Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId}, }; +use http_axum::context::AppContext; +use http_axum::{routes, state::AppState}; use http_body_util::BodyExt; -use presentation::context::AppContext; -use presentation::{routes, state::AppState}; use rss::RssAdapter; use sqlite::{ SqliteDiaryRepository, SqliteInsightsQuery, SqliteMovieRepository, SqliteReviewRepository, -- 2.49.1 From 614aec8df9e6491715bd81a4946e3aefe597b3ef Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:46:03 +0100 Subject: [PATCH 021/104] refactor: http-axum split into api/html/federation modules, router.rs, templates per page group --- crates/adapters/http-axum/Cargo.toml | 2 +- crates/adapters/http-axum/src/api/auth.rs | 101 ++ crates/adapters/http-axum/src/api/diary.rs | 191 +++ .../http-axum/src/{handlers => api}/goals.rs | 28 +- .../adapters/http-axum/src/api/import/mod.rs | 5 + .../http-axum/src/api/import/profiles.rs | 105 ++ .../http-axum/src/api/import/sessions.rs | 309 +++++ .../src/{handlers => api}/insights.rs | 8 +- crates/adapters/http-axum/src/api/mod.rs | 201 +++ .../http-axum/src/{handlers => api}/movies.rs | 111 +- .../http-axum/src/{handlers => api}/search.rs | 40 +- crates/adapters/http-axum/src/api/social.rs | 348 ++++++ .../tests/social.rs => api/social/tests.rs} | 23 - .../http-axum/src/api/user_profile.rs | 279 +++++ crates/adapters/http-axum/src/api/users.rs | 152 +++ .../adapters/http-axum/src/api/watchlist.rs | 148 +++ .../src/{handlers => api}/webhook.rs | 26 +- .../http-axum/src/{handlers => api}/wrapup.rs | 133 +- .../src/{extractors.rs => extractors/mod.rs} | 1 - .../extractors.rs => extractors/tests.rs} | 0 .../http-axum/src/federation/blocklist_api.rs | 169 +++ .../src/federation/blocklist_api/tests.rs | 22 + .../src/federation/blocklist_html.rs | 191 +++ .../http-axum/src/federation/collections.rs | 77 ++ .../adapters/http-axum/src/federation/mod.rs | 120 ++ .../http-axum/src/federation/negotiation.rs | 36 + .../adapters/http-axum/src/handlers/diary.rs | 349 ------ .../adapters/http-axum/src/handlers/import.rs | 812 ------------ crates/adapters/http-axum/src/handlers/mod.rs | 19 - .../adapters/http-axum/src/handlers/social.rs | 1095 ----------------- .../adapters/http-axum/src/handlers/users.rs | 869 ------------- .../http-axum/src/handlers/watchlist.rs | 312 ----- .../http-axum/src/{handlers => html}/auth.rs | 131 +- .../adapters/http-axum/src/{ => html}/csrf.rs | 0 crates/adapters/http-axum/src/html/diary.rs | 166 +++ .../http-axum/src/{ => html}/forms.rs | 2 +- .../{tests/forms.rs => html/forms_tests.rs} | 0 crates/adapters/http-axum/src/html/helpers.rs | 62 + .../src/{handlers => html}/images.rs | 3 +- .../http-axum/src/html/import/mapping.rs | 149 +++ .../adapters/http-axum/src/html/import/mod.rs | 7 + .../http-axum/src/html/import/preview.rs | 131 ++ .../http-axum/src/html/import/upload.rs | 147 +++ .../src/{handlers => html}/integrations.rs | 51 +- .../src/{ => html}/mappers/import.rs | 0 .../src/{ => html}/mappers/integrations.rs | 0 .../http-axum/src/{ => html}/mappers/mod.rs | 3 - .../http-axum/src/{ => html}/mappers/users.rs | 0 .../src/{ => html}/mappers/watchlist.rs | 0 crates/adapters/http-axum/src/html/mod.rs | 162 +++ crates/adapters/http-axum/src/html/movies.rs | 77 ++ crates/adapters/http-axum/src/html/profile.rs | 260 ++++ .../http-axum/src/{ => html}/render.rs | 0 .../http-axum/src/{handlers => html}/rss.rs | 12 +- crates/adapters/http-axum/src/html/social.rs | 319 +++++ .../http-axum/src/html/social/tests.rs | 22 + crates/adapters/http-axum/src/html/tags.rs | 12 + .../adapters/http-axum/src/html/templates.rs | 495 -------- .../http-axum/src/html/templates/auth.rs | 16 + .../http-axum/src/html/templates/common.rs | 36 + .../http-axum/src/html/templates/diary.rs | 55 + .../http-axum/src/html/templates/import.rs | 47 + .../src/html/templates/integrations.rs | 36 + .../http-axum/src/html/templates/mod.rs | 36 + .../http-axum/src/html/templates/movies.rs | 19 + .../http-axum/src/html/templates/social.rs | 55 + .../http-axum/src/html/templates/users.rs | 193 +++ .../http-axum/src/html/templates/watchlist.rs | 25 + .../http-axum/src/html/templates/wrapup.rs | 16 + crates/adapters/http-axum/src/html/users.rs | 179 +++ .../adapters/http-axum/src/html/watchlist.rs | 171 +++ crates/adapters/http-axum/src/html/wrapup.rs | 106 ++ crates/adapters/http-axum/src/lib.rs | 10 +- crates/adapters/http-axum/src/openapi/auth.rs | 2 +- .../adapters/http-axum/src/openapi/diary.rs | 10 +- .../adapters/http-axum/src/openapi/goals.rs | 14 +- .../adapters/http-axum/src/openapi/import.rs | 14 +- .../http-axum/src/openapi/insights.rs | 2 +- .../adapters/http-axum/src/openapi/movies.rs | 10 +- .../adapters/http-axum/src/openapi/search.rs | 6 +- .../adapters/http-axum/src/openapi/social.rs | 34 +- .../adapters/http-axum/src/openapi/users.rs | 10 +- .../http-axum/src/openapi/watchlist.rs | 8 +- .../adapters/http-axum/src/openapi/webhook.rs | 16 +- .../adapters/http-axum/src/openapi/wrapup.rs | 10 +- crates/adapters/http-axum/src/router.rs | 131 ++ crates/adapters/http-axum/src/routes.rs | 593 --------- .../src/{handlers/helpers.rs => support.rs} | 69 +- .../http-axum/src/tests/api_handlers.rs | 18 +- crates/api-types/src/lib.rs | 1 + .../src/mappers/diary.rs | 2 +- crates/api-types/src/mappers/goals.rs | 12 + crates/api-types/src/mappers/mod.rs | 10 + .../src/mappers/movies.rs | 2 +- .../src/mappers/search.rs | 2 +- crates/bootstrap/src/server.rs | 4 +- crates/server/tests/api_test.rs | 4 +- 97 files changed, 5317 insertions(+), 5160 deletions(-) create mode 100644 crates/adapters/http-axum/src/api/auth.rs create mode 100644 crates/adapters/http-axum/src/api/diary.rs rename crates/adapters/http-axum/src/{handlers => api}/goals.rs (85%) create mode 100644 crates/adapters/http-axum/src/api/import/mod.rs create mode 100644 crates/adapters/http-axum/src/api/import/profiles.rs create mode 100644 crates/adapters/http-axum/src/api/import/sessions.rs rename crates/adapters/http-axum/src/{handlers => api}/insights.rs (99%) create mode 100644 crates/adapters/http-axum/src/api/mod.rs rename crates/adapters/http-axum/src/{handlers => api}/movies.rs (71%) rename crates/adapters/http-axum/src/{handlers => api}/search.rs (86%) create mode 100644 crates/adapters/http-axum/src/api/social.rs rename crates/adapters/http-axum/src/{handlers/tests/social.rs => api/social/tests.rs} (73%) create mode 100644 crates/adapters/http-axum/src/api/user_profile.rs create mode 100644 crates/adapters/http-axum/src/api/users.rs create mode 100644 crates/adapters/http-axum/src/api/watchlist.rs rename crates/adapters/http-axum/src/{handlers => api}/webhook.rs (94%) rename crates/adapters/http-axum/src/{handlers => api}/wrapup.rs (62%) rename crates/adapters/http-axum/src/{extractors.rs => extractors/mod.rs} (99%) rename crates/adapters/http-axum/src/{tests/extractors.rs => extractors/tests.rs} (100%) create mode 100644 crates/adapters/http-axum/src/federation/blocklist_api.rs create mode 100644 crates/adapters/http-axum/src/federation/blocklist_api/tests.rs create mode 100644 crates/adapters/http-axum/src/federation/blocklist_html.rs create mode 100644 crates/adapters/http-axum/src/federation/collections.rs create mode 100644 crates/adapters/http-axum/src/federation/mod.rs create mode 100644 crates/adapters/http-axum/src/federation/negotiation.rs delete mode 100644 crates/adapters/http-axum/src/handlers/diary.rs delete mode 100644 crates/adapters/http-axum/src/handlers/import.rs delete mode 100644 crates/adapters/http-axum/src/handlers/mod.rs delete mode 100644 crates/adapters/http-axum/src/handlers/social.rs delete mode 100644 crates/adapters/http-axum/src/handlers/users.rs delete mode 100644 crates/adapters/http-axum/src/handlers/watchlist.rs rename crates/adapters/http-axum/src/{handlers => html}/auth.rs (54%) rename crates/adapters/http-axum/src/{ => html}/csrf.rs (100%) create mode 100644 crates/adapters/http-axum/src/html/diary.rs rename crates/adapters/http-axum/src/{ => html}/forms.rs (99%) rename crates/adapters/http-axum/src/{tests/forms.rs => html/forms_tests.rs} (100%) create mode 100644 crates/adapters/http-axum/src/html/helpers.rs rename crates/adapters/http-axum/src/{handlers => html}/images.rs (99%) create mode 100644 crates/adapters/http-axum/src/html/import/mapping.rs create mode 100644 crates/adapters/http-axum/src/html/import/mod.rs create mode 100644 crates/adapters/http-axum/src/html/import/preview.rs create mode 100644 crates/adapters/http-axum/src/html/import/upload.rs rename crates/adapters/http-axum/src/{handlers => html}/integrations.rs (83%) rename crates/adapters/http-axum/src/{ => html}/mappers/import.rs (100%) rename crates/adapters/http-axum/src/{ => html}/mappers/integrations.rs (100%) rename crates/adapters/http-axum/src/{ => html}/mappers/mod.rs (60%) rename crates/adapters/http-axum/src/{ => html}/mappers/users.rs (100%) rename crates/adapters/http-axum/src/{ => html}/mappers/watchlist.rs (100%) create mode 100644 crates/adapters/http-axum/src/html/movies.rs create mode 100644 crates/adapters/http-axum/src/html/profile.rs rename crates/adapters/http-axum/src/{ => html}/render.rs (100%) rename crates/adapters/http-axum/src/{handlers => html}/rss.rs (95%) create mode 100644 crates/adapters/http-axum/src/html/social.rs create mode 100644 crates/adapters/http-axum/src/html/social/tests.rs create mode 100644 crates/adapters/http-axum/src/html/tags.rs delete mode 100644 crates/adapters/http-axum/src/html/templates.rs create mode 100644 crates/adapters/http-axum/src/html/templates/auth.rs create mode 100644 crates/adapters/http-axum/src/html/templates/common.rs create mode 100644 crates/adapters/http-axum/src/html/templates/diary.rs create mode 100644 crates/adapters/http-axum/src/html/templates/import.rs create mode 100644 crates/adapters/http-axum/src/html/templates/integrations.rs create mode 100644 crates/adapters/http-axum/src/html/templates/mod.rs create mode 100644 crates/adapters/http-axum/src/html/templates/movies.rs create mode 100644 crates/adapters/http-axum/src/html/templates/social.rs create mode 100644 crates/adapters/http-axum/src/html/templates/users.rs create mode 100644 crates/adapters/http-axum/src/html/templates/watchlist.rs create mode 100644 crates/adapters/http-axum/src/html/templates/wrapup.rs create mode 100644 crates/adapters/http-axum/src/html/users.rs create mode 100644 crates/adapters/http-axum/src/html/watchlist.rs create mode 100644 crates/adapters/http-axum/src/html/wrapup.rs create mode 100644 crates/adapters/http-axum/src/router.rs delete mode 100644 crates/adapters/http-axum/src/routes.rs rename crates/adapters/http-axum/src/{handlers/helpers.rs => support.rs} (66%) rename crates/{adapters/http-axum => api-types}/src/mappers/diary.rs (95%) create mode 100644 crates/api-types/src/mappers/goals.rs create mode 100644 crates/api-types/src/mappers/mod.rs rename crates/{adapters/http-axum => api-types}/src/mappers/movies.rs (97%) rename crates/{adapters/http-axum => api-types}/src/mappers/search.rs (96%) diff --git a/crates/adapters/http-axum/Cargo.toml b/crates/adapters/http-axum/Cargo.toml index 77e1df8e..c7954a7a 100644 --- a/crates/adapters/http-axum/Cargo.toml +++ b/crates/adapters/http-axum/Cargo.toml @@ -2,7 +2,7 @@ name = "http-axum" version = "0.1.0" edition.workspace = true -description = "Self-hosted movie diary with REST API and ActivityPub federation" +description = "Axum HTTP adapter: JSON API, classic HTML UI, optional ActivityPub-facing routes" license = "MIT" [features] diff --git a/crates/adapters/http-axum/src/api/auth.rs b/crates/adapters/http-axum/src/api/auth.rs new file mode 100644 index 00000000..b3aecd34 --- /dev/null +++ b/crates/adapters/http-axum/src/api/auth.rs @@ -0,0 +1,101 @@ +use crate::{errors::ApiError, state::AppState}; +use api_types::{ + LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest, +}; +use application::auth::{ + commands::RegisterCommand, login as login_uc, queries::LoginCommand, register as register_uc, +}; +use axum::{Json, extract::State, http::StatusCode}; + +#[utoipa::path( + post, path = "/api/v1/auth/login", + request_body = LoginRequest, + responses( + (status = 200, body = LoginResponse), + (status = 401, description = "Invalid credentials"), + ) +)] +pub async fn login( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let result = login_uc::execute( + &state.app_ctx.deps.auth.login, + LoginCommand { + email: req.email, + password: req.password, + }, + ) + .await?; + Ok(Json(LoginResponse { + token: result.token, + refresh_token: result.refresh_token, + user_id: result.user_id, + email: result.email, + expires_at: result.expires_at.to_rfc3339(), + role: result.role, + })) +} + +#[utoipa::path( + post, path = "/api/v1/auth/register", + request_body = RegisterRequest, + responses( + (status = 201, description = "User registered"), + (status = 400, description = "Invalid input"), + ) +)] +pub async fn register( + State(state): State, + Json(req): Json, +) -> Result { + register_uc::execute( + &state.app_ctx.deps.auth.register, + RegisterCommand { + email: req.email, + username: req.username, + password: req.password, + role: domain::models::UserRole::Standard, + }, + ) + .await?; + Ok(StatusCode::CREATED) +} + +#[utoipa::path( + post, path = "/api/v1/auth/refresh", + request_body = RefreshRequest, + responses( + (status = 200, body = RefreshResponse), + (status = 401, description = "Invalid or expired refresh token"), + ) +)] +pub async fn refresh( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let result = + application::auth::refresh::execute(&state.app_ctx.deps.auth.refresh, &req.refresh_token) + .await?; + Ok(Json(RefreshResponse { + token: result.token, + refresh_token: result.refresh_token, + expires_at: result.expires_at.to_rfc3339(), + })) +} + +#[utoipa::path( + post, path = "/api/v1/auth/logout", + request_body = LogoutRequest, + responses( + (status = 204, description = "Logged out"), + ) +)] +pub async fn api_logout( + State(state): State, + Json(req): Json, +) -> StatusCode { + let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token) + .await; + StatusCode::NO_CONTENT +} diff --git a/crates/adapters/http-axum/src/api/diary.rs b/crates/adapters/http-axum/src/api/diary.rs new file mode 100644 index 00000000..583ea659 --- /dev/null +++ b/crates/adapters/http-axum/src/api/diary.rs @@ -0,0 +1,191 @@ +use crate::support::build_export_response; +use crate::{ + errors::ApiError, + extractors::AuthenticatedUser, + html::forms::{LogReviewData, to_diary_query}, + state::AppState, +}; +use api_types::{ + ActivityFeedQueryParams, ActivityFeedResponse, DiaryQueryParams, DiaryResponse, + EditReviewRequest, ExportQueryParams, LogReviewRequest, +}; +use application::diary::{ + commands::{DeleteReviewCommand, EditReviewCommand}, + delete_review, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review, + queries::GetActivityFeedQuery, +}; +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use uuid::Uuid; + +#[utoipa::path( + get, path = "/api/v1/diary", + params(DiaryQueryParams), + responses( + (status = 200, body = DiaryResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_diary( + State(state): State, + Query(params): Query, +) -> Result, ApiError> { + let page = + get_diary::execute(&state.app_ctx.deps.diary.get_diary, to_diary_query(params)).await?; + + Ok(Json(DiaryResponse { + items: page + .items + .iter() + .map(api_types::mappers::entry_to_dto) + .collect(), + total_count: page.total_count, + limit: page.limit, + offset: page.offset, + })) +} + +#[utoipa::path( + post, path = "/api/v1/reviews", + request_body = LogReviewRequest, + responses( + (status = 201, description = "Review created"), + (status = 400, description = "Invalid input"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn post_review( + State(state): State, + user: AuthenticatedUser, + Json(req): Json, +) -> Result { + let data = LogReviewData::try_from(req).map_err(ApiError)?; + log_review::execute( + &state.app_ctx.services.review_logger, + data.into_command(user.0.value()), + ) + .await?; + Ok(StatusCode::CREATED) +} + +#[utoipa::path( + delete, path = "/api/v1/reviews/{id}", + params(("id" = Uuid, Path, description = "Review ID")), + responses( + (status = 204, description = "Review deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Review not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_review( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(review_id): Path, +) -> Result { + let cmd = DeleteReviewCommand { + review_id, + requesting_user_id: user_id.value(), + }; + delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + patch, path = "/api/v1/reviews/{id}", + request_body = EditReviewRequest, + params(("id" = Uuid, Path, description = "Review ID")), + responses( + (status = 200, description = "Review updated"), + (status = 400, description = "Invalid input"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Review not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn patch_review( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(review_id): Path, + Json(req): Json, +) -> Result { + let watched_at = req + .watched_at + .map(|s| domain::value_objects::parse_watched_at(&s).map_err(ApiError)) + .transpose()?; + + let cmd = EditReviewCommand { + review_id, + requesting_user_id: user_id.value(), + rating: req.rating, + comment: req.comment, + watched_at, + watch_medium: req.watch_medium, + spoilers: req.spoilers, + }; + edit_review::execute(&state.app_ctx.deps.diary.edit_review, cmd).await?; + Ok(StatusCode::OK) +} + +#[utoipa::path( + get, path = "/api/v1/diary/export", + params(ExportQueryParams), + responses( + (status = 200, description = "Diary file download", content_type = "text/csv"), + (status = 400, description = "Invalid format parameter"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn export_diary( + State(state): State, + user: AuthenticatedUser, + Query(params): Query, +) -> impl IntoResponse { + build_export_response(¶ms.format, user.0.value(), &state) +} + +#[utoipa::path( + get, path = "/api/v1/activity-feed", + params(ActivityFeedQueryParams), + responses((status = 200, body = ActivityFeedResponse)), +)] +pub async fn get_activity_feed( + State(state): State, + Query(params): Query, +) -> Result, ApiError> { + let page = get_feed_uc::execute( + &state.app_ctx.deps.diary.get_activity_feed, + GetActivityFeedQuery { + limit: params.limit.unwrap_or(20), + offset: params.offset.unwrap_or(0), + sort_by: params + .sort_by + .as_deref() + .map(|s| s.parse().unwrap_or_default()) + .unwrap_or_default(), + search: None, + viewer_user_id: None, + filter_following: false, + }, + ) + .await?; + Ok(Json(ActivityFeedResponse { + items: page + .items + .iter() + .map(api_types::mappers::feed_entry_to_dto) + .collect(), + total_count: page.total_count, + limit: page.limit, + offset: page.offset, + })) +} diff --git a/crates/adapters/http-axum/src/handlers/goals.rs b/crates/adapters/http-axum/src/api/goals.rs similarity index 85% rename from crates/adapters/http-axum/src/handlers/goals.rs rename to crates/adapters/http-axum/src/api/goals.rs index 2e1778c3..963630e0 100644 --- a/crates/adapters/http-axum/src/handlers/goals.rs +++ b/crates/adapters/http-axum/src/api/goals.rs @@ -1,3 +1,9 @@ +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use api_types::mappers::goal_with_progress_to_dto; +use api_types::{ + CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest, + UserSettingsDto, +}; use axum::{ Json, extract::{Path, State}, @@ -5,26 +11,6 @@ use axum::{ }; use uuid::Uuid; -use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; -use api_types::{ - CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest, - UserSettingsDto, -}; -// ── Shared mapper ──────────────────────────────────────────────────────────── - -pub fn goal_with_progress_to_dto(g: &domain::models::GoalWithProgress) -> GoalDto { - GoalDto { - year: g.goal.year(), - target_count: g.goal.target_count(), - current_count: g.current_count, - percentage: g.percentage(), - is_complete: g.is_complete(), - goal_type: g.goal.goal_type().clone(), - } -} - -// ── Goals API ──────────────────────────────────────────────────────────────── - #[utoipa::path( get, path = "/api/v1/goals", responses( @@ -151,8 +137,6 @@ pub async fn get_user_goals( })) } -// ── User Settings ──────────────────────────────────────────────────────────── - #[utoipa::path( get, path = "/api/v1/settings", responses( diff --git a/crates/adapters/http-axum/src/api/import/mod.rs b/crates/adapters/http-axum/src/api/import/mod.rs new file mode 100644 index 00000000..283cac86 --- /dev/null +++ b/crates/adapters/http-axum/src/api/import/mod.rs @@ -0,0 +1,5 @@ +mod profiles; +mod sessions; + +pub use profiles::*; +pub use sessions::*; diff --git a/crates/adapters/http-axum/src/api/import/profiles.rs b/crates/adapters/http-axum/src/api/import/profiles.rs new file mode 100644 index 00000000..9ede9328 --- /dev/null +++ b/crates/adapters/http-axum/src/api/import/profiles.rs @@ -0,0 +1,105 @@ +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use api_types::SaveProfileRequest; +use application::import::{ + commands::{DeleteImportProfileCommand, SaveImportProfileCommand}, + delete_profile as delete_import_profile, list_profiles as list_import_profiles, + save_profile as save_import_profile, +}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use domain::errors::DomainError; +use domain::value_objects::ImportSessionId; + +#[utoipa::path( + get, path = "/api/v1/import/profiles", + responses( + (status = 200, description = "List of saved import profiles", body = inline(Vec)), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_get_profiles( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, +) -> Result { + let profiles = + list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id).await?; + Ok(axum::Json( + profiles + .into_iter() + .map(|p| { + serde_json::json!({ + "id": p.id.value().to_string(), + "name": p.name, + "created_at": p.created_at.to_string(), + }) + }) + .collect::>(), + )) +} + +#[utoipa::path( + post, path = "/api/v1/import/profiles", + request_body = SaveProfileRequest, + responses( + (status = 200, description = "Profile saved", body = inline(serde_json::Value)), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Session has no mapping yet"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_post_profile( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + axum::Json(body): axum::Json, +) -> Result { + let session_id = body + .session_id + .parse::() + .map(ImportSessionId::from_uuid) + .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; + let id = save_import_profile::execute( + &state.app_ctx.deps.import.save_profile, + SaveImportProfileCommand { + user_id: user_id.value(), + session_id: session_id.value(), + name: body.name, + }, + ) + .await?; + Ok(axum::Json( + serde_json::json!({"id": id.value().to_string()}), + )) +} + +#[utoipa::path( + delete, path = "/api/v1/import/profiles/{id}", + params(("id" = String, Path, description = "Import profile UUID")), + responses( + (status = 204, description = "Deleted"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Profile not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_delete_profile( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(profile_id_str): Path, +) -> Result { + let profile_id = profile_id_str + .parse::() + .map_err(|_| DomainError::ValidationError("invalid profile id".into()))?; + delete_import_profile::execute( + &state.app_ctx.deps.import.delete_profile, + DeleteImportProfileCommand { + user_id: user_id.value(), + profile_id, + }, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/adapters/http-axum/src/api/import/sessions.rs b/crates/adapters/http-axum/src/api/import/sessions.rs new file mode 100644 index 00000000..96566246 --- /dev/null +++ b/crates/adapters/http-axum/src/api/import/sessions.rs @@ -0,0 +1,309 @@ +use crate::support::str_to_domain_field; +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use api_types::{ + ApplyMappingRequest, ConfirmRequest, ImportPreviewResponse, PreviewRowDto, + SessionCreatedResponse, SessionStateResponse, +}; +use application::import::{ + apply_mapping as apply_import_mapping, apply_profile_and_map, + commands::{ + ApplyImportMappingCommand, ApplyProfileAndMapCommand, CreateImportSessionCommand, + ExecuteImportCommand, + }, + create_session as create_import_session, execute as execute_import, get_preview_stage, + get_session_state, +}; +use axum::{ + extract::{Multipart, Path, State}, + response::IntoResponse, +}; +use domain::errors::DomainError; +use domain::models::{ + AnnotatedRow, FieldMapping, FileFormat, + import::{DomainField, Transform}, +}; +use domain::value_objects::ImportSessionId; + +#[utoipa::path( + post, path = "/api/v1/import/sessions", + request_body(content_type = "multipart/form-data", description = "file (binary) + format (csv|json|xlsx)"), + responses( + (status = 200, body = SessionCreatedResponse), + (status = 400, description = "No file provided"), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Parse error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_post_session( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + mut multipart: Multipart, +) -> Result { + let mut file_bytes: Option> = None; + let mut format_str = "csv".to_string(); + while let Ok(Some(field)) = multipart.next_field().await { + match field.name() { + Some("file") => { + if let Ok(b) = field.bytes().await { + file_bytes = Some(b.to_vec()); + } + } + Some("format") => { + if let Ok(t) = field.text().await { + format_str = t; + } + } + _ => {} + } + } + let bytes = match file_bytes { + Some(b) if !b.is_empty() => b, + _ => return Err(DomainError::ValidationError("no file".into()).into()), + }; + let format = match format_str.as_str() { + "json" => FileFormat::Json, + "xlsx" => FileFormat::Xlsx, + _ => FileFormat::Csv, + }; + let r = create_import_session::execute( + &state.app_ctx.deps.import.create_session, + CreateImportSessionCommand { + user_id: user_id.value(), + bytes, + format, + }, + ) + .await?; + Ok(axum::Json(SessionCreatedResponse { + session_id: r.session_id.value().to_string(), + columns: r.columns, + sample_rows: r.sample_rows, + })) +} + +#[utoipa::path( + get, path = "/api/v1/import/sessions/{id}", + params(("id" = String, Path, description = "Import session UUID")), + responses( + (status = 200, body = SessionStateResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Session not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_get_session( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(session_id_str): Path, +) -> Result { + let session_id = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; + let state_data = get_session_state::execute( + &state.app_ctx.deps.import.get_session_state, + session_id, + user_id.value(), + ) + .await?; + Ok(axum::Json(SessionStateResponse { + session_id: session_id_str, + columns: state_data.columns, + has_mappings: state_data.has_mappings, + row_count: state_data.row_count, + })) +} + +#[utoipa::path( + put, path = "/api/v1/import/sessions/{id}/mapping", + params(("id" = String, Path, description = "Import session UUID")), + request_body = ApplyMappingRequest, + responses( + (status = 200, description = "Mapping applied", body = inline(serde_json::Value)), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Mapping error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_put_mapping( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(session_id_str): Path, + axum::Json(body): axum::Json, +) -> Result { + let session_id = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; + let mappings: Vec = body + .mappings + .into_iter() + .filter_map(|m| { + let domain_field = str_to_domain_field(&m.domain_field)?; + let transform = if domain_field == DomainField::Rating { + Transform::RatingScale(m.rating_scale.unwrap_or(1.0)) + } else if domain_field == DomainField::WatchedAt { + m.date_format + .map(Transform::DateFormat) + .unwrap_or(Transform::Identity) + } else { + Transform::Identity + }; + Some(FieldMapping { + source_column: m.source_column, + domain_field, + transform, + }) + }) + .collect(); + + let rows = apply_import_mapping::execute( + &state.app_ctx.deps.import.apply_mapping, + ApplyImportMappingCommand { + user_id: user_id.value(), + session_id: session_id.value(), + mappings, + }, + ) + .await?; + Ok(axum::Json(serde_json::json!({"row_count": rows.len()}))) +} + +pub async fn api_get_preview( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(session_id_str): Path, +) -> Result, ApiError> { + let session_id = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; + + let stage = get_preview_stage::execute( + &state.app_ctx.deps.import.get_preview_stage, + session_id, + user_id.value(), + ) + .await?; + + // Not-yet-mapped sessions render as an empty preview here (200, `rows: []`), + // matching this endpoint's behavior before the stage-gate moved into + // `get_preview_stage` — unlike the HTML handler, this API route never + // redirected on the same condition, so its response shape is unchanged. + let annotated: Vec = match stage { + get_preview_stage::PreviewStage::Ready(preview) => preview.rows, + get_preview_stage::PreviewStage::NotYetMapped => Vec::new(), + }; + let rows = annotated + .iter() + .enumerate() + .map(|(i, a)| { + use domain::models::import::RowResult; + match &a.result { + RowResult::Valid(row) if a.is_duplicate => { + PreviewRowDto::Duplicate(api_types::PreviewRowData { + index: i, + title: row.title.clone(), + release_year: row.release_year.clone(), + director: row.director.clone(), + rating: row.rating.clone(), + watched_at: row.watched_at.clone(), + comment: row.comment.clone(), + }) + } + RowResult::Valid(row) => PreviewRowDto::Valid(api_types::PreviewRowData { + index: i, + title: row.title.clone(), + release_year: row.release_year.clone(), + director: row.director.clone(), + rating: row.rating.clone(), + watched_at: row.watched_at.clone(), + comment: row.comment.clone(), + }), + RowResult::Invalid { errors, .. } => PreviewRowDto::Invalid { + index: i, + errors: errors.clone(), + }, + } + }) + .collect(); + + Ok(axum::Json(ImportPreviewResponse { rows })) +} + +#[utoipa::path( + post, path = "/api/v1/import/sessions/{id}/confirm", + params(("id" = String, Path, description = "Import session UUID")), + request_body = ConfirmRequest, + responses( + (status = 200, description = "Import summary", body = inline(serde_json::Value)), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Session not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_post_confirm( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(session_id_str): Path, + axum::Json(body): axum::Json, +) -> Result { + let session_id = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; + let s = execute_import::execute( + &state.app_ctx.deps.import.execute_import, + ExecuteImportCommand { + user_id: user_id.value(), + session_id: session_id.value(), + confirmed_indices: body.confirmed_indices, + }, + ) + .await?; + Ok(axum::Json(serde_json::json!({ + "imported": s.imported, + "skipped_duplicates": s.skipped_duplicates, + "failed": s.failed.iter().map(|(i, e)| serde_json::json!({"index": i, "error": e})).collect::>(), + }))) +} + +#[utoipa::path( + put, path = "/api/v1/import/sessions/{id}/profile/{profile_id}", + params( + ("id" = String, Path, description = "Import session UUID"), + ("profile_id" = String, Path, description = "Import profile UUID"), + ), + responses( + (status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)), + (status = 400, description = "Invalid ID"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Session or profile not found"), + (status = 422, description = "Mapping error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_apply_profile( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path((session_id_str, profile_id_str)): Path<(String, String)>, +) -> Result { + let session_id = session_id_str + .parse::() + .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; + let profile_id = profile_id_str + .parse::() + .map_err(|_| DomainError::ValidationError("invalid profile id".into()))?; + + let rows = apply_profile_and_map::execute( + &state.app_ctx.deps.import.apply_profile_and_map, + ApplyProfileAndMapCommand { + user_id: user_id.value(), + session_id, + profile_id, + }, + ) + .await?; + Ok(axum::Json(serde_json::json!({"row_count": rows.len()}))) +} diff --git a/crates/adapters/http-axum/src/handlers/insights.rs b/crates/adapters/http-axum/src/api/insights.rs similarity index 99% rename from crates/adapters/http-axum/src/handlers/insights.rs rename to crates/adapters/http-axum/src/api/insights.rs index c71ccad8..61211321 100644 --- a/crates/adapters/http-axum/src/handlers/insights.rs +++ b/crates/adapters/http-axum/src/api/insights.rs @@ -1,14 +1,12 @@ +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use api_types::insights::{InsightDto, InsightsQueryParams, InsightsResponse}; +use application::insights::get as get_insights_uc; use axum::{ Json, extract::{Query, State}, }; - -use application::insights::get as get_insights_uc; use domain::{errors::DomainError, models::Insight}; -use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; -use api_types::insights::{InsightDto, InsightsQueryParams, InsightsResponse}; - fn to_dto(insight: Insight) -> InsightDto { match insight { Insight::RatingLift { diff --git a/crates/adapters/http-axum/src/api/mod.rs b/crates/adapters/http-axum/src/api/mod.rs new file mode 100644 index 00000000..95440c08 --- /dev/null +++ b/crates/adapters/http-axum/src/api/mod.rs @@ -0,0 +1,201 @@ +pub mod auth; +pub mod diary; +pub mod goals; +pub mod import; +pub mod insights; +pub mod movies; +pub mod search; +pub mod social; +pub mod user_profile; +pub mod users; +pub mod watchlist; +pub mod webhook; +pub mod wrapup; + +use axum::{Router, routing}; + +use crate::state::AppState; + +/// JSON API routes (mounted under `/api/v1`), minus the webhook ingestion +/// endpoints ([`webhook_router`], separately rate limited) and the +/// federation-gated admin endpoints (`federation::api_routes`). +pub fn router() -> Router { + let base = Router::new() + .route("/diary", routing::get(diary::get_diary)) + .route( + "/movies/{id}/history", + routing::get(movies::get_review_history), + ) + .route("/movies", routing::get(movies::list_movies)) + .route("/movies/{id}", routing::get(movies::get_movie_detail)) + .route( + "/movies/{id}/profile", + routing::get(movies::get_movie_profile), + ) + .route("/reviews", routing::post(diary::post_review)) + .route( + "/reviews/{id}", + routing::delete(diary::delete_review).patch(diary::patch_review), + ) + .route( + "/movies/{id}/sync-poster", + routing::post(movies::sync_poster), + ) + .route("/auth/login", routing::post(auth::login)) + .route("/auth/register", routing::post(auth::register)) + .route("/auth/refresh", routing::post(auth::refresh)) + .route("/auth/logout", routing::post(auth::api_logout)) + .route("/diary/export", routing::get(diary::export_diary)) + .route("/activity-feed", routing::get(diary::get_activity_feed)) + .route("/users", routing::get(users::list_users)) + .route("/users/{id}", routing::get(user_profile::get_user_profile)) + .route("/import/sessions", routing::post(import::api_post_session)) + .route( + "/import/sessions/{id}", + routing::get(import::api_get_session), + ) + .route( + "/import/sessions/{id}/mapping", + routing::put(import::api_put_mapping), + ) + .route( + "/import/sessions/{id}/preview", + routing::get(import::api_get_preview), + ) + .route( + "/import/sessions/{id}/confirm", + routing::post(import::api_post_confirm), + ) + .route( + "/import/profiles", + routing::get(import::api_get_profiles).post(import::api_post_profile), + ) + .route( + "/import/profiles/{id}", + routing::delete(import::api_delete_profile), + ) + .route( + "/import/sessions/{id}/profile/{profile_id}", + routing::put(import::api_apply_profile), + ) + .route( + "/profile", + routing::get(users::get_profile).put(users::update_profile_handler), + ) + .route( + "/profile/fields", + routing::put(users::update_profile_fields_handler), + ) + .route("/search", routing::get(search::get_search)) + .route("/people/{id}", routing::get(search::get_person_handler)) + .route( + "/people/{id}/credits", + routing::get(search::get_person_credits_handler), + ) + .route( + "/watchlist", + routing::get(watchlist::get_watchlist_handler).post(watchlist::post_watchlist_add), + ) + .route( + "/watchlist/{movie_id}", + routing::get(watchlist::get_watchlist_status).delete(watchlist::delete_watchlist_entry), + ) + .route( + "/settings/webhook-tokens", + routing::get(webhook::get_webhook_tokens).post(webhook::post_generate_webhook_token), + ) + .route( + "/settings/webhook-tokens/{id}", + routing::delete(webhook::delete_webhook_token), + ) + .route("/watch-queue", routing::get(webhook::get_watch_queue)) + .route( + "/watch-queue/confirm", + routing::post(webhook::post_confirm_watch_events), + ) + .route( + "/watch-queue/dismiss", + routing::post(webhook::post_dismiss_watch_events), + ) + .route("/wrapups/generate", routing::post(wrapup::post_generate)) + .route("/wrapups", routing::get(wrapup::get_list)) + .route( + "/wrapups/{id}", + routing::get(wrapup::get_status).delete(wrapup::delete_wrapup_handler), + ) + .route("/wrapups/{id}/report", routing::get(wrapup::get_report)) + .route("/insights", routing::get(insights::get_insights)) + .route( + "/admin/reindex-search", + routing::post(search::post_reindex_search), + ) + .route( + "/goals", + routing::get(goals::list_goals).post(goals::create_goal), + ) + .route( + "/goals/{year}", + routing::put(goals::update_goal).delete(goals::delete_goal), + ) + .route("/users/{id}/goals", routing::get(goals::get_user_goals)) + .route( + "/settings", + routing::get(goals::get_settings).put(goals::update_settings), + ); + + base.merge(social_routes()) +} + +/// Media-server webhook ingestion endpoints (mounted under `/api/v1`). +pub fn webhook_router() -> Router { + Router::new() + .route( + "/webhooks/jellyfin", + routing::post(webhook::post_jellyfin_webhook), + ) + .route("/webhooks/plex", routing::post(webhook::post_plex_webhook)) +} + +fn social_routes() -> Router { + Router::new() + .route("/social/following", routing::get(social::get_following)) + .route("/social/followers", routing::get(social::get_followers)) + .route( + "/social/followers/pending", + routing::get(social::get_pending_followers), + ) + .route( + "/social/followers/pending/count", + routing::get(social::get_pending_follower_count), + ) + .route( + "/social/following/pending", + routing::get(social::get_pending_following), + ) + .route( + "/social/relationship", + routing::get(social::get_relationship), + ) + .route("/social/follow", routing::post(social::follow)) + .route("/social/unfollow", routing::post(social::unfollow)) + .route( + "/social/followers/accept", + routing::post(social::accept_follower), + ) + .route( + "/social/followers/reject", + routing::post(social::reject_follower), + ) + .route( + "/social/followers/remove", + routing::post(social::remove_follower), + ) + .route( + "/users/{id}/following", + routing::get(social::get_user_following), + ) + .route( + "/users/{id}/followers", + routing::get(social::get_user_followers), + ) +} diff --git a/crates/adapters/http-axum/src/handlers/movies.rs b/crates/adapters/http-axum/src/api/movies.rs similarity index 71% rename from crates/adapters/http-axum/src/handlers/movies.rs rename to crates/adapters/http-axum/src/api/movies.rs index 89f74ba0..fd893267 100644 --- a/crates/adapters/http-axum/src/handlers/movies.rs +++ b/crates/adapters/http-axum/src/api/movies.rs @@ -1,11 +1,9 @@ -use axum::{ - Json, - extract::{Extension, Path, Query, State}, - http::StatusCode, - response::IntoResponse, +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use api_types::{ + CastMemberDto, CrewMemberDto, GenreDto, KeywordDto, MovieDetailResponse, MovieProfileResponse, + MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams, ReviewHistoryResponse, + SocialFeedResponse, SocialReviewDto, }; -use uuid::Uuid; - use application::{ diary::{ commands::SyncPosterCommand, @@ -13,27 +11,15 @@ use application::{ queries::{GetMovieSocialPageQuery, GetReviewHistoryQuery}, }, movies::{get_movies, queries::GetMoviesQuery, sync_poster}, - watchlist::{is_on as is_on_watchlist, queries::IsOnWatchlistQuery}, +}; +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, }; use domain::services::review_history::Trend; - -use crate::html::templates::MovieDetailTemplate; -use crate::{ - csrf::CsrfToken, - errors::ApiError, - extractors::{AuthenticatedUser, OptionalCookieUser}, - render::render_page, - state::AppState, -}; -use api_types::{ - CastMemberDto, CrewMemberDto, GenreDto, KeywordDto, MovieDetailResponse, MovieProfileResponse, - MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams, ReviewHistoryResponse, - SocialFeedResponse, SocialReviewDto, -}; - -use super::helpers::build_page_context; - -// ── API ────────────────────────────────────────────────────────────────────── +use uuid::Uuid; #[utoipa::path( get, path = "/api/v1/movies", @@ -62,7 +48,7 @@ pub async fn list_movies( items: page .items .iter() - .map(crate::mappers::movies::summary_to_dto) + .map(api_types::mappers::summary_to_dto) .collect(), total_count: page.total_count, limit: page.limit, @@ -89,11 +75,11 @@ pub async fn get_review_history( .await?; Ok(Json(ReviewHistoryResponse { - movie: crate::mappers::movies::movie_to_dto(history.movie()), + movie: api_types::mappers::movie_to_dto(history.movie()), viewings: history .viewings() .iter() - .map(crate::mappers::movies::review_to_dto) + .map(api_types::mappers::review_to_dto) .collect(), trend: match trend { Trend::Improved => "improved", @@ -154,7 +140,7 @@ pub async fn get_movie_detail( .await?; Ok(Json(MovieDetailResponse { - movie: crate::mappers::movies::movie_to_dto(&result.movie), + movie: api_types::mappers::movie_to_dto(&result.movie), stats: MovieStatsDto { total_count: result.stats.total_count, avg_rating: result.stats.avg_rating, @@ -261,68 +247,3 @@ pub async fn get_movie_profile( Err(e) => crate::errors::domain_error_response(e), } } - -// ── HTML ───────────────────────────────────────────────────────────────────── - -pub async fn get_movie_detail_html( - OptionalCookieUser(user_id): OptionalCookieUser, - State(state): State, - Path(movie_id): Path, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - let ctx = build_page_context(&state, user_id.clone(), csrf.0).await; - let limit = params.limit.unwrap_or(20); - let offset = params.offset.unwrap_or(0); - - match get_movie_social_page::execute( - &state.app_ctx.deps.diary.get_movie_social_page, - GetMovieSocialPageQuery { - movie_id, - limit, - offset, - }, - ) - .await - { - Err(e) => crate::errors::domain_error_response(e), - Ok(result) => { - let histogram_max = result - .stats - .rating_histogram - .iter() - .copied() - .max() - .unwrap_or(1); - let has_more = - result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32; - let on_watchlist = match &user_id { - Some(uid) => is_on_watchlist::execute( - &state.app_ctx.deps.watchlist.is_on_watchlist, - IsOnWatchlistQuery { - user_id: uid.value(), - movie_id, - }, - ) - .await - .unwrap_or(false), - None => false, - }; - let current_offset = result.reviews.offset; - let reviews_limit = result.reviews.limit; - render_page(MovieDetailTemplate { - ctx: &ctx, - movie: &result.movie, - stats: &result.stats, - profile: result.profile.as_ref(), - reviews: result.reviews.items.as_slice(), - on_watchlist, - current_offset, - has_more, - limit: reviews_limit, - histogram_max, - }) - .into_response() - } - } -} diff --git a/crates/adapters/http-axum/src/handlers/search.rs b/crates/adapters/http-axum/src/api/search.rs similarity index 86% rename from crates/adapters/http-axum/src/handlers/search.rs rename to crates/adapters/http-axum/src/api/search.rs index 4f39f9bc..63da0f06 100644 --- a/crates/adapters/http-axum/src/handlers/search.rs +++ b/crates/adapters/http-axum/src/api/search.rs @@ -1,22 +1,18 @@ -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - response::{IntoResponse, Redirect}, -}; - -use application::{ - person::{get as get_person, get_credits as get_person_credits}, - search::execute as search_uc, -}; -use domain::models::{PersonId, collections::PageParams}; - use crate::state::AppState; use api_types::search::{ CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits, PersonCreditsDto, PersonSearchHitDto, SearchQueryParams, SearchResponse, }; - -// ── API ────────────────────────────────────────────────────────────────────── +use application::{ + person::{get as get_person, get_credits as get_person_credits}, + search::execute as search_uc, +}; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use domain::models::{PersonId, collections::PageParams}; #[utoipa::path( get, path = "/api/v1/search", @@ -107,9 +103,7 @@ pub async fn get_person_handler( ) .await { - Ok(Some(person)) => { - axum::Json(crate::mappers::search::person_to_dto(&person)).into_response() - } + Ok(Some(person)) => axum::Json(api_types::mappers::person_to_dto(&person)).into_response(), Ok(None) => StatusCode::NOT_FOUND.into_response(), Err(e) => crate::errors::domain_error_response(e), } @@ -135,7 +129,7 @@ pub async fn get_person_credits_handler( .await { Ok(credits) => axum::Json(PersonCreditsDto { - person: crate::mappers::search::person_to_dto(&credits.person), + person: api_types::mappers::person_to_dto(&credits.person), cast: credits .cast .iter() @@ -178,13 +172,3 @@ pub async fn post_reindex_search( } } } - -// ── HTML ───────────────────────────────────────────────────────────────────── - -pub async fn get_tag(Path(tag): Path) -> impl IntoResponse { - if tag.eq_ignore_ascii_case("moviesdiary") { - Redirect::temporary("/") - } else { - Redirect::temporary(&format!("/?search={}", tag)) - } -} diff --git a/crates/adapters/http-axum/src/api/social.rs b/crates/adapters/http-axum/src/api/social.rs new file mode 100644 index 00000000..be1c54ac --- /dev/null +++ b/crates/adapters/http-axum/src/api/social.rs @@ -0,0 +1,348 @@ +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use api_types::{ + ActorListResponse, ActorUrlRequest, FollowRelationResponse, FollowRequest, + PendingCountResponse, RemoteActorDto, +}; +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor, SocialIdentity}; +use uuid::Uuid; + +fn social_actor_to_dto(actor: SocialActor, instance: &InstanceIdentity) -> RemoteActorDto { + RemoteActorDto { + url: instance.actor_url_of(&actor.identity), + user_id: match &actor.identity { + SocialIdentity::Local(uid) => Some(uid.value()), + SocialIdentity::Remote { .. } => None, + }, + handle: actor.handle, + display_name: actor.display_name, + avatar_url: actor.avatar_url, + } +} + +#[utoipa::path( + get, path = "/api/v1/social/following", + responses( + (status = 200, body = ActorListResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_following( + State(state): State, + user: AuthenticatedUser, +) -> Result, ApiError> { + let instance = state.app_ctx.instance.clone(); + let identities = application::social::get_following::execute( + &state.app_ctx.deps.social.query, + user.0.value(), + ) + .await?; + Ok(Json(ActorListResponse { + actors: identities + .into_iter() + .map(|a| social_actor_to_dto(a, &instance)) + .collect(), + })) +} + +#[utoipa::path( + get, path = "/api/v1/social/followers", + responses( + (status = 200, body = ActorListResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_followers( + State(state): State, + user: AuthenticatedUser, +) -> Result, ApiError> { + let instance = state.app_ctx.instance.clone(); + let identities = application::social::get_followers::execute( + &state.app_ctx.deps.social.query, + user.0.value(), + ) + .await?; + Ok(Json(ActorListResponse { + actors: identities + .into_iter() + .map(|a| social_actor_to_dto(a, &instance)) + .collect(), + })) +} + +pub async fn get_user_following( + State(state): State, + _user: AuthenticatedUser, + Path(user_id): Path, +) -> Result, ApiError> { + let instance = state.app_ctx.instance.clone(); + let identities = + application::social::get_following::execute(&state.app_ctx.deps.social.query, user_id) + .await?; + Ok(Json(ActorListResponse { + actors: identities + .into_iter() + .map(|a| social_actor_to_dto(a, &instance)) + .collect(), + })) +} + +pub async fn get_user_followers( + State(state): State, + _user: AuthenticatedUser, + Path(user_id): Path, +) -> Result, ApiError> { + let instance = state.app_ctx.instance.clone(); + let identities = + application::social::get_followers::execute(&state.app_ctx.deps.social.query, user_id) + .await?; + Ok(Json(ActorListResponse { + actors: identities + .into_iter() + .map(|a| social_actor_to_dto(a, &instance)) + .collect(), + })) +} + +#[utoipa::path( + post, path = "/api/v1/social/follow", + request_body = FollowRequest, + responses( + (status = 200, description = "Follow request sent"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn follow( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> Result { + application::social::execute::execute_command( + &state.app_ctx.deps.social.command, + application::social::commands::SocialCmd::Follow { + follower_id: user.0.value(), + target: FollowTarget::Handle(body.handle), + }, + ) + .await?; + Ok(StatusCode::OK) +} + +#[utoipa::path( + post, path = "/api/v1/social/unfollow", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Unfollowed"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn unfollow( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> Result { + let instance = state.app_ctx.instance.clone(); + application::social::execute::execute_command( + &state.app_ctx.deps.social.command, + application::social::commands::SocialCmd::Unfollow { + follower_id: user.0.value(), + target: instance.identify(&body.actor_url), + }, + ) + .await?; + Ok(StatusCode::OK) +} + +#[utoipa::path( + post, path = "/api/v1/social/followers/accept", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Follower accepted"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn accept_follower( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> Result { + let instance = state.app_ctx.instance.clone(); + application::social::execute::execute_command( + &state.app_ctx.deps.social.command, + application::social::commands::SocialCmd::AcceptFollow { + owner_id: user.0.value(), + requester: instance.identify(&body.actor_url), + }, + ) + .await?; + Ok(StatusCode::OK) +} + +#[utoipa::path( + post, path = "/api/v1/social/followers/reject", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Follower rejected"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn reject_follower( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> Result { + let instance = state.app_ctx.instance.clone(); + application::social::execute::execute_command( + &state.app_ctx.deps.social.command, + application::social::commands::SocialCmd::RejectFollow { + owner_id: user.0.value(), + requester: instance.identify(&body.actor_url), + }, + ) + .await?; + Ok(StatusCode::OK) +} + +#[utoipa::path( + post, path = "/api/v1/social/followers/remove", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Follower removed"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn remove_follower( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> Result { + let instance = state.app_ctx.instance.clone(); + application::social::execute::execute_command( + &state.app_ctx.deps.social.command, + application::social::commands::SocialCmd::RemoveFollower { + owner_id: user.0.value(), + follower: instance.identify(&body.actor_url), + }, + ) + .await?; + Ok(StatusCode::OK) +} + +#[utoipa::path( + get, path = "/api/v1/social/followers/pending", + responses( + (status = 200, body = ActorListResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_pending_followers( + State(state): State, + user: AuthenticatedUser, +) -> Result, ApiError> { + let instance = state.app_ctx.instance.clone(); + let identities = application::social::get_pending_followers::execute( + &state.app_ctx.deps.social.query, + user.0.value(), + ) + .await?; + Ok(Json(ActorListResponse { + actors: identities + .into_iter() + .map(|a| social_actor_to_dto(a, &instance)) + .collect(), + })) +} + +#[utoipa::path( + get, path = "/api/v1/social/followers/pending/count", + responses( + (status = 200, body = PendingCountResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_pending_follower_count( + State(state): State, + user: AuthenticatedUser, +) -> Result, ApiError> { + let count = application::social::count_pending_followers::execute( + &state.app_ctx.deps.social.query, + user.0.value(), + ) + .await?; + Ok(Json(PendingCountResponse { count })) +} + +#[utoipa::path( + get, path = "/api/v1/social/following/pending", + responses( + (status = 200, body = ActorListResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_pending_following( + State(state): State, + user: AuthenticatedUser, +) -> Result, ApiError> { + let instance = state.app_ctx.instance.clone(); + let actors = application::social::get_pending_following::execute( + &state.app_ctx.deps.social.query, + user.0.value(), + ) + .await?; + Ok(Json(ActorListResponse { + actors: actors + .into_iter() + .map(|a| social_actor_to_dto(a, &instance)) + .collect(), + })) +} + +#[derive(serde::Deserialize)] +pub struct RelationshipQuery { + pub actor_url: String, +} + +#[utoipa::path( + get, path = "/api/v1/social/relationship", + params(("actor_url" = String, Query, description = "Canonical actor URL of the target")), + responses( + (status = 200, body = FollowRelationResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_relationship( + State(state): State, + user: AuthenticatedUser, + Query(q): Query, +) -> Result, ApiError> { + let target = state.app_ctx.instance.identify(&q.actor_url); + let rel = application::social::get_relation::execute( + &state.app_ctx.deps.social.query, + user.0.value(), + target, + ) + .await?; + Ok(Json(FollowRelationResponse { + following: rel.following.into(), + followed_by: rel.followed_by.into(), + })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/adapters/http-axum/src/handlers/tests/social.rs b/crates/adapters/http-axum/src/api/social/tests.rs similarity index 73% rename from crates/adapters/http-axum/src/handlers/tests/social.rs rename to crates/adapters/http-axum/src/api/social/tests.rs index 5c4999bf..0a196dcf 100644 --- a/crates/adapters/http-axum/src/handlers/tests/social.rs +++ b/crates/adapters/http-axum/src/api/social/tests.rs @@ -25,29 +25,6 @@ fn local_actor_dto_url_round_trips_back_to_the_same_identity() { ); } -#[cfg(feature = "federation")] -#[test] -fn local_blocked_dto_url_round_trips_back_to_the_same_identity() { - let instance = InstanceIdentity::new("https://md.example"); - let uid = UserId::from_uuid(uuid::Uuid::new_v4()); - - let dto = social_actor_to_blocked_dto(local_actor(&uid), &instance); - - assert!(!dto.url.starts_with("local:"), "url was {}", dto.url); - assert_eq!(instance.identify(&dto.url), SocialIdentity::Local(uid)); -} - -#[test] -fn local_template_url_round_trips_back_to_the_same_identity() { - let instance = InstanceIdentity::new("https://md.example"); - let uid = UserId::from_uuid(uuid::Uuid::new_v4()); - - let data = social_actor_to_template(local_actor(&uid), &instance); - - assert!(!data.url.starts_with("local:"), "url was {}", data.url); - assert_eq!(instance.identify(&data.url), SocialIdentity::Local(uid)); -} - #[test] fn remote_actor_dto_url_is_passed_through_unchanged() { let instance = InstanceIdentity::new("https://md.example"); diff --git a/crates/adapters/http-axum/src/api/user_profile.rs b/crates/adapters/http-axum/src/api/user_profile.rs new file mode 100644 index 00000000..6be466c7 --- /dev/null +++ b/crates/adapters/http-axum/src/api/user_profile.rs @@ -0,0 +1,279 @@ +use crate::{extractors::AuthenticatedUser, state::AppState}; +use api_types::mappers::goal_with_progress_to_dto; +use api_types::{ + ActorStatDto, DiaryResponse, DirectorStatDto, GenreStatDto, MonthActivityDto, MonthlyRatingDto, + UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserTrendsDto, WatchMediumStatDto, +}; +use application::users::{ + get_federated_profile, get_federated_profile_stats, get_local_profile, + queries::GetUserProfileQuery, +}; +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use domain::errors::DomainError; +use std::str::FromStr; +use uuid::Uuid; + +#[utoipa::path( + get, path = "/api/v1/users/{id}", + params( + ("id" = Uuid, Path, description = "User ID"), + UserProfileQueryParams, + ), + responses( + (status = 200, body = UserProfileResponse), + (status = 404, description = "User not found"), + ) +)] +pub async fn get_user_profile( + State(state): State, + AuthenticatedUser(viewer_id): AuthenticatedUser, + Path(user_id): Path, + Query(params): Query, +) -> impl IntoResponse { + let view_str = params.view.as_deref().unwrap_or("recent"); + let profile_view = match application::users::queries::ProfileView::from_str(view_str) { + Ok(v) => v, + Err(_) => return StatusCode::BAD_REQUEST.into_response(), + }; + + let profile = match get_local_profile::execute( + &state.app_ctx.deps.users.get_local_profile, + GetUserProfileQuery { + user_id, + view: profile_view, + limit: params.limit, + offset: params.offset, + sort_by: domain::models::FeedSortBy::Date, + search: params.search.clone(), + is_own_profile: viewer_id.value() == user_id, + include_remote: false, + }, + ) + .await + { + Ok(p) => p, + Err(DomainError::NotFound(_)) => { + if let Ok(Some(fed)) = get_federated_profile::execute( + &state.app_ctx.deps.users.get_federated_profile, + user_id, + ) + .await + { + return build_federated_profile_response( + &state, + user_id, + fed, + profile_view, + ¶ms, + ) + .await; + } + return StatusCode::NOT_FOUND.into_response(); + } + Err(e) => return crate::errors::domain_error_response(e), + }; + + let view_data = if let Some(p) = profile.entries { + Some(api_types::ProfileViewData::Entries { + entries: DiaryResponse { + items: p + .items + .iter() + .map(api_types::mappers::entry_to_dto) + .collect(), + total_count: p.total_count, + limit: p.limit, + offset: p.offset, + }, + }) + } else if let Some(h) = profile.history { + Some(api_types::ProfileViewData::History { + history: application::users::group_by_month(h) + .into_iter() + .map(|m| MonthActivityDto { + year_month: m.year_month, + month_label: m.month_label, + count: m.count, + entries: m + .entries + .iter() + .map(api_types::mappers::entry_to_dto) + .collect(), + }) + .collect(), + }) + } else { + profile.trends.map(|t| api_types::ProfileViewData::Trends { + trends: trends_to_dto(t), + }) + }; + + Json(UserProfileResponse { + user_id, + profile: api_types::UserProfileBase { + username: profile.identity.username.clone(), + avatar_url: profile.identity.avatar_url.clone(), + banner_url: profile.identity.banner_url.clone(), + display_name: profile.identity.display_name.clone(), + bio: profile.identity.bio.clone(), + }, + stats: UserStatsDto { + total_movies: profile.stats.total_movies, + avg_rating: profile.stats.avg_rating, + favorite_director: profile.stats.favorite_director, + most_active_month: profile.stats.most_active_month, + }, + following_count: profile.following_count, + followers_count: profile.followers_count, + view_data, + goals: { + let goals_list = application::goals::list::execute( + &state.app_ctx.deps.goals.query, + application::goals::queries::ListGoalsQuery { user_id }, + ) + .await + .unwrap_or_default(); + if goals_list.is_empty() { + None + } else { + Some(goals_list.iter().map(goal_with_progress_to_dto).collect()) + } + }, + is_federated: false, + handle: Some(profile.identity.handle.clone()), + actor_url: Some(profile.identity.actor_url.clone()), + }) + .into_response() +} + +async fn build_federated_profile_response( + state: &AppState, + user_id: Uuid, + fed: domain::models::FederatedProfile, + profile_view: application::users::queries::ProfileView, + params: &UserProfileQueryParams, +) -> axum::response::Response { + let profile = match get_federated_profile_stats::execute( + &state.app_ctx.deps.users.get_federated_profile_stats, + GetUserProfileQuery { + user_id, + view: profile_view, + limit: params.limit, + offset: params.offset, + sort_by: domain::models::FeedSortBy::Date, + search: params.search.clone(), + is_own_profile: false, + include_remote: true, + }, + ) + .await + { + Ok(p) => p, + Err(e) => return crate::errors::domain_error_response(e), + }; + + let view_data = if let Some(p) = profile.entries { + Some(api_types::ProfileViewData::Entries { + entries: DiaryResponse { + items: p + .items + .iter() + .map(api_types::mappers::entry_to_dto) + .collect(), + total_count: p.total_count, + limit: p.limit, + offset: p.offset, + }, + }) + } else { + profile.trends.map(|t| api_types::ProfileViewData::Trends { + trends: trends_to_dto(t), + }) + }; + + let username = fed + .display_name + .clone() + .unwrap_or_else(|| fed.handle.clone()); + + Json(UserProfileResponse { + user_id, + profile: api_types::UserProfileBase { + username, + avatar_url: fed.avatar_url, + banner_url: fed.banner_url, + display_name: fed.display_name, + bio: fed.bio, + }, + stats: UserStatsDto { + total_movies: profile.stats.total_movies, + avg_rating: profile.stats.avg_rating, + favorite_director: profile.stats.favorite_director, + most_active_month: profile.stats.most_active_month, + }, + following_count: 0, + followers_count: 0, + view_data, + goals: None, + is_federated: true, + handle: Some(fed.handle), + actor_url: Some(fed.actor_url), + }) + .into_response() +} + +fn trends_to_dto(t: domain::models::UserTrends) -> UserTrendsDto { + UserTrendsDto { + monthly_ratings: t + .monthly_ratings + .into_iter() + .map(|r| MonthlyRatingDto { + year_month: r.year_month, + month_label: r.month_label, + avg_rating: r.avg_rating, + count: r.count, + }) + .collect(), + top_directors: t + .top_directors + .into_iter() + .map(|d| DirectorStatDto { + director: d.director, + count: d.count, + }) + .collect(), + max_director_count: t.max_director_count, + top_genres: t + .top_genres + .into_iter() + .map(|g| GenreStatDto { + genre: g.genre, + count: g.count, + }) + .collect(), + rating_distribution: t.rating_distribution, + watch_medium_distribution: t + .watch_medium_distribution + .into_iter() + .map(|m| WatchMediumStatDto { + medium: m.medium, + count: m.count, + }) + .collect(), + top_actors: t + .top_actors + .into_iter() + .map(|a| ActorStatDto { + name: a.name, + profile_path: a.profile_path, + movie_count: a.movie_count, + score: a.score, + }) + .collect(), + } +} diff --git a/crates/adapters/http-axum/src/api/users.rs b/crates/adapters/http-axum/src/api/users.rs new file mode 100644 index 00000000..71589032 --- /dev/null +++ b/crates/adapters/http-axum/src/api/users.rs @@ -0,0 +1,152 @@ +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use api_types::{ProfileResponse, UserSummaryDto, UsersResponse}; +use application::users::{ + get_users, queries::GetUsersQuery, update_profile, update_profile_fields, +}; +use axum::{ + Json, + extract::{Multipart, State}, + http::StatusCode, + response::IntoResponse, +}; + +#[utoipa::path( + get, path = "/api/v1/profile", + responses( + (status = 200, body = ProfileResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "User not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_profile( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, +) -> Result, ApiError> { + let profile = application::users::get_current_profile::execute( + &state.app_ctx.deps.users.get_current_profile, + application::users::queries::GetCurrentProfileQuery { + user_id: user_id.value(), + }, + ) + .await?; + let instance = &state.app_ctx.instance; + Ok(Json(ProfileResponse { + profile: api_types::UserProfileBase { + username: profile.username, + display_name: profile.display_name, + bio: profile.bio, + avatar_url: profile.avatar_path.map(|p| instance.image_url_for(&p)), + banner_url: profile.banner_path.map(|p| instance.image_url_for(&p)), + }, + also_known_as: profile.also_known_as, + fields: profile + .fields + .into_iter() + .map(|f| api_types::ProfileFieldDto { + name: f.name, + value: f.value, + }) + .collect(), + role: profile.role, + })) +} + +#[utoipa::path( + put, path = "/api/v1/profile", + responses( + (status = 204, description = "Profile updated"), + (status = 400, description = "Invalid input"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn update_profile_handler( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + multipart: Multipart, +) -> impl IntoResponse { + let data = crate::support::parse_profile_multipart(multipart).await; + + let cmd = application::users::commands::UpdateProfileCommand { + user_id: user_id.value(), + display_name: data.display_name, + bio: data.bio, + avatar_bytes: data.avatar_bytes, + avatar_content_type: data.avatar_content_type, + banner_bytes: data.banner_bytes, + banner_content_type: data.banner_content_type, + also_known_as: data.also_known_as, + }; + + match update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => crate::errors::domain_error_response(e), + } +} + +#[utoipa::path( + put, path = "/api/v1/profile/fields", + request_body = api_types::UpdateProfileFieldsRequest, + responses( + (status = 204, description = "Profile fields updated"), + (status = 400, description = "Invalid input"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn update_profile_fields_handler( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + let raw_fields = match body.get("fields").and_then(|f| f.as_array()) { + Some(arr) => arr.clone(), + None => return StatusCode::BAD_REQUEST.into_response(), + }; + + let fields: Vec = raw_fields + .iter() + .filter_map(|f| { + let name = f.get("name").and_then(|n| n.as_str())?.to_string(); + let value = f.get("value").and_then(|v| v.as_str())?.to_string(); + Some(domain::models::ProfileField { name, value }) + }) + .collect(); + + let cmd = application::users::commands::UpdateProfileFieldsCommand { + user_id: user_id.value(), + fields, + }; + + match update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, cmd).await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => crate::errors::domain_error_response(e), + } +} + +#[utoipa::path( + get, path = "/api/v1/users", + responses((status = 200, body = UsersResponse)), +)] +pub async fn list_users(State(state): State) -> Result, ApiError> { + let result = + get_users::execute(&state.app_ctx.deps.users.get_users_list, GetUsersQuery).await?; + Ok(Json(UsersResponse { + users: result + .users + .iter() + .map(|u| UserSummaryDto { + id: u.user_id.value(), + email: u.email().to_string(), + username: u.username().to_string(), + display_name: u.display_name().map(String::from), + total_movies: u.total_movies, + avg_rating: u.avg_rating, + }) + .collect(), + })) +} diff --git a/crates/adapters/http-axum/src/api/watchlist.rs b/crates/adapters/http-axum/src/api/watchlist.rs new file mode 100644 index 00000000..7753d75c --- /dev/null +++ b/crates/adapters/http-axum/src/api/watchlist.rs @@ -0,0 +1,148 @@ +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use api_types::{ + AddToWatchlistRequest, PaginationQueryParams, WatchlistEntryDto, WatchlistResponse, + WatchlistStatusResponse, +}; +use application::{ + diary::commands::MovieInput, + watchlist::{ + add as add_to_watchlist, + commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand}, + get as get_watchlist, is_on as is_on_watchlist, + queries::{GetWatchlistQuery, IsOnWatchlistQuery}, + remove as remove_from_watchlist, + }, +}; +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use uuid::Uuid; + +#[utoipa::path( + get, path = "/api/v1/watchlist", + params( + ("limit" = Option, Query, description = "Max results"), + ("offset" = Option, Query, description = "Offset"), + ), + responses( + (status = 200, body = WatchlistResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_watchlist_handler( + State(state): State, + user: AuthenticatedUser, + Query(params): Query, +) -> Result, ApiError> { + let page = get_watchlist::execute( + &state.app_ctx.deps.watchlist.get_watchlist, + GetWatchlistQuery { + user_id: user.0.value(), + limit: params.limit, + offset: params.offset, + }, + ) + .await?; + + Ok(Json(WatchlistResponse { + items: page + .items + .into_iter() + .map(|w| WatchlistEntryDto { + id: w.entry.id.value(), + movie: api_types::mappers::movie_to_dto(&w.movie), + added_at: w.entry.added_at.to_string(), + }) + .collect(), + total_count: page.total_count, + limit: page.limit, + offset: page.offset, + })) +} + +#[utoipa::path( + post, path = "/api/v1/watchlist", + request_body = AddToWatchlistRequest, + responses( + (status = 201, description = "Added to watchlist"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Movie not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn post_watchlist_add( + State(state): State, + user: AuthenticatedUser, + Json(req): Json, +) -> Result { + add_to_watchlist::execute( + &state.app_ctx.deps.watchlist.add, + AddToWatchlistCommand { + user_id: user.0.value(), + input: MovieInput { + movie_id: req.movie_id, + external_metadata_id: req.external_metadata_id, + manual_title: req.manual_title, + manual_release_year: req.manual_release_year, + manual_director: None, + }, + }, + ) + .await?; + Ok(StatusCode::CREATED) +} + +#[utoipa::path( + delete, path = "/api/v1/watchlist/{movie_id}", + params(("movie_id" = Uuid, Path, description = "Movie ID")), + responses( + (status = 204, description = "Removed from watchlist"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not on watchlist"), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_watchlist_entry( + State(state): State, + user: AuthenticatedUser, + Path(movie_id): Path, +) -> Result { + remove_from_watchlist::execute( + &state.app_ctx.deps.watchlist.remove_from_watchlist, + RemoveFromWatchlistCommand { + user_id: user.0.value(), + movie_id, + }, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + get, path = "/api/v1/watchlist/{movie_id}", + params(("movie_id" = Uuid, Path, description = "Movie ID")), + responses( + (status = 200, body = WatchlistStatusResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_watchlist_status( + State(state): State, + user: AuthenticatedUser, + Path(movie_id): Path, +) -> Result, ApiError> { + let on_watchlist = is_on_watchlist::execute( + &state.app_ctx.deps.watchlist.is_on_watchlist, + IsOnWatchlistQuery { + user_id: user.0.value(), + movie_id, + }, + ) + .await?; + Ok(Json(WatchlistStatusResponse { on_watchlist })) +} diff --git a/crates/adapters/http-axum/src/handlers/webhook.rs b/crates/adapters/http-axum/src/api/webhook.rs similarity index 94% rename from crates/adapters/http-axum/src/handlers/webhook.rs rename to crates/adapters/http-axum/src/api/webhook.rs index de53ea42..d0a52818 100644 --- a/crates/adapters/http-axum/src/handlers/webhook.rs +++ b/crates/adapters/http-axum/src/api/webhook.rs @@ -1,11 +1,4 @@ -use axum::{ - Json, - extract::{Multipart, Path, State}, - http::{HeaderMap, StatusCode}, - response::IntoResponse, -}; -use uuid::Uuid; - +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse, GenerateTokenRequest, GenerateTokenResponse, WatchQueueEntryDto, WebhookTokenDto, @@ -21,9 +14,14 @@ use application::integrations::{ queries::{GetWatchQueueQuery, GetWebhookTokensQuery}, revoke_token as revoke_webhook_token, }; +use axum::{ + Json, + extract::{Multipart, Path, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, +}; use domain::models::WatchEventSource; - -use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use uuid::Uuid; fn extract_bearer_token(headers: &HeaderMap) -> Option { headers @@ -42,8 +40,6 @@ fn extract_webhook_token(headers: &HeaderMap, query: &WebhookTokenQuery) -> Opti extract_bearer_token(headers).or_else(|| query.token.clone()) } -// ── Webhook ingestion (no JWT, uses webhook bearer token) ───────────────────── - #[utoipa::path( post, path = "/api/v1/webhooks/jellyfin", request_body(content = String, description = "Jellyfin webhook JSON payload (SendAllProperties=true)", content_type = "application/json"), @@ -79,8 +75,6 @@ pub async fn post_jellyfin_webhook( .await } -// ── Plex webhook (multipart form data with `payload` JSON field) ────────────── - #[utoipa::path( post, path = "/api/v1/webhooks/plex", request_body(content = String, description = "Plex webhook multipart form (payload field contains JSON)", content_type = "multipart/form-data"), @@ -143,8 +137,6 @@ async fn run_ingest( } } -// ── Token management (JWT-authenticated) ────────────────────────────────────── - #[utoipa::path( post, path = "/api/v1/settings/webhook-tokens", request_body = GenerateTokenRequest, @@ -243,8 +235,6 @@ pub async fn delete_webhook_token( Ok(StatusCode::NO_CONTENT) } -// ── Watch queue (JWT-authenticated) ─────────────────────────────────────────── - #[utoipa::path( get, path = "/api/v1/watch-queue", responses( diff --git a/crates/adapters/http-axum/src/handlers/wrapup.rs b/crates/adapters/http-axum/src/api/wrapup.rs similarity index 62% rename from crates/adapters/http-axum/src/handlers/wrapup.rs rename to crates/adapters/http-axum/src/api/wrapup.rs index 026dc598..b17b1af2 100644 --- a/crates/adapters/http-axum/src/handlers/wrapup.rs +++ b/crates/adapters/http-axum/src/api/wrapup.rs @@ -1,31 +1,27 @@ -use axum::{ - Json, - extract::{Extension, Path, State}, - http::StatusCode, - response::IntoResponse, -}; -use chrono::NaiveDate; -use uuid::Uuid; - -use application::wrapup::{ - commands::RequestWrapUpCommand, - delete as delete_wrapup, generate, get_ready_report, get_wrapup, - list_wrapups::{self, ListWrapUpsQuery}, -}; -use domain::errors::DomainError; -use domain::models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus}; -use domain::value_objects::WrapUpId; - use crate::{ - csrf::CsrfToken, errors::ApiError, - extractors::{AdminApiUser, AuthenticatedUser, OptionalCookieUser}, - render::render_page, + extractors::{AdminApiUser, AuthenticatedUser}, state::AppState, }; use api_types::wrapup::{ GenerateWrapUpRequest, WrapUpGeneratedResponse, WrapUpListResponse, WrapUpStatusResponse, }; +use application::wrapup::{ + commands::RequestWrapUpCommand, + delete as delete_wrapup, generate, get_wrapup, + list_wrapups::{self, ListWrapUpsQuery}, +}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use chrono::NaiveDate; +use domain::errors::DomainError; +use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; +use domain::value_objects::WrapUpId; +use uuid::Uuid; fn record_to_dto(r: &WrapUpRecord) -> WrapUpStatusResponse { WrapUpStatusResponse { @@ -178,98 +174,3 @@ pub async fn delete_wrapup_handler( .await?; Ok(StatusCode::NO_CONTENT) } - -// ── HTML handlers ─────────────────────────────────────────────────────────── - -fn format_watch_time(minutes: u32) -> String { - let h = minutes / 60; - let m = minutes % 60; - if h > 0 && m > 0 { - format!("{}h {}m", h, m) - } else if h > 0 { - format!("{}h", h) - } else { - format!("{}m", m) - } -} - -fn render_wrapup( - report: &WrapUpReport, - year: i32, - ctx: &api_types::HtmlPageContext, -) -> axum::response::Response { - let rating_max = report - .rating_distribution - .iter() - .copied() - .max() - .unwrap_or(1) - .max(1); - let rating_pcts: [f64; 5] = - std::array::from_fn(|i| report.rating_distribution[i] as f64 / rating_max as f64 * 100.0); - let genre_max = report - .top_genres - .first() - .map(|g| g.count) - .unwrap_or(1) - .max(1); - let genre_pcts: Vec = report - .top_genres - .iter() - .take(8) - .map(|g| g.count as f64 / genre_max as f64 * 100.0) - .collect(); - let tmpl = crate::html::templates::WrapUpPageTemplate { - ctx, - report, - year_label: year.to_string(), - watch_time_display: format_watch_time(report.total_watch_time_minutes), - rating_max, - genre_max, - rating_pcts, - genre_pcts, - }; - render_page(tmpl) -} - -pub async fn get_user_wrapup_html( - OptionalCookieUser(viewer): OptionalCookieUser, - State(state): State, - Path((user_id, year)): Path<(Uuid, i32)>, - Extension(csrf): Extension, -) -> impl IntoResponse { - let scope = WrapUpScope::User(user_id); - let report = - match get_ready_report::execute(&state.app_ctx.deps.wrapup.get_ready_report, scope, year) - .await - { - Ok(report) => report, - Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(), - Err(_) => return StatusCode::NOT_FOUND.into_response(), - }; - - let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await; - render_wrapup(&report, year, &ctx) -} - -pub async fn get_global_wrapup_html( - OptionalCookieUser(viewer): OptionalCookieUser, - State(state): State, - Path(year): Path, - Extension(csrf): Extension, -) -> impl IntoResponse { - let report = match get_ready_report::execute( - &state.app_ctx.deps.wrapup.get_ready_report, - WrapUpScope::Global, - year, - ) - .await - { - Ok(report) => report, - Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(), - Err(_) => return StatusCode::NOT_FOUND.into_response(), - }; - - let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await; - render_wrapup(&report, year, &ctx) -} diff --git a/crates/adapters/http-axum/src/extractors.rs b/crates/adapters/http-axum/src/extractors/mod.rs similarity index 99% rename from crates/adapters/http-axum/src/extractors.rs rename to crates/adapters/http-axum/src/extractors/mod.rs index b10d0a22..392ee65b 100644 --- a/crates/adapters/http-axum/src/extractors.rs +++ b/crates/adapters/http-axum/src/extractors/mod.rs @@ -155,5 +155,4 @@ where } #[cfg(test)] -#[path = "tests/extractors.rs"] pub(crate) mod tests; diff --git a/crates/adapters/http-axum/src/tests/extractors.rs b/crates/adapters/http-axum/src/extractors/tests.rs similarity index 100% rename from crates/adapters/http-axum/src/tests/extractors.rs rename to crates/adapters/http-axum/src/extractors/tests.rs diff --git a/crates/adapters/http-axum/src/federation/blocklist_api.rs b/crates/adapters/http-axum/src/federation/blocklist_api.rs new file mode 100644 index 00000000..7f213985 --- /dev/null +++ b/crates/adapters/http-axum/src/federation/blocklist_api.rs @@ -0,0 +1,169 @@ +use crate::extractors::AdminApiUser; +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use api_types::ActorUrlRequest; +use api_types::{AddBlockedDomainRequest, BlockedActorResponse, BlockedDomainResponse}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; +use domain::value_objects::{InstanceIdentity, SocialActor}; + +fn social_actor_to_blocked_dto( + actor: SocialActor, + instance: &InstanceIdentity, +) -> BlockedActorResponse { + BlockedActorResponse { + url: instance.actor_url_of(&actor.identity), + handle: actor.handle, + display_name: actor.display_name, + avatar_url: actor.avatar_url, + } +} + +#[utoipa::path( + get, path = "/api/v1/admin/blocked-domains", + responses( + (status = 200, body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - admin only"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_blocked_domains_admin( + State(state): State, + _admin: AdminApiUser, +) -> Result>, ApiError> { + let domains = state.app_ctx.ap_blocklist.get_blocked_domains().await?; + Ok(Json( + domains + .into_iter() + .map(|d| BlockedDomainResponse { + domain: d.domain, + reason: d.reason, + blocked_at: d.blocked_at, + }) + .collect(), + )) +} + +#[utoipa::path( + post, path = "/api/v1/admin/blocked-domains", + request_body = AddBlockedDomainRequest, + responses( + (status = 201, description = "Domain blocked"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - admin only"), + ), + security(("bearer_auth" = [])) +)] +pub async fn add_blocked_domain_admin( + State(state): State, + _admin: AdminApiUser, + axum::Json(body): axum::Json, +) -> Result { + state + .app_ctx + .ap_blocklist + .add_blocked_domain(&body.domain, body.reason.as_deref()) + .await?; + Ok(StatusCode::CREATED) +} + +#[utoipa::path( + delete, path = "/api/v1/admin/blocked-domains/{domain}", + params(("domain" = String, Path, description = "Domain to unblock")), + responses( + (status = 204, description = "Domain unblocked"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - admin only"), + ), + security(("bearer_auth" = [])) +)] +pub async fn remove_blocked_domain_admin( + State(state): State, + _admin: AdminApiUser, + axum::extract::Path(domain): axum::extract::Path, +) -> Result { + state + .app_ctx + .ap_blocklist + .remove_blocked_domain(&domain) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, path = "/api/v1/social/block", + request_body = ActorUrlRequest, + responses( + (status = 204, description = "Actor blocked"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn block_actor_api( + State(state): State, + user: AuthenticatedUser, + axum::Json(body): axum::Json, +) -> Result { + let instance = state.app_ctx.instance.clone(); + application::social::execute::execute_command( + &state.app_ctx.deps.social.command, + application::social::commands::SocialCmd::Block { + blocker_id: user.0.value(), + target: instance.identify(&body.actor_url), + }, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, path = "/api/v1/social/unblock", + request_body = ActorUrlRequest, + responses( + (status = 204, description = "Actor unblocked"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn unblock_actor_api( + State(state): State, + user: AuthenticatedUser, + axum::Json(body): axum::Json, +) -> Result { + let instance = state.app_ctx.instance.clone(); + application::social::execute::execute_command( + &state.app_ctx.deps.social.command, + application::social::commands::SocialCmd::Unblock { + blocker_id: user.0.value(), + target: instance.identify(&body.actor_url), + }, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + get, path = "/api/v1/social/blocked", + responses( + (status = 200, body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_blocked_actors_api( + State(state): State, + user: AuthenticatedUser, +) -> Result>, ApiError> { + let instance = state.app_ctx.instance.clone(); + let identities = + application::social::get_blocked::execute(&state.app_ctx.deps.social.query, user.0.value()) + .await?; + Ok(Json( + identities + .into_iter() + .map(|a| social_actor_to_blocked_dto(a, &instance)) + .collect(), + )) +} + +#[cfg(test)] +mod tests; diff --git a/crates/adapters/http-axum/src/federation/blocklist_api/tests.rs b/crates/adapters/http-axum/src/federation/blocklist_api/tests.rs new file mode 100644 index 00000000..900819de --- /dev/null +++ b/crates/adapters/http-axum/src/federation/blocklist_api/tests.rs @@ -0,0 +1,22 @@ +use super::*; +use domain::value_objects::{InstanceIdentity, SocialActor, SocialIdentity, UserId}; + +fn local_actor(uid: &UserId) -> SocialActor { + SocialActor { + identity: SocialIdentity::Local(uid.clone()), + handle: "@gabriel@md.example".into(), + display_name: None, + avatar_url: None, + } +} + +#[test] +fn local_blocked_dto_url_round_trips_back_to_the_same_identity() { + let instance = InstanceIdentity::new("https://md.example"); + let uid = UserId::from_uuid(uuid::Uuid::new_v4()); + + let dto = social_actor_to_blocked_dto(local_actor(&uid), &instance); + + assert!(!dto.url.starts_with("local:"), "url was {}", dto.url); + assert_eq!(instance.identify(&dto.url), SocialIdentity::Local(uid)); +} diff --git a/crates/adapters/http-axum/src/federation/blocklist_html.rs b/crates/adapters/http-axum/src/federation/blocklist_html.rs new file mode 100644 index 00000000..af57277b --- /dev/null +++ b/crates/adapters/http-axum/src/federation/blocklist_html.rs @@ -0,0 +1,191 @@ +use crate::html::forms::{ActorUrlForm, BlockDomainForm, RemoveDomainForm}; +use crate::html::helpers::build_page_context; +use crate::html::templates::{BlockedActorsTemplate, BlockedDomainsTemplate}; +use crate::{ + extractors::RequiredCookieUser, html::csrf::CsrfToken, html::render::render_page, + state::AppState, +}; +use axum::{ + Form, + extract::{Extension, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, +}; + +pub async fn get_blocked_domains_page( + crate::extractors::AdminUser(user_id): crate::extractors::AdminUser, + State(state): State, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await; + ctx.page_title = "Blocked Domains — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/admin/blocked-domains", state.app_ctx.config.base_url); + match state.app_ctx.ap_blocklist.get_blocked_domains().await { + Ok(domains) => { + let entries: Vec = domains + .into_iter() + .map(|d| crate::html::templates::BlockedDomainEntry { + domain: d.domain, + reason: d.reason, + blocked_at: d.blocked_at, + }) + .collect(); + render_page(BlockedDomainsTemplate { + ctx: &ctx, + domains: &entries, + }) + .into_response() + } + Err(e) => { + tracing::error!("get_blocked_domains error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to load blocked domains", + ) + .into_response() + } + } +} + +pub async fn post_blocked_domain( + crate::extractors::AdminUser(_): crate::extractors::AdminUser, + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty()); + match state + .app_ctx + .ap_blocklist + .add_blocked_domain(&form.domain, reason) + .await + { + Ok(()) => Redirect::to("/admin/blocked-domains").into_response(), + Err(e) => { + tracing::error!("add_blocked_domain error: {:?}", e); + Redirect::to("/admin/blocked-domains").into_response() + } + } +} + +pub async fn post_remove_blocked_domain( + crate::extractors::AdminUser(_): crate::extractors::AdminUser, + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match state + .app_ctx + .ap_blocklist + .remove_blocked_domain(&form.domain) + .await + { + Ok(()) => Redirect::to("/admin/blocked-domains").into_response(), + Err(e) => { + tracing::error!("remove_blocked_domain error: {:?}", e); + Redirect::to("/admin/blocked-domains").into_response() + } + } +} + +pub async fn get_blocked_actors_page( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; + ctx.page_title = "Blocked Users — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/social/blocked", state.app_ctx.config.base_url); + let instance = state.app_ctx.instance.clone(); + match application::social::get_blocked::execute( + &state.app_ctx.deps.social.query, + user_id.value(), + ) + .await + { + Ok(blocked) => { + let entries: Vec = blocked + .into_iter() + .map(|a| crate::html::templates::BlockedActorEntry { + url: instance.actor_url_of(&a.identity), + handle: a.handle, + display_name: a.display_name, + avatar_url: a.avatar_url, + }) + .collect(); + render_page(BlockedActorsTemplate { + ctx: &ctx, + actors: &entries, + }) + .into_response() + } + Err(e) => { + tracing::error!("get_blocked_actors error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to load blocked users", + ) + .into_response() + } + } +} + +pub async fn post_block_actor_html( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let instance = state.app_ctx.instance.clone(); + match application::social::execute::execute_command( + &state.app_ctx.deps.social.command, + application::social::commands::SocialCmd::Block { + blocker_id: user_id.value(), + target: instance.identify(&form.actor_url), + }, + ) + .await + { + Ok(()) => Redirect::to("/social/blocked").into_response(), + Err(e) => { + tracing::error!("block_actor html error: {:?}", e); + Redirect::to("/social/blocked").into_response() + } + } +} + +pub async fn post_unblock_actor( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let instance = state.app_ctx.instance.clone(); + match application::social::execute::execute_command( + &state.app_ctx.deps.social.command, + application::social::commands::SocialCmd::Unblock { + blocker_id: user_id.value(), + target: instance.identify(&form.actor_url), + }, + ) + .await + { + Ok(()) => Redirect::to("/social/blocked").into_response(), + Err(e) => { + tracing::error!("unblock_actor error: {:?}", e); + Redirect::to("/social/blocked").into_response() + } + } +} diff --git a/crates/adapters/http-axum/src/federation/collections.rs b/crates/adapters/http-axum/src/federation/collections.rs new file mode 100644 index 00000000..2f4cca72 --- /dev/null +++ b/crates/adapters/http-axum/src/federation/collections.rs @@ -0,0 +1,77 @@ +use crate::state::AppState; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use uuid::Uuid; + +pub async fn get_followers_collection( + State(state): State, + Path(user_id): Path, + headers: axum::http::HeaderMap, + Query(params): Query>, +) -> impl IntoResponse { + let accept = headers + .get(axum::http::header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if accept.contains("application/activity+json") || accept.contains("application/ld+json") { + let page = params.get("page").and_then(|p| p.parse::().ok()); + return match state + .app_ctx + .ap_document + .followers_collection_json(user_id, page) + .await + { + Ok(json) => ( + [( + axum::http::header::CONTENT_TYPE, + "application/activity+json", + )], + json, + ) + .into_response(), + Err(e) => { + tracing::error!("followers_collection_json error: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + }; + } + axum::response::Redirect::to(&format!("/users/{}/followers-list", user_id)).into_response() +} + +pub async fn get_following_collection( + State(state): State, + Path(user_id): Path, + headers: axum::http::HeaderMap, + Query(params): Query>, +) -> impl IntoResponse { + let accept = headers + .get(axum::http::header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if accept.contains("application/activity+json") || accept.contains("application/ld+json") { + let page = params.get("page").and_then(|p| p.parse::().ok()); + return match state + .app_ctx + .ap_document + .following_collection_json(user_id, page) + .await + { + Ok(json) => ( + [( + axum::http::header::CONTENT_TYPE, + "application/activity+json", + )], + json, + ) + .into_response(), + Err(e) => { + tracing::error!("following_collection_json error: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + }; + } + axum::response::Redirect::to(&format!("/users/{}/following-list", user_id)).into_response() +} diff --git a/crates/adapters/http-axum/src/federation/mod.rs b/crates/adapters/http-axum/src/federation/mod.rs new file mode 100644 index 00000000..a182b0a2 --- /dev/null +++ b/crates/adapters/http-axum/src/federation/mod.rs @@ -0,0 +1,120 @@ +//! Federation-only HTTP surface. This is the one module that decides, per +//! build, whether federation routes exist: both builds expose the same +//! functions, so `router.rs` and the api/html modules stay free of `cfg`. + +#[cfg(feature = "federation")] +pub(crate) mod blocklist_api; +#[cfg(feature = "federation")] +mod blocklist_html; +#[cfg(feature = "federation")] +pub(crate) mod collections; +#[cfg(feature = "federation")] +mod negotiation; + +#[cfg(feature = "federation")] +pub use negotiation::actor_document_response; + +use axum::{Router, routing}; + +use crate::state::AppState; + +/// Classic-UI routes that only exist with federation (AP collections, admin pages). +#[cfg(feature = "federation")] +pub fn html_routes() -> Router { + Router::new() + .route( + "/users/{id}/followers", + routing::get(collections::get_followers_collection), + ) + .route( + "/users/{id}/following", + routing::get(collections::get_following_collection), + ) + .route( + "/admin/blocked-domains", + routing::get(blocklist_html::get_blocked_domains_page) + .post(blocklist_html::post_blocked_domain), + ) + .route( + "/admin/blocked-domains/remove", + routing::post(blocklist_html::post_remove_blocked_domain), + ) + .route( + "/social/blocked", + routing::get(blocklist_html::get_blocked_actors_page), + ) + .route( + "/social/block", + routing::post(blocklist_html::post_block_actor_html), + ) + .route( + "/social/unblock", + routing::post(blocklist_html::post_unblock_actor), + ) +} + +/// JSON API routes that only exist with federation (blocklist administration). +#[cfg(feature = "federation")] +pub fn api_routes() -> Router { + Router::new() + .route( + "/admin/blocked-domains", + routing::get(blocklist_api::get_blocked_domains_admin) + .post(blocklist_api::add_blocked_domain_admin), + ) + .route( + "/admin/blocked-domains/{domain}", + routing::delete(blocklist_api::remove_blocked_domain_admin), + ) + .route( + "/social/block", + routing::post(blocklist_api::block_actor_api), + ) + .route( + "/social/unblock", + routing::post(blocklist_api::unblock_actor_api), + ) + .route( + "/social/blocked", + routing::get(blocklist_api::get_blocked_actors_api), + ) +} + +/// Without federation the follower/following collection URLs fall back to the +/// classic list pages. +#[cfg(not(feature = "federation"))] +pub fn html_routes() -> Router { + Router::new() + .route( + "/users/{id}/followers", + routing::get( + |axum::extract::Path(id): axum::extract::Path| async move { + axum::response::Redirect::permanent(&format!("/users/{}/followers-list", id)) + }, + ), + ) + .route( + "/users/{id}/following", + routing::get( + |axum::extract::Path(id): axum::extract::Path| async move { + axum::response::Redirect::permanent(&format!("/users/{}/following-list", id)) + }, + ), + ) +} + +#[cfg(not(feature = "federation"))] +pub fn api_routes() -> Router { + Router::new() +} + +/// ActivityPub content negotiation for `GET /users/{id}`: federation builds +/// answer AP clients with the actor document, others always render HTML. +#[cfg(not(feature = "federation"))] +pub async fn actor_document_response( + _state: &AppState, + _headers: &axum::http::HeaderMap, + _user_id: uuid::Uuid, +) -> Option { + None +} diff --git a/crates/adapters/http-axum/src/federation/negotiation.rs b/crates/adapters/http-axum/src/federation/negotiation.rs new file mode 100644 index 00000000..3c3a9382 --- /dev/null +++ b/crates/adapters/http-axum/src/federation/negotiation.rs @@ -0,0 +1,36 @@ +use axum::{ + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use uuid::Uuid; + +use crate::state::AppState; + +/// Answers ActivityPub clients (`Accept: application/activity+json` or +/// `application/ld+json`) with the actor document; `None` means "render HTML". +pub async fn actor_document_response( + state: &AppState, + headers: &HeaderMap, + user_id: Uuid, +) -> Option { + let accept = headers + .get(header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if !(accept.contains("application/activity+json") || accept.contains("application/ld+json")) { + return None; + } + Some( + match state + .app_ctx + .ap_document + .actor_json(&user_id.to_string()) + .await + { + Ok(json) => { + ([(header::CONTENT_TYPE, "application/activity+json")], json).into_response() + } + Err(_) => StatusCode::NOT_FOUND.into_response(), + }, + ) +} diff --git a/crates/adapters/http-axum/src/handlers/diary.rs b/crates/adapters/http-axum/src/handlers/diary.rs deleted file mode 100644 index f374b7ba..00000000 --- a/crates/adapters/http-axum/src/handlers/diary.rs +++ /dev/null @@ -1,349 +0,0 @@ -use axum::{ - Form, Json, - extract::{Extension, Path, Query, State}, - http::StatusCode, - response::{IntoResponse, Redirect}, -}; -use uuid::Uuid; - -use application::diary::{ - commands::{DeleteReviewCommand, EditReviewCommand}, - delete_review, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review, - queries::GetActivityFeedQuery, -}; - -use crate::html::templates::{ActivityFeedTemplate, NewReviewTemplate, build_page_items}; -use crate::{ - csrf::CsrfToken, - errors::ApiError, - extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser}, - forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, to_diary_query}, - render::render_page, - state::AppState, -}; -use api_types::{ - ActivityFeedQueryParams, ActivityFeedResponse, DiaryQueryParams, DiaryResponse, - EditReviewRequest, ExportQueryParams, LogReviewRequest, -}; - -use super::helpers::{build_export_response, build_page_context, encode_domain_error}; - -// ── API ────────────────────────────────────────────────────────────────────── - -#[utoipa::path( - get, path = "/api/v1/diary", - params(DiaryQueryParams), - responses( - (status = 200, body = DiaryResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_diary( - State(state): State, - Query(params): Query, -) -> Result, ApiError> { - let page = - get_diary::execute(&state.app_ctx.deps.diary.get_diary, to_diary_query(params)).await?; - - Ok(Json(DiaryResponse { - items: page - .items - .iter() - .map(crate::mappers::movies::entry_to_dto) - .collect(), - total_count: page.total_count, - limit: page.limit, - offset: page.offset, - })) -} - -#[utoipa::path( - post, path = "/api/v1/reviews", - request_body = LogReviewRequest, - responses( - (status = 201, description = "Review created"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn post_review( - State(state): State, - user: AuthenticatedUser, - Json(req): Json, -) -> Result { - let data = LogReviewData::try_from(req).map_err(ApiError)?; - log_review::execute( - &state.app_ctx.services.review_logger, - data.into_command(user.0.value()), - ) - .await?; - Ok(StatusCode::CREATED) -} - -#[utoipa::path( - delete, path = "/api/v1/reviews/{id}", - params(("id" = Uuid, Path, description = "Review ID")), - responses( - (status = 204, description = "Review deleted"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden"), - (status = 404, description = "Review not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn delete_review( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(review_id): Path, -) -> Result { - let cmd = DeleteReviewCommand { - review_id, - requesting_user_id: user_id.value(), - }; - delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - patch, path = "/api/v1/reviews/{id}", - request_body = EditReviewRequest, - params(("id" = Uuid, Path, description = "Review ID")), - responses( - (status = 200, description = "Review updated"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden"), - (status = 404, description = "Review not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn patch_review( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(review_id): Path, - Json(req): Json, -) -> Result { - let watched_at = req - .watched_at - .map(|s| domain::value_objects::parse_watched_at(&s).map_err(ApiError)) - .transpose()?; - - let cmd = EditReviewCommand { - review_id, - requesting_user_id: user_id.value(), - rating: req.rating, - comment: req.comment, - watched_at, - watch_medium: req.watch_medium, - spoilers: req.spoilers, - }; - edit_review::execute(&state.app_ctx.deps.diary.edit_review, cmd).await?; - Ok(StatusCode::OK) -} - -#[utoipa::path( - get, path = "/api/v1/diary/export", - params(ExportQueryParams), - responses( - (status = 200, description = "Diary file download", content_type = "text/csv"), - (status = 400, description = "Invalid format parameter"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn export_diary( - State(state): State, - user: AuthenticatedUser, - Query(params): Query, -) -> impl IntoResponse { - build_export_response(¶ms.format, user.0.value(), &state) -} - -#[utoipa::path( - get, path = "/api/v1/activity-feed", - params(ActivityFeedQueryParams), - responses((status = 200, body = ActivityFeedResponse)), -)] -pub async fn get_activity_feed( - State(state): State, - Query(params): Query, -) -> Result, ApiError> { - let page = get_feed_uc::execute( - &state.app_ctx.deps.diary.get_activity_feed, - GetActivityFeedQuery { - limit: params.limit.unwrap_or(20), - offset: params.offset.unwrap_or(0), - sort_by: params - .sort_by - .as_deref() - .map(|s| s.parse().unwrap_or_default()) - .unwrap_or_default(), - search: None, - viewer_user_id: None, - filter_following: false, - }, - ) - .await?; - Ok(Json(ActivityFeedResponse { - items: page - .items - .iter() - .map(crate::mappers::diary::feed_entry_to_dto) - .collect(), - total_count: page.total_count, - limit: page.limit, - offset: page.offset, - })) -} - -// ── HTML ───────────────────────────────────────────────────────────────────── - -pub async fn get_new_review_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await; - ctx.page_title = "Log a Review — Movies Diary".to_string(); - ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url); - render_page(NewReviewTemplate { - ctx: &ctx, - error: params.error.as_deref(), - }) -} - -pub async fn post_review_html( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let data = match LogReviewData::try_from(form) { - Ok(d) => d, - Err(_) => { - return Redirect::to("/reviews/new?error=Invalid+date+format").into_response(); - } - }; - - match log_review::execute( - &state.app_ctx.services.review_logger, - data.into_command(user_id.value()), - ) - .await - { - Ok(_) => Redirect::to("/").into_response(), - Err(e) => { - let msg = encode_domain_error(&e); - Redirect::to(&format!("/reviews/new?error={}", msg)).into_response() - } - } -} - -pub async fn post_delete_review_html( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - Path(review_id): Path, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let cmd = DeleteReviewCommand { - review_id, - requesting_user_id: user_id.value(), - }; - match delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await { - Ok(()) => { - let redirect_url = form - .redirect_after - .filter(|url| { - (url.starts_with('/') && !url.starts_with("//")) || url.starts_with('?') - }) - .unwrap_or_else(|| "/".to_string()); - Redirect::to(&redirect_url).into_response() - } - Err(e) => crate::errors::domain_error_response(e), - } -} - -pub async fn get_export_html( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Query(params): Query, -) -> impl IntoResponse { - build_export_response(¶ms.format, user_id.value(), &state) -} - -pub async fn get_activity_feed_html( - OptionalCookieUser(user_id): OptionalCookieUser, - State(state): State, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - let ctx = build_page_context(&state, user_id.clone(), csrf.0).await; - let limit = params.limit.unwrap_or(20); - let offset = params.offset.unwrap_or(0); - - let filter_following = params.filter == "following" && user_id.is_some(); - let filter_str = if filter_following { "following" } else { "all" }; - - let sort_by_str = match params.sort_by.as_str() { - "date_asc" => "date_asc", - "rating" => "rating", - "rating_asc" => "rating_asc", - _ => "date", - }; - - let query = application::diary::queries::GetActivityFeedQuery { - limit, - offset, - sort_by: sort_by_str.parse().unwrap_or_default(), - search: if params.search.is_empty() { - None - } else { - Some(params.search.clone()) - }, - viewer_user_id: user_id.map(|u| u.value()), - filter_following, - }; - - match application::diary::get_activity_feed::execute( - &state.app_ctx.deps.diary.get_activity_feed, - query, - ) - .await - { - Ok(entries) => { - let entry_limit = entries.limit; - let entry_offset = entries.offset; - let has_more = - (entry_offset as u64).saturating_add(entry_limit as u64) < entries.total_count; - let total_pages = (entries.total_count as u32) - .saturating_add(entry_limit.saturating_sub(1)) - .checked_div(entry_limit) - .unwrap_or(1); - let current_page = entry_offset.checked_div(entry_limit).unwrap_or(0); - let page_items = build_page_items(total_pages, current_page); - render_page(ActivityFeedTemplate { - entries: entries.items.as_slice(), - current_offset: entry_offset, - limit: entry_limit, - has_more, - ctx: &ctx, - page_items, - filter: filter_str.to_string(), - sort_by: sort_by_str.to_string(), - search: params.search, - }) - .into_response() - } - Err(e) => crate::errors::domain_error_response(e), - } -} diff --git a/crates/adapters/http-axum/src/handlers/import.rs b/crates/adapters/http-axum/src/handlers/import.rs deleted file mode 100644 index af372f84..00000000 --- a/crates/adapters/http-axum/src/handlers/import.rs +++ /dev/null @@ -1,812 +0,0 @@ -use api_types::{ - ApplyMappingRequest, ConfirmRequest, ImportPreviewResponse, PreviewRowDto, SaveProfileRequest, - SessionCreatedResponse, SessionStateResponse, -}; -use axum::{ - Extension, Form, - extract::{Multipart, Path, State}, - http::StatusCode, - response::{Html, IntoResponse, Redirect}, -}; -use serde::Deserialize; -use std::collections::HashMap; - -use crate::html::templates::{ - ImportMappingTemplate, ImportPreviewRow, ImportPreviewTemplate, ImportProfileView, - ImportUploadTemplate, -}; -use crate::render::render_page; -use application::import::{ - apply_mapping as apply_import_mapping, apply_profile_and_map, - commands::{ - ApplyImportMappingCommand, ApplyProfileAndMapCommand, CreateImportSessionCommand, - DeleteImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand, - }, - create_session as create_import_session, delete_profile as delete_import_profile, - execute as execute_import, get_mapping_stage, get_preview_stage, get_session_state, - list_profiles as list_import_profiles, save_profile as save_import_profile, -}; -use domain::errors::DomainError; -use domain::models::{ - AnnotatedRow, FieldMapping, FileFormat, - import::{DomainField, Transform}, -}; -use domain::value_objects::ImportSessionId; - -use crate::{ - csrf::CsrfToken, - errors::ApiError, - extractors::{AuthenticatedUser, RequiredCookieUser}, - state::AppState, -}; - -use super::helpers::encode_domain_error; - -fn str_to_domain_field(field: &str) -> Option { - match field { - "title" => Some(DomainField::Title), - "release_year" => Some(DomainField::ReleaseYear), - "director" => Some(DomainField::Director), - "rating" => Some(DomainField::Rating), - "watched_at" => Some(DomainField::WatchedAt), - "comment" => Some(DomainField::Comment), - "external_metadata_id" => Some(DomainField::ExternalMetadataId), - _ => None, - } -} - -fn parse_mapping_form(form: &HashMap) -> Vec { - let mut mappings = Vec::new(); - let mut i = 0usize; - loop { - if i > 64 { - break; - } - let col_key = format!("mapping_{i}_col"); - let Some(col) = form.get(&col_key).cloned() else { - break; - }; - let field_str = form - .get(&format!("mapping_{i}_field")) - .map(|s| s.as_str()) - .unwrap_or(""); - if let Some(domain_field) = str_to_domain_field(field_str) { - let transform = if domain_field == DomainField::Rating { - let scale: f64 = form - .get(&format!("mapping_{i}_scale")) - .and_then(|s| s.parse().ok()) - .unwrap_or(1.0); - Transform::RatingScale(scale) - } else if domain_field == DomainField::WatchedAt { - form.get(&format!("mapping_{i}_datefmt")) - .filter(|s| !s.is_empty()) - .cloned() - .map(Transform::DateFormat) - .unwrap_or(Transform::Identity) - } else { - Transform::Identity - }; - mappings.push(FieldMapping { - source_column: col, - domain_field, - transform, - }); - } - i += 1; - } - mappings -} - -use crate::mappers::import::annotated_to_preview_row; - -// ── HTML wizard handlers ─────────────────────────────────────────────────── - -pub async fn get_import_page( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, -) -> impl IntoResponse { - let ctx = super::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await; - let profiles = - list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id) - .await - .unwrap_or_default() - .into_iter() - .map(|p| ImportProfileView { - id: p.id.value().to_string(), - name: p.name, - }) - .collect::>(); - render_page(ImportUploadTemplate { - ctx: &ctx, - profiles: &profiles, - error: None, - }) -} - -pub async fn post_upload( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - mut multipart: Multipart, -) -> impl IntoResponse { - let mut file_bytes: Option> = None; - let mut format_str = "csv".to_string(); - - while let Ok(Some(field)) = multipart.next_field().await { - match field.name() { - Some("file") => { - if let Ok(bytes) = field.bytes().await { - file_bytes = Some(bytes.to_vec()); - } - } - Some("format") => { - if let Ok(text) = field.text().await { - format_str = text; - } - } - _ => {} - } - } - - let bytes = match file_bytes { - Some(b) if !b.is_empty() => b, - _ => return Redirect::to("/import?error=no+file+provided").into_response(), - }; - - let format = match format_str.as_str() { - "json" => FileFormat::Json, - "xlsx" => FileFormat::Xlsx, - _ => FileFormat::Csv, - }; - - match create_import_session::execute( - &state.app_ctx.deps.import.create_session, - CreateImportSessionCommand { - user_id: user_id.value(), - bytes, - format, - }, - ) - .await - { - Ok(r) => Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response(), - Err(e) => { - Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response() - } - } -} - -pub async fn get_mapping_page( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Path(session_id_str): Path, - Extension(csrf): Extension, -) -> impl IntoResponse { - let Ok(session_id) = session_id_str - .parse::() - .map(ImportSessionId::from_uuid) - else { - return Redirect::to("/import").into_response(); - }; - let Ok(stage) = get_mapping_stage::execute( - &state.app_ctx.deps.import.get_mapping_stage, - session_id, - user_id.value(), - ) - .await - else { - return Redirect::to("/import").into_response(); - }; - - let ctx = super::helpers::build_page_context(&state, Some(user_id), csrf.0).await; - let domain_fields: Vec<(&str, &str)> = vec![ - ("title", "Title"), - ("release_year", "Release Year"), - ("director", "Director"), - ("rating", "Rating"), - ("watched_at", "Watched At"), - ("comment", "Comment"), - ("external_metadata_id", "External ID"), - ]; - render_page(ImportMappingTemplate { - ctx: &ctx, - session_id: &session_id_str, - columns: &stage.columns, - sample_rows: &stage.sample_rows, - domain_fields: &domain_fields, - error: None, - }) - .into_response() -} - -pub async fn post_mapping( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Path(session_id_str): Path, - Extension(csrf): Extension, - Form(form): Form>, -) -> impl IntoResponse { - let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or(""); - if crate::csrf::mismatch(&csrf, csrf_token) { - return Redirect::to("/import").into_response(); - } - let Ok(session_id) = session_id_str - .parse::() - .map(ImportSessionId::from_uuid) - else { - return Redirect::to("/import").into_response(); - }; - let mappings = parse_mapping_form(&form); - if mappings.is_empty() { - return Redirect::to(&format!( - "/import/{}/mapping?error=select+at+least+one+mapping", - session_id_str - )) - .into_response(); - } - match apply_import_mapping::execute( - &state.app_ctx.deps.import.apply_mapping, - ApplyImportMappingCommand { - user_id: user_id.value(), - session_id: session_id.value(), - mappings, - }, - ) - .await - { - Ok(_) => Redirect::to(&format!("/import/{}/preview", session_id_str)).into_response(), - Err(e) => Redirect::to(&format!( - "/import/{}/mapping?error={}", - session_id_str, - encode_domain_error(&e) - )) - .into_response(), - } -} - -pub async fn get_preview_page( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Path(session_id_str): Path, - Extension(csrf): Extension, -) -> impl IntoResponse { - let Ok(session_id) = session_id_str - .parse::() - .map(ImportSessionId::from_uuid) - else { - return Redirect::to("/import").into_response(); - }; - let Ok(stage) = get_preview_stage::execute( - &state.app_ctx.deps.import.get_preview_stage, - session_id, - user_id.value(), - ) - .await - else { - return Redirect::to("/import").into_response(); - }; - - let preview = match stage { - get_preview_stage::PreviewStage::Ready(preview) => preview, - get_preview_stage::PreviewStage::NotYetMapped => { - return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response(); - } - }; - - let rows: Vec = preview - .rows - .iter() - .enumerate() - .map(|(i, a)| annotated_to_preview_row(i, a)) - .collect(); - - let ctx = super::helpers::build_page_context(&state, Some(user_id), csrf.0).await; - render_page(ImportPreviewTemplate { - ctx: &ctx, - session_id: &session_id_str, - columns: &preview.columns, - rows: &rows, - }) - .into_response() -} - -pub async fn post_confirm( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Path(session_id_str): Path, - Extension(csrf): Extension, - Form(form_entries): Form>, -) -> impl IntoResponse { - let csrf_token = form_entries - .iter() - .find(|(k, _)| k == "_csrf") - .map(|(_, v)| v.as_str()) - .unwrap_or(""); - if crate::csrf::mismatch(&csrf, csrf_token) { - return Redirect::to("/import").into_response(); - } - let Ok(session_id) = session_id_str - .parse::() - .map(ImportSessionId::from_uuid) - else { - return Redirect::to("/import").into_response(); - }; - - // Save profile if name provided - let profile_name = form_entries - .iter() - .find(|(k, _)| k == "profile_name") - .map(|(_, v)| v.clone()) - .filter(|n| !n.trim().is_empty()); - if let Some(name) = profile_name { - let _ = save_import_profile::execute( - &state.app_ctx.deps.import.save_profile, - SaveImportProfileCommand { - user_id: user_id.value(), - session_id: session_id.value(), - name, - }, - ) - .await; - } - - // Collect all "confirmed" checkbox values - let confirmed: Vec = form_entries - .iter() - .filter(|(k, _)| k == "confirmed") - .filter_map(|(_, v)| v.parse::().ok()) - .collect(); - - match execute_import::execute( - &state.app_ctx.deps.import.execute_import, - ExecuteImportCommand { - user_id: user_id.value(), - session_id: session_id.value(), - confirmed_indices: confirmed, - }, - ) - .await - { - Ok(summary) => Redirect::to(&format!( - "/import/done?imported={}&skipped={}&failed={}", - summary.imported, - summary.skipped_duplicates, - summary.failed.len() - )) - .into_response(), - Err(e) => { - Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response() - } - } -} - -pub async fn post_delete_profile( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Path(profile_id_str): Path, - Extension(csrf): Extension, - Form(form): Form>, -) -> impl IntoResponse { - let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or(""); - if crate::csrf::mismatch(&csrf, csrf_token) { - return Redirect::to("/import").into_response(); - } - if let Ok(profile_id) = profile_id_str.parse::() { - let _ = delete_import_profile::execute( - &state.app_ctx.deps.import.delete_profile, - DeleteImportProfileCommand { - user_id: user_id.value(), - profile_id, - }, - ) - .await; - } - Redirect::to("/import").into_response() -} - -#[derive(Deserialize)] -pub struct ImportDoneParams { - pub imported: Option, - pub skipped: Option, - pub failed: Option, -} - -pub async fn get_import_done( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - axum::extract::Query(params): axum::extract::Query, -) -> impl IntoResponse { - let _ctx = super::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await; - let html = format!( - r#" -

Import Complete

-

Imported: {}

-

Skipped duplicates: {}

-

Failed: {}

- Go to My Profile - "#, - params.imported.unwrap_or(0), - params.skipped.unwrap_or(0), - params.failed.unwrap_or(0), - user_id.value(), - ); - Html(html) -} - -// ── REST API handlers ────────────────────────────────────────────────────── - -#[utoipa::path( - post, path = "/api/v1/import/sessions", - request_body(content_type = "multipart/form-data", description = "file (binary) + format (csv|json|xlsx)"), - responses( - (status = 200, body = SessionCreatedResponse), - (status = 400, description = "No file provided"), - (status = 401, description = "Unauthorized"), - (status = 422, description = "Parse error"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_post_session( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - mut multipart: Multipart, -) -> Result { - let mut file_bytes: Option> = None; - let mut format_str = "csv".to_string(); - while let Ok(Some(field)) = multipart.next_field().await { - match field.name() { - Some("file") => { - if let Ok(b) = field.bytes().await { - file_bytes = Some(b.to_vec()); - } - } - Some("format") => { - if let Ok(t) = field.text().await { - format_str = t; - } - } - _ => {} - } - } - let bytes = match file_bytes { - Some(b) if !b.is_empty() => b, - _ => return Err(DomainError::ValidationError("no file".into()).into()), - }; - let format = match format_str.as_str() { - "json" => FileFormat::Json, - "xlsx" => FileFormat::Xlsx, - _ => FileFormat::Csv, - }; - let r = create_import_session::execute( - &state.app_ctx.deps.import.create_session, - CreateImportSessionCommand { - user_id: user_id.value(), - bytes, - format, - }, - ) - .await?; - Ok(axum::Json(SessionCreatedResponse { - session_id: r.session_id.value().to_string(), - columns: r.columns, - sample_rows: r.sample_rows, - })) -} - -#[utoipa::path( - get, path = "/api/v1/import/sessions/{id}", - params(("id" = String, Path, description = "Import session UUID")), - responses( - (status = 200, body = SessionStateResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Session not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_get_session( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, -) -> Result { - let session_id = session_id_str - .parse::() - .map(ImportSessionId::from_uuid) - .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; - let state_data = get_session_state::execute( - &state.app_ctx.deps.import.get_session_state, - session_id, - user_id.value(), - ) - .await?; - Ok(axum::Json(SessionStateResponse { - session_id: session_id_str, - columns: state_data.columns, - has_mappings: state_data.has_mappings, - row_count: state_data.row_count, - })) -} - -#[utoipa::path( - put, path = "/api/v1/import/sessions/{id}/mapping", - params(("id" = String, Path, description = "Import session UUID")), - request_body = ApplyMappingRequest, - responses( - (status = 200, description = "Mapping applied", body = inline(serde_json::Value)), - (status = 401, description = "Unauthorized"), - (status = 422, description = "Mapping error"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_put_mapping( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, - axum::Json(body): axum::Json, -) -> Result { - let session_id = session_id_str - .parse::() - .map(ImportSessionId::from_uuid) - .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; - let mappings: Vec = body - .mappings - .into_iter() - .filter_map(|m| { - let domain_field = str_to_domain_field(&m.domain_field)?; - let transform = if domain_field == DomainField::Rating { - Transform::RatingScale(m.rating_scale.unwrap_or(1.0)) - } else if domain_field == DomainField::WatchedAt { - m.date_format - .map(Transform::DateFormat) - .unwrap_or(Transform::Identity) - } else { - Transform::Identity - }; - Some(FieldMapping { - source_column: m.source_column, - domain_field, - transform, - }) - }) - .collect(); - - let rows = apply_import_mapping::execute( - &state.app_ctx.deps.import.apply_mapping, - ApplyImportMappingCommand { - user_id: user_id.value(), - session_id: session_id.value(), - mappings, - }, - ) - .await?; - Ok(axum::Json(serde_json::json!({"row_count": rows.len()}))) -} - -pub async fn api_get_preview( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, -) -> Result, ApiError> { - let session_id = session_id_str - .parse::() - .map(ImportSessionId::from_uuid) - .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; - - let stage = get_preview_stage::execute( - &state.app_ctx.deps.import.get_preview_stage, - session_id, - user_id.value(), - ) - .await?; - - // Not-yet-mapped sessions render as an empty preview here (200, `rows: []`), - // matching this endpoint's behavior before the stage-gate moved into - // `get_preview_stage` — unlike the HTML handler, this API route never - // redirected on the same condition, so its response shape is unchanged. - let annotated: Vec = match stage { - get_preview_stage::PreviewStage::Ready(preview) => preview.rows, - get_preview_stage::PreviewStage::NotYetMapped => Vec::new(), - }; - let rows = annotated - .iter() - .enumerate() - .map(|(i, a)| { - use domain::models::import::RowResult; - match &a.result { - RowResult::Valid(row) if a.is_duplicate => { - PreviewRowDto::Duplicate(api_types::PreviewRowData { - index: i, - title: row.title.clone(), - release_year: row.release_year.clone(), - director: row.director.clone(), - rating: row.rating.clone(), - watched_at: row.watched_at.clone(), - comment: row.comment.clone(), - }) - } - RowResult::Valid(row) => PreviewRowDto::Valid(api_types::PreviewRowData { - index: i, - title: row.title.clone(), - release_year: row.release_year.clone(), - director: row.director.clone(), - rating: row.rating.clone(), - watched_at: row.watched_at.clone(), - comment: row.comment.clone(), - }), - RowResult::Invalid { errors, .. } => PreviewRowDto::Invalid { - index: i, - errors: errors.clone(), - }, - } - }) - .collect(); - - Ok(axum::Json(ImportPreviewResponse { rows })) -} - -#[utoipa::path( - post, path = "/api/v1/import/sessions/{id}/confirm", - params(("id" = String, Path, description = "Import session UUID")), - request_body = ConfirmRequest, - responses( - (status = 200, description = "Import summary", body = inline(serde_json::Value)), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Session not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_post_confirm( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, - axum::Json(body): axum::Json, -) -> Result { - let session_id = session_id_str - .parse::() - .map(ImportSessionId::from_uuid) - .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; - let s = execute_import::execute( - &state.app_ctx.deps.import.execute_import, - ExecuteImportCommand { - user_id: user_id.value(), - session_id: session_id.value(), - confirmed_indices: body.confirmed_indices, - }, - ) - .await?; - Ok(axum::Json(serde_json::json!({ - "imported": s.imported, - "skipped_duplicates": s.skipped_duplicates, - "failed": s.failed.iter().map(|(i, e)| serde_json::json!({"index": i, "error": e})).collect::>(), - }))) -} - -#[utoipa::path( - get, path = "/api/v1/import/profiles", - responses( - (status = 200, description = "List of saved import profiles", body = inline(Vec)), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_get_profiles( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, -) -> Result { - let profiles = - list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id).await?; - Ok(axum::Json( - profiles - .into_iter() - .map(|p| { - serde_json::json!({ - "id": p.id.value().to_string(), - "name": p.name, - "created_at": p.created_at.to_string(), - }) - }) - .collect::>(), - )) -} - -#[utoipa::path( - post, path = "/api/v1/import/profiles", - request_body = SaveProfileRequest, - responses( - (status = 200, description = "Profile saved", body = inline(serde_json::Value)), - (status = 401, description = "Unauthorized"), - (status = 422, description = "Session has no mapping yet"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_post_profile( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - axum::Json(body): axum::Json, -) -> Result { - let session_id = body - .session_id - .parse::() - .map(ImportSessionId::from_uuid) - .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; - let id = save_import_profile::execute( - &state.app_ctx.deps.import.save_profile, - SaveImportProfileCommand { - user_id: user_id.value(), - session_id: session_id.value(), - name: body.name, - }, - ) - .await?; - Ok(axum::Json( - serde_json::json!({"id": id.value().to_string()}), - )) -} - -#[utoipa::path( - delete, path = "/api/v1/import/profiles/{id}", - params(("id" = String, Path, description = "Import profile UUID")), - responses( - (status = 204, description = "Deleted"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Profile not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_delete_profile( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(profile_id_str): Path, -) -> Result { - let profile_id = profile_id_str - .parse::() - .map_err(|_| DomainError::ValidationError("invalid profile id".into()))?; - delete_import_profile::execute( - &state.app_ctx.deps.import.delete_profile, - DeleteImportProfileCommand { - user_id: user_id.value(), - profile_id, - }, - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - put, path = "/api/v1/import/sessions/{id}/profile/{profile_id}", - params( - ("id" = String, Path, description = "Import session UUID"), - ("profile_id" = String, Path, description = "Import profile UUID"), - ), - responses( - (status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)), - (status = 400, description = "Invalid ID"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Session or profile not found"), - (status = 422, description = "Mapping error"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_apply_profile( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path((session_id_str, profile_id_str)): Path<(String, String)>, -) -> Result { - let session_id = session_id_str - .parse::() - .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; - let profile_id = profile_id_str - .parse::() - .map_err(|_| DomainError::ValidationError("invalid profile id".into()))?; - - let rows = apply_profile_and_map::execute( - &state.app_ctx.deps.import.apply_profile_and_map, - ApplyProfileAndMapCommand { - user_id: user_id.value(), - session_id, - profile_id, - }, - ) - .await?; - Ok(axum::Json(serde_json::json!({"row_count": rows.len()}))) -} diff --git a/crates/adapters/http-axum/src/handlers/mod.rs b/crates/adapters/http-axum/src/handlers/mod.rs deleted file mode 100644 index 0fc869cf..00000000 --- a/crates/adapters/http-axum/src/handlers/mod.rs +++ /dev/null @@ -1,19 +0,0 @@ -pub mod auth; -pub mod diary; -pub mod goals; -mod helpers; -pub mod images; -pub mod import; -pub mod insights; -pub mod integrations; -pub mod movies; -pub mod rss; -pub mod search; -pub mod social; -pub mod users; -pub mod watchlist; -pub mod webhook; -pub mod wrapup; - -const DEFAULT_PAGE_LIMIT: u32 = 5; -const RSS_FEED_LIMIT: u32 = 50; diff --git a/crates/adapters/http-axum/src/handlers/social.rs b/crates/adapters/http-axum/src/handlers/social.rs deleted file mode 100644 index b311b686..00000000 --- a/crates/adapters/http-axum/src/handlers/social.rs +++ /dev/null @@ -1,1095 +0,0 @@ -use axum::{ - Form, Json, - extract::{Extension, Path, Query, State}, - http::StatusCode, - response::{IntoResponse, Redirect}, -}; -use uuid::Uuid; - -#[cfg(feature = "federation")] -use crate::html::templates::{BlockedActorsTemplate, BlockedDomainsTemplate}; -use crate::html::templates::{FollowersTemplate, FollowingTemplate, RemoteActorData}; -use crate::{ - csrf::CsrfToken, - errors::ApiError, - extractors::{AuthenticatedUser, RequiredCookieUser}, - forms::{FollowForm, FollowerActionForm, UnfollowForm}, - render::render_page, - state::AppState, -}; -#[cfg(feature = "federation")] -use crate::{ - extractors::AdminApiUser, - forms::{ActorUrlForm, BlockDomainForm, RemoveDomainForm}, -}; -use api_types::{ - ActorListResponse, ActorUrlRequest, FollowRelationResponse, FollowRequest, - PendingCountResponse, RemoteActorDto, -}; -#[cfg(feature = "federation")] -use api_types::{AddBlockedDomainRequest, BlockedActorResponse, BlockedDomainResponse}; -use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor, SocialIdentity}; - -use super::helpers::{build_page_context, encode_domain_error}; - -fn social_actor_to_dto(actor: SocialActor, instance: &InstanceIdentity) -> RemoteActorDto { - RemoteActorDto { - url: instance.actor_url_of(&actor.identity), - user_id: match &actor.identity { - SocialIdentity::Local(uid) => Some(uid.value()), - SocialIdentity::Remote { .. } => None, - }, - handle: actor.handle, - display_name: actor.display_name, - avatar_url: actor.avatar_url, - } -} - -#[cfg(feature = "federation")] -fn social_actor_to_blocked_dto( - actor: SocialActor, - instance: &InstanceIdentity, -) -> BlockedActorResponse { - BlockedActorResponse { - url: instance.actor_url_of(&actor.identity), - handle: actor.handle, - display_name: actor.display_name, - avatar_url: actor.avatar_url, - } -} - -fn social_actor_to_template(actor: SocialActor, instance: &InstanceIdentity) -> RemoteActorData { - RemoteActorData { - url: instance.actor_url_of(&actor.identity), - handle: actor.handle, - display_name: actor.display_name, - avatar_url: actor.avatar_url, - } -} - -// ── API ────────────────────────────────────────────────────────────────────── - -#[cfg(feature = "federation")] -#[utoipa::path( - get, path = "/api/v1/admin/blocked-domains", - responses( - (status = 200, body = Vec), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - admin only"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_blocked_domains_admin( - State(state): State, - _admin: AdminApiUser, -) -> Result>, ApiError> { - let domains = state.app_ctx.ap_blocklist.get_blocked_domains().await?; - Ok(Json( - domains - .into_iter() - .map(|d| BlockedDomainResponse { - domain: d.domain, - reason: d.reason, - blocked_at: d.blocked_at, - }) - .collect(), - )) -} - -#[cfg(feature = "federation")] -#[utoipa::path( - post, path = "/api/v1/admin/blocked-domains", - request_body = AddBlockedDomainRequest, - responses( - (status = 201, description = "Domain blocked"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - admin only"), - ), - security(("bearer_auth" = [])) -)] -pub async fn add_blocked_domain_admin( - State(state): State, - _admin: AdminApiUser, - axum::Json(body): axum::Json, -) -> Result { - state - .app_ctx - .ap_blocklist - .add_blocked_domain(&body.domain, body.reason.as_deref()) - .await?; - Ok(StatusCode::CREATED) -} - -#[cfg(feature = "federation")] -#[utoipa::path( - delete, path = "/api/v1/admin/blocked-domains/{domain}", - params(("domain" = String, Path, description = "Domain to unblock")), - responses( - (status = 204, description = "Domain unblocked"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - admin only"), - ), - security(("bearer_auth" = [])) -)] -pub async fn remove_blocked_domain_admin( - State(state): State, - _admin: AdminApiUser, - axum::extract::Path(domain): axum::extract::Path, -) -> Result { - state - .app_ctx - .ap_blocklist - .remove_blocked_domain(&domain) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -#[cfg(feature = "federation")] -#[utoipa::path( - post, path = "/api/v1/social/block", - request_body = ActorUrlRequest, - responses( - (status = 204, description = "Actor blocked"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn block_actor_api( - State(state): State, - user: AuthenticatedUser, - axum::Json(body): axum::Json, -) -> Result { - let instance = state.app_ctx.instance.clone(); - application::social::execute::execute_command( - &state.app_ctx.deps.social.command, - application::social::commands::SocialCmd::Block { - blocker_id: user.0.value(), - target: instance.identify(&body.actor_url), - }, - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -#[cfg(feature = "federation")] -#[utoipa::path( - post, path = "/api/v1/social/unblock", - request_body = ActorUrlRequest, - responses( - (status = 204, description = "Actor unblocked"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn unblock_actor_api( - State(state): State, - user: AuthenticatedUser, - axum::Json(body): axum::Json, -) -> Result { - let instance = state.app_ctx.instance.clone(); - application::social::execute::execute_command( - &state.app_ctx.deps.social.command, - application::social::commands::SocialCmd::Unblock { - blocker_id: user.0.value(), - target: instance.identify(&body.actor_url), - }, - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -#[cfg(feature = "federation")] -#[utoipa::path( - get, path = "/api/v1/social/blocked", - responses( - (status = 200, body = Vec), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_blocked_actors_api( - State(state): State, - user: AuthenticatedUser, -) -> Result>, ApiError> { - let instance = state.app_ctx.instance.clone(); - let identities = - application::social::get_blocked::execute(&state.app_ctx.deps.social.query, user.0.value()) - .await?; - Ok(Json( - identities - .into_iter() - .map(|a| social_actor_to_blocked_dto(a, &instance)) - .collect(), - )) -} - -#[utoipa::path( - get, path = "/api/v1/social/following", - responses( - (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_following( - State(state): State, - user: AuthenticatedUser, -) -> Result, ApiError> { - let instance = state.app_ctx.instance.clone(); - let identities = application::social::get_following::execute( - &state.app_ctx.deps.social.query, - user.0.value(), - ) - .await?; - Ok(Json(ActorListResponse { - actors: identities - .into_iter() - .map(|a| social_actor_to_dto(a, &instance)) - .collect(), - })) -} - -#[utoipa::path( - get, path = "/api/v1/social/followers", - responses( - (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_followers( - State(state): State, - user: AuthenticatedUser, -) -> Result, ApiError> { - let instance = state.app_ctx.instance.clone(); - let identities = application::social::get_followers::execute( - &state.app_ctx.deps.social.query, - user.0.value(), - ) - .await?; - Ok(Json(ActorListResponse { - actors: identities - .into_iter() - .map(|a| social_actor_to_dto(a, &instance)) - .collect(), - })) -} - -pub async fn get_user_following( - State(state): State, - _user: AuthenticatedUser, - Path(user_id): Path, -) -> Result, ApiError> { - let instance = state.app_ctx.instance.clone(); - let identities = - application::social::get_following::execute(&state.app_ctx.deps.social.query, user_id) - .await?; - Ok(Json(ActorListResponse { - actors: identities - .into_iter() - .map(|a| social_actor_to_dto(a, &instance)) - .collect(), - })) -} - -pub async fn get_user_followers( - State(state): State, - _user: AuthenticatedUser, - Path(user_id): Path, -) -> Result, ApiError> { - let instance = state.app_ctx.instance.clone(); - let identities = - application::social::get_followers::execute(&state.app_ctx.deps.social.query, user_id) - .await?; - Ok(Json(ActorListResponse { - actors: identities - .into_iter() - .map(|a| social_actor_to_dto(a, &instance)) - .collect(), - })) -} - -#[utoipa::path( - post, path = "/api/v1/social/follow", - request_body = FollowRequest, - responses( - (status = 200, description = "Follow request sent"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn follow( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, -) -> Result { - application::social::execute::execute_command( - &state.app_ctx.deps.social.command, - application::social::commands::SocialCmd::Follow { - follower_id: user.0.value(), - target: FollowTarget::Handle(body.handle), - }, - ) - .await?; - Ok(StatusCode::OK) -} - -#[utoipa::path( - post, path = "/api/v1/social/unfollow", - request_body = ActorUrlRequest, - responses( - (status = 200, description = "Unfollowed"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn unfollow( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, -) -> Result { - let instance = state.app_ctx.instance.clone(); - application::social::execute::execute_command( - &state.app_ctx.deps.social.command, - application::social::commands::SocialCmd::Unfollow { - follower_id: user.0.value(), - target: instance.identify(&body.actor_url), - }, - ) - .await?; - Ok(StatusCode::OK) -} - -#[utoipa::path( - post, path = "/api/v1/social/followers/accept", - request_body = ActorUrlRequest, - responses( - (status = 200, description = "Follower accepted"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn accept_follower( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, -) -> Result { - let instance = state.app_ctx.instance.clone(); - application::social::execute::execute_command( - &state.app_ctx.deps.social.command, - application::social::commands::SocialCmd::AcceptFollow { - owner_id: user.0.value(), - requester: instance.identify(&body.actor_url), - }, - ) - .await?; - Ok(StatusCode::OK) -} - -#[utoipa::path( - post, path = "/api/v1/social/followers/reject", - request_body = ActorUrlRequest, - responses( - (status = 200, description = "Follower rejected"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn reject_follower( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, -) -> Result { - let instance = state.app_ctx.instance.clone(); - application::social::execute::execute_command( - &state.app_ctx.deps.social.command, - application::social::commands::SocialCmd::RejectFollow { - owner_id: user.0.value(), - requester: instance.identify(&body.actor_url), - }, - ) - .await?; - Ok(StatusCode::OK) -} - -#[utoipa::path( - post, path = "/api/v1/social/followers/remove", - request_body = ActorUrlRequest, - responses( - (status = 200, description = "Follower removed"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn remove_follower( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, -) -> Result { - let instance = state.app_ctx.instance.clone(); - application::social::execute::execute_command( - &state.app_ctx.deps.social.command, - application::social::commands::SocialCmd::RemoveFollower { - owner_id: user.0.value(), - follower: instance.identify(&body.actor_url), - }, - ) - .await?; - Ok(StatusCode::OK) -} - -#[utoipa::path( - get, path = "/api/v1/social/followers/pending", - responses( - (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_pending_followers( - State(state): State, - user: AuthenticatedUser, -) -> Result, ApiError> { - let instance = state.app_ctx.instance.clone(); - let identities = application::social::get_pending_followers::execute( - &state.app_ctx.deps.social.query, - user.0.value(), - ) - .await?; - Ok(Json(ActorListResponse { - actors: identities - .into_iter() - .map(|a| social_actor_to_dto(a, &instance)) - .collect(), - })) -} - -#[utoipa::path( - get, path = "/api/v1/social/followers/pending/count", - responses( - (status = 200, body = PendingCountResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_pending_follower_count( - State(state): State, - user: AuthenticatedUser, -) -> Result, ApiError> { - let count = application::social::count_pending_followers::execute( - &state.app_ctx.deps.social.query, - user.0.value(), - ) - .await?; - Ok(Json(PendingCountResponse { count })) -} - -#[utoipa::path( - get, path = "/api/v1/social/following/pending", - responses( - (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_pending_following( - State(state): State, - user: AuthenticatedUser, -) -> Result, ApiError> { - let instance = state.app_ctx.instance.clone(); - let actors = application::social::get_pending_following::execute( - &state.app_ctx.deps.social.query, - user.0.value(), - ) - .await?; - Ok(Json(ActorListResponse { - actors: actors - .into_iter() - .map(|a| social_actor_to_dto(a, &instance)) - .collect(), - })) -} - -#[derive(serde::Deserialize)] -pub struct RelationshipQuery { - pub actor_url: String, -} - -#[utoipa::path( - get, path = "/api/v1/social/relationship", - params(("actor_url" = String, Query, description = "Canonical actor URL of the target")), - responses( - (status = 200, body = FollowRelationResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_relationship( - State(state): State, - user: AuthenticatedUser, - Query(q): Query, -) -> Result, ApiError> { - let target = state.app_ctx.instance.identify(&q.actor_url); - let rel = application::social::get_relation::execute( - &state.app_ctx.deps.social.query, - user.0.value(), - target, - ) - .await?; - Ok(Json(FollowRelationResponse { - following: rel.following.into(), - followed_by: rel.followed_by.into(), - })) -} - -// ── HTML ───────────────────────────────────────────────────────────────────── - -pub async fn follow_user( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let redirect_base = form - .redirect_after - .as_deref() - .filter(|u| u.starts_with('/') && !u.starts_with("//")) - .unwrap_or(&format!("/users/{}", profile_user_uuid)) - .to_string(); - - match application::social::execute::execute_command( - &state.app_ctx.deps.social.command, - application::social::commands::SocialCmd::Follow { - follower_id: user_id.value(), - target: FollowTarget::Handle(form.handle), - }, - ) - .await - { - Ok(()) => Redirect::to(&redirect_base).into_response(), - Err(e) => { - tracing::error!("follow error: {:?}", e); - let msg = encode_domain_error(&e); - let sep = if redirect_base.contains('?') { - '&' - } else { - '?' - }; - Redirect::to(&format!("{}{}error={}", redirect_base, sep, msg)).into_response() - } - } -} - -pub async fn unfollow_user( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let instance = state.app_ctx.instance.clone(); - match application::social::execute::execute_command( - &state.app_ctx.deps.social.command, - application::social::commands::SocialCmd::Unfollow { - follower_id: user_id.value(), - target: instance.identify(&form.actor_url), - }, - ) - .await - { - Ok(()) => { - Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)).into_response() - } - Err(e) => { - let msg = encode_domain_error(&e); - Redirect::to(&format!( - "/users/{}/following-list?error={}", - profile_user_uuid, msg - )) - .into_response() - } - } -} - -pub async fn accept_follower_html( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let instance = state.app_ctx.instance.clone(); - match application::social::execute::execute_command( - &state.app_ctx.deps.social.command, - application::social::commands::SocialCmd::AcceptFollow { - owner_id: user_id.value(), - requester: instance.identify(&form.actor_url), - }, - ) - .await - { - Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), - Err(e) => { - let msg = encode_domain_error(&e); - Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response() - } - } -} - -pub async fn reject_follower_html( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let instance = state.app_ctx.instance.clone(); - match application::social::execute::execute_command( - &state.app_ctx.deps.social.command, - application::social::commands::SocialCmd::RejectFollow { - owner_id: user_id.value(), - requester: instance.identify(&form.actor_url), - }, - ) - .await - { - Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), - Err(e) => { - let msg = encode_domain_error(&e); - Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn get_followers_collection( - State(state): State, - Path(user_id): Path, - headers: axum::http::HeaderMap, - Query(params): Query>, -) -> impl IntoResponse { - let accept = headers - .get(axum::http::header::ACCEPT) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if accept.contains("application/activity+json") || accept.contains("application/ld+json") { - let page = params.get("page").and_then(|p| p.parse::().ok()); - return match state - .app_ctx - .ap_document - .followers_collection_json(user_id, page) - .await - { - Ok(json) => ( - [( - axum::http::header::CONTENT_TYPE, - "application/activity+json", - )], - json, - ) - .into_response(), - Err(e) => { - tracing::error!("followers_collection_json error: {:?}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - }; - } - axum::response::Redirect::to(&format!("/users/{}/followers-list", user_id)).into_response() -} - -#[cfg(feature = "federation")] -pub async fn get_following_collection( - State(state): State, - Path(user_id): Path, - headers: axum::http::HeaderMap, - Query(params): Query>, -) -> impl IntoResponse { - let accept = headers - .get(axum::http::header::ACCEPT) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if accept.contains("application/activity+json") || accept.contains("application/ld+json") { - let page = params.get("page").and_then(|p| p.parse::().ok()); - return match state - .app_ctx - .ap_document - .following_collection_json(user_id, page) - .await - { - Ok(json) => ( - [( - axum::http::header::CONTENT_TYPE, - "application/activity+json", - )], - json, - ) - .into_response(), - Err(e) => { - tracing::error!("following_collection_json error: {:?}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - }; - } - axum::response::Redirect::to(&format!("/users/{}/following-list", user_id)).into_response() -} - -pub async fn get_following_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; - ctx.page_title = "Following — Movies Diary".to_string(); - ctx.canonical_url = format!( - "{}/users/{}/following-list", - state.app_ctx.config.base_url, profile_user_uuid - ); - let instance = state.app_ctx.instance.clone(); - match application::social::get_following::execute( - &state.app_ctx.deps.social.query, - user_id.value(), - ) - .await - { - Ok(following) => { - let actors: Vec = following - .into_iter() - .map(|a| social_actor_to_template(a, &instance)) - .collect(); - let pending_actors: Vec = - match application::social::get_pending_following::execute( - &state.app_ctx.deps.social.query, - user_id.value(), - ) - .await - { - Ok(pending) => pending - .into_iter() - .map(|a| social_actor_to_template(a, &instance)) - .collect(), - Err(e) => { - tracing::error!("get_pending_following error: {:?}", e); - Vec::new() - } - }; - render_page(FollowingTemplate { - ctx, - user_id: profile_user_uuid, - actors, - pending_actors, - error: params.error, - }) - .into_response() - } - Err(e) => { - tracing::error!("get_following error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to load following list", - ) - .into_response() - } - } -} - -pub async fn get_followers_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; - ctx.page_title = "Followers — Movies Diary".to_string(); - ctx.canonical_url = format!( - "{}/users/{}/followers-list", - state.app_ctx.config.base_url, profile_user_uuid - ); - let instance = state.app_ctx.instance.clone(); - match application::social::get_followers::execute( - &state.app_ctx.deps.social.query, - user_id.value(), - ) - .await - { - Ok(followers) => { - let actors: Vec = followers - .into_iter() - .map(|a| social_actor_to_template(a, &instance)) - .collect(); - render_page(FollowersTemplate { - ctx, - user_id: profile_user_uuid, - actors, - error: params.error, - }) - .into_response() - } - Err(e) => { - tracing::error!("get_followers error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to load followers list", - ) - .into_response() - } - } -} - -pub async fn remove_follower_html( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let instance = state.app_ctx.instance.clone(); - match application::social::execute::execute_command( - &state.app_ctx.deps.social.command, - application::social::commands::SocialCmd::RemoveFollower { - owner_id: user_id.value(), - follower: instance.identify(&form.actor_url), - }, - ) - .await - { - Ok(_) => { - Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)).into_response() - } - Err(e) => { - let msg = encode_domain_error(&e); - Redirect::to(&format!( - "/users/{}/followers-list?error={}", - profile_user_uuid, msg - )) - .into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn get_blocked_domains_page( - crate::extractors::AdminUser(user_id): crate::extractors::AdminUser, - State(state): State, - Extension(csrf): Extension, -) -> impl IntoResponse { - let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await; - ctx.page_title = "Blocked Domains — Movies Diary".to_string(); - ctx.canonical_url = format!("{}/admin/blocked-domains", state.app_ctx.config.base_url); - match state.app_ctx.ap_blocklist.get_blocked_domains().await { - Ok(domains) => { - let entries: Vec = domains - .into_iter() - .map(|d| crate::html::templates::BlockedDomainEntry { - domain: d.domain, - reason: d.reason, - blocked_at: d.blocked_at, - }) - .collect(); - render_page(BlockedDomainsTemplate { - ctx: &ctx, - domains: &entries, - }) - .into_response() - } - Err(e) => { - tracing::error!("get_blocked_domains error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to load blocked domains", - ) - .into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn post_blocked_domain( - crate::extractors::AdminUser(_): crate::extractors::AdminUser, - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty()); - match state - .app_ctx - .ap_blocklist - .add_blocked_domain(&form.domain, reason) - .await - { - Ok(()) => Redirect::to("/admin/blocked-domains").into_response(), - Err(e) => { - tracing::error!("add_blocked_domain error: {:?}", e); - Redirect::to("/admin/blocked-domains").into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn post_remove_blocked_domain( - crate::extractors::AdminUser(_): crate::extractors::AdminUser, - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - match state - .app_ctx - .ap_blocklist - .remove_blocked_domain(&form.domain) - .await - { - Ok(()) => Redirect::to("/admin/blocked-domains").into_response(), - Err(e) => { - tracing::error!("remove_blocked_domain error: {:?}", e); - Redirect::to("/admin/blocked-domains").into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn get_blocked_actors_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Extension(csrf): Extension, -) -> impl IntoResponse { - let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; - ctx.page_title = "Blocked Users — Movies Diary".to_string(); - ctx.canonical_url = format!("{}/social/blocked", state.app_ctx.config.base_url); - let instance = state.app_ctx.instance.clone(); - match application::social::get_blocked::execute( - &state.app_ctx.deps.social.query, - user_id.value(), - ) - .await - { - Ok(blocked) => { - let entries: Vec = blocked - .into_iter() - .map(|a| crate::html::templates::BlockedActorEntry { - url: instance.actor_url_of(&a.identity), - handle: a.handle, - display_name: a.display_name, - avatar_url: a.avatar_url, - }) - .collect(); - render_page(BlockedActorsTemplate { - ctx: &ctx, - actors: &entries, - }) - .into_response() - } - Err(e) => { - tracing::error!("get_blocked_actors error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to load blocked users", - ) - .into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn post_block_actor_html( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let instance = state.app_ctx.instance.clone(); - match application::social::execute::execute_command( - &state.app_ctx.deps.social.command, - application::social::commands::SocialCmd::Block { - blocker_id: user_id.value(), - target: instance.identify(&form.actor_url), - }, - ) - .await - { - Ok(()) => Redirect::to("/social/blocked").into_response(), - Err(e) => { - tracing::error!("block_actor html error: {:?}", e); - Redirect::to("/social/blocked").into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn post_unblock_actor( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let instance = state.app_ctx.instance.clone(); - match application::social::execute::execute_command( - &state.app_ctx.deps.social.command, - application::social::commands::SocialCmd::Unblock { - blocker_id: user_id.value(), - target: instance.identify(&form.actor_url), - }, - ) - .await - { - Ok(()) => Redirect::to("/social/blocked").into_response(), - Err(e) => { - tracing::error!("unblock_actor error: {:?}", e); - Redirect::to("/social/blocked").into_response() - } - } -} - -#[cfg(test)] -#[path = "tests/social.rs"] -mod tests; diff --git a/crates/adapters/http-axum/src/handlers/users.rs b/crates/adapters/http-axum/src/handlers/users.rs deleted file mode 100644 index c6f8034d..00000000 --- a/crates/adapters/http-axum/src/handlers/users.rs +++ /dev/null @@ -1,869 +0,0 @@ -use std::str::FromStr; - -use axum::{ - Json, - extract::{Extension, Multipart, Path, Query, State}, - http::StatusCode, - response::IntoResponse, -}; -use uuid::Uuid; - -use application::users::{ - get_federated_profile, get_federated_profile_stats, get_local_profile, - get_profile_settings as get_profile_settings_uc, get_users, - queries::{GetUserProfileQuery, GetUsersQuery}, - resolve_username_to_id, update_profile, update_profile_fields, -}; -use domain::{errors::DomainError, value_objects::Username}; - -use crate::html::templates::{ - EmbedProfileTemplate, MonthlyRatingRow, ProfileSettingsTemplate, ProfileTemplate, - RemoteActorData, RemoteActorDisplay, UserSummaryView, UsersTemplate, bar_height_px, - build_heatmap, build_page_items, -}; -use crate::{ - csrf::CsrfToken, - errors::ApiError, - extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser}, - render::render_page, - state::AppState, -}; -use api_types::{ - ActorStatDto, DiaryResponse, DirectorStatDto, GenreStatDto, MonthActivityDto, MonthlyRatingDto, - ProfileResponse, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, - UserTrendsDto, UsersResponse, WatchMediumStatDto, -}; - -use super::goals::goal_with_progress_to_dto; -use super::helpers::build_page_context; - -// ── API ────────────────────────────────────────────────────────────────────── - -#[utoipa::path( - get, path = "/api/v1/profile", - responses( - (status = 200, body = ProfileResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "User not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_profile( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, -) -> Result, ApiError> { - let profile = application::users::get_current_profile::execute( - &state.app_ctx.deps.users.get_current_profile, - application::users::queries::GetCurrentProfileQuery { - user_id: user_id.value(), - }, - ) - .await?; - let instance = &state.app_ctx.instance; - Ok(Json(ProfileResponse { - profile: api_types::UserProfileBase { - username: profile.username, - display_name: profile.display_name, - bio: profile.bio, - avatar_url: profile.avatar_path.map(|p| instance.image_url_for(&p)), - banner_url: profile.banner_path.map(|p| instance.image_url_for(&p)), - }, - also_known_as: profile.also_known_as, - fields: profile - .fields - .into_iter() - .map(|f| api_types::ProfileFieldDto { - name: f.name, - value: f.value, - }) - .collect(), - role: profile.role, - })) -} - -#[utoipa::path( - put, path = "/api/v1/profile", - responses( - (status = 204, description = "Profile updated"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error"), - ), - security(("bearer_auth" = [])) -)] -pub async fn update_profile_handler( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - multipart: Multipart, -) -> impl IntoResponse { - let data = super::helpers::parse_profile_multipart(multipart).await; - - let cmd = application::users::commands::UpdateProfileCommand { - user_id: user_id.value(), - display_name: data.display_name, - bio: data.bio, - avatar_bytes: data.avatar_bytes, - avatar_content_type: data.avatar_content_type, - banner_bytes: data.banner_bytes, - banner_content_type: data.banner_content_type, - also_known_as: data.also_known_as, - }; - - match update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => crate::errors::domain_error_response(e), - } -} - -#[utoipa::path( - put, path = "/api/v1/profile/fields", - request_body = api_types::UpdateProfileFieldsRequest, - responses( - (status = 204, description = "Profile fields updated"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error"), - ), - security(("bearer_auth" = [])) -)] -pub async fn update_profile_fields_handler( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - axum::Json(body): axum::Json, -) -> impl IntoResponse { - let raw_fields = match body.get("fields").and_then(|f| f.as_array()) { - Some(arr) => arr.clone(), - None => return StatusCode::BAD_REQUEST.into_response(), - }; - - let fields: Vec = raw_fields - .iter() - .filter_map(|f| { - let name = f.get("name").and_then(|n| n.as_str())?.to_string(); - let value = f.get("value").and_then(|v| v.as_str())?.to_string(); - Some(domain::models::ProfileField { name, value }) - }) - .collect(); - - let cmd = application::users::commands::UpdateProfileFieldsCommand { - user_id: user_id.value(), - fields, - }; - - match update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, cmd).await - { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => crate::errors::domain_error_response(e), - } -} - -#[utoipa::path( - get, path = "/api/v1/users", - responses((status = 200, body = UsersResponse)), -)] -pub async fn list_users(State(state): State) -> Result, ApiError> { - let result = - get_users::execute(&state.app_ctx.deps.users.get_users_list, GetUsersQuery).await?; - Ok(Json(UsersResponse { - users: result - .users - .iter() - .map(|u| UserSummaryDto { - id: u.user_id.value(), - email: u.email().to_string(), - username: u.username().to_string(), - display_name: u.display_name().map(String::from), - total_movies: u.total_movies, - avg_rating: u.avg_rating, - }) - .collect(), - })) -} - -#[utoipa::path( - get, path = "/api/v1/users/{id}", - params( - ("id" = Uuid, Path, description = "User ID"), - UserProfileQueryParams, - ), - responses( - (status = 200, body = UserProfileResponse), - (status = 404, description = "User not found"), - ) -)] -pub async fn get_user_profile( - State(state): State, - AuthenticatedUser(viewer_id): AuthenticatedUser, - Path(user_id): Path, - Query(params): Query, -) -> impl IntoResponse { - let view_str = params.view.as_deref().unwrap_or("recent"); - let profile_view = match application::users::queries::ProfileView::from_str(view_str) { - Ok(v) => v, - Err(_) => return StatusCode::BAD_REQUEST.into_response(), - }; - - let profile = match get_local_profile::execute( - &state.app_ctx.deps.users.get_local_profile, - GetUserProfileQuery { - user_id, - view: profile_view, - limit: params.limit, - offset: params.offset, - sort_by: domain::models::FeedSortBy::Date, - search: params.search.clone(), - is_own_profile: viewer_id.value() == user_id, - include_remote: false, - }, - ) - .await - { - Ok(p) => p, - Err(DomainError::NotFound(_)) => { - if let Ok(Some(fed)) = get_federated_profile::execute( - &state.app_ctx.deps.users.get_federated_profile, - user_id, - ) - .await - { - return build_federated_profile_response( - &state, - user_id, - fed, - profile_view, - ¶ms, - ) - .await; - } - return StatusCode::NOT_FOUND.into_response(); - } - Err(e) => return crate::errors::domain_error_response(e), - }; - - let view_data = if let Some(p) = profile.entries { - Some(api_types::ProfileViewData::Entries { - entries: DiaryResponse { - items: p - .items - .iter() - .map(crate::mappers::movies::entry_to_dto) - .collect(), - total_count: p.total_count, - limit: p.limit, - offset: p.offset, - }, - }) - } else if let Some(h) = profile.history { - Some(api_types::ProfileViewData::History { - history: application::users::group_by_month(h) - .into_iter() - .map(|m| MonthActivityDto { - year_month: m.year_month, - month_label: m.month_label, - count: m.count, - entries: m - .entries - .iter() - .map(crate::mappers::movies::entry_to_dto) - .collect(), - }) - .collect(), - }) - } else { - profile.trends.map(|t| api_types::ProfileViewData::Trends { - trends: trends_to_dto(t), - }) - }; - - Json(UserProfileResponse { - user_id, - profile: api_types::UserProfileBase { - username: profile.identity.username.clone(), - avatar_url: profile.identity.avatar_url.clone(), - banner_url: profile.identity.banner_url.clone(), - display_name: profile.identity.display_name.clone(), - bio: profile.identity.bio.clone(), - }, - stats: UserStatsDto { - total_movies: profile.stats.total_movies, - avg_rating: profile.stats.avg_rating, - favorite_director: profile.stats.favorite_director, - most_active_month: profile.stats.most_active_month, - }, - following_count: profile.following_count, - followers_count: profile.followers_count, - view_data, - goals: { - let goals_list = application::goals::list::execute( - &state.app_ctx.deps.goals.query, - application::goals::queries::ListGoalsQuery { user_id }, - ) - .await - .unwrap_or_default(); - if goals_list.is_empty() { - None - } else { - Some(goals_list.iter().map(goal_with_progress_to_dto).collect()) - } - }, - is_federated: false, - handle: Some(profile.identity.handle.clone()), - actor_url: Some(profile.identity.actor_url.clone()), - }) - .into_response() -} - -async fn build_federated_profile_response( - state: &AppState, - user_id: Uuid, - fed: domain::models::FederatedProfile, - profile_view: application::users::queries::ProfileView, - params: &UserProfileQueryParams, -) -> axum::response::Response { - let profile = match get_federated_profile_stats::execute( - &state.app_ctx.deps.users.get_federated_profile_stats, - GetUserProfileQuery { - user_id, - view: profile_view, - limit: params.limit, - offset: params.offset, - sort_by: domain::models::FeedSortBy::Date, - search: params.search.clone(), - is_own_profile: false, - include_remote: true, - }, - ) - .await - { - Ok(p) => p, - Err(e) => return crate::errors::domain_error_response(e), - }; - - let view_data = if let Some(p) = profile.entries { - Some(api_types::ProfileViewData::Entries { - entries: DiaryResponse { - items: p - .items - .iter() - .map(crate::mappers::movies::entry_to_dto) - .collect(), - total_count: p.total_count, - limit: p.limit, - offset: p.offset, - }, - }) - } else { - profile.trends.map(|t| api_types::ProfileViewData::Trends { - trends: trends_to_dto(t), - }) - }; - - let username = fed - .display_name - .clone() - .unwrap_or_else(|| fed.handle.clone()); - - Json(UserProfileResponse { - user_id, - profile: api_types::UserProfileBase { - username, - avatar_url: fed.avatar_url, - banner_url: fed.banner_url, - display_name: fed.display_name, - bio: fed.bio, - }, - stats: UserStatsDto { - total_movies: profile.stats.total_movies, - avg_rating: profile.stats.avg_rating, - favorite_director: profile.stats.favorite_director, - most_active_month: profile.stats.most_active_month, - }, - following_count: 0, - followers_count: 0, - view_data, - goals: None, - is_federated: true, - handle: Some(fed.handle), - actor_url: Some(fed.actor_url), - }) - .into_response() -} - -fn trends_to_dto(t: domain::models::UserTrends) -> UserTrendsDto { - UserTrendsDto { - monthly_ratings: t - .monthly_ratings - .into_iter() - .map(|r| MonthlyRatingDto { - year_month: r.year_month, - month_label: r.month_label, - avg_rating: r.avg_rating, - count: r.count, - }) - .collect(), - top_directors: t - .top_directors - .into_iter() - .map(|d| DirectorStatDto { - director: d.director, - count: d.count, - }) - .collect(), - max_director_count: t.max_director_count, - top_genres: t - .top_genres - .into_iter() - .map(|g| GenreStatDto { - genre: g.genre, - count: g.count, - }) - .collect(), - rating_distribution: t.rating_distribution, - watch_medium_distribution: t - .watch_medium_distribution - .into_iter() - .map(|m| WatchMediumStatDto { - medium: m.medium, - count: m.count, - }) - .collect(), - top_actors: t - .top_actors - .into_iter() - .map(|a| ActorStatDto { - name: a.name, - profile_path: a.profile_path, - movie_count: a.movie_count, - score: a.score, - }) - .collect(), - } -} - -// ── HTML ───────────────────────────────────────────────────────────────────── - -pub async fn get_users_list( - OptionalCookieUser(user_id): OptionalCookieUser, - State(state): State, - Extension(csrf): Extension, -) -> impl IntoResponse { - let mut ctx = build_page_context(&state, user_id, csrf.0).await; - ctx.page_title = "Members — Movies Diary".to_string(); - ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url); - - match application::users::get_users::execute( - &state.app_ctx.deps.users.get_users_list, - application::users::queries::GetUsersQuery, - ) - .await - { - Ok(result) => { - let users: Vec = result - .users - .iter() - .map(crate::mappers::users::user_summary_view) - .collect(); - let remote_actors: Vec = result - .remote_actors - .iter() - .map(crate::mappers::users::remote_actor_display) - .collect(); - render_page(UsersTemplate { - users, - ctx: &ctx, - remote_actors, - }) - .into_response() - } - Err(e) => crate::errors::domain_error_response(e), - } -} - -pub async fn get_user_by_username( - State(state): State, - Path(username): Path, -) -> impl IntoResponse { - let uname = match Username::new(username) { - Ok(u) => u, - Err(_) => return StatusCode::NOT_FOUND.into_response(), - }; - match resolve_username_to_id::execute(&state.app_ctx.deps.users.resolve_username, &uname).await - { - Ok(Some(uid)) => { - axum::response::Redirect::permanent(&format!("/users/{}", uid.value())).into_response() - } - _ => StatusCode::NOT_FOUND.into_response(), - } -} - -// ── Profile helpers (private) ─────────────────────────────────────────────── - -struct PaginationInfo { - offset: u32, - has_more: bool, - limit: u32, - page_items: Vec, -} - -fn compute_pagination( - entries: Option<&domain::models::collections::Paginated>, -) -> PaginationInfo { - let (offset, has_more, limit) = entries - .map(|e| { - let has_more = (e.offset as u64).saturating_add(e.limit as u64) < e.total_count; - (e.offset, has_more, e.limit) - }) - .unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT)); - let total = entries.map(|e| e.total_count as u32).unwrap_or(0); - let total_pages = total - .saturating_add(limit.saturating_sub(1)) - .checked_div(limit) - .unwrap_or(1); - let current_page = offset.checked_div(limit).unwrap_or(0); - let page_items = build_page_items(total_pages, current_page); - PaginationInfo { - offset, - has_more, - limit, - page_items, - } -} - -struct StatsDisplay { - avg_rating: String, - favorite_director: String, - most_active_month: String, -} - -fn build_stats_display(stats: &domain::models::UserStats) -> StatsDisplay { - StatsDisplay { - avg_rating: stats - .avg_rating - .map(|r| format!("{:.1}", r)) - .unwrap_or_else(|| "\u{2014}".to_string()), - favorite_director: stats - .favorite_director - .clone() - .unwrap_or_else(|| "\u{2014}".to_string()), - most_active_month: stats - .most_active_month - .clone() - .unwrap_or_else(|| "\u{2014}".to_string()), - } -} - -fn build_monthly_rating_rows<'a>( - trends: Option<&'a domain::models::UserTrends>, -) -> Vec> { - trends - .map(|t| { - t.monthly_ratings - .iter() - .map(|r| MonthlyRatingRow { - rating: r, - bar_height_px: bar_height_px(r.avg_rating), - }) - .collect() - }) - .unwrap_or_default() -} - -async fn fetch_profile_goals( - state: &AppState, - user_id: Uuid, -) -> Vec { - let goals_list = application::goals::list::execute( - &state.app_ctx.deps.goals.query, - application::goals::queries::ListGoalsQuery { user_id }, - ) - .await - .unwrap_or_default(); - goals_list - .iter() - .map(|g| crate::html::templates::GoalViewData { - year: g.goal.year(), - target_count: g.goal.target_count(), - current_count: g.current_count, - percentage: g.percentage().round(), - is_complete: g.is_complete(), - }) - .collect() -} - -// ── Handler ───────────────────────────────────────────────────────────────── - -pub async fn get_user_profile_html( - OptionalCookieUser(user_id): OptionalCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - headers: axum::http::HeaderMap, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - // Content negotiation: AP clients request application/activity+json - #[cfg(feature = "federation")] - { - let accept = headers - .get(axum::http::header::ACCEPT) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if accept.contains("application/activity+json") || accept.contains("application/ld+json") { - return match state - .app_ctx - .ap_document - .actor_json(&profile_user_uuid.to_string()) - .await - { - Ok(json) => ( - [( - axum::http::header::CONTENT_TYPE, - "application/activity+json", - )], - json, - ) - .into_response(), - Err(_) => StatusCode::NOT_FOUND.into_response(), - }; - } - } - - #[cfg(not(feature = "federation"))] - let _ = &headers; - - let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await; - let view_str = params.view.as_deref().unwrap_or("recent"); - let profile_view = match application::users::queries::ProfileView::from_str(view_str) { - Ok(v) => v, - Err(_) => { - return ( - axum::http::StatusCode::BAD_REQUEST, - "invalid view parameter", - ) - .into_response(); - } - }; - - let sort_by_str = match params.sort_by.as_str() { - "date_asc" => "date_asc", - "rating" => "rating", - "rating_asc" => "rating_asc", - _ => "date", - }; - - let is_own_profile = user_id - .as_ref() - .map(|u| u.value() == profile_user_uuid) - .unwrap_or(false); - - let query = application::users::queries::GetUserProfileQuery { - user_id: profile_user_uuid, - view: profile_view, - limit: params.limit, - offset: params.offset, - sort_by: sort_by_str.parse().unwrap_or_default(), - search: if params.search.is_empty() { - None - } else { - Some(params.search.clone()) - }, - is_own_profile, - include_remote: false, - }; - - match get_local_profile::execute(&state.app_ctx.deps.users.get_local_profile, query).await { - Ok(profile) => { - ctx.page_title = format!("{}'s Diary — Movies Diary", profile.identity.username); - ctx.canonical_url = format!( - "{}/users/{}", - state.app_ctx.config.base_url, profile_user_uuid - ); - - let pag = compute_pagination(profile.entries.as_ref()); - if !is_own_profile { - ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid)); - } - let display_name = profile - .identity - .email - .split('@') - .next() - .unwrap_or("?") - .to_string(); - let stats_disp = build_stats_display(&profile.stats); - let history = profile.history.map(application::users::group_by_month); - let heatmap = history.as_deref().map(build_heatmap).unwrap_or_default(); - let monthly_rating_rows = build_monthly_rating_rows(profile.trends.as_ref()); - let pending_followers: Vec = profile - .pending_followers - .iter() - .map(crate::mappers::users::pending_follower_data) - .collect(); - if params.embed { - let profile_url = format!( - "{}/users/{}", - state.app_ctx.config.base_url, profile_user_uuid - ); - let response = render_page(EmbedProfileTemplate { - profile_display_name: display_name, - profile_user_id: profile_user_uuid, - profile_url, - stats: &profile.stats, - avg_rating_display: stats_disp.avg_rating, - favorite_director_display: stats_disp.favorite_director, - most_active_month_display: stats_disp.most_active_month, - view: profile_view.as_str(), - entries: profile.entries.as_ref(), - current_offset: pag.offset, - has_more: pag.has_more, - limit: pag.limit, - history: history.as_ref(), - trends: profile.trends.as_ref(), - monthly_rating_rows, - heatmap, - page_items: pag.page_items, - sort_by: sort_by_str.to_string(), - }); - let mut resp = response.into_response(); - resp.headers_mut().remove("x-frame-options"); - resp - } else { - let goals = fetch_profile_goals(&state, profile_user_uuid).await; - render_page(ProfileTemplate { - ctx: &ctx, - profile_display_name: display_name, - profile_user_id: profile_user_uuid, - stats: &profile.stats, - avg_rating_display: stats_disp.avg_rating, - favorite_director_display: stats_disp.favorite_director, - most_active_month_display: stats_disp.most_active_month, - view: profile_view.as_str(), - entries: profile.entries.as_ref(), - current_offset: pag.offset, - has_more: pag.has_more, - limit: pag.limit, - history: history.as_ref(), - trends: profile.trends.as_ref(), - monthly_rating_rows, - heatmap, - page_items: pag.page_items, - is_own_profile, - error: params.error, - following_count: profile.following_count, - followers_count: profile.followers_count, - pending_followers, - sort_by: sort_by_str.to_string(), - search: params.search.clone(), - goals, - }) - .into_response() - } - } - Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(), - Err(e) => crate::errors::domain_error_response(e), - } -} - -#[derive(serde::Deserialize, Default)] -pub struct SavedQuery { - pub saved: Option, -} - -pub async fn get_profile_settings( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; - ctx.page_title = "Profile Settings — Movies Diary".to_string(); - ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url); - - let settings = match get_profile_settings_uc::execute( - &state.app_ctx.deps.users.get_profile_settings, - user_id.value(), - ) - .await - { - Ok(s) => s, - Err(DomainError::NotFound(_)) => return StatusCode::NOT_FOUND.into_response(), - Err(e) => return crate::errors::domain_error_response(e), - }; - - let saved = params.saved.as_deref() == Some("1"); - - render_page(ProfileSettingsTemplate { - ctx: &ctx, - bio: settings.bio.as_deref(), - avatar_url: settings.avatar_url.as_deref(), - banner_url: settings.banner_url.as_deref(), - also_known_as: settings.also_known_as.as_deref(), - profile_fields: &settings.fields, - saved, - embed_url: format!( - "{}/users/{}?embed=true", - state.app_ctx.config.base_url, - user_id.value() - ), - }) - .into_response() -} - -pub async fn post_profile_settings( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - multipart: Multipart, -) -> impl IntoResponse { - let data = super::helpers::parse_profile_multipart(multipart).await; - - let cmd = application::users::commands::UpdateProfileCommand { - user_id: user_id.value(), - display_name: data.display_name, - bio: data.bio, - avatar_bytes: data.avatar_bytes, - avatar_content_type: data.avatar_content_type, - banner_bytes: data.banner_bytes, - banner_content_type: data.banner_content_type, - also_known_as: data.also_known_as, - }; - if let Err(e) = update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await { - tracing::error!("update_profile error: {:?}", e); - return axum::response::Redirect::to(&format!( - "/settings/profile?error={}", - super::helpers::encode_domain_error(&e) - )) - .into_response(); - } - - let fields: Vec = (0..4) - .filter_map(|i| { - data.profile_field_names - .get(&i) - .map(|name| domain::models::ProfileField { - name: name.clone(), - value: data - .profile_field_values - .get(&i) - .cloned() - .unwrap_or_default(), - }) - }) - .collect(); - - let fields_cmd = application::users::commands::UpdateProfileFieldsCommand { - user_id: user_id.value(), - fields, - }; - if let Err(e) = - update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, fields_cmd) - .await - { - tracing::error!("update_profile_fields error: {:?}", e); - return axum::response::Redirect::to(&format!( - "/settings/profile?error={}", - super::helpers::encode_domain_error(&e) - )) - .into_response(); - } - - axum::response::Redirect::to("/settings/profile?saved=1").into_response() -} diff --git a/crates/adapters/http-axum/src/handlers/watchlist.rs b/crates/adapters/http-axum/src/handlers/watchlist.rs deleted file mode 100644 index 9854e60f..00000000 --- a/crates/adapters/http-axum/src/handlers/watchlist.rs +++ /dev/null @@ -1,312 +0,0 @@ -use axum::{ - Form, Json, - extract::{Extension, Path, Query, State}, - http::StatusCode, - response::{IntoResponse, Redirect}, -}; -use uuid::Uuid; - -use application::{ - diary::commands::MovieInput, - watchlist::{ - add as add_to_watchlist, - commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand}, - get as get_watchlist, get_watchlist_for_owner, is_on as is_on_watchlist, - queries::{GetWatchlistQuery, IsOnWatchlistQuery}, - remove as remove_from_watchlist, - }, -}; -use domain::errors::DomainError; - -use crate::html::templates::WatchlistTemplate; -use crate::{ - csrf::CsrfToken, - errors::ApiError, - extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser}, - render::render_page, - state::AppState, -}; -use api_types::{ - AddToWatchlistRequest, PaginationQueryParams, WatchlistEntryDto, WatchlistResponse, - WatchlistStatusResponse, -}; - -use super::helpers::{build_page_context, encode_error}; - -// ── API ────────────────────────────────────────────────────────────────────── - -#[utoipa::path( - get, path = "/api/v1/watchlist", - params( - ("limit" = Option, Query, description = "Max results"), - ("offset" = Option, Query, description = "Offset"), - ), - responses( - (status = 200, body = WatchlistResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_watchlist_handler( - State(state): State, - user: AuthenticatedUser, - Query(params): Query, -) -> Result, ApiError> { - let page = get_watchlist::execute( - &state.app_ctx.deps.watchlist.get_watchlist, - GetWatchlistQuery { - user_id: user.0.value(), - limit: params.limit, - offset: params.offset, - }, - ) - .await?; - - Ok(Json(WatchlistResponse { - items: page - .items - .into_iter() - .map(|w| WatchlistEntryDto { - id: w.entry.id.value(), - movie: crate::mappers::movies::movie_to_dto(&w.movie), - added_at: w.entry.added_at.to_string(), - }) - .collect(), - total_count: page.total_count, - limit: page.limit, - offset: page.offset, - })) -} - -#[utoipa::path( - post, path = "/api/v1/watchlist", - request_body = AddToWatchlistRequest, - responses( - (status = 201, description = "Added to watchlist"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Movie not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn post_watchlist_add( - State(state): State, - user: AuthenticatedUser, - Json(req): Json, -) -> Result { - add_to_watchlist::execute( - &state.app_ctx.deps.watchlist.add, - AddToWatchlistCommand { - user_id: user.0.value(), - input: MovieInput { - movie_id: req.movie_id, - external_metadata_id: req.external_metadata_id, - manual_title: req.manual_title, - manual_release_year: req.manual_release_year, - manual_director: None, - }, - }, - ) - .await?; - Ok(StatusCode::CREATED) -} - -#[utoipa::path( - delete, path = "/api/v1/watchlist/{movie_id}", - params(("movie_id" = Uuid, Path, description = "Movie ID")), - responses( - (status = 204, description = "Removed from watchlist"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Not on watchlist"), - ), - security(("bearer_auth" = [])) -)] -pub async fn delete_watchlist_entry( - State(state): State, - user: AuthenticatedUser, - Path(movie_id): Path, -) -> Result { - remove_from_watchlist::execute( - &state.app_ctx.deps.watchlist.remove_from_watchlist, - RemoveFromWatchlistCommand { - user_id: user.0.value(), - movie_id, - }, - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - get, path = "/api/v1/watchlist/{movie_id}", - params(("movie_id" = Uuid, Path, description = "Movie ID")), - responses( - (status = 200, body = WatchlistStatusResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_watchlist_status( - State(state): State, - user: AuthenticatedUser, - Path(movie_id): Path, -) -> Result, ApiError> { - let on_watchlist = is_on_watchlist::execute( - &state.app_ctx.deps.watchlist.is_on_watchlist, - IsOnWatchlistQuery { - user_id: user.0.value(), - movie_id, - }, - ) - .await?; - Ok(Json(WatchlistStatusResponse { on_watchlist })) -} - -// ── HTML ───────────────────────────────────────────────────────────────────── - -pub async fn get_watchlist_page( - OptionalCookieUser(viewer_id): OptionalCookieUser, - State(state): State, - Path(owner_id): Path, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await; - let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false); - - let view = match get_watchlist_for_owner::execute( - &state.app_ctx.deps.watchlist.get_watchlist_for_owner, - owner_id, - params.limit.or(Some(20)), - params.offset.or(Some(0)), - ) - .await - { - Ok(view) => view, - Err(e) => return crate::errors::domain_error_response(e), - }; - - let result = match view { - get_watchlist_for_owner::WatchlistView::Local(page) => { - crate::mappers::watchlist::build_watchlist_page(page, is_owner) - } - get_watchlist_for_owner::WatchlistView::Remote(entries) => { - crate::mappers::watchlist::build_remote_watchlist_page(entries) - } - }; - - render_page(WatchlistTemplate { - ctx: &ctx, - owner_id, - display_entries: &result.display_entries, - current_offset: result.current_offset, - has_more: result.has_more, - limit: result.limit, - is_owner, - error: params.error, - }) - .into_response() -} - -pub async fn post_watchlist_add_html( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - - let redirect_base = form - .redirect_after - .as_deref() - .filter(|u| u.starts_with('/') && !u.starts_with("//")) - .unwrap_or("/") - .to_string(); - - let input = if let Some(id) = form.movie_id { - MovieInput { - movie_id: Some(id), - external_metadata_id: None, - manual_title: None, - manual_release_year: None, - manual_director: None, - } - } else { - let query = form.query.as_deref().unwrap_or("").trim().to_string(); - let is_external_id = query.starts_with("tmdb:") - || (query.starts_with("tt") - && query.len() > 2 - && query[2..].chars().all(|c| c.is_ascii_digit())); - if is_external_id { - MovieInput { - movie_id: None, - external_metadata_id: Some(query), - manual_title: None, - manual_release_year: None, - manual_director: None, - } - } else { - MovieInput { - movie_id: None, - external_metadata_id: None, - manual_title: if query.is_empty() { None } else { Some(query) }, - manual_release_year: form.year, - manual_director: None, - } - } - }; - - match add_to_watchlist::execute( - &state.app_ctx.deps.watchlist.add, - AddToWatchlistCommand { - user_id: user_id.value(), - input, - }, - ) - .await - { - Ok(()) => Redirect::to(&redirect_base).into_response(), - Err(DomainError::NotFound(_)) => Redirect::to(&redirect_base).into_response(), - Err(DomainError::ValidationError(msg)) => { - let sep = if redirect_base.contains('?') { - '&' - } else { - '?' - }; - let url = format!("{}{}error={}", redirect_base, sep, encode_error(&msg)); - Redirect::to(&url).into_response() - } - Err(e) => crate::errors::domain_error_response(e), - } -} - -pub async fn post_watchlist_remove_html( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - Path(movie_id): Path, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - match remove_from_watchlist::execute( - &state.app_ctx.deps.watchlist.remove_from_watchlist, - RemoveFromWatchlistCommand { - user_id: user_id.value(), - movie_id, - }, - ) - .await - { - Ok(()) | Err(DomainError::NotFound(_)) => { - let redirect_url = form - .redirect_after - .filter(|u| u.starts_with('/') && !u.starts_with("//")) - .unwrap_or_else(|| "/".to_string()); - Redirect::to(&redirect_url).into_response() - } - Err(e) => crate::errors::domain_error_response(e), - } -} diff --git a/crates/adapters/http-axum/src/handlers/auth.rs b/crates/adapters/http-axum/src/html/auth.rs similarity index 54% rename from crates/adapters/http-axum/src/handlers/auth.rs rename to crates/adapters/http-axum/src/html/auth.rs index e6754dfb..b3f02252 100644 --- a/crates/adapters/http-axum/src/handlers/auth.rs +++ b/crates/adapters/http-axum/src/html/auth.rs @@ -1,30 +1,20 @@ +use crate::html::templates::{LoginTemplate, RegisterTemplate}; +use crate::{ + html::csrf::{CsrfToken, secure_flag}, + html::forms::{ErrorQuery, LoginForm, RegisterForm}, + html::render::render_page, + state::AppState, +}; +use api_types::HtmlPageContext; +use application::auth::{login as login_uc, queries::LoginCommand}; use axum::{ - Form, Json, + Form, extract::{Extension, Query, State}, http::{HeaderValue, StatusCode, header::SET_COOKIE}, response::{IntoResponse, Redirect}, }; use chrono::Utc; -use application::auth::{ - commands::RegisterCommand, login as login_uc, queries::LoginCommand, register as register_uc, -}; - -use crate::html::templates::{LoginTemplate, RegisterTemplate}; -use crate::{ - csrf::{CsrfToken, secure_flag}, - errors::ApiError, - forms::{ErrorQuery, LoginForm, RegisterForm}, - render::render_page, - state::AppState, -}; -use api_types::HtmlPageContext; -use api_types::{ - LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest, -}; - -// ── HTML helpers ───────────────────────────────────────────────────────────── - fn set_cookie_header( token: &str, max_age: i64, @@ -42,103 +32,6 @@ fn set_cookie_header( ) } -// ── API ────────────────────────────────────────────────────────────────────── - -#[utoipa::path( - post, path = "/api/v1/auth/login", - request_body = LoginRequest, - responses( - (status = 200, body = LoginResponse), - (status = 401, description = "Invalid credentials"), - ) -)] -pub async fn login( - State(state): State, - Json(req): Json, -) -> Result, ApiError> { - let result = login_uc::execute( - &state.app_ctx.deps.auth.login, - LoginCommand { - email: req.email, - password: req.password, - }, - ) - .await?; - Ok(Json(LoginResponse { - token: result.token, - refresh_token: result.refresh_token, - user_id: result.user_id, - email: result.email, - expires_at: result.expires_at.to_rfc3339(), - role: result.role, - })) -} - -#[utoipa::path( - post, path = "/api/v1/auth/register", - request_body = RegisterRequest, - responses( - (status = 201, description = "User registered"), - (status = 400, description = "Invalid input"), - ) -)] -pub async fn register( - State(state): State, - Json(req): Json, -) -> Result { - register_uc::execute( - &state.app_ctx.deps.auth.register, - RegisterCommand { - email: req.email, - username: req.username, - password: req.password, - role: domain::models::UserRole::Standard, - }, - ) - .await?; - Ok(StatusCode::CREATED) -} - -#[utoipa::path( - post, path = "/api/v1/auth/refresh", - request_body = RefreshRequest, - responses( - (status = 200, body = RefreshResponse), - (status = 401, description = "Invalid or expired refresh token"), - ) -)] -pub async fn refresh( - State(state): State, - Json(req): Json, -) -> Result, ApiError> { - let result = - application::auth::refresh::execute(&state.app_ctx.deps.auth.refresh, &req.refresh_token) - .await?; - Ok(Json(RefreshResponse { - token: result.token, - refresh_token: result.refresh_token, - expires_at: result.expires_at.to_rfc3339(), - })) -} - -#[utoipa::path( - post, path = "/api/v1/auth/logout", - request_body = LogoutRequest, - responses( - (status = 204, description = "Logged out"), - ) -)] -pub async fn api_logout( - State(state): State, - Json(req): Json, -) -> StatusCode { - let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token) - .await; - StatusCode::NO_CONTENT -} - -// ── HTML ───────────────────────────────────────────────────────────────────── - pub async fn get_login_page( State(state): State, Query(params): Query, @@ -167,7 +60,7 @@ pub async fn post_login( Extension(csrf): Extension, Form(form): Form, ) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } match login_uc::execute( @@ -236,7 +129,7 @@ pub async fn post_register( if !state.app_ctx.config.allow_registration { return Redirect::to("/").into_response(); } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } match application::auth::register_and_login::execute( diff --git a/crates/adapters/http-axum/src/csrf.rs b/crates/adapters/http-axum/src/html/csrf.rs similarity index 100% rename from crates/adapters/http-axum/src/csrf.rs rename to crates/adapters/http-axum/src/html/csrf.rs diff --git a/crates/adapters/http-axum/src/html/diary.rs b/crates/adapters/http-axum/src/html/diary.rs new file mode 100644 index 00000000..ae3501e3 --- /dev/null +++ b/crates/adapters/http-axum/src/html/diary.rs @@ -0,0 +1,166 @@ +use crate::html::helpers::{build_page_context, encode_domain_error}; +use crate::html::templates::{ActivityFeedTemplate, NewReviewTemplate, build_page_items}; +use crate::support::build_export_response; +use crate::{ + extractors::{OptionalCookieUser, RequiredCookieUser}, + html::csrf::CsrfToken, + html::forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm}, + html::render::render_page, + state::AppState, +}; +use application::diary::{commands::DeleteReviewCommand, delete_review, log_review}; +use axum::{ + Form, + extract::{Extension, Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, +}; +use uuid::Uuid; + +pub async fn get_new_review_page( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await; + ctx.page_title = "Log a Review — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url); + render_page(NewReviewTemplate { + ctx: &ctx, + error: params.error.as_deref(), + }) +} + +pub async fn post_review_html( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let data = match LogReviewData::try_from(form) { + Ok(d) => d, + Err(_) => { + return Redirect::to("/reviews/new?error=Invalid+date+format").into_response(); + } + }; + + match log_review::execute( + &state.app_ctx.services.review_logger, + data.into_command(user_id.value()), + ) + .await + { + Ok(_) => Redirect::to("/").into_response(), + Err(e) => { + let msg = encode_domain_error(&e); + Redirect::to(&format!("/reviews/new?error={}", msg)).into_response() + } + } +} + +pub async fn post_delete_review_html( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Path(review_id): Path, + Form(form): Form, +) -> impl IntoResponse { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let cmd = DeleteReviewCommand { + review_id, + requesting_user_id: user_id.value(), + }; + match delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await { + Ok(()) => { + let redirect_url = form + .redirect_after + .filter(|url| { + (url.starts_with('/') && !url.starts_with("//")) || url.starts_with('?') + }) + .unwrap_or_else(|| "/".to_string()); + Redirect::to(&redirect_url).into_response() + } + Err(e) => crate::errors::domain_error_response(e), + } +} + +pub async fn get_export_html( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Query(params): Query, +) -> impl IntoResponse { + build_export_response(¶ms.format, user_id.value(), &state) +} + +pub async fn get_activity_feed_html( + OptionalCookieUser(user_id): OptionalCookieUser, + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let ctx = build_page_context(&state, user_id.clone(), csrf.0).await; + let limit = params.limit.unwrap_or(20); + let offset = params.offset.unwrap_or(0); + + let filter_following = params.filter == "following" && user_id.is_some(); + let filter_str = if filter_following { "following" } else { "all" }; + + let sort_by_str = match params.sort_by.as_str() { + "date_asc" => "date_asc", + "rating" => "rating", + "rating_asc" => "rating_asc", + _ => "date", + }; + + let query = application::diary::queries::GetActivityFeedQuery { + limit, + offset, + sort_by: sort_by_str.parse().unwrap_or_default(), + search: if params.search.is_empty() { + None + } else { + Some(params.search.clone()) + }, + viewer_user_id: user_id.map(|u| u.value()), + filter_following, + }; + + match application::diary::get_activity_feed::execute( + &state.app_ctx.deps.diary.get_activity_feed, + query, + ) + .await + { + Ok(entries) => { + let entry_limit = entries.limit; + let entry_offset = entries.offset; + let has_more = + (entry_offset as u64).saturating_add(entry_limit as u64) < entries.total_count; + let total_pages = (entries.total_count as u32) + .saturating_add(entry_limit.saturating_sub(1)) + .checked_div(entry_limit) + .unwrap_or(1); + let current_page = entry_offset.checked_div(entry_limit).unwrap_or(0); + let page_items = build_page_items(total_pages, current_page); + render_page(ActivityFeedTemplate { + entries: entries.items.as_slice(), + current_offset: entry_offset, + limit: entry_limit, + has_more, + ctx: &ctx, + page_items, + filter: filter_str.to_string(), + sort_by: sort_by_str.to_string(), + search: params.search, + }) + .into_response() + } + Err(e) => crate::errors::domain_error_response(e), + } +} diff --git a/crates/adapters/http-axum/src/forms.rs b/crates/adapters/http-axum/src/html/forms.rs similarity index 99% rename from crates/adapters/http-axum/src/forms.rs rename to crates/adapters/http-axum/src/html/forms.rs index ef32ac3e..d5573800 100644 --- a/crates/adapters/http-axum/src/forms.rs +++ b/crates/adapters/http-axum/src/html/forms.rs @@ -312,5 +312,5 @@ pub struct DismissWatchForm { } #[cfg(test)] -#[path = "tests/forms.rs"] +#[path = "forms_tests.rs"] mod tests; diff --git a/crates/adapters/http-axum/src/tests/forms.rs b/crates/adapters/http-axum/src/html/forms_tests.rs similarity index 100% rename from crates/adapters/http-axum/src/tests/forms.rs rename to crates/adapters/http-axum/src/html/forms_tests.rs diff --git a/crates/adapters/http-axum/src/html/helpers.rs b/crates/adapters/http-axum/src/html/helpers.rs new file mode 100644 index 00000000..6e0f8e48 --- /dev/null +++ b/crates/adapters/http-axum/src/html/helpers.rs @@ -0,0 +1,62 @@ +use api_types::HtmlPageContext; +use domain::value_objects::UserId; + +use crate::state::AppState; + +pub(crate) fn encode_error(msg: &str) -> String { + use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; + utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() +} + +/// Error text for a classic-page `?error=` redirect. Infrastructure failures +/// are logged here — the redirect is their only exit, so nothing else would — +/// and shown to the user generically, never with internal detail. +pub(crate) fn encode_domain_error(e: &domain::errors::DomainError) -> String { + match e { + domain::errors::DomainError::InfrastructureError(_) => { + tracing::error!("Internal error: {e}"); + encode_error("Something went wrong, please try again") + } + _ => encode_error(&e.to_string()), + } +} + +pub(crate) async fn build_page_context( + state: &AppState, + user_id: Option, + csrf_token: String, +) -> HtmlPageContext { + let uuid = user_id.as_ref().map(|u| u.value()); + // Page chrome (email/admin badge, pending-follow badge) degrades rather than + // blanking the page: a failed lookup or count logs a warning and falls back + // to a zeroed `PageViewer`, same as the previous silent `.unwrap_or(0)` — now + // logged and explicit, per the error policy in `get_page_viewer`'s doc comment. + let (user_email, is_admin, pending_follow_count) = if let Some(ref id) = user_id { + match application::users::get_page_viewer::execute( + &state.app_ctx.deps.users.get_page_viewer, + id.value(), + ) + .await + { + Ok(viewer) => (viewer.email, viewer.is_admin, viewer.pending_follow_count), + Err(e) => { + tracing::warn!("get_page_viewer failed for {}: {e}", id.value()); + (None, false, 0) + } + } + } else { + (None, false, 0) + }; + HtmlPageContext { + user_email, + user_id: uuid, + is_admin, + register_enabled: state.app_ctx.config.allow_registration, + rss_url: "/feed.rss".to_string(), + page_title: "Movies Diary".to_string(), + canonical_url: state.app_ctx.config.base_url.clone(), + csrf_token, + page_rss_url: None, + pending_follow_count, + } +} diff --git a/crates/adapters/http-axum/src/handlers/images.rs b/crates/adapters/http-axum/src/html/images.rs similarity index 99% rename from crates/adapters/http-axum/src/handlers/images.rs rename to crates/adapters/http-axum/src/html/images.rs index 04820aaf..6595f5f5 100644 --- a/crates/adapters/http-axum/src/handlers/images.rs +++ b/crates/adapters/http-axum/src/html/images.rs @@ -1,11 +1,10 @@ +use crate::state::AppState; use axum::{ extract::{Path, State}, http::{StatusCode, header}, response::IntoResponse, }; -use crate::state::AppState; - pub async fn get_image( State(state): State, Path(key): Path, diff --git a/crates/adapters/http-axum/src/html/import/mapping.rs b/crates/adapters/http-axum/src/html/import/mapping.rs new file mode 100644 index 00000000..26c820e1 --- /dev/null +++ b/crates/adapters/http-axum/src/html/import/mapping.rs @@ -0,0 +1,149 @@ +use crate::html::helpers::encode_domain_error; +use crate::html::render::render_page; +use crate::html::templates::ImportMappingTemplate; +use crate::support::str_to_domain_field; +use crate::{extractors::RequiredCookieUser, html::csrf::CsrfToken, state::AppState}; +use application::import::{ + apply_mapping as apply_import_mapping, commands::ApplyImportMappingCommand, get_mapping_stage, +}; +use axum::{ + Extension, Form, + extract::{Path, State}, + response::{IntoResponse, Redirect}, +}; +use domain::models::{ + FieldMapping, + import::{DomainField, Transform}, +}; +use domain::value_objects::ImportSessionId; +use std::collections::HashMap; + +fn parse_mapping_form(form: &HashMap) -> Vec { + let mut mappings = Vec::new(); + let mut i = 0usize; + loop { + if i > 64 { + break; + } + let col_key = format!("mapping_{i}_col"); + let Some(col) = form.get(&col_key).cloned() else { + break; + }; + let field_str = form + .get(&format!("mapping_{i}_field")) + .map(|s| s.as_str()) + .unwrap_or(""); + if let Some(domain_field) = str_to_domain_field(field_str) { + let transform = if domain_field == DomainField::Rating { + let scale: f64 = form + .get(&format!("mapping_{i}_scale")) + .and_then(|s| s.parse().ok()) + .unwrap_or(1.0); + Transform::RatingScale(scale) + } else if domain_field == DomainField::WatchedAt { + form.get(&format!("mapping_{i}_datefmt")) + .filter(|s| !s.is_empty()) + .cloned() + .map(Transform::DateFormat) + .unwrap_or(Transform::Identity) + } else { + Transform::Identity + }; + mappings.push(FieldMapping { + source_column: col, + domain_field, + transform, + }); + } + i += 1; + } + mappings +} + +pub async fn get_mapping_page( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Path(session_id_str): Path, + Extension(csrf): Extension, +) -> impl IntoResponse { + let Ok(session_id) = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + else { + return Redirect::to("/import").into_response(); + }; + let Ok(stage) = get_mapping_stage::execute( + &state.app_ctx.deps.import.get_mapping_stage, + session_id, + user_id.value(), + ) + .await + else { + return Redirect::to("/import").into_response(); + }; + + let ctx = crate::html::helpers::build_page_context(&state, Some(user_id), csrf.0).await; + let domain_fields: Vec<(&str, &str)> = vec![ + ("title", "Title"), + ("release_year", "Release Year"), + ("director", "Director"), + ("rating", "Rating"), + ("watched_at", "Watched At"), + ("comment", "Comment"), + ("external_metadata_id", "External ID"), + ]; + render_page(ImportMappingTemplate { + ctx: &ctx, + session_id: &session_id_str, + columns: &stage.columns, + sample_rows: &stage.sample_rows, + domain_fields: &domain_fields, + error: None, + }) + .into_response() +} + +pub async fn post_mapping( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Path(session_id_str): Path, + Extension(csrf): Extension, + Form(form): Form>, +) -> impl IntoResponse { + let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or(""); + if crate::html::csrf::mismatch(&csrf, csrf_token) { + return Redirect::to("/import").into_response(); + } + let Ok(session_id) = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + else { + return Redirect::to("/import").into_response(); + }; + let mappings = parse_mapping_form(&form); + if mappings.is_empty() { + return Redirect::to(&format!( + "/import/{}/mapping?error=select+at+least+one+mapping", + session_id_str + )) + .into_response(); + } + match apply_import_mapping::execute( + &state.app_ctx.deps.import.apply_mapping, + ApplyImportMappingCommand { + user_id: user_id.value(), + session_id: session_id.value(), + mappings, + }, + ) + .await + { + Ok(_) => Redirect::to(&format!("/import/{}/preview", session_id_str)).into_response(), + Err(e) => Redirect::to(&format!( + "/import/{}/mapping?error={}", + session_id_str, + encode_domain_error(&e) + )) + .into_response(), + } +} diff --git a/crates/adapters/http-axum/src/html/import/mod.rs b/crates/adapters/http-axum/src/html/import/mod.rs new file mode 100644 index 00000000..6ad8b3b5 --- /dev/null +++ b/crates/adapters/http-axum/src/html/import/mod.rs @@ -0,0 +1,7 @@ +mod mapping; +mod preview; +mod upload; + +pub use mapping::*; +pub use preview::*; +pub use upload::*; diff --git a/crates/adapters/http-axum/src/html/import/preview.rs b/crates/adapters/http-axum/src/html/import/preview.rs new file mode 100644 index 00000000..ccf99f70 --- /dev/null +++ b/crates/adapters/http-axum/src/html/import/preview.rs @@ -0,0 +1,131 @@ +use crate::html::helpers::encode_domain_error; +use crate::html::mappers::import::annotated_to_preview_row; +use crate::html::render::render_page; +use crate::html::templates::{ImportPreviewRow, ImportPreviewTemplate}; +use crate::{extractors::RequiredCookieUser, html::csrf::CsrfToken, state::AppState}; +use application::import::{ + commands::{ExecuteImportCommand, SaveImportProfileCommand}, + execute as execute_import, get_preview_stage, save_profile as save_import_profile, +}; +use axum::{ + Extension, Form, + extract::{Path, State}, + response::{IntoResponse, Redirect}, +}; +use domain::value_objects::ImportSessionId; + +pub async fn get_preview_page( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Path(session_id_str): Path, + Extension(csrf): Extension, +) -> impl IntoResponse { + let Ok(session_id) = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + else { + return Redirect::to("/import").into_response(); + }; + let Ok(stage) = get_preview_stage::execute( + &state.app_ctx.deps.import.get_preview_stage, + session_id, + user_id.value(), + ) + .await + else { + return Redirect::to("/import").into_response(); + }; + + let preview = match stage { + get_preview_stage::PreviewStage::Ready(preview) => preview, + get_preview_stage::PreviewStage::NotYetMapped => { + return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response(); + } + }; + + let rows: Vec = preview + .rows + .iter() + .enumerate() + .map(|(i, a)| annotated_to_preview_row(i, a)) + .collect(); + + let ctx = crate::html::helpers::build_page_context(&state, Some(user_id), csrf.0).await; + render_page(ImportPreviewTemplate { + ctx: &ctx, + session_id: &session_id_str, + columns: &preview.columns, + rows: &rows, + }) + .into_response() +} + +pub async fn post_confirm( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Path(session_id_str): Path, + Extension(csrf): Extension, + Form(form_entries): Form>, +) -> impl IntoResponse { + let csrf_token = form_entries + .iter() + .find(|(k, _)| k == "_csrf") + .map(|(_, v)| v.as_str()) + .unwrap_or(""); + if crate::html::csrf::mismatch(&csrf, csrf_token) { + return Redirect::to("/import").into_response(); + } + let Ok(session_id) = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + else { + return Redirect::to("/import").into_response(); + }; + + // Save profile if name provided + let profile_name = form_entries + .iter() + .find(|(k, _)| k == "profile_name") + .map(|(_, v)| v.clone()) + .filter(|n| !n.trim().is_empty()); + if let Some(name) = profile_name { + let _ = save_import_profile::execute( + &state.app_ctx.deps.import.save_profile, + SaveImportProfileCommand { + user_id: user_id.value(), + session_id: session_id.value(), + name, + }, + ) + .await; + } + + // Collect all "confirmed" checkbox values + let confirmed: Vec = form_entries + .iter() + .filter(|(k, _)| k == "confirmed") + .filter_map(|(_, v)| v.parse::().ok()) + .collect(); + + match execute_import::execute( + &state.app_ctx.deps.import.execute_import, + ExecuteImportCommand { + user_id: user_id.value(), + session_id: session_id.value(), + confirmed_indices: confirmed, + }, + ) + .await + { + Ok(summary) => Redirect::to(&format!( + "/import/done?imported={}&skipped={}&failed={}", + summary.imported, + summary.skipped_duplicates, + summary.failed.len() + )) + .into_response(), + Err(e) => { + Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response() + } + } +} diff --git a/crates/adapters/http-axum/src/html/import/upload.rs b/crates/adapters/http-axum/src/html/import/upload.rs new file mode 100644 index 00000000..9ed8ae33 --- /dev/null +++ b/crates/adapters/http-axum/src/html/import/upload.rs @@ -0,0 +1,147 @@ +use crate::html::helpers::encode_domain_error; +use crate::html::render::render_page; +use crate::html::templates::{ImportProfileView, ImportUploadTemplate}; +use crate::{extractors::RequiredCookieUser, html::csrf::CsrfToken, state::AppState}; +use application::import::{ + commands::{CreateImportSessionCommand, DeleteImportProfileCommand}, + create_session as create_import_session, delete_profile as delete_import_profile, + list_profiles as list_import_profiles, +}; +use axum::{ + Extension, Form, + extract::{Multipart, Path, State}, + response::{Html, IntoResponse, Redirect}, +}; +use domain::models::FileFormat; +use serde::Deserialize; +use std::collections::HashMap; + +pub async fn get_import_page( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, +) -> impl IntoResponse { + let ctx = crate::html::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await; + let profiles = + list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id) + .await + .unwrap_or_default() + .into_iter() + .map(|p| ImportProfileView { + id: p.id.value().to_string(), + name: p.name, + }) + .collect::>(); + render_page(ImportUploadTemplate { + ctx: &ctx, + profiles: &profiles, + error: None, + }) +} + +pub async fn post_upload( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + mut multipart: Multipart, +) -> impl IntoResponse { + let mut file_bytes: Option> = None; + let mut format_str = "csv".to_string(); + + while let Ok(Some(field)) = multipart.next_field().await { + match field.name() { + Some("file") => { + if let Ok(bytes) = field.bytes().await { + file_bytes = Some(bytes.to_vec()); + } + } + Some("format") => { + if let Ok(text) = field.text().await { + format_str = text; + } + } + _ => {} + } + } + + let bytes = match file_bytes { + Some(b) if !b.is_empty() => b, + _ => return Redirect::to("/import?error=no+file+provided").into_response(), + }; + + let format = match format_str.as_str() { + "json" => FileFormat::Json, + "xlsx" => FileFormat::Xlsx, + _ => FileFormat::Csv, + }; + + match create_import_session::execute( + &state.app_ctx.deps.import.create_session, + CreateImportSessionCommand { + user_id: user_id.value(), + bytes, + format, + }, + ) + .await + { + Ok(r) => Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response(), + Err(e) => { + Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response() + } + } +} + +pub async fn get_import_done( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + axum::extract::Query(params): axum::extract::Query, +) -> impl IntoResponse { + let _ctx = + crate::html::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await; + let html = format!( + r#" +

Import Complete

+

Imported: {}

+

Skipped duplicates: {}

+

Failed: {}

+ Go to My Profile + "#, + params.imported.unwrap_or(0), + params.skipped.unwrap_or(0), + params.failed.unwrap_or(0), + user_id.value(), + ); + Html(html) +} + +#[derive(Deserialize)] +pub struct ImportDoneParams { + pub imported: Option, + pub skipped: Option, + pub failed: Option, +} + +pub async fn post_delete_profile( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Path(profile_id_str): Path, + Extension(csrf): Extension, + Form(form): Form>, +) -> impl IntoResponse { + let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or(""); + if crate::html::csrf::mismatch(&csrf, csrf_token) { + return Redirect::to("/import").into_response(); + } + if let Ok(profile_id) = profile_id_str.parse::() { + let _ = delete_import_profile::execute( + &state.app_ctx.deps.import.delete_profile, + DeleteImportProfileCommand { + user_id: user_id.value(), + profile_id, + }, + ) + .await; + } + Redirect::to("/import").into_response() +} diff --git a/crates/adapters/http-axum/src/handlers/integrations.rs b/crates/adapters/http-axum/src/html/integrations.rs similarity index 83% rename from crates/adapters/http-axum/src/handlers/integrations.rs rename to crates/adapters/http-axum/src/html/integrations.rs index 2d6aab44..8e98de87 100644 --- a/crates/adapters/http-axum/src/handlers/integrations.rs +++ b/crates/adapters/http-axum/src/html/integrations.rs @@ -1,11 +1,9 @@ -use axum::{ - Form, - extract::{Extension, Path, Query, State}, - http::StatusCode, - response::{IntoResponse, Redirect}, +use crate::html::helpers::{build_page_context, encode_domain_error}; +use crate::html::templates::{IntegrationsTemplate, WatchQueueTemplate}; +use crate::{ + extractors::RequiredCookieUser, html::csrf::CsrfToken, html::forms::ErrorQuery, + html::render::render_page, state::AppState, }; -use uuid::Uuid; - use application::integrations::{ commands::{ ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand, @@ -17,21 +15,18 @@ use application::integrations::{ queries::{GetWatchQueueQuery, GetWebhookTokensQuery}, revoke_token as revoke_webhook_token, }; - -use crate::html::templates::{IntegrationsTemplate, WatchQueueTemplate}; -use crate::{ - csrf::CsrfToken, extractors::RequiredCookieUser, forms::ErrorQuery, render::render_page, - state::AppState, +use axum::{ + Form, + extract::{Extension, Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, }; - -use super::helpers::{build_page_context, encode_domain_error}; - -// ── HTML ───────────────────────────────────────────────────────────────────── +use uuid::Uuid; pub async fn get_integrations_page( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, - Query(params): Query, + Query(params): Query, Extension(csrf): Extension, ) -> impl IntoResponse { let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; @@ -48,7 +43,7 @@ pub async fn get_integrations_page( let token_views: Vec = tokens .iter() - .map(crate::mappers::integrations::webhook_token_view) + .map(crate::html::mappers::integrations::webhook_token_view) .collect(); let webhook_base_url = state.app_ctx.config.base_url.clone(); @@ -65,9 +60,9 @@ pub async fn post_generate_token( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, Extension(csrf): Extension, - Form(form): Form, + Form(form): Form, ) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } @@ -107,9 +102,9 @@ pub async fn post_revoke_token( State(state): State, Path(token_id): Path, Extension(csrf): Extension, - Form(form): Form, + Form(form): Form, ) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } @@ -127,8 +122,6 @@ pub async fn post_revoke_token( Redirect::to("/settings/integrations").into_response() } -// ── Watch Queue ────────────────────────────────────────────────────────────── - pub async fn get_watch_queue_page( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, @@ -148,7 +141,7 @@ pub async fn get_watch_queue_page( let entries: Vec = events .iter() - .map(crate::mappers::integrations::watch_queue_entry) + .map(crate::html::mappers::integrations::watch_queue_entry) .collect(); render_page(WatchQueueTemplate { @@ -164,9 +157,9 @@ pub async fn post_confirm_single( State(state): State, Path(event_id): Path, Extension(csrf): Extension, - Form(form): Form, + Form(form): Form, ) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } @@ -195,9 +188,9 @@ pub async fn post_dismiss_single( State(state): State, Path(event_id): Path, Extension(csrf): Extension, - Form(form): Form, + Form(form): Form, ) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } diff --git a/crates/adapters/http-axum/src/mappers/import.rs b/crates/adapters/http-axum/src/html/mappers/import.rs similarity index 100% rename from crates/adapters/http-axum/src/mappers/import.rs rename to crates/adapters/http-axum/src/html/mappers/import.rs diff --git a/crates/adapters/http-axum/src/mappers/integrations.rs b/crates/adapters/http-axum/src/html/mappers/integrations.rs similarity index 100% rename from crates/adapters/http-axum/src/mappers/integrations.rs rename to crates/adapters/http-axum/src/html/mappers/integrations.rs diff --git a/crates/adapters/http-axum/src/mappers/mod.rs b/crates/adapters/http-axum/src/html/mappers/mod.rs similarity index 60% rename from crates/adapters/http-axum/src/mappers/mod.rs rename to crates/adapters/http-axum/src/html/mappers/mod.rs index 12a30a15..b1c2872f 100644 --- a/crates/adapters/http-axum/src/mappers/mod.rs +++ b/crates/adapters/http-axum/src/html/mappers/mod.rs @@ -1,7 +1,4 @@ -pub mod diary; pub mod import; pub mod integrations; -pub mod movies; -pub mod search; pub mod users; pub mod watchlist; diff --git a/crates/adapters/http-axum/src/mappers/users.rs b/crates/adapters/http-axum/src/html/mappers/users.rs similarity index 100% rename from crates/adapters/http-axum/src/mappers/users.rs rename to crates/adapters/http-axum/src/html/mappers/users.rs diff --git a/crates/adapters/http-axum/src/mappers/watchlist.rs b/crates/adapters/http-axum/src/html/mappers/watchlist.rs similarity index 100% rename from crates/adapters/http-axum/src/mappers/watchlist.rs rename to crates/adapters/http-axum/src/html/mappers/watchlist.rs diff --git a/crates/adapters/http-axum/src/html/mod.rs b/crates/adapters/http-axum/src/html/mod.rs index b9a16841..e735855e 100644 --- a/crates/adapters/http-axum/src/html/mod.rs +++ b/crates/adapters/http-axum/src/html/mod.rs @@ -1 +1,163 @@ +pub mod csrf; +pub mod forms; +pub(crate) mod helpers; +mod mappers; +pub mod render; pub mod templates; + +mod auth; +mod diary; +mod images; +mod import; +mod integrations; +mod movies; +mod profile; +mod rss; +mod social; +mod tags; +mod users; +mod watchlist; +mod wrapup; + +use axum::{Router, routing}; + +use crate::state::AppState; + +/// Classic server-rendered UI routes, minus the rate-limited auth forms +/// ([`auth_router`]) and the federation-gated pages (`federation::html_routes`). +pub fn router() -> Router { + let base = Router::new() + .route("/", routing::get(diary::get_activity_feed_html)) + .route("/users", routing::get(users::get_users_list)) + .route("/u/{username}", routing::get(users::get_user_by_username)) + .route("/users/{id}", routing::get(profile::get_user_profile_html)) + .route( + "/movies/{movie_id}", + routing::get(movies::get_movie_detail_html), + ) + .route("/reviews/new", routing::get(diary::get_new_review_page)) + .route("/reviews", routing::post(diary::post_review_html)) + .route( + "/reviews/{id}/delete", + routing::post(diary::post_delete_review_html), + ) + .route("/images/{*key}", routing::get(images::get_image)) + .route( + "/posters/{path}", + routing::get( + |axum::extract::Path(p): axum::extract::Path| async move { + axum::response::Redirect::permanent(&format!("/images/{}", p)) + }, + ), + ) + .route("/diary/export", routing::get(diary::get_export_html)) + .route("/import", routing::get(import::get_import_page)) + .route("/import/upload", routing::post(import::post_upload)) + .route( + "/import/{id}/mapping", + routing::get(import::get_mapping_page).post(import::post_mapping), + ) + .route( + "/import/{id}/preview", + routing::get(import::get_preview_page), + ) + .route("/import/{id}/confirm", routing::post(import::post_confirm)) + .route("/import/done", routing::get(import::get_import_done)) + .route( + "/import/profiles/{profile_id}/delete", + routing::post(import::post_delete_profile), + ) + .route("/feed.rss", routing::get(rss::get_feed)) + .route("/users/{id}/feed.rss", routing::get(rss::get_user_feed)) + .route( + "/settings/profile", + routing::get(users::get_profile_settings).post(users::post_profile_settings), + ) + .route("/tags/{tag}", routing::get(tags::get_tag)) + .route( + "/users/{id}/watchlist", + routing::get(watchlist::get_watchlist_page), + ) + .route( + "/watchlist/add", + routing::post(watchlist::post_watchlist_add_html), + ) + .route( + "/watchlist/{movie_id}/remove", + routing::post(watchlist::post_watchlist_remove_html), + ) + .route( + "/settings/integrations", + routing::get(integrations::get_integrations_page), + ) + .route( + "/settings/integrations/generate", + routing::post(integrations::post_generate_token), + ) + .route( + "/settings/integrations/{id}/revoke", + routing::post(integrations::post_revoke_token), + ) + .route( + "/watch-queue", + routing::get(integrations::get_watch_queue_page), + ) + .route( + "/watch-queue/{id}/confirm", + routing::post(integrations::post_confirm_single), + ) + .route( + "/watch-queue/{id}/dismiss", + routing::post(integrations::post_dismiss_single), + ) + .route( + "/wrapups/{user_id}/{year}", + routing::get(wrapup::get_user_wrapup_html), + ) + .route( + "/wrapups/global/{year}", + routing::get(wrapup::get_global_wrapup_html), + ); + + base.merge(social_routes()) +} + +/// Login, logout and registration forms; the caller adds the auth rate limiter. +pub fn auth_router() -> Router { + Router::new() + .route( + "/login", + routing::get(auth::get_login_page).post(auth::post_login), + ) + .route("/logout", routing::get(auth::get_logout)) + .route( + "/register", + routing::get(auth::get_register_page).post(auth::post_register), + ) +} + +fn social_routes() -> Router { + Router::new() + .route("/users/{id}/follow", routing::post(social::follow_user)) + .route("/users/{id}/unfollow", routing::post(social::unfollow_user)) + .route( + "/users/{id}/followers/accept", + routing::post(social::accept_follower_html), + ) + .route( + "/users/{id}/followers/reject", + routing::post(social::reject_follower_html), + ) + .route( + "/users/{id}/followers/remove", + routing::post(social::remove_follower_html), + ) + .route( + "/users/{id}/following-list", + routing::get(social::get_following_page), + ) + .route( + "/users/{id}/followers-list", + routing::get(social::get_followers_page), + ) +} diff --git a/crates/adapters/http-axum/src/html/movies.rs b/crates/adapters/http-axum/src/html/movies.rs new file mode 100644 index 00000000..28aad195 --- /dev/null +++ b/crates/adapters/http-axum/src/html/movies.rs @@ -0,0 +1,77 @@ +use crate::html::helpers::build_page_context; +use crate::html::templates::MovieDetailTemplate; +use crate::{ + extractors::OptionalCookieUser, html::csrf::CsrfToken, html::render::render_page, + state::AppState, +}; +use application::{ + diary::{get_movie_social_page, queries::GetMovieSocialPageQuery}, + watchlist::{is_on as is_on_watchlist, queries::IsOnWatchlistQuery}, +}; +use axum::{ + extract::{Extension, Path, Query, State}, + response::IntoResponse, +}; + +pub async fn get_movie_detail_html( + OptionalCookieUser(user_id): OptionalCookieUser, + State(state): State, + Path(movie_id): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let ctx = build_page_context(&state, user_id.clone(), csrf.0).await; + let limit = params.limit.unwrap_or(20); + let offset = params.offset.unwrap_or(0); + + match get_movie_social_page::execute( + &state.app_ctx.deps.diary.get_movie_social_page, + GetMovieSocialPageQuery { + movie_id, + limit, + offset, + }, + ) + .await + { + Err(e) => crate::errors::domain_error_response(e), + Ok(result) => { + let histogram_max = result + .stats + .rating_histogram + .iter() + .copied() + .max() + .unwrap_or(1); + let has_more = + result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32; + let on_watchlist = match &user_id { + Some(uid) => is_on_watchlist::execute( + &state.app_ctx.deps.watchlist.is_on_watchlist, + IsOnWatchlistQuery { + user_id: uid.value(), + movie_id, + }, + ) + .await + .unwrap_or(false), + None => false, + }; + let current_offset = result.reviews.offset; + let reviews_limit = result.reviews.limit; + render_page(MovieDetailTemplate { + ctx: &ctx, + movie: &result.movie, + stats: &result.stats, + profile: result.profile.as_ref(), + reviews: result.reviews.items.as_slice(), + on_watchlist, + current_offset, + has_more, + limit: reviews_limit, + histogram_max, + }) + .into_response() + } + } +} diff --git a/crates/adapters/http-axum/src/html/profile.rs b/crates/adapters/http-axum/src/html/profile.rs new file mode 100644 index 00000000..a27e99d3 --- /dev/null +++ b/crates/adapters/http-axum/src/html/profile.rs @@ -0,0 +1,260 @@ +use crate::html::helpers::build_page_context; +use crate::html::templates::{ + EmbedProfileTemplate, MonthlyRatingRow, ProfileTemplate, RemoteActorData, bar_height_px, + build_heatmap, build_page_items, +}; +use crate::{ + extractors::OptionalCookieUser, html::csrf::CsrfToken, html::render::render_page, + state::AppState, +}; +use application::users::get_local_profile; +use axum::{ + extract::{Extension, Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use domain::errors::DomainError; +use std::str::FromStr; +use uuid::Uuid; + +const DEFAULT_PAGE_LIMIT: u32 = 5; + +struct PaginationInfo { + offset: u32, + has_more: bool, + limit: u32, + page_items: Vec, +} + +fn compute_pagination( + entries: Option<&domain::models::collections::Paginated>, +) -> PaginationInfo { + let (offset, has_more, limit) = entries + .map(|e| { + let has_more = (e.offset as u64).saturating_add(e.limit as u64) < e.total_count; + (e.offset, has_more, e.limit) + }) + .unwrap_or((0, false, DEFAULT_PAGE_LIMIT)); + let total = entries.map(|e| e.total_count as u32).unwrap_or(0); + let total_pages = total + .saturating_add(limit.saturating_sub(1)) + .checked_div(limit) + .unwrap_or(1); + let current_page = offset.checked_div(limit).unwrap_or(0); + let page_items = build_page_items(total_pages, current_page); + PaginationInfo { + offset, + has_more, + limit, + page_items, + } +} + +struct StatsDisplay { + avg_rating: String, + favorite_director: String, + most_active_month: String, +} + +fn build_stats_display(stats: &domain::models::UserStats) -> StatsDisplay { + StatsDisplay { + avg_rating: stats + .avg_rating + .map(|r| format!("{:.1}", r)) + .unwrap_or_else(|| "\u{2014}".to_string()), + favorite_director: stats + .favorite_director + .clone() + .unwrap_or_else(|| "\u{2014}".to_string()), + most_active_month: stats + .most_active_month + .clone() + .unwrap_or_else(|| "\u{2014}".to_string()), + } +} + +fn build_monthly_rating_rows<'a>( + trends: Option<&'a domain::models::UserTrends>, +) -> Vec> { + trends + .map(|t| { + t.monthly_ratings + .iter() + .map(|r| MonthlyRatingRow { + rating: r, + bar_height_px: bar_height_px(r.avg_rating), + }) + .collect() + }) + .unwrap_or_default() +} + +async fn fetch_profile_goals( + state: &AppState, + user_id: Uuid, +) -> Vec { + let goals_list = application::goals::list::execute( + &state.app_ctx.deps.goals.query, + application::goals::queries::ListGoalsQuery { user_id }, + ) + .await + .unwrap_or_default(); + goals_list + .iter() + .map(|g| crate::html::templates::GoalViewData { + year: g.goal.year(), + target_count: g.goal.target_count(), + current_count: g.current_count, + percentage: g.percentage().round(), + is_complete: g.is_complete(), + }) + .collect() +} + +pub async fn get_user_profile_html( + OptionalCookieUser(user_id): OptionalCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + headers: axum::http::HeaderMap, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + // Content negotiation: AP clients request application/activity+json + if let Some(response) = + crate::federation::actor_document_response(&state, &headers, profile_user_uuid).await + { + return response; + } + + let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await; + let view_str = params.view.as_deref().unwrap_or("recent"); + let profile_view = match application::users::queries::ProfileView::from_str(view_str) { + Ok(v) => v, + Err(_) => { + return ( + axum::http::StatusCode::BAD_REQUEST, + "invalid view parameter", + ) + .into_response(); + } + }; + + let sort_by_str = match params.sort_by.as_str() { + "date_asc" => "date_asc", + "rating" => "rating", + "rating_asc" => "rating_asc", + _ => "date", + }; + + let is_own_profile = user_id + .as_ref() + .map(|u| u.value() == profile_user_uuid) + .unwrap_or(false); + + let query = application::users::queries::GetUserProfileQuery { + user_id: profile_user_uuid, + view: profile_view, + limit: params.limit, + offset: params.offset, + sort_by: sort_by_str.parse().unwrap_or_default(), + search: if params.search.is_empty() { + None + } else { + Some(params.search.clone()) + }, + is_own_profile, + include_remote: false, + }; + + match get_local_profile::execute(&state.app_ctx.deps.users.get_local_profile, query).await { + Ok(profile) => { + ctx.page_title = format!("{}'s Diary — Movies Diary", profile.identity.username); + ctx.canonical_url = format!( + "{}/users/{}", + state.app_ctx.config.base_url, profile_user_uuid + ); + + let pag = compute_pagination(profile.entries.as_ref()); + if !is_own_profile { + ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid)); + } + let display_name = profile + .identity + .email + .split('@') + .next() + .unwrap_or("?") + .to_string(); + let stats_disp = build_stats_display(&profile.stats); + let history = profile.history.map(application::users::group_by_month); + let heatmap = history.as_deref().map(build_heatmap).unwrap_or_default(); + let monthly_rating_rows = build_monthly_rating_rows(profile.trends.as_ref()); + let pending_followers: Vec = profile + .pending_followers + .iter() + .map(crate::html::mappers::users::pending_follower_data) + .collect(); + if params.embed { + let profile_url = format!( + "{}/users/{}", + state.app_ctx.config.base_url, profile_user_uuid + ); + let response = render_page(EmbedProfileTemplate { + profile_display_name: display_name, + profile_user_id: profile_user_uuid, + profile_url, + stats: &profile.stats, + avg_rating_display: stats_disp.avg_rating, + favorite_director_display: stats_disp.favorite_director, + most_active_month_display: stats_disp.most_active_month, + view: profile_view.as_str(), + entries: profile.entries.as_ref(), + current_offset: pag.offset, + has_more: pag.has_more, + limit: pag.limit, + history: history.as_ref(), + trends: profile.trends.as_ref(), + monthly_rating_rows, + heatmap, + page_items: pag.page_items, + sort_by: sort_by_str.to_string(), + }); + let mut resp = response.into_response(); + resp.headers_mut().remove("x-frame-options"); + resp + } else { + let goals = fetch_profile_goals(&state, profile_user_uuid).await; + render_page(ProfileTemplate { + ctx: &ctx, + profile_display_name: display_name, + profile_user_id: profile_user_uuid, + stats: &profile.stats, + avg_rating_display: stats_disp.avg_rating, + favorite_director_display: stats_disp.favorite_director, + most_active_month_display: stats_disp.most_active_month, + view: profile_view.as_str(), + entries: profile.entries.as_ref(), + current_offset: pag.offset, + has_more: pag.has_more, + limit: pag.limit, + history: history.as_ref(), + trends: profile.trends.as_ref(), + monthly_rating_rows, + heatmap, + page_items: pag.page_items, + is_own_profile, + error: params.error, + following_count: profile.following_count, + followers_count: profile.followers_count, + pending_followers, + sort_by: sort_by_str.to_string(), + search: params.search.clone(), + goals, + }) + .into_response() + } + } + Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(), + Err(e) => crate::errors::domain_error_response(e), + } +} diff --git a/crates/adapters/http-axum/src/render.rs b/crates/adapters/http-axum/src/html/render.rs similarity index 100% rename from crates/adapters/http-axum/src/render.rs rename to crates/adapters/http-axum/src/html/render.rs diff --git a/crates/adapters/http-axum/src/handlers/rss.rs b/crates/adapters/http-axum/src/html/rss.rs similarity index 95% rename from crates/adapters/http-axum/src/handlers/rss.rs rename to crates/adapters/http-axum/src/html/rss.rs index 847b73ff..94b35d5e 100644 --- a/crates/adapters/http-axum/src/handlers/rss.rs +++ b/crates/adapters/http-axum/src/html/rss.rs @@ -1,18 +1,18 @@ +use crate::{errors::ApiError, state::AppState}; +use application::{diary::get_diary, diary::get_user_feed, diary::queries::GetDiaryQuery}; use axum::{ extract::{Path, State}, http::header, response::IntoResponse, }; +use domain::{models::ReviewSortBy, ports::FeedChannel}; use uuid::Uuid; -use application::{diary::get_diary, diary::get_user_feed, diary::queries::GetDiaryQuery}; -use domain::{models::ReviewSortBy, ports::FeedChannel}; - -use crate::{errors::ApiError, state::AppState}; +const RSS_FEED_LIMIT: u32 = 50; pub async fn get_feed(State(state): State) -> Result { let query = GetDiaryQuery { - limit: Some(super::RSS_FEED_LIMIT), + limit: Some(RSS_FEED_LIMIT), offset: Some(0), sort_by: Some(ReviewSortBy::Descending), movie_id: None, @@ -43,7 +43,7 @@ pub async fn get_user_feed( let feed = get_user_feed::execute( &state.app_ctx.deps.diary.get_user_feed, user_id, - super::RSS_FEED_LIMIT, + RSS_FEED_LIMIT, ) .await?; diff --git a/crates/adapters/http-axum/src/html/social.rs b/crates/adapters/http-axum/src/html/social.rs new file mode 100644 index 00000000..0dece217 --- /dev/null +++ b/crates/adapters/http-axum/src/html/social.rs @@ -0,0 +1,319 @@ +use crate::html::helpers::{build_page_context, encode_domain_error}; +use crate::html::templates::{FollowersTemplate, FollowingTemplate, RemoteActorData}; +use crate::{ + extractors::RequiredCookieUser, + html::csrf::CsrfToken, + html::forms::{FollowForm, FollowerActionForm, UnfollowForm}, + html::render::render_page, + state::AppState, +}; +use axum::{ + Form, + extract::{Extension, Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, +}; +use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor}; +use uuid::Uuid; + +fn social_actor_to_template(actor: SocialActor, instance: &InstanceIdentity) -> RemoteActorData { + RemoteActorData { + url: instance.actor_url_of(&actor.identity), + handle: actor.handle, + display_name: actor.display_name, + avatar_url: actor.avatar_url, + } +} + +pub async fn follow_user( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let redirect_base = form + .redirect_after + .as_deref() + .filter(|u| u.starts_with('/') && !u.starts_with("//")) + .unwrap_or(&format!("/users/{}", profile_user_uuid)) + .to_string(); + + match application::social::execute::execute_command( + &state.app_ctx.deps.social.command, + application::social::commands::SocialCmd::Follow { + follower_id: user_id.value(), + target: FollowTarget::Handle(form.handle), + }, + ) + .await + { + Ok(()) => Redirect::to(&redirect_base).into_response(), + Err(e) => { + tracing::error!("follow error: {:?}", e); + let msg = encode_domain_error(&e); + let sep = if redirect_base.contains('?') { + '&' + } else { + '?' + }; + Redirect::to(&format!("{}{}error={}", redirect_base, sep, msg)).into_response() + } + } +} + +pub async fn unfollow_user( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let instance = state.app_ctx.instance.clone(); + match application::social::execute::execute_command( + &state.app_ctx.deps.social.command, + application::social::commands::SocialCmd::Unfollow { + follower_id: user_id.value(), + target: instance.identify(&form.actor_url), + }, + ) + .await + { + Ok(()) => { + Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)).into_response() + } + Err(e) => { + let msg = encode_domain_error(&e); + Redirect::to(&format!( + "/users/{}/following-list?error={}", + profile_user_uuid, msg + )) + .into_response() + } + } +} + +pub async fn accept_follower_html( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let instance = state.app_ctx.instance.clone(); + match application::social::execute::execute_command( + &state.app_ctx.deps.social.command, + application::social::commands::SocialCmd::AcceptFollow { + owner_id: user_id.value(), + requester: instance.identify(&form.actor_url), + }, + ) + .await + { + Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), + Err(e) => { + let msg = encode_domain_error(&e); + Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response() + } + } +} + +pub async fn reject_follower_html( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let instance = state.app_ctx.instance.clone(); + match application::social::execute::execute_command( + &state.app_ctx.deps.social.command, + application::social::commands::SocialCmd::RejectFollow { + owner_id: user_id.value(), + requester: instance.identify(&form.actor_url), + }, + ) + .await + { + Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), + Err(e) => { + let msg = encode_domain_error(&e); + Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response() + } + } +} + +pub async fn get_following_page( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; + ctx.page_title = "Following — Movies Diary".to_string(); + ctx.canonical_url = format!( + "{}/users/{}/following-list", + state.app_ctx.config.base_url, profile_user_uuid + ); + let instance = state.app_ctx.instance.clone(); + match application::social::get_following::execute( + &state.app_ctx.deps.social.query, + user_id.value(), + ) + .await + { + Ok(following) => { + let actors: Vec = following + .into_iter() + .map(|a| social_actor_to_template(a, &instance)) + .collect(); + let pending_actors: Vec = + match application::social::get_pending_following::execute( + &state.app_ctx.deps.social.query, + user_id.value(), + ) + .await + { + Ok(pending) => pending + .into_iter() + .map(|a| social_actor_to_template(a, &instance)) + .collect(), + Err(e) => { + tracing::error!("get_pending_following error: {:?}", e); + Vec::new() + } + }; + render_page(FollowingTemplate { + ctx, + user_id: profile_user_uuid, + actors, + pending_actors, + error: params.error, + }) + .into_response() + } + Err(e) => { + tracing::error!("get_following error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to load following list", + ) + .into_response() + } + } +} + +pub async fn get_followers_page( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; + ctx.page_title = "Followers — Movies Diary".to_string(); + ctx.canonical_url = format!( + "{}/users/{}/followers-list", + state.app_ctx.config.base_url, profile_user_uuid + ); + let instance = state.app_ctx.instance.clone(); + match application::social::get_followers::execute( + &state.app_ctx.deps.social.query, + user_id.value(), + ) + .await + { + Ok(followers) => { + let actors: Vec = followers + .into_iter() + .map(|a| social_actor_to_template(a, &instance)) + .collect(); + render_page(FollowersTemplate { + ctx, + user_id: profile_user_uuid, + actors, + error: params.error, + }) + .into_response() + } + Err(e) => { + tracing::error!("get_followers error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to load followers list", + ) + .into_response() + } + } +} + +pub async fn remove_follower_html( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let instance = state.app_ctx.instance.clone(); + match application::social::execute::execute_command( + &state.app_ctx.deps.social.command, + application::social::commands::SocialCmd::RemoveFollower { + owner_id: user_id.value(), + follower: instance.identify(&form.actor_url), + }, + ) + .await + { + Ok(_) => { + Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)).into_response() + } + Err(e) => { + let msg = encode_domain_error(&e); + Redirect::to(&format!( + "/users/{}/followers-list?error={}", + profile_user_uuid, msg + )) + .into_response() + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/adapters/http-axum/src/html/social/tests.rs b/crates/adapters/http-axum/src/html/social/tests.rs new file mode 100644 index 00000000..9ef45c28 --- /dev/null +++ b/crates/adapters/http-axum/src/html/social/tests.rs @@ -0,0 +1,22 @@ +use super::*; +use domain::value_objects::{InstanceIdentity, SocialActor, SocialIdentity, UserId}; + +fn local_actor(uid: &UserId) -> SocialActor { + SocialActor { + identity: SocialIdentity::Local(uid.clone()), + handle: "@gabriel@md.example".into(), + display_name: None, + avatar_url: None, + } +} + +#[test] +fn local_template_url_round_trips_back_to_the_same_identity() { + let instance = InstanceIdentity::new("https://md.example"); + let uid = UserId::from_uuid(uuid::Uuid::new_v4()); + + let data = social_actor_to_template(local_actor(&uid), &instance); + + assert!(!data.url.starts_with("local:"), "url was {}", data.url); + assert_eq!(instance.identify(&data.url), SocialIdentity::Local(uid)); +} diff --git a/crates/adapters/http-axum/src/html/tags.rs b/crates/adapters/http-axum/src/html/tags.rs new file mode 100644 index 00000000..24dddec8 --- /dev/null +++ b/crates/adapters/http-axum/src/html/tags.rs @@ -0,0 +1,12 @@ +use axum::{ + extract::Path, + response::{IntoResponse, Redirect}, +}; + +pub async fn get_tag(Path(tag): Path) -> impl IntoResponse { + if tag.eq_ignore_ascii_case("moviesdiary") { + Redirect::temporary("/") + } else { + Redirect::temporary(&format!("/?search={}", tag)) + } +} diff --git a/crates/adapters/http-axum/src/html/templates.rs b/crates/adapters/http-axum/src/html/templates.rs deleted file mode 100644 index 77e140d6..00000000 --- a/crates/adapters/http-axum/src/html/templates.rs +++ /dev/null @@ -1,495 +0,0 @@ -pub use askama; -use askama::Template; - -use api_types::HtmlPageContext; -use chrono::Datelike; -use domain::models::{ - DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends, - collections::Paginated, -}; - -#[derive(Clone, Debug)] -pub struct WatchlistDisplayEntry { - pub poster_url: Option, - pub movie_title: String, - pub release_year: u16, - pub movie_url: Option, - pub added_at: String, - pub remove_url: Option, -} - -mod filters { - #[askama::filter_fn] - pub fn poster_src( - path: T, - _env: &dyn askama::Values, - ) -> askama::Result { - let p = path.to_string(); - if p.starts_with("http://") || p.starts_with("https://") { - Ok(p) - } else { - Ok(format!("/images/{}", p)) - } - } -} - -pub struct PageItem { - pub number: u32, - pub is_current: bool, - pub is_ellipsis: bool, -} - -pub fn build_page_items(total_pages: u32, current_page: u32) -> Vec { - if total_pages <= 1 { - return vec![]; - } - let mut set = std::collections::BTreeSet::new(); - set.insert(0u32); - set.insert(total_pages - 1); - let start = current_page.saturating_sub(2); - let end = (current_page + 2).min(total_pages - 1); - for p in start..=end { - set.insert(p); - } - let pages: Vec = set.into_iter().collect(); - let mut items = Vec::new(); - for (i, &p) in pages.iter().enumerate() { - if i > 0 && p > pages[i - 1] + 1 { - items.push(PageItem { - number: 0, - is_current: false, - is_ellipsis: true, - }); - } - items.push(PageItem { - number: p, - is_current: p == current_page, - is_ellipsis: false, - }); - } - items -} - -#[derive(Template)] -#[template(path = "diary.html")] -pub struct DiaryTemplate<'a> { - pub entries: &'a [DiaryEntry], - pub current_offset: u32, - pub limit: u32, - pub has_more: bool, - pub ctx: &'a HtmlPageContext, - pub page_items: Vec, -} - -#[derive(Template)] -#[template(path = "login.html")] -pub struct LoginTemplate<'a> { - pub error: Option<&'a str>, - pub ctx: &'a HtmlPageContext, -} - -#[derive(Template)] -#[template(path = "register.html")] -pub struct RegisterTemplate<'a> { - pub error: Option<&'a str>, - pub ctx: &'a HtmlPageContext, -} - -#[derive(Template)] -#[template(path = "new_review.html")] -pub struct NewReviewTemplate<'a> { - pub error: Option<&'a str>, - pub ctx: &'a HtmlPageContext, -} - -#[derive(Template)] -#[template(path = "activity_feed.html")] -pub struct ActivityFeedTemplate<'a> { - pub entries: &'a [FeedEntry], - pub current_offset: u32, - pub limit: u32, - pub has_more: bool, - pub ctx: &'a HtmlPageContext, - pub page_items: Vec, - pub filter: String, - pub sort_by: String, - pub search: String, -} - -#[derive(Template)] -#[template(path = "movie_detail.html")] -pub struct MovieDetailTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub movie: &'a domain::models::Movie, - pub stats: &'a domain::models::MovieStats, - pub profile: Option<&'a domain::models::MovieProfile>, - pub reviews: &'a [domain::models::FeedEntry], - pub on_watchlist: bool, - pub current_offset: u32, - pub has_more: bool, - pub limit: u32, - pub histogram_max: u64, -} - -#[derive(Template)] -#[template(path = "watchlist.html")] -pub struct WatchlistTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub owner_id: uuid::Uuid, - pub display_entries: &'a [WatchlistDisplayEntry], - pub current_offset: u32, - pub has_more: bool, - pub limit: u32, - pub is_owner: bool, - pub error: Option, -} - -impl<'a> ActivityFeedTemplate<'a> { - pub fn filter_qs(&self) -> String { - let mut parts = vec![ - format!("filter={}", self.filter), - format!("sort_by={}", self.sort_by), - ]; - if !self.search.is_empty() { - let encoded = self - .search - .replace(' ', "+") - .replace('#', "%23") - .replace('&', "%26") - .replace('=', "%3D"); - parts.push(format!("search={}", encoded)); - } - format!("&{}", parts.join("&")) - } -} - -pub struct RemoteActorDisplay { - pub handle: String, - pub display_name: String, - pub initial: char, - pub url: String, -} - -pub struct UserSummaryView { - pub user_id: uuid::Uuid, - pub display_name: String, - pub initial: char, - pub avg_rating_display: String, - pub total_movies: i64, - pub avatar_url: Option, -} - -#[derive(Template)] -#[template(path = "users.html")] -pub struct UsersTemplate<'a> { - pub users: Vec, - pub ctx: &'a HtmlPageContext, - pub remote_actors: Vec, -} - -pub struct MonthlyRatingRow<'a> { - pub rating: &'a MonthlyRating, - pub bar_height_px: i64, -} - -#[derive(Template)] -#[template(path = "profile.html")] -pub struct ProfileTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub profile_display_name: String, - pub profile_user_id: uuid::Uuid, - pub stats: &'a UserStats, - pub avg_rating_display: String, - pub favorite_director_display: String, - pub most_active_month_display: String, - pub view: &'a str, - pub entries: Option<&'a Paginated>, - pub current_offset: u32, - pub has_more: bool, - pub limit: u32, - pub history: Option<&'a Vec>, - pub trends: Option<&'a UserTrends>, - pub monthly_rating_rows: Vec>, - pub heatmap: Vec, - pub page_items: Vec, - pub is_own_profile: bool, - pub error: Option, - pub following_count: usize, - pub followers_count: usize, - pub pending_followers: Vec, - pub sort_by: String, - pub search: String, - pub goals: Vec, -} - -pub struct GoalViewData { - pub year: u16, - pub target_count: u32, - pub current_count: u32, - pub percentage: f64, - pub is_complete: bool, -} - -impl<'a> ProfileTemplate<'a> { - pub fn filter_qs(&self) -> String { - let mut parts = vec![ - format!("view={}", self.view), - format!("sort_by={}", self.sort_by), - ]; - if !self.search.is_empty() { - let encoded = self - .search - .replace(' ', "+") - .replace('#', "%23") - .replace('&', "%26") - .replace('=', "%3D"); - parts.push(format!("search={}", encoded)); - } - format!("&{}", parts.join("&")) - } -} - -#[derive(Template)] -#[template(path = "embed_profile.html")] -pub struct EmbedProfileTemplate<'a> { - pub profile_display_name: String, - pub profile_user_id: uuid::Uuid, - pub profile_url: String, - pub stats: &'a UserStats, - pub avg_rating_display: String, - pub favorite_director_display: String, - pub most_active_month_display: String, - pub view: &'a str, - pub entries: Option<&'a Paginated>, - pub current_offset: u32, - pub has_more: bool, - pub limit: u32, - pub history: Option<&'a Vec>, - pub trends: Option<&'a UserTrends>, - pub monthly_rating_rows: Vec>, - pub heatmap: Vec, - pub page_items: Vec, - pub sort_by: String, -} - -impl<'a> EmbedProfileTemplate<'a> { - pub fn filter_qs(&self) -> String { - let parts = [ - format!("view={}", self.view), - format!("sort_by={}", self.sort_by), - "embed=true".to_string(), - ]; - format!("&{}", parts.join("&")) - } -} - -pub struct RemoteActorData { - pub handle: String, - pub display_name: Option, - pub url: String, - pub avatar_url: Option, -} - -#[derive(Template)] -#[template(path = "following.html")] -pub struct FollowingTemplate { - pub ctx: HtmlPageContext, - pub user_id: uuid::Uuid, - pub actors: Vec, - pub pending_actors: Vec, - pub error: Option, -} - -#[derive(Template)] -#[template(path = "followers.html")] -pub struct FollowersTemplate { - pub ctx: HtmlPageContext, - pub user_id: uuid::Uuid, - pub actors: Vec, - pub error: Option, -} - -#[derive(Template)] -#[template(path = "blocked_domains.html")] -pub struct BlockedDomainsTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub domains: &'a [BlockedDomainEntry], -} - -#[derive(Template)] -#[template(path = "blocked_actors.html")] -pub struct BlockedActorsTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub actors: &'a [BlockedActorEntry], -} - -pub struct BlockedDomainEntry { - pub domain: String, - pub reason: Option, - pub blocked_at: String, -} - -pub struct BlockedActorEntry { - pub url: String, - pub handle: String, - pub display_name: Option, - pub avatar_url: Option, -} - -pub struct HeatmapCell { - pub month_label: String, - pub count: i64, - pub alpha: f64, -} - -pub fn build_heatmap(history: &[MonthActivity]) -> Vec { - let current_year = chrono::Utc::now().year(); - let count_for = |m: &str| -> i64 { - history - .iter() - .find(|a| a.year_month == format!("{}-{}", current_year, m)) - .map(|a| a.count) - .unwrap_or(0) - }; - let months = [ - ("01", "Jan"), - ("02", "Feb"), - ("03", "Mar"), - ("04", "Apr"), - ("05", "May"), - ("06", "Jun"), - ("07", "Jul"), - ("08", "Aug"), - ("09", "Sep"), - ("10", "Oct"), - ("11", "Nov"), - ("12", "Dec"), - ]; - let counts: Vec = months.iter().map(|(m, _)| count_for(m)).collect(); - let max = counts.iter().copied().max().unwrap_or(0).max(1); - months - .iter() - .zip(counts.iter()) - .map(|((_, label), &count)| { - let alpha = if count == 0 { - 0.05 - } else { - 0.15 + 0.75 * (count as f64 / max as f64) - }; - HeatmapCell { - month_label: label.to_string(), - count, - alpha, - } - }) - .collect() -} - -pub fn bar_height_px(avg_rating: f64) -> i64 { - (avg_rating / 5.0 * 60.0) as i64 -} - -#[derive(Template)] -#[template(path = "profile_settings.html")] -pub struct ProfileSettingsTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub bio: Option<&'a str>, - pub avatar_url: Option<&'a str>, - pub banner_url: Option<&'a str>, - pub also_known_as: Option<&'a str>, - pub profile_fields: &'a [(String, String)], - pub saved: bool, - pub embed_url: String, -} - -#[derive(Template)] -#[template(path = "integrations.html")] -pub struct IntegrationsTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub tokens: &'a [WebhookTokenView], - pub webhook_base_url: &'a str, - pub new_token: Option<&'a str>, -} - -pub struct WebhookTokenView { - pub id: String, - pub provider: String, - pub label: Option, - pub created_at: String, - pub last_used_at: Option, -} - -#[derive(Template)] -#[template(path = "watch_queue.html")] -pub struct WatchQueueTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub entries: &'a [WatchQueueDisplayEntry], - pub error: Option<&'a str>, -} - -pub struct WatchQueueDisplayEntry { - pub id: String, - pub title: String, - pub year: Option, - pub source: String, - pub watched_at: String, - pub movie_url: Option, -} - -#[derive(Template)] -#[template(path = "import_upload.html")] -pub struct ImportUploadTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub profiles: &'a [ImportProfileView], - pub error: Option<&'a str>, -} - -pub struct ImportProfileView { - pub id: String, - pub name: String, -} - -#[derive(Template)] -#[template(path = "import_mapping.html")] -pub struct ImportMappingTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub session_id: &'a str, - pub columns: &'a [String], - pub sample_rows: &'a [Vec], - pub domain_fields: &'a [(&'static str, &'static str)], - pub error: Option<&'a str>, -} - -#[derive(Template)] -#[template(path = "import_preview.html")] -pub struct ImportPreviewTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub session_id: &'a str, - pub columns: &'a [String], - pub rows: &'a [ImportPreviewRow], -} - -pub struct ImportPreviewRow { - pub index: usize, - pub status: ImportRowStatus, - pub cells: Vec, -} - -pub enum ImportRowStatus { - Valid, - Duplicate, - Invalid(String), -} - -#[derive(Template)] -#[template(path = "wrapup.html")] -pub struct WrapUpPageTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub report: &'a domain::models::wrapup::WrapUpReport, - pub year_label: String, - pub watch_time_display: String, - pub rating_max: u32, - pub genre_max: u32, - pub rating_pcts: [f64; 5], - pub genre_pcts: Vec, -} diff --git a/crates/adapters/http-axum/src/html/templates/auth.rs b/crates/adapters/http-axum/src/html/templates/auth.rs new file mode 100644 index 00000000..29d9be2c --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/auth.rs @@ -0,0 +1,16 @@ +use api_types::HtmlPageContext; +use askama::Template; + +#[derive(Template)] +#[template(path = "login.html")] +pub struct LoginTemplate<'a> { + pub error: Option<&'a str>, + pub ctx: &'a HtmlPageContext, +} + +#[derive(Template)] +#[template(path = "register.html")] +pub struct RegisterTemplate<'a> { + pub error: Option<&'a str>, + pub ctx: &'a HtmlPageContext, +} diff --git a/crates/adapters/http-axum/src/html/templates/common.rs b/crates/adapters/http-axum/src/html/templates/common.rs new file mode 100644 index 00000000..c6aea406 --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/common.rs @@ -0,0 +1,36 @@ +pub struct PageItem { + pub number: u32, + pub is_current: bool, + pub is_ellipsis: bool, +} + +pub fn build_page_items(total_pages: u32, current_page: u32) -> Vec { + if total_pages <= 1 { + return vec![]; + } + let mut set = std::collections::BTreeSet::new(); + set.insert(0u32); + set.insert(total_pages - 1); + let start = current_page.saturating_sub(2); + let end = (current_page + 2).min(total_pages - 1); + for p in start..=end { + set.insert(p); + } + let pages: Vec = set.into_iter().collect(); + let mut items = Vec::new(); + for (i, &p) in pages.iter().enumerate() { + if i > 0 && p > pages[i - 1] + 1 { + items.push(PageItem { + number: 0, + is_current: false, + is_ellipsis: true, + }); + } + items.push(PageItem { + number: p, + is_current: p == current_page, + is_ellipsis: false, + }); + } + items +} diff --git a/crates/adapters/http-axum/src/html/templates/diary.rs b/crates/adapters/http-axum/src/html/templates/diary.rs new file mode 100644 index 00000000..2a7ef24c --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/diary.rs @@ -0,0 +1,55 @@ +use super::{PageItem, filters}; +use api_types::HtmlPageContext; +use askama::Template; +use domain::models::{DiaryEntry, FeedEntry, ReviewSource}; + +#[derive(Template)] +#[template(path = "diary.html")] +pub struct DiaryTemplate<'a> { + pub entries: &'a [DiaryEntry], + pub current_offset: u32, + pub limit: u32, + pub has_more: bool, + pub ctx: &'a HtmlPageContext, + pub page_items: Vec, +} + +#[derive(Template)] +#[template(path = "new_review.html")] +pub struct NewReviewTemplate<'a> { + pub error: Option<&'a str>, + pub ctx: &'a HtmlPageContext, +} + +#[derive(Template)] +#[template(path = "activity_feed.html")] +pub struct ActivityFeedTemplate<'a> { + pub entries: &'a [FeedEntry], + pub current_offset: u32, + pub limit: u32, + pub has_more: bool, + pub ctx: &'a HtmlPageContext, + pub page_items: Vec, + pub filter: String, + pub sort_by: String, + pub search: String, +} + +impl<'a> ActivityFeedTemplate<'a> { + pub fn filter_qs(&self) -> String { + let mut parts = vec![ + format!("filter={}", self.filter), + format!("sort_by={}", self.sort_by), + ]; + if !self.search.is_empty() { + let encoded = self + .search + .replace(' ', "+") + .replace('#', "%23") + .replace('&', "%26") + .replace('=', "%3D"); + parts.push(format!("search={}", encoded)); + } + format!("&{}", parts.join("&")) + } +} diff --git a/crates/adapters/http-axum/src/html/templates/import.rs b/crates/adapters/http-axum/src/html/templates/import.rs new file mode 100644 index 00000000..f91e5976 --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/import.rs @@ -0,0 +1,47 @@ +use api_types::HtmlPageContext; +use askama::Template; + +#[derive(Template)] +#[template(path = "import_upload.html")] +pub struct ImportUploadTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub profiles: &'a [ImportProfileView], + pub error: Option<&'a str>, +} + +pub struct ImportProfileView { + pub id: String, + pub name: String, +} + +#[derive(Template)] +#[template(path = "import_mapping.html")] +pub struct ImportMappingTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub session_id: &'a str, + pub columns: &'a [String], + pub sample_rows: &'a [Vec], + pub domain_fields: &'a [(&'static str, &'static str)], + pub error: Option<&'a str>, +} + +#[derive(Template)] +#[template(path = "import_preview.html")] +pub struct ImportPreviewTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub session_id: &'a str, + pub columns: &'a [String], + pub rows: &'a [ImportPreviewRow], +} + +pub struct ImportPreviewRow { + pub index: usize, + pub status: ImportRowStatus, + pub cells: Vec, +} + +pub enum ImportRowStatus { + Valid, + Duplicate, + Invalid(String), +} diff --git a/crates/adapters/http-axum/src/html/templates/integrations.rs b/crates/adapters/http-axum/src/html/templates/integrations.rs new file mode 100644 index 00000000..bd79128d --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/integrations.rs @@ -0,0 +1,36 @@ +use api_types::HtmlPageContext; +use askama::Template; + +#[derive(Template)] +#[template(path = "integrations.html")] +pub struct IntegrationsTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub tokens: &'a [WebhookTokenView], + pub webhook_base_url: &'a str, + pub new_token: Option<&'a str>, +} + +pub struct WebhookTokenView { + pub id: String, + pub provider: String, + pub label: Option, + pub created_at: String, + pub last_used_at: Option, +} + +#[derive(Template)] +#[template(path = "watch_queue.html")] +pub struct WatchQueueTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub entries: &'a [WatchQueueDisplayEntry], + pub error: Option<&'a str>, +} + +pub struct WatchQueueDisplayEntry { + pub id: String, + pub title: String, + pub year: Option, + pub source: String, + pub watched_at: String, + pub movie_url: Option, +} diff --git a/crates/adapters/http-axum/src/html/templates/mod.rs b/crates/adapters/http-axum/src/html/templates/mod.rs new file mode 100644 index 00000000..1d662904 --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/mod.rs @@ -0,0 +1,36 @@ +mod auth; +mod common; +mod diary; +mod import; +mod integrations; +mod movies; +mod social; +mod users; +mod watchlist; +mod wrapup; + +pub use auth::*; +pub use common::*; +pub use diary::*; +pub use import::*; +pub use integrations::*; +pub use movies::*; +pub use social::*; +pub use users::*; +pub use watchlist::*; +pub use wrapup::*; + +pub(super) mod filters { + #[askama::filter_fn] + pub fn poster_src( + path: T, + _env: &dyn askama::Values, + ) -> askama::Result { + let p = path.to_string(); + if p.starts_with("http://") || p.starts_with("https://") { + Ok(p) + } else { + Ok(format!("/images/{}", p)) + } + } +} diff --git a/crates/adapters/http-axum/src/html/templates/movies.rs b/crates/adapters/http-axum/src/html/templates/movies.rs new file mode 100644 index 00000000..0a7f4840 --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/movies.rs @@ -0,0 +1,19 @@ +use super::filters; +use api_types::HtmlPageContext; +use askama::Template; +use domain::models::ReviewSource; + +#[derive(Template)] +#[template(path = "movie_detail.html")] +pub struct MovieDetailTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub movie: &'a domain::models::Movie, + pub stats: &'a domain::models::MovieStats, + pub profile: Option<&'a domain::models::MovieProfile>, + pub reviews: &'a [domain::models::FeedEntry], + pub on_watchlist: bool, + pub current_offset: u32, + pub has_more: bool, + pub limit: u32, + pub histogram_max: u64, +} diff --git a/crates/adapters/http-axum/src/html/templates/social.rs b/crates/adapters/http-axum/src/html/templates/social.rs new file mode 100644 index 00000000..5123baed --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/social.rs @@ -0,0 +1,55 @@ +use api_types::HtmlPageContext; +use askama::Template; + +pub struct RemoteActorData { + pub handle: String, + pub display_name: Option, + pub url: String, + pub avatar_url: Option, +} + +#[derive(Template)] +#[template(path = "following.html")] +pub struct FollowingTemplate { + pub ctx: HtmlPageContext, + pub user_id: uuid::Uuid, + pub actors: Vec, + pub pending_actors: Vec, + pub error: Option, +} + +#[derive(Template)] +#[template(path = "followers.html")] +pub struct FollowersTemplate { + pub ctx: HtmlPageContext, + pub user_id: uuid::Uuid, + pub actors: Vec, + pub error: Option, +} + +#[derive(Template)] +#[template(path = "blocked_domains.html")] +pub struct BlockedDomainsTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub domains: &'a [BlockedDomainEntry], +} + +#[derive(Template)] +#[template(path = "blocked_actors.html")] +pub struct BlockedActorsTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub actors: &'a [BlockedActorEntry], +} + +pub struct BlockedDomainEntry { + pub domain: String, + pub reason: Option, + pub blocked_at: String, +} + +pub struct BlockedActorEntry { + pub url: String, + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, +} diff --git a/crates/adapters/http-axum/src/html/templates/users.rs b/crates/adapters/http-axum/src/html/templates/users.rs new file mode 100644 index 00000000..0e514ea9 --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/users.rs @@ -0,0 +1,193 @@ +use super::{PageItem, RemoteActorData, filters}; +use api_types::HtmlPageContext; +use askama::Template; +use chrono::Datelike; +use domain::models::{ + DiaryEntry, MonthActivity, MonthlyRating, UserStats, UserTrends, collections::Paginated, +}; + +pub struct RemoteActorDisplay { + pub handle: String, + pub display_name: String, + pub initial: char, + pub url: String, +} + +pub struct UserSummaryView { + pub user_id: uuid::Uuid, + pub display_name: String, + pub initial: char, + pub avg_rating_display: String, + pub total_movies: i64, + pub avatar_url: Option, +} + +#[derive(Template)] +#[template(path = "users.html")] +pub struct UsersTemplate<'a> { + pub users: Vec, + pub ctx: &'a HtmlPageContext, + pub remote_actors: Vec, +} + +pub struct MonthlyRatingRow<'a> { + pub rating: &'a MonthlyRating, + pub bar_height_px: i64, +} + +#[derive(Template)] +#[template(path = "profile.html")] +pub struct ProfileTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub profile_display_name: String, + pub profile_user_id: uuid::Uuid, + pub stats: &'a UserStats, + pub avg_rating_display: String, + pub favorite_director_display: String, + pub most_active_month_display: String, + pub view: &'a str, + pub entries: Option<&'a Paginated>, + pub current_offset: u32, + pub has_more: bool, + pub limit: u32, + pub history: Option<&'a Vec>, + pub trends: Option<&'a UserTrends>, + pub monthly_rating_rows: Vec>, + pub heatmap: Vec, + pub page_items: Vec, + pub is_own_profile: bool, + pub error: Option, + pub following_count: usize, + pub followers_count: usize, + pub pending_followers: Vec, + pub sort_by: String, + pub search: String, + pub goals: Vec, +} + +pub struct GoalViewData { + pub year: u16, + pub target_count: u32, + pub current_count: u32, + pub percentage: f64, + pub is_complete: bool, +} + +impl<'a> ProfileTemplate<'a> { + pub fn filter_qs(&self) -> String { + let mut parts = vec![ + format!("view={}", self.view), + format!("sort_by={}", self.sort_by), + ]; + if !self.search.is_empty() { + let encoded = self + .search + .replace(' ', "+") + .replace('#', "%23") + .replace('&', "%26") + .replace('=', "%3D"); + parts.push(format!("search={}", encoded)); + } + format!("&{}", parts.join("&")) + } +} + +#[derive(Template)] +#[template(path = "embed_profile.html")] +pub struct EmbedProfileTemplate<'a> { + pub profile_display_name: String, + pub profile_user_id: uuid::Uuid, + pub profile_url: String, + pub stats: &'a UserStats, + pub avg_rating_display: String, + pub favorite_director_display: String, + pub most_active_month_display: String, + pub view: &'a str, + pub entries: Option<&'a Paginated>, + pub current_offset: u32, + pub has_more: bool, + pub limit: u32, + pub history: Option<&'a Vec>, + pub trends: Option<&'a UserTrends>, + pub monthly_rating_rows: Vec>, + pub heatmap: Vec, + pub page_items: Vec, + pub sort_by: String, +} + +impl<'a> EmbedProfileTemplate<'a> { + pub fn filter_qs(&self) -> String { + let parts = [ + format!("view={}", self.view), + format!("sort_by={}", self.sort_by), + "embed=true".to_string(), + ]; + format!("&{}", parts.join("&")) + } +} + +pub struct HeatmapCell { + pub month_label: String, + pub count: i64, + pub alpha: f64, +} + +pub fn build_heatmap(history: &[MonthActivity]) -> Vec { + let current_year = chrono::Utc::now().year(); + let count_for = |m: &str| -> i64 { + history + .iter() + .find(|a| a.year_month == format!("{}-{}", current_year, m)) + .map(|a| a.count) + .unwrap_or(0) + }; + let months = [ + ("01", "Jan"), + ("02", "Feb"), + ("03", "Mar"), + ("04", "Apr"), + ("05", "May"), + ("06", "Jun"), + ("07", "Jul"), + ("08", "Aug"), + ("09", "Sep"), + ("10", "Oct"), + ("11", "Nov"), + ("12", "Dec"), + ]; + let counts: Vec = months.iter().map(|(m, _)| count_for(m)).collect(); + let max = counts.iter().copied().max().unwrap_or(0).max(1); + months + .iter() + .zip(counts.iter()) + .map(|((_, label), &count)| { + let alpha = if count == 0 { + 0.05 + } else { + 0.15 + 0.75 * (count as f64 / max as f64) + }; + HeatmapCell { + month_label: label.to_string(), + count, + alpha, + } + }) + .collect() +} + +pub fn bar_height_px(avg_rating: f64) -> i64 { + (avg_rating / 5.0 * 60.0) as i64 +} + +#[derive(Template)] +#[template(path = "profile_settings.html")] +pub struct ProfileSettingsTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub bio: Option<&'a str>, + pub avatar_url: Option<&'a str>, + pub banner_url: Option<&'a str>, + pub also_known_as: Option<&'a str>, + pub profile_fields: &'a [(String, String)], + pub saved: bool, + pub embed_url: String, +} diff --git a/crates/adapters/http-axum/src/html/templates/watchlist.rs b/crates/adapters/http-axum/src/html/templates/watchlist.rs new file mode 100644 index 00000000..6de0f23f --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/watchlist.rs @@ -0,0 +1,25 @@ +use api_types::HtmlPageContext; +use askama::Template; + +#[derive(Clone, Debug)] +pub struct WatchlistDisplayEntry { + pub poster_url: Option, + pub movie_title: String, + pub release_year: u16, + pub movie_url: Option, + pub added_at: String, + pub remove_url: Option, +} + +#[derive(Template)] +#[template(path = "watchlist.html")] +pub struct WatchlistTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub owner_id: uuid::Uuid, + pub display_entries: &'a [WatchlistDisplayEntry], + pub current_offset: u32, + pub has_more: bool, + pub limit: u32, + pub is_owner: bool, + pub error: Option, +} diff --git a/crates/adapters/http-axum/src/html/templates/wrapup.rs b/crates/adapters/http-axum/src/html/templates/wrapup.rs new file mode 100644 index 00000000..f26f91c7 --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/wrapup.rs @@ -0,0 +1,16 @@ +use super::filters; +use api_types::HtmlPageContext; +use askama::Template; + +#[derive(Template)] +#[template(path = "wrapup.html")] +pub struct WrapUpPageTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub report: &'a domain::models::wrapup::WrapUpReport, + pub year_label: String, + pub watch_time_display: String, + pub rating_max: u32, + pub genre_max: u32, + pub rating_pcts: [f64; 5], + pub genre_pcts: Vec, +} diff --git a/crates/adapters/http-axum/src/html/users.rs b/crates/adapters/http-axum/src/html/users.rs new file mode 100644 index 00000000..b6621b70 --- /dev/null +++ b/crates/adapters/http-axum/src/html/users.rs @@ -0,0 +1,179 @@ +use crate::html::helpers::build_page_context; +use crate::html::templates::{ + ProfileSettingsTemplate, RemoteActorDisplay, UserSummaryView, UsersTemplate, +}; +use crate::{ + extractors::{OptionalCookieUser, RequiredCookieUser}, + html::csrf::CsrfToken, + html::render::render_page, + state::AppState, +}; +use application::users::{ + get_profile_settings as get_profile_settings_uc, resolve_username_to_id, update_profile, + update_profile_fields, +}; +use axum::{ + extract::{Extension, Multipart, Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use domain::{errors::DomainError, value_objects::Username}; + +pub async fn get_users_list( + OptionalCookieUser(user_id): OptionalCookieUser, + State(state): State, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, user_id, csrf.0).await; + ctx.page_title = "Members — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url); + + match application::users::get_users::execute( + &state.app_ctx.deps.users.get_users_list, + application::users::queries::GetUsersQuery, + ) + .await + { + Ok(result) => { + let users: Vec = result + .users + .iter() + .map(crate::html::mappers::users::user_summary_view) + .collect(); + let remote_actors: Vec = result + .remote_actors + .iter() + .map(crate::html::mappers::users::remote_actor_display) + .collect(); + render_page(UsersTemplate { + users, + ctx: &ctx, + remote_actors, + }) + .into_response() + } + Err(e) => crate::errors::domain_error_response(e), + } +} + +pub async fn get_user_by_username( + State(state): State, + Path(username): Path, +) -> impl IntoResponse { + let uname = match Username::new(username) { + Ok(u) => u, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + match resolve_username_to_id::execute(&state.app_ctx.deps.users.resolve_username, &uname).await + { + Ok(Some(uid)) => { + axum::response::Redirect::permanent(&format!("/users/{}", uid.value())).into_response() + } + _ => StatusCode::NOT_FOUND.into_response(), + } +} + +#[derive(serde::Deserialize, Default)] +pub struct SavedQuery { + pub saved: Option, +} + +pub async fn get_profile_settings( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; + ctx.page_title = "Profile Settings — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url); + + let settings = match get_profile_settings_uc::execute( + &state.app_ctx.deps.users.get_profile_settings, + user_id.value(), + ) + .await + { + Ok(s) => s, + Err(DomainError::NotFound(_)) => return StatusCode::NOT_FOUND.into_response(), + Err(e) => return crate::errors::domain_error_response(e), + }; + + let saved = params.saved.as_deref() == Some("1"); + + render_page(ProfileSettingsTemplate { + ctx: &ctx, + bio: settings.bio.as_deref(), + avatar_url: settings.avatar_url.as_deref(), + banner_url: settings.banner_url.as_deref(), + also_known_as: settings.also_known_as.as_deref(), + profile_fields: &settings.fields, + saved, + embed_url: format!( + "{}/users/{}?embed=true", + state.app_ctx.config.base_url, + user_id.value() + ), + }) + .into_response() +} + +pub async fn post_profile_settings( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + multipart: Multipart, +) -> impl IntoResponse { + let data = crate::support::parse_profile_multipart(multipart).await; + + let cmd = application::users::commands::UpdateProfileCommand { + user_id: user_id.value(), + display_name: data.display_name, + bio: data.bio, + avatar_bytes: data.avatar_bytes, + avatar_content_type: data.avatar_content_type, + banner_bytes: data.banner_bytes, + banner_content_type: data.banner_content_type, + also_known_as: data.also_known_as, + }; + if let Err(e) = update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await { + tracing::error!("update_profile error: {:?}", e); + return axum::response::Redirect::to(&format!( + "/settings/profile?error={}", + crate::html::helpers::encode_domain_error(&e) + )) + .into_response(); + } + + let fields: Vec = (0..4) + .filter_map(|i| { + data.profile_field_names + .get(&i) + .map(|name| domain::models::ProfileField { + name: name.clone(), + value: data + .profile_field_values + .get(&i) + .cloned() + .unwrap_or_default(), + }) + }) + .collect(); + + let fields_cmd = application::users::commands::UpdateProfileFieldsCommand { + user_id: user_id.value(), + fields, + }; + if let Err(e) = + update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, fields_cmd) + .await + { + tracing::error!("update_profile_fields error: {:?}", e); + return axum::response::Redirect::to(&format!( + "/settings/profile?error={}", + crate::html::helpers::encode_domain_error(&e) + )) + .into_response(); + } + + axum::response::Redirect::to("/settings/profile?saved=1").into_response() +} diff --git a/crates/adapters/http-axum/src/html/watchlist.rs b/crates/adapters/http-axum/src/html/watchlist.rs new file mode 100644 index 00000000..1cd9eab3 --- /dev/null +++ b/crates/adapters/http-axum/src/html/watchlist.rs @@ -0,0 +1,171 @@ +use crate::html::helpers::{build_page_context, encode_error}; +use crate::html::templates::WatchlistTemplate; +use crate::{ + extractors::{OptionalCookieUser, RequiredCookieUser}, + html::csrf::CsrfToken, + html::render::render_page, + state::AppState, +}; +use application::{ + diary::commands::MovieInput, + watchlist::{ + add as add_to_watchlist, + commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand}, + get_watchlist_for_owner, remove as remove_from_watchlist, + }, +}; +use axum::{ + Form, + extract::{Extension, Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, +}; +use domain::errors::DomainError; + +pub async fn get_watchlist_page( + OptionalCookieUser(viewer_id): OptionalCookieUser, + State(state): State, + Path(owner_id): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await; + let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false); + + let view = match get_watchlist_for_owner::execute( + &state.app_ctx.deps.watchlist.get_watchlist_for_owner, + owner_id, + params.limit.or(Some(20)), + params.offset.or(Some(0)), + ) + .await + { + Ok(view) => view, + Err(e) => return crate::errors::domain_error_response(e), + }; + + let result = match view { + get_watchlist_for_owner::WatchlistView::Local(page) => { + crate::html::mappers::watchlist::build_watchlist_page(page, is_owner) + } + get_watchlist_for_owner::WatchlistView::Remote(entries) => { + crate::html::mappers::watchlist::build_remote_watchlist_page(entries) + } + }; + + render_page(WatchlistTemplate { + ctx: &ctx, + owner_id, + display_entries: &result.display_entries, + current_offset: result.current_offset, + has_more: result.has_more, + limit: result.limit, + is_owner, + error: params.error, + }) + .into_response() +} + +pub async fn post_watchlist_add_html( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + + let redirect_base = form + .redirect_after + .as_deref() + .filter(|u| u.starts_with('/') && !u.starts_with("//")) + .unwrap_or("/") + .to_string(); + + let input = if let Some(id) = form.movie_id { + MovieInput { + movie_id: Some(id), + external_metadata_id: None, + manual_title: None, + manual_release_year: None, + manual_director: None, + } + } else { + let query = form.query.as_deref().unwrap_or("").trim().to_string(); + let is_external_id = query.starts_with("tmdb:") + || (query.starts_with("tt") + && query.len() > 2 + && query[2..].chars().all(|c| c.is_ascii_digit())); + if is_external_id { + MovieInput { + movie_id: None, + external_metadata_id: Some(query), + manual_title: None, + manual_release_year: None, + manual_director: None, + } + } else { + MovieInput { + movie_id: None, + external_metadata_id: None, + manual_title: if query.is_empty() { None } else { Some(query) }, + manual_release_year: form.year, + manual_director: None, + } + } + }; + + match add_to_watchlist::execute( + &state.app_ctx.deps.watchlist.add, + AddToWatchlistCommand { + user_id: user_id.value(), + input, + }, + ) + .await + { + Ok(()) => Redirect::to(&redirect_base).into_response(), + Err(DomainError::NotFound(_)) => Redirect::to(&redirect_base).into_response(), + Err(DomainError::ValidationError(msg)) => { + let sep = if redirect_base.contains('?') { + '&' + } else { + '?' + }; + let url = format!("{}{}error={}", redirect_base, sep, encode_error(&msg)); + Redirect::to(&url).into_response() + } + Err(e) => crate::errors::domain_error_response(e), + } +} + +pub async fn post_watchlist_remove_html( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Path(movie_id): Path, + Form(form): Form, +) -> impl IntoResponse { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match remove_from_watchlist::execute( + &state.app_ctx.deps.watchlist.remove_from_watchlist, + RemoveFromWatchlistCommand { + user_id: user_id.value(), + movie_id, + }, + ) + .await + { + Ok(()) | Err(DomainError::NotFound(_)) => { + let redirect_url = form + .redirect_after + .filter(|u| u.starts_with('/') && !u.starts_with("//")) + .unwrap_or_else(|| "/".to_string()); + Redirect::to(&redirect_url).into_response() + } + Err(e) => crate::errors::domain_error_response(e), + } +} diff --git a/crates/adapters/http-axum/src/html/wrapup.rs b/crates/adapters/http-axum/src/html/wrapup.rs new file mode 100644 index 00000000..edb7e042 --- /dev/null +++ b/crates/adapters/http-axum/src/html/wrapup.rs @@ -0,0 +1,106 @@ +use crate::{ + extractors::OptionalCookieUser, html::csrf::CsrfToken, html::render::render_page, + state::AppState, +}; +use application::wrapup::get_ready_report; +use axum::{ + extract::{Extension, Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use domain::errors::DomainError; +use domain::models::wrapup::{WrapUpReport, WrapUpScope}; +use uuid::Uuid; + +fn format_watch_time(minutes: u32) -> String { + let h = minutes / 60; + let m = minutes % 60; + if h > 0 && m > 0 { + format!("{}h {}m", h, m) + } else if h > 0 { + format!("{}h", h) + } else { + format!("{}m", m) + } +} + +fn render_wrapup( + report: &WrapUpReport, + year: i32, + ctx: &api_types::HtmlPageContext, +) -> axum::response::Response { + let rating_max = report + .rating_distribution + .iter() + .copied() + .max() + .unwrap_or(1) + .max(1); + let rating_pcts: [f64; 5] = + std::array::from_fn(|i| report.rating_distribution[i] as f64 / rating_max as f64 * 100.0); + let genre_max = report + .top_genres + .first() + .map(|g| g.count) + .unwrap_or(1) + .max(1); + let genre_pcts: Vec = report + .top_genres + .iter() + .take(8) + .map(|g| g.count as f64 / genre_max as f64 * 100.0) + .collect(); + let tmpl = crate::html::templates::WrapUpPageTemplate { + ctx, + report, + year_label: year.to_string(), + watch_time_display: format_watch_time(report.total_watch_time_minutes), + rating_max, + genre_max, + rating_pcts, + genre_pcts, + }; + render_page(tmpl) +} + +pub async fn get_user_wrapup_html( + OptionalCookieUser(viewer): OptionalCookieUser, + State(state): State, + Path((user_id, year)): Path<(Uuid, i32)>, + Extension(csrf): Extension, +) -> impl IntoResponse { + let scope = WrapUpScope::User(user_id); + let report = + match get_ready_report::execute(&state.app_ctx.deps.wrapup.get_ready_report, scope, year) + .await + { + Ok(report) => report, + Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(), + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + + let ctx = crate::html::helpers::build_page_context(&state, viewer, csrf.0).await; + render_wrapup(&report, year, &ctx) +} + +pub async fn get_global_wrapup_html( + OptionalCookieUser(viewer): OptionalCookieUser, + State(state): State, + Path(year): Path, + Extension(csrf): Extension, +) -> impl IntoResponse { + let report = match get_ready_report::execute( + &state.app_ctx.deps.wrapup.get_ready_report, + WrapUpScope::Global, + year, + ) + .await + { + Ok(report) => report, + Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(), + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + + let ctx = crate::html::helpers::build_page_context(&state, viewer, csrf.0).await; + render_wrapup(&report, year, &ctx) +} diff --git a/crates/adapters/http-axum/src/lib.rs b/crates/adapters/http-axum/src/lib.rs index f7858366..76d8477f 100644 --- a/crates/adapters/http-axum/src/lib.rs +++ b/crates/adapters/http-axum/src/lib.rs @@ -1,15 +1,13 @@ +pub mod api; pub mod context; -pub mod csrf; pub mod errors; pub mod extractors; -pub mod forms; -pub mod handlers; +pub mod federation; pub mod html; -pub mod mappers; pub mod openapi; -pub mod render; -pub mod routes; +pub mod router; pub mod state; +mod support; #[cfg(test)] mod tests; diff --git a/crates/adapters/http-axum/src/openapi/auth.rs b/crates/adapters/http-axum/src/openapi/auth.rs index 8e9ff839..f727a70a 100644 --- a/crates/adapters/http-axum/src/openapi/auth.rs +++ b/crates/adapters/http-axum/src/openapi/auth.rs @@ -3,7 +3,7 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( - paths(crate::handlers::auth::login, crate::handlers::auth::register,), + paths(crate::api::auth::login, crate::api::auth::register,), components(schemas(LoginRequest, LoginResponse, RegisterRequest)) )] pub struct AuthDoc; diff --git a/crates/adapters/http-axum/src/openapi/diary.rs b/crates/adapters/http-axum/src/openapi/diary.rs index 971cf3d7..a91c5ec5 100644 --- a/crates/adapters/http-axum/src/openapi/diary.rs +++ b/crates/adapters/http-axum/src/openapi/diary.rs @@ -6,11 +6,11 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::diary::get_diary, - crate::handlers::diary::post_review, - crate::handlers::diary::delete_review, - crate::handlers::diary::export_diary, - crate::handlers::diary::get_activity_feed, + crate::api::diary::get_diary, + crate::api::diary::post_review, + crate::api::diary::delete_review, + crate::api::diary::export_diary, + crate::api::diary::get_activity_feed, ), components(schemas( DiaryResponse, diff --git a/crates/adapters/http-axum/src/openapi/goals.rs b/crates/adapters/http-axum/src/openapi/goals.rs index 889e08a3..e55456d5 100644 --- a/crates/adapters/http-axum/src/openapi/goals.rs +++ b/crates/adapters/http-axum/src/openapi/goals.rs @@ -7,13 +7,13 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::goals::list_goals, - crate::handlers::goals::create_goal, - crate::handlers::goals::update_goal, - crate::handlers::goals::delete_goal, - crate::handlers::goals::get_user_goals, - crate::handlers::goals::get_settings, - crate::handlers::goals::update_settings, + crate::api::goals::list_goals, + crate::api::goals::create_goal, + crate::api::goals::update_goal, + crate::api::goals::delete_goal, + crate::api::goals::get_user_goals, + crate::api::goals::get_settings, + crate::api::goals::update_settings, ), components(schemas( GoalDto, diff --git a/crates/adapters/http-axum/src/openapi/import.rs b/crates/adapters/http-axum/src/openapi/import.rs index c14ffc37..5bb1ccba 100644 --- a/crates/adapters/http-axum/src/openapi/import.rs +++ b/crates/adapters/http-axum/src/openapi/import.rs @@ -7,13 +7,13 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::import::api_post_session, - crate::handlers::import::api_get_session, - crate::handlers::import::api_put_mapping, - crate::handlers::import::api_post_confirm, - crate::handlers::import::api_get_profiles, - crate::handlers::import::api_post_profile, - crate::handlers::import::api_delete_profile, + crate::api::import::api_post_session, + crate::api::import::api_get_session, + crate::api::import::api_put_mapping, + crate::api::import::api_post_confirm, + crate::api::import::api_get_profiles, + crate::api::import::api_post_profile, + crate::api::import::api_delete_profile, ), components(schemas( SessionCreatedResponse, diff --git a/crates/adapters/http-axum/src/openapi/insights.rs b/crates/adapters/http-axum/src/openapi/insights.rs index b84e841f..19680b2a 100644 --- a/crates/adapters/http-axum/src/openapi/insights.rs +++ b/crates/adapters/http-axum/src/openapi/insights.rs @@ -2,7 +2,7 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( - paths(crate::handlers::insights::get_insights,), + paths(crate::api::insights::get_insights,), components(schemas(api_types::insights::InsightsResponse, api_types::insights::InsightDto,)) )] pub struct InsightsDoc; diff --git a/crates/adapters/http-axum/src/openapi/movies.rs b/crates/adapters/http-axum/src/openapi/movies.rs index edcecb1c..b76529f1 100644 --- a/crates/adapters/http-axum/src/openapi/movies.rs +++ b/crates/adapters/http-axum/src/openapi/movies.rs @@ -8,11 +8,11 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::movies::list_movies, - crate::handlers::movies::get_movie_detail, - crate::handlers::movies::get_review_history, - crate::handlers::movies::get_movie_profile, - crate::handlers::movies::sync_poster, + crate::api::movies::list_movies, + crate::api::movies::get_movie_detail, + crate::api::movies::get_review_history, + crate::api::movies::get_movie_profile, + crate::api::movies::sync_poster, ), components(schemas( MoviesResponse, diff --git a/crates/adapters/http-axum/src/openapi/search.rs b/crates/adapters/http-axum/src/openapi/search.rs index 8c9fa017..a3d6c794 100644 --- a/crates/adapters/http-axum/src/openapi/search.rs +++ b/crates/adapters/http-axum/src/openapi/search.rs @@ -7,9 +7,9 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::search::get_search, - crate::handlers::search::get_person_handler, - crate::handlers::search::get_person_credits_handler, + crate::api::search::get_search, + crate::api::search::get_person_handler, + crate::api::search::get_person_credits_handler, ), components(schemas( SearchResponse, diff --git a/crates/adapters/http-axum/src/openapi/social.rs b/crates/adapters/http-axum/src/openapi/social.rs index c5c8cee5..22065985 100644 --- a/crates/adapters/http-axum/src/openapi/social.rs +++ b/crates/adapters/http-axum/src/openapi/social.rs @@ -11,23 +11,23 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::social::get_following, - crate::handlers::social::get_followers, - crate::handlers::social::get_pending_followers, - crate::handlers::social::get_pending_follower_count, - crate::handlers::social::get_pending_following, - crate::handlers::social::get_relationship, - crate::handlers::social::follow, - crate::handlers::social::unfollow, - crate::handlers::social::accept_follower, - crate::handlers::social::reject_follower, - crate::handlers::social::remove_follower, - crate::handlers::social::get_blocked_domains_admin, - crate::handlers::social::add_blocked_domain_admin, - crate::handlers::social::remove_blocked_domain_admin, - crate::handlers::social::block_actor_api, - crate::handlers::social::unblock_actor_api, - crate::handlers::social::get_blocked_actors_api, + crate::api::social::get_following, + crate::api::social::get_followers, + crate::api::social::get_pending_followers, + crate::api::social::get_pending_follower_count, + crate::api::social::get_pending_following, + crate::api::social::get_relationship, + crate::api::social::follow, + crate::api::social::unfollow, + crate::api::social::accept_follower, + crate::api::social::reject_follower, + crate::api::social::remove_follower, + crate::federation::blocklist_api::get_blocked_domains_admin, + crate::federation::blocklist_api::add_blocked_domain_admin, + crate::federation::blocklist_api::remove_blocked_domain_admin, + crate::federation::blocklist_api::block_actor_api, + crate::federation::blocklist_api::unblock_actor_api, + crate::federation::blocklist_api::get_blocked_actors_api, ), components(schemas( ActorListResponse, diff --git a/crates/adapters/http-axum/src/openapi/users.rs b/crates/adapters/http-axum/src/openapi/users.rs index 67e4bed6..6bbd4961 100644 --- a/crates/adapters/http-axum/src/openapi/users.rs +++ b/crates/adapters/http-axum/src/openapi/users.rs @@ -7,11 +7,11 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::users::list_users, - crate::handlers::users::get_user_profile, - crate::handlers::users::get_profile, - crate::handlers::users::update_profile_handler, - crate::handlers::users::update_profile_fields_handler, + crate::api::users::list_users, + crate::api::user_profile::get_user_profile, + crate::api::users::get_profile, + crate::api::users::update_profile_handler, + crate::api::users::update_profile_fields_handler, ), components(schemas( UsersResponse, diff --git a/crates/adapters/http-axum/src/openapi/watchlist.rs b/crates/adapters/http-axum/src/openapi/watchlist.rs index 5573c28a..b2f0f1ca 100644 --- a/crates/adapters/http-axum/src/openapi/watchlist.rs +++ b/crates/adapters/http-axum/src/openapi/watchlist.rs @@ -6,10 +6,10 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::watchlist::get_watchlist_handler, - crate::handlers::watchlist::post_watchlist_add, - crate::handlers::watchlist::delete_watchlist_entry, - crate::handlers::watchlist::get_watchlist_status, + crate::api::watchlist::get_watchlist_handler, + crate::api::watchlist::post_watchlist_add, + crate::api::watchlist::delete_watchlist_entry, + crate::api::watchlist::get_watchlist_status, ), components(schemas( WatchlistResponse, diff --git a/crates/adapters/http-axum/src/openapi/webhook.rs b/crates/adapters/http-axum/src/openapi/webhook.rs index c1d5f6a7..f48e72b0 100644 --- a/crates/adapters/http-axum/src/openapi/webhook.rs +++ b/crates/adapters/http-axum/src/openapi/webhook.rs @@ -8,14 +8,14 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::webhook::post_jellyfin_webhook, - crate::handlers::webhook::post_plex_webhook, - crate::handlers::webhook::post_generate_webhook_token, - crate::handlers::webhook::get_webhook_tokens, - crate::handlers::webhook::delete_webhook_token, - crate::handlers::webhook::get_watch_queue, - crate::handlers::webhook::post_confirm_watch_events, - crate::handlers::webhook::post_dismiss_watch_events, + crate::api::webhook::post_jellyfin_webhook, + crate::api::webhook::post_plex_webhook, + crate::api::webhook::post_generate_webhook_token, + crate::api::webhook::get_webhook_tokens, + crate::api::webhook::delete_webhook_token, + crate::api::webhook::get_watch_queue, + crate::api::webhook::post_confirm_watch_events, + crate::api::webhook::post_dismiss_watch_events, ), components(schemas( GenerateTokenRequest, diff --git a/crates/adapters/http-axum/src/openapi/wrapup.rs b/crates/adapters/http-axum/src/openapi/wrapup.rs index ba6e0093..f93a8ed9 100644 --- a/crates/adapters/http-axum/src/openapi/wrapup.rs +++ b/crates/adapters/http-axum/src/openapi/wrapup.rs @@ -3,11 +3,11 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::wrapup::post_generate, - crate::handlers::wrapup::get_list, - crate::handlers::wrapup::get_status, - crate::handlers::wrapup::get_report, - crate::handlers::wrapup::delete_wrapup_handler, + crate::api::wrapup::post_generate, + crate::api::wrapup::get_list, + crate::api::wrapup::get_status, + crate::api::wrapup::get_report, + crate::api::wrapup::delete_wrapup_handler, ), components(schemas( api_types::wrapup::GenerateWrapUpRequest, diff --git a/crates/adapters/http-axum/src/router.rs b/crates/adapters/http-axum/src/router.rs new file mode 100644 index 00000000..1185bde2 --- /dev/null +++ b/crates/adapters/http-axum/src/router.rs @@ -0,0 +1,131 @@ +use std::num::NonZeroU32; + +use axum::{Router, routing}; +use axum_governor::{GovernorConfigBuilder, GovernorLayer, Quota, extractor::PeerIp}; +use tower_http::{ + cors::CorsLayer, + services::{ServeDir, ServeFile}, + trace::TraceLayer, +}; + +use crate::{api, federation, html, state::AppState}; + +pub fn build_router(state: AppState, ap_router: Router) -> Router { + let rate_limit = state.app_ctx.config.rate_limit; + let secure_cookies = state.app_ctx.config.secure_cookies; + let cors_origins = state.app_ctx.config.cors_origins.clone(); + + let ap_cfg = GovernorConfigBuilder::default() + .with_extractor(PeerIp::default()) + .expect_connect_info() + .quota_default(per_minute(rate_limit / 2)) + .finish() + .unwrap(); + let ap_router = ap_router.layer(GovernorLayer::new(ap_cfg)); + + Router::new() + .route("/health", routing::get(health_handler)) + .merge(html_routes(rate_limit, secure_cookies)) + .merge(api_routes(rate_limit, &cors_origins)) + .nest_service("/static", ServeDir::new("static")) + .nest_service( + "/app", + ServeDir::new("spa/dist").fallback(ServeFile::new("spa/dist/index.html")), + ) + .layer(TraceLayer::new_for_http()) + .with_state(state) + .merge(ap_router) +} + +async fn health_handler() -> axum::Json { + axum::Json(serde_json::json!({ "status": "ok" })) +} + +fn per_minute(n: u64) -> Quota { + let n = NonZeroU32::new(n.clamp(1, u32::MAX as u64) as u32).unwrap(); + Quota::requests_per_minute(n) +} + +fn html_routes(rate_limit: u64, secure_cookies: bool) -> Router { + let auth = html::auth_router().layer({ + let cfg = GovernorConfigBuilder::default() + .with_extractor(PeerIp::default()) + .expect_connect_info() + .quota_default(per_minute(rate_limit)) + .finish() + .unwrap(); + GovernorLayer::new(cfg) + }); + + html::router() + .merge(federation::html_routes()) + .merge(auth) + .layer(axum::middleware::from_fn_with_state( + crate::html::csrf::SecureCookies(secure_cookies), + crate::html::csrf::csrf_middleware, + )) +} + +fn cors_layer(origins: &str) -> CorsLayer { + use axum::http::{HeaderName, Method}; + use tower_http::cors::AllowOrigin; + + let layer = CorsLayer::new() + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ]) + .allow_headers([ + HeaderName::from_static("content-type"), + HeaderName::from_static("authorization"), + ]); + + if origins.is_empty() || origins == "*" { + layer.allow_origin(AllowOrigin::any()) + } else { + let parsed: Vec<_> = origins + .split(',') + .filter_map(|s| { + let trimmed = s.trim(); + match trimmed.parse() { + Ok(v) => Some(v), + Err(e) => { + tracing::warn!("ignoring invalid CORS origin {trimmed:?}: {e}"); + None + } + } + }) + .collect(); + layer + .allow_origin(AllowOrigin::list(parsed)) + .allow_credentials(true) + } +} + +fn api_routes(rate_limit: u64, cors_origins: &str) -> Router { + let cfg = GovernorConfigBuilder::default() + .with_extractor(PeerIp::default()) + .expect_connect_info() + .quota_default(per_minute(rate_limit)) + .finish() + .unwrap(); + + let base = api::router().merge(federation::api_routes()); + + let webhook_cfg = GovernorConfigBuilder::default() + .with_extractor(PeerIp::default()) + .expect_connect_info() + .quota_default(per_minute(rate_limit / 4)) + .finish() + .unwrap(); + let webhook_routes = api::webhook_router().layer(GovernorLayer::new(webhook_cfg)); + + Router::new() + .nest("/api/v1", base.layer(GovernorLayer::new(cfg))) + .nest("/api/v1", webhook_routes) + .layer(cors_layer(cors_origins)) +} diff --git a/crates/adapters/http-axum/src/routes.rs b/crates/adapters/http-axum/src/routes.rs deleted file mode 100644 index 77e08d53..00000000 --- a/crates/adapters/http-axum/src/routes.rs +++ /dev/null @@ -1,593 +0,0 @@ -use std::num::NonZeroU32; - -use axum::{Router, routing}; -use axum_governor::{GovernorConfigBuilder, GovernorLayer, Quota, extractor::PeerIp}; -use tower_http::{ - cors::CorsLayer, - services::{ServeDir, ServeFile}, - trace::TraceLayer, -}; - -use crate::{handlers, state::AppState}; - -pub fn build_router(state: AppState, ap_router: Router) -> Router { - let rate_limit = state.app_ctx.config.rate_limit; - let secure_cookies = state.app_ctx.config.secure_cookies; - let cors_origins = state.app_ctx.config.cors_origins.clone(); - - let ap_cfg = GovernorConfigBuilder::default() - .with_extractor(PeerIp::default()) - .expect_connect_info() - .quota_default(per_minute(rate_limit / 2)) - .finish() - .unwrap(); - let ap_router = ap_router.layer(GovernorLayer::new(ap_cfg)); - - Router::new() - .route("/health", routing::get(health_handler)) - .merge(html_routes(rate_limit, secure_cookies)) - .merge(api_routes(rate_limit, &cors_origins)) - .nest_service("/static", ServeDir::new("static")) - .nest_service( - "/app", - ServeDir::new("spa/dist").fallback(ServeFile::new("spa/dist/index.html")), - ) - .layer(TraceLayer::new_for_http()) - .with_state(state) - .merge(ap_router) -} - -async fn health_handler() -> axum::Json { - axum::Json(serde_json::json!({ "status": "ok" })) -} - -fn per_minute(n: u64) -> Quota { - let n = NonZeroU32::new(n.clamp(1, u32::MAX as u64) as u32).unwrap(); - Quota::requests_per_minute(n) -} - -fn html_routes(rate_limit: u64, secure_cookies: bool) -> Router { - let auth = Router::new() - .route( - "/login", - routing::get(handlers::auth::get_login_page).post(handlers::auth::post_login), - ) - .route("/logout", routing::get(handlers::auth::get_logout)) - .route( - "/register", - routing::get(handlers::auth::get_register_page).post(handlers::auth::post_register), - ) - .layer({ - let cfg = GovernorConfigBuilder::default() - .with_extractor(PeerIp::default()) - .expect_connect_info() - .quota_default(per_minute(rate_limit)) - .finish() - .unwrap(); - GovernorLayer::new(cfg) - }); - - let base = Router::new() - .route("/", routing::get(handlers::diary::get_activity_feed_html)) - .route("/users", routing::get(handlers::users::get_users_list)) - .route( - "/u/{username}", - routing::get(handlers::users::get_user_by_username), - ) - .route( - "/users/{id}", - routing::get(handlers::users::get_user_profile_html), - ) - .route( - "/movies/{movie_id}", - routing::get(handlers::movies::get_movie_detail_html), - ) - .merge(auth) - .route( - "/reviews/new", - routing::get(handlers::diary::get_new_review_page), - ) - .route("/reviews", routing::post(handlers::diary::post_review_html)) - .route( - "/reviews/{id}/delete", - routing::post(handlers::diary::post_delete_review_html), - ) - .route("/images/{*key}", routing::get(handlers::images::get_image)) - .route( - "/posters/{path}", - routing::get( - |axum::extract::Path(p): axum::extract::Path| async move { - axum::response::Redirect::permanent(&format!("/images/{}", p)) - }, - ), - ) - .route( - "/diary/export", - routing::get(handlers::diary::get_export_html), - ) - .route("/import", routing::get(handlers::import::get_import_page)) - .route( - "/import/upload", - routing::post(handlers::import::post_upload), - ) - .route( - "/import/{id}/mapping", - routing::get(handlers::import::get_mapping_page).post(handlers::import::post_mapping), - ) - .route( - "/import/{id}/preview", - routing::get(handlers::import::get_preview_page), - ) - .route( - "/import/{id}/confirm", - routing::post(handlers::import::post_confirm), - ) - .route( - "/import/done", - routing::get(handlers::import::get_import_done), - ) - .route( - "/import/profiles/{profile_id}/delete", - routing::post(handlers::import::post_delete_profile), - ) - .route("/feed.rss", routing::get(handlers::rss::get_feed)) - .route( - "/users/{id}/feed.rss", - routing::get(handlers::rss::get_user_feed), - ) - .route( - "/settings/profile", - routing::get(handlers::users::get_profile_settings) - .post(handlers::users::post_profile_settings), - ) - .route("/tags/{tag}", routing::get(handlers::search::get_tag)) - .route( - "/users/{id}/watchlist", - routing::get(handlers::watchlist::get_watchlist_page), - ) - .route( - "/watchlist/add", - routing::post(handlers::watchlist::post_watchlist_add_html), - ) - .route( - "/watchlist/{movie_id}/remove", - routing::post(handlers::watchlist::post_watchlist_remove_html), - ) - .route( - "/settings/integrations", - routing::get(handlers::integrations::get_integrations_page), - ) - .route( - "/settings/integrations/generate", - routing::post(handlers::integrations::post_generate_token), - ) - .route( - "/settings/integrations/{id}/revoke", - routing::post(handlers::integrations::post_revoke_token), - ) - .route( - "/watch-queue", - routing::get(handlers::integrations::get_watch_queue_page), - ) - .route( - "/watch-queue/{id}/confirm", - routing::post(handlers::integrations::post_confirm_single), - ) - .route( - "/watch-queue/{id}/dismiss", - routing::post(handlers::integrations::post_dismiss_single), - ) - .route( - "/wrapups/{user_id}/{year}", - routing::get(handlers::wrapup::get_user_wrapup_html), - ) - .route( - "/wrapups/global/{year}", - routing::get(handlers::wrapup::get_global_wrapup_html), - ); - - let base = base.merge(social_html_routes()); - #[cfg(feature = "federation")] - let base = base.merge(federation_html_routes()); - - base.layer(axum::middleware::from_fn_with_state( - crate::csrf::SecureCookies(secure_cookies), - crate::csrf::csrf_middleware, - )) -} - -fn social_html_routes() -> Router { - let base = Router::new() - .route( - "/users/{id}/follow", - routing::post(handlers::social::follow_user), - ) - .route( - "/users/{id}/unfollow", - routing::post(handlers::social::unfollow_user), - ) - .route( - "/users/{id}/followers/accept", - routing::post(handlers::social::accept_follower_html), - ) - .route( - "/users/{id}/followers/reject", - routing::post(handlers::social::reject_follower_html), - ) - .route( - "/users/{id}/followers/remove", - routing::post(handlers::social::remove_follower_html), - ) - .route( - "/users/{id}/following-list", - routing::get(handlers::social::get_following_page), - ) - .route( - "/users/{id}/followers-list", - routing::get(handlers::social::get_followers_page), - ); - - #[cfg(not(feature = "federation"))] - let base = base - .route( - "/users/{id}/followers", - routing::get( - |axum::extract::Path(id): axum::extract::Path| async move { - axum::response::Redirect::permanent(&format!("/users/{}/followers-list", id)) - }, - ), - ) - .route( - "/users/{id}/following", - routing::get( - |axum::extract::Path(id): axum::extract::Path| async move { - axum::response::Redirect::permanent(&format!("/users/{}/following-list", id)) - }, - ), - ); - - base -} - -#[cfg(feature = "federation")] -fn federation_html_routes() -> Router { - Router::new() - .route( - "/users/{id}/followers", - routing::get(handlers::social::get_followers_collection), - ) - .route( - "/users/{id}/following", - routing::get(handlers::social::get_following_collection), - ) - .route( - "/admin/blocked-domains", - routing::get(handlers::social::get_blocked_domains_page) - .post(handlers::social::post_blocked_domain), - ) - .route( - "/admin/blocked-domains/remove", - routing::post(handlers::social::post_remove_blocked_domain), - ) - .route( - "/social/blocked", - routing::get(handlers::social::get_blocked_actors_page), - ) - .route( - "/social/block", - routing::post(handlers::social::post_block_actor_html), - ) - .route( - "/social/unblock", - routing::post(handlers::social::post_unblock_actor), - ) -} - -fn cors_layer(origins: &str) -> CorsLayer { - use axum::http::{HeaderName, Method}; - use tower_http::cors::AllowOrigin; - - let layer = CorsLayer::new() - .allow_methods([ - Method::GET, - Method::POST, - Method::PUT, - Method::PATCH, - Method::DELETE, - Method::OPTIONS, - ]) - .allow_headers([ - HeaderName::from_static("content-type"), - HeaderName::from_static("authorization"), - ]); - - if origins.is_empty() || origins == "*" { - layer.allow_origin(AllowOrigin::any()) - } else { - let parsed: Vec<_> = origins - .split(',') - .filter_map(|s| { - let trimmed = s.trim(); - match trimmed.parse() { - Ok(v) => Some(v), - Err(e) => { - tracing::warn!("ignoring invalid CORS origin {trimmed:?}: {e}"); - None - } - } - }) - .collect(); - layer - .allow_origin(AllowOrigin::list(parsed)) - .allow_credentials(true) - } -} - -fn api_routes(rate_limit: u64, cors_origins: &str) -> Router { - let cfg = GovernorConfigBuilder::default() - .with_extractor(PeerIp::default()) - .expect_connect_info() - .quota_default(per_minute(rate_limit)) - .finish() - .unwrap(); - - let base = Router::new() - .route("/diary", routing::get(handlers::diary::get_diary)) - .route( - "/movies/{id}/history", - routing::get(handlers::movies::get_review_history), - ) - .route("/movies", routing::get(handlers::movies::list_movies)) - .route( - "/movies/{id}", - routing::get(handlers::movies::get_movie_detail), - ) - .route( - "/movies/{id}/profile", - routing::get(handlers::movies::get_movie_profile), - ) - .route("/reviews", routing::post(handlers::diary::post_review)) - .route( - "/reviews/{id}", - routing::delete(handlers::diary::delete_review).patch(handlers::diary::patch_review), - ) - .route( - "/movies/{id}/sync-poster", - routing::post(handlers::movies::sync_poster), - ) - .route("/auth/login", routing::post(handlers::auth::login)) - .route("/auth/register", routing::post(handlers::auth::register)) - .route("/auth/refresh", routing::post(handlers::auth::refresh)) - .route("/auth/logout", routing::post(handlers::auth::api_logout)) - .route("/diary/export", routing::get(handlers::diary::export_diary)) - .route( - "/activity-feed", - routing::get(handlers::diary::get_activity_feed), - ) - .route("/users", routing::get(handlers::users::list_users)) - .route( - "/users/{id}", - routing::get(handlers::users::get_user_profile), - ) - .route( - "/import/sessions", - routing::post(handlers::import::api_post_session), - ) - .route( - "/import/sessions/{id}", - routing::get(handlers::import::api_get_session), - ) - .route( - "/import/sessions/{id}/mapping", - routing::put(handlers::import::api_put_mapping), - ) - .route( - "/import/sessions/{id}/preview", - routing::get(handlers::import::api_get_preview), - ) - .route( - "/import/sessions/{id}/confirm", - routing::post(handlers::import::api_post_confirm), - ) - .route( - "/import/profiles", - routing::get(handlers::import::api_get_profiles) - .post(handlers::import::api_post_profile), - ) - .route( - "/import/profiles/{id}", - routing::delete(handlers::import::api_delete_profile), - ) - .route( - "/import/sessions/{id}/profile/{profile_id}", - routing::put(handlers::import::api_apply_profile), - ) - .route( - "/profile", - routing::get(handlers::users::get_profile).put(handlers::users::update_profile_handler), - ) - .route( - "/profile/fields", - routing::put(handlers::users::update_profile_fields_handler), - ) - .route("/search", routing::get(handlers::search::get_search)) - .route( - "/people/{id}", - routing::get(handlers::search::get_person_handler), - ) - .route( - "/people/{id}/credits", - routing::get(handlers::search::get_person_credits_handler), - ) - .route( - "/watchlist", - routing::get(handlers::watchlist::get_watchlist_handler) - .post(handlers::watchlist::post_watchlist_add), - ) - .route( - "/watchlist/{movie_id}", - routing::get(handlers::watchlist::get_watchlist_status) - .delete(handlers::watchlist::delete_watchlist_entry), - ) - .route( - "/settings/webhook-tokens", - routing::get(handlers::webhook::get_webhook_tokens) - .post(handlers::webhook::post_generate_webhook_token), - ) - .route( - "/settings/webhook-tokens/{id}", - routing::delete(handlers::webhook::delete_webhook_token), - ) - .route( - "/watch-queue", - routing::get(handlers::webhook::get_watch_queue), - ) - .route( - "/watch-queue/confirm", - routing::post(handlers::webhook::post_confirm_watch_events), - ) - .route( - "/watch-queue/dismiss", - routing::post(handlers::webhook::post_dismiss_watch_events), - ) - .route( - "/wrapups/generate", - routing::post(handlers::wrapup::post_generate), - ) - .route("/wrapups", routing::get(handlers::wrapup::get_list)) - .route( - "/wrapups/{id}", - routing::get(handlers::wrapup::get_status) - .delete(handlers::wrapup::delete_wrapup_handler), - ) - .route( - "/wrapups/{id}/report", - routing::get(handlers::wrapup::get_report), - ) - .route("/insights", routing::get(handlers::insights::get_insights)) - .route( - "/admin/reindex-search", - routing::post(handlers::search::post_reindex_search), - ) - .route( - "/goals", - routing::get(handlers::goals::list_goals).post(handlers::goals::create_goal), - ) - .route( - "/goals/{year}", - routing::put(handlers::goals::update_goal).delete(handlers::goals::delete_goal), - ) - .route( - "/users/{id}/goals", - routing::get(handlers::goals::get_user_goals), - ) - .route( - "/settings", - routing::get(handlers::goals::get_settings).put(handlers::goals::update_settings), - ); - - let base = base.merge(social_api_routes()); - #[cfg(feature = "federation")] - let base = base.merge(federation_api_routes()); - - let webhook_cfg = GovernorConfigBuilder::default() - .with_extractor(PeerIp::default()) - .expect_connect_info() - .quota_default(per_minute(rate_limit / 4)) - .finish() - .unwrap(); - let webhook_routes = Router::new() - .route( - "/webhooks/jellyfin", - routing::post(handlers::webhook::post_jellyfin_webhook), - ) - .route( - "/webhooks/plex", - routing::post(handlers::webhook::post_plex_webhook), - ) - .layer(GovernorLayer::new(webhook_cfg)); - - Router::new() - .nest("/api/v1", base.layer(GovernorLayer::new(cfg))) - .nest("/api/v1", webhook_routes) - .layer(cors_layer(cors_origins)) -} - -fn social_api_routes() -> Router { - Router::new() - .route( - "/social/following", - routing::get(handlers::social::get_following), - ) - .route( - "/social/followers", - routing::get(handlers::social::get_followers), - ) - .route( - "/social/followers/pending", - routing::get(handlers::social::get_pending_followers), - ) - .route( - "/social/followers/pending/count", - routing::get(handlers::social::get_pending_follower_count), - ) - .route( - "/social/following/pending", - routing::get(handlers::social::get_pending_following), - ) - .route( - "/social/relationship", - routing::get(handlers::social::get_relationship), - ) - .route("/social/follow", routing::post(handlers::social::follow)) - .route( - "/social/unfollow", - routing::post(handlers::social::unfollow), - ) - .route( - "/social/followers/accept", - routing::post(handlers::social::accept_follower), - ) - .route( - "/social/followers/reject", - routing::post(handlers::social::reject_follower), - ) - .route( - "/social/followers/remove", - routing::post(handlers::social::remove_follower), - ) - .route( - "/users/{id}/following", - routing::get(handlers::social::get_user_following), - ) - .route( - "/users/{id}/followers", - routing::get(handlers::social::get_user_followers), - ) -} - -#[cfg(feature = "federation")] -fn federation_api_routes() -> Router { - Router::new() - .route( - "/admin/blocked-domains", - routing::get(handlers::social::get_blocked_domains_admin) - .post(handlers::social::add_blocked_domain_admin), - ) - .route( - "/admin/blocked-domains/{domain}", - routing::delete(handlers::social::remove_blocked_domain_admin), - ) - .route( - "/social/block", - routing::post(handlers::social::block_actor_api), - ) - .route( - "/social/unblock", - routing::post(handlers::social::unblock_actor_api), - ) - .route( - "/social/blocked", - routing::get(handlers::social::get_blocked_actors_api), - ) -} diff --git a/crates/adapters/http-axum/src/handlers/helpers.rs b/crates/adapters/http-axum/src/support.rs similarity index 66% rename from crates/adapters/http-axum/src/handlers/helpers.rs rename to crates/adapters/http-axum/src/support.rs index 7a0daf06..75420146 100644 --- a/crates/adapters/http-axum/src/handlers/helpers.rs +++ b/crates/adapters/http-axum/src/support.rs @@ -1,25 +1,7 @@ -use api_types::HtmlPageContext; -use domain::value_objects::UserId; +//! Request/response helpers shared by the JSON API and the classic HTML UI. use crate::state::AppState; - -pub(crate) fn encode_error(msg: &str) -> String { - use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; - utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() -} - -/// Error text for a classic-page `?error=` redirect. Infrastructure failures -/// are logged here — the redirect is their only exit, so nothing else would — -/// and shown to the user generically, never with internal detail. -pub(crate) fn encode_domain_error(e: &domain::errors::DomainError) -> String { - match e { - domain::errors::DomainError::InfrastructureError(_) => { - tracing::error!("Internal error: {e}"); - encode_error("Something went wrong, please try again") - } - _ => encode_error(&e.to_string()), - } -} +use domain::models::import::DomainField; pub(crate) fn build_export_response( format_str: &str, @@ -149,42 +131,15 @@ pub(crate) async fn parse_profile_multipart( data } -pub(crate) async fn build_page_context( - state: &AppState, - user_id: Option, - csrf_token: String, -) -> HtmlPageContext { - let uuid = user_id.as_ref().map(|u| u.value()); - // Page chrome (email/admin badge, pending-follow badge) degrades rather than - // blanking the page: a failed lookup or count logs a warning and falls back - // to a zeroed `PageViewer`, same as the previous silent `.unwrap_or(0)` — now - // logged and explicit, per the error policy in `get_page_viewer`'s doc comment. - let (user_email, is_admin, pending_follow_count) = if let Some(ref id) = user_id { - match application::users::get_page_viewer::execute( - &state.app_ctx.deps.users.get_page_viewer, - id.value(), - ) - .await - { - Ok(viewer) => (viewer.email, viewer.is_admin, viewer.pending_follow_count), - Err(e) => { - tracing::warn!("get_page_viewer failed for {}: {e}", id.value()); - (None, false, 0) - } - } - } else { - (None, false, 0) - }; - HtmlPageContext { - user_email, - user_id: uuid, - is_admin, - register_enabled: state.app_ctx.config.allow_registration, - rss_url: "/feed.rss".to_string(), - page_title: "Movies Diary".to_string(), - canonical_url: state.app_ctx.config.base_url.clone(), - csrf_token, - page_rss_url: None, - pending_follow_count, +pub(crate) fn str_to_domain_field(field: &str) -> Option { + match field { + "title" => Some(DomainField::Title), + "release_year" => Some(DomainField::ReleaseYear), + "director" => Some(DomainField::Director), + "rating" => Some(DomainField::Rating), + "watched_at" => Some(DomainField::WatchedAt), + "comment" => Some(DomainField::Comment), + "external_metadata_id" => Some(DomainField::ExternalMetadataId), + _ => None, } } diff --git a/crates/adapters/http-axum/src/tests/api_handlers.rs b/crates/adapters/http-axum/src/tests/api_handlers.rs index f79e7fe8..94b8ee2b 100644 --- a/crates/adapters/http-axum/src/tests/api_handlers.rs +++ b/crates/adapters/http-axum/src/tests/api_handlers.rs @@ -75,7 +75,7 @@ impl domain::ports::PersonQuery for PersonQueryStub { async fn search_endpoint_returns_200_with_empty_results() { let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub)); let app = Router::new() - .route("/api/v1/search", get(crate::handlers::search::get_search)) + .route("/api/v1/search", get(crate::api::search::get_search)) .with_state(state); let resp = app @@ -95,7 +95,7 @@ async fn search_endpoint_returns_200_with_empty_results() { async fn search_endpoint_with_no_query_returns_200() { let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub)); let app = Router::new() - .route("/api/v1/search", get(crate::handlers::search::get_search)) + .route("/api/v1/search", get(crate::api::search::get_search)) .with_state(state); let resp = app @@ -120,7 +120,7 @@ async fn person_endpoint_returns_404_for_unknown_id() { let app = Router::new() .route( "/api/v1/people/{id}", - get(crate::handlers::search::get_person_handler), + get(crate::api::search::get_person_handler), ) .with_state(state); @@ -145,7 +145,7 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() { let app = Router::new() .route( "/api/v1/people/{id}/credits", - get(crate::handlers::search::get_person_credits_handler), + get(crate::api::search::get_person_credits_handler), ) .with_state(state); @@ -183,7 +183,7 @@ async fn get_watchlist_requires_auth() { let app = Router::new() .route( "/api/v1/watchlist", - get(crate::handlers::watchlist::get_watchlist_handler), + get(crate::api::watchlist::get_watchlist_handler), ) .with_state(state); @@ -266,7 +266,7 @@ async fn pending_count_endpoint_reports_the_use_case_result() { let app = Router::new() .route( "/api/v1/social/followers/pending/count", - get(crate::handlers::social::get_pending_follower_count), + get(crate::api::social::get_pending_follower_count), ) .with_state(state); @@ -295,7 +295,7 @@ async fn get_watchlist_status_requires_auth() { let app = Router::new() .route( "/api/v1/watchlist/{movie_id}", - get(crate::handlers::watchlist::get_watchlist_status), + get(crate::api::watchlist::get_watchlist_status), ) .with_state(state); @@ -352,7 +352,7 @@ async fn followers_collection_returns_500_on_federation_error() { let app = Router::new() .route( "/users/{id}/followers", - get(crate::handlers::social::get_followers_collection), + get(crate::federation::collections::get_followers_collection), ) .with_state(state_with_failing_ap_document()); @@ -376,7 +376,7 @@ async fn following_collection_returns_500_on_federation_error() { let app = Router::new() .route( "/users/{id}/following", - get(crate::handlers::social::get_following_collection), + get(crate::federation::collections::get_following_collection), ) .with_state(state_with_failing_ap_document()); diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs index 7d9319fc..1c862985 100644 --- a/crates/api-types/src/lib.rs +++ b/crates/api-types/src/lib.rs @@ -4,6 +4,7 @@ pub mod diary; pub mod goals; pub mod import; pub mod insights; +pub mod mappers; pub mod movies; pub mod rendering; pub mod search; diff --git a/crates/adapters/http-axum/src/mappers/diary.rs b/crates/api-types/src/mappers/diary.rs similarity index 95% rename from crates/adapters/http-axum/src/mappers/diary.rs rename to crates/api-types/src/mappers/diary.rs index e38c24f5..9aa851ca 100644 --- a/crates/adapters/http-axum/src/mappers/diary.rs +++ b/crates/api-types/src/mappers/diary.rs @@ -1,4 +1,4 @@ -use api_types::FeedEntryDto; +use crate::FeedEntryDto; use domain::models::FeedEntry; use super::movies::{movie_to_dto, review_to_dto}; diff --git a/crates/api-types/src/mappers/goals.rs b/crates/api-types/src/mappers/goals.rs new file mode 100644 index 00000000..b69d542f --- /dev/null +++ b/crates/api-types/src/mappers/goals.rs @@ -0,0 +1,12 @@ +use crate::GoalDto; + +pub fn goal_with_progress_to_dto(g: &domain::models::GoalWithProgress) -> GoalDto { + GoalDto { + year: g.goal.year(), + target_count: g.goal.target_count(), + current_count: g.current_count, + percentage: g.percentage(), + is_complete: g.is_complete(), + goal_type: g.goal.goal_type().clone(), + } +} diff --git a/crates/api-types/src/mappers/mod.rs b/crates/api-types/src/mappers/mod.rs new file mode 100644 index 00000000..20b918b2 --- /dev/null +++ b/crates/api-types/src/mappers/mod.rs @@ -0,0 +1,10 @@ +//! Pure domain -> DTO mappers (depend only on `domain` and this crate). +mod diary; +mod goals; +mod movies; +mod search; + +pub use diary::*; +pub use goals::*; +pub use movies::*; +pub use search::*; diff --git a/crates/adapters/http-axum/src/mappers/movies.rs b/crates/api-types/src/mappers/movies.rs similarity index 97% rename from crates/adapters/http-axum/src/mappers/movies.rs rename to crates/api-types/src/mappers/movies.rs index 4bca1606..9b6b729d 100644 --- a/crates/adapters/http-axum/src/mappers/movies.rs +++ b/crates/api-types/src/mappers/movies.rs @@ -1,4 +1,4 @@ -use api_types::{DiaryEntryDto, MovieDto, ReviewDto}; +use crate::{DiaryEntryDto, MovieDto, ReviewDto}; use domain::models::{DiaryEntry, Movie, MovieSummary, Review}; pub fn movie_to_dto(movie: &Movie) -> MovieDto { diff --git a/crates/adapters/http-axum/src/mappers/search.rs b/crates/api-types/src/mappers/search.rs similarity index 96% rename from crates/adapters/http-axum/src/mappers/search.rs rename to crates/api-types/src/mappers/search.rs index 874dc755..74ee04ae 100644 --- a/crates/adapters/http-axum/src/mappers/search.rs +++ b/crates/api-types/src/mappers/search.rs @@ -1,4 +1,4 @@ -use api_types::search::PersonDto; +use crate::search::PersonDto; use domain::models::person::Person; pub fn person_to_dto(p: &Person) -> PersonDto { diff --git a/crates/bootstrap/src/server.rs b/crates/bootstrap/src/server.rs index 76302609..dd58922f 100644 --- a/crates/bootstrap/src/server.rs +++ b/crates/bootstrap/src/server.rs @@ -7,7 +7,7 @@ use domain::ports::{DiaryExporter, DocumentParser, EventPublisher}; use domain::value_objects::InstanceIdentity; use export::ExportAdapter; use http_axum::context::AppContext; -use http_axum::{openapi, routes, state::AppState}; +use http_axum::{openapi, router, state::AppState}; use importer::ImporterDocumentParser; use rss::RssAdapter; @@ -140,7 +140,7 @@ async fn assemble( app_ctx, rss_renderer: Arc::new(RssAdapter::new(config.server.base_url.clone())), }; - Ok(openapi::serve(routes::build_router( + Ok(openapi::serve(router::build_router( state, social.ap_router, ))) diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs index ef4c96b1..c7cb1434 100644 --- a/crates/server/tests/api_test.rs +++ b/crates/server/tests/api_test.rs @@ -22,7 +22,7 @@ use domain::{ value_objects::{Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId}, }; use http_axum::context::AppContext; -use http_axum::{routes, state::AppState}; +use http_axum::{router, state::AppState}; use http_body_util::BodyExt; use rss::RssAdapter; use sqlite::{ @@ -477,7 +477,7 @@ async fn test_app_with(auth: impl AuthService + 'static) -> (Router, SqlitePool) rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())), }; - let router = routes::build_router(state, axum::Router::new()); + let router = router::build_router(state, axum::Router::new()); (router, pool) } -- 2.49.1 From 816d39ffd6f4fe8cf07c0acf2d21244daeb25f99 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:46:03 +0100 Subject: [PATCH 022/104] chore: check-http-adapter-deps guard, README layout --- .gitea/workflows/ci.yml | 2 +- .github/workflows/ci.yml | 2 +- Makefile | 30 +++++++++++++++--------------- README.md | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a8a1c209..7cb9e078 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -47,4 +47,4 @@ jobs: # Layering guards. These live in the Makefile and were previously local-only, # so a PR could go green in CI while violating them. - name: guards - run: make check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free + run: make check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0a39ecd..00020619 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: # Layering guards. These live in the Makefile and were previously local-only, # so a PR could go green in CI while violating them. - name: guards - run: make check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free + run: make check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free docker: name: Build & Push Docker Image diff --git a/Makefile b/Makefile index ba3f5966..7de7a00d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := check -check: fmt-check clippy test test-federation-off check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free +check: fmt-check clippy test test-federation-off check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free @echo "✅ All checks passed" check-appcontext: @@ -12,35 +12,35 @@ check-appcontext: @echo "✅ No AppContext in application crate" check-handler-deps: - @if grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests | grep -q .; then \ - echo "❌ hand-built deps struct found in presentation:"; \ - grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests; \ + @if grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests --exclude=tests.rs | grep -q .; then \ + echo "❌ hand-built deps struct found in http-axum:"; \ + grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests --exclude=tests.rs; \ exit 1; \ fi - @echo "✅ No hand-built deps structs in presentation" + @echo "✅ No hand-built deps structs in http-axum" check-handler-repos: - @if grep -rn "app_ctx\.repos" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests | grep -q .; then \ - echo "❌ app_ctx.repos referenced in presentation:"; \ - grep -rn "app_ctx\.repos" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests; \ + @if grep -rn "app_ctx\.repos" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests --exclude=tests.rs | grep -q .; then \ + echo "❌ app_ctx.repos referenced in http-axum:"; \ + grep -rn "app_ctx\.repos" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests --exclude=tests.rs; \ exit 1; \ fi - @echo "✅ No app_ctx.repos references in presentation" + @echo "✅ No app_ctx.repos references in http-axum" -PRESENTATION_ALLOWED_ADAPTERS := +HTTP_ADAPTER_ALLOWED_ADAPTERS := -check-presentation-adapters: +check-http-adapter-deps: @deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/adapters/http-axum/Cargo.toml \ | sed -n 's/^\([A-Za-z0-9_-]\{1,\}\)[[:space:]]*=.*/\1/p'); \ bad=""; \ for a in $$(ls crates/adapters); do \ - case " $(PRESENTATION_ALLOWED_ADAPTERS) " in *" $$a "*) continue;; esac; \ + case " $(HTTP_ADAPTER_ALLOWED_ADAPTERS) " in *" $$a "*) continue;; esac; \ if echo "$$deps" | grep -qx "$$a"; then bad="$$bad $$a"; fi; \ done; \ if [ -n "$$bad" ]; then \ - echo "❌ presentation depends on adapter crate(s):$$bad"; \ - echo " presentation may name renderers, not reachers — see ADR-0008."; \ + echo "❌ http-axum depends on adapter crate(s):$$bad"; \ + echo " http-axum may name renderers, not reachers — see ADR-0008."; \ exit 1; \ fi; \ echo "✅ http-axum depends on no other adapter crate" @@ -129,4 +129,4 @@ dev-spa: spa-dev: @npm --prefix spa run dev -.PHONY: check fmt fmt-check clippy test test-federation-off fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free +.PHONY: check fmt fmt-check clippy test test-federation-off fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free diff --git a/README.md b/README.md index bc3bf316..8de059c6 100644 --- a/README.md +++ b/README.md @@ -95,10 +95,10 @@ config — typed configuration (defaults, config.toml, legacy env n bootstrap — composition root: the only crate that names adapters; builds the server router and the worker service domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, SocialCommand/SocialQuery, ImageFetcher, RssFeedRenderer), no external deps except serde application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic; modules: auth, diary, goals, import, integrations, movies, person, search, social, users, watchlist, wrapup -adapters/http-axum — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving; library crate, no binary of its own server — thin HTTP binary (dotenv, config, bootstrap::build_server, axum::serve); features (federation, nats) forward to bootstrap worker — thin worker binary (bootstrap::build_worker, run until ctrl-c) adapters/ + http-axum — Axum HTTP adapter: JSON API, classic HTML UI (askama templates), OpenAPI spec, Swagger UI + Scalar, federation-gated routes; library crate, no binary of its own auth — JWT issuance and validation (Argon2 passwords) sqlite — SQLite repositories, search (FTS5), social graph, durable event queue, connection factory + migrations metadata — TMDB / OMDb HTTP client -- 2.49.1 From c8d9138349f3a3d2ed61d797f874450c3c69e3ac Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:55:38 +0100 Subject: [PATCH 023/104] refactor: single refusal shape with openapi contract test --- crates/adapters/http-axum/src/api/auth.rs | 15 +- crates/adapters/http-axum/src/api/diary.rs | 58 ++++---- crates/adapters/http-axum/src/api/goals.rs | 37 +++-- .../http-axum/src/api/import/profiles.rs | 21 ++- .../http-axum/src/api/import/sessions.rs | 43 +++--- crates/adapters/http-axum/src/api/insights.rs | 12 +- crates/adapters/http-axum/src/api/movies.rs | 37 +++-- crates/adapters/http-axum/src/api/search.rs | 36 ++--- crates/adapters/http-axum/src/api/social.rs | 46 +++--- .../http-axum/src/api/user_profile.rs | 32 +++-- crates/adapters/http-axum/src/api/users.rs | 36 +++-- .../adapters/http-axum/src/api/watchlist.rs | 28 ++-- crates/adapters/http-axum/src/api/webhook.rs | 67 +++++---- crates/adapters/http-axum/src/api/wrapup.rs | 48 +++---- crates/adapters/http-axum/src/errors.rs | 38 +++-- .../adapters/http-axum/src/extractors/json.rs | 101 +++++++++++++ .../adapters/http-axum/src/extractors/mod.rs | 3 + .../http-axum/src/federation/blocklist_api.rs | 27 ++-- crates/adapters/http-axum/src/openapi/mod.rs | 11 +- crates/adapters/http-axum/src/router.rs | 31 +++- .../http-axum/src/tests/api_handlers.rs | 136 ++++++++++++++++++ .../http-axum/tests/openapi_contract_test.rs | 93 ++++++++++++ crates/api-types/src/common.rs | 9 ++ crates/bootstrap/tests/route_smoke_test.rs | 20 +++ ...0014-http-adapter-and-one-refusal-shape.md | 52 +++++++ 25 files changed, 764 insertions(+), 273 deletions(-) create mode 100644 crates/adapters/http-axum/src/extractors/json.rs create mode 100644 crates/adapters/http-axum/tests/openapi_contract_test.rs create mode 100644 docs/adr/0014-http-adapter-and-one-refusal-shape.md diff --git a/crates/adapters/http-axum/src/api/auth.rs b/crates/adapters/http-axum/src/api/auth.rs index b3aecd34..1c7955ea 100644 --- a/crates/adapters/http-axum/src/api/auth.rs +++ b/crates/adapters/http-axum/src/api/auth.rs @@ -1,3 +1,4 @@ +use crate::extractors::Body; use crate::{errors::ApiError, state::AppState}; use api_types::{ LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest, @@ -12,12 +13,12 @@ use axum::{Json, extract::State, http::StatusCode}; request_body = LoginRequest, responses( (status = 200, body = LoginResponse), - (status = 401, description = "Invalid credentials"), + (status = 401, description = "Invalid credentials", body = api_types::ErrorResponse), ) )] pub async fn login( State(state): State, - Json(req): Json, + Body(req): Body, ) -> Result, ApiError> { let result = login_uc::execute( &state.app_ctx.deps.auth.login, @@ -42,12 +43,12 @@ pub async fn login( request_body = RegisterRequest, responses( (status = 201, description = "User registered"), - (status = 400, description = "Invalid input"), + (status = 400, description = "Invalid input", body = api_types::ErrorResponse), ) )] pub async fn register( State(state): State, - Json(req): Json, + Body(req): Body, ) -> Result { register_uc::execute( &state.app_ctx.deps.auth.register, @@ -67,12 +68,12 @@ pub async fn register( request_body = RefreshRequest, responses( (status = 200, body = RefreshResponse), - (status = 401, description = "Invalid or expired refresh token"), + (status = 401, description = "Invalid or expired refresh token", body = api_types::ErrorResponse), ) )] pub async fn refresh( State(state): State, - Json(req): Json, + Body(req): Body, ) -> Result, ApiError> { let result = application::auth::refresh::execute(&state.app_ctx.deps.auth.refresh, &req.refresh_token) @@ -93,7 +94,7 @@ pub async fn refresh( )] pub async fn api_logout( State(state): State, - Json(req): Json, + Body(req): Body, ) -> StatusCode { let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token) .await; diff --git a/crates/adapters/http-axum/src/api/diary.rs b/crates/adapters/http-axum/src/api/diary.rs index 583ea659..6413acad 100644 --- a/crates/adapters/http-axum/src/api/diary.rs +++ b/crates/adapters/http-axum/src/api/diary.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Body, Params, PathId}; use crate::support::build_export_response; use crate::{ errors::ApiError, @@ -14,12 +15,7 @@ use application::diary::{ delete_review, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review, queries::GetActivityFeedQuery, }; -use axum::{ - Json, - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use uuid::Uuid; #[utoipa::path( @@ -27,13 +23,13 @@ use uuid::Uuid; params(DiaryQueryParams), responses( (status = 200, body = DiaryResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_diary( State(state): State, - Query(params): Query, + Params(params): Params, ) -> Result, ApiError> { let page = get_diary::execute(&state.app_ctx.deps.diary.get_diary, to_diary_query(params)).await?; @@ -55,15 +51,15 @@ pub async fn get_diary( request_body = LogReviewRequest, responses( (status = 201, description = "Review created"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), + (status = 400, description = "Invalid input", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_review( State(state): State, user: AuthenticatedUser, - Json(req): Json, + Body(req): Body, ) -> Result { let data = LogReviewData::try_from(req).map_err(ApiError)?; log_review::execute( @@ -79,16 +75,16 @@ pub async fn post_review( params(("id" = Uuid, Path, description = "Review ID")), responses( (status = 204, description = "Review deleted"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden"), - (status = 404, description = "Review not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden", body = api_types::ErrorResponse), + (status = 404, description = "Review not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn delete_review( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path(review_id): Path, + PathId(review_id): PathId, ) -> Result { let cmd = DeleteReviewCommand { review_id, @@ -104,18 +100,18 @@ pub async fn delete_review( params(("id" = Uuid, Path, description = "Review ID")), responses( (status = 200, description = "Review updated"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden"), - (status = 404, description = "Review not found"), + (status = 400, description = "Invalid input", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden", body = api_types::ErrorResponse), + (status = 404, description = "Review not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn patch_review( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path(review_id): Path, - Json(req): Json, + PathId(review_id): PathId, + Body(req): Body, ) -> Result { let watched_at = req .watched_at @@ -140,27 +136,37 @@ pub async fn patch_review( params(ExportQueryParams), responses( (status = 200, description = "Diary file download", content_type = "text/csv"), - (status = 400, description = "Invalid format parameter"), - (status = 401, description = "Unauthorized"), + (status = 400, description = "Invalid format parameter", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn export_diary( State(state): State, user: AuthenticatedUser, - Query(params): Query, + Params(params): Params, ) -> impl IntoResponse { + if !matches!(params.format.as_str(), "csv" | "json") { + return crate::errors::refuse( + StatusCode::BAD_REQUEST, + crate::errors::INVALID_INPUT, + "format must be csv or json", + ); + } build_export_response(¶ms.format, user.0.value(), &state) } #[utoipa::path( get, path = "/api/v1/activity-feed", params(ActivityFeedQueryParams), - responses((status = 200, body = ActivityFeedResponse)), + responses( + (status = 200, body = ActivityFeedResponse), + (status = 400, description = "Invalid query", body = api_types::ErrorResponse), + ), )] pub async fn get_activity_feed( State(state): State, - Query(params): Query, + Params(params): Params, ) -> Result, ApiError> { let page = get_feed_uc::execute( &state.app_ctx.deps.diary.get_activity_feed, diff --git a/crates/adapters/http-axum/src/api/goals.rs b/crates/adapters/http-axum/src/api/goals.rs index 963630e0..ceaac9df 100644 --- a/crates/adapters/http-axum/src/api/goals.rs +++ b/crates/adapters/http-axum/src/api/goals.rs @@ -1,21 +1,18 @@ +use crate::extractors::{Body, PathId}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::mappers::goal_with_progress_to_dto; use api_types::{ CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest, UserSettingsDto, }; -use axum::{ - Json, - extract::{Path, State}, - http::StatusCode, -}; +use axum::{Json, extract::State, http::StatusCode}; use uuid::Uuid; #[utoipa::path( get, path = "/api/v1/goals", responses( (status = 200, body = GoalsResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -40,14 +37,14 @@ pub async fn list_goals( request_body = CreateGoalRequest, responses( (status = 200, body = GoalDto), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn create_goal( State(state): State, user: AuthenticatedUser, - Json(req): Json, + Body(req): Body, ) -> Result, ApiError> { let g = application::goals::create::execute( &state.app_ctx.deps.goals.command, @@ -66,16 +63,16 @@ pub async fn create_goal( request_body = UpdateGoalRequest, responses( (status = 200, body = GoalDto), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Goal not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Goal not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn update_goal( State(state): State, user: AuthenticatedUser, - Path(year): Path, - Json(req): Json, + PathId(year): PathId, + Body(req): Body, ) -> Result, ApiError> { let g = application::goals::update::execute( &state.app_ctx.deps.goals.command, @@ -93,15 +90,15 @@ pub async fn update_goal( delete, path = "/api/v1/goals/{year}", responses( (status = 204, description = "Goal deleted"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Goal not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Goal not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn delete_goal( State(state): State, user: AuthenticatedUser, - Path(year): Path, + PathId(year): PathId, ) -> Result { application::goals::delete::execute( &state.app_ctx.deps.goals.command, @@ -118,14 +115,14 @@ pub async fn delete_goal( get, path = "/api/v1/users/{id}/goals", responses( (status = 200, body = GoalsResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_user_goals( State(state): State, AuthenticatedUser(_viewer): AuthenticatedUser, - Path(user_id): Path, + PathId(user_id): PathId, ) -> Result, ApiError> { let goals = application::goals::list::execute( &state.app_ctx.deps.goals.query, @@ -141,7 +138,7 @@ pub async fn get_user_goals( get, path = "/api/v1/settings", responses( (status = 200, body = UserSettingsDto), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -166,14 +163,14 @@ pub async fn get_settings( request_body = UpdateUserSettingsRequest, responses( (status = 204, description = "Settings updated"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn update_settings( State(state): State, user: AuthenticatedUser, - Json(req): Json, + Body(req): Body, ) -> Result { application::users::update_settings::execute( &state.app_ctx.deps.users.update_settings, diff --git a/crates/adapters/http-axum/src/api/import/profiles.rs b/crates/adapters/http-axum/src/api/import/profiles.rs index 9ede9328..4b631f6c 100644 --- a/crates/adapters/http-axum/src/api/import/profiles.rs +++ b/crates/adapters/http-axum/src/api/import/profiles.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Body, PathId}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::SaveProfileRequest; use application::import::{ @@ -5,11 +6,7 @@ use application::import::{ delete_profile as delete_import_profile, list_profiles as list_import_profiles, save_profile as save_import_profile, }; -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{extract::State, http::StatusCode, response::IntoResponse}; use domain::errors::DomainError; use domain::value_objects::ImportSessionId; @@ -17,7 +14,7 @@ use domain::value_objects::ImportSessionId; get, path = "/api/v1/import/profiles", responses( (status = 200, description = "List of saved import profiles", body = inline(Vec)), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -46,15 +43,15 @@ pub async fn api_get_profiles( request_body = SaveProfileRequest, responses( (status = 200, description = "Profile saved", body = inline(serde_json::Value)), - (status = 401, description = "Unauthorized"), - (status = 422, description = "Session has no mapping yet"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 422, description = "Session has no mapping yet", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn api_post_profile( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - axum::Json(body): axum::Json, + Body(body): Body, ) -> Result { let session_id = body .session_id @@ -80,15 +77,15 @@ pub async fn api_post_profile( params(("id" = String, Path, description = "Import profile UUID")), responses( (status = 204, description = "Deleted"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Profile not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Profile not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn api_delete_profile( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path(profile_id_str): Path, + PathId(profile_id_str): PathId, ) -> Result { let profile_id = profile_id_str .parse::() diff --git a/crates/adapters/http-axum/src/api/import/sessions.rs b/crates/adapters/http-axum/src/api/import/sessions.rs index 96566246..0340939e 100644 --- a/crates/adapters/http-axum/src/api/import/sessions.rs +++ b/crates/adapters/http-axum/src/api/import/sessions.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Body, PathId}; use crate::support::str_to_domain_field; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ @@ -14,7 +15,7 @@ use application::import::{ get_session_state, }; use axum::{ - extract::{Multipart, Path, State}, + extract::{Multipart, State}, response::IntoResponse, }; use domain::errors::DomainError; @@ -29,9 +30,9 @@ use domain::value_objects::ImportSessionId; request_body(content_type = "multipart/form-data", description = "file (binary) + format (csv|json|xlsx)"), responses( (status = 200, body = SessionCreatedResponse), - (status = 400, description = "No file provided"), - (status = 401, description = "Unauthorized"), - (status = 422, description = "Parse error"), + (status = 400, description = "No file provided", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 422, description = "Parse error", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -87,15 +88,15 @@ pub async fn api_post_session( params(("id" = String, Path, description = "Import session UUID")), responses( (status = 200, body = SessionStateResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Session not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Session not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn api_get_session( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, + PathId(session_id_str): PathId, ) -> Result { let session_id = session_id_str .parse::() @@ -121,16 +122,16 @@ pub async fn api_get_session( request_body = ApplyMappingRequest, responses( (status = 200, description = "Mapping applied", body = inline(serde_json::Value)), - (status = 401, description = "Unauthorized"), - (status = 422, description = "Mapping error"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 422, description = "Mapping error", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn api_put_mapping( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, - axum::Json(body): axum::Json, + PathId(session_id_str): PathId, + Body(body): Body, ) -> Result { let session_id = session_id_str .parse::() @@ -173,7 +174,7 @@ pub async fn api_put_mapping( pub async fn api_get_preview( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, + PathId(session_id_str): PathId, ) -> Result, ApiError> { let session_id = session_id_str .parse::() @@ -238,16 +239,16 @@ pub async fn api_get_preview( request_body = ConfirmRequest, responses( (status = 200, description = "Import summary", body = inline(serde_json::Value)), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Session not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Session not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn api_post_confirm( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, - axum::Json(body): axum::Json, + PathId(session_id_str): PathId, + Body(body): Body, ) -> Result { let session_id = session_id_str .parse::() @@ -277,17 +278,17 @@ pub async fn api_post_confirm( ), responses( (status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)), - (status = 400, description = "Invalid ID"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Session or profile not found"), - (status = 422, description = "Mapping error"), + (status = 400, description = "Invalid ID", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Session or profile not found", body = api_types::ErrorResponse), + (status = 422, description = "Mapping error", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn api_apply_profile( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path((session_id_str, profile_id_str)): Path<(String, String)>, + PathId((session_id_str, profile_id_str)): PathId<(String, String)>, ) -> Result { let session_id = session_id_str .parse::() diff --git a/crates/adapters/http-axum/src/api/insights.rs b/crates/adapters/http-axum/src/api/insights.rs index 61211321..918b6c66 100644 --- a/crates/adapters/http-axum/src/api/insights.rs +++ b/crates/adapters/http-axum/src/api/insights.rs @@ -1,10 +1,8 @@ +use crate::extractors::Params; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::insights::{InsightDto, InsightsQueryParams, InsightsResponse}; use application::insights::get as get_insights_uc; -use axum::{ - Json, - extract::{Query, State}, -}; +use axum::{Json, extract::State}; use domain::{errors::DomainError, models::Insight}; fn to_dto(insight: Insight) -> InsightDto { @@ -68,15 +66,15 @@ fn to_dto(insight: Insight) -> InsightDto { params(InsightsQueryParams), responses( (status = 200, body = InsightsResponse), - (status = 400, description = "Unknown time zone"), - (status = 401, description = "Unauthorized"), + (status = 400, description = "Unknown time zone", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_insights( State(state): State, user: AuthenticatedUser, - Query(params): Query, + Params(params): Params, ) -> Result, ApiError> { let tz: chrono_tz::Tz = match params.tz.as_deref() { None => chrono_tz::UTC, diff --git a/crates/adapters/http-axum/src/api/movies.rs b/crates/adapters/http-axum/src/api/movies.rs index fd893267..b7b50d3a 100644 --- a/crates/adapters/http-axum/src/api/movies.rs +++ b/crates/adapters/http-axum/src/api/movies.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Params, PathId}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ CastMemberDto, CrewMemberDto, GenreDto, KeywordDto, MovieDetailResponse, MovieProfileResponse, @@ -12,12 +13,7 @@ use application::{ }, movies::{get_movies, queries::GetMoviesQuery, sync_poster}, }; -use axum::{ - Json, - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use domain::services::review_history::Trend; use uuid::Uuid; @@ -26,11 +22,12 @@ use uuid::Uuid; params(MoviesQueryParams), responses( (status = 200, body = MoviesResponse), + (status = 400, description = "Invalid query", body = api_types::ErrorResponse), ) )] pub async fn list_movies( State(state): State, - Query(params): Query, + Params(params): Params, ) -> Result, ApiError> { let page = get_movies::execute( &state.app_ctx.deps.movies.get_movies, @@ -61,12 +58,12 @@ pub async fn list_movies( params(("id" = Uuid, Path, description = "Movie ID")), responses( (status = 200, body = ReviewHistoryResponse), - (status = 404, description = "Movie not found"), + (status = 404, description = "Movie not found", body = api_types::ErrorResponse), ) )] pub async fn get_review_history( State(state): State, - Path(movie_id): Path, + PathId(movie_id): PathId, ) -> Result, ApiError> { let (history, trend) = get_review_history::execute( &state.app_ctx.deps.diary.get_review_history, @@ -95,15 +92,15 @@ pub async fn get_review_history( params(("id" = Uuid, Path, description = "Movie ID")), responses( (status = 204, description = "Poster synced"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Movie not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Movie not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn sync_poster( State(state): State, _user: AuthenticatedUser, - Path(movie_id): Path, + PathId(movie_id): PathId, ) -> Result { sync_poster::execute( &state.app_ctx.deps.movies.sync_poster, @@ -118,13 +115,13 @@ pub async fn sync_poster( params(("movie_id" = Uuid, Path, description = "Movie ID")), responses( (status = 200, body = MovieDetailResponse), - (status = 404, description = "Movie not found"), + (status = 404, description = "Movie not found", body = api_types::ErrorResponse), ) )] pub async fn get_movie_detail( State(state): State, - Path(movie_id): Path, - Query(params): Query, + PathId(movie_id): PathId, + Params(params): Params, ) -> Result, ApiError> { let limit = params.limit.unwrap_or(20); let offset = params.offset.unwrap_or(0); @@ -175,12 +172,12 @@ pub async fn get_movie_detail( params(("id" = Uuid, Path, description = "Movie ID")), responses( (status = 200, body = MovieProfileResponse), - (status = 404, description = "No profile found for this movie"), + (status = 404, description = "No profile found for this movie", body = api_types::ErrorResponse), ) )] pub async fn get_movie_profile( State(state): State, - Path(movie_id): Path, + PathId(movie_id): PathId, ) -> impl IntoResponse { use application::movies::get_movie_profile; let query = get_movie_profile::GetMovieProfileQuery { movie_id }; @@ -243,7 +240,9 @@ pub async fn get_movie_profile( }) .into_response() } - Ok(None) => StatusCode::NOT_FOUND.into_response(), - Err(e) => crate::errors::domain_error_response(e), + Ok(None) => { + crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found") + } + Err(e) => crate::errors::ApiError(e).into_response(), } } diff --git a/crates/adapters/http-axum/src/api/search.rs b/crates/adapters/http-axum/src/api/search.rs index 63da0f06..e0c28512 100644 --- a/crates/adapters/http-axum/src/api/search.rs +++ b/crates/adapters/http-axum/src/api/search.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Params, PathId}; use crate::state::AppState; use api_types::search::{ CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits, @@ -7,11 +8,7 @@ use application::{ person::{get as get_person, get_credits as get_person_credits}, search::execute as search_uc, }; -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{extract::State, http::StatusCode, response::IntoResponse}; use domain::models::{PersonId, collections::PageParams}; #[utoipa::path( @@ -19,12 +16,13 @@ use domain::models::{PersonId, collections::PageParams}; params(api_types::search::SearchQueryParams), responses( (status = 200, body = api_types::search::SearchResponse), + (status = 400, description = "Invalid query", body = api_types::ErrorResponse), ), tag = "search", )] pub async fn get_search( State(state): State, - Query(params): Query, + Params(params): Params, ) -> impl IntoResponse { let query = domain::models::SearchQuery { text: params.q, @@ -80,7 +78,7 @@ pub async fn get_search( }, }) .into_response(), - Err(e) => crate::errors::domain_error_response(e), + Err(e) => crate::errors::ApiError(e).into_response(), } } @@ -89,13 +87,13 @@ pub async fn get_search( params(("id" = Uuid, Path, description = "Person ID")), responses( (status = 200, body = api_types::search::PersonDto), - (status = 404, description = "Person not found"), + (status = 404, description = "Person not found", body = api_types::ErrorResponse), ), tag = "search", )] pub async fn get_person_handler( State(state): State, - Path(id): Path, + PathId(id): PathId, ) -> impl IntoResponse { match get_person::execute( &state.app_ctx.deps.person.get_person, @@ -104,8 +102,10 @@ pub async fn get_person_handler( .await { Ok(Some(person)) => axum::Json(api_types::mappers::person_to_dto(&person)).into_response(), - Ok(None) => StatusCode::NOT_FOUND.into_response(), - Err(e) => crate::errors::domain_error_response(e), + Ok(None) => { + crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found") + } + Err(e) => crate::errors::ApiError(e).into_response(), } } @@ -114,13 +114,13 @@ pub async fn get_person_handler( params(("id" = Uuid, Path, description = "Person ID")), responses( (status = 200, body = api_types::search::PersonCreditsDto), - (status = 404, description = "Person not found"), + (status = 404, description = "Person not found", body = api_types::ErrorResponse), ), tag = "search", )] pub async fn get_person_credits_handler( State(state): State, - Path(id): Path, + PathId(id): PathId, ) -> impl IntoResponse { match get_person_credits::execute( &state.app_ctx.deps.person.get_person, @@ -155,7 +155,7 @@ pub async fn get_person_credits_handler( .collect(), }) .into_response(), - Err(e) => crate::errors::domain_error_response(e), + Err(e) => crate::errors::ApiError(e).into_response(), } } @@ -165,10 +165,14 @@ pub async fn post_reindex_search( ) -> impl IntoResponse { let event = domain::events::DomainEvent::SearchReindexRequested; match state.app_ctx.services.event_publisher.publish(&event).await { - Ok(()) => StatusCode::ACCEPTED, + Ok(()) => StatusCode::ACCEPTED.into_response(), Err(e) => { tracing::error!("failed to publish reindex event: {:?}", e); - StatusCode::INTERNAL_SERVER_ERROR + crate::errors::refuse( + StatusCode::INTERNAL_SERVER_ERROR, + crate::errors::INTERNAL, + "internal server error", + ) } } } diff --git a/crates/adapters/http-axum/src/api/social.rs b/crates/adapters/http-axum/src/api/social.rs index be1c54ac..02cf5bb3 100644 --- a/crates/adapters/http-axum/src/api/social.rs +++ b/crates/adapters/http-axum/src/api/social.rs @@ -1,14 +1,10 @@ +use crate::extractors::{Body, Params, PathId}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ ActorListResponse, ActorUrlRequest, FollowRelationResponse, FollowRequest, PendingCountResponse, RemoteActorDto, }; -use axum::{ - Json, - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor, SocialIdentity}; use uuid::Uuid; @@ -29,7 +25,7 @@ fn social_actor_to_dto(actor: SocialActor, instance: &InstanceIdentity) -> Remot get, path = "/api/v1/social/following", responses( (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -55,7 +51,7 @@ pub async fn get_following( get, path = "/api/v1/social/followers", responses( (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -80,7 +76,7 @@ pub async fn get_followers( pub async fn get_user_following( State(state): State, _user: AuthenticatedUser, - Path(user_id): Path, + PathId(user_id): PathId, ) -> Result, ApiError> { let instance = state.app_ctx.instance.clone(); let identities = @@ -97,7 +93,7 @@ pub async fn get_user_following( pub async fn get_user_followers( State(state): State, _user: AuthenticatedUser, - Path(user_id): Path, + PathId(user_id): PathId, ) -> Result, ApiError> { let instance = state.app_ctx.instance.clone(); let identities = @@ -116,14 +112,14 @@ pub async fn get_user_followers( request_body = FollowRequest, responses( (status = 200, description = "Follow request sent"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn follow( State(state): State, user: AuthenticatedUser, - Json(body): Json, + Body(body): Body, ) -> Result { application::social::execute::execute_command( &state.app_ctx.deps.social.command, @@ -141,14 +137,14 @@ pub async fn follow( request_body = ActorUrlRequest, responses( (status = 200, description = "Unfollowed"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn unfollow( State(state): State, user: AuthenticatedUser, - Json(body): Json, + Body(body): Body, ) -> Result { let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( @@ -167,14 +163,14 @@ pub async fn unfollow( request_body = ActorUrlRequest, responses( (status = 200, description = "Follower accepted"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn accept_follower( State(state): State, user: AuthenticatedUser, - Json(body): Json, + Body(body): Body, ) -> Result { let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( @@ -193,14 +189,14 @@ pub async fn accept_follower( request_body = ActorUrlRequest, responses( (status = 200, description = "Follower rejected"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn reject_follower( State(state): State, user: AuthenticatedUser, - Json(body): Json, + Body(body): Body, ) -> Result { let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( @@ -219,14 +215,14 @@ pub async fn reject_follower( request_body = ActorUrlRequest, responses( (status = 200, description = "Follower removed"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn remove_follower( State(state): State, user: AuthenticatedUser, - Json(body): Json, + Body(body): Body, ) -> Result { let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( @@ -244,7 +240,7 @@ pub async fn remove_follower( get, path = "/api/v1/social/followers/pending", responses( (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -270,7 +266,7 @@ pub async fn get_pending_followers( get, path = "/api/v1/social/followers/pending/count", responses( (status = 200, body = PendingCountResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -290,7 +286,7 @@ pub async fn get_pending_follower_count( get, path = "/api/v1/social/following/pending", responses( (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -322,14 +318,14 @@ pub struct RelationshipQuery { params(("actor_url" = String, Query, description = "Canonical actor URL of the target")), responses( (status = 200, body = FollowRelationResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_relationship( State(state): State, user: AuthenticatedUser, - Query(q): Query, + Params(q): Params, ) -> Result, ApiError> { let target = state.app_ctx.instance.identify(&q.actor_url); let rel = application::social::get_relation::execute( diff --git a/crates/adapters/http-axum/src/api/user_profile.rs b/crates/adapters/http-axum/src/api/user_profile.rs index 6be466c7..c12874ef 100644 --- a/crates/adapters/http-axum/src/api/user_profile.rs +++ b/crates/adapters/http-axum/src/api/user_profile.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Params, PathId}; use crate::{extractors::AuthenticatedUser, state::AppState}; use api_types::mappers::goal_with_progress_to_dto; use api_types::{ @@ -8,12 +9,7 @@ use application::users::{ get_federated_profile, get_federated_profile_stats, get_local_profile, queries::GetUserProfileQuery, }; -use axum::{ - Json, - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use domain::errors::DomainError; use std::str::FromStr; use uuid::Uuid; @@ -26,19 +22,25 @@ use uuid::Uuid; ), responses( (status = 200, body = UserProfileResponse), - (status = 404, description = "User not found"), + (status = 404, description = "User not found", body = api_types::ErrorResponse), ) )] pub async fn get_user_profile( State(state): State, AuthenticatedUser(viewer_id): AuthenticatedUser, - Path(user_id): Path, - Query(params): Query, + PathId(user_id): PathId, + Params(params): Params, ) -> impl IntoResponse { let view_str = params.view.as_deref().unwrap_or("recent"); let profile_view = match application::users::queries::ProfileView::from_str(view_str) { Ok(v) => v, - Err(_) => return StatusCode::BAD_REQUEST.into_response(), + Err(_) => { + return crate::errors::refuse( + StatusCode::BAD_REQUEST, + crate::errors::INVALID_INPUT, + "invalid profile view", + ); + } }; let profile = match get_local_profile::execute( @@ -73,9 +75,13 @@ pub async fn get_user_profile( ) .await; } - return StatusCode::NOT_FOUND.into_response(); + return crate::errors::refuse( + StatusCode::NOT_FOUND, + crate::errors::NOT_FOUND, + "not found", + ); } - Err(e) => return crate::errors::domain_error_response(e), + Err(e) => return crate::errors::ApiError(e).into_response(), }; let view_data = if let Some(p) = profile.entries { @@ -174,7 +180,7 @@ async fn build_federated_profile_response( .await { Ok(p) => p, - Err(e) => return crate::errors::domain_error_response(e), + Err(e) => return crate::errors::ApiError(e).into_response(), }; let view_data = if let Some(p) = profile.entries { diff --git a/crates/adapters/http-axum/src/api/users.rs b/crates/adapters/http-axum/src/api/users.rs index 71589032..744c4283 100644 --- a/crates/adapters/http-axum/src/api/users.rs +++ b/crates/adapters/http-axum/src/api/users.rs @@ -1,3 +1,4 @@ +use crate::extractors::Body; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ProfileResponse, UserSummaryDto, UsersResponse}; use application::users::{ @@ -14,8 +15,8 @@ use axum::{ get, path = "/api/v1/profile", responses( (status = 200, body = ProfileResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "User not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "User not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -56,9 +57,9 @@ pub async fn get_profile( put, path = "/api/v1/profile", responses( (status = 204, description = "Profile updated"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error"), + (status = 400, description = "Invalid input", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 500, description = "Internal server error", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -82,7 +83,7 @@ pub async fn update_profile_handler( match update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => crate::errors::domain_error_response(e), + Err(e) => crate::errors::ApiError(e).into_response(), } } @@ -91,20 +92,26 @@ pub async fn update_profile_handler( request_body = api_types::UpdateProfileFieldsRequest, responses( (status = 204, description = "Profile fields updated"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error"), + (status = 400, description = "Invalid input", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 500, description = "Internal server error", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn update_profile_fields_handler( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - axum::Json(body): axum::Json, + Body(body): Body, ) -> impl IntoResponse { let raw_fields = match body.get("fields").and_then(|f| f.as_array()) { Some(arr) => arr.clone(), - None => return StatusCode::BAD_REQUEST.into_response(), + None => { + return crate::errors::refuse( + StatusCode::BAD_REQUEST, + crate::errors::INVALID_INPUT, + "body must contain a fields array", + ); + } }; let fields: Vec = raw_fields @@ -124,13 +131,16 @@ pub async fn update_profile_fields_handler( match update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, cmd).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => crate::errors::domain_error_response(e), + Err(e) => crate::errors::ApiError(e).into_response(), } } #[utoipa::path( get, path = "/api/v1/users", - responses((status = 200, body = UsersResponse)), + responses( + (status = 200, body = UsersResponse), + (status = 429, description = "Rate limited", body = api_types::ErrorResponse), + ), )] pub async fn list_users(State(state): State) -> Result, ApiError> { let result = diff --git a/crates/adapters/http-axum/src/api/watchlist.rs b/crates/adapters/http-axum/src/api/watchlist.rs index 7753d75c..18a24234 100644 --- a/crates/adapters/http-axum/src/api/watchlist.rs +++ b/crates/adapters/http-axum/src/api/watchlist.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Body, Params, PathId}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ AddToWatchlistRequest, PaginationQueryParams, WatchlistEntryDto, WatchlistResponse, @@ -13,12 +14,7 @@ use application::{ remove as remove_from_watchlist, }, }; -use axum::{ - Json, - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use uuid::Uuid; #[utoipa::path( @@ -29,14 +25,14 @@ use uuid::Uuid; ), responses( (status = 200, body = WatchlistResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_watchlist_handler( State(state): State, user: AuthenticatedUser, - Query(params): Query, + Params(params): Params, ) -> Result, ApiError> { let page = get_watchlist::execute( &state.app_ctx.deps.watchlist.get_watchlist, @@ -69,15 +65,15 @@ pub async fn get_watchlist_handler( request_body = AddToWatchlistRequest, responses( (status = 201, description = "Added to watchlist"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Movie not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Movie not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_watchlist_add( State(state): State, user: AuthenticatedUser, - Json(req): Json, + Body(req): Body, ) -> Result { add_to_watchlist::execute( &state.app_ctx.deps.watchlist.add, @@ -101,15 +97,15 @@ pub async fn post_watchlist_add( params(("movie_id" = Uuid, Path, description = "Movie ID")), responses( (status = 204, description = "Removed from watchlist"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Not on watchlist"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Not on watchlist", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn delete_watchlist_entry( State(state): State, user: AuthenticatedUser, - Path(movie_id): Path, + PathId(movie_id): PathId, ) -> Result { remove_from_watchlist::execute( &state.app_ctx.deps.watchlist.remove_from_watchlist, @@ -127,14 +123,14 @@ pub async fn delete_watchlist_entry( params(("movie_id" = Uuid, Path, description = "Movie ID")), responses( (status = 200, body = WatchlistStatusResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_watchlist_status( State(state): State, user: AuthenticatedUser, - Path(movie_id): Path, + PathId(movie_id): PathId, ) -> Result, ApiError> { let on_watchlist = is_on_watchlist::execute( &state.app_ctx.deps.watchlist.is_on_watchlist, diff --git a/crates/adapters/http-axum/src/api/webhook.rs b/crates/adapters/http-axum/src/api/webhook.rs index d0a52818..f8a43ab8 100644 --- a/crates/adapters/http-axum/src/api/webhook.rs +++ b/crates/adapters/http-axum/src/api/webhook.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Body, Params, PathId}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse, @@ -16,7 +17,7 @@ use application::integrations::{ }; use axum::{ Json, - extract::{Multipart, Path, State}, + extract::{Multipart, State}, http::{HeaderMap, StatusCode}, response::IntoResponse, }; @@ -36,6 +37,14 @@ pub struct WebhookTokenQuery { pub token: Option, } +fn missing_token() -> axum::response::Response { + crate::errors::refuse( + StatusCode::UNAUTHORIZED, + crate::errors::UNAUTHORIZED, + "missing webhook token", + ) +} + fn extract_webhook_token(headers: &HeaderMap, query: &WebhookTokenQuery) -> Option { extract_bearer_token(headers).or_else(|| query.token.clone()) } @@ -45,20 +54,20 @@ fn extract_webhook_token(headers: &HeaderMap, query: &WebhookTokenQuery) -> Opti request_body(content = String, description = "Jellyfin webhook JSON payload (SendAllProperties=true)", content_type = "application/json"), responses( (status = 200, description = "Event accepted or ignored"), - (status = 400, description = "Invalid payload"), - (status = 401, description = "Invalid or missing webhook token"), + (status = 400, description = "Invalid payload", body = api_types::ErrorResponse), + (status = 401, description = "Invalid or missing webhook token", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_jellyfin_webhook( State(state): State, headers: HeaderMap, - axum::extract::Query(query): axum::extract::Query, + Params(query): Params, body: axum::body::Bytes, ) -> impl IntoResponse { let token = match extract_webhook_token(&headers, &query) { Some(t) => t, - None => return StatusCode::UNAUTHORIZED, + None => return missing_token(), }; let cmd = IngestWatchEventCommand { @@ -80,20 +89,20 @@ pub async fn post_jellyfin_webhook( request_body(content = String, description = "Plex webhook multipart form (payload field contains JSON)", content_type = "multipart/form-data"), responses( (status = 200, description = "Event accepted or ignored"), - (status = 400, description = "Invalid payload"), - (status = 401, description = "Invalid or missing webhook token"), + (status = 400, description = "Invalid payload", body = api_types::ErrorResponse), + (status = 401, description = "Invalid or missing webhook token", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_plex_webhook( State(state): State, headers: HeaderMap, - axum::extract::Query(query): axum::extract::Query, + Params(query): Params, mut multipart: Multipart, ) -> impl IntoResponse { let token = match extract_webhook_token(&headers, &query) { Some(t) => t, - None => return StatusCode::UNAUTHORIZED, + None => return missing_token(), }; let mut payload_bytes: Option> = None; @@ -108,7 +117,13 @@ pub async fn post_plex_webhook( let raw_payload = match payload_bytes { Some(b) => b, - None => return StatusCode::BAD_REQUEST, + None => { + return crate::errors::refuse( + StatusCode::BAD_REQUEST, + crate::errors::INVALID_INPUT, + "multipart form has no payload field", + ); + } }; let cmd = IngestWatchEventCommand { @@ -124,7 +139,7 @@ async fn run_ingest( state: &AppState, cmd: IngestWatchEventCommand, parser: &dyn domain::ports::MediaServerParser, -) -> StatusCode { +) -> axum::response::Response { match ingest_watch_event::execute( &state.app_ctx.deps.integrations.ingest_watch_event, cmd, @@ -132,8 +147,8 @@ async fn run_ingest( ) .await { - Ok(()) => StatusCode::OK, - Err(e) => crate::errors::domain_error_status(&e), + Ok(()) => StatusCode::OK.into_response(), + Err(e) => crate::errors::ApiError(e).into_response(), } } @@ -142,14 +157,14 @@ async fn run_ingest( request_body = GenerateTokenRequest, responses( (status = 200, body = GenerateTokenResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_generate_webhook_token( State(state): State, user: AuthenticatedUser, - Json(req): Json, + Body(req): Body, ) -> Result, ApiError> { let provider: WatchEventSource = req .provider @@ -182,7 +197,7 @@ pub async fn post_generate_webhook_token( get, path = "/api/v1/settings/webhook-tokens", responses( (status = 200, body = Vec), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -216,15 +231,15 @@ pub async fn get_webhook_tokens( params(("id" = Uuid, Path, description = "Webhook token ID")), responses( (status = 204, description = "Token revoked"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Token not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Token not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn delete_webhook_token( State(state): State, user: AuthenticatedUser, - Path(id): Path, + PathId(id): PathId, ) -> Result { let cmd = RevokeWebhookTokenCommand { user_id: user.0.value(), @@ -239,7 +254,7 @@ pub async fn delete_webhook_token( get, path = "/api/v1/watch-queue", responses( (status = 200, body = Vec), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -273,15 +288,15 @@ pub async fn get_watch_queue( request_body = ConfirmWatchRequest, responses( (status = 200, body = ConfirmWatchResponse), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden — not your watch event"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden — not your watch event", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_confirm_watch_events( State(state): State, user: AuthenticatedUser, - Json(req): Json, + Body(req): Body, ) -> Result, ApiError> { let cmd = ConfirmWatchEventsCommand { user_id: user.0.value(), @@ -307,15 +322,15 @@ pub async fn post_confirm_watch_events( request_body = DismissWatchRequest, responses( (status = 200, body = DismissWatchResponse), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden — not your watch event"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden — not your watch event", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_dismiss_watch_events( State(state): State, user: AuthenticatedUser, - Json(req): Json, + Body(req): Body, ) -> Result, ApiError> { let cmd = DismissWatchEventsCommand { user_id: user.0.value(), diff --git a/crates/adapters/http-axum/src/api/wrapup.rs b/crates/adapters/http-axum/src/api/wrapup.rs index b17b1af2..00ba1b42 100644 --- a/crates/adapters/http-axum/src/api/wrapup.rs +++ b/crates/adapters/http-axum/src/api/wrapup.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Body, PathId}; use crate::{ errors::ApiError, extractors::{AdminApiUser, AuthenticatedUser}, @@ -11,12 +12,7 @@ use application::wrapup::{ delete as delete_wrapup, generate, get_wrapup, list_wrapups::{self, ListWrapUpsQuery}, }; -use axum::{ - Json, - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use chrono::NaiveDate; use domain::errors::DomainError; use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; @@ -41,16 +37,16 @@ fn record_to_dto(r: &WrapUpRecord) -> WrapUpStatusResponse { request_body = GenerateWrapUpRequest, responses( (status = 200, body = WrapUpGeneratedResponse), - (status = 400, description = "Invalid date format"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden — admin only"), + (status = 400, description = "Invalid date format", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden — admin only", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_generate( State(state): State, _admin: AdminApiUser, - Json(req): Json, + Body(req): Body, ) -> Result, ApiError> { let start = NaiveDate::parse_from_str(&req.start_date, "%Y-%m-%d") .map_err(|_| DomainError::ValidationError("invalid start_date".into()))?; @@ -72,7 +68,7 @@ pub async fn post_generate( get, path = "/api/v1/wrapups", responses( (status = 200, body = WrapUpListResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -97,15 +93,15 @@ pub async fn get_list( params(("id" = Uuid, Path, description = "Wrap-up ID")), responses( (status = 200, body = WrapUpStatusResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_status( State(state): State, _user: AuthenticatedUser, - Path(id): Path, + PathId(id): PathId, ) -> Result, ApiError> { let record = get_wrapup::execute( &state.app_ctx.deps.wrapup.get_wrapup, @@ -122,15 +118,15 @@ pub async fn get_status( responses( (status = 200, description = "Report JSON", content_type = "application/json"), (status = 202, description = "Still generating"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_report( State(state): State, _user: AuthenticatedUser, - Path(id): Path, + PathId(id): PathId, ) -> impl IntoResponse { match get_wrapup::execute( &state.app_ctx.deps.wrapup.get_wrapup, @@ -143,11 +139,15 @@ pub async fn get_report( let json = serde_json::to_string(report).unwrap_or_default(); (StatusCode::OK, [("content-type", "application/json")], json).into_response() } - None => StatusCode::NOT_FOUND.into_response(), + None => { + crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found") + } }, Ok(Some(_)) => StatusCode::ACCEPTED.into_response(), - Ok(None) => StatusCode::NOT_FOUND.into_response(), - Err(e) => crate::errors::domain_error_response(e), + Ok(None) => { + crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found") + } + Err(e) => crate::errors::ApiError(e).into_response(), } } @@ -156,16 +156,16 @@ pub async fn get_report( params(("id" = Uuid, Path, description = "Wrap-up ID")), responses( (status = 204, description = "Deleted"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden — admin only"), - (status = 404, description = "Not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden — admin only", body = api_types::ErrorResponse), + (status = 404, description = "Not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn delete_wrapup_handler( State(state): State, _admin: AdminApiUser, - Path(id): Path, + PathId(id): PathId, ) -> Result { delete_wrapup::execute( &state.app_ctx.deps.wrapup.delete_wrapup, diff --git a/crates/adapters/http-axum/src/errors.rs b/crates/adapters/http-axum/src/errors.rs index da5e6f5c..a212053a 100644 --- a/crates/adapters/http-axum/src/errors.rs +++ b/crates/adapters/http-axum/src/errors.rs @@ -1,9 +1,30 @@ +use api_types::ErrorResponse; use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; use domain::errors::DomainError; +pub const NOT_FOUND: &str = "NOT_FOUND"; +pub const INVALID_INPUT: &str = "INVALID_INPUT"; +pub const CONFLICT: &str = "CONFLICT"; +pub const UNAUTHORIZED: &str = "UNAUTHORIZED"; +pub const FORBIDDEN: &str = "FORBIDDEN"; +pub const RATE_LIMITED: &str = "RATE_LIMITED"; +pub const INTERNAL: &str = "INTERNAL"; + +/// The only constructor of JSON error bodies in this crate. +pub fn refuse(status: StatusCode, code: &'static str, message: impl Into) -> Response { + ( + status, + axum::Json(ErrorResponse { + error: message.into(), + code: code.to_string(), + }), + ) + .into_response() +} + pub fn domain_error_status(e: &DomainError) -> StatusCode { match e { DomainError::InvalidRating { .. } | DomainError::ValidationError(_) => { @@ -38,19 +59,16 @@ impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = domain_error_status(&self.0); match &self.0 { + DomainError::InvalidRating { .. } | DomainError::ValidationError(_) => { + refuse(status, INVALID_INPUT, self.0.to_string()) + } + DomainError::NotFound(_) => refuse(status, NOT_FOUND, self.0.to_string()), + DomainError::Unauthorized(_) => refuse(status, UNAUTHORIZED, self.0.to_string()), + DomainError::Forbidden(_) => refuse(status, FORBIDDEN, self.0.to_string()), DomainError::InfrastructureError(_) => { tracing::error!("Internal error: {:?}", self.0); - ( - status, - axum::Json(serde_json::json!({"error": "internal server error"})), - ) - .into_response() + refuse(status, INTERNAL, "internal server error") } - _ => ( - status, - axum::Json(serde_json::json!({"error": self.0.to_string()})), - ) - .into_response(), } } } diff --git a/crates/adapters/http-axum/src/extractors/json.rs b/crates/adapters/http-axum/src/extractors/json.rs new file mode 100644 index 00000000..08114170 --- /dev/null +++ b/crates/adapters/http-axum/src/extractors/json.rs @@ -0,0 +1,101 @@ +//! JSON API extractors whose rejections are refused in the one error shape. + +use axum::{ + extract::{ + FromRequest, FromRequestParts, Path, Query, Request, + rejection::{JsonRejection, PathRejection, QueryRejection}, + }, + http::{StatusCode, request::Parts}, + response::{IntoResponse, Response}, +}; +use serde::de::DeserializeOwned; + +use crate::errors::{INVALID_INPUT, refuse}; + +/// Malformed request (bad JSON, query or path), refused as `INVALID_INPUT`. +/// Status follows axum: 400 syntax, 415 content type, 422 data errors, 413 size. +pub struct MalformedRequest { + status: StatusCode, + message: String, +} + +impl IntoResponse for MalformedRequest { + fn into_response(self) -> Response { + refuse(self.status, INVALID_INPUT, self.message) + } +} + +impl From for MalformedRequest { + fn from(r: JsonRejection) -> Self { + Self { + status: r.status(), + message: r.body_text(), + } + } +} + +impl From for MalformedRequest { + fn from(r: QueryRejection) -> Self { + Self { + status: r.status(), + message: r.body_text(), + } + } +} + +impl From for MalformedRequest { + fn from(r: PathRejection) -> Self { + Self { + status: r.status(), + message: r.body_text(), + } + } +} + +/// `axum::Json` with refusals in the one error shape. +pub struct Body(pub T); + +impl FromRequest for Body +where + S: Send + Sync, + T: DeserializeOwned, +{ + type Rejection = MalformedRequest; + + async fn from_request(req: Request, state: &S) -> Result { + let axum::Json(v) = axum::Json::::from_request(req, state).await?; + Ok(Self(v)) + } +} + +/// `axum::extract::Query` with refusals in the one error shape. +pub struct Params(pub T); + +impl FromRequestParts for Params +where + S: Send + Sync, + T: DeserializeOwned, +{ + type Rejection = MalformedRequest; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let Query(v) = Query::::from_request_parts(parts, state).await?; + Ok(Self(v)) + } +} + +/// `axum::extract::Path` with refusals in the one error shape. +pub struct PathId(pub T); + +impl FromRequestParts for PathId +where + S: Send + Sync, + T: DeserializeOwned + Send, +{ + type Rejection = MalformedRequest; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let Path(v) = Path::::from_request_parts(parts, state).await?; + Ok(Self(v)) + } +} diff --git a/crates/adapters/http-axum/src/extractors/mod.rs b/crates/adapters/http-axum/src/extractors/mod.rs index 392ee65b..9ac5cc89 100644 --- a/crates/adapters/http-axum/src/extractors/mod.rs +++ b/crates/adapters/http-axum/src/extractors/mod.rs @@ -1,3 +1,6 @@ +mod json; +pub use json::{Body, MalformedRequest, Params, PathId}; + use axum::{ extract::{FromRef, FromRequestParts}, http::{StatusCode, header, header::AUTHORIZATION, request::Parts}, diff --git a/crates/adapters/http-axum/src/federation/blocklist_api.rs b/crates/adapters/http-axum/src/federation/blocklist_api.rs index 7f213985..6d7a950c 100644 --- a/crates/adapters/http-axum/src/federation/blocklist_api.rs +++ b/crates/adapters/http-axum/src/federation/blocklist_api.rs @@ -1,4 +1,5 @@ use crate::extractors::AdminApiUser; +use crate::extractors::{Body, PathId}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::ActorUrlRequest; use api_types::{AddBlockedDomainRequest, BlockedActorResponse, BlockedDomainResponse}; @@ -21,8 +22,8 @@ fn social_actor_to_blocked_dto( get, path = "/api/v1/admin/blocked-domains", responses( (status = 200, body = Vec), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - admin only"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -48,15 +49,15 @@ pub async fn get_blocked_domains_admin( request_body = AddBlockedDomainRequest, responses( (status = 201, description = "Domain blocked"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - admin only"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn add_blocked_domain_admin( State(state): State, _admin: AdminApiUser, - axum::Json(body): axum::Json, + Body(body): Body, ) -> Result { state .app_ctx @@ -71,15 +72,15 @@ pub async fn add_blocked_domain_admin( params(("domain" = String, Path, description = "Domain to unblock")), responses( (status = 204, description = "Domain unblocked"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - admin only"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn remove_blocked_domain_admin( State(state): State, _admin: AdminApiUser, - axum::extract::Path(domain): axum::extract::Path, + PathId(domain): PathId, ) -> Result { state .app_ctx @@ -94,14 +95,14 @@ pub async fn remove_blocked_domain_admin( request_body = ActorUrlRequest, responses( (status = 204, description = "Actor blocked"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn block_actor_api( State(state): State, user: AuthenticatedUser, - axum::Json(body): axum::Json, + Body(body): Body, ) -> Result { let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( @@ -120,14 +121,14 @@ pub async fn block_actor_api( request_body = ActorUrlRequest, responses( (status = 204, description = "Actor unblocked"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn unblock_actor_api( State(state): State, user: AuthenticatedUser, - axum::Json(body): axum::Json, + Body(body): Body, ) -> Result { let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( @@ -145,7 +146,7 @@ pub async fn unblock_actor_api( get, path = "/api/v1/social/blocked", responses( (status = 200, body = Vec), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] diff --git a/crates/adapters/http-axum/src/openapi/mod.rs b/crates/adapters/http-axum/src/openapi/mod.rs index 79401054..947cfcc4 100644 --- a/crates/adapters/http-axum/src/openapi/mod.rs +++ b/crates/adapters/http-axum/src/openapi/mod.rs @@ -31,8 +31,15 @@ impl Modify for SecurityAddon { } } -fn build() -> utoipa::openapi::OpenApi { +pub fn spec() -> utoipa::openapi::OpenApi { let mut api = auth::AuthDoc::openapi(); + api.components + .get_or_insert_with(Default::default) + .schemas + .insert( + "ErrorResponse".into(), + ::schema(), + ); api.info = utoipa::openapi::InfoBuilder::new() .title("Movies Diary API") .version("1.0.0") @@ -56,7 +63,7 @@ fn build() -> utoipa::openapi::OpenApi { pub fn serve(router: Router) -> Router { tracing::info!("API docs at /docs (Swagger) and /scalar"); - let spec = build(); + let spec = spec(); router .merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone())) .merge(Scalar::with_url("/scalar", spec)) diff --git a/crates/adapters/http-axum/src/router.rs b/crates/adapters/http-axum/src/router.rs index 1185bde2..44be6dd2 100644 --- a/crates/adapters/http-axum/src/router.rs +++ b/crates/adapters/http-axum/src/router.rs @@ -1,14 +1,21 @@ use std::num::NonZeroU32; -use axum::{Router, routing}; -use axum_governor::{GovernorConfigBuilder, GovernorLayer, Quota, extractor::PeerIp}; +use axum::{Router, http::StatusCode, routing}; +use axum_governor::{ + GovernorConfigBuilder, GovernorLayer, Quota, RejectionReason, extractor::PeerIp, +}; use tower_http::{ cors::CorsLayer, services::{ServeDir, ServeFile}, trace::TraceLayer, }; -use crate::{api, federation, html, state::AppState}; +use crate::{ + api, + errors::{INTERNAL, RATE_LIMITED, refuse}, + federation, html, + state::AppState, +}; pub fn build_router(state: AppState, ap_router: Router) -> Router { let rate_limit = state.app_ctx.config.rate_limit; @@ -41,6 +48,22 @@ async fn health_handler() -> axum::Json { axum::Json(serde_json::json!({ "status": "ok" })) } +/// JSON API rate-limit refusals use the one error shape. +fn slow_down(reason: RejectionReason) -> axum::response::Response { + match reason { + RejectionReason::QuotaExceeded { .. } => refuse( + StatusCode::TOO_MANY_REQUESTS, + RATE_LIMITED, + "too many requests: slow down and try again shortly", + ), + RejectionReason::KeyExtractionFailed(_) => refuse( + StatusCode::INTERNAL_SERVER_ERROR, + INTERNAL, + "the caller could not be identified for rate limiting", + ), + } +} + fn per_minute(n: u64) -> Quota { let n = NonZeroU32::new(n.clamp(1, u32::MAX as u64) as u32).unwrap(); Quota::requests_per_minute(n) @@ -111,6 +134,7 @@ fn api_routes(rate_limit: u64, cors_origins: &str) -> Router { .with_extractor(PeerIp::default()) .expect_connect_info() .quota_default(per_minute(rate_limit)) + .error_handler(slow_down) .finish() .unwrap(); @@ -120,6 +144,7 @@ fn api_routes(rate_limit: u64, cors_origins: &str) -> Router { .with_extractor(PeerIp::default()) .expect_connect_info() .quota_default(per_minute(rate_limit / 4)) + .error_handler(slow_down) .finish() .unwrap(); let webhook_routes = api::webhook_router().layer(GovernorLayer::new(webhook_cfg)); diff --git a/crates/adapters/http-axum/src/tests/api_handlers.rs b/crates/adapters/http-axum/src/tests/api_handlers.rs index 94b8ee2b..893257ab 100644 --- a/crates/adapters/http-axum/src/tests/api_handlers.rs +++ b/crates/adapters/http-axum/src/tests/api_handlers.rs @@ -393,3 +393,139 @@ async fn following_collection_returns_500_on_federation_error() { assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } + +// --- One refusal shape --- + +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::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub)); + let app = Router::new() + .route( + "/api/v1/people/{id}", + get(crate::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(crate::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::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub)); + let app = Router::new() + .route( + "/api/v1/people/{id}", + get(crate::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(crate::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(crate::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"); +} diff --git a/crates/adapters/http-axum/tests/openapi_contract_test.rs b/crates/adapters/http-axum/tests/openapi_contract_test.rs new file mode 100644 index 00000000..a878f9a5 --- /dev/null +++ b/crates/adapters/http-axum/tests/openapi_contract_test.rs @@ -0,0 +1,93 @@ +fn spec() -> serde_json::Value { + serde_json::to_value(http_axum::openapi::spec()).unwrap() +} + +fn operations(spec: &serde_json::Value) -> Vec<(String, String, serde_json::Value)> { + spec["paths"] + .as_object() + .unwrap() + .iter() + .flat_map(|(path, methods)| { + methods + .as_object() + .unwrap() + .iter() + .map(move |(verb, op)| (verb.to_uppercase(), path.clone(), op.clone())) + }) + .collect() +} + +#[test] +fn every_operation_that_can_succeed_with_a_body_declares_its_shape() { + let spec = spec(); + let mut untyped = Vec::new(); + + for (verb, path, op) in operations(&spec) { + let responses = op["responses"].as_object().unwrap(); + let typed = responses.iter().any(|(code, r)| { + code.starts_with('2') && r["content"]["application/json"]["schema"].is_object() + }); + let bodyless = responses + .iter() + .any(|(code, r)| code.starts_with('2') && r.get("content").is_none()); + let binary = responses.iter().any(|(code, r)| { + code.starts_with('2') + && r["content"] + .as_object() + .is_some_and(|c| !c.contains_key("application/json")) + }); + if !typed && !bodyless && !binary { + untyped.push(format!("{verb} {path}")); + } + } + + assert!(untyped.is_empty(), "2xx without a schema: {untyped:?}"); +} + +#[test] +fn every_operation_documents_a_refusal() { + let spec = spec(); + let silent: Vec<_> = operations(&spec) + .into_iter() + .filter(|(_, _, op)| { + !op["responses"] + .as_object() + .unwrap() + .keys() + .any(|c| c.starts_with('4')) + }) + .map(|(v, p, _)| format!("{v} {p}")) + .collect(); + assert!(silent.is_empty(), "no 4xx documented: {silent:?}"); +} + +#[test] +fn every_authenticated_operation_documents_401() { + let spec = spec(); + let silent: Vec<_> = operations(&spec) + .into_iter() + .filter(|(_, _, op)| op.get("security").is_some() && op["responses"].get("401").is_none()) + .map(|(v, p, _)| format!("{v} {p}")) + .collect(); + assert!(silent.is_empty(), "no 401 documented: {silent:?}"); +} + +#[test] +fn every_refusal_is_the_one_error_shape() { + let spec = spec(); + let mut odd = Vec::new(); + for (verb, path, op) in operations(&spec) { + for (code, response) in op["responses"].as_object().unwrap() { + if !code.starts_with('4') && !code.starts_with('5') { + continue; + } + let named = response["content"]["application/json"]["schema"]["$ref"] + .as_str() + .unwrap_or_default(); + if named != "#/components/schemas/ErrorResponse" { + odd.push(format!("{verb} {path} -> {code}")); + } + } + } + assert!(odd.is_empty(), "refusals not ErrorResponse: {odd:?}"); +} diff --git a/crates/api-types/src/common.rs b/crates/api-types/src/common.rs index e2e7a42c..e009b46a 100644 --- a/crates/api-types/src/common.rs +++ b/crates/api-types/src/common.rs @@ -21,3 +21,12 @@ pub type ActivityFeedResponse = PaginatedResponse; pub type WatchlistResponse = PaginatedResponse; pub type PaginatedMovieHits = PaginatedResponse; pub type PaginatedPersonHits = PaginatedResponse; + +/// The one body every JSON API refusal carries. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ErrorResponse { + /// Human-readable message. + pub error: String, + /// Stable machine-readable code, e.g. `NOT_FOUND`. + pub code: String, +} diff --git a/crates/bootstrap/tests/route_smoke_test.rs b/crates/bootstrap/tests/route_smoke_test.rs index de892ab4..d11d7c98 100644 --- a/crates/bootstrap/tests/route_smoke_test.rs +++ b/crates/bootstrap/tests/route_smoke_test.rs @@ -414,3 +414,23 @@ async fn openapi_paths_match_the_recorded_list() { 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"); +} diff --git a/docs/adr/0014-http-adapter-and-one-refusal-shape.md b/docs/adr/0014-http-adapter-and-one-refusal-shape.md new file mode 100644 index 00000000..876a68a9 --- /dev/null +++ b/docs/adr/0014-http-adapter-and-one-refusal-shape.md @@ -0,0 +1,52 @@ +# `http-axum` is the driving adapter, and every JSON refusal has one shape + +The old `presentation` crate was renamed `http-axum` and now reads as what it is: the HTTP driving +adapter. It is split into three modules by audience: `api` (JSON under `/api/v1`), `html` (the +classic server-rendered UI, with its askama templates absorbed into `html/templates`) and +`federation` (ActivityPub-facing and federation-gated routes, compiled only with the `federation` +feature, ADR-0009). `router.rs` assembles them; handlers reach use cases only through +`app_ctx.deps` (ADR-0006). + +A JSON client previously met several failure bodies: `{"error": "..."}` from `ApiError`, bare +status codes with empty bodies from handlers that returned `StatusCode::NOT_FOUND`, plain text from +`domain_error_response`, axum's plain-text rejections for malformed `Json`/`Query`/`Path`, and the +rate limiter's own 429 text. None of the refusals appeared in the OpenAPI document. + +## Decision + +**One body.** `api_types::ErrorResponse { error, code }`. `error` keeps its name and meaning (the +SPA and existing clients read it); `code` is an additive, stable machine-readable field: +`NOT_FOUND`, `INVALID_INPUT`, `CONFLICT`, `UNAUTHORIZED`, `FORBIDDEN`, `RATE_LIMITED`, `INTERNAL`. + +**One constructor.** `errors::refuse(status, code, message) -> Response` is the only place a JSON +error body is built. `ApiError` maps every `DomainError` variant to it with the status mapping that +existed before (`InvalidRating`/`ValidationError` 400, `NotFound` 404, `Unauthorized` 401, +`Forbidden` 403, `InfrastructureError` 500 with the generic message `internal server error` and the +cause logged, never sent). + +**Extractors.** `extractors::Body`, `Params` and `PathId` wrap axum's `Json`, `Query` and +`Path` and turn their rejections into `refuse(.., INVALID_INPUT, ..)`. The status is whatever axum +chose for the rejection (400 for syntax errors, 415 wrong content type, 422 well-formed JSON of the +wrong shape, 413 oversize), so clients that already handled those statuses are unaffected; only the +body changed. JSON API handlers use them instead of the axum originals. + +**Rate limiting.** The 429 of the `/api/v1` limiters goes through `refuse` (`RATE_LIMITED`). The +HTML and ActivityPub limiters keep axum-governor's default response. + +**HTML is unchanged.** Classic handlers keep `domain_error_response` (plain status text / HTML +pages), redirects and statuses. ActivityPub-facing routes keep their status-only errors. Only JSON +responses were unified. + +**The spec says so.** Every operation documents at least one 4xx, every authenticated operation +documents 401, and every 4xx/5xx references `ErrorResponse`. `tests/openapi_contract_test.rs` +builds the spec from `http_axum::openapi::spec()` (no bootstrap) and fails when a new endpoint +skips this, together with a test that every 2xx JSON response declares a schema. + +## Consequences + +- Statuses of existing JSON endpoints are unchanged; error bodies that were empty or plain text on + `/api/v1` are now `{"error", "code"}` JSON. Bodies that were already `{"error"}` gained `code`. +- `cargo test -p http-axum` alone does not build with default features because the dev-dependency + on `bootstrap` needs its `federation` feature to match; `cargo test -p http-axum + --no-default-features --test openapi_contract_test` runs the contract test standalone, and + `cargo test --workspace` runs it with federation on. -- 2.49.1 From 2ac1d4e0cf11cdd8eae2e4d5a80166f172d54bec Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:59:31 +0100 Subject: [PATCH 024/104] refactor: Upload extractor, 429 refusal test, stricter contract test --- .../http-axum/src/api/import/sessions.rs | 9 +-- crates/adapters/http-axum/src/api/users.rs | 11 +-- crates/adapters/http-axum/src/api/webhook.rs | 6 +- crates/adapters/http-axum/src/api/wrapup.rs | 2 +- .../adapters/http-axum/src/extractors/json.rs | 26 ++++++- .../adapters/http-axum/src/extractors/mod.rs | 2 +- crates/adapters/http-axum/src/router.rs | 70 +++++++++++++++---- .../http-axum/src/tests/api_handlers.rs | 25 +++++++ .../http-axum/tests/openapi_contract_test.rs | 35 +++++----- ...0014-http-adapter-and-one-refusal-shape.md | 5 +- 10 files changed, 139 insertions(+), 52 deletions(-) diff --git a/crates/adapters/http-axum/src/api/import/sessions.rs b/crates/adapters/http-axum/src/api/import/sessions.rs index 0340939e..db67a1af 100644 --- a/crates/adapters/http-axum/src/api/import/sessions.rs +++ b/crates/adapters/http-axum/src/api/import/sessions.rs @@ -1,4 +1,4 @@ -use crate::extractors::{Body, PathId}; +use crate::extractors::{Body, PathId, Upload}; use crate::support::str_to_domain_field; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ @@ -14,10 +14,7 @@ use application::import::{ create_session as create_import_session, execute as execute_import, get_preview_stage, get_session_state, }; -use axum::{ - extract::{Multipart, State}, - response::IntoResponse, -}; +use axum::{extract::State, response::IntoResponse}; use domain::errors::DomainError; use domain::models::{ AnnotatedRow, FieldMapping, FileFormat, @@ -39,7 +36,7 @@ use domain::value_objects::ImportSessionId; pub async fn api_post_session( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - mut multipart: Multipart, + Upload(mut multipart): Upload, ) -> Result { let mut file_bytes: Option> = None; let mut format_str = "csv".to_string(); diff --git a/crates/adapters/http-axum/src/api/users.rs b/crates/adapters/http-axum/src/api/users.rs index 744c4283..bbf4d737 100644 --- a/crates/adapters/http-axum/src/api/users.rs +++ b/crates/adapters/http-axum/src/api/users.rs @@ -1,15 +1,10 @@ -use crate::extractors::Body; +use crate::extractors::{Body, Upload}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ProfileResponse, UserSummaryDto, UsersResponse}; use application::users::{ get_users, queries::GetUsersQuery, update_profile, update_profile_fields, }; -use axum::{ - Json, - extract::{Multipart, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; #[utoipa::path( get, path = "/api/v1/profile", @@ -66,7 +61,7 @@ pub async fn get_profile( pub async fn update_profile_handler( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - multipart: Multipart, + Upload(multipart): Upload, ) -> impl IntoResponse { let data = crate::support::parse_profile_multipart(multipart).await; diff --git a/crates/adapters/http-axum/src/api/webhook.rs b/crates/adapters/http-axum/src/api/webhook.rs index f8a43ab8..f0201ef1 100644 --- a/crates/adapters/http-axum/src/api/webhook.rs +++ b/crates/adapters/http-axum/src/api/webhook.rs @@ -1,4 +1,4 @@ -use crate::extractors::{Body, Params, PathId}; +use crate::extractors::{Body, Params, PathId, Upload}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse, @@ -17,7 +17,7 @@ use application::integrations::{ }; use axum::{ Json, - extract::{Multipart, State}, + extract::State, http::{HeaderMap, StatusCode}, response::IntoResponse, }; @@ -98,7 +98,7 @@ pub async fn post_plex_webhook( State(state): State, headers: HeaderMap, Params(query): Params, - mut multipart: Multipart, + Upload(mut multipart): Upload, ) -> impl IntoResponse { let token = match extract_webhook_token(&headers, &query) { Some(t) => t, diff --git a/crates/adapters/http-axum/src/api/wrapup.rs b/crates/adapters/http-axum/src/api/wrapup.rs index 00ba1b42..4b3a6df5 100644 --- a/crates/adapters/http-axum/src/api/wrapup.rs +++ b/crates/adapters/http-axum/src/api/wrapup.rs @@ -116,7 +116,7 @@ pub async fn get_status( get, path = "/api/v1/wrapups/{id}/report", params(("id" = Uuid, Path, description = "Wrap-up ID")), responses( - (status = 200, description = "Report JSON", content_type = "application/json"), + (status = 200, description = "Report JSON", body = inline(serde_json::Value)), (status = 202, description = "Still generating"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), (status = 404, description = "Not found", body = api_types::ErrorResponse), diff --git a/crates/adapters/http-axum/src/extractors/json.rs b/crates/adapters/http-axum/src/extractors/json.rs index 08114170..9974ec8d 100644 --- a/crates/adapters/http-axum/src/extractors/json.rs +++ b/crates/adapters/http-axum/src/extractors/json.rs @@ -2,7 +2,8 @@ use axum::{ extract::{ - FromRequest, FromRequestParts, Path, Query, Request, + FromRequest, FromRequestParts, Multipart, Path, Query, Request, + multipart::MultipartRejection, rejection::{JsonRejection, PathRejection, QueryRejection}, }, http::{StatusCode, request::Parts}, @@ -52,6 +53,29 @@ impl From for MalformedRequest { } } +impl From for MalformedRequest { + fn from(r: MultipartRejection) -> Self { + Self { + status: r.status(), + message: r.body_text(), + } + } +} + +/// `axum::extract::Multipart` with refusals in the one error shape. +pub struct Upload(pub Multipart); + +impl FromRequest for Upload +where + S: Send + Sync, +{ + type Rejection = MalformedRequest; + + async fn from_request(req: Request, state: &S) -> Result { + Ok(Self(Multipart::from_request(req, state).await?)) + } +} + /// `axum::Json` with refusals in the one error shape. pub struct Body(pub T); diff --git a/crates/adapters/http-axum/src/extractors/mod.rs b/crates/adapters/http-axum/src/extractors/mod.rs index 9ac5cc89..e4620219 100644 --- a/crates/adapters/http-axum/src/extractors/mod.rs +++ b/crates/adapters/http-axum/src/extractors/mod.rs @@ -1,5 +1,5 @@ mod json; -pub use json::{Body, MalformedRequest, Params, PathId}; +pub use json::{Body, MalformedRequest, Params, PathId, Upload}; use axum::{ extract::{FromRef, FromRequestParts}, diff --git a/crates/adapters/http-axum/src/router.rs b/crates/adapters/http-axum/src/router.rs index 44be6dd2..7a9a02f5 100644 --- a/crates/adapters/http-axum/src/router.rs +++ b/crates/adapters/http-axum/src/router.rs @@ -64,6 +64,17 @@ fn slow_down(reason: RejectionReason) -> axum::response::Response { } } +/// Every `/api/v1` limiter is built here so its refusals use the one error shape. +fn api_governor(quota: Quota) -> axum_governor::GovernorConfig { + GovernorConfigBuilder::default() + .with_extractor(PeerIp::default()) + .expect_connect_info() + .quota_default(quota) + .error_handler(slow_down) + .finish() + .unwrap() +} + fn per_minute(n: u64) -> Quota { let n = NonZeroU32::new(n.clamp(1, u32::MAX as u64) as u32).unwrap(); Quota::requests_per_minute(n) @@ -130,23 +141,11 @@ fn cors_layer(origins: &str) -> CorsLayer { } fn api_routes(rate_limit: u64, cors_origins: &str) -> Router { - let cfg = GovernorConfigBuilder::default() - .with_extractor(PeerIp::default()) - .expect_connect_info() - .quota_default(per_minute(rate_limit)) - .error_handler(slow_down) - .finish() - .unwrap(); + let cfg = api_governor(per_minute(rate_limit)); let base = api::router().merge(federation::api_routes()); - let webhook_cfg = GovernorConfigBuilder::default() - .with_extractor(PeerIp::default()) - .expect_connect_info() - .quota_default(per_minute(rate_limit / 4)) - .error_handler(slow_down) - .finish() - .unwrap(); + let webhook_cfg = api_governor(per_minute(rate_limit / 4)); let webhook_routes = api::webhook_router().layer(GovernorLayer::new(webhook_cfg)); Router::new() @@ -154,3 +153,46 @@ fn api_routes(rate_limit: u64, cors_origins: &str) -> Router { .nest("/api/v1", webhook_routes) .layer(cors_layer(cors_origins)) } + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Body, http::Request}; + use tower::ServiceExt; + + async fn body_json(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).unwrap() + } + + #[tokio::test] + async fn api_rate_limit_refusal_is_the_one_error_shape() { + let app = Router::new() + .route("/x", routing::get(|| async { "ok" })) + .layer(GovernorLayer::new(api_governor(per_minute(1)))); + let addr: std::net::SocketAddr = "127.0.0.1:1".parse().unwrap(); + let mut last = None; + for _ in 0..3 { + let mut req = Request::builder().uri("/x").body(Body::empty()).unwrap(); + req.extensions_mut() + .insert(axum::extract::ConnectInfo::(addr)); + last = Some(app.clone().oneshot(req).await.unwrap()); + } + let resp = last.unwrap(); + assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); + let body = body_json(resp).await; + assert_eq!(body["code"], "RATE_LIMITED"); + assert!(body["error"].is_string()); + } + + #[tokio::test] + async fn unidentifiable_caller_is_refused_in_the_one_error_shape() { + let resp = slow_down(RejectionReason::KeyExtractionFailed( + axum_governor::ExtractionError::MissingConnectInfo, + )); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body_json(resp).await["code"], "INTERNAL"); + } +} diff --git a/crates/adapters/http-axum/src/tests/api_handlers.rs b/crates/adapters/http-axum/src/tests/api_handlers.rs index 893257ab..9062745f 100644 --- a/crates/adapters/http-axum/src/tests/api_handlers.rs +++ b/crates/adapters/http-axum/src/tests/api_handlers.rs @@ -529,3 +529,28 @@ async fn a_json_body_of_the_wrong_shape_is_refused_as_unprocessable() { 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(crate::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"); +} diff --git a/crates/adapters/http-axum/tests/openapi_contract_test.rs b/crates/adapters/http-axum/tests/openapi_contract_test.rs index a878f9a5..67e6d6f5 100644 --- a/crates/adapters/http-axum/tests/openapi_contract_test.rs +++ b/crates/adapters/http-axum/tests/openapi_contract_test.rs @@ -18,30 +18,31 @@ fn operations(spec: &serde_json::Value) -> Vec<(String, String, serde_json::Valu } #[test] -fn every_operation_that_can_succeed_with_a_body_declares_its_shape() { +fn the_spec_is_not_vacuous() { + let spec = spec(); + assert!(!operations(&spec).is_empty()); + assert!(spec["components"]["schemas"]["ErrorResponse"].is_object()); +} + +#[test] +fn every_2xx_json_response_declares_a_schema() { let spec = spec(); let mut untyped = Vec::new(); for (verb, path, op) in operations(&spec) { - let responses = op["responses"].as_object().unwrap(); - let typed = responses.iter().any(|(code, r)| { - code.starts_with('2') && r["content"]["application/json"]["schema"].is_object() - }); - let bodyless = responses - .iter() - .any(|(code, r)| code.starts_with('2') && r.get("content").is_none()); - let binary = responses.iter().any(|(code, r)| { - code.starts_with('2') - && r["content"] - .as_object() - .is_some_and(|c| !c.contains_key("application/json")) - }); - if !typed && !bodyless && !binary { - untyped.push(format!("{verb} {path}")); + for (code, r) in op["responses"].as_object().unwrap() { + if !code.starts_with('2') { + continue; + } + if let Some(json) = r["content"].get("application/json") + && !json["schema"].is_object() + { + untyped.push(format!("{verb} {path} -> {code}")); + } } } - assert!(untyped.is_empty(), "2xx without a schema: {untyped:?}"); + assert!(untyped.is_empty(), "2xx JSON without a schema: {untyped:?}"); } #[test] diff --git a/docs/adr/0014-http-adapter-and-one-refusal-shape.md b/docs/adr/0014-http-adapter-and-one-refusal-shape.md index 876a68a9..fefc0ca4 100644 --- a/docs/adr/0014-http-adapter-and-one-refusal-shape.md +++ b/docs/adr/0014-http-adapter-and-one-refusal-shape.md @@ -10,7 +10,7 @@ feature, ADR-0009). `router.rs` assembles them; handlers reach use cases only th A JSON client previously met several failure bodies: `{"error": "..."}` from `ApiError`, bare status codes with empty bodies from handlers that returned `StatusCode::NOT_FOUND`, plain text from `domain_error_response`, axum's plain-text rejections for malformed `Json`/`Query`/`Path`, and the -rate limiter's own 429 text. None of the refusals appeared in the OpenAPI document. +rate limiter's own 429 text. The refusals were documented without a body schema. ## Decision @@ -37,6 +37,9 @@ HTML and ActivityPub limiters keep axum-governor's default response. pages), redirects and statuses. ActivityPub-facing routes keep their status-only errors. Only JSON responses were unified. +**Not covered.** Unmatched `/api/v1` paths (404) and wrong methods (405) are produced by the router +itself and still return axum's empty bodies. + **The spec says so.** Every operation documents at least one 4xx, every authenticated operation documents 401, and every 4xx/5xx references `ErrorResponse`. `tests/openapi_contract_test.rs` builds the spec from `http_axum::openapi::spec()` (no bootstrap) and fails when a new endpoint -- 2.49.1 From 51d92e08a73445c451cc0c905086cbe59254bcd5 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:08:17 +0100 Subject: [PATCH 025/104] test: shared factories and command builders --- crates/adapters/export/Cargo.toml | 1 + crates/adapters/export/src/tests/lib.rs | 86 +++---- .../http-axum/src/extractors/tests.rs | 28 +- crates/adapters/rss/Cargo.toml | 1 + crates/adapters/rss/src/tests/lib.rs | 28 +- crates/adapters/sqlite/Cargo.toml | 1 + .../sqlite/src/repositories/persons/tests.rs | 24 +- .../sqlite/src/repositories/users/tests.rs | 17 +- crates/application/src/auth/tests/login.rs | 40 +-- crates/application/src/auth/tests/logout.rs | 33 +-- crates/application/src/auth/tests/refresh.rs | 26 +- crates/application/src/auth/tests/register.rs | 13 +- .../src/diary/tests/delete_review.rs | 35 +-- .../src/diary/tests/edit_review.rs | 71 ++---- .../src/diary/tests/get_movie_social_page.rs | 11 +- .../src/diary/tests/get_review_history.rs | 16 +- .../src/diary/tests/get_user_feed.rs | 40 +-- .../application/src/diary/tests/log_review.rs | 68 +---- .../src/diary/tests/movie_resolver.rs | 21 +- .../src/diary/tests/review_logger.rs | 133 ++-------- crates/application/src/goals/tests/create.rs | 78 ++---- crates/application/src/goals/tests/delete.rs | 19 +- crates/application/src/goals/tests/get.rs | 16 +- crates/application/src/goals/tests/list.rs | 16 +- crates/application/src/goals/tests/update.rs | 66 +---- .../src/import/tests/apply_mapping.rs | 70 +---- .../src/import/tests/apply_profile.rs | 21 +- .../src/import/tests/apply_profile_and_map.rs | 14 +- .../src/import/tests/create_session.rs | 9 +- .../application/src/import/tests/execute.rs | 166 +++--------- .../src/integrations/tests/confirm.rs | 48 +--- .../src/integrations/tests/dismiss.rs | 18 +- .../src/integrations/tests/generate_token.rs | 10 +- .../src/integrations/tests/get_tokens.rs | 9 +- .../src/integrations/tests/ingest.rs | 9 +- .../src/integrations/tests/revoke_token.rs | 14 +- crates/application/src/lib.rs | 3 + .../src/movies/tests/enrich_movie.rs | 21 +- .../src/movies/tests/sync_poster.rs | 37 +-- .../src/social/tests/local_service.rs | 13 +- crates/application/src/test_support.rs | 240 ++++++++++++++++++ .../src/users/tests/authorize_admin.rs | 5 +- .../src/users/tests/get_current_profile.rs | 17 +- .../src/users/tests/get_local_profile.rs | 24 +- .../src/users/tests/get_page_viewer.rs | 17 +- .../src/users/tests/get_profile_settings.rs | 11 +- .../src/users/tests/update_profile.rs | 69 +---- .../src/users/tests/update_settings.rs | 13 +- crates/application/src/watchlist/tests/add.rs | 66 +---- .../tests/get_watchlist_for_owner.rs | 17 +- .../application/src/wrapup/tests/generate.rs | 20 +- crates/domain/src/lib.rs | 2 +- crates/domain/src/models/tests/goal.rs | 25 +- crates/domain/src/models/tests/mod.rs | 60 ++--- crates/domain/src/models/tests/review.rs | 36 ++- .../src/services/tests/review_history.rs | 23 +- crates/domain/src/testing/factories.rs | 131 ++++++++++ crates/domain/src/testing/mod.rs | 2 + 58 files changed, 784 insertions(+), 1344 deletions(-) create mode 100644 crates/application/src/test_support.rs create mode 100644 crates/domain/src/testing/factories.rs diff --git a/crates/adapters/export/Cargo.toml b/crates/adapters/export/Cargo.toml index edcdcfc3..3259145a 100644 --- a/crates/adapters/export/Cargo.toml +++ b/crates/adapters/export/Cargo.toml @@ -13,5 +13,6 @@ bytes = { workspace = true } async-stream = { workspace = true } [dev-dependencies] +domain = { workspace = true, features = ["test-helpers"] } uuid = { workspace = true } tokio = { workspace = true } diff --git a/crates/adapters/export/src/tests/lib.rs b/crates/adapters/export/src/tests/lib.rs index 83b82b83..674dd7e8 100644 --- a/crates/adapters/export/src/tests/lib.rs +++ b/crates/adapters/export/src/tests/lib.rs @@ -1,8 +1,11 @@ use super::ExportAdapter; use domain::{ - models::{DiaryEntry, ExportFormat, Movie, Review}, + models::{DiaryEntry, ExportFormat, Movie, ReviewEdit}, ports::DiaryExporter, - value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear}, + testing::{ + test_movie_with_director, test_movie_with_external_id, test_movie_with_year, test_review, + }, + value_objects::{Comment, ExternalMetadataId, UserId}, }; async fn collect_stream( @@ -26,55 +29,19 @@ fn entry_stream( Box::pin(futures::stream::iter(entries.into_iter().map(Ok))) } -fn make_entry( - title: &str, - year: u16, - director: Option<&str>, - rating: u8, - comment: Option<&str>, -) -> DiaryEntry { - make_entry_full(title, year, director, rating, comment, None) -} - -fn make_entry_full( - title: &str, - year: u16, - director: Option<&str>, - rating: u8, - comment: Option<&str>, - external_id: Option<&str>, -) -> DiaryEntry { - let movie = Movie::new( - external_id.map(|id| ExternalMetadataId::new(id.to_string()).unwrap()), - MovieTitle::new(title.to_string()).unwrap(), - ReleaseYear::new(year).unwrap(), - director.map(str::to_string), - None, - ); - let user_id = domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4()); - let review = Review::new( - movie.id().clone(), - user_id, - Rating::new(rating).unwrap(), - comment.map(|c| domain::value_objects::Comment::new(c.to_string()).unwrap()), - chrono::NaiveDate::from_ymd_opt(2024, 3, 15) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap(), - None, - false, - ) - .unwrap(); +fn entry_for(movie: Movie, rating: u8, comment: Option<&str>) -> DiaryEntry { + let review = test_review(movie.id(), &UserId::generate(), rating).apply_edit(ReviewEdit { + comment: Some(comment.map(|c| Comment::new(c.to_string()).unwrap())), + ..Default::default() + }); DiaryEntry::new(movie, review) } #[tokio::test] async fn csv_has_header_and_one_row() { let adapter = ExportAdapter; - let entry = make_entry( - "Inception", - 2010, - Some("Christopher Nolan"), + let entry = entry_for( + test_movie_with_director("Inception", 2010, "Christopher Nolan"), 5, Some("great"), ); @@ -95,7 +62,7 @@ async fn csv_has_header_and_one_row() { #[tokio::test] async fn csv_escapes_commas_in_title() { let adapter = ExportAdapter; - let entry = make_entry("Tár, A Film", 2022, None, 4, None); + let entry = entry_for(test_movie_with_year("Tár, A Film", 2022), 4, None); let bytes = collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await; let text = String::from_utf8(bytes).unwrap(); @@ -105,7 +72,11 @@ async fn csv_escapes_commas_in_title() { #[tokio::test] async fn json_is_valid_array() { let adapter = ExportAdapter; - let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None); + let entry = entry_for( + test_movie_with_director("Dune", 2021, "Denis Villeneuve"), + 5, + None, + ); let bytes = collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await; let arr: Vec = serde_json::from_slice(&bytes).unwrap(); @@ -120,20 +91,29 @@ async fn json_is_valid_array() { #[tokio::test] async fn external_metadata_id_included_when_present() { let adapter = ExportAdapter; - let entry = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748")); + let entry = entry_for( + test_movie_with_external_id( + "Alien", + 1979, + ExternalMetadataId::new("tt0078748".to_string()).unwrap(), + ), + 5, + None, + ); let bytes = collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await; let arr: Vec = serde_json::from_slice(&bytes).unwrap(); assert_eq!(arr[0]["external_metadata_id"], "tt0078748"); let bytes = collect_stream(adapter.stream_entries( - entry_stream(vec![make_entry_full( - "Alien", - 1979, - None, + entry_stream(vec![entry_for( + test_movie_with_external_id( + "Alien", + 1979, + ExternalMetadataId::new("tt0078748".to_string()).unwrap(), + ), 5, None, - Some("tt0078748"), )]), ExportFormat::Csv, )) diff --git a/crates/adapters/http-axum/src/extractors/tests.rs b/crates/adapters/http-axum/src/extractors/tests.rs index 71dd3670..af6d7b20 100644 --- a/crates/adapters/http-axum/src/extractors/tests.rs +++ b/crates/adapters/http-axum/src/extractors/tests.rs @@ -966,26 +966,6 @@ impl UserRepository for ErroringUserRepo { } } -/// Builds a bare `User` with the given role, for the admin/non-admin extractor -/// tests — only `id()` and `role()` are read by `authorize_admin::execute`. -fn make_user_with_role(id: UserId, role: domain::models::UserRole) -> domain::models::User { - domain::models::User::from_persistence( - id, - domain::value_objects::Email::new("extractor-test@example.com".into()).unwrap(), - domain::value_objects::Username::new("extractor_test_user".into()).unwrap(), - domain::value_objects::PasswordHash::new("hashed".into()).unwrap(), - role, - domain::models::UserProfile { - display_name: None, - bio: None, - avatar_path: None, - banner_path: None, - also_known_as: None, - profile_fields: vec![], - }, - ) -} - /// 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. @@ -1154,7 +1134,7 @@ async fn admin_api_user_allows_an_admin_user() { let user_repo = InMemoryUserRepository::new(); user_repo.store.lock().unwrap().insert( id.value(), - make_user_with_role(id.clone(), domain::models::UserRole::Admin), + 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 _, @@ -1179,7 +1159,7 @@ async fn admin_api_user_rejects_a_non_admin_user() { let user_repo = InMemoryUserRepository::new(); user_repo.store.lock().unwrap().insert( id.value(), - make_user_with_role(id.clone(), domain::models::UserRole::Standard), + 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 _, @@ -1245,7 +1225,7 @@ async fn admin_user_allows_an_admin_user() { let user_repo = InMemoryUserRepository::new(); user_repo.store.lock().unwrap().insert( id.value(), - make_user_with_role(id.clone(), domain::models::UserRole::Admin), + 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 _, @@ -1270,7 +1250,7 @@ async fn admin_user_rejects_a_non_admin_user() { let user_repo = InMemoryUserRepository::new(); user_repo.store.lock().unwrap().insert( id.value(), - make_user_with_role(id.clone(), domain::models::UserRole::Standard), + 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 _, diff --git a/crates/adapters/rss/Cargo.toml b/crates/adapters/rss/Cargo.toml index 73207cc5..e5024f23 100644 --- a/crates/adapters/rss/Cargo.toml +++ b/crates/adapters/rss/Cargo.toml @@ -8,4 +8,5 @@ rss-feed = { package = "rss", version = "2", features = ["atom"] } domain = { workspace = true } [dev-dependencies] +domain = { workspace = true, features = ["test-helpers"] } chrono = { workspace = true } diff --git a/crates/adapters/rss/src/tests/lib.rs b/crates/adapters/rss/src/tests/lib.rs index 3b282f5e..7f567f55 100644 --- a/crates/adapters/rss/src/tests/lib.rs +++ b/crates/adapters/rss/src/tests/lib.rs @@ -1,6 +1,7 @@ use super::*; -use domain::models::{Movie, Review}; -use domain::value_objects::{Comment, MovieTitle, Rating, ReleaseYear, UserId}; +use domain::models::ReviewEdit; +use domain::testing::{test_movie_with_year, test_review}; +use domain::value_objects::{Comment, UserId}; fn channel<'a>(title: &'a str) -> FeedChannel<'a> { FeedChannel { @@ -11,23 +12,12 @@ fn channel<'a>(title: &'a str) -> FeedChannel<'a> { } fn entry(spoilers: bool) -> DiaryEntry { - let movie = Movie::new( - None, - MovieTitle::new("Blade Runner".into()).unwrap(), - ReleaseYear::new(1982).unwrap(), - None, - None, - ); - let review = Review::new( - movie.id().clone(), - UserId::generate(), - Rating::new(4).unwrap(), - Some(Comment::new("replicants".into()).unwrap()), - chrono::Utc::now().naive_utc(), - None, - spoilers, - ) - .unwrap(); + let movie = test_movie_with_year("Blade Runner", 1982); + let review = test_review(movie.id(), &UserId::generate(), 4).apply_edit(ReviewEdit { + comment: Some(Some(Comment::new("replicants".into()).unwrap())), + spoilers: Some(spoilers), + ..Default::default() + }); DiaryEntry::new(movie, review) } diff --git a/crates/adapters/sqlite/Cargo.toml b/crates/adapters/sqlite/Cargo.toml index 373715e7..de6cd663 100644 --- a/crates/adapters/sqlite/Cargo.toml +++ b/crates/adapters/sqlite/Cargo.toml @@ -22,4 +22,5 @@ bytes = { workspace = true } async-stream = { workspace = true } [dev-dependencies] +domain = { workspace = true, features = ["test-helpers"] } tempfile = { workspace = true } diff --git a/crates/adapters/sqlite/src/repositories/persons/tests.rs b/crates/adapters/sqlite/src/repositories/persons/tests.rs index 3dff2026..16623ad1 100644 --- a/crates/adapters/sqlite/src/repositories/persons/tests.rs +++ b/crates/adapters/sqlite/src/repositories/persons/tests.rs @@ -1,6 +1,7 @@ use super::SqlitePersonAdapter; +use domain::testing::test_person_with_tmdb; use domain::{ - models::{ExternalPersonId, Person, PersonId}, + models::{ExternalPersonId, PersonId}, ports::{PersonCommand, PersonQuery}, }; use sqlx::SqlitePool; @@ -47,25 +48,14 @@ async fn pool_with_schema() -> SqlitePool { pool } -fn make_person(tmdb_id: i64, name: &str, dept: Option<&str>) -> Person { - let ext = ExternalPersonId::new(format!("tmdb:{tmdb_id}")); - Person::basic( - PersonId::from_external(&ext), - ext, - name.to_string(), - dept.map(str::to_string), - None, - ) -} - #[tokio::test] async fn upsert_batch_inserts_persons() { let pool = pool_with_schema().await; let adapter = SqlitePersonAdapter::new(pool.clone()); let persons = vec![ - make_person(1, "Alice", Some("Acting")), - make_person(2, "Bob", Some("Directing")), + test_person_with_tmdb(1, "Alice", Some("Acting")), + test_person_with_tmdb(2, "Bob", Some("Directing")), ]; adapter.upsert_batch(&persons).await.unwrap(); @@ -81,7 +71,7 @@ async fn upsert_batch_is_idempotent() { let pool = pool_with_schema().await; let adapter = SqlitePersonAdapter::new(pool.clone()); - let persons = vec![make_person(1, "Alice", Some("Acting"))]; + let persons = vec![test_person_with_tmdb(1, "Alice", Some("Acting"))]; adapter.upsert_batch(&persons).await.unwrap(); adapter.upsert_batch(&persons).await.unwrap(); @@ -97,7 +87,7 @@ async fn get_by_id_returns_person() { let pool = pool_with_schema().await; let adapter = SqlitePersonAdapter::new(pool.clone()); - let p = make_person(42, "Charlie", Some("Acting")); + let p = test_person_with_tmdb(42, "Charlie", Some("Acting")); adapter .upsert_batch(std::slice::from_ref(&p)) .await @@ -122,7 +112,7 @@ async fn get_credits_returns_cast_and_crew() { let pool = pool_with_schema().await; let adapter = SqlitePersonAdapter::new(pool.clone()); - let p = make_person(7, "Diana", Some("Acting")); + let p = test_person_with_tmdb(7, "Diana", Some("Acting")); adapter .upsert_batch(std::slice::from_ref(&p)) .await diff --git a/crates/adapters/sqlite/src/repositories/users/tests.rs b/crates/adapters/sqlite/src/repositories/users/tests.rs index c6fd2faa..bd94d1d6 100644 --- a/crates/adapters/sqlite/src/repositories/users/tests.rs +++ b/crates/adapters/sqlite/src/repositories/users/tests.rs @@ -1,6 +1,5 @@ use super::*; -use domain::models::UserRole; -use domain::value_objects::{Email, PasswordHash, Username}; +use domain::testing::test_user; use sqlx::SqlitePool; async fn setup() -> (SqlitePool, SqliteUserRepository) { @@ -55,12 +54,7 @@ async fn find_by_id_returns_user_when_found() { #[tokio::test] async fn update_profile_persists_bio_and_avatar() { let (_, repo) = setup().await; - let user = domain::models::User::new( - Email::new("test@example.com".to_string()).unwrap(), - Username::new("testuser".to_string()).unwrap(), - PasswordHash::new("hash".to_string()).unwrap(), - UserRole::Standard, - ); + let user = test_user("testuser"); repo.save(&user).await.unwrap(); repo.update_profile( @@ -82,12 +76,7 @@ async fn update_profile_persists_bio_and_avatar() { #[tokio::test] async fn update_profile_clears_fields_with_none() { let (_, repo) = setup().await; - let user = domain::models::User::new( - Email::new("test2@example.com".to_string()).unwrap(), - Username::new("testuser2".to_string()).unwrap(), - PasswordHash::new("hash".to_string()).unwrap(), - UserRole::Standard, - ); + let user = test_user("testuser2"); repo.save(&user).await.unwrap(); repo.update_profile( user.id(), diff --git a/crates/application/src/auth/tests/login.rs b/crates/application/src/auth/tests/login.rs index c60fd40a..b317e2b5 100644 --- a/crates/application/src/auth/tests/login.rs +++ b/crates/application/src/auth/tests/login.rs @@ -1,15 +1,13 @@ +use crate::test_support::{login_command, register_command}; use std::sync::Arc; -use domain::models::UserRole; use domain::testing::InMemoryUserRepository; use crate::{ auth::{ commands::RegisterCommand, deps::{LoginDeps, RegisterDeps}, - login, - queries::LoginCommand, - register, + login, register, }, test_helpers::TestContextBuilder, }; @@ -23,10 +21,8 @@ async fn setup_user(b: &TestContextBuilder, email: &str, password: &str) { register::execute( &deps, RegisterCommand { - email: email.to_string(), - username: "testuser".to_string(), password: password.to_string(), - role: UserRole::Standard, + ..register_command(email, "testuser") }, ) .await @@ -46,15 +42,9 @@ async fn test_login_valid_credentials_returns_token() { refresh_session: b.refresh_session_repo.clone(), config: b.config.clone(), }; - let result = login::execute( - &deps, - LoginCommand { - email: "carol@example.com".into(), - password: "secret123".into(), - }, - ) - .await - .unwrap(); + let result = login::execute(&deps, login_command("carol@example.com", "secret123")) + .await + .unwrap(); assert!(!result.token.is_empty()); assert!(!result.refresh_token.is_empty()); @@ -74,14 +64,7 @@ async fn test_login_wrong_password_fails() { refresh_session: b.refresh_session_repo.clone(), config: b.config.clone(), }; - let result = login::execute( - &deps, - LoginCommand { - email: "dave@example.com".into(), - password: "wrong_password".into(), - }, - ) - .await; + let result = login::execute(&deps, login_command("dave@example.com", "wrong_password")).await; assert!(result.is_err()); } @@ -96,14 +79,7 @@ async fn test_login_unknown_email_fails() { refresh_session: b.refresh_session_repo.clone(), config: b.config.clone(), }; - let result = login::execute( - &deps, - LoginCommand { - email: "nobody@example.com".into(), - password: "anything".into(), - }, - ) - .await; + let result = login::execute(&deps, login_command("nobody@example.com", "anything")).await; assert!(result.is_err()); } diff --git a/crates/application/src/auth/tests/logout.rs b/crates/application/src/auth/tests/logout.rs index ed4a6ff2..ac355548 100644 --- a/crates/application/src/auth/tests/logout.rs +++ b/crates/application/src/auth/tests/logout.rs @@ -1,15 +1,12 @@ +use crate::test_support::{login_command, register_command}; use std::sync::Arc; -use domain::models::UserRole; use domain::testing::InMemoryUserRepository; use crate::{ auth::{ - commands::RegisterCommand, deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterDeps}, - login, logout, - queries::LoginCommand, - refresh, register, + login, logout, refresh, register, }, test_helpers::TestContextBuilder, }; @@ -24,17 +21,9 @@ async fn logout_revokes_refresh_token() { password_hasher: b.password_hasher.clone(), config: b.config.clone(), }; - register::execute( - ®_deps, - RegisterCommand { - email: "bob@example.com".to_string(), - username: "bob".to_string(), - password: "password123".to_string(), - role: UserRole::Standard, - }, - ) - .await - .unwrap(); + register::execute(®_deps, register_command("bob@example.com", "bob")) + .await + .unwrap(); let login_deps = LoginDeps { user: b.user_repo.clone(), @@ -43,15 +32,9 @@ async fn logout_revokes_refresh_token() { refresh_session: b.refresh_session_repo.clone(), config: b.config.clone(), }; - let login_result = login::execute( - &login_deps, - LoginCommand { - email: "bob@example.com".into(), - password: "password123".into(), - }, - ) - .await - .unwrap(); + let login_result = login::execute(&login_deps, login_command("bob@example.com", "password123")) + .await + .unwrap(); let logout_deps = LogoutDeps { refresh_session: b.refresh_session_repo.clone(), diff --git a/crates/application/src/auth/tests/refresh.rs b/crates/application/src/auth/tests/refresh.rs index 09191959..21493cc4 100644 --- a/crates/application/src/auth/tests/refresh.rs +++ b/crates/application/src/auth/tests/refresh.rs @@ -1,15 +1,12 @@ +use crate::test_support::{login_command, register_command}; use std::sync::Arc; -use domain::models::UserRole; use domain::testing::InMemoryUserRepository; use crate::{ auth::{ - commands::RegisterCommand, deps::{LoginDeps, RefreshDeps, RegisterDeps}, - login, - queries::LoginCommand, - refresh, register, + login, refresh, register, }, test_helpers::TestContextBuilder, }; @@ -20,17 +17,9 @@ async fn login_user(b: &TestContextBuilder) -> login::LoginResult { password_hasher: b.password_hasher.clone(), config: b.config.clone(), }; - register::execute( - ®_deps, - RegisterCommand { - email: "alice@example.com".to_string(), - username: "alice".to_string(), - password: "password123".to_string(), - role: UserRole::Standard, - }, - ) - .await - .unwrap(); + register::execute(®_deps, register_command("alice@example.com", "alice")) + .await + .unwrap(); let login_deps = LoginDeps { user: b.user_repo.clone(), @@ -41,10 +30,7 @@ async fn login_user(b: &TestContextBuilder) -> login::LoginResult { }; login::execute( &login_deps, - LoginCommand { - email: "alice@example.com".into(), - password: "password123".into(), - }, + login_command("alice@example.com", "password123"), ) .await .unwrap() diff --git a/crates/application/src/auth/tests/register.rs b/crates/application/src/auth/tests/register.rs index c67c7f96..5249ddcc 100644 --- a/crates/application/src/auth/tests/register.rs +++ b/crates/application/src/auth/tests/register.rs @@ -1,6 +1,6 @@ +use crate::test_support::register_command; use std::sync::Arc; -use domain::models::UserRole; use domain::ports::UserRepository; use domain::testing::InMemoryUserRepository; use domain::value_objects::Email; @@ -11,12 +11,7 @@ use crate::{ }; fn cmd(email: &str) -> RegisterCommand { - RegisterCommand { - email: email.to_string(), - username: "alice".to_string(), - password: "password123".to_string(), - role: UserRole::Standard, - } + register_command(email, "alice") } #[tokio::test] @@ -67,10 +62,8 @@ async fn test_register_short_password_fails() { let result = register::execute( &deps, RegisterCommand { - email: "x@y.com".to_string(), - username: "testuser".to_string(), password: "short".to_string(), - role: UserRole::Standard, + ..register_command("x@y.com", "testuser") }, ) .await; diff --git a/crates/application/src/diary/tests/delete_review.rs b/crates/application/src/diary/tests/delete_review.rs index 3c36f9f2..a30902ab 100644 --- a/crates/application/src/diary/tests/delete_review.rs +++ b/crates/application/src/diary/tests/delete_review.rs @@ -1,43 +1,18 @@ +use domain::testing::{test_movie_with_year, test_review}; use std::sync::Arc; -use chrono::Utc; - use domain::{ - models::{Movie, Review}, ports::{MovieCommand, MovieQuery, ReviewRepository}, testing::{ FakeDiaryQuery, InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher, }, - value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId}, + value_objects::{MovieId, UserId}, }; use crate::{ diary::commands::DeleteReviewCommand, diary::delete_review, diary::deps::DeleteReviewDeps, }; -fn make_movie() -> Movie { - Movie::new( - None, - MovieTitle::new("Terminator".into()).unwrap(), - ReleaseYear::new(1984).unwrap(), - None, - None, - ) -} - -fn make_review(movie_id: MovieId, user_id: UserId) -> Review { - Review::new( - movie_id, - user_id, - Rating::new(4).unwrap(), - None, - Utc::now().naive_utc(), - None, - false, - ) - .unwrap() -} - #[tokio::test] async fn test_delete_review_removes_it() { let movies = InMemoryMovieRepository::new(); @@ -45,9 +20,9 @@ async fn test_delete_review_removes_it() { let diary = FakeDiaryQuery::new(); let events = NoopEventPublisher::new(); - let movie = make_movie(); + let movie = test_movie_with_year("Terminator", 1984); let user_id = UserId::from_uuid(uuid::Uuid::new_v4()); - let review = make_review(movie.id().clone(), user_id.clone()); + let review = test_review(movie.id(), &user_id, 4); movies.upsert_movie(&movie).await.unwrap(); reviews.save_review(&review).await.unwrap(); @@ -87,7 +62,7 @@ async fn test_delete_review_wrong_user_is_unauthorized() { let movie_id = MovieId::from_uuid(uuid::Uuid::new_v4()); let owner_id = UserId::from_uuid(uuid::Uuid::new_v4()); let other_id = uuid::Uuid::new_v4(); - let review = make_review(movie_id, owner_id); + let review = test_review(&movie_id, &owner_id, 4); reviews.save_review(&review).await.unwrap(); diff --git a/crates/application/src/diary/tests/edit_review.rs b/crates/application/src/diary/tests/edit_review.rs index 37ace682..e5244c8b 100644 --- a/crates/application/src/diary/tests/edit_review.rs +++ b/crates/application/src/diary/tests/edit_review.rs @@ -1,30 +1,15 @@ +use crate::test_support::edit_review_command; use std::sync::Arc; -use chrono::Utc; -use uuid::Uuid; - use domain::{ - models::Review, + models::ReviewEdit, ports::ReviewRepository, - testing::{InMemoryReviewRepository, NoopEventPublisher}, - value_objects::{Comment, MovieId, Rating, ReviewId, UserId, WatchMedium}, + testing::{InMemoryReviewRepository, NoopEventPublisher, test_review}, + value_objects::{Comment, MovieId, ReviewId, UserId, WatchMedium}, }; use crate::diary::{commands::EditReviewCommand, deps::EditReviewDeps, edit_review}; -fn make_review(user_id: UserId) -> Review { - Review::new( - MovieId::generate(), - user_id, - Rating::new(3).unwrap(), - Some(Comment::new("original comment".into()).unwrap()), - Utc::now().naive_utc(), - Some(WatchMedium::Streaming), - false, - ) - .unwrap() -} - async fn setup() -> ( Arc, Arc, @@ -34,7 +19,11 @@ async fn setup() -> ( let reviews = InMemoryReviewRepository::new(); let events = NoopEventPublisher::new(); let user_id = UserId::generate(); - let review = make_review(user_id.clone()); + let review = test_review(&MovieId::generate(), &user_id, 3).apply_edit(ReviewEdit { + comment: Some(Some(Comment::new("original comment".into()).unwrap())), + watch_medium: Some(Some(WatchMedium::Streaming)), + ..Default::default() + }); let review_id = review.id().clone(); reviews.save_review(&review).await.unwrap(); (reviews, events, review_id, user_id) @@ -59,11 +48,7 @@ async fn edit_own_review_updates_rating() { EditReviewCommand { review_id: review_id.value(), requesting_user_id: user_id.value(), - rating: Some(5), - comment: None, - watched_at: None, - watch_medium: None, - spoilers: None, + ..edit_review_command(5) }, ) .await @@ -80,20 +65,9 @@ async fn edit_nonexistent_review_returns_not_found() { let reviews = InMemoryReviewRepository::new(); let events = NoopEventPublisher::new(); - let err = edit_review::execute( - &deps(&reviews, &events), - EditReviewCommand { - review_id: Uuid::new_v4(), - requesting_user_id: Uuid::new_v4(), - rating: Some(5), - comment: None, - watched_at: None, - watch_medium: None, - spoilers: None, - }, - ) - .await - .unwrap_err(); + let err = edit_review::execute(&deps(&reviews, &events), edit_review_command(5)) + .await + .unwrap_err(); assert!(matches!(err, domain::errors::DomainError::NotFound(_))); } @@ -106,12 +80,7 @@ async fn edit_other_users_review_returns_forbidden() { &deps(&reviews, &events), EditReviewCommand { review_id: review_id.value(), - requesting_user_id: Uuid::new_v4(), - rating: Some(5), - comment: None, - watched_at: None, - watch_medium: None, - spoilers: None, + ..edit_review_command(5) }, ) .await @@ -129,11 +98,7 @@ async fn edit_publishes_review_updated_event() { EditReviewCommand { review_id: review_id.value(), requesting_user_id: user_id.value(), - rating: Some(1), - comment: None, - watched_at: None, - watch_medium: None, - spoilers: None, + ..edit_review_command(1) }, ) .await @@ -154,13 +119,11 @@ async fn edit_sets_watch_medium() { edit_review::execute( &deps(&reviews, &events), EditReviewCommand { + rating: None, review_id: review_id.value(), requesting_user_id: user_id.value(), - rating: None, - comment: None, - watched_at: None, watch_medium: Some(Some(WatchMedium::Cinema)), - spoilers: None, + ..edit_review_command(1) }, ) .await diff --git a/crates/application/src/diary/tests/get_movie_social_page.rs b/crates/application/src/diary/tests/get_movie_social_page.rs index c3780679..547b543b 100644 --- a/crates/application/src/diary/tests/get_movie_social_page.rs +++ b/crates/application/src/diary/tests/get_movie_social_page.rs @@ -1,12 +1,11 @@ +use domain::testing::test_movie_with_year; use std::sync::Arc; use uuid::Uuid; use domain::{ - models::Movie, ports::MovieCommand, testing::{FakeDiaryQuery, InMemoryMovieProfileRepository, InMemoryMovieRepository}, - value_objects::{MovieTitle, ReleaseYear}, }; use crate::{ @@ -39,13 +38,7 @@ async fn fails_when_movie_not_found() { async fn returns_movie_social_page() { let movies = InMemoryMovieRepository::new(); - let movie = Movie::new( - None, - MovieTitle::new("Social Movie".into()).unwrap(), - ReleaseYear::new(2024).unwrap(), - None, - None, - ); + let movie = test_movie_with_year("Social Movie", 2024); let movie_uuid = movie.id().value(); movies.upsert_movie(&movie).await.unwrap(); diff --git a/crates/application/src/diary/tests/get_review_history.rs b/crates/application/src/diary/tests/get_review_history.rs index b0131a7c..6306e956 100644 --- a/crates/application/src/diary/tests/get_review_history.rs +++ b/crates/application/src/diary/tests/get_review_history.rs @@ -1,11 +1,7 @@ +use domain::testing::test_movie_with_year; use std::sync::Arc; -use domain::{ - models::Movie, - ports::DiaryQuery, - services::review_history::Trend, - value_objects::{MovieTitle, ReleaseYear}, -}; +use domain::{ports::DiaryQuery, services::review_history::Trend}; use crate::{ diary::deps::GetReviewHistoryDeps, diary::get_review_history, @@ -14,13 +10,7 @@ use crate::{ #[tokio::test] async fn returns_empty_history() { - let movie = Movie::new( - None, - MovieTitle::new("Test".into()).unwrap(), - ReleaseYear::new(2024).unwrap(), - None, - None, - ); + let movie = test_movie_with_year("Test", 2024); let movie_id = movie.id().value(); let diary = domain::testing::FakeDiaryQuery::new(); diff --git a/crates/application/src/diary/tests/get_user_feed.rs b/crates/application/src/diary/tests/get_user_feed.rs index 76e06879..6ada6df8 100644 --- a/crates/application/src/diary/tests/get_user_feed.rs +++ b/crates/application/src/diary/tests/get_user_feed.rs @@ -1,13 +1,14 @@ +use crate::test_support::register_command; +use domain::testing::{test_movie_with_year, test_review}; use std::sync::Arc; use uuid::Uuid; use domain::errors::DomainError; -use domain::models::{DiaryEntry, Movie, Review, UserRole, collections::Paginated}; +use domain::models::{DiaryEntry, collections::Paginated}; use domain::testing::FakeDiaryQuery; -use domain::value_objects::{Email, MovieTitle, Rating, ReleaseYear, UserId}; +use domain::value_objects::{Email, UserId}; -use crate::auth::commands::RegisterCommand; use crate::auth::deps::RegisterDeps; use crate::auth::register; use crate::diary::deps::GetUserFeedDeps; @@ -20,17 +21,9 @@ async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) { password_hasher: b.password_hasher.clone(), config: b.config.clone(), }; - register::execute( - &deps, - RegisterCommand { - email: email.into(), - username: username.into(), - password: "password123".into(), - role: UserRole::Standard, - }, - ) - .await - .unwrap(); + register::execute(&deps, register_command(email, username)) + .await + .unwrap(); } #[tokio::test] @@ -43,23 +36,8 @@ async fn user_feed_carries_author_and_entries() { let uid = user.id().value(); let diary = FakeDiaryQuery::new(); - let movie = Movie::new( - None, - MovieTitle::new("Feed Movie".into()).unwrap(), - ReleaseYear::new(2020).unwrap(), - None, - None, - ); - let review = Review::new( - movie.id().clone(), - UserId::from_uuid(uid), - Rating::new(5).unwrap(), - None, - chrono::Utc::now().naive_utc(), - None, - false, - ) - .unwrap(); + let movie = test_movie_with_year("Feed Movie", 2020); + let review = test_review(movie.id(), &UserId::from_uuid(uid), 5); diary.set_diary_page(Paginated { items: vec![DiaryEntry::new(movie, review)], total_count: 1, diff --git a/crates/application/src/diary/tests/log_review.rs b/crates/application/src/diary/tests/log_review.rs index a4696a17..f511bfdd 100644 --- a/crates/application/src/diary/tests/log_review.rs +++ b/crates/application/src/diary/tests/log_review.rs @@ -1,20 +1,14 @@ +use crate::test_support::{log_review_command, log_review_command_manual}; +use domain::testing::test_movie_with_year; use std::sync::Arc; -use chrono::Utc; - -use domain::{ - models::Movie, - ports::MovieCommand, - value_objects::{MovieTitle, ReleaseYear}, -}; +use domain::ports::MovieCommand; use domain::testing::{InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher}; use crate::{ - diary::commands::{LogReviewCommand, MovieInput}, - diary::log_review, - diary::review_logger::DefaultReviewLogger, - test_helpers::TestContextBuilder, + diary::commands::LogReviewCommand, diary::log_review, + diary::review_logger::DefaultReviewLogger, test_helpers::TestContextBuilder, }; fn build_logger( @@ -32,26 +26,6 @@ fn build_logger( )) } -fn movie_input_manual(title: &str, year: u16) -> MovieInput { - MovieInput { - movie_id: None, - external_metadata_id: None, - manual_title: Some(title.to_string()), - manual_release_year: Some(year), - manual_director: None, - } -} - -fn movie_input_by_id(id: uuid::Uuid) -> MovieInput { - MovieInput { - movie_id: Some(id), - external_metadata_id: None, - manual_title: None, - manual_release_year: None, - manual_director: None, - } -} - #[tokio::test] async fn test_log_review_creates_movie_and_review() { let movies = InMemoryMovieRepository::new(); @@ -60,15 +34,7 @@ async fn test_log_review_creates_movie_and_review() { let logger = build_logger(&movies, &reviews, &events); let user_id = uuid::Uuid::new_v4(); - let cmd = LogReviewCommand { - user_id, - input: movie_input_manual("Blade Runner", 1982), - rating: 4, - comment: None, - watched_at: Utc::now().naive_utc(), - watch_medium: None, - spoilers: false, - }; + let cmd = log_review_command_manual(user_id, "Blade Runner", 1982); log_review::execute(&logger, cmd).await.unwrap(); @@ -81,13 +47,7 @@ async fn test_log_review_reuses_existing_movie() { let movies = InMemoryMovieRepository::new(); let reviews = InMemoryReviewRepository::new(); - let existing_movie = Movie::new( - None, - MovieTitle::new("Alien".into()).unwrap(), - ReleaseYear::new(1979).unwrap(), - None, - None, - ); + let existing_movie = test_movie_with_year("Alien", 1979); let movie_uuid = existing_movie.id().value(); movies.upsert_movie(&existing_movie).await.unwrap(); @@ -95,13 +55,8 @@ async fn test_log_review_reuses_existing_movie() { let logger = build_logger(&movies, &reviews, &events); let cmd = LogReviewCommand { - user_id: uuid::Uuid::new_v4(), - input: movie_input_by_id(movie_uuid), rating: 5, - comment: None, - watched_at: Utc::now().naive_utc(), - watch_medium: None, - spoilers: false, + ..log_review_command(uuid::Uuid::new_v4(), movie_uuid) }; log_review::execute(&logger, cmd).await.unwrap(); @@ -118,13 +73,8 @@ async fn test_log_review_with_invalid_rating_fails() { let logger = build_logger(&movies, &reviews, &events); let cmd = LogReviewCommand { - user_id: uuid::Uuid::new_v4(), - input: movie_input_manual("Some Film", 2000), rating: 6, - comment: None, - watched_at: Utc::now().naive_utc(), - watch_medium: None, - spoilers: false, + ..log_review_command_manual(uuid::Uuid::new_v4(), "Some Film", 2000) }; let result = log_review::execute(&logger, cmd).await; assert!(result.is_err(), "rating > 5 should fail"); diff --git a/crates/application/src/diary/tests/movie_resolver.rs b/crates/application/src/diary/tests/movie_resolver.rs index b9416a83..7387c9ad 100644 --- a/crates/application/src/diary/tests/movie_resolver.rs +++ b/crates/application/src/diary/tests/movie_resolver.rs @@ -1,5 +1,6 @@ use super::*; use crate::diary::commands::MovieInput; +use domain::testing::test_movie_with_year; use domain::{ errors::DomainError, models::{MetadataSearchCriteria, Movie}, @@ -17,16 +18,6 @@ fn make_input(ext_id: Option<&str>, title: Option<&str>, year: Option) -> M } } -fn make_movie() -> Movie { - Movie::new( - None, - MovieTitle::new("Inception".to_string()).unwrap(), - ReleaseYear::new(2010).unwrap(), - None, - None, - ) -} - struct RepoWithExternalMovie(Movie); struct RepoEmpty; struct RepoWithTitleMatch(Movie); @@ -213,7 +204,7 @@ fn external_id_strategy_cannot_handle_cmd_without_id() { #[tokio::test] async fn external_id_strategy_returns_cached_movie() { - let movie = make_movie(); + let movie = test_movie_with_year("Inception", 2010); let repo = RepoWithExternalMovie(movie.clone()); let meta = MetaErrors; let deps = MovieResolverDeps { @@ -227,7 +218,7 @@ async fn external_id_strategy_returns_cached_movie() { #[tokio::test] async fn external_id_strategy_fetches_from_metadata_when_not_cached() { - let movie = make_movie(); + let movie = test_movie_with_year("Inception", 2010); let repo = RepoEmpty; let meta = MetaReturnsMovie(movie); let deps = MovieResolverDeps { @@ -268,7 +259,7 @@ fn title_strategy_cannot_handle_cmd_without_title() { #[tokio::test] async fn title_strategy_fetches_from_metadata() { - let movie = make_movie(); + let movie = test_movie_with_year("Inception", 2010); let repo = RepoEmpty; let meta = MetaReturnsMovie(movie); let deps = MovieResolverDeps { @@ -309,7 +300,7 @@ fn manual_strategy_cannot_handle_cmd_without_title() { #[tokio::test] async fn manual_strategy_returns_existing_movie() { - let movie = make_movie(); + let movie = test_movie_with_year("Inception", 2010); let repo = RepoWithTitleMatch(movie.clone()); let meta = MetaErrors; let deps = MovieResolverDeps { @@ -365,7 +356,7 @@ async fn resolver_returns_error_when_no_strategy_matches() { #[tokio::test] async fn resolver_uses_cached_movie_when_external_id_matches() { - let movie = make_movie(); + let movie = test_movie_with_year("Inception", 2010); let repo = RepoWithExternalMovie(movie.clone()); let meta = MetaErrors; let deps = MovieResolverDeps { diff --git a/crates/application/src/diary/tests/review_logger.rs b/crates/application/src/diary/tests/review_logger.rs index ac2ea4e7..3b8fbaf0 100644 --- a/crates/application/src/diary/tests/review_logger.rs +++ b/crates/application/src/diary/tests/review_logger.rs @@ -1,7 +1,8 @@ +use crate::test_support::{log_review_command, log_review_command_manual}; +use domain::testing::{test_movie_with_external_id, test_movie_with_year}; use std::sync::Arc; use async_trait::async_trait; -use chrono::Utc; use domain::{ errors::DomainError, models::WatchlistEntry, @@ -11,11 +12,11 @@ use domain::{ FakeMetadataClient, InMemoryMovieRepository, InMemoryReviewRepository, InMemoryWatchlistRepository, NoopEventPublisher, }, - value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear, UserId}, + value_objects::{ExternalMetadataId, MovieId, PosterUrl, UserId}, }; use uuid::Uuid; -use crate::diary::commands::{LogReviewCommand, MovieInput}; +use crate::diary::commands::LogReviewCommand; use crate::diary::review_logger::DefaultReviewLogger; use crate::ports::ReviewLogger; @@ -44,21 +45,7 @@ async fn logs_review_with_manual_movie() { let logger = make_logger(&movies, &reviews, &watchlist, &events); let uid = Uuid::new_v4(); - let cmd = LogReviewCommand { - user_id: uid, - input: MovieInput { - movie_id: None, - external_metadata_id: None, - manual_title: Some("Test Film".into()), - manual_release_year: Some(2024), - manual_director: None, - }, - rating: 4, - comment: None, - watched_at: Utc::now().naive_utc(), - watch_medium: None, - spoilers: false, - }; + let cmd = log_review_command_manual(uid, "Test Film", 2024); logger.log_review(cmd).await.unwrap(); @@ -78,13 +65,7 @@ async fn removes_from_watchlist_on_review() { let user_id = UserId::from_uuid(uid); // Create and store movie - let movie = Movie::new( - None, - MovieTitle::new("Watchlisted Film".into()).unwrap(), - ReleaseYear::new(2024).unwrap(), - None, - None, - ); + let movie = test_movie_with_year("Watchlisted Film", 2024); let movie_id = movie.id().value(); movies.upsert_movie(&movie).await.unwrap(); @@ -95,19 +76,8 @@ async fn removes_from_watchlist_on_review() { // Log review for same movie let cmd = LogReviewCommand { - user_id: uid, - input: MovieInput { - movie_id: Some(movie_id), - external_metadata_id: None, - manual_title: None, - manual_release_year: None, - manual_director: None, - }, rating: 5, - comment: None, - watched_at: Utc::now().naive_utc(), - watch_medium: None, - spoilers: false, + ..log_review_command(uid, movie_id) }; logger.log_review(cmd).await.unwrap(); @@ -123,30 +93,13 @@ async fn logs_review_with_existing_movie_by_id() { let events = NoopEventPublisher::new(); let logger = make_logger(&movies, &reviews, &watchlist, &events); - let movie = Movie::new( - None, - MovieTitle::new("Existing Film".into()).unwrap(), - ReleaseYear::new(2020).unwrap(), - None, - None, - ); + let movie = test_movie_with_year("Existing Film", 2020); let movie_uuid = movie.id().value(); movies.upsert_movie(&movie).await.unwrap(); let cmd = LogReviewCommand { - user_id: Uuid::new_v4(), - input: MovieInput { - movie_id: Some(movie_uuid), - external_metadata_id: None, - manual_title: None, - manual_release_year: None, - manual_director: None, - }, rating: 3, - comment: None, - watched_at: Utc::now().naive_utc(), - watch_medium: None, - spoilers: false, + ..log_review_command(Uuid::new_v4(), movie_uuid) }; logger.log_review(cmd).await.unwrap(); @@ -163,21 +116,7 @@ async fn existing_movie_not_found_returns_error() { let events = NoopEventPublisher::new(); let logger = make_logger(&movies, &reviews, &watchlist, &events); - let cmd = LogReviewCommand { - user_id: Uuid::new_v4(), - input: MovieInput { - movie_id: Some(Uuid::new_v4()), - external_metadata_id: None, - manual_title: None, - manual_release_year: None, - manual_director: None, - }, - rating: 4, - comment: None, - watched_at: Utc::now().naive_utc(), - watch_medium: None, - spoilers: false, - }; + let cmd = log_review_command(Uuid::new_v4(), Uuid::new_v4()); assert!(logger.log_review(cmd).await.is_err()); } @@ -191,19 +130,8 @@ async fn invalid_rating_returns_error() { let logger = make_logger(&movies, &reviews, &watchlist, &events); let cmd = LogReviewCommand { - user_id: Uuid::new_v4(), - input: MovieInput { - movie_id: None, - external_metadata_id: None, - manual_title: Some("Film".into()), - manual_release_year: Some(2024), - manual_director: None, - }, rating: 6, - comment: None, - watched_at: Utc::now().naive_utc(), - watch_medium: None, - spoilers: false, + ..log_review_command_manual(Uuid::new_v4(), "Film", 2024) }; let result = logger.log_review(cmd).await; @@ -221,21 +149,7 @@ async fn watchlist_not_present_does_not_publish_removed() { let events = NoopEventPublisher::new(); let logger = make_logger(&movies, &reviews, &watchlist, &events); - let cmd = LogReviewCommand { - user_id: Uuid::new_v4(), - input: MovieInput { - movie_id: None, - external_metadata_id: None, - manual_title: Some("No Watchlist Film".into()), - manual_release_year: Some(2024), - manual_director: None, - }, - rating: 4, - comment: None, - watched_at: Utc::now().naive_utc(), - watch_medium: None, - spoilers: false, - }; + let cmd = log_review_command_manual(Uuid::new_v4(), "No Watchlist Film", 2024); logger.log_review(cmd).await.unwrap(); @@ -256,12 +170,10 @@ impl MetadataClient for MetadataClientWithExternalId { &self, _criteria: &MetadataSearchCriteria, ) -> Result { - Ok(Movie::new( - Some(ExternalMetadataId::new("tmdb:99999".into()).unwrap()), - MovieTitle::new("Discovered Film".into()).unwrap(), - ReleaseYear::new(2024).unwrap(), - None, - None, + Ok(test_movie_with_external_id( + "Discovered Film", + 2024, + ExternalMetadataId::new("tmdb:99999".into()).unwrap(), )) } @@ -290,19 +202,8 @@ async fn publishes_movie_discovered_for_new_movie_with_external_id() { ); let cmd = LogReviewCommand { - user_id: Uuid::new_v4(), - input: MovieInput { - movie_id: None, - external_metadata_id: None, - manual_title: Some("Discovered Film".into()), - manual_release_year: Some(2024), - manual_director: None, - }, rating: 5, - comment: None, - watched_at: Utc::now().naive_utc(), - watch_medium: None, - spoilers: false, + ..log_review_command_manual(Uuid::new_v4(), "Discovered Film", 2024) }; logger.log_review(cmd).await.unwrap(); diff --git a/crates/application/src/goals/tests/create.rs b/crates/application/src/goals/tests/create.rs index 7a9f9783..b3db8e00 100644 --- a/crates/application/src/goals/tests/create.rs +++ b/crates/application/src/goals/tests/create.rs @@ -1,11 +1,12 @@ +use crate::test_support::create_goal_command; use std::sync::Arc; use domain::events::DomainEvent; use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher}; use uuid::Uuid; +use crate::goals::create; use crate::goals::deps::GoalCommandDeps; -use crate::goals::{commands::CreateGoalCommand, create}; use crate::test_helpers::TestContextBuilder; #[tokio::test] @@ -20,16 +21,9 @@ async fn creates_goal_and_returns_progress() { event_publisher: Arc::clone(&events) as _, }; - let result = create::execute( - &deps, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 50, - }, - ) - .await - .unwrap(); + let result = create::execute(&deps, create_goal_command(2025, 50)) + .await + .unwrap(); assert_eq!(result.goal.year(), 2025); assert_eq!(result.goal.target_count(), 50); @@ -50,16 +44,9 @@ async fn creates_goal_with_review_count() { event_publisher: Arc::clone(&events) as _, }; - let result = create::execute( - &deps, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 50, - }, - ) - .await - .unwrap(); + let result = create::execute(&deps, create_goal_command(2025, 50)) + .await + .unwrap(); assert_eq!(result.current_count, 5); assert_eq!(goals.count(), 1); @@ -76,16 +63,9 @@ async fn emits_goal_created_event() { event_publisher: Arc::clone(&events) as _, }; - create::execute( - &deps, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 10, - }, - ) - .await - .unwrap(); + create::execute(&deps, create_goal_command(2025, 10)) + .await + .unwrap(); let published = events.published(); assert!( @@ -104,23 +84,11 @@ async fn rejects_duplicate_year() { stats: b.stats_repo.clone(), event_publisher: b.event_publisher.clone(), }; - let cmd = CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 10, - }; + let cmd = create_goal_command(2025, 10); create::execute(&deps, cmd).await.unwrap(); - let result = create::execute( - &deps, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 20, - }, - ) - .await; + let result = create::execute(&deps, create_goal_command(2025, 20)).await; assert!(result.is_err()); } @@ -134,15 +102,7 @@ async fn rejects_year_before_2020() { stats: b.stats_repo.clone(), event_publisher: b.event_publisher.clone(), }; - let result = create::execute( - &deps, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2019, - target_count: 10, - }, - ) - .await; + let result = create::execute(&deps, create_goal_command(2019, 10)).await; assert!(result.is_err()); } @@ -156,15 +116,7 @@ async fn rejects_zero_target() { stats: b.stats_repo.clone(), event_publisher: b.event_publisher.clone(), }; - let result = create::execute( - &deps, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 0, - }, - ) - .await; + let result = create::execute(&deps, create_goal_command(2025, 0)).await; assert!(result.is_err()); } diff --git a/crates/application/src/goals/tests/delete.rs b/crates/application/src/goals/tests/delete.rs index 408cbdf2..07aa2724 100644 --- a/crates/application/src/goals/tests/delete.rs +++ b/crates/application/src/goals/tests/delete.rs @@ -1,13 +1,11 @@ +use crate::test_support::create_goal_command; use std::sync::Arc; use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher}; use uuid::Uuid; use crate::goals::deps::GoalCommandDeps; -use crate::goals::{ - commands::{CreateGoalCommand, DeleteGoalCommand}, - create, delete, -}; +use crate::goals::{commands::DeleteGoalCommand, create, delete}; use crate::test_helpers::TestContextBuilder; #[tokio::test] @@ -22,16 +20,9 @@ async fn deletes_existing_goal() { event_publisher: Arc::clone(&events) as _, }; - create::execute( - &deps, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 10, - }, - ) - .await - .unwrap(); + create::execute(&deps, create_goal_command(2025, 10)) + .await + .unwrap(); assert_eq!(goals.count(), 1); delete::execute( diff --git a/crates/application/src/goals/tests/get.rs b/crates/application/src/goals/tests/get.rs index 8ffb37b9..020b63e9 100644 --- a/crates/application/src/goals/tests/get.rs +++ b/crates/application/src/goals/tests/get.rs @@ -1,7 +1,8 @@ +use crate::test_support::create_goal_command; use uuid::Uuid; use crate::goals::deps::{GoalCommandDeps, GoalQueryDeps}; -use crate::goals::{commands::CreateGoalCommand, create, get, queries::GetGoalQuery}; +use crate::goals::{create, get, queries::GetGoalQuery}; use crate::test_helpers::TestContextBuilder; #[tokio::test] @@ -18,16 +19,9 @@ async fn returns_goal_when_exists() { stats: b.stats_repo.clone(), }; - create::execute( - &cmd_deps, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 50, - }, - ) - .await - .unwrap(); + create::execute(&cmd_deps, create_goal_command(2025, 50)) + .await + .unwrap(); let result = get::execute( &query_deps, diff --git a/crates/application/src/goals/tests/list.rs b/crates/application/src/goals/tests/list.rs index 3edb380f..05d8e688 100644 --- a/crates/application/src/goals/tests/list.rs +++ b/crates/application/src/goals/tests/list.rs @@ -1,7 +1,8 @@ +use crate::test_support::create_goal_command; use uuid::Uuid; use crate::goals::deps::{GoalCommandDeps, GoalQueryDeps}; -use crate::goals::{commands::CreateGoalCommand, create, list, queries::ListGoalsQuery}; +use crate::goals::{create, list, queries::ListGoalsQuery}; use crate::test_helpers::TestContextBuilder; #[tokio::test] @@ -38,16 +39,9 @@ async fn returns_all_goals_for_user() { }; for year in [2023, 2024, 2025] { - create::execute( - &cmd_deps, - CreateGoalCommand { - user_id: Uuid::nil(), - year, - target_count: 10, - }, - ) - .await - .unwrap(); + create::execute(&cmd_deps, create_goal_command(year, 10)) + .await + .unwrap(); } let result = list::execute( diff --git a/crates/application/src/goals/tests/update.rs b/crates/application/src/goals/tests/update.rs index e973ee55..9ec7e650 100644 --- a/crates/application/src/goals/tests/update.rs +++ b/crates/application/src/goals/tests/update.rs @@ -1,10 +1,7 @@ -use uuid::Uuid; +use crate::test_support::{create_goal_command, update_goal_command}; use crate::goals::deps::GoalCommandDeps; -use crate::goals::{ - commands::{CreateGoalCommand, UpdateGoalCommand}, - create, update, -}; +use crate::goals::{create, update}; use crate::test_helpers::TestContextBuilder; #[tokio::test] @@ -17,27 +14,13 @@ async fn updates_target_count() { event_publisher: b.event_publisher.clone(), }; - create::execute( - &deps, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 10, - }, - ) - .await - .unwrap(); + create::execute(&deps, create_goal_command(2025, 10)) + .await + .unwrap(); - let result = update::execute( - &deps, - UpdateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 100, - }, - ) - .await - .unwrap(); + let result = update::execute(&deps, update_goal_command(2025, 100)) + .await + .unwrap(); assert_eq!(result.goal.target_count(), 100); } @@ -51,15 +34,7 @@ async fn fails_when_goal_not_found() { stats: b.stats_repo.clone(), event_publisher: b.event_publisher.clone(), }; - let result = update::execute( - &deps, - UpdateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 10, - }, - ) - .await; + let result = update::execute(&deps, update_goal_command(2025, 10)).await; assert!(result.is_err()); } @@ -74,26 +49,11 @@ async fn rejects_zero_target() { event_publisher: b.event_publisher.clone(), }; - create::execute( - &deps, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 10, - }, - ) - .await - .unwrap(); + create::execute(&deps, create_goal_command(2025, 10)) + .await + .unwrap(); - let result = update::execute( - &deps, - UpdateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 0, - }, - ) - .await; + let result = update::execute(&deps, update_goal_command(2025, 0)).await; assert!(result.is_err()); } diff --git a/crates/application/src/import/tests/apply_mapping.rs b/crates/application/src/import/tests/apply_mapping.rs index 86d3417a..93d7755b 100644 --- a/crates/application/src/import/tests/apply_mapping.rs +++ b/crates/application/src/import/tests/apply_mapping.rs @@ -1,23 +1,21 @@ +use crate::test_support::{apply_import_mapping_command, create_import_session_command}; +use domain::testing::{test_movie_with_external_id, test_movie_with_year}; use std::sync::Arc; use uuid::Uuid; use domain::{ models::{ - AnnotatedRow, Movie, + AnnotatedRow, import::{ImportRow, ParsedFile, RowResult}, }, ports::{DocumentParser, MovieCommand}, testing::{InMemoryImportSessionRepository, InMemoryMovieRepository}, - value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear}, + value_objects::ExternalMetadataId, }; use crate::import::deps::{ApplyMappingDeps, CreateSessionDeps}; -use crate::import::{ - apply_mapping, - commands::{ApplyImportMappingCommand, CreateImportSessionCommand}, - create_session, -}; +use crate::import::{apply_mapping, create_session}; use crate::test_helpers::TestContextBuilder; #[tokio::test] @@ -33,11 +31,7 @@ async fn applies_mapping_to_session() { let session = create_session::execute( &create_deps, - CreateImportSessionCommand { - user_id, - bytes: b"title\nTest".to_vec(), - format: domain::models::FileFormat::Csv, - }, + create_import_session_command(user_id, b"title\nTest"), ) .await .unwrap(); @@ -50,11 +44,7 @@ async fn applies_mapping_to_session() { let rows = apply_mapping::execute( &mapping_deps, - ApplyImportMappingCommand { - user_id, - session_id: session.session_id.value(), - mappings: vec![], - }, + apply_import_mapping_command(user_id, session.session_id.value()), ) .await .unwrap(); @@ -75,11 +65,7 @@ async fn fails_when_session_not_found() { let result = apply_mapping::execute( &deps, - ApplyImportMappingCommand { - user_id: Uuid::new_v4(), - session_id: Uuid::new_v4(), - mappings: vec![], - }, + apply_import_mapping_command(Uuid::new_v4(), Uuid::new_v4()), ) .await; @@ -123,13 +109,7 @@ async fn marks_duplicate_by_external_id() { let sessions = InMemoryImportSessionRepository::new(); let ext_id = ExternalMetadataId::new("tt1234567".into()).unwrap(); - let movie = Movie::new( - Some(ext_id), - MovieTitle::new("Known Movie".into()).unwrap(), - ReleaseYear::new(2020).unwrap(), - None, - None, - ); + let movie = test_movie_with_external_id("Known Movie", 2020, ext_id); movies.upsert_movie(&movie).await.unwrap(); let parser = Arc::new(DuplicateTestParser { @@ -150,11 +130,7 @@ async fn marks_duplicate_by_external_id() { let session = create_session::execute( &create_deps, - CreateImportSessionCommand { - user_id, - bytes: b"title\nKnown Movie".to_vec(), - format: domain::models::FileFormat::Csv, - }, + create_import_session_command(user_id, b"title\nKnown Movie"), ) .await .unwrap(); @@ -167,11 +143,7 @@ async fn marks_duplicate_by_external_id() { let rows = apply_mapping::execute( &mapping_deps, - ApplyImportMappingCommand { - user_id, - session_id: session.session_id.value(), - mappings: vec![], - }, + apply_import_mapping_command(user_id, session.session_id.value()), ) .await .unwrap(); @@ -185,13 +157,7 @@ async fn marks_duplicate_by_title_and_year() { let movies = InMemoryMovieRepository::new(); let sessions = InMemoryImportSessionRepository::new(); - let movie = Movie::new( - None, - MovieTitle::new("Duplicate Film".into()).unwrap(), - ReleaseYear::new(2022).unwrap(), - None, - None, - ); + let movie = test_movie_with_year("Duplicate Film", 2022); movies.upsert_movie(&movie).await.unwrap(); let parser = Arc::new(DuplicateTestParser { @@ -211,11 +177,7 @@ async fn marks_duplicate_by_title_and_year() { let session = create_session::execute( &create_deps, - CreateImportSessionCommand { - user_id, - bytes: b"title\nDuplicate Film".to_vec(), - format: domain::models::FileFormat::Csv, - }, + create_import_session_command(user_id, b"title\nDuplicate Film"), ) .await .unwrap(); @@ -228,11 +190,7 @@ async fn marks_duplicate_by_title_and_year() { let rows = apply_mapping::execute( &mapping_deps, - ApplyImportMappingCommand { - user_id, - session_id: session.session_id.value(), - mappings: vec![], - }, + apply_import_mapping_command(user_id, session.session_id.value()), ) .await .unwrap(); diff --git a/crates/application/src/import/tests/apply_profile.rs b/crates/application/src/import/tests/apply_profile.rs index 59398e7a..c90caaa9 100644 --- a/crates/application/src/import/tests/apply_profile.rs +++ b/crates/application/src/import/tests/apply_profile.rs @@ -1,3 +1,4 @@ +use crate::test_support::apply_import_profile_command; use std::sync::Arc; use chrono::Utc; @@ -7,8 +8,8 @@ use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepo use domain::value_objects::{ImportProfileId, UserId}; use uuid::Uuid; +use crate::import::apply_profile; use crate::import::deps::ApplyProfileDeps; -use crate::import::{apply_profile, commands::ApplyImportProfileCommand}; #[tokio::test] async fn fails_when_profile_not_found() { @@ -22,11 +23,7 @@ async fn fails_when_profile_not_found() { let result = apply_profile::execute( &deps, - ApplyImportProfileCommand { - user_id: Uuid::new_v4(), - session_id: Uuid::new_v4(), - profile_id: Uuid::new_v4(), - }, + apply_import_profile_command(Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()), ) .await; @@ -56,11 +53,7 @@ async fn fails_when_session_not_found() { let result = apply_profile::execute( &deps, - ApplyImportProfileCommand { - user_id, - session_id: Uuid::new_v4(), - profile_id: profile_id.value(), - }, + apply_import_profile_command(user_id, Uuid::new_v4(), profile_id.value()), ) .await; @@ -98,11 +91,7 @@ async fn applies_profile_mappings_to_session() { apply_profile::execute( &deps, - ApplyImportProfileCommand { - user_id, - session_id: session_id.value(), - profile_id: profile_id.value(), - }, + apply_import_profile_command(user_id, session_id.value(), profile_id.value()), ) .await .unwrap(); diff --git a/crates/application/src/import/tests/apply_profile_and_map.rs b/crates/application/src/import/tests/apply_profile_and_map.rs index a5ccb6b1..8906d5d1 100644 --- a/crates/application/src/import/tests/apply_profile_and_map.rs +++ b/crates/application/src/import/tests/apply_profile_and_map.rs @@ -1,19 +1,17 @@ +use crate::test_support::create_import_session_command; use std::sync::Arc; use chrono::Utc; use uuid::Uuid; use domain::models::import::{DomainField, Transform}; -use domain::models::{FieldMapping, FileFormat, ImportProfile}; +use domain::models::{FieldMapping, ImportProfile}; use domain::ports::{ImportProfileRepository, ImportSessionRepository}; use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepository}; use domain::value_objects::{ImportProfileId, UserId}; use crate::import::deps::{ApplyProfileAndMapDeps, CreateSessionDeps}; -use crate::import::{ - apply_profile_and_map, commands::ApplyProfileAndMapCommand, - commands::CreateImportSessionCommand, create_session, -}; +use crate::import::{apply_profile_and_map, commands::ApplyProfileAndMapCommand, create_session}; use crate::test_helpers::TestContextBuilder; #[tokio::test] @@ -69,11 +67,7 @@ async fn applies_profile_then_regenerates_mapping() { }; let created = create_session::execute( &create_deps, - CreateImportSessionCommand { - user_id, - bytes: b"title\nTest".to_vec(), - format: FileFormat::Csv, - }, + create_import_session_command(user_id, b"title\nTest"), ) .await .unwrap(); diff --git a/crates/application/src/import/tests/create_session.rs b/crates/application/src/import/tests/create_session.rs index bc7f3c66..9ebe974c 100644 --- a/crates/application/src/import/tests/create_session.rs +++ b/crates/application/src/import/tests/create_session.rs @@ -1,11 +1,12 @@ +use crate::test_support::create_import_session_command; use std::sync::Arc; use uuid::Uuid; use domain::testing::InMemoryImportSessionRepository; +use crate::import::create_session; use crate::import::deps::CreateSessionDeps; -use crate::import::{commands::CreateImportSessionCommand, create_session}; use crate::test_helpers::TestContextBuilder; #[tokio::test] @@ -20,11 +21,7 @@ async fn creates_session_with_parsed_file() { let result = create_session::execute( &deps, - CreateImportSessionCommand { - user_id: Uuid::new_v4(), - bytes: b"col1\nval1".to_vec(), - format: domain::models::FileFormat::Csv, - }, + create_import_session_command(Uuid::new_v4(), b"col1\nval1"), ) .await .unwrap(); diff --git a/crates/application/src/import/tests/execute.rs b/crates/application/src/import/tests/execute.rs index 197b7061..cc9fddf0 100644 --- a/crates/application/src/import/tests/execute.rs +++ b/crates/application/src/import/tests/execute.rs @@ -1,3 +1,4 @@ +use crate::test_support::execute_import_command; use std::sync::Arc; use domain::models::{AnnotatedRow, ImportSession, import::RowResult}; @@ -59,9 +60,8 @@ async fn imports_confirmed_rows() { let result = execute::execute( &deps, ExecuteImportCommand { - user_id: uid, - session_id: sid.value(), confirmed_indices: vec![0, 1], + ..execute_import_command(uid, sid.value()) }, ) .await @@ -86,16 +86,9 @@ async fn skips_unconfirmed_rows() { review_logger: Arc::new(NoopReviewLogger), }; - let result = execute::execute( - &deps, - ExecuteImportCommand { - user_id: uid, - session_id: sid.value(), - confirmed_indices: vec![0], - }, - ) - .await - .unwrap(); + let result = execute::execute(&deps, execute_import_command(uid, sid.value())) + .await + .unwrap(); assert_eq!(result.imported, 1); assert_eq!(result.skipped_duplicates, 1); @@ -113,9 +106,8 @@ async fn fails_when_session_not_found() { let result = execute::execute( &deps, ExecuteImportCommand { - user_id: Uuid::new_v4(), - session_id: Uuid::new_v4(), confirmed_indices: vec![], + ..execute_import_command(Uuid::new_v4(), Uuid::new_v4()) }, ) .await; @@ -149,16 +141,9 @@ async fn handles_datetime_format() { review_logger: Arc::new(NoopReviewLogger), }; - let result = execute::execute( - &deps, - ExecuteImportCommand { - user_id: uid, - session_id: sid.value(), - confirmed_indices: vec![0], - }, - ) - .await - .unwrap(); + let result = execute::execute(&deps, execute_import_command(uid, sid.value())) + .await + .unwrap(); assert_eq!(result.imported, 1); assert!(result.failed.is_empty()); @@ -190,16 +175,9 @@ async fn fails_on_invalid_rating() { review_logger: Arc::new(NoopReviewLogger), }; - let result = execute::execute( - &deps, - ExecuteImportCommand { - user_id: uid, - session_id: sid.value(), - confirmed_indices: vec![0], - }, - ) - .await - .unwrap(); + let result = execute::execute(&deps, execute_import_command(uid, sid.value())) + .await + .unwrap(); assert_eq!(result.imported, 0); assert_eq!(result.failed.len(), 1); @@ -231,16 +209,9 @@ async fn fails_on_missing_watched_at() { review_logger: Arc::new(NoopReviewLogger), }; - let result = execute::execute( - &deps, - ExecuteImportCommand { - user_id: uid, - session_id: sid.value(), - confirmed_indices: vec![0], - }, - ) - .await - .unwrap(); + let result = execute::execute(&deps, execute_import_command(uid, sid.value())) + .await + .unwrap(); assert_eq!(result.imported, 0); assert_eq!(result.failed.len(), 1); @@ -272,16 +243,9 @@ async fn imports_row_with_external_metadata_id() { review_logger: Arc::new(NoopReviewLogger), }; - let result = execute::execute( - &deps, - ExecuteImportCommand { - user_id: uid, - session_id: sid.value(), - confirmed_indices: vec![0], - }, - ) - .await - .unwrap(); + let result = execute::execute(&deps, execute_import_command(uid, sid.value())) + .await + .unwrap(); assert_eq!(result.imported, 1); assert!(result.failed.is_empty()); @@ -313,16 +277,9 @@ async fn imports_row_with_director_and_comment() { review_logger: Arc::new(NoopReviewLogger), }; - let result = execute::execute( - &deps, - ExecuteImportCommand { - user_id: uid, - session_id: sid.value(), - confirmed_indices: vec![0], - }, - ) - .await - .unwrap(); + let result = execute::execute(&deps, execute_import_command(uid, sid.value())) + .await + .unwrap(); assert_eq!(result.imported, 1); assert!(result.failed.is_empty()); @@ -354,16 +311,9 @@ async fn handles_space_separated_datetime_format() { review_logger: Arc::new(NoopReviewLogger), }; - let result = execute::execute( - &deps, - ExecuteImportCommand { - user_id: uid, - session_id: sid.value(), - confirmed_indices: vec![0], - }, - ) - .await - .unwrap(); + let result = execute::execute(&deps, execute_import_command(uid, sid.value())) + .await + .unwrap(); assert_eq!(result.imported, 1); assert!(result.failed.is_empty()); @@ -390,16 +340,9 @@ async fn reports_invalid_row_result_errors() { review_logger: Arc::new(NoopReviewLogger), }; - let result = execute::execute( - &deps, - ExecuteImportCommand { - user_id: uid, - session_id: sid.value(), - confirmed_indices: vec![0], - }, - ) - .await - .unwrap(); + let result = execute::execute(&deps, execute_import_command(uid, sid.value())) + .await + .unwrap(); assert_eq!(result.imported, 0); assert_eq!(result.failed.len(), 1); @@ -433,16 +376,9 @@ async fn fails_on_missing_rating() { review_logger: Arc::new(NoopReviewLogger), }; - let result = execute::execute( - &deps, - ExecuteImportCommand { - user_id: uid, - session_id: sid.value(), - confirmed_indices: vec![0], - }, - ) - .await - .unwrap(); + let result = execute::execute(&deps, execute_import_command(uid, sid.value())) + .await + .unwrap(); assert_eq!(result.imported, 0); assert_eq!(result.failed.len(), 1); @@ -475,16 +411,9 @@ async fn fails_on_unparseable_date() { review_logger: Arc::new(NoopReviewLogger), }; - let result = execute::execute( - &deps, - ExecuteImportCommand { - user_id: uid, - session_id: sid.value(), - confirmed_indices: vec![0], - }, - ) - .await - .unwrap(); + let result = execute::execute(&deps, execute_import_command(uid, sid.value())) + .await + .unwrap(); assert_eq!(result.imported, 0); assert_eq!(result.failed.len(), 1); @@ -517,16 +446,9 @@ async fn imports_row_without_release_year() { review_logger: Arc::new(NoopReviewLogger), }; - let result = execute::execute( - &deps, - ExecuteImportCommand { - user_id: uid, - session_id: sid.value(), - confirmed_indices: vec![0], - }, - ) - .await - .unwrap(); + let result = execute::execute(&deps, execute_import_command(uid, sid.value())) + .await + .unwrap(); assert_eq!(result.imported, 1); assert!(result.failed.is_empty()); @@ -547,16 +469,9 @@ async fn deletes_session_after_import() { review_logger: Arc::new(NoopReviewLogger), }; - execute::execute( - &deps, - ExecuteImportCommand { - user_id: uid, - session_id: sid.value(), - confirmed_indices: vec![0], - }, - ) - .await - .unwrap(); + execute::execute(&deps, execute_import_command(uid, sid.value())) + .await + .unwrap(); assert_eq!( sessions.count(), @@ -599,9 +514,8 @@ async fn imports_more_rows_than_concurrency_limit() { let result = execute::execute( &deps, ExecuteImportCommand { - user_id: uid, - session_id: sid.value(), confirmed_indices, + ..execute_import_command(uid, sid.value()) }, ) .await diff --git a/crates/application/src/integrations/tests/confirm.rs b/crates/application/src/integrations/tests/confirm.rs index ce18f3b5..9c7f3a99 100644 --- a/crates/application/src/integrations/tests/confirm.rs +++ b/crates/application/src/integrations/tests/confirm.rs @@ -1,3 +1,4 @@ +use crate::test_support::confirm_watch_events_command; use std::sync::Arc; use domain::models::{WatchEvent, WatchEventSource}; @@ -45,14 +46,7 @@ async fn confirms_watch_event_via_review_logger() { let result = confirm::execute( &deps(&watch_events, noop_logger()), - ConfirmWatchEventsCommand { - user_id: uid, - confirmations: vec![WatchEventConfirmation { - watch_event_id: event_id, - rating: 4, - comment: None, - }], - }, + confirm_watch_events_command(uid, event_id, 4), ) .await .unwrap(); @@ -131,14 +125,7 @@ async fn rejects_other_users_event() { let result = confirm::execute( &deps(&watch_events, noop_logger()), - ConfirmWatchEventsCommand { - user_id: intruder, - confirmations: vec![WatchEventConfirmation { - watch_event_id: event_id, - rating: 3, - comment: None, - }], - }, + confirm_watch_events_command(intruder, event_id, 3), ) .await; @@ -151,14 +138,7 @@ async fn fails_when_event_not_found() { let result = confirm::execute( &deps(&watch_events, noop_logger()), - ConfirmWatchEventsCommand { - user_id: Uuid::new_v4(), - confirmations: vec![WatchEventConfirmation { - watch_event_id: Uuid::new_v4(), - rating: 4, - comment: None, - }], - }, + confirm_watch_events_command(Uuid::new_v4(), Uuid::new_v4(), 4), ) .await; @@ -211,14 +191,7 @@ async fn confirms_event_with_movie_id() { let result = confirm::execute( &deps(&watch_events, review_logger), - ConfirmWatchEventsCommand { - user_id: uid, - confirmations: vec![WatchEventConfirmation { - watch_event_id: event_id, - rating: 4, - comment: None, - }], - }, + confirm_watch_events_command(uid, event_id, 4), ) .await .unwrap(); @@ -293,7 +266,6 @@ async fn confirms_multiple_events() { let result = confirm::execute( &deps(&watch_events, noop_logger()), ConfirmWatchEventsCommand { - user_id: uid, confirmations: vec![ WatchEventConfirmation { watch_event_id: id1, @@ -306,6 +278,7 @@ async fn confirms_multiple_events() { comment: None, }, ], + ..confirm_watch_events_command(uid, id1, 3) }, ) .await @@ -333,14 +306,7 @@ async fn confirms_event_without_year() { let result = confirm::execute( &deps(&watch_events, noop_logger()), - ConfirmWatchEventsCommand { - user_id: uid, - confirmations: vec![WatchEventConfirmation { - watch_event_id: event_id, - rating: 3, - comment: None, - }], - }, + confirm_watch_events_command(uid, event_id, 3), ) .await .unwrap(); diff --git a/crates/application/src/integrations/tests/dismiss.rs b/crates/application/src/integrations/tests/dismiss.rs index 3db9f356..e764efc6 100644 --- a/crates/application/src/integrations/tests/dismiss.rs +++ b/crates/application/src/integrations/tests/dismiss.rs @@ -1,3 +1,4 @@ +use crate::test_support::dismiss_watch_events_command; use std::sync::Arc; use domain::models::{WatchEvent, WatchEventSource}; @@ -7,7 +8,7 @@ use domain::value_objects::UserId; use uuid::Uuid; use crate::integrations::deps::DismissWatchEventsDeps; -use crate::integrations::{commands::DismissWatchEventsCommand, dismiss}; +use crate::integrations::dismiss; fn deps(watch_events: &Arc) -> DismissWatchEventsDeps { DismissWatchEventsDeps { @@ -22,10 +23,7 @@ async fn dismisses_empty_list_returns_zero() { let result = dismiss::execute( &deps(&events), - DismissWatchEventsCommand { - user_id: Uuid::new_v4(), - event_ids: vec![], - }, + dismiss_watch_events_command(Uuid::new_v4(), vec![]), ) .await .unwrap(); @@ -39,10 +37,7 @@ async fn fails_when_event_not_found() { let result = dismiss::execute( &deps(&events), - DismissWatchEventsCommand { - user_id: Uuid::new_v4(), - event_ids: vec![Uuid::new_v4()], - }, + dismiss_watch_events_command(Uuid::new_v4(), vec![Uuid::new_v4()]), ) .await; @@ -80,10 +75,7 @@ async fn dismisses_existing_events() { let result = dismiss::execute( &deps(&watch_events), - DismissWatchEventsCommand { - user_id: uid, - event_ids: vec![id1, id2], - }, + dismiss_watch_events_command(uid, vec![id1, id2]), ) .await .unwrap(); diff --git a/crates/application/src/integrations/tests/generate_token.rs b/crates/application/src/integrations/tests/generate_token.rs index 2b038efc..31227c5e 100644 --- a/crates/application/src/integrations/tests/generate_token.rs +++ b/crates/application/src/integrations/tests/generate_token.rs @@ -1,12 +1,12 @@ +use crate::test_support::generate_webhook_token_command; use std::sync::Arc; -use domain::models::WatchEventSource; use domain::ports::WebhookTokenRepository; use domain::testing::InMemoryWebhookTokenRepository; use uuid::Uuid; use crate::integrations::deps::GenerateWebhookTokenDeps; -use crate::integrations::{commands::GenerateWebhookTokenCommand, generate_token}; +use crate::integrations::generate_token; #[tokio::test] async fn generates_token_and_saves() { @@ -17,11 +17,7 @@ async fn generates_token_and_saves() { &GenerateWebhookTokenDeps { webhook_token: Arc::clone(&tokens), }, - GenerateWebhookTokenCommand { - user_id, - provider: WatchEventSource::Jellyfin, - label: None, - }, + generate_webhook_token_command(user_id), ) .await .unwrap(); diff --git a/crates/application/src/integrations/tests/get_tokens.rs b/crates/application/src/integrations/tests/get_tokens.rs index 43474440..8577eb87 100644 --- a/crates/application/src/integrations/tests/get_tokens.rs +++ b/crates/application/src/integrations/tests/get_tokens.rs @@ -1,3 +1,4 @@ +use crate::test_support::generate_webhook_token_command; use std::sync::Arc; use domain::models::WatchEventSource; @@ -47,11 +48,7 @@ async fn returns_tokens_after_generate() { generate_token::execute( &generate_deps(&tokens), - GenerateWebhookTokenCommand { - user_id, - provider: WatchEventSource::Jellyfin, - label: None, - }, + generate_webhook_token_command(user_id), ) .await .unwrap(); @@ -59,9 +56,9 @@ async fn returns_tokens_after_generate() { generate_token::execute( &generate_deps(&tokens), GenerateWebhookTokenCommand { - user_id, provider: WatchEventSource::Plex, label: Some("living room".into()), + ..generate_webhook_token_command(user_id) }, ) .await diff --git a/crates/application/src/integrations/tests/ingest.rs b/crates/application/src/integrations/tests/ingest.rs index 04099b92..b5d5ed47 100644 --- a/crates/application/src/integrations/tests/ingest.rs +++ b/crates/application/src/integrations/tests/ingest.rs @@ -1,3 +1,4 @@ +use crate::test_support::generate_webhook_token_command; use std::sync::Arc; use domain::models::WatchEventSource; @@ -7,7 +8,7 @@ use domain::testing::{ }; use uuid::Uuid; -use crate::integrations::commands::{GenerateWebhookTokenCommand, IngestWatchEventCommand}; +use crate::integrations::commands::IngestWatchEventCommand; use crate::integrations::deps::{GenerateWebhookTokenDeps, IngestWatchEventDeps}; use crate::integrations::{generate_token, ingest}; @@ -38,11 +39,7 @@ async fn ingests_watch_event() { &GenerateWebhookTokenDeps { webhook_token: Arc::clone(&tokens), }, - GenerateWebhookTokenCommand { - user_id, - provider: WatchEventSource::Jellyfin, - label: None, - }, + generate_webhook_token_command(user_id), ) .await .unwrap(); diff --git a/crates/application/src/integrations/tests/revoke_token.rs b/crates/application/src/integrations/tests/revoke_token.rs index a89d701f..bc7dac40 100644 --- a/crates/application/src/integrations/tests/revoke_token.rs +++ b/crates/application/src/integrations/tests/revoke_token.rs @@ -1,6 +1,6 @@ +use crate::test_support::generate_webhook_token_command; use std::sync::Arc; -use domain::models::WatchEventSource; use domain::ports::WebhookTokenRepository; use domain::testing::InMemoryWebhookTokenRepository; use uuid::Uuid; @@ -9,10 +9,8 @@ use crate::integrations::deps::{ GenerateWebhookTokenDeps, GetWebhookTokensDeps, RevokeWebhookTokenDeps, }; use crate::integrations::{ - commands::{GenerateWebhookTokenCommand, RevokeWebhookTokenCommand}, - generate_token, get_tokens, - queries::GetWebhookTokensQuery, - revoke_token, + commands::RevokeWebhookTokenCommand, generate_token, get_tokens, + queries::GetWebhookTokensQuery, revoke_token, }; #[tokio::test] @@ -25,11 +23,7 @@ async fn revokes_existing_token() { &GenerateWebhookTokenDeps { webhook_token: Arc::clone(&tokens), }, - GenerateWebhookTokenCommand { - user_id, - provider: WatchEventSource::Jellyfin, - label: None, - }, + generate_webhook_token_command(user_id), ) .await .unwrap(); diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 76345c55..d47b1b82 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -22,6 +22,9 @@ pub mod wrapup; #[cfg(test)] pub mod test_helpers; +#[cfg(test)] +mod test_support; + #[cfg(test)] #[path = "tests/services.rs"] mod services_tests; diff --git a/crates/application/src/movies/tests/enrich_movie.rs b/crates/application/src/movies/tests/enrich_movie.rs index 90982582..8fb3a21f 100644 --- a/crates/application/src/movies/tests/enrich_movie.rs +++ b/crates/application/src/movies/tests/enrich_movie.rs @@ -1,14 +1,15 @@ +use domain::testing::test_movie_with_year; use std::sync::Arc; use chrono::Utc; use domain::{ - models::{Movie, MovieProfile}, + models::MovieProfile, ports::MovieCommand, testing::{ FakeSearchCommand, InMemoryMovieProfileRepository, InMemoryMovieRepository, PanicPersonCommand, }, - value_objects::{MovieId, MovieTitle, ReleaseYear}, + value_objects::MovieId, }; use crate::movies::{commands::EnrichMovieCommand, deps::EnrichMovieDeps, enrich_movie}; @@ -19,13 +20,7 @@ async fn stores_profile_and_indexes() { let profile_repo = InMemoryMovieProfileRepository::new(); // PanicPersonCommand is safe here — empty cast/crew means upsert_batch is never called - let movie = Movie::new( - None, - MovieTitle::new("Test".into()).unwrap(), - ReleaseYear::new(2024).unwrap(), - None, - None, - ); + let movie = test_movie_with_year("Test", 2024); let movie_id = MovieId::from_uuid(movie.id().value()); movie_repo.upsert_movie(&movie).await.unwrap(); @@ -99,13 +94,7 @@ async fn extracts_and_indexes_persons() { let movie_repo = InMemoryMovieRepository::new(); let profile_repo = InMemoryMovieProfileRepository::new(); - let movie = Movie::new( - None, - MovieTitle::new("Cast Movie".into()).unwrap(), - ReleaseYear::new(2024).unwrap(), - None, - None, - ); + let movie = test_movie_with_year("Cast Movie", 2024); let movie_id = MovieId::from_uuid(movie.id().value()); movie_repo.upsert_movie(&movie).await.unwrap(); diff --git a/crates/application/src/movies/tests/sync_poster.rs b/crates/application/src/movies/tests/sync_poster.rs index 97245209..40604afd 100644 --- a/crates/application/src/movies/tests/sync_poster.rs +++ b/crates/application/src/movies/tests/sync_poster.rs @@ -1,3 +1,5 @@ +use crate::test_support::sync_poster_command; +use domain::testing::{test_movie_with_external_id, test_movie_with_year}; use std::sync::Arc; use uuid::Uuid; @@ -10,13 +12,10 @@ use domain::{ FakeSearchCommand, InMemoryMovieProfileRepository, InMemoryMovieRepository, NoopEventPublisher, NoopObjectStorage, }, - value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear}, + value_objects::{ExternalMetadataId, PosterUrl}, }; -use crate::{ - diary::commands::SyncPosterCommand, - movies::{deps::SyncPosterDeps, sync_poster}, -}; +use crate::movies::{deps::SyncPosterDeps, sync_poster}; fn default_deps() -> SyncPosterDeps { SyncPosterDeps { @@ -35,13 +34,7 @@ fn default_deps() -> SyncPosterDeps { async fn fails_when_movie_not_found() { let deps = default_deps(); - let result = sync_poster::execute( - &deps, - SyncPosterCommand { - movie_id: Uuid::new_v4(), - }, - ) - .await; + let result = sync_poster::execute(&deps, sync_poster_command(Uuid::new_v4())).await; assert!(result.is_err()); } @@ -49,13 +42,7 @@ async fn fails_when_movie_not_found() { #[tokio::test] async fn fails_when_no_external_id() { let movies = InMemoryMovieRepository::new(); - let movie = Movie::new( - None, - MovieTitle::new("Test".into()).unwrap(), - ReleaseYear::new(2024).unwrap(), - None, - None, - ); + let movie = test_movie_with_year("Test", 2024); let movie_id = movie.id().value(); movies.upsert_movie(&movie).await.unwrap(); @@ -65,7 +52,7 @@ async fn fails_when_no_external_id() { ..default_deps() }; - let result = sync_poster::execute(&deps, SyncPosterCommand { movie_id }).await; + let result = sync_poster::execute(&deps, sync_poster_command(movie_id)).await; assert!(result.is_err()); } @@ -94,13 +81,7 @@ impl MetadataClient for FakeMetaWithPoster { async fn syncs_poster_for_movie_with_external_id() { let movies = InMemoryMovieRepository::new(); let ext_id = ExternalMetadataId::new("tmdb:999".into()).unwrap(); - let movie = Movie::new( - Some(ext_id), - MovieTitle::new("Poster Movie".into()).unwrap(), - ReleaseYear::new(2024).unwrap(), - None, - None, - ); + let movie = test_movie_with_external_id("Poster Movie", 2024, ext_id); let movie_id = movie.id().value(); movies.upsert_movie(&movie).await.unwrap(); @@ -111,7 +92,7 @@ async fn syncs_poster_for_movie_with_external_id() { ..default_deps() }; - sync_poster::execute(&deps, SyncPosterCommand { movie_id }) + sync_poster::execute(&deps, sync_poster_command(movie_id)) .await .unwrap(); diff --git a/crates/application/src/social/tests/local_service.rs b/crates/application/src/social/tests/local_service.rs index 53615d81..0acc875e 100644 --- a/crates/application/src/social/tests/local_service.rs +++ b/crates/application/src/social/tests/local_service.rs @@ -1,16 +1,16 @@ +use domain::testing::test_user; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::{ errors::DomainError, - models::{User, UserRole}, ports::{ BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, SocialCommand, UserRepository, }, testing::InMemoryUserRepository, value_objects::{ - Email, FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, PasswordHash, - SocialActor, SocialIdentity, UserId, Username, + FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, + UserId, }, }; use uuid::Uuid; @@ -247,12 +247,7 @@ fn instance() -> InstanceIdentity { } async fn register_user(repo: &Arc, username: &str) -> UserId { - let user = User::new( - Email::new(format!("{username}@example.com")).unwrap(), - Username::new(username.to_string()).unwrap(), - PasswordHash::new("hashed-password".to_string()).unwrap(), - UserRole::Standard, - ); + let user = test_user(username); let id = user.id().clone(); repo.save(&user).await.unwrap(); id diff --git a/crates/application/src/test_support.rs b/crates/application/src/test_support.rs new file mode 100644 index 00000000..ab4862fb --- /dev/null +++ b/crates/application/src/test_support.rs @@ -0,0 +1,240 @@ +//! Command builders for tests. Each builder returns a command with sensible +//! defaults; tests override individual fields with struct-update syntax: +//! `LogReviewCommand { rating: 6, ..log_review_command(user, movie) }`. +//! Moves to `tests/support/mod.rs` when application tests become integration tests. + +use chrono::{NaiveDate, NaiveDateTime}; +use uuid::Uuid; + +use domain::models::{FileFormat, UserRole, WatchEventSource}; + +use crate::auth::commands::RegisterCommand; +use crate::auth::queries::LoginCommand; +use crate::diary::commands::{EditReviewCommand, LogReviewCommand, MovieInput, SyncPosterCommand}; +use crate::goals::commands::{CreateGoalCommand, UpdateGoalCommand}; +use crate::import::commands::{ + ApplyImportMappingCommand, ApplyImportProfileCommand, CreateImportSessionCommand, + ExecuteImportCommand, +}; +use crate::integrations::commands::{ + ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand, + WatchEventConfirmation, +}; +use crate::users::commands::UpdateProfileCommand; +use crate::users::update_settings::UpdateUserSettingsCommand; +use crate::watchlist::commands::AddToWatchlistCommand; +use crate::wrapup::commands::RequestWrapUpCommand; + +fn watched_at() -> NaiveDateTime { + NaiveDate::from_ymd_opt(2024, 6, 15) + .unwrap() + .and_hms_opt(20, 0, 0) + .unwrap() +} + +pub(crate) fn movie_input_by_id(movie_id: Uuid) -> MovieInput { + MovieInput { + movie_id: Some(movie_id), + external_metadata_id: None, + manual_title: None, + manual_release_year: None, + manual_director: None, + } +} + +pub(crate) fn movie_input_manual(title: &str, year: u16) -> MovieInput { + MovieInput { + movie_id: None, + external_metadata_id: None, + manual_title: Some(title.to_string()), + manual_release_year: Some(year), + manual_director: None, + } +} + +pub(crate) fn log_review_command(user_id: Uuid, movie_id: Uuid) -> LogReviewCommand { + LogReviewCommand { + user_id, + input: movie_input_by_id(movie_id), + rating: 4, + comment: None, + watched_at: watched_at(), + watch_medium: None, + spoilers: false, + } +} + +pub(crate) fn log_review_command_manual(user_id: Uuid, title: &str, year: u16) -> LogReviewCommand { + LogReviewCommand { + input: movie_input_manual(title, year), + ..log_review_command(user_id, Uuid::nil()) + } +} + +/// Edits only the rating; `review_id` and `requesting_user_id` are random. +pub(crate) fn edit_review_command(rating: u8) -> EditReviewCommand { + EditReviewCommand { + review_id: Uuid::new_v4(), + requesting_user_id: Uuid::new_v4(), + rating: Some(rating), + comment: None, + watched_at: None, + watch_medium: None, + spoilers: None, + } +} + +pub(crate) fn sync_poster_command(movie_id: Uuid) -> SyncPosterCommand { + SyncPosterCommand { movie_id } +} + +pub(crate) fn register_command( + email: impl Into, + username: impl Into, +) -> RegisterCommand { + RegisterCommand { + email: email.into(), + username: username.into(), + password: "password123".to_string(), + role: UserRole::Standard, + } +} + +pub(crate) fn login_command(email: impl Into, password: impl Into) -> LoginCommand { + LoginCommand { + email: email.into(), + password: password.into(), + } +} + +pub(crate) fn create_goal_command(year: u16, target_count: u32) -> CreateGoalCommand { + CreateGoalCommand { + user_id: Uuid::nil(), + year, + target_count, + } +} + +pub(crate) fn update_goal_command(year: u16, target_count: u32) -> UpdateGoalCommand { + UpdateGoalCommand { + user_id: Uuid::nil(), + year, + target_count, + } +} + +/// Confirms indices `[0]`. +pub(crate) fn execute_import_command(user_id: Uuid, session_id: Uuid) -> ExecuteImportCommand { + ExecuteImportCommand { + user_id, + session_id, + confirmed_indices: vec![0], + } +} + +pub(crate) fn create_import_session_command( + user_id: Uuid, + csv: &[u8], +) -> CreateImportSessionCommand { + CreateImportSessionCommand { + user_id, + bytes: csv.to_vec(), + format: FileFormat::Csv, + } +} + +/// No field mappings. +pub(crate) fn apply_import_mapping_command( + user_id: Uuid, + session_id: Uuid, +) -> ApplyImportMappingCommand { + ApplyImportMappingCommand { + user_id, + session_id, + mappings: vec![], + } +} + +pub(crate) fn apply_import_profile_command( + user_id: Uuid, + session_id: Uuid, + profile_id: Uuid, +) -> ApplyImportProfileCommand { + ApplyImportProfileCommand { + user_id, + session_id, + profile_id, + } +} + +/// One confirmation, rating as given, no comment. +pub(crate) fn confirm_watch_events_command( + user_id: Uuid, + watch_event_id: Uuid, + rating: u8, +) -> ConfirmWatchEventsCommand { + ConfirmWatchEventsCommand { + user_id, + confirmations: vec![WatchEventConfirmation { + watch_event_id, + rating, + comment: None, + }], + } +} + +pub(crate) fn dismiss_watch_events_command( + user_id: Uuid, + event_ids: Vec, +) -> DismissWatchEventsCommand { + DismissWatchEventsCommand { user_id, event_ids } +} + +/// Jellyfin provider, no label. +pub(crate) fn generate_webhook_token_command(user_id: Uuid) -> GenerateWebhookTokenCommand { + GenerateWebhookTokenCommand { + user_id, + provider: WatchEventSource::Jellyfin, + label: None, + } +} + +/// Every field unset; override what the test cares about. +pub(crate) fn update_profile_command(user_id: Uuid) -> UpdateProfileCommand { + UpdateProfileCommand { + user_id, + display_name: None, + bio: None, + avatar_bytes: None, + avatar_content_type: None, + banner_bytes: None, + banner_content_type: None, + also_known_as: None, + } +} + +/// All federation toggles on. +pub(crate) fn update_user_settings_command(user_id: Uuid) -> UpdateUserSettingsCommand { + UpdateUserSettingsCommand { + user_id, + federate_goals: true, + federate_reviews: true, + federate_watchlist: true, + } +} + +pub(crate) fn add_to_watchlist_command(user_id: Uuid, movie_id: Uuid) -> AddToWatchlistCommand { + AddToWatchlistCommand { + user_id, + input: movie_input_by_id(movie_id), + } +} + +/// Window is 1 Jan of `year` to 1 Jan of `year + 1`. +pub(crate) fn request_wrap_up_command(user_id: Option, year: i32) -> RequestWrapUpCommand { + RequestWrapUpCommand { + user_id, + start_date: NaiveDate::from_ymd_opt(year, 1, 1).unwrap(), + end_date: NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap(), + } +} diff --git a/crates/application/src/users/tests/authorize_admin.rs b/crates/application/src/users/tests/authorize_admin.rs index 7e1810b9..aebf71cb 100644 --- a/crates/application/src/users/tests/authorize_admin.rs +++ b/crates/application/src/users/tests/authorize_admin.rs @@ -1,3 +1,4 @@ +use crate::test_support::register_command; use std::sync::Arc; use domain::models::UserRole; @@ -23,10 +24,8 @@ async fn register_user( register::execute( ®_deps, RegisterCommand { - email: email.into(), - username: username.into(), - password: "password123".into(), role, + ..register_command(email, username) }, ) .await diff --git a/crates/application/src/users/tests/get_current_profile.rs b/crates/application/src/users/tests/get_current_profile.rs index 7aa383cb..3f7b0f5b 100644 --- a/crates/application/src/users/tests/get_current_profile.rs +++ b/crates/application/src/users/tests/get_current_profile.rs @@ -1,3 +1,4 @@ +use crate::test_support::register_command; use std::sync::Arc; use domain::models::{ProfileField, User, UserProfile, UserRole}; @@ -7,7 +8,7 @@ use domain::value_objects::{Email, PasswordHash, UserId, Username}; use uuid::Uuid; use crate::{ - auth::{commands::RegisterCommand, deps::RegisterDeps, register}, + auth::{deps::RegisterDeps, register}, test_helpers::TestContextBuilder, users::{deps::GetCurrentProfileDeps, get_current_profile, queries::GetCurrentProfileQuery}, }; @@ -23,17 +24,9 @@ async fn returns_profile_for_existing_user() { config: b.config.clone(), }; - register::execute( - ®_deps, - RegisterCommand { - email: "alice@example.com".into(), - username: "alice".into(), - password: "password123".into(), - role: UserRole::Standard, - }, - ) - .await - .unwrap(); + register::execute(®_deps, register_command("alice@example.com", "alice")) + .await + .unwrap(); let user = users .find_by_email(&domain::value_objects::Email::new("alice@example.com".into()).unwrap()) diff --git a/crates/application/src/users/tests/get_local_profile.rs b/crates/application/src/users/tests/get_local_profile.rs index 27e4b9b2..6892046f 100644 --- a/crates/application/src/users/tests/get_local_profile.rs +++ b/crates/application/src/users/tests/get_local_profile.rs @@ -1,8 +1,7 @@ +use crate::test_support::{register_command, update_profile_command}; use domain::errors::DomainError; -use domain::models::UserRole; use domain::value_objects::Email; -use crate::auth::commands::RegisterCommand; use crate::auth::deps::RegisterDeps; use crate::auth::register; use crate::test_helpers::TestContextBuilder; @@ -18,17 +17,9 @@ async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) { password_hasher: b.password_hasher.clone(), config: b.config.clone(), }; - register::execute( - &deps, - RegisterCommand { - email: email.into(), - username: username.into(), - password: "password123".into(), - role: UserRole::Standard, - }, - ) - .await - .unwrap(); + register::execute(&deps, register_command(email, username)) + .await + .unwrap(); } fn deps(b: &TestContextBuilder) -> GetLocalProfileDeps { @@ -276,14 +267,9 @@ async fn populates_handle_and_actor_url_for_a_local_user() { update_profile::execute( &update_deps, UpdateProfileCommand { - user_id: uid, display_name: Some("Gabriel K".into()), bio: Some("Movies and diaries.".into()), - avatar_bytes: None, - avatar_content_type: None, - banner_bytes: None, - banner_content_type: None, - also_known_as: None, + ..update_profile_command(uid) }, ) .await diff --git a/crates/application/src/users/tests/get_page_viewer.rs b/crates/application/src/users/tests/get_page_viewer.rs index 7667b4b0..788f5d0e 100644 --- a/crates/application/src/users/tests/get_page_viewer.rs +++ b/crates/application/src/users/tests/get_page_viewer.rs @@ -1,7 +1,6 @@ -use domain::models::UserRole; +use crate::test_support::register_command; use domain::value_objects::{Email, FollowTarget, SocialIdentity, UserId}; -use crate::auth::commands::RegisterCommand; use crate::auth::deps::RegisterDeps; use crate::auth::register; use crate::test_helpers::TestContextBuilder; @@ -14,17 +13,9 @@ async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) -> uuid password_hasher: b.password_hasher.clone(), config: b.config.clone(), }; - register::execute( - &deps, - RegisterCommand { - email: email.into(), - username: username.into(), - password: "password123".into(), - role: UserRole::Standard, - }, - ) - .await - .unwrap(); + register::execute(&deps, register_command(email, username)) + .await + .unwrap(); let user = b .user_repo .find_by_email(&Email::new(email.into()).unwrap()) diff --git a/crates/application/src/users/tests/get_profile_settings.rs b/crates/application/src/users/tests/get_profile_settings.rs index a5c3a98b..defb2a06 100644 --- a/crates/application/src/users/tests/get_profile_settings.rs +++ b/crates/application/src/users/tests/get_profile_settings.rs @@ -1,7 +1,7 @@ -use domain::models::{ProfileField, UserRole}; +use crate::test_support::register_command; +use domain::models::ProfileField; use domain::value_objects::{Email, UserId}; -use crate::auth::commands::RegisterCommand; use crate::auth::deps::RegisterDeps; use crate::auth::register; use crate::test_helpers::TestContextBuilder; @@ -18,12 +18,7 @@ async fn get_profile_settings_includes_profile_fields() { }; register::execute( ®ister_deps, - RegisterCommand { - email: "settings@test.com".into(), - username: "settingsuser".into(), - password: "password123".into(), - role: UserRole::Standard, - }, + register_command("settings@test.com", "settingsuser"), ) .await .unwrap(); diff --git a/crates/application/src/users/tests/update_profile.rs b/crates/application/src/users/tests/update_profile.rs index 157171e3..1145c77d 100644 --- a/crates/application/src/users/tests/update_profile.rs +++ b/crates/application/src/users/tests/update_profile.rs @@ -1,13 +1,13 @@ +use crate::test_support::{register_command, update_profile_command}; use std::sync::Arc; use domain::events::DomainEvent; -use domain::models::UserRole; use domain::ports::UserRepository; use domain::testing::{InMemoryUserRepository, NoopEventPublisher}; use uuid::Uuid; use crate::{ - auth::{commands::RegisterCommand, deps::RegisterDeps, register}, + auth::{deps::RegisterDeps, register}, test_helpers::TestContextBuilder, users::{commands::UpdateProfileCommand, deps::UpdateProfileDeps, update_profile}, }; @@ -18,17 +18,9 @@ async fn register_user(b: &TestContextBuilder, users: &Arc, @@ -30,28 +27,13 @@ async fn test_add_to_watchlist_resolves_and_saves() { let movies = InMemoryMovieRepository::new(); let watchlist = InMemoryWatchlistRepository::new(); - let movie = Movie::new( - None, - MovieTitle::new("The Thing".into()).unwrap(), - ReleaseYear::new(1982).unwrap(), - None, - None, - ); + let movie = test_movie_with_year("The Thing", 1982); let movie_uuid = movie.id().value(); movies.upsert_movie(&movie).await.unwrap(); let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist)); - let cmd = AddToWatchlistCommand { - user_id: uuid::Uuid::new_v4(), - input: MovieInput { - movie_id: Some(movie_uuid), - external_metadata_id: None, - manual_title: None, - manual_release_year: None, - manual_director: None, - }, - }; + let cmd = add_to_watchlist_command(uuid::Uuid::new_v4(), movie_uuid); add::execute(&deps, cmd).await.unwrap(); @@ -63,29 +45,14 @@ async fn test_add_to_watchlist_already_present_is_idempotent() { let movies = InMemoryMovieRepository::new(); let watchlist = InMemoryWatchlistRepository::new(); - let movie = Movie::new( - None, - MovieTitle::new("RoboCop".into()).unwrap(), - ReleaseYear::new(1987).unwrap(), - None, - None, - ); + let movie = test_movie_with_year("RoboCop", 1987); let movie_uuid = movie.id().value(); let user_id = uuid::Uuid::new_v4(); movies.upsert_movie(&movie).await.unwrap(); let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist)); - let make_cmd = || AddToWatchlistCommand { - user_id, - input: MovieInput { - movie_id: Some(movie_uuid), - external_metadata_id: None, - manual_title: None, - manual_release_year: None, - manual_director: None, - }, - }; + let make_cmd = || add_to_watchlist_command(user_id, movie_uuid); add::execute(&deps, make_cmd()).await.unwrap(); add::execute(&deps, make_cmd()).await.unwrap(); @@ -102,13 +69,7 @@ async fn test_add_to_watchlist_with_manual_movie() { let cmd = AddToWatchlistCommand { user_id: uuid::Uuid::new_v4(), - input: MovieInput { - movie_id: None, - external_metadata_id: None, - manual_title: Some("New Manual Movie".into()), - manual_release_year: Some(2024), - manual_director: None, - }, + input: movie_input_manual("New Manual Movie", 2024), }; add::execute(&deps, cmd).await.unwrap(); @@ -124,16 +85,7 @@ async fn test_add_to_watchlist_movie_not_found_by_id() { let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist)); - let cmd = AddToWatchlistCommand { - user_id: uuid::Uuid::new_v4(), - input: MovieInput { - movie_id: Some(uuid::Uuid::new_v4()), - external_metadata_id: None, - manual_title: None, - manual_release_year: None, - manual_director: None, - }, - }; + let cmd = add_to_watchlist_command(uuid::Uuid::new_v4(), uuid::Uuid::new_v4()); assert!(add::execute(&deps, cmd).await.is_err()); } diff --git a/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs b/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs index 72527542..e9788ba3 100644 --- a/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs +++ b/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs @@ -1,15 +1,14 @@ +use crate::test_support::register_command; use std::sync::Arc; use uuid::Uuid; -use domain::models::UserRole; use domain::testing::{ InMemoryRemoteWatchlistRepository, InMemoryUserRepository, InMemoryWatchlistRepository, PanicRemoteWatchlistRepository, PanicWatchlistRepository, }; use domain::value_objects::Email; -use crate::auth::commands::RegisterCommand; use crate::auth::deps::RegisterDeps; use crate::auth::register; use crate::test_helpers::TestContextBuilder; @@ -22,17 +21,9 @@ async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) { password_hasher: b.password_hasher.clone(), config: b.config.clone(), }; - register::execute( - &deps, - RegisterCommand { - email: email.into(), - username: username.into(), - password: "password123".into(), - role: UserRole::Standard, - }, - ) - .await - .unwrap(); + register::execute(&deps, register_command(email, username)) + .await + .unwrap(); } #[tokio::test] diff --git a/crates/application/src/wrapup/tests/generate.rs b/crates/application/src/wrapup/tests/generate.rs index 1212d49a..fceec0bf 100644 --- a/crates/application/src/wrapup/tests/generate.rs +++ b/crates/application/src/wrapup/tests/generate.rs @@ -1,3 +1,4 @@ +use crate::test_support::request_wrap_up_command; use chrono::NaiveDate; use domain::events::DomainEvent; use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; @@ -21,11 +22,7 @@ fn deps( } fn past_cmd() -> RequestWrapUpCommand { - RequestWrapUpCommand { - user_id: Some(Uuid::nil()), - start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), - end_date: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), - } + request_wrap_up_command(Some(Uuid::nil()), 2024) } #[tokio::test] @@ -105,16 +102,9 @@ async fn replaces_failed_wrapup() { async fn rejects_future_end_date() { let repo = InMemoryWrapUpRepository::new(); let events = NoopEventPublisher::new(); - let err = generate::execute( - &deps(&repo, &events), - RequestWrapUpCommand { - user_id: None, - start_date: NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(), - end_date: NaiveDate::from_ymd_opt(2031, 1, 1).unwrap(), - }, - ) - .await - .unwrap_err(); + let err = generate::execute(&deps(&repo, &events), request_wrap_up_command(None, 2030)) + .await + .unwrap_err(); assert!(err.to_string().contains("future")); } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 30bb5eb8..42b7ebc1 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -5,5 +5,5 @@ pub mod ports; pub mod services; pub mod value_objects; -#[cfg(feature = "test-helpers")] +#[cfg(any(test, feature = "test-helpers"))] pub mod testing; diff --git a/crates/domain/src/models/tests/goal.rs b/crates/domain/src/models/tests/goal.rs index c3575b42..41c2c2b9 100644 --- a/crates/domain/src/models/tests/goal.rs +++ b/crates/domain/src/models/tests/goal.rs @@ -1,39 +1,34 @@ use super::*; +use crate::testing::test_goal; use crate::value_objects::UserId; -fn make_goal(year: u16, target: u32) -> Result { - Goal::new(UserId::generate(), year, target, GoalType::Movies) -} - #[test] fn new_goal_valid() { - let g = make_goal(2024, 52); - assert!(g.is_ok()); - let g = g.unwrap(); + let g = test_goal(&UserId::generate(), 2024, 52); assert_eq!(g.year(), 2024); assert_eq!(g.target_count(), 52); } #[test] fn new_goal_rejects_year_before_2020() { - assert!(make_goal(2019, 10).is_err()); + assert!(Goal::new(UserId::generate(), 2019, 10, GoalType::Movies).is_err()); } #[test] fn new_goal_rejects_zero_target() { - assert!(make_goal(2024, 0).is_err()); + assert!(Goal::new(UserId::generate(), 2024, 0, GoalType::Movies).is_err()); } #[test] fn update_target_valid() { - let mut g = make_goal(2024, 10).unwrap(); + let mut g = test_goal(&UserId::generate(), 2024, 10); assert!(g.update_target(50).is_ok()); assert_eq!(g.target_count(), 50); } #[test] fn update_target_rejects_zero() { - let mut g = make_goal(2024, 10).unwrap(); + let mut g = test_goal(&UserId::generate(), 2024, 10); assert!(g.update_target(0).is_err()); } @@ -52,7 +47,7 @@ fn from_persistence_preserves_fields() { #[test] fn percentage_calculation() { - let g = make_goal(2024, 100).unwrap(); + let g = test_goal(&UserId::generate(), 2024, 100); let wp = GoalWithProgress { goal: g, current_count: 50, @@ -62,7 +57,7 @@ fn percentage_calculation() { #[test] fn percentage_caps_at_100() { - let g = make_goal(2024, 10).unwrap(); + let g = test_goal(&UserId::generate(), 2024, 10); let wp = GoalWithProgress { goal: g, current_count: 20, @@ -72,7 +67,7 @@ fn percentage_caps_at_100() { #[test] fn is_complete() { - let g = make_goal(2024, 10).unwrap(); + let g = test_goal(&UserId::generate(), 2024, 10); let wp = GoalWithProgress { goal: g, current_count: 10, @@ -82,7 +77,7 @@ fn is_complete() { #[test] fn is_not_complete() { - let g = make_goal(2024, 10).unwrap(); + let g = test_goal(&UserId::generate(), 2024, 10); let wp = GoalWithProgress { goal: g, current_count: 9, diff --git a/crates/domain/src/models/tests/mod.rs b/crates/domain/src/models/tests/mod.rs index 57e89c80..f8eb4b7e 100644 --- a/crates/domain/src/models/tests/mod.rs +++ b/crates/domain/src/models/tests/mod.rs @@ -1,20 +1,10 @@ use super::*; +use crate::testing::{test_movie_with_director, test_movie_with_year, test_review, test_user}; use crate::value_objects::{Email, PasswordHash, UserId, Username}; -fn make_user() -> User { - User::from_persistence( - UserId::generate(), - Email::new("a@b.com".to_string()).unwrap(), - Username::new("alice".to_string()).unwrap(), - PasswordHash::new("hash".to_string()).unwrap(), - UserRole::Standard, - UserProfile::default(), - ) -} - #[test] fn update_profile_sets_fields() { - let mut user = make_user(); + let mut user = test_user("alice"); user.update_profile(UserProfile { bio: Some("My bio".to_string()), avatar_path: Some("avatars/abc".to_string()), @@ -26,7 +16,7 @@ fn update_profile_sets_fields() { #[test] fn update_profile_clears_with_none() { - let mut user = make_user(); + let mut user = test_user("alice"); user.update_profile(UserProfile { bio: Some("bio".to_string()), avatar_path: Some("path".to_string()), @@ -39,19 +29,15 @@ fn update_profile_clears_with_none() { // ── Movie ──────────────────────────────────────────────────────────────────── -fn make_movie() -> Movie { - Movie::new( +#[test] +fn movie_new_sets_fields() { + let m = Movie::new( Some(crate::value_objects::ExternalMetadataId::new("tt1234567".into()).unwrap()), crate::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(), crate::value_objects::ReleaseYear::new(1982).unwrap(), Some("Ridley Scott".into()), Some(crate::value_objects::PosterPath::new("/poster.jpg".into()).unwrap()), - ) -} - -#[test] -fn movie_new_sets_fields() { - let m = make_movie(); + ); assert_eq!(m.title().value(), "Blade Runner"); assert_eq!(m.release_year().value(), 1982); assert_eq!(m.director(), Some("Ridley Scott")); @@ -61,7 +47,7 @@ fn movie_new_sets_fields() { #[test] fn movie_update_poster() { - let mut m = make_movie(); + let mut m = test_movie_with_year("Blade Runner", 1982); let new_poster = crate::value_objects::PosterPath::new("/new.jpg".into()).unwrap(); m.update_poster(new_poster); assert_eq!(m.poster_path().unwrap().value(), "/new.jpg"); @@ -69,31 +55,18 @@ fn movie_update_poster() { // ── Review ─────────────────────────────────────────────────────────────────── -use crate::value_objects::{Comment, MovieId, Rating, ReviewId}; - -fn make_review() -> Review { - Review::new( - MovieId::generate(), - UserId::generate(), - Rating::new(4).unwrap(), - Some(Comment::new("great".into()).unwrap()), - chrono::Utc::now().naive_utc(), - None, - false, - ) - .unwrap() -} +use crate::value_objects::{MovieId, Rating, ReviewId}; #[test] fn review_new_sets_local_source() { - let r = make_review(); + let r = test_review(&MovieId::generate(), &UserId::generate(), 4); assert_eq!(*r.source(), ReviewSource::Local); assert!(!r.is_remote()); } #[test] fn review_stars() { - let r = make_review(); // rating=4 + let r = test_review(&MovieId::generate(), &UserId::generate(), 4); assert_eq!(r.stars(), [true, true, true, true, false]); } @@ -141,7 +114,7 @@ fn user_new() { #[test] fn user_update_password() { - let mut u = make_user(); + let mut u = test_user("alice"); u.update_password(PasswordHash::new("new_hash".into()).unwrap()); assert_eq!(u.password_hash().value(), "new_hash"); } @@ -197,7 +170,10 @@ fn movie_stats_construction() { #[test] fn feed_entry_display_name_from_email() { - let entry = DiaryEntry::new(make_movie(), make_review()); + let entry = DiaryEntry::new( + test_movie_with_year("Test", 2000), + test_review(&MovieId::generate(), &UserId::generate(), 4), + ); let fe = FeedEntry::new(entry, "alice@example.com".into()); assert_eq!(fe.user_display_name(), "alice"); assert_eq!(fe.user_email(), "alice@example.com"); @@ -222,7 +198,7 @@ fn month_activity_construction() { #[test] fn movie_is_manual_match_same_title_year() { - let m = make_movie(); + let m = test_movie_with_director("Blade Runner", 1982, "Ridley Scott"); let title = crate::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(); let year = crate::value_objects::ReleaseYear::new(1982).unwrap(); assert!(m.is_manual_match(&title, &year, Some("ridley scott"))); @@ -230,7 +206,7 @@ fn movie_is_manual_match_same_title_year() { #[test] fn movie_is_manual_match_different_director_fails() { - let m = make_movie(); + let m = test_movie_with_director("Blade Runner", 1982, "Ridley Scott"); let title = crate::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(); let year = crate::value_objects::ReleaseYear::new(1982).unwrap(); assert!(!m.is_manual_match(&title, &year, Some("Denis Villeneuve"))); diff --git a/crates/domain/src/models/tests/review.rs b/crates/domain/src/models/tests/review.rs index c299d344..74c9c9b1 100644 --- a/crates/domain/src/models/tests/review.rs +++ b/crates/domain/src/models/tests/review.rs @@ -1,37 +1,31 @@ -use chrono::NaiveDateTime; - +use crate::testing::test_review; use crate::value_objects::{Comment, MovieId, Rating, UserId, WatchMedium}; use super::*; -fn make_review(watch_medium: Option) -> Review { - Review::new( - MovieId::generate(), - UserId::generate(), - Rating::new(4).unwrap(), - Some(Comment::new("great film".into()).unwrap()), - NaiveDateTime::parse_from_str("2024-06-15 20:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), - watch_medium, - false, - ) - .unwrap() +fn review_with_medium(watch_medium: Option) -> Review { + test_review(&MovieId::generate(), &UserId::generate(), 4).apply_edit(ReviewEdit { + comment: Some(Some(Comment::new("great film".into()).unwrap())), + watch_medium: Some(watch_medium), + ..Default::default() + }) } #[test] fn new_review_stores_watch_medium() { - let review = make_review(Some(WatchMedium::Cinema)); + let review = review_with_medium(Some(WatchMedium::Cinema)); assert_eq!(review.watch_medium(), Some(&WatchMedium::Cinema)); } #[test] fn new_review_without_medium() { - let review = make_review(None); + let review = review_with_medium(None); assert_eq!(review.watch_medium(), None); } #[test] fn apply_edit_updates_rating_only() { - let original = make_review(Some(WatchMedium::Streaming)); + let original = review_with_medium(Some(WatchMedium::Streaming)); let edited = original.apply_edit(ReviewEdit { rating: Some(Rating::new(2).unwrap()), ..Default::default() @@ -43,7 +37,7 @@ fn apply_edit_updates_rating_only() { #[test] fn apply_edit_clears_comment() { - let original = make_review(None); + let original = review_with_medium(None); let edited = original.apply_edit(ReviewEdit { comment: Some(None), ..Default::default() @@ -54,7 +48,7 @@ fn apply_edit_clears_comment() { #[test] fn apply_edit_sets_watch_medium() { - let original = make_review(None); + let original = review_with_medium(None); let edited = original.apply_edit(ReviewEdit { watch_medium: Some(Some(WatchMedium::Cinema)), ..Default::default() @@ -64,7 +58,7 @@ fn apply_edit_sets_watch_medium() { #[test] fn apply_edit_clears_watch_medium() { - let original = make_review(Some(WatchMedium::Cinema)); + let original = review_with_medium(Some(WatchMedium::Cinema)); let edited = original.apply_edit(ReviewEdit { watch_medium: Some(None), ..Default::default() @@ -74,7 +68,7 @@ fn apply_edit_clears_watch_medium() { #[test] fn apply_edit_toggles_spoilers() { - let original = make_review(None); + let original = review_with_medium(None); let flag_on = original.apply_edit(ReviewEdit { spoilers: Some(true), ..Default::default() @@ -91,7 +85,7 @@ fn apply_edit_toggles_spoilers() { #[test] fn apply_edit_no_changes_preserves_all() { - let original = make_review(Some(WatchMedium::TV)); + let original = review_with_medium(Some(WatchMedium::TV)); let edited = original.apply_edit(ReviewEdit::default()); assert_eq!(edited.rating().value(), 4); assert_eq!(edited.comment().unwrap().value(), "great film"); diff --git a/crates/domain/src/services/tests/review_history.rs b/crates/domain/src/services/tests/review_history.rs index 9c99dbe0..85b4d122 100644 --- a/crates/domain/src/services/tests/review_history.rs +++ b/crates/domain/src/services/tests/review_history.rs @@ -1,18 +1,9 @@ use super::*; -use crate::models::{Movie, Review, ReviewHistory}; -use crate::value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId}; +use crate::models::{Review, ReviewHistory}; +use crate::testing::test_movie_with_year; +use crate::value_objects::{MovieId, Rating, UserId}; use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; -fn make_movie() -> Movie { - Movie::new( - None, - MovieTitle::new("Test".into()).unwrap(), - ReleaseYear::new(2024).unwrap(), - None, - None, - ) -} - fn dt(year: i32, month: u32, day: u32) -> NaiveDateTime { NaiveDateTime::new( NaiveDate::from_ymd_opt(year, month, day).unwrap(), @@ -36,7 +27,7 @@ fn review_with_rating(movie_id: &MovieId, rating: u8, watched_at: NaiveDateTime) #[test] fn neutral_when_empty() { - let movie = make_movie(); + let movie = test_movie_with_year("Test", 2024); let history = ReviewHistory::new(movie, vec![]); let trend = ReviewHistoryAnalyzer::rating_trend(&history).unwrap(); assert_eq!(trend, Trend::Neutral); @@ -44,7 +35,7 @@ fn neutral_when_empty() { #[test] fn neutral_when_single_review() { - let movie = make_movie(); + let movie = test_movie_with_year("Test", 2024); let r = review_with_rating(movie.id(), 4, dt(2024, 1, 1)); let history = ReviewHistory::new(movie, vec![r]); let trend = ReviewHistoryAnalyzer::rating_trend(&history).unwrap(); @@ -53,7 +44,7 @@ fn neutral_when_single_review() { #[test] fn improved_when_latest_above_average() { - let movie = make_movie(); + let movie = test_movie_with_year("Test", 2024); let viewings = vec![ review_with_rating(movie.id(), 2, dt(2024, 1, 1)), review_with_rating(movie.id(), 3, dt(2024, 2, 1)), @@ -66,7 +57,7 @@ fn improved_when_latest_above_average() { #[test] fn declined_when_latest_below_average() { - let movie = make_movie(); + let movie = test_movie_with_year("Test", 2024); let viewings = vec![ review_with_rating(movie.id(), 5, dt(2024, 1, 1)), review_with_rating(movie.id(), 4, dt(2024, 2, 1)), diff --git a/crates/domain/src/testing/factories.rs b/crates/domain/src/testing/factories.rs new file mode 100644 index 00000000..4ad9921b --- /dev/null +++ b/crates/domain/src/testing/factories.rs @@ -0,0 +1,131 @@ +use chrono::{NaiveDate, NaiveDateTime}; + +use crate::models::{ + DiaryEntry, ExternalPersonId, Goal, GoalType, Movie, Person, Review, User, UserProfile, + UserRole, +}; +use crate::value_objects::{ + Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PersonId, Rating, ReleaseYear, + UserId, Username, +}; + +const DEFAULT_YEAR: u16 = 2000; + +/// Fixed watch timestamp used by review factories (2024-03-15 20:00:00). +pub fn test_watched_at() -> NaiveDateTime { + NaiveDate::from_ymd_opt(2024, 3, 15) + .unwrap() + .and_hms_opt(20, 0, 0) + .unwrap() +} + +pub fn test_movie(title: &str) -> Movie { + test_movie_with_year(title, DEFAULT_YEAR) +} + +pub fn test_movie_with_year(title: &str, year: u16) -> Movie { + Movie::new( + None, + MovieTitle::new(title.to_string()).unwrap(), + ReleaseYear::new(year).unwrap(), + None, + None, + ) +} + +pub fn test_movie_with_director(title: &str, year: u16, director: &str) -> Movie { + Movie::new( + None, + MovieTitle::new(title.to_string()).unwrap(), + ReleaseYear::new(year).unwrap(), + Some(director.to_string()), + None, + ) +} + +pub fn test_movie_with_external_id( + title: &str, + year: u16, + external_id: ExternalMetadataId, +) -> Movie { + Movie::new( + Some(external_id), + MovieTitle::new(title.to_string()).unwrap(), + ReleaseYear::new(year).unwrap(), + None, + None, + ) +} + +pub fn test_review(movie_id: &MovieId, user_id: &UserId, rating: u8) -> Review { + build_review(movie_id, user_id, rating, false) +} + +pub fn test_spoiler_review(movie_id: &MovieId, user_id: &UserId, rating: u8) -> Review { + build_review(movie_id, user_id, rating, true) +} + +fn build_review(movie_id: &MovieId, user_id: &UserId, rating: u8, spoilers: bool) -> Review { + Review::new( + movie_id.clone(), + user_id.clone(), + Rating::new(rating).unwrap(), + None, + test_watched_at(), + None, + spoilers, + ) + .unwrap() +} + +pub fn test_user(username: &str) -> User { + User::new( + Email::new(format!("{username}@example.com")).unwrap(), + Username::new(username.to_string()).unwrap(), + PasswordHash::new("hashed-password".to_string()).unwrap(), + UserRole::Standard, + ) +} + +pub fn test_diary_entry(title: &str, rating: u8) -> DiaryEntry { + let movie = test_movie(title); + let review = test_review(movie.id(), &UserId::generate(), rating); + DiaryEntry::new(movie, review) +} + +pub fn test_goal(user_id: &UserId, year: u16, target_count: u32) -> Goal { + Goal::new(user_id.clone(), year, target_count, GoalType::Movies).unwrap() +} + +pub fn test_user_with_role(id: UserId, role: UserRole) -> User { + User::from_persistence( + id, + Email::new("role-test@example.com".to_string()).unwrap(), + Username::new("role_test_user".to_string()).unwrap(), + PasswordHash::new("hashed-password".to_string()).unwrap(), + role, + UserProfile::default(), + ) +} + +pub fn test_person(name: &str) -> Person { + let ext = ExternalPersonId::new(format!("test:{name}")); + Person::basic( + PersonId::from_external(&ext), + ext, + name.to_string(), + None, + None, + ) +} + +pub fn test_person_with_tmdb(tmdb_id: i64, name: &str, department: Option<&str>) -> Person { + let ext = ExternalPersonId::new(format!("tmdb:{tmdb_id}")); + Person::basic( + PersonId::from_external(&ext), + ext, + name.to_string(), + department.map(str::to_string), + None, + ) +} diff --git a/crates/domain/src/testing/mod.rs b/crates/domain/src/testing/mod.rs index 21299b3e..8a36fbf8 100644 --- a/crates/domain/src/testing/mod.rs +++ b/crates/domain/src/testing/mod.rs @@ -1,3 +1,4 @@ +mod factories; mod fakes; mod in_memory; mod insights; @@ -5,6 +6,7 @@ mod noops; mod panics; mod wrapup; +pub use factories::*; pub use fakes::*; pub use in_memory::*; pub use insights::*; -- 2.49.1 From 06eec2feae11b6f0ae7e5f133af4cfc5f1d05c98 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:19:24 +0100 Subject: [PATCH 026/104] test: cascading in-memory store --- .../http-axum/src/extractors/tests.rs | 46 +- crates/adapters/sqlite/tests/cascade_test.rs | 317 +++++ crates/application/src/auth/tests/login.rs | 6 +- crates/application/src/auth/tests/logout.rs | 4 +- crates/application/src/auth/tests/refresh.rs | 8 +- crates/application/src/auth/tests/register.rs | 6 +- .../src/diary/tests/delete_review.rs | 16 +- .../src/diary/tests/edit_review.rs | 13 +- .../src/diary/tests/get_activity_feed.rs | 4 +- .../src/diary/tests/get_movie_social_page.rs | 10 +- .../application/src/diary/tests/log_review.rs | 24 +- .../src/diary/tests/review_logger.rs | 69 +- crates/application/src/goals/tests/create.rs | 10 +- crates/application/src/goals/tests/delete.rs | 8 +- .../src/import/tests/apply_mapping.rs | 14 +- .../src/import/tests/apply_profile.rs | 24 +- .../src/import/tests/apply_profile_and_map.rs | 20 +- .../application/src/import/tests/cleanup.rs | 4 +- .../src/import/tests/create_session.rs | 4 +- .../src/import/tests/delete_profile.rs | 4 +- .../application/src/import/tests/execute.rs | 36 +- .../src/import/tests/get_mapping_stage.rs | 8 +- .../src/import/tests/get_preview_stage.rs | 8 +- .../src/import/tests/get_session_state.rs | 6 +- .../src/import/tests/list_profiles.rs | 4 +- .../src/import/tests/save_profile.rs | 10 +- .../src/integrations/tests/cleanup.rs | 4 +- .../src/integrations/tests/confirm.rs | 28 +- .../src/integrations/tests/dismiss.rs | 10 +- .../src/integrations/tests/generate_token.rs | 4 +- .../src/integrations/tests/get_queue.rs | 8 +- .../src/integrations/tests/get_tokens.rs | 6 +- .../src/integrations/tests/ingest.rs | 20 +- .../src/integrations/tests/revoke_token.rs | 4 +- .../src/movies/tests/enrich_movie.rs | 17 +- .../src/movies/tests/get_movie_profile.rs | 6 +- .../src/movies/tests/get_movies.rs | 4 +- .../src/movies/tests/request_enrichment.rs | 8 +- .../src/movies/tests/sync_poster.rs | 15 +- .../application/src/social/tests/execute.rs | 10 +- .../src/social/tests/local_service.rs | 18 +- crates/application/src/test_helpers.rs | 52 +- .../src/users/tests/authorize_admin.rs | 6 +- .../src/users/tests/get_current_profile.rs | 8 +- .../src/users/tests/update_profile.rs | 16 +- .../src/users/tests/update_profile_fields.rs | 4 +- .../src/users/tests/update_settings.rs | 8 +- crates/application/src/watchlist/tests/add.rs | 35 +- .../tests/get_watchlist_for_owner.rs | 35 +- .../application/src/watchlist/tests/is_on.rs | 4 +- .../application/src/watchlist/tests/remove.rs | 6 +- crates/bootstrap/src/tests/build.rs | 65 +- crates/domain/src/models/tests/review.rs | 19 +- .../src/testing/{ => fakes}/insights.rs | 0 crates/domain/src/testing/fakes/mod.rs | 26 + .../domain/src/testing/{ => fakes}/noops.rs | 0 .../domain/src/testing/{ => fakes}/panics.rs | 0 crates/domain/src/testing/fakes/store.rs | 171 +++ crates/domain/src/testing/fakes/store_goal.rs | 70 + .../domain/src/testing/fakes/store_import.rs | 98 ++ .../domain/src/testing/fakes/store_movie.rs | 200 +++ .../domain/src/testing/fakes/store_review.rs | 52 + .../domain/src/testing/fakes/store_session.rs | 58 + .../domain/src/testing/fakes/store_social.rs | 274 ++++ crates/domain/src/testing/fakes/store_user.rs | 169 +++ .../src/testing/fakes/store_watch_event.rs | 123 ++ .../src/testing/fakes/store_watchlist.rs | 110 ++ .../src/testing/{fakes.rs => fakes/stubs.rs} | 0 crates/domain/src/testing/fakes/tests.rs | 388 ++++++ .../domain/src/testing/{ => fakes}/wrapup.rs | 0 crates/domain/src/testing/in_memory.rs | 1214 ----------------- crates/domain/src/testing/mod.rs | 10 - 72 files changed, 2439 insertions(+), 1627 deletions(-) create mode 100644 crates/adapters/sqlite/tests/cascade_test.rs rename crates/domain/src/testing/{ => fakes}/insights.rs (100%) create mode 100644 crates/domain/src/testing/fakes/mod.rs rename crates/domain/src/testing/{ => fakes}/noops.rs (100%) rename crates/domain/src/testing/{ => fakes}/panics.rs (100%) create mode 100644 crates/domain/src/testing/fakes/store.rs create mode 100644 crates/domain/src/testing/fakes/store_goal.rs create mode 100644 crates/domain/src/testing/fakes/store_import.rs create mode 100644 crates/domain/src/testing/fakes/store_movie.rs create mode 100644 crates/domain/src/testing/fakes/store_review.rs create mode 100644 crates/domain/src/testing/fakes/store_session.rs create mode 100644 crates/domain/src/testing/fakes/store_social.rs create mode 100644 crates/domain/src/testing/fakes/store_user.rs create mode 100644 crates/domain/src/testing/fakes/store_watch_event.rs create mode 100644 crates/domain/src/testing/fakes/store_watchlist.rs rename crates/domain/src/testing/{fakes.rs => fakes/stubs.rs} (100%) create mode 100644 crates/domain/src/testing/fakes/tests.rs rename crates/domain/src/testing/{ => fakes}/wrapup.rs (100%) delete mode 100644 crates/domain/src/testing/in_memory.rs diff --git a/crates/adapters/http-axum/src/extractors/tests.rs b/crates/adapters/http-axum/src/extractors/tests.rs index af6d7b20..271344fc 100644 --- a/crates/adapters/http-axum/src/extractors/tests.rs +++ b/crates/adapters/http-axum/src/extractors/tests.rs @@ -24,7 +24,7 @@ use domain::{ PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository, WatchlistRepository, }, - testing::InMemoryUserRepository, + testing::InMemoryStore, value_objects::{ Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, ReleaseYear, ReviewId, UserId, @@ -1131,11 +1131,11 @@ fn admin_id() -> UserId { #[tokio::test] async fn admin_api_user_allows_an_admin_user() { let id = admin_id(); - let user_repo = InMemoryUserRepository::new(); - user_repo.store.lock().unwrap().insert( - id.value(), - domain::testing::test_user_with_role(id.clone(), domain::models::UserRole::Admin), - ); + 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)), @@ -1156,11 +1156,11 @@ async fn admin_api_user_allows_an_admin_user() { #[tokio::test] async fn admin_api_user_rejects_a_non_admin_user() { let id = admin_id(); - let user_repo = InMemoryUserRepository::new(); - user_repo.store.lock().unwrap().insert( - id.value(), - domain::testing::test_user_with_role(id.clone(), domain::models::UserRole::Standard), - ); + 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)), @@ -1181,7 +1181,7 @@ async fn admin_api_user_rejects_a_non_admin_user() { #[tokio::test] async fn admin_api_user_rejects_an_unknown_user() { let id = admin_id(); - let user_repo = InMemoryUserRepository::new(); // empty — id is not in the store + 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)), @@ -1222,11 +1222,11 @@ async fn admin_api_user_surfaces_a_repository_error_as_500() { #[tokio::test] async fn admin_user_allows_an_admin_user() { let id = admin_id(); - let user_repo = InMemoryUserRepository::new(); - user_repo.store.lock().unwrap().insert( - id.value(), - domain::testing::test_user_with_role(id.clone(), domain::models::UserRole::Admin), - ); + 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)), @@ -1247,11 +1247,11 @@ async fn admin_user_allows_an_admin_user() { #[tokio::test] async fn admin_user_rejects_a_non_admin_user() { let id = admin_id(); - let user_repo = InMemoryUserRepository::new(); - user_repo.store.lock().unwrap().insert( - id.value(), - domain::testing::test_user_with_role(id.clone(), domain::models::UserRole::Standard), - ); + 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)), @@ -1272,7 +1272,7 @@ async fn admin_user_rejects_a_non_admin_user() { #[tokio::test] async fn admin_user_rejects_an_unknown_user() { let id = admin_id(); - let user_repo = InMemoryUserRepository::new(); // empty — id is not in the store + 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)), diff --git a/crates/adapters/sqlite/tests/cascade_test.rs b/crates/adapters/sqlite/tests/cascade_test.rs new file mode 100644 index 00000000..4e671fd4 --- /dev/null +++ b/crates/adapters/sqlite/tests/cascade_test.rs @@ -0,0 +1,317 @@ +//! Real-database counterpart of the `InMemoryStore` cascade tests in the domain +//! crate. Both iterate the same table lists (`domain::testing::*_CASCADE_TABLES`), +//! so the fake and the schema cannot drift apart unnoticed. + +use std::collections::BTreeSet; +use std::str::FromStr; + +use chrono::Utc; +use domain::models::{ + CastMember, CrewMember, Genre, Keyword, Movie, MovieProfile, ProfileField, UserSettings, + WatchEvent, WatchEventSource, WatchlistEntry, WebhookToken, +}; +use domain::ports::{ + FollowCommand, UserProfileFieldsRepository, WatchEventCommand, WebhookTokenRepository, +}; +use domain::testing::{ + MOVIE_CASCADE_TABLES, USER_CASCADE_TABLES, USER_CASCADE_TABLES_OUTSIDE_STORE, test_goal, + test_movie, test_review, test_user, test_watched_at, +}; +use domain::value_objects::{FollowStatus, MovieId, UserId}; +use sqlite::{ + SqliteProfileFieldsRepository, SqliteSocialRepository, SqliteWatchEventRepository, + SqliteWebhookTokenRepository, +}; +use sqlx::SqlitePool; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + +const REMOTE_ACTOR: &str = "https://remote.example/users/ann"; + +/// Same connect options `sqlite::wire` builds from a `sqlite:` URL (sqlx turns +/// `foreign_keys` on by default), against a private in-memory database. +async fn migrated_pool() -> SqlitePool { + let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(opts) + .await + .unwrap(); + let (foreign_keys,): (i64,) = sqlx::query_as("PRAGMA foreign_keys") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + foreign_keys, 1, + "tests must run with FKs enforced like production" + ); + sqlite::migrate(&pool).await.unwrap(); + pool +} + +async fn count(pool: &SqlitePool, table: &str) -> i64 { + sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}")) + .fetch_one(pool) + .await + .unwrap() +} + +async fn tables_cascading_from(pool: &SqlitePool, parent: &str) -> BTreeSet { + sqlx::query_scalar::<_, String>( + "SELECT m.name FROM sqlite_master m, pragma_foreign_key_list(m.name) f + WHERE m.type = 'table' AND f.\"table\" = ? AND f.on_delete = 'CASCADE'", + ) + .bind(parent) + .fetch_all(pool) + .await + .unwrap() + .into_iter() + .collect() +} + +fn names(lists: &[&[&str]]) -> BTreeSet { + lists + .iter() + .flat_map(|l| l.iter()) + .map(|t| t.to_string()) + .collect() +} + +fn movie_profile(movie_id: &MovieId) -> MovieProfile { + MovieProfile { + movie_id: movie_id.clone(), + tmdb_id: 603, + imdb_id: None, + overview: None, + tagline: None, + runtime_minutes: None, + budget_usd: None, + revenue_usd: None, + vote_average: None, + vote_count: None, + original_language: None, + collection_name: None, + genres: vec![Genre { + tmdb_id: 1, + name: "Sci-Fi".into(), + }], + keywords: vec![Keyword { + tmdb_id: 2, + name: "simulation".into(), + }], + cast: vec![CastMember { + tmdb_person_id: 3, + name: "Actor".into(), + character: "Role".into(), + billing_order: 0, + profile_path: None, + }], + crew: vec![CrewMember { + tmdb_person_id: 4, + name: "Director".into(), + job: "Director".into(), + department: "Directing".into(), + profile_path: None, + }], + enriched_at: Utc::now(), + } +} + +async fn seed_user_with_dependents( + pool: &SqlitePool, + parts: &sqlite::SqliteWireOutput, +) -> (UserId, Movie) { + let user = test_user("alice"); + let user_id = user.id().clone(); + let movie = test_movie("Heat"); + parts.user.save(&user).await.unwrap(); + parts.movie_command.upsert_movie(&movie).await.unwrap(); + + parts + .watchlist + .add(&WatchlistEntry::new(user_id.clone(), movie.id().clone())) + .await + .unwrap(); + parts + .goal_command + .save(&test_goal(&user_id, 2024, 50)) + .await + .unwrap(); + parts + .user_settings + .save(&UserSettings::new(user_id.clone())) + .await + .unwrap(); + SqliteProfileFieldsRepository::new(pool.clone()) + .set_fields( + &user_id, + vec![ProfileField { + name: "Site".into(), + value: "https://example.com".into(), + }], + ) + .await + .unwrap(); + SqliteWebhookTokenRepository::new(pool.clone()) + .save(&WebhookToken::new( + user_id.clone(), + "hash".into(), + WatchEventSource::Jellyfin, + None, + )) + .await + .unwrap(); + SqliteWatchEventRepository::new(pool.clone()) + .save(&WatchEvent::new( + user_id.clone(), + "Heat".into(), + Some(1995), + None, + WatchEventSource::Jellyfin, + test_watched_at(), + None, + )) + .await + .unwrap(); + let social = SqliteSocialRepository::new( + pool.clone(), + domain::value_objects::InstanceIdentity::new("https://md.example"), + ); + social + .add_follow(user_id.value(), REMOTE_ACTOR, FollowStatus::Accepted) + .await + .unwrap(); + social + .add_follower(user_id.value(), REMOTE_ACTOR, FollowStatus::Accepted) + .await + .unwrap(); + // No repository in this crate owns these two tables (federation crate). + sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES (?, ?, 'now')") + .bind(user_id.value().to_string()) + .bind(REMOTE_ACTOR) + .execute(pool) + .await + .unwrap(); + sqlx::query("INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) VALUES (?, 'pub', 'priv', 'now')") + .bind(user_id.value().to_string()) + .execute(pool) + .await + .unwrap(); + + parts + .review + .save_review(&test_review(movie.id(), &user_id, 4)) + .await + .unwrap(); + (user_id, movie) +} + +#[tokio::test] +async fn the_cascade_lists_match_the_foreign_keys_declared_by_the_migrations() { + let pool = migrated_pool().await; + + assert_eq!( + tables_cascading_from(&pool, "users").await, + names(&[USER_CASCADE_TABLES, USER_CASCADE_TABLES_OUTSIDE_STORE]), + ); + assert_eq!( + tables_cascading_from(&pool, "movies").await, + names(&[MOVIE_CASCADE_TABLES]), + ); +} + +#[tokio::test] +async fn deleting_a_user_cascades_to_every_table_that_hangs_off_a_user() { + let pool = migrated_pool().await; + let parts = sqlite::wire_pool(pool.clone()).await.unwrap(); + let (user_id, _) = seed_user_with_dependents(&pool, &parts).await; + for table in USER_CASCADE_TABLES + .iter() + .chain(USER_CASCADE_TABLES_OUTSIDE_STORE) + { + assert!(count(&pool, table).await > 0, "{table} should be seeded"); + } + + sqlx::query("DELETE FROM users WHERE id = ?") + .bind(user_id.value().to_string()) + .execute(&pool) + .await + .unwrap(); + + for table in USER_CASCADE_TABLES + .iter() + .chain(USER_CASCADE_TABLES_OUTSIDE_STORE) + { + assert_eq!(count(&pool, table).await, 0, "{table} should cascade"); + } + assert_eq!(count(&pool, "reviews").await, 1, "reviews have no user FK"); +} + +#[tokio::test] +async fn deleting_a_movie_cascades_to_watchlist_entries_and_every_enrichment_table() { + let pool = migrated_pool().await; + let parts = sqlite::wire_pool(pool.clone()).await.unwrap(); + let user = test_user("alice"); + let movie = test_movie("Heat"); + parts.user.save(&user).await.unwrap(); + parts.movie_command.upsert_movie(&movie).await.unwrap(); + parts + .watchlist + .add(&WatchlistEntry::new(user.id().clone(), movie.id().clone())) + .await + .unwrap(); + parts + .movie_profile + .upsert(&movie_profile(movie.id())) + .await + .unwrap(); + SqliteWatchEventRepository::new(pool.clone()) + .save(&WatchEvent::new( + user.id().clone(), + "Heat".into(), + Some(1995), + None, + WatchEventSource::Plex, + test_watched_at(), + Some(movie.id().clone()), + )) + .await + .unwrap(); + for table in MOVIE_CASCADE_TABLES { + assert!(count(&pool, table).await > 0, "{table} should be seeded"); + } + + parts.movie_command.delete_movie(movie.id()).await.unwrap(); + + for table in MOVIE_CASCADE_TABLES { + assert_eq!(count(&pool, table).await, 0, "{table} should cascade"); + } + let (movie_id,): (Option,) = sqlx::query_as("SELECT movie_id FROM watch_events") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(movie_id, None, "watch events are detached, not deleted"); +} + +#[tokio::test] +async fn deleting_a_movie_that_has_reviews_is_refused_because_reviews_do_not_cascade() { + let pool = migrated_pool().await; + let parts = sqlite::wire_pool(pool.clone()).await.unwrap(); + let user = test_user("alice"); + let movie = test_movie("Heat"); + parts.user.save(&user).await.unwrap(); + parts.movie_command.upsert_movie(&movie).await.unwrap(); + parts + .review + .save_review(&test_review(movie.id(), user.id(), 5)) + .await + .unwrap(); + + let result = parts.movie_command.delete_movie(movie.id()).await; + + assert!(matches!( + result, + Err(domain::errors::DomainError::InfrastructureError(_)) + )); + assert_eq!(count(&pool, "movies").await, 1); + assert_eq!(count(&pool, "reviews").await, 1); +} diff --git a/crates/application/src/auth/tests/login.rs b/crates/application/src/auth/tests/login.rs index b317e2b5..6b422981 100644 --- a/crates/application/src/auth/tests/login.rs +++ b/crates/application/src/auth/tests/login.rs @@ -1,7 +1,7 @@ use crate::test_support::{login_command, register_command}; use std::sync::Arc; -use domain::testing::InMemoryUserRepository; +use domain::testing::InMemoryStore; use crate::{ auth::{ @@ -31,7 +31,7 @@ async fn setup_user(b: &TestContextBuilder, email: &str, password: &str) { #[tokio::test] async fn test_login_valid_credentials_returns_token() { - let users = InMemoryUserRepository::new(); + let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); setup_user(&b, "carol@example.com", "secret123").await; @@ -53,7 +53,7 @@ async fn test_login_valid_credentials_returns_token() { #[tokio::test] async fn test_login_wrong_password_fails() { - let users = InMemoryUserRepository::new(); + let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); setup_user(&b, "dave@example.com", "correct_password").await; diff --git a/crates/application/src/auth/tests/logout.rs b/crates/application/src/auth/tests/logout.rs index ac355548..582cf530 100644 --- a/crates/application/src/auth/tests/logout.rs +++ b/crates/application/src/auth/tests/logout.rs @@ -1,7 +1,7 @@ use crate::test_support::{login_command, register_command}; use std::sync::Arc; -use domain::testing::InMemoryUserRepository; +use domain::testing::InMemoryStore; use crate::{ auth::{ @@ -13,7 +13,7 @@ use crate::{ #[tokio::test] async fn logout_revokes_refresh_token() { - let users = InMemoryUserRepository::new(); + let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); let reg_deps = RegisterDeps { diff --git a/crates/application/src/auth/tests/refresh.rs b/crates/application/src/auth/tests/refresh.rs index 21493cc4..cbaf3c77 100644 --- a/crates/application/src/auth/tests/refresh.rs +++ b/crates/application/src/auth/tests/refresh.rs @@ -1,7 +1,7 @@ use crate::test_support::{login_command, register_command}; use std::sync::Arc; -use domain::testing::InMemoryUserRepository; +use domain::testing::InMemoryStore; use crate::{ auth::{ @@ -38,7 +38,7 @@ async fn login_user(b: &TestContextBuilder) -> login::LoginResult { #[tokio::test] async fn refresh_returns_new_tokens() { - let users = InMemoryUserRepository::new(); + let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); let login_result = login_user(&b).await; @@ -58,7 +58,7 @@ async fn refresh_returns_new_tokens() { #[tokio::test] async fn refresh_rotates_token_old_one_invalid() { - let users = InMemoryUserRepository::new(); + let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); let login_result = login_user(&b).await; let old_token = login_result.refresh_token.clone(); @@ -76,7 +76,7 @@ async fn refresh_rotates_token_old_one_invalid() { #[tokio::test] async fn refresh_with_new_token_works() { - let users = InMemoryUserRepository::new(); + let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); let login_result = login_user(&b).await; diff --git a/crates/application/src/auth/tests/register.rs b/crates/application/src/auth/tests/register.rs index 5249ddcc..71025243 100644 --- a/crates/application/src/auth/tests/register.rs +++ b/crates/application/src/auth/tests/register.rs @@ -2,7 +2,7 @@ use crate::test_support::register_command; use std::sync::Arc; use domain::ports::UserRepository; -use domain::testing::InMemoryUserRepository; +use domain::testing::InMemoryStore; use domain::value_objects::Email; use crate::{ @@ -16,7 +16,7 @@ fn cmd(email: &str) -> RegisterCommand { #[tokio::test] async fn test_register_creates_user() { - let users = InMemoryUserRepository::new(); + let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); let deps = RegisterDeps { user: b.user_repo.clone(), @@ -36,7 +36,7 @@ async fn test_register_creates_user() { #[tokio::test] async fn test_register_duplicate_email_fails() { - let users = InMemoryUserRepository::new(); + let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); let deps = RegisterDeps { user: b.user_repo.clone(), diff --git a/crates/application/src/diary/tests/delete_review.rs b/crates/application/src/diary/tests/delete_review.rs index a30902ab..121e0d42 100644 --- a/crates/application/src/diary/tests/delete_review.rs +++ b/crates/application/src/diary/tests/delete_review.rs @@ -3,9 +3,7 @@ use std::sync::Arc; use domain::{ ports::{MovieCommand, MovieQuery, ReviewRepository}, - testing::{ - FakeDiaryQuery, InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher, - }, + testing::{FakeDiaryQuery, InMemoryStore, NoopEventPublisher}, value_objects::{MovieId, UserId}, }; @@ -15,8 +13,8 @@ use crate::{ #[tokio::test] async fn test_delete_review_removes_it() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); let diary = FakeDiaryQuery::new(); let events = NoopEventPublisher::new(); @@ -45,7 +43,7 @@ async fn test_delete_review_removes_it() { .await .unwrap(); - assert_eq!(reviews.count(), 0, "review should be deleted"); + assert_eq!(reviews.review_count(), 0, "review should be deleted"); assert!( movies.get_movie_by_id(movie.id()).await.unwrap().is_none(), "movie should be deleted when no reviews remain" @@ -54,9 +52,9 @@ async fn test_delete_review_removes_it() { #[tokio::test] async fn test_delete_review_wrong_user_is_unauthorized() { - let reviews = InMemoryReviewRepository::new(); + let reviews = InMemoryStore::new(); let diary = FakeDiaryQuery::new(); - let movies = InMemoryMovieRepository::new(); + let movies = Arc::clone(&reviews); let events = NoopEventPublisher::new(); let movie_id = MovieId::from_uuid(uuid::Uuid::new_v4()); @@ -83,5 +81,5 @@ async fn test_delete_review_wrong_user_is_unauthorized() { .await; assert!(result.is_err(), "wrong user should not be able to delete"); - assert_eq!(reviews.count(), 1, "review should still exist"); + assert_eq!(reviews.review_count(), 1, "review should still exist"); } diff --git a/crates/application/src/diary/tests/edit_review.rs b/crates/application/src/diary/tests/edit_review.rs index e5244c8b..d0a92afc 100644 --- a/crates/application/src/diary/tests/edit_review.rs +++ b/crates/application/src/diary/tests/edit_review.rs @@ -4,19 +4,19 @@ use std::sync::Arc; use domain::{ models::ReviewEdit, ports::ReviewRepository, - testing::{InMemoryReviewRepository, NoopEventPublisher, test_review}, + testing::{InMemoryStore, NoopEventPublisher, test_review}, value_objects::{Comment, MovieId, ReviewId, UserId, WatchMedium}, }; use crate::diary::{commands::EditReviewCommand, deps::EditReviewDeps, edit_review}; async fn setup() -> ( - Arc, + Arc, Arc, ReviewId, UserId, ) { - let reviews = InMemoryReviewRepository::new(); + let reviews = InMemoryStore::new(); let events = NoopEventPublisher::new(); let user_id = UserId::generate(); let review = test_review(&MovieId::generate(), &user_id, 3).apply_edit(ReviewEdit { @@ -29,10 +29,7 @@ async fn setup() -> ( (reviews, events, review_id, user_id) } -fn deps( - reviews: &Arc, - events: &Arc, -) -> EditReviewDeps { +fn deps(reviews: &Arc, events: &Arc) -> EditReviewDeps { EditReviewDeps { review: Arc::clone(reviews) as _, event_publisher: Arc::clone(events) as _, @@ -62,7 +59,7 @@ async fn edit_own_review_updates_rating() { #[tokio::test] async fn edit_nonexistent_review_returns_not_found() { - let reviews = InMemoryReviewRepository::new(); + let reviews = InMemoryStore::new(); let events = NoopEventPublisher::new(); let err = edit_review::execute(&deps(&reviews, &events), edit_review_command(5)) diff --git a/crates/application/src/diary/tests/get_activity_feed.rs b/crates/application/src/diary/tests/get_activity_feed.rs index 0780aee8..7e923f56 100644 --- a/crates/application/src/diary/tests/get_activity_feed.rs +++ b/crates/application/src/diary/tests/get_activity_feed.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use async_trait::async_trait; use domain::errors::DomainError; -use domain::testing::InMemorySocialRepository; +use domain::testing::InMemoryStore; use domain::value_objects::{FollowRelation, SocialActor, SocialIdentity, UserId}; use crate::{ @@ -13,7 +13,7 @@ use crate::{ fn default_deps() -> GetActivityFeedDeps { GetActivityFeedDeps { diary: domain::testing::FakeDiaryQuery::new() as _, - social_query: InMemorySocialRepository::new() as _, + social_query: InMemoryStore::new() as _, config: TestContextBuilder::new().config, } } diff --git a/crates/application/src/diary/tests/get_movie_social_page.rs b/crates/application/src/diary/tests/get_movie_social_page.rs index 547b543b..dfab284d 100644 --- a/crates/application/src/diary/tests/get_movie_social_page.rs +++ b/crates/application/src/diary/tests/get_movie_social_page.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use domain::{ ports::MovieCommand, - testing::{FakeDiaryQuery, InMemoryMovieProfileRepository, InMemoryMovieRepository}, + testing::{FakeDiaryQuery, InMemoryStore}, }; use crate::{ @@ -16,9 +16,9 @@ use crate::{ #[tokio::test] async fn fails_when_movie_not_found() { let deps = GetMovieSocialPageDeps { - movie_query: InMemoryMovieRepository::new(), + movie_query: InMemoryStore::new(), diary: FakeDiaryQuery::new() as _, - movie_profile: InMemoryMovieProfileRepository::new(), + movie_profile: InMemoryStore::new(), }; let result = get_movie_social_page::execute( @@ -36,7 +36,7 @@ async fn fails_when_movie_not_found() { #[tokio::test] async fn returns_movie_social_page() { - let movies = InMemoryMovieRepository::new(); + let movies = InMemoryStore::new(); let movie = test_movie_with_year("Social Movie", 2024); let movie_uuid = movie.id().value(); @@ -45,7 +45,7 @@ async fn returns_movie_social_page() { let deps = GetMovieSocialPageDeps { movie_query: Arc::clone(&movies) as _, diary: FakeDiaryQuery::new() as _, - movie_profile: InMemoryMovieProfileRepository::new(), + movie_profile: InMemoryStore::new(), }; let result = get_movie_social_page::execute( diff --git a/crates/application/src/diary/tests/log_review.rs b/crates/application/src/diary/tests/log_review.rs index f511bfdd..78f11a32 100644 --- a/crates/application/src/diary/tests/log_review.rs +++ b/crates/application/src/diary/tests/log_review.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use domain::ports::MovieCommand; -use domain::testing::{InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher}; +use domain::testing::{InMemoryStore, NoopEventPublisher}; use crate::{ diary::commands::LogReviewCommand, diary::log_review, @@ -12,8 +12,8 @@ use crate::{ }; fn build_logger( - movies: &Arc, - reviews: &Arc, + movies: &Arc, + reviews: &Arc, events: &Arc, ) -> Arc { Arc::new(DefaultReviewLogger::new( @@ -28,8 +28,8 @@ fn build_logger( #[tokio::test] async fn test_log_review_creates_movie_and_review() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); let events = NoopEventPublisher::new(); let logger = build_logger(&movies, &reviews, &events); @@ -38,14 +38,14 @@ async fn test_log_review_creates_movie_and_review() { log_review::execute(&logger, cmd).await.unwrap(); - assert_eq!(reviews.count(), 1, "review should be saved"); + assert_eq!(reviews.review_count(), 1, "review should be saved"); assert!(!events.published().is_empty(), "events should be published"); } #[tokio::test] async fn test_log_review_reuses_existing_movie() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); let existing_movie = test_movie_with_year("Alien", 1979); let movie_uuid = existing_movie.id().value(); @@ -61,14 +61,14 @@ async fn test_log_review_reuses_existing_movie() { log_review::execute(&logger, cmd).await.unwrap(); - assert_eq!(movies.count(), 1, "no duplicate movie"); - assert_eq!(reviews.count(), 1); + assert_eq!(movies.movie_count(), 1, "no duplicate movie"); + assert_eq!(reviews.review_count(), 1); } #[tokio::test] async fn test_log_review_with_invalid_rating_fails() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); let events = NoopEventPublisher::new(); let logger = build_logger(&movies, &reviews, &events); diff --git a/crates/application/src/diary/tests/review_logger.rs b/crates/application/src/diary/tests/review_logger.rs index 3b8fbaf0..0077b572 100644 --- a/crates/application/src/diary/tests/review_logger.rs +++ b/crates/application/src/diary/tests/review_logger.rs @@ -8,10 +8,7 @@ use domain::{ models::WatchlistEntry, models::{MetadataSearchCriteria, Movie}, ports::{MetadataClient, MovieCommand, WatchlistRepository}, - testing::{ - FakeMetadataClient, InMemoryMovieRepository, InMemoryReviewRepository, - InMemoryWatchlistRepository, NoopEventPublisher, - }, + testing::{FakeMetadataClient, InMemoryStore, NoopEventPublisher}, value_objects::{ExternalMetadataId, MovieId, PosterUrl, UserId}, }; use uuid::Uuid; @@ -21,9 +18,9 @@ use crate::diary::review_logger::DefaultReviewLogger; use crate::ports::ReviewLogger; fn make_logger( - movies: &Arc, - reviews: &Arc, - watchlist: &Arc, + movies: &Arc, + reviews: &Arc, + watchlist: &Arc, events: &Arc, ) -> DefaultReviewLogger { DefaultReviewLogger::new( @@ -38,9 +35,9 @@ fn make_logger( #[tokio::test] async fn logs_review_with_manual_movie() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + let watchlist = Arc::clone(&movies); let events = NoopEventPublisher::new(); let logger = make_logger(&movies, &reviews, &watchlist, &events); @@ -49,15 +46,15 @@ async fn logs_review_with_manual_movie() { logger.log_review(cmd).await.unwrap(); - assert_eq!(movies.count(), 1); - assert_eq!(reviews.count(), 1); + assert_eq!(movies.movie_count(), 1); + assert_eq!(reviews.review_count(), 1); } #[tokio::test] async fn removes_from_watchlist_on_review() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + let watchlist = Arc::clone(&movies); let events = NoopEventPublisher::new(); let logger = make_logger(&movies, &reviews, &watchlist, &events); @@ -72,7 +69,7 @@ async fn removes_from_watchlist_on_review() { // Add to watchlist let entry = WatchlistEntry::new(user_id.clone(), MovieId::from_uuid(movie_id)); watchlist.add(&entry).await.unwrap(); - assert_eq!(watchlist.count(), 1); + assert_eq!(watchlist.watchlist_count(), 1); // Log review for same movie let cmd = LogReviewCommand { @@ -82,14 +79,14 @@ async fn removes_from_watchlist_on_review() { logger.log_review(cmd).await.unwrap(); - assert_eq!(watchlist.count(), 0); + assert_eq!(watchlist.watchlist_count(), 0); } #[tokio::test] async fn logs_review_with_existing_movie_by_id() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + let watchlist = Arc::clone(&movies); let events = NoopEventPublisher::new(); let logger = make_logger(&movies, &reviews, &watchlist, &events); @@ -104,15 +101,15 @@ async fn logs_review_with_existing_movie_by_id() { logger.log_review(cmd).await.unwrap(); - assert_eq!(movies.count(), 1); - assert_eq!(reviews.count(), 1); + assert_eq!(movies.movie_count(), 1); + assert_eq!(reviews.review_count(), 1); } #[tokio::test] async fn existing_movie_not_found_returns_error() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + let watchlist = Arc::clone(&movies); let events = NoopEventPublisher::new(); let logger = make_logger(&movies, &reviews, &watchlist, &events); @@ -123,9 +120,9 @@ async fn existing_movie_not_found_returns_error() { #[tokio::test] async fn invalid_rating_returns_error() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + let watchlist = Arc::clone(&movies); let events = NoopEventPublisher::new(); let logger = make_logger(&movies, &reviews, &watchlist, &events); @@ -137,15 +134,15 @@ async fn invalid_rating_returns_error() { let result = logger.log_review(cmd).await; assert!(result.is_err()); // No repo calls should have happened - assert_eq!(movies.count(), 0); - assert_eq!(reviews.count(), 0); + assert_eq!(movies.movie_count(), 0); + assert_eq!(reviews.review_count(), 0); } #[tokio::test] async fn watchlist_not_present_does_not_publish_removed() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + let watchlist = Arc::clone(&movies); let events = NoopEventPublisher::new(); let logger = make_logger(&movies, &reviews, &watchlist, &events); @@ -187,9 +184,9 @@ impl MetadataClient for MetadataClientWithExternalId { #[tokio::test] async fn publishes_movie_discovered_for_new_movie_with_external_id() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + let watchlist = Arc::clone(&movies); let events = NoopEventPublisher::new(); let logger = DefaultReviewLogger::new( diff --git a/crates/application/src/goals/tests/create.rs b/crates/application/src/goals/tests/create.rs index b3db8e00..a4996fcd 100644 --- a/crates/application/src/goals/tests/create.rs +++ b/crates/application/src/goals/tests/create.rs @@ -2,7 +2,7 @@ use crate::test_support::create_goal_command; use std::sync::Arc; use domain::events::DomainEvent; -use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher}; +use domain::testing::{FakeStatsRepository, InMemoryStore, NoopEventPublisher}; use uuid::Uuid; use crate::goals::create; @@ -11,7 +11,7 @@ use crate::test_helpers::TestContextBuilder; #[tokio::test] async fn creates_goal_and_returns_progress() { - let goals = InMemoryGoalRepository::new(); + let goals = InMemoryStore::new(); let stats = FakeStatsRepository::new(); let events = NoopEventPublisher::new(); let deps = GoalCommandDeps { @@ -28,12 +28,12 @@ async fn creates_goal_and_returns_progress() { assert_eq!(result.goal.year(), 2025); assert_eq!(result.goal.target_count(), 50); assert_eq!(result.current_count, 0); - assert_eq!(goals.count(), 1); + assert_eq!(goals.goal_count(), 1); } #[tokio::test] async fn creates_goal_with_review_count() { - let goals = InMemoryGoalRepository::new(); + let goals = InMemoryStore::new(); let stats = FakeStatsRepository::new(); stats.set_review_count(Uuid::nil(), 2025, 5); let events = NoopEventPublisher::new(); @@ -49,7 +49,7 @@ async fn creates_goal_with_review_count() { .unwrap(); assert_eq!(result.current_count, 5); - assert_eq!(goals.count(), 1); + assert_eq!(goals.goal_count(), 1); } #[tokio::test] diff --git a/crates/application/src/goals/tests/delete.rs b/crates/application/src/goals/tests/delete.rs index 07aa2724..6ff2d425 100644 --- a/crates/application/src/goals/tests/delete.rs +++ b/crates/application/src/goals/tests/delete.rs @@ -1,7 +1,7 @@ use crate::test_support::create_goal_command; use std::sync::Arc; -use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher}; +use domain::testing::{FakeStatsRepository, InMemoryStore, NoopEventPublisher}; use uuid::Uuid; use crate::goals::deps::GoalCommandDeps; @@ -10,7 +10,7 @@ use crate::test_helpers::TestContextBuilder; #[tokio::test] async fn deletes_existing_goal() { - let goals = InMemoryGoalRepository::new(); + let goals = InMemoryStore::new(); let stats = FakeStatsRepository::new(); let events = NoopEventPublisher::new(); let deps = GoalCommandDeps { @@ -23,7 +23,7 @@ async fn deletes_existing_goal() { create::execute(&deps, create_goal_command(2025, 10)) .await .unwrap(); - assert_eq!(goals.count(), 1); + assert_eq!(goals.goal_count(), 1); delete::execute( &deps, @@ -35,7 +35,7 @@ async fn deletes_existing_goal() { .await .unwrap(); - assert_eq!(goals.count(), 0); + assert_eq!(goals.goal_count(), 0); } #[tokio::test] diff --git a/crates/application/src/import/tests/apply_mapping.rs b/crates/application/src/import/tests/apply_mapping.rs index 93d7755b..57179cd1 100644 --- a/crates/application/src/import/tests/apply_mapping.rs +++ b/crates/application/src/import/tests/apply_mapping.rs @@ -10,7 +10,7 @@ use domain::{ import::{ImportRow, ParsedFile, RowResult}, }, ports::{DocumentParser, MovieCommand}, - testing::{InMemoryImportSessionRepository, InMemoryMovieRepository}, + testing::InMemoryStore, value_objects::ExternalMetadataId, }; @@ -20,7 +20,7 @@ use crate::test_helpers::TestContextBuilder; #[tokio::test] async fn applies_mapping_to_session() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let b = TestContextBuilder::new(); let user_id = Uuid::new_v4(); @@ -54,7 +54,7 @@ async fn applies_mapping_to_session() { #[tokio::test] async fn fails_when_session_not_found() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let b = TestContextBuilder::new(); let deps = ApplyMappingDeps { @@ -105,8 +105,8 @@ impl DocumentParser for DuplicateTestParser { #[tokio::test] async fn marks_duplicate_by_external_id() { - let movies = InMemoryMovieRepository::new(); - let sessions = InMemoryImportSessionRepository::new(); + let movies = InMemoryStore::new(); + let sessions = Arc::clone(&movies); let ext_id = ExternalMetadataId::new("tt1234567".into()).unwrap(); let movie = test_movie_with_external_id("Known Movie", 2020, ext_id); @@ -154,8 +154,8 @@ async fn marks_duplicate_by_external_id() { #[tokio::test] async fn marks_duplicate_by_title_and_year() { - let movies = InMemoryMovieRepository::new(); - let sessions = InMemoryImportSessionRepository::new(); + let movies = InMemoryStore::new(); + let sessions = Arc::clone(&movies); let movie = test_movie_with_year("Duplicate Film", 2022); movies.upsert_movie(&movie).await.unwrap(); diff --git a/crates/application/src/import/tests/apply_profile.rs b/crates/application/src/import/tests/apply_profile.rs index c90caaa9..7420a5fd 100644 --- a/crates/application/src/import/tests/apply_profile.rs +++ b/crates/application/src/import/tests/apply_profile.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use chrono::Utc; use domain::models::ImportProfile; use domain::ports::{ImportProfileRepository, ImportSessionRepository}; -use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepository}; +use domain::testing::InMemoryStore; use domain::value_objects::{ImportProfileId, UserId}; use uuid::Uuid; @@ -13,8 +13,8 @@ use crate::import::deps::ApplyProfileDeps; #[tokio::test] async fn fails_when_profile_not_found() { - let profiles = InMemoryImportProfileRepository::new(); - let sessions = InMemoryImportSessionRepository::new(); + let profiles = InMemoryStore::new(); + let sessions = Arc::clone(&profiles); let deps = ApplyProfileDeps { import_profile: Arc::clone(&profiles) as _, @@ -32,8 +32,8 @@ async fn fails_when_profile_not_found() { #[tokio::test] async fn fails_when_session_not_found() { - let profiles = InMemoryImportProfileRepository::new(); - let sessions = InMemoryImportSessionRepository::new(); + let profiles = InMemoryStore::new(); + let sessions = Arc::clone(&profiles); let user_id = Uuid::new_v4(); let profile = ImportProfile::new( @@ -62,8 +62,8 @@ async fn fails_when_session_not_found() { #[tokio::test] async fn applies_profile_mappings_to_session() { - let profiles = InMemoryImportProfileRepository::new(); - let sessions = domain::testing::InMemoryImportSessionRepository::new(); + let profiles = InMemoryStore::new(); + let sessions = domain::testing::InMemoryStore::new(); let user_id = Uuid::new_v4(); let profile = ImportProfile::new( @@ -97,11 +97,11 @@ async fn applies_profile_mappings_to_session() { .unwrap(); // Verify the session got updated with field_mappings and row_results cleared - let updated = sessions - .get(&session_id, &UserId::from_uuid(user_id)) - .await - .unwrap() - .unwrap(); + let updated = + ImportSessionRepository::get(&*sessions, &session_id, &UserId::from_uuid(user_id)) + .await + .unwrap() + .unwrap(); assert!(updated.field_mappings.is_some()); assert_eq!(updated.field_mappings.unwrap().len(), 1); assert!(updated.row_results.is_none()); diff --git a/crates/application/src/import/tests/apply_profile_and_map.rs b/crates/application/src/import/tests/apply_profile_and_map.rs index 8906d5d1..b4b390d5 100644 --- a/crates/application/src/import/tests/apply_profile_and_map.rs +++ b/crates/application/src/import/tests/apply_profile_and_map.rs @@ -7,7 +7,7 @@ use uuid::Uuid; use domain::models::import::{DomainField, Transform}; use domain::models::{FieldMapping, ImportProfile}; use domain::ports::{ImportProfileRepository, ImportSessionRepository}; -use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepository}; +use domain::testing::InMemoryStore; use domain::value_objects::{ImportProfileId, UserId}; use crate::import::deps::{ApplyProfileAndMapDeps, CreateSessionDeps}; @@ -16,8 +16,8 @@ use crate::test_helpers::TestContextBuilder; #[tokio::test] async fn fails_when_profile_not_found() { - let profiles = InMemoryImportProfileRepository::new(); - let sessions = InMemoryImportSessionRepository::new(); + let profiles = InMemoryStore::new(); + let sessions = Arc::clone(&profiles); let b = TestContextBuilder::new(); let deps = ApplyProfileAndMapDeps { @@ -42,8 +42,8 @@ async fn fails_when_profile_not_found() { #[tokio::test] async fn applies_profile_then_regenerates_mapping() { - let profiles = InMemoryImportProfileRepository::new(); - let sessions = InMemoryImportSessionRepository::new(); + let profiles = InMemoryStore::new(); + let sessions = Arc::clone(&profiles); let b = TestContextBuilder::new(); let user_id = Uuid::new_v4(); @@ -92,11 +92,11 @@ async fn applies_profile_then_regenerates_mapping() { assert!(!rows.is_empty()); - let updated = sessions - .get(&created.session_id, &UserId::from_uuid(user_id)) - .await - .unwrap() - .unwrap(); + let updated = + ImportSessionRepository::get(&*sessions, &created.session_id, &UserId::from_uuid(user_id)) + .await + .unwrap() + .unwrap(); assert!(updated.row_results.is_some()); assert!(updated.field_mappings.is_some()); } diff --git a/crates/application/src/import/tests/cleanup.rs b/crates/application/src/import/tests/cleanup.rs index 9eb3b811..3302099e 100644 --- a/crates/application/src/import/tests/cleanup.rs +++ b/crates/application/src/import/tests/cleanup.rs @@ -1,12 +1,12 @@ use std::sync::Arc; -use domain::testing::InMemoryImportSessionRepository; +use domain::testing::InMemoryStore; use crate::import::cleanup; #[tokio::test] async fn returns_zero_when_nothing_expired() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let result = cleanup::execute(Arc::clone(&sessions) as _).await.unwrap(); diff --git a/crates/application/src/import/tests/create_session.rs b/crates/application/src/import/tests/create_session.rs index 9ebe974c..19119518 100644 --- a/crates/application/src/import/tests/create_session.rs +++ b/crates/application/src/import/tests/create_session.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use uuid::Uuid; -use domain::testing::InMemoryImportSessionRepository; +use domain::testing::InMemoryStore; use crate::import::create_session; use crate::import::deps::CreateSessionDeps; @@ -11,7 +11,7 @@ use crate::test_helpers::TestContextBuilder; #[tokio::test] async fn creates_session_with_parsed_file() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let b = TestContextBuilder::new(); let deps = CreateSessionDeps { diff --git a/crates/application/src/import/tests/delete_profile.rs b/crates/application/src/import/tests/delete_profile.rs index 32568636..79f89d18 100644 --- a/crates/application/src/import/tests/delete_profile.rs +++ b/crates/application/src/import/tests/delete_profile.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use domain::testing::InMemoryImportProfileRepository; +use domain::testing::InMemoryStore; use uuid::Uuid; use crate::import::{ @@ -9,7 +9,7 @@ use crate::import::{ #[tokio::test] async fn fails_when_profile_not_found() { - let profiles = InMemoryImportProfileRepository::new(); + let profiles = InMemoryStore::new(); let deps = DeleteImportProfileDeps { import_profile: Arc::clone(&profiles) as _, }; diff --git a/crates/application/src/import/tests/execute.rs b/crates/application/src/import/tests/execute.rs index cc9fddf0..fc2ca7ab 100644 --- a/crates/application/src/import/tests/execute.rs +++ b/crates/application/src/import/tests/execute.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::models::{AnnotatedRow, ImportSession, import::RowResult}; use domain::ports::ImportSessionRepository; -use domain::testing::InMemoryImportSessionRepository; +use domain::testing::InMemoryStore; use domain::value_objects::UserId; use uuid::Uuid; @@ -45,7 +45,7 @@ fn make_session_with_rows(user_id: UserId) -> ImportSession { #[tokio::test] async fn imports_confirmed_rows() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let uid = Uuid::new_v4(); let session = make_session_with_rows(UserId::from_uuid(uid)); @@ -74,7 +74,7 @@ async fn imports_confirmed_rows() { #[tokio::test] async fn skips_unconfirmed_rows() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let uid = Uuid::new_v4(); let session = make_session_with_rows(UserId::from_uuid(uid)); @@ -96,7 +96,7 @@ async fn skips_unconfirmed_rows() { #[tokio::test] async fn fails_when_session_not_found() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let deps = ExecuteImportDeps { import_session: Arc::clone(&sessions) as _, @@ -117,7 +117,7 @@ async fn fails_when_session_not_found() { #[tokio::test] async fn handles_datetime_format() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let uid = Uuid::new_v4(); let mut session = ImportSession::new(UserId::from_uuid(uid)); @@ -151,7 +151,7 @@ async fn handles_datetime_format() { #[tokio::test] async fn fails_on_invalid_rating() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let uid = Uuid::new_v4(); let mut session = ImportSession::new(UserId::from_uuid(uid)); @@ -185,7 +185,7 @@ async fn fails_on_invalid_rating() { #[tokio::test] async fn fails_on_missing_watched_at() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let uid = Uuid::new_v4(); let mut session = ImportSession::new(UserId::from_uuid(uid)); @@ -219,7 +219,7 @@ async fn fails_on_missing_watched_at() { #[tokio::test] async fn imports_row_with_external_metadata_id() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let uid = Uuid::new_v4(); let mut session = ImportSession::new(UserId::from_uuid(uid)); @@ -253,7 +253,7 @@ async fn imports_row_with_external_metadata_id() { #[tokio::test] async fn imports_row_with_director_and_comment() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let uid = Uuid::new_v4(); let mut session = ImportSession::new(UserId::from_uuid(uid)); @@ -287,7 +287,7 @@ async fn imports_row_with_director_and_comment() { #[tokio::test] async fn handles_space_separated_datetime_format() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let uid = Uuid::new_v4(); let mut session = ImportSession::new(UserId::from_uuid(uid)); @@ -321,7 +321,7 @@ async fn handles_space_separated_datetime_format() { #[tokio::test] async fn reports_invalid_row_result_errors() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let uid = Uuid::new_v4(); let mut session = ImportSession::new(UserId::from_uuid(uid)); @@ -352,7 +352,7 @@ async fn reports_invalid_row_result_errors() { #[tokio::test] async fn fails_on_missing_rating() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let uid = Uuid::new_v4(); let mut session = ImportSession::new(UserId::from_uuid(uid)); @@ -387,7 +387,7 @@ async fn fails_on_missing_rating() { #[tokio::test] async fn fails_on_unparseable_date() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let uid = Uuid::new_v4(); let mut session = ImportSession::new(UserId::from_uuid(uid)); @@ -422,7 +422,7 @@ async fn fails_on_unparseable_date() { #[tokio::test] async fn imports_row_without_release_year() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let uid = Uuid::new_v4(); let mut session = ImportSession::new(UserId::from_uuid(uid)); @@ -456,13 +456,13 @@ async fn imports_row_without_release_year() { #[tokio::test] async fn deletes_session_after_import() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let uid = Uuid::new_v4(); let session = make_session_with_rows(UserId::from_uuid(uid)); let sid = session.id.clone(); sessions.create(&session).await.unwrap(); - assert_eq!(sessions.count(), 1); + assert_eq!(sessions.import_session_count(), 1); let deps = ExecuteImportDeps { import_session: Arc::clone(&sessions) as _, @@ -474,7 +474,7 @@ async fn deletes_session_after_import() { .unwrap(); assert_eq!( - sessions.count(), + sessions.import_session_count(), 0, "session should be deleted after import" ); @@ -482,7 +482,7 @@ async fn deletes_session_after_import() { #[tokio::test] async fn imports_more_rows_than_concurrency_limit() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let uid = Uuid::new_v4(); let rows: Vec<_> = (0..15) diff --git a/crates/application/src/import/tests/get_mapping_stage.rs b/crates/application/src/import/tests/get_mapping_stage.rs index 11743d62..d33392f7 100644 --- a/crates/application/src/import/tests/get_mapping_stage.rs +++ b/crates/application/src/import/tests/get_mapping_stage.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use domain::models::ImportSession; use domain::models::import::ParsedFile; use domain::ports::ImportSessionRepository; -use domain::testing::InMemoryImportSessionRepository; +use domain::testing::InMemoryStore; use domain::value_objects::{ImportSessionId, UserId}; use crate::import::deps::GetMappingStageDeps; @@ -13,7 +13,7 @@ use crate::import::get_mapping_stage::{self, SAMPLE_ROW_LIMIT}; #[tokio::test] async fn get_mapping_stage_is_not_found_when_file_not_parsed() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let user_id = Uuid::new_v4(); let session = ImportSession::new(UserId::from_uuid(user_id)); let session_id = session.id.clone(); @@ -30,7 +30,7 @@ async fn get_mapping_stage_is_not_found_when_file_not_parsed() { #[tokio::test] async fn get_mapping_stage_is_not_found_when_session_missing() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let deps = GetMappingStageDeps { import_session: Arc::clone(&sessions) as _, }; @@ -43,7 +43,7 @@ async fn get_mapping_stage_is_not_found_when_session_missing() { #[tokio::test] async fn get_mapping_stage_returns_columns_and_capped_sample_rows() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let user_id = Uuid::new_v4(); let mut session = ImportSession::new(UserId::from_uuid(user_id)); session.parsed_file = Some(ParsedFile { diff --git a/crates/application/src/import/tests/get_preview_stage.rs b/crates/application/src/import/tests/get_preview_stage.rs index 7467c60a..a8644d3e 100644 --- a/crates/application/src/import/tests/get_preview_stage.rs +++ b/crates/application/src/import/tests/get_preview_stage.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use domain::models::import::{ImportRow, ParsedFile, RowResult}; use domain::models::{AnnotatedRow, ImportSession}; use domain::ports::ImportSessionRepository; -use domain::testing::InMemoryImportSessionRepository; +use domain::testing::InMemoryStore; use domain::value_objects::{ImportSessionId, UserId}; use crate::import::deps::GetPreviewStageDeps; @@ -13,7 +13,7 @@ use crate::import::get_preview_stage::{self, PreviewStage}; #[tokio::test] async fn get_preview_stage_reports_not_yet_mapped_when_row_results_absent() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let user_id = Uuid::new_v4(); let session = ImportSession::new(UserId::from_uuid(user_id)); let session_id = session.id.clone(); @@ -32,7 +32,7 @@ async fn get_preview_stage_reports_not_yet_mapped_when_row_results_absent() { #[tokio::test] async fn get_preview_stage_returns_rows_once_mapped() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let user_id = Uuid::new_v4(); let mut session = ImportSession::new(UserId::from_uuid(user_id)); session.parsed_file = Some(ParsedFile { @@ -68,7 +68,7 @@ async fn get_preview_stage_returns_rows_once_mapped() { #[tokio::test] async fn get_preview_stage_is_not_found_when_session_missing() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let deps = GetPreviewStageDeps { import_session: Arc::clone(&sessions) as _, }; diff --git a/crates/application/src/import/tests/get_session_state.rs b/crates/application/src/import/tests/get_session_state.rs index ac02dbd7..f8d4e218 100644 --- a/crates/application/src/import/tests/get_session_state.rs +++ b/crates/application/src/import/tests/get_session_state.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use domain::models::ImportSession; use domain::models::import::ParsedFile; use domain::ports::ImportSessionRepository; -use domain::testing::InMemoryImportSessionRepository; +use domain::testing::InMemoryStore; use domain::value_objects::{ImportSessionId, UserId}; use crate::import::deps::GetSessionStateDeps; @@ -13,7 +13,7 @@ use crate::import::get_session_state; #[tokio::test] async fn get_session_state_is_not_found_when_session_missing() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let deps = GetSessionStateDeps { import_session: Arc::clone(&sessions) as _, }; @@ -26,7 +26,7 @@ async fn get_session_state_is_not_found_when_session_missing() { #[tokio::test] async fn get_session_state_reports_columns_row_count_and_mapping_status() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let user_id = Uuid::new_v4(); let mut session = ImportSession::new(UserId::from_uuid(user_id)); session.parsed_file = Some(ParsedFile { diff --git a/crates/application/src/import/tests/list_profiles.rs b/crates/application/src/import/tests/list_profiles.rs index 79f20a7a..0fd096e5 100644 --- a/crates/application/src/import/tests/list_profiles.rs +++ b/crates/application/src/import/tests/list_profiles.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use domain::testing::InMemoryImportProfileRepository; +use domain::testing::InMemoryStore; use domain::value_objects::UserId; use uuid::Uuid; @@ -8,7 +8,7 @@ use crate::import::{deps::ListImportProfilesDeps, list_profiles}; #[tokio::test] async fn returns_empty_when_no_profiles() { - let profiles = InMemoryImportProfileRepository::new(); + let profiles = InMemoryStore::new(); let deps = ListImportProfilesDeps { import_profile: Arc::clone(&profiles) as _, }; diff --git a/crates/application/src/import/tests/save_profile.rs b/crates/application/src/import/tests/save_profile.rs index 41698fd5..4714a5dc 100644 --- a/crates/application/src/import/tests/save_profile.rs +++ b/crates/application/src/import/tests/save_profile.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use domain::models::ImportSession; use domain::ports::ImportSessionRepository; -use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepository}; +use domain::testing::InMemoryStore; use domain::value_objects::UserId; use uuid::Uuid; @@ -11,8 +11,8 @@ use crate::import::{commands::SaveImportProfileCommand, save_profile}; #[tokio::test] async fn fails_when_session_not_found() { - let sessions = InMemoryImportSessionRepository::new(); - let profiles = InMemoryImportProfileRepository::new(); + let sessions = InMemoryStore::new(); + let profiles = Arc::clone(&sessions); let deps = SaveProfileDeps { import_session: Arc::clone(&sessions) as _, @@ -34,8 +34,8 @@ async fn fails_when_session_not_found() { #[tokio::test] async fn saves_profile_from_session() { - let sessions = InMemoryImportSessionRepository::new(); - let profiles = InMemoryImportProfileRepository::new(); + let sessions = InMemoryStore::new(); + let profiles = Arc::clone(&sessions); let user_id = Uuid::new_v4(); let mut session = ImportSession::new(UserId::from_uuid(user_id)); diff --git a/crates/application/src/integrations/tests/cleanup.rs b/crates/application/src/integrations/tests/cleanup.rs index df614ae7..ec321c62 100644 --- a/crates/application/src/integrations/tests/cleanup.rs +++ b/crates/application/src/integrations/tests/cleanup.rs @@ -1,10 +1,10 @@ -use domain::testing::InMemoryWatchEventRepository; +use domain::testing::InMemoryStore; use crate::integrations::cleanup; #[tokio::test] async fn returns_zero_when_nothing_to_clean() { - let watch_events = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryStore::new(); let count = cleanup::execute(watch_events).await.unwrap(); diff --git a/crates/application/src/integrations/tests/confirm.rs b/crates/application/src/integrations/tests/confirm.rs index 9c7f3a99..a967d22e 100644 --- a/crates/application/src/integrations/tests/confirm.rs +++ b/crates/application/src/integrations/tests/confirm.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::models::{WatchEvent, WatchEventSource}; use domain::ports::{MovieCommand, WatchEventCommand}; -use domain::testing::{InMemoryWatchEventRepository, NoopEventPublisher}; +use domain::testing::{InMemoryStore, NoopEventPublisher}; use domain::value_objects::UserId; use uuid::Uuid; @@ -17,7 +17,7 @@ fn noop_logger() -> Arc { } fn deps( - watch_events: &Arc, + watch_events: &Arc, review_logger: Arc, ) -> ConfirmWatchEventsDeps { ConfirmWatchEventsDeps { @@ -29,7 +29,7 @@ fn deps( #[tokio::test] async fn confirms_watch_event_via_review_logger() { - let watch_events = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryStore::new(); let uid = Uuid::new_v4(); let event = WatchEvent::new( @@ -56,7 +56,7 @@ async fn confirms_watch_event_via_review_logger() { #[tokio::test] async fn empty_confirmations_returns_zero() { - let watch_events = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryStore::new(); let result = confirm::execute( &deps(&watch_events, noop_logger()), @@ -73,7 +73,7 @@ async fn empty_confirmations_returns_zero() { #[tokio::test] async fn confirms_event_with_external_metadata_id_and_no_movie_id() { - let watch_events = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryStore::new(); let uid = Uuid::new_v4(); let event = WatchEvent::new( @@ -107,7 +107,7 @@ async fn confirms_event_with_external_metadata_id_and_no_movie_id() { #[tokio::test] async fn rejects_other_users_event() { - let watch_events = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryStore::new(); let owner = Uuid::new_v4(); let intruder = Uuid::new_v4(); @@ -134,7 +134,7 @@ async fn rejects_other_users_event() { #[tokio::test] async fn fails_when_event_not_found() { - let watch_events = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryStore::new(); let result = confirm::execute( &deps(&watch_events, noop_logger()), @@ -147,7 +147,7 @@ async fn fails_when_event_not_found() { #[tokio::test] async fn confirms_event_with_movie_id() { - let watch_events = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryStore::new(); let events = NoopEventPublisher::new(); let uid = Uuid::new_v4(); let movie_uuid = Uuid::new_v4(); @@ -165,7 +165,7 @@ async fn confirms_event_with_movie_id() { watch_events.save(&event).await.unwrap(); // Also seed movie repo so review_logger can find it - let movies = domain::testing::InMemoryMovieRepository::new(); + let movies = domain::testing::InMemoryStore::new(); let movie = domain::models::Movie::from_persistence( domain::value_objects::MovieId::from_uuid(movie_uuid), None, @@ -177,8 +177,8 @@ async fn confirms_event_with_movie_id() { movies.upsert_movie(&movie).await.unwrap(); // Build a real review logger - let reviews = domain::testing::InMemoryReviewRepository::new(); - let watchlist = domain::testing::InMemoryWatchlistRepository::new(); + let reviews = domain::testing::InMemoryStore::new(); + let watchlist = domain::testing::InMemoryStore::new(); let review_logger: Arc = Arc::new(crate::diary::review_logger::DefaultReviewLogger::new( Arc::clone(&movies) as _, @@ -201,7 +201,7 @@ async fn confirms_event_with_movie_id() { #[tokio::test] async fn confirms_event_without_movie_id_and_without_external_metadata_id() { - let watch_events = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryStore::new(); let uid = Uuid::new_v4(); let event = WatchEvent::new( @@ -235,7 +235,7 @@ async fn confirms_event_without_movie_id_and_without_external_metadata_id() { #[tokio::test] async fn confirms_multiple_events() { - let watch_events = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryStore::new(); let uid = Uuid::new_v4(); let event1 = WatchEvent::new( @@ -289,7 +289,7 @@ async fn confirms_multiple_events() { #[tokio::test] async fn confirms_event_without_year() { - let watch_events = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryStore::new(); let uid = Uuid::new_v4(); let event = WatchEvent::new( diff --git a/crates/application/src/integrations/tests/dismiss.rs b/crates/application/src/integrations/tests/dismiss.rs index e764efc6..1ebfdfce 100644 --- a/crates/application/src/integrations/tests/dismiss.rs +++ b/crates/application/src/integrations/tests/dismiss.rs @@ -3,14 +3,14 @@ use std::sync::Arc; use domain::models::{WatchEvent, WatchEventSource}; use domain::ports::WatchEventCommand; -use domain::testing::InMemoryWatchEventRepository; +use domain::testing::InMemoryStore; use domain::value_objects::UserId; use uuid::Uuid; use crate::integrations::deps::DismissWatchEventsDeps; use crate::integrations::dismiss; -fn deps(watch_events: &Arc) -> DismissWatchEventsDeps { +fn deps(watch_events: &Arc) -> DismissWatchEventsDeps { DismissWatchEventsDeps { watch_event_command: Arc::clone(watch_events) as _, watch_event_query: Arc::clone(watch_events) as _, @@ -19,7 +19,7 @@ fn deps(watch_events: &Arc) -> DismissWatchEventsD #[tokio::test] async fn dismisses_empty_list_returns_zero() { - let events = InMemoryWatchEventRepository::new(); + let events = InMemoryStore::new(); let result = dismiss::execute( &deps(&events), @@ -33,7 +33,7 @@ async fn dismisses_empty_list_returns_zero() { #[tokio::test] async fn fails_when_event_not_found() { - let events = InMemoryWatchEventRepository::new(); + let events = InMemoryStore::new(); let result = dismiss::execute( &deps(&events), @@ -46,7 +46,7 @@ async fn fails_when_event_not_found() { #[tokio::test] async fn dismisses_existing_events() { - let watch_events = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryStore::new(); let uid = Uuid::new_v4(); let user_id = UserId::from_uuid(uid); diff --git a/crates/application/src/integrations/tests/generate_token.rs b/crates/application/src/integrations/tests/generate_token.rs index 31227c5e..80334ac1 100644 --- a/crates/application/src/integrations/tests/generate_token.rs +++ b/crates/application/src/integrations/tests/generate_token.rs @@ -2,7 +2,7 @@ use crate::test_support::generate_webhook_token_command; use std::sync::Arc; use domain::ports::WebhookTokenRepository; -use domain::testing::InMemoryWebhookTokenRepository; +use domain::testing::InMemoryStore; use uuid::Uuid; use crate::integrations::deps::GenerateWebhookTokenDeps; @@ -10,7 +10,7 @@ use crate::integrations::generate_token; #[tokio::test] async fn generates_token_and_saves() { - let tokens: Arc = InMemoryWebhookTokenRepository::new(); + let tokens: Arc = InMemoryStore::new(); let user_id = Uuid::new_v4(); let result = generate_token::execute( diff --git a/crates/application/src/integrations/tests/get_queue.rs b/crates/application/src/integrations/tests/get_queue.rs index 2169cbc2..50bacfb4 100644 --- a/crates/application/src/integrations/tests/get_queue.rs +++ b/crates/application/src/integrations/tests/get_queue.rs @@ -3,14 +3,14 @@ use std::sync::Arc; use chrono::Utc; use domain::models::{WatchEvent, WatchEventSource}; use domain::ports::WatchEventCommand; -use domain::testing::InMemoryWatchEventRepository; +use domain::testing::InMemoryStore; use domain::value_objects::UserId; use uuid::Uuid; use crate::integrations::deps::GetWatchQueueDeps; use crate::integrations::{get_queue, queries::GetWatchQueueQuery}; -fn deps(events: &Arc) -> GetWatchQueueDeps { +fn deps(events: &Arc) -> GetWatchQueueDeps { GetWatchQueueDeps { watch_event_query: Arc::clone(events) as _, } @@ -18,7 +18,7 @@ fn deps(events: &Arc) -> GetWatchQueueDeps { #[tokio::test] async fn returns_empty_when_no_events() { - let events = InMemoryWatchEventRepository::new(); + let events = InMemoryStore::new(); let result = get_queue::execute( &deps(&events), @@ -34,7 +34,7 @@ async fn returns_empty_when_no_events() { #[tokio::test] async fn returns_pending_events() { - let events = InMemoryWatchEventRepository::new(); + let events = InMemoryStore::new(); let user_id = Uuid::new_v4(); let event = WatchEvent::new( diff --git a/crates/application/src/integrations/tests/get_tokens.rs b/crates/application/src/integrations/tests/get_tokens.rs index 8577eb87..5eedc517 100644 --- a/crates/application/src/integrations/tests/get_tokens.rs +++ b/crates/application/src/integrations/tests/get_tokens.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::models::WatchEventSource; use domain::ports::WebhookTokenRepository; -use domain::testing::InMemoryWebhookTokenRepository; +use domain::testing::InMemoryStore; use uuid::Uuid; use crate::integrations::deps::{GenerateWebhookTokenDeps, GetWebhookTokensDeps}; @@ -26,7 +26,7 @@ fn get_deps(tokens: &Arc) -> GetWebhookTokensDeps { #[tokio::test] async fn returns_empty_when_no_tokens() { - let tokens: Arc = InMemoryWebhookTokenRepository::new(); + let tokens: Arc = InMemoryStore::new(); let result = get_tokens::execute( &get_deps(&tokens), @@ -42,7 +42,7 @@ async fn returns_empty_when_no_tokens() { #[tokio::test] async fn returns_tokens_after_generate() { - let tokens: Arc = InMemoryWebhookTokenRepository::new(); + let tokens: Arc = InMemoryStore::new(); let user_id = Uuid::new_v4(); diff --git a/crates/application/src/integrations/tests/ingest.rs b/crates/application/src/integrations/tests/ingest.rs index b5d5ed47..98194231 100644 --- a/crates/application/src/integrations/tests/ingest.rs +++ b/crates/application/src/integrations/tests/ingest.rs @@ -2,10 +2,8 @@ use crate::test_support::generate_webhook_token_command; use std::sync::Arc; use domain::models::WatchEventSource; -use domain::ports::{EventPublisher, WebhookTokenRepository}; -use domain::testing::{ - InMemoryWatchEventRepository, InMemoryWebhookTokenRepository, NoopEventPublisher, -}; +use domain::ports::EventPublisher; +use domain::testing::{InMemoryStore, NoopEventPublisher}; use uuid::Uuid; use crate::integrations::commands::IngestWatchEventCommand; @@ -30,14 +28,14 @@ impl domain::ports::MediaServerParser for FakeParser { #[tokio::test] async fn ingests_watch_event() { - let tokens: Arc = InMemoryWebhookTokenRepository::new(); - let watch_events = InMemoryWatchEventRepository::new(); + let tokens = InMemoryStore::new(); + let watch_events = Arc::clone(&tokens); let event_publisher: Arc = NoopEventPublisher::new(); let user_id = Uuid::new_v4(); let generated = generate_token::execute( &GenerateWebhookTokenDeps { - webhook_token: Arc::clone(&tokens), + webhook_token: Arc::clone(&tokens) as _, }, generate_webhook_token_command(user_id), ) @@ -45,7 +43,7 @@ async fn ingests_watch_event() { .unwrap(); let deps = IngestWatchEventDeps { - webhook_token: Arc::clone(&tokens), + webhook_token: Arc::clone(&tokens) as _, watch_event_command: Arc::clone(&watch_events) as _, watch_event_query: Arc::clone(&watch_events) as _, event_publisher: Arc::clone(&event_publisher), @@ -67,12 +65,12 @@ async fn ingests_watch_event() { #[tokio::test] async fn rejects_invalid_token() { - let tokens: Arc = InMemoryWebhookTokenRepository::new(); - let watch_events = InMemoryWatchEventRepository::new(); + let tokens = InMemoryStore::new(); + let watch_events = Arc::clone(&tokens); let event_publisher: Arc = NoopEventPublisher::new(); let deps = IngestWatchEventDeps { - webhook_token: Arc::clone(&tokens), + webhook_token: Arc::clone(&tokens) as _, watch_event_command: Arc::clone(&watch_events) as _, watch_event_query: Arc::clone(&watch_events) as _, event_publisher: Arc::clone(&event_publisher), diff --git a/crates/application/src/integrations/tests/revoke_token.rs b/crates/application/src/integrations/tests/revoke_token.rs index bc7dac40..718ac757 100644 --- a/crates/application/src/integrations/tests/revoke_token.rs +++ b/crates/application/src/integrations/tests/revoke_token.rs @@ -2,7 +2,7 @@ use crate::test_support::generate_webhook_token_command; use std::sync::Arc; use domain::ports::WebhookTokenRepository; -use domain::testing::InMemoryWebhookTokenRepository; +use domain::testing::InMemoryStore; use uuid::Uuid; use crate::integrations::deps::{ @@ -15,7 +15,7 @@ use crate::integrations::{ #[tokio::test] async fn revokes_existing_token() { - let tokens: Arc = InMemoryWebhookTokenRepository::new(); + let tokens: Arc = InMemoryStore::new(); let user_id = Uuid::new_v4(); diff --git a/crates/application/src/movies/tests/enrich_movie.rs b/crates/application/src/movies/tests/enrich_movie.rs index 8fb3a21f..89d7c59f 100644 --- a/crates/application/src/movies/tests/enrich_movie.rs +++ b/crates/application/src/movies/tests/enrich_movie.rs @@ -5,10 +5,7 @@ use chrono::Utc; use domain::{ models::MovieProfile, ports::MovieCommand, - testing::{ - FakeSearchCommand, InMemoryMovieProfileRepository, InMemoryMovieRepository, - PanicPersonCommand, - }, + testing::{FakeSearchCommand, InMemoryStore, PanicPersonCommand}, value_objects::MovieId, }; @@ -16,8 +13,8 @@ use crate::movies::{commands::EnrichMovieCommand, deps::EnrichMovieDeps, enrich_ #[tokio::test] async fn stores_profile_and_indexes() { - let movie_repo = InMemoryMovieRepository::new(); - let profile_repo = InMemoryMovieProfileRepository::new(); + let movie_repo = InMemoryStore::new(); + let profile_repo = Arc::clone(&movie_repo); // PanicPersonCommand is safe here — empty cast/crew means upsert_batch is never called let movie = test_movie_with_year("Test", 2024); @@ -61,7 +58,7 @@ async fn stores_profile_and_indexes() { .await .unwrap(); - assert_eq!(profile_repo.count(), 1); + assert_eq!(profile_repo.movie_profile_count(), 1); } struct NoopPersonCommand; @@ -91,8 +88,8 @@ impl domain::ports::PersonCommand for NoopPersonCommand { #[tokio::test] async fn extracts_and_indexes_persons() { - let movie_repo = InMemoryMovieRepository::new(); - let profile_repo = InMemoryMovieProfileRepository::new(); + let movie_repo = InMemoryStore::new(); + let profile_repo = Arc::clone(&movie_repo); let movie = test_movie_with_year("Cast Movie", 2024); let movie_id = MovieId::from_uuid(movie.id().value()); @@ -147,5 +144,5 @@ async fn extracts_and_indexes_persons() { .await .unwrap(); - assert_eq!(profile_repo.count(), 1); + assert_eq!(profile_repo.movie_profile_count(), 1); } diff --git a/crates/application/src/movies/tests/get_movie_profile.rs b/crates/application/src/movies/tests/get_movie_profile.rs index 577ea3d4..969625ae 100644 --- a/crates/application/src/movies/tests/get_movie_profile.rs +++ b/crates/application/src/movies/tests/get_movie_profile.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use domain::{ models::{CastMember, CrewMember, MovieProfile}, ports::MovieProfileRepository, - testing::InMemoryMovieProfileRepository, + testing::InMemoryStore, value_objects::MovieId, }; @@ -13,7 +13,7 @@ use crate::movies::get_movie_profile::{self, GetMovieProfileQuery}; #[tokio::test] async fn returns_none_when_no_profile() { - let movie_profile = InMemoryMovieProfileRepository::new(); + let movie_profile = InMemoryStore::new(); let deps = GetMovieProfileDeps { movie_profile }; let result = get_movie_profile::execute( @@ -30,7 +30,7 @@ async fn returns_none_when_no_profile() { #[tokio::test] async fn returns_profile_with_cast_and_crew() { - let profile_repo = InMemoryMovieProfileRepository::new(); + let profile_repo = InMemoryStore::new(); let movie_id = MovieId::generate(); let profile = MovieProfile { diff --git a/crates/application/src/movies/tests/get_movies.rs b/crates/application/src/movies/tests/get_movies.rs index 9ac9c9e0..3bb8c49d 100644 --- a/crates/application/src/movies/tests/get_movies.rs +++ b/crates/application/src/movies/tests/get_movies.rs @@ -1,10 +1,10 @@ -use domain::testing::InMemoryMovieRepository; +use domain::testing::InMemoryStore; use crate::movies::{deps::GetMoviesDeps, get_movies, queries::GetMoviesQuery}; #[tokio::test] async fn returns_empty_when_no_movies() { - let movie = InMemoryMovieRepository::new(); + let movie = InMemoryStore::new(); let deps = GetMoviesDeps { movie }; let result = get_movies::execute( diff --git a/crates/application/src/movies/tests/request_enrichment.rs b/crates/application/src/movies/tests/request_enrichment.rs index 1de0be2d..bee95b9d 100644 --- a/crates/application/src/movies/tests/request_enrichment.rs +++ b/crates/application/src/movies/tests/request_enrichment.rs @@ -6,7 +6,7 @@ use domain::{ errors::DomainError, models::MovieProfile, ports::{MovieEnrichmentClient, MovieProfileRepository}, - testing::{FakeMovieEnrichmentClient, InMemoryMovieProfileRepository}, + testing::{FakeMovieEnrichmentClient, InMemoryStore}, value_objects::MovieId, }; @@ -15,7 +15,7 @@ use crate::movies::request_enrichment; #[tokio::test] async fn returns_profile_when_none_cached() { let enrichment = FakeMovieEnrichmentClient; - let profile_repo = InMemoryMovieProfileRepository::new(); + let profile_repo = InMemoryStore::new(); let movie_id = MovieId::generate(); let result = request_enrichment::fetch_if_stale( @@ -34,7 +34,7 @@ async fn returns_profile_when_none_cached() { #[tokio::test] async fn returns_none_when_profile_is_fresh() { let enrichment = FakeMovieEnrichmentClient; - let profile_repo = InMemoryMovieProfileRepository::new(); + let profile_repo = InMemoryStore::new(); let movie_id = MovieId::generate(); // Seed a fresh profile (enriched_at = now) @@ -87,7 +87,7 @@ impl MovieEnrichmentClient for NotFoundEnrichmentClient { #[tokio::test] async fn returns_none_on_not_found_from_client() { let enrichment = NotFoundEnrichmentClient; - let profile_repo = InMemoryMovieProfileRepository::new(); + let profile_repo = InMemoryStore::new(); let movie_id = MovieId::generate(); let result = request_enrichment::fetch_if_stale( diff --git a/crates/application/src/movies/tests/sync_poster.rs b/crates/application/src/movies/tests/sync_poster.rs index 40604afd..b9588196 100644 --- a/crates/application/src/movies/tests/sync_poster.rs +++ b/crates/application/src/movies/tests/sync_poster.rs @@ -8,10 +8,7 @@ use domain::{ errors::DomainError, models::Movie, ports::{MetadataClient, MovieCommand, MovieQuery}, - testing::{ - FakeSearchCommand, InMemoryMovieProfileRepository, InMemoryMovieRepository, - NoopEventPublisher, NoopObjectStorage, - }, + testing::{FakeSearchCommand, InMemoryStore, NoopEventPublisher, NoopObjectStorage}, value_objects::{ExternalMetadataId, PosterUrl}, }; @@ -19,9 +16,9 @@ use crate::movies::{deps::SyncPosterDeps, sync_poster}; fn default_deps() -> SyncPosterDeps { SyncPosterDeps { - movie_command: InMemoryMovieRepository::new(), - movie_query: InMemoryMovieRepository::new(), - movie_profile: InMemoryMovieProfileRepository::new(), + movie_command: InMemoryStore::new(), + movie_query: InMemoryStore::new(), + movie_profile: InMemoryStore::new(), metadata: Arc::new(domain::testing::FakeMetadataClient), poster_fetcher: Arc::new(domain::testing::FakePosterFetcher), object_storage: Arc::new(NoopObjectStorage), @@ -41,7 +38,7 @@ async fn fails_when_movie_not_found() { #[tokio::test] async fn fails_when_no_external_id() { - let movies = InMemoryMovieRepository::new(); + let movies = InMemoryStore::new(); let movie = test_movie_with_year("Test", 2024); let movie_id = movie.id().value(); movies.upsert_movie(&movie).await.unwrap(); @@ -79,7 +76,7 @@ impl MetadataClient for FakeMetaWithPoster { #[tokio::test] async fn syncs_poster_for_movie_with_external_id() { - let movies = InMemoryMovieRepository::new(); + let movies = InMemoryStore::new(); let ext_id = ExternalMetadataId::new("tmdb:999".into()).unwrap(); let movie = test_movie_with_external_id("Poster Movie", 2024, ext_id); let movie_id = movie.id().value(); diff --git a/crates/application/src/social/tests/execute.rs b/crates/application/src/social/tests/execute.rs index 724afa00..3e907a03 100644 --- a/crates/application/src/social/tests/execute.rs +++ b/crates/application/src/social/tests/execute.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use domain::{ events::DomainEvent, - testing::{InMemorySocialRepository, NoopEventPublisher}, + testing::{InMemoryStore, NoopEventPublisher}, value_objects::{FollowStatus, FollowTarget, SocialIdentity, UserId}, }; use uuid::Uuid; @@ -14,11 +14,11 @@ use crate::social::{ }; fn make_cmd_deps() -> ( - Arc, + Arc, Arc, SocialCommandDeps, ) { - let social = InMemorySocialRepository::new(); + let social = InMemoryStore::new(); let events = NoopEventPublisher::new(); let deps = SocialCommandDeps { social_command: Arc::clone(&social) as _, @@ -359,7 +359,7 @@ async fn get_following_returns_accepted_targets() { #[tokio::test] async fn returns_accepted_followers() { - let social = InMemorySocialRepository::new(); + let social = InMemoryStore::new(); let events = NoopEventPublisher::new(); let cmd_deps = SocialCommandDeps { social_command: Arc::clone(&social) as _, @@ -403,7 +403,7 @@ async fn returns_accepted_followers() { #[tokio::test] async fn returns_only_pending_followers() { - let social = InMemorySocialRepository::new(); + let social = InMemoryStore::new(); let events = NoopEventPublisher::new(); let cmd_deps = SocialCommandDeps { social_command: Arc::clone(&social) as _, diff --git a/crates/application/src/social/tests/local_service.rs b/crates/application/src/social/tests/local_service.rs index 0acc875e..cbe2b3ad 100644 --- a/crates/application/src/social/tests/local_service.rs +++ b/crates/application/src/social/tests/local_service.rs @@ -7,7 +7,7 @@ use domain::{ ports::{ BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, SocialCommand, UserRepository, }, - testing::InMemoryUserRepository, + testing::InMemoryStore, value_objects::{ FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, UserId, @@ -19,7 +19,7 @@ use crate::social::local_service::LocalSocialService; // ── Fakes ──────────────────────────────────────────────────────────────────── // -// No fake exists anywhere for FollowCommand/FollowQuery. `InMemorySocialRepository` +// No fake exists anywhere for FollowCommand/FollowQuery. `InMemoryStore` // (domain::testing) implements SocialCommand/FollowGraphQuery/BlockQuery — the same // level as LocalSocialService itself — so it cannot stand in as its dependency. // A single struct backs both ports, mirroring how one database table serves both @@ -246,7 +246,7 @@ fn instance() -> InstanceIdentity { InstanceIdentity::new("http://md.example") } -async fn register_user(repo: &Arc, username: &str) -> UserId { +async fn register_user(repo: &Arc, username: &str) -> UserId { let user = test_user(username); let id = user.id().clone(); repo.save(&user).await.unwrap(); @@ -254,7 +254,7 @@ async fn register_user(repo: &Arc, username: &str) -> Us } fn service( - user_repo: Arc, + user_repo: Arc, follow_store: &Arc, instance: InstanceIdentity, ) -> LocalSocialService { @@ -270,7 +270,7 @@ fn service( #[tokio::test] async fn follow_local_user_writes_both_sides() { - let user_repo = InMemoryUserRepository::new(); + let user_repo = InMemoryStore::new(); let bob_id = register_user(&user_repo, "bob").await; let alice_id = register_user(&user_repo, "alice").await; let follow_store = FollowFakeStore::new(); @@ -302,7 +302,7 @@ async fn follow_local_user_writes_both_sides() { #[tokio::test] async fn follow_self_is_rejected() { - let user_repo = InMemoryUserRepository::new(); + let user_repo = InMemoryStore::new(); let alice_id = register_user(&user_repo, "alice").await; let follow_store = FollowFakeStore::new(); let svc = service(user_repo, &follow_store, instance()); @@ -335,7 +335,7 @@ async fn follow_self_is_rejected() { #[tokio::test] async fn follow_remote_target_errors_rather_than_silently_succeeding() { - let user_repo = InMemoryUserRepository::new(); + let user_repo = InMemoryStore::new(); let alice_id = register_user(&user_repo, "alice").await; let follow_store = FollowFakeStore::new(); let svc = service(user_repo, &follow_store, instance()); @@ -376,7 +376,7 @@ async fn reject_follow_updates_follower_status_but_removes_the_follow_row() { // the follow row outright. A test that only checked the follower row would // pass against a reject_follow "corrected" into accept_follow's symmetry — // the second assertion is the one that carries the weight. - let user_repo = InMemoryUserRepository::new(); + let user_repo = InMemoryStore::new(); let owner_id = register_user(&user_repo, "owner").await; let requester_id = register_user(&user_repo, "requester").await; let follow_store = FollowFakeStore::new(); @@ -422,7 +422,7 @@ async fn reject_follow_updates_follower_status_but_removes_the_follow_row() { #[tokio::test] async fn block_errors_and_get_blocked_is_empty() { - let user_repo = InMemoryUserRepository::new(); + let user_repo = InMemoryStore::new(); let alice_id = register_user(&user_repo, "alice").await; let follow_store = FollowFakeStore::new(); let svc = service(user_repo, &follow_store, instance()); diff --git a/crates/application/src/test_helpers.rs b/crates/application/src/test_helpers.rs index 51872a51..a377538b 100644 --- a/crates/application/src/test_helpers.rs +++ b/crates/application/src/test_helpers.rs @@ -1,8 +1,7 @@ use std::sync::Arc; use domain::testing::{ - InMemoryGoalRepository, InMemorySocialRepository, InMemoryWrapUpRepository, - InMemoryWrapUpStatsQuery, NoopFederationAdminQuery, + InMemoryStore, InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, NoopFederationAdminQuery, }; use domain::{ ports::{ @@ -17,11 +16,7 @@ use domain::{ testing::{ FakeAuthService, FakeDiaryQuery, FakeDocumentParser, FakeMetadataClient, FakePasswordHasher, FakePersonQuery, FakePosterFetcher, FakeSearchCommand, FakeSearchPort, - FakeStatsRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository, - InMemoryMovieProfileRepository, InMemoryMovieRepository, InMemoryProfileFieldsRepo, - InMemoryRefreshSessionRepository, InMemoryReviewRepository, InMemoryUserRepository, - InMemoryUserSettingsRepository, InMemoryWatchEventRepository, InMemoryWatchlistRepository, - InMemoryWebhookTokenRepository, NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, + FakeStatsRepository, NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, PanicPersonCommand, }, }; @@ -89,14 +84,11 @@ impl Default for TestContextBuilder { impl TestContextBuilder { pub fn new() -> Self { - let movies = InMemoryMovieRepository::new(); - let watch_events = InMemoryWatchEventRepository::new(); - let goals = InMemoryGoalRepository::new(); - let social = InMemorySocialRepository::new(); + let store = InMemoryStore::new(); Self { - movie_command: Arc::clone(&movies) as _, - movie_query: movies as _, - review_repo: InMemoryReviewRepository::new(), + movie_command: Arc::clone(&store) as _, + movie_query: Arc::clone(&store) as _, + review_repo: Arc::clone(&store) as _, diary_repo: FakeDiaryQuery::new(), diary_exporter: Arc::new(PanicDiaryExporter), document_parser: Arc::new(FakeDocumentParser), @@ -107,30 +99,30 @@ impl TestContextBuilder { event_publisher: NoopEventPublisher::new(), auth_service: Arc::new(FakeAuthService), password_hasher: Arc::new(FakePasswordHasher), - user_repo: InMemoryUserRepository::new(), - import_session_repo: InMemoryImportSessionRepository::new(), - import_profile_repo: InMemoryImportProfileRepository::new(), - movie_profile_repo: InMemoryMovieProfileRepository::new(), - watchlist_repo: InMemoryWatchlistRepository::new(), - watch_event_command: Arc::clone(&watch_events) as _, - watch_event_query: watch_events as _, - webhook_token_repo: InMemoryWebhookTokenRepository::new(), - profile_fields_repo: InMemoryProfileFieldsRepo::new(), + user_repo: Arc::clone(&store) as _, + import_session_repo: Arc::clone(&store) as _, + import_profile_repo: Arc::clone(&store) as _, + movie_profile_repo: Arc::clone(&store) as _, + watchlist_repo: Arc::clone(&store) as _, + watch_event_command: Arc::clone(&store) as _, + watch_event_query: Arc::clone(&store) as _, + webhook_token_repo: Arc::clone(&store) as _, + profile_fields_repo: Arc::clone(&store) as _, person_command: Arc::new(PanicPersonCommand), person_query: Arc::new(FakePersonQuery), search_port: Arc::new(FakeSearchPort), search_command: Arc::new(FakeSearchCommand), wrapup_stats: InMemoryWrapUpStatsQuery::new(), wrapup_repo: InMemoryWrapUpRepository::new(), - goal_command: Arc::clone(&goals) as _, - goal_query: goals as _, - user_settings_repo: InMemoryUserSettingsRepository::new(), + goal_command: Arc::clone(&store) as _, + goal_query: Arc::clone(&store) as _, + user_settings_repo: Arc::clone(&store) as _, review_logger: Arc::new(NoopReviewLogger), - social_command: Arc::clone(&social) as _, - social_query_unified: Arc::clone(&social) as _, - block_query: Arc::clone(&social) as _, + social_command: Arc::clone(&store) as _, + social_query_unified: Arc::clone(&store) as _, + block_query: Arc::clone(&store) as _, federation_admin: Arc::new(NoopFederationAdminQuery), - refresh_session_repo: InMemoryRefreshSessionRepository::new(), + refresh_session_repo: Arc::clone(&store) as _, config: AppConfig { allow_registration: true, base_url: "http://localhost:3000".into(), diff --git a/crates/application/src/users/tests/authorize_admin.rs b/crates/application/src/users/tests/authorize_admin.rs index aebf71cb..ce2e6eda 100644 --- a/crates/application/src/users/tests/authorize_admin.rs +++ b/crates/application/src/users/tests/authorize_admin.rs @@ -2,7 +2,7 @@ use crate::test_support::register_command; use std::sync::Arc; use domain::models::UserRole; -use domain::testing::InMemoryUserRepository; +use domain::testing::InMemoryStore; use crate::{ auth::{commands::RegisterCommand, deps::RegisterDeps, register}, @@ -40,7 +40,7 @@ async fn register_user( #[tokio::test] async fn authorize_admin_is_true_for_an_admin_user() { - let users = InMemoryUserRepository::new(); + let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); let user = register_user(&b, "admin@example.com", "admin_user", UserRole::Admin).await; @@ -56,7 +56,7 @@ async fn authorize_admin_is_true_for_an_admin_user() { #[tokio::test] async fn authorize_admin_is_false_for_a_regular_user() { - let users = InMemoryUserRepository::new(); + let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); let user = register_user( &b, diff --git a/crates/application/src/users/tests/get_current_profile.rs b/crates/application/src/users/tests/get_current_profile.rs index 3f7b0f5b..01f6faac 100644 --- a/crates/application/src/users/tests/get_current_profile.rs +++ b/crates/application/src/users/tests/get_current_profile.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::models::{ProfileField, User, UserProfile, UserRole}; use domain::ports::UserRepository; -use domain::testing::InMemoryUserRepository; +use domain::testing::InMemoryStore; use domain::value_objects::{Email, PasswordHash, UserId, Username}; use uuid::Uuid; @@ -15,7 +15,7 @@ use crate::{ #[tokio::test] async fn returns_profile_for_existing_user() { - let users = InMemoryUserRepository::new(); + let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); let user_repo = b.user_repo.clone(); let reg_deps = RegisterDeps { @@ -66,7 +66,7 @@ async fn fails_for_nonexistent_user() { #[tokio::test] async fn returns_profile_with_avatar_banner_and_fields() { - let users = InMemoryUserRepository::new(); + let users = InMemoryStore::new(); let uid = UserId::generate(); let user = User::from_persistence( @@ -87,7 +87,7 @@ async fn returns_profile_with_avatar_banner_and_fields() { }], }, ); - users.store.lock().unwrap().insert(uid.value(), user); + users.seed_user(user); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); let user_repo = b.user_repo.clone(); diff --git a/crates/application/src/users/tests/update_profile.rs b/crates/application/src/users/tests/update_profile.rs index 1145c77d..f1e0de0c 100644 --- a/crates/application/src/users/tests/update_profile.rs +++ b/crates/application/src/users/tests/update_profile.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::events::DomainEvent; use domain::ports::UserRepository; -use domain::testing::{InMemoryUserRepository, NoopEventPublisher}; +use domain::testing::{InMemoryStore, NoopEventPublisher}; use uuid::Uuid; use crate::{ @@ -12,7 +12,7 @@ use crate::{ users::{commands::UpdateProfileCommand, deps::UpdateProfileDeps, update_profile}, }; -async fn register_user(b: &TestContextBuilder, users: &Arc) -> Uuid { +async fn register_user(b: &TestContextBuilder, users: &Arc) -> Uuid { let reg_deps = RegisterDeps { user: b.user_repo.clone(), password_hasher: b.password_hasher.clone(), @@ -32,7 +32,7 @@ async fn register_user(b: &TestContextBuilder, users: &Arc, - watchlist: Arc, -) -> WatchlistAddDeps { +fn make_deps(movies: Arc, watchlist: Arc) -> WatchlistAddDeps { WatchlistAddDeps { movie_command: Arc::clone(&movies) as _, movie_query: movies, @@ -24,8 +21,8 @@ fn make_deps( #[tokio::test] async fn test_add_to_watchlist_resolves_and_saves() { - let movies = InMemoryMovieRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); + let movies = InMemoryStore::new(); + let watchlist = Arc::clone(&movies); let movie = test_movie_with_year("The Thing", 1982); let movie_uuid = movie.id().value(); @@ -37,13 +34,13 @@ async fn test_add_to_watchlist_resolves_and_saves() { add::execute(&deps, cmd).await.unwrap(); - assert_eq!(watchlist.count(), 1); + assert_eq!(watchlist.watchlist_count(), 1); } #[tokio::test] async fn test_add_to_watchlist_already_present_is_idempotent() { - let movies = InMemoryMovieRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); + let movies = InMemoryStore::new(); + let watchlist = Arc::clone(&movies); let movie = test_movie_with_year("RoboCop", 1987); let movie_uuid = movie.id().value(); @@ -57,13 +54,17 @@ async fn test_add_to_watchlist_already_present_is_idempotent() { add::execute(&deps, make_cmd()).await.unwrap(); add::execute(&deps, make_cmd()).await.unwrap(); - assert_eq!(watchlist.count(), 1, "idempotent add should not duplicate"); + assert_eq!( + watchlist.watchlist_count(), + 1, + "idempotent add should not duplicate" + ); } #[tokio::test] async fn test_add_to_watchlist_with_manual_movie() { - let movies = InMemoryMovieRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); + let movies = InMemoryStore::new(); + let watchlist = Arc::clone(&movies); let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist)); @@ -74,14 +75,14 @@ async fn test_add_to_watchlist_with_manual_movie() { add::execute(&deps, cmd).await.unwrap(); - assert_eq!(watchlist.count(), 1); - assert_eq!(movies.count(), 1); + assert_eq!(watchlist.watchlist_count(), 1); + assert_eq!(movies.movie_count(), 1); } #[tokio::test] async fn test_add_to_watchlist_movie_not_found_by_id() { - let movies = InMemoryMovieRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); + let movies = InMemoryStore::new(); + let watchlist = Arc::clone(&movies); let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist)); diff --git a/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs b/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs index e9788ba3..2d13a9d4 100644 --- a/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs +++ b/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs @@ -3,10 +3,7 @@ use std::sync::Arc; use uuid::Uuid; -use domain::testing::{ - InMemoryRemoteWatchlistRepository, InMemoryUserRepository, InMemoryWatchlistRepository, - PanicRemoteWatchlistRepository, PanicWatchlistRepository, -}; +use domain::testing::{InMemoryStore, PanicRemoteWatchlistRepository, PanicWatchlistRepository}; use domain::value_objects::Email; use crate::auth::deps::RegisterDeps; @@ -37,7 +34,7 @@ async fn watchlist_for_owner_returns_local_when_user_row_exists() { let deps = GetWatchlistForOwnerDeps { user: b.user_repo.clone(), - watchlist: InMemoryWatchlistRepository::new(), + watchlist: InMemoryStore::new(), remote_watchlist: Arc::new(PanicRemoteWatchlistRepository), }; @@ -52,20 +49,18 @@ async fn watchlist_for_owner_returns_local_when_user_row_exists() { async fn watchlist_for_owner_returns_remote_when_no_local_row() { let owner_id = Uuid::new_v4(); - let remote = InMemoryRemoteWatchlistRepository::with_entries(vec![ - domain::models::RemoteWatchlistEntry { - ap_id: "https://remote.example/ap/1".into(), - actor_url: "https://remote.example/users/owner".into(), - movie_title: "Remote Movie".into(), - release_year: 2021, - external_metadata_id: None, - poster_url: None, - added_at: chrono::Utc::now(), - }, - ]); + let remote = InMemoryStore::with_remote_watchlist(vec![domain::models::RemoteWatchlistEntry { + ap_id: "https://remote.example/ap/1".into(), + actor_url: "https://remote.example/users/owner".into(), + movie_title: "Remote Movie".into(), + release_year: 2021, + external_metadata_id: None, + poster_url: None, + added_at: chrono::Utc::now(), + }]); let deps = GetWatchlistForOwnerDeps { - user: InMemoryUserRepository::new(), + user: InMemoryStore::new(), watchlist: Arc::new(PanicWatchlistRepository), remote_watchlist: remote, }; @@ -94,7 +89,7 @@ async fn watchlist_for_owner_local_arm_validates_limit() { let deps = GetWatchlistForOwnerDeps { user: b.user_repo.clone(), - watchlist: InMemoryWatchlistRepository::new(), + watchlist: InMemoryStore::new(), remote_watchlist: Arc::new(PanicRemoteWatchlistRepository), }; @@ -111,10 +106,10 @@ async fn watchlist_for_owner_local_arm_validates_limit() { #[tokio::test] async fn watchlist_for_owner_remote_arm_ignores_invalid_limit() { let owner_id = Uuid::new_v4(); - let remote = InMemoryRemoteWatchlistRepository::new(); + let remote = InMemoryStore::new(); let deps = GetWatchlistForOwnerDeps { - user: InMemoryUserRepository::new(), + user: InMemoryStore::new(), watchlist: Arc::new(PanicWatchlistRepository), remote_watchlist: remote, }; diff --git a/crates/application/src/watchlist/tests/is_on.rs b/crates/application/src/watchlist/tests/is_on.rs index 7ec7e09a..9ca68ba3 100644 --- a/crates/application/src/watchlist/tests/is_on.rs +++ b/crates/application/src/watchlist/tests/is_on.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use domain::models::WatchlistEntry; use domain::ports::WatchlistRepository; -use domain::testing::InMemoryWatchlistRepository; +use domain::testing::InMemoryStore; use domain::value_objects::{MovieId, UserId}; use uuid::Uuid; @@ -11,7 +11,7 @@ use crate::watchlist::{deps::IsOnWatchlistDeps, is_on, queries::IsOnWatchlistQue #[tokio::test] async fn returns_true_when_present() { - let watchlist = InMemoryWatchlistRepository::new(); + let watchlist = InMemoryStore::new(); let uid = Uuid::new_v4(); let mid = Uuid::new_v4(); watchlist diff --git a/crates/application/src/watchlist/tests/remove.rs b/crates/application/src/watchlist/tests/remove.rs index 0a58d27d..256f629c 100644 --- a/crates/application/src/watchlist/tests/remove.rs +++ b/crates/application/src/watchlist/tests/remove.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::events::DomainEvent; use domain::models::WatchlistEntry; use domain::ports::WatchlistRepository; -use domain::testing::{InMemoryWatchlistRepository, NoopEventPublisher}; +use domain::testing::{InMemoryStore, NoopEventPublisher}; use domain::value_objects::{MovieId, UserId}; use uuid::Uuid; @@ -14,7 +14,7 @@ use crate::watchlist::{ #[tokio::test] async fn removes_entry_and_emits_event() { - let watchlist = InMemoryWatchlistRepository::new(); + let watchlist = InMemoryStore::new(); let events = NoopEventPublisher::new(); let uid = Uuid::new_v4(); let mid = Uuid::new_v4(); @@ -40,7 +40,7 @@ async fn removes_entry_and_emits_event() { .await .unwrap(); - assert_eq!(watchlist.count(), 0); + assert_eq!(watchlist.watchlist_count(), 0); let published = events.published(); assert!( published diff --git a/crates/bootstrap/src/tests/build.rs b/crates/bootstrap/src/tests/build.rs index c3c506f9..f88cf6b8 100644 --- a/crates/bootstrap/src/tests/build.rs +++ b/crates/bootstrap/src/tests/build.rs @@ -4,13 +4,8 @@ use application::config::AppConfig; use domain::testing::{ FakeAuthService, FakeDocumentParser, FakeMetadataClient, FakePasswordHasher, FakePersonQuery, FakePosterFetcher, FakeSearchCommand, FakeSearchPort, FakeStatsRepository, - InMemoryGoalRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository, - InMemoryInsightsQuery, InMemoryMovieProfileRepository, InMemoryMovieRepository, - InMemoryProfileFieldsRepo, InMemoryRefreshSessionRepository, InMemoryReviewRepository, - InMemorySocialRepository, InMemoryUserRepository, InMemoryUserSettingsRepository, - InMemoryWatchEventRepository, InMemoryWatchlistRepository, InMemoryWebhookTokenRepository, - InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, NoopEventPublisher, - NoopFederationAdminQuery, NoopObjectStorage, NoopRemoteGoalRepository, + InMemoryInsightsQuery, InMemoryStore, InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, + NoopEventPublisher, NoopFederationAdminQuery, NoopObjectStorage, NoopRemoteGoalRepository, NoopRemoteWatchlistRepository, PanicDiaryExporter, PanicPersonCommand, }; @@ -92,7 +87,7 @@ impl domain::ports::ImageRefQuery for NoopImageRefQuery { } /// `DatabaseOutput::follow_command`/`follow_query` need a fake independent of -/// `InMemorySocialRepository` (domain::testing), which implements +/// `InMemoryStore` (domain::testing), which implements /// `SocialCommand`/`FollowGraphQuery`/`BlockQuery` — the composite ports these /// two feed, one level up — so it cannot stand in as their dependency. These /// fixture fields are never exercised by `build_deps`/`build_worker_deps` @@ -223,30 +218,30 @@ impl domain::ports::FollowQuery for NoopFollowQuery { } fn test_repositories() -> Repositories { - let movies = InMemoryMovieRepository::new(); - let watch_events = InMemoryWatchEventRepository::new(); - let goals = InMemoryGoalRepository::new(); - let social = InMemorySocialRepository::new(); + let movies = InMemoryStore::new(); + let watch_events = Arc::clone(&movies); + let goals = Arc::clone(&movies); + let social = Arc::clone(&movies); Repositories { movie_command: Arc::clone(&movies) as _, movie_query: movies as _, - review: InMemoryReviewRepository::new(), + review: InMemoryStore::new(), diary: domain::testing::FakeDiaryQuery::new(), stats: FakeStatsRepository::new(), - user: InMemoryUserRepository::new(), - import_session: InMemoryImportSessionRepository::new(), - import_profile: InMemoryImportProfileRepository::new(), - movie_profile: InMemoryMovieProfileRepository::new(), - watchlist: InMemoryWatchlistRepository::new(), + user: InMemoryStore::new(), + import_session: InMemoryStore::new(), + import_profile: InMemoryStore::new(), + movie_profile: InMemoryStore::new(), + watchlist: InMemoryStore::new(), watch_event_command: Arc::clone(&watch_events) as _, watch_event_query: watch_events as _, - webhook_token: InMemoryWebhookTokenRepository::new(), + webhook_token: InMemoryStore::new(), person_command: Arc::new(PanicPersonCommand), person_query: Arc::new(FakePersonQuery), search_port: Arc::new(FakeSearchPort), search_command: Arc::new(FakeSearchCommand), - profile_fields: InMemoryProfileFieldsRepo::new(), + profile_fields: InMemoryStore::new(), remote_watchlist: Arc::new(NoopRemoteWatchlistRepository), social_command: Arc::clone(&social) as _, follow_graph: Arc::clone(&social) as _, @@ -257,9 +252,9 @@ fn test_repositories() -> Repositories { insights: InMemoryInsightsQuery::new(), goal_command: Arc::clone(&goals) as _, goal_query: goals as _, - user_settings: InMemoryUserSettingsRepository::new(), + user_settings: InMemoryStore::new(), remote_goal: Arc::new(NoopRemoteGoalRepository), - refresh_session: InMemoryRefreshSessionRepository::new(), + refresh_session: InMemoryStore::new(), federated_profile: None, } } @@ -274,30 +269,30 @@ fn test_db_pool() -> crate::DbPool { } fn test_database_output() -> DatabaseOutput { - let movies = InMemoryMovieRepository::new(); - let watch_events = InMemoryWatchEventRepository::new(); - let goals = InMemoryGoalRepository::new(); - let user_settings = InMemoryUserSettingsRepository::new(); + let movies = InMemoryStore::new(); + let watch_events = Arc::clone(&movies); + let goals = Arc::clone(&movies); + let user_settings = Arc::clone(&movies); DatabaseOutput { movie_command: Arc::clone(&movies) as _, movie_query: movies as _, - review: InMemoryReviewRepository::new(), + review: InMemoryStore::new(), diary: domain::testing::FakeDiaryQuery::new(), stats: FakeStatsRepository::new(), - user: InMemoryUserRepository::new(), - import_session: InMemoryImportSessionRepository::new(), - import_profile: InMemoryImportProfileRepository::new(), - movie_profile: InMemoryMovieProfileRepository::new(), - watchlist: InMemoryWatchlistRepository::new(), + user: InMemoryStore::new(), + import_session: InMemoryStore::new(), + import_profile: InMemoryStore::new(), + movie_profile: InMemoryStore::new(), + watchlist: InMemoryStore::new(), watch_event_command: Arc::clone(&watch_events) as _, watch_event_query: watch_events as _, - webhook_token: InMemoryWebhookTokenRepository::new(), + webhook_token: InMemoryStore::new(), person_command: Arc::new(PanicPersonCommand), person_query: Arc::new(FakePersonQuery), search_port: Arc::new(FakeSearchPort), search_command: Arc::new(FakeSearchCommand), - profile_fields: InMemoryProfileFieldsRepo::new(), + profile_fields: InMemoryStore::new(), ap_content: Arc::new(NoopLocalApContentQuery), wrapup_stats: InMemoryWrapUpStatsQuery::new(), wrapup_repo: InMemoryWrapUpRepository::new(), @@ -307,7 +302,7 @@ fn test_database_output() -> DatabaseOutput { user_settings: Arc::clone(&user_settings) as _, federation_settings: user_settings as _, remote_goal: Arc::new(NoopRemoteGoalRepository), - refresh_session: InMemoryRefreshSessionRepository::new(), + refresh_session: InMemoryStore::new(), deduplicator: Arc::new(NoopMovieDeduplicator), image_ref_command: Arc::new(NoopImageRefCommand), image_ref_query: Arc::new(NoopImageRefQuery), diff --git a/crates/domain/src/models/tests/review.rs b/crates/domain/src/models/tests/review.rs index 74c9c9b1..13eacffa 100644 --- a/crates/domain/src/models/tests/review.rs +++ b/crates/domain/src/models/tests/review.rs @@ -1,4 +1,4 @@ -use crate::testing::test_review; +use crate::testing::{test_review, test_watched_at}; use crate::value_objects::{Comment, MovieId, Rating, UserId, WatchMedium}; use super::*; @@ -11,15 +11,28 @@ fn review_with_medium(watch_medium: Option) -> Review { }) } +fn new_review_with_medium(watch_medium: Option) -> Review { + Review::new( + MovieId::generate(), + UserId::generate(), + Rating::new(4).unwrap(), + Some(Comment::new("great film".into()).unwrap()), + test_watched_at(), + watch_medium, + false, + ) + .unwrap() +} + #[test] fn new_review_stores_watch_medium() { - let review = review_with_medium(Some(WatchMedium::Cinema)); + let review = new_review_with_medium(Some(WatchMedium::Cinema)); assert_eq!(review.watch_medium(), Some(&WatchMedium::Cinema)); } #[test] fn new_review_without_medium() { - let review = review_with_medium(None); + let review = new_review_with_medium(None); assert_eq!(review.watch_medium(), None); } diff --git a/crates/domain/src/testing/insights.rs b/crates/domain/src/testing/fakes/insights.rs similarity index 100% rename from crates/domain/src/testing/insights.rs rename to crates/domain/src/testing/fakes/insights.rs diff --git a/crates/domain/src/testing/fakes/mod.rs b/crates/domain/src/testing/fakes/mod.rs new file mode 100644 index 00000000..59a15eaf --- /dev/null +++ b/crates/domain/src/testing/fakes/mod.rs @@ -0,0 +1,26 @@ +mod insights; +mod noops; +mod panics; +mod store; +mod store_goal; +mod store_import; +mod store_movie; +mod store_review; +mod store_session; +mod store_social; +mod store_user; +mod store_watch_event; +mod store_watchlist; +mod stubs; +#[cfg(test)] +mod tests; +mod wrapup; + +pub use insights::*; +pub use noops::*; +pub use panics::*; +pub use store::{ + InMemoryStore, MOVIE_CASCADE_TABLES, USER_CASCADE_TABLES, USER_CASCADE_TABLES_OUTSIDE_STORE, +}; +pub use stubs::*; +pub use wrapup::*; diff --git a/crates/domain/src/testing/noops.rs b/crates/domain/src/testing/fakes/noops.rs similarity index 100% rename from crates/domain/src/testing/noops.rs rename to crates/domain/src/testing/fakes/noops.rs diff --git a/crates/domain/src/testing/panics.rs b/crates/domain/src/testing/fakes/panics.rs similarity index 100% rename from crates/domain/src/testing/panics.rs rename to crates/domain/src/testing/fakes/panics.rs diff --git a/crates/domain/src/testing/fakes/store.rs b/crates/domain/src/testing/fakes/store.rs new file mode 100644 index 00000000..857210a0 --- /dev/null +++ b/crates/domain/src/testing/fakes/store.rs @@ -0,0 +1,171 @@ +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use uuid::Uuid; + +use crate::models::{ + Goal, ImportProfile, ImportSession, Movie, MovieProfile, ProfileField, RefreshSession, + RemoteWatchlistEntry, Review, User, UserSettings, WatchEvent, WatchlistEntry, WebhookToken, +}; +use crate::value_objects::SocialIdentity; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum FollowState { + Pending, + Accepted, +} + +/// Tables whose rows the schema removes when a user row is deleted +/// (`ON DELETE CASCADE` on a column referencing `users(id)`), and which +/// `InMemoryStore` models. The sqlite adapter's cascade test asserts the same list. +pub const USER_CASCADE_TABLES: &[&str] = &[ + "watchlist_entries", + "goals", + "user_settings", + "user_profile_fields", + "webhook_tokens", + "watch_events", + "ap_followers", + "ap_following", + "blocked_actors", +]; + +/// Tables that cascade from a user but whose aggregate this store does not hold +/// (they belong to the federation crate). +pub const USER_CASCADE_TABLES_OUTSIDE_STORE: &[&str] = &["ap_local_actors"]; + +/// Tables whose rows the schema removes when a movie row is deleted. +pub const MOVIE_CASCADE_TABLES: &[&str] = &[ + "watchlist_entries", + "movie_profiles", + "movie_genres", + "movie_keywords", + "movie_cast", + "movie_crew", +]; + +/// One in-memory implementation of every persistence port, mirroring the +/// relational constraints of the sqlite schema (cascades, uniqueness, FK +/// restrictions). Share one `Arc` across all port slots. +pub struct InMemoryStore { + pub(super) movies: RwLock>, + pub(super) movie_profiles: RwLock>, + pub(super) reviews: RwLock>, + pub(super) users: RwLock>, + pub(super) profile_fields: RwLock>>, + pub(super) user_settings: RwLock>, + pub(super) refresh_sessions: RwLock>, + pub(super) watchlist: RwLock>, + pub(super) remote_watchlist: RwLock>, + pub(super) goals: RwLock>, + pub(super) watch_events: RwLock>, + pub(super) webhook_tokens: RwLock>, + pub(super) import_sessions: RwLock>, + pub(super) import_profiles: RwLock>, + pub(super) follows: RwLock>, + pub(super) blocked: RwLock>, +} + +impl InMemoryStore { + pub fn new() -> Arc { + Arc::new(Self { + movies: RwLock::default(), + movie_profiles: RwLock::default(), + reviews: RwLock::default(), + users: RwLock::default(), + profile_fields: RwLock::default(), + user_settings: RwLock::default(), + refresh_sessions: RwLock::default(), + watchlist: RwLock::default(), + remote_watchlist: RwLock::default(), + goals: RwLock::default(), + watch_events: RwLock::default(), + webhook_tokens: RwLock::default(), + import_sessions: RwLock::default(), + import_profiles: RwLock::default(), + follows: RwLock::default(), + blocked: RwLock::default(), + }) + } + + pub fn with_remote_watchlist(entries: Vec) -> Arc { + let store = Self::new(); + *store.remote_watchlist.write().unwrap() = entries; + store + } + + pub fn movie_count(&self) -> usize { + self.movies.read().unwrap().len() + } + + pub fn movie_profile_count(&self) -> usize { + self.movie_profiles.read().unwrap().len() + } + + pub fn review_count(&self) -> usize { + self.reviews.read().unwrap().len() + } + + pub fn user_count(&self) -> usize { + self.users.read().unwrap().len() + } + + pub fn watchlist_count(&self) -> usize { + self.watchlist.read().unwrap().len() + } + + pub fn goal_count(&self) -> usize { + self.goals.read().unwrap().len() + } + + pub fn import_session_count(&self) -> usize { + self.import_sessions.read().unwrap().len() + } + + pub fn refresh_session_count(&self) -> usize { + self.refresh_sessions.read().unwrap().len() + } + + /// Inserts a user bypassing the uniqueness checks of `UserRepository::save`. + pub fn seed_user(&self, user: User) { + self.users.write().unwrap().insert(user.id().value(), user); + } + + /// Row count of the sqlite table of the same name, for the tables this + /// store models. Panics on a table the store does not hold. + pub fn rows_in(&self, table: &str) -> usize { + match table { + "watchlist_entries" => self.watchlist_count(), + "goals" => self.goal_count(), + "user_settings" => self.user_settings.read().unwrap().len(), + "user_profile_fields" => self + .profile_fields + .read() + .unwrap() + .values() + .map(Vec::len) + .sum(), + "webhook_tokens" => self.webhook_tokens.read().unwrap().len(), + "watch_events" => self.watch_events.read().unwrap().len(), + "ap_following" => self.follows.read().unwrap().len(), + "ap_followers" => self + .follows + .read() + .unwrap() + .iter() + .filter(|(_, target, _)| matches!(target, SocialIdentity::Local(_))) + .count(), + "blocked_actors" => self.blocked.read().unwrap().len(), + "movie_profiles" => self.movie_profile_count(), + "movie_genres" => self.sum_profiles(|p| p.genres.len()), + "movie_keywords" => self.sum_profiles(|p| p.keywords.len()), + "movie_cast" => self.sum_profiles(|p| p.cast.len()), + "movie_crew" => self.sum_profiles(|p| p.crew.len()), + other => panic!("InMemoryStore does not model table {other}"), + } + } + + fn sum_profiles(&self, f: impl Fn(&MovieProfile) -> usize) -> usize { + self.movie_profiles.read().unwrap().values().map(f).sum() + } +} diff --git a/crates/domain/src/testing/fakes/store_goal.rs b/crates/domain/src/testing/fakes/store_goal.rs new file mode 100644 index 00000000..c927457e --- /dev/null +++ b/crates/domain/src/testing/fakes/store_goal.rs @@ -0,0 +1,70 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + models::Goal, + ports::{GoalCommand, GoalQuery}, + value_objects::{GoalId, UserId}, +}; + +use super::InMemoryStore; + +#[async_trait] +impl GoalCommand for InMemoryStore { + /// `goals` is UNIQUE(user_id, year). + async fn save(&self, goal: &Goal) -> Result<(), DomainError> { + let mut goals = self.goals.write().unwrap(); + let duplicate = goals.values().any(|g| { + g.id() == goal.id() || (g.user_id() == goal.user_id() && g.year() == goal.year()) + }); + if duplicate { + return Err(DomainError::InfrastructureError( + "Database operation failed".into(), + )); + } + goals.insert(goal.id().value(), goal.clone()); + Ok(()) + } + + async fn update(&self, goal: &Goal) -> Result<(), DomainError> { + let mut store = self.goals.write().unwrap(); + match store.entry(goal.id().value()) { + std::collections::hash_map::Entry::Occupied(mut e) => { + e.insert(goal.clone()); + Ok(()) + } + std::collections::hash_map::Entry::Vacant(_) => { + Err(DomainError::NotFound("goal".into())) + } + } + } + + async fn delete(&self, id: &GoalId, _user_id: &UserId) -> Result<(), DomainError> { + self.goals.write().unwrap().remove(&id.value()); + Ok(()) + } +} + +#[async_trait] +impl GoalQuery for InMemoryStore { + async fn find_by_user_and_year( + &self, + user_id: &UserId, + year: u16, + ) -> Result, DomainError> { + let store = self.goals.read().unwrap(); + Ok(store + .values() + .find(|g| g.user_id().value() == user_id.value() && g.year() == year) + .cloned()) + } + + async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { + let store = self.goals.read().unwrap(); + Ok(store + .values() + .filter(|g| g.user_id().value() == user_id.value()) + .cloned() + .collect()) + } +} diff --git a/crates/domain/src/testing/fakes/store_import.rs b/crates/domain/src/testing/fakes/store_import.rs new file mode 100644 index 00000000..b5108ec2 --- /dev/null +++ b/crates/domain/src/testing/fakes/store_import.rs @@ -0,0 +1,98 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + models::{ImportProfile, ImportSession}, + ports::{ImportProfileRepository, ImportSessionRepository}, + value_objects::{ImportProfileId, ImportSessionId, UserId}, +}; + +use super::InMemoryStore; + +#[async_trait] +impl ImportSessionRepository for InMemoryStore { + async fn create(&self, session: &ImportSession) -> Result<(), DomainError> { + self.import_sessions + .write() + .unwrap() + .insert(session.id.value(), session.clone()); + Ok(()) + } + + async fn get( + &self, + id: &ImportSessionId, + user_id: &UserId, + ) -> Result, DomainError> { + let store = self.import_sessions.read().unwrap(); + Ok(store + .get(&id.value()) + .filter(|s| s.user_id.value() == user_id.value()) + .cloned()) + } + + async fn update(&self, session: &ImportSession) -> Result<(), DomainError> { + self.import_sessions + .write() + .unwrap() + .insert(session.id.value(), session.clone()); + Ok(()) + } + + async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> { + self.import_sessions.write().unwrap().remove(&id.value()); + Ok(()) + } + + async fn delete_expired(&self) -> Result { + let mut store = self.import_sessions.write().unwrap(); + let now = chrono::Utc::now().naive_utc(); + let before_len = store.len(); + store.retain(|_, s| s.expires_at > now); + Ok((before_len - store.len()) as u64) + } + + async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { + let mut store = self.import_sessions.write().unwrap(); + let now = chrono::Utc::now().naive_utc(); + store.retain(|_, s| !(s.user_id.value() == user_id.value() && s.expires_at <= now)); + Ok(()) + } +} + +#[async_trait] +impl ImportProfileRepository for InMemoryStore { + async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError> { + self.import_profiles + .write() + .unwrap() + .insert(profile.id.value(), profile.clone()); + Ok(()) + } + + async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { + let store = self.import_profiles.read().unwrap(); + Ok(store + .values() + .filter(|p| p.user_id.value() == user_id.value()) + .cloned() + .collect()) + } + + async fn get( + &self, + id: &ImportProfileId, + user_id: &UserId, + ) -> Result, DomainError> { + let store = self.import_profiles.read().unwrap(); + Ok(store + .get(&id.value()) + .filter(|p| p.user_id.value() == user_id.value()) + .cloned()) + } + + async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> { + self.import_profiles.write().unwrap().remove(&id.value()); + Ok(()) + } +} diff --git a/crates/domain/src/testing/fakes/store_movie.rs b/crates/domain/src/testing/fakes/store_movie.rs new file mode 100644 index 00000000..56852ee8 --- /dev/null +++ b/crates/domain/src/testing/fakes/store_movie.rs @@ -0,0 +1,200 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + models::{ + Movie, MovieFilter, MovieProfile, MovieSummary, PersistedWatchEvent, WatchEvent, + collections::{PageParams, Paginated}, + }, + ports::{MovieCommand, MovieProfileRepository, MovieQuery}, + value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear}, +}; + +use super::InMemoryStore; + +#[async_trait] +impl MovieCommand for InMemoryStore { + /// `movies.external_metadata_id` is UNIQUE: another movie row holding the + /// same external id is a constraint violation, which the sqlite adapter + /// surfaces as an infrastructure error. + async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { + let mut movies = self.movies.write().unwrap(); + if let Some(external) = movie.external_metadata_id() { + let taken = movies.values().any(|m| { + m.id() != movie.id() + && m.external_metadata_id().map(|e| e.value()) == Some(external.value()) + }); + if taken { + return Err(DomainError::InfrastructureError( + "Database operation failed".into(), + )); + } + } + movies.insert(movie.id().value(), movie.clone()); + Ok(()) + } + + /// Schema: `reviews.movie_id REFERENCES movies(id)` has no cascade, so a + /// movie with reviews cannot be deleted. `watchlist_entries` and the + /// `movie_*` profile tables cascade; `watch_events.movie_id` is set to NULL. + async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { + let id = movie_id.value(); + if !self.movies.read().unwrap().contains_key(&id) { + return Ok(()); + } + if self + .reviews + .read() + .unwrap() + .values() + .any(|r| r.movie_id().value() == id) + { + return Err(DomainError::InfrastructureError( + "Database operation failed".into(), + )); + } + self.movies.write().unwrap().remove(&id); + self.watchlist + .write() + .unwrap() + .retain(|(_, movie), _| *movie != id); + self.movie_profiles.write().unwrap().remove(&id); + let mut events = self.watch_events.write().unwrap(); + for event in events.iter_mut() { + if event.movie_id().map(|m| m.value()) == Some(id) { + *event = detached_from_movie(event); + } + } + Ok(()) + } +} + +#[async_trait] +impl MovieQuery for InMemoryStore { + async fn get_movie_by_external_id( + &self, + external_metadata_id: &ExternalMetadataId, + ) -> Result, DomainError> { + let store = self.movies.read().unwrap(); + Ok(store + .values() + .find(|m| { + m.external_metadata_id() + .map(|e| e.value() == external_metadata_id.value()) + .unwrap_or(false) + }) + .cloned()) + } + + async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError> { + Ok(self.movies.read().unwrap().get(&movie_id.value()).cloned()) + } + + async fn get_movies_by_title_and_year( + &self, + title: &MovieTitle, + year: &ReleaseYear, + ) -> Result, DomainError> { + let store = self.movies.read().unwrap(); + Ok(store + .values() + .filter(|m| m.title() == title && m.release_year() == year) + .cloned() + .collect()) + } + + async fn existing_external_ids( + &self, + ids: &[ExternalMetadataId], + ) -> Result, DomainError> { + let store = self.movies.read().unwrap(); + let known: std::collections::HashSet = store + .values() + .filter_map(|m| m.external_metadata_id().map(|e| e.value().to_string())) + .collect(); + Ok(ids + .iter() + .map(|id| id.value().to_string()) + .filter(|v| known.contains(v)) + .collect()) + } + + async fn existing_title_year_pairs( + &self, + pairs: &[(MovieTitle, ReleaseYear)], + ) -> Result, DomainError> { + let store = self.movies.read().unwrap(); + let known: std::collections::HashSet<(String, u16)> = store + .values() + .map(|m| (m.title().value().to_string(), m.release_year().value())) + .collect(); + Ok(pairs + .iter() + .map(|(t, y)| (t.value().to_string(), y.value())) + .filter(|p| known.contains(p)) + .collect()) + } + + async fn list_movies( + &self, + _page: &PageParams, + _filter: &MovieFilter, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total_count: 0, + limit: 10, + offset: 0, + }) + } + + async fn list_movies_with_external_id(&self) -> Result, DomainError> { + Ok(self + .movies + .read() + .unwrap() + .values() + .filter(|m| m.external_metadata_id().is_some()) + .cloned() + .collect()) + } +} + +#[async_trait] +impl MovieProfileRepository for InMemoryStore { + async fn upsert(&self, profile: &MovieProfile) -> Result<(), DomainError> { + self.movie_profiles + .write() + .unwrap() + .insert(profile.movie_id.value(), profile.clone()); + Ok(()) + } + + async fn get_by_movie_id(&self, id: &MovieId) -> Result, DomainError> { + Ok(self + .movie_profiles + .read() + .unwrap() + .get(&id.value()) + .cloned()) + } + + async fn list_stale(&self) -> Result, DomainError> { + Ok(vec![]) + } +} + +fn detached_from_movie(event: &WatchEvent) -> WatchEvent { + WatchEvent::from_persistence(PersistedWatchEvent { + id: event.id().clone(), + user_id: event.user_id().clone(), + movie_id: None, + title: event.title().to_string(), + year: event.year(), + external_metadata_id: event.external_metadata_id().map(str::to_string), + source: event.source().clone(), + watched_at: *event.watched_at(), + status: event.status().clone(), + created_at: *event.created_at(), + }) +} diff --git a/crates/domain/src/testing/fakes/store_review.rs b/crates/domain/src/testing/fakes/store_review.rs new file mode 100644 index 00000000..cf276ac4 --- /dev/null +++ b/crates/domain/src/testing/fakes/store_review.rs @@ -0,0 +1,52 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + models::Review, + ports::ReviewRepository, + value_objects::{ReviewId, UserId}, +}; + +use super::InMemoryStore; + +#[async_trait] +impl ReviewRepository for InMemoryStore { + async fn save_review(&self, review: &Review) -> Result<(), DomainError> { + self.reviews + .write() + .unwrap() + .insert(review.id().value(), review.clone()); + Ok(()) + } + + async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { + Ok(self + .reviews + .read() + .unwrap() + .get(&review_id.value()) + .cloned()) + } + + async fn update_review(&self, review: &Review) -> Result<(), DomainError> { + self.reviews + .write() + .unwrap() + .insert(review.id().value(), review.clone()); + Ok(()) + } + + async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> { + self.reviews.write().unwrap().remove(&review_id.value()); + Ok(()) + } + + async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError> { + let store = self.reviews.read().unwrap(); + Ok(store + .values() + .filter(|r| r.user_id() == user_id) + .cloned() + .collect()) + } +} diff --git a/crates/domain/src/testing/fakes/store_session.rs b/crates/domain/src/testing/fakes/store_session.rs new file mode 100644 index 00000000..514e56af --- /dev/null +++ b/crates/domain/src/testing/fakes/store_session.rs @@ -0,0 +1,58 @@ +use async_trait::async_trait; +use chrono::Utc; + +use crate::{ + errors::DomainError, models::RefreshSession, ports::RefreshSessionRepository, + value_objects::UserId, +}; + +use super::InMemoryStore; + +#[async_trait] +impl RefreshSessionRepository for InMemoryStore { + /// `refresh_sessions.token` is UNIQUE. + async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> { + let mut sessions = self.refresh_sessions.write().unwrap(); + if sessions.iter().any(|s| s.token == session.token) { + return Err(DomainError::InfrastructureError( + "Database operation failed".into(), + )); + } + sessions.push(session.clone()); + Ok(()) + } + + async fn get_by_token(&self, token: &str) -> Result, DomainError> { + Ok(self + .refresh_sessions + .read() + .unwrap() + .iter() + .find(|s| s.token == token) + .cloned()) + } + + async fn revoke(&self, token: &str) -> Result<(), DomainError> { + self.refresh_sessions + .write() + .unwrap() + .retain(|s| s.token != token); + Ok(()) + } + + async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { + self.refresh_sessions + .write() + .unwrap() + .retain(|s| s.user_id != *user_id); + Ok(()) + } + + async fn delete_expired(&self) -> Result { + let mut store = self.refresh_sessions.write().unwrap(); + let before = store.len(); + let now = Utc::now(); + store.retain(|s| s.expires_at >= now); + Ok((before - store.len()) as u64) + } +} diff --git a/crates/domain/src/testing/fakes/store_social.rs b/crates/domain/src/testing/fakes/store_social.rs new file mode 100644 index 00000000..2d5395ff --- /dev/null +++ b/crates/domain/src/testing/fakes/store_social.rs @@ -0,0 +1,274 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + ports::{BlockQuery, FollowGraphQuery, SocialCommand}, + value_objects::{FollowRelation, FollowStatus, SocialActor, SocialIdentity, UserId}, +}; + +use super::InMemoryStore; +use super::store::FollowState; + +fn identity_to_actor(identity: &SocialIdentity) -> SocialActor { + let handle = match identity { + SocialIdentity::Local(uid) => format!("user-{}", uid.value()), + SocialIdentity::Remote { actor_url } => actor_url.clone(), + }; + SocialActor { + identity: identity.clone(), + handle, + display_name: None, + avatar_url: None, + } +} + +#[async_trait] +impl SocialCommand for InMemoryStore { + async fn follow( + &self, + follower: &UserId, + target: &crate::value_objects::FollowTarget, + ) -> Result<(), DomainError> { + let identity = match target { + crate::value_objects::FollowTarget::Identity(id) => id.clone(), + crate::value_objects::FollowTarget::Handle(h) => SocialIdentity::Remote { + actor_url: h.clone(), + }, + }; + if let SocialIdentity::Local(target_id) = &identity + && follower == target_id + { + return Err(DomainError::ValidationError( + "Cannot follow yourself".into(), + )); + } + let mut store = self.follows.write().unwrap(); + let already = store + .iter() + .any(|(f, t, _)| *f == follower.value() && *t == identity); + if already { + return Err(DomainError::ValidationError("Already following".into())); + } + store.push((follower.value(), identity, FollowState::Pending)); + Ok(()) + } + + async fn unfollow( + &self, + follower: &UserId, + target: &SocialIdentity, + ) -> Result<(), DomainError> { + let mut store = self.follows.write().unwrap(); + let before = store.len(); + store.retain(|(f, t, _)| !(*f == follower.value() && t == target)); + if store.len() == before { + return Err(DomainError::NotFound( + "Follow relationship not found".into(), + )); + } + Ok(()) + } + + async fn accept_follow( + &self, + owner: &UserId, + requester: &SocialIdentity, + ) -> Result<(), DomainError> { + let mut store = self.follows.write().unwrap(); + let target_identity = SocialIdentity::Local(owner.clone()); + for (f, t, state) in store.iter_mut() { + let requester_matches = match requester { + SocialIdentity::Local(uid) => *f == uid.value(), + SocialIdentity::Remote { actor_url } => { + if let SocialIdentity::Remote { + actor_url: stored_url, + } = requester + { + stored_url == actor_url + } else { + false + } + } + }; + if requester_matches && *t == target_identity && *state == FollowState::Pending { + *state = FollowState::Accepted; + return Ok(()); + } + } + Err(DomainError::NotFound( + "Pending follow request not found".into(), + )) + } + + async fn reject_follow( + &self, + owner: &UserId, + requester: &SocialIdentity, + ) -> Result<(), DomainError> { + let mut store = self.follows.write().unwrap(); + let target_identity = SocialIdentity::Local(owner.clone()); + let before = store.len(); + store.retain(|(f, t, state)| { + let requester_matches = match requester { + SocialIdentity::Local(uid) => *f == uid.value(), + SocialIdentity::Remote { .. } => { + // For remote, match by checking the stored requester identity + false // simplified: reject removes by follower uuid match + } + }; + !(requester_matches && *t == target_identity && *state == FollowState::Pending) + }); + if store.len() == before { + return Err(DomainError::NotFound( + "Pending follow request not found".into(), + )); + } + Ok(()) + } + + async fn remove_follower( + &self, + owner: &UserId, + follower: &SocialIdentity, + ) -> Result<(), DomainError> { + let mut store = self.follows.write().unwrap(); + let target_identity = SocialIdentity::Local(owner.clone()); + let before = store.len(); + store.retain(|(f, t, _)| { + let follower_matches = match follower { + SocialIdentity::Local(uid) => *f == uid.value(), + SocialIdentity::Remote { .. } => false, + }; + !(follower_matches && *t == target_identity) + }); + if store.len() == before { + return Err(DomainError::NotFound("Follower not found".into())); + } + Ok(()) + } + + async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> { + let mut store = self.blocked.write().unwrap(); + store.push((blocker.value(), target.clone())); + // Also remove any existing follow relationships + let mut follows = self.follows.write().unwrap(); + follows.retain(|(f, t, _)| !(*f == blocker.value() && t == target)); + Ok(()) + } + + async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> { + let mut store = self.blocked.write().unwrap(); + store.retain(|(b, t)| !(*b == blocker.value() && t == target)); + Ok(()) + } +} + +#[async_trait] +impl FollowGraphQuery for InMemoryStore { + async fn get_following(&self, user: &UserId) -> Result, DomainError> { + let store = self.follows.read().unwrap(); + Ok(store + .iter() + .filter(|(f, _, state)| *f == user.value() && *state == FollowState::Accepted) + .map(|(_, t, _)| identity_to_actor(t)) + .collect()) + } + + async fn get_followers(&self, user: &UserId) -> Result, DomainError> { + let store = self.follows.read().unwrap(); + let target = SocialIdentity::Local(user.clone()); + Ok(store + .iter() + .filter(|(_, t, state)| *t == target && *state == FollowState::Accepted) + .map(|(f, _, _)| { + let id = SocialIdentity::Local(UserId::from_uuid(*f)); + identity_to_actor(&id) + }) + .collect()) + } + + async fn get_pending_followers(&self, user: &UserId) -> Result, DomainError> { + let store = self.follows.read().unwrap(); + let target = SocialIdentity::Local(user.clone()); + Ok(store + .iter() + .filter(|(_, t, state)| *t == target && *state == FollowState::Pending) + .map(|(f, _, _)| { + let id = SocialIdentity::Local(UserId::from_uuid(*f)); + identity_to_actor(&id) + }) + .collect()) + } + + async fn get_pending_following(&self, user: &UserId) -> Result, DomainError> { + let store = self.follows.read().unwrap(); + Ok(store + .iter() + .filter(|(f, _, state)| *f == user.value() && *state == FollowState::Pending) + .map(|(_, t, _)| identity_to_actor(t)) + .collect()) + } + + async fn count_following(&self, user: &UserId) -> Result { + let store = self.follows.read().unwrap(); + Ok(store + .iter() + .filter(|(f, _, state)| *f == user.value() && *state == FollowState::Accepted) + .count()) + } + + async fn count_followers(&self, user: &UserId) -> Result { + let store = self.follows.read().unwrap(); + let target = SocialIdentity::Local(user.clone()); + Ok(store + .iter() + .filter(|(_, t, state)| *t == target && *state == FollowState::Accepted) + .count()) + } + + async fn count_pending_followers(&self, user: &UserId) -> Result { + let store = self.follows.read().unwrap(); + let target = SocialIdentity::Local(user.clone()); + Ok(store + .iter() + .filter(|(_, t, state)| *t == target && *state == FollowState::Pending) + .count()) + } + + async fn get_relation( + &self, + viewer: &UserId, + target: &SocialIdentity, + ) -> Result { + let store = self.follows.read().unwrap(); + let viewer_identity = SocialIdentity::Local(viewer.clone()); + let state_to_status = |s: &FollowState| match s { + FollowState::Pending => FollowStatus::Pending, + FollowState::Accepted => FollowStatus::Accepted, + }; + Ok(FollowRelation { + following: store + .iter() + .find(|(f, t, _)| *f == viewer.value() && t == target) + .map(|(_, _, s)| state_to_status(s)), + followed_by: store + .iter() + .find(|(f, t, _)| { + SocialIdentity::Local(UserId::from_uuid(*f)) == *target && *t == viewer_identity + }) + .map(|(_, _, s)| state_to_status(s)), + }) + } +} + +#[async_trait] +impl BlockQuery for InMemoryStore { + async fn get_blocked(&self, user: &UserId) -> Result, DomainError> { + let store = self.blocked.read().unwrap(); + Ok(store + .iter() + .filter(|(b, _)| *b == user.value()) + .map(|(_, t)| identity_to_actor(t)) + .collect()) + } +} diff --git a/crates/domain/src/testing/fakes/store_user.rs b/crates/domain/src/testing/fakes/store_user.rs new file mode 100644 index 00000000..9c0a4c28 --- /dev/null +++ b/crates/domain/src/testing/fakes/store_user.rs @@ -0,0 +1,169 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + models::{FederationFlags, ProfileField, User, UserSettings, UserSummary}, + ports::{ + UserFederationSettingsQuery, UserProfileFieldsRepository, UserRepository, + UserSettingsRepository, + }, + value_objects::{Email, SocialIdentity, UserId, Username}, +}; + +use super::InMemoryStore; + +#[async_trait] +impl UserRepository for InMemoryStore { + async fn find_by_email(&self, email: &Email) -> Result, DomainError> { + let store = self.users.read().unwrap(); + Ok(store + .values() + .find(|u| u.email().value() == email.value()) + .cloned()) + } + + async fn find_by_username(&self, username: &Username) -> Result, DomainError> { + let store = self.users.read().unwrap(); + Ok(store + .values() + .find(|u| u.username().value() == username.value()) + .cloned()) + } + + /// `users.email` and `users.username` are UNIQUE; the sqlite adapter checks + /// both up front and reports a validation error. + async fn save(&self, user: &User) -> Result<(), DomainError> { + let mut users = self.users.write().unwrap(); + if users + .values() + .any(|u| u.email().value() == user.email().value()) + { + return Err(DomainError::ValidationError( + "Email already registered".into(), + )); + } + if users + .values() + .any(|u| u.username().value() == user.username().value()) + { + return Err(DomainError::ValidationError( + "Username already taken".into(), + )); + } + users.insert(user.id().value(), user.clone()); + Ok(()) + } + + async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + Ok(self.users.read().unwrap().get(&id.value()).cloned()) + } + + async fn list_with_stats(&self) -> Result, DomainError> { + Ok(vec![]) + } + + async fn update_profile( + &self, + user_id: &UserId, + profile: &crate::models::UserProfile, + ) -> Result<(), DomainError> { + let mut store = self.users.write().unwrap(); + if let Some(user) = store.get_mut(&user_id.value()) { + user.update_profile(profile.clone()); + } + Ok(()) + } +} + +#[async_trait] +impl UserProfileFieldsRepository for InMemoryStore { + async fn get_fields(&self, user_id: &UserId) -> Result, DomainError> { + let store = self.profile_fields.read().unwrap(); + Ok(store.get(&user_id.value()).cloned().unwrap_or_default()) + } + + async fn set_fields( + &self, + user_id: &UserId, + fields: Vec, + ) -> Result<(), DomainError> { + self.profile_fields + .write() + .unwrap() + .insert(user_id.value(), fields); + Ok(()) + } +} + +#[async_trait] +impl UserSettingsRepository for InMemoryStore { + async fn get(&self, user_id: &UserId) -> Result { + let store = self.user_settings.read().unwrap(); + Ok(store + .get(&user_id.value()) + .cloned() + .unwrap_or_else(|| UserSettings::new(user_id.clone()))) + } + + async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> { + self.user_settings + .write() + .unwrap() + .insert(settings.user_id().value(), settings.clone()); + Ok(()) + } +} + +#[async_trait] +impl UserFederationSettingsQuery for InMemoryStore { + async fn get_federation_flags(&self, user_id: &UserId) -> Result { + let store = self.user_settings.read().unwrap(); + let settings = store + .get(&user_id.value()) + .cloned() + .unwrap_or_else(|| UserSettings::new(user_id.clone())); + Ok(FederationFlags { + goals: settings.federate_goals(), + reviews: settings.federate_reviews(), + watchlist: settings.federate_watchlist(), + }) + } +} + +impl InMemoryStore { + /// Deletes a user the way `DELETE FROM users` does under the production + /// schema: rows in `USER_CASCADE_TABLES` go with it. Reviews, refresh + /// sessions, import sessions and import profiles carry a plain `user_id` + /// column with no foreign key, so they stay. + pub fn delete_user(&self, user_id: &UserId) { + let id = user_id.value(); + self.users.write().unwrap().remove(&id); + self.watchlist + .write() + .unwrap() + .retain(|(user, _), _| *user != id); + self.goals + .write() + .unwrap() + .retain(|_, g| g.user_id().value() != id); + self.user_settings.write().unwrap().remove(&id); + self.profile_fields.write().unwrap().remove(&id); + self.webhook_tokens + .write() + .unwrap() + .retain(|t| t.user_id().value() != id); + self.watch_events + .write() + .unwrap() + .retain(|e| e.user_id().value() != id); + let me = SocialIdentity::Local(user_id.clone()); + self.follows + .write() + .unwrap() + .retain(|(follower, target, _)| *follower != id && *target != me); + self.blocked + .write() + .unwrap() + .retain(|(blocker, _)| *blocker != id); + } +} diff --git a/crates/domain/src/testing/fakes/store_watch_event.rs b/crates/domain/src/testing/fakes/store_watch_event.rs new file mode 100644 index 00000000..1bd2625f --- /dev/null +++ b/crates/domain/src/testing/fakes/store_watch_event.rs @@ -0,0 +1,123 @@ +use async_trait::async_trait; +use chrono::NaiveDateTime; +use uuid::Uuid; + +use crate::{ + errors::DomainError, + models::{WatchEvent, WatchEventStatus, WebhookToken}, + ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository}, + value_objects::{UserId, WatchEventId, WebhookTokenId}, +}; + +use super::InMemoryStore; + +#[async_trait] +impl WatchEventCommand for InMemoryStore { + async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { + self.watch_events.write().unwrap().push(event.clone()); + Ok(()) + } + + async fn update_status( + &self, + _id: &WatchEventId, + _status: WatchEventStatus, + ) -> Result<(), DomainError> { + Ok(()) + } + + async fn update_status_batch( + &self, + ids: &[WatchEventId], + _status: WatchEventStatus, + ) -> Result { + Ok(ids.len() as u64) + } + + async fn delete_non_pending_older_than( + &self, + before: NaiveDateTime, + ) -> Result { + let mut store = self.watch_events.write().unwrap(); + let before_len = store.len(); + store.retain(|e| *e.status() == WatchEventStatus::Pending || *e.created_at() >= before); + Ok((before_len - store.len()) as u64) + } +} + +#[async_trait] +impl WatchEventQuery for InMemoryStore { + async fn list_pending(&self, user_id: &UserId) -> Result, DomainError> { + let store = self.watch_events.read().unwrap(); + Ok(store + .iter() + .filter(|e| { + e.user_id().value() == user_id.value() && *e.status() == WatchEventStatus::Pending + }) + .cloned() + .collect()) + } + + async fn get_by_id(&self, id: &WatchEventId) -> Result, DomainError> { + let store = self.watch_events.read().unwrap(); + Ok(store.iter().find(|e| e.id().value() == id.value()).cloned()) + } + + async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result, DomainError> { + let id_vals: Vec = ids.iter().map(|id| id.value()).collect(); + let store = self.watch_events.read().unwrap(); + Ok(store + .iter() + .filter(|e| id_vals.contains(&e.id().value())) + .cloned() + .collect()) + } + + async fn find_duplicate( + &self, + user_id: &UserId, + external_id: &str, + after: NaiveDateTime, + ) -> Result { + let store = self.watch_events.read().unwrap(); + Ok(store.iter().any(|e| { + e.user_id().value() == user_id.value() + && e.external_metadata_id() == Some(external_id) + && *e.watched_at() > after + })) + } +} + +#[async_trait] +impl WebhookTokenRepository for InMemoryStore { + async fn save(&self, token: &WebhookToken) -> Result<(), DomainError> { + self.webhook_tokens.write().unwrap().push(token.clone()); + Ok(()) + } + + async fn find_by_token_hash(&self, hash: &str) -> Result, DomainError> { + let store = self.webhook_tokens.read().unwrap(); + Ok(store.iter().find(|t| t.token_hash() == hash).cloned()) + } + + async fn list_by_user(&self, user_id: &UserId) -> Result, DomainError> { + let store = self.webhook_tokens.read().unwrap(); + Ok(store + .iter() + .filter(|t| t.user_id().value() == user_id.value()) + .cloned() + .collect()) + } + + async fn delete(&self, id: &WebhookTokenId, _user_id: &UserId) -> Result<(), DomainError> { + self.webhook_tokens + .write() + .unwrap() + .retain(|t| t.id().value() != id.value()); + Ok(()) + } + + async fn touch_last_used(&self, _id: &WebhookTokenId) -> Result<(), DomainError> { + Ok(()) + } +} diff --git a/crates/domain/src/testing/fakes/store_watchlist.rs b/crates/domain/src/testing/fakes/store_watchlist.rs new file mode 100644 index 00000000..57c2a7ba --- /dev/null +++ b/crates/domain/src/testing/fakes/store_watchlist.rs @@ -0,0 +1,110 @@ +use async_trait::async_trait; +use uuid::Uuid; + +use crate::{ + errors::DomainError, + models::{ + RemoteWatchlistEntry, WatchlistEntry, WatchlistWithMovie, + collections::{PageParams, Paginated}, + }, + ports::{RemoteWatchlistRepository, WatchlistRepository}, + value_objects::{MovieId, UserId}, +}; + +use super::InMemoryStore; + +#[async_trait] +impl WatchlistRepository for InMemoryStore { + /// `INSERT OR IGNORE` on UNIQUE(user_id, movie_id): adding twice is a no-op. + async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> { + let key = (entry.user_id.value(), entry.movie_id.value()); + self.watchlist + .write() + .unwrap() + .entry(key) + .or_insert_with(|| entry.clone()); + Ok(()) + } + + async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> { + let key = (user_id.value(), movie_id.value()); + self.watchlist + .write() + .unwrap() + .remove(&key) + .ok_or_else(|| DomainError::NotFound("watchlist entry".into()))?; + Ok(()) + } + + async fn remove_if_present( + &self, + user_id: &UserId, + movie_id: &MovieId, + ) -> Result { + let key = (user_id.value(), movie_id.value()); + Ok(self.watchlist.write().unwrap().remove(&key).is_some()) + } + + async fn get_for_user( + &self, + _user_id: &UserId, + _page: &PageParams, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total_count: 0, + limit: 10, + offset: 0, + }) + } + + async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result { + let key = (user_id.value(), movie_id.value()); + Ok(self.watchlist.read().unwrap().contains_key(&key)) + } +} + +#[async_trait] +impl RemoteWatchlistRepository for InMemoryStore { + async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> { + self.remote_watchlist.write().unwrap().push(entry); + Ok(()) + } + + async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> { + self.remote_watchlist + .write() + .unwrap() + .retain(|e| !(e.ap_id == ap_id && e.actor_url == actor_url)); + Ok(()) + } + + async fn get_by_actor_url( + &self, + actor_url: &str, + ) -> Result, DomainError> { + Ok(self + .remote_watchlist + .read() + .unwrap() + .iter() + .filter(|e| e.actor_url == actor_url) + .cloned() + .collect()) + } + + async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> { + self.remote_watchlist + .write() + .unwrap() + .retain(|e| e.actor_url != actor_url); + Ok(()) + } + + async fn get_by_derived_uuid( + &self, + _uuid: Uuid, + ) -> Result, DomainError> { + Ok(self.remote_watchlist.read().unwrap().clone()) + } +} diff --git a/crates/domain/src/testing/fakes.rs b/crates/domain/src/testing/fakes/stubs.rs similarity index 100% rename from crates/domain/src/testing/fakes.rs rename to crates/domain/src/testing/fakes/stubs.rs diff --git a/crates/domain/src/testing/fakes/tests.rs b/crates/domain/src/testing/fakes/tests.rs new file mode 100644 index 00000000..34305ea3 --- /dev/null +++ b/crates/domain/src/testing/fakes/tests.rs @@ -0,0 +1,388 @@ +use std::sync::Arc; + +use chrono::Utc; +use uuid::Uuid; + +use super::{InMemoryStore, MOVIE_CASCADE_TABLES, USER_CASCADE_TABLES}; +use crate::errors::DomainError; +use crate::models::{ + CastMember, CrewMember, Genre, Keyword, Movie, MovieProfile, ProfileField, RefreshSession, + UserSettings, WatchEvent, WatchEventSource, WatchlistEntry, WebhookToken, +}; +use crate::ports::{ + GoalCommand, MovieCommand, MovieProfileRepository, RefreshSessionRepository, ReviewRepository, + SocialCommand, UserProfileFieldsRepository, UserRepository, UserSettingsRepository, + WatchEventCommand, WatchEventQuery, WatchlistRepository, WebhookTokenRepository, +}; +use crate::testing::{ + test_goal, test_movie, test_movie_with_external_id, test_review, test_user, test_watched_at, +}; +use crate::value_objects::{ExternalMetadataId, FollowTarget, MovieId, SocialIdentity, UserId}; + +const REMOTE_ACTOR: &str = "https://remote.example/users/ann"; + +fn movie_profile(movie_id: &MovieId) -> MovieProfile { + MovieProfile { + movie_id: movie_id.clone(), + tmdb_id: 603, + imdb_id: None, + overview: None, + tagline: None, + runtime_minutes: None, + budget_usd: None, + revenue_usd: None, + vote_average: None, + vote_count: None, + original_language: None, + collection_name: None, + genres: vec![Genre { + tmdb_id: 1, + name: "Sci-Fi".into(), + }], + keywords: vec![Keyword { + tmdb_id: 2, + name: "simulation".into(), + }], + cast: vec![CastMember { + tmdb_person_id: 3, + name: "Actor".into(), + character: "Role".into(), + billing_order: 0, + profile_path: None, + }], + crew: vec![CrewMember { + tmdb_person_id: 4, + name: "Director".into(), + job: "Director".into(), + department: "Directing".into(), + profile_path: None, + }], + enriched_at: Utc::now(), + } +} + +/// Saves a user and one row in every table the schema cascades from a user, +/// plus rows in the tables that hang off a user without a foreign key. +async fn seed_user_with_dependents(store: &Arc) -> (UserId, Movie) { + let user = test_user("alice"); + let user_id = user.id().clone(); + let other = test_user("bob"); + let movie = test_movie("Heat"); + UserRepository::save(&**store, &user).await.unwrap(); + UserRepository::save(&**store, &other).await.unwrap(); + store.upsert_movie(&movie).await.unwrap(); + + store + .add(&WatchlistEntry::new(user_id.clone(), movie.id().clone())) + .await + .unwrap(); + GoalCommand::save(&**store, &test_goal(&user_id, 2024, 50)) + .await + .unwrap(); + UserSettingsRepository::save(&**store, &UserSettings::new(user_id.clone())) + .await + .unwrap(); + store + .set_fields( + &user_id, + vec![ProfileField { + name: "Site".into(), + value: "https://example.com".into(), + }], + ) + .await + .unwrap(); + WebhookTokenRepository::save( + &**store, + &WebhookToken::new( + user_id.clone(), + "hash".into(), + WatchEventSource::Jellyfin, + None, + ), + ) + .await + .unwrap(); + WatchEventCommand::save( + &**store, + &WatchEvent::new( + user_id.clone(), + "Heat".into(), + Some(1995), + None, + WatchEventSource::Jellyfin, + test_watched_at(), + None, + ), + ) + .await + .unwrap(); + store + .follow( + &user_id, + &FollowTarget::Identity(SocialIdentity::Remote { + actor_url: REMOTE_ACTOR.into(), + }), + ) + .await + .unwrap(); + store + .follow( + other.id(), + &FollowTarget::Identity(SocialIdentity::Local(user_id.clone())), + ) + .await + .unwrap(); + store + .block( + &user_id, + &SocialIdentity::Remote { + actor_url: REMOTE_ACTOR.into(), + }, + ) + .await + .unwrap(); + + store + .save_review(&test_review(movie.id(), &user_id, 4)) + .await + .unwrap(); + store + .create(&RefreshSession { + id: Uuid::new_v4(), + user_id: user_id.clone(), + token: "refresh-token".into(), + expires_at: Utc::now() + chrono::Duration::days(1), + created_at: Utc::now(), + }) + .await + .unwrap(); + + (user_id, movie) +} + +#[tokio::test] +async fn deleting_a_user_removes_watchlist_goals_settings_profile_fields_tokens_events_follows_and_blocks() + { + let store = InMemoryStore::new(); + let (user_id, _) = seed_user_with_dependents(&store).await; + for table in USER_CASCADE_TABLES { + assert!(store.rows_in(table) > 0, "{table} should be seeded"); + } + + store.delete_user(&user_id); + + assert!(store.find_by_id(&user_id).await.unwrap().is_none()); + for table in USER_CASCADE_TABLES { + assert_eq!(store.rows_in(table), 0, "{table} should cascade"); + } +} + +#[tokio::test] +async fn deleting_a_user_keeps_reviews_and_refresh_sessions_because_the_schema_has_no_foreign_key() +{ + let store = InMemoryStore::new(); + let (user_id, _) = seed_user_with_dependents(&store).await; + + store.delete_user(&user_id); + + assert_eq!(store.review_count(), 1); + assert_eq!(store.refresh_session_count(), 1); +} + +#[tokio::test] +async fn deleting_a_user_leaves_other_users_rows_alone() { + let store = InMemoryStore::new(); + let (user_id, movie) = seed_user_with_dependents(&store).await; + let bystander = test_user("carol"); + UserRepository::save(&*store, &bystander).await.unwrap(); + store + .add(&WatchlistEntry::new( + bystander.id().clone(), + movie.id().clone(), + )) + .await + .unwrap(); + + store.delete_user(&user_id); + + assert_eq!(store.watchlist_count(), 1); + assert!(store.find_by_id(bystander.id()).await.unwrap().is_some()); +} + +async fn seed_movie_with_dependents(store: &Arc, with_review: bool) -> Movie { + let user = test_user("alice"); + UserRepository::save(&**store, &user).await.unwrap(); + let movie = test_movie("Heat"); + store.upsert_movie(&movie).await.unwrap(); + store + .add(&WatchlistEntry::new(user.id().clone(), movie.id().clone())) + .await + .unwrap(); + store.upsert(&movie_profile(movie.id())).await.unwrap(); + WatchEventCommand::save( + &**store, + &WatchEvent::new( + user.id().clone(), + "Heat".into(), + Some(1995), + None, + WatchEventSource::Plex, + test_watched_at(), + Some(movie.id().clone()), + ), + ) + .await + .unwrap(); + if with_review { + store + .save_review(&test_review(movie.id(), user.id(), 5)) + .await + .unwrap(); + } + movie +} + +#[tokio::test] +async fn deleting_a_movie_removes_watchlist_entries_and_enrichment_profile() { + let store = InMemoryStore::new(); + let movie = seed_movie_with_dependents(&store, false).await; + for table in MOVIE_CASCADE_TABLES { + assert!(store.rows_in(table) > 0, "{table} should be seeded"); + } + + store.delete_movie(movie.id()).await.unwrap(); + + assert_eq!(store.movie_count(), 0); + for table in MOVIE_CASCADE_TABLES { + assert_eq!(store.rows_in(table), 0, "{table} should cascade"); + } +} + +#[tokio::test] +async fn deleting_a_movie_detaches_watch_events_instead_of_removing_them() { + let store = InMemoryStore::new(); + let movie = seed_movie_with_dependents(&store, false).await; + let user_id = store.watch_events.read().unwrap()[0].user_id().clone(); + + store.delete_movie(movie.id()).await.unwrap(); + + let pending = store.list_pending(&user_id).await.unwrap(); + assert_eq!(pending.len(), 1); + assert!(pending[0].movie_id().is_none()); +} + +#[tokio::test] +async fn deleting_a_movie_that_has_reviews_is_refused_because_reviews_do_not_cascade() { + let store = InMemoryStore::new(); + let movie = seed_movie_with_dependents(&store, true).await; + + let result = store.delete_movie(movie.id()).await; + + assert!(matches!(result, Err(DomainError::InfrastructureError(_)))); + assert_eq!(store.movie_count(), 1); + assert_eq!(store.review_count(), 1); + assert_eq!(store.watchlist_count(), 1); +} + +#[tokio::test] +async fn saving_a_user_with_a_taken_username_is_a_validation_error() { + let store = InMemoryStore::new(); + UserRepository::save(&*store, &test_user("alice")) + .await + .unwrap(); + let clash = crate::models::User::new( + crate::value_objects::Email::new("other@example.com".into()).unwrap(), + crate::value_objects::Username::new("alice".into()).unwrap(), + crate::value_objects::PasswordHash::new("hashed".into()).unwrap(), + crate::models::UserRole::Standard, + ); + + let result = UserRepository::save(&*store, &clash).await; + + assert!(matches!(result, Err(DomainError::ValidationError(_)))); +} + +#[tokio::test] +async fn saving_a_user_with_a_taken_email_is_a_validation_error() { + let store = InMemoryStore::new(); + UserRepository::save(&*store, &test_user("alice")) + .await + .unwrap(); + let clash = crate::models::User::new( + crate::value_objects::Email::new("alice@example.com".into()).unwrap(), + crate::value_objects::Username::new("alice2".into()).unwrap(), + crate::value_objects::PasswordHash::new("hashed".into()).unwrap(), + crate::models::UserRole::Standard, + ); + + let result = UserRepository::save(&*store, &clash).await; + + assert!(matches!(result, Err(DomainError::ValidationError(_)))); +} + +#[tokio::test] +async fn saving_a_second_goal_for_the_same_user_and_year_is_an_infrastructure_error() { + let store = InMemoryStore::new(); + let user_id = UserId::generate(); + GoalCommand::save(&*store, &test_goal(&user_id, 2024, 10)) + .await + .unwrap(); + + let result = GoalCommand::save(&*store, &test_goal(&user_id, 2024, 20)).await; + + assert!(matches!(result, Err(DomainError::InfrastructureError(_)))); + assert_eq!(store.goal_count(), 1); +} + +#[tokio::test] +async fn adding_the_same_movie_to_a_watchlist_twice_keeps_one_entry() { + let store = InMemoryStore::new(); + let user_id = UserId::generate(); + let movie_id = MovieId::generate(); + + store + .add(&WatchlistEntry::new(user_id.clone(), movie_id.clone())) + .await + .unwrap(); + store + .add(&WatchlistEntry::new(user_id, movie_id)) + .await + .unwrap(); + + assert_eq!(store.watchlist_count(), 1); +} + +#[tokio::test] +async fn upserting_a_different_movie_with_a_taken_external_id_is_an_infrastructure_error() { + let store = InMemoryStore::new(); + let external = ExternalMetadataId::new("tt0113277".into()).unwrap(); + store + .upsert_movie(&test_movie_with_external_id("Heat", 1995, external.clone())) + .await + .unwrap(); + + let result = store + .upsert_movie(&test_movie_with_external_id("Heat 2", 2030, external)) + .await; + + assert!(matches!(result, Err(DomainError::InfrastructureError(_)))); + assert_eq!(store.movie_count(), 1); +} + +#[tokio::test] +async fn creating_a_refresh_session_with_a_taken_token_is_an_infrastructure_error() { + let store = InMemoryStore::new(); + let session = |token: &str| RefreshSession { + id: Uuid::new_v4(), + user_id: UserId::generate(), + token: token.into(), + expires_at: Utc::now() + chrono::Duration::days(1), + created_at: Utc::now(), + }; + store.create(&session("same")).await.unwrap(); + + let result = store.create(&session("same")).await; + + assert!(matches!(result, Err(DomainError::InfrastructureError(_)))); +} diff --git a/crates/domain/src/testing/wrapup.rs b/crates/domain/src/testing/fakes/wrapup.rs similarity index 100% rename from crates/domain/src/testing/wrapup.rs rename to crates/domain/src/testing/fakes/wrapup.rs diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs deleted file mode 100644 index 21a20c6d..00000000 --- a/crates/domain/src/testing/in_memory.rs +++ /dev/null @@ -1,1214 +0,0 @@ -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use async_trait::async_trait; -use uuid::Uuid; - -use chrono::NaiveDateTime; - -use chrono::Utc; - -use crate::{ - errors::DomainError, - models::{ - FederationFlags, Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, - MovieSummary, ProfileField, RefreshSession, RemoteWatchlistEntry, Review, User, - UserSettings, UserSummary, WatchEvent, WatchEventStatus, WatchlistEntry, - WatchlistWithMovie, WebhookToken, - collections::{PageParams, Paginated}, - }, - ports::{ - BlockQuery, FollowGraphQuery, GoalCommand, GoalQuery, ImportProfileRepository, - ImportSessionRepository, MovieCommand, MovieProfileRepository, MovieQuery, - RefreshSessionRepository, RemoteWatchlistRepository, ReviewRepository, SocialCommand, - UserFederationSettingsQuery, UserProfileFieldsRepository, UserRepository, - UserSettingsRepository, WatchEventCommand, WatchEventQuery, WatchlistRepository, - WebhookTokenRepository, - }, - value_objects::{ - Email, ExternalMetadataId, FollowRelation, FollowStatus, GoalId, ImportProfileId, - ImportSessionId, MovieId, MovieTitle, ReleaseYear, ReviewId, SocialActor, SocialIdentity, - UserId, Username, WatchEventId, WebhookTokenId, - }, -}; - -// ── InMemoryMovieRepository ─────────────────────────────────────────────────── - -pub struct InMemoryMovieRepository { - pub store: Mutex>, -} - -impl InMemoryMovieRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl MovieCommand for InMemoryMovieRepository { - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(movie.id().value(), movie.clone()); - Ok(()) - } - - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { - self.store.lock().unwrap().remove(&movie_id.value()); - Ok(()) - } -} - -#[async_trait] -impl MovieQuery for InMemoryMovieRepository { - async fn get_movie_by_external_id( - &self, - external_metadata_id: &ExternalMetadataId, - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .find(|m| { - m.external_metadata_id() - .map(|e| e.value() == external_metadata_id.value()) - .unwrap_or(false) - }) - .cloned()) - } - - async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError> { - Ok(self.store.lock().unwrap().get(&movie_id.value()).cloned()) - } - - async fn get_movies_by_title_and_year( - &self, - title: &MovieTitle, - year: &ReleaseYear, - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .filter(|m| m.title() == title && m.release_year() == year) - .cloned() - .collect()) - } - - async fn existing_external_ids( - &self, - ids: &[ExternalMetadataId], - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - let known: std::collections::HashSet = store - .values() - .filter_map(|m| m.external_metadata_id().map(|e| e.value().to_string())) - .collect(); - Ok(ids - .iter() - .map(|id| id.value().to_string()) - .filter(|v| known.contains(v)) - .collect()) - } - - async fn existing_title_year_pairs( - &self, - pairs: &[(MovieTitle, ReleaseYear)], - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - let known: std::collections::HashSet<(String, u16)> = store - .values() - .map(|m| (m.title().value().to_string(), m.release_year().value())) - .collect(); - Ok(pairs - .iter() - .map(|(t, y)| (t.value().to_string(), y.value())) - .filter(|p| known.contains(p)) - .collect()) - } - - async fn list_movies( - &self, - _page: &PageParams, - _filter: &MovieFilter, - ) -> Result, DomainError> { - Ok(Paginated { - items: vec![], - total_count: 0, - limit: 10, - offset: 0, - }) - } - - async fn list_movies_with_external_id(&self) -> Result, DomainError> { - Ok(self - .store - .lock() - .unwrap() - .values() - .filter(|m| m.external_metadata_id().is_some()) - .cloned() - .collect()) - } -} - -// ── InMemoryReviewRepository ────────────────────────────────────────────────── - -pub struct InMemoryReviewRepository { - store: Mutex>, -} - -impl InMemoryReviewRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl ReviewRepository for InMemoryReviewRepository { - async fn save_review(&self, review: &Review) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(review.id().value(), review.clone()); - Ok(()) - } - - async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { - Ok(self.store.lock().unwrap().get(&review_id.value()).cloned()) - } - - async fn update_review(&self, review: &Review) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(review.id().value(), review.clone()); - Ok(()) - } - - async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> { - self.store.lock().unwrap().remove(&review_id.value()); - Ok(()) - } - - async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .filter(|r| r.user_id() == user_id) - .cloned() - .collect()) - } -} - -// ── InMemoryUserRepository ──────────────────────────────────────────────────── - -pub struct InMemoryUserRepository { - pub store: Mutex>, -} - -impl InMemoryUserRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl UserRepository for InMemoryUserRepository { - async fn find_by_email(&self, email: &Email) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .find(|u| u.email().value() == email.value()) - .cloned()) - } - - async fn find_by_username(&self, username: &Username) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .find(|u| u.username().value() == username.value()) - .cloned()) - } - - async fn save(&self, user: &User) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(user.id().value(), user.clone()); - Ok(()) - } - - async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { - Ok(self.store.lock().unwrap().get(&id.value()).cloned()) - } - - async fn list_with_stats(&self) -> Result, DomainError> { - Ok(vec![]) - } - - async fn update_profile( - &self, - user_id: &UserId, - profile: &crate::models::UserProfile, - ) -> Result<(), DomainError> { - let mut store = self.store.lock().unwrap(); - if let Some(user) = store.get_mut(&user_id.value()) { - user.update_profile(profile.clone()); - } - Ok(()) - } -} - -// ── InMemoryWatchlistRepository ─────────────────────────────────────────────── - -pub struct InMemoryWatchlistRepository { - store: Mutex>, -} - -impl InMemoryWatchlistRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl WatchlistRepository for InMemoryWatchlistRepository { - async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> { - let key = (entry.user_id.value(), entry.movie_id.value()); - self.store - .lock() - .unwrap() - .entry(key) - .or_insert_with(|| entry.clone()); - Ok(()) - } - - async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> { - let key = (user_id.value(), movie_id.value()); - self.store - .lock() - .unwrap() - .remove(&key) - .ok_or_else(|| DomainError::NotFound("watchlist entry".into()))?; - Ok(()) - } - - async fn remove_if_present( - &self, - user_id: &UserId, - movie_id: &MovieId, - ) -> Result { - let key = (user_id.value(), movie_id.value()); - Ok(self.store.lock().unwrap().remove(&key).is_some()) - } - - async fn get_for_user( - &self, - _user_id: &UserId, - _page: &PageParams, - ) -> Result, DomainError> { - Ok(Paginated { - items: vec![], - total_count: 0, - limit: 10, - offset: 0, - }) - } - - async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result { - let key = (user_id.value(), movie_id.value()); - Ok(self.store.lock().unwrap().contains_key(&key)) - } -} - -// ── InMemoryRemoteWatchlistRepository ───────────────────────────────────────── - -pub struct InMemoryRemoteWatchlistRepository { - store: Mutex>, -} - -impl InMemoryRemoteWatchlistRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(Vec::new()), - }) - } - - pub fn with_entries(entries: Vec) -> Arc { - Arc::new(Self { - store: Mutex::new(entries), - }) - } -} - -#[async_trait] -impl RemoteWatchlistRepository for InMemoryRemoteWatchlistRepository { - async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> { - self.store.lock().unwrap().push(entry); - Ok(()) - } - - async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .retain(|e| !(e.ap_id == ap_id && e.actor_url == actor_url)); - Ok(()) - } - - async fn get_by_actor_url( - &self, - actor_url: &str, - ) -> Result, DomainError> { - Ok(self - .store - .lock() - .unwrap() - .iter() - .filter(|e| e.actor_url == actor_url) - .cloned() - .collect()) - } - - async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .retain(|e| e.actor_url != actor_url); - Ok(()) - } - - async fn get_by_derived_uuid( - &self, - _uuid: Uuid, - ) -> Result, DomainError> { - Ok(self.store.lock().unwrap().clone()) - } -} - -// ── InMemoryGoalRepository ────────────────────────────────────────────────── - -pub struct InMemoryGoalRepository { - store: Mutex>, -} - -impl InMemoryGoalRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl GoalCommand for InMemoryGoalRepository { - async fn save(&self, goal: &Goal) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(goal.id().value(), goal.clone()); - Ok(()) - } - - async fn update(&self, goal: &Goal) -> Result<(), DomainError> { - let mut store = self.store.lock().unwrap(); - match store.entry(goal.id().value()) { - std::collections::hash_map::Entry::Occupied(mut e) => { - e.insert(goal.clone()); - Ok(()) - } - std::collections::hash_map::Entry::Vacant(_) => { - Err(DomainError::NotFound("goal".into())) - } - } - } - - async fn delete(&self, id: &GoalId, _user_id: &UserId) -> Result<(), DomainError> { - self.store.lock().unwrap().remove(&id.value()); - Ok(()) - } -} - -#[async_trait] -impl GoalQuery for InMemoryGoalRepository { - async fn find_by_user_and_year( - &self, - user_id: &UserId, - year: u16, - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .find(|g| g.user_id().value() == user_id.value() && g.year() == year) - .cloned()) - } - - async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .filter(|g| g.user_id().value() == user_id.value()) - .cloned() - .collect()) - } -} - -// ── InMemoryUserSettingsRepository ────────────────────────────────────────── - -pub struct InMemoryUserSettingsRepository { - store: Mutex>, -} - -impl InMemoryUserSettingsRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl UserSettingsRepository for InMemoryUserSettingsRepository { - async fn get(&self, user_id: &UserId) -> Result { - let store = self.store.lock().unwrap(); - Ok(store - .get(&user_id.value()) - .cloned() - .unwrap_or_else(|| UserSettings::new(user_id.clone()))) - } - - async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(settings.user_id().value(), settings.clone()); - Ok(()) - } -} - -#[async_trait] -impl UserFederationSettingsQuery for InMemoryUserSettingsRepository { - async fn get_federation_flags(&self, user_id: &UserId) -> Result { - let store = self.store.lock().unwrap(); - let settings = store - .get(&user_id.value()) - .cloned() - .unwrap_or_else(|| UserSettings::new(user_id.clone())); - Ok(FederationFlags { - goals: settings.federate_goals(), - reviews: settings.federate_reviews(), - watchlist: settings.federate_watchlist(), - }) - } -} - -// ── InMemoryWebhookTokenRepository ────────────────────────────────────────── - -pub struct InMemoryWebhookTokenRepository { - store: Mutex>, -} - -impl InMemoryWebhookTokenRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(Vec::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl WebhookTokenRepository for InMemoryWebhookTokenRepository { - async fn save(&self, token: &WebhookToken) -> Result<(), DomainError> { - self.store.lock().unwrap().push(token.clone()); - Ok(()) - } - - async fn find_by_token_hash(&self, hash: &str) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store.iter().find(|t| t.token_hash() == hash).cloned()) - } - - async fn list_by_user(&self, user_id: &UserId) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .iter() - .filter(|t| t.user_id().value() == user_id.value()) - .cloned() - .collect()) - } - - async fn delete(&self, id: &WebhookTokenId, _user_id: &UserId) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .retain(|t| t.id().value() != id.value()); - Ok(()) - } - - async fn touch_last_used(&self, _id: &WebhookTokenId) -> Result<(), DomainError> { - Ok(()) - } -} - -// ── InMemoryWatchEventRepository ──────────────────────────────────────────── - -pub struct InMemoryWatchEventRepository { - store: Mutex>, -} - -impl InMemoryWatchEventRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(Vec::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl WatchEventCommand for InMemoryWatchEventRepository { - async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { - self.store.lock().unwrap().push(event.clone()); - Ok(()) - } - - async fn update_status( - &self, - _id: &WatchEventId, - _status: WatchEventStatus, - ) -> Result<(), DomainError> { - Ok(()) - } - - async fn update_status_batch( - &self, - ids: &[WatchEventId], - _status: WatchEventStatus, - ) -> Result { - Ok(ids.len() as u64) - } - - async fn delete_non_pending_older_than( - &self, - before: NaiveDateTime, - ) -> Result { - let mut store = self.store.lock().unwrap(); - let before_len = store.len(); - store.retain(|e| *e.status() == WatchEventStatus::Pending || *e.created_at() >= before); - Ok((before_len - store.len()) as u64) - } -} - -#[async_trait] -impl WatchEventQuery for InMemoryWatchEventRepository { - async fn list_pending(&self, user_id: &UserId) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .iter() - .filter(|e| { - e.user_id().value() == user_id.value() && *e.status() == WatchEventStatus::Pending - }) - .cloned() - .collect()) - } - - async fn get_by_id(&self, id: &WatchEventId) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store.iter().find(|e| e.id().value() == id.value()).cloned()) - } - - async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result, DomainError> { - let id_vals: Vec = ids.iter().map(|id| id.value()).collect(); - let store = self.store.lock().unwrap(); - Ok(store - .iter() - .filter(|e| id_vals.contains(&e.id().value())) - .cloned() - .collect()) - } - - async fn find_duplicate( - &self, - user_id: &UserId, - external_id: &str, - after: NaiveDateTime, - ) -> Result { - let store = self.store.lock().unwrap(); - Ok(store.iter().any(|e| { - e.user_id().value() == user_id.value() - && e.external_metadata_id() == Some(external_id) - && *e.watched_at() > after - })) - } -} - -// ── InMemoryImportSessionRepository ───────────────────────────────────────── - -pub struct InMemoryImportSessionRepository { - store: Mutex>, -} - -impl InMemoryImportSessionRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl ImportSessionRepository for InMemoryImportSessionRepository { - async fn create(&self, session: &ImportSession) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(session.id.value(), session.clone()); - Ok(()) - } - - async fn get( - &self, - id: &ImportSessionId, - user_id: &UserId, - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .get(&id.value()) - .filter(|s| s.user_id.value() == user_id.value()) - .cloned()) - } - - async fn update(&self, session: &ImportSession) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(session.id.value(), session.clone()); - Ok(()) - } - - async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> { - self.store.lock().unwrap().remove(&id.value()); - Ok(()) - } - - async fn delete_expired(&self) -> Result { - let mut store = self.store.lock().unwrap(); - let now = chrono::Utc::now().naive_utc(); - let before_len = store.len(); - store.retain(|_, s| s.expires_at > now); - Ok((before_len - store.len()) as u64) - } - - async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { - let mut store = self.store.lock().unwrap(); - let now = chrono::Utc::now().naive_utc(); - store.retain(|_, s| !(s.user_id.value() == user_id.value() && s.expires_at <= now)); - Ok(()) - } -} - -// ── InMemoryImportProfileRepository ───────────────────────────────────────── - -pub struct InMemoryImportProfileRepository { - store: Mutex>, -} - -impl InMemoryImportProfileRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl ImportProfileRepository for InMemoryImportProfileRepository { - async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(profile.id.value(), profile.clone()); - Ok(()) - } - - async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .filter(|p| p.user_id.value() == user_id.value()) - .cloned() - .collect()) - } - - async fn get( - &self, - id: &ImportProfileId, - user_id: &UserId, - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .get(&id.value()) - .filter(|p| p.user_id.value() == user_id.value()) - .cloned()) - } - - async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> { - self.store.lock().unwrap().remove(&id.value()); - Ok(()) - } -} - -// ── InMemoryMovieProfileRepository ────────────────────────────────────────── - -pub struct InMemoryMovieProfileRepository { - store: Mutex>, -} - -impl InMemoryMovieProfileRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl MovieProfileRepository for InMemoryMovieProfileRepository { - async fn upsert(&self, profile: &MovieProfile) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(profile.movie_id.value(), profile.clone()); - Ok(()) - } - - async fn get_by_movie_id(&self, id: &MovieId) -> Result, DomainError> { - Ok(self.store.lock().unwrap().get(&id.value()).cloned()) - } - - async fn list_stale(&self) -> Result, DomainError> { - Ok(vec![]) - } -} - -// ── InMemoryProfileFieldsRepo ─────────────────────────────────────────────── - -pub struct InMemoryProfileFieldsRepo { - store: Mutex>>, -} - -impl InMemoryProfileFieldsRepo { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl UserProfileFieldsRepository for InMemoryProfileFieldsRepo { - async fn get_fields(&self, user_id: &UserId) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store.get(&user_id.value()).cloned().unwrap_or_default()) - } - - async fn set_fields( - &self, - user_id: &UserId, - fields: Vec, - ) -> Result<(), DomainError> { - self.store.lock().unwrap().insert(user_id.value(), fields); - Ok(()) - } -} - -// ── InMemoryRefreshSessionRepository ──────────────────────────────────────── - -pub struct InMemoryRefreshSessionRepository { - pub store: Mutex>, -} - -impl InMemoryRefreshSessionRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(Vec::new()), - }) - } -} - -#[async_trait] -impl RefreshSessionRepository for InMemoryRefreshSessionRepository { - async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> { - self.store.lock().unwrap().push(session.clone()); - Ok(()) - } - - async fn get_by_token(&self, token: &str) -> Result, DomainError> { - Ok(self - .store - .lock() - .unwrap() - .iter() - .find(|s| s.token == token) - .cloned()) - } - - async fn revoke(&self, token: &str) -> Result<(), DomainError> { - self.store.lock().unwrap().retain(|s| s.token != token); - Ok(()) - } - - async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { - self.store.lock().unwrap().retain(|s| s.user_id != *user_id); - Ok(()) - } - - async fn delete_expired(&self) -> Result { - let mut store = self.store.lock().unwrap(); - let before = store.len(); - let now = Utc::now(); - store.retain(|s| s.expires_at >= now); - Ok((before - store.len()) as u64) - } -} - -// ── InMemorySocialRepository ──────────────────────────────────────────────── - -#[derive(Debug, Clone, PartialEq, Eq)] -enum FollowState { - Pending, - Accepted, -} - -pub struct InMemorySocialRepository { - follows: Mutex>, - blocked: Mutex>, -} - -impl InMemorySocialRepository { - pub fn new() -> Arc { - Arc::new(Self { - follows: Mutex::new(Vec::new()), - blocked: Mutex::new(Vec::new()), - }) - } - - fn identity_to_actor(identity: &SocialIdentity) -> SocialActor { - let handle = match identity { - SocialIdentity::Local(uid) => format!("user-{}", uid.value()), - SocialIdentity::Remote { actor_url } => actor_url.clone(), - }; - SocialActor { - identity: identity.clone(), - handle, - display_name: None, - avatar_url: None, - } - } -} - -#[async_trait] -impl SocialCommand for InMemorySocialRepository { - async fn follow( - &self, - follower: &UserId, - target: &crate::value_objects::FollowTarget, - ) -> Result<(), DomainError> { - let identity = match target { - crate::value_objects::FollowTarget::Identity(id) => id.clone(), - crate::value_objects::FollowTarget::Handle(h) => SocialIdentity::Remote { - actor_url: h.clone(), - }, - }; - if let SocialIdentity::Local(target_id) = &identity - && follower == target_id - { - return Err(DomainError::ValidationError( - "Cannot follow yourself".into(), - )); - } - let mut store = self.follows.lock().unwrap(); - let already = store - .iter() - .any(|(f, t, _)| *f == follower.value() && *t == identity); - if already { - return Err(DomainError::ValidationError("Already following".into())); - } - store.push((follower.value(), identity, FollowState::Pending)); - Ok(()) - } - - async fn unfollow( - &self, - follower: &UserId, - target: &SocialIdentity, - ) -> Result<(), DomainError> { - let mut store = self.follows.lock().unwrap(); - let before = store.len(); - store.retain(|(f, t, _)| !(*f == follower.value() && t == target)); - if store.len() == before { - return Err(DomainError::NotFound( - "Follow relationship not found".into(), - )); - } - Ok(()) - } - - async fn accept_follow( - &self, - owner: &UserId, - requester: &SocialIdentity, - ) -> Result<(), DomainError> { - let mut store = self.follows.lock().unwrap(); - let target_identity = SocialIdentity::Local(owner.clone()); - for (f, t, state) in store.iter_mut() { - let requester_matches = match requester { - SocialIdentity::Local(uid) => *f == uid.value(), - SocialIdentity::Remote { actor_url } => { - if let SocialIdentity::Remote { - actor_url: stored_url, - } = requester - { - stored_url == actor_url - } else { - false - } - } - }; - if requester_matches && *t == target_identity && *state == FollowState::Pending { - *state = FollowState::Accepted; - return Ok(()); - } - } - Err(DomainError::NotFound( - "Pending follow request not found".into(), - )) - } - - async fn reject_follow( - &self, - owner: &UserId, - requester: &SocialIdentity, - ) -> Result<(), DomainError> { - let mut store = self.follows.lock().unwrap(); - let target_identity = SocialIdentity::Local(owner.clone()); - let before = store.len(); - store.retain(|(f, t, state)| { - let requester_matches = match requester { - SocialIdentity::Local(uid) => *f == uid.value(), - SocialIdentity::Remote { .. } => { - // For remote, match by checking the stored requester identity - false // simplified: reject removes by follower uuid match - } - }; - !(requester_matches && *t == target_identity && *state == FollowState::Pending) - }); - if store.len() == before { - return Err(DomainError::NotFound( - "Pending follow request not found".into(), - )); - } - Ok(()) - } - - async fn remove_follower( - &self, - owner: &UserId, - follower: &SocialIdentity, - ) -> Result<(), DomainError> { - let mut store = self.follows.lock().unwrap(); - let target_identity = SocialIdentity::Local(owner.clone()); - let before = store.len(); - store.retain(|(f, t, _)| { - let follower_matches = match follower { - SocialIdentity::Local(uid) => *f == uid.value(), - SocialIdentity::Remote { .. } => false, - }; - !(follower_matches && *t == target_identity) - }); - if store.len() == before { - return Err(DomainError::NotFound("Follower not found".into())); - } - Ok(()) - } - - async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> { - let mut store = self.blocked.lock().unwrap(); - store.push((blocker.value(), target.clone())); - // Also remove any existing follow relationships - let mut follows = self.follows.lock().unwrap(); - follows.retain(|(f, t, _)| !(*f == blocker.value() && t == target)); - Ok(()) - } - - async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> { - let mut store = self.blocked.lock().unwrap(); - store.retain(|(b, t)| !(*b == blocker.value() && t == target)); - Ok(()) - } -} - -#[async_trait] -impl FollowGraphQuery for InMemorySocialRepository { - async fn get_following(&self, user: &UserId) -> Result, DomainError> { - let store = self.follows.lock().unwrap(); - Ok(store - .iter() - .filter(|(f, _, state)| *f == user.value() && *state == FollowState::Accepted) - .map(|(_, t, _)| Self::identity_to_actor(t)) - .collect()) - } - - async fn get_followers(&self, user: &UserId) -> Result, DomainError> { - let store = self.follows.lock().unwrap(); - let target = SocialIdentity::Local(user.clone()); - Ok(store - .iter() - .filter(|(_, t, state)| *t == target && *state == FollowState::Accepted) - .map(|(f, _, _)| { - let id = SocialIdentity::Local(UserId::from_uuid(*f)); - Self::identity_to_actor(&id) - }) - .collect()) - } - - async fn get_pending_followers(&self, user: &UserId) -> Result, DomainError> { - let store = self.follows.lock().unwrap(); - let target = SocialIdentity::Local(user.clone()); - Ok(store - .iter() - .filter(|(_, t, state)| *t == target && *state == FollowState::Pending) - .map(|(f, _, _)| { - let id = SocialIdentity::Local(UserId::from_uuid(*f)); - Self::identity_to_actor(&id) - }) - .collect()) - } - - async fn get_pending_following(&self, user: &UserId) -> Result, DomainError> { - let store = self.follows.lock().unwrap(); - Ok(store - .iter() - .filter(|(f, _, state)| *f == user.value() && *state == FollowState::Pending) - .map(|(_, t, _)| Self::identity_to_actor(t)) - .collect()) - } - - async fn count_following(&self, user: &UserId) -> Result { - let store = self.follows.lock().unwrap(); - Ok(store - .iter() - .filter(|(f, _, state)| *f == user.value() && *state == FollowState::Accepted) - .count()) - } - - async fn count_followers(&self, user: &UserId) -> Result { - let store = self.follows.lock().unwrap(); - let target = SocialIdentity::Local(user.clone()); - Ok(store - .iter() - .filter(|(_, t, state)| *t == target && *state == FollowState::Accepted) - .count()) - } - - async fn count_pending_followers(&self, user: &UserId) -> Result { - let store = self.follows.lock().unwrap(); - let target = SocialIdentity::Local(user.clone()); - Ok(store - .iter() - .filter(|(_, t, state)| *t == target && *state == FollowState::Pending) - .count()) - } - - async fn get_relation( - &self, - viewer: &UserId, - target: &SocialIdentity, - ) -> Result { - let store = self.follows.lock().unwrap(); - let viewer_identity = SocialIdentity::Local(viewer.clone()); - let state_to_status = |s: &FollowState| match s { - FollowState::Pending => FollowStatus::Pending, - FollowState::Accepted => FollowStatus::Accepted, - }; - Ok(FollowRelation { - following: store - .iter() - .find(|(f, t, _)| *f == viewer.value() && t == target) - .map(|(_, _, s)| state_to_status(s)), - followed_by: store - .iter() - .find(|(f, t, _)| { - SocialIdentity::Local(UserId::from_uuid(*f)) == *target && *t == viewer_identity - }) - .map(|(_, _, s)| state_to_status(s)), - }) - } -} - -#[async_trait] -impl BlockQuery for InMemorySocialRepository { - async fn get_blocked(&self, user: &UserId) -> Result, DomainError> { - let store = self.blocked.lock().unwrap(); - Ok(store - .iter() - .filter(|(b, _)| *b == user.value()) - .map(|(_, t)| Self::identity_to_actor(t)) - .collect()) - } -} diff --git a/crates/domain/src/testing/mod.rs b/crates/domain/src/testing/mod.rs index 8a36fbf8..007b939e 100644 --- a/crates/domain/src/testing/mod.rs +++ b/crates/domain/src/testing/mod.rs @@ -1,15 +1,5 @@ mod factories; mod fakes; -mod in_memory; -mod insights; -mod noops; -mod panics; -mod wrapup; pub use factories::*; pub use fakes::*; -pub use in_memory::*; -pub use insights::*; -pub use noops::*; -pub use panics::*; -pub use wrapup::*; -- 2.49.1 From 5256c6e851ad8cad8dcbaf5389504c33ee301f0d Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:25:36 +0100 Subject: [PATCH 027/104] test: refusing fakes, failure-path tests, shared panic stubs --- .../http-axum/src/extractors/tests.rs | 839 ++---------------- .../adapters/http-axum/src/tests/context.rs | 2 +- crates/adapters/sqlite/tests/cascade_test.rs | 4 +- .../src/diary/tests/edit_review.rs | 24 + .../src/diary/tests/review_logger.rs | 44 + .../src/movies/tests/enrich_movie.rs | 27 +- crates/application/src/tests/worker.rs | 15 +- crates/application/src/watchlist/tests/add.rs | 18 + crates/domain/src/testing/fakes/mod.rs | 2 + crates/domain/src/testing/fakes/noops.rs | 40 +- crates/domain/src/testing/fakes/panics.rs | 280 ++++++ crates/domain/src/testing/fakes/refusing.rs | 85 ++ 12 files changed, 555 insertions(+), 825 deletions(-) create mode 100644 crates/domain/src/testing/fakes/refusing.rs diff --git a/crates/adapters/http-axum/src/extractors/tests.rs b/crates/adapters/http-axum/src/extractors/tests.rs index 271344fc..1795bb7e 100644 --- a/crates/adapters/http-axum/src/extractors/tests.rs +++ b/crates/adapters/http-axum/src/extractors/tests.rs @@ -10,420 +10,28 @@ use axum::{ use bootstrap::Repositories; use domain::{ errors::DomainError, - events::DomainEvent, - models::{ - DiaryEntry, DiaryFilter, EntityType, FeedEntry, GeneratedToken, IndexableDocument, Movie, - Person, PersonCredits, PersonEnrichmentData, PersonId, Review, ReviewHistory, SearchQuery, - SearchResults, UserStats, UserTrends, - collections::{PageParams, Paginated}, - insights::WatchRow, - }, - ports::{ - AuthService, DiaryQuery, EventPublisher, FollowGraphQuery, InsightsQuery, MetadataClient, - MovieCommand, MovieQuery, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, - PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort, StatsRepository, - UserRepository, WatchlistRepository, - }, - testing::InMemoryStore, - value_objects::{ - Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, ReleaseYear, - ReviewId, UserId, + models::GeneratedToken, + ports::{AuthService, FollowGraphQuery, PersonQuery, SearchPort, UserRepository}, + testing::{ + InMemoryStore, 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 std::sync::Arc; use tower::ServiceExt; -// --- Panic stubs (defined once) --- +// --- Local stubs --- -pub struct Panic; - -#[async_trait::async_trait] -impl MovieCommand for Panic { - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { - panic!() - } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { - panic!() - } -} -#[async_trait::async_trait] -impl MovieQuery for Panic { - async fn get_movie_by_external_id( - &self, - _: &ExternalMetadataId, - ) -> Result, DomainError> { - panic!() - } - async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { - panic!() - } - async fn get_movies_by_title_and_year( - &self, - _: &MovieTitle, - _: &ReleaseYear, - ) -> Result, DomainError> { - panic!() - } - async fn existing_external_ids( - &self, - _: &[ExternalMetadataId], - ) -> Result, DomainError> { - panic!() - } - async fn existing_title_year_pairs( - &self, - _: &[(MovieTitle, ReleaseYear)], - ) -> Result, DomainError> { - panic!() - } - async fn list_movies( - &self, - _: &domain::models::collections::PageParams, - _: &domain::models::MovieFilter, - ) -> Result, DomainError> - { - panic!() - } - async fn list_movies_with_external_id( - &self, - ) -> Result, DomainError> { - Ok(vec![]) - } -} -#[async_trait::async_trait] -impl ReviewRepository for Panic { - async fn save_review(&self, _: &Review) -> Result<(), DomainError> { - panic!() - } - async fn get_review_by_id(&self, _: &ReviewId) -> Result, DomainError> { - panic!() - } - async fn update_review(&self, _: &Review) -> Result<(), DomainError> { - panic!() - } - async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { - panic!() - } - async fn get_all_reviews_for_user(&self, _: &UserId) -> Result, DomainError> { - panic!() - } -} -#[async_trait::async_trait] -impl DiaryQuery for Panic { - async fn query_diary(&self, _: &DiaryFilter) -> Result, DomainError> { - panic!() - } - async fn query_activity_feed( - &self, - _: &PageParams, - ) -> Result, DomainError> { - panic!() - } - async fn query_activity_feed_filtered( - &self, - _: &PageParams, - _: &domain::models::FeedSortBy, - _: Option<&str>, - _: Option<&domain::models::FollowingFilter>, - ) -> Result, DomainError> { - panic!() - } - async fn get_review_history(&self, _: &MovieId) -> Result { - panic!() - } - async fn get_user_history(&self, _: &UserId) -> Result, DomainError> { - panic!() - } - fn stream_user_history( - &self, - _: UserId, - ) -> futures::stream::BoxStream<'static, Result> { - panic!() - } - async fn get_movie_stats( - &self, - _: &MovieId, - ) -> Result { - panic!() - } - async fn get_movie_social_feed( - &self, - _: &MovieId, - _: &PageParams, - ) -> Result, DomainError> { - panic!() - } - async fn count_local_posts(&self) -> Result { - panic!() - } -} -#[async_trait::async_trait] -impl StatsRepository for Panic { - async fn get_user_stats(&self, _: &UserId) -> Result { - panic!() - } - async fn get_user_trends(&self, _: &UserId) -> Result { - panic!() - } - async fn count_reviews_in_year(&self, _: &UserId, _: u16) -> Result { - panic!() - } -} -#[async_trait::async_trait] -impl InsightsQuery for Panic { - async fn get_watch_rows(&self, _: &UserId) -> Result, DomainError> { - panic!() - } -} -#[async_trait::async_trait] -impl MetadataClient for Panic { - async fn fetch_movie_metadata( - &self, - _: &domain::models::MetadataSearchCriteria, - ) -> Result { - panic!() - } - async fn get_poster_url( - &self, - _: &ExternalMetadataId, - ) -> Result, DomainError> { - panic!() - } -} -#[async_trait::async_trait] -impl PosterFetcherClient for Panic { - async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result, DomainError> { - panic!() - } -} -#[async_trait::async_trait] -impl ObjectStorage for Panic { - async fn store(&self, _: &str, _: &[u8]) -> Result { - panic!() - } - async fn get(&self, _: &str) -> Result, DomainError> { - panic!() - } - async fn get_stream( - &self, - _: &str, - ) -> Result>, DomainError> - { - panic!() - } - async fn delete(&self, _: &str) -> Result<(), DomainError> { - panic!() - } -} -#[async_trait::async_trait] -impl AuthService for Panic { - async fn generate_token(&self, _: &UserId) -> Result { - panic!() - } - async fn validate_token(&self, _: &str) -> Result { - panic!() - } -} -#[async_trait::async_trait] -impl PasswordHasher for Panic { - async fn hash(&self, _: &str) -> Result { - panic!() - } - async fn verify(&self, _: &str, _: &PasswordHash) -> Result { - panic!() - } -} -#[async_trait::async_trait] -impl UserRepository for Panic { - async fn find_by_email(&self, _: &Email) -> Result, DomainError> { - panic!() - } - async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> { - panic!() - } - async fn find_by_id(&self, _: &UserId) -> Result, DomainError> { - panic!() - } - async fn find_by_username( - &self, - _: &domain::value_objects::Username, - ) -> Result, DomainError> { - panic!() - } - async fn list_with_stats(&self) -> Result, DomainError> { - panic!() - } - async fn update_profile( - &self, - _: &UserId, - _: &domain::models::UserProfile, - ) -> Result<(), DomainError> { - panic!() - } -} -#[async_trait::async_trait] -impl domain::ports::UserProfileFieldsRepository for Panic { - async fn get_fields( - &self, - _: &UserId, - ) -> Result, DomainError> { - panic!() - } - async fn set_fields( - &self, - _: &UserId, - _: Vec, - ) -> Result<(), DomainError> { - panic!() - } -} -#[async_trait::async_trait] -impl EventPublisher for Panic { - async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { - panic!() - } -} -#[async_trait::async_trait] -impl domain::ports::ImportSessionRepository for Panic { - async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { - panic!() - } - async fn get( - &self, - _: &domain::value_objects::ImportSessionId, - _: &UserId, - ) -> Result, DomainError> { - panic!() - } - async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { - panic!() - } - async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> { - panic!() - } - async fn delete_expired(&self) -> Result { - panic!() - } - async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { - panic!() - } -} -#[async_trait::async_trait] -impl domain::ports::ImportProfileRepository for Panic { - async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { - panic!() - } - async fn list_for_user( - &self, - _: &UserId, - ) -> Result, DomainError> { - panic!() - } - async fn get( - &self, - _: &domain::value_objects::ImportProfileId, - _: &UserId, - ) -> Result, DomainError> { - panic!() - } - async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { - panic!() - } -} -#[async_trait::async_trait] -impl WatchlistRepository for Panic { - async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> { - panic!() - } - async fn remove( - &self, - _: &domain::value_objects::UserId, - _: &domain::value_objects::MovieId, - ) -> Result<(), DomainError> { - panic!() - } - async fn remove_if_present( - &self, - _: &domain::value_objects::UserId, - _: &domain::value_objects::MovieId, - ) -> Result { - Ok(false) - } - async fn get_for_user( - &self, - _: &domain::value_objects::UserId, - _: &domain::models::collections::PageParams, - ) -> Result< - domain::models::collections::Paginated, - DomainError, - > { - panic!() - } - async fn contains( - &self, - _: &domain::value_objects::UserId, - _: &domain::value_objects::MovieId, - ) -> Result { - Ok(false) - } -} -#[async_trait::async_trait] -impl domain::ports::MovieProfileRepository for Panic { - async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { - panic!() - } - async fn get_by_movie_id( - &self, - _: &domain::value_objects::MovieId, - ) -> Result, DomainError> { - Ok(None) - } - async fn list_stale( - &self, - ) -> Result, DomainError> { - Ok(vec![]) - } -} -impl domain::ports::DiaryExporter for Panic { - fn stream_entries( - &self, - _stream: futures::stream::BoxStream< - 'static, - Result, - >, - _format: domain::models::ExportFormat, - ) -> futures::stream::BoxStream<'static, Result> - { - panic!("Panic DiaryExporter called") - } -} - -impl domain::ports::DocumentParser for Panic { - fn parse( - &self, - _: &[u8], - _: domain::models::FileFormat, - ) -> Result { - panic!() - } - fn apply_mapping( - &self, - _: &domain::models::ParsedFile, - _: &[domain::models::FieldMapping], - ) -> Vec { - panic!() - } -} - -impl domain::ports::RssFeedRenderer for Panic { - fn render_feed( - &self, - _: &[DiaryEntry], - _: &domain::ports::FeedChannel<'_>, - ) -> Result { - panic!() - } -} +struct PanicReviewLogger; struct RejectingAuth; #[async_trait::async_trait] @@ -437,334 +45,7 @@ impl AuthService for RejectingAuth { } #[async_trait::async_trait] -impl PersonCommand for Panic { - async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { - panic!() - } - async fn backfill_from_credits_batch( - &self, - _batch_size: u32, - ) -> Result<(u64, bool), DomainError> { - panic!() - } - async fn update_enrichment( - &self, - _: &PersonId, - _: &PersonEnrichmentData, - ) -> Result<(), DomainError> { - panic!() - } -} -#[async_trait::async_trait] -impl PersonQuery for Panic { - async fn get_by_id(&self, _: &PersonId) -> Result, DomainError> { - panic!() - } - async fn get_by_external_id( - &self, - _: &domain::models::ExternalPersonId, - ) -> Result, DomainError> { - panic!() - } - async fn get_credits(&self, _: &PersonId) -> Result { - panic!() - } - async fn list_orphaned_persons(&self) -> Result, DomainError> { - panic!() - } - async fn list_page( - &self, - _limit: u32, - _offset: u32, - ) -> Result, DomainError> { - panic!() - } -} -#[async_trait::async_trait] -impl SearchPort for Panic { - async fn search(&self, _: &SearchQuery) -> Result { - panic!() - } -} -#[async_trait::async_trait] -impl SearchCommand for Panic { - async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { - panic!() - } - async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { - panic!() - } -} -#[async_trait::async_trait] -impl domain::ports::RemoteWatchlistRepository for Panic { - async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> { - Ok(()) - } - async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { - Ok(()) - } - async fn get_by_actor_url( - &self, - _: &str, - ) -> Result, DomainError> { - Ok(vec![]) - } - async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { - Ok(()) - } - async fn get_by_derived_uuid( - &self, - _: uuid::Uuid, - ) -> Result, DomainError> { - Ok(vec![]) - } -} - -#[async_trait::async_trait] -impl domain::ports::WatchEventCommand for Panic { - async fn save(&self, _: &domain::models::WatchEvent) -> Result<(), DomainError> { - panic!() - } - async fn update_status( - &self, - _: &domain::value_objects::WatchEventId, - _: domain::models::WatchEventStatus, - ) -> Result<(), DomainError> { - panic!() - } - async fn update_status_batch( - &self, - _: &[domain::value_objects::WatchEventId], - _: domain::models::WatchEventStatus, - ) -> Result { - panic!() - } - async fn delete_non_pending_older_than( - &self, - _: chrono::NaiveDateTime, - ) -> Result { - panic!() - } -} -#[async_trait::async_trait] -impl domain::ports::WatchEventQuery for Panic { - async fn list_pending( - &self, - _: &domain::value_objects::UserId, - ) -> Result, DomainError> { - panic!() - } - async fn get_by_id( - &self, - _: &domain::value_objects::WatchEventId, - ) -> Result, DomainError> { - panic!() - } - async fn get_by_ids( - &self, - _: &[domain::value_objects::WatchEventId], - ) -> Result, DomainError> { - panic!() - } - async fn find_duplicate( - &self, - _: &domain::value_objects::UserId, - _: &str, - _: chrono::NaiveDateTime, - ) -> Result { - panic!() - } -} -#[async_trait::async_trait] -impl domain::ports::WebhookTokenRepository for Panic { - async fn save(&self, _: &domain::models::WebhookToken) -> Result<(), DomainError> { - panic!() - } - async fn find_by_token_hash( - &self, - _: &str, - ) -> Result, DomainError> { - panic!() - } - async fn list_by_user( - &self, - _: &domain::value_objects::UserId, - ) -> Result, DomainError> { - panic!() - } - async fn delete( - &self, - _: &domain::value_objects::WebhookTokenId, - _: &domain::value_objects::UserId, - ) -> Result<(), DomainError> { - panic!() - } - async fn touch_last_used( - &self, - _: &domain::value_objects::WebhookTokenId, - ) -> Result<(), DomainError> { - panic!() - } -} - -#[async_trait::async_trait] -impl domain::ports::WrapUpStatsQuery for Panic { - async fn get_reviews_with_profiles( - &self, - _: &domain::models::wrapup::WrapUpScope, - _: &domain::models::wrapup::DateRange, - ) -> Result, DomainError> { - panic!() - } -} - -#[async_trait::async_trait] -impl domain::ports::WrapUpRepository for Panic { - async fn create(&self, _: &domain::models::wrapup::WrapUpRecord) -> Result<(), DomainError> { - panic!() - } - async fn update_status( - &self, - _: &domain::value_objects::WrapUpId, - _: &domain::models::wrapup::WrapUpStatus, - _: Option<&str>, - ) -> Result<(), DomainError> { - panic!() - } - async fn set_complete( - &self, - _: &domain::value_objects::WrapUpId, - _: &domain::models::wrapup::WrapUpReport, - ) -> Result<(), DomainError> { - panic!() - } - async fn get_by_id( - &self, - _: &domain::value_objects::WrapUpId, - ) -> Result, DomainError> { - panic!() - } - async fn list_for_user( - &self, - _: uuid::Uuid, - ) -> Result, DomainError> { - panic!() - } - async fn list_global(&self) -> Result, DomainError> { - panic!() - } - async fn find_existing( - &self, - _: Option, - _: chrono::NaiveDate, - _: chrono::NaiveDate, - ) -> Result, DomainError> { - panic!() - } - async fn delete(&self, _: &domain::value_objects::WrapUpId) -> Result<(), DomainError> { - panic!() - } - async fn delete_failed_older_than(&self, _: chrono::NaiveDateTime) -> Result { - panic!() - } -} - -#[async_trait::async_trait] -impl domain::ports::GoalCommand for Panic { - async fn save(&self, _: &domain::models::Goal) -> Result<(), DomainError> { - panic!() - } - async fn update(&self, _: &domain::models::Goal) -> Result<(), DomainError> { - panic!() - } - async fn delete( - &self, - _: &domain::value_objects::GoalId, - _: &domain::value_objects::UserId, - ) -> Result<(), DomainError> { - panic!() - } -} - -#[async_trait::async_trait] -impl domain::ports::GoalQuery for Panic { - async fn find_by_user_and_year( - &self, - _: &domain::value_objects::UserId, - _: u16, - ) -> Result, DomainError> { - panic!() - } - async fn list_for_user( - &self, - _: &domain::value_objects::UserId, - ) -> Result, DomainError> { - panic!() - } -} - -#[async_trait::async_trait] -impl domain::ports::UserSettingsRepository for Panic { - async fn get( - &self, - _: &domain::value_objects::UserId, - ) -> Result { - panic!() - } - async fn save(&self, _: &domain::models::UserSettings) -> Result<(), DomainError> { - panic!() - } -} - -#[async_trait::async_trait] -impl domain::ports::RemoteGoalRepository for Panic { - async fn save(&self, _: domain::models::RemoteGoalEntry) -> Result<(), DomainError> { - panic!() - } - async fn update_by_ap_id(&self, _: &str, _: u32, _: u32) -> Result<(), DomainError> { - panic!() - } - async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { - panic!() - } - async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { - panic!() - } - async fn get_by_actor_url( - &self, - _: &str, - ) -> Result, DomainError> { - panic!() - } -} - -#[async_trait::async_trait] -impl domain::ports::RefreshSessionRepository for Panic { - async fn create(&self, _: &domain::models::RefreshSession) -> Result<(), DomainError> { - panic!() - } - async fn get_by_token( - &self, - _: &str, - ) -> Result, DomainError> { - panic!() - } - async fn revoke(&self, _: &str) -> Result<(), DomainError> { - panic!() - } - async fn revoke_all_for_user( - &self, - _: &domain::value_objects::UserId, - ) -> Result<(), DomainError> { - panic!() - } - async fn delete_expired(&self) -> Result { - panic!() - } -} - -#[async_trait::async_trait] -impl application::ports::ReviewLogger for Panic { +impl application::ports::ReviewLogger for PanicReviewLogger { async fn log_review( &self, _: application::diary::commands::LogReviewCommand, @@ -773,43 +54,41 @@ impl application::ports::ReviewLogger for Panic { } } -// --- Repositories factory — `Panic` stubs everywhere --- +// --- Repositories factory — `Panic*` stubs everywhere --- pub(crate) fn test_repositories() -> Repositories { - let repo = Arc::new(Panic); - Repositories { - movie_command: Arc::clone(&repo) as _, - movie_query: Arc::clone(&repo) as _, - review: Arc::clone(&repo) as _, - diary: Arc::clone(&repo) as _, - stats: Arc::clone(&repo) as _, - user: Arc::clone(&repo) as _, - import_session: Arc::clone(&repo) as _, - import_profile: Arc::clone(&repo) as _, - movie_profile: Arc::clone(&repo) as _, - watchlist: Arc::clone(&repo) as _, - watch_event_command: Arc::clone(&repo) as _, - watch_event_query: Arc::clone(&repo) as _, - webhook_token: Arc::clone(&repo) as _, - profile_fields: Arc::clone(&repo) as _, - person_command: Arc::clone(&repo) as _, - person_query: Arc::clone(&repo) as _, - search_port: Arc::clone(&repo) as _, - search_command: Arc::clone(&repo) as _, - remote_watchlist: Arc::clone(&repo) as _, + 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::clone(&repo) as _, - wrapup_repo: Arc::clone(&repo) as _, - insights: Arc::clone(&repo) as _, - goal_command: Arc::clone(&repo) as _, - goal_query: Arc::clone(&repo) as _, - user_settings: Arc::clone(&repo) as _, - remote_goal: Arc::clone(&repo) as _, - refresh_session: Arc::clone(&repo) 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, } } @@ -823,18 +102,16 @@ pub(crate) fn build_test_state_from( repos: Repositories, auth_service: Arc, ) -> crate::state::AppState { - let repo = Arc::new(Panic); - let services = application::Services { auth: auth_service, - password_hasher: Arc::clone(&repo) as _, - metadata: Arc::clone(&repo) as _, - poster_fetcher: Arc::clone(&repo) as _, - object_storage: Arc::clone(&repo) as _, - event_publisher: Arc::clone(&repo) as _, - diary_exporter: Arc::clone(&repo) as _, - document_parser: Arc::clone(&repo) as _, - review_logger: Arc::clone(&repo) as _, + 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, }; @@ -867,7 +144,7 @@ pub(crate) fn build_test_state_from( #[cfg(feature = "federation")] ap_blocklist: Arc::new(domain::ports::noop::NoopInstanceBlocklist), }, - rss_renderer: Arc::new(Panic), + rss_renderer: Arc::new(PanicRssFeedRenderer), } } @@ -875,13 +152,13 @@ pub fn make_test_state(auth_service: Arc) -> crate::state::AppS build_test_state_from(test_repositories(), auth_service) } -/// Reusable default test `AppState`: `Panic` stubs everywhere, real prebuilt `deps`. +/// 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(crate) fn test_app_state() -> crate::state::AppState { - make_test_state(Arc::new(Panic)) + make_test_state(Arc::new(PanicAuthService)) } /// Test state with `person_query` overridden BEFORE `build_deps` runs, so the @@ -892,7 +169,7 @@ pub(crate) fn test_app_state_with_person_query( ) -> crate::state::AppState { let mut repos = test_repositories(); repos.person_query = person_query; - build_test_state_from(repos, Arc::new(Panic)) + build_test_state_from(repos, Arc::new(PanicAuthService)) } /// Test state with `search_port` overridden BEFORE `build_deps` runs, so the @@ -903,7 +180,7 @@ pub(crate) fn test_app_state_with_search_port( ) -> crate::state::AppState { let mut repos = test_repositories(); repos.search_port = search_port; - build_test_state_from(repos, Arc::new(Panic)) + build_test_state_from(repos, Arc::new(PanicAuthService)) } /// Test state with `follow_graph` overridden BEFORE `build_deps` runs, so the diff --git a/crates/adapters/http-axum/src/tests/context.rs b/crates/adapters/http-axum/src/tests/context.rs index f555730e..b7896e4d 100644 --- a/crates/adapters/http-axum/src/tests/context.rs +++ b/crates/adapters/http-axum/src/tests/context.rs @@ -8,7 +8,7 @@ fn prebuilt_deps_share_port_instances_with_repos() { let state = crate::extractors::tests::build_test_state_from( repos, - Arc::new(crate::extractors::tests::Panic), + Arc::new(domain::testing::PanicAuthService), ); assert!( diff --git a/crates/adapters/sqlite/tests/cascade_test.rs b/crates/adapters/sqlite/tests/cascade_test.rs index 4e671fd4..44c7d2e5 100644 --- a/crates/adapters/sqlite/tests/cascade_test.rs +++ b/crates/adapters/sqlite/tests/cascade_test.rs @@ -27,8 +27,8 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; const REMOTE_ACTOR: &str = "https://remote.example/users/ann"; -/// Same connect options `sqlite::wire` builds from a `sqlite:` URL (sqlx turns -/// `foreign_keys` on by default), against a private in-memory database. +/// Private in-memory database with plain `SqliteConnectOptions`; relies on +/// sqlx's default `foreign_keys = ON`, which production also depends on. async fn migrated_pool() -> SqlitePool { let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); let pool = SqlitePoolOptions::new() diff --git a/crates/application/src/diary/tests/edit_review.rs b/crates/application/src/diary/tests/edit_review.rs index d0a92afc..e47e3d7b 100644 --- a/crates/application/src/diary/tests/edit_review.rs +++ b/crates/application/src/diary/tests/edit_review.rs @@ -129,3 +129,27 @@ async fn edit_sets_watch_medium() { let updated = reviews.get_review_by_id(&review_id).await.unwrap().unwrap(); assert_eq!(updated.watch_medium(), Some(&WatchMedium::Cinema)); } + +#[tokio::test] +async fn editing_a_review_fails_when_the_store_refuses() { + let (_, events, review_id, user_id) = setup().await; + let deps = EditReviewDeps { + review: Arc::new(domain::testing::RefusingReviewRepository), + event_publisher: Arc::clone(&events) as _, + }; + + let result = edit_review::execute( + &deps, + EditReviewCommand { + review_id: review_id.value(), + requesting_user_id: user_id.value(), + ..edit_review_command(5) + }, + ) + .await; + + assert!(matches!( + result, + Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused" + )); +} diff --git a/crates/application/src/diary/tests/review_logger.rs b/crates/application/src/diary/tests/review_logger.rs index 0077b572..6e070ab1 100644 --- a/crates/application/src/diary/tests/review_logger.rs +++ b/crates/application/src/diary/tests/review_logger.rs @@ -220,3 +220,47 @@ async fn publishes_movie_discovered_for_new_movie_with_external_id() { "should publish MovieEnrichmentRequested" ); } + +#[tokio::test] +async fn logging_a_review_fails_when_the_review_store_refuses() { + let movies = InMemoryStore::new(); + let logger = DefaultReviewLogger::new( + Arc::clone(&movies) as _, + Arc::clone(&movies) as _, + Arc::new(domain::testing::RefusingReviewRepository), + Arc::clone(&movies) as _, + Arc::new(FakeMetadataClient), + NoopEventPublisher::new(), + ); + + let result = logger + .log_review(log_review_command_manual(Uuid::new_v4(), "Test Film", 2024)) + .await; + + assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused")); +} + +#[tokio::test] +async fn logging_a_review_when_event_publishing_fails_returns_the_error_but_keeps_the_saved_review() +{ + let movies = InMemoryStore::new(); + let logger = DefaultReviewLogger::new( + Arc::clone(&movies) as _, + Arc::clone(&movies) as _, + Arc::clone(&movies) as _, + Arc::clone(&movies) as _, + Arc::new(FakeMetadataClient), + Arc::new(domain::testing::RefusingEventPublisher), + ); + + let result = logger + .log_review(log_review_command_manual(Uuid::new_v4(), "Test Film", 2024)) + .await; + + assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused")); + assert_eq!( + movies.review_count(), + 1, + "review is saved before publishing" + ); +} diff --git a/crates/application/src/movies/tests/enrich_movie.rs b/crates/application/src/movies/tests/enrich_movie.rs index 89d7c59f..a6ef9eab 100644 --- a/crates/application/src/movies/tests/enrich_movie.rs +++ b/crates/application/src/movies/tests/enrich_movie.rs @@ -1,4 +1,4 @@ -use domain::testing::test_movie_with_year; +use domain::testing::{NoopPersonCommand, test_movie_with_year}; use std::sync::Arc; use chrono::Utc; @@ -61,31 +61,6 @@ async fn stores_profile_and_indexes() { assert_eq!(profile_repo.movie_profile_count(), 1); } -struct NoopPersonCommand; - -#[async_trait::async_trait] -impl domain::ports::PersonCommand for NoopPersonCommand { - async fn upsert_batch( - &self, - _: &[domain::models::Person], - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } - async fn backfill_from_credits_batch( - &self, - _: u32, - ) -> Result<(u64, bool), domain::errors::DomainError> { - Ok((0, false)) - } - async fn update_enrichment( - &self, - _: &domain::models::PersonId, - _: &domain::models::PersonEnrichmentData, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } -} - #[tokio::test] async fn extracts_and_indexes_persons() { let movie_repo = InMemoryStore::new(); diff --git a/crates/application/src/tests/worker.rs b/crates/application/src/tests/worker.rs index 2e156a7d..01729a65 100644 --- a/crates/application/src/tests/worker.rs +++ b/crates/application/src/tests/worker.rs @@ -1,5 +1,6 @@ use super::*; use async_trait::async_trait; +use domain::testing::NoopAckHandle; use domain::value_objects::{ExternalMetadataId, MovieId}; use domain::{ errors::DomainError, @@ -8,18 +9,6 @@ use domain::{ use futures::{stream, stream::BoxStream}; use std::sync::{Arc, Mutex}; -struct NoopAck; - -#[async_trait] -impl AckHandle for NoopAck { - async fn ack(&self) -> Result<(), DomainError> { - Ok(()) - } - async fn nack(&self) -> Result<(), DomainError> { - Ok(()) - } -} - struct TrackingAck { acked: Arc>, nacked: Arc>, @@ -47,7 +36,7 @@ impl EventConsumer for VecConsumer { .events .iter() .cloned() - .map(|e| Ok(EventEnvelope::new(e, Box::new(NoopAck)))) + .map(|e| Ok(EventEnvelope::new(e, Box::new(NoopAckHandle)))) .collect(); Box::pin(stream::iter(envelopes)) } diff --git a/crates/application/src/watchlist/tests/add.rs b/crates/application/src/watchlist/tests/add.rs index e245d9d4..4b664c79 100644 --- a/crates/application/src/watchlist/tests/add.rs +++ b/crates/application/src/watchlist/tests/add.rs @@ -90,3 +90,21 @@ async fn test_add_to_watchlist_movie_not_found_by_id() { assert!(add::execute(&deps, cmd).await.is_err()); } + +#[tokio::test] +async fn adding_the_same_movie_to_a_watchlist_twice_keeps_a_single_entry_and_succeeds() { + let movies = InMemoryStore::new(); + let movie = test_movie_with_year("Heat", 1995); + let movie_uuid = movie.id().value(); + let user_id = uuid::Uuid::new_v4(); + movies.upsert_movie(&movie).await.unwrap(); + let deps = make_deps(Arc::clone(&movies), Arc::clone(&movies)); + + for _ in 0..2 { + add::execute(&deps, add_to_watchlist_command(user_id, movie_uuid)) + .await + .expect("second add must not error"); + } + + assert_eq!(movies.watchlist_count(), 1); +} diff --git a/crates/domain/src/testing/fakes/mod.rs b/crates/domain/src/testing/fakes/mod.rs index 59a15eaf..00f1fe69 100644 --- a/crates/domain/src/testing/fakes/mod.rs +++ b/crates/domain/src/testing/fakes/mod.rs @@ -1,6 +1,7 @@ mod insights; mod noops; mod panics; +mod refusing; mod store; mod store_goal; mod store_import; @@ -19,6 +20,7 @@ mod wrapup; pub use insights::*; pub use noops::*; pub use panics::*; +pub use refusing::*; pub use store::{ InMemoryStore, MOVIE_CASCADE_TABLES, USER_CASCADE_TABLES, USER_CASCADE_TABLES_OUTSIDE_STORE, }; diff --git a/crates/domain/src/testing/fakes/noops.rs b/crates/domain/src/testing/fakes/noops.rs index c3d9a851..19beaf94 100644 --- a/crates/domain/src/testing/fakes/noops.rs +++ b/crates/domain/src/testing/fakes/noops.rs @@ -4,8 +4,9 @@ use async_trait::async_trait; use crate::{ errors::DomainError, - events::DomainEvent, - ports::{EventPublisher, ObjectStorage}, + events::{AckHandle, DomainEvent}, + models::{Person, PersonEnrichmentData, PersonId}, + ports::{EventPublisher, ObjectStorage, PersonCommand}, value_objects::UserId, }; @@ -145,3 +146,38 @@ impl crate::ports::RemoteGoalRepository for NoopRemoteGoalRepository { Ok(vec![]) } } + +// ── NoopAckHandle ───────────────────────────────────────────────────────────── + +pub struct NoopAckHandle; + +#[async_trait] +impl AckHandle for NoopAckHandle { + async fn ack(&self) -> Result<(), DomainError> { + Ok(()) + } + async fn nack(&self) -> Result<(), DomainError> { + Ok(()) + } +} + +// ── NoopPersonCommand ───────────────────────────────────────────────────────── + +pub struct NoopPersonCommand; + +#[async_trait] +impl PersonCommand for NoopPersonCommand { + async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { + Ok(()) + } + async fn backfill_from_credits_batch(&self, _: u32) -> Result<(u64, bool), DomainError> { + Ok((0, false)) + } + async fn update_enrichment( + &self, + _: &PersonId, + _: &PersonEnrichmentData, + ) -> Result<(), DomainError> { + Ok(()) + } +} diff --git a/crates/domain/src/testing/fakes/panics.rs b/crates/domain/src/testing/fakes/panics.rs index a427a85c..1039d599 100644 --- a/crates/domain/src/testing/fakes/panics.rs +++ b/crates/domain/src/testing/fakes/panics.rs @@ -20,6 +20,16 @@ use crate::{ value_objects::{ImportProfileId, ImportSessionId, MovieId, PosterUrl, UserId}, }; +use crate::{ + events::DomainEvent, + models::{GeneratedToken, Movie, Review}, + ports::{ + AuthService, EventPublisher, MetadataClient, MovieCommand, MovieQuery, ObjectStorage, + PasswordHasher, ReviewRepository, UserRepository, + }, + value_objects::{Email, ExternalMetadataId, MovieTitle, PasswordHash, ReleaseYear, ReviewId}, +}; + // ── PanicDiaryQuery ─────────────────────────────────────────────────────────── pub struct PanicDiaryQuery; @@ -467,3 +477,273 @@ impl crate::ports::WebhookTokenRepository for PanicWebhookTokenRepository { panic!("PanicWebhookTokenRepository called") } } +pub struct PanicMovieCommand; + +#[async_trait] +impl MovieCommand for PanicMovieCommand { + async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { + panic!("PanicMovieCommand called") + } + async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { + panic!("PanicMovieCommand called") + } +} + +pub struct PanicMovieQuery; + +#[async_trait] +impl MovieQuery for PanicMovieQuery { + async fn get_movie_by_external_id( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + panic!("PanicMovieQuery called") + } + async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { + panic!("PanicMovieQuery called") + } + async fn get_movies_by_title_and_year( + &self, + _: &MovieTitle, + _: &ReleaseYear, + ) -> Result, DomainError> { + panic!("PanicMovieQuery called") + } + async fn existing_external_ids( + &self, + _: &[ExternalMetadataId], + ) -> Result, DomainError> { + panic!("PanicMovieQuery called") + } + async fn existing_title_year_pairs( + &self, + _: &[(MovieTitle, ReleaseYear)], + ) -> Result, DomainError> { + panic!("PanicMovieQuery called") + } + async fn list_movies( + &self, + _: &crate::models::collections::PageParams, + _: &crate::models::MovieFilter, + ) -> Result, DomainError> + { + panic!("PanicMovieQuery called") + } + async fn list_movies_with_external_id(&self) -> Result, DomainError> { + Ok(vec![]) + } +} + +pub struct PanicReviewRepository; + +#[async_trait] +impl ReviewRepository for PanicReviewRepository { + async fn save_review(&self, _: &Review) -> Result<(), DomainError> { + panic!("PanicReviewRepository called") + } + async fn get_review_by_id(&self, _: &ReviewId) -> Result, DomainError> { + panic!("PanicReviewRepository called") + } + async fn update_review(&self, _: &Review) -> Result<(), DomainError> { + panic!("PanicReviewRepository called") + } + async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { + panic!("PanicReviewRepository called") + } + async fn get_all_reviews_for_user(&self, _: &UserId) -> Result, DomainError> { + panic!("PanicReviewRepository called") + } +} + +pub struct PanicMetadataClient; + +#[async_trait] +impl MetadataClient for PanicMetadataClient { + async fn fetch_movie_metadata( + &self, + _: &crate::models::MetadataSearchCriteria, + ) -> Result { + panic!("PanicMetadataClient called") + } + async fn get_poster_url( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + panic!("PanicMetadataClient called") + } +} + +pub struct PanicObjectStorage; + +#[async_trait] +impl ObjectStorage for PanicObjectStorage { + async fn store(&self, _: &str, _: &[u8]) -> Result { + panic!("PanicObjectStorage called") + } + async fn get(&self, _: &str) -> Result, DomainError> { + panic!("PanicObjectStorage called") + } + async fn get_stream( + &self, + _: &str, + ) -> Result>, DomainError> + { + panic!("PanicObjectStorage called") + } + async fn delete(&self, _: &str) -> Result<(), DomainError> { + panic!("PanicObjectStorage called") + } +} + +pub struct PanicAuthService; + +#[async_trait] +impl AuthService for PanicAuthService { + async fn generate_token(&self, _: &UserId) -> Result { + panic!("PanicAuthService called") + } + async fn validate_token(&self, _: &str) -> Result { + panic!("PanicAuthService called") + } +} + +pub struct PanicPasswordHasher; + +#[async_trait] +impl PasswordHasher for PanicPasswordHasher { + async fn hash(&self, _: &str) -> Result { + panic!("PanicPasswordHasher called") + } + async fn verify(&self, _: &str, _: &PasswordHash) -> Result { + panic!("PanicPasswordHasher called") + } +} + +pub struct PanicUserRepository; + +#[async_trait] +impl UserRepository for PanicUserRepository { + async fn find_by_email(&self, _: &Email) -> Result, DomainError> { + panic!("PanicUserRepository called") + } + async fn save(&self, _: &crate::models::User) -> Result<(), DomainError> { + panic!("PanicUserRepository called") + } + async fn find_by_id(&self, _: &UserId) -> Result, DomainError> { + panic!("PanicUserRepository called") + } + async fn find_by_username( + &self, + _: &crate::value_objects::Username, + ) -> Result, DomainError> { + panic!("PanicUserRepository called") + } + async fn list_with_stats(&self) -> Result, DomainError> { + panic!("PanicUserRepository called") + } + async fn update_profile( + &self, + _: &UserId, + _: &crate::models::UserProfile, + ) -> Result<(), DomainError> { + panic!("PanicUserRepository called") + } +} + +pub struct PanicEventPublisher; + +#[async_trait] +impl EventPublisher for PanicEventPublisher { + async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { + panic!("PanicEventPublisher called") + } +} + +pub struct PanicRssFeedRenderer; + +impl crate::ports::RssFeedRenderer for PanicRssFeedRenderer { + fn render_feed( + &self, + _: &[DiaryEntry], + _: &crate::ports::FeedChannel<'_>, + ) -> Result { + panic!("PanicRssFeedRenderer called") + } +} + +pub struct PanicGoalCommand; + +#[async_trait] +impl crate::ports::GoalCommand for PanicGoalCommand { + async fn save(&self, _: &crate::models::Goal) -> Result<(), DomainError> { + panic!("PanicGoalCommand called") + } + async fn update(&self, _: &crate::models::Goal) -> Result<(), DomainError> { + panic!("PanicGoalCommand called") + } + async fn delete( + &self, + _: &crate::value_objects::GoalId, + _: &crate::value_objects::UserId, + ) -> Result<(), DomainError> { + panic!("PanicGoalCommand called") + } +} + +pub struct PanicGoalQuery; + +#[async_trait] +impl crate::ports::GoalQuery for PanicGoalQuery { + async fn find_by_user_and_year( + &self, + _: &crate::value_objects::UserId, + _: u16, + ) -> Result, DomainError> { + panic!("PanicGoalQuery called") + } + async fn list_for_user( + &self, + _: &crate::value_objects::UserId, + ) -> Result, DomainError> { + panic!("PanicGoalQuery called") + } +} + +pub struct PanicUserSettingsRepository; + +#[async_trait] +impl crate::ports::UserSettingsRepository for PanicUserSettingsRepository { + async fn get( + &self, + _: &crate::value_objects::UserId, + ) -> Result { + panic!("PanicUserSettingsRepository called") + } + async fn save(&self, _: &crate::models::UserSettings) -> Result<(), DomainError> { + panic!("PanicUserSettingsRepository called") + } +} + +pub struct PanicRemoteGoalRepository; + +#[async_trait] +impl crate::ports::RemoteGoalRepository for PanicRemoteGoalRepository { + async fn save(&self, _: crate::models::RemoteGoalEntry) -> Result<(), DomainError> { + panic!("PanicRemoteGoalRepository called") + } + async fn update_by_ap_id(&self, _: &str, _: u32, _: u32) -> Result<(), DomainError> { + panic!("PanicRemoteGoalRepository called") + } + async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { + panic!("PanicRemoteGoalRepository called") + } + async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { + panic!("PanicRemoteGoalRepository called") + } + async fn get_by_actor_url( + &self, + _: &str, + ) -> Result, DomainError> { + panic!("PanicRemoteGoalRepository called") + } +} diff --git a/crates/domain/src/testing/fakes/refusing.rs b/crates/domain/src/testing/fakes/refusing.rs new file mode 100644 index 00000000..4549d3d4 --- /dev/null +++ b/crates/domain/src/testing/fakes/refusing.rs @@ -0,0 +1,85 @@ +//! Fakes whose every call fails with an infrastructure error, for failure-path tests. + +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + events::DomainEvent, + models::{ + Movie, Review, WatchlistEntry, WatchlistWithMovie, + collections::{PageParams, Paginated}, + }, + ports::{EventPublisher, MovieCommand, ReviewRepository, WatchlistRepository}, + value_objects::{MovieId, ReviewId, UserId}, +}; + +fn refused() -> DomainError { + DomainError::InfrastructureError("refused".into()) +} + +pub struct RefusingReviewRepository; + +#[async_trait] +impl ReviewRepository for RefusingReviewRepository { + async fn save_review(&self, _: &Review) -> Result<(), DomainError> { + Err(refused()) + } + async fn get_review_by_id(&self, _: &ReviewId) -> Result, DomainError> { + Err(refused()) + } + async fn update_review(&self, _: &Review) -> Result<(), DomainError> { + Err(refused()) + } + async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { + Err(refused()) + } + async fn get_all_reviews_for_user(&self, _: &UserId) -> Result, DomainError> { + Err(refused()) + } +} + +pub struct RefusingMovieCommand; + +#[async_trait] +impl MovieCommand for RefusingMovieCommand { + async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { + Err(refused()) + } + async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { + Err(refused()) + } +} + +pub struct RefusingWatchlistRepository; + +#[async_trait] +impl WatchlistRepository for RefusingWatchlistRepository { + async fn add(&self, _: &WatchlistEntry) -> Result<(), DomainError> { + Err(refused()) + } + async fn remove(&self, _: &UserId, _: &MovieId) -> Result<(), DomainError> { + Err(refused()) + } + async fn remove_if_present(&self, _: &UserId, _: &MovieId) -> Result { + Err(refused()) + } + async fn get_for_user( + &self, + _: &UserId, + _: &PageParams, + ) -> Result, DomainError> { + Err(refused()) + } + async fn contains(&self, _: &UserId, _: &MovieId) -> Result { + Err(refused()) + } +} + +pub struct RefusingEventPublisher; + +#[async_trait] +impl EventPublisher for RefusingEventPublisher { + async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { + Err(refused()) + } +} -- 2.49.1 From 1e6e9ddf6b5c67d467e6d9d515de38f5451cc280 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:30:40 +0100 Subject: [PATCH 028/104] test: test app from bootstrap, prune wiring tests --- Cargo.lock | 13 - crates/bootstrap/Cargo.toml | 1 - crates/bootstrap/src/lib.rs | 6 +- crates/bootstrap/src/tests/build.rs | 1074 -------------------- crates/bootstrap/src/worker.rs | 27 +- crates/bootstrap/tests/api_test.rs | 390 +++++++ crates/bootstrap/tests/bootstrap_test.rs | 69 ++ crates/bootstrap/tests/route_smoke_test.rs | 135 +-- crates/bootstrap/tests/support/mod.rs | 162 +++ crates/server/Cargo.toml | 15 - crates/server/tests/api_test.rs | 844 --------------- 11 files changed, 650 insertions(+), 2086 deletions(-) delete mode 100644 crates/bootstrap/src/tests/build.rs create mode 100644 crates/bootstrap/tests/api_test.rs create mode 100644 crates/bootstrap/tests/bootstrap_test.rs create mode 100644 crates/bootstrap/tests/support/mod.rs delete mode 100644 crates/server/tests/api_test.rs diff --git a/Cargo.lock b/Cargo.lock index 754ef1e4..49d019f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4929,25 +4929,12 @@ name = "server" version = "0.1.0" dependencies = [ "anyhow", - "application", - "async-trait", "axum", "bootstrap", - "bytes", "config", - "domain", "dotenvy", - "futures", - "http-axum", - "http-body-util", - "rss 0.1.0", - "serde_json", - "sqlite", - "sqlx", "tokio", - "tower", "tracing", - "uuid", ] [[package]] diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index c9e21232..bb15f275 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -43,7 +43,6 @@ sqlite-federation = { workspace = true, optional = true } [dev-dependencies] chrono = { workspace = true } -domain = { workspace = true, features = ["test-helpers"] } uuid = { workspace = true } serde_json = { workspace = true } tower = { version = "0.5", features = ["util"] } diff --git a/crates/bootstrap/src/lib.rs b/crates/bootstrap/src/lib.rs index 12533284..5ac674ef 100644 --- a/crates/bootstrap/src/lib.rs +++ b/crates/bootstrap/src/lib.rs @@ -21,8 +21,4 @@ pub use event_bus::EventBusBackend; pub use repositories::Repositories; pub use server::{ServerApp, build_server, build_server_on}; pub use tracing_setup::init_tracing; -pub use worker::{WorkerApp, build_worker}; - -#[cfg(test)] -#[path = "tests/build.rs"] -mod build_tests; +pub use worker::{WorkerApp, build_worker, build_worker_on}; diff --git a/crates/bootstrap/src/tests/build.rs b/crates/bootstrap/src/tests/build.rs deleted file mode 100644 index f88cf6b8..00000000 --- a/crates/bootstrap/src/tests/build.rs +++ /dev/null @@ -1,1074 +0,0 @@ -use std::sync::Arc; - -use application::config::AppConfig; -use domain::testing::{ - FakeAuthService, FakeDocumentParser, FakeMetadataClient, FakePasswordHasher, FakePersonQuery, - FakePosterFetcher, FakeSearchCommand, FakeSearchPort, FakeStatsRepository, - InMemoryInsightsQuery, InMemoryStore, InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, - NoopEventPublisher, NoopFederationAdminQuery, NoopObjectStorage, NoopRemoteGoalRepository, - NoopRemoteWatchlistRepository, PanicDiaryExporter, PanicPersonCommand, -}; - -use crate::{DatabaseOutput, Repositories, build::build_deps, build::build_worker_deps}; - -struct NoopReviewLogger; - -#[async_trait::async_trait] -impl application::ports::ReviewLogger for NoopReviewLogger { - async fn log_review( - &self, - _cmd: application::diary::commands::LogReviewCommand, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } -} - -struct NoopMovieDeduplicator; - -#[async_trait::async_trait] -impl domain::ports::MovieDeduplicator for NoopMovieDeduplicator { - async fn merge_into_canonical( - &self, - _old_id: &domain::value_objects::MovieId, - _canonical: &domain::models::Movie, - ) -> Result { - Ok(0) - } -} - -struct NoopLocalApContentQuery; - -#[async_trait::async_trait] -impl domain::ports::LocalApContentQuery for NoopLocalApContentQuery { - async fn get_local_watchlist_for_user( - &self, - _user_id: &domain::value_objects::UserId, - ) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } - - async fn get_local_reviews_for_movie( - &self, - _movie_id: &domain::value_objects::MovieId, - ) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } - - async fn get_local_reviews_page( - &self, - _user_id: &domain::value_objects::UserId, - _before: Option, - _limit: usize, - ) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } -} - -struct NoopImageRefCommand; - -#[async_trait::async_trait] -impl domain::ports::ImageRefCommand for NoopImageRefCommand { - async fn swap( - &self, - _old_key: &str, - _new_key: &str, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } -} - -struct NoopImageRefQuery; - -#[async_trait::async_trait] -impl domain::ports::ImageRefQuery for NoopImageRefQuery { - async fn list_keys(&self) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } -} - -/// `DatabaseOutput::follow_command`/`follow_query` need a fake independent of -/// `InMemoryStore` (domain::testing), which implements -/// `SocialCommand`/`FollowGraphQuery`/`BlockQuery` — the composite ports these -/// two feed, one level up — so it cannot stand in as their dependency. These -/// fixture fields are never exercised by `build_deps`/`build_worker_deps` -/// (neither wires them further), so the bodies just need to type-check. -struct NoopFollowCommand; - -#[async_trait::async_trait] -impl domain::ports::FollowCommand for NoopFollowCommand { - async fn add_follow( - &self, - _follower_id: uuid::Uuid, - _target_actor_url: &str, - _status: domain::value_objects::FollowStatus, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } - - async fn update_follow_status( - &self, - _follower_id: uuid::Uuid, - _target_actor_url: &str, - _status: domain::value_objects::FollowStatus, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } - - async fn remove_follow( - &self, - _follower_id: uuid::Uuid, - _target_actor_url: &str, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } - - async fn add_follower( - &self, - _local_user_id: uuid::Uuid, - _follower_actor_url: &str, - _status: domain::value_objects::FollowStatus, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } - - async fn update_follower_status( - &self, - _local_user_id: uuid::Uuid, - _follower_actor_url: &str, - _status: domain::value_objects::FollowStatus, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } - - async fn remove_follower_record( - &self, - _local_user_id: uuid::Uuid, - _follower_actor_url: &str, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } -} - -struct NoopFollowQuery; - -#[async_trait::async_trait] -impl domain::ports::FollowQuery for NoopFollowQuery { - async fn get_following( - &self, - _user_id: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } - - async fn get_followers( - &self, - _user_id: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } - - async fn get_pending_followers( - &self, - _user_id: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } - - async fn get_pending_following( - &self, - _user_id: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } - - async fn count_following( - &self, - _user_id: uuid::Uuid, - ) -> Result { - Ok(0) - } - - async fn count_followers( - &self, - _user_id: uuid::Uuid, - ) -> Result { - Ok(0) - } - - async fn count_pending_followers( - &self, - _user_id: uuid::Uuid, - ) -> Result { - Ok(0) - } - - async fn get_relation( - &self, - _viewer_id: uuid::Uuid, - _target_actor_url: &str, - ) -> Result { - // Empty/no-edge, matching every sibling method's empty-success shape above — - // unlike them this isn't exercised by any test, so there's no behavior to pin, - // just consistency for a future reader. - Ok(domain::value_objects::FollowRelation { - following: None, - followed_by: None, - }) - } -} - -fn test_repositories() -> Repositories { - let movies = InMemoryStore::new(); - let watch_events = Arc::clone(&movies); - let goals = Arc::clone(&movies); - let social = Arc::clone(&movies); - - Repositories { - movie_command: Arc::clone(&movies) as _, - movie_query: movies as _, - review: InMemoryStore::new(), - diary: domain::testing::FakeDiaryQuery::new(), - stats: FakeStatsRepository::new(), - user: InMemoryStore::new(), - import_session: InMemoryStore::new(), - import_profile: InMemoryStore::new(), - movie_profile: InMemoryStore::new(), - watchlist: InMemoryStore::new(), - watch_event_command: Arc::clone(&watch_events) as _, - watch_event_query: watch_events as _, - webhook_token: InMemoryStore::new(), - person_command: Arc::new(PanicPersonCommand), - person_query: Arc::new(FakePersonQuery), - search_port: Arc::new(FakeSearchPort), - search_command: Arc::new(FakeSearchCommand), - profile_fields: InMemoryStore::new(), - remote_watchlist: Arc::new(NoopRemoteWatchlistRepository), - social_command: Arc::clone(&social) as _, - follow_graph: Arc::clone(&social) as _, - block_query: Arc::clone(&social) as _, - federation_admin: Arc::new(NoopFederationAdminQuery), - wrapup_stats: InMemoryWrapUpStatsQuery::new(), - wrapup_repo: InMemoryWrapUpRepository::new(), - insights: InMemoryInsightsQuery::new(), - goal_command: Arc::clone(&goals) as _, - goal_query: goals as _, - user_settings: InMemoryStore::new(), - remote_goal: Arc::new(NoopRemoteGoalRepository), - refresh_session: InMemoryStore::new(), - federated_profile: None, - } -} - -/// A lazily-connected in-memory sqlite pool — never actually opened in this test, -/// just a value to satisfy `DatabaseOutput::db_pool`. -fn test_db_pool() -> crate::DbPool { - let pool = sqlx::sqlite::SqlitePoolOptions::new() - .connect_lazy("sqlite::memory:") - .expect("lazy sqlite pool"); - crate::DbPool::Sqlite(pool) -} - -fn test_database_output() -> DatabaseOutput { - let movies = InMemoryStore::new(); - let watch_events = Arc::clone(&movies); - let goals = Arc::clone(&movies); - let user_settings = Arc::clone(&movies); - - DatabaseOutput { - movie_command: Arc::clone(&movies) as _, - movie_query: movies as _, - review: InMemoryStore::new(), - diary: domain::testing::FakeDiaryQuery::new(), - stats: FakeStatsRepository::new(), - user: InMemoryStore::new(), - import_session: InMemoryStore::new(), - import_profile: InMemoryStore::new(), - movie_profile: InMemoryStore::new(), - watchlist: InMemoryStore::new(), - watch_event_command: Arc::clone(&watch_events) as _, - watch_event_query: watch_events as _, - webhook_token: InMemoryStore::new(), - person_command: Arc::new(PanicPersonCommand), - person_query: Arc::new(FakePersonQuery), - search_port: Arc::new(FakeSearchPort), - search_command: Arc::new(FakeSearchCommand), - profile_fields: InMemoryStore::new(), - ap_content: Arc::new(NoopLocalApContentQuery), - wrapup_stats: InMemoryWrapUpStatsQuery::new(), - wrapup_repo: InMemoryWrapUpRepository::new(), - insights: InMemoryInsightsQuery::new(), - goal_command: Arc::clone(&goals) as _, - goal_query: goals as _, - user_settings: Arc::clone(&user_settings) as _, - federation_settings: user_settings as _, - remote_goal: Arc::new(NoopRemoteGoalRepository), - refresh_session: InMemoryStore::new(), - deduplicator: Arc::new(NoopMovieDeduplicator), - image_ref_command: Arc::new(NoopImageRefCommand), - image_ref_query: Arc::new(NoopImageRefQuery), - follow_command: Arc::new(NoopFollowCommand), - follow_query: Arc::new(NoopFollowQuery), - db_pool: test_db_pool(), - } -} - -fn test_services() -> application::Services { - application::Services { - auth: Arc::new(FakeAuthService), - password_hasher: Arc::new(FakePasswordHasher), - metadata: Arc::new(FakeMetadataClient), - poster_fetcher: Arc::new(FakePosterFetcher), - object_storage: Arc::new(NoopObjectStorage), - event_publisher: NoopEventPublisher::new(), - diary_exporter: Arc::new(PanicDiaryExporter), - document_parser: Arc::new(FakeDocumentParser), - review_logger: Arc::new(NoopReviewLogger), - person_enrichment: None, - } -} - -fn test_worker_services() -> application::WorkerServices { - application::WorkerServices { - object_storage: Arc::new(NoopObjectStorage), - event_publisher: NoopEventPublisher::new(), - person_enrichment: None, - } -} - -fn test_config() -> AppConfig { - AppConfig { - allow_registration: true, - base_url: "http://localhost:3000".into(), - rate_limit: 20, - refresh_ttl_seconds: 2_592_000, - secure_cookies: false, - cors_origins: String::new(), - wrapup: application::config::WrapUpConfig { - font_path: None, - logo_path: None, - bg_dir: None, - }, - } -} - -/// The composition root must produce a Deps whose narrow structs point at the -/// same Arcs it was given. Proven by pointer identity on one representative -/// field per group boundary — if wiring is crossed, these differ. -#[test] -fn build_deps_wires_the_given_arcs() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - - let deps = build_deps(&repos, &services, &config, &instance); - - assert!( - std::sync::Arc::ptr_eq(&deps.users.get_local_profile.user, &repos.user), - "get_local_profile.user must be the repositories' user repo" - ); - assert!( - std::sync::Arc::ptr_eq(&deps.social.query.follow_graph, &repos.follow_graph), - "social.query.follow_graph must be the repositories' follow_graph" - ); - assert_eq!(deps.users.get_local_profile.instance, instance); - - assert!( - std::sync::Arc::ptr_eq( - &deps.social.command.event_publisher, - &services.event_publisher - ), - "social.command.event_publisher must be the services' event_publisher" - ); -} - -// The test above covers one representative field per group boundary. The tests below -// give every one of the 11 groups its own coverage: at least one `Arc::ptr_eq` per -// struct in the group, favoring fields most likely to get silently crossed in a future -// edit — same-prefixed fields reused across sibling structs in the group (e.g. -// `wrapup_repo`/`wrapup_stats`, `watch_event_command`/`watch_event_query`), and the two -// name-mismatched `social_query` fields that read like they should come from a -// `social_query` repo but must come from `repos.follow_graph`. - -#[test] -fn auth_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq(&deps.auth.login.user, &repos.user)); - assert!(Arc::ptr_eq(&deps.auth.login.auth, &services.auth)); - assert!(Arc::ptr_eq( - &deps.auth.login.refresh_session, - &repos.refresh_session - )); - assert!(Arc::ptr_eq( - &deps.auth.register.password_hasher, - &services.password_hasher - )); - // login and refresh both carry refresh_session + auth — confirm the second struct - // gets the same repo/service Arcs independently, not a stray clone of something else. - assert!(Arc::ptr_eq( - &deps.auth.refresh.refresh_session, - &repos.refresh_session - )); - assert!(Arc::ptr_eq(&deps.auth.refresh.auth, &services.auth)); - assert!(Arc::ptr_eq(&deps.auth.register_and_login.user, &repos.user)); - assert_eq!(deps.auth.login.config.base_url, config.base_url); - // logout shares refresh_session with login/refresh — confirm it's wired - // independently to the same repo Arc. - assert!(Arc::ptr_eq( - &deps.auth.logout.refresh_session, - &repos.refresh_session - )); -} - -#[test] -fn diary_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq(&deps.diary.delete_review.review, &repos.review)); - assert!(Arc::ptr_eq(&deps.diary.delete_review.diary, &repos.diary)); - assert!(Arc::ptr_eq( - &deps.diary.delete_review.movie_command, - &repos.movie_command - )); - assert!(Arc::ptr_eq( - &deps.diary.delete_review.event_publisher, - &services.event_publisher - )); - // edit_review also carries `review` — confirm it's independently wired, not left - // pointing at delete_review's clone by accident. - assert!(Arc::ptr_eq(&deps.diary.edit_review.review, &repos.review)); - assert!(Arc::ptr_eq( - &deps.diary.get_movie_social_page.movie_query, - &repos.movie_query - )); - assert!(Arc::ptr_eq( - &deps.diary.get_movie_social_page.movie_profile, - &repos.movie_profile - )); - // Name mismatch: the field is called `social_query` but there is no - // `repos.social_query` — it must come from `repos.follow_graph`. - assert!(Arc::ptr_eq( - &deps.diary.get_activity_feed.social_query, - &repos.follow_graph - )); - assert_eq!( - deps.diary.get_activity_feed.config.base_url, - config.base_url - ); - assert!(Arc::ptr_eq(&deps.diary.get_user_feed.user, &repos.user)); - assert!(Arc::ptr_eq(&deps.diary.get_user_feed.diary, &repos.diary)); - assert!(Arc::ptr_eq(&deps.diary.get_diary.diary, &repos.diary)); - assert!(Arc::ptr_eq( - &deps.diary.get_review_history.diary, - &repos.diary - )); - assert!(Arc::ptr_eq(&deps.diary.export_diary.diary, &repos.diary)); - assert!(Arc::ptr_eq( - &deps.diary.export_diary.diary_exporter, - &services.diary_exporter - )); -} - -#[test] -fn goals_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.goals.command.goal_command, - &repos.goal_command - )); - assert!(Arc::ptr_eq( - &deps.goals.command.event_publisher, - &services.event_publisher - )); - // goal_query and stats each appear in both command and query — confirm both - // structs land on the same underlying repo Arc rather than diverging. - assert!(Arc::ptr_eq( - &deps.goals.command.goal_query, - &repos.goal_query - )); - assert!(Arc::ptr_eq(&deps.goals.query.goal_query, &repos.goal_query)); - assert!(Arc::ptr_eq(&deps.goals.command.stats, &repos.stats)); - assert!(Arc::ptr_eq(&deps.goals.query.stats, &repos.stats)); -} - -#[test] -fn import_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - // document_parser (a service) is shared by create_session and apply_mapping — - // confirm both point at the same Arc, not two independently-sourced ones. - assert!(Arc::ptr_eq( - &deps.import.create_session.document_parser, - &services.document_parser - )); - assert!(Arc::ptr_eq( - &deps.import.apply_mapping.document_parser, - &services.document_parser - )); - assert!(Arc::ptr_eq( - &deps.import.apply_mapping.movie_query, - &repos.movie_query - )); - // import_session/import_profile recur across apply_profile and save_profile — - // confirm both fields land correctly in both structs. - assert!(Arc::ptr_eq( - &deps.import.apply_profile.import_profile, - &repos.import_profile - )); - assert!(Arc::ptr_eq( - &deps.import.apply_profile.import_session, - &repos.import_session - )); - assert!(Arc::ptr_eq( - &deps.import.save_profile.import_session, - &repos.import_session - )); - assert!(Arc::ptr_eq( - &deps.import.save_profile.import_profile, - &repos.import_profile - )); - assert!(Arc::ptr_eq( - &deps.import.execute_import.review_logger, - &services.review_logger - )); - - // The four stage-gating/orchestration use cases added alongside the - // handler-bypass removal — each field asserted individually since several - // recur across siblings above (import_session, movie_query, document_parser). - assert!(Arc::ptr_eq( - &deps.import.get_mapping_stage.import_session, - &repos.import_session - )); - assert!(Arc::ptr_eq( - &deps.import.get_preview_stage.import_session, - &repos.import_session - )); - assert!(Arc::ptr_eq( - &deps.import.get_session_state.import_session, - &repos.import_session - )); - assert!(Arc::ptr_eq( - &deps.import.apply_profile_and_map.import_profile, - &repos.import_profile - )); - assert!(Arc::ptr_eq( - &deps.import.apply_profile_and_map.import_session, - &repos.import_session - )); - assert!(Arc::ptr_eq( - &deps.import.apply_profile_and_map.document_parser, - &services.document_parser - )); - assert!(Arc::ptr_eq( - &deps.import.apply_profile_and_map.movie_query, - &repos.movie_query - )); - // import_profile recurs again here — confirm both new use cases are wired - // independently rather than sharing apply_profile's/save_profile's clone. - assert!(Arc::ptr_eq( - &deps.import.delete_profile.import_profile, - &repos.import_profile - )); - assert!(Arc::ptr_eq( - &deps.import.list_profiles.import_profile, - &repos.import_profile - )); -} - -#[test] -fn integrations_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - // watch_event_command and watch_event_query are the classic same-prefix pair most - // at risk of being swapped by a future reader. - assert!(Arc::ptr_eq( - &deps.integrations.ingest_watch_event.watch_event_command, - &repos.watch_event_command - )); - assert!(Arc::ptr_eq( - &deps.integrations.ingest_watch_event.watch_event_query, - &repos.watch_event_query - )); - assert!(Arc::ptr_eq( - &deps.integrations.ingest_watch_event.webhook_token, - &repos.webhook_token - )); - assert!(Arc::ptr_eq( - &deps.integrations.ingest_watch_event.event_publisher, - &services.event_publisher - )); - assert!(Arc::ptr_eq( - &deps.integrations.confirm_watch_events.watch_event_command, - &repos.watch_event_command - )); - assert!(Arc::ptr_eq( - &deps.integrations.confirm_watch_events.watch_event_query, - &repos.watch_event_query - )); - assert!(Arc::ptr_eq( - &deps.integrations.confirm_watch_events.review_logger, - &services.review_logger - )); - assert!(Arc::ptr_eq( - &deps.integrations.dismiss_watch_events.watch_event_command, - &repos.watch_event_command - )); - assert!(Arc::ptr_eq( - &deps.integrations.dismiss_watch_events.watch_event_query, - &repos.watch_event_query - )); - assert!(Arc::ptr_eq( - &deps.integrations.generate_webhook_token.webhook_token, - &repos.webhook_token - )); - assert!(Arc::ptr_eq( - &deps.integrations.get_watch_queue.watch_event_query, - &repos.watch_event_query - )); - assert!(Arc::ptr_eq( - &deps.integrations.get_webhook_tokens.webhook_token, - &repos.webhook_token - )); - assert!(Arc::ptr_eq( - &deps.integrations.revoke_webhook_token.webhook_token, - &repos.webhook_token - )); -} - -/// MoviesGroup was the reviewer's worked example of the risk this suite closes: 8 -/// same-shaped `.clone()` calls on one struct where a crossed pair would still compile. -/// Every field of `sync_poster` gets its own assertion for that reason. -#[test] -fn movies_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.movie_command, - &repos.movie_command - )); - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.movie_query, - &repos.movie_query - )); - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.movie_profile, - &repos.movie_profile - )); - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.metadata, - &services.metadata - )); - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.poster_fetcher, - &services.poster_fetcher - )); - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.object_storage, - &services.object_storage - )); - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.event_publisher, - &services.event_publisher - )); - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.search_command, - &repos.search_command - )); - // movie_profile/movie_query also recur in sync_poster above — confirm these two - // use cases are wired independently rather than reusing sync_poster's clone. - assert!(Arc::ptr_eq( - &deps.movies.get_movie_profile.movie_profile, - &repos.movie_profile - )); - assert!(Arc::ptr_eq( - &deps.movies.get_movies.movie, - &repos.movie_query - )); -} - -#[test] -fn person_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.person.get_person.person_query, - &repos.person_query - )); - assert!(Arc::ptr_eq( - &deps.person.get_person.event_publisher, - &services.event_publisher - )); -} - -#[test] -fn search_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.search.execute.search_port, - &repos.search_port - )); -} - -#[test] -fn social_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.social.command.social_command, - &repos.social_command - )); - assert!(Arc::ptr_eq( - &deps.social.command.event_publisher, - &services.event_publisher - )); - assert!(Arc::ptr_eq( - &deps.social.query.follow_graph, - &repos.follow_graph - )); - assert!(Arc::ptr_eq( - &deps.social.query.block_query, - &repos.block_query - )); -} - -#[test] -fn users_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.users.get_local_profile.stats, - &repos.stats - )); - assert!(Arc::ptr_eq( - &deps.users.get_local_profile.diary, - &repos.diary - )); - // Name mismatch: the field is called `social_query` but must come from - // `repos.follow_graph`, same as diary::get_activity_feed above. - assert!(Arc::ptr_eq( - &deps.users.get_local_profile.social_query, - &repos.follow_graph - )); - assert_eq!(deps.users.get_local_profile.instance, instance); - - assert!(Arc::ptr_eq( - &deps.users.get_federated_profile_stats.stats, - &repos.stats - )); - assert!(Arc::ptr_eq( - &deps.users.get_federated_profile_stats.diary, - &repos.diary - )); - assert!(Arc::ptr_eq( - &deps.users.get_federated_profile_stats.social_query, - &repos.follow_graph - )); - - assert!(Arc::ptr_eq(&deps.users.get_page_viewer.user, &repos.user)); - assert!(Arc::ptr_eq( - &deps.users.get_page_viewer.follow_graph, - &repos.follow_graph - )); - - assert!(Arc::ptr_eq(&deps.users.resolve_username.user, &repos.user)); - - assert!(Arc::ptr_eq( - &deps.users.get_profile_settings.user, - &repos.user - )); - assert!(Arc::ptr_eq( - &deps.users.get_profile_settings.profile_fields, - &repos.profile_fields - )); - assert_eq!(deps.users.get_profile_settings.instance, instance); - - // `user` recurs in several structs of this group — confirm each is wired - // independently rather than one struct silently sharing another's clone call. - assert!(Arc::ptr_eq(&deps.users.get_local_profile.user, &repos.user)); - assert!(Arc::ptr_eq(&deps.users.get_users_list.user, &repos.user)); - assert!(Arc::ptr_eq(&deps.users.update_profile.user, &repos.user)); - assert!(Arc::ptr_eq(&deps.users.delete_account.user, &repos.user)); - - assert!(Arc::ptr_eq( - &deps.users.get_users_list.federation_admin, - &repos.federation_admin - )); - assert!(Arc::ptr_eq( - &deps.users.update_profile.object_storage, - &services.object_storage - )); - // event_publisher recurs in update_profile and delete_account — confirm both. - assert!(Arc::ptr_eq( - &deps.users.update_profile.event_publisher, - &services.event_publisher - )); - assert!(Arc::ptr_eq( - &deps.users.delete_account.event_publisher, - &services.event_publisher - )); - - // `user` recurs again here — confirm get_current_profile is independently wired. - assert!(Arc::ptr_eq( - &deps.users.get_current_profile.user, - &repos.user - )); - assert!(Arc::ptr_eq( - &deps.users.update_profile_fields.profile_fields, - &repos.profile_fields - )); - assert!(Arc::ptr_eq( - &deps.users.update_profile_fields.event_publisher, - &services.event_publisher - )); - // user_settings recurs across get_settings and update_settings — confirm both - // land on the same repo Arc independently. - assert!(Arc::ptr_eq( - &deps.users.get_settings.user_settings, - &repos.user_settings - )); - assert!(Arc::ptr_eq( - &deps.users.update_settings.user_settings, - &repos.user_settings - )); - assert!(Arc::ptr_eq(&deps.users.authorize_admin.user, &repos.user)); - - // federated_profile is genuinely optional configuration (federation on/off), - // the same exemption as WorkerServices::person_enrichment (see - // worker_deps_wiring below) — the fixture leaves it None, so pointer - // identity doesn't apply; confirm it passes through unchanged instead. - assert_eq!( - deps.users.get_federated_profile.federated_profile.is_none(), - repos.federated_profile.is_none(), - "the optional federated_profile port must pass through as given" - ); -} - -#[test] -fn watchlist_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.watchlist.add.movie_command, - &repos.movie_command - )); - assert!(Arc::ptr_eq( - &deps.watchlist.add.movie_query, - &repos.movie_query - )); - assert!(Arc::ptr_eq( - &deps.watchlist.add.metadata, - &services.metadata - )); - assert!(Arc::ptr_eq(&deps.watchlist.add.watchlist, &repos.watchlist)); - assert!(Arc::ptr_eq( - &deps.watchlist.add.event_publisher, - &services.event_publisher - )); - assert!(Arc::ptr_eq( - &deps.watchlist.get_watchlist_for_owner.user, - &repos.user - )); - // `watchlist` also appears in `add` — confirm `get_watchlist_for_owner` is - // independently wired to the same repo Arc, not a stray clone. - assert!(Arc::ptr_eq( - &deps.watchlist.get_watchlist_for_owner.watchlist, - &repos.watchlist - )); - assert!(Arc::ptr_eq( - &deps.watchlist.get_watchlist_for_owner.remote_watchlist, - &repos.remote_watchlist - )); - assert!(Arc::ptr_eq( - &deps.watchlist.get_watchlist.watchlist, - &repos.watchlist - )); - assert!(Arc::ptr_eq( - &deps.watchlist.is_on_watchlist.watchlist, - &repos.watchlist - )); - assert!(Arc::ptr_eq( - &deps.watchlist.remove_from_watchlist.watchlist, - &repos.watchlist - )); - assert!(Arc::ptr_eq( - &deps.watchlist.remove_from_watchlist.event_publisher, - &services.event_publisher - )); -} - -#[test] -fn wrapup_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.wrapup.get_ready_report.wrapup_repo, - &repos.wrapup_repo - )); - assert!(Arc::ptr_eq( - &deps.wrapup.delete_wrapup.wrapup_repo, - &repos.wrapup_repo - )); - assert!(Arc::ptr_eq( - &deps.wrapup.generate.wrapup_repo, - &repos.wrapup_repo - )); - assert!(Arc::ptr_eq( - &deps.wrapup.generate.event_publisher, - &services.event_publisher - )); - assert!(Arc::ptr_eq( - &deps.wrapup.get_wrapup.wrapup_repo, - &repos.wrapup_repo - )); - assert!(Arc::ptr_eq( - &deps.wrapup.list_wrapups.wrapup_repo, - &repos.wrapup_repo - )); -} - -/// Covers the five groups that moved to `WorkerDeps` during worker unification: -/// `enrich_movie`, `reindex_search`, `merge_duplicates`, `enrich_person`, -/// `handle_requested`. Assertions here are the ones that used to live in -/// `movies_group_wiring`, `person_group_wiring`, and `wrapup_group_wiring` against -/// `build_deps` — they now drive `build_worker_deps` against `WorkerServices` instead. -#[tokio::test] -async fn worker_deps_wiring() { - let db = test_database_output(); - let services = test_worker_services(); - let worker_deps = build_worker_deps(&db, &services); - - // enrich_movie and reindex_search share movie_query/movie_profile/search_command - // with each other — confirm each struct is wired independently rather than - // reusing a stray clone from a sibling struct. - assert!(Arc::ptr_eq( - &worker_deps.enrich_movie.movie_query, - &db.movie_query - )); - assert!(Arc::ptr_eq( - &worker_deps.enrich_movie.movie_profile, - &db.movie_profile - )); - assert!(Arc::ptr_eq( - &worker_deps.enrich_movie.person_command, - &db.person_command - )); - assert!(Arc::ptr_eq( - &worker_deps.enrich_movie.search_command, - &db.search_command - )); - assert!(Arc::ptr_eq( - &worker_deps.reindex_search.movie_query, - &db.movie_query - )); - assert!(Arc::ptr_eq( - &worker_deps.reindex_search.movie_profile, - &db.movie_profile - )); - assert!(Arc::ptr_eq( - &worker_deps.reindex_search.search_command, - &db.search_command - )); - assert!(Arc::ptr_eq( - &worker_deps.reindex_search.person_command, - &db.person_command - )); - assert!(Arc::ptr_eq( - &worker_deps.reindex_search.person_query, - &db.person_query - )); - - assert!(Arc::ptr_eq( - &worker_deps.merge_duplicates.movie_query, - &db.movie_query - )); - assert!(Arc::ptr_eq( - &worker_deps.merge_duplicates.object_storage, - &services.object_storage - )); - assert!( - Arc::ptr_eq(&worker_deps.merge_duplicates.deduplicator, &db.deduplicator), - "merge_duplicates must carry the DatabaseOutput deduplicator — this field was \ - unconstructible before worker unification" - ); - - assert!(Arc::ptr_eq( - &worker_deps.enrich_person.person_query, - &db.person_query - )); - assert!(Arc::ptr_eq( - &worker_deps.enrich_person.person_command, - &db.person_command - )); - assert_eq!( - worker_deps.enrich_person.person_enrichment.is_none(), - services.person_enrichment.is_none(), - "the optional person_enrichment port must pass through as given" - ); - - // wrapup_repo and wrapup_stats are the same-prefix pair most at risk of a swap; - // both are different trait types, but a future reader "correcting" a name could - // still reach for the wrong one. - assert!(Arc::ptr_eq( - &worker_deps.handle_requested.wrapup_repo, - &db.wrapup_repo - )); - assert!(Arc::ptr_eq( - &worker_deps.handle_requested.wrapup_stats, - &db.wrapup_stats - )); - assert!(Arc::ptr_eq( - &worker_deps.handle_requested.event_publisher, - &services.event_publisher - )); -} diff --git a/crates/bootstrap/src/worker.rs b/crates/bootstrap/src/worker.rs index de35f6dd..5d9c244f 100644 --- a/crates/bootstrap/src/worker.rs +++ b/crates/bootstrap/src/worker.rs @@ -1,13 +1,13 @@ use std::sync::Arc; -use application::config::AppConfig as AppSettings; use application::{ MovieDiscoveryIndexer, SearchCleanupHandler, SearchReindexHandler, worker::WorkerService, }; use domain::ports::{EventHandler, MovieEnrichmentClient, PeriodicJob, PersonEnrichmentClient}; use domain::value_objects::InstanceIdentity; -use crate::{database, event_bus}; +use crate::database::{self, DatabaseOutput}; +use crate::event_bus; pub struct WorkerApp { pub service: WorkerService, @@ -17,9 +17,27 @@ pub struct WorkerApp { /// spawned) and the consumer. Run it with `service.run(shutdown)`. pub async fn build_worker(config: &config::AppConfig) -> anyhow::Result { database::warn_if_database_backend_set(); - let app_config = AppSettings::from(config); - let instance = InstanceIdentity::new(app_config.base_url.clone()); + 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 +} +/// Builds the same worker as [`build_worker`] on a caller-provided pool. +/// Migrations run on it exactly as they do for the normal path. +pub async fn build_worker_on( + config: &config::AppConfig, + pool: sqlx::SqlitePool, +) -> anyhow::Result { + 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 { let metadata_client = metadata::create(&config.metadata)?; let poster_fetcher = poster_fetcher::create(&config.metadata)?; let object_storage = object_storage::create(&config.storage)?; @@ -40,7 +58,6 @@ pub async fn build_worker(config: &config::AppConfig) -> anyhow::Result); - let db = database::build_database_adapters(&config.database.url, &instance).await?; let (event_publisher_arc, consumer_arc) = event_bus::create_channel(&db.db_pool, config).await?; diff --git a/crates/bootstrap/tests/api_test.rs b/crates/bootstrap/tests/api_test.rs new file mode 100644 index 00000000..eb7dd57f --- /dev/null +++ b/crates/bootstrap/tests/api_test.rs @@ -0,0 +1,390 @@ +//! The HTTP surface of the production app, built through `bootstrap` on an +//! in-memory database with real adapters. + +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 +} + +/// An app with one registered user; returns its pool, id and bearer token. +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) -> Request { + let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap(); + let mut req = req; + req.extensions_mut() + .insert(axum::extract::ConnectInfo::(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); +} diff --git a/crates/bootstrap/tests/bootstrap_test.rs b/crates/bootstrap/tests/bootstrap_test.rs new file mode 100644 index 00000000..33a2e027 --- /dev/null +++ b/crates/bootstrap/tests/bootstrap_test.rs @@ -0,0 +1,69 @@ +//! The composition root assembles on a fresh database, with every adapter real. + +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"); +} + +/// Without the federation feature, following another local user still works +/// (the request lands as pending for the target): +/// the local social ports are wired in place of the ActivityPub ones. +#[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}" + ); +} diff --git a/crates/bootstrap/tests/route_smoke_test.rs b/crates/bootstrap/tests/route_smoke_test.rs index d11d7c98..1d4efd2d 100644 --- a/crates/bootstrap/tests/route_smoke_test.rs +++ b/crates/bootstrap/tests/route_smoke_test.rs @@ -1,14 +1,15 @@ //! Characterization of the classic HTML routes, feeds and the OpenAPI surface, //! recorded before the HTTP layer moves. Statuses are what the app returns today. +mod support; + use axum::{ Router, body::Body, http::{Method, Request, header}, }; -use tower::ServiceExt; +use support::{get, json_request, send}; -const PASSWORD: &str = "correct-horse-battery"; const EMAIL: &str = "owner@example.com"; const USERNAME: &str = "owner"; @@ -83,135 +84,11 @@ struct Seed { owner_cookie: String, } -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(), - // Required to build; never called (seeding logs reviews with manual titles). - metadata: config::MetadataConfig { - tmdb_api_key: Some("test-key".into()), - ..Default::default() - }, - federation: Default::default(), - wrapup: Default::default(), - } -} - -async fn send(router: &Router, mut req: Request) -> (u16, axum::http::HeaderMap, String) { - let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap(); - req.extensions_mut() - .insert(axum::extract::ConnectInfo::(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(), - ) -} - -fn get(uri: &str, cookie: Option<&str>) -> Request { - 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() -} - -fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option { - 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) - }) -} - -fn json_request( - method: Method, - uri: &str, - token: Option<&str>, - body: serde_json::Value, -) -> Request { - 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() -} - /// One user (registered through the classic form), one movie with a spoiler review, one goal. async fn seed() -> Seed { - let pool = sqlx::sqlite::SqlitePoolOptions::new() - .max_connections(1) - .connect("sqlite::memory:") - .await - .unwrap(); - let router = bootstrap::build_server_on(&test_config(), pool.clone()) - .await - .unwrap(); - - // Register through the form: GET for the csrf cookie, then POST with the token. - 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 owner_cookie = format!("csrf={csrf}; token={session}"); - - // Bearer token for the JSON API. - 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(); - let token = login["token"].as_str().unwrap().to_string(); - let user_id = login["user_id"].as_str().unwrap().to_string(); + 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, diff --git a/crates/bootstrap/tests/support/mod.rs b/crates/bootstrap/tests/support/mod.rs new file mode 100644 index 00000000..9429d37e --- /dev/null +++ b/crates/bootstrap/tests/support/mod.rs @@ -0,0 +1,162 @@ +//! Shared helpers for the bootstrap integration tests: a production app built +//! through `bootstrap` on an in-memory database, driven over HTTP. +#![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(), + // Required to build; never called (seeding logs reviews with manual titles). + 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) -> (u16, axum::http::HeaderMap, String) { + let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap(); + req.extensions_mut() + .insert(axum::extract::ConnectInfo::(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 { + 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 { + 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 { + 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() +} + +/// A single-connection in-memory pool (every connection would otherwise get its own database). +pub async fn memory_pool() -> sqlx::SqlitePool { + sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap() +} + +/// The production server router on a fresh in-memory database, plus its pool. +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, + /// Bearer token for the JSON API. + pub token: String, + /// Cookie header for the classic UI (csrf + session). + pub cookie: String, +} + +/// Registers through the classic form (GET for the csrf cookie, then POST with +/// the token), then logs in over the JSON API for a bearer token. +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, + } +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index ccbdb448..06e72338 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -21,18 +21,3 @@ tokio = { workspace = true } anyhow = { workspace = true } dotenvy = { workspace = true } tracing = { workspace = true } - -[dev-dependencies] -domain = { workspace = true, features = ["test-helpers"] } -application = { workspace = true } -http-axum = { path = "../adapters/http-axum", default-features = false } -rss = { workspace = true } -sqlite = { workspace = true } -sqlx = { workspace = true } -async-trait = { workspace = true } -bytes = { workspace = true } -futures = { workspace = true } -serde_json = { workspace = true } -uuid = { workspace = true } -tower = { version = "0.5", features = ["util"] } -http-body-util = "0.1" diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs deleted file mode 100644 index c7cb1434..00000000 --- a/crates/server/tests/api_test.rs +++ /dev/null @@ -1,844 +0,0 @@ -use std::sync::Arc; - -use application::config::AppConfig; -use async_trait::async_trait; -use axum::{ - Router, - body::Body, - http::{Request, StatusCode}, -}; -use bootstrap::Repositories; -use domain::{ - errors::DomainError, - events::DomainEvent, - models::{ - EntityType, ExternalPersonId, IndexableDocument, MetadataSearchCriteria, Movie, Person, - PersonCredits, PersonEnrichmentData, PersonId, SearchQuery, SearchResults, User, - }, - ports::{ - AuthService, EventPublisher, MetadataClient, ObjectStorage, PasswordHasher, PersonCommand, - PersonQuery, PosterFetcherClient, SearchCommand, SearchPort, UserRepository, - }, - value_objects::{Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId}, -}; -use http_axum::context::AppContext; -use http_axum::{router, state::AppState}; -use http_body_util::BodyExt; -use rss::RssAdapter; -use sqlite::{ - SqliteDiaryRepository, SqliteInsightsQuery, SqliteMovieRepository, SqliteReviewRepository, - SqliteStatsRepository, migrate as sqlite_migrate, -}; -use sqlx::SqlitePool; -use tower::ServiceExt; -use uuid::Uuid; - -struct NoopEventPublisher; -#[async_trait] -impl EventPublisher for NoopEventPublisher { - async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { - Ok(()) - } -} - -struct PanicReviewLogger; -#[async_trait] -impl application::ports::ReviewLogger for PanicReviewLogger { - async fn log_review( - &self, - _: application::diary::commands::LogReviewCommand, - ) -> Result<(), DomainError> { - panic!("review_logger not wired in tests") - } -} - -struct PanicMeta; -#[async_trait] -impl MetadataClient for PanicMeta { - async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result { - panic!("metadata not wired in tests") - } - async fn get_poster_url( - &self, - _: &ExternalMetadataId, - ) -> Result, DomainError> { - panic!() - } -} - -struct PanicFetcher; -#[async_trait] -impl PosterFetcherClient for PanicFetcher { - async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result, DomainError> { - panic!() - } -} - -struct PanicObjectStorage; -#[async_trait] -impl ObjectStorage for PanicObjectStorage { - async fn store(&self, _: &str, _: &[u8]) -> Result { - panic!() - } - async fn get(&self, _: &str) -> Result, DomainError> { - panic!() - } - async fn get_stream( - &self, - _: &str, - ) -> Result>, DomainError> - { - panic!() - } - async fn delete(&self, _: &str) -> Result<(), DomainError> { - panic!() - } -} - -struct PanicHasher; -#[async_trait] -impl PasswordHasher for PanicHasher { - async fn hash(&self, _: &str) -> Result { - panic!() - } - async fn verify(&self, _: &str, _: &PasswordHash) -> Result { - panic!() - } -} - -struct NobodyUserRepo; -#[async_trait] -impl UserRepository for NobodyUserRepo { - async fn find_by_email(&self, _: &Email) -> Result, DomainError> { - Ok(None) - } - async fn find_by_username( - &self, - _: &domain::value_objects::Username, - ) -> Result, DomainError> { - Ok(None) - } - async fn save(&self, _: &User) -> Result<(), DomainError> { - panic!() - } - async fn find_by_id(&self, _: &UserId) -> Result, DomainError> { - panic!() - } - async fn list_with_stats(&self) -> Result, DomainError> { - panic!() - } - async fn update_profile( - &self, - _: &UserId, - _: &domain::models::UserProfile, - ) -> Result<(), DomainError> { - Ok(()) - } -} - -struct PanicProfileFields; -#[async_trait] -impl domain::ports::UserProfileFieldsRepository for PanicProfileFields { - async fn get_fields( - &self, - _: &UserId, - ) -> Result, DomainError> { - Ok(vec![]) - } - async fn set_fields( - &self, - _: &UserId, - _: Vec, - ) -> Result<(), DomainError> { - panic!() - } -} - -struct PanicExporter; -impl domain::ports::DiaryExporter for PanicExporter { - fn stream_entries( - &self, - _stream: futures::stream::BoxStream< - 'static, - Result, - >, - _format: domain::models::ExportFormat, - ) -> futures::stream::BoxStream<'static, Result> { - panic!("PanicExporter::stream_entries") - } -} - -struct PanicImportSession; -#[async_trait] -impl domain::ports::ImportSessionRepository for PanicImportSession { - async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { - panic!() - } - async fn get( - &self, - _: &domain::value_objects::ImportSessionId, - _: &UserId, - ) -> Result, DomainError> { - panic!() - } - async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { - panic!() - } - async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> { - panic!() - } - async fn delete_expired(&self) -> Result { - panic!() - } - async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { - panic!() - } -} - -struct PanicDocumentParser; -impl domain::ports::DocumentParser for PanicDocumentParser { - fn parse( - &self, - _: &[u8], - _: domain::models::FileFormat, - ) -> Result { - panic!("DocumentParser not wired in tests") - } - fn apply_mapping( - &self, - _: &domain::models::ParsedFile, - _: &[domain::models::FieldMapping], - ) -> Vec { - panic!("DocumentParser not wired in tests") - } -} - -struct PanicImportProfile; - -struct PanicMovieProfile; -#[async_trait] -impl domain::ports::MovieProfileRepository for PanicMovieProfile { - async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { - panic!() - } - async fn get_by_movie_id( - &self, - _: &domain::value_objects::MovieId, - ) -> Result, DomainError> { - Ok(None) - } - async fn list_stale( - &self, - ) -> Result, DomainError> { - Ok(vec![]) - } -} -#[async_trait] -impl domain::ports::ImportProfileRepository for PanicImportProfile { - async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { - panic!() - } - async fn list_for_user( - &self, - _: &UserId, - ) -> Result, DomainError> { - panic!() - } - async fn get( - &self, - _: &domain::value_objects::ImportProfileId, - _: &UserId, - ) -> Result, DomainError> { - panic!() - } - async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { - panic!() - } -} - -struct PanicWatchlist; -#[async_trait] -impl domain::ports::WatchlistRepository for PanicWatchlist { - async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> { - panic!() - } - async fn remove( - &self, - _: &domain::value_objects::UserId, - _: &domain::value_objects::MovieId, - ) -> Result<(), DomainError> { - panic!() - } - async fn remove_if_present( - &self, - _: &domain::value_objects::UserId, - _: &domain::value_objects::MovieId, - ) -> Result { - Ok(false) - } - async fn get_for_user( - &self, - _: &domain::value_objects::UserId, - _: &domain::models::collections::PageParams, - ) -> Result< - domain::models::collections::Paginated, - DomainError, - > { - panic!() - } - async fn contains( - &self, - _: &domain::value_objects::UserId, - _: &domain::value_objects::MovieId, - ) -> Result { - Ok(false) - } -} - -struct PanicPersonCommand; -#[async_trait] -impl PersonCommand for PanicPersonCommand { - async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { - panic!() - } - async fn backfill_from_credits_batch( - &self, - _batch_size: u32, - ) -> Result<(u64, bool), DomainError> { - panic!() - } - async fn update_enrichment( - &self, - _: &PersonId, - _: &PersonEnrichmentData, - ) -> Result<(), DomainError> { - panic!() - } -} - -struct PanicPersonQuery; -#[async_trait] -impl PersonQuery for PanicPersonQuery { - async fn get_by_id(&self, _: &PersonId) -> Result, DomainError> { - panic!() - } - async fn get_by_external_id( - &self, - _: &ExternalPersonId, - ) -> Result, DomainError> { - panic!() - } - async fn get_credits(&self, _: &PersonId) -> Result { - panic!() - } - async fn list_orphaned_persons(&self) -> Result, DomainError> { - panic!() - } - async fn list_page( - &self, - _limit: u32, - _offset: u32, - ) -> Result, DomainError> { - panic!() - } -} - -struct PanicSearchPort; -#[async_trait] -impl SearchPort for PanicSearchPort { - async fn search(&self, _: &SearchQuery) -> Result { - panic!() - } -} - -struct PanicSearchCommand; -#[async_trait] -impl SearchCommand for PanicSearchCommand { - async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { - panic!() - } - async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { - panic!() - } -} - -struct PanicRemoteWatchlist; -#[async_trait::async_trait] -impl domain::ports::RemoteWatchlistRepository for PanicRemoteWatchlist { - async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> { - Ok(()) - } - async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { - Ok(()) - } - async fn get_by_actor_url( - &self, - _: &str, - ) -> Result, DomainError> { - Ok(vec![]) - } - async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { - Ok(()) - } - async fn get_by_derived_uuid( - &self, - _: uuid::Uuid, - ) -> Result, DomainError> { - Ok(vec![]) - } -} -async fn test_app() -> Router { - test_app_with(domain::testing::FakeAuthService).await.0 -} - -async fn test_app_with(auth: impl AuthService + 'static) -> (Router, SqlitePool) { - let pool = SqlitePool::connect("sqlite::memory:") - .await - .expect("in-memory SQLite failed"); - sqlite_migrate(&pool).await.expect("migration failed"); - - let repos = Repositories { - movie_command: Arc::new(SqliteMovieRepository::new(pool.clone())) as _, - movie_query: Arc::new(SqliteMovieRepository::new(pool.clone())) as _, - review: Arc::new(SqliteReviewRepository::new(pool.clone())) as _, - diary: Arc::new(SqliteDiaryRepository::new(pool.clone())) as _, - stats: Arc::new(SqliteStatsRepository::new(pool.clone())) as _, - user: Arc::new(NobodyUserRepo), - import_session: Arc::new(PanicImportSession), - import_profile: Arc::new(PanicImportProfile), - movie_profile: Arc::new(PanicMovieProfile), - watchlist: Arc::new(PanicWatchlist), - watch_event_command: Arc::new(domain::testing::PanicWatchEventCommand), - watch_event_query: Arc::new(domain::testing::PanicWatchEventQuery), - webhook_token: Arc::new(domain::testing::PanicWebhookTokenRepository), - profile_fields: Arc::new(PanicProfileFields), - person_command: Arc::new(PanicPersonCommand), - person_query: Arc::new(PanicPersonQuery), - search_port: Arc::new(PanicSearchPort), - search_command: Arc::new(PanicSearchCommand), - remote_watchlist: Arc::new(PanicRemoteWatchlist), - social_command: Arc::new(domain::ports::noop::NoopSocialCommand), - follow_graph: Arc::new(domain::ports::noop::NoopSocialQuery), - block_query: Arc::new(domain::ports::noop::NoopSocialQuery), - federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _, - wrapup_stats: Arc::new(domain::testing::PanicWrapUpStatsQuery) as _, - wrapup_repo: Arc::new(domain::testing::PanicWrapUpRepository) as _, - insights: Arc::new(SqliteInsightsQuery::new(pool.clone())) as _, - goal_command: Arc::new(domain::testing::NoopGoalCommand), - goal_query: Arc::new(domain::testing::NoopGoalQuery), - user_settings: Arc::new(domain::testing::NoopUserSettingsRepository), - remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository), - refresh_session: Arc::new(domain::testing::PanicRefreshSessionRepository), - federated_profile: None, - }; - - let services = application::Services { - auth: Arc::new(auth), - password_hasher: Arc::new(PanicHasher), - metadata: Arc::new(PanicMeta), - poster_fetcher: Arc::new(PanicFetcher), - object_storage: Arc::new(PanicObjectStorage), - event_publisher: Arc::new(NoopEventPublisher), - diary_exporter: Arc::new(PanicExporter), - document_parser: Arc::new(PanicDocumentParser), - review_logger: Arc::new(PanicReviewLogger), - person_enrichment: None, - }; - - let config = AppConfig { - 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::WrapUpConfig { - 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)); - - let 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(RssAdapter::new("http://localhost:3000".into())), - }; - - let router = router::build_router(state, axum::Router::new()); - (router, pool) -} - -/// Inject a fake peer IP so the GovernorLayer can extract ConnectInfo. -fn with_ip(req: Request) -> Request { - let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap(); - let mut req = req; - req.extensions_mut() - .insert(axum::extract::ConnectInfo::(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 = response.into_body().collect().await.unwrap().to_bytes(); - 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) = test_app_with(domain::testing::FakeAuthService).await; - let user_id = Uuid::new_v4(); - seed_insights_diary(&pool, user_id).await; - - let response = app - .oneshot(with_ip( - Request::builder() - .uri("/api/v1/insights") - .header("authorization", format!("Bearer {user_id}")) - .body(Body::empty()) - .unwrap(), - )) - .await - .unwrap(); - - let (status, bytes) = ( - response.status(), - response.into_body().collect().await.unwrap().to_bytes(), - ); - 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, - user_id: Uuid, - query: &str, -) -> (StatusCode, serde_json::Value) { - let response = app - .oneshot(with_ip( - Request::builder() - .uri(format!("/api/v1/insights{query}")) - .header("authorization", format!("Bearer {user_id}")) - .body(Body::empty()) - .unwrap(), - )) - .await - .unwrap(); - let status = response.status(); - let bytes = response.into_body().collect().await.unwrap().to_bytes(); - 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) = test_app_with(domain::testing::FakeAuthService).await; - let user_id = Uuid::new_v4(); - 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, user_id, "").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) = test_app_with(domain::testing::FakeAuthService).await; - let user_id = Uuid::new_v4(); - 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, user_id, "?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) = test_app_with(domain::testing::FakeAuthService).await; - let (status, _) = get_insights_json(app, Uuid::new_v4(), "?tz=Mars/Olympus").await; - assert_eq!(status, StatusCode::BAD_REQUEST); -} -- 2.49.1 From a2c713c1fa92c058d940dfe7a0d591802b408067 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:34:08 +0100 Subject: [PATCH 029/104] test: move domain tests to tests/ Self dev-dep w/ test-helpers for domain::testing. Also moved social blanket-impl + InMemoryStore tests (public API only). No private-item exceptions. --- Cargo.lock | 1 + crates/domain/Cargo.toml | 1 + crates/domain/src/errors/mod.rs | 4 - crates/domain/src/events.rs | 4 - crates/domain/src/models/goal.rs | 4 - crates/domain/src/models/mod.rs | 4 - crates/domain/src/models/person.rs | 4 - crates/domain/src/models/review.rs | 4 - crates/domain/src/models/stats.rs | 4 - crates/domain/src/models/watch_event.rs | 4 - crates/domain/src/ports/federation.rs | 4 - crates/domain/src/ports/social.rs | 161 ----------------- .../domain/src/services/insights_analyzer.rs | 4 - crates/domain/src/services/review_history.rs | 4 - crates/domain/src/services/wrapup_analyzer.rs | 4 - crates/domain/src/testing/fakes/mod.rs | 2 - crates/domain/src/value_objects/mod.rs | 4 - crates/domain/tests/errors.rs | 2 + .../tests.rs => tests/errors/errors_test.rs} | 2 +- crates/domain/tests/events.rs | 2 + .../events.rs => tests/events/events_test.rs} | 4 +- crates/domain/tests/models.rs | 12 ++ .../models/constructors_test.rs} | 26 +-- .../goal.rs => tests/models/goal_test.rs} | 6 +- .../person.rs => tests/models/person_test.rs} | 2 +- .../review.rs => tests/models/review_test.rs} | 6 +- .../stats.rs => tests/models/stats_test.rs} | 2 +- .../models/watch_event_test.rs} | 4 +- crates/domain/tests/ports.rs | 4 + .../ports/federation_test.rs} | 4 +- crates/domain/tests/ports/social_test.rs | 162 ++++++++++++++++++ crates/domain/tests/services.rs | 6 + .../services/insights_analyzer_test.rs} | 4 +- .../services/review_history_test.rs} | 8 +- .../services/wrapup_analyzer_test.rs} | 6 +- crates/domain/tests/testing.rs | 2 + .../testing/in_memory_store_test.rs} | 46 ++--- crates/domain/tests/value_objects.rs | 2 + .../value_objects/value_objects_test.rs} | 2 +- 39 files changed, 257 insertions(+), 274 deletions(-) create mode 100644 crates/domain/tests/errors.rs rename crates/domain/{src/errors/tests.rs => tests/errors/errors_test.rs} (95%) create mode 100644 crates/domain/tests/events.rs rename crates/domain/{src/tests/events.rs => tests/events/events_test.rs} (93%) create mode 100644 crates/domain/tests/models.rs rename crates/domain/{src/models/tests/mod.rs => tests/models/constructors_test.rs} (89%) rename crates/domain/{src/models/tests/goal.rs => tests/models/goal_test.rs} (95%) rename crates/domain/{src/models/tests/person.rs => tests/models/person_test.rs} (98%) rename crates/domain/{src/models/tests/review.rs => tests/models/review_test.rs} (95%) rename crates/domain/{src/models/tests/stats.rs => tests/models/stats_test.rs} (98%) rename crates/domain/{src/models/tests/watch_event.rs => tests/models/watch_event_test.rs} (97%) create mode 100644 crates/domain/tests/ports.rs rename crates/domain/{src/ports/tests/federation.rs => tests/ports/federation_test.rs} (87%) create mode 100644 crates/domain/tests/ports/social_test.rs create mode 100644 crates/domain/tests/services.rs rename crates/domain/{src/services/tests/insights_analyzer.rs => tests/services/insights_analyzer_test.rs} (99%) rename crates/domain/{src/services/tests/review_history.rs => tests/services/review_history_test.rs} (90%) rename crates/domain/{src/services/tests/wrapup_analyzer.rs => tests/services/wrapup_analyzer_test.rs} (95%) create mode 100644 crates/domain/tests/testing.rs rename crates/domain/{src/testing/fakes/tests.rs => tests/testing/in_memory_store_test.rs} (89%) create mode 100644 crates/domain/tests/value_objects.rs rename crates/domain/{src/tests/value_objects.rs => tests/value_objects/value_objects_test.rs} (99%) diff --git a/Cargo.lock b/Cargo.lock index 49d019f9..300197eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1663,6 +1663,7 @@ dependencies = [ "async-trait", "bytes", "chrono", + "domain", "email_address", "futures", "serde", diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index c16d2d0f..787c2e72 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -16,6 +16,7 @@ serde_json = { workspace = true } email_address = "0.2.9" [dev-dependencies] +domain = { path = ".", features = ["test-helpers"] } tokio = { workspace = true } [features] diff --git a/crates/domain/src/errors/mod.rs b/crates/domain/src/errors/mod.rs index 4e1dbf1d..b09e30c6 100644 --- a/crates/domain/src/errors/mod.rs +++ b/crates/domain/src/errors/mod.rs @@ -26,7 +26,3 @@ impl DomainError { matches!(self, DomainError::InfrastructureError(_)) } } - -#[cfg(test)] -#[path = "tests.rs"] -mod tests; diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 073c6e3a..43bc964a 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -168,7 +168,3 @@ impl EventEnvelope { self.ack.nack().await } } - -#[cfg(test)] -#[path = "tests/events.rs"] -mod tests; diff --git a/crates/domain/src/models/goal.rs b/crates/domain/src/models/goal.rs index 534f0dc8..87c87fbc 100644 --- a/crates/domain/src/models/goal.rs +++ b/crates/domain/src/models/goal.rs @@ -109,7 +109,3 @@ impl GoalWithProgress { self.current_count >= self.goal.target_count } } - -#[cfg(test)] -#[path = "tests/goal.rs"] -mod tests; diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index eae18a79..eda2c9bb 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -103,7 +103,3 @@ pub enum ExportFormat { Csv, Json, } - -#[cfg(test)] -#[path = "tests/mod.rs"] -mod tests; diff --git a/crates/domain/src/models/person.rs b/crates/domain/src/models/person.rs index 34d589b9..8a8ddec2 100644 --- a/crates/domain/src/models/person.rs +++ b/crates/domain/src/models/person.rs @@ -181,7 +181,3 @@ pub struct CrewCredit { pub department: String, pub poster_path: Option, } - -#[cfg(test)] -#[path = "tests/person.rs"] -mod tests; diff --git a/crates/domain/src/models/review.rs b/crates/domain/src/models/review.rs index e78b4475..3dbd54f9 100644 --- a/crates/domain/src/models/review.rs +++ b/crates/domain/src/models/review.rs @@ -202,7 +202,3 @@ impl ReviewHistory { self.viewings.sort_by_key(|r| *r.watched_at()); } } - -#[cfg(test)] -#[path = "tests/review.rs"] -mod tests; diff --git a/crates/domain/src/models/stats.rs b/crates/domain/src/models/stats.rs index a6156b5c..adafdc90 100644 --- a/crates/domain/src/models/stats.rs +++ b/crates/domain/src/models/stats.rs @@ -138,7 +138,3 @@ pub struct MovieStats { pub federated_count: u64, pub rating_histogram: [u64; 5], // index 0 = 1★, index 4 = 5★ } - -#[cfg(test)] -#[path = "tests/stats.rs"] -mod tests; diff --git a/crates/domain/src/models/watch_event.rs b/crates/domain/src/models/watch_event.rs index 8c8b7c9e..de225eb5 100644 --- a/crates/domain/src/models/watch_event.rs +++ b/crates/domain/src/models/watch_event.rs @@ -234,7 +234,3 @@ pub struct ParsedPlaybackEvent { pub tmdb_id: Option, pub imdb_id: Option, } - -#[cfg(test)] -#[path = "tests/watch_event.rs"] -mod tests; diff --git a/crates/domain/src/ports/federation.rs b/crates/domain/src/ports/federation.rs index 9c944d8a..c71ff2ee 100644 --- a/crates/domain/src/ports/federation.rs +++ b/crates/domain/src/ports/federation.rs @@ -49,7 +49,3 @@ pub trait ApBackfillPort: Send + Sync { follower_inbox_url: String, ) -> Result<(), DomainError>; } - -#[cfg(test)] -#[path = "tests/federation.rs"] -mod tests; diff --git a/crates/domain/src/ports/social.rs b/crates/domain/src/ports/social.rs index 6680e9f6..067040aa 100644 --- a/crates/domain/src/ports/social.rs +++ b/crates/domain/src/ports/social.rs @@ -150,164 +150,3 @@ impl Result<(), DomainError> { - Ok(()) - } - async fn unfollow( - &self, - _: &crate::value_objects::UserId, - _: &crate::value_objects::SocialIdentity, - ) -> Result<(), DomainError> { - Ok(()) - } - async fn accept_follow( - &self, - _: &crate::value_objects::UserId, - _: &crate::value_objects::SocialIdentity, - ) -> Result<(), DomainError> { - Ok(()) - } - async fn reject_follow( - &self, - _: &crate::value_objects::UserId, - _: &crate::value_objects::SocialIdentity, - ) -> Result<(), DomainError> { - Ok(()) - } - async fn remove_follower( - &self, - _: &crate::value_objects::UserId, - _: &crate::value_objects::SocialIdentity, - ) -> Result<(), DomainError> { - Ok(()) - } - async fn block( - &self, - _: &crate::value_objects::UserId, - _: &crate::value_objects::SocialIdentity, - ) -> Result<(), DomainError> { - Ok(()) - } - async fn unblock( - &self, - _: &crate::value_objects::UserId, - _: &crate::value_objects::SocialIdentity, - ) -> Result<(), DomainError> { - Ok(()) - } - } - - #[async_trait] - impl FollowGraphQuery for Stub { - async fn get_following( - &self, - _: &crate::value_objects::UserId, - ) -> Result, DomainError> { - Ok(vec![]) - } - async fn get_followers( - &self, - _: &crate::value_objects::UserId, - ) -> Result, DomainError> { - Ok(vec![]) - } - async fn get_pending_followers( - &self, - _: &crate::value_objects::UserId, - ) -> Result, DomainError> { - Ok(vec![]) - } - async fn get_pending_following( - &self, - _: &crate::value_objects::UserId, - ) -> Result, DomainError> { - Ok(vec![]) - } - async fn count_following( - &self, - _: &crate::value_objects::UserId, - ) -> Result { - Ok(0) - } - async fn count_followers( - &self, - _: &crate::value_objects::UserId, - ) -> Result { - Ok(0) - } - async fn count_pending_followers( - &self, - _: &crate::value_objects::UserId, - ) -> Result { - Ok(0) - } - async fn get_relation( - &self, - _: &crate::value_objects::UserId, - _: &crate::value_objects::SocialIdentity, - ) -> Result { - Err(DomainError::NotFound("stub".into())) - } - } - - #[async_trait] - impl BlockQuery for Stub { - async fn get_blocked( - &self, - _: &crate::value_objects::UserId, - ) -> Result, DomainError> { - Ok(vec![]) - } - } - - #[async_trait] - impl FollowTargetResolver for Stub { - async fn resolve_target( - &self, - target: &crate::value_objects::FollowTarget, - ) -> Result { - match target { - crate::value_objects::FollowTarget::Identity(id) => Ok(id.clone()), - crate::value_objects::FollowTarget::Handle(handle) => Ok(SocialIdentity::Remote { - actor_url: handle.clone(), - }), - } - } - } - - #[async_trait] - impl ResolvedFollow for Stub { - async fn follow_resolved( - &self, - _: &crate::value_objects::UserId, - _: &SocialIdentity, - ) -> Result<(), DomainError> { - Ok(()) - } - } - - #[test] - fn blanket_impl_yields_a_trait_object() { - let local: Arc = Arc::new(Stub); - // Reachable through each supertrait without a separate Arc. - let _: &dyn SocialCommand = local.as_ref(); - let _: &dyn FollowGraphQuery = local.as_ref(); - let _: &dyn BlockQuery = local.as_ref(); - let _: &dyn FollowTargetResolver = local.as_ref(); - let _: &dyn ResolvedFollow = local.as_ref(); - } -} diff --git a/crates/domain/src/services/insights_analyzer.rs b/crates/domain/src/services/insights_analyzer.rs index 706e1ecd..73228f6f 100644 --- a/crates/domain/src/services/insights_analyzer.rs +++ b/crates/domain/src/services/insights_analyzer.rs @@ -342,7 +342,3 @@ fn label_of(insight: &Insight) -> &str { fn round2(v: f64) -> f64 { (v * 100.0).round() / 100.0 } - -#[cfg(test)] -#[path = "tests/insights_analyzer.rs"] -mod tests; diff --git a/crates/domain/src/services/review_history.rs b/crates/domain/src/services/review_history.rs index 38f2ba0b..e4efc31a 100644 --- a/crates/domain/src/services/review_history.rs +++ b/crates/domain/src/services/review_history.rs @@ -51,7 +51,3 @@ impl ReviewHistoryAnalyzer { } } } - -#[cfg(test)] -#[path = "tests/review_history.rs"] -mod tests; diff --git a/crates/domain/src/services/wrapup_analyzer.rs b/crates/domain/src/services/wrapup_analyzer.rs index 8d5c56c9..da3bb94d 100644 --- a/crates/domain/src/services/wrapup_analyzer.rs +++ b/crates/domain/src/services/wrapup_analyzer.rs @@ -464,7 +464,3 @@ fn compute_global_stats( (most_active_user, most_watched, total_users_active) } - -#[cfg(test)] -#[path = "tests/wrapup_analyzer.rs"] -mod tests; diff --git a/crates/domain/src/testing/fakes/mod.rs b/crates/domain/src/testing/fakes/mod.rs index 00f1fe69..3b0750fe 100644 --- a/crates/domain/src/testing/fakes/mod.rs +++ b/crates/domain/src/testing/fakes/mod.rs @@ -13,8 +13,6 @@ mod store_user; mod store_watch_event; mod store_watchlist; mod stubs; -#[cfg(test)] -mod tests; mod wrapup; pub use insights::*; diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs index 1d41b86e..f0ec554e 100644 --- a/crates/domain/src/value_objects/mod.rs +++ b/crates/domain/src/value_objects/mod.rs @@ -11,7 +11,3 @@ pub use movie::*; pub use review::*; pub use social::*; pub use user::*; - -#[cfg(test)] -#[path = "../tests/value_objects.rs"] -mod tests; diff --git a/crates/domain/tests/errors.rs b/crates/domain/tests/errors.rs new file mode 100644 index 00000000..e8a917ab --- /dev/null +++ b/crates/domain/tests/errors.rs @@ -0,0 +1,2 @@ +#[path = "errors/errors_test.rs"] +mod errors_test; diff --git a/crates/domain/src/errors/tests.rs b/crates/domain/tests/errors/errors_test.rs similarity index 95% rename from crates/domain/src/errors/tests.rs rename to crates/domain/tests/errors/errors_test.rs index 4dc2e662..ebc69539 100644 --- a/crates/domain/src/errors/tests.rs +++ b/crates/domain/tests/errors/errors_test.rs @@ -1,4 +1,4 @@ -use super::*; +use domain::errors::DomainError; #[test] fn infrastructure_error_is_transient() { diff --git a/crates/domain/tests/events.rs b/crates/domain/tests/events.rs new file mode 100644 index 00000000..37b15d41 --- /dev/null +++ b/crates/domain/tests/events.rs @@ -0,0 +1,2 @@ +#[path = "events/events_test.rs"] +mod events_test; diff --git a/crates/domain/src/tests/events.rs b/crates/domain/tests/events/events_test.rs similarity index 93% rename from crates/domain/src/tests/events.rs rename to crates/domain/tests/events/events_test.rs index 57b618e3..bde7014d 100644 --- a/crates/domain/src/tests/events.rs +++ b/crates/domain/tests/events/events_test.rs @@ -1,5 +1,5 @@ -use super::*; -use crate::value_objects::{FollowTarget, SocialIdentity, UserId}; +use domain::events::DomainEvent; +use domain::value_objects::{FollowTarget, SocialIdentity, UserId}; #[test] fn follow_accepted_matches() { diff --git a/crates/domain/tests/models.rs b/crates/domain/tests/models.rs new file mode 100644 index 00000000..2cba8662 --- /dev/null +++ b/crates/domain/tests/models.rs @@ -0,0 +1,12 @@ +#[path = "models/constructors_test.rs"] +mod constructors_test; +#[path = "models/goal_test.rs"] +mod goal_test; +#[path = "models/person_test.rs"] +mod person_test; +#[path = "models/review_test.rs"] +mod review_test; +#[path = "models/stats_test.rs"] +mod stats_test; +#[path = "models/watch_event_test.rs"] +mod watch_event_test; diff --git a/crates/domain/src/models/tests/mod.rs b/crates/domain/tests/models/constructors_test.rs similarity index 89% rename from crates/domain/src/models/tests/mod.rs rename to crates/domain/tests/models/constructors_test.rs index f8eb4b7e..becb04bb 100644 --- a/crates/domain/src/models/tests/mod.rs +++ b/crates/domain/tests/models/constructors_test.rs @@ -1,6 +1,6 @@ -use super::*; -use crate::testing::{test_movie_with_director, test_movie_with_year, test_review, test_user}; -use crate::value_objects::{Email, PasswordHash, UserId, Username}; +use domain::models::*; +use domain::testing::{test_movie_with_director, test_movie_with_year, test_review, test_user}; +use domain::value_objects::{Email, PasswordHash, UserId, Username}; #[test] fn update_profile_sets_fields() { @@ -32,11 +32,11 @@ fn update_profile_clears_with_none() { #[test] fn movie_new_sets_fields() { let m = Movie::new( - Some(crate::value_objects::ExternalMetadataId::new("tt1234567".into()).unwrap()), - crate::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(), - crate::value_objects::ReleaseYear::new(1982).unwrap(), + Some(domain::value_objects::ExternalMetadataId::new("tt1234567".into()).unwrap()), + domain::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(), + domain::value_objects::ReleaseYear::new(1982).unwrap(), Some("Ridley Scott".into()), - Some(crate::value_objects::PosterPath::new("/poster.jpg".into()).unwrap()), + Some(domain::value_objects::PosterPath::new("/poster.jpg".into()).unwrap()), ); assert_eq!(m.title().value(), "Blade Runner"); assert_eq!(m.release_year().value(), 1982); @@ -48,14 +48,14 @@ fn movie_new_sets_fields() { #[test] fn movie_update_poster() { let mut m = test_movie_with_year("Blade Runner", 1982); - let new_poster = crate::value_objects::PosterPath::new("/new.jpg".into()).unwrap(); + let new_poster = domain::value_objects::PosterPath::new("/new.jpg".into()).unwrap(); m.update_poster(new_poster); assert_eq!(m.poster_path().unwrap().value(), "/new.jpg"); } // ── Review ─────────────────────────────────────────────────────────────────── -use crate::value_objects::{MovieId, Rating, ReviewId}; +use domain::value_objects::{MovieId, Rating, ReviewId}; #[test] fn review_new_sets_local_source() { @@ -199,16 +199,16 @@ fn month_activity_construction() { #[test] fn movie_is_manual_match_same_title_year() { let m = test_movie_with_director("Blade Runner", 1982, "Ridley Scott"); - let title = crate::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(); - let year = crate::value_objects::ReleaseYear::new(1982).unwrap(); + let title = domain::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(); + let year = domain::value_objects::ReleaseYear::new(1982).unwrap(); assert!(m.is_manual_match(&title, &year, Some("ridley scott"))); } #[test] fn movie_is_manual_match_different_director_fails() { let m = test_movie_with_director("Blade Runner", 1982, "Ridley Scott"); - let title = crate::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(); - let year = crate::value_objects::ReleaseYear::new(1982).unwrap(); + let title = domain::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(); + let year = domain::value_objects::ReleaseYear::new(1982).unwrap(); assert!(!m.is_manual_match(&title, &year, Some("Denis Villeneuve"))); } diff --git a/crates/domain/src/models/tests/goal.rs b/crates/domain/tests/models/goal_test.rs similarity index 95% rename from crates/domain/src/models/tests/goal.rs rename to crates/domain/tests/models/goal_test.rs index 41c2c2b9..7b7afd86 100644 --- a/crates/domain/src/models/tests/goal.rs +++ b/crates/domain/tests/models/goal_test.rs @@ -1,6 +1,6 @@ -use super::*; -use crate::testing::test_goal; -use crate::value_objects::UserId; +use domain::models::*; +use domain::testing::test_goal; +use domain::value_objects::{GoalId, UserId}; #[test] fn new_goal_valid() { diff --git a/crates/domain/src/models/tests/person.rs b/crates/domain/tests/models/person_test.rs similarity index 98% rename from crates/domain/src/models/tests/person.rs rename to crates/domain/tests/models/person_test.rs index 2e11467e..de485e79 100644 --- a/crates/domain/src/models/tests/person.rs +++ b/crates/domain/tests/models/person_test.rs @@ -1,4 +1,4 @@ -use super::*; +use domain::models::*; #[test] fn person_new() { diff --git a/crates/domain/src/models/tests/review.rs b/crates/domain/tests/models/review_test.rs similarity index 95% rename from crates/domain/src/models/tests/review.rs rename to crates/domain/tests/models/review_test.rs index 13eacffa..4d99db1d 100644 --- a/crates/domain/src/models/tests/review.rs +++ b/crates/domain/tests/models/review_test.rs @@ -1,7 +1,7 @@ -use crate::testing::{test_review, test_watched_at}; -use crate::value_objects::{Comment, MovieId, Rating, UserId, WatchMedium}; +use domain::testing::{test_review, test_watched_at}; +use domain::value_objects::{Comment, MovieId, Rating, UserId, WatchMedium}; -use super::*; +use domain::models::*; fn review_with_medium(watch_medium: Option) -> Review { test_review(&MovieId::generate(), &UserId::generate(), 4).apply_edit(ReviewEdit { diff --git a/crates/domain/src/models/tests/stats.rs b/crates/domain/tests/models/stats_test.rs similarity index 98% rename from crates/domain/src/models/tests/stats.rs rename to crates/domain/tests/models/stats_test.rs index 345eb3a5..dd9af144 100644 --- a/crates/domain/src/models/tests/stats.rs +++ b/crates/domain/tests/models/stats_test.rs @@ -1,4 +1,4 @@ -use super::*; +use domain::models::*; fn appearance(person_id: u64, name: &str, billing: u32, movie: &str) -> ActorAppearance { ActorAppearance { diff --git a/crates/domain/src/models/tests/watch_event.rs b/crates/domain/tests/models/watch_event_test.rs similarity index 97% rename from crates/domain/src/models/tests/watch_event.rs rename to crates/domain/tests/models/watch_event_test.rs index de495a81..d94ebc84 100644 --- a/crates/domain/src/models/tests/watch_event.rs +++ b/crates/domain/tests/models/watch_event_test.rs @@ -1,4 +1,6 @@ -use super::*; +use chrono::NaiveDateTime; +use domain::models::*; +use domain::value_objects::{MovieId, UserId, WebhookTokenId}; fn ts() -> NaiveDateTime { chrono::NaiveDate::from_ymd_opt(2024, 6, 1) diff --git a/crates/domain/tests/ports.rs b/crates/domain/tests/ports.rs new file mode 100644 index 00000000..eafd8bf0 --- /dev/null +++ b/crates/domain/tests/ports.rs @@ -0,0 +1,4 @@ +#[path = "ports/federation_test.rs"] +mod federation_test; +#[path = "ports/social_test.rs"] +mod social_test; diff --git a/crates/domain/src/ports/tests/federation.rs b/crates/domain/tests/ports/federation_test.rs similarity index 87% rename from crates/domain/src/ports/tests/federation.rs rename to crates/domain/tests/ports/federation_test.rs index 052cf14b..62728bb1 100644 --- a/crates/domain/src/ports/tests/federation.rs +++ b/crates/domain/tests/ports/federation_test.rs @@ -1,5 +1,5 @@ -use super::*; -use crate::ports::noop::{NoopApBackfill, NoopApDocument, NoopInstanceBlocklist}; +use domain::ports::noop::{NoopApBackfill, NoopApDocument, NoopInstanceBlocklist}; +use domain::ports::{ApBackfillPort, ApDocumentPort, InstanceBlocklistPort}; #[tokio::test] async fn noop_document_returns_empty_strings() { diff --git a/crates/domain/tests/ports/social_test.rs b/crates/domain/tests/ports/social_test.rs new file mode 100644 index 00000000..4a023a8b --- /dev/null +++ b/crates/domain/tests/ports/social_test.rs @@ -0,0 +1,162 @@ +use async_trait::async_trait; +use domain::errors::DomainError; +use domain::ports::{ + BlockQuery, FollowGraphQuery, FollowTargetResolver, LocalSocial, ResolvedFollow, SocialCommand, +}; +use domain::value_objects::SocialIdentity; +use std::sync::Arc; + +struct Stub; + +#[async_trait] +impl SocialCommand for Stub { + async fn follow( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::FollowTarget, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn unfollow( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::SocialIdentity, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn accept_follow( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::SocialIdentity, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn reject_follow( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::SocialIdentity, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn remove_follower( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::SocialIdentity, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn block( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::SocialIdentity, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn unblock( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::SocialIdentity, + ) -> Result<(), DomainError> { + Ok(()) + } +} + +#[async_trait] +impl FollowGraphQuery for Stub { + async fn get_following( + &self, + _: &domain::value_objects::UserId, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_followers( + &self, + _: &domain::value_objects::UserId, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_pending_followers( + &self, + _: &domain::value_objects::UserId, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_pending_following( + &self, + _: &domain::value_objects::UserId, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn count_following( + &self, + _: &domain::value_objects::UserId, + ) -> Result { + Ok(0) + } + async fn count_followers( + &self, + _: &domain::value_objects::UserId, + ) -> Result { + Ok(0) + } + async fn count_pending_followers( + &self, + _: &domain::value_objects::UserId, + ) -> Result { + Ok(0) + } + async fn get_relation( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::SocialIdentity, + ) -> Result { + Err(DomainError::NotFound("stub".into())) + } +} + +#[async_trait] +impl BlockQuery for Stub { + async fn get_blocked( + &self, + _: &domain::value_objects::UserId, + ) -> Result, DomainError> { + Ok(vec![]) + } +} + +#[async_trait] +impl FollowTargetResolver for Stub { + async fn resolve_target( + &self, + target: &domain::value_objects::FollowTarget, + ) -> Result { + match target { + domain::value_objects::FollowTarget::Identity(id) => Ok(id.clone()), + domain::value_objects::FollowTarget::Handle(handle) => Ok(SocialIdentity::Remote { + actor_url: handle.clone(), + }), + } + } +} + +#[async_trait] +impl ResolvedFollow for Stub { + async fn follow_resolved( + &self, + _: &domain::value_objects::UserId, + _: &SocialIdentity, + ) -> Result<(), DomainError> { + Ok(()) + } +} + +#[test] +fn blanket_impl_yields_a_trait_object() { + let local: Arc = Arc::new(Stub); + // Reachable through each supertrait without a separate Arc. + let _: &dyn SocialCommand = local.as_ref(); + let _: &dyn FollowGraphQuery = local.as_ref(); + let _: &dyn BlockQuery = local.as_ref(); + let _: &dyn FollowTargetResolver = local.as_ref(); + let _: &dyn ResolvedFollow = local.as_ref(); +} diff --git a/crates/domain/tests/services.rs b/crates/domain/tests/services.rs new file mode 100644 index 00000000..e43f552b --- /dev/null +++ b/crates/domain/tests/services.rs @@ -0,0 +1,6 @@ +#[path = "services/insights_analyzer_test.rs"] +mod insights_analyzer_test; +#[path = "services/review_history_test.rs"] +mod review_history_test; +#[path = "services/wrapup_analyzer_test.rs"] +mod wrapup_analyzer_test; diff --git a/crates/domain/src/services/tests/insights_analyzer.rs b/crates/domain/tests/services/insights_analyzer_test.rs similarity index 99% rename from crates/domain/src/services/tests/insights_analyzer.rs rename to crates/domain/tests/services/insights_analyzer_test.rs index 27597bab..a9b5c242 100644 --- a/crates/domain/src/services/tests/insights_analyzer.rs +++ b/crates/domain/tests/services/insights_analyzer_test.rs @@ -1,9 +1,9 @@ use chrono::{Datelike, FixedOffset, NaiveDate, Utc, Weekday}; use uuid::Uuid; -use crate::models::{Insight, WatchRow}; +use domain::models::{Insight, WatchRow}; -use super::super::insights_analyzer::build_insights; +use domain::services::insights_analyzer::build_insights; fn watch(genres: &[&str], rating: u8, date: NaiveDate, hour: u32) -> WatchRow { WatchRow { diff --git a/crates/domain/src/services/tests/review_history.rs b/crates/domain/tests/services/review_history_test.rs similarity index 90% rename from crates/domain/src/services/tests/review_history.rs rename to crates/domain/tests/services/review_history_test.rs index 85b4d122..312e1a86 100644 --- a/crates/domain/src/services/tests/review_history.rs +++ b/crates/domain/tests/services/review_history_test.rs @@ -1,8 +1,8 @@ -use super::*; -use crate::models::{Review, ReviewHistory}; -use crate::testing::test_movie_with_year; -use crate::value_objects::{MovieId, Rating, UserId}; use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; +use domain::models::{Review, ReviewHistory}; +use domain::services::review_history::{ReviewHistoryAnalyzer, Trend}; +use domain::testing::test_movie_with_year; +use domain::value_objects::{MovieId, Rating, UserId}; fn dt(year: i32, month: u32, day: u32) -> NaiveDateTime { NaiveDateTime::new( diff --git a/crates/domain/src/services/tests/wrapup_analyzer.rs b/crates/domain/tests/services/wrapup_analyzer_test.rs similarity index 95% rename from crates/domain/src/services/tests/wrapup_analyzer.rs rename to crates/domain/tests/services/wrapup_analyzer_test.rs index b3d985c0..0c020d39 100644 --- a/crates/domain/src/services/tests/wrapup_analyzer.rs +++ b/crates/domain/tests/services/wrapup_analyzer_test.rs @@ -1,10 +1,10 @@ use chrono::NaiveDate; use uuid::Uuid; -use crate::models::WrapUpMovieRow; -use crate::models::wrapup::{DateRange, WrapUpScope}; +use domain::models::WrapUpMovieRow; +use domain::models::wrapup::{DateRange, WrapUpScope}; -use super::super::wrapup_analyzer::build_report; +use domain::services::wrapup_analyzer::build_report; fn row(title: &str, rating: u8, ym: &str) -> WrapUpMovieRow { WrapUpMovieRow { diff --git a/crates/domain/tests/testing.rs b/crates/domain/tests/testing.rs new file mode 100644 index 00000000..e9d0282a --- /dev/null +++ b/crates/domain/tests/testing.rs @@ -0,0 +1,2 @@ +#[path = "testing/in_memory_store_test.rs"] +mod in_memory_store_test; diff --git a/crates/domain/src/testing/fakes/tests.rs b/crates/domain/tests/testing/in_memory_store_test.rs similarity index 89% rename from crates/domain/src/testing/fakes/tests.rs rename to crates/domain/tests/testing/in_memory_store_test.rs index 34305ea3..ec8bb068 100644 --- a/crates/domain/src/testing/fakes/tests.rs +++ b/crates/domain/tests/testing/in_memory_store_test.rs @@ -3,21 +3,21 @@ use std::sync::Arc; use chrono::Utc; use uuid::Uuid; -use super::{InMemoryStore, MOVIE_CASCADE_TABLES, USER_CASCADE_TABLES}; -use crate::errors::DomainError; -use crate::models::{ +use domain::errors::DomainError; +use domain::models::{ CastMember, CrewMember, Genre, Keyword, Movie, MovieProfile, ProfileField, RefreshSession, UserSettings, WatchEvent, WatchEventSource, WatchlistEntry, WebhookToken, }; -use crate::ports::{ +use domain::ports::{ GoalCommand, MovieCommand, MovieProfileRepository, RefreshSessionRepository, ReviewRepository, SocialCommand, UserProfileFieldsRepository, UserRepository, UserSettingsRepository, WatchEventCommand, WatchEventQuery, WatchlistRepository, WebhookTokenRepository, }; -use crate::testing::{ +use domain::testing::{InMemoryStore, MOVIE_CASCADE_TABLES, USER_CASCADE_TABLES}; +use domain::testing::{ test_goal, test_movie, test_movie_with_external_id, test_review, test_user, test_watched_at, }; -use crate::value_objects::{ExternalMetadataId, FollowTarget, MovieId, SocialIdentity, UserId}; +use domain::value_objects::{ExternalMetadataId, FollowTarget, MovieId, SocialIdentity, UserId}; const REMOTE_ACTOR: &str = "https://remote.example/users/ann"; @@ -210,7 +210,10 @@ async fn deleting_a_user_leaves_other_users_rows_alone() { assert!(store.find_by_id(bystander.id()).await.unwrap().is_some()); } -async fn seed_movie_with_dependents(store: &Arc, with_review: bool) -> Movie { +async fn seed_movie_with_dependents( + store: &Arc, + with_review: bool, +) -> (Movie, UserId) { let user = test_user("alice"); UserRepository::save(&**store, &user).await.unwrap(); let movie = test_movie("Heat"); @@ -240,13 +243,13 @@ async fn seed_movie_with_dependents(store: &Arc, with_review: boo .await .unwrap(); } - movie + (movie, user.id().clone()) } #[tokio::test] async fn deleting_a_movie_removes_watchlist_entries_and_enrichment_profile() { let store = InMemoryStore::new(); - let movie = seed_movie_with_dependents(&store, false).await; + let (movie, _) = seed_movie_with_dependents(&store, false).await; for table in MOVIE_CASCADE_TABLES { assert!(store.rows_in(table) > 0, "{table} should be seeded"); } @@ -262,8 +265,7 @@ async fn deleting_a_movie_removes_watchlist_entries_and_enrichment_profile() { #[tokio::test] async fn deleting_a_movie_detaches_watch_events_instead_of_removing_them() { let store = InMemoryStore::new(); - let movie = seed_movie_with_dependents(&store, false).await; - let user_id = store.watch_events.read().unwrap()[0].user_id().clone(); + let (movie, user_id) = seed_movie_with_dependents(&store, false).await; store.delete_movie(movie.id()).await.unwrap(); @@ -275,7 +277,7 @@ async fn deleting_a_movie_detaches_watch_events_instead_of_removing_them() { #[tokio::test] async fn deleting_a_movie_that_has_reviews_is_refused_because_reviews_do_not_cascade() { let store = InMemoryStore::new(); - let movie = seed_movie_with_dependents(&store, true).await; + let (movie, _) = seed_movie_with_dependents(&store, true).await; let result = store.delete_movie(movie.id()).await; @@ -291,11 +293,11 @@ async fn saving_a_user_with_a_taken_username_is_a_validation_error() { UserRepository::save(&*store, &test_user("alice")) .await .unwrap(); - let clash = crate::models::User::new( - crate::value_objects::Email::new("other@example.com".into()).unwrap(), - crate::value_objects::Username::new("alice".into()).unwrap(), - crate::value_objects::PasswordHash::new("hashed".into()).unwrap(), - crate::models::UserRole::Standard, + let clash = domain::models::User::new( + domain::value_objects::Email::new("other@example.com".into()).unwrap(), + domain::value_objects::Username::new("alice".into()).unwrap(), + domain::value_objects::PasswordHash::new("hashed".into()).unwrap(), + domain::models::UserRole::Standard, ); let result = UserRepository::save(&*store, &clash).await; @@ -309,11 +311,11 @@ async fn saving_a_user_with_a_taken_email_is_a_validation_error() { UserRepository::save(&*store, &test_user("alice")) .await .unwrap(); - let clash = crate::models::User::new( - crate::value_objects::Email::new("alice@example.com".into()).unwrap(), - crate::value_objects::Username::new("alice2".into()).unwrap(), - crate::value_objects::PasswordHash::new("hashed".into()).unwrap(), - crate::models::UserRole::Standard, + let clash = domain::models::User::new( + domain::value_objects::Email::new("alice@example.com".into()).unwrap(), + domain::value_objects::Username::new("alice2".into()).unwrap(), + domain::value_objects::PasswordHash::new("hashed".into()).unwrap(), + domain::models::UserRole::Standard, ); let result = UserRepository::save(&*store, &clash).await; diff --git a/crates/domain/tests/value_objects.rs b/crates/domain/tests/value_objects.rs new file mode 100644 index 00000000..e94a056b --- /dev/null +++ b/crates/domain/tests/value_objects.rs @@ -0,0 +1,2 @@ +#[path = "value_objects/value_objects_test.rs"] +mod value_objects_test; diff --git a/crates/domain/src/tests/value_objects.rs b/crates/domain/tests/value_objects/value_objects_test.rs similarity index 99% rename from crates/domain/src/tests/value_objects.rs rename to crates/domain/tests/value_objects/value_objects_test.rs index beed6f0a..76c737bc 100644 --- a/crates/domain/src/tests/value_objects.rs +++ b/crates/domain/tests/value_objects/value_objects_test.rs @@ -1,4 +1,4 @@ -use super::*; +use domain::value_objects::*; #[test] fn movie_id_generate_unique() { -- 2.49.1 From 8526b77f18fd799481b569c9cc7ae8568cf1b034 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:36:52 +0100 Subject: [PATCH 030/104] test: move application tests to tests/ test_support/test_helpers -> tests/support; watched_at unified w/ domain test_watched_at (2024-03-15). Kept inline: users::diary_filter tests (feed_sort_to_direction_all_variants, paged_user_filter_builds_correctly) - pub(super) helpers in private module. Dropped stale never-compiled users/tests/get_users.rs (old execute signature). --- crates/application/src/auth/login.rs | 4 - crates/application/src/auth/logout.rs | 4 - crates/application/src/auth/refresh.rs | 4 - crates/application/src/auth/register.rs | 4 - .../src/auth/register_and_login.rs | 4 - crates/application/src/diary/delete_review.rs | 4 - crates/application/src/diary/edit_review.rs | 4 - .../src/diary/get_activity_feed.rs | 4 - crates/application/src/diary/get_diary.rs | 4 - .../src/diary/get_movie_social_page.rs | 4 - .../src/diary/get_review_history.rs | 4 - crates/application/src/diary/get_user_feed.rs | 4 - crates/application/src/diary/log_review.rs | 4 - .../application/src/diary/movie_resolver.rs | 4 - crates/application/src/diary/review_logger.rs | 4 - crates/application/src/goals/create.rs | 4 - crates/application/src/goals/delete.rs | 4 - crates/application/src/goals/get.rs | 4 - crates/application/src/goals/list.rs | 4 - crates/application/src/goals/update.rs | 4 - .../application/src/import/apply_mapping.rs | 4 - .../application/src/import/apply_profile.rs | 4 - .../src/import/apply_profile_and_map.rs | 4 - crates/application/src/import/cleanup.rs | 4 - .../application/src/import/create_session.rs | 4 - .../application/src/import/delete_profile.rs | 4 - crates/application/src/import/execute.rs | 4 - .../src/import/get_mapping_stage.rs | 4 - .../src/import/get_preview_stage.rs | 4 - .../src/import/get_session_state.rs | 4 - .../application/src/import/list_profiles.rs | 4 - crates/application/src/import/save_profile.rs | 4 - crates/application/src/insights/get.rs | 4 - .../application/src/integrations/cleanup.rs | 4 - .../application/src/integrations/confirm.rs | 4 - .../application/src/integrations/dismiss.rs | 4 - .../src/integrations/generate_token.rs | 4 - .../application/src/integrations/get_queue.rs | 4 - .../src/integrations/get_tokens.rs | 4 - crates/application/src/integrations/ingest.rs | 4 - .../src/integrations/revoke_token.rs | 4 - crates/application/src/lib.rs | 10 --- crates/application/src/movies/enrich_movie.rs | 4 - .../src/movies/get_movie_profile.rs | 4 - crates/application/src/movies/get_movies.rs | 4 - .../src/movies/request_enrichment.rs | 4 - crates/application/src/movies/sync_poster.rs | 4 - crates/application/src/person/get.rs | 4 - crates/application/src/person/get_credits.rs | 4 - crates/application/src/search/execute.rs | 4 - crates/application/src/social/execute.rs | 4 - .../application/src/social/local_service.rs | 4 - .../application/src/users/authorize_admin.rs | 4 - crates/application/src/users/diary_filter.rs | 42 ++++++++- .../src/users/get_current_profile.rs | 4 - .../src/users/get_federated_profile.rs | 4 - .../src/users/get_federated_profile_stats.rs | 4 - .../src/users/get_local_profile.rs | 4 - .../application/src/users/get_page_viewer.rs | 4 - .../src/users/get_profile_settings.rs | 4 - crates/application/src/users/get_settings.rs | 4 - .../src/users/resolve_username_to_id.rs | 4 - .../src/users/tests/diary_filter.rs | 38 -------- .../application/src/users/tests/get_users.rs | 17 ---- .../application/src/users/update_profile.rs | 4 - .../src/users/update_profile_fields.rs | 4 - .../application/src/users/update_settings.rs | 4 - crates/application/src/watchlist/add.rs | 4 - crates/application/src/watchlist/get.rs | 4 - .../src/watchlist/get_watchlist_for_owner.rs | 4 - crates/application/src/watchlist/is_on.rs | 4 - crates/application/src/watchlist/remove.rs | 4 - crates/application/src/worker.rs | 4 - crates/application/src/wrapup/compute.rs | 4 - crates/application/src/wrapup/delete.rs | 4 - crates/application/src/wrapup/generate.rs | 4 - .../src/wrapup/get_ready_report.rs | 4 - crates/application/src/wrapup/get_wrapup.rs | 4 - .../src/wrapup/handle_requested.rs | 4 - crates/application/src/wrapup/list_wrapups.rs | 4 - crates/application/tests/auth.rs | 12 +++ .../login.rs => tests/auth/login_test.rs} | 20 ++--- .../logout.rs => tests/auth/logout_test.rs} | 12 ++- .../refresh.rs => tests/auth/refresh_test.rs} | 12 ++- .../auth/register_and_login_test.rs} | 8 +- .../auth/register_test.rs} | 14 ++- crates/application/tests/diary.rs | 22 +++++ .../diary/delete_review_test.rs} | 6 +- .../diary/edit_review_test.rs} | 4 +- .../diary/get_activity_feed_test.rs} | 7 +- .../diary/get_diary_test.rs} | 2 +- .../diary/get_movie_social_page_test.rs} | 2 +- .../diary/get_review_history_test.rs} | 2 +- .../diary/get_user_feed_test.rs} | 12 +-- .../diary/log_review_test.rs} | 16 ++-- .../diary/movie_resolver_test.rs} | 5 +- .../diary/review_logger_test.rs} | 8 +- crates/application/tests/goals.rs | 12 +++ .../create.rs => tests/goals/create_test.rs} | 8 +- .../delete.rs => tests/goals/delete_test.rs} | 8 +- .../tests/get.rs => tests/goals/get_test.rs} | 8 +- .../list.rs => tests/goals/list_test.rs} | 8 +- .../update.rs => tests/goals/update_test.rs} | 8 +- crates/application/tests/import.rs | 26 ++++++ .../import/apply_mapping_test.rs} | 8 +- .../import/apply_profile_and_map_test.rs} | 10 ++- .../import/apply_profile_test.rs} | 6 +- .../import/cleanup_test.rs} | 2 +- .../import/create_session_test.rs} | 8 +- .../import/delete_profile_test.rs} | 2 +- .../import/execute_test.rs} | 10 +-- .../import/get_mapping_stage_test.rs} | 4 +- .../import/get_preview_stage_test.rs} | 4 +- .../import/get_session_state_test.rs} | 4 +- .../import/list_profiles_test.rs} | 2 +- .../import/save_profile_test.rs} | 4 +- crates/application/tests/insights.rs | 4 + .../get.rs => tests/insights/get_test.rs} | 2 +- crates/application/tests/integrations.rs | 18 ++++ .../integrations/cleanup_test.rs} | 2 +- .../integrations/confirm_test.rs} | 18 ++-- .../integrations/dismiss_test.rs} | 6 +- .../integrations/generate_token_test.rs} | 6 +- .../integrations/get_queue_test.rs} | 4 +- .../integrations/get_tokens_test.rs} | 6 +- .../integrations/ingest_test.rs} | 8 +- .../integrations/revoke_token_test.rs} | 6 +- crates/application/tests/movies.rs | 12 +++ .../movies/enrich_movie_test.rs} | 2 +- .../movies/get_movie_profile_test.rs} | 4 +- .../movies/get_movies_test.rs} | 2 +- .../movies/request_enrichment_test.rs} | 2 +- .../movies/sync_poster_test.rs} | 4 +- crates/application/tests/person.rs | 6 ++ .../person/get_credits_test.rs} | 2 +- .../tests/get.rs => tests/person/get_test.rs} | 2 +- crates/application/tests/search.rs | 4 + .../search/execute_test.rs} | 6 +- crates/application/tests/services.rs | 4 + .../services/services_test.rs} | 6 +- crates/application/tests/social.rs | 6 ++ .../social/execute_test.rs} | 29 ++++--- .../social/local_service_test.rs} | 2 +- .../support/commands.rs} | 86 ++++++++----------- .../support/context.rs} | 4 +- crates/application/tests/support/mod.rs | 10 +++ crates/application/tests/users.rs | 26 ++++++ .../users/authorize_admin_test.rs} | 6 +- .../users/get_current_profile_test.rs} | 6 +- .../get_federated_profile_stats_test.rs} | 8 +- .../users/get_federated_profile_test.rs} | 4 +- .../users/get_local_profile_test.rs} | 18 ++-- .../users/get_page_viewer_test.rs} | 12 +-- .../users/get_profile_settings_test.rs} | 12 +-- .../users/get_settings_test.rs} | 6 +- .../users/resolve_username_to_id_test.rs} | 6 +- .../users/update_profile_fields_test.rs} | 8 +- .../users/update_profile_test.rs} | 6 +- .../users/update_settings_test.rs} | 20 ++--- crates/application/tests/watchlist.rs | 12 +++ .../add.rs => tests/watchlist/add_test.rs} | 12 +-- .../get.rs => tests/watchlist/get_test.rs} | 4 +- .../get_watchlist_for_owner_test.rs} | 12 +-- .../watchlist/is_on_test.rs} | 4 +- .../watchlist/remove_test.rs} | 4 +- crates/application/tests/worker.rs | 4 + .../worker.rs => tests/worker/worker_test.rs} | 6 +- crates/application/tests/wrapup.rs | 16 ++++ .../wrapup/compute_test.rs} | 10 +-- .../delete.rs => tests/wrapup/delete_test.rs} | 4 +- .../wrapup/generate_test.rs} | 6 +- .../wrapup/get_ready_report_test.rs} | 4 +- .../wrapup/get_wrapup_test.rs} | 4 +- .../wrapup/handle_requested_test.rs} | 2 +- .../wrapup/list_wrapups_test.rs} | 4 +- 175 files changed, 541 insertions(+), 695 deletions(-) delete mode 100644 crates/application/src/users/tests/diary_filter.rs delete mode 100644 crates/application/src/users/tests/get_users.rs create mode 100644 crates/application/tests/auth.rs rename crates/application/{src/auth/tests/login.rs => tests/auth/login_test.rs} (85%) rename crates/application/{src/auth/tests/logout.rs => tests/auth/logout_test.rs} (87%) rename crates/application/{src/auth/tests/refresh.rs => tests/auth/refresh_test.rs} (93%) rename crates/application/{src/auth/tests/register_and_login.rs => tests/auth/register_and_login_test.rs} (78%) rename crates/application/{src/auth/tests/register.rs => tests/auth/register_test.rs} (86%) create mode 100644 crates/application/tests/diary.rs rename crates/application/{src/diary/tests/delete_review.rs => tests/diary/delete_review_test.rs} (95%) rename crates/application/{src/diary/tests/edit_review.rs => tests/diary/edit_review_test.rs} (97%) rename crates/application/{src/diary/tests/get_activity_feed.rs => tests/diary/get_activity_feed_test.rs} (96%) rename crates/application/{src/diary/tests/get_diary.rs => tests/diary/get_diary_test.rs} (85%) rename crates/application/{src/diary/tests/get_movie_social_page.rs => tests/diary/get_movie_social_page_test.rs} (98%) rename crates/application/{src/diary/tests/get_review_history.rs => tests/diary/get_review_history_test.rs} (97%) rename crates/application/{src/diary/tests/get_user_feed.rs => tests/diary/get_user_feed_test.rs} (89%) rename crates/application/{src/diary/tests/log_review.rs => tests/diary/log_review_test.rs} (82%) rename crates/application/{src/diary/tests/movie_resolver.rs => tests/diary/movie_resolver_test.rs} (98%) rename crates/application/{src/diary/tests/review_logger.rs => tests/diary/review_logger_test.rs} (97%) create mode 100644 crates/application/tests/goals.rs rename crates/application/{src/goals/tests/create.rs => tests/goals/create_test.rs} (95%) rename crates/application/{src/goals/tests/delete.rs => tests/goals/delete_test.rs} (87%) rename crates/application/{src/goals/tests/get.rs => tests/goals/get_test.rs} (85%) rename crates/application/{src/goals/tests/list.rs => tests/goals/list_test.rs} (85%) rename crates/application/{src/goals/tests/update.rs => tests/goals/update_test.rs} (88%) create mode 100644 crates/application/tests/import.rs rename crates/application/{src/import/tests/apply_mapping.rs => tests/import/apply_mapping_test.rs} (95%) rename crates/application/{src/import/tests/apply_profile_and_map.rs => tests/import/apply_profile_and_map_test.rs} (91%) rename crates/application/{src/import/tests/apply_profile.rs => tests/import/apply_profile_test.rs} (95%) rename crates/application/{src/import/tests/cleanup.rs => tests/import/cleanup_test.rs} (88%) rename crates/application/{src/import/tests/create_session.rs => tests/import/create_session_test.rs} (75%) rename crates/application/{src/import/tests/delete_profile.rs => tests/import/delete_profile_test.rs} (95%) rename crates/application/{src/import/tests/execute.rs => tests/import/execute_test.rs} (98%) rename crates/application/{src/import/tests/get_mapping_stage.rs => tests/import/get_mapping_stage_test.rs} (94%) rename crates/application/{src/import/tests/get_preview_stage.rs => tests/import/get_preview_stage_test.rs} (95%) rename crates/application/{src/import/tests/get_session_state.rs => tests/import/get_session_state_test.rs} (93%) rename crates/application/{src/import/tests/list_profiles.rs => tests/import/list_profiles_test.rs} (86%) rename crates/application/{src/import/tests/save_profile.rs => tests/import/save_profile_test.rs} (92%) create mode 100644 crates/application/tests/insights.rs rename crates/application/{src/insights/tests/get.rs => tests/insights/get_test.rs} (97%) create mode 100644 crates/application/tests/integrations.rs rename crates/application/{src/integrations/tests/cleanup.rs => tests/integrations/cleanup_test.rs} (85%) rename crates/application/{src/integrations/tests/confirm.rs => tests/integrations/confirm_test.rs} (93%) rename crates/application/{src/integrations/tests/dismiss.rs => tests/integrations/dismiss_test.rs} (92%) rename crates/application/{src/integrations/tests/generate_token.rs => tests/integrations/generate_token_test.rs} (82%) rename crates/application/{src/integrations/tests/get_queue.rs => tests/integrations/get_queue_test.rs} (90%) rename crates/application/{src/integrations/tests/get_tokens.rs => tests/integrations/get_tokens_test.rs} (90%) rename crates/application/{src/integrations/tests/ingest.rs => tests/integrations/ingest_test.rs} (90%) rename crates/application/{src/integrations/tests/revoke_token.rs => tests/integrations/revoke_token_test.rs} (90%) create mode 100644 crates/application/tests/movies.rs rename crates/application/{src/movies/tests/enrich_movie.rs => tests/movies/enrich_movie_test.rs} (97%) rename crates/application/{src/movies/tests/get_movie_profile.rs => tests/movies/get_movie_profile_test.rs} (94%) rename crates/application/{src/movies/tests/get_movies.rs => tests/movies/get_movies_test.rs} (84%) rename crates/application/{src/movies/tests/request_enrichment.rs => tests/movies/request_enrichment_test.rs} (98%) rename crates/application/{src/movies/tests/sync_poster.rs => tests/movies/sync_poster_test.rs} (96%) create mode 100644 crates/application/tests/person.rs rename crates/application/{src/person/tests/get_credits.rs => tests/person/get_credits_test.rs} (89%) rename crates/application/{src/person/tests/get.rs => tests/person/get_test.rs} (89%) create mode 100644 crates/application/tests/search.rs rename crates/application/{src/search/tests/execute.rs => tests/search/execute_test.rs} (77%) create mode 100644 crates/application/tests/services.rs rename crates/application/{src/tests/services.rs => tests/services/services_test.rs} (88%) create mode 100644 crates/application/tests/social.rs rename crates/application/{src/social/tests/execute.rs => tests/social/execute_test.rs} (95%) rename crates/application/{src/social/tests/local_service.rs => tests/social/local_service_test.rs} (99%) rename crates/application/{src/test_support.rs => tests/support/commands.rs} (61%) rename crates/application/{src/test_helpers.rs => tests/support/context.rs} (98%) create mode 100644 crates/application/tests/support/mod.rs create mode 100644 crates/application/tests/users.rs rename crates/application/{src/users/tests/authorize_admin.rs => tests/users/authorize_admin_test.rs} (95%) rename crates/application/{src/users/tests/get_current_profile.rs => tests/users/get_current_profile_test.rs} (97%) rename crates/application/{src/users/tests/get_federated_profile_stats.rs => tests/users/get_federated_profile_stats_test.rs} (80%) rename crates/application/{src/users/tests/get_federated_profile.rs => tests/users/get_federated_profile_test.rs} (96%) rename crates/application/{src/users/tests/get_local_profile.rs => tests/users/get_local_profile_test.rs} (95%) rename crates/application/{src/users/tests/get_page_viewer.rs => tests/users/get_page_viewer_test.rs} (86%) rename crates/application/{src/users/tests/get_profile_settings.rs => tests/users/get_profile_settings_test.rs} (84%) rename crates/application/{src/users/tests/get_settings.rs => tests/users/get_settings_test.rs} (79%) rename crates/application/{src/users/tests/resolve_username_to_id.rs => tests/users/resolve_username_to_id_test.rs} (75%) rename crates/application/{src/users/tests/update_profile_fields.rs => tests/users/update_profile_fields_test.rs} (91%) rename crates/application/{src/users/tests/update_profile.rs => tests/users/update_profile_test.rs} (98%) rename crates/application/{src/users/tests/update_settings.rs => tests/users/update_settings_test.rs} (86%) create mode 100644 crates/application/tests/watchlist.rs rename crates/application/{src/watchlist/tests/add.rs => tests/watchlist/add_test.rs} (87%) rename crates/application/{src/watchlist/tests/get.rs => tests/watchlist/get_test.rs} (79%) rename crates/application/{src/watchlist/tests/get_watchlist_for_owner.rs => tests/watchlist/get_watchlist_for_owner_test.rs} (92%) rename crates/application/{src/watchlist/tests/is_on.rs => tests/watchlist/is_on_test.rs} (90%) rename crates/application/{src/watchlist/tests/remove.rs => tests/watchlist/remove_test.rs} (96%) create mode 100644 crates/application/tests/worker.rs rename crates/application/{src/tests/worker.rs => tests/worker/worker_test.rs} (98%) create mode 100644 crates/application/tests/wrapup.rs rename crates/application/{src/wrapup/tests/compute.rs => tests/wrapup/compute_test.rs} (93%) rename crates/application/{src/wrapup/tests/delete.rs => tests/wrapup/delete_test.rs} (93%) rename crates/application/{src/wrapup/tests/generate.rs => tests/wrapup/generate_test.rs} (95%) rename crates/application/{src/wrapup/tests/get_ready_report.rs => tests/wrapup/get_ready_report_test.rs} (97%) rename crates/application/{src/wrapup/tests/get_wrapup.rs => tests/wrapup/get_wrapup_test.rs} (93%) rename crates/application/{src/wrapup/tests/handle_requested.rs => tests/wrapup/handle_requested_test.rs} (98%) rename crates/application/{src/wrapup/tests/list_wrapups.rs => tests/wrapup/list_wrapups_test.rs} (93%) diff --git a/crates/application/src/auth/login.rs b/crates/application/src/auth/login.rs index 3c25cfc0..a47adf29 100644 --- a/crates/application/src/auth/login.rs +++ b/crates/application/src/auth/login.rs @@ -52,7 +52,3 @@ pub async fn execute(deps: &LoginDeps, query: LoginCommand) -> Result Result<(), DomainError> { deps.refresh_session.revoke(refresh_token).await } - -#[cfg(test)] -#[path = "tests/logout.rs"] -mod tests; diff --git a/crates/application/src/auth/refresh.rs b/crates/application/src/auth/refresh.rs index ec354e76..15e6d8c8 100644 --- a/crates/application/src/auth/refresh.rs +++ b/crates/application/src/auth/refresh.rs @@ -50,7 +50,3 @@ pub async fn execute( expires_at: generated.expires_at, }) } - -#[cfg(test)] -#[path = "tests/refresh.rs"] -mod tests; diff --git a/crates/application/src/auth/register.rs b/crates/application/src/auth/register.rs index 2d398d7f..b19ae607 100644 --- a/crates/application/src/auth/register.rs +++ b/crates/application/src/auth/register.rs @@ -32,7 +32,3 @@ pub async fn execute(deps: &RegisterDeps, cmd: RegisterCommand) -> Result<(), Do .save(&User::new(email, username, hash, cmd.role)) .await } - -#[cfg(test)] -#[path = "tests/register.rs"] -mod tests; diff --git a/crates/application/src/auth/register_and_login.rs b/crates/application/src/auth/register_and_login.rs index 5a1c90a0..4f11939d 100644 --- a/crates/application/src/auth/register_and_login.rs +++ b/crates/application/src/auth/register_and_login.rs @@ -44,7 +44,3 @@ pub async fn execute( ) .await } - -#[cfg(test)] -#[path = "tests/register_and_login.rs"] -mod tests; diff --git a/crates/application/src/diary/delete_review.rs b/crates/application/src/diary/delete_review.rs index 0b804236..5c6c7000 100644 --- a/crates/application/src/diary/delete_review.rs +++ b/crates/application/src/diary/delete_review.rs @@ -52,7 +52,3 @@ pub async fn execute(deps: &DeleteReviewDeps, cmd: DeleteReviewCommand) -> Resul Ok(()) } - -#[cfg(test)] -#[path = "tests/delete_review.rs"] -mod tests; diff --git a/crates/application/src/diary/edit_review.rs b/crates/application/src/diary/edit_review.rs index c1d21770..64f1c159 100644 --- a/crates/application/src/diary/edit_review.rs +++ b/crates/application/src/diary/edit_review.rs @@ -55,7 +55,3 @@ pub async fn execute(deps: &EditReviewDeps, cmd: EditReviewCommand) -> Result<() Ok(()) } - -#[cfg(test)] -#[path = "tests/edit_review.rs"] -mod tests; diff --git a/crates/application/src/diary/get_activity_feed.rs b/crates/application/src/diary/get_activity_feed.rs index 6f5efe44..ca761f5f 100644 --- a/crates/application/src/diary/get_activity_feed.rs +++ b/crates/application/src/diary/get_activity_feed.rs @@ -60,7 +60,3 @@ async fn build_following_filter( remote_actor_urls: remote_urls, }) } - -#[cfg(test)] -#[path = "tests/get_activity_feed.rs"] -mod tests; diff --git a/crates/application/src/diary/get_diary.rs b/crates/application/src/diary/get_diary.rs index dae085ca..6582e5d0 100644 --- a/crates/application/src/diary/get_diary.rs +++ b/crates/application/src/diary/get_diary.rs @@ -29,7 +29,3 @@ pub async fn execute( deps.diary.query_diary(&filter).await } - -#[cfg(test)] -#[path = "tests/get_diary.rs"] -mod tests; diff --git a/crates/application/src/diary/get_movie_social_page.rs b/crates/application/src/diary/get_movie_social_page.rs index 62d5e21e..ac7f5fdb 100644 --- a/crates/application/src/diary/get_movie_social_page.rs +++ b/crates/application/src/diary/get_movie_social_page.rs @@ -42,7 +42,3 @@ pub async fn execute( profile, }) } - -#[cfg(test)] -#[path = "tests/get_movie_social_page.rs"] -mod tests; diff --git a/crates/application/src/diary/get_review_history.rs b/crates/application/src/diary/get_review_history.rs index a8f4d3e3..53aad064 100644 --- a/crates/application/src/diary/get_review_history.rs +++ b/crates/application/src/diary/get_review_history.rs @@ -22,7 +22,3 @@ pub async fn execute( Ok((history, trend)) } - -#[cfg(test)] -#[path = "tests/get_review_history.rs"] -mod tests; diff --git a/crates/application/src/diary/get_user_feed.rs b/crates/application/src/diary/get_user_feed.rs index 00ff5467..4a424690 100644 --- a/crates/application/src/diary/get_user_feed.rs +++ b/crates/application/src/diary/get_user_feed.rs @@ -52,7 +52,3 @@ pub async fn execute( entries: page.items, }) } - -#[cfg(test)] -#[path = "tests/get_user_feed.rs"] -mod tests; diff --git a/crates/application/src/diary/log_review.rs b/crates/application/src/diary/log_review.rs index ab965add..ae1255f2 100644 --- a/crates/application/src/diary/log_review.rs +++ b/crates/application/src/diary/log_review.rs @@ -10,7 +10,3 @@ pub async fn execute( ) -> Result<(), DomainError> { review_logger.log_review(cmd).await } - -#[cfg(test)] -#[path = "tests/log_review.rs"] -mod tests; diff --git a/crates/application/src/diary/movie_resolver.rs b/crates/application/src/diary/movie_resolver.rs index 221c7b8b..25ac9697 100644 --- a/crates/application/src/diary/movie_resolver.rs +++ b/crates/application/src/diary/movie_resolver.rs @@ -179,7 +179,3 @@ impl ResolutionStrategy for ManualMovieStrategy { } } } - -#[cfg(test)] -#[path = "tests/movie_resolver.rs"] -mod tests; diff --git a/crates/application/src/diary/review_logger.rs b/crates/application/src/diary/review_logger.rs index 7472b924..a5e4683f 100644 --- a/crates/application/src/diary/review_logger.rs +++ b/crates/application/src/diary/review_logger.rs @@ -110,7 +110,3 @@ impl ReviewLogger for DefaultReviewLogger { self.event_publisher.publish(&review_event).await } } - -#[cfg(test)] -#[path = "tests/review_logger.rs"] -mod tests; diff --git a/crates/application/src/goals/create.rs b/crates/application/src/goals/create.rs index e3c6f677..4ada4d4d 100644 --- a/crates/application/src/goals/create.rs +++ b/crates/application/src/goals/create.rs @@ -47,7 +47,3 @@ pub async fn execute( current_count, }) } - -#[cfg(test)] -#[path = "tests/create.rs"] -mod tests; diff --git a/crates/application/src/goals/delete.rs b/crates/application/src/goals/delete.rs index ab25278d..ce092a77 100644 --- a/crates/application/src/goals/delete.rs +++ b/crates/application/src/goals/delete.rs @@ -23,7 +23,3 @@ pub async fn execute(deps: &GoalCommandDeps, cmd: DeleteGoalCommand) -> Result<( Ok(()) } - -#[cfg(test)] -#[path = "tests/delete.rs"] -mod tests; diff --git a/crates/application/src/goals/get.rs b/crates/application/src/goals/get.rs index 6e8db792..0fa4e5dd 100644 --- a/crates/application/src/goals/get.rs +++ b/crates/application/src/goals/get.rs @@ -25,7 +25,3 @@ pub async fn execute( current_count, })) } - -#[cfg(test)] -#[path = "tests/get.rs"] -mod tests; diff --git a/crates/application/src/goals/list.rs b/crates/application/src/goals/list.rs index 10cbeac5..939e5ed8 100644 --- a/crates/application/src/goals/list.rs +++ b/crates/application/src/goals/list.rs @@ -20,7 +20,3 @@ pub async fn execute( Ok(result) } - -#[cfg(test)] -#[path = "tests/list.rs"] -mod tests; diff --git a/crates/application/src/goals/update.rs b/crates/application/src/goals/update.rs index f91db407..d0fbe230 100644 --- a/crates/application/src/goals/update.rs +++ b/crates/application/src/goals/update.rs @@ -35,7 +35,3 @@ pub async fn execute( current_count, }) } - -#[cfg(test)] -#[path = "tests/update.rs"] -mod tests; diff --git a/crates/application/src/import/apply_mapping.rs b/crates/application/src/import/apply_mapping.rs index a9b32615..2ef944d8 100644 --- a/crates/application/src/import/apply_mapping.rs +++ b/crates/application/src/import/apply_mapping.rs @@ -87,7 +87,3 @@ async fn mark_duplicates( Ok(()) } - -#[cfg(test)] -#[path = "tests/apply_mapping.rs"] -mod tests; diff --git a/crates/application/src/import/apply_profile.rs b/crates/application/src/import/apply_profile.rs index d1330d96..9b719668 100644 --- a/crates/application/src/import/apply_profile.rs +++ b/crates/application/src/import/apply_profile.rs @@ -27,7 +27,3 @@ pub async fn execute( session.row_results = None; deps.import_session.update(&session).await } - -#[cfg(test)] -#[path = "tests/apply_profile.rs"] -mod tests; diff --git a/crates/application/src/import/apply_profile_and_map.rs b/crates/application/src/import/apply_profile_and_map.rs index ad214ef0..09ec468f 100644 --- a/crates/application/src/import/apply_profile_and_map.rs +++ b/crates/application/src/import/apply_profile_and_map.rs @@ -58,7 +58,3 @@ pub async fn execute( ) .await } - -#[cfg(test)] -#[path = "tests/apply_profile_and_map.rs"] -mod tests; diff --git a/crates/application/src/import/cleanup.rs b/crates/application/src/import/cleanup.rs index 2bd6416d..df9a0bc0 100644 --- a/crates/application/src/import/cleanup.rs +++ b/crates/application/src/import/cleanup.rs @@ -5,7 +5,3 @@ use domain::{errors::DomainError, ports::ImportSessionRepository}; pub async fn execute(import_session: Arc) -> Result { import_session.delete_expired().await } - -#[cfg(test)] -#[path = "tests/cleanup.rs"] -mod tests; diff --git a/crates/application/src/import/create_session.rs b/crates/application/src/import/create_session.rs index cc22281a..f3aef72a 100644 --- a/crates/application/src/import/create_session.rs +++ b/crates/application/src/import/create_session.rs @@ -41,7 +41,3 @@ pub async fn execute( sample_rows, }) } - -#[cfg(test)] -#[path = "tests/create_session.rs"] -mod tests; diff --git a/crates/application/src/import/delete_profile.rs b/crates/application/src/import/delete_profile.rs index 69e9fcb0..f8ae207b 100644 --- a/crates/application/src/import/delete_profile.rs +++ b/crates/application/src/import/delete_profile.rs @@ -18,7 +18,3 @@ pub async fn execute( .ok_or_else(|| DomainError::NotFound("import profile".into()))?; deps.import_profile.delete(&profile_id).await } - -#[cfg(test)] -#[path = "tests/delete_profile.rs"] -mod tests; diff --git a/crates/application/src/import/execute.rs b/crates/application/src/import/execute.rs index 73df7b7a..808e3276 100644 --- a/crates/application/src/import/execute.rs +++ b/crates/application/src/import/execute.rs @@ -88,10 +88,6 @@ pub async fn execute( }) } -#[cfg(test)] -#[path = "tests/execute.rs"] -mod tests; - /// Row-level validation failures keep their bare message in the summary. fn import_error_message(e: DomainError) -> String { match e { diff --git a/crates/application/src/import/get_mapping_stage.rs b/crates/application/src/import/get_mapping_stage.rs index 583fa107..67b1b15a 100644 --- a/crates/application/src/import/get_mapping_stage.rs +++ b/crates/application/src/import/get_mapping_stage.rs @@ -33,7 +33,3 @@ pub async fn execute( sample_rows, }) } - -#[cfg(test)] -#[path = "tests/get_mapping_stage.rs"] -mod tests; diff --git a/crates/application/src/import/get_preview_stage.rs b/crates/application/src/import/get_preview_stage.rs index f9e6aaf5..56886a54 100644 --- a/crates/application/src/import/get_preview_stage.rs +++ b/crates/application/src/import/get_preview_stage.rs @@ -37,7 +37,3 @@ pub async fn execute( Ok(PreviewStage::Ready(PreviewRows { columns, rows })) } - -#[cfg(test)] -#[path = "tests/get_preview_stage.rs"] -mod tests; diff --git a/crates/application/src/import/get_session_state.rs b/crates/application/src/import/get_session_state.rs index 4898ad3c..241d35d8 100644 --- a/crates/application/src/import/get_session_state.rs +++ b/crates/application/src/import/get_session_state.rs @@ -37,7 +37,3 @@ pub async fn execute( row_count, }) } - -#[cfg(test)] -#[path = "tests/get_session_state.rs"] -mod tests; diff --git a/crates/application/src/import/list_profiles.rs b/crates/application/src/import/list_profiles.rs index ca5e214d..ba731597 100644 --- a/crates/application/src/import/list_profiles.rs +++ b/crates/application/src/import/list_profiles.rs @@ -7,7 +7,3 @@ pub async fn execute( ) -> Result, DomainError> { deps.import_profile.list_for_user(user_id).await } - -#[cfg(test)] -#[path = "tests/list_profiles.rs"] -mod tests; diff --git a/crates/application/src/import/save_profile.rs b/crates/application/src/import/save_profile.rs index cfc4273d..5b9d8d49 100644 --- a/crates/application/src/import/save_profile.rs +++ b/crates/application/src/import/save_profile.rs @@ -33,7 +33,3 @@ pub async fn execute( deps.import_profile.save(&profile).await?; Ok(id) } - -#[cfg(test)] -#[path = "tests/save_profile.rs"] -mod tests; diff --git a/crates/application/src/insights/get.rs b/crates/application/src/insights/get.rs index ae610a9a..3f907bd2 100644 --- a/crates/application/src/insights/get.rs +++ b/crates/application/src/insights/get.rs @@ -18,7 +18,3 @@ pub async fn execute( &rows, tz, )) } - -#[cfg(test)] -#[path = "tests/get.rs"] -mod tests; diff --git a/crates/application/src/integrations/cleanup.rs b/crates/application/src/integrations/cleanup.rs index 02d7fc80..3492ff42 100644 --- a/crates/application/src/integrations/cleanup.rs +++ b/crates/application/src/integrations/cleanup.rs @@ -9,7 +9,3 @@ pub async fn execute(watch_event_command: Arc) -> Result< .delete_non_pending_older_than(cutoff) .await } - -#[cfg(test)] -#[path = "tests/cleanup.rs"] -mod tests; diff --git a/crates/application/src/integrations/confirm.rs b/crates/application/src/integrations/confirm.rs index 027a6108..6bf6209e 100644 --- a/crates/application/src/integrations/confirm.rs +++ b/crates/application/src/integrations/confirm.rs @@ -67,7 +67,3 @@ pub async fn execute( Ok(confirmed) } - -#[cfg(test)] -#[path = "tests/confirm.rs"] -mod tests; diff --git a/crates/application/src/integrations/dismiss.rs b/crates/application/src/integrations/dismiss.rs index cc90721e..12246c6e 100644 --- a/crates/application/src/integrations/dismiss.rs +++ b/crates/application/src/integrations/dismiss.rs @@ -41,7 +41,3 @@ pub async fn execute( Ok(count as u32) } - -#[cfg(test)] -#[path = "tests/dismiss.rs"] -mod tests; diff --git a/crates/application/src/integrations/generate_token.rs b/crates/application/src/integrations/generate_token.rs index aeee1424..7fe845a0 100644 --- a/crates/application/src/integrations/generate_token.rs +++ b/crates/application/src/integrations/generate_token.rs @@ -36,7 +36,3 @@ pub fn hash_token(plaintext: &str) -> String { hasher.update(plaintext.as_bytes()); hex::encode(hasher.finalize()) } - -#[cfg(test)] -#[path = "tests/generate_token.rs"] -mod tests; diff --git a/crates/application/src/integrations/get_queue.rs b/crates/application/src/integrations/get_queue.rs index 1516ed2c..13529eb6 100644 --- a/crates/application/src/integrations/get_queue.rs +++ b/crates/application/src/integrations/get_queue.rs @@ -9,7 +9,3 @@ pub async fn execute( let user_id = UserId::from_uuid(query.user_id); deps.watch_event_query.list_pending(&user_id).await } - -#[cfg(test)] -#[path = "tests/get_queue.rs"] -mod tests; diff --git a/crates/application/src/integrations/get_tokens.rs b/crates/application/src/integrations/get_tokens.rs index 66faf731..8be2a708 100644 --- a/crates/application/src/integrations/get_tokens.rs +++ b/crates/application/src/integrations/get_tokens.rs @@ -9,7 +9,3 @@ pub async fn execute( let user_id = UserId::from_uuid(query.user_id); deps.webhook_token.list_by_user(&user_id).await } - -#[cfg(test)] -#[path = "tests/get_tokens.rs"] -mod tests; diff --git a/crates/application/src/integrations/ingest.rs b/crates/application/src/integrations/ingest.rs index 384baa2c..d7fe4da7 100644 --- a/crates/application/src/integrations/ingest.rs +++ b/crates/application/src/integrations/ingest.rs @@ -62,7 +62,3 @@ pub async fn execute( Ok(()) } - -#[cfg(test)] -#[path = "tests/ingest.rs"] -mod tests; diff --git a/crates/application/src/integrations/revoke_token.rs b/crates/application/src/integrations/revoke_token.rs index 8122cc23..a33ba179 100644 --- a/crates/application/src/integrations/revoke_token.rs +++ b/crates/application/src/integrations/revoke_token.rs @@ -13,7 +13,3 @@ pub async fn execute( let token_id = WebhookTokenId::from_uuid(cmd.token_id); deps.webhook_token.delete(&token_id, &user_id).await } - -#[cfg(test)] -#[path = "tests/revoke_token.rs"] -mod tests; diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index d47b1b82..e3eaeca3 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -19,16 +19,6 @@ pub mod users; pub mod watchlist; pub mod wrapup; -#[cfg(test)] -pub mod test_helpers; - -#[cfg(test)] -mod test_support; - -#[cfg(test)] -#[path = "tests/services.rs"] -mod services_tests; - pub use deps::Deps; pub use deps::{WorkerDeps, WorkerServices}; pub use movies::MovieDiscoveryIndexer; diff --git a/crates/application/src/movies/enrich_movie.rs b/crates/application/src/movies/enrich_movie.rs index f95d728c..32f9f838 100644 --- a/crates/application/src/movies/enrich_movie.rs +++ b/crates/application/src/movies/enrich_movie.rs @@ -76,7 +76,3 @@ fn extract_persons(cast: &[CastMember], crew: &[CrewMember]) -> Vec { seen.into_values().collect() } - -#[cfg(test)] -#[path = "tests/enrich_movie.rs"] -mod tests; diff --git a/crates/application/src/movies/get_movie_profile.rs b/crates/application/src/movies/get_movie_profile.rs index 03335e6e..089f693b 100644 --- a/crates/application/src/movies/get_movie_profile.rs +++ b/crates/application/src/movies/get_movie_profile.rs @@ -76,7 +76,3 @@ pub async fn execute( } })) } - -#[cfg(test)] -#[path = "tests/get_movie_profile.rs"] -mod tests; diff --git a/crates/application/src/movies/get_movies.rs b/crates/application/src/movies/get_movies.rs index f4fd975f..cc68de9e 100644 --- a/crates/application/src/movies/get_movies.rs +++ b/crates/application/src/movies/get_movies.rs @@ -19,7 +19,3 @@ pub async fn execute( }; deps.movie.list_movies(&page, &filter).await } - -#[cfg(test)] -#[path = "tests/get_movies.rs"] -mod tests; diff --git a/crates/application/src/movies/request_enrichment.rs b/crates/application/src/movies/request_enrichment.rs index bb25d69e..95faf7b8 100644 --- a/crates/application/src/movies/request_enrichment.rs +++ b/crates/application/src/movies/request_enrichment.rs @@ -42,7 +42,3 @@ pub async fn fetch_if_stale( Err(e) => Err(e), } } - -#[cfg(test)] -#[path = "tests/request_enrichment.rs"] -mod tests; diff --git a/crates/application/src/movies/sync_poster.rs b/crates/application/src/movies/sync_poster.rs index 99a75a47..a778f513 100644 --- a/crates/application/src/movies/sync_poster.rs +++ b/crates/application/src/movies/sync_poster.rs @@ -83,7 +83,3 @@ pub async fn execute(deps: &SyncPosterDeps, cmd: SyncPosterCommand) -> Result<() Ok(()) } - -#[cfg(test)] -#[path = "tests/sync_poster.rs"] -mod tests; diff --git a/crates/application/src/person/get.rs b/crates/application/src/person/get.rs index 96267203..3405baa4 100644 --- a/crates/application/src/person/get.rs +++ b/crates/application/src/person/get.rs @@ -21,7 +21,3 @@ pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result Result Result { deps.search_port.search(&query).await } - -#[cfg(test)] -#[path = "tests/execute.rs"] -mod tests; diff --git a/crates/application/src/social/execute.rs b/crates/application/src/social/execute.rs index b5d97f39..6b928f0a 100644 --- a/crates/application/src/social/execute.rs +++ b/crates/application/src/social/execute.rs @@ -60,7 +60,3 @@ pub async fn execute_command(deps: &SocialCommandDeps, cmd: SocialCmd) -> Result }; deps.event_publisher.publish(&event).await } - -#[cfg(test)] -#[path = "tests/execute.rs"] -mod tests; diff --git a/crates/application/src/social/local_service.rs b/crates/application/src/social/local_service.rs index 368e0ffd..599362b4 100644 --- a/crates/application/src/social/local_service.rs +++ b/crates/application/src/social/local_service.rs @@ -263,7 +263,3 @@ impl ResolvedFollow for LocalSocialService { Ok(()) } } - -#[cfg(test)] -#[path = "tests/local_service.rs"] -mod tests; diff --git a/crates/application/src/users/authorize_admin.rs b/crates/application/src/users/authorize_admin.rs index bec7f06f..1a7e7765 100644 --- a/crates/application/src/users/authorize_admin.rs +++ b/crates/application/src/users/authorize_admin.rs @@ -13,7 +13,3 @@ pub async fn execute( let found = deps.user.find_by_id(&id).await?; Ok(found.map(|user| matches!(user.role(), UserRole::Admin))) } - -#[cfg(test)] -#[path = "tests/authorize_admin.rs"] -mod tests; diff --git a/crates/application/src/users/diary_filter.rs b/crates/application/src/users/diary_filter.rs index e52744e8..326b9cf2 100644 --- a/crates/application/src/users/diary_filter.rs +++ b/crates/application/src/users/diary_filter.rs @@ -38,5 +38,43 @@ pub(super) fn paged_user_filter( } #[cfg(test)] -#[path = "tests/diary_filter.rs"] -mod tests; +mod tests { + use super::*; + + #[test] + fn feed_sort_to_direction_all_variants() { + assert!(matches!( + feed_sort_to_direction(FeedSortBy::Date), + ReviewSortBy::Descending + )); + assert!(matches!( + feed_sort_to_direction(FeedSortBy::DateAsc), + ReviewSortBy::Ascending + )); + assert!(matches!( + feed_sort_to_direction(FeedSortBy::Rating), + ReviewSortBy::ByRatingDesc + )); + assert!(matches!( + feed_sort_to_direction(FeedSortBy::RatingAsc), + ReviewSortBy::ByRatingAsc + )); + } + + #[test] + fn paged_user_filter_builds_correctly() { + let uid = UserId::from_uuid(uuid::Uuid::new_v4()); + let filter = paged_user_filter( + uid.clone(), + ReviewSortBy::Descending, + Some(20), + Some(5), + Some("blade".into()), + false, + ) + .unwrap(); + + assert_eq!(filter.user_id.unwrap().value(), uid.value()); + assert_eq!(filter.search.as_deref(), Some("blade")); + } +} diff --git a/crates/application/src/users/get_current_profile.rs b/crates/application/src/users/get_current_profile.rs index 3a0593e0..4e460fd5 100644 --- a/crates/application/src/users/get_current_profile.rs +++ b/crates/application/src/users/get_current_profile.rs @@ -50,7 +50,3 @@ pub async fn execute( role: found.role().clone(), }) } - -#[cfg(test)] -#[path = "tests/get_current_profile.rs"] -mod tests; diff --git a/crates/application/src/users/get_federated_profile.rs b/crates/application/src/users/get_federated_profile.rs index 0b3198d7..ba16c3bf 100644 --- a/crates/application/src/users/get_federated_profile.rs +++ b/crates/application/src/users/get_federated_profile.rs @@ -13,7 +13,3 @@ pub async fn execute( None => Ok(None), } } - -#[cfg(test)] -#[path = "tests/get_federated_profile.rs"] -mod tests; diff --git a/crates/application/src/users/get_federated_profile_stats.rs b/crates/application/src/users/get_federated_profile_stats.rs index e37b7ff7..913c927e 100644 --- a/crates/application/src/users/get_federated_profile_stats.rs +++ b/crates/application/src/users/get_federated_profile_stats.rs @@ -83,7 +83,3 @@ pub async fn execute( } } } - -#[cfg(test)] -#[path = "tests/get_federated_profile_stats.rs"] -mod tests; diff --git a/crates/application/src/users/get_local_profile.rs b/crates/application/src/users/get_local_profile.rs index fbdc8b30..27687352 100644 --- a/crates/application/src/users/get_local_profile.rs +++ b/crates/application/src/users/get_local_profile.rs @@ -144,7 +144,3 @@ async fn load_social_counts( .collect(); (following, followers, pending) } - -#[cfg(test)] -#[path = "tests/get_local_profile.rs"] -mod tests; diff --git a/crates/application/src/users/get_page_viewer.rs b/crates/application/src/users/get_page_viewer.rs index f2e7d24c..64927bf6 100644 --- a/crates/application/src/users/get_page_viewer.rs +++ b/crates/application/src/users/get_page_viewer.rs @@ -38,7 +38,3 @@ pub async fn execute(deps: &GetPageViewerDeps, user_id: Uuid) -> Result>>, envelope: EventEnve tracing::error!("ack failed: {e}"); } } - -#[cfg(test)] -#[path = "tests/worker.rs"] -mod tests; diff --git a/crates/application/src/wrapup/compute.rs b/crates/application/src/wrapup/compute.rs index 5a4b56e7..0a857566 100644 --- a/crates/application/src/wrapup/compute.rs +++ b/crates/application/src/wrapup/compute.rs @@ -19,7 +19,3 @@ pub async fn execute( &rows, )) } - -#[cfg(test)] -#[path = "tests/compute.rs"] -mod tests; diff --git a/crates/application/src/wrapup/delete.rs b/crates/application/src/wrapup/delete.rs index 84ac0ef9..2d72f524 100644 --- a/crates/application/src/wrapup/delete.rs +++ b/crates/application/src/wrapup/delete.rs @@ -11,7 +11,3 @@ pub async fn execute(deps: &DeleteWrapUpDeps, id: WrapUpId) -> Result<(), Domain deps.wrapup_repo.delete(&id).await } - -#[cfg(test)] -#[path = "tests/delete.rs"] -mod tests; diff --git a/crates/application/src/wrapup/generate.rs b/crates/application/src/wrapup/generate.rs index 01dd5d1c..f560baf6 100644 --- a/crates/application/src/wrapup/generate.rs +++ b/crates/application/src/wrapup/generate.rs @@ -59,7 +59,3 @@ pub async fn execute( Ok(id) } - -#[cfg(test)] -#[path = "tests/generate.rs"] -mod tests; diff --git a/crates/application/src/wrapup/get_ready_report.rs b/crates/application/src/wrapup/get_ready_report.rs index fc92654c..8edc7e85 100644 --- a/crates/application/src/wrapup/get_ready_report.rs +++ b/crates/application/src/wrapup/get_ready_report.rs @@ -30,7 +30,3 @@ pub async fn execute( .report .ok_or_else(|| DomainError::NotFound("wrap-up report".into())) } - -#[cfg(test)] -#[path = "tests/get_ready_report.rs"] -mod tests; diff --git a/crates/application/src/wrapup/get_wrapup.rs b/crates/application/src/wrapup/get_wrapup.rs index 0613849f..a2cb1518 100644 --- a/crates/application/src/wrapup/get_wrapup.rs +++ b/crates/application/src/wrapup/get_wrapup.rs @@ -10,7 +10,3 @@ pub async fn execute( ) -> Result, DomainError> { deps.wrapup_repo.get_by_id(&id).await } - -#[cfg(test)] -#[path = "tests/get_wrapup.rs"] -mod tests; diff --git a/crates/application/src/wrapup/handle_requested.rs b/crates/application/src/wrapup/handle_requested.rs index 0b86e46d..70a93bfb 100644 --- a/crates/application/src/wrapup/handle_requested.rs +++ b/crates/application/src/wrapup/handle_requested.rs @@ -52,7 +52,3 @@ pub async fn execute( } } } - -#[cfg(test)] -#[path = "tests/handle_requested.rs"] -mod tests; diff --git a/crates/application/src/wrapup/list_wrapups.rs b/crates/application/src/wrapup/list_wrapups.rs index c270158c..e4235cb2 100644 --- a/crates/application/src/wrapup/list_wrapups.rs +++ b/crates/application/src/wrapup/list_wrapups.rs @@ -18,7 +18,3 @@ pub async fn execute( None => deps.wrapup_repo.list_global().await, } } - -#[cfg(test)] -#[path = "tests/list_wrapups.rs"] -mod tests; diff --git a/crates/application/tests/auth.rs b/crates/application/tests/auth.rs new file mode 100644 index 00000000..2d11147f --- /dev/null +++ b/crates/application/tests/auth.rs @@ -0,0 +1,12 @@ +mod support; + +#[path = "auth/login_test.rs"] +mod login_test; +#[path = "auth/logout_test.rs"] +mod logout_test; +#[path = "auth/refresh_test.rs"] +mod refresh_test; +#[path = "auth/register_and_login_test.rs"] +mod register_and_login_test; +#[path = "auth/register_test.rs"] +mod register_test; diff --git a/crates/application/src/auth/tests/login.rs b/crates/application/tests/auth/login_test.rs similarity index 85% rename from crates/application/src/auth/tests/login.rs rename to crates/application/tests/auth/login_test.rs index 6b422981..492bdbd9 100644 --- a/crates/application/src/auth/tests/login.rs +++ b/crates/application/tests/auth/login_test.rs @@ -1,15 +1,13 @@ -use crate::test_support::{login_command, register_command}; +use crate::support::TestContextBuilder; +use crate::support::{login_command, register_command}; use std::sync::Arc; use domain::testing::InMemoryStore; -use crate::{ - auth::{ - commands::RegisterCommand, - deps::{LoginDeps, RegisterDeps}, - login, register, - }, - test_helpers::TestContextBuilder, +use application::auth::{ + commands::RegisterCommand, + deps::{LoginDeps, RegisterDeps}, + login, register, }; async fn setup_user(b: &TestContextBuilder, email: &str, password: &str) { @@ -30,7 +28,7 @@ async fn setup_user(b: &TestContextBuilder, email: &str, password: &str) { } #[tokio::test] -async fn test_login_valid_credentials_returns_token() { +async fn login_with_valid_credentials_returns_a_token() { let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); setup_user(&b, "carol@example.com", "secret123").await; @@ -52,7 +50,7 @@ async fn test_login_valid_credentials_returns_token() { } #[tokio::test] -async fn test_login_wrong_password_fails() { +async fn login_with_the_wrong_password_fails() { let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); setup_user(&b, "dave@example.com", "correct_password").await; @@ -70,7 +68,7 @@ async fn test_login_wrong_password_fails() { } #[tokio::test] -async fn test_login_unknown_email_fails() { +async fn login_with_an_unknown_email_fails() { let b = TestContextBuilder::new(); let deps = LoginDeps { user: b.user_repo.clone(), diff --git a/crates/application/src/auth/tests/logout.rs b/crates/application/tests/auth/logout_test.rs similarity index 87% rename from crates/application/src/auth/tests/logout.rs rename to crates/application/tests/auth/logout_test.rs index 582cf530..fec5de05 100644 --- a/crates/application/src/auth/tests/logout.rs +++ b/crates/application/tests/auth/logout_test.rs @@ -1,14 +1,12 @@ -use crate::test_support::{login_command, register_command}; +use crate::support::TestContextBuilder; +use crate::support::{login_command, register_command}; use std::sync::Arc; use domain::testing::InMemoryStore; -use crate::{ - auth::{ - deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterDeps}, - login, logout, refresh, register, - }, - test_helpers::TestContextBuilder, +use application::auth::{ + deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterDeps}, + login, logout, refresh, register, }; #[tokio::test] diff --git a/crates/application/src/auth/tests/refresh.rs b/crates/application/tests/auth/refresh_test.rs similarity index 93% rename from crates/application/src/auth/tests/refresh.rs rename to crates/application/tests/auth/refresh_test.rs index cbaf3c77..7161731f 100644 --- a/crates/application/src/auth/tests/refresh.rs +++ b/crates/application/tests/auth/refresh_test.rs @@ -1,14 +1,12 @@ -use crate::test_support::{login_command, register_command}; +use crate::support::TestContextBuilder; +use crate::support::{login_command, register_command}; use std::sync::Arc; use domain::testing::InMemoryStore; -use crate::{ - auth::{ - deps::{LoginDeps, RefreshDeps, RegisterDeps}, - login, refresh, register, - }, - test_helpers::TestContextBuilder, +use application::auth::{ + deps::{LoginDeps, RefreshDeps, RegisterDeps}, + login, refresh, register, }; async fn login_user(b: &TestContextBuilder) -> login::LoginResult { diff --git a/crates/application/src/auth/tests/register_and_login.rs b/crates/application/tests/auth/register_and_login_test.rs similarity index 78% rename from crates/application/src/auth/tests/register_and_login.rs rename to crates/application/tests/auth/register_and_login_test.rs index 79eadaf4..5318753d 100644 --- a/crates/application/src/auth/tests/register_and_login.rs +++ b/crates/application/tests/auth/register_and_login_test.rs @@ -1,7 +1,7 @@ -use crate::auth::commands::RegisterAndLoginCommand; -use crate::auth::deps::RegisterAndLoginDeps; -use crate::auth::register_and_login; -use crate::test_helpers::TestContextBuilder; +use crate::support::TestContextBuilder; +use application::auth::commands::RegisterAndLoginCommand; +use application::auth::deps::RegisterAndLoginDeps; +use application::auth::register_and_login; #[tokio::test] async fn registers_and_returns_token() { diff --git a/crates/application/src/auth/tests/register.rs b/crates/application/tests/auth/register_test.rs similarity index 86% rename from crates/application/src/auth/tests/register.rs rename to crates/application/tests/auth/register_test.rs index 71025243..e28f30be 100644 --- a/crates/application/src/auth/tests/register.rs +++ b/crates/application/tests/auth/register_test.rs @@ -1,21 +1,19 @@ -use crate::test_support::register_command; +use crate::support::TestContextBuilder; +use crate::support::register_command; use std::sync::Arc; use domain::ports::UserRepository; use domain::testing::InMemoryStore; use domain::value_objects::Email; -use crate::{ - auth::{commands::RegisterCommand, deps::RegisterDeps, register}, - test_helpers::TestContextBuilder, -}; +use application::auth::{commands::RegisterCommand, deps::RegisterDeps, register}; fn cmd(email: &str) -> RegisterCommand { register_command(email, "alice") } #[tokio::test] -async fn test_register_creates_user() { +async fn registering_creates_the_user() { let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); let deps = RegisterDeps { @@ -35,7 +33,7 @@ async fn test_register_creates_user() { } #[tokio::test] -async fn test_register_duplicate_email_fails() { +async fn registering_with_a_duplicate_email_fails() { let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); let deps = RegisterDeps { @@ -52,7 +50,7 @@ async fn test_register_duplicate_email_fails() { } #[tokio::test] -async fn test_register_short_password_fails() { +async fn registering_with_a_short_password_fails() { let b = TestContextBuilder::new(); let deps = RegisterDeps { user: b.user_repo.clone(), diff --git a/crates/application/tests/diary.rs b/crates/application/tests/diary.rs new file mode 100644 index 00000000..2850e033 --- /dev/null +++ b/crates/application/tests/diary.rs @@ -0,0 +1,22 @@ +mod support; + +#[path = "diary/delete_review_test.rs"] +mod delete_review_test; +#[path = "diary/edit_review_test.rs"] +mod edit_review_test; +#[path = "diary/get_activity_feed_test.rs"] +mod get_activity_feed_test; +#[path = "diary/get_diary_test.rs"] +mod get_diary_test; +#[path = "diary/get_movie_social_page_test.rs"] +mod get_movie_social_page_test; +#[path = "diary/get_review_history_test.rs"] +mod get_review_history_test; +#[path = "diary/get_user_feed_test.rs"] +mod get_user_feed_test; +#[path = "diary/log_review_test.rs"] +mod log_review_test; +#[path = "diary/movie_resolver_test.rs"] +mod movie_resolver_test; +#[path = "diary/review_logger_test.rs"] +mod review_logger_test; diff --git a/crates/application/src/diary/tests/delete_review.rs b/crates/application/tests/diary/delete_review_test.rs similarity index 95% rename from crates/application/src/diary/tests/delete_review.rs rename to crates/application/tests/diary/delete_review_test.rs index 121e0d42..9ef43edc 100644 --- a/crates/application/src/diary/tests/delete_review.rs +++ b/crates/application/tests/diary/delete_review_test.rs @@ -7,12 +7,12 @@ use domain::{ value_objects::{MovieId, UserId}, }; -use crate::{ +use application::{ diary::commands::DeleteReviewCommand, diary::delete_review, diary::deps::DeleteReviewDeps, }; #[tokio::test] -async fn test_delete_review_removes_it() { +async fn deleting_a_review_removes_it() { let movies = InMemoryStore::new(); let reviews = Arc::clone(&movies); let diary = FakeDiaryQuery::new(); @@ -51,7 +51,7 @@ async fn test_delete_review_removes_it() { } #[tokio::test] -async fn test_delete_review_wrong_user_is_unauthorized() { +async fn deleting_another_users_review_is_unauthorized() { let reviews = InMemoryStore::new(); let diary = FakeDiaryQuery::new(); let movies = Arc::clone(&reviews); diff --git a/crates/application/src/diary/tests/edit_review.rs b/crates/application/tests/diary/edit_review_test.rs similarity index 97% rename from crates/application/src/diary/tests/edit_review.rs rename to crates/application/tests/diary/edit_review_test.rs index e47e3d7b..da78fd4c 100644 --- a/crates/application/src/diary/tests/edit_review.rs +++ b/crates/application/tests/diary/edit_review_test.rs @@ -1,4 +1,4 @@ -use crate::test_support::edit_review_command; +use crate::support::edit_review_command; use std::sync::Arc; use domain::{ @@ -8,7 +8,7 @@ use domain::{ value_objects::{Comment, MovieId, ReviewId, UserId, WatchMedium}, }; -use crate::diary::{commands::EditReviewCommand, deps::EditReviewDeps, edit_review}; +use application::diary::{commands::EditReviewCommand, deps::EditReviewDeps, edit_review}; async fn setup() -> ( Arc, diff --git a/crates/application/src/diary/tests/get_activity_feed.rs b/crates/application/tests/diary/get_activity_feed_test.rs similarity index 96% rename from crates/application/src/diary/tests/get_activity_feed.rs rename to crates/application/tests/diary/get_activity_feed_test.rs index 7e923f56..4fe14907 100644 --- a/crates/application/src/diary/tests/get_activity_feed.rs +++ b/crates/application/tests/diary/get_activity_feed_test.rs @@ -1,3 +1,4 @@ +use crate::support::TestContextBuilder; use std::sync::Arc; use async_trait::async_trait; @@ -5,9 +6,9 @@ use domain::errors::DomainError; use domain::testing::InMemoryStore; use domain::value_objects::{FollowRelation, SocialActor, SocialIdentity, UserId}; -use crate::{ +use application::{ config::AppConfig, diary::deps::GetActivityFeedDeps, diary::get_activity_feed, - diary::queries::GetActivityFeedQuery, test_helpers::TestContextBuilder, + diary::queries::GetActivityFeedQuery, }; fn default_deps() -> GetActivityFeedDeps { @@ -131,7 +132,7 @@ async fn following_filter_separates_local_and_remote() { refresh_ttl_seconds: 2_592_000, secure_cookies: false, cors_origins: String::new(), - wrapup: crate::config::WrapUpConfig { + wrapup: application::config::WrapUpConfig { font_path: None, logo_path: None, bg_dir: None, diff --git a/crates/application/src/diary/tests/get_diary.rs b/crates/application/tests/diary/get_diary_test.rs similarity index 85% rename from crates/application/src/diary/tests/get_diary.rs rename to crates/application/tests/diary/get_diary_test.rs index 5a2fd04c..382ae19b 100644 --- a/crates/application/src/diary/tests/get_diary.rs +++ b/crates/application/tests/diary/get_diary_test.rs @@ -1,7 +1,7 @@ use domain::testing::FakeDiaryQuery; use std::sync::Arc; -use crate::{diary::deps::GetDiaryDeps, diary::get_diary, diary::queries::GetDiaryQuery}; +use application::{diary::deps::GetDiaryDeps, diary::get_diary, diary::queries::GetDiaryQuery}; #[tokio::test] async fn returns_empty_page() { diff --git a/crates/application/src/diary/tests/get_movie_social_page.rs b/crates/application/tests/diary/get_movie_social_page_test.rs similarity index 98% rename from crates/application/src/diary/tests/get_movie_social_page.rs rename to crates/application/tests/diary/get_movie_social_page_test.rs index dfab284d..60f91880 100644 --- a/crates/application/src/diary/tests/get_movie_social_page.rs +++ b/crates/application/tests/diary/get_movie_social_page_test.rs @@ -8,7 +8,7 @@ use domain::{ testing::{FakeDiaryQuery, InMemoryStore}, }; -use crate::{ +use application::{ diary::deps::GetMovieSocialPageDeps, diary::get_movie_social_page, diary::queries::GetMovieSocialPageQuery, }; diff --git a/crates/application/src/diary/tests/get_review_history.rs b/crates/application/tests/diary/get_review_history_test.rs similarity index 97% rename from crates/application/src/diary/tests/get_review_history.rs rename to crates/application/tests/diary/get_review_history_test.rs index 6306e956..774826b9 100644 --- a/crates/application/src/diary/tests/get_review_history.rs +++ b/crates/application/tests/diary/get_review_history_test.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::{ports::DiaryQuery, services::review_history::Trend}; -use crate::{ +use application::{ diary::deps::GetReviewHistoryDeps, diary::get_review_history, diary::queries::GetReviewHistoryQuery, }; diff --git a/crates/application/src/diary/tests/get_user_feed.rs b/crates/application/tests/diary/get_user_feed_test.rs similarity index 89% rename from crates/application/src/diary/tests/get_user_feed.rs rename to crates/application/tests/diary/get_user_feed_test.rs index 6ada6df8..7b890a9c 100644 --- a/crates/application/src/diary/tests/get_user_feed.rs +++ b/crates/application/tests/diary/get_user_feed_test.rs @@ -1,4 +1,4 @@ -use crate::test_support::register_command; +use crate::support::register_command; use domain::testing::{test_movie_with_year, test_review}; use std::sync::Arc; @@ -9,11 +9,11 @@ use domain::models::{DiaryEntry, collections::Paginated}; use domain::testing::FakeDiaryQuery; use domain::value_objects::{Email, UserId}; -use crate::auth::deps::RegisterDeps; -use crate::auth::register; -use crate::diary::deps::GetUserFeedDeps; -use crate::diary::get_user_feed; -use crate::test_helpers::TestContextBuilder; +use crate::support::TestContextBuilder; +use application::auth::deps::RegisterDeps; +use application::auth::register; +use application::diary::deps::GetUserFeedDeps; +use application::diary::get_user_feed; async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) { let deps = RegisterDeps { diff --git a/crates/application/src/diary/tests/log_review.rs b/crates/application/tests/diary/log_review_test.rs similarity index 82% rename from crates/application/src/diary/tests/log_review.rs rename to crates/application/tests/diary/log_review_test.rs index 78f11a32..3dc95590 100644 --- a/crates/application/src/diary/tests/log_review.rs +++ b/crates/application/tests/diary/log_review_test.rs @@ -1,4 +1,5 @@ -use crate::test_support::{log_review_command, log_review_command_manual}; +use crate::support::TestContextBuilder; +use crate::support::{log_review_command, log_review_command_manual}; use domain::testing::test_movie_with_year; use std::sync::Arc; @@ -6,16 +7,15 @@ use domain::ports::MovieCommand; use domain::testing::{InMemoryStore, NoopEventPublisher}; -use crate::{ - diary::commands::LogReviewCommand, diary::log_review, - diary::review_logger::DefaultReviewLogger, test_helpers::TestContextBuilder, +use application::{ + diary::commands::LogReviewCommand, diary::log_review, diary::review_logger::DefaultReviewLogger, }; fn build_logger( movies: &Arc, reviews: &Arc, events: &Arc, -) -> Arc { +) -> Arc { Arc::new(DefaultReviewLogger::new( Arc::clone(movies) as _, Arc::clone(movies) as _, @@ -27,7 +27,7 @@ fn build_logger( } #[tokio::test] -async fn test_log_review_creates_movie_and_review() { +async fn logging_a_review_creates_the_movie_and_the_review() { let movies = InMemoryStore::new(); let reviews = Arc::clone(&movies); let events = NoopEventPublisher::new(); @@ -43,7 +43,7 @@ async fn test_log_review_creates_movie_and_review() { } #[tokio::test] -async fn test_log_review_reuses_existing_movie() { +async fn logging_a_review_reuses_an_existing_movie() { let movies = InMemoryStore::new(); let reviews = Arc::clone(&movies); @@ -66,7 +66,7 @@ async fn test_log_review_reuses_existing_movie() { } #[tokio::test] -async fn test_log_review_with_invalid_rating_fails() { +async fn logging_a_review_with_an_invalid_rating_fails() { let movies = InMemoryStore::new(); let reviews = Arc::clone(&movies); let events = NoopEventPublisher::new(); diff --git a/crates/application/src/diary/tests/movie_resolver.rs b/crates/application/tests/diary/movie_resolver_test.rs similarity index 98% rename from crates/application/src/diary/tests/movie_resolver.rs rename to crates/application/tests/diary/movie_resolver_test.rs index 7387c9ad..53b38482 100644 --- a/crates/application/src/diary/tests/movie_resolver.rs +++ b/crates/application/tests/diary/movie_resolver_test.rs @@ -1,5 +1,6 @@ -use super::*; -use crate::diary::commands::MovieInput; +use application::diary::commands::MovieInput; +use application::diary::movie_resolver::*; +use domain::ports::MetadataClient; use domain::testing::test_movie_with_year; use domain::{ errors::DomainError, diff --git a/crates/application/src/diary/tests/review_logger.rs b/crates/application/tests/diary/review_logger_test.rs similarity index 97% rename from crates/application/src/diary/tests/review_logger.rs rename to crates/application/tests/diary/review_logger_test.rs index 6e070ab1..98e76180 100644 --- a/crates/application/src/diary/tests/review_logger.rs +++ b/crates/application/tests/diary/review_logger_test.rs @@ -1,4 +1,4 @@ -use crate::test_support::{log_review_command, log_review_command_manual}; +use crate::support::{log_review_command, log_review_command_manual}; use domain::testing::{test_movie_with_external_id, test_movie_with_year}; use std::sync::Arc; @@ -13,9 +13,9 @@ use domain::{ }; use uuid::Uuid; -use crate::diary::commands::LogReviewCommand; -use crate::diary::review_logger::DefaultReviewLogger; -use crate::ports::ReviewLogger; +use application::diary::commands::LogReviewCommand; +use application::diary::review_logger::DefaultReviewLogger; +use application::ports::ReviewLogger; fn make_logger( movies: &Arc, diff --git a/crates/application/tests/goals.rs b/crates/application/tests/goals.rs new file mode 100644 index 00000000..d91c5f58 --- /dev/null +++ b/crates/application/tests/goals.rs @@ -0,0 +1,12 @@ +mod support; + +#[path = "goals/create_test.rs"] +mod create_test; +#[path = "goals/delete_test.rs"] +mod delete_test; +#[path = "goals/get_test.rs"] +mod get_test; +#[path = "goals/list_test.rs"] +mod list_test; +#[path = "goals/update_test.rs"] +mod update_test; diff --git a/crates/application/src/goals/tests/create.rs b/crates/application/tests/goals/create_test.rs similarity index 95% rename from crates/application/src/goals/tests/create.rs rename to crates/application/tests/goals/create_test.rs index a4996fcd..7bc05398 100644 --- a/crates/application/src/goals/tests/create.rs +++ b/crates/application/tests/goals/create_test.rs @@ -1,13 +1,13 @@ -use crate::test_support::create_goal_command; +use crate::support::create_goal_command; use std::sync::Arc; use domain::events::DomainEvent; use domain::testing::{FakeStatsRepository, InMemoryStore, NoopEventPublisher}; use uuid::Uuid; -use crate::goals::create; -use crate::goals::deps::GoalCommandDeps; -use crate::test_helpers::TestContextBuilder; +use crate::support::TestContextBuilder; +use application::goals::create; +use application::goals::deps::GoalCommandDeps; #[tokio::test] async fn creates_goal_and_returns_progress() { diff --git a/crates/application/src/goals/tests/delete.rs b/crates/application/tests/goals/delete_test.rs similarity index 87% rename from crates/application/src/goals/tests/delete.rs rename to crates/application/tests/goals/delete_test.rs index 6ff2d425..86d8de0f 100644 --- a/crates/application/src/goals/tests/delete.rs +++ b/crates/application/tests/goals/delete_test.rs @@ -1,12 +1,12 @@ -use crate::test_support::create_goal_command; +use crate::support::create_goal_command; use std::sync::Arc; use domain::testing::{FakeStatsRepository, InMemoryStore, NoopEventPublisher}; use uuid::Uuid; -use crate::goals::deps::GoalCommandDeps; -use crate::goals::{commands::DeleteGoalCommand, create, delete}; -use crate::test_helpers::TestContextBuilder; +use crate::support::TestContextBuilder; +use application::goals::deps::GoalCommandDeps; +use application::goals::{commands::DeleteGoalCommand, create, delete}; #[tokio::test] async fn deletes_existing_goal() { diff --git a/crates/application/src/goals/tests/get.rs b/crates/application/tests/goals/get_test.rs similarity index 85% rename from crates/application/src/goals/tests/get.rs rename to crates/application/tests/goals/get_test.rs index 020b63e9..a4562bdf 100644 --- a/crates/application/src/goals/tests/get.rs +++ b/crates/application/tests/goals/get_test.rs @@ -1,9 +1,9 @@ -use crate::test_support::create_goal_command; +use crate::support::create_goal_command; use uuid::Uuid; -use crate::goals::deps::{GoalCommandDeps, GoalQueryDeps}; -use crate::goals::{create, get, queries::GetGoalQuery}; -use crate::test_helpers::TestContextBuilder; +use crate::support::TestContextBuilder; +use application::goals::deps::{GoalCommandDeps, GoalQueryDeps}; +use application::goals::{create, get, queries::GetGoalQuery}; #[tokio::test] async fn returns_goal_when_exists() { diff --git a/crates/application/src/goals/tests/list.rs b/crates/application/tests/goals/list_test.rs similarity index 85% rename from crates/application/src/goals/tests/list.rs rename to crates/application/tests/goals/list_test.rs index 05d8e688..430c3d45 100644 --- a/crates/application/src/goals/tests/list.rs +++ b/crates/application/tests/goals/list_test.rs @@ -1,9 +1,9 @@ -use crate::test_support::create_goal_command; +use crate::support::create_goal_command; use uuid::Uuid; -use crate::goals::deps::{GoalCommandDeps, GoalQueryDeps}; -use crate::goals::{create, list, queries::ListGoalsQuery}; -use crate::test_helpers::TestContextBuilder; +use crate::support::TestContextBuilder; +use application::goals::deps::{GoalCommandDeps, GoalQueryDeps}; +use application::goals::{create, list, queries::ListGoalsQuery}; #[tokio::test] async fn returns_empty_when_no_goals() { diff --git a/crates/application/src/goals/tests/update.rs b/crates/application/tests/goals/update_test.rs similarity index 88% rename from crates/application/src/goals/tests/update.rs rename to crates/application/tests/goals/update_test.rs index 9ec7e650..7d7bac10 100644 --- a/crates/application/src/goals/tests/update.rs +++ b/crates/application/tests/goals/update_test.rs @@ -1,8 +1,8 @@ -use crate::test_support::{create_goal_command, update_goal_command}; +use crate::support::{create_goal_command, update_goal_command}; -use crate::goals::deps::GoalCommandDeps; -use crate::goals::{create, update}; -use crate::test_helpers::TestContextBuilder; +use crate::support::TestContextBuilder; +use application::goals::deps::GoalCommandDeps; +use application::goals::{create, update}; #[tokio::test] async fn updates_target_count() { diff --git a/crates/application/tests/import.rs b/crates/application/tests/import.rs new file mode 100644 index 00000000..ca5dd921 --- /dev/null +++ b/crates/application/tests/import.rs @@ -0,0 +1,26 @@ +mod support; + +#[path = "import/apply_mapping_test.rs"] +mod apply_mapping_test; +#[path = "import/apply_profile_and_map_test.rs"] +mod apply_profile_and_map_test; +#[path = "import/apply_profile_test.rs"] +mod apply_profile_test; +#[path = "import/cleanup_test.rs"] +mod cleanup_test; +#[path = "import/create_session_test.rs"] +mod create_session_test; +#[path = "import/delete_profile_test.rs"] +mod delete_profile_test; +#[path = "import/execute_test.rs"] +mod execute_test; +#[path = "import/get_mapping_stage_test.rs"] +mod get_mapping_stage_test; +#[path = "import/get_preview_stage_test.rs"] +mod get_preview_stage_test; +#[path = "import/get_session_state_test.rs"] +mod get_session_state_test; +#[path = "import/list_profiles_test.rs"] +mod list_profiles_test; +#[path = "import/save_profile_test.rs"] +mod save_profile_test; diff --git a/crates/application/src/import/tests/apply_mapping.rs b/crates/application/tests/import/apply_mapping_test.rs similarity index 95% rename from crates/application/src/import/tests/apply_mapping.rs rename to crates/application/tests/import/apply_mapping_test.rs index 57179cd1..c5749e0a 100644 --- a/crates/application/src/import/tests/apply_mapping.rs +++ b/crates/application/tests/import/apply_mapping_test.rs @@ -1,4 +1,4 @@ -use crate::test_support::{apply_import_mapping_command, create_import_session_command}; +use crate::support::{apply_import_mapping_command, create_import_session_command}; use domain::testing::{test_movie_with_external_id, test_movie_with_year}; use std::sync::Arc; @@ -14,9 +14,9 @@ use domain::{ value_objects::ExternalMetadataId, }; -use crate::import::deps::{ApplyMappingDeps, CreateSessionDeps}; -use crate::import::{apply_mapping, create_session}; -use crate::test_helpers::TestContextBuilder; +use crate::support::TestContextBuilder; +use application::import::deps::{ApplyMappingDeps, CreateSessionDeps}; +use application::import::{apply_mapping, create_session}; #[tokio::test] async fn applies_mapping_to_session() { diff --git a/crates/application/src/import/tests/apply_profile_and_map.rs b/crates/application/tests/import/apply_profile_and_map_test.rs similarity index 91% rename from crates/application/src/import/tests/apply_profile_and_map.rs rename to crates/application/tests/import/apply_profile_and_map_test.rs index b4b390d5..18b05647 100644 --- a/crates/application/src/import/tests/apply_profile_and_map.rs +++ b/crates/application/tests/import/apply_profile_and_map_test.rs @@ -1,4 +1,4 @@ -use crate::test_support::create_import_session_command; +use crate::support::create_import_session_command; use std::sync::Arc; use chrono::Utc; @@ -10,9 +10,11 @@ use domain::ports::{ImportProfileRepository, ImportSessionRepository}; use domain::testing::InMemoryStore; use domain::value_objects::{ImportProfileId, UserId}; -use crate::import::deps::{ApplyProfileAndMapDeps, CreateSessionDeps}; -use crate::import::{apply_profile_and_map, commands::ApplyProfileAndMapCommand, create_session}; -use crate::test_helpers::TestContextBuilder; +use crate::support::TestContextBuilder; +use application::import::deps::{ApplyProfileAndMapDeps, CreateSessionDeps}; +use application::import::{ + apply_profile_and_map, commands::ApplyProfileAndMapCommand, create_session, +}; #[tokio::test] async fn fails_when_profile_not_found() { diff --git a/crates/application/src/import/tests/apply_profile.rs b/crates/application/tests/import/apply_profile_test.rs similarity index 95% rename from crates/application/src/import/tests/apply_profile.rs rename to crates/application/tests/import/apply_profile_test.rs index 7420a5fd..1a6068cd 100644 --- a/crates/application/src/import/tests/apply_profile.rs +++ b/crates/application/tests/import/apply_profile_test.rs @@ -1,4 +1,4 @@ -use crate::test_support::apply_import_profile_command; +use crate::support::apply_import_profile_command; use std::sync::Arc; use chrono::Utc; @@ -8,8 +8,8 @@ use domain::testing::InMemoryStore; use domain::value_objects::{ImportProfileId, UserId}; use uuid::Uuid; -use crate::import::apply_profile; -use crate::import::deps::ApplyProfileDeps; +use application::import::apply_profile; +use application::import::deps::ApplyProfileDeps; #[tokio::test] async fn fails_when_profile_not_found() { diff --git a/crates/application/src/import/tests/cleanup.rs b/crates/application/tests/import/cleanup_test.rs similarity index 88% rename from crates/application/src/import/tests/cleanup.rs rename to crates/application/tests/import/cleanup_test.rs index 3302099e..90ef0787 100644 --- a/crates/application/src/import/tests/cleanup.rs +++ b/crates/application/tests/import/cleanup_test.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use domain::testing::InMemoryStore; -use crate::import::cleanup; +use application::import::cleanup; #[tokio::test] async fn returns_zero_when_nothing_expired() { diff --git a/crates/application/src/import/tests/create_session.rs b/crates/application/tests/import/create_session_test.rs similarity index 75% rename from crates/application/src/import/tests/create_session.rs rename to crates/application/tests/import/create_session_test.rs index 19119518..39f3ed4d 100644 --- a/crates/application/src/import/tests/create_session.rs +++ b/crates/application/tests/import/create_session_test.rs @@ -1,13 +1,13 @@ -use crate::test_support::create_import_session_command; +use crate::support::create_import_session_command; use std::sync::Arc; use uuid::Uuid; use domain::testing::InMemoryStore; -use crate::import::create_session; -use crate::import::deps::CreateSessionDeps; -use crate::test_helpers::TestContextBuilder; +use crate::support::TestContextBuilder; +use application::import::create_session; +use application::import::deps::CreateSessionDeps; #[tokio::test] async fn creates_session_with_parsed_file() { diff --git a/crates/application/src/import/tests/delete_profile.rs b/crates/application/tests/import/delete_profile_test.rs similarity index 95% rename from crates/application/src/import/tests/delete_profile.rs rename to crates/application/tests/import/delete_profile_test.rs index 79f89d18..091fca72 100644 --- a/crates/application/src/import/tests/delete_profile.rs +++ b/crates/application/tests/import/delete_profile_test.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::testing::InMemoryStore; use uuid::Uuid; -use crate::import::{ +use application::import::{ commands::DeleteImportProfileCommand, delete_profile, deps::DeleteImportProfileDeps, }; diff --git a/crates/application/src/import/tests/execute.rs b/crates/application/tests/import/execute_test.rs similarity index 98% rename from crates/application/src/import/tests/execute.rs rename to crates/application/tests/import/execute_test.rs index fc2ca7ab..45b2349a 100644 --- a/crates/application/src/import/tests/execute.rs +++ b/crates/application/tests/import/execute_test.rs @@ -1,4 +1,4 @@ -use crate::test_support::execute_import_command; +use crate::support::execute_import_command; use std::sync::Arc; use domain::models::{AnnotatedRow, ImportSession, import::RowResult}; @@ -7,10 +7,10 @@ use domain::testing::InMemoryStore; use domain::value_objects::UserId; use uuid::Uuid; -use crate::import::commands::ExecuteImportCommand; -use crate::import::deps::ExecuteImportDeps; -use crate::import::execute; -use crate::test_helpers::NoopReviewLogger; +use crate::support::NoopReviewLogger; +use application::import::commands::ExecuteImportCommand; +use application::import::deps::ExecuteImportDeps; +use application::import::execute; fn make_session_with_rows(user_id: UserId) -> ImportSession { let mut session = ImportSession::new(user_id); diff --git a/crates/application/src/import/tests/get_mapping_stage.rs b/crates/application/tests/import/get_mapping_stage_test.rs similarity index 94% rename from crates/application/src/import/tests/get_mapping_stage.rs rename to crates/application/tests/import/get_mapping_stage_test.rs index d33392f7..6c7b8855 100644 --- a/crates/application/src/import/tests/get_mapping_stage.rs +++ b/crates/application/tests/import/get_mapping_stage_test.rs @@ -8,8 +8,8 @@ use domain::ports::ImportSessionRepository; use domain::testing::InMemoryStore; use domain::value_objects::{ImportSessionId, UserId}; -use crate::import::deps::GetMappingStageDeps; -use crate::import::get_mapping_stage::{self, SAMPLE_ROW_LIMIT}; +use application::import::deps::GetMappingStageDeps; +use application::import::get_mapping_stage::{self, SAMPLE_ROW_LIMIT}; #[tokio::test] async fn get_mapping_stage_is_not_found_when_file_not_parsed() { diff --git a/crates/application/src/import/tests/get_preview_stage.rs b/crates/application/tests/import/get_preview_stage_test.rs similarity index 95% rename from crates/application/src/import/tests/get_preview_stage.rs rename to crates/application/tests/import/get_preview_stage_test.rs index a8644d3e..d3c9585e 100644 --- a/crates/application/src/import/tests/get_preview_stage.rs +++ b/crates/application/tests/import/get_preview_stage_test.rs @@ -8,8 +8,8 @@ use domain::ports::ImportSessionRepository; use domain::testing::InMemoryStore; use domain::value_objects::{ImportSessionId, UserId}; -use crate::import::deps::GetPreviewStageDeps; -use crate::import::get_preview_stage::{self, PreviewStage}; +use application::import::deps::GetPreviewStageDeps; +use application::import::get_preview_stage::{self, PreviewStage}; #[tokio::test] async fn get_preview_stage_reports_not_yet_mapped_when_row_results_absent() { diff --git a/crates/application/src/import/tests/get_session_state.rs b/crates/application/tests/import/get_session_state_test.rs similarity index 93% rename from crates/application/src/import/tests/get_session_state.rs rename to crates/application/tests/import/get_session_state_test.rs index f8d4e218..5cb513fd 100644 --- a/crates/application/src/import/tests/get_session_state.rs +++ b/crates/application/tests/import/get_session_state_test.rs @@ -8,8 +8,8 @@ use domain::ports::ImportSessionRepository; use domain::testing::InMemoryStore; use domain::value_objects::{ImportSessionId, UserId}; -use crate::import::deps::GetSessionStateDeps; -use crate::import::get_session_state; +use application::import::deps::GetSessionStateDeps; +use application::import::get_session_state; #[tokio::test] async fn get_session_state_is_not_found_when_session_missing() { diff --git a/crates/application/src/import/tests/list_profiles.rs b/crates/application/tests/import/list_profiles_test.rs similarity index 86% rename from crates/application/src/import/tests/list_profiles.rs rename to crates/application/tests/import/list_profiles_test.rs index 0fd096e5..384af871 100644 --- a/crates/application/src/import/tests/list_profiles.rs +++ b/crates/application/tests/import/list_profiles_test.rs @@ -4,7 +4,7 @@ use domain::testing::InMemoryStore; use domain::value_objects::UserId; use uuid::Uuid; -use crate::import::{deps::ListImportProfilesDeps, list_profiles}; +use application::import::{deps::ListImportProfilesDeps, list_profiles}; #[tokio::test] async fn returns_empty_when_no_profiles() { diff --git a/crates/application/src/import/tests/save_profile.rs b/crates/application/tests/import/save_profile_test.rs similarity index 92% rename from crates/application/src/import/tests/save_profile.rs rename to crates/application/tests/import/save_profile_test.rs index 4714a5dc..ea13603a 100644 --- a/crates/application/src/import/tests/save_profile.rs +++ b/crates/application/tests/import/save_profile_test.rs @@ -6,8 +6,8 @@ use domain::testing::InMemoryStore; use domain::value_objects::UserId; use uuid::Uuid; -use crate::import::deps::SaveProfileDeps; -use crate::import::{commands::SaveImportProfileCommand, save_profile}; +use application::import::deps::SaveProfileDeps; +use application::import::{commands::SaveImportProfileCommand, save_profile}; #[tokio::test] async fn fails_when_session_not_found() { diff --git a/crates/application/tests/insights.rs b/crates/application/tests/insights.rs new file mode 100644 index 00000000..767e47cd --- /dev/null +++ b/crates/application/tests/insights.rs @@ -0,0 +1,4 @@ +mod support; + +#[path = "insights/get_test.rs"] +mod get_test; diff --git a/crates/application/src/insights/tests/get.rs b/crates/application/tests/insights/get_test.rs similarity index 97% rename from crates/application/src/insights/tests/get.rs rename to crates/application/tests/insights/get_test.rs index c053e507..eec9221a 100644 --- a/crates/application/src/insights/tests/get.rs +++ b/crates/application/tests/insights/get_test.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use domain::{models::insights::WatchRow, testing::InMemoryInsightsQuery, value_objects::UserId}; -use super::*; +use application::insights::{deps::GetInsightsDeps, get::*}; fn watch(genre: &str, rating: u8, day: u32, hour: u32) -> WatchRow { WatchRow { diff --git a/crates/application/tests/integrations.rs b/crates/application/tests/integrations.rs new file mode 100644 index 00000000..b39eecde --- /dev/null +++ b/crates/application/tests/integrations.rs @@ -0,0 +1,18 @@ +mod support; + +#[path = "integrations/cleanup_test.rs"] +mod cleanup_test; +#[path = "integrations/confirm_test.rs"] +mod confirm_test; +#[path = "integrations/dismiss_test.rs"] +mod dismiss_test; +#[path = "integrations/generate_token_test.rs"] +mod generate_token_test; +#[path = "integrations/get_queue_test.rs"] +mod get_queue_test; +#[path = "integrations/get_tokens_test.rs"] +mod get_tokens_test; +#[path = "integrations/ingest_test.rs"] +mod ingest_test; +#[path = "integrations/revoke_token_test.rs"] +mod revoke_token_test; diff --git a/crates/application/src/integrations/tests/cleanup.rs b/crates/application/tests/integrations/cleanup_test.rs similarity index 85% rename from crates/application/src/integrations/tests/cleanup.rs rename to crates/application/tests/integrations/cleanup_test.rs index ec321c62..0209b4ba 100644 --- a/crates/application/src/integrations/tests/cleanup.rs +++ b/crates/application/tests/integrations/cleanup_test.rs @@ -1,6 +1,6 @@ use domain::testing::InMemoryStore; -use crate::integrations::cleanup; +use application::integrations::cleanup; #[tokio::test] async fn returns_zero_when_nothing_to_clean() { diff --git a/crates/application/src/integrations/tests/confirm.rs b/crates/application/tests/integrations/confirm_test.rs similarity index 93% rename from crates/application/src/integrations/tests/confirm.rs rename to crates/application/tests/integrations/confirm_test.rs index a967d22e..79430ce2 100644 --- a/crates/application/src/integrations/tests/confirm.rs +++ b/crates/application/tests/integrations/confirm_test.rs @@ -1,4 +1,4 @@ -use crate::test_support::confirm_watch_events_command; +use crate::support::confirm_watch_events_command; use std::sync::Arc; use domain::models::{WatchEvent, WatchEventSource}; @@ -7,18 +7,18 @@ use domain::testing::{InMemoryStore, NoopEventPublisher}; use domain::value_objects::UserId; use uuid::Uuid; -use crate::integrations::commands::{ConfirmWatchEventsCommand, WatchEventConfirmation}; -use crate::integrations::confirm; -use crate::integrations::deps::ConfirmWatchEventsDeps; -use crate::test_helpers::NoopReviewLogger; +use crate::support::NoopReviewLogger; +use application::integrations::commands::{ConfirmWatchEventsCommand, WatchEventConfirmation}; +use application::integrations::confirm; +use application::integrations::deps::ConfirmWatchEventsDeps; -fn noop_logger() -> Arc { +fn noop_logger() -> Arc { Arc::new(NoopReviewLogger) } fn deps( watch_events: &Arc, - review_logger: Arc, + review_logger: Arc, ) -> ConfirmWatchEventsDeps { ConfirmWatchEventsDeps { watch_event_command: Arc::clone(watch_events) as _, @@ -179,8 +179,8 @@ async fn confirms_event_with_movie_id() { // Build a real review logger let reviews = domain::testing::InMemoryStore::new(); let watchlist = domain::testing::InMemoryStore::new(); - let review_logger: Arc = - Arc::new(crate::diary::review_logger::DefaultReviewLogger::new( + let review_logger: Arc = + Arc::new(application::diary::review_logger::DefaultReviewLogger::new( Arc::clone(&movies) as _, Arc::clone(&movies) as _, Arc::clone(&reviews) as _, diff --git a/crates/application/src/integrations/tests/dismiss.rs b/crates/application/tests/integrations/dismiss_test.rs similarity index 92% rename from crates/application/src/integrations/tests/dismiss.rs rename to crates/application/tests/integrations/dismiss_test.rs index 1ebfdfce..d5278125 100644 --- a/crates/application/src/integrations/tests/dismiss.rs +++ b/crates/application/tests/integrations/dismiss_test.rs @@ -1,4 +1,4 @@ -use crate::test_support::dismiss_watch_events_command; +use crate::support::dismiss_watch_events_command; use std::sync::Arc; use domain::models::{WatchEvent, WatchEventSource}; @@ -7,8 +7,8 @@ use domain::testing::InMemoryStore; use domain::value_objects::UserId; use uuid::Uuid; -use crate::integrations::deps::DismissWatchEventsDeps; -use crate::integrations::dismiss; +use application::integrations::deps::DismissWatchEventsDeps; +use application::integrations::dismiss; fn deps(watch_events: &Arc) -> DismissWatchEventsDeps { DismissWatchEventsDeps { diff --git a/crates/application/src/integrations/tests/generate_token.rs b/crates/application/tests/integrations/generate_token_test.rs similarity index 82% rename from crates/application/src/integrations/tests/generate_token.rs rename to crates/application/tests/integrations/generate_token_test.rs index 80334ac1..3867d53e 100644 --- a/crates/application/src/integrations/tests/generate_token.rs +++ b/crates/application/tests/integrations/generate_token_test.rs @@ -1,12 +1,12 @@ -use crate::test_support::generate_webhook_token_command; +use crate::support::generate_webhook_token_command; use std::sync::Arc; use domain::ports::WebhookTokenRepository; use domain::testing::InMemoryStore; use uuid::Uuid; -use crate::integrations::deps::GenerateWebhookTokenDeps; -use crate::integrations::generate_token; +use application::integrations::deps::GenerateWebhookTokenDeps; +use application::integrations::generate_token; #[tokio::test] async fn generates_token_and_saves() { diff --git a/crates/application/src/integrations/tests/get_queue.rs b/crates/application/tests/integrations/get_queue_test.rs similarity index 90% rename from crates/application/src/integrations/tests/get_queue.rs rename to crates/application/tests/integrations/get_queue_test.rs index 50bacfb4..33d3389d 100644 --- a/crates/application/src/integrations/tests/get_queue.rs +++ b/crates/application/tests/integrations/get_queue_test.rs @@ -7,8 +7,8 @@ use domain::testing::InMemoryStore; use domain::value_objects::UserId; use uuid::Uuid; -use crate::integrations::deps::GetWatchQueueDeps; -use crate::integrations::{get_queue, queries::GetWatchQueueQuery}; +use application::integrations::deps::GetWatchQueueDeps; +use application::integrations::{get_queue, queries::GetWatchQueueQuery}; fn deps(events: &Arc) -> GetWatchQueueDeps { GetWatchQueueDeps { diff --git a/crates/application/src/integrations/tests/get_tokens.rs b/crates/application/tests/integrations/get_tokens_test.rs similarity index 90% rename from crates/application/src/integrations/tests/get_tokens.rs rename to crates/application/tests/integrations/get_tokens_test.rs index 5eedc517..09fe4393 100644 --- a/crates/application/src/integrations/tests/get_tokens.rs +++ b/crates/application/tests/integrations/get_tokens_test.rs @@ -1,4 +1,4 @@ -use crate::test_support::generate_webhook_token_command; +use crate::support::generate_webhook_token_command; use std::sync::Arc; use domain::models::WatchEventSource; @@ -6,8 +6,8 @@ use domain::ports::WebhookTokenRepository; use domain::testing::InMemoryStore; use uuid::Uuid; -use crate::integrations::deps::{GenerateWebhookTokenDeps, GetWebhookTokensDeps}; -use crate::integrations::{ +use application::integrations::deps::{GenerateWebhookTokenDeps, GetWebhookTokensDeps}; +use application::integrations::{ commands::GenerateWebhookTokenCommand, generate_token, get_tokens, queries::GetWebhookTokensQuery, }; diff --git a/crates/application/src/integrations/tests/ingest.rs b/crates/application/tests/integrations/ingest_test.rs similarity index 90% rename from crates/application/src/integrations/tests/ingest.rs rename to crates/application/tests/integrations/ingest_test.rs index 98194231..9f8cc7b0 100644 --- a/crates/application/src/integrations/tests/ingest.rs +++ b/crates/application/tests/integrations/ingest_test.rs @@ -1,4 +1,4 @@ -use crate::test_support::generate_webhook_token_command; +use crate::support::generate_webhook_token_command; use std::sync::Arc; use domain::models::WatchEventSource; @@ -6,9 +6,9 @@ use domain::ports::EventPublisher; use domain::testing::{InMemoryStore, NoopEventPublisher}; use uuid::Uuid; -use crate::integrations::commands::IngestWatchEventCommand; -use crate::integrations::deps::{GenerateWebhookTokenDeps, IngestWatchEventDeps}; -use crate::integrations::{generate_token, ingest}; +use application::integrations::commands::IngestWatchEventCommand; +use application::integrations::deps::{GenerateWebhookTokenDeps, IngestWatchEventDeps}; +use application::integrations::{generate_token, ingest}; struct FakeParser; diff --git a/crates/application/src/integrations/tests/revoke_token.rs b/crates/application/tests/integrations/revoke_token_test.rs similarity index 90% rename from crates/application/src/integrations/tests/revoke_token.rs rename to crates/application/tests/integrations/revoke_token_test.rs index 718ac757..65510f73 100644 --- a/crates/application/src/integrations/tests/revoke_token.rs +++ b/crates/application/tests/integrations/revoke_token_test.rs @@ -1,14 +1,14 @@ -use crate::test_support::generate_webhook_token_command; +use crate::support::generate_webhook_token_command; use std::sync::Arc; use domain::ports::WebhookTokenRepository; use domain::testing::InMemoryStore; use uuid::Uuid; -use crate::integrations::deps::{ +use application::integrations::deps::{ GenerateWebhookTokenDeps, GetWebhookTokensDeps, RevokeWebhookTokenDeps, }; -use crate::integrations::{ +use application::integrations::{ commands::RevokeWebhookTokenCommand, generate_token, get_tokens, queries::GetWebhookTokensQuery, revoke_token, }; diff --git a/crates/application/tests/movies.rs b/crates/application/tests/movies.rs new file mode 100644 index 00000000..5d07e747 --- /dev/null +++ b/crates/application/tests/movies.rs @@ -0,0 +1,12 @@ +mod support; + +#[path = "movies/enrich_movie_test.rs"] +mod enrich_movie_test; +#[path = "movies/get_movie_profile_test.rs"] +mod get_movie_profile_test; +#[path = "movies/get_movies_test.rs"] +mod get_movies_test; +#[path = "movies/request_enrichment_test.rs"] +mod request_enrichment_test; +#[path = "movies/sync_poster_test.rs"] +mod sync_poster_test; diff --git a/crates/application/src/movies/tests/enrich_movie.rs b/crates/application/tests/movies/enrich_movie_test.rs similarity index 97% rename from crates/application/src/movies/tests/enrich_movie.rs rename to crates/application/tests/movies/enrich_movie_test.rs index a6ef9eab..83d090ab 100644 --- a/crates/application/src/movies/tests/enrich_movie.rs +++ b/crates/application/tests/movies/enrich_movie_test.rs @@ -9,7 +9,7 @@ use domain::{ value_objects::MovieId, }; -use crate::movies::{commands::EnrichMovieCommand, deps::EnrichMovieDeps, enrich_movie}; +use application::movies::{commands::EnrichMovieCommand, deps::EnrichMovieDeps, enrich_movie}; #[tokio::test] async fn stores_profile_and_indexes() { diff --git a/crates/application/src/movies/tests/get_movie_profile.rs b/crates/application/tests/movies/get_movie_profile_test.rs similarity index 94% rename from crates/application/src/movies/tests/get_movie_profile.rs rename to crates/application/tests/movies/get_movie_profile_test.rs index 969625ae..b457d6ba 100644 --- a/crates/application/src/movies/tests/get_movie_profile.rs +++ b/crates/application/tests/movies/get_movie_profile_test.rs @@ -8,8 +8,8 @@ use domain::{ value_objects::MovieId, }; -use crate::movies::deps::GetMovieProfileDeps; -use crate::movies::get_movie_profile::{self, GetMovieProfileQuery}; +use application::movies::deps::GetMovieProfileDeps; +use application::movies::get_movie_profile::{self, GetMovieProfileQuery}; #[tokio::test] async fn returns_none_when_no_profile() { diff --git a/crates/application/src/movies/tests/get_movies.rs b/crates/application/tests/movies/get_movies_test.rs similarity index 84% rename from crates/application/src/movies/tests/get_movies.rs rename to crates/application/tests/movies/get_movies_test.rs index 3bb8c49d..45325c27 100644 --- a/crates/application/src/movies/tests/get_movies.rs +++ b/crates/application/tests/movies/get_movies_test.rs @@ -1,6 +1,6 @@ use domain::testing::InMemoryStore; -use crate::movies::{deps::GetMoviesDeps, get_movies, queries::GetMoviesQuery}; +use application::movies::{deps::GetMoviesDeps, get_movies, queries::GetMoviesQuery}; #[tokio::test] async fn returns_empty_when_no_movies() { diff --git a/crates/application/src/movies/tests/request_enrichment.rs b/crates/application/tests/movies/request_enrichment_test.rs similarity index 98% rename from crates/application/src/movies/tests/request_enrichment.rs rename to crates/application/tests/movies/request_enrichment_test.rs index bee95b9d..b2dc79cc 100644 --- a/crates/application/src/movies/tests/request_enrichment.rs +++ b/crates/application/tests/movies/request_enrichment_test.rs @@ -10,7 +10,7 @@ use domain::{ value_objects::MovieId, }; -use crate::movies::request_enrichment; +use application::movies::request_enrichment; #[tokio::test] async fn returns_profile_when_none_cached() { diff --git a/crates/application/src/movies/tests/sync_poster.rs b/crates/application/tests/movies/sync_poster_test.rs similarity index 96% rename from crates/application/src/movies/tests/sync_poster.rs rename to crates/application/tests/movies/sync_poster_test.rs index b9588196..6e2d937c 100644 --- a/crates/application/src/movies/tests/sync_poster.rs +++ b/crates/application/tests/movies/sync_poster_test.rs @@ -1,4 +1,4 @@ -use crate::test_support::sync_poster_command; +use crate::support::sync_poster_command; use domain::testing::{test_movie_with_external_id, test_movie_with_year}; use std::sync::Arc; @@ -12,7 +12,7 @@ use domain::{ value_objects::{ExternalMetadataId, PosterUrl}, }; -use crate::movies::{deps::SyncPosterDeps, sync_poster}; +use application::movies::{deps::SyncPosterDeps, sync_poster}; fn default_deps() -> SyncPosterDeps { SyncPosterDeps { diff --git a/crates/application/tests/person.rs b/crates/application/tests/person.rs new file mode 100644 index 00000000..67ba3ad8 --- /dev/null +++ b/crates/application/tests/person.rs @@ -0,0 +1,6 @@ +mod support; + +#[path = "person/get_credits_test.rs"] +mod get_credits_test; +#[path = "person/get_test.rs"] +mod get_test; diff --git a/crates/application/src/person/tests/get_credits.rs b/crates/application/tests/person/get_credits_test.rs similarity index 89% rename from crates/application/src/person/tests/get_credits.rs rename to crates/application/tests/person/get_credits_test.rs index 0ab6b61c..139477a3 100644 --- a/crates/application/src/person/tests/get_credits.rs +++ b/crates/application/tests/person/get_credits_test.rs @@ -3,7 +3,7 @@ use domain::testing::{FakePersonQuery, NoopEventPublisher}; use std::sync::Arc; use uuid::Uuid; -use crate::person::{deps::GetPersonDeps, get_credits}; +use application::person::{deps::GetPersonDeps, get_credits}; #[tokio::test] async fn returns_empty_credits() { diff --git a/crates/application/src/person/tests/get.rs b/crates/application/tests/person/get_test.rs similarity index 89% rename from crates/application/src/person/tests/get.rs rename to crates/application/tests/person/get_test.rs index 3a1684ae..9b747bed 100644 --- a/crates/application/src/person/tests/get.rs +++ b/crates/application/tests/person/get_test.rs @@ -3,7 +3,7 @@ use domain::testing::{FakePersonQuery, NoopEventPublisher}; use std::sync::Arc; use uuid::Uuid; -use crate::person::{deps::GetPersonDeps, get}; +use application::person::{deps::GetPersonDeps, get}; #[tokio::test] async fn returns_none_for_unknown_person() { diff --git a/crates/application/tests/search.rs b/crates/application/tests/search.rs new file mode 100644 index 00000000..e97757d0 --- /dev/null +++ b/crates/application/tests/search.rs @@ -0,0 +1,4 @@ +mod support; + +#[path = "search/execute_test.rs"] +mod execute_test; diff --git a/crates/application/src/search/tests/execute.rs b/crates/application/tests/search/execute_test.rs similarity index 77% rename from crates/application/src/search/tests/execute.rs rename to crates/application/tests/search/execute_test.rs index db219fa1..452c6227 100644 --- a/crates/application/src/search/tests/execute.rs +++ b/crates/application/tests/search/execute_test.rs @@ -1,8 +1,8 @@ use domain::models::SearchQuery; -use crate::search::deps::SearchDeps; -use crate::search::execute; -use crate::test_helpers::TestContextBuilder; +use crate::support::TestContextBuilder; +use application::search::deps::SearchDeps; +use application::search::execute; #[tokio::test] async fn returns_empty_results() { diff --git a/crates/application/tests/services.rs b/crates/application/tests/services.rs new file mode 100644 index 00000000..a996916a --- /dev/null +++ b/crates/application/tests/services.rs @@ -0,0 +1,4 @@ +mod support; + +#[path = "services/services_test.rs"] +mod services_test; diff --git a/crates/application/src/tests/services.rs b/crates/application/tests/services/services_test.rs similarity index 88% rename from crates/application/src/tests/services.rs rename to crates/application/tests/services/services_test.rs index 17990a55..43aa3054 100644 --- a/crates/application/src/tests/services.rs +++ b/crates/application/tests/services/services_test.rs @@ -1,5 +1,5 @@ -use crate::services::Services; -use crate::test_helpers::NoopReviewLogger; +use crate::support::NoopReviewLogger; +use application::services::Services; use domain::testing::{ FakeAuthService, FakeDocumentParser, FakeMetadataClient, FakePasswordHasher, FakePosterFetcher, NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, @@ -8,7 +8,7 @@ use std::sync::Arc; #[test] fn services_clone_shares_the_same_port_instances() { - let logger: Arc = Arc::new(NoopReviewLogger); + let logger: Arc = Arc::new(NoopReviewLogger); let s = Services { review_logger: Arc::clone(&logger), person_enrichment: None, diff --git a/crates/application/tests/social.rs b/crates/application/tests/social.rs new file mode 100644 index 00000000..5ab9527e --- /dev/null +++ b/crates/application/tests/social.rs @@ -0,0 +1,6 @@ +mod support; + +#[path = "social/execute_test.rs"] +mod execute_test; +#[path = "social/local_service_test.rs"] +mod local_service_test; diff --git a/crates/application/src/social/tests/execute.rs b/crates/application/tests/social/execute_test.rs similarity index 95% rename from crates/application/src/social/tests/execute.rs rename to crates/application/tests/social/execute_test.rs index 3e907a03..cf8331c3 100644 --- a/crates/application/src/social/tests/execute.rs +++ b/crates/application/tests/social/execute_test.rs @@ -7,7 +7,7 @@ use domain::{ }; use uuid::Uuid; -use crate::social::{ +use application::social::{ commands::SocialCmd, deps::{SocialCommandDeps, SocialQueryDeps}, execute::execute_command, @@ -329,7 +329,7 @@ async fn get_following_returns_accepted_targets() { .await .unwrap(); - let before = crate::social::get_following::execute(&query_deps, follower) + let before = application::social::get_following::execute(&query_deps, follower) .await .unwrap(); assert!( @@ -347,7 +347,7 @@ async fn get_following_returns_accepted_targets() { .await .unwrap(); - let actors = crate::social::get_following::execute(&query_deps, follower) + let actors = application::social::get_following::execute(&query_deps, follower) .await .unwrap(); @@ -393,7 +393,7 @@ async fn returns_accepted_followers() { .await .unwrap(); - let followers = crate::social::get_followers::execute(&query_deps, owner_id) + let followers = application::social::get_followers::execute(&query_deps, owner_id) .await .unwrap(); assert_eq!(followers.len(), 1); @@ -427,7 +427,7 @@ async fn returns_only_pending_followers() { .await .unwrap(); - let pending = crate::social::get_pending_followers::execute(&query_deps, owner_id) + let pending = application::social::get_pending_followers::execute(&query_deps, owner_id) .await .unwrap(); assert_eq!(pending.len(), 1); @@ -580,10 +580,10 @@ async fn get_pending_following_returns_unaccepted_targets_and_get_following_does .unwrap(); // deliberately NOT accepted - let pending = crate::social::get_pending_following::execute(&query_deps, follower) + let pending = application::social::get_pending_following::execute(&query_deps, follower) .await .unwrap(); - let accepted = crate::social::get_following::execute(&query_deps, follower) + let accepted = application::social::get_following::execute(&query_deps, follower) .await .unwrap(); @@ -620,7 +620,7 @@ async fn count_pending_followers_drops_as_requests_are_accepted() { .unwrap(); } - let before = crate::social::count_pending_followers::execute(&query_deps, owner.value()) + let before = application::social::count_pending_followers::execute(&query_deps, owner.value()) .await .unwrap(); assert_eq!(before, 2); @@ -635,7 +635,7 @@ async fn count_pending_followers_drops_as_requests_are_accepted() { .await .unwrap(); - let after = crate::social::count_pending_followers::execute(&query_deps, owner.value()) + let after = application::social::count_pending_followers::execute(&query_deps, owner.value()) .await .unwrap(); assert_eq!( @@ -666,10 +666,13 @@ async fn get_relation_use_case_reports_direction_asymmetrically() { .await .unwrap(); - let rel = - crate::social::get_relation::execute(&query_deps, viewer, SocialIdentity::Local(target)) - .await - .unwrap(); + let rel = application::social::get_relation::execute( + &query_deps, + viewer, + SocialIdentity::Local(target), + ) + .await + .unwrap(); assert_eq!( rel.following, diff --git a/crates/application/src/social/tests/local_service.rs b/crates/application/tests/social/local_service_test.rs similarity index 99% rename from crates/application/src/social/tests/local_service.rs rename to crates/application/tests/social/local_service_test.rs index cbe2b3ad..c2e9e7c2 100644 --- a/crates/application/src/social/tests/local_service.rs +++ b/crates/application/tests/social/local_service_test.rs @@ -15,7 +15,7 @@ use domain::{ }; use uuid::Uuid; -use crate::social::local_service::LocalSocialService; +use application::social::local_service::LocalSocialService; // ── Fakes ──────────────────────────────────────────────────────────────────── // diff --git a/crates/application/src/test_support.rs b/crates/application/tests/support/commands.rs similarity index 61% rename from crates/application/src/test_support.rs rename to crates/application/tests/support/commands.rs index ab4862fb..9cf0d28b 100644 --- a/crates/application/src/test_support.rs +++ b/crates/application/tests/support/commands.rs @@ -1,38 +1,33 @@ //! Command builders for tests. Each builder returns a command with sensible //! defaults; tests override individual fields with struct-update syntax: //! `LogReviewCommand { rating: 6, ..log_review_command(user, movie) }`. -//! Moves to `tests/support/mod.rs` when application tests become integration tests. -use chrono::{NaiveDate, NaiveDateTime}; +use chrono::NaiveDate; use uuid::Uuid; use domain::models::{FileFormat, UserRole, WatchEventSource}; +use domain::testing::test_watched_at; -use crate::auth::commands::RegisterCommand; -use crate::auth::queries::LoginCommand; -use crate::diary::commands::{EditReviewCommand, LogReviewCommand, MovieInput, SyncPosterCommand}; -use crate::goals::commands::{CreateGoalCommand, UpdateGoalCommand}; -use crate::import::commands::{ +use application::auth::commands::RegisterCommand; +use application::auth::queries::LoginCommand; +use application::diary::commands::{ + EditReviewCommand, LogReviewCommand, MovieInput, SyncPosterCommand, +}; +use application::goals::commands::{CreateGoalCommand, UpdateGoalCommand}; +use application::import::commands::{ ApplyImportMappingCommand, ApplyImportProfileCommand, CreateImportSessionCommand, ExecuteImportCommand, }; -use crate::integrations::commands::{ +use application::integrations::commands::{ ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand, WatchEventConfirmation, }; -use crate::users::commands::UpdateProfileCommand; -use crate::users::update_settings::UpdateUserSettingsCommand; -use crate::watchlist::commands::AddToWatchlistCommand; -use crate::wrapup::commands::RequestWrapUpCommand; +use application::users::commands::UpdateProfileCommand; +use application::users::update_settings::UpdateUserSettingsCommand; +use application::watchlist::commands::AddToWatchlistCommand; +use application::wrapup::commands::RequestWrapUpCommand; -fn watched_at() -> NaiveDateTime { - NaiveDate::from_ymd_opt(2024, 6, 15) - .unwrap() - .and_hms_opt(20, 0, 0) - .unwrap() -} - -pub(crate) fn movie_input_by_id(movie_id: Uuid) -> MovieInput { +pub fn movie_input_by_id(movie_id: Uuid) -> MovieInput { MovieInput { movie_id: Some(movie_id), external_metadata_id: None, @@ -42,7 +37,7 @@ pub(crate) fn movie_input_by_id(movie_id: Uuid) -> MovieInput { } } -pub(crate) fn movie_input_manual(title: &str, year: u16) -> MovieInput { +pub fn movie_input_manual(title: &str, year: u16) -> MovieInput { MovieInput { movie_id: None, external_metadata_id: None, @@ -52,19 +47,19 @@ pub(crate) fn movie_input_manual(title: &str, year: u16) -> MovieInput { } } -pub(crate) fn log_review_command(user_id: Uuid, movie_id: Uuid) -> LogReviewCommand { +pub fn log_review_command(user_id: Uuid, movie_id: Uuid) -> LogReviewCommand { LogReviewCommand { user_id, input: movie_input_by_id(movie_id), rating: 4, comment: None, - watched_at: watched_at(), + watched_at: test_watched_at(), watch_medium: None, spoilers: false, } } -pub(crate) fn log_review_command_manual(user_id: Uuid, title: &str, year: u16) -> LogReviewCommand { +pub fn log_review_command_manual(user_id: Uuid, title: &str, year: u16) -> LogReviewCommand { LogReviewCommand { input: movie_input_manual(title, year), ..log_review_command(user_id, Uuid::nil()) @@ -72,7 +67,7 @@ pub(crate) fn log_review_command_manual(user_id: Uuid, title: &str, year: u16) - } /// Edits only the rating; `review_id` and `requesting_user_id` are random. -pub(crate) fn edit_review_command(rating: u8) -> EditReviewCommand { +pub fn edit_review_command(rating: u8) -> EditReviewCommand { EditReviewCommand { review_id: Uuid::new_v4(), requesting_user_id: Uuid::new_v4(), @@ -84,14 +79,11 @@ pub(crate) fn edit_review_command(rating: u8) -> EditReviewCommand { } } -pub(crate) fn sync_poster_command(movie_id: Uuid) -> SyncPosterCommand { +pub fn sync_poster_command(movie_id: Uuid) -> SyncPosterCommand { SyncPosterCommand { movie_id } } -pub(crate) fn register_command( - email: impl Into, - username: impl Into, -) -> RegisterCommand { +pub fn register_command(email: impl Into, username: impl Into) -> RegisterCommand { RegisterCommand { email: email.into(), username: username.into(), @@ -100,14 +92,14 @@ pub(crate) fn register_command( } } -pub(crate) fn login_command(email: impl Into, password: impl Into) -> LoginCommand { +pub fn login_command(email: impl Into, password: impl Into) -> LoginCommand { LoginCommand { email: email.into(), password: password.into(), } } -pub(crate) fn create_goal_command(year: u16, target_count: u32) -> CreateGoalCommand { +pub fn create_goal_command(year: u16, target_count: u32) -> CreateGoalCommand { CreateGoalCommand { user_id: Uuid::nil(), year, @@ -115,7 +107,7 @@ pub(crate) fn create_goal_command(year: u16, target_count: u32) -> CreateGoalCom } } -pub(crate) fn update_goal_command(year: u16, target_count: u32) -> UpdateGoalCommand { +pub fn update_goal_command(year: u16, target_count: u32) -> UpdateGoalCommand { UpdateGoalCommand { user_id: Uuid::nil(), year, @@ -124,7 +116,7 @@ pub(crate) fn update_goal_command(year: u16, target_count: u32) -> UpdateGoalCom } /// Confirms indices `[0]`. -pub(crate) fn execute_import_command(user_id: Uuid, session_id: Uuid) -> ExecuteImportCommand { +pub fn execute_import_command(user_id: Uuid, session_id: Uuid) -> ExecuteImportCommand { ExecuteImportCommand { user_id, session_id, @@ -132,10 +124,7 @@ pub(crate) fn execute_import_command(user_id: Uuid, session_id: Uuid) -> Execute } } -pub(crate) fn create_import_session_command( - user_id: Uuid, - csv: &[u8], -) -> CreateImportSessionCommand { +pub fn create_import_session_command(user_id: Uuid, csv: &[u8]) -> CreateImportSessionCommand { CreateImportSessionCommand { user_id, bytes: csv.to_vec(), @@ -144,10 +133,7 @@ pub(crate) fn create_import_session_command( } /// No field mappings. -pub(crate) fn apply_import_mapping_command( - user_id: Uuid, - session_id: Uuid, -) -> ApplyImportMappingCommand { +pub fn apply_import_mapping_command(user_id: Uuid, session_id: Uuid) -> ApplyImportMappingCommand { ApplyImportMappingCommand { user_id, session_id, @@ -155,7 +141,7 @@ pub(crate) fn apply_import_mapping_command( } } -pub(crate) fn apply_import_profile_command( +pub fn apply_import_profile_command( user_id: Uuid, session_id: Uuid, profile_id: Uuid, @@ -168,7 +154,7 @@ pub(crate) fn apply_import_profile_command( } /// One confirmation, rating as given, no comment. -pub(crate) fn confirm_watch_events_command( +pub fn confirm_watch_events_command( user_id: Uuid, watch_event_id: Uuid, rating: u8, @@ -183,7 +169,7 @@ pub(crate) fn confirm_watch_events_command( } } -pub(crate) fn dismiss_watch_events_command( +pub fn dismiss_watch_events_command( user_id: Uuid, event_ids: Vec, ) -> DismissWatchEventsCommand { @@ -191,7 +177,7 @@ pub(crate) fn dismiss_watch_events_command( } /// Jellyfin provider, no label. -pub(crate) fn generate_webhook_token_command(user_id: Uuid) -> GenerateWebhookTokenCommand { +pub fn generate_webhook_token_command(user_id: Uuid) -> GenerateWebhookTokenCommand { GenerateWebhookTokenCommand { user_id, provider: WatchEventSource::Jellyfin, @@ -200,7 +186,7 @@ pub(crate) fn generate_webhook_token_command(user_id: Uuid) -> GenerateWebhookTo } /// Every field unset; override what the test cares about. -pub(crate) fn update_profile_command(user_id: Uuid) -> UpdateProfileCommand { +pub fn update_profile_command(user_id: Uuid) -> UpdateProfileCommand { UpdateProfileCommand { user_id, display_name: None, @@ -214,7 +200,7 @@ pub(crate) fn update_profile_command(user_id: Uuid) -> UpdateProfileCommand { } /// All federation toggles on. -pub(crate) fn update_user_settings_command(user_id: Uuid) -> UpdateUserSettingsCommand { +pub fn update_user_settings_command(user_id: Uuid) -> UpdateUserSettingsCommand { UpdateUserSettingsCommand { user_id, federate_goals: true, @@ -223,7 +209,7 @@ pub(crate) fn update_user_settings_command(user_id: Uuid) -> UpdateUserSettingsC } } -pub(crate) fn add_to_watchlist_command(user_id: Uuid, movie_id: Uuid) -> AddToWatchlistCommand { +pub fn add_to_watchlist_command(user_id: Uuid, movie_id: Uuid) -> AddToWatchlistCommand { AddToWatchlistCommand { user_id, input: movie_input_by_id(movie_id), @@ -231,7 +217,7 @@ pub(crate) fn add_to_watchlist_command(user_id: Uuid, movie_id: Uuid) -> AddToWa } /// Window is 1 Jan of `year` to 1 Jan of `year + 1`. -pub(crate) fn request_wrap_up_command(user_id: Option, year: i32) -> RequestWrapUpCommand { +pub fn request_wrap_up_command(user_id: Option, year: i32) -> RequestWrapUpCommand { RequestWrapUpCommand { user_id, start_date: NaiveDate::from_ymd_opt(year, 1, 1).unwrap(), diff --git a/crates/application/src/test_helpers.rs b/crates/application/tests/support/context.rs similarity index 98% rename from crates/application/src/test_helpers.rs rename to crates/application/tests/support/context.rs index a377538b..1bc9d162 100644 --- a/crates/application/src/test_helpers.rs +++ b/crates/application/tests/support/context.rs @@ -24,7 +24,7 @@ use domain::{ use async_trait::async_trait; use domain::errors::DomainError; -use crate::{config::AppConfig, diary::commands::LogReviewCommand, ports::ReviewLogger}; +use application::{config::AppConfig, diary::commands::LogReviewCommand, ports::ReviewLogger}; pub struct NoopReviewLogger; @@ -130,7 +130,7 @@ impl TestContextBuilder { refresh_ttl_seconds: 2_592_000, secure_cookies: false, cors_origins: String::new(), - wrapup: crate::config::WrapUpConfig { + wrapup: application::config::WrapUpConfig { font_path: None, logo_path: None, bg_dir: None, diff --git a/crates/application/tests/support/mod.rs b/crates/application/tests/support/mod.rs new file mode 100644 index 00000000..fd6d9819 --- /dev/null +++ b/crates/application/tests/support/mod.rs @@ -0,0 +1,10 @@ +//! Shared fakes and builders for the application integration tests. +//! Included via `mod support;` from each `tests/.rs`; not every +//! binary uses every helper. +#![allow(dead_code, unused_imports)] + +mod commands; +mod context; + +pub use commands::*; +pub use context::*; diff --git a/crates/application/tests/users.rs b/crates/application/tests/users.rs new file mode 100644 index 00000000..855ac1db --- /dev/null +++ b/crates/application/tests/users.rs @@ -0,0 +1,26 @@ +mod support; + +#[path = "users/authorize_admin_test.rs"] +mod authorize_admin_test; +#[path = "users/get_current_profile_test.rs"] +mod get_current_profile_test; +#[path = "users/get_federated_profile_stats_test.rs"] +mod get_federated_profile_stats_test; +#[path = "users/get_federated_profile_test.rs"] +mod get_federated_profile_test; +#[path = "users/get_local_profile_test.rs"] +mod get_local_profile_test; +#[path = "users/get_page_viewer_test.rs"] +mod get_page_viewer_test; +#[path = "users/get_profile_settings_test.rs"] +mod get_profile_settings_test; +#[path = "users/get_settings_test.rs"] +mod get_settings_test; +#[path = "users/resolve_username_to_id_test.rs"] +mod resolve_username_to_id_test; +#[path = "users/update_profile_fields_test.rs"] +mod update_profile_fields_test; +#[path = "users/update_profile_test.rs"] +mod update_profile_test; +#[path = "users/update_settings_test.rs"] +mod update_settings_test; diff --git a/crates/application/src/users/tests/authorize_admin.rs b/crates/application/tests/users/authorize_admin_test.rs similarity index 95% rename from crates/application/src/users/tests/authorize_admin.rs rename to crates/application/tests/users/authorize_admin_test.rs index ce2e6eda..8e13cee0 100644 --- a/crates/application/src/users/tests/authorize_admin.rs +++ b/crates/application/tests/users/authorize_admin_test.rs @@ -1,12 +1,12 @@ -use crate::test_support::register_command; +use crate::support::TestContextBuilder; +use crate::support::register_command; use std::sync::Arc; use domain::models::UserRole; use domain::testing::InMemoryStore; -use crate::{ +use application::{ auth::{commands::RegisterCommand, deps::RegisterDeps, register}, - test_helpers::TestContextBuilder, users::{authorize_admin, deps::AuthorizeAdminDeps}, }; diff --git a/crates/application/src/users/tests/get_current_profile.rs b/crates/application/tests/users/get_current_profile_test.rs similarity index 97% rename from crates/application/src/users/tests/get_current_profile.rs rename to crates/application/tests/users/get_current_profile_test.rs index 01f6faac..f88e9194 100644 --- a/crates/application/src/users/tests/get_current_profile.rs +++ b/crates/application/tests/users/get_current_profile_test.rs @@ -1,4 +1,5 @@ -use crate::test_support::register_command; +use crate::support::TestContextBuilder; +use crate::support::register_command; use std::sync::Arc; use domain::models::{ProfileField, User, UserProfile, UserRole}; @@ -7,9 +8,8 @@ use domain::testing::InMemoryStore; use domain::value_objects::{Email, PasswordHash, UserId, Username}; use uuid::Uuid; -use crate::{ +use application::{ auth::{deps::RegisterDeps, register}, - test_helpers::TestContextBuilder, users::{deps::GetCurrentProfileDeps, get_current_profile, queries::GetCurrentProfileQuery}, }; diff --git a/crates/application/src/users/tests/get_federated_profile_stats.rs b/crates/application/tests/users/get_federated_profile_stats_test.rs similarity index 80% rename from crates/application/src/users/tests/get_federated_profile_stats.rs rename to crates/application/tests/users/get_federated_profile_stats_test.rs index ff2d43fd..05939ab6 100644 --- a/crates/application/src/users/tests/get_federated_profile_stats.rs +++ b/crates/application/tests/users/get_federated_profile_stats_test.rs @@ -1,7 +1,7 @@ -use crate::test_helpers::TestContextBuilder; -use crate::users::deps::GetFederatedProfileStatsDeps; -use crate::users::get_federated_profile_stats; -use crate::users::queries::{GetUserProfileQuery, ProfileView}; +use crate::support::TestContextBuilder; +use application::users::deps::GetFederatedProfileStatsDeps; +use application::users::get_federated_profile_stats; +use application::users::queries::{GetUserProfileQuery, ProfileView}; #[tokio::test] async fn succeeds_for_a_user_id_with_no_local_row() { diff --git a/crates/application/src/users/tests/get_federated_profile.rs b/crates/application/tests/users/get_federated_profile_test.rs similarity index 96% rename from crates/application/src/users/tests/get_federated_profile.rs rename to crates/application/tests/users/get_federated_profile_test.rs index d754cf86..45390240 100644 --- a/crates/application/src/users/tests/get_federated_profile.rs +++ b/crates/application/tests/users/get_federated_profile_test.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use async_trait::async_trait; use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery}; -use crate::users::deps::GetFederatedProfileDeps; -use crate::users::get_federated_profile; +use application::users::deps::GetFederatedProfileDeps; +use application::users::get_federated_profile; fn a_profile() -> FederatedProfile { FederatedProfile { diff --git a/crates/application/src/users/tests/get_local_profile.rs b/crates/application/tests/users/get_local_profile_test.rs similarity index 95% rename from crates/application/src/users/tests/get_local_profile.rs rename to crates/application/tests/users/get_local_profile_test.rs index 6892046f..338df9a1 100644 --- a/crates/application/src/users/tests/get_local_profile.rs +++ b/crates/application/tests/users/get_local_profile_test.rs @@ -1,15 +1,15 @@ -use crate::test_support::{register_command, update_profile_command}; +use crate::support::{register_command, update_profile_command}; use domain::errors::DomainError; use domain::value_objects::Email; -use crate::auth::deps::RegisterDeps; -use crate::auth::register; -use crate::test_helpers::TestContextBuilder; -use crate::users::commands::UpdateProfileCommand; -use crate::users::deps::{GetLocalProfileDeps, UpdateProfileDeps}; -use crate::users::get_local_profile; -use crate::users::queries::{GetUserProfileQuery, ProfileView}; -use crate::users::update_profile; +use crate::support::TestContextBuilder; +use application::auth::deps::RegisterDeps; +use application::auth::register; +use application::users::commands::UpdateProfileCommand; +use application::users::deps::{GetLocalProfileDeps, UpdateProfileDeps}; +use application::users::get_local_profile; +use application::users::queries::{GetUserProfileQuery, ProfileView}; +use application::users::update_profile; async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) { let deps = RegisterDeps { diff --git a/crates/application/src/users/tests/get_page_viewer.rs b/crates/application/tests/users/get_page_viewer_test.rs similarity index 86% rename from crates/application/src/users/tests/get_page_viewer.rs rename to crates/application/tests/users/get_page_viewer_test.rs index 788f5d0e..318c9c78 100644 --- a/crates/application/src/users/tests/get_page_viewer.rs +++ b/crates/application/tests/users/get_page_viewer_test.rs @@ -1,11 +1,11 @@ -use crate::test_support::register_command; +use crate::support::register_command; use domain::value_objects::{Email, FollowTarget, SocialIdentity, UserId}; -use crate::auth::deps::RegisterDeps; -use crate::auth::register; -use crate::test_helpers::TestContextBuilder; -use crate::users::deps::GetPageViewerDeps; -use crate::users::get_page_viewer; +use crate::support::TestContextBuilder; +use application::auth::deps::RegisterDeps; +use application::auth::register; +use application::users::deps::GetPageViewerDeps; +use application::users::get_page_viewer; async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) -> uuid::Uuid { let deps = RegisterDeps { diff --git a/crates/application/src/users/tests/get_profile_settings.rs b/crates/application/tests/users/get_profile_settings_test.rs similarity index 84% rename from crates/application/src/users/tests/get_profile_settings.rs rename to crates/application/tests/users/get_profile_settings_test.rs index defb2a06..d8ca2c49 100644 --- a/crates/application/src/users/tests/get_profile_settings.rs +++ b/crates/application/tests/users/get_profile_settings_test.rs @@ -1,12 +1,12 @@ -use crate::test_support::register_command; +use crate::support::register_command; use domain::models::ProfileField; use domain::value_objects::{Email, UserId}; -use crate::auth::deps::RegisterDeps; -use crate::auth::register; -use crate::test_helpers::TestContextBuilder; -use crate::users::deps::GetProfileSettingsDeps; -use crate::users::get_profile_settings; +use crate::support::TestContextBuilder; +use application::auth::deps::RegisterDeps; +use application::auth::register; +use application::users::deps::GetProfileSettingsDeps; +use application::users::get_profile_settings; #[tokio::test] async fn get_profile_settings_includes_profile_fields() { diff --git a/crates/application/src/users/tests/get_settings.rs b/crates/application/tests/users/get_settings_test.rs similarity index 79% rename from crates/application/src/users/tests/get_settings.rs rename to crates/application/tests/users/get_settings_test.rs index fd02f4f7..96fc273e 100644 --- a/crates/application/src/users/tests/get_settings.rs +++ b/crates/application/tests/users/get_settings_test.rs @@ -1,9 +1,7 @@ +use crate::support::TestContextBuilder; use uuid::Uuid; -use crate::{ - test_helpers::TestContextBuilder, - users::{deps::GetSettingsDeps, get_settings}, -}; +use application::users::{deps::GetSettingsDeps, get_settings}; #[tokio::test] async fn returns_default_settings() { diff --git a/crates/application/src/users/tests/resolve_username_to_id.rs b/crates/application/tests/users/resolve_username_to_id_test.rs similarity index 75% rename from crates/application/src/users/tests/resolve_username_to_id.rs rename to crates/application/tests/users/resolve_username_to_id_test.rs index 05fa48a5..cbf865ba 100644 --- a/crates/application/src/users/tests/resolve_username_to_id.rs +++ b/crates/application/tests/users/resolve_username_to_id_test.rs @@ -1,8 +1,8 @@ use domain::value_objects::Username; -use crate::test_helpers::TestContextBuilder; -use crate::users::deps::ResolveUsernameDeps; -use crate::users::resolve_username_to_id; +use crate::support::TestContextBuilder; +use application::users::deps::ResolveUsernameDeps; +use application::users::resolve_username_to_id; #[tokio::test] async fn resolve_username_to_id_returns_none_for_unknown() { diff --git a/crates/application/src/users/tests/update_profile_fields.rs b/crates/application/tests/users/update_profile_fields_test.rs similarity index 91% rename from crates/application/src/users/tests/update_profile_fields.rs rename to crates/application/tests/users/update_profile_fields_test.rs index 7cf15d8b..eb00c1fa 100644 --- a/crates/application/src/users/tests/update_profile_fields.rs +++ b/crates/application/tests/users/update_profile_fields_test.rs @@ -1,3 +1,4 @@ +use crate::support::TestContextBuilder; use std::sync::Arc; use domain::events::DomainEvent; @@ -5,11 +6,8 @@ use domain::models::ProfileField; use domain::testing::{InMemoryStore, NoopEventPublisher}; use uuid::Uuid; -use crate::{ - test_helpers::TestContextBuilder, - users::{ - commands::UpdateProfileFieldsCommand, deps::UpdateProfileFieldsDeps, update_profile_fields, - }, +use application::users::{ + commands::UpdateProfileFieldsCommand, deps::UpdateProfileFieldsDeps, update_profile_fields, }; #[tokio::test] diff --git a/crates/application/src/users/tests/update_profile.rs b/crates/application/tests/users/update_profile_test.rs similarity index 98% rename from crates/application/src/users/tests/update_profile.rs rename to crates/application/tests/users/update_profile_test.rs index f1e0de0c..14540b62 100644 --- a/crates/application/src/users/tests/update_profile.rs +++ b/crates/application/tests/users/update_profile_test.rs @@ -1,4 +1,5 @@ -use crate::test_support::{register_command, update_profile_command}; +use crate::support::TestContextBuilder; +use crate::support::{register_command, update_profile_command}; use std::sync::Arc; use domain::events::DomainEvent; @@ -6,9 +7,8 @@ use domain::ports::UserRepository; use domain::testing::{InMemoryStore, NoopEventPublisher}; use uuid::Uuid; -use crate::{ +use application::{ auth::{deps::RegisterDeps, register}, - test_helpers::TestContextBuilder, users::{commands::UpdateProfileCommand, deps::UpdateProfileDeps, update_profile}, }; diff --git a/crates/application/src/users/tests/update_settings.rs b/crates/application/tests/users/update_settings_test.rs similarity index 86% rename from crates/application/src/users/tests/update_settings.rs rename to crates/application/tests/users/update_settings_test.rs index 77d4bce5..cd4b51b4 100644 --- a/crates/application/src/users/tests/update_settings.rs +++ b/crates/application/tests/users/update_settings_test.rs @@ -1,16 +1,14 @@ -use crate::test_support::update_user_settings_command; +use crate::support::TestContextBuilder; +use crate::support::update_user_settings_command; use std::sync::Arc; use domain::testing::InMemoryStore; use uuid::Uuid; -use crate::{ - test_helpers::TestContextBuilder, - users::{ - deps::{GetSettingsDeps, UpdateSettingsDeps}, - get_settings, - update_settings::UpdateUserSettingsCommand, - }, +use application::users::{ + deps::{GetSettingsDeps, UpdateSettingsDeps}, + get_settings, + update_settings::UpdateUserSettingsCommand, }; #[tokio::test] @@ -20,7 +18,7 @@ async fn updates_federate_goals() { let user_settings = b.user_settings_repo.clone(); let uid = Uuid::nil(); - crate::users::update_settings::execute( + application::users::update_settings::execute( &UpdateSettingsDeps { user_settings: user_settings.clone(), }, @@ -47,7 +45,7 @@ async fn updates_federate_reviews() { let user_settings = b.user_settings_repo.clone(); let uid = Uuid::nil(); - crate::users::update_settings::execute( + application::users::update_settings::execute( &UpdateSettingsDeps { user_settings: user_settings.clone(), }, @@ -74,7 +72,7 @@ async fn updates_federate_watchlist() { let user_settings = b.user_settings_repo.clone(); let uid = Uuid::nil(); - crate::users::update_settings::execute( + application::users::update_settings::execute( &UpdateSettingsDeps { user_settings: user_settings.clone(), }, diff --git a/crates/application/tests/watchlist.rs b/crates/application/tests/watchlist.rs new file mode 100644 index 00000000..fdc78e7c --- /dev/null +++ b/crates/application/tests/watchlist.rs @@ -0,0 +1,12 @@ +mod support; + +#[path = "watchlist/add_test.rs"] +mod add_test; +#[path = "watchlist/get_test.rs"] +mod get_test; +#[path = "watchlist/get_watchlist_for_owner_test.rs"] +mod get_watchlist_for_owner_test; +#[path = "watchlist/is_on_test.rs"] +mod is_on_test; +#[path = "watchlist/remove_test.rs"] +mod remove_test; diff --git a/crates/application/src/watchlist/tests/add.rs b/crates/application/tests/watchlist/add_test.rs similarity index 87% rename from crates/application/src/watchlist/tests/add.rs rename to crates/application/tests/watchlist/add_test.rs index 4b664c79..20c3572c 100644 --- a/crates/application/src/watchlist/tests/add.rs +++ b/crates/application/tests/watchlist/add_test.rs @@ -1,4 +1,4 @@ -use crate::test_support::{add_to_watchlist_command, movie_input_manual}; +use crate::support::{add_to_watchlist_command, movie_input_manual}; use domain::testing::test_movie_with_year; use std::sync::Arc; @@ -7,7 +7,7 @@ use domain::{ testing::{InMemoryStore, NoopEventPublisher}, }; -use crate::watchlist::{add, commands::AddToWatchlistCommand, deps::WatchlistAddDeps}; +use application::watchlist::{add, commands::AddToWatchlistCommand, deps::WatchlistAddDeps}; fn make_deps(movies: Arc, watchlist: Arc) -> WatchlistAddDeps { WatchlistAddDeps { @@ -20,7 +20,7 @@ fn make_deps(movies: Arc, watchlist: Arc) -> Watch } #[tokio::test] -async fn test_add_to_watchlist_resolves_and_saves() { +async fn adding_to_the_watchlist_resolves_the_movie_and_saves_the_entry() { let movies = InMemoryStore::new(); let watchlist = Arc::clone(&movies); @@ -38,7 +38,7 @@ async fn test_add_to_watchlist_resolves_and_saves() { } #[tokio::test] -async fn test_add_to_watchlist_already_present_is_idempotent() { +async fn adding_a_movie_already_on_the_watchlist_is_idempotent() { let movies = InMemoryStore::new(); let watchlist = Arc::clone(&movies); @@ -62,7 +62,7 @@ async fn test_add_to_watchlist_already_present_is_idempotent() { } #[tokio::test] -async fn test_add_to_watchlist_with_manual_movie() { +async fn adding_a_manual_movie_to_the_watchlist_creates_and_saves_it() { let movies = InMemoryStore::new(); let watchlist = Arc::clone(&movies); @@ -80,7 +80,7 @@ async fn test_add_to_watchlist_with_manual_movie() { } #[tokio::test] -async fn test_add_to_watchlist_movie_not_found_by_id() { +async fn adding_an_unknown_movie_id_to_the_watchlist_fails() { let movies = InMemoryStore::new(); let watchlist = Arc::clone(&movies); diff --git a/crates/application/src/watchlist/tests/get.rs b/crates/application/tests/watchlist/get_test.rs similarity index 79% rename from crates/application/src/watchlist/tests/get.rs rename to crates/application/tests/watchlist/get_test.rs index a77ba973..d1d621f6 100644 --- a/crates/application/src/watchlist/tests/get.rs +++ b/crates/application/tests/watchlist/get_test.rs @@ -1,7 +1,7 @@ use uuid::Uuid; -use crate::test_helpers::TestContextBuilder; -use crate::watchlist::{deps::GetWatchlistDeps, get, queries::GetWatchlistQuery}; +use crate::support::TestContextBuilder; +use application::watchlist::{deps::GetWatchlistDeps, get, queries::GetWatchlistQuery}; #[tokio::test] async fn returns_empty_page_for_new_user() { diff --git a/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs b/crates/application/tests/watchlist/get_watchlist_for_owner_test.rs similarity index 92% rename from crates/application/src/watchlist/tests/get_watchlist_for_owner.rs rename to crates/application/tests/watchlist/get_watchlist_for_owner_test.rs index 2d13a9d4..c0d627ce 100644 --- a/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs +++ b/crates/application/tests/watchlist/get_watchlist_for_owner_test.rs @@ -1,4 +1,4 @@ -use crate::test_support::register_command; +use crate::support::register_command; use std::sync::Arc; use uuid::Uuid; @@ -6,11 +6,11 @@ use uuid::Uuid; use domain::testing::{InMemoryStore, PanicRemoteWatchlistRepository, PanicWatchlistRepository}; use domain::value_objects::Email; -use crate::auth::deps::RegisterDeps; -use crate::auth::register; -use crate::test_helpers::TestContextBuilder; -use crate::watchlist::deps::GetWatchlistForOwnerDeps; -use crate::watchlist::get_watchlist_for_owner::{self, WatchlistView}; +use crate::support::TestContextBuilder; +use application::auth::deps::RegisterDeps; +use application::auth::register; +use application::watchlist::deps::GetWatchlistForOwnerDeps; +use application::watchlist::get_watchlist_for_owner::{self, WatchlistView}; async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) { let deps = RegisterDeps { diff --git a/crates/application/src/watchlist/tests/is_on.rs b/crates/application/tests/watchlist/is_on_test.rs similarity index 90% rename from crates/application/src/watchlist/tests/is_on.rs rename to crates/application/tests/watchlist/is_on_test.rs index 9ca68ba3..2b59f127 100644 --- a/crates/application/src/watchlist/tests/is_on.rs +++ b/crates/application/tests/watchlist/is_on_test.rs @@ -6,8 +6,8 @@ use domain::testing::InMemoryStore; use domain::value_objects::{MovieId, UserId}; use uuid::Uuid; -use crate::test_helpers::TestContextBuilder; -use crate::watchlist::{deps::IsOnWatchlistDeps, is_on, queries::IsOnWatchlistQuery}; +use crate::support::TestContextBuilder; +use application::watchlist::{deps::IsOnWatchlistDeps, is_on, queries::IsOnWatchlistQuery}; #[tokio::test] async fn returns_true_when_present() { diff --git a/crates/application/src/watchlist/tests/remove.rs b/crates/application/tests/watchlist/remove_test.rs similarity index 96% rename from crates/application/src/watchlist/tests/remove.rs rename to crates/application/tests/watchlist/remove_test.rs index 256f629c..0616e298 100644 --- a/crates/application/src/watchlist/tests/remove.rs +++ b/crates/application/tests/watchlist/remove_test.rs @@ -7,8 +7,8 @@ use domain::testing::{InMemoryStore, NoopEventPublisher}; use domain::value_objects::{MovieId, UserId}; use uuid::Uuid; -use crate::test_helpers::TestContextBuilder; -use crate::watchlist::{ +use crate::support::TestContextBuilder; +use application::watchlist::{ commands::RemoveFromWatchlistCommand, deps::RemoveFromWatchlistDeps, remove, }; diff --git a/crates/application/tests/worker.rs b/crates/application/tests/worker.rs new file mode 100644 index 00000000..be788c10 --- /dev/null +++ b/crates/application/tests/worker.rs @@ -0,0 +1,4 @@ +mod support; + +#[path = "worker/worker_test.rs"] +mod worker_test; diff --git a/crates/application/src/tests/worker.rs b/crates/application/tests/worker/worker_test.rs similarity index 98% rename from crates/application/src/tests/worker.rs rename to crates/application/tests/worker/worker_test.rs index 01729a65..56324c71 100644 --- a/crates/application/src/tests/worker.rs +++ b/crates/application/tests/worker/worker_test.rs @@ -1,4 +1,4 @@ -use super::*; +use application::worker::*; use async_trait::async_trait; use domain::testing::NoopAckHandle; use domain::value_objects::{ExternalMetadataId, MovieId}; @@ -6,6 +6,10 @@ use domain::{ errors::DomainError, events::{AckHandle, DomainEvent}, }; +use domain::{ + events::EventEnvelope, + ports::{EventConsumer, EventHandler}, +}; use futures::{stream, stream::BoxStream}; use std::sync::{Arc, Mutex}; diff --git a/crates/application/tests/wrapup.rs b/crates/application/tests/wrapup.rs new file mode 100644 index 00000000..8281caa3 --- /dev/null +++ b/crates/application/tests/wrapup.rs @@ -0,0 +1,16 @@ +mod support; + +#[path = "wrapup/compute_test.rs"] +mod compute_test; +#[path = "wrapup/delete_test.rs"] +mod delete_test; +#[path = "wrapup/generate_test.rs"] +mod generate_test; +#[path = "wrapup/get_ready_report_test.rs"] +mod get_ready_report_test; +#[path = "wrapup/get_wrapup_test.rs"] +mod get_wrapup_test; +#[path = "wrapup/handle_requested_test.rs"] +mod handle_requested_test; +#[path = "wrapup/list_wrapups_test.rs"] +mod list_wrapups_test; diff --git a/crates/application/src/wrapup/tests/compute.rs b/crates/application/tests/wrapup/compute_test.rs similarity index 93% rename from crates/application/src/wrapup/tests/compute.rs rename to crates/application/tests/wrapup/compute_test.rs index 1fdee7ec..42459dd3 100644 --- a/crates/application/src/wrapup/tests/compute.rs +++ b/crates/application/tests/wrapup/compute_test.rs @@ -4,7 +4,7 @@ use domain::models::wrapup::{DateRange, WrapUpScope}; use domain::testing::InMemoryWrapUpStatsQuery; use uuid::Uuid; -use crate::wrapup::queries::ComputeWrapUpQuery; +use application::wrapup::queries::ComputeWrapUpQuery; fn make_row(title: &str, rating: u8, watched_at: &str) -> WrapUpMovieRow { WrapUpMovieRow { @@ -44,7 +44,7 @@ async fn empty_report() { let stats = InMemoryWrapUpStatsQuery::new(); let user_id = Uuid::new_v4(); - let report = super::execute( + let report = application::wrapup::compute::execute( stats, ComputeWrapUpQuery { scope: WrapUpScope::User(user_id), @@ -74,7 +74,7 @@ async fn basic_stats() { let stats = InMemoryWrapUpStatsQuery::with_rows(vec![r1, r2]); - let report = super::execute( + let report = application::wrapup::compute::execute( stats, ComputeWrapUpQuery { scope: WrapUpScope::User(user_id), @@ -108,7 +108,7 @@ async fn rewatch_detection() { let stats = InMemoryWrapUpStatsQuery::with_rows(vec![r1, r2]); - let report = super::execute( + let report = application::wrapup::compute::execute( stats, ComputeWrapUpQuery { scope: WrapUpScope::User(user_id), @@ -139,7 +139,7 @@ async fn global_scope() { let stats = InMemoryWrapUpStatsQuery::with_rows(vec![r1, r2]); - let report = super::execute( + let report = application::wrapup::compute::execute( stats, ComputeWrapUpQuery { scope: WrapUpScope::Global, diff --git a/crates/application/src/wrapup/tests/delete.rs b/crates/application/tests/wrapup/delete_test.rs similarity index 93% rename from crates/application/src/wrapup/tests/delete.rs rename to crates/application/tests/wrapup/delete_test.rs index 2cbeb1df..08cb7a7b 100644 --- a/crates/application/src/wrapup/tests/delete.rs +++ b/crates/application/tests/wrapup/delete_test.rs @@ -3,8 +3,8 @@ use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; use domain::testing::InMemoryWrapUpRepository; use domain::value_objects::WrapUpId; -use crate::wrapup::delete; -use crate::wrapup::deps::DeleteWrapUpDeps; +use application::wrapup::delete; +use application::wrapup::deps::DeleteWrapUpDeps; #[tokio::test] async fn deletes_existing_wrapup() { diff --git a/crates/application/src/wrapup/tests/generate.rs b/crates/application/tests/wrapup/generate_test.rs similarity index 95% rename from crates/application/src/wrapup/tests/generate.rs rename to crates/application/tests/wrapup/generate_test.rs index fceec0bf..0d0657c6 100644 --- a/crates/application/src/wrapup/tests/generate.rs +++ b/crates/application/tests/wrapup/generate_test.rs @@ -1,4 +1,4 @@ -use crate::test_support::request_wrap_up_command; +use crate::support::request_wrap_up_command; use chrono::NaiveDate; use domain::events::DomainEvent; use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; @@ -8,8 +8,8 @@ use uuid::Uuid; use std::sync::Arc; -use crate::wrapup::deps::GenerateWrapUpDeps; -use crate::wrapup::{commands::RequestWrapUpCommand, generate}; +use application::wrapup::deps::GenerateWrapUpDeps; +use application::wrapup::{commands::RequestWrapUpCommand, generate}; fn deps( repo: &Arc, diff --git a/crates/application/src/wrapup/tests/get_ready_report.rs b/crates/application/tests/wrapup/get_ready_report_test.rs similarity index 97% rename from crates/application/src/wrapup/tests/get_ready_report.rs rename to crates/application/tests/wrapup/get_ready_report_test.rs index 5fc46c62..e8dcef7f 100644 --- a/crates/application/src/wrapup/tests/get_ready_report.rs +++ b/crates/application/tests/wrapup/get_ready_report_test.rs @@ -8,8 +8,8 @@ use domain::services::wrapup_analyzer; use domain::testing::InMemoryWrapUpRepository; use domain::value_objects::WrapUpId; -use crate::wrapup::deps::GetReadyReportDeps; -use crate::wrapup::get_ready_report; +use application::wrapup::deps::GetReadyReportDeps; +use application::wrapup::get_ready_report; fn year_range(year: i32) -> DateRange { DateRange::new( diff --git a/crates/application/src/wrapup/tests/get_wrapup.rs b/crates/application/tests/wrapup/get_wrapup_test.rs similarity index 93% rename from crates/application/src/wrapup/tests/get_wrapup.rs rename to crates/application/tests/wrapup/get_wrapup_test.rs index 9792d7ff..3a2f85a1 100644 --- a/crates/application/src/wrapup/tests/get_wrapup.rs +++ b/crates/application/tests/wrapup/get_wrapup_test.rs @@ -3,8 +3,8 @@ use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; use domain::testing::InMemoryWrapUpRepository; use domain::value_objects::WrapUpId; -use crate::wrapup::deps::GetWrapUpDeps; -use crate::wrapup::get_wrapup; +use application::wrapup::deps::GetWrapUpDeps; +use application::wrapup::get_wrapup; #[tokio::test] async fn returns_record_when_exists() { diff --git a/crates/application/src/wrapup/tests/handle_requested.rs b/crates/application/tests/wrapup/handle_requested_test.rs similarity index 98% rename from crates/application/src/wrapup/tests/handle_requested.rs rename to crates/application/tests/wrapup/handle_requested_test.rs index e436b96e..c7fb26ce 100644 --- a/crates/application/src/wrapup/tests/handle_requested.rs +++ b/crates/application/tests/wrapup/handle_requested_test.rs @@ -4,7 +4,7 @@ use domain::ports::WrapUpRepository; use domain::testing::{InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, NoopEventPublisher}; use domain::value_objects::WrapUpId; -use crate::wrapup::{deps::HandleWrapUpRequestedDeps, handle_requested}; +use application::wrapup::{deps::HandleWrapUpRequestedDeps, handle_requested}; #[tokio::test] async fn skips_if_already_ready() { diff --git a/crates/application/src/wrapup/tests/list_wrapups.rs b/crates/application/tests/wrapup/list_wrapups_test.rs similarity index 93% rename from crates/application/src/wrapup/tests/list_wrapups.rs rename to crates/application/tests/wrapup/list_wrapups_test.rs index da71dd39..ee4c50a8 100644 --- a/crates/application/src/wrapup/tests/list_wrapups.rs +++ b/crates/application/tests/wrapup/list_wrapups_test.rs @@ -4,8 +4,8 @@ use domain::testing::InMemoryWrapUpRepository; use domain::value_objects::WrapUpId; use uuid::Uuid; -use crate::wrapup::deps::ListWrapUpsDeps; -use crate::wrapup::list_wrapups::{self, ListWrapUpsQuery}; +use application::wrapup::deps::ListWrapUpsDeps; +use application::wrapup::list_wrapups::{self, ListWrapUpsQuery}; fn make_record(user_id: Option) -> WrapUpRecord { WrapUpRecord { -- 2.49.1 From f6383d6c0467d11fd86be9d66cf28d8b3cb03919 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:40:53 +0100 Subject: [PATCH 031/104] test: move sqlite tests to tests/ No inline exceptions; migrate via sqlite::migrate, renamed diary/social tests. --- .../sqlite/src/repositories/diary/mod.rs | 3 --- .../sqlite/src/repositories/image_ref/mod.rs | 3 --- .../sqlite/src/repositories/persons/mod.rs | 3 --- .../sqlite/src/repositories/profile/mod.rs | 3 --- .../sqlite/src/repositories/search/mod.rs | 3 --- .../sqlite/src/repositories/social/mod.rs | 3 --- .../sqlite/src/repositories/users/mod.rs | 3 --- crates/adapters/sqlite/tests/repositories.rs | 14 +++++++++++++ .../repositories/diary_repository_test.rs} | 20 +++++++++---------- .../repositories/image_ref_adapter_test.rs} | 4 +++- .../movie_profile_repository_test.rs} | 2 +- .../repositories/person_adapter_test.rs} | 2 +- .../repositories/search_adapter_test.rs} | 2 +- .../repositories/social_repository_test.rs} | 4 ++-- .../repositories/user_repository_test.rs} | 11 ++++++---- 15 files changed, 39 insertions(+), 41 deletions(-) create mode 100644 crates/adapters/sqlite/tests/repositories.rs rename crates/adapters/sqlite/{src/repositories/diary/tests.rs => tests/repositories/diary_repository_test.rs} (92%) rename crates/adapters/sqlite/{src/repositories/image_ref/tests.rs => tests/repositories/image_ref_adapter_test.rs} (96%) rename crates/adapters/sqlite/{src/repositories/profile/tests.rs => tests/repositories/movie_profile_repository_test.rs} (98%) rename crates/adapters/sqlite/{src/repositories/persons/tests.rs => tests/repositories/person_adapter_test.rs} (99%) rename crates/adapters/sqlite/{src/repositories/search/tests.rs => tests/repositories/search_adapter_test.rs} (99%) rename crates/adapters/sqlite/{src/repositories/social/tests.rs => tests/repositories/social_repository_test.rs} (98%) rename crates/adapters/sqlite/{src/repositories/users/tests.rs => tests/repositories/user_repository_test.rs} (92%) diff --git a/crates/adapters/sqlite/src/repositories/diary/mod.rs b/crates/adapters/sqlite/src/repositories/diary/mod.rs index c1a211d7..62aa7dfd 100644 --- a/crates/adapters/sqlite/src/repositories/diary/mod.rs +++ b/crates/adapters/sqlite/src/repositories/diary/mod.rs @@ -169,6 +169,3 @@ impl SqliteDiaryRepository { .map_err(crate::shared::map_sqlx_error) } } - -#[cfg(test)] -mod tests; diff --git a/crates/adapters/sqlite/src/repositories/image_ref/mod.rs b/crates/adapters/sqlite/src/repositories/image_ref/mod.rs index 322d1b90..0a1397c5 100644 --- a/crates/adapters/sqlite/src/repositories/image_ref/mod.rs +++ b/crates/adapters/sqlite/src/repositories/image_ref/mod.rs @@ -64,6 +64,3 @@ impl ImageRefQuery for SqliteImageRefAdapter { Ok(rows.into_iter().map(|(k,)| k).collect()) } } - -#[cfg(test)] -mod tests; diff --git a/crates/adapters/sqlite/src/repositories/persons/mod.rs b/crates/adapters/sqlite/src/repositories/persons/mod.rs index 19118f8f..38055c2b 100644 --- a/crates/adapters/sqlite/src/repositories/persons/mod.rs +++ b/crates/adapters/sqlite/src/repositories/persons/mod.rs @@ -23,6 +23,3 @@ pub fn create_person_adapter(pool: SqlitePool) -> (Arc, Arc, ) } - -#[cfg(test)] -mod tests; diff --git a/crates/adapters/sqlite/src/repositories/profile/mod.rs b/crates/adapters/sqlite/src/repositories/profile/mod.rs index 9b827dd2..348ff85a 100644 --- a/crates/adapters/sqlite/src/repositories/profile/mod.rs +++ b/crates/adapters/sqlite/src/repositories/profile/mod.rs @@ -298,6 +298,3 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { .collect()) } } - -#[cfg(test)] -mod tests; diff --git a/crates/adapters/sqlite/src/repositories/search/mod.rs b/crates/adapters/sqlite/src/repositories/search/mod.rs index 9dd37f91..48e876d4 100644 --- a/crates/adapters/sqlite/src/repositories/search/mod.rs +++ b/crates/adapters/sqlite/src/repositories/search/mod.rs @@ -22,6 +22,3 @@ pub fn create_search_adapter(pool: SqlitePool) -> (Arc, Arc, ) } - -#[cfg(test)] -mod tests; diff --git a/crates/adapters/sqlite/src/repositories/social/mod.rs b/crates/adapters/sqlite/src/repositories/social/mod.rs index 1c003a59..4e6042c8 100644 --- a/crates/adapters/sqlite/src/repositories/social/mod.rs +++ b/crates/adapters/sqlite/src/repositories/social/mod.rs @@ -22,6 +22,3 @@ pub fn create_federated_profile_query( ) -> std::sync::Arc { std::sync::Arc::new(SqliteSocialRepository::new(pool, instance)) } - -#[cfg(test)] -mod tests; diff --git a/crates/adapters/sqlite/src/repositories/users/mod.rs b/crates/adapters/sqlite/src/repositories/users/mod.rs index d69b8be8..f6990409 100644 --- a/crates/adapters/sqlite/src/repositories/users/mod.rs +++ b/crates/adapters/sqlite/src/repositories/users/mod.rs @@ -197,6 +197,3 @@ impl UserRepository for SqliteUserRepository { .collect() } } - -#[cfg(test)] -mod tests; diff --git a/crates/adapters/sqlite/tests/repositories.rs b/crates/adapters/sqlite/tests/repositories.rs new file mode 100644 index 00000000..489a9fa5 --- /dev/null +++ b/crates/adapters/sqlite/tests/repositories.rs @@ -0,0 +1,14 @@ +#[path = "repositories/diary_repository_test.rs"] +mod diary_repository_test; +#[path = "repositories/image_ref_adapter_test.rs"] +mod image_ref_adapter_test; +#[path = "repositories/movie_profile_repository_test.rs"] +mod movie_profile_repository_test; +#[path = "repositories/person_adapter_test.rs"] +mod person_adapter_test; +#[path = "repositories/search_adapter_test.rs"] +mod search_adapter_test; +#[path = "repositories/social_repository_test.rs"] +mod social_repository_test; +#[path = "repositories/user_repository_test.rs"] +mod user_repository_test; diff --git a/crates/adapters/sqlite/src/repositories/diary/tests.rs b/crates/adapters/sqlite/tests/repositories/diary_repository_test.rs similarity index 92% rename from crates/adapters/sqlite/src/repositories/diary/tests.rs rename to crates/adapters/sqlite/tests/repositories/diary_repository_test.rs index 477beaf6..59a0d7c4 100644 --- a/crates/adapters/sqlite/src/repositories/diary/tests.rs +++ b/crates/adapters/sqlite/tests/repositories/diary_repository_test.rs @@ -1,13 +1,13 @@ -use super::*; use domain::{ models::collections::PageParams, models::{FeedSortBy, FollowingFilter}, ports::DiaryQuery, }; +use sqlite::SqliteDiaryRepository; use sqlx::SqlitePool; async fn setup(pool: &SqlitePool) { - sqlx::migrate!("./migrations").run(pool).await.unwrap(); + sqlite::migrate(pool).await.unwrap(); sqlx::query( "INSERT INTO users (id, email, username, password_hash, created_at) VALUES @@ -41,7 +41,7 @@ async fn setup(pool: &SqlitePool) { } #[tokio::test] -async fn test_sort_by_rating_descending() { +async fn activity_feed_sorts_by_rating_descending() { let pool = SqlitePool::connect(":memory:").await.unwrap(); setup(&pool).await; let repo = SqliteDiaryRepository::new(pool); @@ -61,7 +61,7 @@ async fn test_sort_by_rating_descending() { } #[tokio::test] -async fn test_search_by_title() { +async fn activity_feed_filters_by_title_search() { let pool = SqlitePool::connect(":memory:").await.unwrap(); setup(&pool).await; let repo = SqliteDiaryRepository::new(pool); @@ -77,7 +77,7 @@ async fn test_search_by_title() { } #[tokio::test] -async fn test_following_filter() { +async fn activity_feed_restricts_to_followed_local_and_remote_authors() { let pool = SqlitePool::connect(":memory:").await.unwrap(); setup(&pool).await; let repo = SqliteDiaryRepository::new(pool); @@ -105,7 +105,7 @@ async fn test_following_filter() { } #[tokio::test] -async fn test_get_movie_stats_local() { +async fn movie_stats_for_local_review() { let pool = SqlitePool::connect(":memory:").await.unwrap(); setup(&pool).await; let repo = SqliteDiaryRepository::new(pool); @@ -123,7 +123,7 @@ async fn test_get_movie_stats_local() { } #[tokio::test] -async fn test_get_movie_social_feed_returns_reviews_for_movie() { +async fn movie_social_feed_returns_reviews_for_movie() { let pool = SqlitePool::connect(":memory:").await.unwrap(); setup(&pool).await; let repo = SqliteDiaryRepository::new(pool); @@ -143,7 +143,7 @@ async fn test_get_movie_social_feed_returns_reviews_for_movie() { } #[tokio::test] -async fn test_get_movie_social_feed_federated_review() { +async fn movie_social_feed_marks_federated_review_as_remote() { let pool = SqlitePool::connect(":memory:").await.unwrap(); setup(&pool).await; let repo = SqliteDiaryRepository::new(pool); @@ -164,7 +164,7 @@ async fn test_get_movie_social_feed_federated_review() { } #[tokio::test] -async fn test_get_movie_social_feed_pagination() { +async fn movie_social_feed_offset_past_end_returns_no_items() { let pool = SqlitePool::connect(":memory:").await.unwrap(); setup(&pool).await; let repo = SqliteDiaryRepository::new(pool); @@ -180,7 +180,7 @@ async fn test_get_movie_social_feed_pagination() { } #[tokio::test] -async fn test_get_movie_stats_federated() { +async fn movie_stats_counts_federated_review() { let pool = SqlitePool::connect(":memory:").await.unwrap(); setup(&pool).await; let repo = SqliteDiaryRepository::new(pool); diff --git a/crates/adapters/sqlite/src/repositories/image_ref/tests.rs b/crates/adapters/sqlite/tests/repositories/image_ref_adapter_test.rs similarity index 96% rename from crates/adapters/sqlite/src/repositories/image_ref/tests.rs rename to crates/adapters/sqlite/tests/repositories/image_ref_adapter_test.rs index e71c9d38..7a49eca4 100644 --- a/crates/adapters/sqlite/src/repositories/image_ref/tests.rs +++ b/crates/adapters/sqlite/tests/repositories/image_ref_adapter_test.rs @@ -1,4 +1,6 @@ -use super::*; +use domain::ports::{ImageRefCommand, ImageRefQuery}; +use sqlite::SqliteImageRefAdapter; +use sqlx::SqlitePool; async fn setup(pool: &SqlitePool) { sqlx::query( diff --git a/crates/adapters/sqlite/src/repositories/profile/tests.rs b/crates/adapters/sqlite/tests/repositories/movie_profile_repository_test.rs similarity index 98% rename from crates/adapters/sqlite/src/repositories/profile/tests.rs rename to crates/adapters/sqlite/tests/repositories/movie_profile_repository_test.rs index 2a85a1c0..116e6e17 100644 --- a/crates/adapters/sqlite/src/repositories/profile/tests.rs +++ b/crates/adapters/sqlite/tests/repositories/movie_profile_repository_test.rs @@ -1,5 +1,5 @@ -use super::SqliteMovieProfileRepository; use domain::{ports::MovieProfileRepository, value_objects::MovieId}; +use sqlite::SqliteMovieProfileRepository; use sqlx::SqlitePool; async fn pool_with_schema() -> SqlitePool { diff --git a/crates/adapters/sqlite/src/repositories/persons/tests.rs b/crates/adapters/sqlite/tests/repositories/person_adapter_test.rs similarity index 99% rename from crates/adapters/sqlite/src/repositories/persons/tests.rs rename to crates/adapters/sqlite/tests/repositories/person_adapter_test.rs index 16623ad1..da08eb6d 100644 --- a/crates/adapters/sqlite/src/repositories/persons/tests.rs +++ b/crates/adapters/sqlite/tests/repositories/person_adapter_test.rs @@ -1,9 +1,9 @@ -use super::SqlitePersonAdapter; use domain::testing::test_person_with_tmdb; use domain::{ models::{ExternalPersonId, PersonId}, ports::{PersonCommand, PersonQuery}, }; +use sqlite::SqlitePersonAdapter; use sqlx::SqlitePool; async fn pool_with_schema() -> SqlitePool { diff --git a/crates/adapters/sqlite/src/repositories/search/tests.rs b/crates/adapters/sqlite/tests/repositories/search_adapter_test.rs similarity index 99% rename from crates/adapters/sqlite/src/repositories/search/tests.rs rename to crates/adapters/sqlite/tests/repositories/search_adapter_test.rs index 80f0faf6..874d3f1c 100644 --- a/crates/adapters/sqlite/src/repositories/search/tests.rs +++ b/crates/adapters/sqlite/tests/repositories/search_adapter_test.rs @@ -1,10 +1,10 @@ -use super::create_search_adapter; use domain::{ models::{ EntityType, IndexableDocument, Movie, SearchFilters, SearchQuery, collections::PageParams, }, value_objects::{MovieId, MovieTitle, ReleaseYear}, }; +use sqlite::create_search_adapter; use sqlx::SqlitePool; async fn pool_with_schema() -> SqlitePool { diff --git a/crates/adapters/sqlite/src/repositories/social/tests.rs b/crates/adapters/sqlite/tests/repositories/social_repository_test.rs similarity index 98% rename from crates/adapters/sqlite/src/repositories/social/tests.rs rename to crates/adapters/sqlite/tests/repositories/social_repository_test.rs index dcb18b64..526b6dbf 100644 --- a/crates/adapters/sqlite/src/repositories/social/tests.rs +++ b/crates/adapters/sqlite/tests/repositories/social_repository_test.rs @@ -1,6 +1,6 @@ -use super::*; use domain::ports::{FederationAdminQuery, FollowQuery}; use domain::value_objects::{FollowStatus, InstanceIdentity, SocialIdentity}; +use sqlite::SqliteSocialRepository; use sqlx::SqlitePool; async fn test_pool() -> SqlitePool { @@ -220,7 +220,7 @@ async fn setup_admin_query_db(pool: &SqlitePool) { } #[tokio::test] -async fn test_list_all_followed_remote_actors_deduplicates() { +async fn list_all_followed_remote_actors_deduplicates_across_users() { let pool = SqlitePool::connect(":memory:").await.unwrap(); setup_admin_query_db(&pool).await; let repo = diff --git a/crates/adapters/sqlite/src/repositories/users/tests.rs b/crates/adapters/sqlite/tests/repositories/user_repository_test.rs similarity index 92% rename from crates/adapters/sqlite/src/repositories/users/tests.rs rename to crates/adapters/sqlite/tests/repositories/user_repository_test.rs index bd94d1d6..fa6c3290 100644 --- a/crates/adapters/sqlite/src/repositories/users/tests.rs +++ b/crates/adapters/sqlite/tests/repositories/user_repository_test.rs @@ -1,5 +1,8 @@ -use super::*; +use domain::models::UserProfile; +use domain::ports::UserRepository; use domain::testing::test_user; +use domain::value_objects::UserId; +use sqlite::SqliteUserRepository; use sqlx::SqlitePool; async fn setup() -> (SqlitePool, SqliteUserRepository) { @@ -59,7 +62,7 @@ async fn update_profile_persists_bio_and_avatar() { repo.update_profile( user.id(), - &domain::models::UserProfile { + &UserProfile { bio: Some("My biography".to_string()), avatar_path: Some("avatars/user1".to_string()), ..Default::default() @@ -80,7 +83,7 @@ async fn update_profile_clears_fields_with_none() { repo.save(&user).await.unwrap(); repo.update_profile( user.id(), - &domain::models::UserProfile { + &UserProfile { bio: Some("bio".to_string()), avatar_path: Some("path".to_string()), ..Default::default() @@ -88,7 +91,7 @@ async fn update_profile_clears_fields_with_none() { ) .await .unwrap(); - repo.update_profile(user.id(), &domain::models::UserProfile::default()) + repo.update_profile(user.id(), &UserProfile::default()) .await .unwrap(); -- 2.49.1 From 5118bf4700dac318efc8aed305c6841ecafe0bbe Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:41:26 +0100 Subject: [PATCH 032/104] test: move activitypub tests to tests/ Assert via serialized JSON (fields pub(crate)); normalize_hashtag covered via review tags. No inline exceptions. --- crates/adapters/activitypub/src/objects.rs | 4 - .../adapters/activitypub/src/tests/objects.rs | 175 ------------------ crates/adapters/activitypub/tests/objects.rs | 4 + .../tests/objects/review_object_test.rs | 112 +++++++++++ .../tests/objects/watchlist_object_test.rs | 26 +++ 5 files changed, 142 insertions(+), 179 deletions(-) delete mode 100644 crates/adapters/activitypub/src/tests/objects.rs create mode 100644 crates/adapters/activitypub/tests/objects.rs create mode 100644 crates/adapters/activitypub/tests/objects/review_object_test.rs create mode 100644 crates/adapters/activitypub/tests/objects/watchlist_object_test.rs diff --git a/crates/adapters/activitypub/src/objects.rs b/crates/adapters/activitypub/src/objects.rs index ea7a7445..7efddc63 100644 --- a/crates/adapters/activitypub/src/objects.rs +++ b/crates/adapters/activitypub/src/objects.rs @@ -306,7 +306,3 @@ pub fn goal_to_ap_object( cc: vec![format!("{}/followers", actor_url)], } } - -#[cfg(test)] -#[path = "tests/objects.rs"] -mod tests; diff --git a/crates/adapters/activitypub/src/tests/objects.rs b/crates/adapters/activitypub/src/tests/objects.rs deleted file mode 100644 index 49090e43..00000000 --- a/crates/adapters/activitypub/src/tests/objects.rs +++ /dev/null @@ -1,175 +0,0 @@ -use super::*; - -#[test] -fn normalize_hashtag_strips_non_alphanumeric() { - assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight"); - assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList"); - assert_eq!( - normalize_hashtag("2001: A Space Odyssey"), - "2001ASpaceOdyssey" - ); -} - -#[test] -fn review_to_ap_object_includes_two_hashtags() { - use chrono::NaiveDateTime; - use domain::{ - models::{PersistedReview, Review, ReviewSource}, - value_objects::{MovieId, Rating, ReviewId, UserId}, - }; - - let review = Review::from_persistence(PersistedReview { - id: ReviewId::generate(), - movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()), - user_id: UserId::from_uuid(uuid::Uuid::new_v4()), - rating: Rating::new(4).unwrap(), - comment: None, - watched_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S") - .unwrap(), - created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S") - .unwrap(), - source: ReviewSource::Local, - watch_medium: None, - spoilers: false, - }); - let obj = review_to_ap_object( - &review, - ReviewApInput { - ap_id: "https://example.com/reviews/1".parse().unwrap(), - actor_url: "https://example.com/users/1".parse().unwrap(), - movie_title: "Dune".to_string(), - release_year: 2021, - external_metadata_id: None, - poster_url: None, - base_url: "https://example.com".to_string(), - }, - ); - assert_eq!(obj.tag.len(), 2); - let names: Vec<&str> = obj.tag.iter().map(|t| t.name.as_str()).collect(); - assert!(names.contains(&"#MoviesDiary")); - assert!(names.contains(&"#Dune")); -} - -#[test] -fn review_to_ap_object_has_public_addressing() { - use chrono::NaiveDateTime; - use domain::{ - models::{PersistedReview, Review, ReviewSource}, - value_objects::{MovieId, Rating, ReviewId, UserId}, - }; - - let review = Review::from_persistence(PersistedReview { - id: ReviewId::generate(), - movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()), - user_id: UserId::from_uuid(uuid::Uuid::new_v4()), - rating: Rating::new(3).unwrap(), - comment: None, - watched_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S") - .unwrap(), - created_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S") - .unwrap(), - source: ReviewSource::Local, - watch_medium: None, - spoilers: false, - }); - let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap(); - let obj = review_to_ap_object( - &review, - ReviewApInput { - ap_id: "https://example.com/reviews/1".parse().unwrap(), - actor_url: actor_url.clone(), - movie_title: "Dune".to_string(), - release_year: 2021, - external_metadata_id: None, - poster_url: None, - base_url: "https://example.com".to_string(), - }, - ); - assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]); - assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]); -} - -#[test] -fn review_to_ap_object_carries_spoiler_flag_and_summary() { - use chrono::NaiveDateTime; - use domain::{ - models::{PersistedReview, Review, ReviewSource}, - value_objects::{Comment, MovieId, Rating, ReviewId, UserId}, - }; - - let ts = NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); - let review = Review::from_persistence(PersistedReview { - id: ReviewId::generate(), - movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()), - user_id: UserId::from_uuid(uuid::Uuid::new_v4()), - rating: Rating::new(4).unwrap(), - comment: Some(Comment::new("the mentor is the villain".into()).unwrap()), - watched_at: ts, - created_at: ts, - source: ReviewSource::Local, - watch_medium: None, - spoilers: true, - }); - let obj = review_to_ap_object( - &review, - ReviewApInput { - ap_id: "https://example.com/reviews/1".parse().unwrap(), - actor_url: "https://example.com/users/1".parse().unwrap(), - movie_title: "Dune".to_string(), - release_year: 2021, - external_metadata_id: None, - poster_url: None, - base_url: "https://example.com".to_string(), - }, - ); - assert!(obj.contains_spoilers); - assert_eq!(obj.summary.as_deref(), Some("Contains spoilers")); - let json = serde_json::to_value(&obj).unwrap(); - assert_eq!(json["containsSpoilers"], serde_json::Value::Bool(true)); - assert_eq!(json["summary"], "Contains spoilers"); - - // A flag without a comment never travels: there is nothing to veil. - let review_no_comment = Review::from_persistence(PersistedReview { - id: ReviewId::generate(), - movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()), - user_id: UserId::from_uuid(uuid::Uuid::new_v4()), - rating: Rating::new(4).unwrap(), - comment: None, - watched_at: ts, - created_at: ts, - source: ReviewSource::Local, - watch_medium: None, - spoilers: true, - }); - let obj2 = review_to_ap_object( - &review_no_comment, - ReviewApInput { - ap_id: "https://example.com/reviews/2".parse().unwrap(), - actor_url: "https://example.com/users/1".parse().unwrap(), - movie_title: "Dune".to_string(), - release_year: 2021, - external_metadata_id: None, - poster_url: None, - base_url: "https://example.com".to_string(), - }, - ); - assert!(!obj2.contains_spoilers); - assert!(obj2.summary.is_none()); -} - -#[test] -fn watchlist_to_ap_object_has_public_addressing() { - let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap(); - let obj = watchlist_to_ap_object(WatchlistApInput { - ap_id: "https://example.com/watchlist/1".parse().unwrap(), - actor_url: actor_url.clone(), - movie_title: "Alien".to_string(), - release_year: 1979, - external_metadata_id: None, - poster_url: None, - added_at: chrono::Utc::now(), - base_url: "https://example.com".to_string(), - }); - assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]); - assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]); -} diff --git a/crates/adapters/activitypub/tests/objects.rs b/crates/adapters/activitypub/tests/objects.rs new file mode 100644 index 00000000..5e9a6e49 --- /dev/null +++ b/crates/adapters/activitypub/tests/objects.rs @@ -0,0 +1,4 @@ +#[path = "objects/review_object_test.rs"] +mod review_object_test; +#[path = "objects/watchlist_object_test.rs"] +mod watchlist_object_test; diff --git a/crates/adapters/activitypub/tests/objects/review_object_test.rs b/crates/adapters/activitypub/tests/objects/review_object_test.rs new file mode 100644 index 00000000..5d0d0932 --- /dev/null +++ b/crates/adapters/activitypub/tests/objects/review_object_test.rs @@ -0,0 +1,112 @@ +use activitypub::objects::{ReviewApInput, review_to_ap_object}; +use chrono::NaiveDateTime; +use domain::models::{PersistedReview, Review, ReviewSource}; +use domain::value_objects::{Comment, MovieId, Rating, ReviewId, UserId}; +use serde_json::{Value, json}; + +const PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public"; + +fn timestamp() -> NaiveDateTime { + NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap() +} + +fn review(rating: u8, comment: Option<&str>, spoilers: bool) -> Review { + Review::from_persistence(PersistedReview { + id: ReviewId::generate(), + movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()), + user_id: UserId::from_uuid(uuid::Uuid::new_v4()), + rating: Rating::new(rating).unwrap(), + comment: comment.map(|c| Comment::new(c.into()).unwrap()), + watched_at: timestamp(), + created_at: timestamp(), + source: ReviewSource::Local, + watch_medium: None, + spoilers, + }) +} + +fn input(movie_title: &str, actor_url: &str) -> ReviewApInput { + ReviewApInput { + ap_id: "https://example.com/reviews/1".parse().unwrap(), + actor_url: actor_url.parse().unwrap(), + movie_title: movie_title.to_string(), + release_year: 2021, + external_metadata_id: None, + poster_url: None, + base_url: "https://example.com".to_string(), + } +} + +fn render(review: &Review, input: ReviewApInput) -> Value { + serde_json::to_value(review_to_ap_object(review, input)).unwrap() +} + +fn tag_names(json: &Value) -> Vec<&str> { + json["tag"] + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap()) + .collect() +} + +#[test] +fn review_object_tags_movie_title_with_non_alphanumerics_stripped() { + for (title, hashtag) in [ + ("The Dark Knight", "#TheDarkKnight"), + ("Schindler's List", "#SchindlersList"), + ("2001: A Space Odyssey", "#2001ASpaceOdyssey"), + ] { + let json = render( + &review(4, None, false), + input(title, "https://example.com/users/1"), + ); + assert!( + tag_names(&json).contains(&hashtag), + "{title}: {:?}", + tag_names(&json) + ); + } +} + +#[test] +fn review_object_includes_two_hashtags() { + let json = render( + &review(4, None, false), + input("Dune", "https://example.com/users/1"), + ); + + assert_eq!(tag_names(&json), vec!["#MoviesDiary", "#Dune"]); +} + +#[test] +fn review_object_is_publicly_addressed_with_followers_in_cc() { + let json = render( + &review(3, None, false), + input("Dune", "https://example.com/users/abc"), + ); + + assert_eq!(json["to"], json!([PUBLIC])); + assert_eq!( + json["cc"], + json!(["https://example.com/users/abc/followers"]) + ); +} + +#[test] +fn review_object_carries_spoiler_flag_and_summary_only_with_a_comment() { + let json = render( + &review(4, Some("the mentor is the villain"), true), + input("Dune", "https://example.com/users/1"), + ); + assert_eq!(json["containsSpoilers"], Value::Bool(true)); + assert_eq!(json["summary"], "Contains spoilers"); + + // A flag without a comment never travels: there is nothing to veil. + let json = render( + &review(4, None, true), + input("Dune", "https://example.com/users/1"), + ); + assert!(json.get("containsSpoilers").is_none()); + assert!(json.get("summary").is_none()); +} diff --git a/crates/adapters/activitypub/tests/objects/watchlist_object_test.rs b/crates/adapters/activitypub/tests/objects/watchlist_object_test.rs new file mode 100644 index 00000000..a4fa5960 --- /dev/null +++ b/crates/adapters/activitypub/tests/objects/watchlist_object_test.rs @@ -0,0 +1,26 @@ +use activitypub::objects::{WatchlistApInput, watchlist_to_ap_object}; +use serde_json::json; + +#[test] +fn watchlist_object_is_publicly_addressed_with_followers_in_cc() { + let obj = watchlist_to_ap_object(WatchlistApInput { + ap_id: "https://example.com/watchlist/1".parse().unwrap(), + actor_url: "https://example.com/users/abc".parse().unwrap(), + movie_title: "Alien".to_string(), + release_year: 1979, + external_metadata_id: None, + poster_url: None, + added_at: chrono::Utc::now(), + base_url: "https://example.com".to_string(), + }); + let json = serde_json::to_value(&obj).unwrap(); + + assert_eq!( + json["to"], + json!(["https://www.w3.org/ns/activitystreams#Public"]) + ); + assert_eq!( + json["cc"], + json!(["https://example.com/users/abc/followers"]) + ); +} -- 2.49.1 From ec22ac1d7578749b7d1c54e5e4f876f6e5f6886f Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:41:31 +0100 Subject: [PATCH 033/104] test: move sqlite-federation tests to tests/ No inline exceptions. --- crates/adapters/sqlite-federation/src/lib.rs | 12 ------------ .../adapters/sqlite-federation/tests/federation.rs | 6 ++++++ .../federation/actor_block_test.rs} | 4 ++-- .../lib.rs => tests/federation/announce_test.rs} | 4 ++-- .../federation/domain_block_test.rs} | 2 +- 5 files changed, 11 insertions(+), 17 deletions(-) create mode 100644 crates/adapters/sqlite-federation/tests/federation.rs rename crates/adapters/sqlite-federation/{src/tests/actor_block_tests.rs => tests/federation/actor_block_test.rs} (93%) rename crates/adapters/sqlite-federation/{src/tests/lib.rs => tests/federation/announce_test.rs} (93%) rename crates/adapters/sqlite-federation/{src/tests/domain_block_tests.rs => tests/federation/domain_block_test.rs} (96%) diff --git a/crates/adapters/sqlite-federation/src/lib.rs b/crates/adapters/sqlite-federation/src/lib.rs index 32e6a9c4..e3d13bed 100644 --- a/crates/adapters/sqlite-federation/src/lib.rs +++ b/crates/adapters/sqlite-federation/src/lib.rs @@ -92,15 +92,3 @@ pub fn wire( follow_query: social as _, } } - -#[cfg(test)] -#[path = "tests/actor_block_tests.rs"] -mod actor_block_tests; - -#[cfg(test)] -#[path = "tests/domain_block_tests.rs"] -mod domain_block_tests; - -#[cfg(test)] -#[path = "tests/lib.rs"] -mod tests; diff --git a/crates/adapters/sqlite-federation/tests/federation.rs b/crates/adapters/sqlite-federation/tests/federation.rs new file mode 100644 index 00000000..3387415d --- /dev/null +++ b/crates/adapters/sqlite-federation/tests/federation.rs @@ -0,0 +1,6 @@ +#[path = "federation/actor_block_test.rs"] +mod actor_block_test; +#[path = "federation/announce_test.rs"] +mod announce_test; +#[path = "federation/domain_block_test.rs"] +mod domain_block_test; diff --git a/crates/adapters/sqlite-federation/src/tests/actor_block_tests.rs b/crates/adapters/sqlite-federation/tests/federation/actor_block_test.rs similarity index 93% rename from crates/adapters/sqlite-federation/src/tests/actor_block_tests.rs rename to crates/adapters/sqlite-federation/tests/federation/actor_block_test.rs index c3624bad..892d0d6d 100644 --- a/crates/adapters/sqlite-federation/src/tests/actor_block_tests.rs +++ b/crates/adapters/sqlite-federation/tests/federation/actor_block_test.rs @@ -1,5 +1,5 @@ -use super::*; use k_ap::ActorBlocklist; +use sqlite_federation::SqliteFederationRepository; use sqlx::SqlitePool; async fn test_pool() -> SqlitePool { @@ -25,7 +25,7 @@ async fn test_pool() -> SqlitePool { } #[tokio::test] -async fn block_and_check_actor() { +async fn blocked_actor_is_detected_listed_and_unblocked() { let pool = test_pool().await; let user_id = uuid::Uuid::parse_str( &sqlx::query_scalar::<_, String>("SELECT id FROM users LIMIT 1") diff --git a/crates/adapters/sqlite-federation/src/tests/lib.rs b/crates/adapters/sqlite-federation/tests/federation/announce_test.rs similarity index 93% rename from crates/adapters/sqlite-federation/src/tests/lib.rs rename to crates/adapters/sqlite-federation/tests/federation/announce_test.rs index 99958a22..215a1756 100644 --- a/crates/adapters/sqlite-federation/src/tests/lib.rs +++ b/crates/adapters/sqlite-federation/tests/federation/announce_test.rs @@ -1,6 +1,6 @@ -use super::*; use chrono::Utc; use k_ap::AnnounceRepository; +use sqlite_federation::SqliteFederationRepository; use sqlx::SqlitePool; async fn test_pool() -> SqlitePool { @@ -11,7 +11,7 @@ async fn test_pool() -> SqlitePool { } #[tokio::test] -async fn add_announce_stores_and_counts() { +async fn added_announce_is_counted() { let pool = test_pool().await; let repo = SqliteFederationRepository::new(pool); repo.add_announce( diff --git a/crates/adapters/sqlite-federation/src/tests/domain_block_tests.rs b/crates/adapters/sqlite-federation/tests/federation/domain_block_test.rs similarity index 96% rename from crates/adapters/sqlite-federation/src/tests/domain_block_tests.rs rename to crates/adapters/sqlite-federation/tests/federation/domain_block_test.rs index cfb7df80..b151ad01 100644 --- a/crates/adapters/sqlite-federation/src/tests/domain_block_tests.rs +++ b/crates/adapters/sqlite-federation/tests/federation/domain_block_test.rs @@ -1,5 +1,5 @@ -use super::*; use k_ap::DomainBlocklist; +use sqlite_federation::SqliteFederationRepository; use sqlx::SqlitePool; async fn test_pool() -> SqlitePool { -- 2.49.1 From fdf4df38afbdb5fb6781109d97e2f3b940344f67 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:41:54 +0100 Subject: [PATCH 034/104] test: get_users use case Fake list_with_stats now returns stored users (was always empty). --- crates/application/tests/users.rs | 2 ++ .../application/tests/users/get_users_test.rs | 36 +++++++++++++++++++ crates/domain/src/testing/fakes/store_user.rs | 16 ++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 crates/application/tests/users/get_users_test.rs diff --git a/crates/application/tests/users.rs b/crates/application/tests/users.rs index 855ac1db..dfcc27c1 100644 --- a/crates/application/tests/users.rs +++ b/crates/application/tests/users.rs @@ -16,6 +16,8 @@ mod get_page_viewer_test; mod get_profile_settings_test; #[path = "users/get_settings_test.rs"] mod get_settings_test; +#[path = "users/get_users_test.rs"] +mod get_users_test; #[path = "users/resolve_username_to_id_test.rs"] mod resolve_username_to_id_test; #[path = "users/update_profile_fields_test.rs"] diff --git a/crates/application/tests/users/get_users_test.rs b/crates/application/tests/users/get_users_test.rs new file mode 100644 index 00000000..6994d900 --- /dev/null +++ b/crates/application/tests/users/get_users_test.rs @@ -0,0 +1,36 @@ +use crate::support::TestContextBuilder; +use domain::testing::test_user; + +use application::users::deps::GetUsersListDeps; +use application::users::get_users; +use application::users::queries::GetUsersQuery; + +fn deps(b: &TestContextBuilder) -> GetUsersListDeps { + GetUsersListDeps { + user: b.user_repo.clone(), + federation_admin: b.federation_admin.clone(), + } +} + +#[tokio::test] +async fn returns_empty_lists_when_nothing_exists() { + let b = TestContextBuilder::new(); + + let result = get_users::execute(&deps(&b), GetUsersQuery).await.unwrap(); + + assert!(result.users.is_empty()); + assert!(result.remote_actors.is_empty()); +} + +#[tokio::test] +async fn lists_saved_user() { + let b = TestContextBuilder::new(); + let user = test_user("alice"); + b.user_repo.save(&user).await.unwrap(); + + let result = get_users::execute(&deps(&b), GetUsersQuery).await.unwrap(); + + assert_eq!(result.users.len(), 1); + assert_eq!(result.users[0].username(), "alice"); + assert!(result.remote_actors.is_empty()); +} diff --git a/crates/domain/src/testing/fakes/store_user.rs b/crates/domain/src/testing/fakes/store_user.rs index 9c0a4c28..d19008a9 100644 --- a/crates/domain/src/testing/fakes/store_user.rs +++ b/crates/domain/src/testing/fakes/store_user.rs @@ -59,7 +59,21 @@ impl UserRepository for InMemoryStore { } async fn list_with_stats(&self) -> Result, DomainError> { - Ok(vec![]) + let store = self.users.read().unwrap(); + Ok(store + .values() + .map(|u| { + UserSummary::new( + u.id().clone(), + u.email().clone(), + u.username().clone(), + u.display_name().map(str::to_string), + 0, + None, + u.avatar_path().map(str::to_string), + ) + }) + .collect()) } async fn update_profile( -- 2.49.1 From 74674b157c79f4fdaf71121316dda8a53a4b6a62 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:47:54 +0100 Subject: [PATCH 035/104] test: move small adapter tests to tests/ event-publisher, export, rss, media-server, object-storage, nats(config). Kept inline (private fn, no public path): nats src/subject.rs tests::{review_logged_subject,review_updated_subject,movie_discovered_subject, consumer_subject_filter_appends_wildcard} - event_to_subject/consumer_subject_filter private. object-storage build_local_store tests rewritten via StorageConfig::from_config. --- crates/adapters/event-publisher/src/lib.rs | 4 ---- .../lib.rs => tests/event_channel_test.rs} | 3 ++- crates/adapters/export/src/lib.rs | 4 ---- .../lib.rs => tests/export_adapter_test.rs} | 2 +- crates/adapters/media-server/src/jellyfin.rs | 4 ---- crates/adapters/media-server/src/plex.rs | 4 ---- .../jellyfin_parser_test.rs} | 3 ++- .../plex.rs => tests/plex_parser_test.rs} | 3 ++- crates/adapters/nats/src/config.rs | 4 ---- .../config.rs => tests/nats_config_test.rs} | 10 +++++----- crates/adapters/object-storage/src/config.rs | 4 ---- crates/adapters/object-storage/src/lib.rs | 4 ---- .../object_storage_adapter_test.rs} | 8 +++++++- .../config.rs => tests/storage_config_test.rs} | 18 +++++++++++++----- crates/adapters/rss/src/lib.rs | 4 ---- .../tests/lib.rs => tests/rss_adapter_test.rs} | 4 +++- 16 files changed, 35 insertions(+), 48 deletions(-) rename crates/adapters/event-publisher/{src/tests/lib.rs => tests/event_channel_test.rs} (93%) rename crates/adapters/export/{src/tests/lib.rs => tests/export_adapter_test.rs} (99%) rename crates/adapters/media-server/{src/tests/jellyfin.rs => tests/jellyfin_parser_test.rs} (96%) rename crates/adapters/media-server/{src/tests/plex.rs => tests/plex_parser_test.rs} (97%) rename crates/adapters/nats/{src/tests/config.rs => tests/nats_config_test.rs} (77%) rename crates/adapters/object-storage/{src/tests/lib.rs => tests/object_storage_adapter_test.rs} (91%) rename crates/adapters/object-storage/{src/tests/config.rs => tests/storage_config_test.rs} (66%) rename crates/adapters/rss/{src/tests/lib.rs => tests/rss_adapter_test.rs} (95%) diff --git a/crates/adapters/event-publisher/src/lib.rs b/crates/adapters/event-publisher/src/lib.rs index aec30ee4..64ba21d2 100644 --- a/crates/adapters/event-publisher/src/lib.rs +++ b/crates/adapters/event-publisher/src/lib.rs @@ -84,7 +84,3 @@ pub fn create_event_channel( }, ) } - -#[cfg(test)] -#[path = "tests/lib.rs"] -mod tests; diff --git a/crates/adapters/event-publisher/src/tests/lib.rs b/crates/adapters/event-publisher/tests/event_channel_test.rs similarity index 93% rename from crates/adapters/event-publisher/src/tests/lib.rs rename to crates/adapters/event-publisher/tests/event_channel_test.rs index 922a95a1..4824d1dc 100644 --- a/crates/adapters/event-publisher/src/tests/lib.rs +++ b/crates/adapters/event-publisher/tests/event_channel_test.rs @@ -1,8 +1,9 @@ -use super::*; +use domain::ports::{EventConsumer, EventPublisher}; use domain::{ events::DomainEvent, value_objects::{ExternalMetadataId, MovieId}, }; +use event_publisher::{EventPublisherConfig, create_event_channel}; use futures::StreamExt; fn movie_discovered() -> DomainEvent { diff --git a/crates/adapters/export/src/lib.rs b/crates/adapters/export/src/lib.rs index 44f4e62e..fb3b1bcd 100644 --- a/crates/adapters/export/src/lib.rs +++ b/crates/adapters/export/src/lib.rs @@ -107,7 +107,3 @@ fn entry_to_json(e: &DiaryEntry) -> serde_json::Value { "spoilers": e.review().spoilers(), }) } - -#[cfg(test)] -#[path = "tests/lib.rs"] -mod tests; diff --git a/crates/adapters/export/src/tests/lib.rs b/crates/adapters/export/tests/export_adapter_test.rs similarity index 99% rename from crates/adapters/export/src/tests/lib.rs rename to crates/adapters/export/tests/export_adapter_test.rs index 674dd7e8..46655597 100644 --- a/crates/adapters/export/src/tests/lib.rs +++ b/crates/adapters/export/tests/export_adapter_test.rs @@ -1,4 +1,3 @@ -use super::ExportAdapter; use domain::{ models::{DiaryEntry, ExportFormat, Movie, ReviewEdit}, ports::DiaryExporter, @@ -7,6 +6,7 @@ use domain::{ }, value_objects::{Comment, ExternalMetadataId, UserId}, }; +use export::ExportAdapter; async fn collect_stream( stream: futures::stream::BoxStream<'static, Result>, diff --git a/crates/adapters/media-server/src/jellyfin.rs b/crates/adapters/media-server/src/jellyfin.rs index aaa565d8..a139ae4a 100644 --- a/crates/adapters/media-server/src/jellyfin.rs +++ b/crates/adapters/media-server/src/jellyfin.rs @@ -58,7 +58,3 @@ struct JellyfinPayload { #[serde(rename = "Provider_imdb")] provider_imdb: Option, } - -#[cfg(test)] -#[path = "tests/jellyfin.rs"] -mod tests; diff --git a/crates/adapters/media-server/src/plex.rs b/crates/adapters/media-server/src/plex.rs index 16187867..4b59b76b 100644 --- a/crates/adapters/media-server/src/plex.rs +++ b/crates/adapters/media-server/src/plex.rs @@ -68,7 +68,3 @@ struct PlexMetadata { struct PlexGuid { id: String, } - -#[cfg(test)] -#[path = "tests/plex.rs"] -mod tests; diff --git a/crates/adapters/media-server/src/tests/jellyfin.rs b/crates/adapters/media-server/tests/jellyfin_parser_test.rs similarity index 96% rename from crates/adapters/media-server/src/tests/jellyfin.rs rename to crates/adapters/media-server/tests/jellyfin_parser_test.rs index 9511eb78..b02c1213 100644 --- a/crates/adapters/media-server/src/tests/jellyfin.rs +++ b/crates/adapters/media-server/tests/jellyfin_parser_test.rs @@ -1,4 +1,5 @@ -use super::*; +use domain::ports::MediaServerParser; +use media_server::JellyfinParser; #[test] fn parses_valid_playback_stop() { diff --git a/crates/adapters/media-server/src/tests/plex.rs b/crates/adapters/media-server/tests/plex_parser_test.rs similarity index 97% rename from crates/adapters/media-server/src/tests/plex.rs rename to crates/adapters/media-server/tests/plex_parser_test.rs index 2459f256..ae90056f 100644 --- a/crates/adapters/media-server/src/tests/plex.rs +++ b/crates/adapters/media-server/tests/plex_parser_test.rs @@ -1,4 +1,5 @@ -use super::*; +use domain::ports::MediaServerParser; +use media_server::PlexParser; #[test] fn parses_movie_scrobble() { diff --git a/crates/adapters/nats/src/config.rs b/crates/adapters/nats/src/config.rs index d7ed4381..34003cac 100644 --- a/crates/adapters/nats/src/config.rs +++ b/crates/adapters/nats/src/config.rs @@ -35,7 +35,3 @@ impl NatsConfig { }) } } - -#[cfg(test)] -#[path = "tests/config.rs"] -mod tests; diff --git a/crates/adapters/nats/src/tests/config.rs b/crates/adapters/nats/tests/nats_config_test.rs similarity index 77% rename from crates/adapters/nats/src/tests/config.rs rename to crates/adapters/nats/tests/nats_config_test.rs index 8d749d06..505672af 100644 --- a/crates/adapters/nats/src/tests/config.rs +++ b/crates/adapters/nats/tests/nats_config_test.rs @@ -1,7 +1,7 @@ -use super::*; +use nats::{NatsConfig, NatsMode}; -fn section(url: Option<&str>, mode: &str) -> ::config::NatsConfig { - ::config::NatsConfig { +fn section(url: Option<&str>, mode: &str) -> config::NatsConfig { + config::NatsConfig { url: url.map(str::to_owned), mode: mode.into(), ..Default::default() @@ -10,12 +10,12 @@ fn section(url: Option<&str>, mode: &str) -> ::config::NatsConfig { #[test] fn errors_without_nats_url() { - assert!(NatsConfig::from_config(&::config::NatsConfig::default()).is_err()); + assert!(NatsConfig::from_config(&config::NatsConfig::default()).is_err()); } #[test] fn defaults_with_only_url() { - let cfg = NatsConfig::from_config(&::config::NatsConfig { + let cfg = NatsConfig::from_config(&config::NatsConfig { url: Some("nats://localhost:4222".into()), ..Default::default() }) diff --git a/crates/adapters/object-storage/src/config.rs b/crates/adapters/object-storage/src/config.rs index a6f66855..053d57a2 100644 --- a/crates/adapters/object-storage/src/config.rs +++ b/crates/adapters/object-storage/src/config.rs @@ -62,7 +62,3 @@ fn build_local_store(path: &str) -> anyhow::Result> { .context("Failed to initialise local file system store")?; Ok(Arc::new(store)) } - -#[cfg(test)] -#[path = "tests/config.rs"] -mod tests; diff --git a/crates/adapters/object-storage/src/lib.rs b/crates/adapters/object-storage/src/lib.rs index f66c339a..a1144e57 100644 --- a/crates/adapters/object-storage/src/lib.rs +++ b/crates/adapters/object-storage/src/lib.rs @@ -132,7 +132,3 @@ pub fn create(config: &::config::StorageConfig) -> anyhow::Result ObjectStorageAdapter { diff --git a/crates/adapters/object-storage/src/tests/config.rs b/crates/adapters/object-storage/tests/storage_config_test.rs similarity index 66% rename from crates/adapters/object-storage/src/tests/config.rs rename to crates/adapters/object-storage/tests/storage_config_test.rs index 26fcd862..34450003 100644 --- a/crates/adapters/object-storage/src/tests/config.rs +++ b/crates/adapters/object-storage/tests/storage_config_test.rs @@ -1,9 +1,17 @@ -use super::*; +use object_storage::StorageConfig; + +fn local_section(dir: &std::path::Path) -> config::StorageConfig { + config::StorageConfig { + backend: "local".into(), + path: dir.to_str().unwrap().into(), + ..Default::default() + } +} #[test] fn local_store_creates_dir_and_succeeds() { let dir = std::env::temp_dir().join(format!("image_test_{}", uuid::Uuid::new_v4())); - let result = build_local_store(dir.to_str().unwrap()); + let result = StorageConfig::from_config(&local_section(&dir)); assert!(result.is_ok(), "expected Ok, got: {:?}", result.err()); assert!(dir.exists(), "directory should have been created"); } @@ -12,13 +20,13 @@ fn local_store_creates_dir_and_succeeds() { fn local_store_succeeds_if_dir_already_exists() { let dir = std::env::temp_dir().join(format!("image_test_{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); - let result = build_local_store(dir.to_str().unwrap()); + let result = StorageConfig::from_config(&local_section(&dir)); assert!(result.is_ok()); } #[test] fn s3_backend_requires_endpoint() { - let section = ::config::StorageConfig { + let section = config::StorageConfig { backend: "s3".into(), ..Default::default() }; @@ -28,7 +36,7 @@ fn s3_backend_requires_endpoint() { #[test] fn unknown_backend_is_error() { - let section = ::config::StorageConfig { + let section = config::StorageConfig { backend: "ftp".into(), ..Default::default() }; diff --git a/crates/adapters/rss/src/lib.rs b/crates/adapters/rss/src/lib.rs index 87057c4e..f873f22d 100644 --- a/crates/adapters/rss/src/lib.rs +++ b/crates/adapters/rss/src/lib.rs @@ -88,7 +88,3 @@ impl RssFeedRenderer for RssAdapter { Ok(channel.to_string()) } } - -#[cfg(test)] -#[path = "tests/lib.rs"] -mod tests; diff --git a/crates/adapters/rss/src/tests/lib.rs b/crates/adapters/rss/tests/rss_adapter_test.rs similarity index 95% rename from crates/adapters/rss/src/tests/lib.rs rename to crates/adapters/rss/tests/rss_adapter_test.rs index 7f567f55..511b19c9 100644 --- a/crates/adapters/rss/src/tests/lib.rs +++ b/crates/adapters/rss/tests/rss_adapter_test.rs @@ -1,7 +1,9 @@ -use super::*; +use domain::models::DiaryEntry; use domain::models::ReviewEdit; +use domain::ports::{FeedChannel, RssFeedRenderer}; use domain::testing::{test_movie_with_year, test_review}; use domain::value_objects::{Comment, UserId}; +use rss::RssAdapter; fn channel<'a>(title: &'a str) -> FeedChannel<'a> { FeedChannel { -- 2.49.1 From 760acb1f9f0e2317aea98db48162516bbea9ac73 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:48:29 +0100 Subject: [PATCH 036/104] test: move image-converter tests to tests/ --- .../adapters/image-converter/src/backfill.rs | 4 ---- crates/adapters/image-converter/src/config.rs | 4 ---- .../adapters/image-converter/src/handler.rs | 4 ---- .../backfill_job_test.rs} | 10 +++++++-- .../conversion_config_test.rs} | 22 +++++++++---------- .../conversion_handler_test.rs} | 10 +++++++-- .../tests/mapper.rs => tests/mapping_test.rs} | 0 .../tests.rs => tests/parsing_test.rs} | 0 8 files changed, 27 insertions(+), 27 deletions(-) rename crates/adapters/image-converter/{src/tests/backfill.rs => tests/backfill_job_test.rs} (91%) rename crates/adapters/image-converter/{src/tests/config.rs => tests/conversion_config_test.rs} (62%) rename crates/adapters/image-converter/{src/tests/handler.rs => tests/conversion_handler_test.rs} (95%) rename crates/adapters/importer/{src/tests/mapper.rs => tests/mapping_test.rs} (100%) rename crates/adapters/importer/{src/parsers/tests.rs => tests/parsing_test.rs} (100%) diff --git a/crates/adapters/image-converter/src/backfill.rs b/crates/adapters/image-converter/src/backfill.rs index 1a814942..49ec08c5 100644 --- a/crates/adapters/image-converter/src/backfill.rs +++ b/crates/adapters/image-converter/src/backfill.rs @@ -49,7 +49,3 @@ impl PeriodicJob for ConversionBackfillJob { Ok(()) } } - -#[cfg(test)] -#[path = "tests/backfill.rs"] -mod tests; diff --git a/crates/adapters/image-converter/src/config.rs b/crates/adapters/image-converter/src/config.rs index 2bf3934a..8241ff9a 100644 --- a/crates/adapters/image-converter/src/config.rs +++ b/crates/adapters/image-converter/src/config.rs @@ -37,7 +37,3 @@ impl ConversionConfig { Ok(Some(Self { format })) } } - -#[cfg(test)] -#[path = "tests/config.rs"] -mod tests; diff --git a/crates/adapters/image-converter/src/handler.rs b/crates/adapters/image-converter/src/handler.rs index cba99581..e0ae7b14 100644 --- a/crates/adapters/image-converter/src/handler.rs +++ b/crates/adapters/image-converter/src/handler.rs @@ -99,7 +99,3 @@ fn convert(bytes: Vec, format: Format) -> Result, String> { } } } - -#[cfg(test)] -#[path = "tests/handler.rs"] -mod tests; diff --git a/crates/adapters/image-converter/src/tests/backfill.rs b/crates/adapters/image-converter/tests/backfill_job_test.rs similarity index 91% rename from crates/adapters/image-converter/src/tests/backfill.rs rename to crates/adapters/image-converter/tests/backfill_job_test.rs index 7b0a35ae..f4f152ff 100644 --- a/crates/adapters/image-converter/src/tests/backfill.rs +++ b/crates/adapters/image-converter/tests/backfill_job_test.rs @@ -1,5 +1,11 @@ -use super::*; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; + +use domain::{ + errors::DomainError, + events::DomainEvent, + ports::{EventPublisher, ImageRefQuery, PeriodicJob}, +}; +use image_converter::ConversionBackfillJob; struct MockImageRef { keys: Vec, diff --git a/crates/adapters/image-converter/src/tests/config.rs b/crates/adapters/image-converter/tests/conversion_config_test.rs similarity index 62% rename from crates/adapters/image-converter/src/tests/config.rs rename to crates/adapters/image-converter/tests/conversion_config_test.rs index 63a302c3..bf4022ad 100644 --- a/crates/adapters/image-converter/src/tests/config.rs +++ b/crates/adapters/image-converter/tests/conversion_config_test.rs @@ -1,7 +1,7 @@ -use super::*; +use image_converter::{ConversionConfig, Format}; -fn section(enabled: bool, format: Option<&str>) -> ::config::ImageConversionConfig { - ::config::ImageConversionConfig { +fn section(enabled: bool, format: Option<&str>) -> config::ImageConversionConfig { + config::ImageConversionConfig { enabled, format: format.map(str::to_owned), } @@ -12,9 +12,9 @@ fn convert(enabled: bool, format: Option<&str>) -> anyhow::Result>, diff --git a/crates/adapters/importer/src/tests/mapper.rs b/crates/adapters/importer/tests/mapping_test.rs similarity index 100% rename from crates/adapters/importer/src/tests/mapper.rs rename to crates/adapters/importer/tests/mapping_test.rs diff --git a/crates/adapters/importer/src/parsers/tests.rs b/crates/adapters/importer/tests/parsing_test.rs similarity index 100% rename from crates/adapters/importer/src/parsers/tests.rs rename to crates/adapters/importer/tests/parsing_test.rs -- 2.49.1 From 52bbb85980fee13d1231a8db1585c5f50a59f255 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:48:30 +0100 Subject: [PATCH 037/104] test: move importer tests to tests/ (via DocumentParser) --- crates/adapters/importer/src/mapper.rs | 4 ---- crates/adapters/importer/src/parsers/mod.rs | 4 ---- crates/adapters/importer/tests/mapping_test.rs | 9 +++++++-- crates/adapters/importer/tests/parsing_test.rs | 14 +++++++++++++- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/crates/adapters/importer/src/mapper.rs b/crates/adapters/importer/src/mapper.rs index fddb7721..d4a30090 100644 --- a/crates/adapters/importer/src/mapper.rs +++ b/crates/adapters/importer/src/mapper.rs @@ -79,7 +79,3 @@ fn set_field(row: &mut ImportRow, field: &DomainField, value: String) { DomainField::ExternalMetadataId => row.external_metadata_id = Some(value), } } - -#[cfg(test)] -#[path = "tests/mapper.rs"] -mod tests; diff --git a/crates/adapters/importer/src/parsers/mod.rs b/crates/adapters/importer/src/parsers/mod.rs index 32daef77..0d911661 100644 --- a/crates/adapters/importer/src/parsers/mod.rs +++ b/crates/adapters/importer/src/parsers/mod.rs @@ -7,7 +7,3 @@ pub use csv::parse_csv; pub use json::parse_json; #[cfg(feature = "xlsx")] pub use xlsx::parse_xlsx; - -#[cfg(test)] -#[path = "tests.rs"] -mod tests; diff --git a/crates/adapters/importer/tests/mapping_test.rs b/crates/adapters/importer/tests/mapping_test.rs index fec3bb94..c1482dea 100644 --- a/crates/adapters/importer/tests/mapping_test.rs +++ b/crates/adapters/importer/tests/mapping_test.rs @@ -1,5 +1,10 @@ -use super::*; -use domain::models::{DomainField, FieldMapping, ParsedFile, RowResult, Transform}; +use domain::models::{AnnotatedRow, DomainField, FieldMapping, ParsedFile, RowResult, Transform}; +use domain::ports::DocumentParser; +use importer::ImporterDocumentParser; + +fn apply_mapping(file: &ParsedFile, mappings: &[FieldMapping]) -> Vec { + ImporterDocumentParser.apply_mapping(file, mappings) +} fn sample_file() -> ParsedFile { ParsedFile { diff --git a/crates/adapters/importer/tests/parsing_test.rs b/crates/adapters/importer/tests/parsing_test.rs index 0d44c4b0..6d4b713b 100644 --- a/crates/adapters/importer/tests/parsing_test.rs +++ b/crates/adapters/importer/tests/parsing_test.rs @@ -1,4 +1,16 @@ -use super::*; +use domain::{ + models::{FileFormat, ParsedFile}, + ports::DocumentParser, +}; +use importer::ImporterDocumentParser; + +fn parse_csv(data: &[u8]) -> Result { + ImporterDocumentParser.parse(data, FileFormat::Csv) +} + +fn parse_json(data: &[u8]) -> Result { + ImporterDocumentParser.parse(data, FileFormat::Json) +} #[test] fn csv_parses_headers_and_rows() { -- 2.49.1 From 08a97c9ee9fb5ed88c82b4a1a1237fb19075760b Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:48:43 +0100 Subject: [PATCH 038/104] test: move api-types, event-payload tests to tests/ --- crates/api-types/src/social.rs | 4 ---- .../{src/tests/social.rs => tests/social_dto_test.rs} | 2 +- crates/event-payload/src/lib.rs | 4 ---- .../{src/tests/lib.rs => tests/event_payload_test.rs} | 8 +++++++- 4 files changed, 8 insertions(+), 10 deletions(-) rename crates/api-types/{src/tests/social.rs => tests/social_dto_test.rs} (92%) rename crates/event-payload/{src/tests/lib.rs => tests/event_payload_test.rs} (93%) diff --git a/crates/api-types/src/social.rs b/crates/api-types/src/social.rs index f7172b78..d899710f 100644 --- a/crates/api-types/src/social.rs +++ b/crates/api-types/src/social.rs @@ -77,7 +77,3 @@ pub struct FollowRelationResponse { pub struct PendingCountResponse { pub count: usize, } - -#[cfg(test)] -#[path = "tests/social.rs"] -mod tests; diff --git a/crates/api-types/src/tests/social.rs b/crates/api-types/tests/social_dto_test.rs similarity index 92% rename from crates/api-types/src/tests/social.rs rename to crates/api-types/tests/social_dto_test.rs index 2e09621b..229b1195 100644 --- a/crates/api-types/src/tests/social.rs +++ b/crates/api-types/tests/social_dto_test.rs @@ -1,4 +1,4 @@ -use super::*; +use api_types::social::FollowStateDto; #[test] fn follow_state_dto_serializes_to_lowercase_strings() { diff --git a/crates/event-payload/src/lib.rs b/crates/event-payload/src/lib.rs index 199c5290..92c32bff 100644 --- a/crates/event-payload/src/lib.rs +++ b/crates/event-payload/src/lib.rs @@ -718,7 +718,3 @@ impl TryFrom for DomainEvent { } } } - -#[cfg(test)] -#[path = "tests/lib.rs"] -mod tests; diff --git a/crates/event-payload/src/tests/lib.rs b/crates/event-payload/tests/event_payload_test.rs similarity index 93% rename from crates/event-payload/src/tests/lib.rs rename to crates/event-payload/tests/event_payload_test.rs index 6eb8b5c0..0f941bc5 100644 --- a/crates/event-payload/src/tests/lib.rs +++ b/crates/event-payload/tests/event_payload_test.rs @@ -1,4 +1,10 @@ -use super::*; +use chrono::NaiveDateTime; +use domain::{ + events::DomainEvent, + value_objects::{ExternalMetadataId, MovieId, Rating, ReviewId, UserId}, +}; +use event_payload::EventPayload; +use uuid::Uuid; fn fixed_dt() -> NaiveDateTime { chrono::DateTime::from_timestamp(1_700_000_000, 0) -- 2.49.1 From 5c5a8c33264d34111f07fb1e373cc31adbed4ebd Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:49:11 +0100 Subject: [PATCH 039/104] test: move tui tests to tests/ Kept inline: tui src/client.rs tests::{api_client_builds_versioned_urls,api_client_update_url} - ApiClient::api/url private. --- crates/tui/src/app.rs | 4 -- crates/tui/src/config.rs | 4 -- crates/tui/src/tests/client.rs | 51 ------------------ crates/tui/tests/api_client_test.rs | 53 +++++++++++++++++++ .../tests/app.rs => tests/app_update_test.rs} | 4 +- .../tests/config.rs => tests/config_test.rs} | 6 +-- 6 files changed, 58 insertions(+), 64 deletions(-) create mode 100644 crates/tui/tests/api_client_test.rs rename crates/tui/{src/tests/app.rs => tests/app_update_test.rs} (99%) rename crates/tui/{src/tests/config.rs => tests/config_test.rs} (73%) diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 5703f9ec..8ea1b1b3 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -988,7 +988,3 @@ pub fn update(app: &mut App, action: Action) -> Vec { } } } - -#[cfg(test)] -#[path = "tests/app.rs"] -mod tests; diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index b387bd10..9512565f 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -76,7 +76,3 @@ impl Config { Ok(()) } } - -#[cfg(test)] -#[path = "tests/config.rs"] -mod tests; diff --git a/crates/tui/src/tests/client.rs b/crates/tui/src/tests/client.rs index a7ca1bfc..bdef7460 100644 --- a/crates/tui/src/tests/client.rs +++ b/crates/tui/src/tests/client.rs @@ -1,56 +1,5 @@ use super::*; -#[test] -fn apierror_unauthorized_display() { - let err = ApiError::Unauthorized; - assert!(matches!(err, ApiError::Unauthorized)); - assert_eq!(err.to_string(), "unauthorized"); -} - -#[test] -fn apierror_validation_display() { - let err = ApiError::Validation("rating must be 0-5".into()); - assert!(err.to_string().contains("validation error")); -} - -#[test] -fn log_review_request_skips_none_fields() { - let req = LogReviewRequest { - external_metadata_id: None, - manual_title: Some("The Matrix".into()), - manual_release_year: None, - manual_director: None, - rating: 5, - comment: None, - watched_at: "2024-01-15T20:00:00".into(), - watch_medium: None, - spoilers: false, - }; - let json = serde_json::to_string(&req).unwrap(); - assert!(!json.contains("external_metadata_id")); - assert!(!json.contains("manual_release_year")); - assert!(!json.contains("manual_director")); - assert!(json.contains("\"manual_title\":\"The Matrix\"")); - assert!(json.contains("\"rating\":5")); -} - -#[test] -fn log_review_request_includes_director_when_set() { - let req = LogReviewRequest { - external_metadata_id: None, - manual_title: Some("Dune".into()), - manual_release_year: Some(2021), - manual_director: Some("Denis Villeneuve".into()), - rating: 5, - comment: None, - watched_at: "2024-01-15T20:00:00".into(), - watch_medium: None, - spoilers: false, - }; - let json = serde_json::to_string(&req).unwrap(); - assert!(json.contains("\"manual_director\":\"Denis Villeneuve\"")); -} - #[test] fn api_client_builds_versioned_urls() { let client = ApiClient::new("http://localhost:3000"); diff --git a/crates/tui/tests/api_client_test.rs b/crates/tui/tests/api_client_test.rs new file mode 100644 index 00000000..c66c721f --- /dev/null +++ b/crates/tui/tests/api_client_test.rs @@ -0,0 +1,53 @@ +use api_types::LogReviewRequest; +use tui::client::ApiError; + +#[test] +fn apierror_unauthorized_display() { + let err = ApiError::Unauthorized; + assert!(matches!(err, ApiError::Unauthorized)); + assert_eq!(err.to_string(), "unauthorized"); +} + +#[test] +fn apierror_validation_display() { + let err = ApiError::Validation("rating must be 0-5".into()); + assert!(err.to_string().contains("validation error")); +} + +#[test] +fn log_review_request_skips_none_fields() { + let req = LogReviewRequest { + external_metadata_id: None, + manual_title: Some("The Matrix".into()), + manual_release_year: None, + manual_director: None, + rating: 5, + comment: None, + watched_at: "2024-01-15T20:00:00".into(), + watch_medium: None, + spoilers: false, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(!json.contains("external_metadata_id")); + assert!(!json.contains("manual_release_year")); + assert!(!json.contains("manual_director")); + assert!(json.contains("\"manual_title\":\"The Matrix\"")); + assert!(json.contains("\"rating\":5")); +} + +#[test] +fn log_review_request_includes_director_when_set() { + let req = LogReviewRequest { + external_metadata_id: None, + manual_title: Some("Dune".into()), + manual_release_year: Some(2021), + manual_director: Some("Denis Villeneuve".into()), + rating: 5, + comment: None, + watched_at: "2024-01-15T20:00:00".into(), + watch_medium: None, + spoilers: false, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"manual_director\":\"Denis Villeneuve\"")); +} diff --git a/crates/tui/src/tests/app.rs b/crates/tui/tests/app_update_test.rs similarity index 99% rename from crates/tui/src/tests/app.rs rename to crates/tui/tests/app_update_test.rs index bbd61883..808dda04 100644 --- a/crates/tui/src/tests/app.rs +++ b/crates/tui/tests/app_update_test.rs @@ -1,5 +1,5 @@ -use super::*; -use api_types::{DiaryEntryDto, MovieDto, ReviewDto}; +use api_types::{DiaryEntryDto, LogReviewRequest, MovieDto, ReviewDto}; +use tui::app::*; use uuid::Uuid; fn setup_app() -> App { diff --git a/crates/tui/src/tests/config.rs b/crates/tui/tests/config_test.rs similarity index 73% rename from crates/tui/src/tests/config.rs rename to crates/tui/tests/config_test.rs index 00f81328..af819e55 100644 --- a/crates/tui/src/tests/config.rs +++ b/crates/tui/tests/config_test.rs @@ -1,7 +1,7 @@ -use super::*; +use tui::config::Config; #[test] -fn config_roundtrip() { +fn config_survives_json_round_trip() { let config = Config { api_url: "http://localhost:3000".into(), }; @@ -11,6 +11,6 @@ fn config_roundtrip() { } #[test] -fn load_returns_none_when_no_file() { +fn load_without_config_file_does_not_panic() { let _ = Config::load(); } -- 2.49.1 From 07c8b011d854dfddd223289a2ca6c3aab9213f49 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:52:41 +0100 Subject: [PATCH 040/104] fake user store: list_with_stats sorted by email --- crates/adapters/http-axum/src/tests/mod.rs | 2 -- .../html/forms_tests.rs => tests/log_review_form_test.rs} | 0 .../tests/http_handlers/api_handlers_test.rs} | 0 .../tests/http_handlers/app_context_test.rs} | 0 .../tests/http_handlers/extractors_test.rs} | 0 crates/domain/src/testing/fakes/store_user.rs | 8 ++++++-- 6 files changed, 6 insertions(+), 4 deletions(-) delete mode 100644 crates/adapters/http-axum/src/tests/mod.rs rename crates/adapters/http-axum/{src/html/forms_tests.rs => tests/log_review_form_test.rs} (100%) rename crates/{adapters/http-axum/src/tests/api_handlers.rs => bootstrap/tests/http_handlers/api_handlers_test.rs} (100%) rename crates/{adapters/http-axum/src/tests/context.rs => bootstrap/tests/http_handlers/app_context_test.rs} (100%) rename crates/{adapters/http-axum/src/extractors/tests.rs => bootstrap/tests/http_handlers/extractors_test.rs} (100%) diff --git a/crates/adapters/http-axum/src/tests/mod.rs b/crates/adapters/http-axum/src/tests/mod.rs deleted file mode 100644 index afb3afae..00000000 --- a/crates/adapters/http-axum/src/tests/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod api_handlers; -mod context; diff --git a/crates/adapters/http-axum/src/html/forms_tests.rs b/crates/adapters/http-axum/tests/log_review_form_test.rs similarity index 100% rename from crates/adapters/http-axum/src/html/forms_tests.rs rename to crates/adapters/http-axum/tests/log_review_form_test.rs diff --git a/crates/adapters/http-axum/src/tests/api_handlers.rs b/crates/bootstrap/tests/http_handlers/api_handlers_test.rs similarity index 100% rename from crates/adapters/http-axum/src/tests/api_handlers.rs rename to crates/bootstrap/tests/http_handlers/api_handlers_test.rs diff --git a/crates/adapters/http-axum/src/tests/context.rs b/crates/bootstrap/tests/http_handlers/app_context_test.rs similarity index 100% rename from crates/adapters/http-axum/src/tests/context.rs rename to crates/bootstrap/tests/http_handlers/app_context_test.rs diff --git a/crates/adapters/http-axum/src/extractors/tests.rs b/crates/bootstrap/tests/http_handlers/extractors_test.rs similarity index 100% rename from crates/adapters/http-axum/src/extractors/tests.rs rename to crates/bootstrap/tests/http_handlers/extractors_test.rs diff --git a/crates/domain/src/testing/fakes/store_user.rs b/crates/domain/src/testing/fakes/store_user.rs index d19008a9..0d76498f 100644 --- a/crates/domain/src/testing/fakes/store_user.rs +++ b/crates/domain/src/testing/fakes/store_user.rs @@ -58,10 +58,14 @@ impl UserRepository for InMemoryStore { Ok(self.users.read().unwrap().get(&id.value()).cloned()) } + /// Ordered by email ascending like the real query. Diary stats are stubbed + /// (review count 0, no last activity); only the identity fields are real. async fn list_with_stats(&self) -> Result, DomainError> { let store = self.users.read().unwrap(); - Ok(store - .values() + let mut users: Vec<_> = store.values().collect(); + users.sort_by(|a, b| a.email().value().cmp(b.email().value())); + Ok(users + .into_iter() .map(|u| { UserSummary::new( u.id().clone(), -- 2.49.1 From a1c3f7760debafe8f37c4c9ac9715b02b2e5d545 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:52:41 +0100 Subject: [PATCH 041/104] test: move http-axum tests to tests/ Drop bootstrap dev-dep. Extractor + api handler + app-context tests (need full AppState via build_deps) -> bootstrap/tests/http_handlers/. forms tests -> http-axum/tests/log_review_form_test.rs. Federation collection tests now go through public federation::html_routes(). Kept inline (private fns): api/social social_actor_to_dto (4), federation/blocklist_api social_actor_to_blocked_dto (1), html/social social_actor_to_template (1), router slow_down/api_governor (2). --- Cargo.lock | 1 - crates/adapters/http-axum/Cargo.toml | 1 - .../adapters/http-axum/src/extractors/mod.rs | 3 - crates/adapters/http-axum/src/html/forms.rs | 4 - crates/adapters/http-axum/src/lib.rs | 3 - .../http-axum/tests/log_review_form_test.rs | 3 +- crates/bootstrap/Cargo.toml | 1 + crates/bootstrap/tests/http_handlers.rs | 9 + .../tests/http_handlers/api_handlers_test.rs | 62 ++--- .../tests/http_handlers/app_context_test.rs | 8 +- .../tests/http_handlers/extractors_test.rs | 255 +----------------- .../bootstrap/tests/http_handlers/support.rs | 243 +++++++++++++++++ 12 files changed, 296 insertions(+), 297 deletions(-) create mode 100644 crates/bootstrap/tests/http_handlers.rs create mode 100644 crates/bootstrap/tests/http_handlers/support.rs diff --git a/Cargo.lock b/Cargo.lock index 300197eb..eda8a741 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2421,7 +2421,6 @@ dependencies = [ "async-trait", "axum", "axum-governor", - "bootstrap", "bytes", "chrono", "chrono-tz", diff --git a/crates/adapters/http-axum/Cargo.toml b/crates/adapters/http-axum/Cargo.toml index c7954a7a..45901788 100644 --- a/crates/adapters/http-axum/Cargo.toml +++ b/crates/adapters/http-axum/Cargo.toml @@ -43,4 +43,3 @@ bytes = { workspace = true } futures = { workspace = true } tower = { version = "0.5", features = ["util"] } domain = { workspace = true, features = ["test-helpers"] } -bootstrap = { workspace = true } diff --git a/crates/adapters/http-axum/src/extractors/mod.rs b/crates/adapters/http-axum/src/extractors/mod.rs index e4620219..539c7222 100644 --- a/crates/adapters/http-axum/src/extractors/mod.rs +++ b/crates/adapters/http-axum/src/extractors/mod.rs @@ -156,6 +156,3 @@ where } } } - -#[cfg(test)] -pub(crate) mod tests; diff --git a/crates/adapters/http-axum/src/html/forms.rs b/crates/adapters/http-axum/src/html/forms.rs index d5573800..835b18e9 100644 --- a/crates/adapters/http-axum/src/html/forms.rs +++ b/crates/adapters/http-axum/src/html/forms.rs @@ -310,7 +310,3 @@ pub struct DismissWatchForm { #[serde(rename = "_csrf", default)] pub csrf_token: String, } - -#[cfg(test)] -#[path = "forms_tests.rs"] -mod tests; diff --git a/crates/adapters/http-axum/src/lib.rs b/crates/adapters/http-axum/src/lib.rs index 76d8477f..2dd8736a 100644 --- a/crates/adapters/http-axum/src/lib.rs +++ b/crates/adapters/http-axum/src/lib.rs @@ -8,6 +8,3 @@ pub mod openapi; pub mod router; pub mod state; mod support; - -#[cfg(test)] -mod tests; diff --git a/crates/adapters/http-axum/tests/log_review_form_test.rs b/crates/adapters/http-axum/tests/log_review_form_test.rs index a7ef08a4..2e1c3da7 100644 --- a/crates/adapters/http-axum/tests/log_review_form_test.rs +++ b/crates/adapters/http-axum/tests/log_review_form_test.rs @@ -1,4 +1,5 @@ -use super::*; +use api_types::{DiaryQueryParams, LogReviewRequest}; +use http_axum::html::forms::{LogReviewData, LogReviewForm, to_diary_query}; fn make_form(watched_at: &str) -> LogReviewForm { LogReviewForm { diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index bb15f275..35303588 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -46,3 +46,4 @@ chrono = { workspace = true } uuid = { workspace = true } serde_json = { workspace = true } tower = { version = "0.5", features = ["util"] } +domain = { workspace = true, features = ["test-helpers"] } diff --git a/crates/bootstrap/tests/http_handlers.rs b/crates/bootstrap/tests/http_handlers.rs new file mode 100644 index 00000000..7456da07 --- /dev/null +++ b/crates/bootstrap/tests/http_handlers.rs @@ -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; diff --git a/crates/bootstrap/tests/http_handlers/api_handlers_test.rs b/crates/bootstrap/tests/http_handlers/api_handlers_test.rs index 9062745f..29ae5b92 100644 --- a/crates/bootstrap/tests/http_handlers/api_handlers_test.rs +++ b/crates/bootstrap/tests/http_handlers/api_handlers_test.rs @@ -1,4 +1,4 @@ -use crate::extractors::tests::{AcceptingAuth, test_app_state, test_app_state_with_follow_graph}; +use crate::support::{AcceptingAuth, test_app_state, test_app_state_with_follow_graph}; use axum::{ Router, body::Body, @@ -73,9 +73,9 @@ impl domain::ports::PersonQuery for PersonQueryStub { #[tokio::test] async fn search_endpoint_returns_200_with_empty_results() { - let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub)); + let state = crate::support::test_app_state_with_search_port(Arc::new(SearchPortStub)); let app = Router::new() - .route("/api/v1/search", get(crate::api::search::get_search)) + .route("/api/v1/search", get(http_axum::api::search::get_search)) .with_state(state); let resp = app @@ -93,9 +93,9 @@ async fn search_endpoint_returns_200_with_empty_results() { #[tokio::test] async fn search_endpoint_with_no_query_returns_200() { - let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub)); + let state = crate::support::test_app_state_with_search_port(Arc::new(SearchPortStub)); let app = Router::new() - .route("/api/v1/search", get(crate::api::search::get_search)) + .route("/api/v1/search", get(http_axum::api::search::get_search)) .with_state(state); let resp = app @@ -115,12 +115,11 @@ async fn search_endpoint_with_no_query_returns_200() { #[tokio::test] async fn person_endpoint_returns_404_for_unknown_id() { - let state = - crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub)); + let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub)); let app = Router::new() .route( "/api/v1/people/{id}", - get(crate::api::search::get_person_handler), + get(http_axum::api::search::get_person_handler), ) .with_state(state); @@ -140,12 +139,11 @@ async fn person_endpoint_returns_404_for_unknown_id() { #[tokio::test] async fn person_credits_endpoint_returns_404_for_unknown_id() { - let state = - crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub)); + let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub)); let app = Router::new() .route( "/api/v1/people/{id}/credits", - get(crate::api::search::get_person_credits_handler), + get(http_axum::api::search::get_person_credits_handler), ) .with_state(state); @@ -166,7 +164,7 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() { #[test] fn injected_stub_reaches_prebuilt_deps() { let stub: Arc = Arc::new(PersonQueryStub); - let state = crate::extractors::tests::test_app_state_with_person_query(Arc::clone(&stub)); + 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), @@ -183,7 +181,7 @@ async fn get_watchlist_requires_auth() { let app = Router::new() .route( "/api/v1/watchlist", - get(crate::api::watchlist::get_watchlist_handler), + get(http_axum::api::watchlist::get_watchlist_handler), ) .with_state(state); @@ -266,7 +264,7 @@ async fn pending_count_endpoint_reports_the_use_case_result() { let app = Router::new() .route( "/api/v1/social/followers/pending/count", - get(crate::api::social::get_pending_follower_count), + get(http_axum::api::social::get_pending_follower_count), ) .with_state(state); @@ -295,7 +293,7 @@ async fn get_watchlist_status_requires_auth() { let app = Router::new() .route( "/api/v1/watchlist/{movie_id}", - get(crate::api::watchlist::get_watchlist_status), + get(http_axum::api::watchlist::get_watchlist_status), ) .with_state(state); @@ -340,7 +338,7 @@ impl domain::ports::ApDocumentPort for FailingApDocument { } #[cfg(feature = "federation")] -fn state_with_failing_ap_document() -> crate::state::AppState { +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 @@ -349,12 +347,7 @@ fn state_with_failing_ap_document() -> crate::state::AppState { #[cfg(feature = "federation")] #[tokio::test] async fn followers_collection_returns_500_on_federation_error() { - let app = Router::new() - .route( - "/users/{id}/followers", - get(crate::federation::collections::get_followers_collection), - ) - .with_state(state_with_failing_ap_document()); + let app = http_axum::federation::html_routes().with_state(state_with_failing_ap_document()); let resp = app .oneshot( @@ -373,12 +366,7 @@ async fn followers_collection_returns_500_on_federation_error() { #[cfg(feature = "federation")] #[tokio::test] async fn following_collection_returns_500_on_federation_error() { - let app = Router::new() - .route( - "/users/{id}/following", - get(crate::federation::collections::get_following_collection), - ) - .with_state(state_with_failing_ap_document()); + let app = http_axum::federation::html_routes().with_state(state_with_failing_ap_document()); let resp = app .oneshot( @@ -405,12 +393,11 @@ async fn refusal_body(resp: axum::response::Response) -> serde_json::Value { #[tokio::test] async fn a_missing_resource_is_refused_with_not_found_code() { - let state = - crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub)); + let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub)); let app = Router::new() .route( "/api/v1/people/{id}", - get(crate::api::search::get_person_handler), + get(http_axum::api::search::get_person_handler), ) .with_state(state); @@ -435,7 +422,7 @@ async fn a_missing_credential_is_refused_with_unauthorized_code() { let app = Router::new() .route( "/api/v1/watchlist", - get(crate::api::watchlist::get_watchlist_handler), + get(http_axum::api::watchlist::get_watchlist_handler), ) .with_state(test_app_state()); @@ -455,12 +442,11 @@ async fn a_missing_credential_is_refused_with_unauthorized_code() { #[tokio::test] async fn a_malformed_path_parameter_is_refused_with_invalid_input_code() { - let state = - crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub)); + let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub)); let app = Router::new() .route( "/api/v1/people/{id}", - get(crate::api::search::get_person_handler), + get(http_axum::api::search::get_person_handler), ) .with_state(state); @@ -483,7 +469,7 @@ async fn a_malformed_json_body_is_refused_with_invalid_input_code() { let app = Router::new() .route( "/api/v1/auth/login", - axum::routing::post(crate::api::auth::login), + axum::routing::post(http_axum::api::auth::login), ) .with_state(test_app_state()); @@ -510,7 +496,7 @@ 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(crate::api::auth::login), + axum::routing::post(http_axum::api::auth::login), ) .with_state(test_app_state()); @@ -535,7 +521,7 @@ async fn a_non_multipart_upload_is_refused_with_invalid_input_code() { let app = Router::new() .route( "/api/v1/webhooks/plex", - axum::routing::post(crate::api::webhook::post_plex_webhook), + axum::routing::post(http_axum::api::webhook::post_plex_webhook), ) .with_state(test_app_state()); diff --git a/crates/bootstrap/tests/http_handlers/app_context_test.rs b/crates/bootstrap/tests/http_handlers/app_context_test.rs index b7896e4d..f8998d4c 100644 --- a/crates/bootstrap/tests/http_handlers/app_context_test.rs +++ b/crates/bootstrap/tests/http_handlers/app_context_test.rs @@ -2,14 +2,12 @@ fn prebuilt_deps_share_port_instances_with_repos() { use std::sync::Arc; - let repos = crate::extractors::tests::test_repositories(); + let repos = crate::support::test_repositories(); let user = Arc::clone(&repos.user); let follow_graph = Arc::clone(&repos.follow_graph); - let state = crate::extractors::tests::build_test_state_from( - repos, - Arc::new(domain::testing::PanicAuthService), - ); + 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), diff --git a/crates/bootstrap/tests/http_handlers/extractors_test.rs b/crates/bootstrap/tests/http_handlers/extractors_test.rs index 1795bb7e..e49fd9a0 100644 --- a/crates/bootstrap/tests/http_handlers/extractors_test.rs +++ b/crates/bootstrap/tests/http_handlers/extractors_test.rs @@ -1,37 +1,23 @@ -use super::*; -use crate::context::AppContext; -use application::config::AppConfig; +use std::sync::Arc; + use axum::{ Router, body::Body, http::{Request, StatusCode}, routing::get, }; -use bootstrap::Repositories; use domain::{ - errors::DomainError, - models::GeneratedToken, - ports::{AuthService, FollowGraphQuery, PersonQuery, SearchPort, UserRepository}, - testing::{ - InMemoryStore, 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, - }, + errors::DomainError, models::GeneratedToken, ports::AuthService, testing::InMemoryStore, value_objects::UserId, }; -use std::sync::Arc; +use http_axum::extractors::{ + AdminApiUser, AdminUser, AuthenticatedUser, OptionalCookieUser, RequiredCookieUser, +}; use tower::ServiceExt; -// --- Local stubs --- - -struct PanicReviewLogger; +use crate::support::{ + AcceptingAuth, ErroringUserRepo, make_test_state, test_app_state, test_app_state_with_user, +}; struct RejectingAuth; #[async_trait::async_trait] @@ -44,219 +30,6 @@ impl AuthService for RejectingAuth { } } -#[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(crate) 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(crate) fn build_test_state_from( - repos: Repositories, - auth_service: Arc, -) -> crate::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 = AppConfig { - 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::WrapUpConfig { - 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)); - - crate::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) -> crate::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(crate) fn test_app_state() -> crate::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(crate) fn test_app_state_with_person_query( - person_query: Arc, -) -> crate::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(crate) fn test_app_state_with_search_port( - search_port: Arc, -) -> crate::state::AppState { - let mut repos = test_repositories(); - repos.search_port = search_port; - build_test_state_from(repos, Arc::new(PanicAuthService)) -} - -/// Test state with `follow_graph` overridden BEFORE `build_deps` runs, so the -/// stub is visible from both `app_ctx.repos.follow_graph` and the prebuilt -/// `deps.social.query` that the social handlers actually read from. -pub(crate) fn test_app_state_with_follow_graph( - follow_graph: Arc, - auth_service: Arc, -) -> crate::state::AppState { - let mut repos = test_repositories(); - repos.follow_graph = follow_graph; - 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(crate) fn test_app_state_with_user( - user: Arc, - auth_service: Arc, -) -> crate::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(crate) struct ErroringUserRepo; -#[async_trait::async_trait] -impl UserRepository for ErroringUserRepo { - async fn find_by_email( - &self, - _: &domain::value_objects::Email, - ) -> Result, DomainError> { - panic!() - } - async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> { - panic!() - } - async fn find_by_id(&self, _: &UserId) -> Result, DomainError> { - Err(DomainError::InfrastructureError("db down".into())) - } - async fn find_by_username( - &self, - _: &domain::value_objects::Username, - ) -> Result, DomainError> { - panic!() - } - async fn list_with_stats(&self) -> Result, DomainError> { - panic!() - } - async fn update_profile( - &self, - _: &UserId, - _: &domain::models::UserProfile, - ) -> Result<(), 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(crate) struct AcceptingAuth(pub UserId); -#[async_trait::async_trait] -impl AuthService for AcceptingAuth { - async fn generate_token(&self, _: &UserId) -> Result { - panic!() - } - async fn validate_token(&self, _: &str) -> Result { - Ok(self.0.clone()) - } -} - // --- Routers --- async fn protected_handler(user: AuthenticatedUser) -> String { @@ -278,27 +51,27 @@ async fn admin_cookie_handler(user: AdminUser) -> String { user.0.value().to_string() } -fn router_protected(state: crate::state::AppState) -> Router { +fn router_protected(state: http_axum::state::AppState) -> Router { Router::new() .route("/protected", get(protected_handler)) .with_state(state) } -fn router_optional(state: crate::state::AppState) -> Router { +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: crate::state::AppState) -> Router { +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: crate::state::AppState) -> Router { +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: crate::state::AppState) -> Router { +fn router_required(state: http_axum::state::AppState) -> Router { Router::new() .route("/required", get(required_cookie_handler)) .with_state(state) diff --git a/crates/bootstrap/tests/http_handlers/support.rs b/crates/bootstrap/tests/http_handlers/support.rs new file mode 100644 index 00000000..16b57e7a --- /dev/null +++ b/crates/bootstrap/tests/http_handlers/support.rs @@ -0,0 +1,243 @@ +//! `AppState` fixtures: `Panic*` stubs everywhere, real prebuilt `deps` built through +//! `bootstrap::build_deps`, with per-port overrides injected BEFORE the deps are built. +#![allow(dead_code)] + +use application::config::AppConfig; +use bootstrap::Repositories; +use domain::{ + errors::DomainError, + models::GeneratedToken, + ports::{AuthService, FollowGraphQuery, 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; + +// --- Local stubs --- + +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, +) -> 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 = AppConfig { + 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::WrapUpConfig { + 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) -> 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, +) -> 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, +) -> http_axum::state::AppState { + let mut repos = test_repositories(); + repos.search_port = search_port; + build_test_state_from(repos, Arc::new(PanicAuthService)) +} + +/// Test state with `follow_graph` overridden BEFORE `build_deps` runs, so the +/// stub is visible from both `app_ctx.repos.follow_graph` and the prebuilt +/// `deps.social.query` that the social handlers actually read from. +pub fn test_app_state_with_follow_graph( + follow_graph: Arc, + auth_service: Arc, +) -> http_axum::state::AppState { + let mut repos = test_repositories(); + repos.follow_graph = follow_graph; + 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, + auth_service: Arc, +) -> 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, DomainError> { + panic!() + } + async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> { + panic!() + } + async fn find_by_id(&self, _: &UserId) -> Result, DomainError> { + Err(DomainError::InfrastructureError("db down".into())) + } + async fn find_by_username( + &self, + _: &domain::value_objects::Username, + ) -> Result, DomainError> { + panic!() + } + async fn list_with_stats(&self) -> Result, DomainError> { + panic!() + } + async fn update_profile( + &self, + _: &UserId, + _: &domain::models::UserProfile, + ) -> Result<(), 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 { + panic!() + } + async fn validate_token(&self, _: &str) -> Result { + Ok(self.0.clone()) + } +} -- 2.49.1 From b76f5996dada4bff35e0b04700bd4da0cdb07e9c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:58:46 +0100 Subject: [PATCH 042/104] chore: dockerfile manifest guard --- .gitea/workflows/ci.yml | 2 +- .github/workflows/ci.yml | 2 +- Makefile | 20 +++++++++++++++++-- ...{poster_sync.rs => poster_sync_handler.rs} | 0 4 files changed, 20 insertions(+), 4 deletions(-) rename crates/application/src/movies/{poster_sync.rs => poster_sync_handler.rs} (100%) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7cb9e078..27dec2ae 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -47,4 +47,4 @@ jobs: # Layering guards. These live in the Makefile and were previously local-only, # so a PR could go green in CI while violating them. - name: guards - run: make check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free + run: make check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-dockerfile-manifests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00020619..b42587f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: # Layering guards. These live in the Makefile and were previously local-only, # so a PR could go green in CI while violating them. - name: guards - run: make check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free + run: make check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-dockerfile-manifests docker: name: Build & Push Docker Image diff --git a/Makefile b/Makefile index 7de7a00d..b018db21 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := check -check: fmt-check clippy test test-federation-off check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free +check: fmt-check clippy test test-federation-off check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-dockerfile-manifests @echo "✅ All checks passed" check-appcontext: @@ -87,6 +87,22 @@ check-sqlite-is-ap-free: fi @echo "✅ sqlite crate depends on no k-ap, activitypub, or *-federation crate" +check-dockerfile-manifests: + @members=$$(awk '/^members = \[/{f=1;next} f&&/^\]/{f=0} f' Cargo.toml | sed -n 's/^[[:space:]]*"\(.*\)",\{0,1\}[[:space:]]*$$/\1/p'); \ + copied=$$(sed -n 's/^COPY \([^[:space:]]*\)\/Cargo\.toml[[:space:]].*/\1/p' Dockerfile | grep -v '^\(Cargo\.toml\|\.\)$$' || true); \ + bad=""; \ + for m in $$members; do \ + echo "$$copied" | grep -qx "$$m" || bad="$$bad missing-COPY:$$m"; \ + done; \ + for c in $$copied; do \ + echo "$$members" | grep -qx "$$c" || bad="$$bad not-a-member:$$c"; \ + done; \ + if [ -n "$$bad" ]; then \ + echo "❌ Dockerfile manifest COPY lines out of sync with workspace members:$$bad"; \ + exit 1; \ + fi; \ + echo "✅ Dockerfile copies a manifest for every workspace member" + fmt: cargo fmt @@ -129,4 +145,4 @@ dev-spa: spa-dev: @npm --prefix spa run dev -.PHONY: check fmt fmt-check clippy test test-federation-off fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-binaries-are-thin check-sqlite-is-ap-free +.PHONY: check fmt fmt-check clippy test test-federation-off fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-dockerfile-manifests diff --git a/crates/application/src/movies/poster_sync.rs b/crates/application/src/movies/poster_sync_handler.rs similarity index 100% rename from crates/application/src/movies/poster_sync.rs rename to crates/application/src/movies/poster_sync_handler.rs -- 2.49.1 From 80072eb463a064e9c1303322ba5020e8748fde66 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:58:46 +0100 Subject: [PATCH 043/104] docs: update for new crate layout --- CODE_STYLE.md | 17 +++-- CONTRIBUTING.md | 44 +++++++------ README.md | 39 ++++++++--- architecture.mmd | 64 +++++++++++-------- .../http-axum/src/api/social/tests.rs | 10 +-- .../src/federation/blocklist_api/tests.rs | 12 +--- .../http-axum/src/html/social/tests.rs | 12 +--- crates/adapters/http-axum/src/lib.rs | 2 + crates/adapters/http-axum/src/test_support.rs | 12 ++++ crates/application/src/deps.rs | 2 +- .../src/import/apply_profile_and_map.rs | 2 +- .../src/import/get_session_state.rs | 2 +- crates/application/src/movies/mod.rs | 2 +- .../src/users/get_federated_profile_stats.rs | 2 +- .../application/src/users/get_page_viewer.rs | 4 +- .../src/users/resolve_username_to_id.rs | 2 +- crates/bootstrap/src/worker.rs | 20 +++--- .../0007-no-repositories-in-presentation.md | 2 + ...esentation-names-renderers-not-reachers.md | 2 + ...ion-is-optional-at-the-dependency-level.md | 2 + 20 files changed, 148 insertions(+), 106 deletions(-) create mode 100644 crates/adapters/http-axum/src/test_support.rs diff --git a/CODE_STYLE.md b/CODE_STYLE.md index 12d18456..9d44b5d2 100644 --- a/CODE_STYLE.md +++ b/CODE_STYLE.md @@ -47,13 +47,17 @@ Small files with a single concern. If a file grows past ~100-150 lines, it's pro ### Tests -Never in the same file as production code. Use cases keep their tests in a sibling `tests/` module, one file per use case: +Never in the same file as production code. Each crate keeps its tests in `crates//tests/`, one file per unit under test, grouped by the same module as the production code. Cargo builds each top-level `tests/.rs` as one integration-test binary; the group file lists its members with `#[path]`: ``` crates/application/src/diary/log_review.rs -crates/application/src/diary/tests/log_review.rs +crates/application/tests/diary.rs # mod support; + #[path] list of the group +crates/application/tests/diary/log_review_test.rs +crates/application/tests/support/ # shared contexts and command builders ``` +Adapters follow the same layout (`crates/adapters/sqlite/tests/repositories/diary_repository_test.rs`). The only tests left beside source are small mapper tests in `crates/adapters/http-axum/src/**/tests.rs`, which need crate-private items; their shared fixtures live in `http-axum/src/test_support.rs`. Route smoke, API and handler tests run against the real composition and live in `crates/bootstrap/tests/`. + ## Naming ### Conventions @@ -174,7 +178,7 @@ Errors propagate with `?` and bubble up to a single handler. No scattered panic ## Configuration -Secrets (API keys, JWT secrets) and deployment settings live in environment variables, read once at startup into a typed config struct. +Secrets (API keys, JWT secrets) and deployment settings are read once at startup by the `config` crate into a typed `AppConfig`: built-in defaults, then `config.toml` (or the file named by `CONFIG_FILE`), then environment variables. Malformed values fail startup; empty values count as unset. Only the binaries load configuration; everything else receives typed config values. Zero magic numbers or strings. Everything named, everything explained by its name. @@ -207,7 +211,8 @@ When none of those can carry it, a short comment stating the reason (never resta - Domain crate: zero external dependencies beyond `thiserror`, `serde` and small value-type crates - Application crate: depends only on domain - Adapters: free to pull in platform crates (`axum`, `sqlx`, `nats`, etc.) -- Bootstrap: wiring only +- Bootstrap: wiring only; the only crate that names adapters +- Binaries (`server`, `worker`): load config, call `bootstrap`, run — nothing else (`make check-binaries-are-thin`) ## Trait Objects for Ports @@ -226,7 +231,7 @@ Prefer a closed enum over `dyn` only where exhaustiveness is the point — when ### Test Helpers Are Gated -`domain::testing` (in-memory fakes and noops) compiles only with the `test-helpers` feature, which crates enable in their `[dev-dependencies]`. Production builds must never contain fakes. `application::test_helpers` is `#[cfg(test)]`. +`domain::testing` (factories, the cascading `InMemoryStore`, noops and `Refusing*` fakes) compiles only with the `test-helpers` feature, which crates enable in their `[dev-dependencies]`. Production builds must never contain fakes. Build entities with the `domain::testing` factories instead of repeating struct literals in each test. ### Coverage Targets @@ -235,7 +240,7 @@ Prefer a closed enum over `dyn` only where exhaustiveness is the point — when | Domain | TDD (red-green-refactor) | 100% | | Application | TDD | High coverage | | Adapters | Integration tests where practical | Best effort | -| Bootstrap | No tests | — | +| Bootstrap | Route smoke, API and handler tests over the real wiring | Every route reachable | ### Fakes Must Not Be Kinder Than Production diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 33661d10..8f3ba768 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,55 +23,61 @@ cd spa && bun install && bun run dev ## Before submitting a PR ```bash -make # runs fmt-check + clippy + test +make check # the full gate; plain `make` runs it too ``` -Or individually: +`make check` runs, in order: `fmt-check`, `clippy` (workspace, all targets, `-D warnings`), `test`, `test-federation-off` (workspace tests with default features off), then the layering guards (`check-appcontext`, `check-handler-deps`, `check-handler-repos`, `check-http-adapter-deps`, `check-federation-off-social`, `check-sqlite-is-ap-free`, `check-binaries-are-thin`, `check-dockerfile-manifests`). CI runs the same steps. + +For the SPA also run: ```bash -cargo fmt --check -cargo clippy -- -D warnings -cargo test cd spa && bunx tsc --noEmit ``` -All four must pass. PRs with clippy warnings or failing tests won't be merged. +PRs with a failing gate won't be merged. ## Architecture The project follows hexagonal (ports & adapters) architecture. See `architecture.mmd` for the full diagram. **Key rules:** -- Presentation handlers never touch repositories directly — all domain logic goes through use cases in the `application` crate -- Application use cases return raw domain data — URL formatting, date display, and view model assembly belong in presentation mappers (`crates/adapters/http-axum/src/mappers/`) -- Use cases called from presentation handlers take a `&FooDeps` struct (registered in `application::Deps`, built by `bootstrap::build_deps`) — never `&AppContext` itself, which `application` cannot even depend on. A few keep individual `Arc` params instead: `enrich_movie` and `request_enrichment` because they are called from adapters rather than handlers, and `diary::log_review` because its one extra dependency comes from `Services` rather than a repository. `wrapup::compute`, `import::cleanup` and `integrations::cleanup` also take individual params, but they are only ever called from jobs. See ADR-0007 for the exact list and why each is legitimate +- HTTP handlers never touch repositories directly — all domain logic goes through use cases in the `application` crate +- Application use cases return raw domain data — URL formatting, date display, and view model assembly belong in the http-axum mappers (`crates/adapters/http-axum/src/html/mappers/` for the classic UI, the DTO conversions beside each handler under `api/`) +- Use cases called from HTTP handlers take a `&FooDeps` struct (registered in `application::Deps`, built by `bootstrap`) — never `&AppContext` itself, which `application` cannot even depend on. A few keep individual `Arc` params instead: `enrich_movie` and `request_enrichment` because they are called from adapters rather than handlers, and `diary::log_review` because its one extra dependency comes from `Services` rather than a repository. `wrapup::compute`, `import::cleanup` and `integrations::cleanup` also take individual params, but they are only ever called from jobs. See ADR-0007 for the exact list and why each is legitimate ``` domain → pure types, traits (ports), zero deps application → use cases, orchestration -http-axum → Axum handlers, routes, OpenAPI -worker → event consumer, background jobs -adapters/* → implements domain ports (sqlite, AP, etc.) +config → typed AppConfig: defaults → config.toml / CONFIG_FILE → env +bootstrap → composition root, the only crate that names adapters +server, worker → thin binaries: load config, call bootstrap, run +adapters/* → implement domain ports (sqlite, activitypub, ...); http-axum is the HTTP driving adapter spa/ → React SPA (TanStack Router + shadcn/ui) ``` +Cargo features: `federation` (default; ActivityPub) and `nats`. `sqlite` and `sqlite-federation` remain as deprecated aliases so old build commands keep working. + ### Adding a new feature -1. **Domain first** — models in `domain/src/models/`, ports in `ports.rs`, events in `events.rs` -2. **Adapters** — implement ports in the `sqlite*` adapters, add migration -3. **Application** — use cases in `application/src//`, wire into `context.rs` +1. **Domain first** — models in `domain/src/models/`, ports in `domain/src/ports/`, events in `domain/src/events.rs` +2. **Adapters** — implement ports under `crates/adapters/sqlite/src/repositories//`, add a migration +3. **Application** — use cases in `application/src//`, add the deps struct to `application/src/deps.rs`, build it in `bootstrap` 4. **API types** — DTOs in `api-types/src/` -5. **Presentation** — handler file in `handlers/.rs`, routes in `routes.rs` +5. **HTTP** — JSON handlers in `crates/adapters/http-axum/src/api/.rs`, classic HTML handlers in `.../html/.rs` (templates in `html/templates/`), routes in `router.rs` / `api/mod.rs` / `html/mod.rs` 6. **SPA** — API client in `spa/src/lib/api/`, hook in `spa/src/hooks/`, components 7. **Classic HTML** — Askama template + CSS in `static/style.css` +### Tests + +Tests live in `crates//tests/`, never in the production file (see `CODE_STYLE.md`). Build fixtures with the `domain::testing` factories and use the cascading `InMemoryStore` and `Refusing*` fakes. Route smoke, API and handler tests over the real wiring are in `crates/bootstrap/tests/`. + ### Database adapters -SQLite is the only supported backend (see [ADR-0012](docs/adr/0012-sqlite-is-the-only-backend.md)). Add migrations under the sqlite adapter; SQLite uses `?` params and text datetimes. +SQLite is the only supported backend (see [ADR-0012](docs/adr/0012-sqlite-is-the-only-backend.md)). Add migrations under `crates/adapters/sqlite`; SQLite uses `?` params and text datetimes. ### Federation (ActivityPub) -Federation is feature-gated (`#[cfg(feature = "federation")]`). If your feature should federate, add domain events, handle them in `activitypub/src/event_handler.rs`, and create an AP object + inbound handler. +Federation is feature-gated (`#[cfg(feature = "federation")]`). If your feature should federate, add domain events, handle them in `crates/adapters/activitypub/src/event_handler.rs`, and create an AP object + inbound handler. ## Code style @@ -83,5 +89,5 @@ Federation is feature-gated (`#[cfg(feature = "federation")]`). If your feature ## Areas seeking help - **TUI** (`crates/tui`) — deprecated, needs a maintainer to bring it up to feature parity -- **Tests** — the domain and application crates have 400+ unit tests; integration tests for the presentation layer are welcome +- **Tests** — the domain and application crates have 400+ tests; more route and handler tests in `crates/bootstrap/tests/` are welcome - **Docs** — API usage examples, deployment guides diff --git a/README.md b/README.md index 8de059c6..1c43fac4 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Hexagonal (Ports & Adapters) with Domain-Driven Design: api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by http-axum and tui config — typed configuration (defaults, config.toml, legacy env names); loaded once by the binaries bootstrap — composition root: the only crate that names adapters; builds the server router and the worker service +event-payload — serde payloads for domain events, shared by the NATS and SQLite-queue adapters domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, SocialCommand/SocialQuery, ImageFetcher, RssFeedRenderer), no external deps except serde application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic; modules: auth, diary, goals, import, integrations, movies, person, search, social, users, watchlist, wrapup server — thin HTTP binary (dotenv, config, bootstrap::build_server, axum::serve); features (federation, nats) forward to bootstrap @@ -100,7 +101,7 @@ worker — thin worker binary (bootstrap::build_worker, run until c adapters/ http-axum — Axum HTTP adapter: JSON API, classic HTML UI (askama templates), OpenAPI spec, Swagger UI + Scalar, federation-gated routes; library crate, no binary of its own auth — JWT issuance and validation (Argon2 passwords) - sqlite — SQLite repositories, search (FTS5), social graph, durable event queue, connection factory + migrations + sqlite — SQLite repositories (one module per aggregate under repositories/), search (FTS5), social graph, durable event queue, connection factory + migrations; the only storage adapter metadata — TMDB / OMDb HTTP client poster-fetcher — downloads poster images object-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage @@ -110,10 +111,10 @@ adapters/ export — CSV and JSON diary serialization importer — CSV/TSV/JSON/XLSX parser and column mapper for bulk import media-server — Jellyfin and Plex webhook payload parsers (MediaServerParser adapter; Plex requires Plex Pass) - event-publisher — in-memory event channel (used in tests) + event-publisher — in-memory event channel nats — NATS Core / JetStream event publisher and consumer activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals - sqlite-federation — SQLite-backed federation repository + sqlite-federation — SQLite-backed federation repository (k-ap trait implementations; only built with the `federation` feature) tui — terminal UI client (ratatui); shares api-types with http-axum for typed API access spa/ — React SPA (TanStack Router + shadcn/ui + Vite); served at /app/ by the backend ``` @@ -127,7 +128,13 @@ spa/ — React SPA (TanStack Router + shadcn/ui + Vite); served a ## Configuration -Copy `.env.example` to `.env` and set the values below. Required fields must be set before the server will start. +Settings are layered, later sources winning: + +1. built-in defaults +2. `config.toml` in the working directory, or the file named by `CONFIG_FILE` (a named file that does not exist is an error; the default `config.toml` is optional) +3. environment variables (including those from `.env`), under the names in the table below + +Copy `.env.example` to `.env` and set the values you need. Malformed values (a non-numeric `PORT`, an unknown `EVENT_BUS_BACKEND`) fail startup with a message naming the setting. An empty value (`PORT=`) counts as unset, so the default applies. The variable names are unchanged from earlier releases; every one maps to a `section.field` key in `config.toml` (for example `PORT` is `server.port`, `JWT_SECRET` is `auth.jwt_secret`). | Variable | Default | Required | Description | |---|---|---|---| @@ -155,6 +162,17 @@ Copy `.env.example` to `.env` and set the values below. Required fields must be | `EVENT_BUS_BACKEND` | `db` | No | `db` (default) or `nats` | | `NATS_URL` | — | NATS only | NATS connection URL (e.g. `nats://localhost:4222`) | +Required fields must be set (in either source) before the server will start. + +### Cargo features + +| Feature | Default | Effect | +|---|---|---| +| `federation` | yes | ActivityPub federation (`activitypub`, `sqlite-federation`, federation routes) | +| `nats` | no | NATS event bus (`EVENT_BUS_BACKEND=nats`) | + +Build without federation with `--no-default-features`. `sqlite` and `sqlite-federation` are deprecated aliases kept so old build commands still work. + The `worker` binary must run alongside `server` to process events: ```bash @@ -208,23 +226,26 @@ Supports review logging, bulk CSV import, and diary browsing. A `Makefile` wraps the most common dev commands: ```bash -make # default: fmt-check + clippy + test (same order as CI) +make # make check: the full gate, same steps as CI make fix # auto-apply fmt + clippy fixes make fmt # apply rustfmt make clippy # clippy with -D warnings -make test # cargo test +make test # cargo test --workspace ``` +`make check` runs fmt-check, clippy, tests (default features and `--no-default-features`), and the layering guards (see `CONTRIBUTING.md`). + ## Test ```bash -cargo test # full workspace (requires DATABASE_URL for sqlx offline checks) -cargo test -p application # business logic tests only — no database required +cargo test --workspace # everything; needs no external services +cargo test -p application # business logic tests only — in-memory fakes, no database cargo test -p domain # domain model + value object tests +cargo test -p bootstrap # route smoke, API and handler tests over the real wiring cargo llvm-cov -p application -p domain # line coverage report (requires cargo-llvm-cov) ``` -The `application` and `domain` crates have 400+ unit tests covering all use case modules (auth, diary, goals, import, integrations, movies, person, search, users, watchlist, wrapup) backed by in-memory fakes from `domain`'s `test-helpers` feature. These run without a database and are the fastest feedback loop for business logic changes. +Each crate keeps its tests in `crates//tests/`. The `application` and `domain` crates have 400+ tests covering all use case modules (auth, diary, goals, import, integrations, movies, person, search, users, watchlist, wrapup) backed by the in-memory fakes and factories in `domain::testing` (the `test-helpers` feature). They are the fastest feedback loop for business logic changes. ## Docker diff --git a/architecture.mmd b/architecture.mmd index d8ef6cb0..c12ede18 100644 --- a/architecture.mmd +++ b/architecture.mmd @@ -2,15 +2,19 @@ title: Movies Diary — Hexagonal Architecture --- graph TB - subgraph Binaries["Binaries (Composition Root)"] - WEB["http-axum
Axum web server + HTML templates
Routes, Handlers, Mappers"] - WORKER["worker
Event consumer
Concurrent dispatch, graceful shutdown"] - TUI["tui
Terminal UI"] + subgraph Binaries["Binaries (thin: config + bootstrap + run)"] + SERVER["server
load config, bootstrap::build_server, axum::serve"] + WORKER["worker
bootstrap::build_worker
Concurrent dispatch, graceful shutdown"] + TUI["tui
Terminal UI (deprecated)"] + end + + subgraph Config["config"] + CFG["AppConfig
defaults, config.toml / CONFIG_FILE,
legacy env names
"] end subgraph Application["Application Layer"] direction TB - CTX["AppContext
Repositories + Services"] + DEPS["Deps
one narrow deps struct per use case"] APP_PORTS["ReviewLogger
application-layer port"] subgraph UseCases["Use Cases"] UC_AUTH["auth
login, register"] @@ -33,7 +37,7 @@ graph TB EH_CLEANUP["SearchCleanupHandler"] EH_REINDEX["SearchReindexHandler"] EH_WRAPUP["WrapUpEventHandler"] - EH_AP["ActivityPubEventHandler
reviews, watchlist, goals,
UserDeleted, UserAccountMoved
"] + EH_POSTER["PosterSyncHandler
poster sync"] end subgraph Jobs["Periodic Jobs"] JOB_IMPORT["ImportSessionCleanup"] @@ -78,60 +82,66 @@ graph TB end subgraph Bootstrap["bootstrap"] - IW["build_server, build_worker,
DbPool, EventBusBackend
Composition root"] + BS["build_server, build_worker
Composition root: the only crate that names adapters"] + end + + subgraph EventPayload["event-payload"] + EP["Serde payloads for domain events"] end subgraph Adapters["Adapters (implement Port Traits)"] direction TB - A_COMMON["adapter-common
Shared row conversions,
error mapping, date utils
"] + subgraph Http["HTTP driving adapter"] + A_HTTP["http-axum
JSON API, classic HTML (askama),
OpenAPI, federation-gated routes,
one refusal shape
"] + end subgraph Storage["Storage"] - A_SQLITE["sqlite
SQLite repos"] - A_SQLITE_SEARCH["sqlite-search
FTS5"] + A_SQLITE["sqlite
repositories per aggregate, FTS5 search,
social graph, durable event queue, migrations
"] A_OBJ["object-storage
S3 / filesystem"] end subgraph Messaging["Messaging"] A_NATS["nats
JetStream / Core"] - A_SQLITE_QUEUE["sqlite-event-queue
Polling, dead-letter"] - A_EVT_PUB["event-publisher
In-memory (tests)"] - A_PAYLOAD["event-payload
Serde (de)serialization"] + A_EVT_PUB["event-publisher
In-memory channel"] end subgraph External["External Services"] - A_METADATA["metadata
TMDB search/details"] + A_METADATA["metadata
TMDB / OMDb search/details"] A_TMDB["tmdb-enrichment
Credits, keywords, cast"] A_POSTER["poster-fetcher
TMDB image download"] A_AUTH["auth
JWT tokens"] end subgraph Federation["Federation (feature-gated)"] - A_AP["activitypub
k_ap library"] - A_SQLITE_FED["sqlite-federation"] + A_AP["activitypub
k_ap library; ActivityPubEventHandler
(reviews, watchlist, goals, UserDeleted, UserAccountMoved)
"] + A_SQLITE_FED["sqlite-federation
k_ap repositories"] end subgraph Media["Media Processing"] A_IMG["image-converter
AVIF/WebP"] - A_POSTER_SYNC["poster-sync"] end - subgraph Presentation["Presentation Helpers"] + subgraph Formats["Formats"] A_RSS["rss
Feed generation"] A_EXPORT["export
CSV/JSON diary"] A_IMPORT["importer
CSV/JSON/XLSX parser"] - end - subgraph Webhooks["Webhook Parsers"] - A_JELLYFIN["jellyfin"] - A_PLEX["plex"] + A_MEDIA_SERVER["media-server
Jellyfin + Plex webhook parsers"] end end %% Dependency arrows - WEB -->|"uses"| Application - WEB -->|"maps to"| ApiTypes - WORKER -->|"uses"| Application + SERVER -->|"loads"| Config + SERVER -->|"calls"| Bootstrap + WORKER -->|"loads"| Config + WORKER -->|"calls"| Bootstrap + Bootstrap -->|"wires"| Adapters + Bootstrap -->|"builds deps for"| Application + A_HTTP -->|"uses"| Application + A_HTTP -->|"maps to"| ApiTypes + TUI -->|"typed API access"| ApiTypes Application -->|"depends on"| Domain UC_WRAPUP -->|"delegates to"| DS_WRAPUP Adapters -.->|"implements"| Ports + Messaging -.->|"serialises with"| EventPayload %% Key data flows - WEB ===|"HTTP"| DTO + A_HTTP ===|"HTTP"| DTO WORKER ===|"Events"| EVENTS classDef domain fill:#1a1a2e,stroke:#e94560,color:#fff @@ -145,4 +155,6 @@ graph TB class Application app class Adapters adapter class Binaries binary + class Config app + class Bootstrap binary class ApiTypes api diff --git a/crates/adapters/http-axum/src/api/social/tests.rs b/crates/adapters/http-axum/src/api/social/tests.rs index 0a196dcf..f078380f 100644 --- a/crates/adapters/http-axum/src/api/social/tests.rs +++ b/crates/adapters/http-axum/src/api/social/tests.rs @@ -1,15 +1,7 @@ use super::*; +use crate::test_support::local_actor; use domain::value_objects::{InstanceIdentity, SocialActor, SocialIdentity, UserId}; -fn local_actor(uid: &UserId) -> SocialActor { - SocialActor { - identity: SocialIdentity::Local(uid.clone()), - handle: "@gabriel@md.example".into(), - display_name: None, - avatar_url: None, - } -} - #[test] fn local_actor_dto_url_round_trips_back_to_the_same_identity() { let instance = InstanceIdentity::new("https://md.example"); diff --git a/crates/adapters/http-axum/src/federation/blocklist_api/tests.rs b/crates/adapters/http-axum/src/federation/blocklist_api/tests.rs index 900819de..2d1a98c7 100644 --- a/crates/adapters/http-axum/src/federation/blocklist_api/tests.rs +++ b/crates/adapters/http-axum/src/federation/blocklist_api/tests.rs @@ -1,14 +1,6 @@ use super::*; -use domain::value_objects::{InstanceIdentity, SocialActor, SocialIdentity, UserId}; - -fn local_actor(uid: &UserId) -> SocialActor { - SocialActor { - identity: SocialIdentity::Local(uid.clone()), - handle: "@gabriel@md.example".into(), - display_name: None, - avatar_url: None, - } -} +use crate::test_support::local_actor; +use domain::value_objects::{InstanceIdentity, SocialIdentity, UserId}; #[test] fn local_blocked_dto_url_round_trips_back_to_the_same_identity() { diff --git a/crates/adapters/http-axum/src/html/social/tests.rs b/crates/adapters/http-axum/src/html/social/tests.rs index 9ef45c28..e03ca953 100644 --- a/crates/adapters/http-axum/src/html/social/tests.rs +++ b/crates/adapters/http-axum/src/html/social/tests.rs @@ -1,14 +1,6 @@ use super::*; -use domain::value_objects::{InstanceIdentity, SocialActor, SocialIdentity, UserId}; - -fn local_actor(uid: &UserId) -> SocialActor { - SocialActor { - identity: SocialIdentity::Local(uid.clone()), - handle: "@gabriel@md.example".into(), - display_name: None, - avatar_url: None, - } -} +use crate::test_support::local_actor; +use domain::value_objects::{InstanceIdentity, SocialIdentity, UserId}; #[test] fn local_template_url_round_trips_back_to_the_same_identity() { diff --git a/crates/adapters/http-axum/src/lib.rs b/crates/adapters/http-axum/src/lib.rs index 2dd8736a..6afdf869 100644 --- a/crates/adapters/http-axum/src/lib.rs +++ b/crates/adapters/http-axum/src/lib.rs @@ -8,3 +8,5 @@ pub mod openapi; pub mod router; pub mod state; mod support; +#[cfg(test)] +mod test_support; diff --git a/crates/adapters/http-axum/src/test_support.rs b/crates/adapters/http-axum/src/test_support.rs new file mode 100644 index 00000000..d92cac11 --- /dev/null +++ b/crates/adapters/http-axum/src/test_support.rs @@ -0,0 +1,12 @@ +//! Fixtures shared by the in-crate mapper tests. + +use domain::value_objects::{SocialActor, SocialIdentity, UserId}; + +pub(crate) fn local_actor(uid: &UserId) -> SocialActor { + SocialActor { + identity: SocialIdentity::Local(uid.clone()), + handle: "@gabriel@md.example".into(), + display_name: None, + avatar_url: None, + } +} diff --git a/crates/application/src/deps.rs b/crates/application/src/deps.rs index 969e3f8c..d5233cf2 100644 --- a/crates/application/src/deps.rs +++ b/crates/application/src/deps.rs @@ -150,7 +150,7 @@ pub struct WrapupGroup { pub list_wrapups: ListWrapUpsDeps, } -/// Every deps struct a handler can need, built once by the composition root. +/// Every deps struct a handler can need, built once by `bootstrap`. /// Use cases still receive only their own narrow struct — nothing takes `&Deps`. /// /// `bootstrap::build_deps` is called only from `bootstrap::build_server`. diff --git a/crates/application/src/import/apply_profile_and_map.rs b/crates/application/src/import/apply_profile_and_map.rs index 09ec468f..a4512322 100644 --- a/crates/application/src/import/apply_profile_and_map.rs +++ b/crates/application/src/import/apply_profile_and_map.rs @@ -1,4 +1,4 @@ -//! Absorbs `handlers/import.rs::api_apply_profile`'s three-step orchestration: +//! Absorbs `http-axum/src/api/import/sessions.rs::api_apply_profile`'s three-step orchestration: //! apply the saved profile's field mappings onto the session, reload the //! session to read back the mappings `apply_profile` just wrote, then run //! `apply_mapping` to regenerate `row_results` from them. All three steps used diff --git a/crates/application/src/import/get_session_state.rs b/crates/application/src/import/get_session_state.rs index 241d35d8..7423951d 100644 --- a/crates/application/src/import/get_session_state.rs +++ b/crates/application/src/import/get_session_state.rs @@ -1,4 +1,4 @@ -//! Backs `handlers/import.rs::api_get_session` — a plain state query, not a +//! Backs `http-axum/src/api/import/sessions.rs::api_get_session` — a plain state query, not a //! redirect-driving gate (the API has nothing to redirect to; a missing //! session is just a 404). diff --git a/crates/application/src/movies/mod.rs b/crates/application/src/movies/mod.rs index 14104074..36287631 100644 --- a/crates/application/src/movies/mod.rs +++ b/crates/application/src/movies/mod.rs @@ -6,7 +6,7 @@ pub mod event_handler; pub mod get_movie_profile; pub mod get_movies; pub mod merge_duplicates; -pub mod poster_sync; +pub mod poster_sync_handler; pub mod queries; pub mod reindex_search; pub mod request_enrichment; diff --git a/crates/application/src/users/get_federated_profile_stats.rs b/crates/application/src/users/get_federated_profile_stats.rs index 913c927e..aebe69d3 100644 --- a/crates/application/src/users/get_federated_profile_stats.rs +++ b/crates/application/src/users/get_federated_profile_stats.rs @@ -1,5 +1,5 @@ //! The federated half of the former `get_profile` (ADR-0004's closed wart). -//! `build_federated_profile_response` (`handlers/users.rs`) calls this with a +//! `build_federated_profile_response` (`http-axum/src/api/user_profile.rs`) calls this with a //! synthetic `user_id` that has no row in the local `users` table — a federated //! actor's identity is built entirely from the resolved `FederatedProfile`, so //! this type carries no `identity` field at all: the old `get_profile` computed diff --git a/crates/application/src/users/get_page_viewer.rs b/crates/application/src/users/get_page_viewer.rs index 64927bf6..483ec281 100644 --- a/crates/application/src/users/get_page_viewer.rs +++ b/crates/application/src/users/get_page_viewer.rs @@ -1,10 +1,10 @@ //! Backs page-chrome data: the nav bar's email/admin state and pending-follow -//! badge. Absorbs `handlers/helpers.rs::build_page_context`'s inline user lookup +//! badge. Absorbs `html/helpers.rs::build_page_context`'s inline user lookup //! and pending-follower count. //! //! **Error policy (deliberately no internal tolerance):** this function //! propagates every error from `user` and `follow_graph` with `?`. It does not -//! itself decide to degrade — that's a presentation-layer policy +//! itself decide to degrade — that's a http-adapter policy //! (`build_page_context` logs a warning and falls back to a zeroed `PageViewer` //! on `Err`). Page chrome degrading is a rendering decision, not a domain one. diff --git a/crates/application/src/users/resolve_username_to_id.rs b/crates/application/src/users/resolve_username_to_id.rs index 7f3421cb..0888879c 100644 --- a/crates/application/src/users/resolve_username_to_id.rs +++ b/crates/application/src/users/resolve_username_to_id.rs @@ -1,4 +1,4 @@ -//! Absorbs `handlers/users.rs::get_user_by_username`'s +//! Absorbs `http-axum/src/html/users.rs::get_user_by_username`'s //! `repos.user.find_by_username()` call. `None` means "no such username" — the //! handler turns that into a 404, same as it always has; this is not an error //! condition. diff --git a/crates/bootstrap/src/worker.rs b/crates/bootstrap/src/worker.rs index 5d9c244f..7313feda 100644 --- a/crates/bootstrap/src/worker.rs +++ b/crates/bootstrap/src/worker.rs @@ -184,15 +184,17 @@ async fn assemble( // ── Event handlers ──────────────────────────────────────────────────────── let handlers: Vec> = { - let poster = Arc::new(application::movies::poster_sync::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; + 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; let cleanup = Arc::new(object_storage::ImageCleanupHandler::new(Arc::clone( &object_storage, diff --git a/docs/adr/0007-no-repositories-in-presentation.md b/docs/adr/0007-no-repositories-in-presentation.md index c7a251bc..d4c6a165 100644 --- a/docs/adr/0007-no-repositories-in-presentation.md +++ b/docs/adr/0007-no-repositories-in-presentation.md @@ -146,3 +146,5 @@ naming the exact file and line, before reverting. - `CONTRIBUTING.md`'s architecture rules were updated in this same commit to stop describing use cases as taking `&AppContext` (a claim already false before this plan) and instead describe the `&FooDeps`-struct convention this ADR documents. + +Now at `crates/adapters/http-axum/src` (was `crates/presentation/src`); see [ADR-0014](0014-http-adapter-and-one-refusal-shape.md). diff --git a/docs/adr/0008-presentation-names-renderers-not-reachers.md b/docs/adr/0008-presentation-names-renderers-not-reachers.md index 572dfae2..946af264 100644 --- a/docs/adr/0008-presentation-names-renderers-not-reachers.md +++ b/docs/adr/0008-presentation-names-renderers-not-reachers.md @@ -263,3 +263,5 @@ recorded under Known follow-ups below, which is also pre-existing. --- Superseded in part by [ADR-0012](0012-sqlite-is-the-only-backend.md): the postgres backends, crates and features mentioned above no longer exist; SQLite is the only backend. + +Now `make check-http-adapter-deps` over `crates/adapters/http-axum/Cargo.toml` (was `check-presentation-adapters`); see [ADR-0014](0014-http-adapter-and-one-refusal-shape.md). diff --git a/docs/adr/0009-federation-is-optional-at-the-dependency-level.md b/docs/adr/0009-federation-is-optional-at-the-dependency-level.md index f85f5f85..70665995 100644 --- a/docs/adr/0009-federation-is-optional-at-the-dependency-level.md +++ b/docs/adr/0009-federation-is-optional-at-the-dependency-level.md @@ -369,3 +369,5 @@ still fails with the same two pre-existing errors. This branch neither fixed nor --- Superseded in part by [ADR-0012](0012-sqlite-is-the-only-backend.md): the postgres backends, crates and features mentioned above no longer exist; SQLite is the only backend. + +Now `make check-sqlite-is-ap-free` (was `check-social-crates-are-ap-free`); the sqlite social crates merged into `crates/adapters/sqlite`. -- 2.49.1 From 7c6dff7f709040eda2f705579fb381cc2dea0689 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 23:07:14 +0100 Subject: [PATCH 044/104] docs: fix config table, test-location claims --- CODE_STYLE.md | 9 +++++- CONTRIBUTING.md | 2 +- Makefile | 4 +-- README.md | 32 +++++++++---------- architecture.mmd | 3 +- .../application/src/users/get_page_viewer.rs | 2 +- 6 files changed, 30 insertions(+), 22 deletions(-) diff --git a/CODE_STYLE.md b/CODE_STYLE.md index 9d44b5d2..b0f021d2 100644 --- a/CODE_STYLE.md +++ b/CODE_STYLE.md @@ -56,7 +56,14 @@ crates/application/tests/diary/log_review_test.rs crates/application/tests/support/ # shared contexts and command builders ``` -Adapters follow the same layout (`crates/adapters/sqlite/tests/repositories/diary_repository_test.rs`). The only tests left beside source are small mapper tests in `crates/adapters/http-axum/src/**/tests.rs`, which need crate-private items; their shared fixtures live in `http-axum/src/test_support.rs`. Route smoke, API and handler tests run against the real composition and live in `crates/bootstrap/tests/`. +Adapters follow the same layout (`crates/adapters/sqlite/tests/repositories/diary_repository_test.rs`). Route smoke, API and handler tests run against the real composition and live in `crates/bootstrap/tests/`; http-axum also has integration tests in `crates/adapters/http-axum/tests/` (`log_review_form_test.rs`, `openapi_contract_test.rs`). + +Inline `#[cfg(test)]` tests remain only where they must reach private items: + +- `crates/adapters/http-axum`: the `api/social`, `federation/blocklist_api` and `html/social` mapper tests (`tests.rs` beside each; shared fixtures in `src/test_support.rs`) and the rate-limit tests in `router.rs` +- `crates/application/src/users/diary_filter.rs` +- `crates/adapters/nats/src/subject.rs` +- `crates/tui/src/client.rs` ## Naming diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f3ba768..f4884241 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,7 +69,7 @@ Cargo features: `federation` (default; ActivityPub) and `nats`. `sqlite` and `sq ### Tests -Tests live in `crates//tests/`, never in the production file (see `CODE_STYLE.md`). Build fixtures with the `domain::testing` factories and use the cascading `InMemoryStore` and `Refusing*` fakes. Route smoke, API and handler tests over the real wiring are in `crates/bootstrap/tests/`. +Tests live in `crates//tests/`, not in the production file. A few inline tests remain where they need private items; `CODE_STYLE.md` lists them. Build fixtures with the `domain::testing` factories and use the cascading `InMemoryStore` and `Refusing*` fakes. Route smoke, API and handler tests over the real wiring are in `crates/bootstrap/tests/`. ### Database adapters diff --git a/Makefile b/Makefile index b018db21..3479d34c 100644 --- a/Makefile +++ b/Makefile @@ -92,10 +92,10 @@ check-dockerfile-manifests: copied=$$(sed -n 's/^COPY \([^[:space:]]*\)\/Cargo\.toml[[:space:]].*/\1/p' Dockerfile | grep -v '^\(Cargo\.toml\|\.\)$$' || true); \ bad=""; \ for m in $$members; do \ - echo "$$copied" | grep -qx "$$m" || bad="$$bad missing-COPY:$$m"; \ + echo "$$copied" | grep -qxF "$$m" || bad="$$bad missing-COPY:$$m"; \ done; \ for c in $$copied; do \ - echo "$$members" | grep -qx "$$c" || bad="$$bad not-a-member:$$c"; \ + echo "$$members" | grep -qxF "$$c" || bad="$$bad not-a-member:$$c"; \ done; \ if [ -n "$$bad" ]; then \ echo "❌ Dockerfile manifest COPY lines out of sync with workspace members:$$bad"; \ diff --git a/README.md b/README.md index 1c43fac4..6df55ec0 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ config — typed configuration (defaults, config.toml, legacy env n bootstrap — composition root: the only crate that names adapters; builds the server router and the worker service event-payload — serde payloads for domain events, shared by the NATS and SQLite-queue adapters domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, SocialCommand/SocialQuery, ImageFetcher, RssFeedRenderer), no external deps except serde -application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic; modules: auth, diary, goals, import, integrations, movies, person, search, social, users, watchlist, wrapup +application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic; modules: auth, diary, goals, import, insights, integrations, jobs, movies, person, search, social, users, watchlist, wrapup server — thin HTTP binary (dotenv, config, bootstrap::build_server, axum::serve); features (federation, nats) forward to bootstrap worker — thin worker binary (bootstrap::build_worker, run until ctrl-c) adapters/ @@ -134,35 +134,35 @@ Settings are layered, later sources winning: 2. `config.toml` in the working directory, or the file named by `CONFIG_FILE` (a named file that does not exist is an error; the default `config.toml` is optional) 3. environment variables (including those from `.env`), under the names in the table below -Copy `.env.example` to `.env` and set the values you need. Malformed values (a non-numeric `PORT`, an unknown `EVENT_BUS_BACKEND`) fail startup with a message naming the setting. An empty value (`PORT=`) counts as unset, so the default applies. The variable names are unchanged from earlier releases; every one maps to a `section.field` key in `config.toml` (for example `PORT` is `server.port`, `JWT_SECRET` is `auth.jwt_secret`). +Copy `.env.example` to `.env` and set the values you need. Malformed values (a non-numeric `PORT`, an unknown `EVENT_BUS_BACKEND`) fail startup with a message naming the setting. An empty value (`PORT=`) counts as unset, so the default applies. The variable names are unchanged from earlier releases; every one except `RUST_LOG` and `CONFIG_FILE` maps to a `section.field` key in `config.toml` (for example `PORT` is `server.port`, `JWT_SECRET` is `auth.jwt_secret`). | Variable | Default | Required | Description | |---|---|---|---| -| `DATABASE_URL` | `sqlite://movies.db` | Yes | SQLite connection string | -| `JWT_SECRET` | — | Yes | Secret for JWT signing — use a long random string | -| `OMDB_API_KEY` | — | Yes | [OMDb](https://www.omdbapi.com/apikey.aspx) key for movie metadata | -| `TMDB_API_KEY` | — | No | [TMDb](https://www.themoviedb.org/settings/api) key — enables cast, crew, genres, enrichment | -| `BASE_URL` | — | Yes | Public URL of your instance (used for ActivityPub actor URLs) | +| `DATABASE_URL` | — | Yes | SQLite connection string (e.g. `sqlite://movies.db`); the only value the config loader itself requires | +| `JWT_SECRET` | — | Server | Secret for JWT signing — use a long random string. The server refuses to start without it (the worker does not need it) | +| `OMDB_API_KEY` | — | One of the two | [OMDb](https://www.omdbapi.com/apikey.aspx) key for movie metadata. Server and worker refuse to start unless `TMDB_API_KEY` or `OMDB_API_KEY` is set | +| `TMDB_API_KEY` | — | One of the two | [TMDb](https://www.themoviedb.org/settings/api) key — used for metadata when set (takes precedence over OMDb); enables cast, crew, genres, enrichment | +| `BASE_URL` | `http://localhost:3000` | No | Public URL of your instance (used for ActivityPub actor URLs); set it for any real deployment | | `IMAGE_STORAGE_BACKEND` | `local` | No | `local` or `s3` | | `IMAGE_STORAGE_PATH` | `./images` | No | Path for local image storage | | `MINIO_ENDPOINT` | — | S3 only | S3-compatible endpoint (e.g. `http://localhost:9000`) | | `MINIO_BUCKET` | — | S3 only | Bucket name | -| `MINIO_REGION` | — | S3 only | Region (e.g. `minio`) | +| `MINIO_REGION` | `minio` | No | Region for S3 storage | | `MINIO_ACCESS_KEY_ID` | — | S3 only | Access key ID | | `MINIO_SECRET_ACCESS_KEY` | — | S3 only | Secret access key | -| `IMAGE_CONVERSION_ENABLED` | `false` | No | Convert stored images to AVIF or WebP | -| `IMAGE_CONVERSION_FORMAT` | `avif` | No | `avif` or `webp` | +| `IMAGE_CONVERSION_ENABLED` | `false` | No | Set `true` to convert stored images to AVIF or WebP (only the value `true` enables it) | +| `IMAGE_CONVERSION_FORMAT` | — | When conversion is enabled | `avif` or `webp`; startup fails if conversion is enabled without it | | `HOST` | `0.0.0.0` | No | Bind address | | `PORT` | `3000` | No | HTTP port | | `RATE_LIMIT` | `60` | No | Requests per minute per IP | -| `ALLOW_REGISTRATION` | `true` | No | Set `false` to disable new sign-ups | -| `SECURE_COOKIES` | `true` | No | Must be `true` when serving over HTTPS | -| `RUST_LOG` | — | No | Log verbosity (e.g. `server=info,worker=info`) | -| `CORS_ORIGINS` | `*` | No | Comma-separated allowed origins for SPA dev | +| `ALLOW_REGISTRATION` | `false` | No | Set `true` (or `1`) to allow new sign-ups | +| `SECURE_COOKIES` | `false` | No | Set `true` when serving over HTTPS (only the value `true` enables it) | +| `RUST_LOG` | — | No | Log verbosity (e.g. `server=info,worker=info`); read by the logger, not part of `config.toml` | +| `CORS_ORIGINS` | empty | No | Comma-separated allowed origins for the API; empty or `*` allows any origin | | `EVENT_BUS_BACKEND` | `db` | No | `db` (default) or `nats` | | `NATS_URL` | — | NATS only | NATS connection URL (e.g. `nats://localhost:4222`) | -Required fields must be set (in either source) before the server will start. +`DATABASE_URL` is the only setting the loader requires; the other "required" entries are enforced when the corresponding adapter is built, and failing there also stops startup. Further optional variables (`CONFIG_FILE`, `JWT_TTL_SECONDS`, `REFRESH_TTL_SECONDS`, `POSTER_FETCH_TIMEOUT_SECONDS`, `EVENT_CHANNEL_BUFFER`, `EVENT_QUEUE_*`, `NATS_MODE`, `NATS_STREAM_NAME`, `NATS_SUBJECT_PREFIX`, `NATS_CONSUMER_NAME`, `FEDERATION_DEBUG`, `WRAPUP_*`) are read by `crates/config`; see `crates/config/src/` for defaults. ### Cargo features @@ -245,7 +245,7 @@ cargo test -p bootstrap # route smoke, API and handler tests over the real cargo llvm-cov -p application -p domain # line coverage report (requires cargo-llvm-cov) ``` -Each crate keeps its tests in `crates//tests/`. The `application` and `domain` crates have 400+ tests covering all use case modules (auth, diary, goals, import, integrations, movies, person, search, users, watchlist, wrapup) backed by the in-memory fakes and factories in `domain::testing` (the `test-helpers` feature). They are the fastest feedback loop for business logic changes. +Tests live in `crates//tests/`; the exceptions are listed in `CODE_STYLE.md`. The `application` and `domain` crates have 400+ tests covering all use case modules (auth, diary, goals, import, insights, integrations, movies, person, search, users, watchlist, wrapup) backed by the in-memory fakes and factories in `domain::testing` (the `test-helpers` feature). They are the fastest feedback loop for business logic changes. ## Docker diff --git a/architecture.mmd b/architecture.mmd index c12ede18..eff20264 100644 --- a/architecture.mmd +++ b/architecture.mmd @@ -138,7 +138,8 @@ graph TB UC_WRAPUP -->|"delegates to"| DS_WRAPUP Adapters -.->|"implements"| Ports - Messaging -.->|"serialises with"| EventPayload + A_NATS -.->|"serialises with"| EventPayload + A_SQLITE -.->|"event queue serialises with"| EventPayload %% Key data flows A_HTTP ===|"HTTP"| DTO diff --git a/crates/application/src/users/get_page_viewer.rs b/crates/application/src/users/get_page_viewer.rs index 483ec281..df3f8c6e 100644 --- a/crates/application/src/users/get_page_viewer.rs +++ b/crates/application/src/users/get_page_viewer.rs @@ -4,7 +4,7 @@ //! //! **Error policy (deliberately no internal tolerance):** this function //! propagates every error from `user` and `follow_graph` with `?`. It does not -//! itself decide to degrade — that's a http-adapter policy +//! itself decide to degrade — that's an http-adapter policy //! (`build_page_context` logs a warning and falls back to a zeroed `PageViewer` //! on `Err`). Page chrome degrading is a rendering decision, not a domain one. -- 2.49.1 From cb5ffe2797894ce478301bcd211d75828f589206 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 23:18:21 +0100 Subject: [PATCH 045/104] fix: log targets in image defaults and docs --- .env.example | 5 +++-- CODE_STYLE.md | 2 +- Dockerfile | 2 +- README.md | 4 ++-- crates/server/src/main.rs | 2 +- crates/worker/src/main.rs | 2 +- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index a859bf8b..49230c82 100644 --- a/.env.example +++ b/.env.example @@ -46,10 +46,11 @@ ALLOW_REGISTRATION=true # RATE_LIMIT=60 # SECURE_COOKIES=true # Handler-level logs come from the `http_axum` crate; startup/wiring logs come -# from `server`. Include both — `server=info` alone silently drops handler logs. +# from `bootstrap`; config-load logs come from `config`. Include all three — +# `server=info` alone silently drops them. # Crates not named here (adapters, etc.) still log warnings and errors unless # you set a bare level yourself (e.g. `info,http_axum=debug`). -# RUST_LOG=server=info,http_axum=info,tower_http=info,worker=info +# RUST_LOG=server=info,worker=info,bootstrap=info,config=info,http_axum=info,tower_http=info # CORS (for SPA development only) # CORS_ORIGINS=http://localhost:5173 diff --git a/CODE_STYLE.md b/CODE_STYLE.md index b0f021d2..73dea40b 100644 --- a/CODE_STYLE.md +++ b/CODE_STYLE.md @@ -47,7 +47,7 @@ Small files with a single concern. If a file grows past ~100-150 lines, it's pro ### Tests -Never in the same file as production code. Each crate keeps its tests in `crates//tests/`, one file per unit under test, grouped by the same module as the production code. Cargo builds each top-level `tests/.rs` as one integration-test binary; the group file lists its members with `#[path]`: +Tests live outside production files, apart from the few exceptions listed below. Each crate keeps its tests in `crates//tests/`, one file per unit under test, grouped by the same module as the production code. Cargo builds each top-level `tests/.rs` as one integration-test binary; the group file lists its members with `#[path]`: ``` crates/application/src/diary/log_review.rs diff --git a/Dockerfile b/Dockerfile index 1b8d79b2..e5401a81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -87,6 +87,6 @@ COPY --from=spa-builder /spa/dist ./spa/dist EXPOSE 3000 -ENV RUST_LOG=server=info,tower_http=info +ENV RUST_LOG=server=info,worker=info,bootstrap=info,config=info,http_axum=info,tower_http=info CMD ["./server"] diff --git a/README.md b/README.md index 6df55ec0..14a75adc 100644 --- a/README.md +++ b/README.md @@ -151,13 +151,13 @@ Copy `.env.example` to `.env` and set the values you need. Malformed values (a n | `MINIO_ACCESS_KEY_ID` | — | S3 only | Access key ID | | `MINIO_SECRET_ACCESS_KEY` | — | S3 only | Secret access key | | `IMAGE_CONVERSION_ENABLED` | `false` | No | Set `true` to convert stored images to AVIF or WebP (only the value `true` enables it) | -| `IMAGE_CONVERSION_FORMAT` | — | When conversion is enabled | `avif` or `webp`; startup fails if conversion is enabled without it | +| `IMAGE_CONVERSION_FORMAT` | — | When conversion is enabled | `avif` or `webp`; the worker fails at startup if conversion is enabled without it | | `HOST` | `0.0.0.0` | No | Bind address | | `PORT` | `3000` | No | HTTP port | | `RATE_LIMIT` | `60` | No | Requests per minute per IP | | `ALLOW_REGISTRATION` | `false` | No | Set `true` (or `1`) to allow new sign-ups | | `SECURE_COOKIES` | `false` | No | Set `true` when serving over HTTPS (only the value `true` enables it) | -| `RUST_LOG` | — | No | Log verbosity (e.g. `server=info,worker=info`); read by the logger, not part of `config.toml` | +| `RUST_LOG` | — | No | Log verbosity (e.g. `server=info,worker=info,bootstrap=info,config=info,http_axum=info`); read by the logger, not part of `config.toml` | | `CORS_ORIGINS` | empty | No | Comma-separated allowed origins for the API; empty or `*` allows any origin | | `EVENT_BUS_BACKEND` | `db` | No | `db` (default) or `nats` | | `NATS_URL` | — | NATS only | NATS connection URL (e.g. `nats://localhost:4222`) | diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 2b111c26..b5593b59 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -4,7 +4,7 @@ use tokio::net::TcpListener; #[tokio::main] async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); - bootstrap::init_tracing("server=debug,bootstrap=debug,tower_http=debug"); + bootstrap::init_tracing("server=debug,bootstrap=debug,config=info,tower_http=debug"); let config = config::load().context("Failed to load configuration")?; let app = bootstrap::build_server(&config).await?; diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 05c679fa..592476b4 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -3,7 +3,7 @@ use anyhow::Context; #[tokio::main] async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); - bootstrap::init_tracing("worker=info,bootstrap=info,application=info,k_ap=info"); + bootstrap::init_tracing("worker=info,bootstrap=info,config=info,application=info,k_ap=info"); let config = config::load().context("Failed to load configuration")?; let app = bootstrap::build_worker(&config).await?; -- 2.49.1 From 980de9697a3e2924e74954a20bd844c862792135 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 23:19:18 +0100 Subject: [PATCH 046/104] refactor: rename application AppConfig to AppSettings --- Cargo.lock | 12 ------------ crates/adapters/http-axum/src/context.rs | 4 ++-- crates/application/src/auth/deps.rs | 10 +++++----- crates/application/src/config.rs | 12 ++++++------ crates/application/src/diary/deps.rs | 4 ++-- .../tests/diary/get_activity_feed_test.rs | 6 +++--- crates/application/tests/support/context.rs | 10 +++++----- crates/bootstrap/src/build.rs | 2 +- crates/bootstrap/src/server.rs | 2 +- crates/bootstrap/tests/http_handlers/support.rs | 6 +++--- 10 files changed, 28 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eda8a741..0edd7148 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,7 +324,6 @@ dependencies = [ "futures", "hex", "rand 0.9.4", - "serde_json", "sha2", "tokio", "tracing", @@ -1910,9 +1909,7 @@ name = "export" version = "0.1.0" dependencies = [ "async-stream", - "async-trait", "bytes", - "chrono", "domain", "futures", "serde_json", @@ -2414,18 +2411,15 @@ dependencies = [ name = "http-axum" version = "0.1.0" dependencies = [ - "anyhow", "api-types", "application", "askama", - "async-trait", "axum", "axum-governor", "bytes", "chrono", "chrono-tz", "domain", - "dotenvy", "futures", "infer", "percent-encoding", @@ -2435,7 +2429,6 @@ dependencies = [ "tower", "tower-http", "tracing", - "tracing-subscriber", "utoipa", "utoipa-scalar", "utoipa-swagger-ui", @@ -3614,7 +3607,6 @@ dependencies = [ "config", "domain", "futures", - "infer", "object_store", "tokio", "tracing", @@ -5173,7 +5165,6 @@ dependencies = [ "anyhow", "async-stream", "async-trait", - "bytes", "chrono", "config", "domain", @@ -5782,15 +5773,12 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" name = "tmdb-enrichment" version = "0.1.0" dependencies = [ - "anyhow", "async-trait", "chrono", "config", "domain", "reqwest 0.13.3", "serde", - "serde_json", - "tracing", ] [[package]] diff --git a/crates/adapters/http-axum/src/context.rs b/crates/adapters/http-axum/src/context.rs index 11f7c6b3..d1fc24ba 100644 --- a/crates/adapters/http-axum/src/context.rs +++ b/crates/adapters/http-axum/src/context.rs @@ -1,12 +1,12 @@ use std::sync::Arc; -use application::config::AppConfig; +use application::config::AppSettings; #[derive(Clone)] pub struct AppContext { pub deps: Arc, pub services: application::Services, - pub config: AppConfig, + pub config: AppSettings, pub instance: domain::value_objects::InstanceIdentity, #[cfg(feature = "federation")] pub ap_document: Arc, diff --git a/crates/application/src/auth/deps.rs b/crates/application/src/auth/deps.rs index cd1c70cb..20a373e0 100644 --- a/crates/application/src/auth/deps.rs +++ b/crates/application/src/auth/deps.rs @@ -2,26 +2,26 @@ use std::sync::Arc; use domain::ports::{AuthService, PasswordHasher, RefreshSessionRepository, UserRepository}; -use crate::config::AppConfig; +use crate::config::AppSettings; pub struct LoginDeps { pub user: Arc, pub password_hasher: Arc, pub auth: Arc, pub refresh_session: Arc, - pub config: AppConfig, + pub config: AppSettings, } pub struct RegisterDeps { pub user: Arc, pub password_hasher: Arc, - pub config: AppConfig, + pub config: AppSettings, } pub struct RefreshDeps { pub refresh_session: Arc, pub auth: Arc, - pub config: AppConfig, + pub config: AppSettings, } pub struct RegisterAndLoginDeps { @@ -29,7 +29,7 @@ pub struct RegisterAndLoginDeps { pub password_hasher: Arc, pub auth: Arc, pub refresh_session: Arc, - pub config: AppConfig, + pub config: AppSettings, } pub struct LogoutDeps { diff --git a/crates/application/src/config.rs b/crates/application/src/config.rs index 101cad4b..9ea9cedf 100644 --- a/crates/application/src/config.rs +++ b/crates/application/src/config.rs @@ -1,22 +1,22 @@ #[derive(Clone)] -pub struct AppConfig { +pub struct AppSettings { pub allow_registration: bool, pub base_url: String, pub rate_limit: u64, pub refresh_ttl_seconds: u64, pub secure_cookies: bool, pub cors_origins: String, - pub wrapup: WrapUpConfig, + pub wrapup: WrapUpSettings, } #[derive(Clone)] -pub struct WrapUpConfig { +pub struct WrapUpSettings { pub font_path: Option, pub logo_path: Option, pub bg_dir: Option, } -impl From<&::config::AppConfig> for AppConfig { +impl From<&::config::AppConfig> for AppSettings { fn from(config: &::config::AppConfig) -> Self { Self { allow_registration: config.server.allow_registration, @@ -25,12 +25,12 @@ impl From<&::config::AppConfig> for AppConfig { refresh_ttl_seconds: config.auth.refresh_ttl_seconds, secure_cookies: config.server.secure_cookies, cors_origins: config.server.cors_origins.clone(), - wrapup: WrapUpConfig::from(&config.wrapup), + wrapup: WrapUpSettings::from(&config.wrapup), } } } -impl From<&::config::WrapUpConfig> for WrapUpConfig { +impl From<&::config::WrapUpConfig> for WrapUpSettings { fn from(config: &::config::WrapUpConfig) -> Self { Self { font_path: config.font_path.clone(), diff --git a/crates/application/src/diary/deps.rs b/crates/application/src/diary/deps.rs index 0309cd05..2f3d0995 100644 --- a/crates/application/src/diary/deps.rs +++ b/crates/application/src/diary/deps.rs @@ -5,7 +5,7 @@ use domain::ports::{ MovieProfileRepository, MovieQuery, ReviewRepository, UserRepository, }; -use crate::config::AppConfig; +use crate::config::AppSettings; pub struct DeleteReviewDeps { pub review: Arc, @@ -28,7 +28,7 @@ pub struct GetMovieSocialPageDeps { pub struct GetActivityFeedDeps { pub diary: Arc, pub social_query: Arc, - pub config: AppConfig, + pub config: AppSettings, } pub struct GetUserFeedDeps { diff --git a/crates/application/tests/diary/get_activity_feed_test.rs b/crates/application/tests/diary/get_activity_feed_test.rs index 4fe14907..5d53f440 100644 --- a/crates/application/tests/diary/get_activity_feed_test.rs +++ b/crates/application/tests/diary/get_activity_feed_test.rs @@ -7,7 +7,7 @@ use domain::testing::InMemoryStore; use domain::value_objects::{FollowRelation, SocialActor, SocialIdentity, UserId}; use application::{ - config::AppConfig, diary::deps::GetActivityFeedDeps, diary::get_activity_feed, + config::AppSettings, diary::deps::GetActivityFeedDeps, diary::get_activity_feed, diary::queries::GetActivityFeedQuery, }; @@ -125,14 +125,14 @@ async fn following_filter_separates_local_and_remote() { let deps = GetActivityFeedDeps { diary: domain::testing::FakeDiaryQuery::new() as _, social_query: social as _, - config: AppConfig { + config: AppSettings { allow_registration: true, base_url: "http://localhost:3000".into(), rate_limit: 20, refresh_ttl_seconds: 2_592_000, secure_cookies: false, cors_origins: String::new(), - wrapup: application::config::WrapUpConfig { + wrapup: application::config::WrapUpSettings { font_path: None, logo_path: None, bg_dir: None, diff --git a/crates/application/tests/support/context.rs b/crates/application/tests/support/context.rs index 1bc9d162..eccfaffb 100644 --- a/crates/application/tests/support/context.rs +++ b/crates/application/tests/support/context.rs @@ -24,7 +24,7 @@ use domain::{ use async_trait::async_trait; use domain::errors::DomainError; -use application::{config::AppConfig, diary::commands::LogReviewCommand, ports::ReviewLogger}; +use application::{config::AppSettings, diary::commands::LogReviewCommand, ports::ReviewLogger}; pub struct NoopReviewLogger; @@ -73,7 +73,7 @@ pub struct TestContextBuilder { pub block_query: Arc, pub federation_admin: Arc, pub refresh_session_repo: Arc, - pub config: AppConfig, + pub config: AppSettings, } impl Default for TestContextBuilder { @@ -123,14 +123,14 @@ impl TestContextBuilder { block_query: Arc::clone(&store) as _, federation_admin: Arc::new(NoopFederationAdminQuery), refresh_session_repo: Arc::clone(&store) as _, - config: AppConfig { + config: AppSettings { allow_registration: true, base_url: "http://localhost:3000".into(), rate_limit: 20, refresh_ttl_seconds: 2_592_000, secure_cookies: false, cors_origins: String::new(), - wrapup: application::config::WrapUpConfig { + wrapup: application::config::WrapUpSettings { font_path: None, logo_path: None, bg_dir: None, @@ -274,7 +274,7 @@ impl TestContextBuilder { self } - pub fn with_config(mut self, config: AppConfig) -> Self { + pub fn with_config(mut self, config: AppSettings) -> Self { self.config = config; self } diff --git a/crates/bootstrap/src/build.rs b/crates/bootstrap/src/build.rs index eb7b0e85..b4ee6116 100644 --- a/crates/bootstrap/src/build.rs +++ b/crates/bootstrap/src/build.rs @@ -52,7 +52,7 @@ use crate::{DatabaseOutput, Repositories}; pub fn build_deps( repos: &Repositories, services: &application::Services, - config: &application::config::AppConfig, + config: &application::config::AppSettings, instance: &InstanceIdentity, ) -> Deps { Deps { diff --git a/crates/bootstrap/src/server.rs b/crates/bootstrap/src/server.rs index dd58922f..919e97ca 100644 --- a/crates/bootstrap/src/server.rs +++ b/crates/bootstrap/src/server.rs @@ -2,7 +2,7 @@ use std::net::SocketAddr; use std::sync::Arc; use anyhow::Context; -use application::config::AppConfig as AppSettings; +use application::config::AppSettings; use domain::ports::{DiaryExporter, DocumentParser, EventPublisher}; use domain::value_objects::InstanceIdentity; use export::ExportAdapter; diff --git a/crates/bootstrap/tests/http_handlers/support.rs b/crates/bootstrap/tests/http_handlers/support.rs index 16b57e7a..5d4715d9 100644 --- a/crates/bootstrap/tests/http_handlers/support.rs +++ b/crates/bootstrap/tests/http_handlers/support.rs @@ -2,7 +2,7 @@ //! `bootstrap::build_deps`, with per-port overrides injected BEFORE the deps are built. #![allow(dead_code)] -use application::config::AppConfig; +use application::config::AppSettings; use bootstrap::Repositories; use domain::{ errors::DomainError, @@ -100,14 +100,14 @@ pub fn build_test_state_from( person_enrichment: None, }; - let config = AppConfig { + 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::WrapUpConfig { + wrapup: application::config::WrapUpSettings { font_path: None, logo_path: None, bg_dir: None, -- 2.49.1 From ec2760fd48dab74526307f76fdd62a0e6f053558 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 23:19:19 +0100 Subject: [PATCH 047/104] chore: drop unused deps --- crates/adapters/export/Cargo.toml | 2 -- crates/adapters/http-axum/Cargo.toml | 4 ---- crates/adapters/object-storage/Cargo.toml | 1 - crates/adapters/sqlite/Cargo.toml | 1 - crates/adapters/tmdb-enrichment/Cargo.toml | 3 --- crates/application/Cargo.toml | 1 - 6 files changed, 12 deletions(-) diff --git a/crates/adapters/export/Cargo.toml b/crates/adapters/export/Cargo.toml index 3259145a..4dbad682 100644 --- a/crates/adapters/export/Cargo.toml +++ b/crates/adapters/export/Cargo.toml @@ -5,9 +5,7 @@ edition.workspace = true [dependencies] domain = { workspace = true } -async-trait = { workspace = true } serde_json = { workspace = true } -chrono = { workspace = true } futures = { workspace = true } bytes = { workspace = true } async-stream = { workspace = true } diff --git a/crates/adapters/http-axum/Cargo.toml b/crates/adapters/http-axum/Cargo.toml index 45901788..7d77f876 100644 --- a/crates/adapters/http-axum/Cargo.toml +++ b/crates/adapters/http-axum/Cargo.toml @@ -19,15 +19,11 @@ axum-governor = "2" axum = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -anyhow = { workspace = true } tracing = { workspace = true } -tracing-subscriber = { workspace = true } tokio = { workspace = true } -dotenvy = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } chrono-tz = { workspace = true } -async-trait = { workspace = true } futures = { workspace = true } api-types = { workspace = true } diff --git a/crates/adapters/object-storage/Cargo.toml b/crates/adapters/object-storage/Cargo.toml index ec36d0c8..415433ab 100644 --- a/crates/adapters/object-storage/Cargo.toml +++ b/crates/adapters/object-storage/Cargo.toml @@ -12,7 +12,6 @@ tracing = { workspace = true } bytes = { workspace = true } futures = { workspace = true } object_store = { workspace = true } -infer = "0.19.0" [dev-dependencies] tokio = { workspace = true } diff --git a/crates/adapters/sqlite/Cargo.toml b/crates/adapters/sqlite/Cargo.toml index de6cd663..75840b2d 100644 --- a/crates/adapters/sqlite/Cargo.toml +++ b/crates/adapters/sqlite/Cargo.toml @@ -18,7 +18,6 @@ tracing = { workspace = true } async-trait = { workspace = true } tokio = { workspace = true } futures = { workspace = true } -bytes = { workspace = true } async-stream = { workspace = true } [dev-dependencies] diff --git a/crates/adapters/tmdb-enrichment/Cargo.toml b/crates/adapters/tmdb-enrichment/Cargo.toml index 7e640900..201a335d 100644 --- a/crates/adapters/tmdb-enrichment/Cargo.toml +++ b/crates/adapters/tmdb-enrichment/Cargo.toml @@ -8,8 +8,5 @@ config = { workspace = true } domain = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } -anyhow = { workspace = true } async-trait = { workspace = true } -tracing = { workspace = true } chrono = { workspace = true } diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index f6366262..a39da2ac 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -15,7 +15,6 @@ tokio = { workspace = true } sha2 = { workspace = true } rand = { workspace = true } hex = { workspace = true } -serde_json = { workspace = true } bytes = { workspace = true } [features] -- 2.49.1 From b7e69b69dc6f36a5299b309f9e54f6b5efbc43a2 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 23:25:16 +0100 Subject: [PATCH 048/104] refactor: drop postgres leftovers, narrow bootstrap api --- crates/adapters/http-axum/src/errors.rs | 1 - .../adapters/http-axum/src/extractors/json.rs | 11 +- crates/adapters/sqlite/src/event_queue/mod.rs | 10 +- .../tests/diary/edit_review_test.rs | 2 +- crates/application/tests/users.rs | 2 + .../tests/users/delete_account_test.rs | 87 +++++++++++++++ crates/bootstrap/Cargo.toml | 2 +- crates/bootstrap/src/build.rs | 2 +- crates/bootstrap/src/database.rs | 29 ++--- crates/bootstrap/src/event_bus.rs | 20 +--- crates/bootstrap/src/federation.rs | 8 +- crates/bootstrap/src/lib.rs | 8 +- crates/bootstrap/src/worker.rs | 2 +- .../tests/config_propagation_test.rs | 103 ++++++++++++++++++ .../tests/http_handlers/extractors_test.rs | 46 ++++++++ ...0014-http-adapter-and-one-refusal-shape.md | 2 +- 16 files changed, 277 insertions(+), 58 deletions(-) create mode 100644 crates/application/tests/users/delete_account_test.rs create mode 100644 crates/bootstrap/tests/config_propagation_test.rs diff --git a/crates/adapters/http-axum/src/errors.rs b/crates/adapters/http-axum/src/errors.rs index a212053a..d4ed68ce 100644 --- a/crates/adapters/http-axum/src/errors.rs +++ b/crates/adapters/http-axum/src/errors.rs @@ -7,7 +7,6 @@ use domain::errors::DomainError; pub const NOT_FOUND: &str = "NOT_FOUND"; pub const INVALID_INPUT: &str = "INVALID_INPUT"; -pub const CONFLICT: &str = "CONFLICT"; pub const UNAUTHORIZED: &str = "UNAUTHORIZED"; pub const FORBIDDEN: &str = "FORBIDDEN"; pub const RATE_LIMITED: &str = "RATE_LIMITED"; diff --git a/crates/adapters/http-axum/src/extractors/json.rs b/crates/adapters/http-axum/src/extractors/json.rs index 9974ec8d..27c8d994 100644 --- a/crates/adapters/http-axum/src/extractors/json.rs +++ b/crates/adapters/http-axum/src/extractors/json.rs @@ -11,10 +11,12 @@ use axum::{ }; use serde::de::DeserializeOwned; -use crate::errors::{INVALID_INPUT, refuse}; +use crate::errors::{INTERNAL, INVALID_INPUT, refuse}; /// Malformed request (bad JSON, query or path), refused as `INVALID_INPUT`. /// Status follows axum: 400 syntax, 415 content type, 422 data errors, 413 size. +/// A rejection with a 5xx status is a server-side fault (e.g. a route that +/// declares no path parameter), refused as `INTERNAL`. pub struct MalformedRequest { status: StatusCode, message: String, @@ -22,7 +24,12 @@ pub struct MalformedRequest { impl IntoResponse for MalformedRequest { fn into_response(self) -> Response { - refuse(self.status, INVALID_INPUT, self.message) + let code = if self.status.is_server_error() { + INTERNAL + } else { + INVALID_INPUT + }; + refuse(self.status, code, self.message) } } diff --git a/crates/adapters/sqlite/src/event_queue/mod.rs b/crates/adapters/sqlite/src/event_queue/mod.rs index 8479ac8a..18f260df 100644 --- a/crates/adapters/sqlite/src/event_queue/mod.rs +++ b/crates/adapters/sqlite/src/event_queue/mod.rs @@ -38,18 +38,18 @@ pub struct SqliteEventQueue { } impl SqliteEventQueue { - pub async fn create(pool: SqlitePool, config: DbEventQueueConfig) -> anyhow::Result { - Ok(Self { + pub fn create(pool: SqlitePool, config: DbEventQueueConfig) -> Self { + Self { pool, config: Arc::new(config), - }) + } } pub async fn create_publisher( pool: SqlitePool, config: &config::EventsConfig, ) -> anyhow::Result> { - let q = Self::create(pool, DbEventQueueConfig::from_config(config)).await?; + let q = Self::create(pool, DbEventQueueConfig::from_config(config)); Ok(Arc::new(q)) } @@ -57,7 +57,7 @@ impl SqliteEventQueue { pool: SqlitePool, config: &config::EventsConfig, ) -> anyhow::Result<(Arc, Arc)> { - let q = Self::create(pool, DbEventQueueConfig::from_config(config)).await?; + let q = Self::create(pool, DbEventQueueConfig::from_config(config)); Ok((Arc::new(q.clone()), Arc::new(q))) } } diff --git a/crates/application/tests/diary/edit_review_test.rs b/crates/application/tests/diary/edit_review_test.rs index da78fd4c..09b0d1b1 100644 --- a/crates/application/tests/diary/edit_review_test.rs +++ b/crates/application/tests/diary/edit_review_test.rs @@ -131,7 +131,7 @@ async fn edit_sets_watch_medium() { } #[tokio::test] -async fn editing_a_review_fails_when_the_store_refuses() { +async fn editing_a_review_fails_when_the_store_refuses_to_load_it() { let (_, events, review_id, user_id) = setup().await; let deps = EditReviewDeps { review: Arc::new(domain::testing::RefusingReviewRepository), diff --git a/crates/application/tests/users.rs b/crates/application/tests/users.rs index dfcc27c1..59c516c3 100644 --- a/crates/application/tests/users.rs +++ b/crates/application/tests/users.rs @@ -2,6 +2,8 @@ mod support; #[path = "users/authorize_admin_test.rs"] mod authorize_admin_test; +#[path = "users/delete_account_test.rs"] +mod delete_account_test; #[path = "users/get_current_profile_test.rs"] mod get_current_profile_test; #[path = "users/get_federated_profile_stats_test.rs"] diff --git a/crates/application/tests/users/delete_account_test.rs b/crates/application/tests/users/delete_account_test.rs new file mode 100644 index 00000000..d65e7e44 --- /dev/null +++ b/crates/application/tests/users/delete_account_test.rs @@ -0,0 +1,87 @@ +use crate::support::{TestContextBuilder, register_command}; +use std::sync::Arc; + +use domain::{ + errors::DomainError, + events::DomainEvent, + ports::UserRepository, + testing::{InMemoryStore, NoopEventPublisher, RefusingEventPublisher}, + value_objects::{Email, UserId}, +}; +use uuid::Uuid; + +use application::{ + auth::{deps::RegisterDeps, register}, + users::{delete_account, deps::DeleteAccountDeps}, +}; + +async fn register_user(b: &TestContextBuilder, users: &Arc) -> Uuid { + let reg_deps = RegisterDeps { + user: b.user_repo.clone(), + password_hasher: b.password_hasher.clone(), + config: b.config.clone(), + }; + register::execute(®_deps, register_command("alice@example.com", "alice")) + .await + .unwrap(); + users + .find_by_email(&Email::new("alice@example.com".into()).unwrap()) + .await + .unwrap() + .unwrap() + .id() + .value() +} + +#[tokio::test] +async fn deleting_an_unknown_user_is_not_found() { + let events = NoopEventPublisher::new(); + let b = TestContextBuilder::new(); + let deps = DeleteAccountDeps { + user: b.user_repo.clone(), + event_publisher: Arc::clone(&events) as _, + }; + + let err = delete_account::execute(&deps, Uuid::new_v4()) + .await + .unwrap_err(); + + assert!(matches!(err, DomainError::NotFound(_))); + assert!(events.published().is_empty()); +} + +#[tokio::test] +async fn deleting_an_existing_user_publishes_user_deleted() { + let users = InMemoryStore::new(); + let events = NoopEventPublisher::new(); + let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); + let deps = DeleteAccountDeps { + user: b.user_repo.clone(), + event_publisher: Arc::clone(&events) as _, + }; + let uid = register_user(&b, &users).await; + + delete_account::execute(&deps, uid).await.unwrap(); + + let published = events.published(); + assert!( + matches!( + published.as_slice(), + [DomainEvent::UserDeleted { user_id }] if *user_id == UserId::from_uuid(uid) + ), + "published: {published:?}" + ); +} + +#[tokio::test] +async fn deleting_fails_when_the_publisher_refuses() { + let users = InMemoryStore::new(); + let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); + let deps = DeleteAccountDeps { + user: b.user_repo.clone(), + event_publisher: Arc::new(RefusingEventPublisher), + }; + let uid = register_user(&b, &users).await; + + assert!(delete_account::execute(&deps, uid).await.is_err()); +} diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index 35303588..f580ea8e 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -17,7 +17,7 @@ federation = [ config = { workspace = true } domain = { workspace = true } application = { workspace = true } -http-axum = { path = "../adapters/http-axum", default-features = false } +http-axum = { workspace = true, default-features = false } auth = { workspace = true } metadata = { workspace = true } poster-fetcher = { workspace = true } diff --git a/crates/bootstrap/src/build.rs b/crates/bootstrap/src/build.rs index b4ee6116..4fdde3d9 100644 --- a/crates/bootstrap/src/build.rs +++ b/crates/bootstrap/src/build.rs @@ -44,7 +44,7 @@ use application::wrapup::deps::{ }; use domain::value_objects::InstanceIdentity; -use crate::{DatabaseOutput, Repositories}; +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 diff --git a/crates/bootstrap/src/database.rs b/crates/bootstrap/src/database.rs index bad5da5e..89ef2cce 100644 --- a/crates/bootstrap/src/database.rs +++ b/crates/bootstrap/src/database.rs @@ -2,16 +2,11 @@ use std::sync::Arc; use anyhow::Context; use domain::ports::{ - AuthService, ImageRefCommand, ImageRefQuery, LocalApContentQuery, MetadataClient, - MovieDeduplicator, ObjectStorage, PasswordHasher, PosterFetcherClient, - RefreshSessionRepository, UserProfileFieldsRepository, WatchEventCommand, WatchEventQuery, - WebhookTokenRepository, + AuthService, ImageRefCommand, ImageRefQuery, MetadataClient, MovieDeduplicator, ObjectStorage, + PasswordHasher, PosterFetcherClient, RefreshSessionRepository, UserProfileFieldsRepository, + WatchEventCommand, WatchEventQuery, WebhookTokenRepository, }; -pub enum DbPool { - Sqlite(sqlx::SqlitePool), -} - pub struct DatabaseOutput { pub movie_command: Arc, pub movie_query: Arc, @@ -31,13 +26,15 @@ pub struct DatabaseOutput { pub search_port: Arc, pub search_command: Arc, pub profile_fields: Arc, - pub ap_content: Arc, + #[cfg(feature = "federation")] + pub ap_content: Arc, pub wrapup_stats: Arc, pub wrapup_repo: Arc, pub insights: Arc, pub goal_command: Arc, pub goal_query: Arc, pub user_settings: Arc, + #[cfg(feature = "federation")] pub federation_settings: std::sync::Arc, pub remote_goal: Arc, pub refresh_session: Arc, @@ -46,7 +43,7 @@ pub struct DatabaseOutput { pub image_ref_query: Arc, pub follow_command: Arc, pub follow_query: Arc, - pub db_pool: DbPool, + pub db_pool: sqlx::SqlitePool, } pub async fn build_database_adapters( @@ -105,6 +102,7 @@ fn assemble( 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, @@ -112,6 +110,7 @@ fn assemble( 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 _, @@ -120,7 +119,7 @@ fn assemble( image_ref_query, follow_command: Arc::clone(&social) as _, follow_query: social as _, - db_pool: DbPool::Sqlite(w.pool), + db_pool: w.pool, }) } @@ -148,14 +147,6 @@ pub fn build_object_storage( object_storage::create(config) } -pub fn build_profile_fields_repo( - pool: &DbPool, -) -> anyhow::Result> { - match pool { - DbPool::Sqlite(pool) => Ok(sqlite::create_profile_fields_repo(pool.clone())), - } -} - pub fn warn_if_database_backend_set() { if let Ok(v) = std::env::var("DATABASE_BACKEND") && v != "sqlite" diff --git a/crates/bootstrap/src/event_bus.rs b/crates/bootstrap/src/event_bus.rs index b4ace78a..222832d2 100644 --- a/crates/bootstrap/src/event_bus.rs +++ b/crates/bootstrap/src/event_bus.rs @@ -4,8 +4,6 @@ use std::sync::Arc; use anyhow::Context; use domain::ports::{EventConsumer, EventPublisher}; -use crate::database::DbPool; - #[derive(Clone, Copy)] pub enum EventBusBackend { Db, @@ -29,17 +27,13 @@ impl EventBusBackend { } pub async fn create_publisher( - db_pool: &DbPool, + db_pool: &sqlx::SqlitePool, config: &config::AppConfig, ) -> anyhow::Result> { match EventBusBackend::from_config(&config.events)? { EventBusBackend::Db => { tracing::info!("event bus: DB queue"); - Ok(match db_pool { - DbPool::Sqlite(pool) => { - sqlite::SqliteEventQueue::create_publisher(pool.clone(), &config.events).await? - } - }) + Ok(sqlite::SqliteEventQueue::create_publisher(db_pool.clone(), &config.events).await?) } #[cfg(feature = "nats")] EventBusBackend::Nats => { @@ -52,19 +46,13 @@ pub async fn create_publisher( } pub async fn create_channel( - db_pool: &DbPool, + db_pool: &sqlx::SqlitePool, config: &config::AppConfig, ) -> anyhow::Result<(Arc, Arc)> { match EventBusBackend::from_config(&config.events)? { EventBusBackend::Db => { tracing::info!("event bus: DB queue"); - match db_pool { - DbPool::Sqlite(pool) => Ok(sqlite::SqliteEventQueue::create_channel( - pool.clone(), - &config.events, - ) - .await?), - } + Ok(sqlite::SqliteEventQueue::create_channel(db_pool.clone(), &config.events).await?) } #[cfg(feature = "nats")] EventBusBackend::Nats => { diff --git a/crates/bootstrap/src/federation.rs b/crates/bootstrap/src/federation.rs index 941b9c9d..7517a38f 100644 --- a/crates/bootstrap/src/federation.rs +++ b/crates/bootstrap/src/federation.rs @@ -64,8 +64,7 @@ pub async fn wire_server_social( config: &config::AppConfig, event_publisher: &Arc, ) -> anyhow::Result { - let crate::database::DbPool::Sqlite(pool) = &db.db_pool; - let fed_repos = sqlite_federation::wire(pool.clone(), instance.clone()); + let fed_repos = sqlite_federation::wire(db.db_pool.clone(), instance.clone()); let ap = activitypub::wire(activitypub::ActivityPubDeps { activity_repo: fed_repos.activity, @@ -117,7 +116,7 @@ pub async fn wire_server_social( remote_watchlist: fed_repos.remote_watchlist, federation_admin: fed_repos.admin_query, federated_profile: Some(sqlite::create_federated_profile_query( - pool.clone(), + db.db_pool.clone(), instance.clone(), )), }) @@ -132,8 +131,7 @@ pub async fn wire_worker_handlers( config: &config::AppConfig, event_publisher: &Arc, ) -> anyhow::Result>> { - let crate::database::DbPool::Sqlite(pool) = &db.db_pool; - let fed_repos = sqlite_federation::wire(pool.clone(), instance.clone()); + let fed_repos = sqlite_federation::wire(db.db_pool.clone(), instance.clone()); let ap_wire = activitypub::wire(activitypub::ActivityPubDeps { activity_repo: fed_repos.activity, diff --git a/crates/bootstrap/src/lib.rs b/crates/bootstrap/src/lib.rs index 5ac674ef..58711843 100644 --- a/crates/bootstrap/src/lib.rs +++ b/crates/bootstrap/src/lib.rs @@ -4,8 +4,8 @@ //! [`build_worker`]; everything between configuration and a running service is //! assembled here (ADR-0005, ADR-0013). -pub mod build; -pub mod database; +mod build; +mod database; mod event_bus; mod federation; #[cfg(feature = "federation")] @@ -15,9 +15,7 @@ mod server; mod tracing_setup; mod worker; -pub use build::{build_deps, build_worker_deps}; -pub use database::{DatabaseOutput, DbPool}; -pub use event_bus::EventBusBackend; +pub use build::build_deps; pub use repositories::Repositories; pub use server::{ServerApp, build_server, build_server_on}; pub use tracing_setup::init_tracing; diff --git a/crates/bootstrap/src/worker.rs b/crates/bootstrap/src/worker.rs index 7313feda..1c49dce9 100644 --- a/crates/bootstrap/src/worker.rs +++ b/crates/bootstrap/src/worker.rs @@ -66,7 +66,7 @@ async fn assemble( event_publisher: Arc::clone(&event_publisher_arc), person_enrichment, }; - let worker_deps = crate::build_worker_deps(&db, &worker_services); + 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); diff --git a/crates/bootstrap/tests/config_propagation_test.rs b/crates/bootstrap/tests/config_propagation_test.rs new file mode 100644 index 00000000..f7a4af4f --- /dev/null +++ b/crates/bootstrap/tests/config_propagation_test.rs @@ -0,0 +1,103 @@ +//! Configuration values reach the running app through the composition root. + +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 { + 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}" + ); + } +} diff --git a/crates/bootstrap/tests/http_handlers/extractors_test.rs b/crates/bootstrap/tests/http_handlers/extractors_test.rs index e49fd9a0..d2d727e5 100644 --- a/crates/bootstrap/tests/http_handlers/extractors_test.rs +++ b/crates/bootstrap/tests/http_handlers/extractors_test.rs @@ -359,3 +359,49 @@ async fn admin_user_surfaces_a_repository_error_as_500() { .unwrap(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } + +async fn path_id_handler( + http_axum::extractors::PathId(id): http_axum::extractors::PathId, +) -> 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"); +} diff --git a/docs/adr/0014-http-adapter-and-one-refusal-shape.md b/docs/adr/0014-http-adapter-and-one-refusal-shape.md index fefc0ca4..88928e42 100644 --- a/docs/adr/0014-http-adapter-and-one-refusal-shape.md +++ b/docs/adr/0014-http-adapter-and-one-refusal-shape.md @@ -16,7 +16,7 @@ rate limiter's own 429 text. The refusals were documented without a body schema. **One body.** `api_types::ErrorResponse { error, code }`. `error` keeps its name and meaning (the SPA and existing clients read it); `code` is an additive, stable machine-readable field: -`NOT_FOUND`, `INVALID_INPUT`, `CONFLICT`, `UNAUTHORIZED`, `FORBIDDEN`, `RATE_LIMITED`, `INTERNAL`. +`NOT_FOUND`, `INVALID_INPUT`, `UNAUTHORIZED`, `FORBIDDEN`, `RATE_LIMITED`, `INTERNAL`. **One constructor.** `errors::refuse(status, code, message) -> Response` is the only place a JSON error body is built. `ApiError` maps every `DomainError` variant to it with the status mapping that -- 2.49.1 From d073922308341689c600a3cd7e9c44fcca72cae9 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 23:39:04 +0100 Subject: [PATCH 049/104] fix(sqlite): atomic event queue claim --- crates/adapters/sqlite/src/event_queue/mod.rs | 56 ++++------ .../adapters/sqlite/src/event_queue/tests.rs | 103 ++++++++++++++++++ 2 files changed, 124 insertions(+), 35 deletions(-) create mode 100644 crates/adapters/sqlite/src/event_queue/tests.rs diff --git a/crates/adapters/sqlite/src/event_queue/mod.rs b/crates/adapters/sqlite/src/event_queue/mod.rs index 18f260df..32dc370e 100644 --- a/crates/adapters/sqlite/src/event_queue/mod.rs +++ b/crates/adapters/sqlite/src/event_queue/mod.rs @@ -127,51 +127,34 @@ struct QueueRow { id: i64, payload: String, attempts: i32, + next_attempt_at: String, } +/// Claims a batch in one atomic statement. A read-then-write transaction can +/// fail with SQLITE_BUSY_SNAPSHOT (517) when another connection commits in +/// between, which `busy_timeout` cannot retry. async fn claim_batch( pool: &SqlitePool, config: &DbEventQueueConfig, ) -> Result, DomainError> { - let mut tx = pool - .begin() - .await - .map_err(|e| DomainError::InfrastructureError(format!("begin tx: {e}")))?; - - let rows = sqlx::query_as::<_, QueueRow>( - "SELECT id, payload, attempts FROM event_queue - WHERE status = 'pending' - AND next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now') - ORDER BY next_attempt_at ASC - LIMIT ?", + let mut rows = sqlx::query_as::<_, QueueRow>( + "UPDATE event_queue SET status = 'processing' + WHERE id IN ( + SELECT id FROM event_queue + WHERE status = 'pending' + AND next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + ORDER BY next_attempt_at ASC + LIMIT ? + ) + RETURNING id, payload, attempts, next_attempt_at", ) .bind(config.batch_size) - .fetch_all(&mut *tx) + .fetch_all(pool) .await - .map_err(|e| DomainError::InfrastructureError(format!("select pending: {e}")))?; - - if rows.is_empty() { - tx.rollback().await.ok(); - return Ok(vec![]); - } - - let placeholders = rows.iter().map(|_| "?").collect::>().join(", "); - let sql = format!( - "UPDATE event_queue SET status = 'processing' WHERE id IN ({})", - placeholders - ); - let mut q = sqlx::query(&sql); - for r in &rows { - q = q.bind(r.id); - } - q.execute(&mut *tx) - .await - .map_err(|e| DomainError::InfrastructureError(format!("mark processing: {e}")))?; - - tx.commit() - .await - .map_err(|e| DomainError::InfrastructureError(format!("commit claim: {e}")))?; + .map_err(|e| DomainError::InfrastructureError(format!("claim batch: {e}")))?; + // RETURNING order is unspecified; deliver oldest first. + rows.sort_by(|a, b| (&a.next_attempt_at, a.id).cmp(&(&b.next_attempt_at, b.id))); Ok(rows) } @@ -243,3 +226,6 @@ fn backoff_seconds(attempts: i32) -> i64 { let base: i64 = 5 * (1i64 << attempts.min(6)); base.min(300) } + +#[cfg(test)] +mod tests; diff --git a/crates/adapters/sqlite/src/event_queue/tests.rs b/crates/adapters/sqlite/src/event_queue/tests.rs new file mode 100644 index 00000000..87f76d6b --- /dev/null +++ b/crates/adapters/sqlite/src/event_queue/tests.rs @@ -0,0 +1,103 @@ +//! Inline (not under `tests/`) because `claim_batch` is private and the race +//! only shows on its claim path; two pools on a file-backed WAL database are +//! needed, as in production. + +use std::str::FromStr; + +use sqlx::SqlitePool; +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; + +use super::{DbEventQueueConfig, claim_batch}; + +async fn pool_on(path: &std::path::Path) -> SqlitePool { + let opts = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display())) + .unwrap() + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .busy_timeout(std::time::Duration::from_secs(5)); + SqlitePoolOptions::new() + .max_connections(4) + .connect_with(opts) + .await + .unwrap() +} + +fn config(batch_size: i64) -> DbEventQueueConfig { + DbEventQueueConfig { + poll_interval_ms: 10, + batch_size, + max_attempts: 3, + } +} + +async fn insert(pool: &SqlitePool, n: usize) { + for i in 0..n { + sqlx::query("INSERT INTO event_queue (event_type, payload) VALUES ('t', ?)") + .bind(format!("p{i}")) + .execute(pool) + .await + .unwrap(); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn claiming_while_another_connection_writes_does_not_fail() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("q.db"); + let claimer = pool_on(&path).await; + crate::migrate(&claimer).await.unwrap(); + let writer = pool_on(&path).await; + insert(&claimer, 50).await; + + let writer_task = { + let writer = writer.clone(); + tokio::spawn(async move { + for i in 0..2000 { + sqlx::query("INSERT INTO event_queue (event_type, payload) VALUES ('t', ?)") + .bind(format!("w{i}")) + .execute(&writer) + .await + .unwrap(); + } + }) + }; + + let cfg = config(5); + let mut claimed = 0; + while !writer_task.is_finished() { + claimed += claim_batch(&claimer, &cfg) + .await + .expect("claim must not fail while another connection writes") + .len(); + } + writer_task.await.unwrap(); + assert!(claimed > 0); +} + +#[tokio::test] +async fn claimed_rows_are_marked_processing_and_not_claimed_twice() { + let dir = tempfile::tempdir().unwrap(); + let pool = pool_on(&dir.path().join("q.db")).await; + crate::migrate(&pool).await.unwrap(); + insert(&pool, 5).await; + + let cfg = config(3); + let first = claim_batch(&pool, &cfg).await.unwrap(); + assert_eq!(first.len(), 3); + let second = claim_batch(&pool, &cfg).await.unwrap(); + assert_eq!(second.len(), 2); + assert!(claim_batch(&pool, &cfg).await.unwrap().is_empty()); + + let mut ids: Vec = first.iter().chain(&second).map(|r| r.id).collect(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), 5, "no row claimed twice"); + let (processing,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM event_queue WHERE status = 'processing'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(processing, 5); + let payloads: Vec<&str> = first.iter().map(|r| r.payload.as_str()).collect(); + assert_eq!(payloads, ["p0", "p1", "p2"], "oldest first"); +} -- 2.49.1 From b96f03f86f3012dcbf982052cec8f7b9ac2e918b Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 23:39:04 +0100 Subject: [PATCH 050/104] fix(sqlite): dedup clears old external id before canonical upsert --- .../src/repositories/movie_dedup/mod.rs | 8 + crates/adapters/sqlite/tests/repositories.rs | 2 + .../tests/repositories/movie_dedup_test.rs | 163 ++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 crates/adapters/sqlite/tests/repositories/movie_dedup_test.rs diff --git a/crates/adapters/sqlite/src/repositories/movie_dedup/mod.rs b/crates/adapters/sqlite/src/repositories/movie_dedup/mod.rs index 32440e3b..f380ef56 100644 --- a/crates/adapters/sqlite/src/repositories/movie_dedup/mod.rs +++ b/crates/adapters/sqlite/src/repositories/movie_dedup/mod.rs @@ -37,6 +37,14 @@ impl MovieDeduplicator for SqliteMovieDeduplicator { .await .map_err(crate::shared::map_sqlx_error)?; + // The old row still holds the external id (it is deleted last); release it + // so the canonical upsert does not violate UNIQUE(external_metadata_id). + sqlx::query("UPDATE movies SET external_metadata_id = NULL WHERE id = ?") + .bind(&old) + .execute(&mut *tx) + .await + .map_err(crate::shared::map_sqlx_error)?; + sqlx::query( "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) VALUES (?, ?, ?, ?, ?, ?) diff --git a/crates/adapters/sqlite/tests/repositories.rs b/crates/adapters/sqlite/tests/repositories.rs index 489a9fa5..88f5f4e2 100644 --- a/crates/adapters/sqlite/tests/repositories.rs +++ b/crates/adapters/sqlite/tests/repositories.rs @@ -2,6 +2,8 @@ mod diary_repository_test; #[path = "repositories/image_ref_adapter_test.rs"] mod image_ref_adapter_test; +#[path = "repositories/movie_dedup_test.rs"] +mod movie_dedup_test; #[path = "repositories/movie_profile_repository_test.rs"] mod movie_profile_repository_test; #[path = "repositories/person_adapter_test.rs"] diff --git a/crates/adapters/sqlite/tests/repositories/movie_dedup_test.rs b/crates/adapters/sqlite/tests/repositories/movie_dedup_test.rs new file mode 100644 index 00000000..50f2a236 --- /dev/null +++ b/crates/adapters/sqlite/tests/repositories/movie_dedup_test.rs @@ -0,0 +1,163 @@ +use std::str::FromStr; + +use domain::{ + models::Movie, + ports::MovieDeduplicator, + value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterPath, ReleaseYear}, +}; +use sqlite::SqliteMovieDeduplicator; +use sqlx::SqlitePool; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + +async fn migrated_pool() -> SqlitePool { + let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(opts) + .await + .unwrap(); + sqlite::migrate(&pool).await.unwrap(); + pool +} + +async fn insert_movie(pool: &SqlitePool, id: &MovieId, ext: Option<&str>, poster: Option<&str>) { + sqlx::query( + "INSERT INTO movies (id, external_metadata_id, title, release_year, poster_path) + VALUES (?, ?, 'Heat', 1995, ?)", + ) + .bind(id.value().to_string()) + .bind(ext) + .bind(poster) + .execute(pool) + .await + .unwrap(); +} + +async fn insert_review(pool: &SqlitePool, movie: &MovieId) { + sqlx::query( + "INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) + VALUES (?, ?, ?, 4, '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(movie.value().to_string()) + .bind(uuid::Uuid::new_v4().to_string()) + .execute(pool) + .await + .unwrap(); +} + +/// Mirrors what `application::movies::merge_duplicates` builds when the +/// canonical row does not exist. +fn canonical_from(old: &Movie, canonical_id: MovieId) -> Movie { + Movie::from_persistence( + canonical_id, + old.external_metadata_id().cloned(), + old.title().clone(), + old.release_year().clone(), + old.director().map(str::to_string), + old.poster_path().cloned(), + ) +} + +fn old_movie(id: &MovieId, ext: &ExternalMetadataId, poster: Option<&str>) -> Movie { + Movie::from_persistence( + id.clone(), + Some(ext.clone()), + MovieTitle::new("Heat".into()).unwrap(), + ReleaseYear::new(1995).unwrap(), + None, + poster.map(|p| PosterPath::new(p.into()).unwrap()), + ) +} + +async fn scalar(pool: &SqlitePool, sql: &str, bind: &str) -> Option { + sqlx::query_scalar(sql) + .bind(bind) + .fetch_optional(pool) + .await + .unwrap() +} + +#[tokio::test] +async fn merging_into_a_canonical_movie_that_does_not_exist_yet_succeeds() { + let pool = migrated_pool().await; + let ext = ExternalMetadataId::new("tmdb:603".into()).unwrap(); + let old_id = MovieId::generate(); + insert_movie(&pool, &old_id, Some("tmdb:603"), None).await; + insert_review(&pool, &old_id).await; + + let canonical_id = MovieId::from_external(&ext); + let canonical = canonical_from(&old_movie(&old_id, &ext, None), canonical_id.clone()); + + let repointed = SqliteMovieDeduplicator::new(pool.clone()) + .merge_into_canonical(&old_id, &canonical) + .await + .unwrap(); + + assert_eq!(repointed, 1); + let ext_of_canonical = scalar( + &pool, + "SELECT external_metadata_id FROM movies WHERE id = ?", + &canonical_id.value().to_string(), + ) + .await; + assert_eq!(ext_of_canonical.as_deref(), Some("tmdb:603")); + assert!( + scalar( + &pool, + "SELECT id FROM movies WHERE id = ?", + &old_id.value().to_string() + ) + .await + .is_none() + ); + let on_canonical: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE movie_id = ?") + .bind(canonical_id.value().to_string()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(on_canonical, 1); +} + +#[tokio::test] +async fn merging_into_an_existing_canonical_movie_keeps_its_poster() { + let pool = migrated_pool().await; + let ext = ExternalMetadataId::new("tmdb:603".into()).unwrap(); + let old_id = MovieId::generate(); + let canonical_id = MovieId::from_external(&ext); + insert_movie(&pool, &old_id, Some("tmdb:603"), Some("old.jpg")).await; + insert_movie(&pool, &canonical_id, None, Some("canonical.jpg")).await; + insert_review(&pool, &old_id).await; + + let canonical = Movie::from_persistence( + canonical_id.clone(), + Some(ext.clone()), + MovieTitle::new("Heat".into()).unwrap(), + ReleaseYear::new(1995).unwrap(), + None, + Some(PosterPath::new("canonical.jpg".into()).unwrap()), + ); + + SqliteMovieDeduplicator::new(pool.clone()) + .merge_into_canonical(&old_id, &canonical) + .await + .unwrap(); + + let id = canonical_id.value().to_string(); + assert_eq!( + scalar(&pool, "SELECT poster_path FROM movies WHERE id = ?", &id) + .await + .as_deref(), + Some("canonical.jpg") + ); + assert_eq!( + scalar( + &pool, + "SELECT external_metadata_id FROM movies WHERE id = ?", + &id + ) + .await + .as_deref(), + Some("tmdb:603") + ); +} -- 2.49.1 From 98ad1a8fe7fb19ffc9eea60ca8b2d17e9993746a Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 23:39:04 +0100 Subject: [PATCH 051/104] docs: adr 0013 accuracy --- docs/adr/0013-config-and-bootstrap.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/adr/0013-config-and-bootstrap.md b/docs/adr/0013-config-and-bootstrap.md index 2af5842a..2ca5dc20 100644 --- a/docs/adr/0013-config-and-bootstrap.md +++ b/docs/adr/0013-config-and-bootstrap.md @@ -25,9 +25,10 @@ raw environment strings. **`bootstrap` crate.** Replaces `composition` and `infra-wiring`. It is the only crate that names concrete adapters. It exposes `build_server(&AppConfig) -> ServerApp { router, addr }`, `build_worker(&AppConfig) -> WorkerApp { service }`, `build_server_on(&AppConfig, SqlitePool)` (the -same router on a caller-provided pool, for tests) and `init_tracing`. `DbPool` and -`EventBusBackend` live here. `application` keeps a small application-owned `AppConfig` / -`WrapUpConfig` built from the `config` sections, so use-case deps structs and their test fixtures +same router on a caller-provided pool, for tests) and `init_tracing`. Bootstrap runs +on `SqlitePool`; the event-bus backend is chosen inside bootstrap and not re-exported. `application` +keeps small application-owned `AppSettings` / `WrapUpSettings` types built from the `config` +sections, so use-case deps structs and their test fixtures are unchanged, and `application` no longer pulls `sqlx` or `anyhow`. **Binaries are thin.** `server` and `worker` do dotenv, `config::load`, `init_tracing`, one -- 2.49.1 From c31aa033cc79adf1249e31c41ac91a4151a3e5e6 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 23:40:43 +0100 Subject: [PATCH 052/104] test: harden claim race test --- .../adapters/sqlite/src/event_queue/tests.rs | 18 +++++++++++++----- .../tests/repositories/movie_dedup_test.rs | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/adapters/sqlite/src/event_queue/tests.rs b/crates/adapters/sqlite/src/event_queue/tests.rs index 87f76d6b..0b52662d 100644 --- a/crates/adapters/sqlite/src/event_queue/tests.rs +++ b/crates/adapters/sqlite/src/event_queue/tests.rs @@ -10,11 +10,15 @@ use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; use super::{DbEventQueueConfig, claim_batch}; async fn pool_on(path: &std::path::Path) -> SqlitePool { + pool_with_timeout(path, 5).await +} + +async fn pool_with_timeout(path: &std::path::Path, busy_secs: u64) -> SqlitePool { let opts = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display())) .unwrap() .create_if_missing(true) .journal_mode(SqliteJournalMode::Wal) - .busy_timeout(std::time::Duration::from_secs(5)); + .busy_timeout(std::time::Duration::from_secs(busy_secs)); SqlitePoolOptions::new() .max_connections(4) .connect_with(opts) @@ -46,7 +50,7 @@ async fn claiming_while_another_connection_writes_does_not_fail() { let path = dir.path().join("q.db"); let claimer = pool_on(&path).await; crate::migrate(&claimer).await.unwrap(); - let writer = pool_on(&path).await; + let writer = pool_with_timeout(&path, 30).await; insert(&claimer, 50).await; let writer_task = { @@ -65,10 +69,14 @@ async fn claiming_while_another_connection_writes_does_not_fail() { let cfg = config(5); let mut claimed = 0; while !writer_task.is_finished() { - claimed += claim_batch(&claimer, &cfg) + let batch = claim_batch(&claimer, &cfg) .await - .expect("claim must not fail while another connection writes") - .len(); + .expect("claim must not fail while another connection writes"); + if batch.is_empty() { + // an empty UPDATE still takes the write lock; don't starve the writer + tokio::task::yield_now().await; + } + claimed += batch.len(); } writer_task.await.unwrap(); assert!(claimed > 0); diff --git a/crates/adapters/sqlite/tests/repositories/movie_dedup_test.rs b/crates/adapters/sqlite/tests/repositories/movie_dedup_test.rs index 50f2a236..f243ae69 100644 --- a/crates/adapters/sqlite/tests/repositories/movie_dedup_test.rs +++ b/crates/adapters/sqlite/tests/repositories/movie_dedup_test.rs @@ -120,7 +120,7 @@ async fn merging_into_a_canonical_movie_that_does_not_exist_yet_succeeds() { } #[tokio::test] -async fn merging_into_an_existing_canonical_movie_keeps_its_poster() { +async fn merging_into_an_existing_canonical_movie_moves_the_external_id_and_keeps_its_poster() { let pool = migrated_pool().await; let ext = ExternalMetadataId::new("tmdb:603".into()).unwrap(); let old_id = MovieId::generate(); -- 2.49.1 From 32dfc84697d8248bacca76dcc13d702c85385ab7 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 23:57:24 +0100 Subject: [PATCH 053/104] feat: event ids, outbox identity migration --- crates/adapters/event-publisher/src/lib.rs | 2 +- crates/adapters/nats/src/consumer.rs | 30 ++++--- crates/adapters/nats/src/payload.rs | 1 + .../migrations/0101_outbox_identity.sql | 23 ++++++ crates/adapters/sqlite/src/event_queue/mod.rs | 33 +++++--- .../adapters/sqlite/src/event_queue/tests.rs | 81 +++++++++++++++++++ crates/adapters/sqlite/src/shared.rs | 22 +++++ .../tests/fixtures/migration_ledger.txt | 1 + .../application/tests/worker/worker_test.rs | 6 +- crates/bootstrap/src/event_bus.rs | 17 +--- crates/bootstrap/tests/bootstrap_test.rs | 40 +++++++++ crates/domain/src/events.rs | 34 +++++++- crates/event-payload/src/lib.rs | 10 +++ .../event-payload/tests/event_payload_test.rs | 26 ++++++ 14 files changed, 287 insertions(+), 39 deletions(-) create mode 100644 crates/adapters/sqlite/migrations/0101_outbox_identity.sql diff --git a/crates/adapters/event-publisher/src/lib.rs b/crates/adapters/event-publisher/src/lib.rs index 64ba21d2..8c68fc40 100644 --- a/crates/adapters/event-publisher/src/lib.rs +++ b/crates/adapters/event-publisher/src/lib.rs @@ -58,7 +58,7 @@ impl EventConsumer for ChannelEventConsumer { let receiver = Arc::clone(&self.receiver); Box::pin(stream::unfold(receiver, |rx| async move { let event = rx.lock().await.recv().await?; - let envelope = EventEnvelope::new(event, Box::new(NoopAck)); + let envelope = EventEnvelope::new(None, event, Box::new(NoopAck)); Some((Ok(envelope), rx)) })) } diff --git a/crates/adapters/nats/src/consumer.rs b/crates/adapters/nats/src/consumer.rs index 278fc83e..8fcdad59 100644 --- a/crates/adapters/nats/src/consumer.rs +++ b/crates/adapters/nats/src/consumer.rs @@ -5,7 +5,7 @@ use async_nats::{ use async_trait::async_trait; use domain::{ errors::DomainError, - events::{AckHandle, DomainEvent, EventEnvelope}, + events::{AckHandle, DomainEvent, EventEnvelope, EventId}, ports::EventConsumer, }; use futures::{ @@ -15,7 +15,7 @@ use futures::{ use std::sync::Arc; use tokio::sync::{Mutex, mpsc}; -use crate::{config::NatsConfig, payload::NatsEventPayload, subject::consumer_subject_filter}; +use crate::{config::NatsConfig, payload::NatsEventMessage, subject::consumer_subject_filter}; // ── JetStream ack handle ───────────────────────────────────────────────────── @@ -66,21 +66,33 @@ impl AckHandle for NoopAck { // ── Envelope construction helpers ──────────────────────────────────────────── -fn decode_js(msg: async_nats::jetstream::Message) -> Result { - let payload: NatsEventPayload = serde_json::from_slice(&msg.payload) +fn decode_message(bytes: &[u8]) -> Result<(Option, DomainEvent), DomainError> { + let message: NatsEventMessage = serde_json::from_slice(bytes) .map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?; - let event = DomainEvent::try_from(payload)?; + let id = message + .event_id + .as_deref() + .map(|s| { + uuid::Uuid::parse_str(s) + .map(EventId::from_uuid) + .map_err(|e| DomainError::InfrastructureError(format!("event_id: {e}"))) + }) + .transpose()?; + Ok((id, DomainEvent::try_from(message.payload)?)) +} + +fn decode_js(msg: async_nats::jetstream::Message) -> Result { + let (id, event) = decode_message(&msg.payload)?; Ok(EventEnvelope::new( + id, event, Box::new(NatsJetStreamAckHandle { message: msg }), )) } fn decode_core(msg: async_nats::Message) -> Result { - let payload: NatsEventPayload = serde_json::from_slice(&msg.payload) - .map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?; - let event = DomainEvent::try_from(payload)?; - Ok(EventEnvelope::new(event, Box::new(NoopAck))) + let (id, event) = decode_message(&msg.payload)?; + Ok(EventEnvelope::new(id, event, Box::new(NoopAck))) } // ── Channel-bridge shared by both consumers ────────────────────────────────── diff --git a/crates/adapters/nats/src/payload.rs b/crates/adapters/nats/src/payload.rs index 2ca19ecc..cc948a08 100644 --- a/crates/adapters/nats/src/payload.rs +++ b/crates/adapters/nats/src/payload.rs @@ -1 +1,2 @@ +pub use event_payload::EventMessage as NatsEventMessage; pub use event_payload::EventPayload as NatsEventPayload; diff --git a/crates/adapters/sqlite/migrations/0101_outbox_identity.sql b/crates/adapters/sqlite/migrations/0101_outbox_identity.sql new file mode 100644 index 00000000..caeabf65 --- /dev/null +++ b/crates/adapters/sqlite/migrations/0101_outbox_identity.sql @@ -0,0 +1,23 @@ +-- Outbox identity: every queued event carries a stable id; handlers record +-- (event_id, handler) pairs so redeliveries are skipped. +ALTER TABLE event_queue ADD COLUMN event_id TEXT; + +-- Legacy rows get a random v4-shaped uuid. The subquery references the row id +-- so it is re-evaluated per row rather than cached. +UPDATE event_queue SET event_id = ( + SELECT lower( + substr(h, 1, 8) || '-' || substr(h, 9, 4) || '-4' || substr(h, 14, 3) || '-' + || substr('89ab', (abs(random()) % 4) + 1, 1) || substr(h, 18, 3) || '-' || substr(h, 21, 12) + ) + FROM (SELECT hex(randomblob(16)) AS h, event_queue.id AS row_id) +); + +CREATE UNIQUE INDEX idx_event_queue_event_id + ON event_queue (event_id) WHERE event_id IS NOT NULL; + +CREATE TABLE processed_events ( + event_id TEXT NOT NULL, + handler TEXT NOT NULL, + processed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + PRIMARY KEY (event_id, handler) +); diff --git a/crates/adapters/sqlite/src/event_queue/mod.rs b/crates/adapters/sqlite/src/event_queue/mod.rs index 32dc370e..a26ff578 100644 --- a/crates/adapters/sqlite/src/event_queue/mod.rs +++ b/crates/adapters/sqlite/src/event_queue/mod.rs @@ -6,14 +6,15 @@ use std::time::Duration; use async_trait::async_trait; use domain::{ errors::DomainError, - events::{AckHandle, DomainEvent, EventEnvelope}, + events::{AckHandle, DomainEvent, EventEnvelope, EventId}, ports::{EventConsumer, EventPublisher}, }; use futures::stream::{self, BoxStream}; use sqlx::SqlitePool; use tokio::sync::{Mutex, mpsc}; -use payload::DbEventPayload; +use crate::shared::enqueue_events; +pub(crate) use payload::DbEventPayload; pub struct DbEventQueueConfig { pub poll_interval_ms: u64, @@ -65,17 +66,15 @@ impl SqliteEventQueue { #[async_trait] impl EventPublisher for SqliteEventQueue { async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { - let db_payload = DbEventPayload::from(event); - let event_type = db_payload.event_type(); - let payload_json = serde_json::to_string(&db_payload) - .map_err(|e| DomainError::InfrastructureError(format!("serialize: {e}")))?; - - sqlx::query("INSERT INTO event_queue (event_type, payload) VALUES (?, ?)") - .bind(event_type) - .bind(payload_json) - .execute(&self.pool) + let mut tx = self + .pool + .begin() .await - .map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?; + .map_err(|e| DomainError::InfrastructureError(format!("begin: {e}")))?; + enqueue_events(&mut tx, std::slice::from_ref(event)).await?; + tx.commit() + .await + .map_err(|e| DomainError::InfrastructureError(format!("commit: {e}")))?; Ok(()) } @@ -126,6 +125,7 @@ impl EventConsumer for SqliteEventQueue { struct QueueRow { id: i64, payload: String, + event_id: Option, attempts: i32, next_attempt_at: String, } @@ -146,7 +146,7 @@ async fn claim_batch( ORDER BY next_attempt_at ASC LIMIT ? ) - RETURNING id, payload, attempts, next_attempt_at", + RETURNING id, payload, event_id, attempts, next_attempt_at", ) .bind(config.batch_size) .fetch_all(pool) @@ -166,7 +166,14 @@ fn decode_row( let db_payload: DbEventPayload = serde_json::from_str(&row.payload) .map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?; let event = DomainEvent::try_from(db_payload)?; + let id = row + .event_id + .as_deref() + .map(crate::shared::parse_uuid) + .transpose()? + .map(EventId::from_uuid); Ok(EventEnvelope::new( + id, event, Box::new(DbAckHandle { pool: pool.clone(), diff --git a/crates/adapters/sqlite/src/event_queue/tests.rs b/crates/adapters/sqlite/src/event_queue/tests.rs index 0b52662d..79b66e9f 100644 --- a/crates/adapters/sqlite/src/event_queue/tests.rs +++ b/crates/adapters/sqlite/src/event_queue/tests.rs @@ -109,3 +109,84 @@ async fn claimed_rows_are_marked_processing_and_not_claimed_twice() { let payloads: Vec<&str> = first.iter().map(|r| r.payload.as_str()).collect(); assert_eq!(payloads, ["p0", "p1", "p2"], "oldest first"); } + +mod identity { + use domain::{ + events::DomainEvent, + ports::{EventConsumer, EventPublisher}, + value_objects::UserId, + }; + use futures::StreamExt; + use sqlx::SqlitePool; + + use super::{DbEventQueueConfig, config}; + use crate::event_queue::SqliteEventQueue; + + async fn queue() -> (SqliteEventQueue, SqlitePool) { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + crate::migrate(&pool).await.unwrap(); + let cfg: DbEventQueueConfig = config(10); + (SqliteEventQueue::create(pool.clone(), cfg), pool) + } + + fn event() -> DomainEvent { + DomainEvent::UserUpdated { + user_id: UserId::generate(), + } + } + + #[tokio::test] + async fn published_events_get_a_unique_event_id() { + let (q, pool) = queue().await; + + q.publish(&event()).await.unwrap(); + q.publish(&event()).await.unwrap(); + + let ids: Vec> = + sqlx::query_scalar("SELECT event_id FROM event_queue ORDER BY id") + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(ids.len(), 2); + let a = ids[0].as_deref().expect("first event has an id"); + let b = ids[1].as_deref().expect("second event has an id"); + assert_ne!(a, b); + uuid::Uuid::parse_str(a).expect("event id is a uuid"); + } + + #[tokio::test] + async fn the_consumer_surfaces_the_published_event_id() { + let (q, pool) = queue().await; + q.publish(&event()).await.unwrap(); + let stored: String = sqlx::query_scalar("SELECT event_id FROM event_queue") + .fetch_one(&pool) + .await + .unwrap(); + + let envelope = q.consume().next().await.unwrap().unwrap(); + + assert_eq!(envelope.id().unwrap().value().to_string(), stored); + } + + #[tokio::test] + async fn legacy_queue_rows_without_event_id_still_decode() { + let (q, pool) = queue().await; + let payload = + serde_json::to_string(&crate::event_queue::DbEventPayload::from(&event())).unwrap(); + sqlx::query( + "INSERT INTO event_queue (event_type, payload, event_id) VALUES ('UserUpdated', ?, NULL)", + ) + .bind(payload) + .execute(&pool) + .await + .unwrap(); + + let envelope = q.consume().next().await.unwrap().unwrap(); + + assert!(envelope.id().is_none()); + } +} diff --git a/crates/adapters/sqlite/src/shared.rs b/crates/adapters/sqlite/src/shared.rs index 13ae4078..151839d0 100644 --- a/crates/adapters/sqlite/src/shared.rs +++ b/crates/adapters/sqlite/src/shared.rs @@ -10,6 +10,28 @@ use domain::{ }, }; +/// THE outbox write path: inserts one `event_queue` row per event, each with a +/// fresh `EventId`, inside the caller's transaction so a change and the events +/// announcing it commit or roll back together. +pub(crate) async fn enqueue_events( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + events: &[domain::events::DomainEvent], +) -> Result<(), DomainError> { + for event in events { + let payload = event_payload::EventPayload::from(event); + let payload_json = serde_json::to_string(&payload) + .map_err(|e| DomainError::InfrastructureError(format!("serialize: {e}")))?; + sqlx::query("INSERT INTO event_queue (event_type, payload, event_id) VALUES (?, ?, ?)") + .bind(payload.event_type()) + .bind(payload_json) + .bind(domain::events::EventId::new().value().to_string()) + .execute(&mut **tx) + .await + .map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?; + } + Ok(()) +} + pub(crate) fn map_sqlx_error(e: sqlx::Error) -> DomainError { tracing::error!("Database error: {:?}", e); DomainError::InfrastructureError("Database operation failed".into()) diff --git a/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt index 98d343ed..93d4a5c3 100644 --- a/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt +++ b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt @@ -29,3 +29,4 @@ 29 56F4F7B41A09F07211D7543204F0EAC93B7BFB15AFD9BC90DF2654DAE5BE96B1E358B50E2190BF25739C5F0B1FD4B806 30 DAAA0AF35B4ACB271A9D8068B82F02A72EEC62F3AC07ED504C7A7887976C2C8BACB35FFF477B63A931DF15F79018390D 100 310ADC9ABF0CC2F8E9EDAB33168D009D64228FF5BD8D2A14185513699EBB782606A1EEA8FEE6EC6AF65D72396153F13F +101 D4712BF715F8BD86502ED8162488998B1BAFF41C241072173BC2210A3676C05B5B373DD3ED33A16B473A2EC983211302 diff --git a/crates/application/tests/worker/worker_test.rs b/crates/application/tests/worker/worker_test.rs index 56324c71..61f4fc43 100644 --- a/crates/application/tests/worker/worker_test.rs +++ b/crates/application/tests/worker/worker_test.rs @@ -40,7 +40,7 @@ impl EventConsumer for VecConsumer { .events .iter() .cloned() - .map(|e| Ok(EventEnvelope::new(e, Box::new(NoopAckHandle)))) + .map(|e| Ok(EventEnvelope::new(None, e, Box::new(NoopAckHandle)))) .collect(); Box::pin(stream::iter(envelopes)) } @@ -130,6 +130,7 @@ async fn acks_when_all_handlers_succeed() { impl EventConsumer for TrackingConsumer { fn consume(&self) -> BoxStream<'_, Result> { let envelope = EventEnvelope::new( + None, self.event.clone(), Box::new(TrackingAck { acked: Arc::clone(&self.acked), @@ -167,6 +168,7 @@ async fn nacks_on_transient_handler_failure() { impl EventConsumer for TrackingConsumer { fn consume(&self) -> BoxStream<'_, Result> { let envelope = EventEnvelope::new( + None, self.event.clone(), Box::new(TrackingAck { acked: Arc::clone(&self.acked), @@ -212,6 +214,7 @@ async fn acks_on_permanent_handler_failure() { impl EventConsumer for TrackingConsumer { fn consume(&self) -> BoxStream<'_, Result> { let envelope = EventEnvelope::new( + None, self.event.clone(), Box::new(TrackingAck { acked: Arc::clone(&self.acked), @@ -260,6 +263,7 @@ async fn nacks_if_any_handler_is_transient() { impl EventConsumer for TrackingConsumer { fn consume(&self) -> BoxStream<'_, Result> { let envelope = EventEnvelope::new( + None, self.event.clone(), Box::new(TrackingAck { acked: Arc::clone(&self.acked), diff --git a/crates/bootstrap/src/event_bus.rs b/crates/bootstrap/src/event_bus.rs index 222832d2..885c59b5 100644 --- a/crates/bootstrap/src/event_bus.rs +++ b/crates/bootstrap/src/event_bus.rs @@ -26,23 +26,14 @@ impl EventBusBackend { } } +/// The server's publisher is always the outbox writer: it never talks to the +/// bus, whatever `EVENT_BUS_BACKEND` says. The worker owns delivery. pub async fn create_publisher( db_pool: &sqlx::SqlitePool, config: &config::AppConfig, ) -> anyhow::Result> { - match EventBusBackend::from_config(&config.events)? { - EventBusBackend::Db => { - tracing::info!("event bus: DB queue"); - Ok(sqlite::SqliteEventQueue::create_publisher(db_pool.clone(), &config.events).await?) - } - #[cfg(feature = "nats")] - EventBusBackend::Nats => { - let cfg = nats::NatsConfig::from_config(&config.nats) - .context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?; - tracing::info!("event bus: NATS ({})", cfg.url); - Ok(nats::create_publisher(cfg).await?) - } - } + tracing::info!("event publisher: outbox (DB queue)"); + sqlite::SqliteEventQueue::create_publisher(db_pool.clone(), &config.events).await } pub async fn create_channel( diff --git a/crates/bootstrap/tests/bootstrap_test.rs b/crates/bootstrap/tests/bootstrap_test.rs index 33a2e027..5f0fc821 100644 --- a/crates/bootstrap/tests/bootstrap_test.rs +++ b/crates/bootstrap/tests/bootstrap_test.rs @@ -67,3 +67,43 @@ async fn federation_off_build_wires_local_social() { "follower should be alice: {body}" ); } + +/// The server never talks to the bus: whatever `EVENT_BUS_BACKEND` says, a +/// change and its event land in the outbox table. No NATS server is reachable here. +#[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); +} diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 43bc964a..113d5fe8 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -150,14 +150,44 @@ pub trait AckHandle: Send + Sync { async fn nack(&self) -> Result<(), DomainError>; } +/// Stable identity of one queued event; the key for de-duplicating deliveries. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct EventId(uuid::Uuid); + +impl EventId { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } + + pub fn from_uuid(uuid: uuid::Uuid) -> Self { + Self(uuid) + } + + pub fn value(&self) -> uuid::Uuid { + self.0 + } +} + +impl Default for EventId { + fn default() -> Self { + Self::new() + } +} + pub struct EventEnvelope { pub event: DomainEvent, + id: Option, ack: Box, } impl EventEnvelope { - pub fn new(event: DomainEvent, ack: Box) -> Self { - Self { event, ack } + /// `id` is `None` only for events queued before identities existed. + pub fn new(id: Option, event: DomainEvent, ack: Box) -> Self { + Self { event, id, ack } + } + + pub fn id(&self) -> Option<&EventId> { + self.id.as_ref() } pub async fn ack(self) -> Result<(), DomainError> { diff --git a/crates/event-payload/src/lib.rs b/crates/event-payload/src/lib.rs index 92c32bff..e63059d0 100644 --- a/crates/event-payload/src/lib.rs +++ b/crates/event-payload/src/lib.rs @@ -154,6 +154,16 @@ pub enum EventPayload { }, } +/// Wire form of an event: the tagged payload plus its optional identity. +/// Messages without `event_id` (queued before identities existed) still decode. +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct EventMessage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub event_id: Option, + #[serde(flatten)] + pub payload: EventPayload, +} + impl EventPayload { pub fn event_type(&self) -> &'static str { match self { diff --git a/crates/event-payload/tests/event_payload_test.rs b/crates/event-payload/tests/event_payload_test.rs index 0f941bc5..79cb331f 100644 --- a/crates/event-payload/tests/event_payload_test.rs +++ b/crates/event-payload/tests/event_payload_test.rs @@ -105,3 +105,29 @@ fn image_stored_event_type() { }); assert_eq!(payload.event_type(), "ImageStored"); } + +#[test] +fn a_message_round_trips_its_event_id_and_decodes_without_one() { + use event_payload::EventMessage; + + let with_id = EventMessage { + event_id: Some(Uuid::new_v4().to_string()), + payload: EventPayload::from(&review_logged()), + }; + let json = serde_json::to_string(&with_id).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + with_id + ); + + let legacy = serde_json::to_string(&EventPayload::from(&review_logged())).unwrap(); + let decoded: EventMessage = serde_json::from_str(&legacy).unwrap(); + assert_eq!(decoded.event_id, None); + + let unit = EventMessage { + event_id: Some("x".into()), + payload: EventPayload::SearchReindexRequested, + }; + let json = serde_json::to_string(&unit).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), unit); +} -- 2.49.1 From c06d3d920758c7277b000bc720ec9a07d347a412 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 00:06:09 +0100 Subject: [PATCH 054/104] feat: worker relays outbox to nats --- Cargo.lock | 1 + crates/adapters/nats/src/consumer.rs | 4 + crates/adapters/nats/src/lib.rs | 59 +++--- crates/adapters/nats/src/payload.rs | 1 - crates/adapters/nats/src/publisher.rs | 60 ------ crates/adapters/nats/src/relay.rs | 104 ++++++++++ crates/adapters/nats/src/tests/consumer.rs | 46 +++++ crates/adapters/nats/src/tests/relay.rs | 37 ++++ crates/bootstrap/Cargo.toml | 1 + crates/bootstrap/src/event_bus.rs | 103 +++++++++- crates/bootstrap/src/lib.rs | 1 + crates/bootstrap/src/outbox_relay.rs | 48 +++++ crates/bootstrap/tests/outbox_relay_test.rs | 115 +++++++++++ crates/domain/src/ports/events.rs | 9 +- .../event-payload/tests/event_payload_test.rs | 191 ++++++++++++++++++ 15 files changed, 678 insertions(+), 102 deletions(-) delete mode 100644 crates/adapters/nats/src/publisher.rs create mode 100644 crates/adapters/nats/src/relay.rs create mode 100644 crates/adapters/nats/src/tests/consumer.rs create mode 100644 crates/adapters/nats/src/tests/relay.rs create mode 100644 crates/bootstrap/src/outbox_relay.rs create mode 100644 crates/bootstrap/tests/outbox_relay_test.rs diff --git a/Cargo.lock b/Cargo.lock index 0edd7148..a0b17504 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -918,6 +918,7 @@ dependencies = [ "config", "domain", "export", + "futures", "http-axum", "image-converter", "importer", diff --git a/crates/adapters/nats/src/consumer.rs b/crates/adapters/nats/src/consumer.rs index 8fcdad59..f428ab80 100644 --- a/crates/adapters/nats/src/consumer.rs +++ b/crates/adapters/nats/src/consumer.rs @@ -223,3 +223,7 @@ fn _assert_send_sync() { check::(); check::(); } + +#[cfg(test)] +#[path = "tests/consumer.rs"] +mod tests; diff --git a/crates/adapters/nats/src/lib.rs b/crates/adapters/nats/src/lib.rs index a762b365..ca2125fc 100644 --- a/crates/adapters/nats/src/lib.rs +++ b/crates/adapters/nats/src/lib.rs @@ -1,52 +1,41 @@ mod config; mod consumer; mod payload; -mod publisher; +mod relay; mod subject; pub use config::{NatsConfig, NatsMode}; pub use consumer::{NatsCoreConsumer, NatsJetStreamConsumer}; -pub use publisher::NatsEventPublisher; +pub use relay::NatsRelaySink; use std::sync::Arc; -use domain::ports::{EventConsumer, EventPublisher}; +use domain::ports::{EventConsumer, EventRelaySink}; -pub async fn create_publisher(cfg: NatsConfig) -> anyhow::Result> { - let client = async_nats::connect(&cfg.url).await?; - let publisher: Arc = match cfg.mode { - NatsMode::Core => Arc::new(NatsEventPublisher::new_core(client, cfg.subject_prefix)), - NatsMode::JetStream => Arc::new(NatsEventPublisher::new_jetstream( - client, - cfg.subject_prefix, - )), - }; - - tracing::info!("NATS publisher created (mode: {:?})", cfg.mode); - Ok(publisher) -} - -pub async fn create_channel( +/// The worker's NATS side: where handlers read events from, and where the +/// outbox relay writes them to. +pub async fn create_bus( cfg: NatsConfig, -) -> anyhow::Result<(Arc, Arc)> { +) -> anyhow::Result<(Arc, Arc)> { let client = async_nats::connect(&cfg.url).await?; - let publisher: Arc = match cfg.mode { - NatsMode::Core => Arc::new(NatsEventPublisher::new_core( - client.clone(), - cfg.subject_prefix.clone(), - )), - NatsMode::JetStream => Arc::new(NatsEventPublisher::new_jetstream( - client.clone(), - cfg.subject_prefix.clone(), - )), + let (consumer, sink): (Arc, Arc) = match cfg.mode { + NatsMode::Core => { + tracing::info!( + "core NATS mode: no broker-side dedup or redelivery; relying on consumer ledger" + ); + ( + Arc::new(NatsCoreConsumer::create(&cfg, client.clone()).await?), + Arc::new(NatsRelaySink::new_core(client, cfg.subject_prefix)), + ) + } + // The consumer creates the stream the relay publishes into. + NatsMode::JetStream => ( + Arc::new(NatsJetStreamConsumer::create(&cfg, client.clone()).await?), + Arc::new(NatsRelaySink::new_jetstream(client, cfg.subject_prefix)), + ), }; - let consumer: Arc = match cfg.mode { - NatsMode::Core => Arc::new(NatsCoreConsumer::create(&cfg, client).await?), - NatsMode::JetStream => Arc::new(NatsJetStreamConsumer::create(&cfg, client).await?), - }; - - tracing::info!("NATS channel created (mode: {:?})", cfg.mode); - Ok((publisher, consumer)) + tracing::info!("NATS bus created (mode: {:?})", cfg.mode); + Ok((consumer, sink)) } diff --git a/crates/adapters/nats/src/payload.rs b/crates/adapters/nats/src/payload.rs index cc948a08..20248847 100644 --- a/crates/adapters/nats/src/payload.rs +++ b/crates/adapters/nats/src/payload.rs @@ -1,2 +1 @@ pub use event_payload::EventMessage as NatsEventMessage; -pub use event_payload::EventPayload as NatsEventPayload; diff --git a/crates/adapters/nats/src/publisher.rs b/crates/adapters/nats/src/publisher.rs deleted file mode 100644 index d19d5f18..00000000 --- a/crates/adapters/nats/src/publisher.rs +++ /dev/null @@ -1,60 +0,0 @@ -use async_nats::{Client, jetstream}; -use async_trait::async_trait; -use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher}; - -use crate::{payload::NatsEventPayload, subject::event_to_subject}; - -enum PublisherMode { - Core(Client), - JetStream(jetstream::Context), -} - -pub struct NatsEventPublisher { - mode: PublisherMode, - subject_prefix: String, -} - -impl NatsEventPublisher { - pub fn new_core(client: Client, subject_prefix: String) -> Self { - Self { - mode: PublisherMode::Core(client), - subject_prefix, - } - } - - pub fn new_jetstream(client: Client, subject_prefix: String) -> Self { - Self { - mode: PublisherMode::JetStream(jetstream::new(client)), - subject_prefix, - } - } -} - -#[async_trait] -impl EventPublisher for NatsEventPublisher { - async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { - let subject = event_to_subject(&self.subject_prefix, event); - let payload = serde_json::to_vec(&NatsEventPayload::from(event)) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - match &self.mode { - PublisherMode::Core(client) => client - .publish(subject, payload.into()) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string())), - - PublisherMode::JetStream(js) => js - .publish(subject, payload.into()) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))? - .await - .map(|_| ()) - .map_err(|e| DomainError::InfrastructureError(e.to_string())), - } - } -} - -fn _assert_send_sync() { - fn check() {} - check::(); -} diff --git a/crates/adapters/nats/src/relay.rs b/crates/adapters/nats/src/relay.rs new file mode 100644 index 00000000..52454ffe --- /dev/null +++ b/crates/adapters/nats/src/relay.rs @@ -0,0 +1,104 @@ +use async_nats::{Client, HeaderMap, header::NATS_MESSAGE_ID, jetstream}; +use async_trait::async_trait; +use domain::{ + errors::DomainError, + events::{DomainEvent, EventId}, + ports::EventRelaySink, +}; +use event_payload::EventPayload; + +use crate::{payload::NatsEventMessage, subject::event_to_subject}; + +/// What goes on the wire for one outbox row. Built purely from the row, so +/// relaying it again (after a crash between publish and mark-done) yields the +/// same `Nats-Msg-Id` and JetStream drops the duplicate. +pub(crate) struct RelayMessage { + pub subject: String, + pub payload: Vec, + /// `None` for rows queued before identities existed: those get no dedup. + pub msg_id: Option, +} + +pub(crate) fn relay_message( + prefix: &str, + id: Option<&EventId>, + event: &DomainEvent, +) -> Result { + let msg_id = id.map(|id| id.value().to_string()); + let payload = serde_json::to_vec(&NatsEventMessage { + event_id: msg_id.clone(), + payload: EventPayload::from(event), + }) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(RelayMessage { + subject: event_to_subject(prefix, event), + payload, + msg_id, + }) +} + +enum Transport { + Core(Client), + JetStream(jetstream::Context), +} + +/// Publishes outbox rows to NATS. JetStream mode waits for the broker ack and +/// sets `Nats-Msg-Id`; core mode is fire-and-forget with no broker-side dedup. +pub struct NatsRelaySink { + transport: Transport, + subject_prefix: String, +} + +impl NatsRelaySink { + pub fn new_core(client: Client, subject_prefix: String) -> Self { + Self { + transport: Transport::Core(client), + subject_prefix, + } + } + + pub fn new_jetstream(client: Client, subject_prefix: String) -> Self { + Self { + transport: Transport::JetStream(jetstream::new(client)), + subject_prefix, + } + } +} + +fn infra(e: impl ToString) -> DomainError { + DomainError::InfrastructureError(e.to_string()) +} + +#[async_trait] +impl EventRelaySink for NatsRelaySink { + async fn relay(&self, id: Option<&EventId>, event: &DomainEvent) -> Result<(), DomainError> { + let message = relay_message(&self.subject_prefix, id, event)?; + match &self.transport { + Transport::Core(client) => client + .publish(message.subject, message.payload.into()) + .await + .map_err(infra), + Transport::JetStream(js) => { + let mut headers = HeaderMap::new(); + if let Some(msg_id) = &message.msg_id { + headers.insert(NATS_MESSAGE_ID, msg_id.as_str()); + } + js.publish_with_headers(message.subject, headers, message.payload.into()) + .await + .map_err(infra)? + .await + .map(|_| ()) + .map_err(infra) + } + } + } +} + +fn _assert_send_sync() { + fn check() {} + check::(); +} + +#[cfg(test)] +#[path = "tests/relay.rs"] +mod tests; diff --git a/crates/adapters/nats/src/tests/consumer.rs b/crates/adapters/nats/src/tests/consumer.rs new file mode 100644 index 00000000..d80c0d0e --- /dev/null +++ b/crates/adapters/nats/src/tests/consumer.rs @@ -0,0 +1,46 @@ +use super::*; +use domain::value_objects::UserId; +use event_payload::{EventMessage, EventPayload}; +use uuid::Uuid; + +fn payload() -> EventPayload { + EventPayload::from(&DomainEvent::UserUpdated { + user_id: UserId::from_uuid(Uuid::new_v4()), + }) +} + +fn encode(event_id: Option<&str>) -> Vec { + serde_json::to_vec(&EventMessage { + event_id: event_id.map(str::to_owned), + payload: payload(), + }) + .unwrap() +} + +#[test] +fn decode_message_reads_the_event_id() { + let id = Uuid::new_v4(); + let (decoded, event) = decode_message(&encode(Some(&id.to_string()))).unwrap(); + assert_eq!(decoded.unwrap().value(), id); + assert!(matches!(event, DomainEvent::UserUpdated { .. })); +} + +#[test] +fn decode_message_accepts_a_legacy_payload_without_an_id() { + let legacy = serde_json::to_vec(&payload()).unwrap(); + let (decoded, _) = decode_message(&legacy).unwrap(); + assert!(decoded.is_none()); + let (decoded, _) = decode_message(&encode(None)).unwrap(); + assert!(decoded.is_none()); +} + +#[test] +fn decode_message_rejects_a_malformed_id() { + let err = decode_message(&encode(Some("not-a-uuid"))).unwrap_err(); + assert!(err.to_string().contains("event_id"), "{err}"); +} + +#[test] +fn decode_message_rejects_garbage() { + assert!(decode_message(b"{not json").is_err()); +} diff --git a/crates/adapters/nats/src/tests/relay.rs b/crates/adapters/nats/src/tests/relay.rs new file mode 100644 index 00000000..b1f741f1 --- /dev/null +++ b/crates/adapters/nats/src/tests/relay.rs @@ -0,0 +1,37 @@ +use super::*; +use domain::value_objects::UserId; +use uuid::Uuid; + +fn event() -> DomainEvent { + DomainEvent::UserUpdated { + user_id: UserId::from_uuid(Uuid::new_v4()), + } +} + +#[test] +fn relaying_the_same_row_twice_sets_the_same_msg_id() { + let id = EventId::new(); + let event = event(); + let first = relay_message("p", Some(&id), &event).unwrap(); + let second = relay_message("p", Some(&id), &event).unwrap(); + assert_eq!(first.msg_id, Some(id.value().to_string())); + assert_eq!(first.msg_id, second.msg_id); + assert_eq!(first.subject, "p.user.updated"); +} + +#[test] +fn a_legacy_row_without_an_id_has_no_msg_id() { + let message = relay_message("p", None, &event()).unwrap(); + assert_eq!(message.msg_id, None); + let decoded: serde_json::Value = serde_json::from_slice(&message.payload).unwrap(); + assert!(decoded.get("event_id").is_none()); +} + +#[test] +fn the_body_carries_the_event_id_for_the_consumer() { + let id = EventId::new(); + let message = relay_message("p", Some(&id), &event()).unwrap(); + let decoded: serde_json::Value = serde_json::from_slice(&message.payload).unwrap(); + assert_eq!(decoded["event_id"], id.value().to_string()); + assert_eq!(decoded["type"], "UserUpdated"); +} diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index f580ea8e..1f28bd74 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -34,6 +34,7 @@ anyhow = { workspace = true } async-trait = { workspace = true } axum = { workspace = true } tokio = { workspace = true } +futures = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/crates/bootstrap/src/event_bus.rs b/crates/bootstrap/src/event_bus.rs index 885c59b5..d83486a1 100644 --- a/crates/bootstrap/src/event_bus.rs +++ b/crates/bootstrap/src/event_bus.rs @@ -2,7 +2,7 @@ use std::sync::Arc; #[cfg(feature = "nats")] use anyhow::Context; -use domain::ports::{EventConsumer, EventPublisher}; +use domain::ports::{EventConsumer, EventPublisher, EventRelaySink}; #[derive(Clone, Copy)] pub enum EventBusBackend { @@ -36,21 +36,114 @@ pub async fn create_publisher( sqlite::SqliteEventQueue::create_publisher(db_pool.clone(), &config.events).await } +/// What the worker gets from the bus: a publisher for its own events, the +/// consumer its handlers read, and, when the bus is not the outbox, the relay +/// that moves outbox rows onto it. +struct Wiring { + publisher: Arc, + consumer: Arc, + relay: Option<(Arc, Arc)>, +} + +/// The worker always writes to the outbox. With an external bus +/// (`bus = Some((consumer, sink))`) handlers read from the bus and the relay +/// drains the outbox into it; without one, handlers drain the outbox directly. +fn compose( + (publisher, outbox): (Arc, Arc), + bus: Option<(Arc, Arc)>, +) -> Wiring { + match bus { + None => Wiring { + publisher, + consumer: outbox, + relay: None, + }, + Some((consumer, sink)) => Wiring { + publisher, + consumer, + relay: Some((outbox, sink)), + }, + } +} + pub async fn create_channel( db_pool: &sqlx::SqlitePool, config: &config::AppConfig, ) -> anyhow::Result<(Arc, Arc)> { - match EventBusBackend::from_config(&config.events)? { + 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"); - Ok(sqlite::SqliteEventQueue::create_channel(db_pool.clone(), &config.events).await?) + compose(outbox, None) } #[cfg(feature = "nats")] EventBusBackend::Nats => { let cfg = nats::NatsConfig::from_config(&config.nats) .context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?; - tracing::info!("event bus: NATS ({})", cfg.url); - Ok(nats::create_channel(cfg).await?) + tracing::info!("event bus: NATS ({}), relaying the outbox", cfg.url); + compose(outbox, Some(nats::create_bus(cfg).await?)) + } + }; + if let Some((outbox, sink)) = wiring.relay { + crate::outbox_relay::spawn(outbox, sink); + } + Ok((wiring.publisher, wiring.consumer)) +} + +#[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> { + Box::pin(futures::stream::empty()) + } + } + + fn outbox() -> (Arc, Arc) { + (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 queue_ptr = Arc::as_ptr(&queue); + let bus_consumer: Arc = Arc::new(Nop); + let bus_ptr = Arc::as_ptr(&bus_consumer); + let sink: Arc = Arc::new(Nop); + + let wiring = compose((publisher, queue), Some((bus_consumer, sink))); + + let (source, _) = wiring.relay.expect("relay spawned in bus mode"); + assert!(std::ptr::addr_eq(Arc::as_ptr(&source), queue_ptr)); + assert!(std::ptr::addr_eq(Arc::as_ptr(&wiring.consumer), bus_ptr)); + } } diff --git a/crates/bootstrap/src/lib.rs b/crates/bootstrap/src/lib.rs index 58711843..150daf29 100644 --- a/crates/bootstrap/src/lib.rs +++ b/crates/bootstrap/src/lib.rs @@ -10,6 +10,7 @@ mod event_bus; mod federation; #[cfg(feature = "federation")] mod follow_backfill_handler; +pub mod outbox_relay; pub mod repositories; mod server; mod tracing_setup; diff --git a/crates/bootstrap/src/outbox_relay.rs b/crates/bootstrap/src/outbox_relay.rs new file mode 100644 index 00000000..5264adf0 --- /dev/null +++ b/crates/bootstrap/src/outbox_relay.rs @@ -0,0 +1,48 @@ +//! Moves outbox rows onto the message bus. The worker runs it when the bus is +//! not the outbox itself (`EVENT_BUS_BACKEND=nats`). +//! +//! Rows are claimed by the SQLite queue's own atomic claim, so a row is only +//! ever relayed by one claimant and failures take the queue's existing +//! backoff and dead-letter path. A row is marked done only after the sink +//! reports the bus accepted it; a crash in between re-relays the row, and the +//! bus de-duplicates on the event id. + +use std::sync::Arc; + +use domain::{ + events::EventEnvelope, + ports::{EventConsumer, EventRelaySink}, +}; +use futures::StreamExt; +use tokio::task::JoinHandle; + +pub fn spawn(source: Arc, sink: Arc) -> JoinHandle<()> { + tokio::spawn(async move { + let mut rows = source.consume(); + while let Some(next) = rows.next().await { + match next { + Ok(envelope) => relay_envelope(envelope, sink.as_ref()).await, + // A row that cannot even be decoded stays claimed; there is + // no handle to settle it with. + Err(e) => tracing::error!("outbox relay: undecodable row: {e}"), + } + } + tracing::warn!("outbox relay: source closed, relay stopped"); + }) +} + +async fn relay_envelope(envelope: EventEnvelope, sink: &dyn EventRelaySink) { + match sink.relay(envelope.id(), &envelope.event).await { + Ok(()) => { + if let Err(e) = envelope.ack().await { + tracing::error!("outbox relay: published but could not mark done: {e}"); + } + } + Err(e) => { + tracing::warn!("outbox relay: publish failed, will retry: {e}"); + if let Err(e) = envelope.nack().await { + tracing::error!("outbox relay: could not nack: {e}"); + } + } + } +} diff --git a/crates/bootstrap/tests/outbox_relay_test.rs b/crates/bootstrap/tests/outbox_relay_test.rs new file mode 100644 index 00000000..3cad162e --- /dev/null +++ b/crates/bootstrap/tests/outbox_relay_test.rs @@ -0,0 +1,115 @@ +//! The relay claims outbox rows with the SQLite queue's own atomic claim and +//! settles each row from the sink's verdict, with fakes at the sink boundary. + +mod support; + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +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, + seen: Mutex>>, +} + +#[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 self.fail { + Err(DomainError::InfrastructureError("bus down".into())) + } else { + Ok(()) + } + } +} + +async fn outbox() -> (sqlx::SqlitePool, Arc, SqliteEventQueue) { + let pool = support::memory_pool().await; + sqlite::migrate(&pool).await.unwrap(); + let config = config::EventsConfig { + queue_poll_interval_ms: 10, + ..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) { + 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..200 { + if row(pool).await.0 == status { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("row never reached {status}: {:?}", row(pool).await); +} + +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 relay = bootstrap::outbox_relay::spawn(Arc::new(queue), sink.clone()); + wait_for(&pool, "done").await; + relay.abort(); + + 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_failed_publish_leaves_the_row_pending_with_an_attempt_counted() { + let (pool, publisher, queue) = outbox().await; + publisher.publish(&event()).await.unwrap(); + let sink = Arc::new(FakeSink { + fail: true, + ..Default::default() + }); + + let relay = bootstrap::outbox_relay::spawn(Arc::new(queue), sink.clone()); + for _ in 0..200 { + if row(&pool).await.1 == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + relay.abort(); + + let (status, attempts, _) = row(&pool).await; + assert_eq!((status.as_str(), attempts), ("pending", 1)); +} diff --git a/crates/domain/src/ports/events.rs b/crates/domain/src/ports/events.rs index 99df3530..4d3f72e3 100644 --- a/crates/domain/src/ports/events.rs +++ b/crates/domain/src/ports/events.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use crate::{ errors::DomainError, - events::{DomainEvent, EventEnvelope}, + events::{DomainEvent, EventEnvelope, EventId}, }; #[async_trait] @@ -10,6 +10,13 @@ pub trait EventPublisher: Send + Sync { async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>; } +/// Delivers one outbox row to the message bus. `Ok` means the bus accepted it +/// (for a brokered bus: acknowledged), so the row may be marked done. +#[async_trait] +pub trait EventRelaySink: Send + Sync { + async fn relay(&self, id: Option<&EventId>, event: &DomainEvent) -> Result<(), DomainError>; +} + pub trait EventConsumer: Send + Sync { fn consume(&self) -> futures::stream::BoxStream<'_, Result>; } diff --git a/crates/event-payload/tests/event_payload_test.rs b/crates/event-payload/tests/event_payload_test.rs index 79cb331f..5252bedc 100644 --- a/crates/event-payload/tests/event_payload_test.rs +++ b/crates/event-payload/tests/event_payload_test.rs @@ -131,3 +131,194 @@ fn a_message_round_trips_its_event_id_and_decodes_without_one() { let json = serde_json::to_string(&unit).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), unit); } + +/// One sample per `DomainEvent` variant. `assert_covers_every_variant` is an +/// exhaustive match, so adding a variant breaks the build until it is added +/// here too. +fn every_event() -> Vec { + use domain::{ + models::{ExternalPersonId, PersonId}, + value_objects::{FollowTarget, GoalId, PosterPath, SocialIdentity, WrapUpId}, + }; + let user = || UserId::from_uuid(Uuid::new_v4()); + let movie = || MovieId::from_uuid(Uuid::new_v4()); + let local = || SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())); + let remote = || SocialIdentity::Remote { + actor_url: "https://remote.example/users/a".into(), + }; + let date = |d| chrono::NaiveDate::from_ymd_opt(2026, 1, d).unwrap(); + vec![ + review_logged(), + review_updated(), + movie_discovered(), + DomainEvent::MovieDeleted { + movie_id: movie(), + poster_path: Some(PosterPath::new("p/x.jpg".into()).unwrap()), + }, + DomainEvent::UserUpdated { user_id: user() }, + DomainEvent::ReviewDeleted { + review_id: ReviewId::from_uuid(Uuid::new_v4()), + user_id: user(), + }, + DomainEvent::MovieEnrichmentRequested { + movie_id: movie(), + external_metadata_id: ExternalMetadataId::new("tt7654321".into()).unwrap(), + }, + DomainEvent::PersonEnrichmentRequested { + person_id: PersonId::from_uuid(Uuid::new_v4()), + external_person_id: ExternalPersonId::new("42"), + }, + DomainEvent::ImageStored { key: "k".into() }, + DomainEvent::WatchlistEntryAdded { + user_id: user(), + movie_id: movie(), + movie_title: "Heat".into(), + release_year: 1995, + external_metadata_id: Some("tt0113277".into()), + added_at: fixed_dt(), + }, + DomainEvent::WatchlistEntryRemoved { + user_id: user(), + movie_id: movie(), + }, + DomainEvent::FollowRequested { + follower: user(), + target: FollowTarget::Handle("a@b.example".into()), + }, + DomainEvent::FollowAccepted { + owner: user(), + requester: remote(), + }, + DomainEvent::FollowRejected { + owner: user(), + requester: local(), + }, + DomainEvent::Unfollowed { + follower: user(), + target: remote(), + }, + DomainEvent::FollowerRemoved { + owner: user(), + follower: local(), + }, + DomainEvent::ActorBlocked { + blocker: user(), + target: remote(), + }, + DomainEvent::ActorUnblocked { + blocker: user(), + target: remote(), + }, + DomainEvent::BackfillFollower { + owner_user_id: user(), + follower_inbox_url: "https://remote.example/inbox".into(), + }, + DomainEvent::FederationDeliveryRequested { + inbox_url: "https://remote.example/inbox".into(), + activity_json: serde_json::json!({"type": "Create"}), + signing_actor_id: Uuid::new_v4(), + }, + DomainEvent::WatchEventIngested { + user_id: user(), + title: "Heat".into(), + source: "jellyfin".into(), + }, + DomainEvent::WrapUpRequested { + wrapup_id: WrapUpId::from_uuid(Uuid::new_v4()), + user_id: Some(user()), + start_date: date(1), + end_date: date(2), + }, + DomainEvent::WrapUpCompleted { + wrapup_id: WrapUpId::from_uuid(Uuid::new_v4()), + }, + DomainEvent::SearchReindexRequested, + DomainEvent::PosterSynced { movie_id: movie() }, + DomainEvent::GoalCreated { + goal_id: GoalId::from_uuid(Uuid::new_v4()), + user_id: user(), + year: 2026, + target_count: 50, + }, + DomainEvent::GoalUpdated { + goal_id: GoalId::from_uuid(Uuid::new_v4()), + user_id: user(), + year: 2026, + target_count: 60, + }, + DomainEvent::GoalDeleted { + goal_id: GoalId::from_uuid(Uuid::new_v4()), + user_id: user(), + year: 2026, + }, + DomainEvent::UserDeleted { user_id: user() }, + DomainEvent::UserAccountMoved { + user_id: user(), + new_actor_url: "https://new.example/users/a".into(), + }, + ] +} + +fn assert_covers_every_variant(event: &DomainEvent) { + use DomainEvent::*; + match event { + ReviewLogged { .. } + | ReviewUpdated { .. } + | MovieDiscovered { .. } + | MovieDeleted { .. } + | UserUpdated { .. } + | ReviewDeleted { .. } + | MovieEnrichmentRequested { .. } + | PersonEnrichmentRequested { .. } + | ImageStored { .. } + | WatchlistEntryAdded { .. } + | WatchlistEntryRemoved { .. } + | FollowRequested { .. } + | FollowAccepted { .. } + | FollowRejected { .. } + | Unfollowed { .. } + | FollowerRemoved { .. } + | ActorBlocked { .. } + | ActorUnblocked { .. } + | BackfillFollower { .. } + | FederationDeliveryRequested { .. } + | WatchEventIngested { .. } + | WrapUpRequested { .. } + | WrapUpCompleted { .. } + | SearchReindexRequested + | PosterSynced { .. } + | GoalCreated { .. } + | GoalUpdated { .. } + | GoalDeleted { .. } + | UserDeleted { .. } + | UserAccountMoved { .. } => {} + } +} + +#[test] +fn every_event_round_trips_through_an_event_message_with_and_without_an_id() { + use event_payload::EventMessage; + + let events = every_event(); + events.iter().for_each(assert_covers_every_variant); + let types: std::collections::HashSet<_> = events + .iter() + .map(|e| EventPayload::from(e).event_type()) + .collect(); + assert_eq!(types.len(), events.len(), "one sample per variant"); + + for event in &events { + for id in [Some(Uuid::new_v4().to_string()), None] { + let message = EventMessage { + event_id: id.clone(), + payload: EventPayload::from(event), + }; + let json = serde_json::to_string(&message).unwrap(); + let back: EventMessage = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("{} decode: {e}", message.payload.event_type())); + assert_eq!(back.event_id, id); + let recovered = DomainEvent::try_from(back.payload).unwrap(); + assert_eq!(EventPayload::from(event), EventPayload::from(&recovered)); + } + } +} -- 2.49.1 From fa8e94817ad34ac3ccf6d5f28c0a3125e0f93954 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 00:11:06 +0100 Subject: [PATCH 055/104] fix: relay shutdown, claim lease, dead-letter undecodable, dedup window --- crates/adapters/nats/src/consumer.rs | 7 ++ .../migrations/0101_outbox_identity.sql | 3 + crates/adapters/sqlite/src/event_queue/mod.rs | 52 ++++++++--- .../adapters/sqlite/src/event_queue/tests.rs | 86 +++++++++++++++++++ .../tests/fixtures/migration_ledger.txt | 2 +- crates/bootstrap/src/event_bus.rs | 20 ++--- crates/bootstrap/src/outbox_relay.rs | 61 +++++++++---- crates/bootstrap/src/worker.rs | 61 ++++++++++++- crates/bootstrap/tests/outbox_relay_test.rs | 53 +++++++++++- crates/worker/src/main.rs | 4 +- 10 files changed, 304 insertions(+), 45 deletions(-) diff --git a/crates/adapters/nats/src/consumer.rs b/crates/adapters/nats/src/consumer.rs index f428ab80..59508212 100644 --- a/crates/adapters/nats/src/consumer.rs +++ b/crates/adapters/nats/src/consumer.rs @@ -106,6 +106,12 @@ fn consume_from_rx(rx: EnvelopeRx) -> BoxStream<'static, Result { for row in rows { + let row_id = row.id; let envelope = decode_row(&pool, row, config.max_attempts); + if let Err(e) = &envelope { + dead_letter_undecodable(&pool, row_id, e).await; + } if tx.send(envelope).await.is_err() { tracing::info!("sqlite event queue consumer closed"); return; @@ -137,27 +146,50 @@ async fn claim_batch( pool: &SqlitePool, config: &DbEventQueueConfig, ) -> Result, DomainError> { - let mut rows = sqlx::query_as::<_, QueueRow>( - "UPDATE event_queue SET status = 'processing' + let sql = format!( + "UPDATE event_queue + SET status = 'processing', claimed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id IN ( SELECT id FROM event_queue - WHERE status = 'pending' - AND next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE (status = 'pending' + AND next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + OR (status = 'processing' + AND (claimed_at IS NULL + OR claimed_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-{CLAIM_LEASE_SECONDS} seconds'))) ORDER BY next_attempt_at ASC LIMIT ? ) - RETURNING id, payload, event_id, attempts, next_attempt_at", - ) - .bind(config.batch_size) - .fetch_all(pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("claim batch: {e}")))?; + RETURNING id, payload, event_id, attempts, next_attempt_at" + ); + let mut rows = sqlx::query_as::<_, QueueRow>(&sql) + .bind(config.batch_size) + .fetch_all(pool) + .await + .map_err(|e| DomainError::InfrastructureError(format!("claim batch: {e}")))?; // RETURNING order is unspecified; deliver oldest first. rows.sort_by(|a, b| (&a.next_attempt_at, a.id).cmp(&(&b.next_attempt_at, b.id))); Ok(rows) } +/// A row that cannot be decoded can never succeed, and has no ack handle to +/// settle it with; park it so it is neither retried forever nor left claimed. +async fn dead_letter_undecodable(pool: &SqlitePool, row_id: i64, error: &DomainError) { + tracing::error!( + row_id, + "event queue row is undecodable, dead-lettering: {error}" + ); + let result = + sqlx::query("UPDATE event_queue SET status = 'dead_lettered', last_error = ? WHERE id = ?") + .bind(format!("undecodable: {error}")) + .bind(row_id) + .execute(pool) + .await; + if let Err(e) = result { + tracing::error!(row_id, "could not dead-letter undecodable row: {e}"); + } +} + fn decode_row( pool: &SqlitePool, row: QueueRow, diff --git a/crates/adapters/sqlite/src/event_queue/tests.rs b/crates/adapters/sqlite/src/event_queue/tests.rs index 79b66e9f..8025b5f6 100644 --- a/crates/adapters/sqlite/src/event_queue/tests.rs +++ b/crates/adapters/sqlite/src/event_queue/tests.rs @@ -190,3 +190,89 @@ mod identity { assert!(envelope.id().is_none()); } } + +mod lease { + use domain::ports::EventConsumer; + use futures::StreamExt; + + use super::{claim_batch, config}; + use crate::event_queue::SqliteEventQueue; + + async fn pool() -> sqlx::SqlitePool { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + crate::migrate(&pool).await.unwrap(); + pool + } + + async fn claim_then_age(pool: &sqlx::SqlitePool, claimed_at: Option<&str>) { + super::insert(pool, 1).await; + assert_eq!(claim_batch(pool, &config(10)).await.unwrap().len(), 1); + sqlx::query("UPDATE event_queue SET claimed_at = ?") + .bind(claimed_at) + .execute(pool) + .await + .unwrap(); + } + + fn ago(seconds: i64) -> String { + (chrono::Utc::now() - chrono::Duration::seconds(seconds)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string() + } + + #[tokio::test] + async fn an_expired_claim_is_reclaimed() { + let pool = pool().await; + claim_then_age(&pool, Some(&ago(super::super::CLAIM_LEASE_SECONDS + 60))).await; + + let again = claim_batch(&pool, &config(10)).await.unwrap(); + + assert_eq!(again.len(), 1, "stranded row is claimed again"); + let claimed: String = sqlx::query_scalar("SELECT claimed_at FROM event_queue") + .fetch_one(&pool) + .await + .unwrap(); + assert!(claimed > ago(60), "lease restarted: {claimed}"); + } + + #[tokio::test] + async fn a_legacy_processing_row_without_claimed_at_counts_as_expired() { + let pool = pool().await; + claim_then_age(&pool, None).await; + + assert_eq!(claim_batch(&pool, &config(10)).await.unwrap().len(), 1); + } + + #[tokio::test] + async fn a_fresh_claim_is_not_reclaimed() { + let pool = pool().await; + claim_then_age(&pool, Some(&ago(30))).await; + + assert!(claim_batch(&pool, &config(10)).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn an_undecodable_row_is_dead_lettered_not_left_processing() { + let pool = pool().await; + sqlx::query("INSERT INTO event_queue (event_type, payload) VALUES ('t', 'not json')") + .execute(&pool) + .await + .unwrap(); + let q = SqliteEventQueue::create(pool.clone(), config(10)); + + let first = q.consume().next().await.unwrap(); + assert!(first.is_err()); + + let (status, error): (String, Option) = + sqlx::query_as("SELECT status, last_error FROM event_queue") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(status, "dead_lettered"); + assert!(error.unwrap().contains("undecodable")); + } +} diff --git a/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt index 93d4a5c3..33b259e2 100644 --- a/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt +++ b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt @@ -29,4 +29,4 @@ 29 56F4F7B41A09F07211D7543204F0EAC93B7BFB15AFD9BC90DF2654DAE5BE96B1E358B50E2190BF25739C5F0B1FD4B806 30 DAAA0AF35B4ACB271A9D8068B82F02A72EEC62F3AC07ED504C7A7887976C2C8BACB35FFF477B63A931DF15F79018390D 100 310ADC9ABF0CC2F8E9EDAB33168D009D64228FF5BD8D2A14185513699EBB782606A1EEA8FEE6EC6AF65D72396153F13F -101 D4712BF715F8BD86502ED8162488998B1BAFF41C241072173BC2210A3676C05B5B373DD3ED33A16B473A2EC983211302 +101 C0EE8C5B9FA6A2BE75674A10A008B895FFAF8056139FE0C0F9936B27BA06AFC71B1DC5BD8318948F6E9FCF0689823B71 diff --git a/crates/bootstrap/src/event_bus.rs b/crates/bootstrap/src/event_bus.rs index d83486a1..34fd7f00 100644 --- a/crates/bootstrap/src/event_bus.rs +++ b/crates/bootstrap/src/event_bus.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use crate::outbox_relay::OutboxRelay; #[cfg(feature = "nats")] use anyhow::Context; use domain::ports::{EventConsumer, EventPublisher, EventRelaySink}; @@ -42,7 +43,7 @@ pub async fn create_publisher( struct Wiring { publisher: Arc, consumer: Arc, - relay: Option<(Arc, Arc)>, + relay: Option, } /// The worker always writes to the outbox. With an external bus @@ -61,7 +62,7 @@ fn compose( Some((consumer, sink)) => Wiring { publisher, consumer, - relay: Some((outbox, sink)), + relay: Some(OutboxRelay::new(outbox, sink)), }, } } @@ -69,7 +70,11 @@ fn compose( pub async fn create_channel( db_pool: &sqlx::SqlitePool, config: &config::AppConfig, -) -> anyhow::Result<(Arc, Arc)> { +) -> anyhow::Result<( + Arc, + Arc, + Option, +)> { let outbox = sqlite::SqliteEventQueue::create_channel(db_pool.clone(), &config.events).await?; let wiring = match EventBusBackend::from_config(&config.events)? { EventBusBackend::Db => { @@ -84,10 +89,7 @@ pub async fn create_channel( compose(outbox, Some(nats::create_bus(cfg).await?)) } }; - if let Some((outbox, sink)) = wiring.relay { - crate::outbox_relay::spawn(outbox, sink); - } - Ok((wiring.publisher, wiring.consumer)) + Ok((wiring.publisher, wiring.consumer, wiring.relay)) } #[cfg(test)] @@ -135,15 +137,13 @@ mod tests { #[test] fn bus_mode_relays_the_outbox_and_consumes_the_bus() { let (publisher, queue) = outbox(); - let queue_ptr = Arc::as_ptr(&queue); let bus_consumer: Arc = Arc::new(Nop); let bus_ptr = Arc::as_ptr(&bus_consumer); let sink: Arc = Arc::new(Nop); let wiring = compose((publisher, queue), Some((bus_consumer, sink))); - let (source, _) = wiring.relay.expect("relay spawned in bus mode"); - assert!(std::ptr::addr_eq(Arc::as_ptr(&source), queue_ptr)); + assert!(wiring.relay.is_some(), "relay built in bus mode"); assert!(std::ptr::addr_eq(Arc::as_ptr(&wiring.consumer), bus_ptr)); } } diff --git a/crates/bootstrap/src/outbox_relay.rs b/crates/bootstrap/src/outbox_relay.rs index 5264adf0..86d25152 100644 --- a/crates/bootstrap/src/outbox_relay.rs +++ b/crates/bootstrap/src/outbox_relay.rs @@ -1,11 +1,16 @@ //! Moves outbox rows onto the message bus. The worker runs it when the bus is //! not the outbox itself (`EVENT_BUS_BACKEND=nats`). //! -//! Rows are claimed by the SQLite queue's own atomic claim, so a row is only -//! ever relayed by one claimant and failures take the queue's existing -//! backoff and dead-letter path. A row is marked done only after the sink -//! reports the bus accepted it; a crash in between re-relays the row, and the -//! bus de-duplicates on the event id. +//! Rows are claimed by the SQLite queue's own atomic claim, so a row is +//! relayed by one claimant at a time, and failures take the queue's backoff +//! and dead-letter path. A row is marked done only after the sink reports the +//! bus accepted it. +//! +//! Delivery is at-least-once. A crash between publish and mark-done leaves the +//! row `processing`; when its claim lease lapses it is claimed and relayed +//! again. The bus drops that repeat while it is inside the broker's dedup +//! window (same `Nats-Msg-Id`); beyond the window, or in core mode, the +//! consumer ledger makes the handlers skip it. use std::sync::Arc; @@ -14,21 +19,43 @@ use domain::{ ports::{EventConsumer, EventRelaySink}, }; use futures::StreamExt; -use tokio::task::JoinHandle; +use tokio::sync::watch; -pub fn spawn(source: Arc, sink: Arc) -> JoinHandle<()> { - tokio::spawn(async move { - let mut rows = source.consume(); - while let Some(next) = rows.next().await { - match next { - Ok(envelope) => relay_envelope(envelope, sink.as_ref()).await, - // A row that cannot even be decoded stays claimed; there is - // no handle to settle it with. - Err(e) => tracing::error!("outbox relay: undecodable row: {e}"), +pub struct OutboxRelay { + source: Arc, + sink: Arc, +} + +impl OutboxRelay { + pub fn new(source: Arc, sink: Arc) -> Self { + Self { source, sink } + } + + /// Runs until `shutdown` fires or the source ends. A shutdown never + /// interrupts a row mid-relay: it is acked or nacked before this returns. + /// Rows the claim already took but the relay had not started stay + /// `processing` and come back when their lease lapses. + pub async fn run(self, mut shutdown: watch::Receiver) { + let mut rows = self.source.consume(); + loop { + tokio::select! { + biased; + _ = shutdown.changed() => { + tracing::info!("outbox relay: shutdown signal received, stopping"); + return; + } + next = rows.next() => match next { + Some(Ok(envelope)) => relay_envelope(envelope, self.sink.as_ref()).await, + // The queue already dead-lettered it and logged the row id. + Some(Err(e)) => tracing::error!("outbox relay: skipped undecodable row: {e}"), + None => { + tracing::error!("outbox relay: source closed"); + return; + } + }, } } - tracing::warn!("outbox relay: source closed, relay stopped"); - }) + } } async fn relay_envelope(envelope: EventEnvelope, sink: &dyn EventRelaySink) { diff --git a/crates/bootstrap/src/worker.rs b/crates/bootstrap/src/worker.rs index 1c49dce9..7530520e 100644 --- a/crates/bootstrap/src/worker.rs +++ b/crates/bootstrap/src/worker.rs @@ -8,9 +8,65 @@ 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 { - pub service: WorkerService, + service: WorkerService, + /// Present when the bus is external: drains the outbox into it. + relay: Option, +} + +impl WorkerApp { + /// Runs the handlers and, when there is one, the outbox relay, until + /// `shutdown` fires. Both stop together and finish their in-flight work. + /// + /// If the relay stops or panics on its own, the worker stops too and + /// returns an error so the process exits non-zero and the supervisor + /// restarts it: a worker that keeps handling events but no longer + /// delivers the outbox would fail silently. + pub async fn run(self, mut shutdown: watch::Receiver) -> 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(()), + } + } } /// Wires the background worker: event handlers, periodic jobs (already @@ -58,7 +114,7 @@ async fn assemble( .clone() .map(|c| c as Arc); - let (event_publisher_arc, consumer_arc) = + let (event_publisher_arc, consumer_arc, relay) = event_bus::create_channel(&db.db_pool, config).await?; let worker_services = application::WorkerServices { @@ -246,5 +302,6 @@ async fn assemble( Ok(WorkerApp { service: WorkerService::new(consumer_arc, handlers), + relay, }) } diff --git a/crates/bootstrap/tests/outbox_relay_test.rs b/crates/bootstrap/tests/outbox_relay_test.rs index 3cad162e..e424af6a 100644 --- a/crates/bootstrap/tests/outbox_relay_test.rs +++ b/crates/bootstrap/tests/outbox_relay_test.rs @@ -5,6 +5,7 @@ mod support; use std::sync::{Arc, Mutex}; use std::time::Duration; +use tokio::sync::Notify; use async_trait::async_trait; use domain::{ @@ -19,6 +20,9 @@ use uuid::Uuid; #[derive(Default)] struct FakeSink { fail: bool, + /// When set, `relay` signals `started` then takes this long. + slow: Option, + started: Notify, seen: Mutex>>, } @@ -26,6 +30,10 @@ struct FakeSink { 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; + } if self.fail { Err(DomainError::InfrastructureError("bus down".into())) } else { @@ -65,6 +73,18 @@ async fn wait_for(pool: &sqlx::SqlitePool, status: &str) { panic!("row never reached {status}: {:?}", row(pool).await); } +fn start( + queue: SqliteEventQueue, + sink: Arc, +) -> ( + tokio::sync::watch::Sender, + tokio::task::JoinHandle<()>, +) { + let (stop, rx) = tokio::sync::watch::channel(false); + let relay = bootstrap::outbox_relay::OutboxRelay::new(Arc::new(queue), sink); + (stop, tokio::spawn(relay.run(rx))) +} + fn event() -> DomainEvent { DomainEvent::UserUpdated { user_id: UserId::from_uuid(Uuid::new_v4()), @@ -77,9 +97,10 @@ async fn relay_marks_rows_done_only_after_publish_succeeds() { publisher.publish(&event()).await.unwrap(); let sink = Arc::new(FakeSink::default()); - let relay = bootstrap::outbox_relay::spawn(Arc::new(queue), sink.clone()); + 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); @@ -101,7 +122,7 @@ async fn a_failed_publish_leaves_the_row_pending_with_an_attempt_counted() { ..Default::default() }); - let relay = bootstrap::outbox_relay::spawn(Arc::new(queue), sink.clone()); + let (stop, relay) = start(queue, sink.clone()); for _ in 0..200 { if row(&pool).await.1 == 1 { break; @@ -109,7 +130,35 @@ async fn a_failed_publish_leaves_the_row_pending_with_an_attempt_counted() { 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", 1)); } + +#[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 = 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"); +} diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 592476b4..6be9ed37 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -15,7 +15,5 @@ async fn main() -> anyhow::Result<()> { }); tracing::info!("worker started"); - app.service.run(shutdown_rx).await; - - Ok(()) + app.run(shutdown_rx).await } -- 2.49.1 From 5681d0c034e94a61c766ecf8c438b26a1e973670 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 00:17:28 +0100 Subject: [PATCH 056/104] feat: processed-event ledger, idempotent dispatch --- .../adapters/activitypub/src/event_handler.rs | 4 + .../adapters/image-converter/src/handler.rs | 4 + crates/adapters/object-storage/src/lib.rs | 4 + crates/adapters/sqlite/src/lib.rs | 12 +- .../adapters/sqlite/src/repositories/mod.rs | 2 + .../src/repositories/processed_events/mod.rs | 120 ++++++++++++ crates/application/src/jobs/mod.rs | 2 + .../src/jobs/processed_events_cleanup.rs | 39 ++++ .../src/movies/discovery_indexer.rs | 4 + .../application/src/movies/event_handler.rs | 4 + .../src/movies/poster_sync_handler.rs | 4 + .../application/src/movies/reindex_search.rs | 4 + .../application/src/movies/search_cleanup.rs | 4 + .../application/src/person/event_handler.rs | 4 + crates/application/src/worker.rs | 79 ++++++-- .../application/src/wrapup/event_handler.rs | 4 + crates/application/tests/jobs.rs | 2 + .../jobs/processed_events_cleanup_test.rs | 35 ++++ .../application/tests/worker/worker_test.rs | 181 +++++++++++++++++- crates/bootstrap/src/database.rs | 2 + .../bootstrap/src/follow_backfill_handler.rs | 4 + crates/bootstrap/src/worker.rs | 11 +- crates/bootstrap/tests/bootstrap_test.rs | 31 +++ crates/domain/src/ports/events.rs | 3 + crates/domain/src/ports/mod.rs | 2 + crates/domain/src/ports/processed_events.rs | 16 ++ crates/domain/src/testing/fakes/mod.rs | 1 + crates/domain/src/testing/fakes/store.rs | 19 ++ .../testing/fakes/store_processed_events.rs | 33 ++++ 29 files changed, 610 insertions(+), 24 deletions(-) create mode 100644 crates/adapters/sqlite/src/repositories/processed_events/mod.rs create mode 100644 crates/application/src/jobs/processed_events_cleanup.rs create mode 100644 crates/application/tests/jobs.rs create mode 100644 crates/application/tests/jobs/processed_events_cleanup_test.rs create mode 100644 crates/domain/src/ports/processed_events.rs create mode 100644 crates/domain/src/testing/fakes/store_processed_events.rs diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index 09f6b829..7ee4e60f 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -55,6 +55,10 @@ impl ActivityPubEventHandler { #[async_trait] impl EventHandler for ActivityPubEventHandler { + fn name(&self) -> &'static str { + "activitypub" + } + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { match event { DomainEvent::ReviewLogged { diff --git a/crates/adapters/image-converter/src/handler.rs b/crates/adapters/image-converter/src/handler.rs index e0ae7b14..4664e687 100644 --- a/crates/adapters/image-converter/src/handler.rs +++ b/crates/adapters/image-converter/src/handler.rs @@ -31,6 +31,10 @@ impl ImageConversionHandler { #[async_trait] impl EventHandler for ImageConversionHandler { + fn name(&self) -> &'static str { + "image_conversion" + } + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { let key = match event { DomainEvent::ImageStored { key } => key.clone(), diff --git a/crates/adapters/object-storage/src/lib.rs b/crates/adapters/object-storage/src/lib.rs index a1144e57..b55fde82 100644 --- a/crates/adapters/object-storage/src/lib.rs +++ b/crates/adapters/object-storage/src/lib.rs @@ -112,6 +112,10 @@ impl ImageCleanupHandler { #[async_trait] impl EventHandler for ImageCleanupHandler { + fn name(&self) -> &'static str { + "image_cleanup" + } + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { let poster_path = match event { DomainEvent::MovieDeleted { poster_path, .. } => poster_path, diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 34bb266b..7a5c486f 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -10,12 +10,12 @@ pub use repositories::{ SqliteApContentQuery, SqliteDiaryRepository, SqliteImageRefAdapter, SqliteImportProfileRepository, SqliteImportSessionRepository, SqliteInsightsQuery, SqliteMovieDeduplicator, SqliteMovieProfileRepository, SqliteMovieRepository, - SqlitePersonAdapter, SqliteProfileFieldsRepository, SqliteRefreshSessionAdapter, - SqliteRemoteGoalRepository, SqliteReviewRepository, SqliteSocialRepository, - SqliteStatsRepository, SqliteUserRepository, SqliteWatchEventRepository, - SqliteWatchlistRepository, SqliteWebhookTokenRepository, SqliteWrapUpRepository, - SqliteWrapUpStatsQuery, create_federated_profile_query, create_image_ref, - create_person_adapter, create_search_adapter, + SqlitePersonAdapter, SqliteProcessedEventLedger, SqliteProfileFieldsRepository, + SqliteRefreshSessionAdapter, SqliteRemoteGoalRepository, SqliteReviewRepository, + SqliteSocialRepository, SqliteStatsRepository, SqliteUserRepository, + SqliteWatchEventRepository, SqliteWatchlistRepository, SqliteWebhookTokenRepository, + SqliteWrapUpRepository, SqliteWrapUpStatsQuery, create_federated_profile_query, + create_image_ref, create_person_adapter, create_search_adapter, }; use repositories::{SqliteGoalRepository, SqliteUserSettingsRepository}; diff --git a/crates/adapters/sqlite/src/repositories/mod.rs b/crates/adapters/sqlite/src/repositories/mod.rs index 53803c82..ac467d51 100644 --- a/crates/adapters/sqlite/src/repositories/mod.rs +++ b/crates/adapters/sqlite/src/repositories/mod.rs @@ -8,6 +8,7 @@ mod insights; mod movie; mod movie_dedup; mod persons; +mod processed_events; mod profile; mod profile_fields; mod refresh_sessions; @@ -32,6 +33,7 @@ pub use insights::SqliteInsightsQuery; pub use movie::SqliteMovieRepository; pub use movie_dedup::SqliteMovieDeduplicator; pub use persons::{SqlitePersonAdapter, create_person_adapter}; +pub use processed_events::SqliteProcessedEventLedger; pub use profile::SqliteMovieProfileRepository; pub use profile_fields::SqliteProfileFieldsRepository; pub use refresh_sessions::SqliteRefreshSessionAdapter; diff --git a/crates/adapters/sqlite/src/repositories/processed_events/mod.rs b/crates/adapters/sqlite/src/repositories/processed_events/mod.rs new file mode 100644 index 00000000..6dd277b7 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/processed_events/mod.rs @@ -0,0 +1,120 @@ +use async_trait::async_trait; +use domain::{errors::DomainError, events::EventId, ports::ProcessedEventLedger}; +use sqlx::SqlitePool; + +/// Matches the column default in migration 0101, so string comparison orders rows by time. +const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ"; + +pub struct SqliteProcessedEventLedger { + pool: SqlitePool, +} + +impl SqliteProcessedEventLedger { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl ProcessedEventLedger for SqliteProcessedEventLedger { + async fn has_processed(&self, id: &EventId, handler: &str) -> Result { + let found: Option = + sqlx::query_scalar("SELECT 1 FROM processed_events WHERE event_id = ? AND handler = ?") + .bind(id.value().to_string()) + .bind(handler) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(found.is_some()) + } + + async fn record_processed(&self, id: &EventId, handler: &str) -> Result<(), DomainError> { + sqlx::query("INSERT OR IGNORE INTO processed_events (event_id, handler) VALUES (?, ?)") + .bind(id.value().to_string()) + .bind(handler) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(()) + } + + async fn delete_older_than(&self, cutoff: chrono::NaiveDateTime) -> Result { + let result = sqlx::query("DELETE FROM processed_events WHERE processed_at < ?") + .bind(cutoff.format(TIMESTAMP_FORMAT).to_string()) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(result.rows_affected()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn ledger() -> (SqliteProcessedEventLedger, SqlitePool) { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + crate::migrate(&pool).await.unwrap(); + (SqliteProcessedEventLedger::new(pool.clone()), pool) + } + + #[tokio::test] + async fn a_recorded_pair_is_processed_and_others_are_not() { + let (ledger, _) = ledger().await; + let id = EventId::new(); + assert!(!ledger.has_processed(&id, "a").await.unwrap()); + + ledger.record_processed(&id, "a").await.unwrap(); + + assert!(ledger.has_processed(&id, "a").await.unwrap()); + assert!(!ledger.has_processed(&id, "b").await.unwrap()); + assert!(!ledger.has_processed(&EventId::new(), "a").await.unwrap()); + } + + #[tokio::test] + async fn recording_twice_is_not_an_error() { + let (ledger, pool) = ledger().await; + let id = EventId::new(); + ledger.record_processed(&id, "a").await.unwrap(); + ledger.record_processed(&id, "a").await.unwrap(); + + let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM processed_events") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(rows, 1); + } + + #[tokio::test] + async fn delete_older_than_removes_only_rows_before_the_cutoff() { + let (ledger, pool) = ledger().await; + let (old, recent) = (EventId::new(), EventId::new()); + for (id, at) in [ + (old, "2026-01-01T00:00:00Z"), + (recent, "2026-06-01T00:00:00Z"), + ] { + sqlx::query( + "INSERT INTO processed_events (event_id, handler, processed_at) VALUES (?, 'h', ?)", + ) + .bind(id.value().to_string()) + .bind(at) + .execute(&pool) + .await + .unwrap(); + } + let cutoff = chrono::NaiveDate::from_ymd_opt(2026, 3, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(); + + let removed = ledger.delete_older_than(cutoff).await.unwrap(); + + assert_eq!(removed, 1); + assert!(!ledger.has_processed(&old, "h").await.unwrap()); + assert!(ledger.has_processed(&recent, "h").await.unwrap()); + } +} diff --git a/crates/application/src/jobs/mod.rs b/crates/application/src/jobs/mod.rs index 93fab650..58115c4a 100644 --- a/crates/application/src/jobs/mod.rs +++ b/crates/application/src/jobs/mod.rs @@ -1,6 +1,7 @@ mod enrichment_staleness; mod import_cleanup; mod movie_dedup; +mod processed_events_cleanup; mod refresh_session_cleanup; mod watch_event_cleanup; mod wrapup; @@ -8,6 +9,7 @@ mod wrapup; pub use enrichment_staleness::EnrichmentStalenessJob; pub use import_cleanup::ImportSessionCleanupJob; pub use movie_dedup::MovieDeduplicationJob; +pub use processed_events_cleanup::{PROCESSED_EVENTS_RETENTION_DAYS, ProcessedEventsCleanupJob}; pub use refresh_session_cleanup::RefreshSessionCleanupJob; pub use watch_event_cleanup::WatchEventCleanupJob; pub use wrapup::{WrapUpAutoGenerateJob, WrapUpCleanupJob}; diff --git a/crates/application/src/jobs/processed_events_cleanup.rs b/crates/application/src/jobs/processed_events_cleanup.rs new file mode 100644 index 00000000..7e83302d --- /dev/null +++ b/crates/application/src/jobs/processed_events_cleanup.rs @@ -0,0 +1,39 @@ +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::{PeriodicJob, ProcessedEventLedger}, +}; + +/// Ledger rows older than this can no longer matter: the queue gives up on an +/// event long before, so it cannot be redelivered. +pub const PROCESSED_EVENTS_RETENTION_DAYS: i64 = 30; + +pub struct ProcessedEventsCleanupJob { + ledger: Arc, +} + +impl ProcessedEventsCleanupJob { + pub fn new(ledger: Arc) -> Self { + Self { ledger } + } +} + +#[async_trait] +impl PeriodicJob for ProcessedEventsCleanupJob { + fn interval(&self) -> Duration { + Duration::from_secs(24 * 3600) + } + + async fn run(&self) -> Result<(), DomainError> { + let cutoff = chrono::Utc::now().naive_utc() + - chrono::Duration::days(PROCESSED_EVENTS_RETENTION_DAYS); + let n = self.ledger.delete_older_than(cutoff).await?; + if n > 0 { + tracing::info!("processed events cleanup: removed {n} ledger rows"); + } + Ok(()) + } +} diff --git a/crates/application/src/movies/discovery_indexer.rs b/crates/application/src/movies/discovery_indexer.rs index 82819fd3..1ba14266 100644 --- a/crates/application/src/movies/discovery_indexer.rs +++ b/crates/application/src/movies/discovery_indexer.rs @@ -27,6 +27,10 @@ impl MovieDiscoveryIndexer { #[async_trait] impl EventHandler for MovieDiscoveryIndexer { + fn name(&self) -> &'static str { + "movie_discovery_indexer" + } + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { let movie_id = match event { DomainEvent::MovieDiscovered { movie_id, .. } => movie_id.clone(), diff --git a/crates/application/src/movies/event_handler.rs b/crates/application/src/movies/event_handler.rs index fdc70b3f..d904cd64 100644 --- a/crates/application/src/movies/event_handler.rs +++ b/crates/application/src/movies/event_handler.rs @@ -70,6 +70,10 @@ impl MovieEnrichmentHandler { #[async_trait] impl EventHandler for MovieEnrichmentHandler { + fn name(&self) -> &'static str { + "movie_enrichment" + } + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { let (movie_id, external_metadata_id) = match event { DomainEvent::MovieEnrichmentRequested { diff --git a/crates/application/src/movies/poster_sync_handler.rs b/crates/application/src/movies/poster_sync_handler.rs index c7583013..874f6855 100644 --- a/crates/application/src/movies/poster_sync_handler.rs +++ b/crates/application/src/movies/poster_sync_handler.rs @@ -106,6 +106,10 @@ impl PosterSyncHandler { #[async_trait] impl EventHandler for PosterSyncHandler { + fn name(&self) -> &'static str { + "poster_sync" + } + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { let (movie_id, external_metadata_id) = match event { DomainEvent::MovieDiscovered { diff --git a/crates/application/src/movies/reindex_search.rs b/crates/application/src/movies/reindex_search.rs index bf3bba5e..d833eb0f 100644 --- a/crates/application/src/movies/reindex_search.rs +++ b/crates/application/src/movies/reindex_search.rs @@ -132,6 +132,10 @@ impl SearchReindexHandler { #[async_trait] impl EventHandler for SearchReindexHandler { + fn name(&self) -> &'static str { + "search_reindex" + } + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { if !matches!(event, DomainEvent::SearchReindexRequested) { return Ok(()); diff --git a/crates/application/src/movies/search_cleanup.rs b/crates/application/src/movies/search_cleanup.rs index 280c42a4..88f3fddc 100644 --- a/crates/application/src/movies/search_cleanup.rs +++ b/crates/application/src/movies/search_cleanup.rs @@ -24,6 +24,10 @@ impl SearchCleanupHandler { #[async_trait] impl EventHandler for SearchCleanupHandler { + fn name(&self) -> &'static str { + "search_cleanup" + } + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { let movie_id = match event { DomainEvent::MovieDeleted { movie_id, .. } => movie_id.value().to_string(), diff --git a/crates/application/src/person/event_handler.rs b/crates/application/src/person/event_handler.rs index 9d99b93e..9506669b 100644 --- a/crates/application/src/person/event_handler.rs +++ b/crates/application/src/person/event_handler.rs @@ -31,6 +31,10 @@ impl PersonEnrichmentHandler { #[async_trait] impl EventHandler for PersonEnrichmentHandler { + fn name(&self) -> &'static str { + "person_enrichment" + } + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { let (person_id, external_person_id) = match event { DomainEvent::PersonEnrichmentRequested { diff --git a/crates/application/src/worker.rs b/crates/application/src/worker.rs index 64cd50e0..7f970d30 100644 --- a/crates/application/src/worker.rs +++ b/crates/application/src/worker.rs @@ -1,8 +1,9 @@ use std::sync::Arc; use domain::{ + errors::DomainError, events::EventEnvelope, - ports::{EventConsumer, EventHandler}, + ports::{EventConsumer, EventHandler, ProcessedEventLedger}, }; use futures::StreamExt; use tokio::sync::Semaphore; @@ -12,20 +13,42 @@ const DEFAULT_CONCURRENCY: usize = 4; pub struct WorkerService { consumer: Arc, handlers: Vec>, + ledger: Arc, semaphore: Arc, } impl WorkerService { - pub fn new(consumer: Arc, handlers: Vec>) -> Self { - Self { + /// Fails if two handlers share a name: the name keys the processed-event + /// ledger, so a collision would make one handler's success skip the other. + pub fn new( + consumer: Arc, + handlers: Vec>, + ledger: Arc, + ) -> Result { + let mut seen = std::collections::HashSet::new(); + for handler in &handlers { + if !seen.insert(handler.name()) { + return Err(DomainError::ValidationError(format!( + "duplicate event handler name `{}`", + handler.name() + ))); + } + } + Ok(Self { consumer, handlers, + ledger, semaphore: Arc::new(Semaphore::new(DEFAULT_CONCURRENCY)), - } + }) + } + + pub fn handler_names(&self) -> Vec<&'static str> { + self.handlers.iter().map(|h| h.name()).collect() } pub async fn run(self, mut shutdown: tokio::sync::watch::Receiver) { let handlers = Arc::new(self.handlers); + let ledger = self.ledger; let mut tasks = tokio::task::JoinSet::new(); let mut stream = self.consumer.consume(); @@ -43,8 +66,9 @@ impl WorkerService { let permit = self.semaphore.clone().acquire_owned().await; let Ok(permit) = permit else { break }; let h = Arc::clone(&handlers); + let ledger = Arc::clone(&ledger); tasks.spawn(async move { - dispatch(h, envelope).await; + dispatch(h, ledger, envelope).await; drop(permit); }); } @@ -64,15 +88,48 @@ impl WorkerService { } } -async fn dispatch(handlers: Arc>>, envelope: EventEnvelope) { +async fn dispatch( + handlers: Arc>>, + ledger: Arc, + envelope: EventEnvelope, +) { + let id = envelope.id().copied(); let mut any_transient = false; for handler in handlers.iter() { - if let Err(e) = handler.handle(&envelope.event).await { - if e.is_transient() { - tracing::warn!("transient handler error, will retry: {e}"); + let name = handler.name(); + if let Some(id) = &id { + match ledger.has_processed(id, name).await { + Ok(true) => { + tracing::debug!(handler = name, "already processed, skipping"); + continue; + } + Ok(false) => {} + Err(e) => { + // Running anyway could repeat a side effect; retry the delivery instead. + tracing::warn!(handler = name, "ledger lookup failed, will retry: {e}"); + any_transient = true; + continue; + } + } + } + match handler.handle(&envelope.event).await { + Ok(()) => { + if let Some(id) = &id + && let Err(e) = ledger.record_processed(id, name).await + { + // The work is done; failing the delivery would only repeat it. + tracing::warn!(handler = name, "recording processed event failed: {e}"); + } + } + Err(e) if e.is_transient() => { + tracing::warn!(handler = name, "transient handler error, will retry: {e}"); any_transient = true; - } else { - tracing::warn!("permanent handler error (not retrying): {e}"); + } + Err(e) => { + tracing::warn!( + handler = name, + "permanent handler error (not retrying): {e}" + ); } } } diff --git a/crates/application/src/wrapup/event_handler.rs b/crates/application/src/wrapup/event_handler.rs index 351b7693..1d5de35d 100644 --- a/crates/application/src/wrapup/event_handler.rs +++ b/crates/application/src/wrapup/event_handler.rs @@ -32,6 +32,10 @@ impl WrapUpEventHandler { #[async_trait] impl EventHandler for WrapUpEventHandler { + fn name(&self) -> &'static str { + "wrapup" + } + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { match event { DomainEvent::WrapUpRequested { diff --git a/crates/application/tests/jobs.rs b/crates/application/tests/jobs.rs new file mode 100644 index 00000000..e61924a4 --- /dev/null +++ b/crates/application/tests/jobs.rs @@ -0,0 +1,2 @@ +#[path = "jobs/processed_events_cleanup_test.rs"] +mod processed_events_cleanup_test; diff --git a/crates/application/tests/jobs/processed_events_cleanup_test.rs b/crates/application/tests/jobs/processed_events_cleanup_test.rs new file mode 100644 index 00000000..dcefa601 --- /dev/null +++ b/crates/application/tests/jobs/processed_events_cleanup_test.rs @@ -0,0 +1,35 @@ +use application::jobs::{PROCESSED_EVENTS_RETENTION_DAYS, ProcessedEventsCleanupJob}; +use domain::{events::EventId, ports::PeriodicJob, testing::InMemoryStore}; + +#[tokio::test] +async fn removes_only_rows_older_than_the_retention_window() { + let store = InMemoryStore::new(); + let now = chrono::Utc::now().naive_utc(); + let stale = EventId::new(); + let fresh = EventId::new(); + store.seed_processed_event( + &stale, + "h", + now - chrono::Duration::days(PROCESSED_EVENTS_RETENTION_DAYS + 1), + ); + store.seed_processed_event( + &fresh, + "h", + now - chrono::Duration::days(PROCESSED_EVENTS_RETENTION_DAYS - 1), + ); + + ProcessedEventsCleanupJob::new(store.clone()) + .run() + .await + .unwrap(); + + use domain::ports::ProcessedEventLedger; + assert!(!store.has_processed(&stale, "h").await.unwrap()); + assert!(store.has_processed(&fresh, "h").await.unwrap()); +} + +#[test] +fn runs_daily() { + let job = ProcessedEventsCleanupJob::new(InMemoryStore::new()); + assert_eq!(job.interval(), std::time::Duration::from_secs(86_400)); +} diff --git a/crates/application/tests/worker/worker_test.rs b/crates/application/tests/worker/worker_test.rs index 61f4fc43..c3a049f0 100644 --- a/crates/application/tests/worker/worker_test.rs +++ b/crates/application/tests/worker/worker_test.rs @@ -1,6 +1,6 @@ use application::worker::*; use async_trait::async_trait; -use domain::testing::NoopAckHandle; +use domain::testing::{InMemoryStore, NoopAckHandle}; use domain::value_objects::{ExternalMetadataId, MovieId}; use domain::{ errors::DomainError, @@ -10,7 +10,7 @@ use domain::{ events::EventEnvelope, ports::{EventConsumer, EventHandler}, }; -use futures::{stream, stream::BoxStream}; +use futures::{StreamExt as _, stream, stream::BoxStream}; use std::sync::{Arc, Mutex}; struct TrackingAck { @@ -52,6 +52,9 @@ struct RecordingHandler { #[async_trait] impl EventHandler for RecordingHandler { + fn name(&self) -> &'static str { + "recording" + } async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { let label = match event { DomainEvent::MovieDiscovered { .. } => "movie_discovered", @@ -109,9 +112,14 @@ async fn dispatches_to_all_handlers() { calls: Arc::clone(&calls), }; - WorkerService::new(Arc::new(consumer), vec![Arc::new(handler)]) - .run(tokio::sync::watch::channel(false).1) - .await; + WorkerService::new( + Arc::new(consumer), + vec![Arc::new(handler)], + InMemoryStore::new(), + ) + .unwrap() + .run(tokio::sync::watch::channel(false).1) + .await; assert_eq!(*calls.lock().unwrap(), vec!["movie_discovered"]); } @@ -147,7 +155,8 @@ async fn acks_when_all_handlers_succeed() { nacked: Arc::clone(&nacked), }; - WorkerService::new(Arc::new(consumer), vec![]) + WorkerService::new(Arc::new(consumer), vec![], InMemoryStore::new()) + .unwrap() .run(tokio::sync::watch::channel(false).1) .await; @@ -181,6 +190,9 @@ async fn nacks_on_transient_handler_failure() { struct TransientHandler; #[async_trait] impl EventHandler for TransientHandler { + fn name(&self) -> &'static str { + "transient" + } async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> { Err(DomainError::InfrastructureError("network timeout".into())) } @@ -193,7 +205,9 @@ async fn nacks_on_transient_handler_failure() { nacked: Arc::clone(&nacked), }), vec![Arc::new(TransientHandler)], + InMemoryStore::new(), ) + .unwrap() .run(tokio::sync::watch::channel(false).1) .await; @@ -227,6 +241,9 @@ async fn acks_on_permanent_handler_failure() { struct PermanentHandler; #[async_trait] impl EventHandler for PermanentHandler { + fn name(&self) -> &'static str { + "permanent" + } async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> { Err(DomainError::NotFound("movie not on tmdb".into())) } @@ -239,7 +256,9 @@ async fn acks_on_permanent_handler_failure() { nacked: Arc::clone(&nacked), }), vec![Arc::new(PermanentHandler)], + InMemoryStore::new(), ) + .unwrap() .run(tokio::sync::watch::channel(false).1) .await; @@ -276,6 +295,9 @@ async fn nacks_if_any_handler_is_transient() { struct OkHandler; #[async_trait] impl EventHandler for OkHandler { + fn name(&self) -> &'static str { + "ok" + } async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> { Ok(()) } @@ -283,6 +305,9 @@ async fn nacks_if_any_handler_is_transient() { struct TransientHandler; #[async_trait] impl EventHandler for TransientHandler { + fn name(&self) -> &'static str { + "transient" + } async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> { Err(DomainError::InfrastructureError("db gone".into())) } @@ -295,7 +320,9 @@ async fn nacks_if_any_handler_is_transient() { nacked: Arc::clone(&nacked), }), vec![Arc::new(OkHandler), Arc::new(TransientHandler)], + InMemoryStore::new(), ) + .unwrap() .run(tokio::sync::watch::channel(false).1) .await; @@ -308,3 +335,145 @@ async fn nacks_if_any_handler_is_transient() { "should nack when any handler is transient" ); } + +// ── idempotent dispatch ─────────────────────────────────────────────────────── + +struct CountingHandler { + name: &'static str, + calls: Arc>, + fail_transient_times: Mutex, +} + +impl CountingHandler { + fn new(name: &'static str, fail_transient_times: u32) -> (Arc, Arc>) { + let calls = Arc::new(Mutex::new(0)); + ( + Arc::new(Self { + name, + calls: Arc::clone(&calls), + fail_transient_times: Mutex::new(fail_transient_times), + }), + calls, + ) + } +} + +#[async_trait] +impl EventHandler for CountingHandler { + fn name(&self) -> &'static str { + self.name + } + async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> { + *self.calls.lock().unwrap() += 1; + let mut left = self.fail_transient_times.lock().unwrap(); + if *left > 0 { + *left -= 1; + return Err(DomainError::InfrastructureError("flaky".into())); + } + Ok(()) + } +} + +/// Delivers the same event (same id) `times` times, recording ack/nack per delivery. +struct RedeliveringConsumer { + id: Option, + event: DomainEvent, + times: usize, + nacks: Arc>, +} + +struct CountNack(Arc>); + +#[async_trait] +impl AckHandle for CountNack { + async fn ack(&self) -> Result<(), DomainError> { + Ok(()) + } + async fn nack(&self) -> Result<(), DomainError> { + *self.0.lock().unwrap() += 1; + Ok(()) + } +} + +impl EventConsumer for RedeliveringConsumer { + fn consume(&self) -> BoxStream<'_, Result> { + let nacks = Arc::clone(&self.nacks); + let id = self.id; + let event = self.event.clone(); + Box::pin(stream::iter(0..self.times).then(move |_| { + let envelope = + EventEnvelope::new(id, event.clone(), Box::new(CountNack(Arc::clone(&nacks)))); + async move { + // Let the previous delivery's handlers finish first: a real + // broker redelivers only after the nack. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + Ok(envelope) + } + })) + } +} + +#[tokio::test] +async fn a_redelivered_event_skips_handlers_that_already_succeeded() { + let (first, first_calls) = CountingHandler::new("first", 0); + let (second, second_calls) = CountingHandler::new("second", 1); + let nacks = Arc::new(Mutex::new(0)); + let consumer = RedeliveringConsumer { + id: Some(domain::events::EventId::new()), + event: movie_discovered(), + times: 2, + nacks: Arc::clone(&nacks), + }; + + WorkerService::new( + Arc::new(consumer), + vec![first, second], + InMemoryStore::new(), + ) + .unwrap() + .run(tokio::sync::watch::channel(false).1) + .await; + + assert_eq!(*nacks.lock().unwrap(), 1, "only the first delivery nacks"); + assert_eq!( + *first_calls.lock().unwrap(), + 1, + "first ran once, skipped on redelivery" + ); + assert_eq!( + *second_calls.lock().unwrap(), + 2, + "second retried and succeeded" + ); +} + +#[tokio::test] +async fn events_without_id_run_every_time() { + let (handler, calls) = CountingHandler::new("only", 0); + let consumer = RedeliveringConsumer { + id: None, + event: movie_discovered(), + times: 2, + nacks: Arc::new(Mutex::new(0)), + }; + + WorkerService::new(Arc::new(consumer), vec![handler], InMemoryStore::new()) + .unwrap() + .run(tokio::sync::watch::channel(false).1) + .await; + + assert_eq!(*calls.lock().unwrap(), 2); +} + +#[tokio::test] +async fn duplicate_handler_names_are_rejected() { + let (a, _) = CountingHandler::new("same", 0); + let (b, _) = CountingHandler::new("same", 0); + let result = WorkerService::new( + Arc::new(VecConsumer { events: vec![] }), + vec![a, b], + InMemoryStore::new(), + ); + let err = result.err().expect("duplicate names must be rejected"); + assert!(err.to_string().contains("same"), "{err}"); +} diff --git a/crates/bootstrap/src/database.rs b/crates/bootstrap/src/database.rs index 89ef2cce..a2cc4c00 100644 --- a/crates/bootstrap/src/database.rs +++ b/crates/bootstrap/src/database.rs @@ -38,6 +38,7 @@ pub struct DatabaseOutput { pub federation_settings: std::sync::Arc, pub remote_goal: Arc, pub refresh_session: Arc, + pub processed_events: Arc, pub deduplicator: Arc, pub image_ref_command: Arc, pub image_ref_query: Arc, @@ -114,6 +115,7 @@ fn assemble( 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, image_ref_command, image_ref_query, diff --git a/crates/bootstrap/src/follow_backfill_handler.rs b/crates/bootstrap/src/follow_backfill_handler.rs index 454c12ba..37b18070 100644 --- a/crates/bootstrap/src/follow_backfill_handler.rs +++ b/crates/bootstrap/src/follow_backfill_handler.rs @@ -14,6 +14,10 @@ pub struct FollowBackfillHandler { #[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 { diff --git a/crates/bootstrap/src/worker.rs b/crates/bootstrap/src/worker.rs index 7530520e..c2ba1242 100644 --- a/crates/bootstrap/src/worker.rs +++ b/crates/bootstrap/src/worker.rs @@ -18,6 +18,11 @@ pub struct WorkerApp { } impl WorkerApp { + /// Names of the registered event handlers, in registration order. + pub fn handler_names(&self) -> Vec<&'static str> { + self.service.handler_names() + } + /// Runs the handlers and, when there is one, the outbox relay, until /// `shutdown` fires. Both stop together and finish their in-flight work. /// @@ -142,6 +147,7 @@ async fn assemble( 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 event_publisher = event_publisher_arc; let metadata = metadata_client; @@ -217,6 +223,9 @@ async fn assemble( Arc::new(application::jobs::RefreshSessionCleanupJob::new( Arc::clone(&refresh_session), )), + Arc::new(application::jobs::ProcessedEventsCleanupJob::new( + Arc::clone(&processed_events), + )), ]; if let Some(job) = enrichment_job { periodic_jobs.push(job); @@ -301,7 +310,7 @@ async fn assemble( }; Ok(WorkerApp { - service: WorkerService::new(consumer_arc, handlers), + service: WorkerService::new(consumer_arc, handlers, processed_events)?, relay, }) } diff --git a/crates/bootstrap/tests/bootstrap_test.rs b/crates/bootstrap/tests/bootstrap_test.rs index 5f0fc821..7263272b 100644 --- a/crates/bootstrap/tests/bootstrap_test.rs +++ b/crates/bootstrap/tests/bootstrap_test.rs @@ -107,3 +107,34 @@ async fn server_publishes_to_the_outbox_even_when_bus_is_nats() { .unwrap(); assert_eq!(queued, 1); } + +/// Handler names key the processed-event ledger, so each must be unique. The +/// check runs over the real wiring, in both federation builds. +#[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:?}" + ); + for core in [ + "poster_sync", + "image_cleanup", + "search_cleanup", + "search_reindex", + ] { + assert!(names.contains(&core), "{core} missing from {names:?}"); + } + assert_eq!( + names.contains(&"activitypub"), + cfg!(feature = "federation"), + "{names:?}" + ); +} diff --git a/crates/domain/src/ports/events.rs b/crates/domain/src/ports/events.rs index 4d3f72e3..c3b18e81 100644 --- a/crates/domain/src/ports/events.rs +++ b/crates/domain/src/ports/events.rs @@ -23,5 +23,8 @@ pub trait EventConsumer: Send + Sync { #[async_trait] pub trait EventHandler: Send + Sync { + /// Stable, unique snake_case identity. It keys the processed-event ledger, + /// so renaming a handler makes it re-run events still inside the retention window. + fn name(&self) -> &'static str; async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>; } diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs index 07793697..f25cec7d 100644 --- a/crates/domain/src/ports/mod.rs +++ b/crates/domain/src/ports/mod.rs @@ -14,6 +14,7 @@ pub mod media_server; pub mod movie; pub mod noop; pub mod person; +pub mod processed_events; pub mod rss; pub mod search; pub mod social; @@ -35,6 +36,7 @@ pub use jobs::*; pub use media_server::*; pub use movie::*; pub use person::*; +pub use processed_events::*; pub use rss::*; pub use search::*; pub use social::*; diff --git a/crates/domain/src/ports/processed_events.rs b/crates/domain/src/ports/processed_events.rs new file mode 100644 index 00000000..76b35c89 --- /dev/null +++ b/crates/domain/src/ports/processed_events.rs @@ -0,0 +1,16 @@ +use async_trait::async_trait; + +use crate::{errors::DomainError, events::EventId}; + +/// Records which handler has already completed which event, so a redelivered +/// event does not re-run handlers that succeeded. +#[async_trait] +pub trait ProcessedEventLedger: Send + Sync { + async fn has_processed(&self, id: &EventId, handler: &str) -> Result; + + /// Idempotent: recording an existing pair is not an error. + async fn record_processed(&self, id: &EventId, handler: &str) -> Result<(), DomainError>; + + /// Returns the number of rows removed. + async fn delete_older_than(&self, cutoff: chrono::NaiveDateTime) -> Result; +} diff --git a/crates/domain/src/testing/fakes/mod.rs b/crates/domain/src/testing/fakes/mod.rs index 3b0750fe..95a5bb52 100644 --- a/crates/domain/src/testing/fakes/mod.rs +++ b/crates/domain/src/testing/fakes/mod.rs @@ -6,6 +6,7 @@ mod store; mod store_goal; mod store_import; mod store_movie; +mod store_processed_events; mod store_review; mod store_session; mod store_social; diff --git a/crates/domain/src/testing/fakes/store.rs b/crates/domain/src/testing/fakes/store.rs index 857210a0..808c1fb9 100644 --- a/crates/domain/src/testing/fakes/store.rs +++ b/crates/domain/src/testing/fakes/store.rs @@ -64,6 +64,7 @@ pub struct InMemoryStore { pub(super) import_profiles: RwLock>, pub(super) follows: RwLock>, pub(super) blocked: RwLock>, + pub(super) processed_events: RwLock>, } impl InMemoryStore { @@ -85,6 +86,7 @@ impl InMemoryStore { import_profiles: RwLock::default(), follows: RwLock::default(), blocked: RwLock::default(), + processed_events: RwLock::default(), }) } @@ -122,6 +124,23 @@ impl InMemoryStore { self.import_sessions.read().unwrap().len() } + pub fn processed_event_count(&self) -> usize { + self.processed_events.read().unwrap().len() + } + + /// Inserts a ledger row with an explicit timestamp (for retention tests). + pub fn seed_processed_event( + &self, + id: &crate::events::EventId, + handler: &str, + processed_at: chrono::NaiveDateTime, + ) { + self.processed_events + .write() + .unwrap() + .insert((id.value(), handler.to_owned()), processed_at); + } + pub fn refresh_session_count(&self) -> usize { self.refresh_sessions.read().unwrap().len() } diff --git a/crates/domain/src/testing/fakes/store_processed_events.rs b/crates/domain/src/testing/fakes/store_processed_events.rs new file mode 100644 index 00000000..f87b14a8 --- /dev/null +++ b/crates/domain/src/testing/fakes/store_processed_events.rs @@ -0,0 +1,33 @@ +use async_trait::async_trait; + +use crate::{errors::DomainError, events::EventId, ports::ProcessedEventLedger}; + +use super::InMemoryStore; + +#[async_trait] +impl ProcessedEventLedger for InMemoryStore { + async fn has_processed(&self, id: &EventId, handler: &str) -> Result { + Ok(self + .processed_events + .read() + .unwrap() + .contains_key(&(id.value(), handler.to_owned()))) + } + + /// `(event_id, handler)` is the primary key; a repeat keeps the first row. + async fn record_processed(&self, id: &EventId, handler: &str) -> Result<(), DomainError> { + self.processed_events + .write() + .unwrap() + .entry((id.value(), handler.to_owned())) + .or_insert_with(|| chrono::Utc::now().naive_utc()); + Ok(()) + } + + async fn delete_older_than(&self, cutoff: chrono::NaiveDateTime) -> Result { + let mut rows = self.processed_events.write().unwrap(); + let before = rows.len(); + rows.retain(|_, at| *at >= cutoff); + Ok((before - rows.len()) as u64) + } +} -- 2.49.1 From 53892847f24d8bbed084d8e63c13fe90467d41fb Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 00:24:56 +0100 Subject: [PATCH 057/104] fix: per-handler claim lease in ledger, nats max_deliver, pin handler names --- crates/adapters/nats/src/config.rs | 8 +- crates/adapters/nats/src/consumer.rs | 6 +- .../adapters/nats/tests/nats_config_test.rs | 44 +++- .../migrations/0101_outbox_identity.sql | 7 +- .../src/repositories/processed_events/mod.rs | 243 ++++++++++++++---- .../tests/fixtures/migration_ledger.txt | 2 +- crates/application/src/worker.rs | 51 ++-- .../jobs/processed_events_cleanup_test.rs | 8 +- .../application/tests/worker/worker_test.rs | 80 ++++++ crates/bootstrap/src/event_bus.rs | 2 +- crates/bootstrap/tests/bootstrap_test.rs | 25 +- crates/domain/src/ports/processed_events.rs | 40 ++- crates/domain/src/testing/fakes/store.rs | 26 +- .../testing/fakes/store_processed_events.rs | 65 ++++- 14 files changed, 489 insertions(+), 118 deletions(-) diff --git a/crates/adapters/nats/src/config.rs b/crates/adapters/nats/src/config.rs index 34003cac..e4f31404 100644 --- a/crates/adapters/nats/src/config.rs +++ b/crates/adapters/nats/src/config.rs @@ -11,10 +11,15 @@ pub struct NatsConfig { pub subject_prefix: String, pub stream_name: String, pub consumer_name: String, + /// JetStream redelivery bound, same as the DB queue's attempt limit. + pub max_deliver: i64, } impl NatsConfig { - pub fn from_config(config: &::config::NatsConfig) -> anyhow::Result { + pub fn from_config( + config: &::config::NatsConfig, + events: &::config::EventsConfig, + ) -> anyhow::Result { let url = config .url .clone() @@ -32,6 +37,7 @@ impl NatsConfig { subject_prefix: config.subject_prefix.clone(), stream_name: config.stream_name.clone(), consumer_name: config.consumer_name.clone(), + max_deliver: i64::from(events.queue_max_attempts), }) } } diff --git a/crates/adapters/nats/src/consumer.rs b/crates/adapters/nats/src/consumer.rs index 59508212..fcea56fb 100644 --- a/crates/adapters/nats/src/consumer.rs +++ b/crates/adapters/nats/src/consumer.rs @@ -108,8 +108,9 @@ fn consume_from_rx(rx: EnvelopeRx) -> BoxStream<'static, Result, mode: &str) -> config::NatsConfig { #[test] fn errors_without_nats_url() { - assert!(NatsConfig::from_config(&config::NatsConfig::default()).is_err()); + assert!( + NatsConfig::from_config( + &config::NatsConfig::default(), + &config::EventsConfig::default() + ) + .is_err() + ); } #[test] fn defaults_with_only_url() { - let cfg = NatsConfig::from_config(&config::NatsConfig { - url: Some("nats://localhost:4222".into()), - ..Default::default() - }) + let cfg = NatsConfig::from_config( + &config::NatsConfig { + url: Some("nats://localhost:4222".into()), + ..Default::default() + }, + &config::EventsConfig::default(), + ) .unwrap(); assert_eq!(cfg.url, "nats://localhost:4222"); assert_eq!(cfg.mode, NatsMode::JetStream); @@ -29,12 +38,33 @@ fn defaults_with_only_url() { #[test] fn core_mode_parsed() { - let cfg = NatsConfig::from_config(§ion(Some("nats://test:4222"), "core")).unwrap(); + let cfg = NatsConfig::from_config( + §ion(Some("nats://test:4222"), "core"), + &config::EventsConfig::default(), + ) + .unwrap(); assert_eq!(cfg.url, "nats://test:4222"); assert_eq!(cfg.mode, NatsMode::Core); } #[test] fn invalid_mode_errors() { - assert!(NatsConfig::from_config(§ion(Some("nats://test:4222"), "kafka")).is_err()); + assert!( + NatsConfig::from_config( + §ion(Some("nats://test:4222"), "kafka"), + &config::EventsConfig::default() + ) + .is_err() + ); +} + +#[test] +fn max_deliver_follows_the_queue_attempt_limit() { + let events = config::EventsConfig { + queue_max_attempts: 9, + ..Default::default() + }; + let cfg = + NatsConfig::from_config(§ion(Some("nats://test:4222"), "jetstream"), &events).unwrap(); + assert_eq!(cfg.max_deliver, 9); } diff --git a/crates/adapters/sqlite/migrations/0101_outbox_identity.sql b/crates/adapters/sqlite/migrations/0101_outbox_identity.sql index f045ae91..a7aa1f41 100644 --- a/crates/adapters/sqlite/migrations/0101_outbox_identity.sql +++ b/crates/adapters/sqlite/migrations/0101_outbox_identity.sql @@ -1,5 +1,6 @@ --- Outbox identity: every queued event carries a stable id; handlers record --- (event_id, handler) pairs so redeliveries are skipped. +-- Outbox identity: every queued event carries a stable id; handlers claim +-- (event_id, handler) pairs so redeliveries are skipped and concurrent +-- deliveries never run the same handler twice. ALTER TABLE event_queue ADD COLUMN event_id TEXT; -- When the current claim started; a claim older than the lease is reclaimable. @@ -21,6 +22,8 @@ CREATE UNIQUE INDEX idx_event_queue_event_id CREATE TABLE processed_events ( event_id TEXT NOT NULL, handler TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('in_progress', 'done')), + started_at TEXT, processed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), PRIMARY KEY (event_id, handler) ); diff --git a/crates/adapters/sqlite/src/repositories/processed_events/mod.rs b/crates/adapters/sqlite/src/repositories/processed_events/mod.rs index 6dd277b7..453707e9 100644 --- a/crates/adapters/sqlite/src/repositories/processed_events/mod.rs +++ b/crates/adapters/sqlite/src/repositories/processed_events/mod.rs @@ -1,5 +1,9 @@ use async_trait::async_trait; -use domain::{errors::DomainError, events::EventId, ports::ProcessedEventLedger}; +use domain::{ + errors::DomainError, + events::EventId, + ports::{Begin, HANDLER_LEASE_SECONDS, ProcessedEventLedger}, +}; use sqlx::SqlitePool; /// Matches the column default in migration 0101, so string comparison orders rows by time. @@ -15,32 +19,87 @@ impl SqliteProcessedEventLedger { } } +fn stamp(at: chrono::NaiveDateTime) -> String { + at.format(TIMESTAMP_FORMAT).to_string() +} + #[async_trait] impl ProcessedEventLedger for SqliteProcessedEventLedger { - async fn has_processed(&self, id: &EventId, handler: &str) -> Result { - let found: Option = - sqlx::query_scalar("SELECT 1 FROM processed_events WHERE event_id = ? AND handler = ?") - .bind(id.value().to_string()) - .bind(handler) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - Ok(found.is_some()) - } - - async fn record_processed(&self, id: &EventId, handler: &str) -> Result<(), DomainError> { - sqlx::query("INSERT OR IGNORE INTO processed_events (event_id, handler) VALUES (?, ?)") + async fn begin(&self, id: &EventId, handler: &str) -> Result { + let now = chrono::Utc::now().naive_utc(); + let stale_before = now - chrono::Duration::seconds(HANDLER_LEASE_SECONDS); + // Retried once: the row can vanish (released) between the claim attempt + // and the state lookup. + for _ in 0..2 { + // One statement, so two workers cannot both win: insert if absent, + // or take over an in_progress claim past its lease. + let claimed = sqlx::query( + "INSERT INTO processed_events (event_id, handler, status, started_at, processed_at) + VALUES (?, ?, 'in_progress', ?, ?) + ON CONFLICT (event_id, handler) DO UPDATE SET started_at = excluded.started_at + WHERE processed_events.status = 'in_progress' + AND processed_events.started_at < ?", + ) .bind(id.value().to_string()) .bind(handler) + .bind(stamp(now)) + .bind(stamp(now)) + .bind(stamp(stale_before)) .execute(&self.pool) .await + .map_err(crate::shared::map_sqlx_error)? + .rows_affected(); + if claimed == 1 { + return Ok(Begin::Run); + } + + let status: Option = sqlx::query_scalar( + "SELECT status FROM processed_events WHERE event_id = ? AND handler = ?", + ) + .bind(id.value().to_string()) + .bind(handler) + .fetch_optional(&self.pool) + .await .map_err(crate::shared::map_sqlx_error)?; + match status.as_deref() { + Some("done") => return Ok(Begin::AlreadyDone), + Some(_) => return Ok(Begin::InProgressElsewhere), + None => continue, + } + } + Ok(Begin::InProgressElsewhere) + } + + async fn complete(&self, id: &EventId, handler: &str) -> Result<(), DomainError> { + sqlx::query( + "UPDATE processed_events SET status = 'done', processed_at = ? + WHERE event_id = ? AND handler = ?", + ) + .bind(stamp(chrono::Utc::now().naive_utc())) + .bind(id.value().to_string()) + .bind(handler) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(()) + } + + async fn release(&self, id: &EventId, handler: &str) -> Result<(), DomainError> { + sqlx::query( + "DELETE FROM processed_events + WHERE event_id = ? AND handler = ? AND status = 'in_progress'", + ) + .bind(id.value().to_string()) + .bind(handler) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; Ok(()) } async fn delete_older_than(&self, cutoff: chrono::NaiveDateTime) -> Result { let result = sqlx::query("DELETE FROM processed_events WHERE processed_at < ?") - .bind(cutoff.format(TIMESTAMP_FORMAT).to_string()) + .bind(stamp(cutoff)) .execute(&self.pool) .await .map_err(crate::shared::map_sqlx_error)?; @@ -62,50 +121,134 @@ mod tests { (SqliteProcessedEventLedger::new(pool.clone()), pool) } - #[tokio::test] - async fn a_recorded_pair_is_processed_and_others_are_not() { - let (ledger, _) = ledger().await; - let id = EventId::new(); - assert!(!ledger.has_processed(&id, "a").await.unwrap()); - - ledger.record_processed(&id, "a").await.unwrap(); - - assert!(ledger.has_processed(&id, "a").await.unwrap()); - assert!(!ledger.has_processed(&id, "b").await.unwrap()); - assert!(!ledger.has_processed(&EventId::new(), "a").await.unwrap()); + async fn insert(pool: &SqlitePool, id: &EventId, status: &str, at: &str) { + sqlx::query( + "INSERT INTO processed_events (event_id, handler, status, started_at, processed_at) + VALUES (?, 'h', ?, ?, ?)", + ) + .bind(id.value().to_string()) + .bind(status) + .bind(at) + .bind(at) + .execute(pool) + .await + .unwrap(); } #[tokio::test] - async fn recording_twice_is_not_an_error() { + async fn the_first_begin_runs_and_a_second_sees_it_in_progress() { + let (ledger, _) = ledger().await; + let id = EventId::new(); + + assert_eq!(ledger.begin(&id, "a").await.unwrap(), Begin::Run); + assert_eq!( + ledger.begin(&id, "a").await.unwrap(), + Begin::InProgressElsewhere + ); + assert_eq!(ledger.begin(&id, "b").await.unwrap(), Begin::Run); + assert_eq!( + ledger.begin(&EventId::new(), "a").await.unwrap(), + Begin::Run + ); + } + + #[tokio::test] + async fn a_completed_claim_is_already_done() { + let (ledger, _) = ledger().await; + let id = EventId::new(); + ledger.begin(&id, "a").await.unwrap(); + + ledger.complete(&id, "a").await.unwrap(); + + assert_eq!(ledger.begin(&id, "a").await.unwrap(), Begin::AlreadyDone); + } + + #[tokio::test] + async fn a_released_claim_can_run_again() { + let (ledger, _) = ledger().await; + let id = EventId::new(); + ledger.begin(&id, "a").await.unwrap(); + + ledger.release(&id, "a").await.unwrap(); + + assert_eq!(ledger.begin(&id, "a").await.unwrap(), Begin::Run); + } + + #[tokio::test] + async fn release_never_removes_a_done_claim() { + let (ledger, _) = ledger().await; + let id = EventId::new(); + ledger.begin(&id, "a").await.unwrap(); + ledger.complete(&id, "a").await.unwrap(); + + ledger.release(&id, "a").await.unwrap(); + + assert_eq!(ledger.begin(&id, "a").await.unwrap(), Begin::AlreadyDone); + } + + #[tokio::test] + async fn a_stale_in_progress_claim_is_taken_over() { let (ledger, pool) = ledger().await; let id = EventId::new(); - ledger.record_processed(&id, "a").await.unwrap(); - ledger.record_processed(&id, "a").await.unwrap(); + insert(&pool, &id, "in_progress", "2020-01-01T00:00:00Z").await; - let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM processed_events") - .fetch_one(&pool) + assert_eq!(ledger.begin(&id, "h").await.unwrap(), Begin::Run); + // The takeover refreshed the lease, so a third delivery is blocked. + assert_eq!( + ledger.begin(&id, "h").await.unwrap(), + Begin::InProgressElsewhere + ); + } + + #[tokio::test] + async fn a_done_claim_is_never_taken_over_however_old() { + let (ledger, pool) = ledger().await; + let id = EventId::new(); + insert(&pool, &id, "done", "2020-01-01T00:00:00Z").await; + + assert_eq!(ledger.begin(&id, "h").await.unwrap(), Begin::AlreadyDone); + } + + #[tokio::test] + async fn concurrent_begins_have_exactly_one_winner() { + let dir = tempfile::tempdir().unwrap(); + let opts = sqlx::sqlite::SqliteConnectOptions::new() + .filename(dir.path().join("l.db")) + .create_if_missing(true) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) + .busy_timeout(std::time::Duration::from_secs(5)); + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(4) + .connect_with(opts) .await .unwrap(); - assert_eq!(rows, 1); + crate::migrate(&pool).await.unwrap(); + let ledger = std::sync::Arc::new(SqliteProcessedEventLedger::new(pool)); + let id = EventId::new(); + + let tasks: Vec<_> = (0..8) + .map(|_| { + let ledger = ledger.clone(); + tokio::spawn(async move { ledger.begin(&id, "a").await.unwrap() }) + }) + .collect(); + let mut runs = 0; + for t in tasks { + if t.await.unwrap() == Begin::Run { + runs += 1; + } + } + + assert_eq!(runs, 1); } #[tokio::test] async fn delete_older_than_removes_only_rows_before_the_cutoff() { let (ledger, pool) = ledger().await; - let (old, recent) = (EventId::new(), EventId::new()); - for (id, at) in [ - (old, "2026-01-01T00:00:00Z"), - (recent, "2026-06-01T00:00:00Z"), - ] { - sqlx::query( - "INSERT INTO processed_events (event_id, handler, processed_at) VALUES (?, 'h', ?)", - ) - .bind(id.value().to_string()) - .bind(at) - .execute(&pool) - .await - .unwrap(); - } + let (old_done, old_stuck, recent) = (EventId::new(), EventId::new(), EventId::new()); + insert(&pool, &old_done, "done", "2026-01-01T00:00:00Z").await; + insert(&pool, &old_stuck, "in_progress", "2026-01-02T00:00:00Z").await; + insert(&pool, &recent, "done", "2026-06-01T00:00:00Z").await; let cutoff = chrono::NaiveDate::from_ymd_opt(2026, 3, 1) .unwrap() .and_hms_opt(0, 0, 0) @@ -113,8 +256,10 @@ mod tests { let removed = ledger.delete_older_than(cutoff).await.unwrap(); - assert_eq!(removed, 1); - assert!(!ledger.has_processed(&old, "h").await.unwrap()); - assert!(ledger.has_processed(&recent, "h").await.unwrap()); + assert_eq!(removed, 2); + assert_eq!( + ledger.begin(&recent, "h").await.unwrap(), + Begin::AlreadyDone + ); } } diff --git a/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt index 33b259e2..704ea667 100644 --- a/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt +++ b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt @@ -29,4 +29,4 @@ 29 56F4F7B41A09F07211D7543204F0EAC93B7BFB15AFD9BC90DF2654DAE5BE96B1E358B50E2190BF25739C5F0B1FD4B806 30 DAAA0AF35B4ACB271A9D8068B82F02A72EEC62F3AC07ED504C7A7887976C2C8BACB35FFF477B63A931DF15F79018390D 100 310ADC9ABF0CC2F8E9EDAB33168D009D64228FF5BD8D2A14185513699EBB782606A1EEA8FEE6EC6AF65D72396153F13F -101 C0EE8C5B9FA6A2BE75674A10A008B895FFAF8056139FE0C0F9936B27BA06AFC71B1DC5BD8318948F6E9FCF0689823B71 +101 19A2542D4343F3921A8BF0A10E195DA1915E3EA97A2BCF2D3BF9079504DB2053E4BD2175D94C95BCA363BC2FDF14AA88 diff --git a/crates/application/src/worker.rs b/crates/application/src/worker.rs index 7f970d30..3a768e9b 100644 --- a/crates/application/src/worker.rs +++ b/crates/application/src/worker.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::{ errors::DomainError, events::EventEnvelope, - ports::{EventConsumer, EventHandler, ProcessedEventLedger}, + ports::{Begin, EventConsumer, EventHandler, ProcessedEventLedger}, }; use futures::StreamExt; use tokio::sync::Semaphore; @@ -98,38 +98,57 @@ async fn dispatch( for handler in handlers.iter() { let name = handler.name(); if let Some(id) = &id { - match ledger.has_processed(id, name).await { - Ok(true) => { + match ledger.begin(id, name).await { + Ok(Begin::Run) => {} + Ok(Begin::AlreadyDone) => { tracing::debug!(handler = name, "already processed, skipping"); continue; } - Ok(false) => {} + Ok(Begin::InProgressElsewhere) => { + tracing::debug!( + handler = name, + "running in another delivery, retrying later" + ); + any_transient = true; + continue; + } Err(e) => { // Running anyway could repeat a side effect; retry the delivery instead. - tracing::warn!(handler = name, "ledger lookup failed, will retry: {e}"); + tracing::warn!(handler = name, "ledger claim failed, will retry: {e}"); any_transient = true; continue; } } } - match handler.handle(&envelope.event).await { + let result = handler.handle(&envelope.event).await; + match &result { Ok(()) => { if let Some(id) = &id - && let Err(e) = ledger.record_processed(id, name).await + && let Err(e) = ledger.complete(id, name).await { // The work is done; failing the delivery would only repeat it. - tracing::warn!(handler = name, "recording processed event failed: {e}"); + // The claim lapses after its lease and the handler may run once more. + tracing::warn!(handler = name, "completing ledger claim failed: {e}"); } } - Err(e) if e.is_transient() => { - tracing::warn!(handler = name, "transient handler error, will retry: {e}"); - any_transient = true; - } Err(e) => { - tracing::warn!( - handler = name, - "permanent handler error (not retrying): {e}" - ); + // Permanent failures are released too, not recorded: if a sibling + // handler's transient failure redelivers this event, a handler that + // failed permanently runs (and fails) again. + if let Some(id) = &id + && let Err(le) = ledger.release(id, name).await + { + tracing::warn!(handler = name, "releasing ledger claim failed: {le}"); + } + if e.is_transient() { + tracing::warn!(handler = name, "transient handler error, will retry: {e}"); + any_transient = true; + } else { + tracing::warn!( + handler = name, + "permanent handler error (not retrying): {e}" + ); + } } } } diff --git a/crates/application/tests/jobs/processed_events_cleanup_test.rs b/crates/application/tests/jobs/processed_events_cleanup_test.rs index dcefa601..8ce87ede 100644 --- a/crates/application/tests/jobs/processed_events_cleanup_test.rs +++ b/crates/application/tests/jobs/processed_events_cleanup_test.rs @@ -10,11 +10,13 @@ async fn removes_only_rows_older_than_the_retention_window() { store.seed_processed_event( &stale, "h", + true, now - chrono::Duration::days(PROCESSED_EVENTS_RETENTION_DAYS + 1), ); store.seed_processed_event( &fresh, "h", + true, now - chrono::Duration::days(PROCESSED_EVENTS_RETENTION_DAYS - 1), ); @@ -23,9 +25,9 @@ async fn removes_only_rows_older_than_the_retention_window() { .await .unwrap(); - use domain::ports::ProcessedEventLedger; - assert!(!store.has_processed(&stale, "h").await.unwrap()); - assert!(store.has_processed(&fresh, "h").await.unwrap()); + use domain::ports::{Begin, ProcessedEventLedger}; + assert_eq!(store.begin(&stale, "h").await.unwrap(), Begin::Run); + assert_eq!(store.begin(&fresh, "h").await.unwrap(), Begin::AlreadyDone); } #[test] diff --git a/crates/application/tests/worker/worker_test.rs b/crates/application/tests/worker/worker_test.rs index c3a049f0..2d19f26a 100644 --- a/crates/application/tests/worker/worker_test.rs +++ b/crates/application/tests/worker/worker_test.rs @@ -477,3 +477,83 @@ async fn duplicate_handler_names_are_rejected() { let err = result.err().expect("duplicate names must be rejected"); assert!(err.to_string().contains("same"), "{err}"); } + +#[tokio::test] +async fn a_handler_already_running_elsewhere_is_not_run_concurrently() { + use domain::ports::{Begin, ProcessedEventLedger}; + let (handler, calls) = CountingHandler::new("slow", 0); + let store = InMemoryStore::new(); + let id = domain::events::EventId::new(); + // Another delivery holds a live claim on this (event, handler). + assert_eq!(store.begin(&id, "slow").await.unwrap(), Begin::Run); + let nacks = Arc::new(Mutex::new(0)); + let consumer = RedeliveringConsumer { + id: Some(id), + event: movie_discovered(), + times: 1, + nacks: Arc::clone(&nacks), + }; + + WorkerService::new(Arc::new(consumer), vec![handler], store) + .unwrap() + .run(tokio::sync::watch::channel(false).1) + .await; + + assert_eq!( + *calls.lock().unwrap(), + 0, + "must not run beside the claim holder" + ); + assert_eq!(*nacks.lock().unwrap(), 1, "delivery is retried later"); +} + +#[tokio::test] +async fn a_stale_in_progress_claim_is_taken_over() { + use domain::ports::HANDLER_LEASE_SECONDS; + let (handler, calls) = CountingHandler::new("crashed", 0); + let store = InMemoryStore::new(); + let id = domain::events::EventId::new(); + store.seed_processed_event( + &id, + "crashed", + false, + chrono::Utc::now().naive_utc() - chrono::Duration::seconds(HANDLER_LEASE_SECONDS + 1), + ); + let nacks = Arc::new(Mutex::new(0)); + let consumer = RedeliveringConsumer { + id: Some(id), + event: movie_discovered(), + times: 1, + nacks: Arc::clone(&nacks), + }; + + WorkerService::new(Arc::new(consumer), vec![handler], store) + .unwrap() + .run(tokio::sync::watch::channel(false).1) + .await; + + assert_eq!(*calls.lock().unwrap(), 1); + assert_eq!(*nacks.lock().unwrap(), 0); +} + +#[tokio::test] +async fn a_failed_handler_releases_its_claim_so_redelivery_retries() { + let (handler, calls) = CountingHandler::new("flaky", 1); + let store = InMemoryStore::new(); + let nacks = Arc::new(Mutex::new(0)); + let consumer = RedeliveringConsumer { + id: Some(domain::events::EventId::new()), + event: movie_discovered(), + times: 3, + nacks: Arc::clone(&nacks), + }; + + WorkerService::new(Arc::new(consumer), vec![handler], store.clone()) + .unwrap() + .run(tokio::sync::watch::channel(false).1) + .await; + + // Delivery 1 fails and releases; 2 succeeds; 3 is skipped as done. + assert_eq!(*calls.lock().unwrap(), 2); + assert_eq!(*nacks.lock().unwrap(), 1); +} diff --git a/crates/bootstrap/src/event_bus.rs b/crates/bootstrap/src/event_bus.rs index 34fd7f00..ba7ded83 100644 --- a/crates/bootstrap/src/event_bus.rs +++ b/crates/bootstrap/src/event_bus.rs @@ -83,7 +83,7 @@ pub async fn create_channel( } #[cfg(feature = "nats")] EventBusBackend::Nats => { - let cfg = nats::NatsConfig::from_config(&config.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?)) diff --git a/crates/bootstrap/tests/bootstrap_test.rs b/crates/bootstrap/tests/bootstrap_test.rs index 7263272b..93792411 100644 --- a/crates/bootstrap/tests/bootstrap_test.rs +++ b/crates/bootstrap/tests/bootstrap_test.rs @@ -124,17 +124,24 @@ async fn handler_names_are_unique() { names.len(), "duplicate handler name in {names:?}" ); - for core in [ - "poster_sync", + let mut sorted = names.clone(); + sorted.sort_unstable(); + // Names key the ledger: a rename re-runs events inside the retention + // window, so changing this list must be a deliberate edit. + let mut expected = vec![ "image_cleanup", + "movie_discovery_indexer", + "movie_enrichment", + "person_enrichment", + "poster_sync", "search_cleanup", "search_reindex", - ] { - assert!(names.contains(&core), "{core} missing from {names:?}"); + "wrapup", + ]; + if cfg!(feature = "federation") { + expected.extend(["activitypub", "follow_backfill"]); } - assert_eq!( - names.contains(&"activitypub"), - cfg!(feature = "federation"), - "{names:?}" - ); + expected.sort_unstable(); + // Image conversion ("image_conversion") is disabled in the test config. + assert_eq!(sorted, expected); } diff --git a/crates/domain/src/ports/processed_events.rs b/crates/domain/src/ports/processed_events.rs index 76b35c89..d71a2553 100644 --- a/crates/domain/src/ports/processed_events.rs +++ b/crates/domain/src/ports/processed_events.rs @@ -2,15 +2,43 @@ use async_trait::async_trait; use crate::{errors::DomainError, events::EventId}; -/// Records which handler has already completed which event, so a redelivered -/// event does not re-run handlers that succeeded. +/// How long an `in_progress` claim blocks other deliveries of the same +/// (event, handler). Past it the claim is presumed dead (crashed worker) and +/// the next `begin` takes it over. Must exceed any handler's runtime. +pub const HANDLER_LEASE_SECONDS: i64 = 1800; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Begin { + /// The caller now holds the claim and must run the handler, then + /// `complete` or `release`. + Run, + /// The handler already finished this event; skip it. + AlreadyDone, + /// Another delivery holds a live claim; do not run, retry the delivery later. + InProgressElsewhere, +} + +/// Per-(event, handler) claim ledger that stops redelivered events from +/// re-running handlers. +/// +/// Guarantee: two deliveries never run the same handler for the same event +/// concurrently (`begin` is atomic across workers). After a crash mid-handler +/// the claim lapses after `HANDLER_LEASE_SECONDS` and the handler runs again, +/// so delivery is at-least-once and handlers must be idempotent. #[async_trait] pub trait ProcessedEventLedger: Send + Sync { - async fn has_processed(&self, id: &EventId, handler: &str) -> Result; + /// Atomically claims (event, handler): inserts a claim if there is none, or + /// takes over an `in_progress` claim older than the lease; otherwise + /// reports the existing state. + async fn begin(&self, id: &EventId, handler: &str) -> Result; - /// Idempotent: recording an existing pair is not an error. - async fn record_processed(&self, id: &EventId, handler: &str) -> Result<(), DomainError>; + /// Marks the claim done: the handler will not run for this event again. + async fn complete(&self, id: &EventId, handler: &str) -> Result<(), DomainError>; - /// Returns the number of rows removed. + /// Drops an `in_progress` claim so a retry can run immediately. + async fn release(&self, id: &EventId, handler: &str) -> Result<(), DomainError>; + + /// Removes rows (done, or abandoned `in_progress`) first recorded before + /// `cutoff`. Returns the number removed. async fn delete_older_than(&self, cutoff: chrono::NaiveDateTime) -> Result; } diff --git a/crates/domain/src/testing/fakes/store.rs b/crates/domain/src/testing/fakes/store.rs index 808c1fb9..94f3ca69 100644 --- a/crates/domain/src/testing/fakes/store.rs +++ b/crates/domain/src/testing/fakes/store.rs @@ -9,6 +9,13 @@ use crate::models::{ }; use crate::value_objects::SocialIdentity; +/// One `processed_events` row. +pub(super) struct LedgerRow { + pub(super) done: bool, + pub(super) started_at: chrono::NaiveDateTime, + pub(super) processed_at: chrono::NaiveDateTime, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum FollowState { Pending, @@ -64,7 +71,7 @@ pub struct InMemoryStore { pub(super) import_profiles: RwLock>, pub(super) follows: RwLock>, pub(super) blocked: RwLock>, - pub(super) processed_events: RwLock>, + pub(super) processed_events: RwLock>, } impl InMemoryStore { @@ -128,17 +135,22 @@ impl InMemoryStore { self.processed_events.read().unwrap().len() } - /// Inserts a ledger row with an explicit timestamp (for retention tests). + /// Inserts a ledger row with explicit timestamps (retention and lease tests). pub fn seed_processed_event( &self, id: &crate::events::EventId, handler: &str, - processed_at: chrono::NaiveDateTime, + done: bool, + at: chrono::NaiveDateTime, ) { - self.processed_events - .write() - .unwrap() - .insert((id.value(), handler.to_owned()), processed_at); + self.processed_events.write().unwrap().insert( + (id.value(), handler.to_owned()), + LedgerRow { + done, + started_at: at, + processed_at: at, + }, + ); } pub fn refresh_session_count(&self) -> usize { diff --git a/crates/domain/src/testing/fakes/store_processed_events.rs b/crates/domain/src/testing/fakes/store_processed_events.rs index f87b14a8..f1f96eb7 100644 --- a/crates/domain/src/testing/fakes/store_processed_events.rs +++ b/crates/domain/src/testing/fakes/store_processed_events.rs @@ -1,33 +1,70 @@ use async_trait::async_trait; +use chrono::{Duration, NaiveDateTime, Utc}; -use crate::{errors::DomainError, events::EventId, ports::ProcessedEventLedger}; +use crate::{ + errors::DomainError, + events::EventId, + ports::{Begin, HANDLER_LEASE_SECONDS, ProcessedEventLedger}, +}; use super::InMemoryStore; +use super::store::LedgerRow; +/// Mirrors the sqlite adapter: `begin` inserts, or takes over only a stale +/// `in_progress` claim; `(event_id, handler)` is the key. #[async_trait] impl ProcessedEventLedger for InMemoryStore { - async fn has_processed(&self, id: &EventId, handler: &str) -> Result { - Ok(self - .processed_events - .read() - .unwrap() - .contains_key(&(id.value(), handler.to_owned()))) + async fn begin(&self, id: &EventId, handler: &str) -> Result { + let now = Utc::now().naive_utc(); + let mut rows = self.processed_events.write().unwrap(); + let key = (id.value(), handler.to_owned()); + match rows.get_mut(&key) { + None => { + rows.insert( + key, + LedgerRow { + done: false, + started_at: now, + processed_at: now, + }, + ); + Ok(Begin::Run) + } + Some(row) if row.done => Ok(Begin::AlreadyDone), + Some(row) if row.started_at < now - Duration::seconds(HANDLER_LEASE_SECONDS) => { + row.started_at = now; + Ok(Begin::Run) + } + Some(_) => Ok(Begin::InProgressElsewhere), + } } - /// `(event_id, handler)` is the primary key; a repeat keeps the first row. - async fn record_processed(&self, id: &EventId, handler: &str) -> Result<(), DomainError> { - self.processed_events + async fn complete(&self, id: &EventId, handler: &str) -> Result<(), DomainError> { + if let Some(row) = self + .processed_events .write() .unwrap() - .entry((id.value(), handler.to_owned())) - .or_insert_with(|| chrono::Utc::now().naive_utc()); + .get_mut(&(id.value(), handler.to_owned())) + { + row.done = true; + row.processed_at = Utc::now().naive_utc(); + } Ok(()) } - async fn delete_older_than(&self, cutoff: chrono::NaiveDateTime) -> Result { + async fn release(&self, id: &EventId, handler: &str) -> Result<(), DomainError> { + let mut rows = self.processed_events.write().unwrap(); + let key = (id.value(), handler.to_owned()); + if rows.get(&key).is_some_and(|r| !r.done) { + rows.remove(&key); + } + Ok(()) + } + + async fn delete_older_than(&self, cutoff: NaiveDateTime) -> Result { let mut rows = self.processed_events.write().unwrap(); let before = rows.len(); - rows.retain(|_, at| *at >= cutoff); + rows.retain(|_, r| r.processed_at >= cutoff); Ok((before - rows.len()) as u64) } } -- 2.49.1 From 0236a488fadd61a8082c853d4a52cc85f9be52b8 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 00:33:29 +0100 Subject: [PATCH 058/104] fix: defer blocked deliveries, claim owner token --- crates/adapters/event-publisher/src/lib.rs | 3 + crates/adapters/nats/src/consumer.rs | 13 ++ crates/adapters/sqlite/src/event_queue/mod.rs | 16 ++ .../adapters/sqlite/src/event_queue/tests.rs | 27 +++ .../src/repositories/processed_events/mod.rs | 176 +++++++++++++----- crates/application/src/worker.rs | 37 ++-- .../jobs/processed_events_cleanup_test.rs | 5 +- .../application/tests/worker/worker_test.rs | 109 ++++++++++- .../bootstrap/tests/ledger_recovery_test.rs | 105 +++++++++++ crates/domain/src/events.rs | 9 + crates/domain/src/ports/processed_events.rs | 45 ++++- crates/domain/src/testing/fakes/noops.rs | 3 + .../testing/fakes/store_processed_events.rs | 60 ++++-- 13 files changed, 528 insertions(+), 80 deletions(-) create mode 100644 crates/bootstrap/tests/ledger_recovery_test.rs diff --git a/crates/adapters/event-publisher/src/lib.rs b/crates/adapters/event-publisher/src/lib.rs index 8c68fc40..e3c6289b 100644 --- a/crates/adapters/event-publisher/src/lib.rs +++ b/crates/adapters/event-publisher/src/lib.rs @@ -47,6 +47,9 @@ impl AckHandle for NoopAck { async fn nack(&self) -> Result<(), DomainError> { Ok(()) } + async fn defer(&self, _: std::time::Duration) -> Result<(), DomainError> { + Ok(()) + } } pub struct ChannelEventConsumer { diff --git a/crates/adapters/nats/src/consumer.rs b/crates/adapters/nats/src/consumer.rs index fcea56fb..d26b2156 100644 --- a/crates/adapters/nats/src/consumer.rs +++ b/crates/adapters/nats/src/consumer.rs @@ -48,6 +48,16 @@ impl AckHandle for NatsJetStreamAckHandle { .await .map_err(|e| DomainError::InfrastructureError(e.to_string())) } + + /// JetStream counts every redelivery toward `max_deliver`, deferrals + /// included. The delay is at least the remaining claim lease, so the + /// deliveries allowed by `max_deliver` still span more than one lease. + async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError> { + self.message + .ack_with(AckKind::Nak(Some(delay))) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string())) + } } // ── Core NATS ack handle (no-op) ───────────────────────────────────────────── @@ -62,6 +72,9 @@ impl AckHandle for NoopAck { async fn nack(&self) -> Result<(), DomainError> { Ok(()) } + async fn defer(&self, _: std::time::Duration) -> Result<(), DomainError> { + Ok(()) + } } // ── Envelope construction helpers ──────────────────────────────────────────── diff --git a/crates/adapters/sqlite/src/event_queue/mod.rs b/crates/adapters/sqlite/src/event_queue/mod.rs index f672216e..302ccbee 100644 --- a/crates/adapters/sqlite/src/event_queue/mod.rs +++ b/crates/adapters/sqlite/src/event_queue/mod.rs @@ -259,6 +259,22 @@ impl AckHandle for DbAckHandle { } Ok(()) } + + async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError> { + let secs = delay.as_secs_f64().ceil().max(1.0) as i64; + let sql = format!( + "UPDATE event_queue + SET status = 'pending', claimed_at = NULL, last_error = 'deferred', + next_attempt_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+{secs} seconds') + WHERE id = ?" + ); + sqlx::query(&sql) + .bind(self.row_id) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(format!("defer: {e}")))?; + Ok(()) + } } fn backoff_seconds(attempts: i32) -> i64 { diff --git a/crates/adapters/sqlite/src/event_queue/tests.rs b/crates/adapters/sqlite/src/event_queue/tests.rs index 8025b5f6..7978df3c 100644 --- a/crates/adapters/sqlite/src/event_queue/tests.rs +++ b/crates/adapters/sqlite/src/event_queue/tests.rs @@ -189,6 +189,33 @@ mod identity { assert!(envelope.id().is_none()); } + + #[tokio::test] + async fn defer_reschedules_without_counting_an_attempt() { + let (q, pool) = queue().await; + q.publish(&event()).await.unwrap(); + let envelope = q.consume().next().await.unwrap().unwrap(); + + envelope + .defer(std::time::Duration::from_secs(600)) + .await + .unwrap(); + + let (status, attempts, claimed, due_in): (String, i32, Option, i64) = + sqlx::query_as( + "SELECT status, attempts, claimed_at, + CAST(strftime('%s', next_attempt_at) AS INTEGER) + - CAST(strftime('%s', 'now') AS INTEGER) + FROM event_queue", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(status, "pending"); + assert_eq!(attempts, 0, "a deferral is not a failed attempt"); + assert!(claimed.is_none(), "claim is released"); + assert!((595..=600).contains(&due_in), "due in {due_in}s"); + } } mod lease { diff --git a/crates/adapters/sqlite/src/repositories/processed_events/mod.rs b/crates/adapters/sqlite/src/repositories/processed_events/mod.rs index 453707e9..756b46b9 100644 --- a/crates/adapters/sqlite/src/repositories/processed_events/mod.rs +++ b/crates/adapters/sqlite/src/repositories/processed_events/mod.rs @@ -2,20 +2,34 @@ use async_trait::async_trait; use domain::{ errors::DomainError, events::EventId, - ports::{Begin, HANDLER_LEASE_SECONDS, ProcessedEventLedger}, + ports::{Begin, ClaimToken, HANDLER_LEASE_SECONDS, ProcessedEventLedger}, }; use sqlx::SqlitePool; /// Matches the column default in migration 0101, so string comparison orders rows by time. const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ"; +const CLAIM_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.3fZ"; pub struct SqliteProcessedEventLedger { pool: SqlitePool, + lease: chrono::Duration, } impl SqliteProcessedEventLedger { pub fn new(pool: SqlitePool) -> Self { - Self { pool } + Self::with_lease( + pool, + std::time::Duration::from_secs(HANDLER_LEASE_SECONDS as u64), + ) + } + + /// A shorter lease than production's, so lease takeover can be tested + /// without waiting half an hour. + pub fn with_lease(pool: SqlitePool, lease: std::time::Duration) -> Self { + Self { + pool, + lease: chrono::Duration::from_std(lease).expect("lease fits a chrono duration"), + } } } @@ -23,74 +37,110 @@ fn stamp(at: chrono::NaiveDateTime) -> String { at.format(TIMESTAMP_FORMAT).to_string() } +/// Millisecond precision, so two claims taken in one second have distinct tokens. +fn claim_stamp(at: chrono::NaiveDateTime) -> String { + at.format(CLAIM_FORMAT).to_string() +} + +fn parse_claim_stamp(value: &str) -> Option { + chrono::NaiveDateTime::parse_from_str(value, CLAIM_FORMAT) + .or_else(|_| chrono::NaiveDateTime::parse_from_str(value, TIMESTAMP_FORMAT)) + .ok() +} + #[async_trait] impl ProcessedEventLedger for SqliteProcessedEventLedger { async fn begin(&self, id: &EventId, handler: &str) -> Result { - let now = chrono::Utc::now().naive_utc(); - let stale_before = now - chrono::Duration::seconds(HANDLER_LEASE_SECONDS); // Retried once: the row can vanish (released) between the claim attempt // and the state lookup. for _ in 0..2 { + let now = chrono::Utc::now().naive_utc(); + let started = claim_stamp(now); // One statement, so two workers cannot both win: insert if absent, // or take over an in_progress claim past its lease. let claimed = sqlx::query( "INSERT INTO processed_events (event_id, handler, status, started_at, processed_at) VALUES (?, ?, 'in_progress', ?, ?) - ON CONFLICT (event_id, handler) DO UPDATE SET started_at = excluded.started_at + ON CONFLICT (event_id, handler) DO UPDATE + SET started_at = excluded.started_at, processed_at = excluded.processed_at WHERE processed_events.status = 'in_progress' AND processed_events.started_at < ?", ) .bind(id.value().to_string()) .bind(handler) + .bind(&started) .bind(stamp(now)) - .bind(stamp(now)) - .bind(stamp(stale_before)) + .bind(claim_stamp(now - self.lease)) .execute(&self.pool) .await .map_err(crate::shared::map_sqlx_error)? .rows_affected(); if claimed == 1 { - return Ok(Begin::Run); + return Ok(Begin::Run(ClaimToken::new(started))); } - let status: Option = sqlx::query_scalar( - "SELECT status FROM processed_events WHERE event_id = ? AND handler = ?", + let row: Option<(String, Option)> = sqlx::query_as( + "SELECT status, started_at FROM processed_events WHERE event_id = ? AND handler = ?", ) .bind(id.value().to_string()) .bind(handler) .fetch_optional(&self.pool) .await .map_err(crate::shared::map_sqlx_error)?; - match status.as_deref() { - Some("done") => return Ok(Begin::AlreadyDone), - Some(_) => return Ok(Begin::InProgressElsewhere), + match row { + Some((status, _)) if status == "done" => return Ok(Begin::AlreadyDone), + Some((_, started_at)) => { + let lease_end = started_at + .as_deref() + .and_then(parse_claim_stamp) + .map_or(now + self.lease, |s| s + self.lease); + let retry_after = (lease_end - now) + .to_std() + .unwrap_or_default() + .max(std::time::Duration::from_secs(1)); + return Ok(Begin::InProgressElsewhere { retry_after }); + } None => continue, } } - Ok(Begin::InProgressElsewhere) + Ok(Begin::InProgressElsewhere { + retry_after: std::time::Duration::from_secs(1), + }) } - async fn complete(&self, id: &EventId, handler: &str) -> Result<(), DomainError> { + async fn complete( + &self, + id: &EventId, + handler: &str, + token: &ClaimToken, + ) -> Result<(), DomainError> { sqlx::query( "UPDATE processed_events SET status = 'done', processed_at = ? - WHERE event_id = ? AND handler = ?", + WHERE event_id = ? AND handler = ? AND status = 'in_progress' AND started_at = ?", ) .bind(stamp(chrono::Utc::now().naive_utc())) .bind(id.value().to_string()) .bind(handler) + .bind(token.as_str()) .execute(&self.pool) .await .map_err(crate::shared::map_sqlx_error)?; Ok(()) } - async fn release(&self, id: &EventId, handler: &str) -> Result<(), DomainError> { + async fn release( + &self, + id: &EventId, + handler: &str, + token: &ClaimToken, + ) -> Result<(), DomainError> { sqlx::query( "DELETE FROM processed_events - WHERE event_id = ? AND handler = ? AND status = 'in_progress'", + WHERE event_id = ? AND handler = ? AND status = 'in_progress' AND started_at = ?", ) .bind(id.value().to_string()) .bind(handler) + .bind(token.as_str()) .execute(&self.pool) .await .map_err(crate::shared::map_sqlx_error)?; @@ -111,13 +161,18 @@ impl ProcessedEventLedger for SqliteProcessedEventLedger { mod tests { use super::*; - async fn ledger() -> (SqliteProcessedEventLedger, SqlitePool) { + async fn memory_pool() -> SqlitePool { let pool = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); crate::migrate(&pool).await.unwrap(); + pool + } + + async fn ledger() -> (SqliteProcessedEventLedger, SqlitePool) { + let pool = memory_pool().await; (SqliteProcessedEventLedger::new(pool.clone()), pool) } @@ -135,30 +190,42 @@ mod tests { .unwrap(); } + fn token(begin: Begin) -> ClaimToken { + match begin { + Begin::Run(token) => token, + other => panic!("expected Run, got {other:?}"), + } + } + + fn blocked(begin: Begin) -> std::time::Duration { + match begin { + Begin::InProgressElsewhere { retry_after } => retry_after, + other => panic!("expected InProgressElsewhere, got {other:?}"), + } + } + #[tokio::test] async fn the_first_begin_runs_and_a_second_sees_it_in_progress() { let (ledger, _) = ledger().await; let id = EventId::new(); - assert_eq!(ledger.begin(&id, "a").await.unwrap(), Begin::Run); - assert_eq!( - ledger.begin(&id, "a").await.unwrap(), - Begin::InProgressElsewhere - ); - assert_eq!(ledger.begin(&id, "b").await.unwrap(), Begin::Run); - assert_eq!( - ledger.begin(&EventId::new(), "a").await.unwrap(), - Begin::Run - ); + token(ledger.begin(&id, "a").await.unwrap()); + let retry_after = blocked(ledger.begin(&id, "a").await.unwrap()); + token(ledger.begin(&id, "b").await.unwrap()); + token(ledger.begin(&EventId::new(), "a").await.unwrap()); + + // The blocked delivery is told to come back when the lease runs out. + let lease = std::time::Duration::from_secs(HANDLER_LEASE_SECONDS as u64); + assert!(retry_after <= lease && retry_after > lease - std::time::Duration::from_secs(5)); } #[tokio::test] async fn a_completed_claim_is_already_done() { let (ledger, _) = ledger().await; let id = EventId::new(); - ledger.begin(&id, "a").await.unwrap(); + let t = token(ledger.begin(&id, "a").await.unwrap()); - ledger.complete(&id, "a").await.unwrap(); + ledger.complete(&id, "a", &t).await.unwrap(); assert_eq!(ledger.begin(&id, "a").await.unwrap(), Begin::AlreadyDone); } @@ -167,21 +234,21 @@ mod tests { async fn a_released_claim_can_run_again() { let (ledger, _) = ledger().await; let id = EventId::new(); - ledger.begin(&id, "a").await.unwrap(); + let t = token(ledger.begin(&id, "a").await.unwrap()); - ledger.release(&id, "a").await.unwrap(); + ledger.release(&id, "a", &t).await.unwrap(); - assert_eq!(ledger.begin(&id, "a").await.unwrap(), Begin::Run); + token(ledger.begin(&id, "a").await.unwrap()); } #[tokio::test] async fn release_never_removes_a_done_claim() { let (ledger, _) = ledger().await; let id = EventId::new(); - ledger.begin(&id, "a").await.unwrap(); - ledger.complete(&id, "a").await.unwrap(); + let t = token(ledger.begin(&id, "a").await.unwrap()); + ledger.complete(&id, "a", &t).await.unwrap(); - ledger.release(&id, "a").await.unwrap(); + ledger.release(&id, "a", &t).await.unwrap(); assert_eq!(ledger.begin(&id, "a").await.unwrap(), Begin::AlreadyDone); } @@ -192,11 +259,16 @@ mod tests { let id = EventId::new(); insert(&pool, &id, "in_progress", "2020-01-01T00:00:00Z").await; - assert_eq!(ledger.begin(&id, "h").await.unwrap(), Begin::Run); + token(ledger.begin(&id, "h").await.unwrap()); // The takeover refreshed the lease, so a third delivery is blocked. - assert_eq!( - ledger.begin(&id, "h").await.unwrap(), - Begin::InProgressElsewhere + blocked(ledger.begin(&id, "h").await.unwrap()); + let processed_at: String = sqlx::query_scalar("SELECT processed_at FROM processed_events") + .fetch_one(&pool) + .await + .unwrap(); + assert!( + processed_at.as_str() > "2020-01-01T00:00:00Z", + "takeover refreshes processed_at" ); } @@ -209,6 +281,26 @@ mod tests { assert_eq!(ledger.begin(&id, "h").await.unwrap(), Begin::AlreadyDone); } + #[tokio::test] + async fn a_lapsed_owner_cannot_release_a_claim_taken_over_by_another() { + let pool = memory_pool().await; + let ledger = + SqliteProcessedEventLedger::with_lease(pool.clone(), std::time::Duration::from_secs(1)); + let id = EventId::new(); + let lapsed = token(ledger.begin(&id, "a").await.unwrap()); + tokio::time::sleep(std::time::Duration::from_millis(2100)).await; + let owner = token(ledger.begin(&id, "a").await.unwrap()); + assert_ne!(lapsed, owner); + + ledger.release(&id, "a", &lapsed).await.unwrap(); + ledger.complete(&id, "a", &lapsed).await.unwrap(); + + // Still the new owner's live claim: neither call touched it. + blocked(ledger.begin(&id, "a").await.unwrap()); + ledger.complete(&id, "a", &owner).await.unwrap(); + assert_eq!(ledger.begin(&id, "a").await.unwrap(), Begin::AlreadyDone); + } + #[tokio::test] async fn concurrent_begins_have_exactly_one_winner() { let dir = tempfile::tempdir().unwrap(); @@ -234,7 +326,7 @@ mod tests { .collect(); let mut runs = 0; for t in tasks { - if t.await.unwrap() == Begin::Run { + if matches!(t.await.unwrap(), Begin::Run(_)) { runs += 1; } } diff --git a/crates/application/src/worker.rs b/crates/application/src/worker.rs index 3a768e9b..f01d64b9 100644 --- a/crates/application/src/worker.rs +++ b/crates/application/src/worker.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::time::Duration; use domain::{ errors::DomainError, @@ -88,6 +89,10 @@ impl WorkerService { } } +/// Margin added to a claim's remaining lease before a blocked delivery is +/// retried, so the retry lands after the lease has lapsed rather than on it. +const DEFER_MARGIN: Duration = Duration::from_secs(5); + async fn dispatch( handlers: Arc>>, ledger: Arc, @@ -95,21 +100,21 @@ async fn dispatch( ) { let id = envelope.id().copied(); let mut any_transient = false; + // Longest wait among handlers blocked behind another delivery's live claim. + let mut blocked_for: Option = None; for handler in handlers.iter() { let name = handler.name(); + let mut claim = None; if let Some(id) = &id { match ledger.begin(id, name).await { - Ok(Begin::Run) => {} + Ok(Begin::Run(token)) => claim = Some(token), Ok(Begin::AlreadyDone) => { tracing::debug!(handler = name, "already processed, skipping"); continue; } - Ok(Begin::InProgressElsewhere) => { - tracing::debug!( - handler = name, - "running in another delivery, retrying later" - ); - any_transient = true; + Ok(Begin::InProgressElsewhere { retry_after }) => { + tracing::debug!(handler = name, "running in another delivery, deferring"); + blocked_for = Some(blocked_for.map_or(retry_after, |b| b.max(retry_after))); continue; } Err(e) => { @@ -123,8 +128,8 @@ async fn dispatch( let result = handler.handle(&envelope.event).await; match &result { Ok(()) => { - if let Some(id) = &id - && let Err(e) = ledger.complete(id, name).await + if let (Some(id), Some(token)) = (&id, &claim) + && let Err(e) = ledger.complete(id, name, token).await { // The work is done; failing the delivery would only repeat it. // The claim lapses after its lease and the handler may run once more. @@ -135,8 +140,8 @@ async fn dispatch( // Permanent failures are released too, not recorded: if a sibling // handler's transient failure redelivers this event, a handler that // failed permanently runs (and fails) again. - if let Some(id) = &id - && let Err(le) = ledger.release(id, name).await + if let (Some(id), Some(token)) = (&id, &claim) + && let Err(le) = ledger.release(id, name, token).await { tracing::warn!(handler = name, "releasing ledger claim failed: {le}"); } @@ -152,7 +157,15 @@ async fn dispatch( } } } - if any_transient { + // A blocked handler cannot be retried before its claim is done or lapsed, + // so wait that long without spending a retry attempt; any transient failure + // in the same delivery is retried then too. A plain nack would burn the + // retry budget (and dead-letter) long before a crashed claim's lease ends. + if let Some(wait) = blocked_for { + if let Err(e) = envelope.defer(wait + DEFER_MARGIN).await { + tracing::error!("defer failed: {e}"); + } + } else if any_transient { if let Err(e) = envelope.nack().await { tracing::error!("nack failed: {e}"); } diff --git a/crates/application/tests/jobs/processed_events_cleanup_test.rs b/crates/application/tests/jobs/processed_events_cleanup_test.rs index 8ce87ede..cc685db3 100644 --- a/crates/application/tests/jobs/processed_events_cleanup_test.rs +++ b/crates/application/tests/jobs/processed_events_cleanup_test.rs @@ -26,7 +26,10 @@ async fn removes_only_rows_older_than_the_retention_window() { .unwrap(); use domain::ports::{Begin, ProcessedEventLedger}; - assert_eq!(store.begin(&stale, "h").await.unwrap(), Begin::Run); + assert!(matches!( + store.begin(&stale, "h").await.unwrap(), + Begin::Run(_) + )); assert_eq!(store.begin(&fresh, "h").await.unwrap(), Begin::AlreadyDone); } diff --git a/crates/application/tests/worker/worker_test.rs b/crates/application/tests/worker/worker_test.rs index 2d19f26a..34a24e64 100644 --- a/crates/application/tests/worker/worker_test.rs +++ b/crates/application/tests/worker/worker_test.rs @@ -28,6 +28,9 @@ impl AckHandle for TrackingAck { *self.nacked.lock().unwrap() = true; Ok(()) } + async fn defer(&self, _: std::time::Duration) -> Result<(), DomainError> { + Ok(()) + } } struct VecConsumer { @@ -380,9 +383,10 @@ struct RedeliveringConsumer { event: DomainEvent, times: usize, nacks: Arc>, + defers: Arc>>, } -struct CountNack(Arc>); +struct CountNack(Arc>, Arc>>); #[async_trait] impl AckHandle for CountNack { @@ -393,16 +397,24 @@ impl AckHandle for CountNack { *self.0.lock().unwrap() += 1; Ok(()) } + async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError> { + self.1.lock().unwrap().push(delay); + Ok(()) + } } impl EventConsumer for RedeliveringConsumer { fn consume(&self) -> BoxStream<'_, Result> { let nacks = Arc::clone(&self.nacks); + let defers = Arc::clone(&self.defers); let id = self.id; let event = self.event.clone(); Box::pin(stream::iter(0..self.times).then(move |_| { - let envelope = - EventEnvelope::new(id, event.clone(), Box::new(CountNack(Arc::clone(&nacks)))); + let envelope = EventEnvelope::new( + id, + event.clone(), + Box::new(CountNack(Arc::clone(&nacks), Arc::clone(&defers))), + ); async move { // Let the previous delivery's handlers finish first: a real // broker redelivers only after the nack. @@ -423,6 +435,7 @@ async fn a_redelivered_event_skips_handlers_that_already_succeeded() { event: movie_discovered(), times: 2, nacks: Arc::clone(&nacks), + defers: Default::default(), }; WorkerService::new( @@ -455,6 +468,7 @@ async fn events_without_id_run_every_time() { event: movie_discovered(), times: 2, nacks: Arc::new(Mutex::new(0)), + defers: Default::default(), }; WorkerService::new(Arc::new(consumer), vec![handler], InMemoryStore::new()) @@ -485,13 +499,18 @@ async fn a_handler_already_running_elsewhere_is_not_run_concurrently() { let store = InMemoryStore::new(); let id = domain::events::EventId::new(); // Another delivery holds a live claim on this (event, handler). - assert_eq!(store.begin(&id, "slow").await.unwrap(), Begin::Run); + assert!(matches!( + store.begin(&id, "slow").await.unwrap(), + Begin::Run(_) + )); let nacks = Arc::new(Mutex::new(0)); + let defers: Arc>> = Default::default(); let consumer = RedeliveringConsumer { id: Some(id), event: movie_discovered(), times: 1, nacks: Arc::clone(&nacks), + defers: Arc::clone(&defers), }; WorkerService::new(Arc::new(consumer), vec![handler], store) @@ -504,7 +523,85 @@ async fn a_handler_already_running_elsewhere_is_not_run_concurrently() { 0, "must not run beside the claim holder" ); - assert_eq!(*nacks.lock().unwrap(), 1, "delivery is retried later"); + assert_eq!( + *nacks.lock().unwrap(), + 0, + "a blocked delivery is not a failure" + ); + assert_eq!(defers.lock().unwrap().len(), 1, "delivery is retried later"); +} + +#[tokio::test] +async fn a_delivery_blocked_by_a_live_claim_is_deferred_without_consuming_an_attempt() { + use domain::ports::{Begin, HANDLER_LEASE_SECONDS, ProcessedEventLedger}; + let (handler, calls) = CountingHandler::new("slow", 0); + let store = InMemoryStore::new(); + let id = domain::events::EventId::new(); + assert!(matches!( + store.begin(&id, "slow").await.unwrap(), + Begin::Run(_) + )); + let nacks = Arc::new(Mutex::new(0)); + let defers: Arc>> = Default::default(); + let consumer = RedeliveringConsumer { + id: Some(id), + event: movie_discovered(), + times: 1, + nacks: Arc::clone(&nacks), + defers: Arc::clone(&defers), + }; + + WorkerService::new(Arc::new(consumer), vec![handler], store) + .unwrap() + .run(tokio::sync::watch::channel(false).1) + .await; + + assert_eq!(*calls.lock().unwrap(), 0); + assert_eq!(*nacks.lock().unwrap(), 0, "a nack would consume an attempt"); + let defers = defers.lock().unwrap(); + assert_eq!(defers.len(), 1); + // Retried only once the claim's lease has run out (plus a margin). + let lease = std::time::Duration::from_secs(HANDLER_LEASE_SECONDS as u64); + assert!( + defers[0] > lease - std::time::Duration::from_secs(5), + "{:?}", + defers[0] + ); + assert!( + defers[0] <= lease + std::time::Duration::from_secs(6), + "{:?}", + defers[0] + ); +} + +#[tokio::test] +async fn a_lapsed_owner_cannot_release_a_claim_taken_over_by_another() { + use domain::ports::{Begin, HANDLER_LEASE_SECONDS, ProcessedEventLedger}; + let store = InMemoryStore::new(); + let id = domain::events::EventId::new(); + store.seed_processed_event( + &id, + "h", + false, + chrono::Utc::now().naive_utc() - chrono::Duration::seconds(HANDLER_LEASE_SECONDS + 1), + ); + let Begin::Run(lapsed) = store.begin(&id, "h").await.unwrap() else { + panic!("stale claim should be taken over"); + }; + // Simulate: the original owner's token is from before the takeover. + let original = domain::ports::ClaimToken::new("2020-01-01T00:00:00.000Z".into()); + assert_ne!(original, lapsed); + + store.release(&id, "h", &original).await.unwrap(); + store.complete(&id, "h", &original).await.unwrap(); + + assert!( + matches!( + store.begin(&id, "h").await.unwrap(), + Begin::InProgressElsewhere { .. } + ), + "the new owner's claim must survive the old owner's release and complete" + ); } #[tokio::test] @@ -525,6 +622,7 @@ async fn a_stale_in_progress_claim_is_taken_over() { event: movie_discovered(), times: 1, nacks: Arc::clone(&nacks), + defers: Default::default(), }; WorkerService::new(Arc::new(consumer), vec![handler], store) @@ -546,6 +644,7 @@ async fn a_failed_handler_releases_its_claim_so_redelivery_retries() { event: movie_discovered(), times: 3, nacks: Arc::clone(&nacks), + defers: Default::default(), }; WorkerService::new(Arc::new(consumer), vec![handler], store.clone()) diff --git a/crates/bootstrap/tests/ledger_recovery_test.rs b/crates/bootstrap/tests/ledger_recovery_test.rs new file mode 100644 index 00000000..72e02ac8 --- /dev/null +++ b/crates/bootstrap/tests/ledger_recovery_test.rs @@ -0,0 +1,105 @@ +//! Crash recovery through the real worker dispatch and the real sqlite ledger. + +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); + +#[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>>); + +#[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>); + +impl EventConsumer for Once { + fn consume(&self) -> BoxStream<'_, Result> { + let item = self.0.lock().unwrap().take().map(Ok); + Box::pin(futures::stream::iter(item)) + } +} + +/// A claim abandoned mid-handler blocks redeliveries (deferred, not failed) +/// until its lease lapses; then the next delivery takes it over and runs the +/// handler once, and later deliveries skip it. +#[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(); + // The crashed worker: claimed, never completed or released. + 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"); +} diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 113d5fe8..3543cf9e 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -148,6 +148,11 @@ pub enum DomainEvent { pub trait AckHandle: Send + Sync { async fn ack(&self) -> Result<(), DomainError>; async fn nack(&self) -> Result<(), DomainError>; + /// Redeliver after `delay` without counting a failed attempt: for a + /// delivery that was not tried (it is blocked behind a live claim), so it + /// must not burn the retry budget that `nack` spends. A broker may still + /// count the redelivery itself: JetStream does, toward `max_deliver`. + async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError>; } /// Stable identity of one queued event; the key for de-duplicating deliveries. @@ -194,6 +199,10 @@ impl EventEnvelope { self.ack.ack().await } + pub async fn defer(self, delay: std::time::Duration) -> Result<(), DomainError> { + self.ack.defer(delay).await + } + pub async fn nack(self) -> Result<(), DomainError> { self.ack.nack().await } diff --git a/crates/domain/src/ports/processed_events.rs b/crates/domain/src/ports/processed_events.rs index d71a2553..ea542000 100644 --- a/crates/domain/src/ports/processed_events.rs +++ b/crates/domain/src/ports/processed_events.rs @@ -7,15 +7,32 @@ use crate::{errors::DomainError, events::EventId}; /// the next `begin` takes it over. Must exceed any handler's runtime. pub const HANDLER_LEASE_SECONDS: i64 = 1800; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Proof of ownership of one claim, returned by `begin`. `complete` and +/// `release` act only while the claim still carries this token, so an owner +/// whose lease lapsed cannot disturb whoever took the claim over. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimToken(String); + +impl ClaimToken { + pub fn new(value: String) -> Self { + Self(value) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Begin { /// The caller now holds the claim and must run the handler, then - /// `complete` or `release`. - Run, + /// `complete` or `release` with the token. + Run(ClaimToken), /// The handler already finished this event; skip it. AlreadyDone, - /// Another delivery holds a live claim; do not run, retry the delivery later. - InProgressElsewhere, + /// Another delivery holds a live claim; do not run. Retry the delivery + /// after `retry_after`, when the claim is done or its lease has lapsed. + InProgressElsewhere { retry_after: std::time::Duration }, } /// Per-(event, handler) claim ledger that stops redelivered events from @@ -32,11 +49,21 @@ pub trait ProcessedEventLedger: Send + Sync { /// reports the existing state. async fn begin(&self, id: &EventId, handler: &str) -> Result; - /// Marks the claim done: the handler will not run for this event again. - async fn complete(&self, id: &EventId, handler: &str) -> Result<(), DomainError>; + /// Marks the claim done, if `token` still owns it: the handler will not run for this event again. + async fn complete( + &self, + id: &EventId, + handler: &str, + token: &ClaimToken, + ) -> Result<(), DomainError>; - /// Drops an `in_progress` claim so a retry can run immediately. - async fn release(&self, id: &EventId, handler: &str) -> Result<(), DomainError>; + /// Drops an `in_progress` claim, if `token` still owns it, so a retry can run immediately. + async fn release( + &self, + id: &EventId, + handler: &str, + token: &ClaimToken, + ) -> Result<(), DomainError>; /// Removes rows (done, or abandoned `in_progress`) first recorded before /// `cutoff`. Returns the number removed. diff --git a/crates/domain/src/testing/fakes/noops.rs b/crates/domain/src/testing/fakes/noops.rs index 19beaf94..4a633de4 100644 --- a/crates/domain/src/testing/fakes/noops.rs +++ b/crates/domain/src/testing/fakes/noops.rs @@ -159,6 +159,9 @@ impl AckHandle for NoopAckHandle { async fn nack(&self) -> Result<(), DomainError> { Ok(()) } + async fn defer(&self, _: std::time::Duration) -> Result<(), DomainError> { + Ok(()) + } } // ── NoopPersonCommand ───────────────────────────────────────────────────────── diff --git a/crates/domain/src/testing/fakes/store_processed_events.rs b/crates/domain/src/testing/fakes/store_processed_events.rs index f1f96eb7..f26c71d7 100644 --- a/crates/domain/src/testing/fakes/store_processed_events.rs +++ b/crates/domain/src/testing/fakes/store_processed_events.rs @@ -1,21 +1,36 @@ use async_trait::async_trait; -use chrono::{Duration, NaiveDateTime, Utc}; +use chrono::{Duration, NaiveDateTime, Timelike, Utc}; use crate::{ errors::DomainError, events::EventId, - ports::{Begin, HANDLER_LEASE_SECONDS, ProcessedEventLedger}, + ports::{Begin, ClaimToken, HANDLER_LEASE_SECONDS, ProcessedEventLedger}, }; use super::InMemoryStore; use super::store::LedgerRow; +/// Claim stamps are millisecond-precision UTC, as in the sqlite adapter. +const STAMP: &str = "%Y-%m-%dT%H:%M:%S%.3fZ"; + +fn now_millis() -> NaiveDateTime { + let now = Utc::now().naive_utc(); + now.with_nanosecond(now.nanosecond() / 1_000_000 * 1_000_000) + .expect("truncated nanoseconds are valid") +} + +fn token(at: NaiveDateTime) -> ClaimToken { + ClaimToken::new(at.format(STAMP).to_string()) +} + /// Mirrors the sqlite adapter: `begin` inserts, or takes over only a stale -/// `in_progress` claim; `(event_id, handler)` is the key. +/// `in_progress` claim; `(event_id, handler)` is the key; `complete` and +/// `release` act only for the token that owns the claim. #[async_trait] impl ProcessedEventLedger for InMemoryStore { async fn begin(&self, id: &EventId, handler: &str) -> Result { - let now = Utc::now().naive_utc(); + let now = now_millis(); + let lease = Duration::seconds(HANDLER_LEASE_SECONDS); let mut rows = self.processed_events.write().unwrap(); let key = (id.value(), handler.to_owned()); match rows.get_mut(&key) { @@ -28,23 +43,38 @@ impl ProcessedEventLedger for InMemoryStore { processed_at: now, }, ); - Ok(Begin::Run) + Ok(Begin::Run(token(now))) } Some(row) if row.done => Ok(Begin::AlreadyDone), - Some(row) if row.started_at < now - Duration::seconds(HANDLER_LEASE_SECONDS) => { + Some(row) if row.started_at < now - lease => { row.started_at = now; - Ok(Begin::Run) + row.processed_at = now; + Ok(Begin::Run(token(now))) + } + Some(row) => { + let remaining = (row.started_at + lease - now) + .to_std() + .unwrap_or_default() + .max(std::time::Duration::from_secs(1)); + Ok(Begin::InProgressElsewhere { + retry_after: remaining, + }) } - Some(_) => Ok(Begin::InProgressElsewhere), } } - async fn complete(&self, id: &EventId, handler: &str) -> Result<(), DomainError> { + async fn complete( + &self, + id: &EventId, + handler: &str, + owner: &ClaimToken, + ) -> Result<(), DomainError> { if let Some(row) = self .processed_events .write() .unwrap() .get_mut(&(id.value(), handler.to_owned())) + && token(row.started_at) == *owner { row.done = true; row.processed_at = Utc::now().naive_utc(); @@ -52,10 +82,18 @@ impl ProcessedEventLedger for InMemoryStore { Ok(()) } - async fn release(&self, id: &EventId, handler: &str) -> Result<(), DomainError> { + async fn release( + &self, + id: &EventId, + handler: &str, + owner: &ClaimToken, + ) -> Result<(), DomainError> { let mut rows = self.processed_events.write().unwrap(); let key = (id.value(), handler.to_owned()); - if rows.get(&key).is_some_and(|r| !r.done) { + if rows + .get(&key) + .is_some_and(|r| !r.done && token(r.started_at) == *owner) + { rows.remove(&key); } Ok(()) -- 2.49.1 From c8a1e8bfe4b147c16b2bed8552d744aec1779f34 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 00:40:43 +0100 Subject: [PATCH 059/104] feat: atomic review writes with events --- .../sqlite/src/repositories/review/mod.rs | 57 ++++++--- crates/adapters/sqlite/tests/cascade_test.rs | 4 +- crates/adapters/sqlite/tests/repositories.rs | 2 + .../repositories/review_repository_test.rs | 82 ++++++++++++ crates/application/src/diary/delete_review.rs | 21 ++-- crates/application/src/diary/deps.rs | 1 - crates/application/src/diary/edit_review.rs | 25 ++-- crates/application/src/diary/review_logger.rs | 23 ++-- .../tests/diary/delete_review_test.rs | 72 ++++++++++- .../tests/diary/edit_review_test.rs | 119 +++++++++++++----- .../tests/diary/log_review_test.rs | 5 +- .../tests/diary/review_logger_test.rs | 76 ++++++++--- crates/bootstrap/src/build.rs | 1 - crates/domain/src/ports/diary.rs | 16 ++- crates/domain/src/testing/fakes/panics.rs | 6 +- crates/domain/src/testing/fakes/refusing.rs | 6 +- crates/domain/src/testing/fakes/store.rs | 15 +++ .../domain/src/testing/fakes/store_review.rs | 22 +++- .../tests/testing/in_memory_store_test.rs | 4 +- 19 files changed, 440 insertions(+), 117 deletions(-) create mode 100644 crates/adapters/sqlite/tests/repositories/review_repository_test.rs diff --git a/crates/adapters/sqlite/src/repositories/review/mod.rs b/crates/adapters/sqlite/src/repositories/review/mod.rs index ca1f8e69..948ed04c 100644 --- a/crates/adapters/sqlite/src/repositories/review/mod.rs +++ b/crates/adapters/sqlite/src/repositories/review/mod.rs @@ -1,6 +1,7 @@ use async_trait::async_trait; use domain::{ errors::DomainError, + events::DomainEvent, models::{Review, ReviewSource}, ports::ReviewRepository, value_objects::{ReviewId, UserId}, @@ -10,7 +11,7 @@ use sqlx::SqlitePool; use rows::ReviewRow; pub(crate) mod rows; -use crate::shared::datetime_to_str; +use crate::shared::{datetime_to_str, enqueue_events}; pub struct SqliteReviewRepository { pool: SqlitePool, @@ -24,7 +25,11 @@ impl SqliteReviewRepository { #[async_trait] impl ReviewRepository for SqliteReviewRepository { - async fn save_review(&self, review: &Review) -> Result<(), DomainError> { + async fn save_review( + &self, + review: &Review, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let id = review.id().value().to_string(); let movie_id = review.movie_id().value().to_string(); let user_id = review.user_id().value().to_string(); @@ -37,6 +42,11 @@ impl ReviewRepository for SqliteReviewRepository { ReviewSource::Remote { actor_url } => Some(actor_url.clone()), }; + let mut tx = self + .pool + .begin() + .await + .map_err(crate::shared::map_sqlx_error)?; sqlx::query( "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", @@ -51,11 +61,11 @@ impl ReviewRepository for SqliteReviewRepository { .bind(&remote_actor_url) .bind(review.watch_medium().map(|wm| wm.to_string())) .bind(review.spoilers()) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) + enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) } async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { @@ -72,13 +82,22 @@ impl ReviewRepository for SqliteReviewRepository { .transpose() } - async fn update_review(&self, review: &Review) -> Result<(), DomainError> { + async fn update_review( + &self, + review: &Review, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let id = review.id().value().to_string(); let rating = review.rating().value() as i64; let comment = review.comment().map(|c| c.value().to_string()); let watched_at = datetime_to_str(review.watched_at()); let watch_medium = review.watch_medium().map(|wm| wm.to_string()); + let mut tx = self + .pool + .begin() + .await + .map_err(crate::shared::map_sqlx_error)?; sqlx::query( "UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?, spoilers = ? WHERE id = ?", ) @@ -88,21 +107,31 @@ impl ReviewRepository for SqliteReviewRepository { .bind(&watch_medium) .bind(review.spoilers()) .bind(&id) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) + enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) } - async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> { + async fn delete_review( + &self, + review_id: &ReviewId, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let id = review_id.value().to_string(); - sqlx::query("DELETE FROM reviews WHERE id = ?") - .bind(&id) - .execute(&self.pool) + let mut tx = self + .pool + .begin() .await .map_err(crate::shared::map_sqlx_error)?; - Ok(()) + sqlx::query("DELETE FROM reviews WHERE id = ?") + .bind(&id) + .execute(&mut *tx) + .await + .map_err(crate::shared::map_sqlx_error)?; + enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) } async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError> { diff --git a/crates/adapters/sqlite/tests/cascade_test.rs b/crates/adapters/sqlite/tests/cascade_test.rs index 44c7d2e5..7a07b892 100644 --- a/crates/adapters/sqlite/tests/cascade_test.rs +++ b/crates/adapters/sqlite/tests/cascade_test.rs @@ -199,7 +199,7 @@ async fn seed_user_with_dependents( parts .review - .save_review(&test_review(movie.id(), &user_id, 4)) + .save_review(&test_review(movie.id(), &user_id, 4), &[]) .await .unwrap(); (user_id, movie) @@ -302,7 +302,7 @@ async fn deleting_a_movie_that_has_reviews_is_refused_because_reviews_do_not_cas parts.movie_command.upsert_movie(&movie).await.unwrap(); parts .review - .save_review(&test_review(movie.id(), user.id(), 5)) + .save_review(&test_review(movie.id(), user.id(), 5), &[]) .await .unwrap(); diff --git a/crates/adapters/sqlite/tests/repositories.rs b/crates/adapters/sqlite/tests/repositories.rs index 88f5f4e2..aa49c50f 100644 --- a/crates/adapters/sqlite/tests/repositories.rs +++ b/crates/adapters/sqlite/tests/repositories.rs @@ -8,6 +8,8 @@ mod movie_dedup_test; mod movie_profile_repository_test; #[path = "repositories/person_adapter_test.rs"] mod person_adapter_test; +#[path = "repositories/review_repository_test.rs"] +mod review_repository_test; #[path = "repositories/search_adapter_test.rs"] mod search_adapter_test; #[path = "repositories/social_repository_test.rs"] diff --git a/crates/adapters/sqlite/tests/repositories/review_repository_test.rs b/crates/adapters/sqlite/tests/repositories/review_repository_test.rs new file mode 100644 index 00000000..f8166a4b --- /dev/null +++ b/crates/adapters/sqlite/tests/repositories/review_repository_test.rs @@ -0,0 +1,82 @@ +use std::str::FromStr; + +use domain::{ + events::DomainEvent, + ports::{MovieCommand, ReviewRepository, UserRepository}, + testing::{test_movie, test_review, test_user}, +}; +use sqlite::{SqliteMovieRepository, SqliteReviewRepository, SqliteUserRepository}; +use sqlx::{ + SqlitePool, + sqlite::{SqliteConnectOptions, SqlitePoolOptions}, +}; + +async fn migrated_pool() -> SqlitePool { + let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(opts) + .await + .unwrap(); + sqlite::migrate(&pool).await.unwrap(); + pool +} + +async fn count(pool: &SqlitePool, table: &str) -> i64 { + sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}")) + .fetch_one(pool) + .await + .unwrap() +} + +fn logged(review: &domain::models::Review) -> DomainEvent { + DomainEvent::ReviewLogged { + review_id: review.id().clone(), + movie_id: review.movie_id().clone(), + user_id: review.user_id().clone(), + rating: review.rating().clone(), + watched_at: *review.watched_at(), + } +} + +#[tokio::test] +async fn saving_a_review_writes_the_review_and_its_event_in_one_commit() { + let pool = migrated_pool().await; + let movie = test_movie("Heat"); + let user = test_user("alice"); + SqliteUserRepository::new(pool.clone()) + .save(&user) + .await + .unwrap(); + SqliteMovieRepository::new(pool.clone()) + .upsert_movie(&movie) + .await + .unwrap(); + let review = test_review(movie.id(), user.id(), 4); + + SqliteReviewRepository::new(pool.clone()) + .save_review(&review, &[logged(&review)]) + .await + .unwrap(); + + assert_eq!(count(&pool, "reviews").await, 1); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failed_review_save_leaves_no_event_row() { + let pool = migrated_pool().await; + let review = test_review( + &domain::value_objects::MovieId::generate(), + &domain::value_objects::UserId::generate(), + 4, + ); + + let result = SqliteReviewRepository::new(pool.clone()) + .save_review(&review, &[logged(&review)]) + .await; + + assert!(result.is_err(), "FK violation must fail the save"); + assert_eq!(count(&pool, "reviews").await, 0); + assert_eq!(count(&pool, "event_queue").await, 0); +} diff --git a/crates/application/src/diary/delete_review.rs b/crates/application/src/diary/delete_review.rs index 5c6c7000..3a67b26d 100644 --- a/crates/application/src/diary/delete_review.rs +++ b/crates/application/src/diary/delete_review.rs @@ -20,18 +20,15 @@ pub async fn execute(deps: &DeleteReviewDeps, cmd: DeleteReviewCommand) -> Resul } let movie_id = review.movie_id().clone(); - deps.review.delete_review(&review_id).await?; - - if let Err(e) = deps - .event_publisher - .publish(&DomainEvent::ReviewDeleted { - review_id: review_id.clone(), - user_id: requesting_user_id.clone(), - }) - .await - { - tracing::warn!("failed to publish ReviewDeleted: {e}"); - } + deps.review + .delete_review( + &review_id, + &[DomainEvent::ReviewDeleted { + review_id: review_id.clone(), + user_id: requesting_user_id.clone(), + }], + ) + .await?; let history = deps.diary.get_review_history(&movie_id).await?; if history.viewings().is_empty() { diff --git a/crates/application/src/diary/deps.rs b/crates/application/src/diary/deps.rs index 2f3d0995..bbd56789 100644 --- a/crates/application/src/diary/deps.rs +++ b/crates/application/src/diary/deps.rs @@ -16,7 +16,6 @@ pub struct DeleteReviewDeps { pub struct EditReviewDeps { pub review: Arc, - pub event_publisher: Arc, } pub struct GetMovieSocialPageDeps { diff --git a/crates/application/src/diary/edit_review.rs b/crates/application/src/diary/edit_review.rs index 64f1c159..9e6c7224 100644 --- a/crates/application/src/diary/edit_review.rs +++ b/crates/application/src/diary/edit_review.rs @@ -37,21 +37,12 @@ pub async fn execute(deps: &EditReviewDeps, cmd: EditReviewCommand) -> Result<() spoilers: cmd.spoilers, }); - deps.review.update_review(&updated).await?; - - if let Err(e) = deps - .event_publisher - .publish(&DomainEvent::ReviewUpdated { - review_id: updated.id().clone(), - movie_id: updated.movie_id().clone(), - user_id: updated.user_id().clone(), - rating: updated.rating().clone(), - watched_at: *updated.watched_at(), - }) - .await - { - tracing::warn!("failed to publish ReviewUpdated: {e}"); - } - - Ok(()) + let event = DomainEvent::ReviewUpdated { + review_id: updated.id().clone(), + movie_id: updated.movie_id().clone(), + user_id: updated.user_id().clone(), + rating: updated.rating().clone(), + watched_at: *updated.watched_at(), + }; + deps.review.update_review(&updated, &[event]).await } diff --git a/crates/application/src/diary/review_logger.rs b/crates/application/src/diary/review_logger.rs index a5e4683f..d6415392 100644 --- a/crates/application/src/diary/review_logger.rs +++ b/crates/application/src/diary/review_logger.rs @@ -75,7 +75,6 @@ impl ReviewLogger for DefaultReviewLogger { cmd.watch_medium, cmd.spoilers, )?; - self.review_repo.save_review(&review).await?; let review_event = DomainEvent::ReviewLogged { review_id: review.id().clone(), movie_id: review.movie_id().clone(), @@ -84,6 +83,17 @@ impl ReviewLogger for DefaultReviewLogger { watched_at: *review.watched_at(), }; + // Events ride the review save (the decisive write) so they commit with it. + let mut events = Vec::with_capacity(2); + if let Some(ext_id) = movie.external_metadata_id() { + events.push(DomainEvent::MovieEnrichmentRequested { + movie_id: movie.id().clone(), + external_metadata_id: ext_id.clone(), + }); + } + events.push(review_event); + self.review_repo.save_review(&review, &events).await?; + let was_on_watchlist = self .watchlist_repo .remove_if_present(review.user_id(), review.movie_id()) @@ -98,15 +108,6 @@ impl ReviewLogger for DefaultReviewLogger { .await; } - if let Some(ext_id) = movie.external_metadata_id() { - self.event_publisher - .publish(&DomainEvent::MovieEnrichmentRequested { - movie_id: movie.id().clone(), - external_metadata_id: ext_id.clone(), - }) - .await?; - } - - self.event_publisher.publish(&review_event).await + Ok(()) } } diff --git a/crates/application/tests/diary/delete_review_test.rs b/crates/application/tests/diary/delete_review_test.rs index 9ef43edc..f9d09e5a 100644 --- a/crates/application/tests/diary/delete_review_test.rs +++ b/crates/application/tests/diary/delete_review_test.rs @@ -23,7 +23,7 @@ async fn deleting_a_review_removes_it() { let review = test_review(movie.id(), &user_id, 4); movies.upsert_movie(&movie).await.unwrap(); - reviews.save_review(&review).await.unwrap(); + reviews.save_review(&review, &[]).await.unwrap(); diary.seed_history(movie.clone(), vec![]); let deps = DeleteReviewDeps { @@ -62,7 +62,7 @@ async fn deleting_another_users_review_is_unauthorized() { let other_id = uuid::Uuid::new_v4(); let review = test_review(&movie_id, &owner_id, 4); - reviews.save_review(&review).await.unwrap(); + reviews.save_review(&review, &[]).await.unwrap(); let deps = DeleteReviewDeps { review: Arc::clone(&reviews) as _, @@ -83,3 +83,71 @@ async fn deleting_another_users_review_is_unauthorized() { assert!(result.is_err(), "wrong user should not be able to delete"); assert_eq!(reviews.review_count(), 1, "review should still exist"); } + +#[tokio::test] +async fn deleting_a_review_removes_it_and_records_its_event_together() { + let movies = InMemoryStore::new(); + let diary = FakeDiaryQuery::new(); + let events = NoopEventPublisher::new(); + let movie = test_movie_with_year("Terminator", 1984); + let user_id = UserId::from_uuid(uuid::Uuid::new_v4()); + let review = test_review(movie.id(), &user_id, 4); + movies.upsert_movie(&movie).await.unwrap(); + movies.save_review(&review, &[]).await.unwrap(); + diary.seed_history(movie.clone(), vec![review.clone()]); + let deps = DeleteReviewDeps { + review: Arc::clone(&movies) as _, + diary: diary.clone() as _, + movie_command: Arc::clone(&movies) as _, + event_publisher: Arc::clone(&events) as _, + }; + + delete_review::execute( + &deps, + DeleteReviewCommand { + review_id: review.id().value(), + requesting_user_id: user_id.value(), + }, + ) + .await + .unwrap(); + + assert_eq!(movies.review_count(), 0); + let recorded = movies.recorded_events(); + assert_eq!(recorded.len(), 1); + assert!(matches!( + recorded[0], + domain::events::DomainEvent::ReviewDeleted { .. } + )); +} + +#[tokio::test] +async fn a_refused_delete_records_neither_the_removal_nor_its_event() { + let movies = InMemoryStore::new(); + let diary = FakeDiaryQuery::new(); + let events = NoopEventPublisher::new(); + let user_id = UserId::from_uuid(uuid::Uuid::new_v4()); + let review = test_review(&MovieId::generate(), &user_id, 4); + movies.save_review(&review, &[]).await.unwrap(); + let deps = DeleteReviewDeps { + review: Arc::new(crate::edit_review_test::WriteRefusingReviews(Arc::clone( + &movies, + ))), + diary: diary as _, + movie_command: Arc::clone(&movies) as _, + event_publisher: Arc::clone(&events) as _, + }; + + let result = delete_review::execute( + &deps, + DeleteReviewCommand { + review_id: review.id().value(), + requesting_user_id: user_id.value(), + }, + ) + .await; + + assert!(result.is_err()); + assert_eq!(movies.review_count(), 1); + assert!(movies.recorded_events().is_empty()); +} diff --git a/crates/application/tests/diary/edit_review_test.rs b/crates/application/tests/diary/edit_review_test.rs index 09b0d1b1..9f184dc6 100644 --- a/crates/application/tests/diary/edit_review_test.rs +++ b/crates/application/tests/diary/edit_review_test.rs @@ -4,20 +4,14 @@ use std::sync::Arc; use domain::{ models::ReviewEdit, ports::ReviewRepository, - testing::{InMemoryStore, NoopEventPublisher, test_review}, + testing::{InMemoryStore, test_review}, value_objects::{Comment, MovieId, ReviewId, UserId, WatchMedium}, }; use application::diary::{commands::EditReviewCommand, deps::EditReviewDeps, edit_review}; -async fn setup() -> ( - Arc, - Arc, - ReviewId, - UserId, -) { +async fn setup() -> (Arc, ReviewId, UserId) { let reviews = InMemoryStore::new(); - let events = NoopEventPublisher::new(); let user_id = UserId::generate(); let review = test_review(&MovieId::generate(), &user_id, 3).apply_edit(ReviewEdit { comment: Some(Some(Comment::new("original comment".into()).unwrap())), @@ -25,23 +19,22 @@ async fn setup() -> ( ..Default::default() }); let review_id = review.id().clone(); - reviews.save_review(&review).await.unwrap(); - (reviews, events, review_id, user_id) + reviews.save_review(&review, &[]).await.unwrap(); + (reviews, review_id, user_id) } -fn deps(reviews: &Arc, events: &Arc) -> EditReviewDeps { +fn deps(reviews: &Arc) -> EditReviewDeps { EditReviewDeps { review: Arc::clone(reviews) as _, - event_publisher: Arc::clone(events) as _, } } #[tokio::test] async fn edit_own_review_updates_rating() { - let (reviews, events, review_id, user_id) = setup().await; + let (reviews, review_id, user_id) = setup().await; edit_review::execute( - &deps(&reviews, &events), + &deps(&reviews), EditReviewCommand { review_id: review_id.value(), requesting_user_id: user_id.value(), @@ -60,9 +53,8 @@ async fn edit_own_review_updates_rating() { #[tokio::test] async fn edit_nonexistent_review_returns_not_found() { let reviews = InMemoryStore::new(); - let events = NoopEventPublisher::new(); - let err = edit_review::execute(&deps(&reviews, &events), edit_review_command(5)) + let err = edit_review::execute(&deps(&reviews), edit_review_command(5)) .await .unwrap_err(); @@ -71,10 +63,10 @@ async fn edit_nonexistent_review_returns_not_found() { #[tokio::test] async fn edit_other_users_review_returns_forbidden() { - let (reviews, events, review_id, _user_id) = setup().await; + let (reviews, review_id, _user_id) = setup().await; let err = edit_review::execute( - &deps(&reviews, &events), + &deps(&reviews), EditReviewCommand { review_id: review_id.value(), ..edit_review_command(5) @@ -86,12 +78,59 @@ async fn edit_other_users_review_returns_forbidden() { assert!(matches!(err, domain::errors::DomainError::Forbidden(_))); } +/// Loads from the store but refuses every write. +pub(crate) struct WriteRefusingReviews(pub(crate) Arc); + +#[async_trait::async_trait] +impl ReviewRepository for WriteRefusingReviews { + async fn save_review( + &self, + _: &domain::models::Review, + _: &[domain::events::DomainEvent], + ) -> Result<(), domain::errors::DomainError> { + Err(domain::errors::DomainError::InfrastructureError( + "refused".into(), + )) + } + async fn get_review_by_id( + &self, + id: &ReviewId, + ) -> Result, domain::errors::DomainError> { + self.0.get_review_by_id(id).await + } + async fn update_review( + &self, + _: &domain::models::Review, + _: &[domain::events::DomainEvent], + ) -> Result<(), domain::errors::DomainError> { + Err(domain::errors::DomainError::InfrastructureError( + "refused".into(), + )) + } + async fn delete_review( + &self, + _: &ReviewId, + _: &[domain::events::DomainEvent], + ) -> Result<(), domain::errors::DomainError> { + Err(domain::errors::DomainError::InfrastructureError( + "refused".into(), + )) + } + async fn get_all_reviews_for_user( + &self, + id: &UserId, + ) -> Result, domain::errors::DomainError> { + self.0.get_all_reviews_for_user(id).await + } +} + #[tokio::test] -async fn edit_publishes_review_updated_event() { - let (reviews, events, review_id, user_id) = setup().await; +async fn editing_a_review_saves_the_change_and_its_event_together() { + let (reviews, review_id, user_id) = setup().await; + assert!(reviews.recorded_events().is_empty()); edit_review::execute( - &deps(&reviews, &events), + &deps(&reviews), EditReviewCommand { review_id: review_id.value(), requesting_user_id: user_id.value(), @@ -101,20 +140,45 @@ async fn edit_publishes_review_updated_event() { .await .unwrap(); - let published = events.published(); - assert_eq!(published.len(), 1); + let updated = reviews.get_review_by_id(&review_id).await.unwrap().unwrap(); + assert_eq!(updated.rating().value(), 1); + let recorded = reviews.recorded_events(); + assert_eq!(recorded.len(), 1); assert!(matches!( - published[0], + recorded[0], domain::events::DomainEvent::ReviewUpdated { .. } )); } +#[tokio::test] +async fn a_refused_edit_records_neither_the_change_nor_its_event() { + let (reviews, review_id, user_id) = setup().await; + let deps = EditReviewDeps { + review: Arc::new(WriteRefusingReviews(Arc::clone(&reviews))), + }; + + let result = edit_review::execute( + &deps, + EditReviewCommand { + review_id: review_id.value(), + requesting_user_id: user_id.value(), + ..edit_review_command(1) + }, + ) + .await; + + assert!(result.is_err()); + let unchanged = reviews.get_review_by_id(&review_id).await.unwrap().unwrap(); + assert_eq!(unchanged.rating().value(), 3); + assert!(reviews.recorded_events().is_empty()); +} + #[tokio::test] async fn edit_sets_watch_medium() { - let (reviews, events, review_id, user_id) = setup().await; + let (reviews, review_id, user_id) = setup().await; edit_review::execute( - &deps(&reviews, &events), + &deps(&reviews), EditReviewCommand { rating: None, review_id: review_id.value(), @@ -132,10 +196,9 @@ async fn edit_sets_watch_medium() { #[tokio::test] async fn editing_a_review_fails_when_the_store_refuses_to_load_it() { - let (_, events, review_id, user_id) = setup().await; + let (_, review_id, user_id) = setup().await; let deps = EditReviewDeps { review: Arc::new(domain::testing::RefusingReviewRepository), - event_publisher: Arc::clone(&events) as _, }; let result = edit_review::execute( diff --git a/crates/application/tests/diary/log_review_test.rs b/crates/application/tests/diary/log_review_test.rs index 3dc95590..ab0ca4c1 100644 --- a/crates/application/tests/diary/log_review_test.rs +++ b/crates/application/tests/diary/log_review_test.rs @@ -39,7 +39,10 @@ async fn logging_a_review_creates_the_movie_and_the_review() { log_review::execute(&logger, cmd).await.unwrap(); assert_eq!(reviews.review_count(), 1, "review should be saved"); - assert!(!events.published().is_empty(), "events should be published"); + assert!( + !movies.recorded_events().is_empty(), + "events should be recorded with the review" + ); } #[tokio::test] diff --git a/crates/application/tests/diary/review_logger_test.rs b/crates/application/tests/diary/review_logger_test.rs index 98e76180..2df444c4 100644 --- a/crates/application/tests/diary/review_logger_test.rs +++ b/crates/application/tests/diary/review_logger_test.rs @@ -212,13 +212,6 @@ async fn publishes_movie_discovered_for_new_movie_with_external_id() { .any(|e| matches!(e, domain::events::DomainEvent::MovieDiscovered { .. })), "should publish MovieDiscovered for new movie with external_metadata_id" ); - assert!( - published.iter().any(|e| matches!( - e, - domain::events::DomainEvent::MovieEnrichmentRequested { .. } - )), - "should publish MovieEnrichmentRequested" - ); } #[tokio::test] @@ -241,8 +234,7 @@ async fn logging_a_review_fails_when_the_review_store_refuses() { } #[tokio::test] -async fn logging_a_review_when_event_publishing_fails_returns_the_error_but_keeps_the_saved_review() -{ +async fn logging_a_review_saves_the_review_and_its_event_together() { let movies = InMemoryStore::new(); let logger = DefaultReviewLogger::new( Arc::clone(&movies) as _, @@ -253,14 +245,70 @@ async fn logging_a_review_when_event_publishing_fails_returns_the_error_but_keep Arc::new(domain::testing::RefusingEventPublisher), ); + logger + .log_review(log_review_command_manual(Uuid::new_v4(), "Test Film", 2024)) + .await + .unwrap(); + + assert_eq!(movies.review_count(), 1); + let recorded = movies.recorded_events(); + assert_eq!(recorded.len(), 1); + assert!(matches!( + recorded[0], + domain::events::DomainEvent::ReviewLogged { .. } + )); +} + +#[tokio::test] +async fn a_refused_save_records_neither_the_review_nor_its_event() { + let movies = InMemoryStore::new(); + let logger = DefaultReviewLogger::new( + Arc::clone(&movies) as _, + Arc::clone(&movies) as _, + Arc::new(domain::testing::RefusingReviewRepository), + Arc::clone(&movies) as _, + Arc::new(FakeMetadataClient), + NoopEventPublisher::new(), + ); + let result = logger .log_review(log_review_command_manual(Uuid::new_v4(), "Test Film", 2024)) .await; - assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused")); - assert_eq!( - movies.review_count(), - 1, - "review is saved before publishing" + assert!(result.is_err()); + assert_eq!(movies.review_count(), 0); + assert!(movies.recorded_events().is_empty()); +} + +#[tokio::test] +async fn enrichment_request_rides_the_review_save() { + let movies = InMemoryStore::new(); + let logger = DefaultReviewLogger::new( + Arc::clone(&movies) as _, + Arc::clone(&movies) as _, + Arc::clone(&movies) as _, + Arc::clone(&movies) as _, + Arc::new(MetadataClientWithExternalId), + NoopEventPublisher::new(), + ); + + logger + .log_review(log_review_command_manual( + Uuid::new_v4(), + "Discovered Film", + 2024, + )) + .await + .unwrap(); + + let recorded = movies.recorded_events(); + assert!(recorded.iter().any(|e| matches!( + e, + domain::events::DomainEvent::MovieEnrichmentRequested { .. } + ))); + assert!( + recorded + .iter() + .any(|e| matches!(e, domain::events::DomainEvent::ReviewLogged { .. })) ); } diff --git a/crates/bootstrap/src/build.rs b/crates/bootstrap/src/build.rs index 4fdde3d9..90fd7f6f 100644 --- a/crates/bootstrap/src/build.rs +++ b/crates/bootstrap/src/build.rs @@ -94,7 +94,6 @@ pub fn build_deps( }, edit_review: EditReviewDeps { review: repos.review.clone(), - event_publisher: services.event_publisher.clone(), }, get_movie_social_page: GetMovieSocialPageDeps { movie_query: repos.movie_query.clone(), diff --git a/crates/domain/src/ports/diary.rs b/crates/domain/src/ports/diary.rs index 5c742f4c..3ae36d54 100644 --- a/crates/domain/src/ports/diary.rs +++ b/crates/domain/src/ports/diary.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use crate::{ errors::DomainError, + events::DomainEvent, models::{ DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FeedSortBy, FollowingFilter, MovieStats, Review, ReviewHistory, UserStats, UserTrends, @@ -42,10 +43,19 @@ pub trait DiaryQuery: Send + Sync { #[async_trait] pub trait ReviewRepository: Send + Sync { - async fn save_review(&self, review: &Review) -> Result<(), DomainError>; + async fn save_review(&self, review: &Review, events: &[DomainEvent]) + -> Result<(), DomainError>; async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError>; - async fn update_review(&self, review: &Review) -> Result<(), DomainError>; - async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>; + async fn update_review( + &self, + review: &Review, + events: &[DomainEvent], + ) -> Result<(), DomainError>; + async fn delete_review( + &self, + review_id: &ReviewId, + events: &[DomainEvent], + ) -> Result<(), DomainError>; async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError>; } diff --git a/crates/domain/src/testing/fakes/panics.rs b/crates/domain/src/testing/fakes/panics.rs index 1039d599..fa9dd781 100644 --- a/crates/domain/src/testing/fakes/panics.rs +++ b/crates/domain/src/testing/fakes/panics.rs @@ -538,16 +538,16 @@ pub struct PanicReviewRepository; #[async_trait] impl ReviewRepository for PanicReviewRepository { - async fn save_review(&self, _: &Review) -> Result<(), DomainError> { + async fn save_review(&self, _: &Review, _: &[DomainEvent]) -> Result<(), DomainError> { panic!("PanicReviewRepository called") } async fn get_review_by_id(&self, _: &ReviewId) -> Result, DomainError> { panic!("PanicReviewRepository called") } - async fn update_review(&self, _: &Review) -> Result<(), DomainError> { + async fn update_review(&self, _: &Review, _: &[DomainEvent]) -> Result<(), DomainError> { panic!("PanicReviewRepository called") } - async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { + async fn delete_review(&self, _: &ReviewId, _: &[DomainEvent]) -> Result<(), DomainError> { panic!("PanicReviewRepository called") } async fn get_all_reviews_for_user(&self, _: &UserId) -> Result, DomainError> { diff --git a/crates/domain/src/testing/fakes/refusing.rs b/crates/domain/src/testing/fakes/refusing.rs index 4549d3d4..9ea36bef 100644 --- a/crates/domain/src/testing/fakes/refusing.rs +++ b/crates/domain/src/testing/fakes/refusing.rs @@ -21,16 +21,16 @@ pub struct RefusingReviewRepository; #[async_trait] impl ReviewRepository for RefusingReviewRepository { - async fn save_review(&self, _: &Review) -> Result<(), DomainError> { + async fn save_review(&self, _: &Review, _: &[DomainEvent]) -> Result<(), DomainError> { Err(refused()) } async fn get_review_by_id(&self, _: &ReviewId) -> Result, DomainError> { Err(refused()) } - async fn update_review(&self, _: &Review) -> Result<(), DomainError> { + async fn update_review(&self, _: &Review, _: &[DomainEvent]) -> Result<(), DomainError> { Err(refused()) } - async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { + async fn delete_review(&self, _: &ReviewId, _: &[DomainEvent]) -> Result<(), DomainError> { Err(refused()) } async fn get_all_reviews_for_user(&self, _: &UserId) -> Result, DomainError> { diff --git a/crates/domain/src/testing/fakes/store.rs b/crates/domain/src/testing/fakes/store.rs index 94f3ca69..71e9c6ca 100644 --- a/crates/domain/src/testing/fakes/store.rs +++ b/crates/domain/src/testing/fakes/store.rs @@ -72,6 +72,7 @@ pub struct InMemoryStore { pub(super) follows: RwLock>, pub(super) blocked: RwLock>, pub(super) processed_events: RwLock>, + pub(super) recorded_events: RwLock>, } impl InMemoryStore { @@ -94,6 +95,7 @@ impl InMemoryStore { follows: RwLock::default(), blocked: RwLock::default(), processed_events: RwLock::default(), + recorded_events: RwLock::default(), }) } @@ -103,6 +105,19 @@ impl InMemoryStore { store } + /// Events recorded atomically with a change (the outbox rows the sqlite + /// adapter would have written in the same transaction), in write order. + pub fn recorded_events(&self) -> Vec { + self.recorded_events.read().unwrap().clone() + } + + pub(super) fn record_events(&self, events: &[crate::events::DomainEvent]) { + self.recorded_events + .write() + .unwrap() + .extend_from_slice(events); + } + pub fn movie_count(&self) -> usize { self.movies.read().unwrap().len() } diff --git a/crates/domain/src/testing/fakes/store_review.rs b/crates/domain/src/testing/fakes/store_review.rs index cf276ac4..3bf2f5bd 100644 --- a/crates/domain/src/testing/fakes/store_review.rs +++ b/crates/domain/src/testing/fakes/store_review.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use crate::{ errors::DomainError, + events::DomainEvent, models::Review, ports::ReviewRepository, value_objects::{ReviewId, UserId}, @@ -11,11 +12,16 @@ use super::InMemoryStore; #[async_trait] impl ReviewRepository for InMemoryStore { - async fn save_review(&self, review: &Review) -> Result<(), DomainError> { + async fn save_review( + &self, + review: &Review, + events: &[DomainEvent], + ) -> Result<(), DomainError> { self.reviews .write() .unwrap() .insert(review.id().value(), review.clone()); + self.record_events(events); Ok(()) } @@ -28,16 +34,26 @@ impl ReviewRepository for InMemoryStore { .cloned()) } - async fn update_review(&self, review: &Review) -> Result<(), DomainError> { + async fn update_review( + &self, + review: &Review, + events: &[DomainEvent], + ) -> Result<(), DomainError> { self.reviews .write() .unwrap() .insert(review.id().value(), review.clone()); + self.record_events(events); Ok(()) } - async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> { + async fn delete_review( + &self, + review_id: &ReviewId, + events: &[DomainEvent], + ) -> Result<(), DomainError> { self.reviews.write().unwrap().remove(&review_id.value()); + self.record_events(events); Ok(()) } diff --git a/crates/domain/tests/testing/in_memory_store_test.rs b/crates/domain/tests/testing/in_memory_store_test.rs index ec8bb068..ecab6148 100644 --- a/crates/domain/tests/testing/in_memory_store_test.rs +++ b/crates/domain/tests/testing/in_memory_store_test.rs @@ -144,7 +144,7 @@ async fn seed_user_with_dependents(store: &Arc) -> (UserId, Movie .unwrap(); store - .save_review(&test_review(movie.id(), &user_id, 4)) + .save_review(&test_review(movie.id(), &user_id, 4), &[]) .await .unwrap(); store @@ -239,7 +239,7 @@ async fn seed_movie_with_dependents( .unwrap(); if with_review { store - .save_review(&test_review(movie.id(), user.id(), 5)) + .save_review(&test_review(movie.id(), user.id(), 5), &[]) .await .unwrap(); } -- 2.49.1 From 62ad9f401745a5deebe24e7359fd9c61fb854108 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 00:49:28 +0100 Subject: [PATCH 060/104] feat: atomic goal, watchlist, profile writes --- crates/adapters/sqlite/src/lib.rs | 4 +- .../sqlite/src/repositories/goals/mod.rs | 45 +- .../src/repositories/profile_fields/mod.rs | 19 +- .../sqlite/src/repositories/users/mod.rs | 11 +- .../sqlite/src/repositories/watchlist/mod.rs | 48 +- crates/adapters/sqlite/tests/cascade_test.rs | 13 +- crates/adapters/sqlite/tests/repositories.rs | 4 + .../tests/repositories/outbox_support.rs | 38 ++ .../tests/repositories/outbox_writes_test.rs | 421 ++++++++++++++++++ .../repositories/review_repository_test.rs | 125 +++++- .../repositories/user_repository_test.rs | 4 +- crates/application/src/diary/review_logger.rs | 20 +- crates/application/src/goals/create.rs | 17 +- crates/application/src/goals/delete.rs | 15 +- crates/application/src/goals/deps.rs | 3 +- crates/application/src/goals/update.rs | 17 +- crates/application/src/users/deps.rs | 1 - .../application/src/users/update_profile.rs | 27 +- .../src/users/update_profile_fields.rs | 11 +- crates/application/src/watchlist/add.rs | 22 +- crates/application/src/watchlist/deps.rs | 1 - crates/application/src/watchlist/remove.rs | 11 +- .../tests/diary/delete_review_test.rs | 2 +- .../tests/diary/edit_review_test.rs | 2 +- .../tests/diary/review_logger_test.rs | 20 +- crates/application/tests/goals/create_test.rs | 55 ++- crates/application/tests/goals/delete_test.rs | 69 ++- crates/application/tests/goals/get_test.rs | 1 - crates/application/tests/goals/list_test.rs | 1 - crates/application/tests/goals/update_test.rs | 60 ++- .../tests/users/get_profile_settings_test.rs | 1 + .../tests/users/update_profile_fields_test.rs | 42 +- .../tests/users/update_profile_test.rs | 129 +++++- .../application/tests/watchlist/add_test.rs | 50 ++- .../application/tests/watchlist/is_on_test.rs | 8 +- .../tests/watchlist/remove_test.rs | 68 ++- crates/bootstrap/src/build.rs | 3 - .../bootstrap/tests/http_handlers/support.rs | 1 + crates/domain/src/ports/auth.rs | 2 + crates/domain/src/ports/goals.rs | 12 +- crates/domain/src/ports/watchlist.rs | 12 +- crates/domain/src/testing/fakes/noops.rs | 5 +- crates/domain/src/testing/fakes/panics.rs | 18 +- crates/domain/src/testing/fakes/refusing.rs | 52 ++- crates/domain/src/testing/fakes/store_goal.rs | 17 +- crates/domain/src/testing/fakes/store_user.rs | 5 + .../src/testing/fakes/store_watchlist.rs | 19 +- .../tests/testing/in_memory_store_test.rs | 29 +- 48 files changed, 1296 insertions(+), 264 deletions(-) create mode 100644 crates/adapters/sqlite/tests/repositories/outbox_support.rs create mode 100644 crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 7a5c486f..eeada475 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -6,8 +6,9 @@ mod repositories; mod shared; pub use event_queue::{DbEventQueueConfig, SqliteEventQueue}; +use repositories::SqliteUserSettingsRepository; pub use repositories::{ - SqliteApContentQuery, SqliteDiaryRepository, SqliteImageRefAdapter, + SqliteApContentQuery, SqliteDiaryRepository, SqliteGoalRepository, SqliteImageRefAdapter, SqliteImportProfileRepository, SqliteImportSessionRepository, SqliteInsightsQuery, SqliteMovieDeduplicator, SqliteMovieProfileRepository, SqliteMovieRepository, SqlitePersonAdapter, SqliteProcessedEventLedger, SqliteProfileFieldsRepository, @@ -17,7 +18,6 @@ pub use repositories::{ SqliteWrapUpRepository, SqliteWrapUpStatsQuery, create_federated_profile_query, create_image_ref, create_person_adapter, create_search_adapter, }; -use repositories::{SqliteGoalRepository, SqliteUserSettingsRepository}; /// Row helpers shared with the `sqlite-federation` crate (which cannot live /// here because it depends on `activitypub`/`k-ap`). diff --git a/crates/adapters/sqlite/src/repositories/goals/mod.rs b/crates/adapters/sqlite/src/repositories/goals/mod.rs index a8934cae..d4c82f7b 100644 --- a/crates/adapters/sqlite/src/repositories/goals/mod.rs +++ b/crates/adapters/sqlite/src/repositories/goals/mod.rs @@ -1,12 +1,15 @@ use async_trait::async_trait; use domain::{ errors::DomainError, + events::DomainEvent, models::{Goal, GoalType}, ports::{GoalCommand, GoalQuery}, value_objects::{GoalId, UserId}, }; use sqlx::{Row, SqlitePool}; +use crate::shared::enqueue_events; + pub struct SqliteGoalRepository { pool: SqlitePool, } @@ -19,7 +22,7 @@ impl SqliteGoalRepository { #[async_trait] impl GoalCommand for SqliteGoalRepository { - async fn save(&self, goal: &Goal) -> Result<(), DomainError> { + async fn save(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError> { let id = goal.id().value().to_string(); let user_id = goal.user_id().value().to_string(); let year = goal.year() as i64; @@ -27,6 +30,11 @@ impl GoalCommand for SqliteGoalRepository { let goal_type = goal.goal_type().as_str(); let created_at = goal.created_at().format("%Y-%m-%d %H:%M:%S").to_string(); + let mut tx = self + .pool + .begin() + .await + .map_err(crate::shared::map_sqlx_error)?; sqlx::query( "INSERT INTO goals (id, user_id, year, target_count, goal_type, created_at) \ VALUES (?, ?, ?, ?, ?, ?)", @@ -37,45 +45,62 @@ impl GoalCommand for SqliteGoalRepository { .bind(target) .bind(goal_type) .bind(&created_at) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) + enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) } - async fn update(&self, goal: &Goal) -> Result<(), DomainError> { + async fn update(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError> { let id = goal.id().value().to_string(); let target = goal.target_count() as i64; + let mut tx = self + .pool + .begin() + .await + .map_err(crate::shared::map_sqlx_error)?; let result = sqlx::query("UPDATE goals SET target_count = ? WHERE id = ?") .bind(target) .bind(&id) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(crate::shared::map_sqlx_error)?; if result.rows_affected() == 0 { return Err(DomainError::NotFound("Goal not found".into())); } - Ok(()) + enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) } - async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError> { + async fn delete( + &self, + id: &GoalId, + user_id: &UserId, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let gid = id.value().to_string(); let uid = user_id.value().to_string(); + let mut tx = self + .pool + .begin() + .await + .map_err(crate::shared::map_sqlx_error)?; let result = sqlx::query("DELETE FROM goals WHERE id = ? AND user_id = ?") .bind(&gid) .bind(&uid) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(crate::shared::map_sqlx_error)?; if result.rows_affected() == 0 { return Err(DomainError::NotFound("Goal not found".into())); } - Ok(()) + enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) } } diff --git a/crates/adapters/sqlite/src/repositories/profile_fields/mod.rs b/crates/adapters/sqlite/src/repositories/profile_fields/mod.rs index 9f11241f..9dd32339 100644 --- a/crates/adapters/sqlite/src/repositories/profile_fields/mod.rs +++ b/crates/adapters/sqlite/src/repositories/profile_fields/mod.rs @@ -2,10 +2,12 @@ use async_trait::async_trait; use sqlx::{Row, SqlitePool}; use domain::{ - errors::DomainError, models::ProfileField, ports::UserProfileFieldsRepository, - value_objects::UserId, + errors::DomainError, events::DomainEvent, models::ProfileField, + ports::UserProfileFieldsRepository, value_objects::UserId, }; +use crate::shared::enqueue_events; + pub struct SqliteProfileFieldsRepository { pool: SqlitePool, } @@ -41,12 +43,18 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository { &self, user_id: &UserId, fields: Vec, + events: &[DomainEvent], ) -> Result<(), DomainError> { let id_str = user_id.value().to_string(); + let mut tx = self + .pool + .begin() + .await + .map_err(crate::shared::map_sqlx_error)?; sqlx::query("DELETE FROM user_profile_fields WHERE user_id = ?") .bind(&id_str) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(crate::shared::map_sqlx_error)?; @@ -61,11 +69,12 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository { .bind(&field.name) .bind(&field.value) .bind(position) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(crate::shared::map_sqlx_error)?; } - Ok(()) + enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) } } diff --git a/crates/adapters/sqlite/src/repositories/users/mod.rs b/crates/adapters/sqlite/src/repositories/users/mod.rs index f6990409..e856ea06 100644 --- a/crates/adapters/sqlite/src/repositories/users/mod.rs +++ b/crates/adapters/sqlite/src/repositories/users/mod.rs @@ -161,8 +161,14 @@ impl UserRepository for SqliteUserRepository { &self, user_id: &UserId, profile: &domain::models::UserProfile, + events: &[domain::events::DomainEvent], ) -> Result<(), DomainError> { let id_str = user_id.value().to_string(); + let mut tx = self + .pool + .begin() + .await + .map_err(crate::shared::map_sqlx_error)?; sqlx::query( "UPDATE users SET display_name = ?, bio = ?, avatar_path = ?, banner_path = ?, also_known_as = ? WHERE id = ?", ) @@ -172,10 +178,11 @@ impl UserRepository for SqliteUserRepository { .bind(&profile.banner_path) .bind(&profile.also_known_as) .bind(&id_str) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(()) + crate::shared::enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) } async fn list_with_stats(&self) -> Result, DomainError> { diff --git a/crates/adapters/sqlite/src/repositories/watchlist/mod.rs b/crates/adapters/sqlite/src/repositories/watchlist/mod.rs index 2485dd53..a6046ce3 100644 --- a/crates/adapters/sqlite/src/repositories/watchlist/mod.rs +++ b/crates/adapters/sqlite/src/repositories/watchlist/mod.rs @@ -1,6 +1,7 @@ use async_trait::async_trait; use domain::{ errors::DomainError, + events::DomainEvent, models::{ WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}, @@ -13,7 +14,7 @@ use sqlx::{Row, SqlitePool}; use rows::WatchlistRow; mod rows; -use crate::shared::datetime_to_str; +use crate::shared::{datetime_to_str, enqueue_events}; pub struct SqliteWatchlistRepository { pool: SqlitePool, @@ -27,12 +28,17 @@ impl SqliteWatchlistRepository { #[async_trait] impl WatchlistRepository for SqliteWatchlistRepository { - async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> { + async fn add(&self, entry: &WatchlistEntry, events: &[DomainEvent]) -> Result<(), DomainError> { let id = entry.id.value().to_string(); let user_id = entry.user_id.value().to_string(); let movie_id = entry.movie_id.value().to_string(); let added_at = datetime_to_str(&entry.added_at); + let mut tx = self + .pool + .begin() + .await + .map_err(crate::shared::map_sqlx_error)?; sqlx::query( "INSERT OR IGNORE INTO watchlist_entries (id, user_id, movie_id, added_at) \ VALUES (?, ?, ?, ?)", @@ -41,22 +47,32 @@ impl WatchlistRepository for SqliteWatchlistRepository { .bind(&user_id) .bind(&movie_id) .bind(&added_at) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) + enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) } - async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> { + async fn remove( + &self, + user_id: &UserId, + movie_id: &MovieId, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let uid = user_id.value().to_string(); let mid = movie_id.value().to_string(); + let mut tx = self + .pool + .begin() + .await + .map_err(crate::shared::map_sqlx_error)?; let result = sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?") .bind(&uid) .bind(&mid) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(crate::shared::map_sqlx_error)?; @@ -66,24 +82,36 @@ impl WatchlistRepository for SqliteWatchlistRepository { mid ))); } - Ok(()) + enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) } async fn remove_if_present( &self, user_id: &UserId, movie_id: &MovieId, + events: &[DomainEvent], ) -> Result { let uid = user_id.value().to_string(); let mid = movie_id.value().to_string(); + let mut tx = self + .pool + .begin() + .await + .map_err(crate::shared::map_sqlx_error)?; let result = sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?") .bind(&uid) .bind(&mid) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(crate::shared::map_sqlx_error)?; - Ok(result.rows_affected() > 0) + let removed = result.rows_affected() > 0; + if removed { + enqueue_events(&mut tx, events).await?; + } + tx.commit().await.map_err(crate::shared::map_sqlx_error)?; + Ok(removed) } async fn get_for_user( diff --git a/crates/adapters/sqlite/tests/cascade_test.rs b/crates/adapters/sqlite/tests/cascade_test.rs index 7a07b892..176ed7c6 100644 --- a/crates/adapters/sqlite/tests/cascade_test.rs +++ b/crates/adapters/sqlite/tests/cascade_test.rs @@ -128,12 +128,15 @@ async fn seed_user_with_dependents( parts .watchlist - .add(&WatchlistEntry::new(user_id.clone(), movie.id().clone())) + .add( + &WatchlistEntry::new(user_id.clone(), movie.id().clone()), + &[], + ) .await .unwrap(); parts .goal_command - .save(&test_goal(&user_id, 2024, 50)) + .save(&test_goal(&user_id, 2024, 50), &[]) .await .unwrap(); parts @@ -148,6 +151,7 @@ async fn seed_user_with_dependents( name: "Site".into(), value: "https://example.com".into(), }], + &[], ) .await .unwrap(); @@ -256,7 +260,10 @@ async fn deleting_a_movie_cascades_to_watchlist_entries_and_every_enrichment_tab parts.movie_command.upsert_movie(&movie).await.unwrap(); parts .watchlist - .add(&WatchlistEntry::new(user.id().clone(), movie.id().clone())) + .add( + &WatchlistEntry::new(user.id().clone(), movie.id().clone()), + &[], + ) .await .unwrap(); parts diff --git a/crates/adapters/sqlite/tests/repositories.rs b/crates/adapters/sqlite/tests/repositories.rs index aa49c50f..3b49c84d 100644 --- a/crates/adapters/sqlite/tests/repositories.rs +++ b/crates/adapters/sqlite/tests/repositories.rs @@ -6,6 +6,10 @@ mod image_ref_adapter_test; mod movie_dedup_test; #[path = "repositories/movie_profile_repository_test.rs"] mod movie_profile_repository_test; +#[path = "repositories/outbox_support.rs"] +mod outbox_support; +#[path = "repositories/outbox_writes_test.rs"] +mod outbox_writes_test; #[path = "repositories/person_adapter_test.rs"] mod person_adapter_test; #[path = "repositories/review_repository_test.rs"] diff --git a/crates/adapters/sqlite/tests/repositories/outbox_support.rs b/crates/adapters/sqlite/tests/repositories/outbox_support.rs new file mode 100644 index 00000000..60b4ff3d --- /dev/null +++ b/crates/adapters/sqlite/tests/repositories/outbox_support.rs @@ -0,0 +1,38 @@ +//! Shared helpers for the transactional-outbox repository tests. + +use std::str::FromStr; + +use sqlx::{ + SqlitePool, + sqlite::{SqliteConnectOptions, SqlitePoolOptions}, +}; + +pub async fn migrated_pool() -> SqlitePool { + let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(opts) + .await + .unwrap(); + sqlite::migrate(&pool).await.unwrap(); + pool +} + +pub async fn count(pool: &SqlitePool, table: &str) -> i64 { + sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}")) + .fetch_one(pool) + .await + .unwrap() +} + +/// Makes every later `event_queue` insert abort, so a write whose own row +/// already landed inside the transaction must be rolled back with it. +pub async fn fail_event_inserts(pool: &SqlitePool) { + sqlx::query( + "CREATE TRIGGER fail_event_insert BEFORE INSERT ON event_queue \ + BEGIN SELECT RAISE(ABORT, 'enqueue failed'); END", + ) + .execute(pool) + .await + .unwrap(); +} diff --git a/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs b/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs new file mode 100644 index 00000000..bfdd1f59 --- /dev/null +++ b/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs @@ -0,0 +1,421 @@ +//! Goal, watchlist and profile writes commit with their events or not at all. + +use domain::{ + events::DomainEvent, + models::{ProfileField, UserProfile, WatchlistEntry}, + ports::{ + GoalCommand, MovieCommand, UserProfileFieldsRepository, UserRepository, WatchlistRepository, + }, + testing::{test_goal, test_movie, test_user}, + value_objects::UserId, +}; +use sqlite::{ + SqliteGoalRepository, SqliteMovieRepository, SqliteProfileFieldsRepository, + SqliteUserRepository, SqliteWatchlistRepository, +}; +use sqlx::SqlitePool; + +use super::outbox_support::{count, fail_event_inserts, migrated_pool}; + +async fn pool_with_user() -> (SqlitePool, domain::models::User) { + let pool = migrated_pool().await; + let user = test_user("alice"); + SqliteUserRepository::new(pool.clone()) + .save(&user) + .await + .unwrap(); + (pool, user) +} + +fn goal_created(goal: &domain::models::Goal) -> DomainEvent { + DomainEvent::GoalCreated { + goal_id: goal.id().clone(), + user_id: goal.user_id().clone(), + year: goal.year(), + target_count: goal.target_count(), + } +} + +fn goal_updated(goal: &domain::models::Goal) -> DomainEvent { + DomainEvent::GoalUpdated { + goal_id: goal.id().clone(), + user_id: goal.user_id().clone(), + year: goal.year(), + target_count: goal.target_count(), + } +} + +fn goal_deleted(goal: &domain::models::Goal) -> DomainEvent { + DomainEvent::GoalDeleted { + goal_id: goal.id().clone(), + user_id: goal.user_id().clone(), + year: goal.year(), + } +} + +#[tokio::test] +async fn saving_a_goal_writes_the_goal_and_its_event_in_one_commit() { + let (pool, user) = pool_with_user().await; + let goal = test_goal(user.id(), 2025, 50); + + SqliteGoalRepository::new(pool.clone()) + .save(&goal, &[goal_created(&goal)]) + .await + .unwrap(); + + assert_eq!(count(&pool, "goals").await, 1); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_the_goal_save() { + let (pool, user) = pool_with_user().await; + let goal = test_goal(user.id(), 2025, 50); + fail_event_inserts(&pool).await; + + let result = SqliteGoalRepository::new(pool.clone()) + .save(&goal, &[goal_created(&goal)]) + .await; + + assert!(result.is_err()); + assert_eq!(count(&pool, "goals").await, 0); +} + +#[tokio::test] +async fn updating_a_goal_writes_the_change_and_its_event_in_one_commit() { + let (pool, user) = pool_with_user().await; + let repo = SqliteGoalRepository::new(pool.clone()); + let mut goal = test_goal(user.id(), 2025, 50); + repo.save(&goal, &[]).await.unwrap(); + goal.update_target(80).unwrap(); + + repo.update(&goal, &[goal_updated(&goal)]).await.unwrap(); + + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_the_goal_update() { + let (pool, user) = pool_with_user().await; + let repo = SqliteGoalRepository::new(pool.clone()); + let mut goal = test_goal(user.id(), 2025, 50); + repo.save(&goal, &[]).await.unwrap(); + goal.update_target(80).unwrap(); + fail_event_inserts(&pool).await; + + let result = repo.update(&goal, &[goal_updated(&goal)]).await; + + assert!(result.is_err()); + let target: i64 = sqlx::query_scalar("SELECT target_count FROM goals") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(target, 50, "update rolled back"); +} + +#[tokio::test] +async fn updating_a_missing_goal_enqueues_no_event() { + let (pool, user) = pool_with_user().await; + let goal = test_goal(user.id(), 2025, 50); + + let result = SqliteGoalRepository::new(pool.clone()) + .update(&goal, &[goal_updated(&goal)]) + .await; + + assert!(result.is_err()); + assert_eq!(count(&pool, "event_queue").await, 0); +} + +#[tokio::test] +async fn deleting_a_goal_writes_the_removal_and_its_event_in_one_commit() { + let (pool, user) = pool_with_user().await; + let repo = SqliteGoalRepository::new(pool.clone()); + let goal = test_goal(user.id(), 2025, 50); + repo.save(&goal, &[]).await.unwrap(); + + repo.delete(goal.id(), user.id(), &[goal_deleted(&goal)]) + .await + .unwrap(); + + assert_eq!(count(&pool, "goals").await, 0); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_the_goal_delete() { + let (pool, user) = pool_with_user().await; + let repo = SqliteGoalRepository::new(pool.clone()); + let goal = test_goal(user.id(), 2025, 50); + repo.save(&goal, &[]).await.unwrap(); + fail_event_inserts(&pool).await; + + let result = repo + .delete(goal.id(), user.id(), &[goal_deleted(&goal)]) + .await; + + assert!(result.is_err()); + assert_eq!(count(&pool, "goals").await, 1, "delete rolled back"); +} + +async fn watchlist_fixture() -> (SqlitePool, UserId, domain::models::Movie) { + let (pool, user) = pool_with_user().await; + let movie = test_movie("Heat"); + SqliteMovieRepository::new(pool.clone()) + .upsert_movie(&movie) + .await + .unwrap(); + (pool, user.id().clone(), movie) +} + +fn watchlist_added(entry: &WatchlistEntry, movie: &domain::models::Movie) -> DomainEvent { + DomainEvent::WatchlistEntryAdded { + user_id: entry.user_id.clone(), + movie_id: movie.id().clone(), + movie_title: movie.title().value().to_string(), + release_year: movie.release_year().value(), + external_metadata_id: None, + added_at: entry.added_at, + } +} + +fn watchlist_removed(user_id: &UserId, movie: &domain::models::Movie) -> DomainEvent { + DomainEvent::WatchlistEntryRemoved { + user_id: user_id.clone(), + movie_id: movie.id().clone(), + } +} + +#[tokio::test] +async fn adding_a_watchlist_entry_writes_the_entry_and_its_event_in_one_commit() { + let (pool, user_id, movie) = watchlist_fixture().await; + let entry = WatchlistEntry::new(user_id, movie.id().clone()); + + SqliteWatchlistRepository::new(pool.clone()) + .add(&entry, &[watchlist_added(&entry, &movie)]) + .await + .unwrap(); + + assert_eq!(count(&pool, "watchlist_entries").await, 1); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_the_watchlist_add() { + let (pool, user_id, movie) = watchlist_fixture().await; + let entry = WatchlistEntry::new(user_id, movie.id().clone()); + fail_event_inserts(&pool).await; + + let result = SqliteWatchlistRepository::new(pool.clone()) + .add(&entry, &[watchlist_added(&entry, &movie)]) + .await; + + assert!(result.is_err()); + assert_eq!(count(&pool, "watchlist_entries").await, 0); +} + +#[tokio::test] +async fn removing_a_watchlist_entry_writes_the_removal_and_its_event_in_one_commit() { + let (pool, user_id, movie) = watchlist_fixture().await; + let repo = SqliteWatchlistRepository::new(pool.clone()); + repo.add( + &WatchlistEntry::new(user_id.clone(), movie.id().clone()), + &[], + ) + .await + .unwrap(); + + repo.remove(&user_id, movie.id(), &[watchlist_removed(&user_id, &movie)]) + .await + .unwrap(); + + assert_eq!(count(&pool, "watchlist_entries").await, 0); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_the_watchlist_remove() { + let (pool, user_id, movie) = watchlist_fixture().await; + let repo = SqliteWatchlistRepository::new(pool.clone()); + repo.add( + &WatchlistEntry::new(user_id.clone(), movie.id().clone()), + &[], + ) + .await + .unwrap(); + fail_event_inserts(&pool).await; + + let result = repo + .remove(&user_id, movie.id(), &[watchlist_removed(&user_id, &movie)]) + .await; + + assert!(result.is_err()); + assert_eq!( + count(&pool, "watchlist_entries").await, + 1, + "remove rolled back" + ); +} + +#[tokio::test] +async fn removing_an_absent_watchlist_entry_enqueues_no_event() { + let (pool, user_id, movie) = watchlist_fixture().await; + + let result = SqliteWatchlistRepository::new(pool.clone()) + .remove(&user_id, movie.id(), &[watchlist_removed(&user_id, &movie)]) + .await; + + assert!(result.is_err()); + assert_eq!(count(&pool, "event_queue").await, 0); +} + +#[tokio::test] +async fn remove_if_present_enqueues_its_event_only_when_a_row_was_removed() { + let (pool, user_id, movie) = watchlist_fixture().await; + let repo = SqliteWatchlistRepository::new(pool.clone()); + let event = watchlist_removed(&user_id, &movie); + + let absent = repo + .remove_if_present(&user_id, movie.id(), std::slice::from_ref(&event)) + .await + .unwrap(); + assert!(!absent); + assert_eq!(count(&pool, "event_queue").await, 0); + + repo.add( + &WatchlistEntry::new(user_id.clone(), movie.id().clone()), + &[], + ) + .await + .unwrap(); + let present = repo + .remove_if_present(&user_id, movie.id(), std::slice::from_ref(&event)) + .await + .unwrap(); + + assert!(present); + assert_eq!(count(&pool, "watchlist_entries").await, 0); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_remove_if_present() { + let (pool, user_id, movie) = watchlist_fixture().await; + let repo = SqliteWatchlistRepository::new(pool.clone()); + repo.add( + &WatchlistEntry::new(user_id.clone(), movie.id().clone()), + &[], + ) + .await + .unwrap(); + fail_event_inserts(&pool).await; + + let result = repo + .remove_if_present(&user_id, movie.id(), &[watchlist_removed(&user_id, &movie)]) + .await; + + assert!(result.is_err()); + assert_eq!( + count(&pool, "watchlist_entries").await, + 1, + "remove rolled back" + ); +} + +#[tokio::test] +async fn updating_a_profile_writes_the_change_and_its_event_in_one_commit() { + let (pool, user) = pool_with_user().await; + let repo = SqliteUserRepository::new(pool.clone()); + let profile = UserProfile { + bio: Some("hello".into()), + ..Default::default() + }; + + repo.update_profile( + user.id(), + &profile, + &[DomainEvent::UserUpdated { + user_id: user.id().clone(), + }], + ) + .await + .unwrap(); + + let found = repo.find_by_id(user.id()).await.unwrap().unwrap(); + assert_eq!(found.bio(), Some("hello")); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_the_profile_update() { + let (pool, user) = pool_with_user().await; + let repo = SqliteUserRepository::new(pool.clone()); + fail_event_inserts(&pool).await; + + let result = repo + .update_profile( + user.id(), + &UserProfile { + bio: Some("hello".into()), + ..Default::default() + }, + &[DomainEvent::UserUpdated { + user_id: user.id().clone(), + }], + ) + .await; + + assert!(result.is_err()); + let found = repo.find_by_id(user.id()).await.unwrap().unwrap(); + assert_eq!(found.bio(), None, "profile update rolled back"); +} + +fn fields() -> Vec { + vec![ProfileField { + name: "Site".into(), + value: "https://example.com".into(), + }] +} + +#[tokio::test] +async fn setting_profile_fields_writes_them_and_the_event_in_one_commit() { + let (pool, user) = pool_with_user().await; + + SqliteProfileFieldsRepository::new(pool.clone()) + .set_fields( + user.id(), + fields(), + &[DomainEvent::UserUpdated { + user_id: user.id().clone(), + }], + ) + .await + .unwrap(); + + assert_eq!(count(&pool, "user_profile_fields").await, 1); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_the_profile_fields_replacement() { + let (pool, user) = pool_with_user().await; + let repo = SqliteProfileFieldsRepository::new(pool.clone()); + repo.set_fields(user.id(), fields(), &[]).await.unwrap(); + fail_event_inserts(&pool).await; + + let result = repo + .set_fields( + user.id(), + vec![], + &[DomainEvent::UserUpdated { + user_id: user.id().clone(), + }], + ) + .await; + + assert!(result.is_err()); + assert_eq!( + repo.get_fields(user.id()).await.unwrap().len(), + 1, + "delete of the old fields rolled back" + ); +} diff --git a/crates/adapters/sqlite/tests/repositories/review_repository_test.rs b/crates/adapters/sqlite/tests/repositories/review_repository_test.rs index f8166a4b..9f9cc136 100644 --- a/crates/adapters/sqlite/tests/repositories/review_repository_test.rs +++ b/crates/adapters/sqlite/tests/repositories/review_repository_test.rs @@ -1,33 +1,12 @@ -use std::str::FromStr; - use domain::{ events::DomainEvent, ports::{MovieCommand, ReviewRepository, UserRepository}, testing::{test_movie, test_review, test_user}, }; use sqlite::{SqliteMovieRepository, SqliteReviewRepository, SqliteUserRepository}; -use sqlx::{ - SqlitePool, - sqlite::{SqliteConnectOptions, SqlitePoolOptions}, -}; +use sqlx::SqlitePool; -async fn migrated_pool() -> SqlitePool { - let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect_with(opts) - .await - .unwrap(); - sqlite::migrate(&pool).await.unwrap(); - pool -} - -async fn count(pool: &SqlitePool, table: &str) -> i64 { - sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}")) - .fetch_one(pool) - .await - .unwrap() -} +use super::outbox_support::{count, fail_event_inserts, migrated_pool}; fn logged(review: &domain::models::Review) -> DomainEvent { DomainEvent::ReviewLogged { @@ -80,3 +59,103 @@ async fn a_failed_review_save_leaves_no_event_row() { assert_eq!(count(&pool, "reviews").await, 0); assert_eq!(count(&pool, "event_queue").await, 0); } + +fn updated(review: &domain::models::Review) -> DomainEvent { + DomainEvent::ReviewUpdated { + review_id: review.id().clone(), + movie_id: review.movie_id().clone(), + user_id: review.user_id().clone(), + rating: review.rating().clone(), + watched_at: *review.watched_at(), + } +} + +async fn seeded() -> (SqlitePool, domain::models::Review) { + let pool = migrated_pool().await; + let movie = test_movie("Heat"); + let user = test_user("alice"); + SqliteUserRepository::new(pool.clone()) + .save(&user) + .await + .unwrap(); + SqliteMovieRepository::new(pool.clone()) + .upsert_movie(&movie) + .await + .unwrap(); + let review = test_review(movie.id(), user.id(), 4); + (pool, review) +} + +#[tokio::test] +async fn a_failing_enqueue_after_the_review_insert_rolls_the_review_back() { + let (pool, review) = seeded().await; + fail_event_inserts(&pool).await; + + let result = SqliteReviewRepository::new(pool.clone()) + .save_review(&review, &[logged(&review)]) + .await; + + assert!(result.is_err()); + assert_eq!(count(&pool, "reviews").await, 0); + assert_eq!(count(&pool, "event_queue").await, 0); +} + +#[tokio::test] +async fn updating_a_review_writes_the_change_and_its_event_in_one_commit() { + let (pool, review) = seeded().await; + let repo = SqliteReviewRepository::new(pool.clone()); + repo.save_review(&review, &[]).await.unwrap(); + let event = updated(&review); + + repo.update_review(&review, &[event]).await.unwrap(); + + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_after_the_review_update_rolls_the_update_back() { + let (pool, review) = seeded().await; + let repo = SqliteReviewRepository::new(pool.clone()); + repo.save_review(&review, &[]).await.unwrap(); + fail_event_inserts(&pool).await; + let event = updated(&review); + + let result = repo.update_review(&review, &[event]).await; + + assert!(result.is_err()); + assert_eq!(count(&pool, "event_queue").await, 0); +} + +#[tokio::test] +async fn deleting_a_review_writes_the_removal_and_its_event_in_one_commit() { + let (pool, review) = seeded().await; + let repo = SqliteReviewRepository::new(pool.clone()); + repo.save_review(&review, &[]).await.unwrap(); + let event = DomainEvent::ReviewDeleted { + review_id: review.id().clone(), + user_id: review.user_id().clone(), + }; + + repo.delete_review(review.id(), &[event]).await.unwrap(); + + assert_eq!(count(&pool, "reviews").await, 0); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_after_the_review_delete_rolls_the_delete_back() { + let (pool, review) = seeded().await; + let repo = SqliteReviewRepository::new(pool.clone()); + repo.save_review(&review, &[]).await.unwrap(); + fail_event_inserts(&pool).await; + let event = DomainEvent::ReviewDeleted { + review_id: review.id().clone(), + user_id: review.user_id().clone(), + }; + + let result = repo.delete_review(review.id(), &[event]).await; + + assert!(result.is_err()); + assert_eq!(count(&pool, "reviews").await, 1, "delete rolled back"); + assert_eq!(count(&pool, "event_queue").await, 0); +} diff --git a/crates/adapters/sqlite/tests/repositories/user_repository_test.rs b/crates/adapters/sqlite/tests/repositories/user_repository_test.rs index fa6c3290..42586f4b 100644 --- a/crates/adapters/sqlite/tests/repositories/user_repository_test.rs +++ b/crates/adapters/sqlite/tests/repositories/user_repository_test.rs @@ -67,6 +67,7 @@ async fn update_profile_persists_bio_and_avatar() { avatar_path: Some("avatars/user1".to_string()), ..Default::default() }, + &[], ) .await .unwrap(); @@ -88,10 +89,11 @@ async fn update_profile_clears_fields_with_none() { avatar_path: Some("path".to_string()), ..Default::default() }, + &[], ) .await .unwrap(); - repo.update_profile(user.id(), &UserProfile::default()) + repo.update_profile(user.id(), &UserProfile::default(), &[]) .await .unwrap(); diff --git a/crates/application/src/diary/review_logger.rs b/crates/application/src/diary/review_logger.rs index d6415392..9a31fa07 100644 --- a/crates/application/src/diary/review_logger.rs +++ b/crates/application/src/diary/review_logger.rs @@ -94,19 +94,17 @@ impl ReviewLogger for DefaultReviewLogger { events.push(review_event); self.review_repo.save_review(&review, &events).await?; - let was_on_watchlist = self - .watchlist_repo - .remove_if_present(review.user_id(), review.movie_id()) - .await?; - if was_on_watchlist { - let _ = self - .event_publisher - .publish(&DomainEvent::WatchlistEntryRemoved { + // The removal event is recorded only if an entry was actually removed. + self.watchlist_repo + .remove_if_present( + review.user_id(), + review.movie_id(), + &[DomainEvent::WatchlistEntryRemoved { user_id: review.user_id().clone(), movie_id: review.movie_id().clone(), - }) - .await; - } + }], + ) + .await?; Ok(()) } diff --git a/crates/application/src/goals/create.rs b/crates/application/src/goals/create.rs index 4ada4d4d..ae4bc708 100644 --- a/crates/application/src/goals/create.rs +++ b/crates/application/src/goals/create.rs @@ -29,19 +29,16 @@ pub async fn execute( cmd.target_count, GoalType::Movies, )?; - deps.goal_command.save(&g).await?; + let event = DomainEvent::GoalCreated { + goal_id: g.id().clone(), + user_id: user_id.clone(), + year: cmd.year, + target_count: cmd.target_count, + }; + deps.goal_command.save(&g, &[event]).await?; let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?; - deps.event_publisher - .publish(&DomainEvent::GoalCreated { - goal_id: g.id().clone(), - user_id, - year: cmd.year, - target_count: cmd.target_count, - }) - .await?; - Ok(GoalWithProgress { goal: g, current_count, diff --git a/crates/application/src/goals/delete.rs b/crates/application/src/goals/delete.rs index ce092a77..41529167 100644 --- a/crates/application/src/goals/delete.rs +++ b/crates/application/src/goals/delete.rs @@ -11,15 +11,12 @@ pub async fn execute(deps: &GoalCommandDeps, cmd: DeleteGoalCommand) -> Result<( .await? .ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?; - deps.goal_command.delete(g.id(), &user_id).await?; - - deps.event_publisher - .publish(&DomainEvent::GoalDeleted { - goal_id: g.id().clone(), - user_id, - year: cmd.year, - }) - .await?; + let event = DomainEvent::GoalDeleted { + goal_id: g.id().clone(), + user_id: user_id.clone(), + year: cmd.year, + }; + deps.goal_command.delete(g.id(), &user_id, &[event]).await?; Ok(()) } diff --git a/crates/application/src/goals/deps.rs b/crates/application/src/goals/deps.rs index cfe4b9a3..36ed1a96 100644 --- a/crates/application/src/goals/deps.rs +++ b/crates/application/src/goals/deps.rs @@ -1,12 +1,11 @@ use std::sync::Arc; -use domain::ports::{EventPublisher, GoalCommand, GoalQuery, StatsRepository}; +use domain::ports::{GoalCommand, GoalQuery, StatsRepository}; pub struct GoalCommandDeps { pub goal_command: Arc, pub goal_query: Arc, pub stats: Arc, - pub event_publisher: Arc, } pub struct GoalQueryDeps { diff --git a/crates/application/src/goals/update.rs b/crates/application/src/goals/update.rs index d0fbe230..4274e5ba 100644 --- a/crates/application/src/goals/update.rs +++ b/crates/application/src/goals/update.rs @@ -17,19 +17,16 @@ pub async fn execute( .ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?; g.update_target(cmd.target_count)?; - deps.goal_command.update(&g).await?; + let event = DomainEvent::GoalUpdated { + goal_id: g.id().clone(), + user_id: user_id.clone(), + year: cmd.year, + target_count: cmd.target_count, + }; + deps.goal_command.update(&g, &[event]).await?; let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?; - deps.event_publisher - .publish(&DomainEvent::GoalUpdated { - goal_id: g.id().clone(), - user_id, - year: cmd.year, - target_count: cmd.target_count, - }) - .await?; - Ok(GoalWithProgress { goal: g, current_count, diff --git a/crates/application/src/users/deps.rs b/crates/application/src/users/deps.rs index 4394599c..f60dd06f 100644 --- a/crates/application/src/users/deps.rs +++ b/crates/application/src/users/deps.rs @@ -58,7 +58,6 @@ pub struct GetCurrentProfileDeps { pub struct UpdateProfileFieldsDeps { pub profile_fields: Arc, - pub event_publisher: Arc, } pub struct GetSettingsDeps { diff --git a/crates/application/src/users/update_profile.rs b/crates/application/src/users/update_profile.rs index e2168254..afae932b 100644 --- a/crates/application/src/users/update_profile.rs +++ b/crates/application/src/users/update_profile.rs @@ -91,6 +91,16 @@ pub async fn execute( } }); + let mut events = vec![DomainEvent::UserUpdated { + user_id: user_id.clone(), + }]; + if let Some(new_actor_url) = moved_to { + events.push(DomainEvent::UserAccountMoved { + user_id: user_id.clone(), + new_actor_url, + }); + } + deps.user .update_profile( &user_id, @@ -102,24 +112,9 @@ pub async fn execute( also_known_as: cmd.also_known_as, profile_fields: vec![], }, + &events, ) .await?; - deps.event_publisher - .publish(&DomainEvent::UserUpdated { - user_id: user_id.clone(), - }) - .await?; - - if let Some(new_actor_url) = moved_to { - let _ = deps - .event_publisher - .publish(&DomainEvent::UserAccountMoved { - user_id, - new_actor_url, - }) - .await; - } - Ok(()) } diff --git a/crates/application/src/users/update_profile_fields.rs b/crates/application/src/users/update_profile_fields.rs index 250b497c..c1a5cd1d 100644 --- a/crates/application/src/users/update_profile_fields.rs +++ b/crates/application/src/users/update_profile_fields.rs @@ -11,9 +11,10 @@ pub async fn execute( ) -> Result<(), DomainError> { UserProfile::validate_custom_fields(&cmd.fields)?; let user_id = UserId::from_uuid(cmd.user_id); - deps.profile_fields.set_fields(&user_id, cmd.fields).await?; - deps.event_publisher - .publish(&DomainEvent::UserUpdated { user_id }) - .await?; - Ok(()) + let event = DomainEvent::UserUpdated { + user_id: user_id.clone(), + }; + deps.profile_fields + .set_fields(&user_id, cmd.fields, &[event]) + .await } diff --git a/crates/application/src/watchlist/add.rs b/crates/application/src/watchlist/add.rs index 4d3fa2d7..c85a714e 100644 --- a/crates/application/src/watchlist/add.rs +++ b/crates/application/src/watchlist/add.rs @@ -23,19 +23,15 @@ pub async fn execute( .await?; let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone()); - deps.watchlist.add(&entry).await?; - - let _ = deps - .event_publisher - .publish(&DomainEvent::WatchlistEntryAdded { - user_id, - movie_id: movie.id().clone(), - movie_title: movie.title().value().to_string(), - release_year: movie.release_year().value(), - external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()), - added_at: entry.added_at, - }) - .await; + let event = DomainEvent::WatchlistEntryAdded { + user_id, + movie_id: movie.id().clone(), + movie_title: movie.title().value().to_string(), + release_year: movie.release_year().value(), + external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()), + added_at: entry.added_at, + }; + deps.watchlist.add(&entry, &[event]).await?; Ok(()) } diff --git a/crates/application/src/watchlist/deps.rs b/crates/application/src/watchlist/deps.rs index acb4ef30..e6154553 100644 --- a/crates/application/src/watchlist/deps.rs +++ b/crates/application/src/watchlist/deps.rs @@ -29,5 +29,4 @@ pub struct IsOnWatchlistDeps { pub struct RemoveFromWatchlistDeps { pub watchlist: Arc, - pub event_publisher: Arc, } diff --git a/crates/application/src/watchlist/remove.rs b/crates/application/src/watchlist/remove.rs index 6d84ff82..7c0980e2 100644 --- a/crates/application/src/watchlist/remove.rs +++ b/crates/application/src/watchlist/remove.rs @@ -13,12 +13,11 @@ pub async fn execute( ) -> Result<(), DomainError> { let user_id = UserId::from_uuid(cmd.user_id); let movie_id = MovieId::from_uuid(cmd.movie_id); - deps.watchlist.remove(&user_id, &movie_id).await?; - - let _ = deps - .event_publisher - .publish(&DomainEvent::WatchlistEntryRemoved { user_id, movie_id }) - .await; + let event = DomainEvent::WatchlistEntryRemoved { + user_id: user_id.clone(), + movie_id: movie_id.clone(), + }; + deps.watchlist.remove(&user_id, &movie_id, &[event]).await?; Ok(()) } diff --git a/crates/application/tests/diary/delete_review_test.rs b/crates/application/tests/diary/delete_review_test.rs index f9d09e5a..0230199a 100644 --- a/crates/application/tests/diary/delete_review_test.rs +++ b/crates/application/tests/diary/delete_review_test.rs @@ -122,7 +122,7 @@ async fn deleting_a_review_removes_it_and_records_its_event_together() { } #[tokio::test] -async fn a_refused_delete_records_neither_the_removal_nor_its_event() { +async fn a_refused_delete_propagates_and_records_nothing() { let movies = InMemoryStore::new(); let diary = FakeDiaryQuery::new(); let events = NoopEventPublisher::new(); diff --git a/crates/application/tests/diary/edit_review_test.rs b/crates/application/tests/diary/edit_review_test.rs index 9f184dc6..065a5f08 100644 --- a/crates/application/tests/diary/edit_review_test.rs +++ b/crates/application/tests/diary/edit_review_test.rs @@ -151,7 +151,7 @@ async fn editing_a_review_saves_the_change_and_its_event_together() { } #[tokio::test] -async fn a_refused_edit_records_neither_the_change_nor_its_event() { +async fn a_refused_edit_propagates_and_records_nothing() { let (reviews, review_id, user_id) = setup().await; let deps = EditReviewDeps { review: Arc::new(WriteRefusingReviews(Arc::clone(&reviews))), diff --git a/crates/application/tests/diary/review_logger_test.rs b/crates/application/tests/diary/review_logger_test.rs index 2df444c4..28021ce7 100644 --- a/crates/application/tests/diary/review_logger_test.rs +++ b/crates/application/tests/diary/review_logger_test.rs @@ -68,7 +68,7 @@ async fn removes_from_watchlist_on_review() { // Add to watchlist let entry = WatchlistEntry::new(user_id.clone(), MovieId::from_uuid(movie_id)); - watchlist.add(&entry).await.unwrap(); + watchlist.add(&entry, &[]).await.unwrap(); assert_eq!(watchlist.watchlist_count(), 1); // Log review for same movie @@ -80,6 +80,20 @@ async fn removes_from_watchlist_on_review() { logger.log_review(cmd).await.unwrap(); assert_eq!(watchlist.watchlist_count(), 0); + assert!( + movies + .recorded_events() + .iter() + .any(|e| matches!(e, domain::events::DomainEvent::WatchlistEntryRemoved { .. })), + "the removal event rides the watchlist write" + ); + assert!( + !events + .published() + .iter() + .any(|e| matches!(e, domain::events::DomainEvent::WatchlistEntryRemoved { .. })), + "no longer published out of band" + ); } #[tokio::test] @@ -150,7 +164,7 @@ async fn watchlist_not_present_does_not_publish_removed() { logger.log_review(cmd).await.unwrap(); - let published = events.published(); + let published = movies.recorded_events(); assert!( !published .iter() @@ -260,7 +274,7 @@ async fn logging_a_review_saves_the_review_and_its_event_together() { } #[tokio::test] -async fn a_refused_save_records_neither_the_review_nor_its_event() { +async fn a_refused_save_propagates_and_records_nothing() { let movies = InMemoryStore::new(); let logger = DefaultReviewLogger::new( Arc::clone(&movies) as _, diff --git a/crates/application/tests/goals/create_test.rs b/crates/application/tests/goals/create_test.rs index 7bc05398..38a3b3a8 100644 --- a/crates/application/tests/goals/create_test.rs +++ b/crates/application/tests/goals/create_test.rs @@ -1,8 +1,9 @@ use crate::support::create_goal_command; use std::sync::Arc; +use domain::errors::DomainError; use domain::events::DomainEvent; -use domain::testing::{FakeStatsRepository, InMemoryStore, NoopEventPublisher}; +use domain::testing::{FakeStatsRepository, InMemoryStore, RefusingGoalCommand}; use uuid::Uuid; use crate::support::TestContextBuilder; @@ -13,12 +14,10 @@ use application::goals::deps::GoalCommandDeps; async fn creates_goal_and_returns_progress() { let goals = InMemoryStore::new(); let stats = FakeStatsRepository::new(); - let events = NoopEventPublisher::new(); let deps = GoalCommandDeps { goal_command: Arc::clone(&goals) as _, goal_query: Arc::clone(&goals) as _, stats: Arc::clone(&stats) as _, - event_publisher: Arc::clone(&events) as _, }; let result = create::execute(&deps, create_goal_command(2025, 50)) @@ -36,12 +35,10 @@ async fn creates_goal_with_review_count() { let goals = InMemoryStore::new(); let stats = FakeStatsRepository::new(); stats.set_review_count(Uuid::nil(), 2025, 5); - let events = NoopEventPublisher::new(); let deps = GoalCommandDeps { goal_command: Arc::clone(&goals) as _, goal_query: Arc::clone(&goals) as _, stats: Arc::clone(&stats) as _, - event_publisher: Arc::clone(&events) as _, }; let result = create::execute(&deps, create_goal_command(2025, 50)) @@ -53,26 +50,45 @@ async fn creates_goal_with_review_count() { } #[tokio::test] -async fn emits_goal_created_event() { - let b = TestContextBuilder::new(); - let events = NoopEventPublisher::new(); +async fn creating_a_goal_records_the_goal_and_its_event_together() { + let goals = InMemoryStore::new(); let deps = GoalCommandDeps { - goal_command: b.goal_command.clone(), - goal_query: b.goal_query.clone(), - stats: b.stats_repo.clone(), - event_publisher: Arc::clone(&events) as _, + goal_command: Arc::clone(&goals) as _, + goal_query: Arc::clone(&goals) as _, + stats: FakeStatsRepository::new() as _, }; create::execute(&deps, create_goal_command(2025, 10)) .await .unwrap(); - let published = events.published(); - assert!( - published - .iter() - .any(|e| matches!(e, DomainEvent::GoalCreated { year: 2025, .. })) - ); + assert_eq!(goals.goal_count(), 1); + let recorded = goals.recorded_events(); + assert_eq!(recorded.len(), 1); + assert!(matches!( + recorded[0], + DomainEvent::GoalCreated { + year: 2025, + target_count: 10, + .. + } + )); +} + +#[tokio::test] +async fn a_refused_create_propagates_and_records_nothing() { + let goals = InMemoryStore::new(); + let deps = GoalCommandDeps { + goal_command: Arc::new(RefusingGoalCommand), + goal_query: Arc::clone(&goals) as _, + stats: FakeStatsRepository::new() as _, + }; + + let result = create::execute(&deps, create_goal_command(2025, 10)).await; + + assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused")); + assert_eq!(goals.goal_count(), 0); + assert!(goals.recorded_events().is_empty()); } #[tokio::test] @@ -82,7 +98,6 @@ async fn rejects_duplicate_year() { goal_command: b.goal_command.clone(), goal_query: b.goal_query.clone(), stats: b.stats_repo.clone(), - event_publisher: b.event_publisher.clone(), }; let cmd = create_goal_command(2025, 10); @@ -100,7 +115,6 @@ async fn rejects_year_before_2020() { goal_command: b.goal_command.clone(), goal_query: b.goal_query.clone(), stats: b.stats_repo.clone(), - event_publisher: b.event_publisher.clone(), }; let result = create::execute(&deps, create_goal_command(2019, 10)).await; @@ -114,7 +128,6 @@ async fn rejects_zero_target() { goal_command: b.goal_command.clone(), goal_query: b.goal_query.clone(), stats: b.stats_repo.clone(), - event_publisher: b.event_publisher.clone(), }; let result = create::execute(&deps, create_goal_command(2025, 0)).await; diff --git a/crates/application/tests/goals/delete_test.rs b/crates/application/tests/goals/delete_test.rs index 86d8de0f..415d6fb1 100644 --- a/crates/application/tests/goals/delete_test.rs +++ b/crates/application/tests/goals/delete_test.rs @@ -1,7 +1,9 @@ use crate::support::create_goal_command; use std::sync::Arc; -use domain::testing::{FakeStatsRepository, InMemoryStore, NoopEventPublisher}; +use domain::errors::DomainError; +use domain::events::DomainEvent; +use domain::testing::{FakeStatsRepository, InMemoryStore, RefusingGoalCommand}; use uuid::Uuid; use crate::support::TestContextBuilder; @@ -12,12 +14,10 @@ use application::goals::{commands::DeleteGoalCommand, create, delete}; async fn deletes_existing_goal() { let goals = InMemoryStore::new(); let stats = FakeStatsRepository::new(); - let events = NoopEventPublisher::new(); let deps = GoalCommandDeps { goal_command: Arc::clone(&goals) as _, goal_query: Arc::clone(&goals) as _, stats: Arc::clone(&stats) as _, - event_publisher: Arc::clone(&events) as _, }; create::execute(&deps, create_goal_command(2025, 10)) @@ -45,7 +45,6 @@ async fn fails_when_not_found() { goal_command: b.goal_command.clone(), goal_query: b.goal_query.clone(), stats: b.stats_repo.clone(), - event_publisher: b.event_publisher.clone(), }; let result = delete::execute( &deps, @@ -58,3 +57,65 @@ async fn fails_when_not_found() { assert!(result.is_err()); } + +#[tokio::test] +async fn deleting_a_goal_removes_it_and_records_its_event_together() { + let goals = InMemoryStore::new(); + let deps = GoalCommandDeps { + goal_command: Arc::clone(&goals) as _, + goal_query: Arc::clone(&goals) as _, + stats: FakeStatsRepository::new() as _, + }; + create::execute(&deps, create_goal_command(2025, 10)) + .await + .unwrap(); + + delete::execute( + &deps, + DeleteGoalCommand { + user_id: Uuid::nil(), + year: 2025, + }, + ) + .await + .unwrap(); + + assert_eq!(goals.goal_count(), 0); + let recorded = goals.recorded_events(); + assert_eq!(recorded.len(), 2, "created + deleted"); + assert!(matches!( + recorded[1], + DomainEvent::GoalDeleted { year: 2025, .. } + )); +} + +#[tokio::test] +async fn a_refused_delete_propagates_and_records_nothing() { + let goals = InMemoryStore::new(); + let seed = GoalCommandDeps { + goal_command: Arc::clone(&goals) as _, + goal_query: Arc::clone(&goals) as _, + stats: FakeStatsRepository::new() as _, + }; + create::execute(&seed, create_goal_command(2025, 10)) + .await + .unwrap(); + let before = goals.recorded_events().len(); + let deps = GoalCommandDeps { + goal_command: Arc::new(RefusingGoalCommand), + ..seed + }; + + let result = delete::execute( + &deps, + DeleteGoalCommand { + user_id: Uuid::nil(), + year: 2025, + }, + ) + .await; + + assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused")); + assert_eq!(goals.goal_count(), 1); + assert_eq!(goals.recorded_events().len(), before); +} diff --git a/crates/application/tests/goals/get_test.rs b/crates/application/tests/goals/get_test.rs index a4562bdf..b001896e 100644 --- a/crates/application/tests/goals/get_test.rs +++ b/crates/application/tests/goals/get_test.rs @@ -12,7 +12,6 @@ async fn returns_goal_when_exists() { goal_command: b.goal_command.clone(), goal_query: b.goal_query.clone(), stats: b.stats_repo.clone(), - event_publisher: b.event_publisher.clone(), }; let query_deps = GoalQueryDeps { goal_query: b.goal_query.clone(), diff --git a/crates/application/tests/goals/list_test.rs b/crates/application/tests/goals/list_test.rs index 430c3d45..2ae07992 100644 --- a/crates/application/tests/goals/list_test.rs +++ b/crates/application/tests/goals/list_test.rs @@ -31,7 +31,6 @@ async fn returns_all_goals_for_user() { goal_command: b.goal_command.clone(), goal_query: b.goal_query.clone(), stats: b.stats_repo.clone(), - event_publisher: b.event_publisher.clone(), }; let query_deps = GoalQueryDeps { goal_query: b.goal_query.clone(), diff --git a/crates/application/tests/goals/update_test.rs b/crates/application/tests/goals/update_test.rs index 7d7bac10..48643cfe 100644 --- a/crates/application/tests/goals/update_test.rs +++ b/crates/application/tests/goals/update_test.rs @@ -1,5 +1,11 @@ use crate::support::{create_goal_command, update_goal_command}; +use std::sync::Arc; + +use domain::errors::DomainError; +use domain::events::DomainEvent; +use domain::testing::{FakeStatsRepository, InMemoryStore, RefusingGoalCommand}; + use crate::support::TestContextBuilder; use application::goals::deps::GoalCommandDeps; use application::goals::{create, update}; @@ -11,7 +17,6 @@ async fn updates_target_count() { goal_command: b.goal_command.clone(), goal_query: b.goal_query.clone(), stats: b.stats_repo.clone(), - event_publisher: b.event_publisher.clone(), }; create::execute(&deps, create_goal_command(2025, 10)) @@ -32,7 +37,6 @@ async fn fails_when_goal_not_found() { goal_command: b.goal_command.clone(), goal_query: b.goal_query.clone(), stats: b.stats_repo.clone(), - event_publisher: b.event_publisher.clone(), }; let result = update::execute(&deps, update_goal_command(2025, 10)).await; @@ -46,7 +50,6 @@ async fn rejects_zero_target() { goal_command: b.goal_command.clone(), goal_query: b.goal_query.clone(), stats: b.stats_repo.clone(), - event_publisher: b.event_publisher.clone(), }; create::execute(&deps, create_goal_command(2025, 10)) @@ -57,3 +60,54 @@ async fn rejects_zero_target() { assert!(result.is_err()); } + +#[tokio::test] +async fn updating_a_goal_records_the_change_and_its_event_together() { + let goals = InMemoryStore::new(); + let deps = GoalCommandDeps { + goal_command: Arc::clone(&goals) as _, + goal_query: Arc::clone(&goals) as _, + stats: FakeStatsRepository::new() as _, + }; + create::execute(&deps, create_goal_command(2025, 10)) + .await + .unwrap(); + + update::execute(&deps, update_goal_command(2025, 100)) + .await + .unwrap(); + + let recorded = goals.recorded_events(); + assert_eq!(recorded.len(), 2, "created + updated"); + assert!(matches!( + recorded[1], + DomainEvent::GoalUpdated { + year: 2025, + target_count: 100, + .. + } + )); +} + +#[tokio::test] +async fn a_refused_update_propagates_and_records_nothing() { + let goals = InMemoryStore::new(); + let seed = GoalCommandDeps { + goal_command: Arc::clone(&goals) as _, + goal_query: Arc::clone(&goals) as _, + stats: FakeStatsRepository::new() as _, + }; + create::execute(&seed, create_goal_command(2025, 10)) + .await + .unwrap(); + let before = goals.recorded_events().len(); + let deps = GoalCommandDeps { + goal_command: Arc::new(RefusingGoalCommand), + ..seed + }; + + let result = update::execute(&deps, update_goal_command(2025, 100)).await; + + assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused")); + assert_eq!(goals.recorded_events().len(), before); +} diff --git a/crates/application/tests/users/get_profile_settings_test.rs b/crates/application/tests/users/get_profile_settings_test.rs index d8ca2c49..58496c12 100644 --- a/crates/application/tests/users/get_profile_settings_test.rs +++ b/crates/application/tests/users/get_profile_settings_test.rs @@ -37,6 +37,7 @@ async fn get_profile_settings_includes_profile_fields() { name: "pronouns".into(), value: "they/them".into(), }], + &[], ) .await .unwrap(); diff --git a/crates/application/tests/users/update_profile_fields_test.rs b/crates/application/tests/users/update_profile_fields_test.rs index eb00c1fa..c406829a 100644 --- a/crates/application/tests/users/update_profile_fields_test.rs +++ b/crates/application/tests/users/update_profile_fields_test.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::events::DomainEvent; use domain::models::ProfileField; -use domain::testing::{InMemoryStore, NoopEventPublisher}; +use domain::testing::{InMemoryStore, RefusingProfileFieldsRepository}; use uuid::Uuid; use application::users::{ @@ -11,15 +11,10 @@ use application::users::{ }; #[tokio::test] -async fn saves_profile_fields() { +async fn saving_profile_fields_records_the_change_and_its_event_together() { let fields_repo = InMemoryStore::new(); - let events = NoopEventPublisher::new(); - let b = TestContextBuilder::new() - .with_profile_fields(Arc::clone(&fields_repo) as _) - .with_event_publisher(Arc::clone(&events) as _); let deps = UpdateProfileFieldsDeps { - profile_fields: b.profile_fields_repo.clone(), - event_publisher: b.event_publisher.clone(), + profile_fields: Arc::clone(&fields_repo) as _, }; update_profile_fields::execute( @@ -41,12 +36,9 @@ async fn saves_profile_fields() { .await .unwrap(); - let published = events.published(); - assert!( - published - .iter() - .any(|e| matches!(e, DomainEvent::UserUpdated { .. })) - ); + let recorded = fields_repo.recorded_events(); + assert_eq!(recorded.len(), 1); + assert!(matches!(recorded[0], DomainEvent::UserUpdated { .. })); } #[tokio::test] @@ -54,7 +46,6 @@ async fn rejects_more_than_four_fields() { let b = TestContextBuilder::new(); let deps = UpdateProfileFieldsDeps { profile_fields: b.profile_fields_repo.clone(), - event_publisher: b.event_publisher.clone(), }; let fields: Vec = (0..5) @@ -75,3 +66,24 @@ async fn rejects_more_than_four_fields() { assert!(result.is_err()); } + +#[tokio::test] +async fn refused_profile_fields_propagate_and_record_nothing() { + let deps = UpdateProfileFieldsDeps { + profile_fields: Arc::new(RefusingProfileFieldsRepository), + }; + + let result = update_profile_fields::execute( + &deps, + UpdateProfileFieldsCommand { + user_id: Uuid::nil(), + fields: vec![], + }, + ) + .await; + + assert!(matches!( + result, + Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused" + )); +} diff --git a/crates/application/tests/users/update_profile_test.rs b/crates/application/tests/users/update_profile_test.rs index 14540b62..7bde7f73 100644 --- a/crates/application/tests/users/update_profile_test.rs +++ b/crates/application/tests/users/update_profile_test.rs @@ -31,7 +31,7 @@ async fn register_user(b: &TestContextBuilder, users: &Arc) -> Uu } #[tokio::test] -async fn updates_display_name() { +async fn updating_the_profile_records_the_change_and_its_event_together() { let users = InMemoryStore::new(); let events = NoopEventPublisher::new(); let b = TestContextBuilder::new() @@ -54,12 +54,9 @@ async fn updates_display_name() { .await .unwrap(); - let published = events.published(); - assert!( - published - .iter() - .any(|e| matches!(e, DomainEvent::UserUpdated { .. })) - ); + let recorded = users.recorded_events(); + assert_eq!(recorded.len(), 1); + assert!(matches!(recorded[0], DomainEvent::UserUpdated { .. })); } #[tokio::test] @@ -111,14 +108,15 @@ async fn uploads_avatar() { .await .unwrap(); - let published = events.published(); assert!( - published + users + .recorded_events() .iter() .any(|e| matches!(e, DomainEvent::UserUpdated { .. })) ); assert!( - published + events + .published() .iter() .any(|e| matches!(e, DomainEvent::ImageStored { .. })) ); @@ -149,14 +147,15 @@ async fn uploads_banner() { .await .unwrap(); - let published = events.published(); assert!( - published + users + .recorded_events() .iter() .any(|e| matches!(e, DomainEvent::UserUpdated { .. })) ); assert!( - published + events + .published() .iter() .any(|e| matches!(e, DomainEvent::ImageStored { .. })) ); @@ -232,16 +231,114 @@ async fn text_only_update_emits_user_updated_no_image_stored() { .await .unwrap(); - let published = events.published(); assert!( - published + users + .recorded_events() .iter() .any(|e| matches!(e, DomainEvent::UserUpdated { .. })) ); assert!( - !published + !events + .published() .iter() .any(|e| matches!(e, DomainEvent::ImageStored { .. })), "text-only update should not emit ImageStored" ); } + +#[tokio::test] +async fn changing_also_known_as_records_the_move_with_the_update() { + let users = InMemoryStore::new(); + let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); + let deps = UpdateProfileDeps { + user: b.user_repo.clone(), + object_storage: b.object_storage.clone(), + event_publisher: b.event_publisher.clone(), + }; + let uid = register_user(&b, &users).await; + + update_profile::execute( + &deps, + UpdateProfileCommand { + also_known_as: Some("https://other.example/users/alice".into()), + ..update_profile_command(uid) + }, + ) + .await + .unwrap(); + + let recorded = users.recorded_events(); + assert_eq!(recorded.len(), 2); + assert!(matches!(recorded[0], DomainEvent::UserUpdated { .. })); + assert!(matches!(recorded[1], DomainEvent::UserAccountMoved { .. })); +} + +/// Loads from the store but refuses the profile write. +struct ProfileWriteRefusingUsers(Arc); + +#[async_trait::async_trait] +impl UserRepository for ProfileWriteRefusingUsers { + async fn find_by_email( + &self, + e: &domain::value_objects::Email, + ) -> Result, domain::errors::DomainError> { + self.0.find_by_email(e).await + } + async fn find_by_username( + &self, + u: &domain::value_objects::Username, + ) -> Result, domain::errors::DomainError> { + self.0.find_by_username(u).await + } + async fn save(&self, u: &domain::models::User) -> Result<(), domain::errors::DomainError> { + self.0.save(u).await + } + async fn find_by_id( + &self, + id: &domain::value_objects::UserId, + ) -> Result, domain::errors::DomainError> { + self.0.find_by_id(id).await + } + async fn list_with_stats( + &self, + ) -> Result, domain::errors::DomainError> { + self.0.list_with_stats().await + } + async fn update_profile( + &self, + _: &domain::value_objects::UserId, + _: &domain::models::UserProfile, + _: &[DomainEvent], + ) -> Result<(), domain::errors::DomainError> { + Err(domain::errors::DomainError::InfrastructureError( + "refused".into(), + )) + } +} + +#[tokio::test] +async fn a_refused_profile_update_propagates_and_records_nothing() { + let users = InMemoryStore::new(); + let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); + let uid = register_user(&b, &users).await; + let deps = UpdateProfileDeps { + user: Arc::new(ProfileWriteRefusingUsers(Arc::clone(&users))), + object_storage: b.object_storage.clone(), + event_publisher: b.event_publisher.clone(), + }; + + let result = update_profile::execute( + &deps, + UpdateProfileCommand { + display_name: Some("Alice W.".into()), + ..update_profile_command(uid) + }, + ) + .await; + + assert!(matches!( + result, + Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused" + )); + assert!(users.recorded_events().is_empty()); +} diff --git a/crates/application/tests/watchlist/add_test.rs b/crates/application/tests/watchlist/add_test.rs index 20c3572c..995835d4 100644 --- a/crates/application/tests/watchlist/add_test.rs +++ b/crates/application/tests/watchlist/add_test.rs @@ -3,8 +3,9 @@ use domain::testing::test_movie_with_year; use std::sync::Arc; use domain::{ + events::DomainEvent, ports::MovieCommand, - testing::{InMemoryStore, NoopEventPublisher}, + testing::{InMemoryStore, NoopEventPublisher, RefusingWatchlistRepository}, }; use application::watchlist::{add, commands::AddToWatchlistCommand, deps::WatchlistAddDeps}; @@ -108,3 +109,50 @@ async fn adding_the_same_movie_to_a_watchlist_twice_keeps_a_single_entry_and_suc assert_eq!(movies.watchlist_count(), 1); } + +#[tokio::test] +async fn adding_an_entry_records_the_entry_and_its_event_together() { + let movies = InMemoryStore::new(); + let movie = test_movie_with_year("Heat", 1995); + movies.upsert_movie(&movie).await.unwrap(); + let deps = make_deps(Arc::clone(&movies), Arc::clone(&movies)); + + add::execute( + &deps, + add_to_watchlist_command(uuid::Uuid::new_v4(), movie.id().value()), + ) + .await + .unwrap(); + + assert_eq!(movies.watchlist_count(), 1); + let recorded = movies.recorded_events(); + assert_eq!(recorded.len(), 1); + assert!(matches!( + recorded[0], + DomainEvent::WatchlistEntryAdded { .. } + )); +} + +#[tokio::test] +async fn a_refused_add_propagates_and_records_nothing() { + let movies = InMemoryStore::new(); + let movie = test_movie_with_year("Heat", 1995); + movies.upsert_movie(&movie).await.unwrap(); + let deps = WatchlistAddDeps { + watchlist: Arc::new(RefusingWatchlistRepository), + ..make_deps(Arc::clone(&movies), Arc::clone(&movies)) + }; + + let result = add::execute( + &deps, + add_to_watchlist_command(uuid::Uuid::new_v4(), movie.id().value()), + ) + .await; + + assert!(matches!( + result, + Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused" + )); + assert_eq!(movies.watchlist_count(), 0); + assert!(movies.recorded_events().is_empty()); +} diff --git a/crates/application/tests/watchlist/is_on_test.rs b/crates/application/tests/watchlist/is_on_test.rs index 2b59f127..55650397 100644 --- a/crates/application/tests/watchlist/is_on_test.rs +++ b/crates/application/tests/watchlist/is_on_test.rs @@ -15,10 +15,10 @@ async fn returns_true_when_present() { let uid = Uuid::new_v4(); let mid = Uuid::new_v4(); watchlist - .add(&WatchlistEntry::new( - UserId::from_uuid(uid), - MovieId::from_uuid(mid), - )) + .add( + &WatchlistEntry::new(UserId::from_uuid(uid), MovieId::from_uuid(mid)), + &[], + ) .await .unwrap(); diff --git a/crates/application/tests/watchlist/remove_test.rs b/crates/application/tests/watchlist/remove_test.rs index 0616e298..d0e85fe8 100644 --- a/crates/application/tests/watchlist/remove_test.rs +++ b/crates/application/tests/watchlist/remove_test.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::events::DomainEvent; use domain::models::WatchlistEntry; use domain::ports::WatchlistRepository; -use domain::testing::{InMemoryStore, NoopEventPublisher}; +use domain::testing::{InMemoryStore, RefusingWatchlistRepository}; use domain::value_objects::{MovieId, UserId}; use uuid::Uuid; @@ -13,22 +13,20 @@ use application::watchlist::{ }; #[tokio::test] -async fn removes_entry_and_emits_event() { +async fn removing_an_entry_records_the_removal_and_its_event_together() { let watchlist = InMemoryStore::new(); - let events = NoopEventPublisher::new(); let uid = Uuid::new_v4(); let mid = Uuid::new_v4(); watchlist - .add(&WatchlistEntry::new( - UserId::from_uuid(uid), - MovieId::from_uuid(mid), - )) + .add( + &WatchlistEntry::new(UserId::from_uuid(uid), MovieId::from_uuid(mid)), + &[], + ) .await .unwrap(); let deps = RemoveFromWatchlistDeps { watchlist: Arc::clone(&watchlist) as _, - event_publisher: Arc::clone(&events) as _, }; remove::execute( &deps, @@ -41,12 +39,12 @@ async fn removes_entry_and_emits_event() { .unwrap(); assert_eq!(watchlist.watchlist_count(), 0); - let published = events.published(); - assert!( - published - .iter() - .any(|e| matches!(e, DomainEvent::WatchlistEntryRemoved { .. })) - ); + let recorded = watchlist.recorded_events(); + assert_eq!(recorded.len(), 1); + assert!(matches!( + recorded[0], + DomainEvent::WatchlistEntryRemoved { .. } + )); } #[tokio::test] @@ -54,7 +52,6 @@ async fn fails_when_not_on_watchlist() { let b = TestContextBuilder::new(); let deps = RemoveFromWatchlistDeps { watchlist: b.watchlist_repo.clone(), - event_publisher: b.event_publisher.clone(), }; let result = remove::execute( &deps, @@ -67,3 +64,44 @@ async fn fails_when_not_on_watchlist() { assert!(result.is_err()); } + +#[tokio::test] +async fn a_failed_removal_records_no_event() { + let store = InMemoryStore::new(); + let deps = RemoveFromWatchlistDeps { + watchlist: Arc::clone(&store) as _, + }; + + let result = remove::execute( + &deps, + RemoveFromWatchlistCommand { + user_id: Uuid::new_v4(), + movie_id: Uuid::new_v4(), + }, + ) + .await; + + assert!(result.is_err()); + assert!(store.recorded_events().is_empty()); +} + +#[tokio::test] +async fn a_refused_remove_propagates_the_refusal() { + let deps = RemoveFromWatchlistDeps { + watchlist: Arc::new(RefusingWatchlistRepository), + }; + + let result = remove::execute( + &deps, + RemoveFromWatchlistCommand { + user_id: Uuid::new_v4(), + movie_id: Uuid::new_v4(), + }, + ) + .await; + + assert!(matches!( + result, + Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused" + )); +} diff --git a/crates/bootstrap/src/build.rs b/crates/bootstrap/src/build.rs index 90fd7f6f..9003d371 100644 --- a/crates/bootstrap/src/build.rs +++ b/crates/bootstrap/src/build.rs @@ -125,7 +125,6 @@ pub fn build_deps( goal_command: repos.goal_command.clone(), goal_query: repos.goal_query.clone(), stats: repos.stats.clone(), - event_publisher: services.event_publisher.clone(), }, query: GoalQueryDeps { goal_query: repos.goal_query.clone(), @@ -294,7 +293,6 @@ pub fn build_deps( }, update_profile_fields: UpdateProfileFieldsDeps { profile_fields: repos.profile_fields.clone(), - event_publisher: services.event_publisher.clone(), }, get_settings: GetSettingsDeps { user_settings: repos.user_settings.clone(), @@ -330,7 +328,6 @@ pub fn build_deps( }, remove_from_watchlist: RemoveFromWatchlistDeps { watchlist: repos.watchlist.clone(), - event_publisher: services.event_publisher.clone(), }, }, wrapup: WrapupGroup { diff --git a/crates/bootstrap/tests/http_handlers/support.rs b/crates/bootstrap/tests/http_handlers/support.rs index 5d4715d9..b5477228 100644 --- a/crates/bootstrap/tests/http_handlers/support.rs +++ b/crates/bootstrap/tests/http_handlers/support.rs @@ -223,6 +223,7 @@ impl UserRepository for ErroringUserRepo { &self, _: &UserId, _: &domain::models::UserProfile, + _: &[domain::events::DomainEvent], ) -> Result<(), DomainError> { panic!() } diff --git a/crates/domain/src/ports/auth.rs b/crates/domain/src/ports/auth.rs index a1e95bfd..b4337cb2 100644 --- a/crates/domain/src/ports/auth.rs +++ b/crates/domain/src/ports/auth.rs @@ -23,6 +23,7 @@ pub trait UserRepository: Send + Sync { &self, user_id: &UserId, profile: &crate::models::UserProfile, + events: &[crate::events::DomainEvent], ) -> Result<(), DomainError>; } @@ -36,6 +37,7 @@ pub trait UserProfileFieldsRepository: Send + Sync { &self, user_id: &UserId, fields: Vec, + events: &[crate::events::DomainEvent], ) -> Result<(), DomainError>; } diff --git a/crates/domain/src/ports/goals.rs b/crates/domain/src/ports/goals.rs index c7691f45..17cb63ee 100644 --- a/crates/domain/src/ports/goals.rs +++ b/crates/domain/src/ports/goals.rs @@ -2,15 +2,21 @@ use async_trait::async_trait; use crate::{ errors::DomainError, + events::DomainEvent, models::Goal, value_objects::{GoalId, UserId}, }; #[async_trait] pub trait GoalCommand: Send + Sync { - async fn save(&self, goal: &Goal) -> Result<(), DomainError>; - async fn update(&self, goal: &Goal) -> Result<(), DomainError>; - async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError>; + async fn save(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError>; + async fn update(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError>; + async fn delete( + &self, + id: &GoalId, + user_id: &UserId, + events: &[DomainEvent], + ) -> Result<(), DomainError>; } #[async_trait] diff --git a/crates/domain/src/ports/watchlist.rs b/crates/domain/src/ports/watchlist.rs index ff87adc4..7fe6ace9 100644 --- a/crates/domain/src/ports/watchlist.rs +++ b/crates/domain/src/ports/watchlist.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use crate::{ errors::DomainError, + events::DomainEvent, models::{ WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}, @@ -11,14 +12,21 @@ use crate::{ #[async_trait] pub trait WatchlistRepository: Send + Sync { - async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError>; + async fn add(&self, entry: &WatchlistEntry, events: &[DomainEvent]) -> Result<(), DomainError>; - async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError>; + async fn remove( + &self, + user_id: &UserId, + movie_id: &MovieId, + events: &[DomainEvent], + ) -> Result<(), DomainError>; + /// `events` are recorded only when an entry was actually removed. async fn remove_if_present( &self, user_id: &UserId, movie_id: &MovieId, + events: &[DomainEvent], ) -> Result; async fn get_for_user( diff --git a/crates/domain/src/testing/fakes/noops.rs b/crates/domain/src/testing/fakes/noops.rs index 4a633de4..6d6d5aaf 100644 --- a/crates/domain/src/testing/fakes/noops.rs +++ b/crates/domain/src/testing/fakes/noops.rs @@ -74,16 +74,17 @@ pub struct NoopGoalCommand; #[async_trait] impl crate::ports::GoalCommand for NoopGoalCommand { - async fn save(&self, _: &crate::models::Goal) -> Result<(), DomainError> { + async fn save(&self, _: &crate::models::Goal, _: &[DomainEvent]) -> Result<(), DomainError> { Ok(()) } - async fn update(&self, _: &crate::models::Goal) -> Result<(), DomainError> { + async fn update(&self, _: &crate::models::Goal, _: &[DomainEvent]) -> Result<(), DomainError> { Ok(()) } async fn delete( &self, _: &crate::value_objects::GoalId, _: &UserId, + _: &[DomainEvent], ) -> Result<(), DomainError> { Ok(()) } diff --git a/crates/domain/src/testing/fakes/panics.rs b/crates/domain/src/testing/fakes/panics.rs index fa9dd781..2fc6a186 100644 --- a/crates/domain/src/testing/fakes/panics.rs +++ b/crates/domain/src/testing/fakes/panics.rs @@ -295,13 +295,18 @@ pub struct PanicWatchlistRepository; #[async_trait] impl WatchlistRepository for PanicWatchlistRepository { - async fn add(&self, _: &WatchlistEntry) -> Result<(), DomainError> { + async fn add(&self, _: &WatchlistEntry, _: &[DomainEvent]) -> Result<(), DomainError> { panic!("PanicWatchlistRepository called") } - async fn remove(&self, _: &UserId, _: &MovieId) -> Result<(), DomainError> { + async fn remove(&self, _: &UserId, _: &MovieId, _: &[DomainEvent]) -> Result<(), DomainError> { panic!("PanicWatchlistRepository called") } - async fn remove_if_present(&self, _: &UserId, _: &MovieId) -> Result { + async fn remove_if_present( + &self, + _: &UserId, + _: &MovieId, + _: &[DomainEvent], + ) -> Result { panic!("PanicWatchlistRepository called") } async fn get_for_user( @@ -357,6 +362,7 @@ impl UserProfileFieldsRepository for PanicProfileFieldsRepo { &self, _: &UserId, _: Vec, + _: &[DomainEvent], ) -> Result<(), DomainError> { panic!("PanicProfileFieldsRepo called") } @@ -645,6 +651,7 @@ impl UserRepository for PanicUserRepository { &self, _: &UserId, _: &crate::models::UserProfile, + _: &[DomainEvent], ) -> Result<(), DomainError> { panic!("PanicUserRepository called") } @@ -675,16 +682,17 @@ pub struct PanicGoalCommand; #[async_trait] impl crate::ports::GoalCommand for PanicGoalCommand { - async fn save(&self, _: &crate::models::Goal) -> Result<(), DomainError> { + async fn save(&self, _: &crate::models::Goal, _: &[DomainEvent]) -> Result<(), DomainError> { panic!("PanicGoalCommand called") } - async fn update(&self, _: &crate::models::Goal) -> Result<(), DomainError> { + async fn update(&self, _: &crate::models::Goal, _: &[DomainEvent]) -> Result<(), DomainError> { panic!("PanicGoalCommand called") } async fn delete( &self, _: &crate::value_objects::GoalId, _: &crate::value_objects::UserId, + _: &[DomainEvent], ) -> Result<(), DomainError> { panic!("PanicGoalCommand called") } diff --git a/crates/domain/src/testing/fakes/refusing.rs b/crates/domain/src/testing/fakes/refusing.rs index 9ea36bef..70ac8e6e 100644 --- a/crates/domain/src/testing/fakes/refusing.rs +++ b/crates/domain/src/testing/fakes/refusing.rs @@ -6,11 +6,14 @@ use crate::{ errors::DomainError, events::DomainEvent, models::{ - Movie, Review, WatchlistEntry, WatchlistWithMovie, + Goal, Movie, ProfileField, Review, WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}, }, - ports::{EventPublisher, MovieCommand, ReviewRepository, WatchlistRepository}, - value_objects::{MovieId, ReviewId, UserId}, + ports::{ + EventPublisher, GoalCommand, MovieCommand, ReviewRepository, UserProfileFieldsRepository, + WatchlistRepository, + }, + value_objects::{GoalId, MovieId, ReviewId, UserId}, }; fn refused() -> DomainError { @@ -54,13 +57,18 @@ pub struct RefusingWatchlistRepository; #[async_trait] impl WatchlistRepository for RefusingWatchlistRepository { - async fn add(&self, _: &WatchlistEntry) -> Result<(), DomainError> { + async fn add(&self, _: &WatchlistEntry, _: &[DomainEvent]) -> Result<(), DomainError> { Err(refused()) } - async fn remove(&self, _: &UserId, _: &MovieId) -> Result<(), DomainError> { + async fn remove(&self, _: &UserId, _: &MovieId, _: &[DomainEvent]) -> Result<(), DomainError> { Err(refused()) } - async fn remove_if_present(&self, _: &UserId, _: &MovieId) -> Result { + async fn remove_if_present( + &self, + _: &UserId, + _: &MovieId, + _: &[DomainEvent], + ) -> Result { Err(refused()) } async fn get_for_user( @@ -75,6 +83,38 @@ impl WatchlistRepository for RefusingWatchlistRepository { } } +pub struct RefusingGoalCommand; + +#[async_trait] +impl GoalCommand for RefusingGoalCommand { + async fn save(&self, _: &Goal, _: &[DomainEvent]) -> Result<(), DomainError> { + Err(refused()) + } + async fn update(&self, _: &Goal, _: &[DomainEvent]) -> Result<(), DomainError> { + Err(refused()) + } + async fn delete(&self, _: &GoalId, _: &UserId, _: &[DomainEvent]) -> Result<(), DomainError> { + Err(refused()) + } +} + +pub struct RefusingProfileFieldsRepository; + +#[async_trait] +impl UserProfileFieldsRepository for RefusingProfileFieldsRepository { + async fn get_fields(&self, _: &UserId) -> Result, DomainError> { + Err(refused()) + } + async fn set_fields( + &self, + _: &UserId, + _: Vec, + _: &[DomainEvent], + ) -> Result<(), DomainError> { + Err(refused()) + } +} + pub struct RefusingEventPublisher; #[async_trait] diff --git a/crates/domain/src/testing/fakes/store_goal.rs b/crates/domain/src/testing/fakes/store_goal.rs index c927457e..9d9e1845 100644 --- a/crates/domain/src/testing/fakes/store_goal.rs +++ b/crates/domain/src/testing/fakes/store_goal.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use crate::{ errors::DomainError, + events::DomainEvent, models::Goal, ports::{GoalCommand, GoalQuery}, value_objects::{GoalId, UserId}, @@ -12,7 +13,7 @@ use super::InMemoryStore; #[async_trait] impl GoalCommand for InMemoryStore { /// `goals` is UNIQUE(user_id, year). - async fn save(&self, goal: &Goal) -> Result<(), DomainError> { + async fn save(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError> { let mut goals = self.goals.write().unwrap(); let duplicate = goals.values().any(|g| { g.id() == goal.id() || (g.user_id() == goal.user_id() && g.year() == goal.year()) @@ -23,14 +24,18 @@ impl GoalCommand for InMemoryStore { )); } goals.insert(goal.id().value(), goal.clone()); + drop(goals); + self.record_events(events); Ok(()) } - async fn update(&self, goal: &Goal) -> Result<(), DomainError> { + async fn update(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError> { let mut store = self.goals.write().unwrap(); match store.entry(goal.id().value()) { std::collections::hash_map::Entry::Occupied(mut e) => { e.insert(goal.clone()); + drop(store); + self.record_events(events); Ok(()) } std::collections::hash_map::Entry::Vacant(_) => { @@ -39,8 +44,14 @@ impl GoalCommand for InMemoryStore { } } - async fn delete(&self, id: &GoalId, _user_id: &UserId) -> Result<(), DomainError> { + async fn delete( + &self, + id: &GoalId, + _user_id: &UserId, + events: &[DomainEvent], + ) -> Result<(), DomainError> { self.goals.write().unwrap().remove(&id.value()); + self.record_events(events); Ok(()) } } diff --git a/crates/domain/src/testing/fakes/store_user.rs b/crates/domain/src/testing/fakes/store_user.rs index 0d76498f..83f07176 100644 --- a/crates/domain/src/testing/fakes/store_user.rs +++ b/crates/domain/src/testing/fakes/store_user.rs @@ -84,11 +84,14 @@ impl UserRepository for InMemoryStore { &self, user_id: &UserId, profile: &crate::models::UserProfile, + events: &[crate::events::DomainEvent], ) -> Result<(), DomainError> { let mut store = self.users.write().unwrap(); if let Some(user) = store.get_mut(&user_id.value()) { user.update_profile(profile.clone()); } + drop(store); + self.record_events(events); Ok(()) } } @@ -104,11 +107,13 @@ impl UserProfileFieldsRepository for InMemoryStore { &self, user_id: &UserId, fields: Vec, + events: &[crate::events::DomainEvent], ) -> Result<(), DomainError> { self.profile_fields .write() .unwrap() .insert(user_id.value(), fields); + self.record_events(events); Ok(()) } } diff --git a/crates/domain/src/testing/fakes/store_watchlist.rs b/crates/domain/src/testing/fakes/store_watchlist.rs index 57c2a7ba..c19d6820 100644 --- a/crates/domain/src/testing/fakes/store_watchlist.rs +++ b/crates/domain/src/testing/fakes/store_watchlist.rs @@ -3,6 +3,7 @@ use uuid::Uuid; use crate::{ errors::DomainError, + events::DomainEvent, models::{ RemoteWatchlistEntry, WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}, @@ -16,23 +17,30 @@ use super::InMemoryStore; #[async_trait] impl WatchlistRepository for InMemoryStore { /// `INSERT OR IGNORE` on UNIQUE(user_id, movie_id): adding twice is a no-op. - async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> { + async fn add(&self, entry: &WatchlistEntry, events: &[DomainEvent]) -> Result<(), DomainError> { let key = (entry.user_id.value(), entry.movie_id.value()); self.watchlist .write() .unwrap() .entry(key) .or_insert_with(|| entry.clone()); + self.record_events(events); Ok(()) } - async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> { + async fn remove( + &self, + user_id: &UserId, + movie_id: &MovieId, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let key = (user_id.value(), movie_id.value()); self.watchlist .write() .unwrap() .remove(&key) .ok_or_else(|| DomainError::NotFound("watchlist entry".into()))?; + self.record_events(events); Ok(()) } @@ -40,9 +48,14 @@ impl WatchlistRepository for InMemoryStore { &self, user_id: &UserId, movie_id: &MovieId, + events: &[DomainEvent], ) -> Result { let key = (user_id.value(), movie_id.value()); - Ok(self.watchlist.write().unwrap().remove(&key).is_some()) + let removed = self.watchlist.write().unwrap().remove(&key).is_some(); + if removed { + self.record_events(events); + } + Ok(removed) } async fn get_for_user( diff --git a/crates/domain/tests/testing/in_memory_store_test.rs b/crates/domain/tests/testing/in_memory_store_test.rs index ecab6148..a9f68af1 100644 --- a/crates/domain/tests/testing/in_memory_store_test.rs +++ b/crates/domain/tests/testing/in_memory_store_test.rs @@ -73,10 +73,13 @@ async fn seed_user_with_dependents(store: &Arc) -> (UserId, Movie store.upsert_movie(&movie).await.unwrap(); store - .add(&WatchlistEntry::new(user_id.clone(), movie.id().clone())) + .add( + &WatchlistEntry::new(user_id.clone(), movie.id().clone()), + &[], + ) .await .unwrap(); - GoalCommand::save(&**store, &test_goal(&user_id, 2024, 50)) + GoalCommand::save(&**store, &test_goal(&user_id, 2024, 50), &[]) .await .unwrap(); UserSettingsRepository::save(&**store, &UserSettings::new(user_id.clone())) @@ -89,6 +92,7 @@ async fn seed_user_with_dependents(store: &Arc) -> (UserId, Movie name: "Site".into(), value: "https://example.com".into(), }], + &[], ) .await .unwrap(); @@ -197,10 +201,10 @@ async fn deleting_a_user_leaves_other_users_rows_alone() { let bystander = test_user("carol"); UserRepository::save(&*store, &bystander).await.unwrap(); store - .add(&WatchlistEntry::new( - bystander.id().clone(), - movie.id().clone(), - )) + .add( + &WatchlistEntry::new(bystander.id().clone(), movie.id().clone()), + &[], + ) .await .unwrap(); @@ -219,7 +223,10 @@ async fn seed_movie_with_dependents( let movie = test_movie("Heat"); store.upsert_movie(&movie).await.unwrap(); store - .add(&WatchlistEntry::new(user.id().clone(), movie.id().clone())) + .add( + &WatchlistEntry::new(user.id().clone(), movie.id().clone()), + &[], + ) .await .unwrap(); store.upsert(&movie_profile(movie.id())).await.unwrap(); @@ -327,11 +334,11 @@ async fn saving_a_user_with_a_taken_email_is_a_validation_error() { async fn saving_a_second_goal_for_the_same_user_and_year_is_an_infrastructure_error() { let store = InMemoryStore::new(); let user_id = UserId::generate(); - GoalCommand::save(&*store, &test_goal(&user_id, 2024, 10)) + GoalCommand::save(&*store, &test_goal(&user_id, 2024, 10), &[]) .await .unwrap(); - let result = GoalCommand::save(&*store, &test_goal(&user_id, 2024, 20)).await; + let result = GoalCommand::save(&*store, &test_goal(&user_id, 2024, 20), &[]).await; assert!(matches!(result, Err(DomainError::InfrastructureError(_)))); assert_eq!(store.goal_count(), 1); @@ -344,11 +351,11 @@ async fn adding_the_same_movie_to_a_watchlist_twice_keeps_one_entry() { let movie_id = MovieId::generate(); store - .add(&WatchlistEntry::new(user_id.clone(), movie_id.clone())) + .add(&WatchlistEntry::new(user_id.clone(), movie_id.clone()), &[]) .await .unwrap(); store - .add(&WatchlistEntry::new(user_id, movie_id)) + .add(&WatchlistEntry::new(user_id, movie_id), &[]) .await .unwrap(); -- 2.49.1 From 2f133afb9679ebc621b41bdad1493e6ca319d782 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 00:52:45 +0100 Subject: [PATCH 061/104] fix: honest review update rollback test, goal delete NotFound fake, read before write --- .../tests/repositories/review_repository_test.rs | 13 +++++++++++-- crates/application/src/goals/create.rs | 4 ++-- crates/application/src/goals/update.rs | 4 ++-- crates/domain/src/testing/fakes/store_goal.rs | 6 +++++- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/crates/adapters/sqlite/tests/repositories/review_repository_test.rs b/crates/adapters/sqlite/tests/repositories/review_repository_test.rs index 9f9cc136..557899e6 100644 --- a/crates/adapters/sqlite/tests/repositories/review_repository_test.rs +++ b/crates/adapters/sqlite/tests/repositories/review_repository_test.rs @@ -118,11 +118,20 @@ async fn a_failing_enqueue_after_the_review_update_rolls_the_update_back() { let repo = SqliteReviewRepository::new(pool.clone()); repo.save_review(&review, &[]).await.unwrap(); fail_event_inserts(&pool).await; - let event = updated(&review); + let edited = review.apply_edit(domain::models::ReviewEdit { + rating: Some(domain::value_objects::Rating::new(1).unwrap()), + ..Default::default() + }); + let event = updated(&edited); - let result = repo.update_review(&review, &[event]).await; + let result = repo.update_review(&edited, &[event]).await; assert!(result.is_err()); + let persisted: i64 = sqlx::query_scalar("SELECT rating FROM reviews") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(persisted, 4, "rating update rolled back"); assert_eq!(count(&pool, "event_queue").await, 0); } diff --git a/crates/application/src/goals/create.rs b/crates/application/src/goals/create.rs index ae4bc708..7c5c0701 100644 --- a/crates/application/src/goals/create.rs +++ b/crates/application/src/goals/create.rs @@ -29,6 +29,8 @@ pub async fn execute( cmd.target_count, GoalType::Movies, )?; + // Reads that can fail happen before the write, so an error never follows a commit. + let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?; let event = DomainEvent::GoalCreated { goal_id: g.id().clone(), user_id: user_id.clone(), @@ -37,8 +39,6 @@ pub async fn execute( }; deps.goal_command.save(&g, &[event]).await?; - let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?; - Ok(GoalWithProgress { goal: g, current_count, diff --git a/crates/application/src/goals/update.rs b/crates/application/src/goals/update.rs index 4274e5ba..24151a85 100644 --- a/crates/application/src/goals/update.rs +++ b/crates/application/src/goals/update.rs @@ -17,6 +17,8 @@ pub async fn execute( .ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?; g.update_target(cmd.target_count)?; + // Reads that can fail happen before the write, so an error never follows a commit. + let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?; let event = DomainEvent::GoalUpdated { goal_id: g.id().clone(), user_id: user_id.clone(), @@ -25,8 +27,6 @@ pub async fn execute( }; deps.goal_command.update(&g, &[event]).await?; - let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?; - Ok(GoalWithProgress { goal: g, current_count, diff --git a/crates/domain/src/testing/fakes/store_goal.rs b/crates/domain/src/testing/fakes/store_goal.rs index 9d9e1845..be5ec066 100644 --- a/crates/domain/src/testing/fakes/store_goal.rs +++ b/crates/domain/src/testing/fakes/store_goal.rs @@ -50,7 +50,11 @@ impl GoalCommand for InMemoryStore { _user_id: &UserId, events: &[DomainEvent], ) -> Result<(), DomainError> { - self.goals.write().unwrap().remove(&id.value()); + self.goals + .write() + .unwrap() + .remove(&id.value()) + .ok_or_else(|| DomainError::NotFound("Goal not found".into()))?; self.record_events(events); Ok(()) } -- 2.49.1 From 6c295866a007e507ec3ca64dcc1822530f9ba8e6 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 01:02:34 +0100 Subject: [PATCH 062/104] feat: atomic wrapup, movie, integration writes Ride writes: WrapUpRequested/Completed (wrapup create/set_complete), MovieDiscovered (resolve upsert, new+ext id only), PosterSynced (poster handler upsert), MovieDeleted (delete_movie), WatchEventIngested (save). Deps drop event_publisher: wrapup, ingest, delete_review, watchlist add, review logger. Remaining .publish( sites: announce-only: users/delete_account UserDeleted, person/get + get_credits PersonEnrichmentRequested, jobs/enrichment_staleness, http-axum search, movies/sync_poster + poster_sync_handler + image-converter backfill + users/update_profile ImageStored (object storage), activitypub federation_event_bridge (reacting to events). rides write, deferred: social/execute (see report), activitypub review_handler MovieEnrichmentRequested (after save_remote_review). --- .../sqlite/src/repositories/movie/command.rs | 41 ++- .../src/repositories/watch_event/command.rs | 16 +- .../sqlite/src/repositories/wrapup/mod.rs | 42 ++- crates/adapters/sqlite/tests/cascade_test.rs | 56 ++-- .../tests/repositories/outbox_writes_test.rs | 297 +++++++++++++++++- .../repositories/review_repository_test.rs | 4 +- crates/application/src/diary/delete_review.rs | 21 +- crates/application/src/diary/deps.rs | 5 +- crates/application/src/diary/review_logger.rs | 11 +- crates/application/src/integrations/deps.rs | 3 +- crates/application/src/integrations/ingest.rs | 20 +- crates/application/src/jobs/wrapup.rs | 17 +- .../src/movies/poster_sync_handler.rs | 22 +- crates/application/src/movies/resolve.rs | 21 +- crates/application/src/movies/sync_poster.rs | 2 +- crates/application/src/watchlist/add.rs | 1 - crates/application/src/watchlist/deps.rs | 5 +- crates/application/src/wrapup/deps.rs | 4 +- .../application/src/wrapup/event_handler.rs | 4 +- crates/application/src/wrapup/generate.rs | 19 +- .../src/wrapup/handle_requested.rs | 12 +- .../tests/diary/delete_review_test.rs | 125 +++++++- .../tests/diary/get_movie_social_page_test.rs | 2 +- .../tests/diary/log_review_test.rs | 15 +- .../tests/diary/review_logger_test.rs | 45 +-- .../tests/import/apply_mapping_test.rs | 4 +- .../tests/integrations/confirm_test.rs | 22 +- .../tests/integrations/dismiss_test.rs | 4 +- .../tests/integrations/get_queue_test.rs | 2 +- .../tests/integrations/ingest_test.rs | 50 ++- crates/application/tests/movies.rs | 4 + .../tests/movies/enrich_movie_test.rs | 4 +- .../tests/movies/poster_sync_handler_test.rs | 89 ++++++ .../application/tests/movies/resolve_test.rs | 127 ++++++++ .../tests/movies/sync_poster_test.rs | 4 +- .../application/tests/watchlist/add_test.rs | 13 +- .../application/tests/wrapup/generate_test.rs | 56 ++-- .../tests/wrapup/get_ready_report_test.rs | 23 +- .../tests/wrapup/handle_requested_test.rs | 64 +++- crates/bootstrap/src/build.rs | 5 - crates/bootstrap/src/server.rs | 1 - crates/bootstrap/src/worker.rs | 2 - crates/domain/src/ports/media_server.rs | 3 +- crates/domain/src/ports/movie.rs | 9 +- crates/domain/src/ports/wrapup.rs | 14 +- crates/domain/src/testing/fakes/panics.rs | 10 +- crates/domain/src/testing/fakes/refusing.rs | 38 ++- .../domain/src/testing/fakes/store_movie.rs | 17 +- .../src/testing/fakes/store_watch_event.rs | 4 +- crates/domain/src/testing/fakes/wrapup.rs | 98 +++++- .../tests/testing/in_memory_store_test.rs | 133 +++++++- 51 files changed, 1281 insertions(+), 329 deletions(-) create mode 100644 crates/application/tests/movies/poster_sync_handler_test.rs create mode 100644 crates/application/tests/movies/resolve_test.rs diff --git a/crates/adapters/sqlite/src/repositories/movie/command.rs b/crates/adapters/sqlite/src/repositories/movie/command.rs index 79946f26..f417676d 100644 --- a/crates/adapters/sqlite/src/repositories/movie/command.rs +++ b/crates/adapters/sqlite/src/repositories/movie/command.rs @@ -1,11 +1,15 @@ use async_trait::async_trait; -use domain::{errors::DomainError, models::Movie, ports::MovieCommand, value_objects::MovieId}; +use domain::{ + errors::DomainError, events::DomainEvent, models::Movie, ports::MovieCommand, + value_objects::MovieId, +}; use super::SqliteMovieRepository; +use crate::shared::enqueue_events; #[async_trait] impl MovieCommand for SqliteMovieRepository { - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { + async fn upsert_movie(&self, movie: &Movie, events: &[DomainEvent]) -> Result<(), DomainError> { let id = movie.id().value().to_string(); let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string()); let title = movie.title().value(); @@ -13,6 +17,11 @@ impl MovieCommand for SqliteMovieRepository { let director = movie.director(); let poster_path = movie.poster_path().map(|p| p.value().to_string()); + let mut tx = self + .pool + .begin() + .await + .map_err(crate::shared::map_sqlx_error)?; sqlx::query( "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) VALUES (?, ?, ?, ?, ?, ?) @@ -29,20 +38,32 @@ impl MovieCommand for SqliteMovieRepository { .bind(release_year) .bind(director) .bind(&poster_path) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) + enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) } - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { + async fn delete_movie( + &self, + movie_id: &MovieId, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let id = movie_id.value().to_string(); - sqlx::query("DELETE FROM movies WHERE id = ?") - .bind(&id) - .execute(&self.pool) + let mut tx = self + .pool + .begin() .await .map_err(crate::shared::map_sqlx_error)?; - Ok(()) + let result = sqlx::query("DELETE FROM movies WHERE id = ?") + .bind(&id) + .execute(&mut *tx) + .await + .map_err(crate::shared::map_sqlx_error)?; + if result.rows_affected() > 0 { + enqueue_events(&mut tx, events).await?; + } + tx.commit().await.map_err(crate::shared::map_sqlx_error) } } diff --git a/crates/adapters/sqlite/src/repositories/watch_event/command.rs b/crates/adapters/sqlite/src/repositories/watch_event/command.rs index 4d20916d..a6c59f92 100644 --- a/crates/adapters/sqlite/src/repositories/watch_event/command.rs +++ b/crates/adapters/sqlite/src/repositories/watch_event/command.rs @@ -1,18 +1,19 @@ use async_trait::async_trait; use domain::{ errors::DomainError, + events::DomainEvent, models::{WatchEvent, WatchEventStatus}, ports::WatchEventCommand, value_objects::WatchEventId, }; -use crate::shared::datetime_to_str; +use crate::shared::{datetime_to_str, enqueue_events}; use super::SqliteWatchEventRepository; #[async_trait] impl WatchEventCommand for SqliteWatchEventRepository { - async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { + async fn save(&self, event: &WatchEvent, events: &[DomainEvent]) -> Result<(), DomainError> { let id = event.id().value().to_string(); let user_id = event.user_id().value().to_string(); let movie_id = event.movie_id().map(|m| m.value().to_string()); @@ -21,6 +22,11 @@ impl WatchEventCommand for SqliteWatchEventRepository { let status = event.status().to_string(); let created_at = datetime_to_str(event.created_at()); + let mut tx = self + .pool + .begin() + .await + .map_err(crate::shared::map_sqlx_error)?; sqlx::query( "INSERT INTO watch_events \ (id, user_id, movie_id, title, year, external_metadata_id, source, watched_at, status, created_at) \ @@ -36,11 +42,11 @@ impl WatchEventCommand for SqliteWatchEventRepository { .bind(&watched_at) .bind(&status) .bind(&created_at) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) + enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) } async fn update_status( diff --git a/crates/adapters/sqlite/src/repositories/wrapup/mod.rs b/crates/adapters/sqlite/src/repositories/wrapup/mod.rs index b694630e..4af56488 100644 --- a/crates/adapters/sqlite/src/repositories/wrapup/mod.rs +++ b/crates/adapters/sqlite/src/repositories/wrapup/mod.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use chrono::NaiveDate; use domain::{ errors::DomainError, + events::DomainEvent, models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpStatus}, ports::WrapUpRepository, value_objects::WrapUpId, @@ -9,6 +10,7 @@ use domain::{ use sqlx::SqlitePool; use uuid::Uuid; +use crate::shared::enqueue_events; use rows::{row_to_record, status_to_str}; mod query; @@ -30,7 +32,11 @@ impl SqliteWrapUpRepository { #[async_trait] impl WrapUpRepository for SqliteWrapUpRepository { - async fn create(&self, record: &WrapUpRecord) -> Result<(), DomainError> { + async fn create( + &self, + record: &WrapUpRecord, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let id = record.id.value().to_string(); let user_id = record.user_id.map(|u| u.to_string()); let status = status_to_str(&record.status); @@ -41,6 +47,11 @@ impl WrapUpRepository for SqliteWrapUpRepository { .completed_at .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()); + let mut tx = self + .pool + .begin() + .await + .map_err(crate::shared::map_sqlx_error)?; sqlx::query( "INSERT INTO wrap_up_records \ (id, user_id, start_date, end_date, status, report_json, error_message, created_at, completed_at) \ @@ -55,11 +66,11 @@ impl WrapUpRepository for SqliteWrapUpRepository { .bind(&record.error_message) .bind(&created) .bind(&completed) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) + enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) } async fn update_status( @@ -82,23 +93,36 @@ impl WrapUpRepository for SqliteWrapUpRepository { Ok(()) } - async fn set_complete(&self, id: &WrapUpId, report: &WrapUpReport) -> Result<(), DomainError> { + async fn set_complete( + &self, + id: &WrapUpId, + report: &WrapUpReport, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let id_str = id.value().to_string(); let json = serde_json::to_string(report) .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - sqlx::query( + let mut tx = self + .pool + .begin() + .await + .map_err(crate::shared::map_sqlx_error)?; + let result = sqlx::query( "UPDATE wrap_up_records \ SET status = 'ready', report_json = ?, completed_at = strftime('%Y-%m-%d %H:%M:%S', 'now') \ WHERE id = ?", ) .bind(&json) .bind(&id_str) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) + if result.rows_affected() == 0 { + return Err(DomainError::NotFound("wrapup record".into())); + } + enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) } async fn get_by_id(&self, id: &WrapUpId) -> Result, DomainError> { diff --git a/crates/adapters/sqlite/tests/cascade_test.rs b/crates/adapters/sqlite/tests/cascade_test.rs index 176ed7c6..eba3f58b 100644 --- a/crates/adapters/sqlite/tests/cascade_test.rs +++ b/crates/adapters/sqlite/tests/cascade_test.rs @@ -124,7 +124,7 @@ async fn seed_user_with_dependents( let user_id = user.id().clone(); let movie = test_movie("Heat"); parts.user.save(&user).await.unwrap(); - parts.movie_command.upsert_movie(&movie).await.unwrap(); + parts.movie_command.upsert_movie(&movie, &[]).await.unwrap(); parts .watchlist @@ -165,15 +165,18 @@ async fn seed_user_with_dependents( .await .unwrap(); SqliteWatchEventRepository::new(pool.clone()) - .save(&WatchEvent::new( - user_id.clone(), - "Heat".into(), - Some(1995), - None, - WatchEventSource::Jellyfin, - test_watched_at(), - None, - )) + .save( + &WatchEvent::new( + user_id.clone(), + "Heat".into(), + Some(1995), + None, + WatchEventSource::Jellyfin, + test_watched_at(), + None, + ), + &[], + ) .await .unwrap(); let social = SqliteSocialRepository::new( @@ -257,7 +260,7 @@ async fn deleting_a_movie_cascades_to_watchlist_entries_and_every_enrichment_tab let user = test_user("alice"); let movie = test_movie("Heat"); parts.user.save(&user).await.unwrap(); - parts.movie_command.upsert_movie(&movie).await.unwrap(); + parts.movie_command.upsert_movie(&movie, &[]).await.unwrap(); parts .watchlist .add( @@ -272,22 +275,29 @@ async fn deleting_a_movie_cascades_to_watchlist_entries_and_every_enrichment_tab .await .unwrap(); SqliteWatchEventRepository::new(pool.clone()) - .save(&WatchEvent::new( - user.id().clone(), - "Heat".into(), - Some(1995), - None, - WatchEventSource::Plex, - test_watched_at(), - Some(movie.id().clone()), - )) + .save( + &WatchEvent::new( + user.id().clone(), + "Heat".into(), + Some(1995), + None, + WatchEventSource::Plex, + test_watched_at(), + Some(movie.id().clone()), + ), + &[], + ) .await .unwrap(); for table in MOVIE_CASCADE_TABLES { assert!(count(&pool, table).await > 0, "{table} should be seeded"); } - parts.movie_command.delete_movie(movie.id()).await.unwrap(); + parts + .movie_command + .delete_movie(movie.id(), &[]) + .await + .unwrap(); for table in MOVIE_CASCADE_TABLES { assert_eq!(count(&pool, table).await, 0, "{table} should cascade"); @@ -306,14 +316,14 @@ async fn deleting_a_movie_that_has_reviews_is_refused_because_reviews_do_not_cas let user = test_user("alice"); let movie = test_movie("Heat"); parts.user.save(&user).await.unwrap(); - parts.movie_command.upsert_movie(&movie).await.unwrap(); + parts.movie_command.upsert_movie(&movie, &[]).await.unwrap(); parts .review .save_review(&test_review(movie.id(), user.id(), 5), &[]) .await .unwrap(); - let result = parts.movie_command.delete_movie(movie.id()).await; + let result = parts.movie_command.delete_movie(movie.id(), &[]).await; assert!(matches!( result, diff --git a/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs b/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs index bfdd1f59..ff145b0a 100644 --- a/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs +++ b/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs @@ -4,7 +4,8 @@ use domain::{ events::DomainEvent, models::{ProfileField, UserProfile, WatchlistEntry}, ports::{ - GoalCommand, MovieCommand, UserProfileFieldsRepository, UserRepository, WatchlistRepository, + GoalCommand, MovieCommand, UserProfileFieldsRepository, UserRepository, WatchEventCommand, + WatchlistRepository, WrapUpRepository, }, testing::{test_goal, test_movie, test_user}, value_objects::UserId, @@ -161,7 +162,7 @@ async fn watchlist_fixture() -> (SqlitePool, UserId, domain::models::Movie) { let (pool, user) = pool_with_user().await; let movie = test_movie("Heat"); SqliteMovieRepository::new(pool.clone()) - .upsert_movie(&movie) + .upsert_movie(&movie, &[]) .await .unwrap(); (pool, user.id().clone(), movie) @@ -419,3 +420,295 @@ async fn a_failing_enqueue_rolls_back_the_profile_fields_replacement() { "delete of the old fields rolled back" ); } + +fn movie_discovered(movie: &domain::models::Movie) -> DomainEvent { + DomainEvent::MovieDiscovered { + movie_id: movie.id().clone(), + external_metadata_id: domain::value_objects::ExternalMetadataId::new("tmdb:1".into()) + .unwrap(), + } +} + +fn movie_deleted(movie: &domain::models::Movie) -> DomainEvent { + DomainEvent::MovieDeleted { + movie_id: movie.id().clone(), + poster_path: None, + } +} + +#[tokio::test] +async fn upserting_a_movie_writes_the_movie_and_its_event_in_one_commit() { + let pool = migrated_pool().await; + let movie = test_movie("Heat"); + + SqliteMovieRepository::new(pool.clone()) + .upsert_movie(&movie, &[movie_discovered(&movie)]) + .await + .unwrap(); + + assert_eq!(count(&pool, "movies").await, 1); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_the_movie_upsert_of_a_new_movie() { + let pool = migrated_pool().await; + let movie = test_movie("Heat"); + fail_event_inserts(&pool).await; + + let result = SqliteMovieRepository::new(pool.clone()) + .upsert_movie(&movie, &[movie_discovered(&movie)]) + .await; + + assert!(result.is_err()); + assert_eq!(count(&pool, "movies").await, 0); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_a_movie_update() { + let pool = migrated_pool().await; + let repo = SqliteMovieRepository::new(pool.clone()); + let mut movie = test_movie("Heat"); + repo.upsert_movie(&movie, &[]).await.unwrap(); + movie.update_poster(domain::value_objects::PosterPath::new("posters/new.jpg".into()).unwrap()); + fail_event_inserts(&pool).await; + + let result = repo.upsert_movie(&movie, &[movie_discovered(&movie)]).await; + + assert!(result.is_err()); + let poster: Option = sqlx::query_scalar("SELECT poster_path FROM movies") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(poster, None, "poster update rolled back"); +} + +#[tokio::test] +async fn deleting_a_movie_writes_the_removal_and_its_event_in_one_commit() { + let pool = migrated_pool().await; + let repo = SqliteMovieRepository::new(pool.clone()); + let movie = test_movie("Heat"); + repo.upsert_movie(&movie, &[]).await.unwrap(); + + repo.delete_movie(movie.id(), &[movie_deleted(&movie)]) + .await + .unwrap(); + + assert_eq!(count(&pool, "movies").await, 0); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_the_movie_delete() { + let pool = migrated_pool().await; + let repo = SqliteMovieRepository::new(pool.clone()); + let movie = test_movie("Heat"); + repo.upsert_movie(&movie, &[]).await.unwrap(); + fail_event_inserts(&pool).await; + + let result = repo + .delete_movie(movie.id(), &[movie_deleted(&movie)]) + .await; + + assert!(result.is_err()); + assert_eq!(count(&pool, "movies").await, 1, "delete rolled back"); +} + +#[tokio::test] +async fn deleting_an_absent_movie_enqueues_no_event() { + let pool = migrated_pool().await; + let movie = test_movie("Heat"); + + SqliteMovieRepository::new(pool.clone()) + .delete_movie(movie.id(), &[movie_deleted(&movie)]) + .await + .unwrap(); + + assert_eq!(count(&pool, "event_queue").await, 0); +} + +fn wrapup_record(user_id: Option) -> domain::models::wrapup::WrapUpRecord { + use domain::models::wrapup::WrapUpStatus; + domain::models::wrapup::WrapUpRecord { + id: domain::value_objects::WrapUpId::generate(), + user_id, + start_date: chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + end_date: chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(), + status: WrapUpStatus::Pending, + report: None, + error_message: None, + created_at: chrono::Utc::now().naive_utc(), + completed_at: None, + } +} + +fn wrapup_requested(record: &domain::models::wrapup::WrapUpRecord) -> DomainEvent { + DomainEvent::WrapUpRequested { + wrapup_id: record.id.clone(), + user_id: None, + start_date: record.start_date, + end_date: record.end_date, + } +} + +fn wrapup_completed(record: &domain::models::wrapup::WrapUpRecord) -> DomainEvent { + DomainEvent::WrapUpCompleted { + wrapup_id: record.id.clone(), + } +} + +fn empty_report() -> domain::models::wrapup::WrapUpReport { + serde_json::from_value(serde_json::json!({ + "scope": "Global", + "date_range": { "start": "2024-01-01", "end": "2024-12-31" }, + "generated_at": "2025-01-01T00:00:00Z", + "total_movies": 0, + "total_watch_time_minutes": 0, + "movies_per_month": [], + "rating_distribution": [0, 0, 0, 0, 0], + "top_directors": [], + "top_actors": [], + "director_diversity": 0, + "actor_diversity": 0, + "top_genres": [], + "genre_diversity": 0, + "top_keywords": [], + "language_distribution": [], + "watch_medium_distribution": [], + "total_rewatches": 0, + "poster_paths": [], + "top_cast_profile_paths": [], + })) + .expect("minimal wrap-up report") +} + +#[tokio::test] +async fn creating_a_wrapup_writes_the_record_and_its_event_in_one_commit() { + let pool = migrated_pool().await; + let record = wrapup_record(None); + + sqlite::SqliteWrapUpRepository::new(pool.clone()) + .create(&record, &[wrapup_requested(&record)]) + .await + .unwrap(); + + assert_eq!(count(&pool, "wrap_up_records").await, 1); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_the_wrapup_create() { + let pool = migrated_pool().await; + let record = wrapup_record(None); + fail_event_inserts(&pool).await; + + let result = sqlite::SqliteWrapUpRepository::new(pool.clone()) + .create(&record, &[wrapup_requested(&record)]) + .await; + + assert!(result.is_err()); + assert_eq!(count(&pool, "wrap_up_records").await, 0); +} + +#[tokio::test] +async fn completing_a_wrapup_writes_the_report_and_its_event_in_one_commit() { + let pool = migrated_pool().await; + let repo = sqlite::SqliteWrapUpRepository::new(pool.clone()); + let record = wrapup_record(None); + repo.create(&record, &[]).await.unwrap(); + + repo.set_complete(&record.id, &empty_report(), &[wrapup_completed(&record)]) + .await + .unwrap(); + + let status: String = sqlx::query_scalar("SELECT status FROM wrap_up_records") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(status, "ready"); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_the_wrapup_completion() { + let pool = migrated_pool().await; + let repo = sqlite::SqliteWrapUpRepository::new(pool.clone()); + let record = wrapup_record(None); + repo.create(&record, &[]).await.unwrap(); + fail_event_inserts(&pool).await; + + let result = repo + .set_complete(&record.id, &empty_report(), &[wrapup_completed(&record)]) + .await; + + assert!(result.is_err()); + let status: String = sqlx::query_scalar("SELECT status FROM wrap_up_records") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(status, "pending", "completion rolled back"); +} + +#[tokio::test] +async fn completing_a_missing_wrapup_is_not_found_and_enqueues_no_event() { + let pool = migrated_pool().await; + let record = wrapup_record(None); + + let result = sqlite::SqliteWrapUpRepository::new(pool.clone()) + .set_complete(&record.id, &empty_report(), &[wrapup_completed(&record)]) + .await; + + assert!(matches!( + result, + Err(domain::errors::DomainError::NotFound(_)) + )); + assert_eq!(count(&pool, "event_queue").await, 0); +} + +fn watch_event_for(user_id: &UserId) -> domain::models::WatchEvent { + domain::models::WatchEvent::new( + user_id.clone(), + "Heat".into(), + Some(1995), + None, + domain::models::WatchEventSource::Jellyfin, + chrono::Utc::now().naive_utc(), + None, + ) +} + +fn watch_event_ingested(user_id: &UserId) -> DomainEvent { + DomainEvent::WatchEventIngested { + user_id: user_id.clone(), + title: "Heat".into(), + source: "jellyfin".into(), + } +} + +#[tokio::test] +async fn saving_a_watch_event_writes_the_row_and_its_event_in_one_commit() { + let (pool, user) = pool_with_user().await; + let event = watch_event_for(user.id()); + + sqlite::SqliteWatchEventRepository::new(pool.clone()) + .save(&event, &[watch_event_ingested(user.id())]) + .await + .unwrap(); + + assert_eq!(count(&pool, "watch_events").await, 1); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_the_watch_event_save() { + let (pool, user) = pool_with_user().await; + let event = watch_event_for(user.id()); + fail_event_inserts(&pool).await; + + let result = sqlite::SqliteWatchEventRepository::new(pool.clone()) + .save(&event, &[watch_event_ingested(user.id())]) + .await; + + assert!(result.is_err()); + assert_eq!(count(&pool, "watch_events").await, 0); +} diff --git a/crates/adapters/sqlite/tests/repositories/review_repository_test.rs b/crates/adapters/sqlite/tests/repositories/review_repository_test.rs index 557899e6..b8be38ee 100644 --- a/crates/adapters/sqlite/tests/repositories/review_repository_test.rs +++ b/crates/adapters/sqlite/tests/repositories/review_repository_test.rs @@ -28,7 +28,7 @@ async fn saving_a_review_writes_the_review_and_its_event_in_one_commit() { .await .unwrap(); SqliteMovieRepository::new(pool.clone()) - .upsert_movie(&movie) + .upsert_movie(&movie, &[]) .await .unwrap(); let review = test_review(movie.id(), user.id(), 4); @@ -79,7 +79,7 @@ async fn seeded() -> (SqlitePool, domain::models::Review) { .await .unwrap(); SqliteMovieRepository::new(pool.clone()) - .upsert_movie(&movie) + .upsert_movie(&movie, &[]) .await .unwrap(); let review = test_review(movie.id(), user.id(), 4); diff --git a/crates/application/src/diary/delete_review.rs b/crates/application/src/diary/delete_review.rs index 3a67b26d..f3840a1a 100644 --- a/crates/application/src/diary/delete_review.rs +++ b/crates/application/src/diary/delete_review.rs @@ -33,18 +33,15 @@ pub async fn execute(deps: &DeleteReviewDeps, cmd: DeleteReviewCommand) -> Resul let history = deps.diary.get_review_history(&movie_id).await?; if history.viewings().is_empty() { let poster_path = history.movie().poster_path().cloned(); - deps.movie_command.delete_movie(&movie_id).await?; - // best-effort: movie is already deleted, so publish failure is non-fatal - if let Err(e) = deps - .event_publisher - .publish(&DomainEvent::MovieDeleted { - movie_id, - poster_path, - }) - .await - { - tracing::warn!("failed to publish MovieDeleted event: {e}"); - } + deps.movie_command + .delete_movie( + &movie_id, + &[DomainEvent::MovieDeleted { + movie_id: movie_id.clone(), + poster_path, + }], + ) + .await?; } Ok(()) diff --git a/crates/application/src/diary/deps.rs b/crates/application/src/diary/deps.rs index bbd56789..d9613fbc 100644 --- a/crates/application/src/diary/deps.rs +++ b/crates/application/src/diary/deps.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use domain::ports::{ - DiaryExporter, DiaryQuery, EventPublisher, FollowGraphQuery, MovieCommand, - MovieProfileRepository, MovieQuery, ReviewRepository, UserRepository, + DiaryExporter, DiaryQuery, FollowGraphQuery, MovieCommand, MovieProfileRepository, MovieQuery, + ReviewRepository, UserRepository, }; use crate::config::AppSettings; @@ -11,7 +11,6 @@ pub struct DeleteReviewDeps { pub review: Arc, pub diary: Arc, pub movie_command: Arc, - pub event_publisher: Arc, } pub struct EditReviewDeps { diff --git a/crates/application/src/diary/review_logger.rs b/crates/application/src/diary/review_logger.rs index 9a31fa07..7d95fd3c 100644 --- a/crates/application/src/diary/review_logger.rs +++ b/crates/application/src/diary/review_logger.rs @@ -5,10 +5,7 @@ use domain::{ errors::DomainError, events::DomainEvent, models::Review, - ports::{ - EventPublisher, MetadataClient, MovieCommand, MovieQuery, ReviewRepository, - WatchlistRepository, - }, + ports::{MetadataClient, MovieCommand, MovieQuery, ReviewRepository, WatchlistRepository}, value_objects::{Comment, Rating, UserId}, }; @@ -22,7 +19,6 @@ pub struct DefaultReviewLogger { review_repo: Arc, watchlist_repo: Arc, metadata_client: Arc, - event_publisher: Arc, } impl DefaultReviewLogger { @@ -32,7 +28,6 @@ impl DefaultReviewLogger { review_repo: Arc, watchlist_repo: Arc, metadata_client: Arc, - event_publisher: Arc, ) -> Self { Self { movie_command, @@ -40,7 +35,6 @@ impl DefaultReviewLogger { review_repo, watchlist_repo, metadata_client, - event_publisher, } } } @@ -57,13 +51,12 @@ impl ReviewLogger for DefaultReviewLogger { self.movie_command.as_ref(), self.movie_query.as_ref(), self.metadata_client.as_ref(), - self.event_publisher.as_ref(), ) .await?; // Always upsert: even existing movies may have updated metadata if !is_new_movie { - self.movie_command.upsert_movie(&movie).await?; + self.movie_command.upsert_movie(&movie, &[]).await?; } let review = Review::new( diff --git a/crates/application/src/integrations/deps.rs b/crates/application/src/integrations/deps.rs index d7cb1bbe..7a3af90e 100644 --- a/crates/application/src/integrations/deps.rs +++ b/crates/application/src/integrations/deps.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use domain::ports::{EventPublisher, WatchEventCommand, WatchEventQuery, WebhookTokenRepository}; +use domain::ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository}; use crate::ports::ReviewLogger; @@ -8,7 +8,6 @@ pub struct IngestWatchEventDeps { pub webhook_token: Arc, pub watch_event_command: Arc, pub watch_event_query: Arc, - pub event_publisher: Arc, } pub struct ConfirmWatchEventsDeps { diff --git a/crates/application/src/integrations/ingest.rs b/crates/application/src/integrations/ingest.rs index d7fe4da7..7fc3755a 100644 --- a/crates/application/src/integrations/ingest.rs +++ b/crates/application/src/integrations/ingest.rs @@ -49,16 +49,16 @@ pub async fn execute( None, ); - deps.watch_event_command.save(&event).await?; - - let _ = deps - .event_publisher - .publish(&DomainEvent::WatchEventIngested { - user_id: event.user_id().clone(), - title: event.title().to_string(), - source: event.source().to_string(), - }) - .await; + deps.watch_event_command + .save( + &event, + &[DomainEvent::WatchEventIngested { + user_id: event.user_id().clone(), + title: event.title().to_string(), + source: event.source().to_string(), + }], + ) + .await?; Ok(()) } diff --git a/crates/application/src/jobs/wrapup.rs b/crates/application/src/jobs/wrapup.rs index 2a2f4040..c5231347 100644 --- a/crates/application/src/jobs/wrapup.rs +++ b/crates/application/src/jobs/wrapup.rs @@ -5,26 +5,17 @@ use async_trait::async_trait; use chrono::Datelike; use domain::{ errors::DomainError, - ports::{EventPublisher, PeriodicJob, UserRepository, WrapUpRepository}, + ports::{PeriodicJob, UserRepository, WrapUpRepository}, }; pub struct WrapUpAutoGenerateJob { user: Arc, wrapup_repo: Arc, - event_publisher: Arc, } impl WrapUpAutoGenerateJob { - pub fn new( - user: Arc, - wrapup_repo: Arc, - event_publisher: Arc, - ) -> Self { - Self { - user, - wrapup_repo, - event_publisher, - } + pub fn new(user: Arc, wrapup_repo: Arc) -> Self { + Self { user, wrapup_repo } } } @@ -60,7 +51,6 @@ impl PeriodicJob for WrapUpAutoGenerateJob { }; let deps = crate::wrapup::deps::GenerateWrapUpDeps { wrapup_repo: self.wrapup_repo.clone(), - event_publisher: self.event_publisher.clone(), }; if let Err(e) = crate::wrapup::generate::execute(&deps, cmd).await { tracing::warn!( @@ -81,7 +71,6 @@ impl PeriodicJob for WrapUpAutoGenerateJob { }; let deps = crate::wrapup::deps::GenerateWrapUpDeps { wrapup_repo: self.wrapup_repo.clone(), - event_publisher: self.event_publisher.clone(), }; if let Err(e) = crate::wrapup::generate::execute(&deps, cmd).await { tracing::warn!("auto-generate global wrapup failed: {e}"); diff --git a/crates/application/src/movies/poster_sync_handler.rs b/crates/application/src/movies/poster_sync_handler.rs index 874f6855..faa99518 100644 --- a/crates/application/src/movies/poster_sync_handler.rs +++ b/crates/application/src/movies/poster_sync_handler.rs @@ -85,20 +85,14 @@ impl PosterSyncHandler { let poster_path = PosterPath::new(stored_path)?; movie.update_poster(poster_path); - self.movie_command.upsert_movie(&movie).await?; - - if let Err(e) = self - .event_publisher - .publish(&DomainEvent::PosterSynced { - movie_id: movie.id().clone(), - }) - .await - { - tracing::warn!( - "failed to emit PosterSynced for {}: {e}", - movie.id().value() - ); - } + self.movie_command + .upsert_movie( + &movie, + &[DomainEvent::PosterSynced { + movie_id: movie.id().clone(), + }], + ) + .await?; Ok(()) } diff --git a/crates/application/src/movies/resolve.rs b/crates/application/src/movies/resolve.rs index 978c1e09..0f188c51 100644 --- a/crates/application/src/movies/resolve.rs +++ b/crates/application/src/movies/resolve.rs @@ -2,7 +2,7 @@ use domain::{ errors::DomainError, events::DomainEvent, models::Movie, - ports::{EventPublisher, MetadataClient, MovieCommand, MovieQuery}, + ports::{MetadataClient, MovieCommand, MovieQuery}, value_objects::MovieId, }; @@ -14,7 +14,6 @@ pub async fn resolve_and_persist_movie( movie_command: &dyn MovieCommand, movie_query: &dyn MovieQuery, metadata_client: &dyn MetadataClient, - event_publisher: &dyn EventPublisher, ) -> Result<(Movie, bool), DomainError> { let (movie, is_new) = if let Some(id) = input.movie_id { let movie_id = MovieId::from_uuid(id); @@ -34,15 +33,15 @@ pub async fn resolve_and_persist_movie( }; if is_new { - movie_command.upsert_movie(&movie).await?; - if let Some(ext_id) = movie.external_metadata_id() { - let _ = event_publisher - .publish(&DomainEvent::MovieDiscovered { - movie_id: movie.id().clone(), - external_metadata_id: ext_id.clone(), - }) - .await; - } + let events: Vec = movie + .external_metadata_id() + .map(|ext_id| DomainEvent::MovieDiscovered { + movie_id: movie.id().clone(), + external_metadata_id: ext_id.clone(), + }) + .into_iter() + .collect(); + movie_command.upsert_movie(&movie, &events).await?; } Ok((movie, is_new)) diff --git a/crates/application/src/movies/sync_poster.rs b/crates/application/src/movies/sync_poster.rs index a778f513..4d50e64e 100644 --- a/crates/application/src/movies/sync_poster.rs +++ b/crates/application/src/movies/sync_poster.rs @@ -59,7 +59,7 @@ pub async fn execute(deps: &SyncPosterDeps, cmd: SyncPosterCommand) -> Result<() let poster_path = PosterPath::new(stored_path)?; movie.update_poster(poster_path); - deps.movie_command.upsert_movie(&movie).await?; + deps.movie_command.upsert_movie(&movie, &[]).await?; // Refresh search index so the new poster_path is reflected immediately. // Fetch existing profile if available for a complete index document. diff --git a/crates/application/src/watchlist/add.rs b/crates/application/src/watchlist/add.rs index c85a714e..8d0d7432 100644 --- a/crates/application/src/watchlist/add.rs +++ b/crates/application/src/watchlist/add.rs @@ -18,7 +18,6 @@ pub async fn execute( deps.movie_command.as_ref(), deps.movie_query.as_ref(), deps.metadata.as_ref(), - deps.event_publisher.as_ref(), ) .await?; diff --git a/crates/application/src/watchlist/deps.rs b/crates/application/src/watchlist/deps.rs index e6154553..5d1fe253 100644 --- a/crates/application/src/watchlist/deps.rs +++ b/crates/application/src/watchlist/deps.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use domain::ports::{ - EventPublisher, MetadataClient, MovieCommand, MovieQuery, RemoteWatchlistRepository, - UserRepository, WatchlistRepository, + MetadataClient, MovieCommand, MovieQuery, RemoteWatchlistRepository, UserRepository, + WatchlistRepository, }; pub struct WatchlistAddDeps { @@ -10,7 +10,6 @@ pub struct WatchlistAddDeps { pub movie_query: Arc, pub metadata: Arc, pub watchlist: Arc, - pub event_publisher: Arc, } pub struct GetWatchlistForOwnerDeps { diff --git a/crates/application/src/wrapup/deps.rs b/crates/application/src/wrapup/deps.rs index 74f9bb99..cc8f7bf5 100644 --- a/crates/application/src/wrapup/deps.rs +++ b/crates/application/src/wrapup/deps.rs @@ -1,10 +1,9 @@ use std::sync::Arc; -use domain::ports::{EventPublisher, WrapUpRepository, WrapUpStatsQuery}; +use domain::ports::{WrapUpRepository, WrapUpStatsQuery}; pub struct HandleWrapUpRequestedDeps { pub wrapup_repo: Arc, - pub event_publisher: Arc, pub wrapup_stats: Arc, } @@ -18,7 +17,6 @@ pub struct DeleteWrapUpDeps { pub struct GenerateWrapUpDeps { pub wrapup_repo: Arc, - pub event_publisher: Arc, } pub struct GetWrapUpDeps { diff --git a/crates/application/src/wrapup/event_handler.rs b/crates/application/src/wrapup/event_handler.rs index 1d5de35d..1615719a 100644 --- a/crates/application/src/wrapup/event_handler.rs +++ b/crates/application/src/wrapup/event_handler.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use async_trait::async_trait; use domain::errors::DomainError; use domain::events::DomainEvent; -use domain::ports::{EventHandler, EventPublisher, WrapUpRepository, WrapUpStatsQuery}; +use domain::ports::{EventHandler, WrapUpRepository, WrapUpStatsQuery}; use tokio::sync::Semaphore; use super::deps::HandleWrapUpRequestedDeps; @@ -16,13 +16,11 @@ pub struct WrapUpEventHandler { impl WrapUpEventHandler { pub fn new( wrapup_repo: Arc, - event_publisher: Arc, wrapup_stats: Arc, ) -> Self { Self { deps: HandleWrapUpRequestedDeps { wrapup_repo, - event_publisher, wrapup_stats, }, semaphore: Arc::new(Semaphore::new(2)), diff --git a/crates/application/src/wrapup/generate.rs b/crates/application/src/wrapup/generate.rs index f560baf6..2f136bc9 100644 --- a/crates/application/src/wrapup/generate.rs +++ b/crates/application/src/wrapup/generate.rs @@ -46,15 +46,16 @@ pub async fn execute( created_at: Utc::now().naive_utc(), completed_at: None, }; - deps.wrapup_repo.create(&record).await?; - - deps.event_publisher - .publish(&DomainEvent::WrapUpRequested { - wrapup_id: id.clone(), - user_id: cmd.user_id.map(UserId::from_uuid), - start_date: date_range.start(), - end_date: date_range.end(), - }) + deps.wrapup_repo + .create( + &record, + &[DomainEvent::WrapUpRequested { + wrapup_id: id.clone(), + user_id: cmd.user_id.map(UserId::from_uuid), + start_date: date_range.start(), + end_date: date_range.end(), + }], + ) .await?; Ok(id) diff --git a/crates/application/src/wrapup/handle_requested.rs b/crates/application/src/wrapup/handle_requested.rs index 70a93bfb..c696ec15 100644 --- a/crates/application/src/wrapup/handle_requested.rs +++ b/crates/application/src/wrapup/handle_requested.rs @@ -37,10 +37,14 @@ pub async fn execute( match compute::execute(deps.wrapup_stats.clone(), query).await { Ok(report) => { - deps.wrapup_repo.set_complete(&wrapup_id, &report).await?; - - deps.event_publisher - .publish(&DomainEvent::WrapUpCompleted { wrapup_id }) + deps.wrapup_repo + .set_complete( + &wrapup_id, + &report, + &[DomainEvent::WrapUpCompleted { + wrapup_id: wrapup_id.clone(), + }], + ) .await?; Ok(()) } diff --git a/crates/application/tests/diary/delete_review_test.rs b/crates/application/tests/diary/delete_review_test.rs index 0230199a..67ba9e45 100644 --- a/crates/application/tests/diary/delete_review_test.rs +++ b/crates/application/tests/diary/delete_review_test.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::{ ports::{MovieCommand, MovieQuery, ReviewRepository}, - testing::{FakeDiaryQuery, InMemoryStore, NoopEventPublisher}, + testing::{FakeDiaryQuery, InMemoryStore, RefusingMovieCommand}, value_objects::{MovieId, UserId}, }; @@ -16,13 +16,12 @@ async fn deleting_a_review_removes_it() { let movies = InMemoryStore::new(); let reviews = Arc::clone(&movies); let diary = FakeDiaryQuery::new(); - let events = NoopEventPublisher::new(); let movie = test_movie_with_year("Terminator", 1984); let user_id = UserId::from_uuid(uuid::Uuid::new_v4()); let review = test_review(movie.id(), &user_id, 4); - movies.upsert_movie(&movie).await.unwrap(); + movies.upsert_movie(&movie, &[]).await.unwrap(); reviews.save_review(&review, &[]).await.unwrap(); diary.seed_history(movie.clone(), vec![]); @@ -30,7 +29,6 @@ async fn deleting_a_review_removes_it() { review: Arc::clone(&reviews) as _, diary: diary.clone() as _, movie_command: Arc::clone(&movies) as _, - event_publisher: Arc::clone(&events) as _, }; delete_review::execute( @@ -55,7 +53,6 @@ async fn deleting_another_users_review_is_unauthorized() { let reviews = InMemoryStore::new(); let diary = FakeDiaryQuery::new(); let movies = Arc::clone(&reviews); - let events = NoopEventPublisher::new(); let movie_id = MovieId::from_uuid(uuid::Uuid::new_v4()); let owner_id = UserId::from_uuid(uuid::Uuid::new_v4()); @@ -68,7 +65,6 @@ async fn deleting_another_users_review_is_unauthorized() { review: Arc::clone(&reviews) as _, diary: diary as _, movie_command: movies as _, - event_publisher: Arc::clone(&events) as _, }; let result = delete_review::execute( @@ -88,18 +84,16 @@ async fn deleting_another_users_review_is_unauthorized() { async fn deleting_a_review_removes_it_and_records_its_event_together() { let movies = InMemoryStore::new(); let diary = FakeDiaryQuery::new(); - let events = NoopEventPublisher::new(); let movie = test_movie_with_year("Terminator", 1984); let user_id = UserId::from_uuid(uuid::Uuid::new_v4()); let review = test_review(movie.id(), &user_id, 4); - movies.upsert_movie(&movie).await.unwrap(); + movies.upsert_movie(&movie, &[]).await.unwrap(); movies.save_review(&review, &[]).await.unwrap(); diary.seed_history(movie.clone(), vec![review.clone()]); let deps = DeleteReviewDeps { review: Arc::clone(&movies) as _, diary: diary.clone() as _, movie_command: Arc::clone(&movies) as _, - event_publisher: Arc::clone(&events) as _, }; delete_review::execute( @@ -125,7 +119,6 @@ async fn deleting_a_review_removes_it_and_records_its_event_together() { async fn a_refused_delete_propagates_and_records_nothing() { let movies = InMemoryStore::new(); let diary = FakeDiaryQuery::new(); - let events = NoopEventPublisher::new(); let user_id = UserId::from_uuid(uuid::Uuid::new_v4()); let review = test_review(&MovieId::generate(), &user_id, 4); movies.save_review(&review, &[]).await.unwrap(); @@ -135,7 +128,6 @@ async fn a_refused_delete_propagates_and_records_nothing() { ))), diary: diary as _, movie_command: Arc::clone(&movies) as _, - event_publisher: Arc::clone(&events) as _, }; let result = delete_review::execute( @@ -151,3 +143,114 @@ async fn a_refused_delete_propagates_and_records_nothing() { assert_eq!(movies.review_count(), 1); assert!(movies.recorded_events().is_empty()); } + +#[tokio::test] +async fn deleting_the_last_review_records_movie_deleted_with_the_movie_delete() { + let movies = InMemoryStore::new(); + let diary = FakeDiaryQuery::new(); + let movie = test_movie_with_year("Terminator", 1984); + let user_id = UserId::from_uuid(uuid::Uuid::new_v4()); + let review = test_review(movie.id(), &user_id, 4); + movies.upsert_movie(&movie, &[]).await.unwrap(); + movies.save_review(&review, &[]).await.unwrap(); + diary.seed_history(movie.clone(), vec![]); + let deps = DeleteReviewDeps { + review: Arc::clone(&movies) as _, + diary: diary as _, + movie_command: Arc::clone(&movies) as _, + }; + + delete_review::execute( + &deps, + DeleteReviewCommand { + review_id: review.id().value(), + requesting_user_id: user_id.value(), + }, + ) + .await + .unwrap(); + + assert!(movies.get_movie_by_id(movie.id()).await.unwrap().is_none()); + let recorded = movies.recorded_events(); + assert!(matches!( + recorded[0], + domain::events::DomainEvent::ReviewDeleted { .. } + )); + assert!(matches!( + &recorded[1], + domain::events::DomainEvent::MovieDeleted { movie_id, .. } if movie_id == movie.id() + )); + assert_eq!(recorded.len(), 2); +} + +#[tokio::test] +async fn deleting_a_review_while_viewings_remain_keeps_the_movie_and_records_no_movie_deleted() { + let movies = InMemoryStore::new(); + let diary = FakeDiaryQuery::new(); + let movie = test_movie_with_year("Terminator", 1984); + let user_id = UserId::from_uuid(uuid::Uuid::new_v4()); + let review = test_review(movie.id(), &user_id, 4); + let other = test_review(movie.id(), &UserId::from_uuid(uuid::Uuid::new_v4()), 3); + movies.upsert_movie(&movie, &[]).await.unwrap(); + movies.save_review(&review, &[]).await.unwrap(); + movies.save_review(&other, &[]).await.unwrap(); + diary.seed_history(movie.clone(), vec![other.clone()]); + let deps = DeleteReviewDeps { + review: Arc::clone(&movies) as _, + diary: diary as _, + movie_command: Arc::clone(&movies) as _, + }; + + delete_review::execute( + &deps, + DeleteReviewCommand { + review_id: review.id().value(), + requesting_user_id: user_id.value(), + }, + ) + .await + .unwrap(); + + assert!(movies.get_movie_by_id(movie.id()).await.unwrap().is_some()); + assert!( + !movies + .recorded_events() + .iter() + .any(|e| matches!(e, domain::events::DomainEvent::MovieDeleted { .. })) + ); +} + +#[tokio::test] +async fn a_refused_movie_delete_propagates_and_records_no_movie_deleted() { + let movies = InMemoryStore::new(); + let diary = FakeDiaryQuery::new(); + let movie = test_movie_with_year("Terminator", 1984); + let user_id = UserId::from_uuid(uuid::Uuid::new_v4()); + let review = test_review(movie.id(), &user_id, 4); + movies.upsert_movie(&movie, &[]).await.unwrap(); + movies.save_review(&review, &[]).await.unwrap(); + diary.seed_history(movie.clone(), vec![]); + let deps = DeleteReviewDeps { + review: Arc::clone(&movies) as _, + diary: diary as _, + movie_command: Arc::new(RefusingMovieCommand), + }; + + let result = delete_review::execute( + &deps, + DeleteReviewCommand { + review_id: review.id().value(), + requesting_user_id: user_id.value(), + }, + ) + .await; + + assert!(result.is_err()); + assert!(movies.get_movie_by_id(movie.id()).await.unwrap().is_some()); + assert!( + !movies + .recorded_events() + .iter() + .any(|e| matches!(e, domain::events::DomainEvent::MovieDeleted { .. })) + ); +} diff --git a/crates/application/tests/diary/get_movie_social_page_test.rs b/crates/application/tests/diary/get_movie_social_page_test.rs index 60f91880..0742c0e0 100644 --- a/crates/application/tests/diary/get_movie_social_page_test.rs +++ b/crates/application/tests/diary/get_movie_social_page_test.rs @@ -40,7 +40,7 @@ async fn returns_movie_social_page() { let movie = test_movie_with_year("Social Movie", 2024); let movie_uuid = movie.id().value(); - movies.upsert_movie(&movie).await.unwrap(); + movies.upsert_movie(&movie, &[]).await.unwrap(); let deps = GetMovieSocialPageDeps { movie_query: Arc::clone(&movies) as _, diff --git a/crates/application/tests/diary/log_review_test.rs b/crates/application/tests/diary/log_review_test.rs index ab0ca4c1..95b2b364 100644 --- a/crates/application/tests/diary/log_review_test.rs +++ b/crates/application/tests/diary/log_review_test.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use domain::ports::MovieCommand; -use domain::testing::{InMemoryStore, NoopEventPublisher}; +use domain::testing::InMemoryStore; use application::{ diary::commands::LogReviewCommand, diary::log_review, diary::review_logger::DefaultReviewLogger, @@ -14,7 +14,6 @@ use application::{ fn build_logger( movies: &Arc, reviews: &Arc, - events: &Arc, ) -> Arc { Arc::new(DefaultReviewLogger::new( Arc::clone(movies) as _, @@ -22,7 +21,6 @@ fn build_logger( Arc::clone(reviews) as _, TestContextBuilder::new().watchlist_repo, Arc::new(domain::testing::FakeMetadataClient) as _, - Arc::clone(events) as _, )) } @@ -30,8 +28,7 @@ fn build_logger( async fn logging_a_review_creates_the_movie_and_the_review() { let movies = InMemoryStore::new(); let reviews = Arc::clone(&movies); - let events = NoopEventPublisher::new(); - let logger = build_logger(&movies, &reviews, &events); + let logger = build_logger(&movies, &reviews); let user_id = uuid::Uuid::new_v4(); let cmd = log_review_command_manual(user_id, "Blade Runner", 1982); @@ -52,10 +49,9 @@ async fn logging_a_review_reuses_an_existing_movie() { let existing_movie = test_movie_with_year("Alien", 1979); let movie_uuid = existing_movie.id().value(); - movies.upsert_movie(&existing_movie).await.unwrap(); + movies.upsert_movie(&existing_movie, &[]).await.unwrap(); - let events = NoopEventPublisher::new(); - let logger = build_logger(&movies, &reviews, &events); + let logger = build_logger(&movies, &reviews); let cmd = LogReviewCommand { rating: 5, @@ -72,8 +68,7 @@ async fn logging_a_review_reuses_an_existing_movie() { async fn logging_a_review_with_an_invalid_rating_fails() { let movies = InMemoryStore::new(); let reviews = Arc::clone(&movies); - let events = NoopEventPublisher::new(); - let logger = build_logger(&movies, &reviews, &events); + let logger = build_logger(&movies, &reviews); let cmd = LogReviewCommand { rating: 6, diff --git a/crates/application/tests/diary/review_logger_test.rs b/crates/application/tests/diary/review_logger_test.rs index 28021ce7..b4e4ebdf 100644 --- a/crates/application/tests/diary/review_logger_test.rs +++ b/crates/application/tests/diary/review_logger_test.rs @@ -8,7 +8,7 @@ use domain::{ models::WatchlistEntry, models::{MetadataSearchCriteria, Movie}, ports::{MetadataClient, MovieCommand, WatchlistRepository}, - testing::{FakeMetadataClient, InMemoryStore, NoopEventPublisher}, + testing::{FakeMetadataClient, InMemoryStore}, value_objects::{ExternalMetadataId, MovieId, PosterUrl, UserId}, }; use uuid::Uuid; @@ -21,7 +21,6 @@ fn make_logger( movies: &Arc, reviews: &Arc, watchlist: &Arc, - events: &Arc, ) -> DefaultReviewLogger { DefaultReviewLogger::new( Arc::clone(movies) as _, @@ -29,7 +28,6 @@ fn make_logger( Arc::clone(reviews) as _, Arc::clone(watchlist) as _, Arc::new(FakeMetadataClient) as _, - Arc::clone(events) as _, ) } @@ -38,8 +36,7 @@ async fn logs_review_with_manual_movie() { let movies = InMemoryStore::new(); let reviews = Arc::clone(&movies); let watchlist = Arc::clone(&movies); - let events = NoopEventPublisher::new(); - let logger = make_logger(&movies, &reviews, &watchlist, &events); + let logger = make_logger(&movies, &reviews, &watchlist); let uid = Uuid::new_v4(); let cmd = log_review_command_manual(uid, "Test Film", 2024); @@ -55,8 +52,7 @@ async fn removes_from_watchlist_on_review() { let movies = InMemoryStore::new(); let reviews = Arc::clone(&movies); let watchlist = Arc::clone(&movies); - let events = NoopEventPublisher::new(); - let logger = make_logger(&movies, &reviews, &watchlist, &events); + let logger = make_logger(&movies, &reviews, &watchlist); let uid = Uuid::new_v4(); let user_id = UserId::from_uuid(uid); @@ -64,7 +60,7 @@ async fn removes_from_watchlist_on_review() { // Create and store movie let movie = test_movie_with_year("Watchlisted Film", 2024); let movie_id = movie.id().value(); - movies.upsert_movie(&movie).await.unwrap(); + movies.upsert_movie(&movie, &[]).await.unwrap(); // Add to watchlist let entry = WatchlistEntry::new(user_id.clone(), MovieId::from_uuid(movie_id)); @@ -87,13 +83,6 @@ async fn removes_from_watchlist_on_review() { .any(|e| matches!(e, domain::events::DomainEvent::WatchlistEntryRemoved { .. })), "the removal event rides the watchlist write" ); - assert!( - !events - .published() - .iter() - .any(|e| matches!(e, domain::events::DomainEvent::WatchlistEntryRemoved { .. })), - "no longer published out of band" - ); } #[tokio::test] @@ -101,12 +90,11 @@ async fn logs_review_with_existing_movie_by_id() { let movies = InMemoryStore::new(); let reviews = Arc::clone(&movies); let watchlist = Arc::clone(&movies); - let events = NoopEventPublisher::new(); - let logger = make_logger(&movies, &reviews, &watchlist, &events); + let logger = make_logger(&movies, &reviews, &watchlist); let movie = test_movie_with_year("Existing Film", 2020); let movie_uuid = movie.id().value(); - movies.upsert_movie(&movie).await.unwrap(); + movies.upsert_movie(&movie, &[]).await.unwrap(); let cmd = LogReviewCommand { rating: 3, @@ -124,8 +112,7 @@ async fn existing_movie_not_found_returns_error() { let movies = InMemoryStore::new(); let reviews = Arc::clone(&movies); let watchlist = Arc::clone(&movies); - let events = NoopEventPublisher::new(); - let logger = make_logger(&movies, &reviews, &watchlist, &events); + let logger = make_logger(&movies, &reviews, &watchlist); let cmd = log_review_command(Uuid::new_v4(), Uuid::new_v4()); @@ -137,8 +124,7 @@ async fn invalid_rating_returns_error() { let movies = InMemoryStore::new(); let reviews = Arc::clone(&movies); let watchlist = Arc::clone(&movies); - let events = NoopEventPublisher::new(); - let logger = make_logger(&movies, &reviews, &watchlist, &events); + let logger = make_logger(&movies, &reviews, &watchlist); let cmd = LogReviewCommand { rating: 6, @@ -157,8 +143,7 @@ async fn watchlist_not_present_does_not_publish_removed() { let movies = InMemoryStore::new(); let reviews = Arc::clone(&movies); let watchlist = Arc::clone(&movies); - let events = NoopEventPublisher::new(); - let logger = make_logger(&movies, &reviews, &watchlist, &events); + let logger = make_logger(&movies, &reviews, &watchlist); let cmd = log_review_command_manual(Uuid::new_v4(), "No Watchlist Film", 2024); @@ -197,11 +182,10 @@ impl MetadataClient for MetadataClientWithExternalId { } #[tokio::test] -async fn publishes_movie_discovered_for_new_movie_with_external_id() { +async fn movie_discovered_rides_the_movie_write_for_a_new_movie_with_external_id() { let movies = InMemoryStore::new(); let reviews = Arc::clone(&movies); let watchlist = Arc::clone(&movies); - let events = NoopEventPublisher::new(); let logger = DefaultReviewLogger::new( Arc::clone(&movies) as _, @@ -209,7 +193,6 @@ async fn publishes_movie_discovered_for_new_movie_with_external_id() { Arc::clone(&reviews) as _, Arc::clone(&watchlist) as _, Arc::new(MetadataClientWithExternalId) as _, - Arc::clone(&events) as _, ); let cmd = LogReviewCommand { @@ -219,12 +202,12 @@ async fn publishes_movie_discovered_for_new_movie_with_external_id() { logger.log_review(cmd).await.unwrap(); - let published = events.published(); + let published = movies.recorded_events(); assert!( published .iter() .any(|e| matches!(e, domain::events::DomainEvent::MovieDiscovered { .. })), - "should publish MovieDiscovered for new movie with external_metadata_id" + "MovieDiscovered must be recorded with the movie write" ); } @@ -237,7 +220,6 @@ async fn logging_a_review_fails_when_the_review_store_refuses() { Arc::new(domain::testing::RefusingReviewRepository), Arc::clone(&movies) as _, Arc::new(FakeMetadataClient), - NoopEventPublisher::new(), ); let result = logger @@ -256,7 +238,6 @@ async fn logging_a_review_saves_the_review_and_its_event_together() { Arc::clone(&movies) as _, Arc::clone(&movies) as _, Arc::new(FakeMetadataClient), - Arc::new(domain::testing::RefusingEventPublisher), ); logger @@ -282,7 +263,6 @@ async fn a_refused_save_propagates_and_records_nothing() { Arc::new(domain::testing::RefusingReviewRepository), Arc::clone(&movies) as _, Arc::new(FakeMetadataClient), - NoopEventPublisher::new(), ); let result = logger @@ -303,7 +283,6 @@ async fn enrichment_request_rides_the_review_save() { Arc::clone(&movies) as _, Arc::clone(&movies) as _, Arc::new(MetadataClientWithExternalId), - NoopEventPublisher::new(), ); logger diff --git a/crates/application/tests/import/apply_mapping_test.rs b/crates/application/tests/import/apply_mapping_test.rs index c5749e0a..be7cf6fc 100644 --- a/crates/application/tests/import/apply_mapping_test.rs +++ b/crates/application/tests/import/apply_mapping_test.rs @@ -110,7 +110,7 @@ async fn marks_duplicate_by_external_id() { let ext_id = ExternalMetadataId::new("tt1234567".into()).unwrap(); let movie = test_movie_with_external_id("Known Movie", 2020, ext_id); - movies.upsert_movie(&movie).await.unwrap(); + movies.upsert_movie(&movie, &[]).await.unwrap(); let parser = Arc::new(DuplicateTestParser { rows: vec![ImportRow { @@ -158,7 +158,7 @@ async fn marks_duplicate_by_title_and_year() { let sessions = Arc::clone(&movies); let movie = test_movie_with_year("Duplicate Film", 2022); - movies.upsert_movie(&movie).await.unwrap(); + movies.upsert_movie(&movie, &[]).await.unwrap(); let parser = Arc::new(DuplicateTestParser { rows: vec![ImportRow { diff --git a/crates/application/tests/integrations/confirm_test.rs b/crates/application/tests/integrations/confirm_test.rs index 79430ce2..7c9f0a8a 100644 --- a/crates/application/tests/integrations/confirm_test.rs +++ b/crates/application/tests/integrations/confirm_test.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::models::{WatchEvent, WatchEventSource}; use domain::ports::{MovieCommand, WatchEventCommand}; -use domain::testing::{InMemoryStore, NoopEventPublisher}; +use domain::testing::InMemoryStore; use domain::value_objects::UserId; use uuid::Uuid; @@ -42,7 +42,7 @@ async fn confirms_watch_event_via_review_logger() { None, ); let event_id = event.id().value(); - watch_events.save(&event).await.unwrap(); + watch_events.save(&event, &[]).await.unwrap(); let result = confirm::execute( &deps(&watch_events, noop_logger()), @@ -86,7 +86,7 @@ async fn confirms_event_with_external_metadata_id_and_no_movie_id() { None, ); let event_id = event.id().value(); - watch_events.save(&event).await.unwrap(); + watch_events.save(&event, &[]).await.unwrap(); let result = confirm::execute( &deps(&watch_events, noop_logger()), @@ -121,7 +121,7 @@ async fn rejects_other_users_event() { None, ); let event_id = event.id().value(); - watch_events.save(&event).await.unwrap(); + watch_events.save(&event, &[]).await.unwrap(); let result = confirm::execute( &deps(&watch_events, noop_logger()), @@ -148,7 +148,6 @@ async fn fails_when_event_not_found() { #[tokio::test] async fn confirms_event_with_movie_id() { let watch_events = InMemoryStore::new(); - let events = NoopEventPublisher::new(); let uid = Uuid::new_v4(); let movie_uuid = Uuid::new_v4(); @@ -162,7 +161,7 @@ async fn confirms_event_with_movie_id() { Some(domain::value_objects::MovieId::from_uuid(movie_uuid)), ); let event_id = event.id().value(); - watch_events.save(&event).await.unwrap(); + watch_events.save(&event, &[]).await.unwrap(); // Also seed movie repo so review_logger can find it let movies = domain::testing::InMemoryStore::new(); @@ -174,7 +173,7 @@ async fn confirms_event_with_movie_id() { None, None, ); - movies.upsert_movie(&movie).await.unwrap(); + movies.upsert_movie(&movie, &[]).await.unwrap(); // Build a real review logger let reviews = domain::testing::InMemoryStore::new(); @@ -186,7 +185,6 @@ async fn confirms_event_with_movie_id() { Arc::clone(&reviews) as _, Arc::clone(&watchlist) as _, Arc::new(domain::testing::FakeMetadataClient) as _, - Arc::clone(&events) as _, )); let result = confirm::execute( @@ -214,7 +212,7 @@ async fn confirms_event_without_movie_id_and_without_external_metadata_id() { None, ); let event_id = event.id().value(); - watch_events.save(&event).await.unwrap(); + watch_events.save(&event, &[]).await.unwrap(); let result = confirm::execute( &deps(&watch_events, noop_logger()), @@ -260,8 +258,8 @@ async fn confirms_multiple_events() { ); let id2 = event2.id().value(); - watch_events.save(&event1).await.unwrap(); - watch_events.save(&event2).await.unwrap(); + watch_events.save(&event1, &[]).await.unwrap(); + watch_events.save(&event2, &[]).await.unwrap(); let result = confirm::execute( &deps(&watch_events, noop_logger()), @@ -302,7 +300,7 @@ async fn confirms_event_without_year() { None, ); let event_id = event.id().value(); - watch_events.save(&event).await.unwrap(); + watch_events.save(&event, &[]).await.unwrap(); let result = confirm::execute( &deps(&watch_events, noop_logger()), diff --git a/crates/application/tests/integrations/dismiss_test.rs b/crates/application/tests/integrations/dismiss_test.rs index d5278125..7b0bfc50 100644 --- a/crates/application/tests/integrations/dismiss_test.rs +++ b/crates/application/tests/integrations/dismiss_test.rs @@ -70,8 +70,8 @@ async fn dismisses_existing_events() { ); let id1 = e1.id().value(); let id2 = e2.id().value(); - watch_events.save(&e1).await.unwrap(); - watch_events.save(&e2).await.unwrap(); + watch_events.save(&e1, &[]).await.unwrap(); + watch_events.save(&e2, &[]).await.unwrap(); let result = dismiss::execute( &deps(&watch_events), diff --git a/crates/application/tests/integrations/get_queue_test.rs b/crates/application/tests/integrations/get_queue_test.rs index 33d3389d..886a318a 100644 --- a/crates/application/tests/integrations/get_queue_test.rs +++ b/crates/application/tests/integrations/get_queue_test.rs @@ -46,7 +46,7 @@ async fn returns_pending_events() { Utc::now().naive_utc(), None, ); - events.save(&event).await.unwrap(); + events.save(&event, &[]).await.unwrap(); let result = get_queue::execute(&deps(&events), GetWatchQueueQuery { user_id }) .await diff --git a/crates/application/tests/integrations/ingest_test.rs b/crates/application/tests/integrations/ingest_test.rs index 9f8cc7b0..b3f67109 100644 --- a/crates/application/tests/integrations/ingest_test.rs +++ b/crates/application/tests/integrations/ingest_test.rs @@ -2,8 +2,7 @@ use crate::support::generate_webhook_token_command; use std::sync::Arc; use domain::models::WatchEventSource; -use domain::ports::EventPublisher; -use domain::testing::{InMemoryStore, NoopEventPublisher}; +use domain::testing::{InMemoryStore, RefusingWatchEventCommand}; use uuid::Uuid; use application::integrations::commands::IngestWatchEventCommand; @@ -27,10 +26,9 @@ impl domain::ports::MediaServerParser for FakeParser { } #[tokio::test] -async fn ingests_watch_event() { +async fn ingesting_saves_the_watch_event_and_records_its_event_together() { let tokens = InMemoryStore::new(); let watch_events = Arc::clone(&tokens); - let event_publisher: Arc = NoopEventPublisher::new(); let user_id = Uuid::new_v4(); let generated = generate_token::execute( @@ -46,7 +44,6 @@ async fn ingests_watch_event() { webhook_token: Arc::clone(&tokens) as _, watch_event_command: Arc::clone(&watch_events) as _, watch_event_query: Arc::clone(&watch_events) as _, - event_publisher: Arc::clone(&event_publisher), }; let result = ingest::execute( @@ -61,19 +58,58 @@ async fn ingests_watch_event() { .await; assert!(result.is_ok()); + assert_eq!(watch_events.rows_in("watch_events"), 1); + let recorded = tokens.recorded_events(); + assert_eq!(recorded.len(), 1); + assert!(matches!( + &recorded[0], + domain::events::DomainEvent::WatchEventIngested { title, .. } if title == "Test" + )); +} + +#[tokio::test] +async fn a_refused_save_propagates_and_records_nothing() { + let tokens = InMemoryStore::new(); + let user_id = Uuid::new_v4(); + let generated = generate_token::execute( + &GenerateWebhookTokenDeps { + webhook_token: Arc::clone(&tokens) as _, + }, + generate_webhook_token_command(user_id), + ) + .await + .unwrap(); + let deps = IngestWatchEventDeps { + webhook_token: Arc::clone(&tokens) as _, + watch_event_command: Arc::new(RefusingWatchEventCommand), + watch_event_query: Arc::clone(&tokens) as _, + }; + + let result = ingest::execute( + &deps, + IngestWatchEventCommand { + token: generated.token_plaintext, + raw_payload: vec![], + source: WatchEventSource::Jellyfin, + }, + &FakeParser, + ) + .await; + + assert!(result.is_err()); + assert_eq!(tokens.rows_in("watch_events"), 0); + assert!(tokens.recorded_events().is_empty()); } #[tokio::test] async fn rejects_invalid_token() { let tokens = InMemoryStore::new(); let watch_events = Arc::clone(&tokens); - let event_publisher: Arc = NoopEventPublisher::new(); let deps = IngestWatchEventDeps { webhook_token: Arc::clone(&tokens) as _, watch_event_command: Arc::clone(&watch_events) as _, watch_event_query: Arc::clone(&watch_events) as _, - event_publisher: Arc::clone(&event_publisher), }; let result = ingest::execute( diff --git a/crates/application/tests/movies.rs b/crates/application/tests/movies.rs index 5d07e747..52328748 100644 --- a/crates/application/tests/movies.rs +++ b/crates/application/tests/movies.rs @@ -6,7 +6,11 @@ mod enrich_movie_test; mod get_movie_profile_test; #[path = "movies/get_movies_test.rs"] mod get_movies_test; +#[path = "movies/poster_sync_handler_test.rs"] +mod poster_sync_handler_test; #[path = "movies/request_enrichment_test.rs"] mod request_enrichment_test; +#[path = "movies/resolve_test.rs"] +mod resolve_test; #[path = "movies/sync_poster_test.rs"] mod sync_poster_test; diff --git a/crates/application/tests/movies/enrich_movie_test.rs b/crates/application/tests/movies/enrich_movie_test.rs index 83d090ab..feb36bf1 100644 --- a/crates/application/tests/movies/enrich_movie_test.rs +++ b/crates/application/tests/movies/enrich_movie_test.rs @@ -19,7 +19,7 @@ async fn stores_profile_and_indexes() { let movie = test_movie_with_year("Test", 2024); let movie_id = MovieId::from_uuid(movie.id().value()); - movie_repo.upsert_movie(&movie).await.unwrap(); + movie_repo.upsert_movie(&movie, &[]).await.unwrap(); let profile = MovieProfile { movie_id: movie_id.clone(), @@ -68,7 +68,7 @@ async fn extracts_and_indexes_persons() { let movie = test_movie_with_year("Cast Movie", 2024); let movie_id = MovieId::from_uuid(movie.id().value()); - movie_repo.upsert_movie(&movie).await.unwrap(); + movie_repo.upsert_movie(&movie, &[]).await.unwrap(); let profile = MovieProfile { movie_id: movie_id.clone(), diff --git a/crates/application/tests/movies/poster_sync_handler_test.rs b/crates/application/tests/movies/poster_sync_handler_test.rs new file mode 100644 index 00000000..2c09508f --- /dev/null +++ b/crates/application/tests/movies/poster_sync_handler_test.rs @@ -0,0 +1,89 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::Movie, + ports::{EventHandler, MetadataClient, MovieCommand}, + testing::{ + FakePosterFetcher, InMemoryStore, NoopEventPublisher, NoopObjectStorage, + RefusingMovieCommand, test_movie_with_external_id, + }, + value_objects::{ExternalMetadataId, PosterUrl}, +}; + +use application::movies::poster_sync_handler::PosterSyncHandler; + +struct MetadataWithPoster; + +#[async_trait] +impl MetadataClient for MetadataWithPoster { + async fn fetch_movie_metadata( + &self, + _: &domain::models::MetadataSearchCriteria, + ) -> Result { + unimplemented!() + } + async fn get_poster_url( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + Ok(Some( + PosterUrl::new("https://example.com/poster.jpg".into()).unwrap(), + )) + } +} + +async fn seeded() -> (Arc, Movie, DomainEvent) { + let store = InMemoryStore::new(); + let ext = ExternalMetadataId::new("tmdb:42".into()).unwrap(); + let movie = test_movie_with_external_id("Heat", 1995, ext.clone()); + store.upsert_movie(&movie, &[]).await.unwrap(); + let event = DomainEvent::MovieDiscovered { + movie_id: movie.id().clone(), + external_metadata_id: ext, + }; + (store, movie, event) +} + +fn handler(command: Arc, store: &Arc) -> PosterSyncHandler { + PosterSyncHandler::new( + command, + Arc::clone(store) as _, + Arc::new(MetadataWithPoster), + Arc::new(FakePosterFetcher), + Arc::new(NoopObjectStorage), + NoopEventPublisher::new(), + 0, + ) +} + +#[tokio::test] +async fn poster_synced_rides_the_movie_write() { + let (store, movie, event) = seeded().await; + + handler(Arc::clone(&store) as _, &store) + .handle(&event) + .await + .unwrap(); + + let recorded = store.recorded_events(); + assert_eq!(recorded.len(), 1); + assert!(matches!( + &recorded[0], + DomainEvent::PosterSynced { movie_id } if movie_id == movie.id() + )); +} + +#[tokio::test] +async fn a_refused_upsert_propagates_and_records_nothing() { + let (store, _movie, event) = seeded().await; + + let result = handler(Arc::new(RefusingMovieCommand), &store) + .handle(&event) + .await; + + assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused")); + assert!(store.recorded_events().is_empty()); +} diff --git a/crates/application/tests/movies/resolve_test.rs b/crates/application/tests/movies/resolve_test.rs new file mode 100644 index 00000000..99c541f1 --- /dev/null +++ b/crates/application/tests/movies/resolve_test.rs @@ -0,0 +1,127 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::{MetadataSearchCriteria, Movie}, + ports::{MetadataClient, MovieCommand}, + testing::{ + FakeMetadataClient, InMemoryStore, RefusingMovieCommand, test_movie_with_external_id, + test_movie_with_year, + }, + value_objects::{ExternalMetadataId, PosterUrl}, +}; + +use application::diary::commands::MovieInput; +use application::movies::resolve::resolve_and_persist_movie; + +struct MetadataWithExternalId; + +#[async_trait] +impl MetadataClient for MetadataWithExternalId { + async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result { + Ok(test_movie_with_external_id( + "Discovered Film", + 2024, + ExternalMetadataId::new("tmdb:99999".into()).unwrap(), + )) + } + + async fn get_poster_url( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + Ok(None) + } +} + +fn manual_input(title: &str) -> MovieInput { + MovieInput { + movie_id: None, + external_metadata_id: None, + manual_title: Some(title.into()), + manual_release_year: Some(2024), + manual_director: None, + } +} + +#[tokio::test] +async fn a_new_movie_with_an_external_id_is_persisted_with_movie_discovered() { + let store = InMemoryStore::new(); + + let (movie, is_new) = resolve_and_persist_movie( + &manual_input("Discovered Film"), + store.as_ref(), + store.as_ref(), + &MetadataWithExternalId, + ) + .await + .unwrap(); + + assert!(is_new); + assert_eq!(store.movie_count(), 1); + let recorded = store.recorded_events(); + assert_eq!(recorded.len(), 1); + assert!(matches!( + &recorded[0], + DomainEvent::MovieDiscovered { movie_id, .. } if movie_id == movie.id() + )); +} + +#[tokio::test] +async fn a_new_movie_without_an_external_id_records_no_event() { + let store = InMemoryStore::new(); + + let (_, is_new) = resolve_and_persist_movie( + &manual_input("Home Movie"), + store.as_ref(), + store.as_ref(), + &FakeMetadataClient, + ) + .await + .unwrap(); + + assert!(is_new); + assert_eq!(store.movie_count(), 1); + assert!(store.recorded_events().is_empty()); +} + +#[tokio::test] +async fn an_existing_movie_is_not_rewritten_and_records_no_event() { + let store = InMemoryStore::new(); + let movie = test_movie_with_year("Known", 2001); + store.upsert_movie(&movie, &[]).await.unwrap(); + let input = MovieInput { + movie_id: Some(movie.id().value()), + ..manual_input("ignored") + }; + + let (resolved, is_new) = resolve_and_persist_movie( + &input, + store.as_ref(), + store.as_ref(), + &MetadataWithExternalId, + ) + .await + .unwrap(); + + assert!(!is_new); + assert_eq!(resolved.id(), movie.id()); + assert!(store.recorded_events().is_empty()); +} + +#[tokio::test] +async fn a_refused_upsert_propagates_and_records_nothing() { + let store = InMemoryStore::new(); + + let result = resolve_and_persist_movie( + &manual_input("Discovered Film"), + &RefusingMovieCommand, + store.as_ref(), + &MetadataWithExternalId, + ) + .await; + + assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused")); + assert_eq!(store.movie_count(), 0); + assert!(store.recorded_events().is_empty()); +} diff --git a/crates/application/tests/movies/sync_poster_test.rs b/crates/application/tests/movies/sync_poster_test.rs index 6e2d937c..b8ebff01 100644 --- a/crates/application/tests/movies/sync_poster_test.rs +++ b/crates/application/tests/movies/sync_poster_test.rs @@ -41,7 +41,7 @@ async fn fails_when_no_external_id() { let movies = InMemoryStore::new(); let movie = test_movie_with_year("Test", 2024); let movie_id = movie.id().value(); - movies.upsert_movie(&movie).await.unwrap(); + movies.upsert_movie(&movie, &[]).await.unwrap(); let deps = SyncPosterDeps { movie_command: Arc::clone(&movies) as _, @@ -80,7 +80,7 @@ async fn syncs_poster_for_movie_with_external_id() { let ext_id = ExternalMetadataId::new("tmdb:999".into()).unwrap(); let movie = test_movie_with_external_id("Poster Movie", 2024, ext_id); let movie_id = movie.id().value(); - movies.upsert_movie(&movie).await.unwrap(); + movies.upsert_movie(&movie, &[]).await.unwrap(); let deps = SyncPosterDeps { movie_command: Arc::clone(&movies) as _, diff --git a/crates/application/tests/watchlist/add_test.rs b/crates/application/tests/watchlist/add_test.rs index 995835d4..2d1fb01d 100644 --- a/crates/application/tests/watchlist/add_test.rs +++ b/crates/application/tests/watchlist/add_test.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use domain::{ events::DomainEvent, ports::MovieCommand, - testing::{InMemoryStore, NoopEventPublisher, RefusingWatchlistRepository}, + testing::{InMemoryStore, RefusingWatchlistRepository}, }; use application::watchlist::{add, commands::AddToWatchlistCommand, deps::WatchlistAddDeps}; @@ -16,7 +16,6 @@ fn make_deps(movies: Arc, watchlist: Arc) -> Watch movie_query: movies, metadata: Arc::new(domain::testing::FakeMetadataClient), watchlist, - event_publisher: NoopEventPublisher::new(), } } @@ -27,7 +26,7 @@ async fn adding_to_the_watchlist_resolves_the_movie_and_saves_the_entry() { let movie = test_movie_with_year("The Thing", 1982); let movie_uuid = movie.id().value(); - movies.upsert_movie(&movie).await.unwrap(); + movies.upsert_movie(&movie, &[]).await.unwrap(); let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist)); @@ -46,7 +45,7 @@ async fn adding_a_movie_already_on_the_watchlist_is_idempotent() { let movie = test_movie_with_year("RoboCop", 1987); let movie_uuid = movie.id().value(); let user_id = uuid::Uuid::new_v4(); - movies.upsert_movie(&movie).await.unwrap(); + movies.upsert_movie(&movie, &[]).await.unwrap(); let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist)); @@ -98,7 +97,7 @@ async fn adding_the_same_movie_to_a_watchlist_twice_keeps_a_single_entry_and_suc let movie = test_movie_with_year("Heat", 1995); let movie_uuid = movie.id().value(); let user_id = uuid::Uuid::new_v4(); - movies.upsert_movie(&movie).await.unwrap(); + movies.upsert_movie(&movie, &[]).await.unwrap(); let deps = make_deps(Arc::clone(&movies), Arc::clone(&movies)); for _ in 0..2 { @@ -114,7 +113,7 @@ async fn adding_the_same_movie_to_a_watchlist_twice_keeps_a_single_entry_and_suc async fn adding_an_entry_records_the_entry_and_its_event_together() { let movies = InMemoryStore::new(); let movie = test_movie_with_year("Heat", 1995); - movies.upsert_movie(&movie).await.unwrap(); + movies.upsert_movie(&movie, &[]).await.unwrap(); let deps = make_deps(Arc::clone(&movies), Arc::clone(&movies)); add::execute( @@ -137,7 +136,7 @@ async fn adding_an_entry_records_the_entry_and_its_event_together() { async fn a_refused_add_propagates_and_records_nothing() { let movies = InMemoryStore::new(); let movie = test_movie_with_year("Heat", 1995); - movies.upsert_movie(&movie).await.unwrap(); + movies.upsert_movie(&movie, &[]).await.unwrap(); let deps = WatchlistAddDeps { watchlist: Arc::new(RefusingWatchlistRepository), ..make_deps(Arc::clone(&movies), Arc::clone(&movies)) diff --git a/crates/application/tests/wrapup/generate_test.rs b/crates/application/tests/wrapup/generate_test.rs index 0d0657c6..299174e9 100644 --- a/crates/application/tests/wrapup/generate_test.rs +++ b/crates/application/tests/wrapup/generate_test.rs @@ -2,7 +2,7 @@ use crate::support::request_wrap_up_command; use chrono::NaiveDate; use domain::events::DomainEvent; use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; -use domain::testing::{InMemoryWrapUpRepository, NoopEventPublisher}; +use domain::testing::{InMemoryWrapUpRepository, RefusingWrapUpWrites}; use domain::value_objects::WrapUpId; use uuid::Uuid; @@ -11,13 +11,9 @@ use std::sync::Arc; use application::wrapup::deps::GenerateWrapUpDeps; use application::wrapup::{commands::RequestWrapUpCommand, generate}; -fn deps( - repo: &Arc, - events: &Arc, -) -> GenerateWrapUpDeps { +fn deps(repo: &Arc) -> GenerateWrapUpDeps { GenerateWrapUpDeps { wrapup_repo: Arc::clone(repo) as _, - event_publisher: Arc::clone(events) as _, } } @@ -26,25 +22,22 @@ fn past_cmd() -> RequestWrapUpCommand { } #[tokio::test] -async fn creates_pending_record_and_emits_event() { +async fn creates_pending_record_and_records_its_event_together() { let repo = InMemoryWrapUpRepository::new(); - let events = NoopEventPublisher::new(); - let id = generate::execute(&deps(&repo, &events), past_cmd()) - .await - .unwrap(); + let id = generate::execute(&deps(&repo), past_cmd()).await.unwrap(); let stored = repo.store.lock().unwrap(); assert_eq!(stored.len(), 1); assert_eq!(stored[0].id, id); assert_eq!(stored[0].status, WrapUpStatus::Pending); - let published = events.published(); - assert!( - published - .iter() - .any(|e| matches!(e, DomainEvent::WrapUpRequested { .. })) - ); + let recorded = repo.recorded_events(); + assert_eq!(recorded.len(), 1); + assert!(matches!( + &recorded[0], + DomainEvent::WrapUpRequested { wrapup_id, .. } if *wrapup_id == id + )); } #[tokio::test] @@ -62,13 +55,11 @@ async fn reuses_existing_ready_wrapup() { created_at: chrono::Utc::now().naive_utc(), completed_at: None, }); - let events = NoopEventPublisher::new(); - let id = generate::execute(&deps(&repo, &events), past_cmd()) - .await - .unwrap(); + let id = generate::execute(&deps(&repo), past_cmd()).await.unwrap(); assert_eq!(id, existing_id); assert_eq!(repo.store.lock().unwrap().len(), 1); + assert!(repo.recorded_events().is_empty()); } #[tokio::test] @@ -86,11 +77,7 @@ async fn replaces_failed_wrapup() { completed_at: None, }); - let events = NoopEventPublisher::new(); - - let id = generate::execute(&deps(&repo, &events), past_cmd()) - .await - .unwrap(); + let id = generate::execute(&deps(&repo), past_cmd()).await.unwrap(); let stored = repo.store.lock().unwrap(); assert_eq!(stored.len(), 1); @@ -101,10 +88,23 @@ async fn replaces_failed_wrapup() { #[tokio::test] async fn rejects_future_end_date() { let repo = InMemoryWrapUpRepository::new(); - let events = NoopEventPublisher::new(); - let err = generate::execute(&deps(&repo, &events), request_wrap_up_command(None, 2030)) + let err = generate::execute(&deps(&repo), request_wrap_up_command(None, 2030)) .await .unwrap_err(); assert!(err.to_string().contains("future")); } + +#[tokio::test] +async fn a_refused_create_propagates_and_records_nothing() { + let inner = InMemoryWrapUpRepository::new(); + let deps = GenerateWrapUpDeps { + wrapup_repo: RefusingWrapUpWrites::new(Arc::clone(&inner)), + }; + + let result = generate::execute(&deps, past_cmd()).await; + + assert!(result.is_err()); + assert!(inner.store.lock().unwrap().is_empty()); + assert!(inner.recorded_events().is_empty()); +} diff --git a/crates/application/tests/wrapup/get_ready_report_test.rs b/crates/application/tests/wrapup/get_ready_report_test.rs index e8dcef7f..dc05e673 100644 --- a/crates/application/tests/wrapup/get_ready_report_test.rs +++ b/crates/application/tests/wrapup/get_ready_report_test.rs @@ -56,7 +56,7 @@ fn record( async fn ready_report_returns_report_when_ready() { let repo = InMemoryWrapUpRepository::new(); let user_id = Uuid::new_v4(); - repo.create(&record(Some(user_id), 2024, WrapUpStatus::Ready, true)) + repo.create(&record(Some(user_id), 2024, WrapUpStatus::Ready, true), &[]) .await .unwrap(); @@ -72,12 +72,10 @@ async fn ready_report_returns_report_when_ready() { async fn ready_report_is_not_found_when_status_not_ready() { let repo = InMemoryWrapUpRepository::new(); let user_id = Uuid::new_v4(); - repo.create(&record( - Some(user_id), - 2024, - WrapUpStatus::Generating, - false, - )) + repo.create( + &record(Some(user_id), 2024, WrapUpStatus::Generating, false), + &[], + ) .await .unwrap(); @@ -96,9 +94,12 @@ async fn ready_report_is_not_found_when_report_absent() { // Status is Ready but `report` is None — an inconsistent-but-representable // state the old handler guarded against explicitly (`match record.report { // Some(r) => ..., None => 404 }`), separate from the status gate above. - repo.create(&record(Some(user_id), 2024, WrapUpStatus::Ready, false)) - .await - .unwrap(); + repo.create( + &record(Some(user_id), 2024, WrapUpStatus::Ready, false), + &[], + ) + .await + .unwrap(); let deps = GetReadyReportDeps { wrapup_repo: repo }; @@ -122,7 +123,7 @@ async fn ready_report_is_not_found_when_no_record_exists() { #[tokio::test] async fn ready_report_global_scope_ignores_user_id() { let repo = InMemoryWrapUpRepository::new(); - repo.create(&record(None, 2024, WrapUpStatus::Ready, true)) + repo.create(&record(None, 2024, WrapUpStatus::Ready, true), &[]) .await .unwrap(); diff --git a/crates/application/tests/wrapup/handle_requested_test.rs b/crates/application/tests/wrapup/handle_requested_test.rs index c7fb26ce..53177b9a 100644 --- a/crates/application/tests/wrapup/handle_requested_test.rs +++ b/crates/application/tests/wrapup/handle_requested_test.rs @@ -1,7 +1,7 @@ use chrono::{NaiveDate, Utc}; use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; use domain::ports::WrapUpRepository; -use domain::testing::{InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, NoopEventPublisher}; +use domain::testing::{InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, RefusingWrapUpWrites}; use domain::value_objects::WrapUpId; use application::wrapup::{deps::HandleWrapUpRequestedDeps, handle_requested}; @@ -22,11 +22,10 @@ async fn skips_if_already_ready() { created_at: Utc::now().naive_utc(), completed_at: None, }; - repo.create(&record).await.unwrap(); + repo.create(&record, &[]).await.unwrap(); let deps = HandleWrapUpRequestedDeps { wrapup_repo: repo.clone(), - event_publisher: NoopEventPublisher::new(), wrapup_stats: InMemoryWrapUpStatsQuery::new(), }; @@ -46,7 +45,6 @@ async fn skips_if_already_ready() { async fn generates_wrapup_and_marks_complete() { let repo = InMemoryWrapUpRepository::new(); let stats = InMemoryWrapUpStatsQuery::new(); - let events = NoopEventPublisher::new(); let wrapup_id = WrapUpId::generate(); let uid = uuid::Uuid::new_v4(); @@ -61,11 +59,10 @@ async fn generates_wrapup_and_marks_complete() { created_at: Utc::now().naive_utc(), completed_at: None, }; - repo.create(&record).await.unwrap(); + repo.create(&record, &[]).await.unwrap(); let deps = HandleWrapUpRequestedDeps { wrapup_repo: repo.clone(), - event_publisher: events.clone(), wrapup_stats: stats.clone(), }; @@ -85,13 +82,13 @@ async fn generates_wrapup_and_marks_complete() { assert_eq!(final_rec.status, WrapUpStatus::Ready); assert!(final_rec.report.is_some()); - // Verify event was published - let published = events.published(); - assert!( - published - .iter() - .any(|e| matches!(e, domain::events::DomainEvent::WrapUpCompleted { .. })) - ); + // The completion event is recorded together with the completed row. + let recorded = repo.recorded_events(); + assert_eq!(recorded.len(), 1); + assert!(matches!( + &recorded[0], + domain::events::DomainEvent::WrapUpCompleted { wrapup_id: id } if *id == wrapup_id + )); } #[tokio::test] @@ -110,11 +107,10 @@ async fn skips_if_already_generating() { created_at: Utc::now().naive_utc(), completed_at: None, }; - repo.create(&record).await.unwrap(); + repo.create(&record, &[]).await.unwrap(); let deps = HandleWrapUpRequestedDeps { wrapup_repo: repo.clone(), - event_publisher: NoopEventPublisher::new(), wrapup_stats: InMemoryWrapUpStatsQuery::new(), }; @@ -132,4 +128,42 @@ async fn skips_if_already_generating() { // Status should still be Generating (not changed to Ready) let final_rec = repo.get_by_id(&wrapup_id).await.unwrap().unwrap(); assert_eq!(final_rec.status, WrapUpStatus::Generating); + assert!(repo.recorded_events().is_empty()); +} + +#[tokio::test] +async fn a_refused_completion_propagates_and_records_nothing() { + let inner = InMemoryWrapUpRepository::new(); + let wrapup_id = WrapUpId::generate(); + let record = WrapUpRecord { + id: wrapup_id.clone(), + user_id: None, + start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + end_date: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(), + status: WrapUpStatus::Pending, + report: None, + error_message: None, + created_at: Utc::now().naive_utc(), + completed_at: None, + }; + inner.create(&record, &[]).await.unwrap(); + let deps = HandleWrapUpRequestedDeps { + wrapup_repo: RefusingWrapUpWrites::new(inner.clone()), + wrapup_stats: InMemoryWrapUpStatsQuery::new(), + }; + + let result = handle_requested::execute( + &deps, + wrapup_id.clone(), + None, + NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(), + ) + .await; + + assert!(result.is_err()); + let rec = inner.get_by_id(&wrapup_id).await.unwrap().unwrap(); + assert_ne!(rec.status, WrapUpStatus::Ready); + assert!(rec.report.is_none()); + assert!(inner.recorded_events().is_empty()); } diff --git a/crates/bootstrap/src/build.rs b/crates/bootstrap/src/build.rs index 9003d371..913b002e 100644 --- a/crates/bootstrap/src/build.rs +++ b/crates/bootstrap/src/build.rs @@ -90,7 +90,6 @@ pub fn build_deps( review: repos.review.clone(), diary: repos.diary.clone(), movie_command: repos.movie_command.clone(), - event_publisher: services.event_publisher.clone(), }, edit_review: EditReviewDeps { review: repos.review.clone(), @@ -185,7 +184,6 @@ pub fn build_deps( webhook_token: repos.webhook_token.clone(), watch_event_command: repos.watch_event_command.clone(), watch_event_query: repos.watch_event_query.clone(), - event_publisher: services.event_publisher.clone(), }, confirm_watch_events: ConfirmWatchEventsDeps { watch_event_command: repos.watch_event_command.clone(), @@ -313,7 +311,6 @@ pub fn build_deps( movie_query: repos.movie_query.clone(), metadata: services.metadata.clone(), watchlist: repos.watchlist.clone(), - event_publisher: services.event_publisher.clone(), }, get_watchlist_for_owner: GetWatchlistForOwnerDeps { user: repos.user.clone(), @@ -339,7 +336,6 @@ pub fn build_deps( }, generate: GenerateWrapUpDeps { wrapup_repo: repos.wrapup_repo.clone(), - event_publisher: services.event_publisher.clone(), }, get_wrapup: GetWrapUpDeps { wrapup_repo: repos.wrapup_repo.clone(), @@ -387,7 +383,6 @@ pub fn build_worker_deps( }, handle_requested: HandleWrapUpRequestedDeps { wrapup_repo: db.wrapup_repo.clone(), - event_publisher: services.event_publisher.clone(), wrapup_stats: db.wrapup_stats.clone(), }, } diff --git a/crates/bootstrap/src/server.rs b/crates/bootstrap/src/server.rs index 919e97ca..c3f87a4b 100644 --- a/crates/bootstrap/src/server.rs +++ b/crates/bootstrap/src/server.rs @@ -72,7 +72,6 @@ async fn assemble( Arc::clone(&db.review), Arc::clone(&db.watchlist), Arc::clone(&metadata_client), - Arc::clone(&event_publisher), )); let repos = Repositories { diff --git a/crates/bootstrap/src/worker.rs b/crates/bootstrap/src/worker.rs index c2ba1242..5826e2e0 100644 --- a/crates/bootstrap/src/worker.rs +++ b/crates/bootstrap/src/worker.rs @@ -215,7 +215,6 @@ async fn assemble( Arc::new(application::jobs::WrapUpAutoGenerateJob::new( Arc::clone(&user), Arc::clone(&wrapup_repo), - Arc::clone(&event_publisher), )), Arc::new(application::jobs::WrapUpCleanupJob::new(Arc::clone( &wrapup_repo, @@ -277,7 +276,6 @@ async fn assemble( let wrapup_handler = Arc::new(application::wrapup::event_handler::WrapUpEventHandler::new( worker_deps.handle_requested.wrapup_repo.clone(), - worker_deps.handle_requested.event_publisher.clone(), worker_deps.handle_requested.wrapup_stats.clone(), )) as Arc; diff --git a/crates/domain/src/ports/media_server.rs b/crates/domain/src/ports/media_server.rs index e28e0772..7acbd9bc 100644 --- a/crates/domain/src/ports/media_server.rs +++ b/crates/domain/src/ports/media_server.rs @@ -3,6 +3,7 @@ use chrono::NaiveDateTime; use crate::{ errors::DomainError, + events::DomainEvent, models::{ParsedPlaybackEvent, WatchEvent, WatchEventStatus, WebhookToken}, value_objects::{UserId, WatchEventId, WebhookTokenId}, }; @@ -14,7 +15,7 @@ pub trait MediaServerParser: Send + Sync { #[async_trait] pub trait WatchEventCommand: Send + Sync { - async fn save(&self, event: &WatchEvent) -> Result<(), DomainError>; + async fn save(&self, event: &WatchEvent, events: &[DomainEvent]) -> Result<(), DomainError>; async fn update_status( &self, id: &WatchEventId, diff --git a/crates/domain/src/ports/movie.rs b/crates/domain/src/ports/movie.rs index 5304548c..a25a0ae0 100644 --- a/crates/domain/src/ports/movie.rs +++ b/crates/domain/src/ports/movie.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use crate::{ errors::DomainError, + events::DomainEvent, models::{ MetadataSearchCriteria, Movie, MovieFilter, MovieProfile, MovieSummary, collections::{PageParams, Paginated}, @@ -11,8 +12,12 @@ use crate::{ #[async_trait] pub trait MovieCommand: Send + Sync { - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>; - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>; + async fn upsert_movie(&self, movie: &Movie, events: &[DomainEvent]) -> Result<(), DomainError>; + async fn delete_movie( + &self, + movie_id: &MovieId, + events: &[DomainEvent], + ) -> Result<(), DomainError>; } #[async_trait] diff --git a/crates/domain/src/ports/wrapup.rs b/crates/domain/src/ports/wrapup.rs index cd6129c9..187d6774 100644 --- a/crates/domain/src/ports/wrapup.rs +++ b/crates/domain/src/ports/wrapup.rs @@ -4,6 +4,7 @@ use uuid::Uuid; use crate::{ errors::DomainError, + events::DomainEvent, models::wrapup::{ DateRange, WrapUpMovieRow, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus, }, @@ -12,14 +13,23 @@ use crate::{ #[async_trait] pub trait WrapUpRepository: Send + Sync { - async fn create(&self, record: &WrapUpRecord) -> Result<(), DomainError>; + async fn create( + &self, + record: &WrapUpRecord, + events: &[DomainEvent], + ) -> Result<(), DomainError>; async fn update_status( &self, id: &WrapUpId, status: &WrapUpStatus, error: Option<&str>, ) -> Result<(), DomainError>; - async fn set_complete(&self, id: &WrapUpId, report: &WrapUpReport) -> Result<(), DomainError>; + async fn set_complete( + &self, + id: &WrapUpId, + report: &WrapUpReport, + events: &[DomainEvent], + ) -> Result<(), DomainError>; async fn get_by_id(&self, id: &WrapUpId) -> Result, DomainError>; async fn list_for_user(&self, user_id: Uuid) -> Result, DomainError>; async fn list_global(&self) -> Result, DomainError>; diff --git a/crates/domain/src/testing/fakes/panics.rs b/crates/domain/src/testing/fakes/panics.rs index 2fc6a186..952c4899 100644 --- a/crates/domain/src/testing/fakes/panics.rs +++ b/crates/domain/src/testing/fakes/panics.rs @@ -393,7 +393,11 @@ pub struct PanicWatchEventCommand; #[async_trait] impl crate::ports::WatchEventCommand for PanicWatchEventCommand { - async fn save(&self, _: &crate::models::WatchEvent) -> Result<(), DomainError> { + async fn save( + &self, + _: &crate::models::WatchEvent, + _: &[DomainEvent], + ) -> Result<(), DomainError> { panic!("PanicWatchEventCommand called") } async fn update_status( @@ -487,10 +491,10 @@ pub struct PanicMovieCommand; #[async_trait] impl MovieCommand for PanicMovieCommand { - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { + async fn upsert_movie(&self, _: &Movie, _: &[DomainEvent]) -> Result<(), DomainError> { panic!("PanicMovieCommand called") } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { + async fn delete_movie(&self, _: &MovieId, _: &[DomainEvent]) -> Result<(), DomainError> { panic!("PanicMovieCommand called") } } diff --git a/crates/domain/src/testing/fakes/refusing.rs b/crates/domain/src/testing/fakes/refusing.rs index 70ac8e6e..86864c7c 100644 --- a/crates/domain/src/testing/fakes/refusing.rs +++ b/crates/domain/src/testing/fakes/refusing.rs @@ -1,19 +1,21 @@ //! Fakes whose every call fails with an infrastructure error, for failure-path tests. use async_trait::async_trait; +use chrono::NaiveDateTime; use crate::{ errors::DomainError, events::DomainEvent, models::{ - Goal, Movie, ProfileField, Review, WatchlistEntry, WatchlistWithMovie, + Goal, Movie, ProfileField, Review, WatchEvent, WatchEventStatus, WatchlistEntry, + WatchlistWithMovie, collections::{PageParams, Paginated}, }, ports::{ EventPublisher, GoalCommand, MovieCommand, ReviewRepository, UserProfileFieldsRepository, - WatchlistRepository, + WatchEventCommand, WatchlistRepository, }, - value_objects::{GoalId, MovieId, ReviewId, UserId}, + value_objects::{GoalId, MovieId, ReviewId, UserId, WatchEventId}, }; fn refused() -> DomainError { @@ -45,10 +47,10 @@ pub struct RefusingMovieCommand; #[async_trait] impl MovieCommand for RefusingMovieCommand { - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { + async fn upsert_movie(&self, _: &Movie, _: &[DomainEvent]) -> Result<(), DomainError> { Err(refused()) } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { + async fn delete_movie(&self, _: &MovieId, _: &[DomainEvent]) -> Result<(), DomainError> { Err(refused()) } } @@ -123,3 +125,29 @@ impl EventPublisher for RefusingEventPublisher { Err(refused()) } } + +pub struct RefusingWatchEventCommand; + +#[async_trait] +impl WatchEventCommand for RefusingWatchEventCommand { + async fn save(&self, _: &WatchEvent, _: &[DomainEvent]) -> Result<(), DomainError> { + Err(refused()) + } + async fn update_status( + &self, + _: &WatchEventId, + _: WatchEventStatus, + ) -> Result<(), DomainError> { + Err(refused()) + } + async fn update_status_batch( + &self, + _: &[WatchEventId], + _: WatchEventStatus, + ) -> Result { + Err(refused()) + } + async fn delete_non_pending_older_than(&self, _: NaiveDateTime) -> Result { + Err(refused()) + } +} diff --git a/crates/domain/src/testing/fakes/store_movie.rs b/crates/domain/src/testing/fakes/store_movie.rs index 56852ee8..b70a3877 100644 --- a/crates/domain/src/testing/fakes/store_movie.rs +++ b/crates/domain/src/testing/fakes/store_movie.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use crate::{ errors::DomainError, + events::DomainEvent, models::{ Movie, MovieFilter, MovieProfile, MovieSummary, PersistedWatchEvent, WatchEvent, collections::{PageParams, Paginated}, @@ -17,7 +18,7 @@ impl MovieCommand for InMemoryStore { /// `movies.external_metadata_id` is UNIQUE: another movie row holding the /// same external id is a constraint violation, which the sqlite adapter /// surfaces as an infrastructure error. - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { + async fn upsert_movie(&self, movie: &Movie, events: &[DomainEvent]) -> Result<(), DomainError> { let mut movies = self.movies.write().unwrap(); if let Some(external) = movie.external_metadata_id() { let taken = movies.values().any(|m| { @@ -31,13 +32,19 @@ impl MovieCommand for InMemoryStore { } } movies.insert(movie.id().value(), movie.clone()); + drop(movies); + self.record_events(events); Ok(()) } /// Schema: `reviews.movie_id REFERENCES movies(id)` has no cascade, so a /// movie with reviews cannot be deleted. `watchlist_entries` and the /// `movie_*` profile tables cascade; `watch_events.movie_id` is set to NULL. - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { + async fn delete_movie( + &self, + movie_id: &MovieId, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let id = movie_id.value(); if !self.movies.read().unwrap().contains_key(&id) { return Ok(()); @@ -59,12 +66,14 @@ impl MovieCommand for InMemoryStore { .unwrap() .retain(|(_, movie), _| *movie != id); self.movie_profiles.write().unwrap().remove(&id); - let mut events = self.watch_events.write().unwrap(); - for event in events.iter_mut() { + let mut events_guard = self.watch_events.write().unwrap(); + for event in events_guard.iter_mut() { if event.movie_id().map(|m| m.value()) == Some(id) { *event = detached_from_movie(event); } } + drop(events_guard); + self.record_events(events); Ok(()) } } diff --git a/crates/domain/src/testing/fakes/store_watch_event.rs b/crates/domain/src/testing/fakes/store_watch_event.rs index 1bd2625f..750188fa 100644 --- a/crates/domain/src/testing/fakes/store_watch_event.rs +++ b/crates/domain/src/testing/fakes/store_watch_event.rs @@ -4,6 +4,7 @@ use uuid::Uuid; use crate::{ errors::DomainError, + events::DomainEvent, models::{WatchEvent, WatchEventStatus, WebhookToken}, ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository}, value_objects::{UserId, WatchEventId, WebhookTokenId}, @@ -13,8 +14,9 @@ use super::InMemoryStore; #[async_trait] impl WatchEventCommand for InMemoryStore { - async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { + async fn save(&self, event: &WatchEvent, events: &[DomainEvent]) -> Result<(), DomainError> { self.watch_events.write().unwrap().push(event.clone()); + self.record_events(events); Ok(()) } diff --git a/crates/domain/src/testing/fakes/wrapup.rs b/crates/domain/src/testing/fakes/wrapup.rs index 88085418..e771cde2 100644 --- a/crates/domain/src/testing/fakes/wrapup.rs +++ b/crates/domain/src/testing/fakes/wrapup.rs @@ -3,7 +3,9 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use uuid::Uuid; -use crate::{errors::DomainError, ports::WrapUpRepository, value_objects::WrapUpId}; +use crate::{ + errors::DomainError, events::DomainEvent, ports::WrapUpRepository, value_objects::WrapUpId, +}; // ── PanicWrapUpStatsQuery ─────────────────────────────────────────────────── @@ -68,14 +70,22 @@ impl crate::ports::WrapUpStatsQuery for InMemoryWrapUpStatsQuery { pub struct InMemoryWrapUpRepository { pub store: Mutex>, + recorded: Mutex>, } impl InMemoryWrapUpRepository { pub fn new() -> Arc { Arc::new(Self { store: Mutex::new(Vec::new()), + recorded: Mutex::new(Vec::new()), }) } + + /// Events recorded atomically with a change (the outbox rows the sqlite + /// adapter writes in the same transaction). + pub fn recorded_events(&self) -> Vec { + self.recorded.lock().unwrap().clone() + } } #[async_trait] @@ -83,8 +93,10 @@ impl WrapUpRepository for InMemoryWrapUpRepository { async fn create( &self, record: &crate::models::wrapup::WrapUpRecord, + events: &[DomainEvent], ) -> Result<(), DomainError> { self.store.lock().unwrap().push(record.clone()); + self.recorded.lock().unwrap().extend_from_slice(events); Ok(()) } @@ -108,12 +120,15 @@ impl WrapUpRepository for InMemoryWrapUpRepository { &self, id: &WrapUpId, report: &crate::models::wrapup::WrapUpReport, + events: &[DomainEvent], ) -> Result<(), DomainError> { let mut store = self.store.lock().unwrap(); if let Some(rec) = store.iter_mut().find(|r| r.id == *id) { rec.status = crate::models::wrapup::WrapUpStatus::Ready; rec.report = Some(report.clone()); rec.completed_at = Some(chrono::Utc::now().naive_utc()); + drop(store); + self.recorded.lock().unwrap().extend_from_slice(events); Ok(()) } else { Err(DomainError::NotFound("wrapup record".into())) @@ -197,7 +212,11 @@ pub struct PanicWrapUpRepository; #[async_trait] impl WrapUpRepository for PanicWrapUpRepository { - async fn create(&self, _: &crate::models::wrapup::WrapUpRecord) -> Result<(), DomainError> { + async fn create( + &self, + _: &crate::models::wrapup::WrapUpRecord, + _: &[DomainEvent], + ) -> Result<(), DomainError> { panic!("PanicWrapUpRepository called") } async fn update_status( @@ -212,6 +231,7 @@ impl WrapUpRepository for PanicWrapUpRepository { &self, _: &WrapUpId, _: &crate::models::wrapup::WrapUpReport, + _: &[DomainEvent], ) -> Result<(), DomainError> { panic!("PanicWrapUpRepository called") } @@ -245,3 +265,77 @@ impl WrapUpRepository for PanicWrapUpRepository { panic!("PanicWrapUpRepository called") } } + +// ── RefusingWrapUpWrites ─────────────────────────────────────────────────── + +/// Delegates to an in-memory repository but refuses the two writes that carry +/// outbox events (`create`, `set_complete`), leaving `update_status` and reads +/// working so a refusal can be observed mid-flow. +pub struct RefusingWrapUpWrites { + pub inner: Arc, +} + +impl RefusingWrapUpWrites { + pub fn new(inner: Arc) -> Arc { + Arc::new(Self { inner }) + } +} + +#[async_trait] +impl WrapUpRepository for RefusingWrapUpWrites { + async fn create( + &self, + _: &crate::models::wrapup::WrapUpRecord, + _: &[DomainEvent], + ) -> Result<(), DomainError> { + Err(DomainError::InfrastructureError("refused".into())) + } + async fn update_status( + &self, + id: &WrapUpId, + status: &crate::models::wrapup::WrapUpStatus, + error: Option<&str>, + ) -> Result<(), DomainError> { + self.inner.update_status(id, status, error).await + } + async fn set_complete( + &self, + _: &WrapUpId, + _: &crate::models::wrapup::WrapUpReport, + _: &[DomainEvent], + ) -> Result<(), DomainError> { + Err(DomainError::InfrastructureError("refused".into())) + } + async fn get_by_id( + &self, + id: &WrapUpId, + ) -> Result, DomainError> { + self.inner.get_by_id(id).await + } + async fn list_for_user( + &self, + user_id: Uuid, + ) -> Result, DomainError> { + self.inner.list_for_user(user_id).await + } + async fn list_global(&self) -> Result, DomainError> { + self.inner.list_global().await + } + async fn find_existing( + &self, + user_id: Option, + start: chrono::NaiveDate, + end: chrono::NaiveDate, + ) -> Result, DomainError> { + self.inner.find_existing(user_id, start, end).await + } + async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError> { + self.inner.delete(id).await + } + async fn delete_failed_older_than( + &self, + before: chrono::NaiveDateTime, + ) -> Result { + self.inner.delete_failed_older_than(before).await + } +} diff --git a/crates/domain/tests/testing/in_memory_store_test.rs b/crates/domain/tests/testing/in_memory_store_test.rs index a9f68af1..f8b9c69d 100644 --- a/crates/domain/tests/testing/in_memory_store_test.rs +++ b/crates/domain/tests/testing/in_memory_store_test.rs @@ -70,7 +70,7 @@ async fn seed_user_with_dependents(store: &Arc) -> (UserId, Movie let movie = test_movie("Heat"); UserRepository::save(&**store, &user).await.unwrap(); UserRepository::save(&**store, &other).await.unwrap(); - store.upsert_movie(&movie).await.unwrap(); + store.upsert_movie(&movie, &[]).await.unwrap(); store .add( @@ -118,6 +118,7 @@ async fn seed_user_with_dependents(store: &Arc) -> (UserId, Movie test_watched_at(), None, ), + &[], ) .await .unwrap(); @@ -221,7 +222,7 @@ async fn seed_movie_with_dependents( let user = test_user("alice"); UserRepository::save(&**store, &user).await.unwrap(); let movie = test_movie("Heat"); - store.upsert_movie(&movie).await.unwrap(); + store.upsert_movie(&movie, &[]).await.unwrap(); store .add( &WatchlistEntry::new(user.id().clone(), movie.id().clone()), @@ -241,6 +242,7 @@ async fn seed_movie_with_dependents( test_watched_at(), Some(movie.id().clone()), ), + &[], ) .await .unwrap(); @@ -261,7 +263,7 @@ async fn deleting_a_movie_removes_watchlist_entries_and_enrichment_profile() { assert!(store.rows_in(table) > 0, "{table} should be seeded"); } - store.delete_movie(movie.id()).await.unwrap(); + store.delete_movie(movie.id(), &[]).await.unwrap(); assert_eq!(store.movie_count(), 0); for table in MOVIE_CASCADE_TABLES { @@ -274,7 +276,7 @@ async fn deleting_a_movie_detaches_watch_events_instead_of_removing_them() { let store = InMemoryStore::new(); let (movie, user_id) = seed_movie_with_dependents(&store, false).await; - store.delete_movie(movie.id()).await.unwrap(); + store.delete_movie(movie.id(), &[]).await.unwrap(); let pending = store.list_pending(&user_id).await.unwrap(); assert_eq!(pending.len(), 1); @@ -286,7 +288,7 @@ async fn deleting_a_movie_that_has_reviews_is_refused_because_reviews_do_not_cas let store = InMemoryStore::new(); let (movie, _) = seed_movie_with_dependents(&store, true).await; - let result = store.delete_movie(movie.id()).await; + let result = store.delete_movie(movie.id(), &[]).await; assert!(matches!(result, Err(DomainError::InfrastructureError(_)))); assert_eq!(store.movie_count(), 1); @@ -367,12 +369,15 @@ async fn upserting_a_different_movie_with_a_taken_external_id_is_an_infrastructu let store = InMemoryStore::new(); let external = ExternalMetadataId::new("tt0113277".into()).unwrap(); store - .upsert_movie(&test_movie_with_external_id("Heat", 1995, external.clone())) + .upsert_movie( + &test_movie_with_external_id("Heat", 1995, external.clone()), + &[], + ) .await .unwrap(); let result = store - .upsert_movie(&test_movie_with_external_id("Heat 2", 2030, external)) + .upsert_movie(&test_movie_with_external_id("Heat 2", 2030, external), &[]) .await; assert!(matches!(result, Err(DomainError::InfrastructureError(_)))); @@ -395,3 +400,117 @@ async fn creating_a_refresh_session_with_a_taken_token_is_an_infrastructure_erro assert!(matches!(result, Err(DomainError::InfrastructureError(_)))); } + +fn movie_discovered(movie: &Movie) -> domain::events::DomainEvent { + domain::events::DomainEvent::MovieDiscovered { + movie_id: movie.id().clone(), + external_metadata_id: ExternalMetadataId::new("tmdb:1".into()).unwrap(), + } +} + +fn movie_deleted(movie: &Movie) -> domain::events::DomainEvent { + domain::events::DomainEvent::MovieDeleted { + movie_id: movie.id().clone(), + poster_path: None, + } +} + +#[tokio::test] +async fn upserting_a_movie_records_its_events() { + let store = InMemoryStore::new(); + let movie = test_movie("Heat"); + + store + .upsert_movie(&movie, &[movie_discovered(&movie)]) + .await + .unwrap(); + + assert_eq!(store.movie_count(), 1); + assert_eq!(store.recorded_events().len(), 1); +} + +#[tokio::test] +async fn a_refused_movie_upsert_records_no_events() { + let store = InMemoryStore::new(); + let external = ExternalMetadataId::new("tt0113277".into()).unwrap(); + let first = test_movie_with_external_id("Heat", 1995, external.clone()); + store.upsert_movie(&first, &[]).await.unwrap(); + let clash = test_movie_with_external_id("Heat 2", 2030, external); + + let result = store + .upsert_movie(&clash, &[movie_discovered(&clash)]) + .await; + + assert!(result.is_err()); + assert!(store.recorded_events().is_empty()); +} + +#[tokio::test] +async fn deleting_a_movie_records_its_events() { + let store = InMemoryStore::new(); + let movie = test_movie("Heat"); + store.upsert_movie(&movie, &[]).await.unwrap(); + + store + .delete_movie(movie.id(), &[movie_deleted(&movie)]) + .await + .unwrap(); + + assert_eq!(store.recorded_events().len(), 1); +} + +#[tokio::test] +async fn deleting_an_absent_movie_records_no_events() { + let store = InMemoryStore::new(); + let movie = test_movie("Heat"); + + store + .delete_movie(movie.id(), &[movie_deleted(&movie)]) + .await + .unwrap(); + + assert!(store.recorded_events().is_empty()); +} + +#[tokio::test] +async fn a_refused_movie_delete_records_no_events() { + let store = InMemoryStore::new(); + let (movie, _) = seed_movie_with_dependents(&store, true).await; + + let result = store + .delete_movie(movie.id(), &[movie_deleted(&movie)]) + .await; + + assert!(result.is_err()); + assert!(store.recorded_events().is_empty()); +} + +#[tokio::test] +async fn saving_a_watch_event_records_its_events() { + let store = InMemoryStore::new(); + let user_id = UserId::generate(); + let event = WatchEvent::new( + user_id.clone(), + "Heat".into(), + Some(1995), + None, + WatchEventSource::Jellyfin, + test_watched_at(), + None, + ); + + WatchEventCommand::save( + &*store, + &event, + &[domain::events::DomainEvent::WatchEventIngested { + user_id, + title: "Heat".into(), + source: "jellyfin".into(), + }], + ) + .await + .unwrap(); + + assert_eq!(store.rows_in("watch_events"), 1); + assert_eq!(store.recorded_events().len(), 1); +} -- 2.49.1 From 9a4d0c1714eedb2a99c624af4239ee75a936211c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 01:12:58 +0100 Subject: [PATCH 063/104] feat: atomic social local writes, remote review enrichment SocialCommand methods take events. Local follow ops = one FollowCommand::apply tx (rows + events). k_ap path (remote, block/unblock): best-effort publish in CompositeSocialAdapter after k_ap write; not atomic, k_ap owns tx. save_remote_review takes events, enqueued in same tx via sqlite::persistence::enqueue_events. Deps drop event_publisher: social command, ReviewObjectHandler. Remaining .publish( sites: announce-only: users/delete_account UserDeleted, person/get + get_credits PersonEnrichmentRequested, jobs/enrichment_staleness, http-axum search, ImageStored (sync_poster, poster_sync_handler, image-converter backfill, update_profile; object storage), federation_event_bridge (handler reaction), nats relay (transport). non-atomic by necessity: CompositeSocialAdapter::announce (k_ap-owned write). Everything else rides its write. --- crates/adapters/activitypub/src/lib.rs | 1 - .../src/remote_review_repository.rs | 4 +- .../activitypub/src/review_handler.rs | 30 +-- .../activitypub/src/social_adapter.rs | 127 +++++++--- crates/adapters/sqlite-federation/Cargo.toml | 1 + .../adapters/sqlite-federation/src/review.rs | 16 +- .../sqlite-federation/tests/federation.rs | 2 + .../tests/federation/remote_review_test.rs | 115 +++++++++ crates/adapters/sqlite/src/lib.rs | 2 +- .../src/repositories/social/follow/command.rs | 230 +++++++++--------- crates/adapters/sqlite/src/shared.rs | 2 +- crates/adapters/sqlite/tests/cascade_test.rs | 23 +- .../tests/repositories/outbox_writes_test.rs | 82 +++++++ crates/application/src/social/deps.rs | 3 +- crates/application/src/social/execute.rs | 63 +++-- .../application/src/social/local_service.rs | 130 +++++++--- .../application/tests/social/execute_test.rs | 128 +++++++--- .../tests/social/local_service_test.rs | 209 ++++++++++------ .../tests/users/get_page_viewer_test.rs | 4 +- crates/bootstrap/src/build.rs | 1 - crates/bootstrap/src/federation.rs | 1 + crates/bootstrap/tests/bootstrap_test.rs | 70 ++++++ crates/domain/src/ports/follow.rs | 77 +++--- crates/domain/src/ports/noop.rs | 43 +++- crates/domain/src/ports/social.rs | 34 ++- crates/domain/src/testing/fakes/refusing.rs | 79 +++++- .../domain/src/testing/fakes/store_social.rs | 35 ++- crates/domain/tests/ports/social_test.rs | 8 + .../tests/testing/in_memory_store_test.rs | 3 + 29 files changed, 1115 insertions(+), 408 deletions(-) create mode 100644 crates/adapters/sqlite-federation/tests/federation/remote_review_test.rs diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index 3fa93716..096a1056 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -101,7 +101,6 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { movie_repo: std::sync::Arc::clone(&movie_repo), diary_repo, review_store, - event_publisher: std::sync::Arc::clone(&event_publisher), instance: instance.clone(), }); let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler { diff --git a/crates/adapters/activitypub/src/remote_review_repository.rs b/crates/adapters/activitypub/src/remote_review_repository.rs index 682d4384..b246a7cd 100644 --- a/crates/adapters/activitypub/src/remote_review_repository.rs +++ b/crates/adapters/activitypub/src/remote_review_repository.rs @@ -1,7 +1,7 @@ use anyhow::Result; use async_trait::async_trait; use chrono::NaiveDateTime; -use domain::models::Review; +use domain::{events::DomainEvent, models::Review}; pub struct RemoteReviewUpdate<'a> { pub ap_id: &'a str, @@ -16,6 +16,7 @@ pub struct RemoteReviewUpdate<'a> { #[async_trait] pub trait RemoteReviewRepository: Send + Sync { + #[allow(clippy::too_many_arguments)] async fn save_remote_review( &self, review: &Review, @@ -24,6 +25,7 @@ pub trait RemoteReviewRepository: Send + Sync { release_year: u16, external_metadata_id: Option<&str>, poster_url: Option<&str>, + events: &[DomainEvent], ) -> Result<()>; async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>; diff --git a/crates/adapters/activitypub/src/review_handler.rs b/crates/adapters/activitypub/src/review_handler.rs index 794bc8fb..387f4c5f 100644 --- a/crates/adapters/activitypub/src/review_handler.rs +++ b/crates/adapters/activitypub/src/review_handler.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use domain::{ events::DomainEvent, models::ReviewSource, - ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery}, + ports::{DiaryQuery, LocalApContentQuery, MovieQuery}, value_objects::{ Comment, ExternalMetadataId, InstanceIdentity, MovieId, Rating, ReviewId, UserId, }, @@ -21,7 +21,6 @@ pub struct ReviewObjectHandler { pub movie_repo: Arc, pub diary_repo: Arc, pub review_store: Arc, - pub event_publisher: Arc, pub instance: InstanceIdentity, } @@ -160,6 +159,20 @@ impl ApObjectHandler for ReviewObjectHandler { spoilers: obj.contains_spoilers, }); + // The enrichment request rides the review write (one transaction). + let events: Vec = obj + .external_metadata_id + .as_ref() + .and_then(|ext_id| ExternalMetadataId::new(ext_id.clone()).ok()) + .map( + |external_metadata_id| DomainEvent::MovieEnrichmentRequested { + movie_id: movie_id.clone(), + external_metadata_id, + }, + ) + .into_iter() + .collect(); + self.review_store .save_remote_review( &review, @@ -168,21 +181,10 @@ impl ApObjectHandler for ReviewObjectHandler { obj.release_year, obj.external_metadata_id.as_deref(), obj.poster_url.as_deref(), + &events, ) .await?; - if let Some(ref ext_id_str) = obj.external_metadata_id - && let Ok(external_metadata_id) = ExternalMetadataId::new(ext_id_str.clone()) - { - let _ = self - .event_publisher - .publish(&DomainEvent::MovieEnrichmentRequested { - movie_id: movie_id.clone(), - external_metadata_id, - }) - .await; - } - Ok(()) } diff --git a/crates/adapters/activitypub/src/social_adapter.rs b/crates/adapters/activitypub/src/social_adapter.rs index c0a16c78..e0a54ca2 100644 --- a/crates/adapters/activitypub/src/social_adapter.rs +++ b/crates/adapters/activitypub/src/social_adapter.rs @@ -3,7 +3,10 @@ use std::sync::Arc; use async_trait::async_trait; use domain::{ errors::DomainError, - ports::{BlockQuery, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository}, + events::DomainEvent, + ports::{ + BlockQuery, EventPublisher, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository, + }, value_objects::{ FollowRelation, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, UserId, }, @@ -16,6 +19,7 @@ pub struct CompositeSocialAdapter { ap_service: Arc, user_repo: Arc, instance: InstanceIdentity, + event_publisher: Arc, } impl CompositeSocialAdapter { @@ -24,12 +28,23 @@ impl CompositeSocialAdapter { ap_service: Arc, user_repo: Arc, instance: InstanceIdentity, + event_publisher: Arc, ) -> Self { Self { local, ap_service, user_repo, instance, + event_publisher, + } + } + + /// Best-effort publish for writes k_ap owns (no shared transaction). + async fn announce(&self, events: &[DomainEvent]) { + for event in events { + if let Err(e) = self.event_publisher.publish(event).await { + tracing::warn!("failed to publish {event:?} after k_ap write: {e}"); + } } } } @@ -40,11 +55,19 @@ fn ap_err(e: anyhow::Error) -> DomainError { #[async_trait] impl SocialCommand for CompositeSocialAdapter { - async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> { + async fn follow( + &self, + follower: &UserId, + target: &FollowTarget, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let identity = self.local.resolve_target(target).await?; if let SocialIdentity::Local(_) = identity { - return self.local.follow_resolved(follower, &identity).await; + return self + .local + .follow_resolved(follower, &identity, events) + .await; } let handle = match target { @@ -61,24 +84,33 @@ impl SocialCommand for CompositeSocialAdapter { SocialIdentity::Remote { actor_url } => actor_url.clone(), }, }; + // Not atomic: k_ap owns the transaction, so the events are published + // best-effort after its write succeeds. self.ap_service .follow(follower.value(), &handle) .await - .map_err(ap_err) + .map_err(ap_err)?; + self.announce(events).await; + Ok(()) } async fn unfollow( &self, follower: &UserId, target: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError> { match target { - SocialIdentity::Local(_) => self.local.unfollow(follower, target).await, - SocialIdentity::Remote { .. } => self - .ap_service - .unfollow(follower.value(), &self.instance.actor_url_of(target)) - .await - .map_err(ap_err), + SocialIdentity::Local(_) => self.local.unfollow(follower, target, events).await, + // Not atomic: k_ap owns the transaction. + SocialIdentity::Remote { .. } => { + self.ap_service + .unfollow(follower.value(), &self.instance.actor_url_of(target)) + .await + .map_err(ap_err)?; + self.announce(events).await; + Ok(()) + } } } @@ -86,14 +118,19 @@ impl SocialCommand for CompositeSocialAdapter { &self, owner: &UserId, requester: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError> { match requester { - SocialIdentity::Local(_) => self.local.accept_follow(owner, requester).await, - SocialIdentity::Remote { .. } => self - .ap_service - .accept_follower(owner.value(), &self.instance.actor_url_of(requester)) - .await - .map_err(ap_err), + SocialIdentity::Local(_) => self.local.accept_follow(owner, requester, events).await, + // Not atomic: k_ap owns the transaction. + SocialIdentity::Remote { .. } => { + self.ap_service + .accept_follower(owner.value(), &self.instance.actor_url_of(requester)) + .await + .map_err(ap_err)?; + self.announce(events).await; + Ok(()) + } } } @@ -101,14 +138,19 @@ impl SocialCommand for CompositeSocialAdapter { &self, owner: &UserId, requester: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError> { match requester { - SocialIdentity::Local(_) => self.local.reject_follow(owner, requester).await, - SocialIdentity::Remote { .. } => self - .ap_service - .reject_follower(owner.value(), &self.instance.actor_url_of(requester)) - .await - .map_err(ap_err), + SocialIdentity::Local(_) => self.local.reject_follow(owner, requester, events).await, + // Not atomic: k_ap owns the transaction. + SocialIdentity::Remote { .. } => { + self.ap_service + .reject_follower(owner.value(), &self.instance.actor_url_of(requester)) + .await + .map_err(ap_err)?; + self.announce(events).await; + Ok(()) + } } } @@ -116,31 +158,52 @@ impl SocialCommand for CompositeSocialAdapter { &self, owner: &UserId, follower: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError> { match follower { - SocialIdentity::Local(_) => self.local.remove_follower(owner, follower).await, - SocialIdentity::Remote { .. } => self - .ap_service - .remove_follower(owner.value(), &self.instance.actor_url_of(follower)) - .await - .map_err(ap_err), + SocialIdentity::Local(_) => self.local.remove_follower(owner, follower, events).await, + // Not atomic: k_ap owns the transaction. + SocialIdentity::Remote { .. } => { + self.ap_service + .remove_follower(owner.value(), &self.instance.actor_url_of(follower)) + .await + .map_err(ap_err)?; + self.announce(events).await; + Ok(()) + } } } - async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> { + /// Not atomic: k_ap owns the transaction. + async fn block( + &self, + blocker: &UserId, + target: &SocialIdentity, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let actor_url = self.instance.actor_url_of(target); self.ap_service .block_actor(blocker.value(), &actor_url) .await - .map_err(ap_err) + .map_err(ap_err)?; + self.announce(events).await; + Ok(()) } - async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> { + /// Not atomic: k_ap owns the transaction. + async fn unblock( + &self, + blocker: &UserId, + target: &SocialIdentity, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let actor_url = self.instance.actor_url_of(target); self.ap_service .unblock_actor(blocker.value(), &actor_url) .await - .map_err(ap_err) + .map_err(ap_err)?; + self.announce(events).await; + Ok(()) } } diff --git a/crates/adapters/sqlite-federation/Cargo.toml b/crates/adapters/sqlite-federation/Cargo.toml index 0f7549f1..4ce7d6bd 100644 --- a/crates/adapters/sqlite-federation/Cargo.toml +++ b/crates/adapters/sqlite-federation/Cargo.toml @@ -18,4 +18,5 @@ async-trait = { workspace = true } [dev-dependencies] tokio = { workspace = true } +domain = { workspace = true, features = ["test-helpers"] } uuid = { workspace = true } diff --git a/crates/adapters/sqlite-federation/src/review.rs b/crates/adapters/sqlite-federation/src/review.rs index c648c684..8646343c 100644 --- a/crates/adapters/sqlite-federation/src/review.rs +++ b/crates/adapters/sqlite-federation/src/review.rs @@ -1,13 +1,17 @@ use activitypub::{RemoteReviewRepository, RemoteReviewUpdate}; use anyhow::{Result, anyhow}; use async_trait::async_trait; -use domain::models::{Review, ReviewSource}; +use domain::{ + events::DomainEvent, + models::{Review, ReviewSource}, +}; use super::SqliteFederationRepository; -use sqlite::persistence::datetime_to_str; +use sqlite::persistence::{datetime_to_str, enqueue_events}; #[async_trait] impl RemoteReviewRepository for SqliteFederationRepository { + #[allow(clippy::too_many_arguments)] async fn save_remote_review( &self, review: &Review, @@ -16,6 +20,7 @@ impl RemoteReviewRepository for SqliteFederationRepository { release_year: u16, external_metadata_id: Option<&str>, poster_url: Option<&str>, + events: &[DomainEvent], ) -> Result<()> { let actor_url = match review.source() { ReviewSource::Remote { actor_url } => actor_url.clone(), @@ -24,6 +29,7 @@ impl RemoteReviewRepository for SqliteFederationRepository { } }; let movie_id = review.movie_id().value().to_string(); + let mut tx = self.pool.begin().await?; let _ = sqlx::query( "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) VALUES (?, ?, ?, ?, NULL, ?) @@ -36,7 +42,7 @@ impl RemoteReviewRepository for SqliteFederationRepository { .bind(movie_title) .bind(release_year.max(1888) as i64) .bind(poster_url) - .execute(&self.pool) + .execute(&mut *tx) .await?; let id = review.id().value().to_string(); @@ -59,8 +65,10 @@ impl RemoteReviewRepository for SqliteFederationRepository { .bind(&actor_url) .bind(ap_id) .bind(review.spoilers()) - .execute(&self.pool) + .execute(&mut *tx) .await?; + enqueue_events(&mut tx, events).await?; + tx.commit().await?; Ok(()) } diff --git a/crates/adapters/sqlite-federation/tests/federation.rs b/crates/adapters/sqlite-federation/tests/federation.rs index 3387415d..e44fcd1d 100644 --- a/crates/adapters/sqlite-federation/tests/federation.rs +++ b/crates/adapters/sqlite-federation/tests/federation.rs @@ -4,3 +4,5 @@ mod actor_block_test; mod announce_test; #[path = "federation/domain_block_test.rs"] mod domain_block_test; +#[path = "federation/remote_review_test.rs"] +mod remote_review_test; diff --git a/crates/adapters/sqlite-federation/tests/federation/remote_review_test.rs b/crates/adapters/sqlite-federation/tests/federation/remote_review_test.rs new file mode 100644 index 00000000..5e8e083f --- /dev/null +++ b/crates/adapters/sqlite-federation/tests/federation/remote_review_test.rs @@ -0,0 +1,115 @@ +//! A remote review and the enrichment request it triggers commit together. + +use activitypub::RemoteReviewRepository; +use domain::{ + events::DomainEvent, + models::{PersistedReview, Review, ReviewSource}, + value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId}, +}; +use sqlite_federation::SqliteFederationRepository; +use sqlx::SqlitePool; + +const ACTOR: &str = "https://remote.example/users/ann"; +const AP_ID: &str = "https://remote.example/reviews/1"; + +async fn migrated_pool() -> SqlitePool { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlite::migrate(&pool).await.unwrap(); + pool +} + +async fn count(pool: &SqlitePool, table: &str) -> i64 { + sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}")) + .fetch_one(pool) + .await + .unwrap() +} + +fn remote_review(movie_id: &MovieId) -> Review { + let now = chrono::Utc::now().naive_utc(); + Review::from_persistence(PersistedReview { + id: ReviewId::generate(), + movie_id: movie_id.clone(), + user_id: UserId::generate(), + rating: Rating::new(4).unwrap(), + comment: Some(Comment::new("good".into()).unwrap()), + watched_at: now, + created_at: now, + source: ReviewSource::Remote { + actor_url: ACTOR.into(), + }, + watch_medium: None, + spoilers: false, + }) +} + +fn enrichment_requested(movie_id: &MovieId) -> DomainEvent { + DomainEvent::MovieEnrichmentRequested { + movie_id: movie_id.clone(), + external_metadata_id: ExternalMetadataId::new("tmdb:603".into()).unwrap(), + } +} + +async fn save(pool: &SqlitePool, review: &Review, events: &[DomainEvent]) -> anyhow::Result<()> { + SqliteFederationRepository::new(pool.clone()) + .save_remote_review( + review, + AP_ID, + "The Matrix", + 1999, + Some("tmdb:603"), + None, + events, + ) + .await +} + +#[tokio::test] +async fn saving_a_remote_review_writes_it_and_its_event_in_one_commit() { + let pool = migrated_pool().await; + let movie_id = MovieId::generate(); + let review = remote_review(&movie_id); + + save(&pool, &review, &[enrichment_requested(&movie_id)]) + .await + .unwrap(); + + assert_eq!(count(&pool, "reviews").await, 1); + assert_eq!(count(&pool, "movies").await, 1); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_the_remote_review_and_its_movie() { + let pool = migrated_pool().await; + sqlx::query( + "CREATE TRIGGER fail_event_insert BEFORE INSERT ON event_queue \ + BEGIN SELECT RAISE(ABORT, 'enqueue failed'); END", + ) + .execute(&pool) + .await + .unwrap(); + let movie_id = MovieId::generate(); + let review = remote_review(&movie_id); + + let result = save(&pool, &review, &[enrichment_requested(&movie_id)]).await; + + assert!(result.is_err()); + assert_eq!(count(&pool, "reviews").await, 0); + assert_eq!(count(&pool, "movies").await, 0); +} + +#[tokio::test] +async fn a_remote_review_without_events_enqueues_nothing() { + let pool = migrated_pool().await; + let movie_id = MovieId::generate(); + + save(&pool, &remote_review(&movie_id), &[]).await.unwrap(); + + assert_eq!(count(&pool, "reviews").await, 1); + assert_eq!(count(&pool, "event_queue").await, 0); +} diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index eeada475..0acc52e4 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -22,7 +22,7 @@ pub use repositories::{ /// Row helpers shared with the `sqlite-federation` crate (which cannot live /// here because it depends on `activitypub`/`k-ap`). pub mod persistence { - pub use crate::shared::datetime_to_str; + pub use crate::shared::{datetime_to_str, enqueue_events}; } pub fn create_profile_fields_repo( diff --git a/crates/adapters/sqlite/src/repositories/social/follow/command.rs b/crates/adapters/sqlite/src/repositories/social/follow/command.rs index 7424c2a7..aa859e7b 100644 --- a/crates/adapters/sqlite/src/repositories/social/follow/command.rs +++ b/crates/adapters/sqlite/src/repositories/social/follow/command.rs @@ -1,130 +1,126 @@ use async_trait::async_trait; use chrono::Utc; -use domain::{errors::DomainError, value_objects::FollowStatus}; +use domain::{ + errors::DomainError, + events::DomainEvent, + ports::{FollowCommand, FollowWrite}, +}; -use crate::shared::datetime_to_str; +use crate::shared::{datetime_to_str, enqueue_events}; use super::infra_err; use super::rows::follow_status_to_str; use crate::repositories::social::SqliteSocialRepository; #[async_trait] -impl domain::ports::FollowCommand for SqliteSocialRepository { - async fn add_follow( +impl FollowCommand for SqliteSocialRepository { + async fn apply( &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - status: FollowStatus, + writes: &[FollowWrite], + events: &[DomainEvent], ) -> Result<(), DomainError> { - let uid = follower_id.to_string(); - let status_str = follow_status_to_str(&status); - let now = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query( - "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status) - VALUES (?1, ?2, '', ?3, ?4) - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status", - ) - .bind(&uid) - .bind(target_actor_url) - .bind(&now) - .bind(status_str) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn update_follow_status( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = follower_id.to_string(); - let status_str = follow_status_to_str(&status); - sqlx::query( - "UPDATE ap_following SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3", - ) - .bind(status_str) - .bind(&uid) - .bind(target_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn remove_follow( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - ) -> Result<(), DomainError> { - let uid = follower_id.to_string(); - sqlx::query("DELETE FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2") - .bind(&uid) - .bind(target_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn add_follower( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = local_user_id.to_string(); - let status_str = follow_status_to_str(&status); - let now = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query( - "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) - VALUES (?1, ?2, ?3, ?4, '') - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status", - ) - .bind(&uid) - .bind(follower_actor_url) - .bind(status_str) - .bind(&now) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn update_follower_status( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = local_user_id.to_string(); - let status_str = follow_status_to_str(&status); - sqlx::query( - "UPDATE ap_followers SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3", - ) - .bind(status_str) - .bind(&uid) - .bind(follower_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn remove_follower_record( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - ) -> Result<(), DomainError> { - let uid = local_user_id.to_string(); - sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2") - .bind(&uid) - .bind(follower_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) + let mut tx = self.pool.begin().await.map_err(infra_err)?; + for write in writes { + apply_write(&mut tx, write).await?; + } + enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(infra_err) } } + +async fn apply_write( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + write: &FollowWrite, +) -> Result<(), DomainError> { + let now = datetime_to_str(&Utc::now().naive_utc()); + match write { + FollowWrite::AddFollow { + follower_id, + target_actor_url, + status, + } => { + sqlx::query( + "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status) + VALUES (?1, ?2, '', ?3, ?4) + ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status", + ) + .bind(follower_id.to_string()) + .bind(target_actor_url) + .bind(&now) + .bind(follow_status_to_str(status)) + .execute(&mut **tx) + .await + } + FollowWrite::UpdateFollowStatus { + follower_id, + target_actor_url, + status, + } => { + sqlx::query( + "UPDATE ap_following SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3", + ) + .bind(follow_status_to_str(status)) + .bind(follower_id.to_string()) + .bind(target_actor_url) + .execute(&mut **tx) + .await + } + FollowWrite::RemoveFollow { + follower_id, + target_actor_url, + } => { + sqlx::query( + "DELETE FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2", + ) + .bind(follower_id.to_string()) + .bind(target_actor_url) + .execute(&mut **tx) + .await + } + FollowWrite::AddFollower { + local_user_id, + follower_actor_url, + status, + } => { + sqlx::query( + "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) + VALUES (?1, ?2, ?3, ?4, '') + ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status", + ) + .bind(local_user_id.to_string()) + .bind(follower_actor_url) + .bind(follow_status_to_str(status)) + .bind(&now) + .execute(&mut **tx) + .await + } + FollowWrite::UpdateFollowerStatus { + local_user_id, + follower_actor_url, + status, + } => { + sqlx::query( + "UPDATE ap_followers SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3", + ) + .bind(follow_status_to_str(status)) + .bind(local_user_id.to_string()) + .bind(follower_actor_url) + .execute(&mut **tx) + .await + } + FollowWrite::RemoveFollowerRecord { + local_user_id, + follower_actor_url, + } => { + sqlx::query( + "DELETE FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2", + ) + .bind(local_user_id.to_string()) + .bind(follower_actor_url) + .execute(&mut **tx) + .await + } + } + .map_err(infra_err)?; + Ok(()) +} diff --git a/crates/adapters/sqlite/src/shared.rs b/crates/adapters/sqlite/src/shared.rs index 151839d0..035e240f 100644 --- a/crates/adapters/sqlite/src/shared.rs +++ b/crates/adapters/sqlite/src/shared.rs @@ -13,7 +13,7 @@ use domain::{ /// THE outbox write path: inserts one `event_queue` row per event, each with a /// fresh `EventId`, inside the caller's transaction so a change and the events /// announcing it commit or roll back together. -pub(crate) async fn enqueue_events( +pub async fn enqueue_events( tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, events: &[domain::events::DomainEvent], ) -> Result<(), DomainError> { diff --git a/crates/adapters/sqlite/tests/cascade_test.rs b/crates/adapters/sqlite/tests/cascade_test.rs index eba3f58b..54eac773 100644 --- a/crates/adapters/sqlite/tests/cascade_test.rs +++ b/crates/adapters/sqlite/tests/cascade_test.rs @@ -11,7 +11,8 @@ use domain::models::{ WatchEvent, WatchEventSource, WatchlistEntry, WebhookToken, }; use domain::ports::{ - FollowCommand, UserProfileFieldsRepository, WatchEventCommand, WebhookTokenRepository, + FollowCommand, FollowWrite, UserProfileFieldsRepository, WatchEventCommand, + WebhookTokenRepository, }; use domain::testing::{ MOVIE_CASCADE_TABLES, USER_CASCADE_TABLES, USER_CASCADE_TABLES_OUTSIDE_STORE, test_goal, @@ -184,11 +185,21 @@ async fn seed_user_with_dependents( domain::value_objects::InstanceIdentity::new("https://md.example"), ); social - .add_follow(user_id.value(), REMOTE_ACTOR, FollowStatus::Accepted) - .await - .unwrap(); - social - .add_follower(user_id.value(), REMOTE_ACTOR, FollowStatus::Accepted) + .apply( + &[ + FollowWrite::AddFollow { + follower_id: user_id.value(), + target_actor_url: REMOTE_ACTOR.into(), + status: FollowStatus::Accepted, + }, + FollowWrite::AddFollower { + local_user_id: user_id.value(), + follower_actor_url: REMOTE_ACTOR.into(), + status: FollowStatus::Accepted, + }, + ], + &[], + ) .await .unwrap(); // No repository in this crate owns these two tables (federation crate). diff --git a/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs b/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs index ff145b0a..0a64450a 100644 --- a/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs +++ b/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs @@ -712,3 +712,85 @@ async fn a_failing_enqueue_rolls_back_the_watch_event_save() { assert!(result.is_err()); assert_eq!(count(&pool, "watch_events").await, 0); } + +fn follow_writes(user_id: &UserId) -> Vec { + use domain::{ports::FollowWrite, value_objects::FollowStatus}; + vec![ + FollowWrite::AddFollower { + local_user_id: user_id.value(), + follower_actor_url: "https://md.example/users/other".into(), + status: FollowStatus::Pending, + }, + FollowWrite::AddFollow { + follower_id: user_id.value(), + target_actor_url: "https://md.example/users/other".into(), + status: FollowStatus::Pending, + }, + ] +} + +fn follow_requested(user_id: &UserId) -> DomainEvent { + DomainEvent::FollowRequested { + follower: user_id.clone(), + target: domain::value_objects::FollowTarget::Handle("@other@md.example".into()), + } +} + +fn social_repo(pool: &SqlitePool) -> sqlite::SqliteSocialRepository { + sqlite::SqliteSocialRepository::new( + pool.clone(), + domain::value_objects::InstanceIdentity::new("https://md.example"), + ) +} + +#[tokio::test] +async fn a_local_follow_writes_both_rows_and_its_event_in_one_commit() { + use domain::ports::FollowCommand; + let (pool, user) = pool_with_user().await; + + social_repo(&pool) + .apply(&follow_writes(user.id()), &[follow_requested(user.id())]) + .await + .unwrap(); + + assert_eq!(count(&pool, "ap_followers").await, 1); + assert_eq!(count(&pool, "ap_following").await, 1); + assert_eq!(count(&pool, "event_queue").await, 1); +} + +#[tokio::test] +async fn a_failing_enqueue_rolls_back_both_follow_rows() { + use domain::ports::FollowCommand; + let (pool, user) = pool_with_user().await; + fail_event_inserts(&pool).await; + + let result = social_repo(&pool) + .apply(&follow_writes(user.id()), &[follow_requested(user.id())]) + .await; + + assert!(result.is_err()); + assert_eq!(count(&pool, "ap_followers").await, 0); + assert_eq!(count(&pool, "ap_following").await, 0); +} + +#[tokio::test] +async fn a_failing_follow_write_rolls_back_the_rows_already_applied() { + use domain::ports::{FollowCommand, FollowWrite}; + let (pool, user) = pool_with_user().await; + let mut writes = follow_writes(user.id()); + // An unknown local user violates the ap_following foreign key after the + // follower row was already written. + writes.push(FollowWrite::AddFollow { + follower_id: uuid::Uuid::new_v4(), + target_actor_url: "https://md.example/users/other".into(), + status: domain::value_objects::FollowStatus::Pending, + }); + + let result = social_repo(&pool) + .apply(&writes, &[follow_requested(user.id())]) + .await; + + assert!(result.is_err()); + assert_eq!(count(&pool, "ap_followers").await, 0); + assert_eq!(count(&pool, "event_queue").await, 0); +} diff --git a/crates/application/src/social/deps.rs b/crates/application/src/social/deps.rs index 4e9dcf8d..ac9c7055 100644 --- a/crates/application/src/social/deps.rs +++ b/crates/application/src/social/deps.rs @@ -1,10 +1,9 @@ use std::sync::Arc; -use domain::ports::{BlockQuery, EventPublisher, FollowGraphQuery, SocialCommand}; +use domain::ports::{BlockQuery, FollowGraphQuery, SocialCommand}; pub struct SocialCommandDeps { pub social_command: Arc, - pub event_publisher: Arc, } pub struct SocialQueryDeps { diff --git a/crates/application/src/social/execute.rs b/crates/application/src/social/execute.rs index 6b928f0a..a773da24 100644 --- a/crates/application/src/social/execute.rs +++ b/crates/application/src/social/execute.rs @@ -2,61 +2,78 @@ use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; use super::{commands::SocialCmd, deps::SocialCommandDeps}; +/// The command's event rides the write: the social port records it with the +/// change (atomically where the adapter owns the transaction). pub async fn execute_command(deps: &SocialCommandDeps, cmd: SocialCmd) -> Result<(), DomainError> { - let event = match cmd { + let command = &deps.social_command; + match cmd { SocialCmd::Follow { follower_id, target, } => { let follower = UserId::from_uuid(follower_id); - deps.social_command.follow(&follower, &target).await?; - DomainEvent::FollowRequested { follower, target } + let events = [DomainEvent::FollowRequested { + follower: follower.clone(), + target: target.clone(), + }]; + command.follow(&follower, &target, &events).await } SocialCmd::Unfollow { follower_id, target, } => { let follower = UserId::from_uuid(follower_id); - deps.social_command.unfollow(&follower, &target).await?; - DomainEvent::Unfollowed { follower, target } + let events = [DomainEvent::Unfollowed { + follower: follower.clone(), + target: target.clone(), + }]; + command.unfollow(&follower, &target, &events).await } SocialCmd::AcceptFollow { owner_id, requester, } => { let owner = UserId::from_uuid(owner_id); - deps.social_command - .accept_follow(&owner, &requester) - .await?; - DomainEvent::FollowAccepted { owner, requester } + let events = [DomainEvent::FollowAccepted { + owner: owner.clone(), + requester: requester.clone(), + }]; + command.accept_follow(&owner, &requester, &events).await } SocialCmd::RejectFollow { owner_id, requester, } => { let owner = UserId::from_uuid(owner_id); - deps.social_command - .reject_follow(&owner, &requester) - .await?; - DomainEvent::FollowRejected { owner, requester } + let events = [DomainEvent::FollowRejected { + owner: owner.clone(), + requester: requester.clone(), + }]; + command.reject_follow(&owner, &requester, &events).await } SocialCmd::RemoveFollower { owner_id, follower } => { let owner = UserId::from_uuid(owner_id); - deps.social_command - .remove_follower(&owner, &follower) - .await?; - DomainEvent::FollowerRemoved { owner, follower } + let events = [DomainEvent::FollowerRemoved { + owner: owner.clone(), + follower: follower.clone(), + }]; + command.remove_follower(&owner, &follower, &events).await } SocialCmd::Block { blocker_id, target } => { let blocker = UserId::from_uuid(blocker_id); - deps.social_command.block(&blocker, &target).await?; - DomainEvent::ActorBlocked { blocker, target } + let events = [DomainEvent::ActorBlocked { + blocker: blocker.clone(), + target: target.clone(), + }]; + command.block(&blocker, &target, &events).await } SocialCmd::Unblock { blocker_id, target } => { let blocker = UserId::from_uuid(blocker_id); - deps.social_command.unblock(&blocker, &target).await?; - DomainEvent::ActorUnblocked { blocker, target } + let events = [DomainEvent::ActorUnblocked { + blocker: blocker.clone(), + target: target.clone(), + }]; + command.unblock(&blocker, &target, &events).await } - }; - deps.event_publisher.publish(&event).await + } } diff --git a/crates/application/src/social/local_service.rs b/crates/application/src/social/local_service.rs index 599362b4..69fcac60 100644 --- a/crates/application/src/social/local_service.rs +++ b/crates/application/src/social/local_service.rs @@ -3,9 +3,10 @@ use std::sync::Arc; use async_trait::async_trait; use domain::{ errors::DomainError, + events::DomainEvent, ports::{ BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, FollowTargetResolver, - ResolvedFollow, SocialCommand, UserRepository, + FollowWrite, ResolvedFollow, SocialCommand, UserRepository, }, value_objects::{ FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, @@ -72,27 +73,41 @@ fn remote_unsupported(actor: &str) -> DomainError { #[async_trait] impl SocialCommand for LocalSocialService { - async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> { + async fn follow( + &self, + follower: &UserId, + target: &FollowTarget, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let identity = self.resolve_target_identity(target).await?; - self.follow_resolved(follower, &identity).await + self.follow_resolved(follower, &identity, events).await } async fn unfollow( &self, follower: &UserId, target: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError> { let actor_url = self.instance.actor_url_of(target); match target { SocialIdentity::Local(target_id) => { let follower_url = self.instance.actor_url_for(follower); self.follow_command - .remove_follow(follower.value(), &actor_url) - .await?; - self.follow_command - .remove_follower_record(target_id.value(), &follower_url) - .await?; - Ok(()) + .apply( + &[ + FollowWrite::RemoveFollow { + follower_id: follower.value(), + target_actor_url: actor_url, + }, + FollowWrite::RemoveFollowerRecord { + local_user_id: target_id.value(), + follower_actor_url: follower_url, + }, + ], + events, + ) + .await } SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)), } @@ -102,18 +117,29 @@ impl SocialCommand for LocalSocialService { &self, owner: &UserId, requester: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError> { let actor_url = self.instance.actor_url_of(requester); match requester { SocialIdentity::Local(requester_id) => { let owner_url = self.instance.actor_url_for(owner); self.follow_command - .update_follower_status(owner.value(), &actor_url, FollowStatus::Accepted) - .await?; - self.follow_command - .update_follow_status(requester_id.value(), &owner_url, FollowStatus::Accepted) - .await?; - Ok(()) + .apply( + &[ + FollowWrite::UpdateFollowerStatus { + local_user_id: owner.value(), + follower_actor_url: actor_url, + status: FollowStatus::Accepted, + }, + FollowWrite::UpdateFollowStatus { + follower_id: requester_id.value(), + target_actor_url: owner_url, + status: FollowStatus::Accepted, + }, + ], + events, + ) + .await } SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)), } @@ -123,18 +149,28 @@ impl SocialCommand for LocalSocialService { &self, owner: &UserId, requester: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError> { let actor_url = self.instance.actor_url_of(requester); match requester { SocialIdentity::Local(requester_id) => { let owner_url = self.instance.actor_url_for(owner); self.follow_command - .update_follower_status(owner.value(), &actor_url, FollowStatus::Rejected) - .await?; - self.follow_command - .remove_follow(requester_id.value(), &owner_url) - .await?; - Ok(()) + .apply( + &[ + FollowWrite::UpdateFollowerStatus { + local_user_id: owner.value(), + follower_actor_url: actor_url, + status: FollowStatus::Rejected, + }, + FollowWrite::RemoveFollow { + follower_id: requester_id.value(), + target_actor_url: owner_url, + }, + ], + events, + ) + .await } SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)), } @@ -144,24 +180,38 @@ impl SocialCommand for LocalSocialService { &self, owner: &UserId, follower: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError> { let actor_url = self.instance.actor_url_of(follower); match follower { SocialIdentity::Local(follower_id) => { let owner_url = self.instance.actor_url_for(owner); self.follow_command - .remove_follower_record(owner.value(), &actor_url) - .await?; - self.follow_command - .remove_follow(follower_id.value(), &owner_url) - .await?; - Ok(()) + .apply( + &[ + FollowWrite::RemoveFollowerRecord { + local_user_id: owner.value(), + follower_actor_url: actor_url, + }, + FollowWrite::RemoveFollow { + follower_id: follower_id.value(), + target_actor_url: owner_url, + }, + ], + events, + ) + .await } SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)), } } - async fn block(&self, _blocker: &UserId, _target: &SocialIdentity) -> Result<(), DomainError> { + async fn block( + &self, + _blocker: &UserId, + _target: &SocialIdentity, + _events: &[DomainEvent], + ) -> Result<(), DomainError> { Err(DomainError::ValidationError( "blocking requires the federation feature".into(), )) @@ -171,6 +221,7 @@ impl SocialCommand for LocalSocialService { &self, _blocker: &UserId, _target: &SocialIdentity, + _events: &[DomainEvent], ) -> Result<(), DomainError> { Err(DomainError::ValidationError( "blocking requires the federation feature".into(), @@ -242,6 +293,7 @@ impl ResolvedFollow for LocalSocialService { &self, follower: &UserId, target: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError> { let SocialIdentity::Local(target_id) = target else { let actor_url = self.instance.actor_url_of(target); @@ -255,11 +307,21 @@ impl ResolvedFollow for LocalSocialService { let follower_url = self.instance.actor_url_for(follower); let target_url = self.instance.actor_url_for(target_id); self.follow_command - .add_follower(target_id.value(), &follower_url, FollowStatus::Pending) - .await?; - self.follow_command - .add_follow(follower.value(), &target_url, FollowStatus::Pending) - .await?; - Ok(()) + .apply( + &[ + FollowWrite::AddFollower { + local_user_id: target_id.value(), + follower_actor_url: follower_url, + status: FollowStatus::Pending, + }, + FollowWrite::AddFollow { + follower_id: follower.value(), + target_actor_url: target_url, + status: FollowStatus::Pending, + }, + ], + events, + ) + .await } } diff --git a/crates/application/tests/social/execute_test.rs b/crates/application/tests/social/execute_test.rs index cf8331c3..f246e0f2 100644 --- a/crates/application/tests/social/execute_test.rs +++ b/crates/application/tests/social/execute_test.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use domain::{ events::DomainEvent, - testing::{InMemoryStore, NoopEventPublisher}, + testing::{InMemoryStore, RefusingSocialCommand}, value_objects::{FollowStatus, FollowTarget, SocialIdentity, UserId}, }; use uuid::Uuid; @@ -13,25 +13,19 @@ use application::social::{ execute::execute_command, }; -fn make_cmd_deps() -> ( - Arc, - Arc, - SocialCommandDeps, -) { +fn make_cmd_deps() -> (Arc, SocialCommandDeps) { let social = InMemoryStore::new(); - let events = NoopEventPublisher::new(); let deps = SocialCommandDeps { social_command: Arc::clone(&social) as _, - event_publisher: Arc::clone(&events) as _, }; - (social, events, deps) + (social, deps) } // ── Follow ────────────────────────────────────────────────────────────────── #[tokio::test] async fn follow_emits_follow_requested_event() { - let (_social, events, deps) = make_cmd_deps(); + let (events, deps) = make_cmd_deps(); execute_command( &deps, @@ -45,7 +39,7 @@ async fn follow_emits_follow_requested_event() { .await .unwrap(); - let published = events.published(); + let published = events.recorded_events(); assert!( published .iter() @@ -55,7 +49,7 @@ async fn follow_emits_follow_requested_event() { #[tokio::test] async fn cannot_follow_yourself() { - let (_social, _events, deps) = make_cmd_deps(); + let (_social, deps) = make_cmd_deps(); let user_id = Uuid::new_v4(); let result = execute_command( @@ -72,7 +66,7 @@ async fn cannot_follow_yourself() { #[tokio::test] async fn cannot_follow_same_target_twice() { - let (_social, _events, deps) = make_cmd_deps(); + let (_social, deps) = make_cmd_deps(); let follower_id = Uuid::new_v4(); let target = FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()))); @@ -102,7 +96,7 @@ async fn cannot_follow_same_target_twice() { #[tokio::test] async fn unfollow_emits_unfollowed_event() { - let (_social, events, deps) = make_cmd_deps(); + let (events, deps) = make_cmd_deps(); let follower_id = Uuid::new_v4(); let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())); @@ -126,7 +120,7 @@ async fn unfollow_emits_unfollowed_event() { .await .unwrap(); - let published = events.published(); + let published = events.recorded_events(); assert!( published .iter() @@ -138,7 +132,7 @@ async fn unfollow_emits_unfollowed_event() { #[tokio::test] async fn accept_follow_emits_follow_accepted_event() { - let (_social, events, deps) = make_cmd_deps(); + let (events, deps) = make_cmd_deps(); let follower_id = Uuid::new_v4(); let owner_id = Uuid::new_v4(); let requester = SocialIdentity::Local(UserId::from_uuid(follower_id)); @@ -163,7 +157,7 @@ async fn accept_follow_emits_follow_accepted_event() { .await .unwrap(); - let published = events.published(); + let published = events.recorded_events(); assert!( published .iter() @@ -175,7 +169,7 @@ async fn accept_follow_emits_follow_accepted_event() { #[tokio::test] async fn reject_follow_emits_follow_rejected_event() { - let (_social, events, deps) = make_cmd_deps(); + let (events, deps) = make_cmd_deps(); let follower_id = Uuid::new_v4(); let owner_id = Uuid::new_v4(); @@ -199,7 +193,7 @@ async fn reject_follow_emits_follow_rejected_event() { .await .unwrap(); - let published = events.published(); + let published = events.recorded_events(); assert!( published .iter() @@ -211,7 +205,7 @@ async fn reject_follow_emits_follow_rejected_event() { #[tokio::test] async fn remove_follower_emits_follower_removed_event() { - let (_social, events, deps) = make_cmd_deps(); + let (events, deps) = make_cmd_deps(); let follower_id = Uuid::new_v4(); let owner_id = Uuid::new_v4(); @@ -245,7 +239,7 @@ async fn remove_follower_emits_follower_removed_event() { .await .unwrap(); - let published = events.published(); + let published = events.recorded_events(); assert!( published .iter() @@ -257,7 +251,7 @@ async fn remove_follower_emits_follower_removed_event() { #[tokio::test] async fn block_emits_actor_blocked_event() { - let (_social, events, deps) = make_cmd_deps(); + let (events, deps) = make_cmd_deps(); execute_command( &deps, @@ -269,7 +263,7 @@ async fn block_emits_actor_blocked_event() { .await .unwrap(); - let published = events.published(); + let published = events.recorded_events(); assert!( published .iter() @@ -281,7 +275,7 @@ async fn block_emits_actor_blocked_event() { #[tokio::test] async fn unblock_emits_actor_unblocked_event() { - let (_social, events, deps) = make_cmd_deps(); + let (events, deps) = make_cmd_deps(); let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())); let blocker_id = Uuid::new_v4(); @@ -299,7 +293,7 @@ async fn unblock_emits_actor_unblocked_event() { .await .unwrap(); - let published = events.published(); + let published = events.recorded_events(); assert!( published .iter() @@ -311,7 +305,7 @@ async fn unblock_emits_actor_unblocked_event() { #[tokio::test] async fn get_following_returns_accepted_targets() { - let (social, _events, cmd_deps) = make_cmd_deps(); + let (social, cmd_deps) = make_cmd_deps(); let query_deps = SocialQueryDeps { follow_graph: Arc::clone(&social) as _, block_query: Arc::clone(&social) as _, @@ -360,10 +354,8 @@ async fn get_following_returns_accepted_targets() { #[tokio::test] async fn returns_accepted_followers() { let social = InMemoryStore::new(); - let events = NoopEventPublisher::new(); let cmd_deps = SocialCommandDeps { social_command: Arc::clone(&social) as _, - event_publisher: Arc::clone(&events) as _, }; let query_deps = SocialQueryDeps { follow_graph: Arc::clone(&social) as _, @@ -404,10 +396,8 @@ async fn returns_accepted_followers() { #[tokio::test] async fn returns_only_pending_followers() { let social = InMemoryStore::new(); - let events = NoopEventPublisher::new(); let cmd_deps = SocialCommandDeps { social_command: Arc::clone(&social) as _, - event_publisher: Arc::clone(&events) as _, }; let query_deps = SocialQueryDeps { follow_graph: Arc::clone(&social) as _, @@ -439,7 +429,7 @@ async fn returns_only_pending_followers() { async fn get_relation_reports_none_for_strangers() { use domain::ports::FollowGraphQuery; - let (social, _events, _deps) = make_cmd_deps(); + let (social, _deps) = make_cmd_deps(); let rel = FollowGraphQuery::get_relation( &*social, &UserId::from_uuid(Uuid::new_v4()), @@ -456,7 +446,7 @@ async fn get_relation_reports_none_for_strangers() { async fn get_relation_reports_pending_before_acceptance() { use domain::ports::FollowGraphQuery; - let (social, _events, deps) = make_cmd_deps(); + let (social, deps) = make_cmd_deps(); let a = Uuid::new_v4(); let b_id = UserId::from_uuid(Uuid::new_v4()); @@ -486,7 +476,7 @@ async fn get_relation_reports_pending_before_acceptance() { async fn get_relation_reports_accepted_in_both_directions_after_mutual_follow() { use domain::ports::FollowGraphQuery; - let (social, _events, deps) = make_cmd_deps(); + let (social, deps) = make_cmd_deps(); let a = Uuid::new_v4(); let b = Uuid::new_v4(); let a_id = UserId::from_uuid(a); @@ -528,7 +518,7 @@ async fn get_relation_reports_accepted_in_both_directions_after_mutual_follow() async fn unaccepted_follow_appears_in_pending_following_not_in_following() { use domain::ports::FollowGraphQuery; - let (social, _events, deps) = make_cmd_deps(); + let (social, deps) = make_cmd_deps(); let viewer = Uuid::new_v4(); let target = UserId::from_uuid(Uuid::new_v4()); @@ -561,7 +551,7 @@ async fn unaccepted_follow_appears_in_pending_following_not_in_following() { #[tokio::test] async fn get_pending_following_returns_unaccepted_targets_and_get_following_does_not() { - let (social, _events, cmd_deps) = make_cmd_deps(); + let (social, cmd_deps) = make_cmd_deps(); let query_deps = SocialQueryDeps { follow_graph: Arc::clone(&social) as _, block_query: Arc::clone(&social) as _, @@ -599,7 +589,7 @@ async fn get_pending_following_returns_unaccepted_targets_and_get_following_does #[tokio::test] async fn count_pending_followers_drops_as_requests_are_accepted() { - let (social, _events, cmd_deps) = make_cmd_deps(); + let (social, cmd_deps) = make_cmd_deps(); let query_deps = SocialQueryDeps { follow_graph: Arc::clone(&social) as _, block_query: Arc::clone(&social) as _, @@ -648,7 +638,7 @@ async fn count_pending_followers_drops_as_requests_are_accepted() { #[tokio::test] async fn get_relation_use_case_reports_direction_asymmetrically() { - let (social, _events, cmd_deps) = make_cmd_deps(); + let (social, cmd_deps) = make_cmd_deps(); let query_deps = SocialQueryDeps { follow_graph: Arc::clone(&social) as _, block_query: Arc::clone(&social) as _, @@ -681,3 +671,67 @@ async fn get_relation_use_case_reports_direction_asymmetrically() { ); assert_eq!(rel.followed_by, None, "target has not followed back"); } + +// ── Events ride the write ─────────────────────────────────────────────────── + +#[tokio::test] +async fn a_follow_records_exactly_its_event_with_the_write() { + let (social, deps) = make_cmd_deps(); + + execute_command( + &deps, + SocialCmd::Follow { + follower_id: Uuid::new_v4(), + target: FollowTarget::Identity(SocialIdentity::Local( + UserId::from_uuid(Uuid::new_v4()), + )), + }, + ) + .await + .unwrap(); + + let recorded = social.recorded_events(); + assert_eq!(recorded.len(), 1); + assert!(matches!(recorded[0], DomainEvent::FollowRequested { .. })); +} + +#[tokio::test] +async fn a_rejected_self_follow_records_nothing() { + let (social, deps) = make_cmd_deps(); + let user_id = Uuid::new_v4(); + + let result = execute_command( + &deps, + SocialCmd::Follow { + follower_id: user_id, + target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(user_id))), + }, + ) + .await; + + assert!(result.is_err()); + assert!(social.recorded_events().is_empty()); +} + +#[tokio::test] +async fn a_refused_follow_propagates() { + let deps = SocialCommandDeps { + social_command: Arc::new(RefusingSocialCommand), + }; + + let result = execute_command( + &deps, + SocialCmd::Follow { + follower_id: Uuid::new_v4(), + target: FollowTarget::Identity(SocialIdentity::Local( + UserId::from_uuid(Uuid::new_v4()), + )), + }, + ) + .await; + + assert!(matches!( + result, + Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused" + )); +} diff --git a/crates/application/tests/social/local_service_test.rs b/crates/application/tests/social/local_service_test.rs index c2e9e7c2..9bacd900 100644 --- a/crates/application/tests/social/local_service_test.rs +++ b/crates/application/tests/social/local_service_test.rs @@ -4,8 +4,10 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::{ errors::DomainError, + events::DomainEvent, ports::{ - BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, SocialCommand, UserRepository, + BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, FollowWrite, SocialCommand, + UserRepository, }, testing::InMemoryStore, value_objects::{ @@ -30,6 +32,8 @@ struct FollowFakeStore { follows: Mutex>, // (local_user_id, follower_actor_url, status) — written by add_follower/update_follower_status/remove_follower_record followers: Mutex>, + recorded: Mutex>, + refuse_writes: bool, } impl FollowFakeStore { @@ -37,8 +41,23 @@ impl FollowFakeStore { Arc::new(Self { follows: Mutex::new(Vec::new()), followers: Mutex::new(Vec::new()), + recorded: Mutex::new(Vec::new()), + refuse_writes: false, }) } + + fn refusing() -> Arc { + Arc::new(Self { + follows: Mutex::new(Vec::new()), + followers: Mutex::new(Vec::new()), + recorded: Mutex::new(Vec::new()), + refuse_writes: true, + }) + } + + fn recorded_events(&self) -> Vec { + self.recorded.lock().unwrap().clone() + } } fn fake_actor(url: &str) -> SocialActor { @@ -54,86 +73,63 @@ fn fake_actor(url: &str) -> SocialActor { #[async_trait] impl FollowCommand for FollowFakeStore { - async fn add_follow( + async fn apply( &self, - follower_id: Uuid, - target_actor_url: &str, - status: FollowStatus, + writes: &[FollowWrite], + events: &[DomainEvent], ) -> Result<(), DomainError> { - self.follows - .lock() - .unwrap() - .push((follower_id, target_actor_url.to_string(), status)); - Ok(()) - } - - async fn update_follow_status( - &self, - follower_id: Uuid, - target_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let mut store = self.follows.lock().unwrap(); - if let Some(entry) = store - .iter_mut() - .find(|(f, t, _)| *f == follower_id && t == target_actor_url) - { - entry.2 = status; + if self.refuse_writes { + return Err(DomainError::InfrastructureError("refused".into())); } - Ok(()) - } - - async fn remove_follow( - &self, - follower_id: Uuid, - target_actor_url: &str, - ) -> Result<(), DomainError> { - self.follows - .lock() - .unwrap() - .retain(|(f, t, _)| !(*f == follower_id && t == target_actor_url)); - Ok(()) - } - - async fn add_follower( - &self, - local_user_id: Uuid, - follower_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - self.followers.lock().unwrap().push(( - local_user_id, - follower_actor_url.to_string(), - status, - )); - Ok(()) - } - - async fn update_follower_status( - &self, - local_user_id: Uuid, - follower_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let mut store = self.followers.lock().unwrap(); - if let Some(entry) = store - .iter_mut() - .find(|(u, f, _)| *u == local_user_id && f == follower_actor_url) - { - entry.2 = status; + let mut follows = self.follows.lock().unwrap(); + let mut followers = self.followers.lock().unwrap(); + for write in writes { + match write { + FollowWrite::AddFollow { + follower_id, + target_actor_url, + status, + } => follows.push((*follower_id, target_actor_url.clone(), *status)), + FollowWrite::UpdateFollowStatus { + follower_id, + target_actor_url, + status, + } => { + if let Some(entry) = follows + .iter_mut() + .find(|(f, t, _)| f == follower_id && t == target_actor_url) + { + entry.2 = *status; + } + } + FollowWrite::RemoveFollow { + follower_id, + target_actor_url, + } => follows.retain(|(f, t, _)| !(f == follower_id && t == target_actor_url)), + FollowWrite::AddFollower { + local_user_id, + follower_actor_url, + status, + } => followers.push((*local_user_id, follower_actor_url.clone(), *status)), + FollowWrite::UpdateFollowerStatus { + local_user_id, + follower_actor_url, + status, + } => { + if let Some(entry) = followers + .iter_mut() + .find(|(u, f, _)| u == local_user_id && f == follower_actor_url) + { + entry.2 = *status; + } + } + FollowWrite::RemoveFollowerRecord { + local_user_id, + follower_actor_url, + } => followers.retain(|(u, f, _)| !(u == local_user_id && f == follower_actor_url)), + } } - Ok(()) - } - - async fn remove_follower_record( - &self, - local_user_id: Uuid, - follower_actor_url: &str, - ) -> Result<(), DomainError> { - self.followers - .lock() - .unwrap() - .retain(|(u, f, _)| !(*u == local_user_id && f == follower_actor_url)); + self.recorded.lock().unwrap().extend_from_slice(events); Ok(()) } } @@ -280,6 +276,7 @@ async fn follow_local_user_writes_both_sides() { svc.follow( &alice_id, &FollowTarget::Handle("@bob@md.example".to_string()), + &[], ) .await .unwrap(); @@ -311,6 +308,7 @@ async fn follow_self_is_rejected() { .follow( &alice_id, &FollowTarget::Handle("@alice@md.example".to_string()), + &[], ) .await; @@ -344,6 +342,7 @@ async fn follow_remote_target_errors_rather_than_silently_succeeding() { .follow( &alice_id, &FollowTarget::Handle("@carol@other.example".to_string()), + &[], ) .await; @@ -386,11 +385,12 @@ async fn reject_follow_updates_follower_status_but_removes_the_follow_row() { svc.follow( &requester_id, &FollowTarget::Handle("@owner@md.example".to_string()), + &[], ) .await .unwrap(); - svc.reject_follow(&owner_id, &SocialIdentity::Local(requester_id.clone())) + svc.reject_follow(&owner_id, &SocialIdentity::Local(requester_id.clone()), &[]) .await .unwrap(); @@ -431,9 +431,60 @@ async fn block_errors_and_get_blocked_is_empty() { actor_url: "https://other.example/users/carol".to_string(), }; - let result = svc.block(&alice_id, &target).await; + let result = svc.block(&alice_id, &target, &[]).await; assert!(matches!(result, Err(DomainError::ValidationError(_)))); let blocked = svc.get_blocked(&alice_id).await.unwrap(); assert_eq!(blocked.len(), 0); } + +#[tokio::test] +async fn a_local_follow_records_both_rows_and_its_event_together() { + let user_repo = InMemoryStore::new(); + let bob_id = register_user(&user_repo, "bob").await; + let alice_id = register_user(&user_repo, "alice").await; + let follow_store = FollowFakeStore::new(); + let svc = service(user_repo, &follow_store, instance()); + let event = DomainEvent::FollowRequested { + follower: alice_id.clone(), + target: FollowTarget::Identity(SocialIdentity::Local(bob_id.clone())), + }; + + svc.follow( + &alice_id, + &FollowTarget::Handle("@bob@md.example".to_string()), + std::slice::from_ref(&event), + ) + .await + .unwrap(); + + assert_eq!(svc.get_followers(&bob_id).await.unwrap().len(), 1); + assert_eq!(svc.get_following(&alice_id).await.unwrap().len(), 1); + assert_eq!(follow_store.recorded_events().len(), 1); +} + +#[tokio::test] +async fn a_refused_local_follow_write_propagates_and_records_nothing() { + let user_repo = InMemoryStore::new(); + let bob_id = register_user(&user_repo, "bob").await; + let alice_id = register_user(&user_repo, "alice").await; + let follow_store = FollowFakeStore::refusing(); + let svc = service(user_repo, &follow_store, instance()); + let event = DomainEvent::FollowRequested { + follower: alice_id.clone(), + target: FollowTarget::Identity(SocialIdentity::Local(bob_id.clone())), + }; + + let result = svc + .follow( + &alice_id, + &FollowTarget::Handle("@bob@md.example".to_string()), + std::slice::from_ref(&event), + ) + .await; + + assert!(matches!(result, Err(DomainError::InfrastructureError(_)))); + assert!(svc.get_followers(&bob_id).await.unwrap().is_empty()); + assert!(svc.get_following(&alice_id).await.unwrap().is_empty()); + assert!(follow_store.recorded_events().is_empty()); +} diff --git a/crates/application/tests/users/get_page_viewer_test.rs b/crates/application/tests/users/get_page_viewer_test.rs index 318c9c78..cf5b8bbe 100644 --- a/crates/application/tests/users/get_page_viewer_test.rs +++ b/crates/application/tests/users/get_page_viewer_test.rs @@ -35,11 +35,11 @@ async fn get_page_viewer_reports_pending_follower_count() { let owner_id = UserId::from_uuid(owner_uuid); let target = FollowTarget::Identity(SocialIdentity::Local(owner_id.clone())); b.social_command - .follow(&UserId::from_uuid(follower1), &target) + .follow(&UserId::from_uuid(follower1), &target, &[]) .await .unwrap(); b.social_command - .follow(&UserId::from_uuid(follower2), &target) + .follow(&UserId::from_uuid(follower2), &target, &[]) .await .unwrap(); diff --git a/crates/bootstrap/src/build.rs b/crates/bootstrap/src/build.rs index 913b002e..9fc9bd50 100644 --- a/crates/bootstrap/src/build.rs +++ b/crates/bootstrap/src/build.rs @@ -241,7 +241,6 @@ pub fn build_deps( social: SocialGroup { command: SocialCommandDeps { social_command: repos.social_command.clone(), - event_publisher: services.event_publisher.clone(), }, query: SocialQueryDeps { follow_graph: repos.follow_graph.clone(), diff --git a/crates/bootstrap/src/federation.rs b/crates/bootstrap/src/federation.rs index 7517a38f..7fbf733b 100644 --- a/crates/bootstrap/src/federation.rs +++ b/crates/bootstrap/src/federation.rs @@ -104,6 +104,7 @@ pub async fn wire_server_social( ap.service, Arc::clone(&db.user), instance.clone(), + Arc::clone(event_publisher), )); Ok(ServerSocial { diff --git a/crates/bootstrap/tests/bootstrap_test.rs b/crates/bootstrap/tests/bootstrap_test.rs index 93792411..318b8161 100644 --- a/crates/bootstrap/tests/bootstrap_test.rs +++ b/crates/bootstrap/tests/bootstrap_test.rs @@ -145,3 +145,73 @@ async fn handler_names_are_unique() { // Image conversion ("image_conversion") is disabled in the test config. 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() +} + +/// A local follow writes both follow rows and its event in one commit, in +/// both federation builds. +#[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); +} + +/// The k_ap path owns its transaction, so the adapter publishes the event +/// itself once the k_ap write has succeeded. +#[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); +} diff --git a/crates/domain/src/ports/follow.rs b/crates/domain/src/ports/follow.rs index 427ed160..197b5dd9 100644 --- a/crates/domain/src/ports/follow.rs +++ b/crates/domain/src/ports/follow.rs @@ -2,49 +2,52 @@ use async_trait::async_trait; use crate::{ errors::DomainError, + events::DomainEvent, value_objects::{FollowRelation, FollowStatus, SocialActor}, }; +/// One row-level change to the local follow tables. A local follow operation +/// is a batch of these applied in a single transaction with its events. +#[derive(Clone, Debug, PartialEq)] +pub enum FollowWrite { + AddFollow { + follower_id: uuid::Uuid, + target_actor_url: String, + status: FollowStatus, + }, + UpdateFollowStatus { + follower_id: uuid::Uuid, + target_actor_url: String, + status: FollowStatus, + }, + RemoveFollow { + follower_id: uuid::Uuid, + target_actor_url: String, + }, + AddFollower { + local_user_id: uuid::Uuid, + follower_actor_url: String, + status: FollowStatus, + }, + UpdateFollowerStatus { + local_user_id: uuid::Uuid, + follower_actor_url: String, + status: FollowStatus, + }, + RemoveFollowerRecord { + local_user_id: uuid::Uuid, + follower_actor_url: String, + }, +} + #[async_trait] pub trait FollowCommand: Send + Sync { - async fn add_follow( + /// Applies every write and records `events` in one transaction: all rows + /// and events land, or none do. + async fn apply( &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError>; - - async fn update_follow_status( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError>; - - async fn remove_follow( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - ) -> Result<(), DomainError>; - - async fn add_follower( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError>; - - async fn update_follower_status( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError>; - - async fn remove_follower_record( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, + writes: &[FollowWrite], + events: &[DomainEvent], ) -> Result<(), DomainError>; } diff --git a/crates/domain/src/ports/noop.rs b/crates/domain/src/ports/noop.rs index 28007f00..3c8e0de8 100644 --- a/crates/domain/src/ports/noop.rs +++ b/crates/domain/src/ports/noop.rs @@ -44,25 +44,56 @@ impl super::SocialCommand for NoopSocialCommand { &self, _: &UserId, _: &crate::value_objects::FollowTarget, + _: &[crate::events::DomainEvent], ) -> Result<(), DomainError> { Ok(()) } - async fn unfollow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { + async fn unfollow( + &self, + _: &UserId, + _: &SocialIdentity, + _: &[crate::events::DomainEvent], + ) -> Result<(), DomainError> { Ok(()) } - async fn accept_follow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { + async fn accept_follow( + &self, + _: &UserId, + _: &SocialIdentity, + _: &[crate::events::DomainEvent], + ) -> Result<(), DomainError> { Ok(()) } - async fn reject_follow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { + async fn reject_follow( + &self, + _: &UserId, + _: &SocialIdentity, + _: &[crate::events::DomainEvent], + ) -> Result<(), DomainError> { Ok(()) } - async fn remove_follower(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { + async fn remove_follower( + &self, + _: &UserId, + _: &SocialIdentity, + _: &[crate::events::DomainEvent], + ) -> Result<(), DomainError> { Ok(()) } - async fn block(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { + async fn block( + &self, + _: &UserId, + _: &SocialIdentity, + _: &[crate::events::DomainEvent], + ) -> Result<(), DomainError> { Ok(()) } - async fn unblock(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { + async fn unblock( + &self, + _: &UserId, + _: &SocialIdentity, + _: &[crate::events::DomainEvent], + ) -> Result<(), DomainError> { Ok(()) } } diff --git a/crates/domain/src/ports/social.rs b/crates/domain/src/ports/social.rs index 067040aa..5ea51516 100644 --- a/crates/domain/src/ports/social.rs +++ b/crates/domain/src/ports/social.rs @@ -3,6 +3,7 @@ use chrono::NaiveDateTime; use crate::{ errors::DomainError, + events::DomainEvent, models::{ DiaryEntry, FederationFlags, RemoteActorInfo, RemoteGoalEntry, RemoteWatchlistEntry, WatchlistWithMovie, @@ -14,32 +15,54 @@ use crate::{ #[async_trait] pub trait SocialCommand: Send + Sync { - async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError>; + async fn follow( + &self, + follower: &UserId, + target: &FollowTarget, + events: &[DomainEvent], + ) -> Result<(), DomainError>; - async fn unfollow(&self, follower: &UserId, target: &SocialIdentity) - -> Result<(), DomainError>; + async fn unfollow( + &self, + follower: &UserId, + target: &SocialIdentity, + events: &[DomainEvent], + ) -> Result<(), DomainError>; async fn accept_follow( &self, owner: &UserId, requester: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError>; async fn reject_follow( &self, owner: &UserId, requester: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError>; async fn remove_follower( &self, owner: &UserId, follower: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError>; - async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError>; + async fn block( + &self, + blocker: &UserId, + target: &SocialIdentity, + events: &[DomainEvent], + ) -> Result<(), DomainError>; - async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError>; + async fn unblock( + &self, + blocker: &UserId, + target: &SocialIdentity, + events: &[DomainEvent], + ) -> Result<(), DomainError>; } #[async_trait] @@ -138,6 +161,7 @@ pub trait ResolvedFollow: Send + Sync { &self, follower: &UserId, target: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError>; } diff --git a/crates/domain/src/testing/fakes/refusing.rs b/crates/domain/src/testing/fakes/refusing.rs index 86864c7c..69c10bbd 100644 --- a/crates/domain/src/testing/fakes/refusing.rs +++ b/crates/domain/src/testing/fakes/refusing.rs @@ -12,10 +12,12 @@ use crate::{ collections::{PageParams, Paginated}, }, ports::{ - EventPublisher, GoalCommand, MovieCommand, ReviewRepository, UserProfileFieldsRepository, - WatchEventCommand, WatchlistRepository, + EventPublisher, FollowCommand, FollowWrite, GoalCommand, MovieCommand, ReviewRepository, + SocialCommand, UserProfileFieldsRepository, WatchEventCommand, WatchlistRepository, + }, + value_objects::{ + FollowTarget, GoalId, MovieId, ReviewId, SocialIdentity, UserId, WatchEventId, }, - value_objects::{GoalId, MovieId, ReviewId, UserId, WatchEventId}, }; fn refused() -> DomainError { @@ -151,3 +153,74 @@ impl WatchEventCommand for RefusingWatchEventCommand { Err(refused()) } } + +pub struct RefusingSocialCommand; + +#[async_trait] +impl SocialCommand for RefusingSocialCommand { + async fn follow( + &self, + _: &UserId, + _: &FollowTarget, + _: &[DomainEvent], + ) -> Result<(), DomainError> { + Err(refused()) + } + async fn unfollow( + &self, + _: &UserId, + _: &SocialIdentity, + _: &[DomainEvent], + ) -> Result<(), DomainError> { + Err(refused()) + } + async fn accept_follow( + &self, + _: &UserId, + _: &SocialIdentity, + _: &[DomainEvent], + ) -> Result<(), DomainError> { + Err(refused()) + } + async fn reject_follow( + &self, + _: &UserId, + _: &SocialIdentity, + _: &[DomainEvent], + ) -> Result<(), DomainError> { + Err(refused()) + } + async fn remove_follower( + &self, + _: &UserId, + _: &SocialIdentity, + _: &[DomainEvent], + ) -> Result<(), DomainError> { + Err(refused()) + } + async fn block( + &self, + _: &UserId, + _: &SocialIdentity, + _: &[DomainEvent], + ) -> Result<(), DomainError> { + Err(refused()) + } + async fn unblock( + &self, + _: &UserId, + _: &SocialIdentity, + _: &[DomainEvent], + ) -> Result<(), DomainError> { + Err(refused()) + } +} + +pub struct RefusingFollowCommand; + +#[async_trait] +impl FollowCommand for RefusingFollowCommand { + async fn apply(&self, _: &[FollowWrite], _: &[DomainEvent]) -> Result<(), DomainError> { + Err(refused()) + } +} diff --git a/crates/domain/src/testing/fakes/store_social.rs b/crates/domain/src/testing/fakes/store_social.rs index 2d5395ff..5941cfae 100644 --- a/crates/domain/src/testing/fakes/store_social.rs +++ b/crates/domain/src/testing/fakes/store_social.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use crate::{ errors::DomainError, + events::DomainEvent, ports::{BlockQuery, FollowGraphQuery, SocialCommand}, value_objects::{FollowRelation, FollowStatus, SocialActor, SocialIdentity, UserId}, }; @@ -28,6 +29,7 @@ impl SocialCommand for InMemoryStore { &self, follower: &UserId, target: &crate::value_objects::FollowTarget, + events: &[DomainEvent], ) -> Result<(), DomainError> { let identity = match target { crate::value_objects::FollowTarget::Identity(id) => id.clone(), @@ -50,6 +52,8 @@ impl SocialCommand for InMemoryStore { return Err(DomainError::ValidationError("Already following".into())); } store.push((follower.value(), identity, FollowState::Pending)); + drop(store); + self.record_events(events); Ok(()) } @@ -57,6 +61,7 @@ impl SocialCommand for InMemoryStore { &self, follower: &UserId, target: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError> { let mut store = self.follows.write().unwrap(); let before = store.len(); @@ -66,6 +71,8 @@ impl SocialCommand for InMemoryStore { "Follow relationship not found".into(), )); } + drop(store); + self.record_events(events); Ok(()) } @@ -73,6 +80,7 @@ impl SocialCommand for InMemoryStore { &self, owner: &UserId, requester: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError> { let mut store = self.follows.write().unwrap(); let target_identity = SocialIdentity::Local(owner.clone()); @@ -92,6 +100,8 @@ impl SocialCommand for InMemoryStore { }; if requester_matches && *t == target_identity && *state == FollowState::Pending { *state = FollowState::Accepted; + drop(store); + self.record_events(events); return Ok(()); } } @@ -104,6 +114,7 @@ impl SocialCommand for InMemoryStore { &self, owner: &UserId, requester: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError> { let mut store = self.follows.write().unwrap(); let target_identity = SocialIdentity::Local(owner.clone()); @@ -123,6 +134,8 @@ impl SocialCommand for InMemoryStore { "Pending follow request not found".into(), )); } + drop(store); + self.record_events(events); Ok(()) } @@ -130,6 +143,7 @@ impl SocialCommand for InMemoryStore { &self, owner: &UserId, follower: &SocialIdentity, + events: &[DomainEvent], ) -> Result<(), DomainError> { let mut store = self.follows.write().unwrap(); let target_identity = SocialIdentity::Local(owner.clone()); @@ -144,21 +158,38 @@ impl SocialCommand for InMemoryStore { if store.len() == before { return Err(DomainError::NotFound("Follower not found".into())); } + drop(store); + self.record_events(events); Ok(()) } - async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> { + async fn block( + &self, + blocker: &UserId, + target: &SocialIdentity, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let mut store = self.blocked.write().unwrap(); store.push((blocker.value(), target.clone())); // Also remove any existing follow relationships let mut follows = self.follows.write().unwrap(); follows.retain(|(f, t, _)| !(*f == blocker.value() && t == target)); + drop(follows); + drop(store); + self.record_events(events); Ok(()) } - async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> { + async fn unblock( + &self, + blocker: &UserId, + target: &SocialIdentity, + events: &[DomainEvent], + ) -> Result<(), DomainError> { let mut store = self.blocked.write().unwrap(); store.retain(|(b, t)| !(*b == blocker.value() && t == target)); + drop(store); + self.record_events(events); Ok(()) } } diff --git a/crates/domain/tests/ports/social_test.rs b/crates/domain/tests/ports/social_test.rs index 4a023a8b..89fef2a9 100644 --- a/crates/domain/tests/ports/social_test.rs +++ b/crates/domain/tests/ports/social_test.rs @@ -14,6 +14,7 @@ impl SocialCommand for Stub { &self, _: &domain::value_objects::UserId, _: &domain::value_objects::FollowTarget, + _: &[domain::events::DomainEvent], ) -> Result<(), DomainError> { Ok(()) } @@ -21,6 +22,7 @@ impl SocialCommand for Stub { &self, _: &domain::value_objects::UserId, _: &domain::value_objects::SocialIdentity, + _: &[domain::events::DomainEvent], ) -> Result<(), DomainError> { Ok(()) } @@ -28,6 +30,7 @@ impl SocialCommand for Stub { &self, _: &domain::value_objects::UserId, _: &domain::value_objects::SocialIdentity, + _: &[domain::events::DomainEvent], ) -> Result<(), DomainError> { Ok(()) } @@ -35,6 +38,7 @@ impl SocialCommand for Stub { &self, _: &domain::value_objects::UserId, _: &domain::value_objects::SocialIdentity, + _: &[domain::events::DomainEvent], ) -> Result<(), DomainError> { Ok(()) } @@ -42,6 +46,7 @@ impl SocialCommand for Stub { &self, _: &domain::value_objects::UserId, _: &domain::value_objects::SocialIdentity, + _: &[domain::events::DomainEvent], ) -> Result<(), DomainError> { Ok(()) } @@ -49,6 +54,7 @@ impl SocialCommand for Stub { &self, _: &domain::value_objects::UserId, _: &domain::value_objects::SocialIdentity, + _: &[domain::events::DomainEvent], ) -> Result<(), DomainError> { Ok(()) } @@ -56,6 +62,7 @@ impl SocialCommand for Stub { &self, _: &domain::value_objects::UserId, _: &domain::value_objects::SocialIdentity, + _: &[domain::events::DomainEvent], ) -> Result<(), DomainError> { Ok(()) } @@ -145,6 +152,7 @@ impl ResolvedFollow for Stub { &self, _: &domain::value_objects::UserId, _: &SocialIdentity, + _: &[domain::events::DomainEvent], ) -> Result<(), DomainError> { Ok(()) } diff --git a/crates/domain/tests/testing/in_memory_store_test.rs b/crates/domain/tests/testing/in_memory_store_test.rs index f8b9c69d..ca462742 100644 --- a/crates/domain/tests/testing/in_memory_store_test.rs +++ b/crates/domain/tests/testing/in_memory_store_test.rs @@ -128,6 +128,7 @@ async fn seed_user_with_dependents(store: &Arc) -> (UserId, Movie &FollowTarget::Identity(SocialIdentity::Remote { actor_url: REMOTE_ACTOR.into(), }), + &[], ) .await .unwrap(); @@ -135,6 +136,7 @@ async fn seed_user_with_dependents(store: &Arc) -> (UserId, Movie .follow( other.id(), &FollowTarget::Identity(SocialIdentity::Local(user_id.clone())), + &[], ) .await .unwrap(); @@ -144,6 +146,7 @@ async fn seed_user_with_dependents(store: &Arc) -> (UserId, Movie &SocialIdentity::Remote { actor_url: REMOTE_ACTOR.into(), }, + &[], ) .await .unwrap(); -- 2.49.1 From 7f9c252d35896cd32f0708b4a7c8da20413dc3da Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 01:19:09 +0100 Subject: [PATCH 064/104] fix: NewRemoteReview param struct, atomic review+orphan movie delete --- crates/adapters/activitypub/src/lib.rs | 2 +- .../src/remote_review_repository.rs | 23 +++--- .../activitypub/src/review_handler.rs | 20 ++--- .../adapters/sqlite-federation/src/review.rs | 28 +++---- .../tests/federation/remote_review_test.rs | 16 ++-- .../sqlite/src/repositories/review/mod.rs | 23 +++++- .../repositories/review_repository_test.rs | 75 +++++++++++++++---- crates/application/src/diary/delete_review.rs | 37 +++++---- crates/application/src/diary/deps.rs | 3 +- .../tests/diary/delete_review_test.rs | 29 +++---- .../tests/diary/edit_review_test.rs | 4 +- crates/bootstrap/src/build.rs | 1 - crates/domain/src/ports/diary.rs | 9 ++- crates/domain/src/testing/fakes/panics.rs | 8 +- crates/domain/src/testing/fakes/refusing.rs | 8 +- .../domain/src/testing/fakes/store_movie.rs | 12 ++- .../domain/src/testing/fakes/store_review.rs | 21 ++++-- .../tests/testing/in_memory_store_test.rs | 58 ++++++++++++++ 18 files changed, 261 insertions(+), 116 deletions(-) diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index 096a1056..33576e4c 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -22,7 +22,7 @@ pub use k_ap::{ pub use event_handler::ActivityPubEventHandler; pub use federation_ports::ApServiceAdapter; -pub use remote_review_repository::{RemoteReviewRepository, RemoteReviewUpdate}; +pub use remote_review_repository::{NewRemoteReview, RemoteReviewRepository, RemoteReviewUpdate}; pub use review_handler::ReviewObjectHandler; pub use social_adapter::CompositeSocialAdapter; pub use user_adapter::DomainUserRepoAdapter; diff --git a/crates/adapters/activitypub/src/remote_review_repository.rs b/crates/adapters/activitypub/src/remote_review_repository.rs index b246a7cd..db63262c 100644 --- a/crates/adapters/activitypub/src/remote_review_repository.rs +++ b/crates/adapters/activitypub/src/remote_review_repository.rs @@ -14,19 +14,20 @@ pub struct RemoteReviewUpdate<'a> { pub spoilers: bool, } +/// A remote review to persist, with the events that ride the same write. +pub struct NewRemoteReview<'a> { + pub review: &'a Review, + pub ap_id: &'a str, + pub movie_title: &'a str, + pub release_year: u16, + pub external_metadata_id: Option<&'a str>, + pub poster_url: Option<&'a str>, + pub events: &'a [DomainEvent], +} + #[async_trait] pub trait RemoteReviewRepository: Send + Sync { - #[allow(clippy::too_many_arguments)] - async fn save_remote_review( - &self, - review: &Review, - ap_id: &str, - movie_title: &str, - release_year: u16, - external_metadata_id: Option<&str>, - poster_url: Option<&str>, - events: &[DomainEvent], - ) -> Result<()>; + async fn save_remote_review(&self, new: NewRemoteReview<'_>) -> Result<()>; async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>; diff --git a/crates/adapters/activitypub/src/review_handler.rs b/crates/adapters/activitypub/src/review_handler.rs index 387f4c5f..f1e019be 100644 --- a/crates/adapters/activitypub/src/review_handler.rs +++ b/crates/adapters/activitypub/src/review_handler.rs @@ -13,7 +13,7 @@ use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject}; use url::Url; use crate::objects::{ReviewApInput, ReviewObject, review_to_ap_object}; -use crate::remote_review_repository::RemoteReviewRepository; +use crate::remote_review_repository::{NewRemoteReview, RemoteReviewRepository}; use crate::urls::{actor_url, review_url}; pub struct ReviewObjectHandler { @@ -174,15 +174,15 @@ impl ApObjectHandler for ReviewObjectHandler { .collect(); self.review_store - .save_remote_review( - &review, - obj.id.as_str(), - &obj.movie_title, - obj.release_year, - obj.external_metadata_id.as_deref(), - obj.poster_url.as_deref(), - &events, - ) + .save_remote_review(NewRemoteReview { + review: &review, + ap_id: obj.id.as_str(), + movie_title: &obj.movie_title, + release_year: obj.release_year, + external_metadata_id: obj.external_metadata_id.as_deref(), + poster_url: obj.poster_url.as_deref(), + events: &events, + }) .await?; Ok(()) diff --git a/crates/adapters/sqlite-federation/src/review.rs b/crates/adapters/sqlite-federation/src/review.rs index 8646343c..c40b9cd0 100644 --- a/crates/adapters/sqlite-federation/src/review.rs +++ b/crates/adapters/sqlite-federation/src/review.rs @@ -1,27 +1,23 @@ -use activitypub::{RemoteReviewRepository, RemoteReviewUpdate}; +use activitypub::{NewRemoteReview, RemoteReviewRepository, RemoteReviewUpdate}; use anyhow::{Result, anyhow}; use async_trait::async_trait; -use domain::{ - events::DomainEvent, - models::{Review, ReviewSource}, -}; +use domain::models::ReviewSource; use super::SqliteFederationRepository; use sqlite::persistence::{datetime_to_str, enqueue_events}; #[async_trait] impl RemoteReviewRepository for SqliteFederationRepository { - #[allow(clippy::too_many_arguments)] - async fn save_remote_review( - &self, - review: &Review, - ap_id: &str, - movie_title: &str, - release_year: u16, - external_metadata_id: Option<&str>, - poster_url: Option<&str>, - events: &[DomainEvent], - ) -> Result<()> { + async fn save_remote_review(&self, new: NewRemoteReview<'_>) -> Result<()> { + let NewRemoteReview { + review, + ap_id, + movie_title, + release_year, + external_metadata_id, + poster_url, + events, + } = new; let actor_url = match review.source() { ReviewSource::Remote { actor_url } => actor_url.clone(), ReviewSource::Local => { diff --git a/crates/adapters/sqlite-federation/tests/federation/remote_review_test.rs b/crates/adapters/sqlite-federation/tests/federation/remote_review_test.rs index 5e8e083f..5a82873f 100644 --- a/crates/adapters/sqlite-federation/tests/federation/remote_review_test.rs +++ b/crates/adapters/sqlite-federation/tests/federation/remote_review_test.rs @@ -1,6 +1,6 @@ //! A remote review and the enrichment request it triggers commit together. -use activitypub::RemoteReviewRepository; +use activitypub::{NewRemoteReview, RemoteReviewRepository}; use domain::{ events::DomainEvent, models::{PersistedReview, Review, ReviewSource}, @@ -56,15 +56,15 @@ fn enrichment_requested(movie_id: &MovieId) -> DomainEvent { async fn save(pool: &SqlitePool, review: &Review, events: &[DomainEvent]) -> anyhow::Result<()> { SqliteFederationRepository::new(pool.clone()) - .save_remote_review( + .save_remote_review(NewRemoteReview { review, - AP_ID, - "The Matrix", - 1999, - Some("tmdb:603"), - None, + ap_id: AP_ID, + movie_title: "The Matrix", + release_year: 1999, + external_metadata_id: Some("tmdb:603"), + poster_url: None, events, - ) + }) .await } diff --git a/crates/adapters/sqlite/src/repositories/review/mod.rs b/crates/adapters/sqlite/src/repositories/review/mod.rs index 948ed04c..122f617a 100644 --- a/crates/adapters/sqlite/src/repositories/review/mod.rs +++ b/crates/adapters/sqlite/src/repositories/review/mod.rs @@ -4,7 +4,7 @@ use domain::{ events::DomainEvent, models::{Review, ReviewSource}, ports::ReviewRepository, - value_objects::{ReviewId, UserId}, + value_objects::{MovieId, ReviewId, UserId}, }; use sqlx::SqlitePool; @@ -114,12 +114,15 @@ impl ReviewRepository for SqliteReviewRepository { tx.commit().await.map_err(crate::shared::map_sqlx_error) } - async fn delete_review( + async fn delete_review_and_orphaned_movie( &self, review_id: &ReviewId, - events: &[DomainEvent], + movie_id: &MovieId, + review_events: &[DomainEvent], + orphan_events: &[DomainEvent], ) -> Result<(), DomainError> { let id = review_id.value().to_string(); + let movie = movie_id.value().to_string(); let mut tx = self .pool .begin() @@ -130,7 +133,19 @@ impl ReviewRepository for SqliteReviewRepository { .execute(&mut *tx) .await .map_err(crate::shared::map_sqlx_error)?; - enqueue_events(&mut tx, events).await?; + enqueue_events(&mut tx, review_events).await?; + let orphaned = sqlx::query( + "DELETE FROM movies WHERE id = ? \ + AND NOT EXISTS (SELECT 1 FROM reviews WHERE movie_id = ?)", + ) + .bind(&movie) + .bind(&movie) + .execute(&mut *tx) + .await + .map_err(crate::shared::map_sqlx_error)?; + if orphaned.rows_affected() > 0 { + enqueue_events(&mut tx, orphan_events).await?; + } tx.commit().await.map_err(crate::shared::map_sqlx_error) } diff --git a/crates/adapters/sqlite/tests/repositories/review_repository_test.rs b/crates/adapters/sqlite/tests/repositories/review_repository_test.rs index b8be38ee..76ca0a7d 100644 --- a/crates/adapters/sqlite/tests/repositories/review_repository_test.rs +++ b/crates/adapters/sqlite/tests/repositories/review_repository_test.rs @@ -135,36 +135,85 @@ async fn a_failing_enqueue_after_the_review_update_rolls_the_update_back() { assert_eq!(count(&pool, "event_queue").await, 0); } +fn deleted(review: &domain::models::Review) -> DomainEvent { + DomainEvent::ReviewDeleted { + review_id: review.id().clone(), + user_id: review.user_id().clone(), + } +} + +fn movie_deleted(review: &domain::models::Review) -> DomainEvent { + DomainEvent::MovieDeleted { + movie_id: review.movie_id().clone(), + poster_path: None, + } +} + #[tokio::test] -async fn deleting_a_review_writes_the_removal_and_its_event_in_one_commit() { +async fn deleting_the_last_review_removes_the_movie_and_enqueues_both_events_in_one_commit() { let (pool, review) = seeded().await; let repo = SqliteReviewRepository::new(pool.clone()); repo.save_review(&review, &[]).await.unwrap(); - let event = DomainEvent::ReviewDeleted { - review_id: review.id().clone(), - user_id: review.user_id().clone(), - }; - repo.delete_review(review.id(), &[event]).await.unwrap(); + repo.delete_review_and_orphaned_movie( + review.id(), + review.movie_id(), + &[deleted(&review)], + &[movie_deleted(&review)], + ) + .await + .unwrap(); assert_eq!(count(&pool, "reviews").await, 0); + assert_eq!(count(&pool, "movies").await, 0); + assert_eq!(count(&pool, "event_queue").await, 2); +} + +#[tokio::test] +async fn deleting_a_review_while_another_remains_keeps_the_movie_and_enqueues_only_review_deleted() +{ + let (pool, review) = seeded().await; + let repo = SqliteReviewRepository::new(pool.clone()); + repo.save_review(&review, &[]).await.unwrap(); + let other = test_review(review.movie_id(), review.user_id(), 3); + repo.save_review(&other, &[]).await.unwrap(); + + repo.delete_review_and_orphaned_movie( + review.id(), + review.movie_id(), + &[deleted(&review)], + &[movie_deleted(&review)], + ) + .await + .unwrap(); + + assert_eq!(count(&pool, "reviews").await, 1); + assert_eq!(count(&pool, "movies").await, 1); assert_eq!(count(&pool, "event_queue").await, 1); } #[tokio::test] -async fn a_failing_enqueue_after_the_review_delete_rolls_the_delete_back() { +async fn a_failing_enqueue_rolls_back_the_review_and_movie_delete() { let (pool, review) = seeded().await; let repo = SqliteReviewRepository::new(pool.clone()); repo.save_review(&review, &[]).await.unwrap(); fail_event_inserts(&pool).await; - let event = DomainEvent::ReviewDeleted { - review_id: review.id().clone(), - user_id: review.user_id().clone(), - }; - let result = repo.delete_review(review.id(), &[event]).await; + let result = repo + .delete_review_and_orphaned_movie( + review.id(), + review.movie_id(), + &[deleted(&review)], + &[movie_deleted(&review)], + ) + .await; assert!(result.is_err()); - assert_eq!(count(&pool, "reviews").await, 1, "delete rolled back"); + assert_eq!( + count(&pool, "reviews").await, + 1, + "review delete rolled back" + ); + assert_eq!(count(&pool, "movies").await, 1, "movie delete rolled back"); assert_eq!(count(&pool, "event_queue").await, 0); } diff --git a/crates/application/src/diary/delete_review.rs b/crates/application/src/diary/delete_review.rs index f3840a1a..dcc878c2 100644 --- a/crates/application/src/diary/delete_review.rs +++ b/crates/application/src/diary/delete_review.rs @@ -20,29 +20,28 @@ pub async fn execute(deps: &DeleteReviewDeps, cmd: DeleteReviewCommand) -> Resul } let movie_id = review.movie_id().clone(); + // Read before the write: the orphan event carries the poster to clean up. + let poster_path = deps + .diary + .get_review_history(&movie_id) + .await? + .movie() + .poster_path() + .cloned(); + + // One write: the review, plus its movie when this was the last review. deps.review - .delete_review( + .delete_review_and_orphaned_movie( &review_id, + &movie_id, &[DomainEvent::ReviewDeleted { review_id: review_id.clone(), - user_id: requesting_user_id.clone(), + user_id: requesting_user_id, + }], + &[DomainEvent::MovieDeleted { + movie_id: movie_id.clone(), + poster_path, }], ) - .await?; - - let history = deps.diary.get_review_history(&movie_id).await?; - if history.viewings().is_empty() { - let poster_path = history.movie().poster_path().cloned(); - deps.movie_command - .delete_movie( - &movie_id, - &[DomainEvent::MovieDeleted { - movie_id: movie_id.clone(), - poster_path, - }], - ) - .await?; - } - - Ok(()) + .await } diff --git a/crates/application/src/diary/deps.rs b/crates/application/src/diary/deps.rs index d9613fbc..317af9ea 100644 --- a/crates/application/src/diary/deps.rs +++ b/crates/application/src/diary/deps.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use domain::ports::{ - DiaryExporter, DiaryQuery, FollowGraphQuery, MovieCommand, MovieProfileRepository, MovieQuery, + DiaryExporter, DiaryQuery, FollowGraphQuery, MovieProfileRepository, MovieQuery, ReviewRepository, UserRepository, }; @@ -10,7 +10,6 @@ use crate::config::AppSettings; pub struct DeleteReviewDeps { pub review: Arc, pub diary: Arc, - pub movie_command: Arc, } pub struct EditReviewDeps { diff --git a/crates/application/tests/diary/delete_review_test.rs b/crates/application/tests/diary/delete_review_test.rs index 67ba9e45..f6133104 100644 --- a/crates/application/tests/diary/delete_review_test.rs +++ b/crates/application/tests/diary/delete_review_test.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::{ ports::{MovieCommand, MovieQuery, ReviewRepository}, - testing::{FakeDiaryQuery, InMemoryStore, RefusingMovieCommand}, + testing::{FakeDiaryQuery, InMemoryStore}, value_objects::{MovieId, UserId}, }; @@ -28,7 +28,6 @@ async fn deleting_a_review_removes_it() { let deps = DeleteReviewDeps { review: Arc::clone(&reviews) as _, diary: diary.clone() as _, - movie_command: Arc::clone(&movies) as _, }; delete_review::execute( @@ -52,7 +51,6 @@ async fn deleting_a_review_removes_it() { async fn deleting_another_users_review_is_unauthorized() { let reviews = InMemoryStore::new(); let diary = FakeDiaryQuery::new(); - let movies = Arc::clone(&reviews); let movie_id = MovieId::from_uuid(uuid::Uuid::new_v4()); let owner_id = UserId::from_uuid(uuid::Uuid::new_v4()); @@ -64,7 +62,6 @@ async fn deleting_another_users_review_is_unauthorized() { let deps = DeleteReviewDeps { review: Arc::clone(&reviews) as _, diary: diary as _, - movie_command: movies as _, }; let result = delete_review::execute( @@ -89,11 +86,12 @@ async fn deleting_a_review_removes_it_and_records_its_event_together() { let review = test_review(movie.id(), &user_id, 4); movies.upsert_movie(&movie, &[]).await.unwrap(); movies.save_review(&review, &[]).await.unwrap(); + let other = test_review(movie.id(), &UserId::from_uuid(uuid::Uuid::new_v4()), 3); + movies.save_review(&other, &[]).await.unwrap(); diary.seed_history(movie.clone(), vec![review.clone()]); let deps = DeleteReviewDeps { review: Arc::clone(&movies) as _, diary: diary.clone() as _, - movie_command: Arc::clone(&movies) as _, }; delete_review::execute( @@ -106,7 +104,7 @@ async fn deleting_a_review_removes_it_and_records_its_event_together() { .await .unwrap(); - assert_eq!(movies.review_count(), 0); + assert_eq!(movies.review_count(), 1); let recorded = movies.recorded_events(); assert_eq!(recorded.len(), 1); assert!(matches!( @@ -122,12 +120,12 @@ async fn a_refused_delete_propagates_and_records_nothing() { let user_id = UserId::from_uuid(uuid::Uuid::new_v4()); let review = test_review(&MovieId::generate(), &user_id, 4); movies.save_review(&review, &[]).await.unwrap(); + diary.seed_history(test_movie_with_year("Terminator", 1984), vec![]); let deps = DeleteReviewDeps { review: Arc::new(crate::edit_review_test::WriteRefusingReviews(Arc::clone( &movies, ))), diary: diary as _, - movie_command: Arc::clone(&movies) as _, }; let result = delete_review::execute( @@ -157,7 +155,6 @@ async fn deleting_the_last_review_records_movie_deleted_with_the_movie_delete() let deps = DeleteReviewDeps { review: Arc::clone(&movies) as _, diary: diary as _, - movie_command: Arc::clone(&movies) as _, }; delete_review::execute( @@ -198,7 +195,6 @@ async fn deleting_a_review_while_viewings_remain_keeps_the_movie_and_records_no_ let deps = DeleteReviewDeps { review: Arc::clone(&movies) as _, diary: diary as _, - movie_command: Arc::clone(&movies) as _, }; delete_review::execute( @@ -221,7 +217,7 @@ async fn deleting_a_review_while_viewings_remain_keeps_the_movie_and_records_no_ } #[tokio::test] -async fn a_refused_movie_delete_propagates_and_records_no_movie_deleted() { +async fn a_refused_review_and_movie_delete_leaves_review_and_movie_and_records_nothing() { let movies = InMemoryStore::new(); let diary = FakeDiaryQuery::new(); let movie = test_movie_with_year("Terminator", 1984); @@ -231,9 +227,10 @@ async fn a_refused_movie_delete_propagates_and_records_no_movie_deleted() { movies.save_review(&review, &[]).await.unwrap(); diary.seed_history(movie.clone(), vec![]); let deps = DeleteReviewDeps { - review: Arc::clone(&movies) as _, + review: Arc::new(crate::edit_review_test::WriteRefusingReviews(Arc::clone( + &movies, + ))), diary: diary as _, - movie_command: Arc::new(RefusingMovieCommand), }; let result = delete_review::execute( @@ -246,11 +243,7 @@ async fn a_refused_movie_delete_propagates_and_records_no_movie_deleted() { .await; assert!(result.is_err()); + assert_eq!(movies.review_count(), 1); assert!(movies.get_movie_by_id(movie.id()).await.unwrap().is_some()); - assert!( - !movies - .recorded_events() - .iter() - .any(|e| matches!(e, domain::events::DomainEvent::MovieDeleted { .. })) - ); + assert!(movies.recorded_events().is_empty()); } diff --git a/crates/application/tests/diary/edit_review_test.rs b/crates/application/tests/diary/edit_review_test.rs index 065a5f08..102bb43b 100644 --- a/crates/application/tests/diary/edit_review_test.rs +++ b/crates/application/tests/diary/edit_review_test.rs @@ -107,9 +107,11 @@ impl ReviewRepository for WriteRefusingReviews { "refused".into(), )) } - async fn delete_review( + async fn delete_review_and_orphaned_movie( &self, _: &ReviewId, + _: &domain::value_objects::MovieId, + _: &[domain::events::DomainEvent], _: &[domain::events::DomainEvent], ) -> Result<(), domain::errors::DomainError> { Err(domain::errors::DomainError::InfrastructureError( diff --git a/crates/bootstrap/src/build.rs b/crates/bootstrap/src/build.rs index 9fc9bd50..34a3e562 100644 --- a/crates/bootstrap/src/build.rs +++ b/crates/bootstrap/src/build.rs @@ -89,7 +89,6 @@ pub fn build_deps( delete_review: DeleteReviewDeps { review: repos.review.clone(), diary: repos.diary.clone(), - movie_command: repos.movie_command.clone(), }, edit_review: EditReviewDeps { review: repos.review.clone(), diff --git a/crates/domain/src/ports/diary.rs b/crates/domain/src/ports/diary.rs index 3ae36d54..1417b72f 100644 --- a/crates/domain/src/ports/diary.rs +++ b/crates/domain/src/ports/diary.rs @@ -51,10 +51,15 @@ pub trait ReviewRepository: Send + Sync { review: &Review, events: &[DomainEvent], ) -> Result<(), DomainError>; - async fn delete_review( + /// Deletes the review and, in the same transaction, its movie when no + /// review of any kind references it any more. `review_events` are always + /// recorded; `orphan_events` only when the movie row was deleted. + async fn delete_review_and_orphaned_movie( &self, review_id: &ReviewId, - events: &[DomainEvent], + movie_id: &MovieId, + review_events: &[DomainEvent], + orphan_events: &[DomainEvent], ) -> Result<(), DomainError>; async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError>; } diff --git a/crates/domain/src/testing/fakes/panics.rs b/crates/domain/src/testing/fakes/panics.rs index 952c4899..54c43d44 100644 --- a/crates/domain/src/testing/fakes/panics.rs +++ b/crates/domain/src/testing/fakes/panics.rs @@ -557,7 +557,13 @@ impl ReviewRepository for PanicReviewRepository { async fn update_review(&self, _: &Review, _: &[DomainEvent]) -> Result<(), DomainError> { panic!("PanicReviewRepository called") } - async fn delete_review(&self, _: &ReviewId, _: &[DomainEvent]) -> Result<(), DomainError> { + async fn delete_review_and_orphaned_movie( + &self, + _: &ReviewId, + _: &MovieId, + _: &[DomainEvent], + _: &[DomainEvent], + ) -> Result<(), DomainError> { panic!("PanicReviewRepository called") } async fn get_all_reviews_for_user(&self, _: &UserId) -> Result, DomainError> { diff --git a/crates/domain/src/testing/fakes/refusing.rs b/crates/domain/src/testing/fakes/refusing.rs index 69c10bbd..fedac6d7 100644 --- a/crates/domain/src/testing/fakes/refusing.rs +++ b/crates/domain/src/testing/fakes/refusing.rs @@ -37,7 +37,13 @@ impl ReviewRepository for RefusingReviewRepository { async fn update_review(&self, _: &Review, _: &[DomainEvent]) -> Result<(), DomainError> { Err(refused()) } - async fn delete_review(&self, _: &ReviewId, _: &[DomainEvent]) -> Result<(), DomainError> { + async fn delete_review_and_orphaned_movie( + &self, + _: &ReviewId, + _: &MovieId, + _: &[DomainEvent], + _: &[DomainEvent], + ) -> Result<(), DomainError> { Err(refused()) } async fn get_all_reviews_for_user(&self, _: &UserId) -> Result, DomainError> { diff --git a/crates/domain/src/testing/fakes/store_movie.rs b/crates/domain/src/testing/fakes/store_movie.rs index b70a3877..f260d632 100644 --- a/crates/domain/src/testing/fakes/store_movie.rs +++ b/crates/domain/src/testing/fakes/store_movie.rs @@ -60,6 +60,15 @@ impl MovieCommand for InMemoryStore { "Database operation failed".into(), )); } + self.remove_movie_with_cascades(id); + self.record_events(events); + Ok(()) + } +} + +impl InMemoryStore { + /// Removes the movie row and applies the schema's cascades. + pub(super) fn remove_movie_with_cascades(&self, id: uuid::Uuid) { self.movies.write().unwrap().remove(&id); self.watchlist .write() @@ -72,9 +81,6 @@ impl MovieCommand for InMemoryStore { *event = detached_from_movie(event); } } - drop(events_guard); - self.record_events(events); - Ok(()) } } diff --git a/crates/domain/src/testing/fakes/store_review.rs b/crates/domain/src/testing/fakes/store_review.rs index 3bf2f5bd..db01103c 100644 --- a/crates/domain/src/testing/fakes/store_review.rs +++ b/crates/domain/src/testing/fakes/store_review.rs @@ -5,7 +5,7 @@ use crate::{ events::DomainEvent, models::Review, ports::ReviewRepository, - value_objects::{ReviewId, UserId}, + value_objects::{MovieId, ReviewId, UserId}, }; use super::InMemoryStore; @@ -47,13 +47,24 @@ impl ReviewRepository for InMemoryStore { Ok(()) } - async fn delete_review( + async fn delete_review_and_orphaned_movie( &self, review_id: &ReviewId, - events: &[DomainEvent], + movie_id: &MovieId, + review_events: &[DomainEvent], + orphan_events: &[DomainEvent], ) -> Result<(), DomainError> { - self.reviews.write().unwrap().remove(&review_id.value()); - self.record_events(events); + let movie = movie_id.value(); + let still_referenced = { + let mut reviews = self.reviews.write().unwrap(); + reviews.remove(&review_id.value()); + reviews.values().any(|r| r.movie_id().value() == movie) + }; + self.record_events(review_events); + if !still_referenced && self.movies.read().unwrap().contains_key(&movie) { + self.remove_movie_with_cascades(movie); + self.record_events(orphan_events); + } Ok(()) } diff --git a/crates/domain/tests/testing/in_memory_store_test.rs b/crates/domain/tests/testing/in_memory_store_test.rs index ca462742..2d93af6e 100644 --- a/crates/domain/tests/testing/in_memory_store_test.rs +++ b/crates/domain/tests/testing/in_memory_store_test.rs @@ -517,3 +517,61 @@ async fn saving_a_watch_event_records_its_events() { assert_eq!(store.rows_in("watch_events"), 1); assert_eq!(store.recorded_events().len(), 1); } + +#[tokio::test] +async fn deleting_the_last_review_removes_the_movie_and_records_both_events() { + let store = InMemoryStore::new(); + let (movie, user_id) = seed_movie_with_dependents(&store, true).await; + let review = store + .get_all_reviews_for_user(&user_id) + .await + .unwrap() + .remove(0); + + store + .delete_review_and_orphaned_movie( + review.id(), + movie.id(), + &[domain::events::DomainEvent::ReviewDeleted { + review_id: review.id().clone(), + user_id: user_id.clone(), + }], + &[movie_deleted(&movie)], + ) + .await + .unwrap(); + + assert_eq!(store.review_count(), 0); + assert_eq!(store.movie_count(), 0); + assert_eq!(store.recorded_events().len(), 2); +} + +#[tokio::test] +async fn deleting_a_review_while_another_remains_keeps_the_movie_and_records_one_event() { + let store = InMemoryStore::new(); + let (movie, user_id) = seed_movie_with_dependents(&store, true).await; + let review = store + .get_all_reviews_for_user(&user_id) + .await + .unwrap() + .remove(0); + let other = test_review(movie.id(), &UserId::generate(), 3); + store.save_review(&other, &[]).await.unwrap(); + + store + .delete_review_and_orphaned_movie( + review.id(), + movie.id(), + &[domain::events::DomainEvent::ReviewDeleted { + review_id: review.id().clone(), + user_id: user_id.clone(), + }], + &[movie_deleted(&movie)], + ) + .await + .unwrap(); + + assert_eq!(store.review_count(), 1); + assert_eq!(store.movie_count(), 1); + assert_eq!(store.recorded_events().len(), 1); +} -- 2.49.1 From 4cb2972f633d5a6cd4822098a837f3493091f0fb Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 01:27:03 +0100 Subject: [PATCH 065/104] feat: idempotent handlers, deterministic activity ids --- crates/adapters/activitypub/src/urls.rs | 31 ++ .../adapters/image-converter/src/handler.rs | 11 + .../tests/conversion_handler_test.rs | 30 ++ crates/adapters/object-storage/src/lib.rs | 26 + crates/application/src/movies/enrich_movie.rs | 11 +- .../src/movies/poster_sync_handler.rs | 17 +- .../src/wrapup/handle_requested.rs | 11 +- crates/application/tests/handlers.rs | 2 + .../tests/handlers/idempotency_test.rs | 468 ++++++++++++++++++ .../tests/wrapup/handle_requested_test.rs | 59 ++- .../bootstrap/src/follow_backfill_handler.rs | 72 +++ 11 files changed, 719 insertions(+), 19 deletions(-) create mode 100644 crates/application/tests/handlers.rs create mode 100644 crates/application/tests/handlers/idempotency_test.rs diff --git a/crates/adapters/activitypub/src/urls.rs b/crates/adapters/activitypub/src/urls.rs index 8b36a405..ddb3458b 100644 --- a/crates/adapters/activitypub/src/urls.rs +++ b/crates/adapters/activitypub/src/urls.rs @@ -38,3 +38,34 @@ pub fn watchlist_entry_url( )) .expect("base_url is always a valid URL prefix") } + +#[cfg(test)] +mod tests { + use super::*; + + /// k-ap derives the Create activity id from the object id (UUIDv5), so + /// re-handling an event only re-sends the same activity id if our object + /// ids are pure functions of the entity. Update/Delete/Move ids are minted + /// randomly inside k-ap 0.5 with no override (see ADR-0015): those repeat + /// semantically idempotent activities under a fresh id. + #[test] + fn activitypub_object_ids_are_stable_across_event_redelivery() { + let instance = InstanceIdentity::new("https://diary.example"); + let review = ReviewId::from_uuid(uuid::Uuid::new_v4()); + let user = uuid::Uuid::new_v4(); + let movie = uuid::Uuid::new_v4(); + + assert_eq!( + review_url(&instance, &review), + review_url(&instance, &review) + ); + assert_eq!( + goal_url(&instance, user, 2026), + goal_url(&instance, user, 2026) + ); + assert_eq!( + watchlist_entry_url(&instance, user, movie), + watchlist_entry_url(&instance, user, movie) + ); + } +} diff --git a/crates/adapters/image-converter/src/handler.rs b/crates/adapters/image-converter/src/handler.rs index 4664e687..a0e8a3f9 100644 --- a/crates/adapters/image-converter/src/handler.rs +++ b/crates/adapters/image-converter/src/handler.rs @@ -45,6 +45,17 @@ impl EventHandler for ImageConversionHandler { return Ok(()); } + // `get` falls back to `{key}.webp` / `{key}.avif`, which would re-encode + // an already converted image on redelivery. Probe the exact key first: + // once the original is gone, an earlier delivery finished the conversion. + match self.storage.get_stream(&key).await { + Ok(_) => {} + Err(DomainError::NotFound(_)) => { + tracing::debug!("original {key} already converted, skipping"); + return Ok(()); + } + Err(e) => return Err(e), + } let bytes = self.storage.get(&key).await?; let format = self.format; diff --git a/crates/adapters/image-converter/tests/conversion_handler_test.rs b/crates/adapters/image-converter/tests/conversion_handler_test.rs index b47648a3..c23e48c6 100644 --- a/crates/adapters/image-converter/tests/conversion_handler_test.rs +++ b/crates/adapters/image-converter/tests/conversion_handler_test.rs @@ -162,3 +162,33 @@ async fn converts_jpeg_to_webp_and_swaps_key() { ); assert!(storage.get("avatars/u1.webp").await.is_ok()); } + +#[tokio::test] +async fn image_conversion_is_idempotent_when_run_twice() { + for format in [Format::Webp, Format::Avif] { + let storage = in_memory_storage(); + storage.store("posters/m1", &tiny_jpeg()).await.unwrap(); + let image_ref = MockImageRef::new(); + let handler = ImageConversionHandler::new( + Arc::clone(&storage) as Arc, + Arc::clone(&image_ref) as Arc, + format, + ); + let event = DomainEvent::ImageStored { + key: "posters/m1".into(), + }; + let new_key = format!("posters/m1{}", format.extension()); + + handler.handle(&event).await.unwrap(); + let converted = storage.get(&new_key).await.unwrap(); + // Redelivery after the original was swapped and deleted. + handler.handle(&event).await.unwrap(); + + assert_eq!(image_ref.swaps().len(), 1, "one swap"); + assert_eq!( + storage.get(&new_key).await.unwrap(), + converted, + "converted bytes are not re-encoded" + ); + } +} diff --git a/crates/adapters/object-storage/src/lib.rs b/crates/adapters/object-storage/src/lib.rs index b55fde82..556b6440 100644 --- a/crates/adapters/object-storage/src/lib.rs +++ b/crates/adapters/object-storage/src/lib.rs @@ -136,3 +136,29 @@ pub fn create(config: &::config::StorageConfig) -> anyhow::Result Result<(), DomainError> { - deps.movie_profile.upsert(&cmd.profile).await?; - let persons = extract_persons(&cmd.profile.cast, &cmd.profile.crew); if !persons.is_empty() { deps.person_command.upsert_batch(&persons).await?; } let Some(movie) = deps.movie_query.get_movie_by_id(&cmd.movie_id).await? else { - tracing::warn!(movie_id = %cmd.movie_id.value(), "enrich_movie: movie not found after profile upsert"); + tracing::warn!(movie_id = %cmd.movie_id.value(), "enrich_movie: movie not found, storing profile only"); + deps.movie_profile.upsert(&cmd.profile).await?; return Ok(()); }; @@ -37,10 +36,14 @@ pub async fn execute(deps: &EnrichMovieDeps, cmd: EnrichMovieCommand) -> Result< .await?; } + // Written last: a stored fresh profile is what makes a redelivered + // enrichment skip, so it must only exist once every other effect landed. + deps.movie_profile.upsert(&cmd.profile).await?; + tracing::info!( movie_id = %cmd.movie_id.value(), persons = persons.len(), - "enrich_movie: profile stored and search index updated" + "enrich_movie: search index updated and profile stored" ); Ok(()) } diff --git a/crates/application/src/movies/poster_sync_handler.rs b/crates/application/src/movies/poster_sync_handler.rs index faa99518..cff146b3 100644 --- a/crates/application/src/movies/poster_sync_handler.rs +++ b/crates/application/src/movies/poster_sync_handler.rs @@ -109,7 +109,22 @@ impl EventHandler for PosterSyncHandler { DomainEvent::MovieDiscovered { movie_id, external_metadata_id, - } => (movie_id.value(), external_metadata_id.value().to_owned()), + } => { + // The poster is stored under the movie id and the movie row is + // updated atomically with `PosterSynced`; a poster already keyed + // by this movie means an earlier delivery completed. + let id_str = movie_id.value().to_string(); + let already_synced = self + .movie_query + .get_movie_by_id(&MovieId::from_uuid(movie_id.value())) + .await? + .and_then(|m| m.poster_path().map(|p| p.value().contains(&id_str))) + .unwrap_or(false); + if already_synced { + return Ok(()); + } + (movie_id.value(), external_metadata_id.value().to_owned()) + } DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id, diff --git a/crates/application/src/wrapup/handle_requested.rs b/crates/application/src/wrapup/handle_requested.rs index c696ec15..0332f5a8 100644 --- a/crates/application/src/wrapup/handle_requested.rs +++ b/crates/application/src/wrapup/handle_requested.rs @@ -12,15 +12,14 @@ pub async fn execute( end_date: chrono::NaiveDate, ) -> Result<(), DomainError> { if let Ok(Some(rec)) = deps.wrapup_repo.get_by_id(&wrapup_id).await - && (rec.status == WrapUpStatus::Ready || rec.status == WrapUpStatus::Generating) + && rec.status == WrapUpStatus::Ready { - tracing::debug!( - "wrapup {} already {:?}, skipping", - wrapup_id.value(), - rec.status - ); + tracing::debug!("wrapup {} already ready, skipping", wrapup_id.value()); return Ok(()); } + // A record still `Generating` is an earlier delivery that died mid-way. The + // ledger guarantees no concurrent run, so resume it: generation is a pure + // read of stats and `set_complete` is the single atomic completion write. deps.wrapup_repo .update_status(&wrapup_id, &WrapUpStatus::Generating, None) diff --git a/crates/application/tests/handlers.rs b/crates/application/tests/handlers.rs new file mode 100644 index 00000000..8154d2a8 --- /dev/null +++ b/crates/application/tests/handlers.rs @@ -0,0 +1,2 @@ +#[path = "handlers/idempotency_test.rs"] +mod idempotency_test; diff --git a/crates/application/tests/handlers/idempotency_test.rs b/crates/application/tests/handlers/idempotency_test.rs new file mode 100644 index 00000000..0c8bf976 --- /dev/null +++ b/crates/application/tests/handlers/idempotency_test.rs @@ -0,0 +1,468 @@ +//! Every event handler may run twice for the same event (lease takeover after +//! a crash mid-handler). Running it twice must leave the world as if it ran once. + +use std::collections::HashMap; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, +}; + +use async_trait::async_trait; +use chrono::Utc; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::{ + EntityType, ExternalPersonId, IndexableDocument, Movie, MovieProfile, Person, + PersonCredits, PersonEnrichmentData, PersonId, + }, + ports::{ + EventHandler, MetadataClient, MovieCommand, MovieEnrichmentClient, MovieProfileRepository, + PersonCommand, PersonEnrichmentClient, PersonQuery, SearchCommand, + }, + testing::{ + FakePosterFetcher, InMemoryStore, NoopEventPublisher, NoopObjectStorage, NoopPersonCommand, + test_movie_with_external_id, + }, + value_objects::{ExternalMetadataId, MovieId, PosterUrl}, +}; + +use application::movies::{ + deps::ReindexSearchDeps, discovery_indexer::MovieDiscoveryIndexer, + event_handler::MovieEnrichmentHandler, poster_sync_handler::PosterSyncHandler, + reindex_search::SearchReindexHandler, search_cleanup::SearchCleanupHandler, +}; +use application::person::event_handler::PersonEnrichmentHandler; + +// ── fakes ─────────────────────────────────────────────────────────────────── + +/// Search index keyed like a real one: indexing the same id twice is one doc. +#[derive(Default)] +struct RecordingSearch { + docs: Mutex>, +} + +impl RecordingSearch { + fn new() -> Arc { + Arc::new(Self::default()) + } + fn snapshot(&self) -> Vec<(String, String)> { + let mut keys: Vec<_> = self.docs.lock().unwrap().keys().cloned().collect(); + keys.sort(); + keys + } +} + +fn doc_key(doc: &IndexableDocument) -> (String, String) { + match doc { + IndexableDocument::Movie { id, .. } => ("movie".into(), id.value().to_string()), + IndexableDocument::Person { id, .. } => ("person".into(), id.value().to_string()), + } +} + +#[async_trait] +impl SearchCommand for RecordingSearch { + async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> { + self.docs.lock().unwrap().insert(doc_key(&doc), 1); + Ok(()) + } + async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError> { + let kind = match entity_type { + EntityType::Movie => "movie", + EntityType::Person => "person", + }; + self.docs + .lock() + .unwrap() + .remove(&(kind.to_string(), id.to_string())); + Ok(()) + } +} + +/// Fails the first `index` call, succeeds afterwards. +struct FlakySearch { + inner: Arc, + failed_once: AtomicUsize, +} + +#[async_trait] +impl SearchCommand for FlakySearch { + async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> { + if self.failed_once.fetch_add(1, Ordering::SeqCst) == 0 { + return Err(DomainError::InfrastructureError("index down".into())); + } + self.inner.index(doc).await + } + async fn remove(&self, t: EntityType, id: &str) -> Result<(), DomainError> { + self.inner.remove(t, id).await + } +} + +fn person(tmdb: u64, name: &str) -> Person { + let ext = ExternalPersonId::new(format!("tmdb:{tmdb}")); + Person::basic(PersonId::from_external(&ext), ext, name.into(), None, None) +} + +/// One person whose `enriched_at` is set by `update_enrichment`, like the real store. +struct FakePersons { + person: Mutex, + enrich_writes: AtomicUsize, +} + +impl FakePersons { + fn new(p: Person) -> Arc { + Arc::new(Self { + person: Mutex::new(p), + enrich_writes: AtomicUsize::new(0), + }) + } +} + +#[async_trait] +impl PersonQuery for FakePersons { + async fn get_by_id(&self, id: &PersonId) -> Result, DomainError> { + let p = self.person.lock().unwrap().clone(); + Ok((p.id() == id).then_some(p)) + } + async fn get_by_external_id( + &self, + _: &ExternalPersonId, + ) -> Result, DomainError> { + Ok(None) + } + async fn get_credits(&self, _: &PersonId) -> Result { + unimplemented!() + } + async fn list_orphaned_persons(&self) -> Result, DomainError> { + Ok(vec![self.person.lock().unwrap().id().clone()]) + } + async fn list_page(&self, _limit: u32, offset: u32) -> Result, DomainError> { + if offset == 0 { + Ok(vec![self.person.lock().unwrap().clone()]) + } else { + Ok(vec![]) + } + } +} + +#[async_trait] +impl PersonCommand for FakePersons { + async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { + Ok(()) + } + async fn backfill_from_credits_batch(&self, _: u32) -> Result<(u64, bool), DomainError> { + Ok((0, false)) + } + async fn update_enrichment( + &self, + id: &PersonId, + data: &PersonEnrichmentData, + ) -> Result<(), DomainError> { + self.enrich_writes.fetch_add(1, Ordering::SeqCst); + let mut p = self.person.lock().unwrap(); + assert_eq!(p.id(), id); + *p = Person::new( + p.id().clone(), + p.external_id().clone(), + p.name().to_string(), + p.known_for_department().map(str::to_string), + p.profile_path().map(str::to_string), + data.biography.clone(), + data.birthday, + data.deathday, + data.place_of_birth.clone(), + data.also_known_as.clone(), + data.homepage.clone(), + data.imdb_id.clone(), + Some(Utc::now()), + ); + Ok(()) + } +} + +struct CountingPersonClient(AtomicUsize); + +#[async_trait] +impl PersonEnrichmentClient for CountingPersonClient { + async fn fetch_details(&self, _: &str) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(PersonEnrichmentData { + biography: Some("bio".into()), + birthday: None, + deathday: None, + place_of_birth: None, + also_known_as: vec![], + homepage: None, + imdb_id: None, + }) + } +} + +fn profile_for(movie_id: &MovieId) -> MovieProfile { + MovieProfile { + movie_id: movie_id.clone(), + tmdb_id: 42, + imdb_id: None, + overview: None, + tagline: None, + runtime_minutes: None, + budget_usd: None, + revenue_usd: None, + vote_average: None, + vote_count: None, + original_language: None, + collection_name: None, + genres: vec![], + keywords: vec![], + cast: vec![], + crew: vec![], + enriched_at: Utc::now(), + } +} + +struct CountingMovieClient(AtomicUsize); + +#[async_trait] +impl MovieEnrichmentClient for CountingMovieClient { + async fn fetch_profile(&self, movie_id: MovieId, _: &str) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(profile_for(&movie_id)) + } +} + +struct NoImages; + +#[async_trait] +impl domain::ports::ImageFetcher for NoImages { + async fn fetch_image(&self, _: &str) -> Result, DomainError> { + Err(DomainError::NotFound("none".into())) + } +} + +struct MetadataWithPoster; + +#[async_trait] +impl MetadataClient for MetadataWithPoster { + async fn fetch_movie_metadata( + &self, + _: &domain::models::MetadataSearchCriteria, + ) -> Result { + unimplemented!() + } + async fn get_poster_url( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + Ok(Some( + PosterUrl::new("https://example.com/p.jpg".into()).unwrap(), + )) + } +} + +async fn seeded_movie(store: &Arc) -> (Movie, ExternalMetadataId) { + let ext = ExternalMetadataId::new("tmdb:42".into()).unwrap(); + let movie = test_movie_with_external_id("Heat", 1995, ext.clone()); + store.upsert_movie(&movie, &[]).await.unwrap(); + (movie, ext) +} + +// ── person_enrichment ─────────────────────────────────────────────────────── + +#[tokio::test] +async fn person_enrichment_is_idempotent_when_run_twice() { + let p = person(7, "Al Pacino"); + let persons = FakePersons::new(p.clone()); + let client = Arc::new(CountingPersonClient(AtomicUsize::new(0))); + let handler = PersonEnrichmentHandler::new( + persons.clone(), + Some(client.clone() as Arc), + persons.clone(), + ); + let event = DomainEvent::PersonEnrichmentRequested { + person_id: p.id().clone(), + external_person_id: p.external_id().clone(), + }; + + handler.handle(&event).await.unwrap(); + handler.handle(&event).await.unwrap(); + + assert_eq!(client.0.load(Ordering::SeqCst), 1); + assert_eq!(persons.enrich_writes.load(Ordering::SeqCst), 1); +} + +// ── movie_enrichment ──────────────────────────────────────────────────────── + +fn enrichment_handler( + store: &Arc, + client: Arc, + search: Arc, +) -> MovieEnrichmentHandler { + MovieEnrichmentHandler::new( + client, + Arc::clone(store) as _, + Arc::clone(store) as _, + Arc::new(NoopPersonCommand), + search, + Arc::new(NoopObjectStorage), + Arc::new(NoImages), + ) +} + +#[tokio::test] +async fn movie_enrichment_is_idempotent_when_run_twice() { + let store = InMemoryStore::new(); + let (movie, ext) = seeded_movie(&store).await; + let search = RecordingSearch::new(); + let client = Arc::new(CountingMovieClient(AtomicUsize::new(0))); + let handler = enrichment_handler(&store, client.clone(), search.clone()); + let event = DomainEvent::MovieEnrichmentRequested { + movie_id: movie.id().clone(), + external_metadata_id: ext, + }; + + handler.handle(&event).await.unwrap(); + let after_first = search.snapshot(); + handler.handle(&event).await.unwrap(); + + assert_eq!(search.snapshot(), after_first); + assert_eq!(client.0.load(Ordering::SeqCst), 1, "no second remote fetch"); +} + +#[tokio::test] +async fn a_movie_enrichment_that_failed_midway_is_redone_on_redelivery() { + let store = InMemoryStore::new(); + let (movie, ext) = seeded_movie(&store).await; + let search = RecordingSearch::new(); + let flaky = Arc::new(FlakySearch { + inner: search.clone(), + failed_once: AtomicUsize::new(0), + }); + let client = Arc::new(CountingMovieClient(AtomicUsize::new(0))); + let handler = enrichment_handler(&store, client, flaky); + let event = DomainEvent::MovieEnrichmentRequested { + movie_id: movie.id().clone(), + external_metadata_id: ext, + }; + + assert!(handler.handle(&event).await.is_err()); + // The profile row is the "enrichment done" marker: it must not exist yet, + // or the redelivery below would skip and the search index would stay empty. + assert!(store.get_by_movie_id(movie.id()).await.unwrap().is_none()); + + handler.handle(&event).await.unwrap(); + assert_eq!(search.snapshot().len(), 1); + assert!(store.get_by_movie_id(movie.id()).await.unwrap().is_some()); +} + +// ── search_cleanup ────────────────────────────────────────────────────────── + +#[tokio::test] +async fn search_cleanup_is_idempotent_when_run_twice() { + let orphan = person(9, "Orphan"); + let persons = FakePersons::new(orphan.clone()); + let search = RecordingSearch::new(); + let movie_id = MovieId::from_uuid(uuid::Uuid::new_v4()); + search + .index(IndexableDocument::Person { + id: orphan.id().clone(), + person: Box::new(orphan.clone()), + }) + .await + .unwrap(); + let handler = SearchCleanupHandler::new(search.clone(), persons); + let event = DomainEvent::MovieDeleted { + movie_id, + poster_path: None, + }; + + handler.handle(&event).await.unwrap(); + let after_first = search.snapshot(); + handler.handle(&event).await.unwrap(); + + assert!(after_first.is_empty()); + assert_eq!(search.snapshot(), after_first); +} + +// ── movie_discovery_indexer ───────────────────────────────────────────────── + +#[tokio::test] +async fn movie_discovery_indexer_is_idempotent_when_run_twice() { + let store = InMemoryStore::new(); + let (movie, ext) = seeded_movie(&store).await; + let search = RecordingSearch::new(); + let handler = MovieDiscoveryIndexer::new(store.clone(), search.clone()); + let event = DomainEvent::MovieDiscovered { + movie_id: movie.id().clone(), + external_metadata_id: ext, + }; + + handler.handle(&event).await.unwrap(); + let after_first = search.snapshot(); + handler.handle(&event).await.unwrap(); + + assert_eq!(after_first.len(), 1); + assert_eq!(search.snapshot(), after_first); +} + +// ── search_reindex ────────────────────────────────────────────────────────── + +#[tokio::test] +async fn search_reindex_is_idempotent_when_run_twice() { + let store = InMemoryStore::new(); + seeded_movie(&store).await; + let persons = FakePersons::new(person(3, "Someone")); + let search = RecordingSearch::new(); + let handler = SearchReindexHandler::new(ReindexSearchDeps { + movie_query: store.clone(), + movie_profile: store.clone(), + search_command: search.clone(), + person_command: persons.clone(), + person_query: persons, + }); + + handler + .handle(&DomainEvent::SearchReindexRequested) + .await + .unwrap(); + let after_first = search.snapshot(); + handler + .handle(&DomainEvent::SearchReindexRequested) + .await + .unwrap(); + + assert!(!after_first.is_empty()); + assert_eq!(search.snapshot(), after_first); +} + +// ── poster_sync ───────────────────────────────────────────────────────────── + +#[tokio::test] +async fn poster_sync_is_idempotent_when_run_twice() { + let store = InMemoryStore::new(); + let (movie, ext) = seeded_movie(&store).await; + let publisher = NoopEventPublisher::new(); + let handler = PosterSyncHandler::new( + store.clone(), + store.clone(), + Arc::new(MetadataWithPoster), + Arc::new(FakePosterFetcher), + Arc::new(NoopObjectStorage), + publisher.clone(), + 0, + ); + let event = DomainEvent::MovieDiscovered { + movie_id: movie.id().clone(), + external_metadata_id: ext, + }; + + handler.handle(&event).await.unwrap(); + handler.handle(&event).await.unwrap(); + + let synced = store + .recorded_events() + .iter() + .filter(|e| matches!(e, DomainEvent::PosterSynced { .. })) + .count(); + assert_eq!(synced, 1, "PosterSynced fans out to federation: emit once"); + assert_eq!(publisher.published().len(), 1, "one ImageStored, not two"); +} diff --git a/crates/application/tests/wrapup/handle_requested_test.rs b/crates/application/tests/wrapup/handle_requested_test.rs index 53177b9a..ed69f321 100644 --- a/crates/application/tests/wrapup/handle_requested_test.rs +++ b/crates/application/tests/wrapup/handle_requested_test.rs @@ -92,7 +92,9 @@ async fn generates_wrapup_and_marks_complete() { } #[tokio::test] -async fn skips_if_already_generating() { +async fn a_wrapup_left_generating_is_completed_on_redelivery() { + // A crash after `Generating` was written (or a failed `set_complete`) leaves + // the record stuck; the redelivered event must finish it, not skip it. let repo = InMemoryWrapUpRepository::new(); let wrapup_id = WrapUpId::generate(); @@ -114,21 +116,62 @@ async fn skips_if_already_generating() { wrapup_stats: InMemoryWrapUpStatsQuery::new(), }; - let result = handle_requested::execute( + handle_requested::execute( &deps, wrapup_id.clone(), None, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(), ) - .await; + .await + .unwrap(); - assert!(result.is_ok()); - - // Status should still be Generating (not changed to Ready) let final_rec = repo.get_by_id(&wrapup_id).await.unwrap().unwrap(); - assert_eq!(final_rec.status, WrapUpStatus::Generating); - assert!(repo.recorded_events().is_empty()); + assert_eq!(final_rec.status, WrapUpStatus::Ready); + assert!(final_rec.report.is_some()); + assert_eq!(repo.recorded_events().len(), 1); +} + +#[tokio::test] +async fn wrapup_is_idempotent_when_run_twice() { + let repo = InMemoryWrapUpRepository::new(); + let wrapup_id = WrapUpId::generate(); + let record = WrapUpRecord { + id: wrapup_id.clone(), + user_id: None, + start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + end_date: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(), + status: WrapUpStatus::Pending, + report: None, + error_message: None, + created_at: Utc::now().naive_utc(), + completed_at: None, + }; + repo.create(&record, &[]).await.unwrap(); + let deps = HandleWrapUpRequestedDeps { + wrapup_repo: repo.clone(), + wrapup_stats: InMemoryWrapUpStatsQuery::new(), + }; + + for _ in 0..2 { + handle_requested::execute( + &deps, + wrapup_id.clone(), + None, + NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(), + ) + .await + .unwrap(); + } + + let rec = repo.get_by_id(&wrapup_id).await.unwrap().unwrap(); + assert_eq!(rec.status, WrapUpStatus::Ready); + assert_eq!( + repo.recorded_events().len(), + 1, + "WrapUpCompleted emitted once" + ); } #[tokio::test] diff --git a/crates/bootstrap/src/follow_backfill_handler.rs b/crates/bootstrap/src/follow_backfill_handler.rs index 37b18070..5cce039b 100644 --- a/crates/bootstrap/src/follow_backfill_handler.rs +++ b/crates/bootstrap/src/follow_backfill_handler.rs @@ -51,3 +51,75 @@ impl EventHandler for FollowBackfillHandler { } } } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + use std::sync::Mutex; + + use domain::models::FollowedActorInfo; + use domain::value_objects::UserId; + use uuid::Uuid; + + use super::*; + + /// Models the real port: imports are keyed upserts and backfill sends carry + /// deterministic activity ids, so a repeat lands on the same key. + #[derive(Default)] + struct KeyedBackfill { + imported: Mutex>, + sent: Mutex>, + } + + #[async_trait] + impl ApBackfillPort for KeyedBackfill { + async fn get_following(&self, _: Uuid) -> Result, 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.imported + .lock() + .unwrap() + .insert((outbox.into(), actor.into())); + Ok(()) + } + async fn run_backfill_for_follower( + &self, + owner: Uuid, + inbox: String, + ) -> Result<(), DomainError> { + self.sent.lock().unwrap().insert((owner, inbox)); + Ok(()) + } + } + + #[tokio::test] + async fn follow_backfill_is_idempotent_when_run_twice() { + let port = Arc::new(KeyedBackfill::default()); + let handler = FollowBackfillHandler { + backfill: port.clone(), + }; + let owner = UserId::from_uuid(Uuid::new_v4()); + let accepted = DomainEvent::FollowAccepted { + owner: owner.clone(), + requester: SocialIdentity::Remote { + actor_url: "https://remote.example/users/a".into(), + }, + }; + let backfill = DomainEvent::BackfillFollower { + owner_user_id: owner, + follower_inbox_url: "https://remote.example/inbox".into(), + }; + + for event in [&accepted, &backfill] { + handler.handle(event).await.unwrap(); + handler.handle(event).await.unwrap(); + } + + assert_eq!(port.imported.lock().unwrap().len(), 1); + assert_eq!(port.sent.lock().unwrap().len(), 1); + } +} -- 2.49.1 From 818ab893a1d5dd84635d42d7df46096e044dc5b2 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 01:33:29 +0100 Subject: [PATCH 066/104] fix: real AP id tests, exact poster key check, exists port, list_movies fake --- crates/adapters/activitypub/Cargo.toml | 4 + crates/adapters/activitypub/src/urls.rs | 31 --- .../activitypub/tests/activity_ids.rs | 193 ++++++++++++++++++ .../adapters/image-converter/src/handler.rs | 10 +- crates/adapters/object-storage/src/lib.rs | 8 + crates/application/src/movies/enrich_movie.rs | 5 +- .../src/movies/poster_sync_handler.rs | 17 +- .../tests/handlers/idempotency_test.rs | 89 +++++++- .../bootstrap/src/follow_backfill_handler.rs | 58 +++--- crates/domain/src/ports/images.rs | 2 + crates/domain/src/testing/fakes/noops.rs | 4 + crates/domain/src/testing/fakes/panics.rs | 3 + .../domain/src/testing/fakes/store_movie.rs | 51 ++++- 13 files changed, 395 insertions(+), 80 deletions(-) create mode 100644 crates/adapters/activitypub/tests/activity_ids.rs diff --git a/crates/adapters/activitypub/Cargo.toml b/crates/adapters/activitypub/Cargo.toml index c00dcf72..98e1ef85 100644 --- a/crates/adapters/activitypub/Cargo.toml +++ b/crates/adapters/activitypub/Cargo.toml @@ -18,3 +18,7 @@ async-trait = { workspace = true } url = { version = "2", features = ["serde"] } ammonia = "4" + +[dev-dependencies] +tokio = { workspace = true } +domain = { workspace = true, features = ["test-helpers"] } diff --git a/crates/adapters/activitypub/src/urls.rs b/crates/adapters/activitypub/src/urls.rs index ddb3458b..8b36a405 100644 --- a/crates/adapters/activitypub/src/urls.rs +++ b/crates/adapters/activitypub/src/urls.rs @@ -38,34 +38,3 @@ pub fn watchlist_entry_url( )) .expect("base_url is always a valid URL prefix") } - -#[cfg(test)] -mod tests { - use super::*; - - /// k-ap derives the Create activity id from the object id (UUIDv5), so - /// re-handling an event only re-sends the same activity id if our object - /// ids are pure functions of the entity. Update/Delete/Move ids are minted - /// randomly inside k-ap 0.5 with no override (see ADR-0015): those repeat - /// semantically idempotent activities under a fresh id. - #[test] - fn activitypub_object_ids_are_stable_across_event_redelivery() { - let instance = InstanceIdentity::new("https://diary.example"); - let review = ReviewId::from_uuid(uuid::Uuid::new_v4()); - let user = uuid::Uuid::new_v4(); - let movie = uuid::Uuid::new_v4(); - - assert_eq!( - review_url(&instance, &review), - review_url(&instance, &review) - ); - assert_eq!( - goal_url(&instance, user, 2026), - goal_url(&instance, user, 2026) - ); - assert_eq!( - watchlist_entry_url(&instance, user, movie), - watchlist_entry_url(&instance, user, movie) - ); - } -} diff --git a/crates/adapters/activitypub/tests/activity_ids.rs b/crates/adapters/activitypub/tests/activity_ids.rs new file mode 100644 index 00000000..4a52d46e --- /dev/null +++ b/crates/adapters/activitypub/tests/activity_ids.rs @@ -0,0 +1,193 @@ +//! Outbound activity ids across a redelivered event, through the real k-ap +//! `ActivityPubService` (delivery captured via its `EventPublisher` seam). +//! +//! k-ap 0.5.0 derives Create ids from the object id (UUIDv5, +//! `service/broadcast.rs:285-288`) but mints Update/Delete/Move/actor-update ids +//! randomly (`broadcast.rs:81,129,157,210,321,343,377` -> `url_scheme.rs:53`, +//! `Uuid::new_v4`) with no override. + +use std::sync::Arc; +use std::time::Duration; + +use activitypub::ActivityPubEventHandler; +use async_trait::async_trait; +use chrono::NaiveDate; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::{DiaryEntry, WatchlistWithMovie}, + ports::{EventHandler, LocalApContentQuery, MovieCommand}, + testing::{ + InMemoryStore, PanicGoalQuery, PanicReviewRepository, PanicStatsRepository, test_movie, + }, + value_objects::{InstanceIdentity, MovieId, UserId}, +}; +use k_ap::{ + ActivityPubService, ApUser, FederationEvent, + testing::{MockActorRepoBuilder, MockFollowRepoBuilder, MockUserRepoBuilder}, +}; +use tokio::sync::mpsc; + +struct NoContent; + +#[async_trait] +impl LocalApContentQuery for NoContent { + async fn get_local_watchlist_for_user( + &self, + _: &UserId, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_local_reviews_for_movie( + &self, + _: &MovieId, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_local_reviews_page( + &self, + _: &UserId, + _: Option, + _: usize, + ) -> Result, DomainError> { + Ok(vec![]) + } +} + +struct Capture(mpsc::UnboundedSender); + +#[async_trait] +impl k_ap::EventPublisher for Capture { + async fn publish(&self, event: FederationEvent) -> anyhow::Result<()> { + if let FederationEvent::DeliveryRequested { activity, .. } = event { + let _ = self.0.send(activity); + } + Ok(()) + } +} + +struct Harness { + handler: ActivityPubEventHandler, + delivered: mpsc::UnboundedReceiver, + user: UserId, + movie_id: MovieId, +} + +async fn harness() -> Harness { + let user = UserId::from_uuid(uuid::Uuid::new_v4()); + let uid = user.value(); + let (tx, rx) = mpsc::unbounded_channel(); + let service = ActivityPubService::builder("https://diary.example") + .activity_repo(k_ap::testing::MockActivityRepoBuilder::new().build()) + .follow_repo( + MockFollowRepoBuilder::new() + .on_get_accepted_follower_inboxes(|_| { + Ok(vec!["https://remote.example/inbox".to_string()]) + }) + .build(), + ) + .actor_repo(MockActorRepoBuilder::new().build()) + .blocklist_repo(k_ap::testing::MockBlocklistRepoBuilder::new().build()) + .user_repo( + MockUserRepoBuilder::new() + .on_find_by_id(move |id| { + Ok((id == uid).then(|| ApUser { + id, + username: "alice".into(), + display_name: None, + bio: None, + avatar_url: None, + banner_url: None, + also_known_as: vec![], + profile_url: None, + attachment: vec![], + manually_approves_followers: false, + discoverable: true, + actor_type: Default::default(), + featured_url: None, + })) + }) + .build(), + ) + .content_reader(k_ap::testing::MockContentReaderBuilder::new().build()) + .object_handler(k_ap::testing::MockObjectHandlerBuilder::new().build()) + .event_publisher(Arc::new(Capture(tx))) + .build() + .await + .expect("service builds"); + + let store = InMemoryStore::new(); + let movie = test_movie("Heat"); + store.upsert_movie(&movie, &[]).await.unwrap(); + let handler = ActivityPubEventHandler::new( + Arc::new(service), + Arc::new(NoContent), + Arc::new(PanicReviewRepository), + store.clone(), + Arc::new(PanicGoalQuery), + Arc::new(PanicStatsRepository), + store, + InstanceIdentity::new("https://diary.example"), + ); + Harness { + handler, + delivered: rx, + user, + movie_id: movie.id().clone(), + } +} + +impl Harness { + async fn next_activity_id(&mut self) -> String { + let activity = tokio::time::timeout(Duration::from_secs(5), self.delivered.recv()) + .await + .expect("an activity was delivered") + .expect("channel open"); + activity["id"] + .as_str() + .expect("activity has an id") + .to_string() + } +} + +#[tokio::test] +async fn activitypub_is_idempotent_when_run_twice() { + let mut h = harness().await; + let event = DomainEvent::WatchlistEntryAdded { + user_id: h.user.clone(), + movie_id: h.movie_id.clone(), + movie_title: "Heat".into(), + release_year: 1995, + external_metadata_id: None, + added_at: NaiveDate::from_ymd_opt(2026, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(), + }; + + h.handler.handle(&event).await.unwrap(); + let first = h.next_activity_id().await; + h.handler.handle(&event).await.unwrap(); + let second = h.next_activity_id().await; + + assert!(first.starts_with("https://diary.example/activities/create/")); + assert_eq!(first, second, "a redelivered Create keeps its activity id"); +} + +/// Characterises the k-ap 0.5.0 limitation recorded in ADR-0015. If k-ap starts +/// minting deterministic Delete ids this fails: upgrade the ADR and flip it. +#[tokio::test] +async fn redelivered_delete_gets_a_fresh_activity_id_known_k_ap_limitation() { + let mut h = harness().await; + let event = DomainEvent::WatchlistEntryRemoved { + user_id: h.user.clone(), + movie_id: h.movie_id.clone(), + }; + + h.handler.handle(&event).await.unwrap(); + let first = h.next_activity_id().await; + h.handler.handle(&event).await.unwrap(); + let second = h.next_activity_id().await; + + assert_ne!(first, second); +} diff --git a/crates/adapters/image-converter/src/handler.rs b/crates/adapters/image-converter/src/handler.rs index a0e8a3f9..046562cb 100644 --- a/crates/adapters/image-converter/src/handler.rs +++ b/crates/adapters/image-converter/src/handler.rs @@ -48,13 +48,9 @@ impl EventHandler for ImageConversionHandler { // `get` falls back to `{key}.webp` / `{key}.avif`, which would re-encode // an already converted image on redelivery. Probe the exact key first: // once the original is gone, an earlier delivery finished the conversion. - match self.storage.get_stream(&key).await { - Ok(_) => {} - Err(DomainError::NotFound(_)) => { - tracing::debug!("original {key} already converted, skipping"); - return Ok(()); - } - Err(e) => return Err(e), + if !self.storage.exists(&key).await? { + tracing::warn!(%key, "original image already gone, treating conversion as done"); + return Ok(()); } let bytes = self.storage.get(&key).await?; let format = self.format; diff --git a/crates/adapters/object-storage/src/lib.rs b/crates/adapters/object-storage/src/lib.rs index 556b6440..5f8190ae 100644 --- a/crates/adapters/object-storage/src/lib.rs +++ b/crates/adapters/object-storage/src/lib.rs @@ -82,6 +82,14 @@ impl ObjectStorage for ObjectStorageAdapter { Ok(Box::pin(stream)) } + async fn exists(&self, key: &str) -> Result { + match self.store.head(&Path::from(key)).await { + Ok(_) => Ok(true), + Err(object_store::Error::NotFound { .. }) => Ok(false), + Err(e) => Err(DomainError::InfrastructureError(e.to_string())), + } + } + async fn delete(&self, key: &str) -> Result<(), DomainError> { let path = Path::from(key); match self.store.delete(&path).await { diff --git a/crates/application/src/movies/enrich_movie.rs b/crates/application/src/movies/enrich_movie.rs index 1cf14f6a..c0510e20 100644 --- a/crates/application/src/movies/enrich_movie.rs +++ b/crates/application/src/movies/enrich_movie.rs @@ -14,8 +14,9 @@ pub async fn execute(deps: &EnrichMovieDeps, cmd: EnrichMovieCommand) -> Result< } let Some(movie) = deps.movie_query.get_movie_by_id(&cmd.movie_id).await? else { - tracing::warn!(movie_id = %cmd.movie_id.value(), "enrich_movie: movie not found, storing profile only"); - deps.movie_profile.upsert(&cmd.profile).await?; + // The movie row is written with the event, so a missing row means it was + // deleted. Do not store the profile: it is the "enrichment done" marker. + tracing::warn!(movie_id = %cmd.movie_id.value(), "enrich_movie: movie not found, skipping"); return Ok(()); }; diff --git a/crates/application/src/movies/poster_sync_handler.rs b/crates/application/src/movies/poster_sync_handler.rs index cff146b3..1f9e4620 100644 --- a/crates/application/src/movies/poster_sync_handler.rs +++ b/crates/application/src/movies/poster_sync_handler.rs @@ -110,15 +110,24 @@ impl EventHandler for PosterSyncHandler { movie_id, external_metadata_id, } => { - // The poster is stored under the movie id and the movie row is - // updated atomically with `PosterSynced`; a poster already keyed - // by this movie means an earlier delivery completed. + // The poster is stored under the movie id (the converter later + // appends `.webp`/`.avif`) and the movie row is updated + // atomically with `PosterSynced`; exactly that path on the movie + // means an earlier delivery completed. let id_str = movie_id.value().to_string(); + let canonical = [ + id_str.clone(), + format!("{id_str}.webp"), + format!("{id_str}.avif"), + ]; let already_synced = self .movie_query .get_movie_by_id(&MovieId::from_uuid(movie_id.value())) .await? - .and_then(|m| m.poster_path().map(|p| p.value().contains(&id_str))) + .and_then(|m| { + m.poster_path() + .map(|p| canonical.iter().any(|k| k == p.value())) + }) .unwrap_or(false); if already_synced { return Ok(()); diff --git a/crates/application/tests/handlers/idempotency_test.rs b/crates/application/tests/handlers/idempotency_test.rs index 0c8bf976..0028e324 100644 --- a/crates/application/tests/handlers/idempotency_test.rs +++ b/crates/application/tests/handlers/idempotency_test.rs @@ -24,7 +24,7 @@ use domain::{ FakePosterFetcher, InMemoryStore, NoopEventPublisher, NoopObjectStorage, NoopPersonCommand, test_movie_with_external_id, }, - value_objects::{ExternalMetadataId, MovieId, PosterUrl}, + value_objects::{ExternalMetadataId, MovieId, PosterPath, PosterUrl}, }; use application::movies::{ @@ -239,6 +239,32 @@ impl domain::ports::ImageFetcher for NoImages { } } +/// Returns the key it was given, like the real adapter. +struct KeyStorage; + +#[async_trait] +impl domain::ports::ObjectStorage for KeyStorage { + async fn store(&self, key: &str, _: &[u8]) -> Result { + Ok(key.to_string()) + } + async fn get(&self, _: &str) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_stream( + &self, + _: &str, + ) -> Result>, DomainError> + { + Ok(Box::pin(futures::stream::empty())) + } + async fn delete(&self, _: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn exists(&self, _: &str) -> Result { + Ok(true) + } +} + struct MetadataWithPoster; #[async_trait] @@ -409,7 +435,7 @@ async fn movie_discovery_indexer_is_idempotent_when_run_twice() { #[tokio::test] async fn search_reindex_is_idempotent_when_run_twice() { let store = InMemoryStore::new(); - seeded_movie(&store).await; + let (movie, _) = seeded_movie(&store).await; let persons = FakePersons::new(person(3, "Someone")); let search = RecordingSearch::new(); let handler = SearchReindexHandler::new(ReindexSearchDeps { @@ -430,7 +456,11 @@ async fn search_reindex_is_idempotent_when_run_twice() { .await .unwrap(); - assert!(!after_first.is_empty()); + assert!( + after_first.contains(&("movie".to_string(), movie.id().value().to_string())), + "the movie doc is indexed" + ); + assert!(after_first.iter().any(|(k, _)| k == "person")); assert_eq!(search.snapshot(), after_first); } @@ -446,7 +476,7 @@ async fn poster_sync_is_idempotent_when_run_twice() { store.clone(), Arc::new(MetadataWithPoster), Arc::new(FakePosterFetcher), - Arc::new(NoopObjectStorage), + Arc::new(KeyStorage), publisher.clone(), 0, ); @@ -466,3 +496,54 @@ async fn poster_sync_is_idempotent_when_run_twice() { assert_eq!(synced, 1, "PosterSynced fans out to federation: emit once"); assert_eq!(publisher.published().len(), 1, "one ImageStored, not two"); } + +#[tokio::test] +async fn poster_sync_skips_converted_poster_keys_but_not_lookalike_paths() { + let store = InMemoryStore::new(); + let (mut movie, ext) = seeded_movie(&store).await; + let id = movie.id().value().to_string(); + let publisher = NoopEventPublisher::new(); + let handler = PosterSyncHandler::new( + store.clone(), + store.clone(), + Arc::new(MetadataWithPoster), + Arc::new(FakePosterFetcher), + Arc::new(KeyStorage), + publisher.clone(), + 0, + ); + let event = DomainEvent::MovieDiscovered { + movie_id: movie.id().clone(), + external_metadata_id: ext, + }; + + // The converter's output is the canonical synced key: skipped. + movie.update_poster(PosterPath::new(format!("{id}.webp")).unwrap()); + store.upsert_movie(&movie, &[]).await.unwrap(); + handler.handle(&event).await.unwrap(); + assert!(publisher.published().is_empty()); + + // A foreign path that merely contains the id is not a synced poster. + movie.update_poster(PosterPath::new(format!("https://cdn.example/{id}/p.jpg")).unwrap()); + store.upsert_movie(&movie, &[]).await.unwrap(); + handler.handle(&event).await.unwrap(); + assert_eq!(publisher.published().len(), 1); +} + +#[tokio::test] +async fn enrichment_of_a_deleted_movie_stores_no_profile() { + let store = InMemoryStore::new(); + let ghost = MovieId::from_uuid(uuid::Uuid::new_v4()); + let search = RecordingSearch::new(); + let client = Arc::new(CountingMovieClient(AtomicUsize::new(0))); + let handler = enrichment_handler(&store, client, search.clone()); + let event = DomainEvent::MovieEnrichmentRequested { + movie_id: ghost.clone(), + external_metadata_id: ExternalMetadataId::new("tmdb:1".into()).unwrap(), + }; + + handler.handle(&event).await.unwrap(); + + assert!(store.get_by_movie_id(&ghost).await.unwrap().is_none()); + assert!(search.snapshot().is_empty()); +} diff --git a/crates/bootstrap/src/follow_backfill_handler.rs b/crates/bootstrap/src/follow_backfill_handler.rs index 5cce039b..96f91c2f 100644 --- a/crates/bootstrap/src/follow_backfill_handler.rs +++ b/crates/bootstrap/src/follow_backfill_handler.rs @@ -54,7 +54,6 @@ impl EventHandler for FollowBackfillHandler { #[cfg(test)] mod tests { - use std::collections::HashSet; use std::sync::Mutex; use domain::models::FollowedActorInfo; @@ -63,16 +62,16 @@ mod tests { use super::*; - /// Models the real port: imports are keyed upserts and backfill sends carry - /// deterministic activity ids, so a repeat lands on the same key. + /// The handler has no state of its own: idempotency lives behind the port + /// (remote imports are keyed upserts; backfill Creates use the deterministic + /// k-ap id `activities/create/{uuidv5(object id)}`, see activitypub + /// `tests/activity_ids.rs`). What this proves is that a redelivery replays + /// exactly the same port calls, with no accumulated or derived state. #[derive(Default)] - struct KeyedBackfill { - imported: Mutex>, - sent: Mutex>, - } + struct CallLog(Mutex>); #[async_trait] - impl ApBackfillPort for KeyedBackfill { + impl ApBackfillPort for CallLog { async fn get_following(&self, _: Uuid) -> Result, DomainError> { Ok(vec![FollowedActorInfo { url: "https://remote.example/users/a".into(), @@ -80,10 +79,10 @@ mod tests { }]) } async fn import_remote_outbox(&self, outbox: &str, actor: &str) -> Result<(), DomainError> { - self.imported + self.0 .lock() .unwrap() - .insert((outbox.into(), actor.into())); + .push(format!("import {outbox} {actor}")); Ok(()) } async fn run_backfill_for_follower( @@ -91,35 +90,42 @@ mod tests { owner: Uuid, inbox: String, ) -> Result<(), DomainError> { - self.sent.lock().unwrap().insert((owner, inbox)); + self.0 + .lock() + .unwrap() + .push(format!("backfill {owner} {inbox}")); Ok(()) } } #[tokio::test] - async fn follow_backfill_is_idempotent_when_run_twice() { - let port = Arc::new(KeyedBackfill::default()); + 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 accepted = DomainEvent::FollowAccepted { - owner: owner.clone(), - requester: SocialIdentity::Remote { - actor_url: "https://remote.example/users/a".into(), + let events = [ + DomainEvent::FollowAccepted { + owner: owner.clone(), + requester: SocialIdentity::Remote { + actor_url: "https://remote.example/users/a".into(), + }, }, - }; - let backfill = DomainEvent::BackfillFollower { - owner_user_id: owner, - follower_inbox_url: "https://remote.example/inbox".into(), - }; + DomainEvent::BackfillFollower { + owner_user_id: owner, + follower_inbox_url: "https://remote.example/inbox".into(), + }, + ]; - for event in [&accepted, &backfill] { + 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..]); } - - assert_eq!(port.imported.lock().unwrap().len(), 1); - assert_eq!(port.sent.lock().unwrap().len(), 1); } } diff --git a/crates/domain/src/ports/images.rs b/crates/domain/src/ports/images.rs index a16fe31b..be06f48a 100644 --- a/crates/domain/src/ports/images.rs +++ b/crates/domain/src/ports/images.rs @@ -11,6 +11,8 @@ pub trait ObjectStorage: Send + Sync { key: &str, ) -> Result>, DomainError>; async fn delete(&self, key: &str) -> Result<(), DomainError>; + /// Whether exactly `key` exists. Unlike `get`, no extension fallback. + async fn exists(&self, key: &str) -> Result; } #[async_trait] diff --git a/crates/domain/src/testing/fakes/noops.rs b/crates/domain/src/testing/fakes/noops.rs index 6d6d5aaf..341385d4 100644 --- a/crates/domain/src/testing/fakes/noops.rs +++ b/crates/domain/src/testing/fakes/noops.rs @@ -61,6 +61,10 @@ impl ObjectStorage for NoopObjectStorage { async fn delete(&self, _key: &str) -> Result<(), DomainError> { Ok(()) } + + async fn exists(&self, _key: &str) -> Result { + Ok(true) + } } // Re-export production noop types so test code that imports from diff --git a/crates/domain/src/testing/fakes/panics.rs b/crates/domain/src/testing/fakes/panics.rs index 54c43d44..d2f754a2 100644 --- a/crates/domain/src/testing/fakes/panics.rs +++ b/crates/domain/src/testing/fakes/panics.rs @@ -609,6 +609,9 @@ impl ObjectStorage for PanicObjectStorage { async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!("PanicObjectStorage called") } + async fn exists(&self, _: &str) -> Result { + panic!("PanicObjectStorage called") + } } pub struct PanicAuthService; diff --git a/crates/domain/src/testing/fakes/store_movie.rs b/crates/domain/src/testing/fakes/store_movie.rs index f260d632..139cdae0 100644 --- a/crates/domain/src/testing/fakes/store_movie.rs +++ b/crates/domain/src/testing/fakes/store_movie.rs @@ -152,14 +152,53 @@ impl MovieQuery for InMemoryStore { async fn list_movies( &self, - _page: &PageParams, - _filter: &MovieFilter, + page: &PageParams, + filter: &MovieFilter, ) -> Result, DomainError> { + // Mirrors the sqlite adapter: filter, ORDER BY title ASC, then page. + let movies = self.movies.read().unwrap(); + let profiles = self.movie_profiles.read().unwrap(); + let search = filter.search.as_deref().map(str::to_lowercase); + let mut rows: Vec = movies + .values() + .filter_map(|m| { + let profile = profiles.get(&m.id().value()); + let genres: Vec = profile + .map(|p| p.genres.iter().map(|g| g.name.clone()).collect()) + .unwrap_or_default(); + let language = profile.and_then(|p| p.original_language.clone()); + let keep = search + .as_ref() + .is_none_or(|q| m.title().value().to_lowercase().contains(q)) + && filter + .language + .as_ref() + .is_none_or(|l| language.as_ref() == Some(l)) + && filter.genre.as_ref().is_none_or(|g| { + genres.iter().any(|n| n.to_lowercase() == g.to_lowercase()) + }); + keep.then(|| MovieSummary { + movie: m.clone(), + genres, + runtime_minutes: profile.and_then(|p| p.runtime_minutes), + original_language: language, + overview: profile.and_then(|p| p.overview.clone()), + collection_name: profile.and_then(|p| p.collection_name.clone()), + }) + }) + .collect(); + rows.sort_by(|a, b| a.movie.title().value().cmp(b.movie.title().value())); + let total_count = rows.len() as u64; + let items = rows + .into_iter() + .skip(page.offset as usize) + .take(page.limit as usize) + .collect(); Ok(Paginated { - items: vec![], - total_count: 0, - limit: 10, - offset: 0, + items, + total_count, + limit: page.limit, + offset: page.offset, }) } -- 2.49.1 From 2fb577af652a10f916901533e13127b0c076d518 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 01:35:33 +0100 Subject: [PATCH 067/104] fix: apply dedup window + max_deliver to existing jetstream stream/consumer --- crates/adapters/nats/src/consumer.rs | 54 +++++++++++++++++++++- crates/adapters/nats/src/tests/consumer.rs | 43 +++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/crates/adapters/nats/src/consumer.rs b/crates/adapters/nats/src/consumer.rs index d26b2156..b98191e9 100644 --- a/crates/adapters/nats/src/consumer.rs +++ b/crates/adapters/nats/src/consumer.rs @@ -126,6 +126,40 @@ fn consume_from_rx(rx: EnvelopeRx) -> BoxStream<'static, Result Option { + (existing.duplicate_window != DEDUP_WINDOW).then(|| StreamConfig { + duplicate_window: DEDUP_WINDOW, + ..existing.clone() + }) +} + +/// `Some(updated)` when an existing durable consumer disagrees on `max_deliver` +/// or `ack_wait`. Both are mutable on a live consumer, so the update is safe +/// and keeps the consumer's delivery position. Immutable fields (durable name, +/// filter subject, deliver policy) are never touched. +fn consumer_config_update( + existing: &jetstream::consumer::Config, + desired_max_deliver: i64, +) -> Option { + if existing.max_deliver == desired_max_deliver && existing.ack_wait == ACK_WAIT { + return None; + } + Some(pull::Config { + durable_name: existing.durable_name.clone(), + filter_subject: existing.filter_subject.clone(), + ack_wait: ACK_WAIT, + max_deliver: desired_max_deliver, + ..Default::default() + }) +} + // ── JetStream consumer ──────────────────────────────────────────────────────── pub struct NatsJetStreamConsumer { @@ -145,6 +179,14 @@ impl NatsJetStreamConsumer { ..Default::default() }) .await?; + if let Some(updated) = stream_config_update(&stream.cached_info().config) { + tracing::warn!( + stream = %cfg.stream_name, + "existing JetStream stream has a different duplicate window; updating to {:?}", + DEDUP_WINDOW + ); + js.update_stream(updated).await?; + } let subject_filter = consumer_subject_filter(&cfg.subject_prefix); let consumer = stream @@ -153,12 +195,22 @@ impl NatsJetStreamConsumer { pull::Config { durable_name: Some(cfg.consumer_name.clone()), filter_subject: subject_filter, - ack_wait: std::time::Duration::from_secs(600), + ack_wait: ACK_WAIT, max_deliver: cfg.max_deliver, ..Default::default() }, ) .await?; + if let Some(updated) = + consumer_config_update(&consumer.cached_info().config, cfg.max_deliver) + { + tracing::warn!( + consumer = %cfg.consumer_name, + "existing JetStream consumer has different max_deliver/ack_wait; updating to max_deliver={}", + cfg.max_deliver + ); + stream.update_consumer(updated).await?; + } let (tx, rx) = mpsc::channel(128); diff --git a/crates/adapters/nats/src/tests/consumer.rs b/crates/adapters/nats/src/tests/consumer.rs index d80c0d0e..c8d8dffa 100644 --- a/crates/adapters/nats/src/tests/consumer.rs +++ b/crates/adapters/nats/src/tests/consumer.rs @@ -44,3 +44,46 @@ fn decode_message_rejects_a_malformed_id() { fn decode_message_rejects_garbage() { assert!(decode_message(b"{not json").is_err()); } + +#[test] +fn stream_with_the_default_window_is_updated_and_keeps_other_fields() { + let existing = StreamConfig { + name: "S".into(), + max_messages: 42, + duplicate_window: std::time::Duration::from_secs(120), + ..Default::default() + }; + let updated = stream_config_update(&existing).unwrap(); + assert_eq!(updated.duplicate_window, DEDUP_WINDOW); + assert_eq!(updated.max_messages, 42); + assert_eq!(updated.name, "S"); +} + +#[test] +fn stream_with_the_right_window_is_left_alone() { + let existing = StreamConfig { + duplicate_window: DEDUP_WINDOW, + ..Default::default() + }; + assert!(stream_config_update(&existing).is_none()); +} + +#[test] +fn consumer_max_deliver_drift_is_updated() { + let existing = jetstream::consumer::Config { + durable_name: Some("c".into()), + filter_subject: "a.>".into(), + max_deliver: -1, + ack_wait: ACK_WAIT, + ..Default::default() + }; + let updated = consumer_config_update(&existing, 5).unwrap(); + assert_eq!(updated.max_deliver, 5); + assert_eq!(updated.durable_name.as_deref(), Some("c")); + assert_eq!(updated.filter_subject, "a.>"); + let same = jetstream::consumer::Config { + max_deliver: 5, + ..existing + }; + assert!(consumer_config_update(&same, 5).is_none()); +} -- 2.49.1 From 6ae3f54a3634920c8ac4d98073dd9c9030a70579 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 01:39:01 +0100 Subject: [PATCH 068/104] docs: adr 0015 transactional outbox --- CONTRIBUTING.md | 2 + README.md | 2 + docs/adr/0015-transactional-outbox.md | 85 +++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 docs/adr/0015-transactional-outbox.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4884241..ae8ede8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,6 +77,8 @@ SQLite is the only supported backend (see [ADR-0012](docs/adr/0012-sqlite-is-the ### Federation (ActivityPub) +Events: never publish from a use case after the fact. Pass the events to the repository/command-port method that writes the data so they land in the outbox (`event_queue`) in the same transaction (ADR-0015). The worker delivers them at-least-once and a ledger skips handlers already done, so every handler must be idempotent. + Federation is feature-gated (`#[cfg(feature = "federation")]`). If your feature should federate, add domain events, handle them in `crates/adapters/activitypub/src/event_handler.rs`, and create an AP object + inbound handler. ## Code style diff --git a/README.md b/README.md index 14a75adc..dae48a09 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,8 @@ cargo run -p worker # event worker (poster sync, in a separate terminal) The worker polls the event queue and must run alongside the server to process background tasks like poster fetching. Both processes share the same database. +**Event flow.** The server never publishes events directly: every write that raises an event stores it in the `event_queue` table in the same transaction (a transactional outbox, see [ADR-0015](docs/adr/0015-transactional-outbox.md)). With `EVENT_BUS_BACKEND=db` the worker consumes those rows; with `nats` the worker relays them to JetStream (with `Nats-Msg-Id` set to the event id) and consumes them from there. Duplicates are filtered in three layers: the broker's duplicate window, a claim lease on queue rows, and a per-(event, handler) processed-event ledger. Handlers must stay idempotent. + ## API All REST endpoints are under `/api/v1/`. Authentication uses `Authorization: Bearer ` obtained from `POST /api/v1/auth/login`. diff --git a/docs/adr/0015-transactional-outbox.md b/docs/adr/0015-transactional-outbox.md new file mode 100644 index 00000000..50ae4d24 --- /dev/null +++ b/docs/adr/0015-transactional-outbox.md @@ -0,0 +1,85 @@ +# Events go through a transactional outbox and are delivered idempotently + +Domain events used to be published after the write that caused them, from the use case, on a +channel the server owned. A crash between the write and the publish lost the event (no poster, no +federation delivery, no search index entry), and with NATS the server needed a live broker +connection just to log a review. Delivery was also at-least-once without any guard, so a redelivered +event ran its handlers twice. + +## Decision + +**The outbox is `event_queue`.** The same SQLite table the DB-backed bus already used is now the +outbox for every backend. Migration `0101` adds `event_queue.event_id` (UUID, unique when set) and +`claimed_at`, and creates `processed_events` (`event_id`, `handler`, `status`, `started_at`, owner +token). Legacy rows have a NULL `event_id` and are still delivered; they just get no dedup. + +**Writes are atomic with their events.** Repository and command-port methods that change state take +the events they cause and write them in the same transaction as the data. `shared::enqueue_events` +is the single path that inserts outbox rows. Covered: review log/edit/delete (including orphan +movie deletion and `ReviewDeleted`/`MovieDeleted`), watchlist add/remove, movie discovery and +enrichment requests, wrap-up, integration writes, local follow/unfollow, remote review save. +Use cases never open transactions; the adapter does. + +**The server never talks to NATS.** With `EVENT_BUS_BACKEND=nats` the server still only inserts +outbox rows. The worker runs a relay that claims pending rows and publishes them to JetStream with +`Nats-Msg-Id` set to the event id, then marks the row done. With `db` the worker consumes the rows +directly. `max_deliver` on the JetStream consumer equals `EVENT_QUEUE_MAX_ATTEMPTS`, so both +backends dead-letter after the same number of attempts. + +**Three dedup layers.** +1. *Broker.* JetStream drops a repeated `Nats-Msg-Id` inside `duplicate_window`, set explicitly to 1 + hour (longer than the relay's retry horizon) rather than the 2-minute server default. +2. *Relay lease.* A row claimed by a relay or DB consumer that then died is reclaimed once + `claimed_at` is older than 600 s, so a crash never strands an event in `processing`. +3. *Processed-event ledger.* Before a handler runs, `(event_id, handler)` is claimed in + `processed_events` (INSERT `in_progress`, or take over a claim older than the 1800 s lease). + Success marks it `done`; failure releases it; both are conditioned on an owner token so a + stale worker cannot clobber a newer claim. A delivery that sees a fresh claim held elsewhere is + deferred (`AckHandle::defer`: rescheduled to the lease expiry without consuming an attempt; + JetStream `nak` with delay, which does count toward `max_deliver`). A `done` row skips the handler. + Ledger rows are kept 30 days. A ledger read error skips the handler and nacks. + +## Guarantees + +- No two deliveries run the same handler for the same event concurrently. +- At-least-once after a crash: a lost claim expires and the event is retried. +- Handlers are idempotent, so the remaining duplicate window (crash after the effect, before the + ledger mark) is harmless. The O5 audit made each handler safe to re-run: image cleanup (NotFound + is Ok), image conversion (probes the exact key; missing original means done), follow backfill and + inbound review import (upserts, deterministic Create ids), person/movie enrichment (upserts; the + movie profile write is last because it is the "done" marker), wrap-up (a `Generating` record is + resumed; completion is atomic so `WrapUpCompleted` is emitted once), search handlers (keyed + upserts and removes), poster sync (skips when the poster is already stored under the movie id, so + no second `PosterSynced`/`ImageStored`). + +## Known gaps + +- **Remote and block follow events are best-effort.** `CompositeSocialAdapter` routes remote + follows and blocks through k-ap, which owns that transaction. Its events are published after the + fact through an injected publisher, so a crash between the k-ap write and the publish loses them. + Fixing it needs transaction hooks in k-ap. +- **k-ap 0.5.0 mints random ids for some activities.** Create, Announce and Like ids are + deterministic (`broadcast.rs:22-35`, `:283-288`; backfill `backfill.rs:208-212`). Delete + (`broadcast.rs:157`), Add/Undo (`:210`), Update (`:321`), Move (`:377`) and the actor update + (`:339-344`) use `Uuid::new_v4()` via `url_scheme.rs:52-53` with no override; only + `broadcast_raw_to_followers` (`:395`) takes a caller id. A duplicate of those after a crash is a + fresh-id activity. They are semantically idempotent for remote servers (deleting twice, updating + to the same state), but it is a new activity, not a replay. The ledger makes this rare; closing it + means building those activities ourselves or a k-ap change. +- **Core NATS has no safety net.** `NATS_MODE=core` is fire-and-forget: no broker dedup, no + redelivery. Rows are marked done after publish; a subscriber that is down misses the event. The + ledger still prevents double handling of a repeated id. +- **`ImageStored` stays best-effort.** It is raised after an object-storage write, which no + database transaction can cover. + +## Consequences + +- Existing deployments keep their JetStream stream. `get_or_create_stream` applies config only on + creation, so the consumer now compares an existing stream and consumer with what this build needs + and updates `duplicate_window` (1 h) and the consumer's `max_deliver`/`ack_wait` in place, with a + warning in the log. Both are mutable on a live stream/consumer, so no recreate is needed; + immutable consumer fields (name, filter, deliver policy) are never touched. +- Starting server and worker at the same moment against a database that still needs migration `0101` + can make one fail with `duplicate column name`; start the server first, or restart the loser. +- Handler authors must keep new handlers idempotent; the ledger narrows duplicates but cannot + remove the crash-after-effect case. -- 2.49.1 From 0908b7046a40549753dfa1d357b8fb71ae2ac0a5 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 01:40:40 +0100 Subject: [PATCH 069/104] fix(sqlite): concurrent migrate retries --- crates/adapters/sqlite/src/migrations.rs | 34 +++++++++++--- .../sqlite/tests/migration_ledger_test.rs | 44 +++++++++++++++++++ docs/adr/0015-transactional-outbox.md | 6 ++- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/crates/adapters/sqlite/src/migrations.rs b/crates/adapters/sqlite/src/migrations.rs index 3df60715..afcae43b 100644 --- a/crates/adapters/sqlite/src/migrations.rs +++ b/crates/adapters/sqlite/src/migrations.rs @@ -1,10 +1,34 @@ +use std::time::Duration; + use domain::errors::DomainError; use sqlx::SqlitePool; +/// Server and worker both migrate at startup, so two processes can race on the +/// same database. The loser's migration fails (e.g. duplicate column) because +/// the winner already applied it; re-running re-reads `_sqlx_migrations` and +/// skips what is done. Only an error that persists across all retries is returned. +const RETRY_BACKOFFS: [Duration; 5] = [ + Duration::from_millis(100), + Duration::from_millis(250), + Duration::from_millis(500), + Duration::from_secs(1), + Duration::from_secs(1), +]; + pub(crate) async fn run(pool: &SqlitePool) -> Result<(), DomainError> { - sqlx::migrate!("./migrations") - .set_ignore_missing(true) - .run(pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string())) + let mut migrator = sqlx::migrate!("./migrations"); + migrator.set_ignore_missing(true); + let mut backoffs = RETRY_BACKOFFS.iter(); + loop { + match migrator.run(pool).await { + Ok(()) => return Ok(()), + Err(e) => match backoffs.next() { + Some(delay) => { + tracing::warn!("migration failed, retrying (concurrent migrator?): {e}"); + tokio::time::sleep(*delay).await; + } + None => return Err(DomainError::InfrastructureError(e.to_string())), + }, + } + } } diff --git a/crates/adapters/sqlite/tests/migration_ledger_test.rs b/crates/adapters/sqlite/tests/migration_ledger_test.rs index b226c58a..fbc1c75d 100644 --- a/crates/adapters/sqlite/tests/migration_ledger_test.rs +++ b/crates/adapters/sqlite/tests/migration_ledger_test.rs @@ -56,3 +56,47 @@ async fn a_database_migrated_by_the_old_layout_migrates_again_cleanly() { assert_eq!(ledger(&pool).await, LEDGER); } + +async fn wal_pool(path: &Path, create: bool) -> SqlitePool { + let opts = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(create) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) + .busy_timeout(std::time::Duration::from_secs(5)); + SqlitePoolOptions::new() + .max_connections(2) + .connect_with(opts) + .await + .unwrap() +} + +async fn race_two_migrators(start: Option<&Path>) { + for _ in 0..10 { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("race.db"); + if let Some(src) = start { + std::fs::copy(src, &path).unwrap(); + } + let a = wal_pool(&path, start.is_none()).await; + let b = wal_pool(&path, false).await; + + let (ra, rb) = tokio::join!(sqlite::migrate(&a), sqlite::migrate(&b)); + + ra.unwrap(); + rb.unwrap(); + assert_eq!(ledger(&a).await, LEDGER); + } +} + +#[tokio::test] +async fn two_processes_migrating_the_same_fresh_database_both_succeed() { + race_two_migrators(None).await; +} + +#[tokio::test] +async fn two_processes_migrating_a_database_at_the_old_version_both_succeed() { + race_two_migrators(Some( + &Path::new(env!("CARGO_MANIFEST_DIR")).join(PRE_MERGE_DB), + )) + .await; +} diff --git a/docs/adr/0015-transactional-outbox.md b/docs/adr/0015-transactional-outbox.md index 50ae4d24..19b50a09 100644 --- a/docs/adr/0015-transactional-outbox.md +++ b/docs/adr/0015-transactional-outbox.md @@ -79,7 +79,9 @@ backends dead-letter after the same number of attempts. and updates `duplicate_window` (1 h) and the consumer's `max_deliver`/`ack_wait` in place, with a warning in the log. Both are mutable on a live stream/consumer, so no recreate is needed; immutable consumer fields (name, filter, deliver policy) are never touched. -- Starting server and worker at the same moment against a database that still needs migration `0101` - can make one fail with `duplicate column name`; start the server first, or restart the loser. +- Server and worker both migrate at startup (docker compose starts them together). `sqlite::migrate` + retries with a bounded backoff (100 ms to 1 s, 5 retries), so the loser of a migration race + re-reads `_sqlx_migrations`, skips what the winner applied and succeeds; only a persistent error + is returned. - Handler authors must keep new handlers idempotent; the ledger narrows duplicates but cannot remove the crash-after-effect case. -- 2.49.1 From 56f3d1ac1b0734e64a87ce0fe19577786a1531bb Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 01:43:22 +0100 Subject: [PATCH 070/104] fix: preserve consumer config on update, warn not abort; adr accuracy --- crates/adapters/nats/src/consumer.rs | 33 +++++++++++++++------- crates/adapters/nats/src/tests/consumer.rs | 4 +++ docs/adr/0015-transactional-outbox.md | 22 +++++++++------ 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/crates/adapters/nats/src/consumer.rs b/crates/adapters/nats/src/consumer.rs index b98191e9..1879beed 100644 --- a/crates/adapters/nats/src/consumer.rs +++ b/crates/adapters/nats/src/consumer.rs @@ -141,22 +141,20 @@ fn stream_config_update(existing: &StreamConfig) -> Option { } /// `Some(updated)` when an existing durable consumer disagrees on `max_deliver` -/// or `ack_wait`. Both are mutable on a live consumer, so the update is safe -/// and keeps the consumer's delivery position. Immutable fields (durable name, -/// filter subject, deliver policy) are never touched. +/// or `ack_wait`. The update is a clone of the existing config with only those +/// two fields changed, so operator-set fields survive and immutable ones +/// (durable name, filter subject, deliver policy) cannot mismatch. fn consumer_config_update( existing: &jetstream::consumer::Config, desired_max_deliver: i64, -) -> Option { +) -> Option { if existing.max_deliver == desired_max_deliver && existing.ack_wait == ACK_WAIT { return None; } - Some(pull::Config { - durable_name: existing.durable_name.clone(), - filter_subject: existing.filter_subject.clone(), + Some(jetstream::consumer::Config { ack_wait: ACK_WAIT, max_deliver: desired_max_deliver, - ..Default::default() + ..existing.clone() }) } @@ -185,7 +183,14 @@ impl NatsJetStreamConsumer { "existing JetStream stream has a different duplicate window; updating to {:?}", DEDUP_WINDOW ); - js.update_stream(updated).await?; + if let Err(e) = js.update_stream(updated).await { + tracing::warn!( + "could not update stream {}: {e}; continuing. Fix manually: nats stream edit {} --dupe-window {}s", + cfg.stream_name, + cfg.stream_name, + DEDUP_WINDOW.as_secs() + ); + } } let subject_filter = consumer_subject_filter(&cfg.subject_prefix); @@ -209,7 +214,15 @@ impl NatsJetStreamConsumer { "existing JetStream consumer has different max_deliver/ack_wait; updating to max_deliver={}", cfg.max_deliver ); - stream.update_consumer(updated).await?; + if let Err(e) = stream.update_consumer(updated).await { + tracing::warn!( + "could not update consumer {}: {e}; continuing. Fix manually: nats consumer edit {} {} --max-deliver {}", + cfg.consumer_name, + cfg.stream_name, + cfg.consumer_name, + cfg.max_deliver + ); + } } let (tx, rx) = mpsc::channel(128); diff --git a/crates/adapters/nats/src/tests/consumer.rs b/crates/adapters/nats/src/tests/consumer.rs index c8d8dffa..73899f58 100644 --- a/crates/adapters/nats/src/tests/consumer.rs +++ b/crates/adapters/nats/src/tests/consumer.rs @@ -75,12 +75,16 @@ fn consumer_max_deliver_drift_is_updated() { filter_subject: "a.>".into(), max_deliver: -1, ack_wait: ACK_WAIT, + max_ack_pending: 77, + description: Some("operator".into()), ..Default::default() }; let updated = consumer_config_update(&existing, 5).unwrap(); assert_eq!(updated.max_deliver, 5); assert_eq!(updated.durable_name.as_deref(), Some("c")); assert_eq!(updated.filter_subject, "a.>"); + assert_eq!(updated.max_ack_pending, 77); + assert_eq!(updated.description.as_deref(), Some("operator")); let same = jetstream::consumer::Config { max_deliver: 5, ..existing diff --git a/docs/adr/0015-transactional-outbox.md b/docs/adr/0015-transactional-outbox.md index 19b50a09..07f6a535 100644 --- a/docs/adr/0015-transactional-outbox.md +++ b/docs/adr/0015-transactional-outbox.md @@ -10,8 +10,10 @@ event ran its handlers twice. **The outbox is `event_queue`.** The same SQLite table the DB-backed bus already used is now the outbox for every backend. Migration `0101` adds `event_queue.event_id` (UUID, unique when set) and -`claimed_at`, and creates `processed_events` (`event_id`, `handler`, `status`, `started_at`, owner -token). Legacy rows have a NULL `event_id` and are still delivered; they just get no dedup. +`claimed_at`, and creates `processed_events` (`event_id`, `handler`, `status`, `started_at`, +`processed_at`). The migration backfills every existing row with a random event id, so legacy rows get dedup too. +The column stays nullable, but the only insert path (`enqueue_events`) always sets it, and the +consumer still tolerates a NULL id (no dedup) for rows from foreign writers. **Writes are atomic with their events.** Repository and command-port methods that change state take the events they cause and write them in the same transaction as the data. `shared::enqueue_events` @@ -33,8 +35,9 @@ backends dead-letter after the same number of attempts. `claimed_at` is older than 600 s, so a crash never strands an event in `processing`. 3. *Processed-event ledger.* Before a handler runs, `(event_id, handler)` is claimed in `processed_events` (INSERT `in_progress`, or take over a claim older than the 1800 s lease). - Success marks it `done`; failure releases it; both are conditioned on an owner token so a - stale worker cannot clobber a newer claim. A delivery that sees a fresh claim held elsewhere is + Success marks it `done`; failure releases it; both are conditioned on the claim's millisecond + `started_at` value (`WHERE ... AND started_at = ?`), which acts as the owner token, so a stale + worker cannot clobber a newer claim. A delivery that sees a fresh claim held elsewhere is deferred (`AckHandle::defer`: rescheduled to the lease expiry without consuming an attempt; JetStream `nak` with delay, which does count toward `max_deliver`). A `done` row skips the handler. Ledger rows are kept 30 days. A ledger read error skips the handler and nacks. @@ -60,8 +63,8 @@ backends dead-letter after the same number of attempts. Fixing it needs transaction hooks in k-ap. - **k-ap 0.5.0 mints random ids for some activities.** Create, Announce and Like ids are deterministic (`broadcast.rs:22-35`, `:283-288`; backfill `backfill.rs:208-212`). Delete - (`broadcast.rs:157`), Add/Undo (`:210`), Update (`:321`), Move (`:377`) and the actor update - (`:339-344`) use `Uuid::new_v4()` via `url_scheme.rs:52-53` with no override; only + (`broadcast.rs:157`), Add/Undo (`:210`), Update (`:321`), Move (`:377`), the Undo wrappers for + Announce (`:81`) and Like (`:129`) and the actor update (`:339-344`) use `Uuid::new_v4()` via `url_scheme.rs:52-53` with no override; only `broadcast_raw_to_followers` (`:395`) takes a caller id. A duplicate of those after a crash is a fresh-id activity. They are semantically idempotent for remote servers (deleting twice, updating to the same state), but it is a new activity, not a replay. The ledger makes this rare; closing it @@ -77,8 +80,11 @@ backends dead-letter after the same number of attempts. - Existing deployments keep their JetStream stream. `get_or_create_stream` applies config only on creation, so the consumer now compares an existing stream and consumer with what this build needs and updates `duplicate_window` (1 h) and the consumer's `max_deliver`/`ack_wait` in place, with a - warning in the log. Both are mutable on a live stream/consumer, so no recreate is needed; - immutable consumer fields (name, filter, deliver policy) are never touched. + warning in the log. Both are mutable on a live stream/consumer, so no recreate is needed. Each update is a + clone of the existing config with only those fields changed, so operator-set fields and the + immutable ones (name, filter, deliver policy) are preserved. If an update is rejected the worker + logs a warning with the manual `nats stream edit` / `nats consumer edit` command and keeps + starting. - Server and worker both migrate at startup (docker compose starts them together). `sqlite::migrate` retries with a bounded backoff (100 ms to 1 s, 5 retries), so the loser of a migration race re-reads `_sqlx_migrations`, skips what the winner applied and succeeds; only a persistent error -- 2.49.1 From dfa5e1a4dbc8083ca788e95d8929bf6edda1507e Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 01:52:28 +0100 Subject: [PATCH 071/104] nats nak w/ shared retry_backoff, exhausted log; no 'will retry' lie --- crates/adapters/nats/src/consumer.rs | 59 ++++++++++++++++++---- crates/adapters/nats/src/tests/consumer.rs | 15 ++++++ crates/application/src/worker.rs | 7 ++- crates/domain/src/events.rs | 8 +++ crates/domain/tests/events/events_test.rs | 10 ++++ 5 files changed, 88 insertions(+), 11 deletions(-) diff --git a/crates/adapters/nats/src/consumer.rs b/crates/adapters/nats/src/consumer.rs index 1879beed..261e6a06 100644 --- a/crates/adapters/nats/src/consumer.rs +++ b/crates/adapters/nats/src/consumer.rs @@ -5,7 +5,7 @@ use async_nats::{ use async_trait::async_trait; use domain::{ errors::DomainError, - events::{AckHandle, DomainEvent, EventEnvelope, EventId}, + events::{AckHandle, DomainEvent, EventEnvelope, EventId, retry_backoff}, ports::EventConsumer, }; use futures::{ @@ -21,6 +21,23 @@ use crate::{config::NatsConfig, payload::NatsEventMessage, subject::consumer_sub struct NatsJetStreamAckHandle { message: async_nats::jetstream::Message, + event_id: Option, + max_deliver: i64, +} + +/// How to nak a failed delivery: the same backoff schedule as the SQLite +/// queue, keyed by JetStream's delivery count (1 on first delivery). +struct NakPlan { + delay: std::time::Duration, + /// This was the last delivery `max_deliver` allows. + exhausted: bool, +} + +fn nak_plan(delivered: i64, max_deliver: i64) -> NakPlan { + NakPlan { + delay: retry_backoff(u32::try_from(delivered.max(0)).unwrap_or(u32::MAX)), + exhausted: max_deliver > 0 && delivered >= max_deliver, + } } #[async_trait] @@ -38,13 +55,29 @@ impl AckHandle for NatsJetStreamAckHandle { } async fn nack(&self) -> Result<(), DomainError> { - tracing::debug!( - "negatively acknowledging message with sequence {}", - self.message.info().unwrap().stream_sequence - ); + let info = self + .message + .info() + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + let plan = nak_plan(info.delivered, self.max_deliver); + if plan.exhausted { + tracing::error!( + event_id = ?self.event_id.map(|id| id.value()), + sequence = info.stream_sequence, + delivered = info.delivered, + "event exhausted max deliveries; it remains in the stream — see JetStream max-deliveries advisory" + ); + } else { + tracing::debug!( + sequence = info.stream_sequence, + delivered = info.delivered, + "nak with backoff {:?}", + plan.delay + ); + } self.message - .ack_with(AckKind::Nak(None)) + .ack_with(AckKind::Nak(Some(plan.delay))) .await .map_err(|e| DomainError::InfrastructureError(e.to_string())) } @@ -94,12 +127,19 @@ fn decode_message(bytes: &[u8]) -> Result<(Option, DomainEvent), Domain Ok((id, DomainEvent::try_from(message.payload)?)) } -fn decode_js(msg: async_nats::jetstream::Message) -> Result { +fn decode_js( + msg: async_nats::jetstream::Message, + max_deliver: i64, +) -> Result { let (id, event) = decode_message(&msg.payload)?; Ok(EventEnvelope::new( id, event, - Box::new(NatsJetStreamAckHandle { message: msg }), + Box::new(NatsJetStreamAckHandle { + message: msg, + event_id: id, + max_deliver, + }), )) } @@ -227,6 +267,7 @@ impl NatsJetStreamConsumer { let (tx, rx) = mpsc::channel(128); + let max_deliver = cfg.max_deliver; tokio::spawn(async move { loop { let mut messages = match consumer.messages().await { @@ -243,7 +284,7 @@ impl NatsJetStreamConsumer { while let Some(result) = messages.next().await { let envelope = result .map_err(|e| DomainError::InfrastructureError(e.to_string())) - .and_then(decode_js); + .and_then(|m| decode_js(m, max_deliver)); if tx.send(envelope).await.is_err() { tracing::info!("consumer channel closed, stopping message processing"); diff --git a/crates/adapters/nats/src/tests/consumer.rs b/crates/adapters/nats/src/tests/consumer.rs index 73899f58..098e3b1c 100644 --- a/crates/adapters/nats/src/tests/consumer.rs +++ b/crates/adapters/nats/src/tests/consumer.rs @@ -91,3 +91,18 @@ fn consumer_max_deliver_drift_is_updated() { }; assert!(consumer_config_update(&same, 5).is_none()); } + +#[test] +fn nak_delay_follows_the_shared_backoff_by_delivery_count() { + let secs = |d| nak_plan(d, 5).delay.as_secs(); + assert_eq!((secs(1), secs(2), secs(3), secs(4)), (10, 20, 40, 80)); + assert_eq!(nak_plan(50, 100).delay.as_secs(), 300); +} + +#[test] +fn only_the_final_delivery_is_exhausted() { + assert!(!nak_plan(4, 5).exhausted); + assert!(nak_plan(5, 5).exhausted); + assert!(nak_plan(6, 5).exhausted); + assert!(!nak_plan(1_000, -1).exhausted, "-1 means unlimited"); +} diff --git a/crates/application/src/worker.rs b/crates/application/src/worker.rs index f01d64b9..9288c2e8 100644 --- a/crates/application/src/worker.rs +++ b/crates/application/src/worker.rs @@ -119,7 +119,7 @@ async fn dispatch( } Err(e) => { // Running anyway could repeat a side effect; retry the delivery instead. - tracing::warn!(handler = name, "ledger claim failed, will retry: {e}"); + tracing::warn!(handler = name, "ledger claim failed, nacking delivery: {e}"); any_transient = true; continue; } @@ -146,7 +146,10 @@ async fn dispatch( tracing::warn!(handler = name, "releasing ledger claim failed: {le}"); } if e.is_transient() { - tracing::warn!(handler = name, "transient handler error, will retry: {e}"); + tracing::warn!( + handler = name, + "transient handler error, nacking delivery (the backend logs if attempts are exhausted): {e}" + ); any_transient = true; } else { tracing::warn!( diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 3543cf9e..25218ff7 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -155,6 +155,14 @@ pub trait AckHandle: Send + Sync { async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError>; } +/// Wait before retrying an event whose `attempt`-th delivery just failed: +/// 5s doubling per attempt, capped at 5 minutes. Every backend (SQLite queue, +/// JetStream nak, outbox relay) uses this one schedule. +pub fn retry_backoff(attempt: u32) -> std::time::Duration { + let secs = 5u64 * (1u64 << attempt.min(6)); + std::time::Duration::from_secs(secs.min(300)) +} + /// Stable identity of one queued event; the key for de-duplicating deliveries. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct EventId(uuid::Uuid); diff --git a/crates/domain/tests/events/events_test.rs b/crates/domain/tests/events/events_test.rs index bde7014d..482f2a8f 100644 --- a/crates/domain/tests/events/events_test.rs +++ b/crates/domain/tests/events/events_test.rs @@ -53,3 +53,13 @@ fn follow_requested_with_handle() { } )); } + +#[test] +fn retry_backoff_doubles_from_ten_seconds_and_caps_at_five_minutes() { + use domain::events::retry_backoff; + use std::time::Duration; + let secs: Vec = (1..=8).map(|n| retry_backoff(n).as_secs()).collect(); + assert_eq!(secs, [10, 20, 40, 80, 160, 300, 300, 300]); + assert_eq!(retry_backoff(0), Duration::from_secs(5)); + assert_eq!(retry_backoff(u32::MAX), Duration::from_secs(300)); +} -- 2.49.1 From fe9f28453115dc6a06d639ed865d50fb8f98fa5c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 01:52:28 +0100 Subject: [PATCH 072/104] sqlite ack/nack/defer only if claim still held; use shared backoff --- crates/adapters/sqlite/src/event_queue/mod.rs | 97 ++++++++++++------- .../adapters/sqlite/src/event_queue/tests.rs | 81 ++++++++++++++++ 2 files changed, 141 insertions(+), 37 deletions(-) diff --git a/crates/adapters/sqlite/src/event_queue/mod.rs b/crates/adapters/sqlite/src/event_queue/mod.rs index 302ccbee..5253f1af 100644 --- a/crates/adapters/sqlite/src/event_queue/mod.rs +++ b/crates/adapters/sqlite/src/event_queue/mod.rs @@ -137,6 +137,7 @@ struct QueueRow { event_id: Option, attempts: i32, next_attempt_at: String, + claimed_at: Option, } /// Claims a batch in one atomic statement. A read-then-write transaction can @@ -159,7 +160,7 @@ async fn claim_batch( ORDER BY next_attempt_at ASC LIMIT ? ) - RETURNING id, payload, event_id, attempts, next_attempt_at" + RETURNING id, payload, event_id, attempts, next_attempt_at, claimed_at" ); let mut rows = sqlx::query_as::<_, QueueRow>(&sql) .bind(config.batch_size) @@ -212,6 +213,7 @@ fn decode_row( row_id: row.id, attempts: row.attempts, max_attempts, + claimed_at: row.claimed_at, }), )) } @@ -221,43 +223,74 @@ struct DbAckHandle { row_id: i64, attempts: i32, max_attempts: i32, + /// The claim this handle belongs to. Settling is conditional on it, so a + /// worker whose lease lapsed and was reclaimed cannot flip the new claim's row. + claimed_at: Option, +} + +impl DbAckHandle { + /// Runs a settle statement whose last two binds are the row id and claim + /// stamp. Zero rows changed means the claim is no longer ours. + async fn settle<'q>( + &self, + what: &str, + query: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>, + ) -> Result<(), DomainError> { + let done = query + .bind(self.row_id) + .bind(self.claimed_at.clone()) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(format!("{what}: {e}")))?; + if done.rows_affected() == 0 { + tracing::warn!( + row_id = self.row_id, + "{what}: claim no longer held (lease lapsed and row reclaimed); ignoring" + ); + } + Ok(()) + } } #[async_trait] impl AckHandle for DbAckHandle { async fn ack(&self) -> Result<(), DomainError> { - sqlx::query("UPDATE event_queue SET status = 'done' WHERE id = ?") - .bind(self.row_id) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("ack: {e}")))?; - Ok(()) + self.settle( + "ack", + sqlx::query( + "UPDATE event_queue SET status = 'done' \ + WHERE id = ? AND status = 'processing' AND claimed_at IS ?", + ), + ) + .await } async fn nack(&self) -> Result<(), DomainError> { let new_attempts = self.attempts + 1; if new_attempts >= self.max_attempts { - sqlx::query( - "UPDATE event_queue SET status = 'dead_lettered', attempts = ?, last_error = 'max attempts reached' WHERE id = ?" - ) - .bind(new_attempts) - .bind(self.row_id) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("nack dead-letter: {e}")))?; - } else { - let backoff = backoff_seconds(new_attempts); - let sql = format!( - "UPDATE event_queue SET status = 'pending', attempts = ?, next_attempt_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+{backoff} seconds'), last_error = 'nack' WHERE id = ?" + tracing::error!( + row_id = self.row_id, + attempts = new_attempts, + "event exhausted max attempts, dead-lettering" ); - sqlx::query(&sql) - .bind(new_attempts) - .bind(self.row_id) - .execute(&self.pool) + self.settle( + "nack dead-letter", + sqlx::query( + "UPDATE event_queue SET status = 'dead_lettered', attempts = ?, last_error = 'max attempts reached' \ + WHERE id = ? AND status = 'processing' AND claimed_at IS ?", + ) + .bind(new_attempts), + ) + .await + } else { + let backoff = domain::events::retry_backoff(new_attempts.max(0) as u32).as_secs(); + let sql = format!( + "UPDATE event_queue SET status = 'pending', attempts = ?, next_attempt_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+{backoff} seconds'), last_error = 'nack' \ + WHERE id = ? AND status = 'processing' AND claimed_at IS ?" + ); + self.settle("nack retry", sqlx::query(&sql).bind(new_attempts)) .await - .map_err(|e| DomainError::InfrastructureError(format!("nack retry: {e}")))?; } - Ok(()) } async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError> { @@ -266,21 +299,11 @@ impl AckHandle for DbAckHandle { "UPDATE event_queue SET status = 'pending', claimed_at = NULL, last_error = 'deferred', next_attempt_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+{secs} seconds') - WHERE id = ?" + WHERE id = ? AND status = 'processing' AND claimed_at IS ?" ); - sqlx::query(&sql) - .bind(self.row_id) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("defer: {e}")))?; - Ok(()) + self.settle("defer", sqlx::query(&sql)).await } } -fn backoff_seconds(attempts: i32) -> i64 { - let base: i64 = 5 * (1i64 << attempts.min(6)); - base.min(300) -} - #[cfg(test)] mod tests; diff --git a/crates/adapters/sqlite/src/event_queue/tests.rs b/crates/adapters/sqlite/src/event_queue/tests.rs index 7978df3c..94f86cd9 100644 --- a/crates/adapters/sqlite/src/event_queue/tests.rs +++ b/crates/adapters/sqlite/src/event_queue/tests.rs @@ -303,3 +303,84 @@ mod lease { assert!(error.unwrap().contains("undecodable")); } } + +mod stale_handle { + use domain::{ + events::{DomainEvent, EventEnvelope}, + ports::{EventConsumer, EventPublisher}, + value_objects::UserId, + }; + use futures::StreamExt; + + use super::{claim_batch, config}; + use crate::event_queue::{CLAIM_LEASE_SECONDS, SqliteEventQueue}; + + /// Claims a row, lets its lease lapse, and has another worker reclaim it. + /// Returns the first (now stale) worker's envelope. + async fn reclaimed() -> (sqlx::SqlitePool, EventEnvelope) { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + crate::migrate(&pool).await.unwrap(); + let q = SqliteEventQueue::create(pool.clone(), config(10)); + q.publish(&DomainEvent::UserUpdated { + user_id: UserId::generate(), + }) + .await + .unwrap(); + let stale = q.consume().next().await.unwrap().unwrap(); + let old = (chrono::Utc::now() - chrono::Duration::seconds(CLAIM_LEASE_SECONDS + 60)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + sqlx::query("UPDATE event_queue SET claimed_at = ?") + .bind(old) + .execute(&pool) + .await + .unwrap(); + assert_eq!(claim_batch(&pool, &config(10)).await.unwrap().len(), 1); + // Real reclaims happen a lease later, so their timestamp differs from + // the stale handle's; the claim clock has only second resolution. + let second_claim = (chrono::Utc::now() - chrono::Duration::seconds(5)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + sqlx::query("UPDATE event_queue SET claimed_at = ?") + .bind(second_claim) + .execute(&pool) + .await + .unwrap(); + (pool, stale) + } + + async fn state(pool: &sqlx::SqlitePool) -> (String, i32) { + sqlx::query_as("SELECT status, attempts FROM event_queue") + .fetch_one(pool) + .await + .unwrap() + } + + #[tokio::test] + async fn a_stale_ack_cannot_complete_a_reclaimed_row() { + let (pool, stale) = reclaimed().await; + stale.ack().await.unwrap(); + assert_eq!(state(&pool).await, ("processing".into(), 0)); + } + + #[tokio::test] + async fn a_stale_nack_cannot_spend_an_attempt_on_a_reclaimed_row() { + let (pool, stale) = reclaimed().await; + stale.nack().await.unwrap(); + assert_eq!(state(&pool).await, ("processing".into(), 0)); + } + + #[tokio::test] + async fn a_stale_defer_cannot_release_a_reclaimed_row() { + let (pool, stale) = reclaimed().await; + stale + .defer(std::time::Duration::from_secs(60)) + .await + .unwrap(); + assert_eq!(state(&pool).await, ("processing".into(), 0)); + } +} -- 2.49.1 From d0568d7933ca1102bd2197a5809ffb3345236baf Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 01:53:31 +0100 Subject: [PATCH 073/104] relay: sink failure defers + pauses w/ backoff, no attempt spent --- crates/bootstrap/src/outbox_relay.rs | 64 ++++++++++++++++++--- crates/bootstrap/tests/outbox_relay_test.rs | 64 +++++++++++++++++++-- 2 files changed, 114 insertions(+), 14 deletions(-) diff --git a/crates/bootstrap/src/outbox_relay.rs b/crates/bootstrap/src/outbox_relay.rs index 86d25152..5d3f99cd 100644 --- a/crates/bootstrap/src/outbox_relay.rs +++ b/crates/bootstrap/src/outbox_relay.rs @@ -6,16 +6,21 @@ //! and dead-letter path. A row is marked done only after the sink reports the //! bus accepted it. //! +//! A sink (transport) failure is the bus's fault, not the row's: the row is +//! deferred without spending an attempt, and the relay pauses with a growing +//! backoff instead of churning through the rest of the outbox. Attempts, and +//! so dead-lettering, are only ever spent by consumers on handler failures. +//! //! Delivery is at-least-once. A crash between publish and mark-done leaves the //! row `processing`; when its claim lease lapses it is claimed and relayed //! again. The bus drops that repeat while it is inside the broker's dedup //! window (same `Nats-Msg-Id`); beyond the window, or in core mode, the //! consumer ledger makes the handlers skip it. -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use domain::{ - events::EventEnvelope, + events::{EventEnvelope, retry_backoff}, ports::{EventConsumer, EventRelaySink}, }; use futures::StreamExt; @@ -24,11 +29,22 @@ use tokio::sync::watch; pub struct OutboxRelay { source: Arc, sink: Arc, + backoff: fn(u32) -> Duration, } impl OutboxRelay { pub fn new(source: Arc, sink: Arc) -> Self { - Self { source, sink } + Self { + source, + sink, + backoff: retry_backoff, + } + } + + /// Overrides the pause/defer schedule, keyed by consecutive sink failures. + pub fn with_backoff(mut self, backoff: fn(u32) -> Duration) -> Self { + self.backoff = backoff; + self } /// Runs until `shutdown` fires or the source ends. A shutdown never @@ -37,6 +53,7 @@ impl OutboxRelay { /// `processing` and come back when their lease lapses. pub async fn run(self, mut shutdown: watch::Receiver) { let mut rows = self.source.consume(); + let mut sink_failures: u32 = 0; loop { tokio::select! { biased; @@ -45,7 +62,24 @@ impl OutboxRelay { return; } next = rows.next() => match next { - Some(Ok(envelope)) => relay_envelope(envelope, self.sink.as_ref()).await, + 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; + } + } + } + } // The queue already dead-lettered it and logged the row id. Some(Err(e)) => tracing::error!("outbox relay: skipped undecodable row: {e}"), None => { @@ -58,18 +92,32 @@ impl OutboxRelay { } } -async fn relay_envelope(envelope: EventEnvelope, sink: &dyn EventRelaySink) { +/// Returns how long the relay should pause when the sink failed. +async fn relay_envelope( + envelope: EventEnvelope, + sink: &dyn EventRelaySink, + sink_failures: &mut u32, + backoff: fn(u32) -> Duration, +) -> Option { 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) => { - tracing::warn!("outbox relay: publish failed, will retry: {e}"); - if let Err(e) = envelope.nack().await { - tracing::error!("outbox relay: could not nack: {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) } } } diff --git a/crates/bootstrap/tests/outbox_relay_test.rs b/crates/bootstrap/tests/outbox_relay_test.rs index e424af6a..857a6ab5 100644 --- a/crates/bootstrap/tests/outbox_relay_test.rs +++ b/crates/bootstrap/tests/outbox_relay_test.rs @@ -20,6 +20,8 @@ use uuid::Uuid; #[derive(Default)] struct FakeSink { fail: bool, + /// Fail this many calls, then succeed (a bus outage that ends). + fail_first: std::sync::atomic::AtomicUsize, /// When set, `relay` signals `started` then takes this long. slow: Option, started: Notify, @@ -34,7 +36,15 @@ impl EventRelaySink for FakeSink { self.started.notify_one(); tokio::time::sleep(delay).await; } - if self.fail { + let outage = self + .fail_first + .fetch_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(()) @@ -43,10 +53,17 @@ impl EventRelaySink for FakeSink { } async fn outbox() -> (sqlx::SqlitePool, Arc, SqliteEventQueue) { + outbox_with_attempts(config::EventsConfig::default().queue_max_attempts).await +} + +async fn outbox_with_attempts( + max_attempts: i32, +) -> (sqlx::SqlitePool, Arc, 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( @@ -64,7 +81,7 @@ async fn row(pool: &sqlx::SqlitePool) -> (String, i32, Option) { } async fn wait_for(pool: &sqlx::SqlitePool, status: &str) { - for _ in 0..200 { + for _ in 0..800 { if row(pool).await.0 == status { return; } @@ -81,7 +98,8 @@ fn start( tokio::task::JoinHandle<()>, ) { let (stop, rx) = tokio::sync::watch::channel(false); - let relay = bootstrap::outbox_relay::OutboxRelay::new(Arc::new(queue), sink); + let relay = bootstrap::outbox_relay::OutboxRelay::new(Arc::new(queue), sink) + .with_backoff(|_| Duration::from_millis(10)); (stop, tokio::spawn(relay.run(rx))) } @@ -114,7 +132,32 @@ async fn relay_marks_rows_done_only_after_publish_succeeds() { } #[tokio::test] -async fn a_failed_publish_leaves_the_row_pending_with_an_attempt_counted() { +async fn a_failing_bus_never_spends_the_rows_attempts_and_the_row_relays_on_recovery() { + // Budget of 2 attempts, 3 consecutive bus failures: were failures counted + // as attempts the row would be dead-lettered before the bus recovers. + 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 { @@ -124,7 +167,11 @@ async fn a_failed_publish_leaves_the_row_pending_with_an_attempt_counted() { let (stop, relay) = start(queue, sink.clone()); for _ in 0..200 { - if row(&pool).await.1 == 1 { + let last_error: Option = 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; @@ -133,7 +180,12 @@ async fn a_failed_publish_leaves_the_row_pending_with_an_attempt_counted() { drop(stop); let (status, attempts, _) = row(&pool).await; - assert_eq!((status.as_str(), attempts), ("pending", 1)); + assert_eq!((status.as_str(), attempts), ("pending", 0)); + let last_error: Option = sqlx::query_scalar("SELECT last_error FROM event_queue") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(last_error.as_deref(), Some("deferred")); } #[tokio::test] -- 2.49.1 From bde8040cc34e767a4719ee8796ae854b7bffae9b Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 01:56:38 +0100 Subject: [PATCH 074/104] watchlist add: event only if inserted; adr 0015 jetstream/relay accuracy; drop vacuous assert --- .../sqlite/src/repositories/watchlist/mod.rs | 11 +++++++--- .../tests/repositories/outbox_writes_test.rs | 18 +++++++++++++++ .../tests/diary/review_logger_test.rs | 1 - .../application/tests/watchlist/add_test.rs | 5 +++++ crates/bootstrap/tests/outbox_relay_test.rs | 2 +- .../src/testing/fakes/store_watchlist.rs | 22 +++++++++++++------ docs/adr/0015-transactional-outbox.md | 13 +++++++++-- 7 files changed, 58 insertions(+), 14 deletions(-) diff --git a/crates/adapters/sqlite/src/repositories/watchlist/mod.rs b/crates/adapters/sqlite/src/repositories/watchlist/mod.rs index a6046ce3..8d747023 100644 --- a/crates/adapters/sqlite/src/repositories/watchlist/mod.rs +++ b/crates/adapters/sqlite/src/repositories/watchlist/mod.rs @@ -39,7 +39,7 @@ impl WatchlistRepository for SqliteWatchlistRepository { .begin() .await .map_err(crate::shared::map_sqlx_error)?; - sqlx::query( + let inserted = sqlx::query( "INSERT OR IGNORE INTO watchlist_entries (id, user_id, movie_id, added_at) \ VALUES (?, ?, ?, ?)", ) @@ -49,8 +49,13 @@ impl WatchlistRepository for SqliteWatchlistRepository { .bind(&added_at) .execute(&mut *tx) .await - .map_err(crate::shared::map_sqlx_error)?; - enqueue_events(&mut tx, events).await?; + .map_err(crate::shared::map_sqlx_error)? + .rows_affected() + > 0; + // A repeat add changed nothing, so it announces nothing. + if inserted { + enqueue_events(&mut tx, events).await?; + } tx.commit().await.map_err(crate::shared::map_sqlx_error) } diff --git a/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs b/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs index 0a64450a..14f46307 100644 --- a/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs +++ b/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs @@ -200,6 +200,24 @@ async fn adding_a_watchlist_entry_writes_the_entry_and_its_event_in_one_commit() assert_eq!(count(&pool, "event_queue").await, 1); } +#[tokio::test] +async fn re_adding_a_watchlist_entry_enqueues_no_second_event() { + let (pool, user_id, movie) = watchlist_fixture().await; + let repo = SqliteWatchlistRepository::new(pool.clone()); + let first = WatchlistEntry::new(user_id.clone(), movie.id().clone()); + repo.add(&first, &[watchlist_added(&first, &movie)]) + .await + .unwrap(); + let again = WatchlistEntry::new(user_id, movie.id().clone()); + + repo.add(&again, &[watchlist_added(&again, &movie)]) + .await + .unwrap(); + + assert_eq!(count(&pool, "watchlist_entries").await, 1); + assert_eq!(count(&pool, "event_queue").await, 1, "no phantom event"); +} + #[tokio::test] async fn a_failing_enqueue_rolls_back_the_watchlist_add() { let (pool, user_id, movie) = watchlist_fixture().await; diff --git a/crates/application/tests/diary/review_logger_test.rs b/crates/application/tests/diary/review_logger_test.rs index b4e4ebdf..af524b7d 100644 --- a/crates/application/tests/diary/review_logger_test.rs +++ b/crates/application/tests/diary/review_logger_test.rs @@ -270,7 +270,6 @@ async fn a_refused_save_propagates_and_records_nothing() { .await; assert!(result.is_err()); - assert_eq!(movies.review_count(), 0); assert!(movies.recorded_events().is_empty()); } diff --git a/crates/application/tests/watchlist/add_test.rs b/crates/application/tests/watchlist/add_test.rs index 2d1fb01d..d297cdec 100644 --- a/crates/application/tests/watchlist/add_test.rs +++ b/crates/application/tests/watchlist/add_test.rs @@ -54,6 +54,11 @@ async fn adding_a_movie_already_on_the_watchlist_is_idempotent() { add::execute(&deps, make_cmd()).await.unwrap(); add::execute(&deps, make_cmd()).await.unwrap(); + assert_eq!( + movies.recorded_events().len(), + 1, + "the repeat inserted nothing, so it announces nothing" + ); assert_eq!( watchlist.watchlist_count(), 1, diff --git a/crates/bootstrap/tests/outbox_relay_test.rs b/crates/bootstrap/tests/outbox_relay_test.rs index 857a6ab5..4c453261 100644 --- a/crates/bootstrap/tests/outbox_relay_test.rs +++ b/crates/bootstrap/tests/outbox_relay_test.rs @@ -38,7 +38,7 @@ impl EventRelaySink for FakeSink { } let outage = self .fail_first - .fetch_update( + .try_update( std::sync::atomic::Ordering::SeqCst, std::sync::atomic::Ordering::SeqCst, |n| n.checked_sub(1), diff --git a/crates/domain/src/testing/fakes/store_watchlist.rs b/crates/domain/src/testing/fakes/store_watchlist.rs index c19d6820..ab523cad 100644 --- a/crates/domain/src/testing/fakes/store_watchlist.rs +++ b/crates/domain/src/testing/fakes/store_watchlist.rs @@ -16,15 +16,23 @@ use super::InMemoryStore; #[async_trait] impl WatchlistRepository for InMemoryStore { - /// `INSERT OR IGNORE` on UNIQUE(user_id, movie_id): adding twice is a no-op. + /// `INSERT OR IGNORE` on UNIQUE(user_id, movie_id): adding twice is a no-op + /// and, like the adapter, records events only for the row it inserted. async fn add(&self, entry: &WatchlistEntry, events: &[DomainEvent]) -> Result<(), DomainError> { let key = (entry.user_id.value(), entry.movie_id.value()); - self.watchlist - .write() - .unwrap() - .entry(key) - .or_insert_with(|| entry.clone()); - self.record_events(events); + let inserted = { + let mut entries = self.watchlist.write().unwrap(); + match entries.entry(key) { + std::collections::hash_map::Entry::Vacant(slot) => { + slot.insert(entry.clone()); + true + } + std::collections::hash_map::Entry::Occupied(_) => false, + } + }; + if inserted { + self.record_events(events); + } Ok(()) } diff --git a/docs/adr/0015-transactional-outbox.md b/docs/adr/0015-transactional-outbox.md index 07f6a535..3846834b 100644 --- a/docs/adr/0015-transactional-outbox.md +++ b/docs/adr/0015-transactional-outbox.md @@ -25,8 +25,17 @@ Use cases never open transactions; the adapter does. **The server never talks to NATS.** With `EVENT_BUS_BACKEND=nats` the server still only inserts outbox rows. The worker runs a relay that claims pending rows and publishes them to JetStream with `Nats-Msg-Id` set to the event id, then marks the row done. With `db` the worker consumes the rows -directly. `max_deliver` on the JetStream consumer equals `EVENT_QUEUE_MAX_ATTEMPTS`, so both -backends dead-letter after the same number of attempts. +directly. `max_deliver` on the JetStream consumer equals `EVENT_QUEUE_MAX_ATTEMPTS`, and a JetStream +nak uses the same backoff as the SQLite queue (`domain::events::retry_backoff`: 5 s doubling per +attempt, capped at 300 s), so both backends give up after the same number of deliveries on the same +schedule. They differ in what giving up means. SQLite marks the row `dead_lettered`. JetStream has no +DLQ: after `max_deliver` the message is no longer delivered but stays in the stream (until stream +limits evict it). The consumer logs `event exhausted max deliveries` at error level with the event id +on the final nak. To find such messages, watch the JetStream max-deliveries advisory +(`$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES..`) or inspect the stream +(`nats stream view `) for sequences the consumer has not acked. The relay never spends +attempts on transport failures: when the bus is unreachable it defers the row with growing backoff and +pauses, so an outage cannot dead-letter rows. **Three dedup layers.** 1. *Broker.* JetStream drops a repeated `Nats-Msg-Id` inside `duplicate_window`, set explicitly to 1 -- 2.49.1 From 31f5d5f3371e73ab3c7c6eea883c1e6085c21174 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 02:03:36 +0100 Subject: [PATCH 075/104] feat: user tombstone and retired usernames --- .../migrations/0102_account_deletion.sql | 6 + .../sqlite/src/repositories/diary/query.rs | 21 ++- .../src/repositories/social/follow/query.rs | 12 +- .../sqlite/src/repositories/users/mod.rs | 161 ++++++++++++++---- .../tests/fixtures/migration_ledger.txt | 1 + .../repositories/diary_repository_test.rs | 29 ++++ .../repositories/social_repository_test.rs | 39 ++++- .../repositories/user_repository_test.rs | 139 +++++++++++++-- .../tests/users/update_profile_test.rs | 22 +++ .../bootstrap/tests/http_handlers/support.rs | 22 +++ crates/domain/src/ports/auth.rs | 15 ++ crates/domain/src/testing/fakes/panics.rs | 18 ++ crates/domain/src/testing/fakes/store.rs | 6 + crates/domain/src/testing/fakes/store_user.rs | 62 ++++++- .../tests/testing/in_memory_store_test.rs | 53 ++++++ 15 files changed, 550 insertions(+), 56 deletions(-) create mode 100644 crates/adapters/sqlite/migrations/0102_account_deletion.sql diff --git a/crates/adapters/sqlite/migrations/0102_account_deletion.sql b/crates/adapters/sqlite/migrations/0102_account_deletion.sql new file mode 100644 index 00000000..7918be5c --- /dev/null +++ b/crates/adapters/sqlite/migrations/0102_account_deletion.sql @@ -0,0 +1,6 @@ +ALTER TABLE users ADD COLUMN deleted_at TEXT; + +CREATE TABLE retired_usernames ( + username TEXT PRIMARY KEY COLLATE NOCASE, + retired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); diff --git a/crates/adapters/sqlite/src/repositories/diary/query.rs b/crates/adapters/sqlite/src/repositories/diary/query.rs index 1ce028c8..2d53de26 100644 --- a/crates/adapters/sqlite/src/repositories/diary/query.rs +++ b/crates/adapters/sqlite/src/repositories/diary/query.rs @@ -131,11 +131,16 @@ impl DiaryQuery for SqliteDiaryRepository { FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC", }; + // Tombstoned local authors vanish from the feed; remote and orphan + // rows have no `users` match, so `deleted_at` is NULL for them. + where_parts.push("u.deleted_at IS NULL".to_string()); + let where_clause = where_parts.join(" AND "); let count_sql = format!( "SELECT COUNT(*) FROM reviews r INNER JOIN movies m ON m.id = r.movie_id + LEFT JOIN users u ON u.id = r.user_id WHERE {}", where_clause ); @@ -304,12 +309,14 @@ impl DiaryQuery for SqliteDiaryRepository { let limit = page.limit as i64; let offset = page.offset as i64; - let total: i64 = - sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = ?") - .bind(&id_str) - .fetch_one(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; + let total: i64 = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM reviews r LEFT JOIN users u ON u.id = r.user_id + WHERE r.movie_id = ? AND u.deleted_at IS NULL", + ) + .bind(&id_str) + .fetch_one(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; let rows = sqlx::query_as::<_, FeedRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, @@ -321,7 +328,7 @@ impl DiaryQuery for SqliteDiaryRepository { INNER JOIN movies m ON m.id = r.movie_id LEFT JOIN users u ON u.id = r.user_id LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url - WHERE r.movie_id = ? + WHERE r.movie_id = ? AND u.deleted_at IS NULL ORDER BY r.watched_at DESC LIMIT ? OFFSET ?", ) diff --git a/crates/adapters/sqlite/src/repositories/social/follow/query.rs b/crates/adapters/sqlite/src/repositories/social/follow/query.rs index 05e7c3ea..92ceaa27 100644 --- a/crates/adapters/sqlite/src/repositories/social/follow/query.rs +++ b/crates/adapters/sqlite/src/repositories/social/follow/query.rs @@ -20,7 +20,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository { FROM ap_following f LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ?2 AND f.status = 'accepted'", + WHERE f.local_user_id = ?2 AND f.status = 'accepted' + AND u.deleted_at IS NULL", ) .bind(self.instance.base_url()) .bind(&uid) @@ -42,7 +43,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository { FROM ap_followers f LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ?2 AND f.status = 'accepted'", + WHERE f.local_user_id = ?2 AND f.status = 'accepted' + AND u.deleted_at IS NULL", ) .bind(self.instance.base_url()) .bind(&uid) @@ -67,7 +69,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository { FROM ap_followers f LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ?2 AND f.status = 'pending'", + WHERE f.local_user_id = ?2 AND f.status = 'pending' + AND u.deleted_at IS NULL", ) .bind(self.instance.base_url()) .bind(&uid) @@ -92,7 +95,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository { FROM ap_following f LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ?2 AND f.status = 'pending'", + WHERE f.local_user_id = ?2 AND f.status = 'pending' + AND u.deleted_at IS NULL", ) .bind(self.instance.base_url()) .bind(&uid) diff --git a/crates/adapters/sqlite/src/repositories/users/mod.rs b/crates/adapters/sqlite/src/repositories/users/mod.rs index e856ea06..4abb6cf7 100644 --- a/crates/adapters/sqlite/src/repositories/users/mod.rs +++ b/crates/adapters/sqlite/src/repositories/users/mod.rs @@ -66,11 +66,13 @@ const USER_COLS: &str = "id, email, username, password_hash, role, display_name, impl UserRepository for SqliteUserRepository { async fn find_by_email(&self, email: &Email) -> Result, DomainError> { let email_str = email.value(); - let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE email = ?")) - .bind(email_str) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; + let row = sqlx::query(&format!( + "SELECT {USER_COLS} FROM users WHERE email = ? AND deleted_at IS NULL" + )) + .bind(email_str) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; row.as_ref() .map(|r| Self::row_to_user(r, vec![])) @@ -79,11 +81,13 @@ impl UserRepository for SqliteUserRepository { async fn find_by_username(&self, username: &Username) -> Result, DomainError> { let username_str = username.value(); - let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE username = ?")) - .bind(username_str) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; + let row = sqlx::query(&format!( + "SELECT {USER_COLS} FROM users WHERE username = ? AND deleted_at IS NULL" + )) + .bind(username_str) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; row.as_ref() .map(|r| Self::row_to_user(r, vec![])) @@ -91,12 +95,12 @@ impl UserRepository for SqliteUserRepository { } async fn save(&self, user: &User) -> Result<(), DomainError> { - if self.find_by_email(user.email()).await?.is_some() { + if self.row_exists("email", user.email().value()).await? { return Err(DomainError::ValidationError( "Email already registered".into(), )); } - if self.find_by_username(user.username()).await?.is_some() { + if self.row_exists("username", user.username().value()).await? { return Err(DomainError::ValidationError( "Username already taken".into(), )); @@ -130,31 +134,86 @@ impl UserRepository for SqliteUserRepository { async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { let id_str = id.value().to_string(); - let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE id = ?")) - .bind(&id_str) - .fetch_optional(&self.pool) + let row = sqlx::query(&format!( + "SELECT {USER_COLS} FROM users WHERE id = ? AND deleted_at IS NULL" + )) + .bind(&id_str) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + self.load_with_fields(&id_str, row).await + } + + async fn find_tombstoned_by_id(&self, id: &UserId) -> Result, DomainError> { + let id_str = id.value().to_string(); + let row = sqlx::query(&format!( + "SELECT {USER_COLS} FROM users WHERE id = ? AND deleted_at IS NOT NULL" + )) + .bind(&id_str) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + self.load_with_fields(&id_str, row).await + } + + async fn mark_deleted( + &self, + user_id: &UserId, + events: &[domain::events::DomainEvent], + ) -> Result<(), DomainError> { + let id_str = user_id.value().to_string(); + let mut tx = self + .pool + .begin() .await .map_err(crate::shared::map_sqlx_error)?; - - let Some(r) = row else { return Ok(None) }; - - let field_rows = sqlx::query( - "SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC", + let stamped = + sqlx::query("UPDATE users SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL") + .bind(Utc::now().to_rfc3339()) + .bind(&id_str) + .execute(&mut *tx) + .await + .map_err(crate::shared::map_sqlx_error)?; + if stamped.rows_affected() == 0 { + return Err(DomainError::NotFound("User not found".into())); + } + sqlx::query( + "INSERT OR IGNORE INTO retired_usernames (username) SELECT username FROM users WHERE id = ?", ) .bind(&id_str) - .fetch_all(&self.pool) + .execute(&mut *tx) .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + .map_err(crate::shared::map_sqlx_error)?; + sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?") + .bind(&id_str) + .execute(&mut *tx) + .await + .map_err(crate::shared::map_sqlx_error)?; + crate::shared::enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) + } - let profile_fields = field_rows - .iter() - .map(|f| ProfileField { - name: f.get("name"), - value: f.get("value"), - }) - .collect(); + async fn is_username_retired(&self, username: &Username) -> Result { + sqlx::query_scalar::<_, i64>( + "SELECT EXISTS(SELECT 1 FROM retired_usernames WHERE username = ?)", + ) + .bind(username.value()) + .fetch_one(&self.pool) + .await + .map(|n| n != 0) + .map_err(crate::shared::map_sqlx_error) + } - Self::row_to_user(&r, profile_fields).map(Some) + async fn count_active_admins(&self) -> Result { + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM users WHERE role = 'admin' AND deleted_at IS NULL", + ) + .fetch_one(&self.pool) + .await + .map(|n| n as u64) + .map_err(crate::shared::map_sqlx_error) } async fn update_profile( @@ -193,6 +252,7 @@ impl UserRepository for SqliteUserRepository { u.avatar_path FROM users u LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL + WHERE u.deleted_at IS NULL GROUP BY u.id, u.email, u.username, u.display_name, u.avatar_path ORDER BY u.email ASC"#, ) @@ -204,3 +264,44 @@ impl UserRepository for SqliteUserRepository { .collect() } } + +impl SqliteUserRepository { + /// Uniqueness check over every row, tombstones included: a deleted + /// account keeps its email and username until the purge removes the row. + async fn row_exists(&self, column: &str, value: &str) -> Result { + sqlx::query_scalar::<_, i64>(&format!( + "SELECT EXISTS(SELECT 1 FROM users WHERE {column} = ?)" + )) + .bind(value) + .fetch_one(&self.pool) + .await + .map(|n| n != 0) + .map_err(crate::shared::map_sqlx_error) + } + + async fn load_with_fields( + &self, + id_str: &str, + row: Option, + ) -> Result, DomainError> { + let Some(r) = row else { return Ok(None) }; + + let field_rows = sqlx::query( + "SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC", + ) + .bind(id_str) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + let profile_fields = field_rows + .iter() + .map(|f| ProfileField { + name: f.get("name"), + value: f.get("value"), + }) + .collect(); + + Self::row_to_user(&r, profile_fields).map(Some) + } +} diff --git a/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt index 704ea667..fedc5c9c 100644 --- a/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt +++ b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt @@ -30,3 +30,4 @@ 30 DAAA0AF35B4ACB271A9D8068B82F02A72EEC62F3AC07ED504C7A7887976C2C8BACB35FFF477B63A931DF15F79018390D 100 310ADC9ABF0CC2F8E9EDAB33168D009D64228FF5BD8D2A14185513699EBB782606A1EEA8FEE6EC6AF65D72396153F13F 101 19A2542D4343F3921A8BF0A10E195DA1915E3EA97A2BCF2D3BF9079504DB2053E4BD2175D94C95BCA363BC2FDF14AA88 +102 A2118D59757F99A902B613046CE93C53927FE64330684A8703B6E62A8588B87D37ACD55451566F6248A67B7BD9B779EF diff --git a/crates/adapters/sqlite/tests/repositories/diary_repository_test.rs b/crates/adapters/sqlite/tests/repositories/diary_repository_test.rs index 59a0d7c4..645ebe40 100644 --- a/crates/adapters/sqlite/tests/repositories/diary_repository_test.rs +++ b/crates/adapters/sqlite/tests/repositories/diary_repository_test.rs @@ -205,3 +205,32 @@ async fn count_local_posts_excludes_remote_reviews() { let count = repo.count_local_posts().await.unwrap(); assert_eq!(count, 2); } + +#[tokio::test] +async fn feeds_hide_reviews_of_deleted_local_authors() { + let pool = SqlitePool::connect(":memory:").await.unwrap(); + setup(&pool).await; + sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE username = 'alice'") + .execute(&pool) + .await + .unwrap(); + let repo = SqliteDiaryRepository::new(pool); + + let page = PageParams::new(Some(10), Some(0)).unwrap(); + let feed = repo + .query_activity_feed_filtered(&page, &FeedSortBy::Date, None, None) + .await + .unwrap(); + assert_eq!(feed.total_count, 2); + assert!( + feed.items + .iter() + .all(|e| e.movie().title().value() != "Inception") + ); + + let movie = domain::value_objects::MovieId::from_uuid( + uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(), + ); + let social = repo.get_movie_social_feed(&movie, &page).await.unwrap(); + assert!(social.items.is_empty()); +} diff --git a/crates/adapters/sqlite/tests/repositories/social_repository_test.rs b/crates/adapters/sqlite/tests/repositories/social_repository_test.rs index 526b6dbf..b27ba033 100644 --- a/crates/adapters/sqlite/tests/repositories/social_repository_test.rs +++ b/crates/adapters/sqlite/tests/repositories/social_repository_test.rs @@ -13,7 +13,7 @@ async fn test_pool() -> SqlitePool { follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL, PRIMARY KEY (local_user_id, remote_actor_url))", "CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL, - display_name TEXT, avatar_path TEXT)", + display_name TEXT, avatar_path TEXT, deleted_at TEXT)", "CREATE TABLE ap_remote_actors (url TEXT PRIMARY KEY, handle TEXT NOT NULL, display_name TEXT, avatar_url TEXT)", ] { @@ -251,3 +251,40 @@ async fn list_all_followed_remote_actors_deduplicates_across_users() { assert_eq!(actors.len(), 1); assert_eq!(actors[0].handle, "alice@other.social"); } + +#[tokio::test] +async fn follow_lists_omit_deleted_local_users() { + let pool = test_pool().await; + let viewer = uuid::Uuid::new_v4(); + let gone = uuid::Uuid::new_v4(); + let live = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO users (id, username, deleted_at) VALUES (?1, 'gone', '2026-01-01T00:00:00Z'), (?2, 'live', NULL)", + ) + .bind(gone.to_string()) + .bind(live.to_string()) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status) + VALUES (?1, 'https://md.example/users/' || ?2, '', 'accepted'), + (?1, 'https://md.example/users/' || ?3, '', 'accepted'), + (?1, 'https://other.example/users/remote', '', 'accepted')", + ) + .bind(viewer.to_string()) + .bind(gone.to_string()) + .bind(live.to_string()) + .execute(&pool) + .await + .unwrap(); + + let actors = FollowQuery::get_following(&repo(pool), viewer) + .await + .unwrap(); + + assert_eq!(actors.len(), 2, "deleted local user must not be listed"); + assert!(!actors.iter().any( + |a| a.identity == SocialIdentity::Local(domain::value_objects::UserId::from_uuid(gone)) + )); +} diff --git a/crates/adapters/sqlite/tests/repositories/user_repository_test.rs b/crates/adapters/sqlite/tests/repositories/user_repository_test.rs index 42586f4b..5c86355e 100644 --- a/crates/adapters/sqlite/tests/repositories/user_repository_test.rs +++ b/crates/adapters/sqlite/tests/repositories/user_repository_test.rs @@ -5,20 +5,10 @@ use domain::value_objects::UserId; use sqlite::SqliteUserRepository; use sqlx::SqlitePool; +use super::outbox_support::{count, fail_event_inserts, migrated_pool}; + async fn setup() -> (SqlitePool, SqliteUserRepository) { - let pool = SqlitePool::connect(":memory:").await.unwrap(); - sqlx::query( - "CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', display_name TEXT, bio TEXT, avatar_path TEXT, banner_path TEXT, also_known_as TEXT)" - ) - .execute(&pool) - .await - .unwrap(); - sqlx::query( - "CREATE TABLE user_profile_fields (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, value TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0)" - ) - .execute(&pool) - .await - .unwrap(); + let pool = migrated_pool().await; let repo = SqliteUserRepository::new(pool.clone()); (pool, repo) } @@ -101,3 +91,126 @@ async fn update_profile_clears_fields_with_none() { assert_eq!(found.bio(), None); assert_eq!(found.avatar_path(), None); } + +fn deleted_event(user: &domain::models::User) -> domain::events::DomainEvent { + domain::events::DomainEvent::UserDeleted { + user_id: user.id().clone(), + } +} + +async fn add_session(pool: &SqlitePool, user_id: &UserId, token: &str) { + sqlx::query( + "INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(user_id.value().to_string()) + .bind(token) + .bind("2099-01-01T00:00:00+00:00") + .bind("2026-01-01T00:00:00+00:00") + .execute(pool) + .await + .unwrap(); +} + +#[tokio::test] +async fn marking_a_user_deleted_hides_them_from_every_lookup() { + let (_, repo) = setup().await; + let admin = + domain::testing::test_user_with_role(UserId::generate(), domain::models::UserRole::Admin); + repo.save(&admin).await.unwrap(); + let other = test_user("other"); + repo.save(&other).await.unwrap(); + assert_eq!(repo.count_active_admins().await.unwrap(), 1); + + repo.mark_deleted(admin.id(), &[deleted_event(&admin)]) + .await + .unwrap(); + + assert!(repo.find_by_id(admin.id()).await.unwrap().is_none()); + assert!(repo.find_by_email(admin.email()).await.unwrap().is_none()); + assert!( + repo.find_by_username(admin.username()) + .await + .unwrap() + .is_none() + ); + let listed = repo.list_with_stats().await.unwrap(); + let emails: Vec<_> = listed.iter().map(|u| u.email()).collect(); + assert!(emails.contains(&other.email().value())); + assert!(!emails.contains(&admin.email().value())); + assert_eq!(repo.count_active_admins().await.unwrap(), 0); + // The purge can still read the tombstone; a live user is not one. + assert!( + repo.find_tombstoned_by_id(admin.id()) + .await + .unwrap() + .is_some() + ); + assert!( + repo.find_tombstoned_by_id(other.id()) + .await + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn marking_deleted_retires_the_username_and_drops_sessions_atomically() { + let (pool, repo) = setup().await; + let user = test_user("leaver"); + let keeper = test_user("keeper"); + repo.save(&user).await.unwrap(); + repo.save(&keeper).await.unwrap(); + add_session(&pool, user.id(), "t1").await; + add_session(&pool, user.id(), "t2").await; + add_session(&pool, keeper.id(), "t3").await; + + repo.mark_deleted(user.id(), &[deleted_event(&user)]) + .await + .unwrap(); + + assert!(repo.is_username_retired(user.username()).await.unwrap()); + assert!(!repo.is_username_retired(keeper.username()).await.unwrap()); + assert_eq!(count(&pool, "refresh_sessions").await, 1, "only keeper's"); + assert_eq!(count(&pool, "event_queue").await, 1); + let deleted_at: Option = + sqlx::query_scalar("SELECT deleted_at FROM users WHERE id = ?") + .bind(user.id().value().to_string()) + .fetch_one(&pool) + .await + .unwrap(); + assert!(deleted_at.is_some()); +} + +#[tokio::test] +async fn a_failed_enqueue_rolls_back_tombstone_retirement_and_session_drop() { + let (pool, repo) = setup().await; + let user = test_user("stayer"); + repo.save(&user).await.unwrap(); + add_session(&pool, user.id(), "t1").await; + fail_event_inserts(&pool).await; + + let result = repo.mark_deleted(user.id(), &[deleted_event(&user)]).await; + + assert!(result.is_err()); + assert!(repo.find_by_id(user.id()).await.unwrap().is_some()); + assert!(!repo.is_username_retired(user.username()).await.unwrap()); + assert_eq!(count(&pool, "retired_usernames").await, 0); + assert_eq!(count(&pool, "refresh_sessions").await, 1); + assert_eq!(count(&pool, "event_queue").await, 0); +} + +#[tokio::test] +async fn retired_names_match_case_insensitively() { + let (_, repo) = setup().await; + let user = test_user("MixedCase"); + repo.save(&user).await.unwrap(); + repo.mark_deleted(user.id(), &[deleted_event(&user)]) + .await + .unwrap(); + + let lower = domain::value_objects::Username::new("mixedcase".to_string()).unwrap(); + let upper = domain::value_objects::Username::new("MIXEDCASE".to_string()).unwrap(); + assert!(repo.is_username_retired(&lower).await.unwrap()); + assert!(repo.is_username_retired(&upper).await.unwrap()); +} diff --git a/crates/application/tests/users/update_profile_test.rs b/crates/application/tests/users/update_profile_test.rs index 7bde7f73..2f22e6df 100644 --- a/crates/application/tests/users/update_profile_test.rs +++ b/crates/application/tests/users/update_profile_test.rs @@ -314,6 +314,28 @@ impl UserRepository for ProfileWriteRefusingUsers { "refused".into(), )) } + async fn mark_deleted( + &self, + id: &domain::value_objects::UserId, + events: &[DomainEvent], + ) -> Result<(), domain::errors::DomainError> { + self.0.mark_deleted(id, events).await + } + async fn find_tombstoned_by_id( + &self, + id: &domain::value_objects::UserId, + ) -> Result, domain::errors::DomainError> { + self.0.find_tombstoned_by_id(id).await + } + async fn is_username_retired( + &self, + u: &domain::value_objects::Username, + ) -> Result { + self.0.is_username_retired(u).await + } + async fn count_active_admins(&self) -> Result { + self.0.count_active_admins().await + } } #[tokio::test] diff --git a/crates/bootstrap/tests/http_handlers/support.rs b/crates/bootstrap/tests/http_handlers/support.rs index b5477228..0999592f 100644 --- a/crates/bootstrap/tests/http_handlers/support.rs +++ b/crates/bootstrap/tests/http_handlers/support.rs @@ -227,6 +227,28 @@ impl UserRepository for ErroringUserRepo { ) -> Result<(), DomainError> { panic!() } + async fn mark_deleted( + &self, + _: &UserId, + _: &[domain::events::DomainEvent], + ) -> Result<(), DomainError> { + panic!() + } + async fn find_tombstoned_by_id( + &self, + _: &UserId, + ) -> Result, DomainError> { + panic!() + } + async fn is_username_retired( + &self, + _: &domain::value_objects::Username, + ) -> Result { + panic!() + } + async fn count_active_admins(&self) -> Result { + panic!() + } } /// Auth stub that accepts any bearer token and resolves it to a fixed user id, diff --git a/crates/domain/src/ports/auth.rs b/crates/domain/src/ports/auth.rs index b4337cb2..e96f0152 100644 --- a/crates/domain/src/ports/auth.rs +++ b/crates/domain/src/ports/auth.rs @@ -14,6 +14,8 @@ pub trait AuthService: Send + Sync { #[async_trait] pub trait UserRepository: Send + Sync { + // The finders and `list_with_stats` serve live users only: tombstoned + // (deleted) accounts are invisible to them. async fn find_by_email(&self, email: &Email) -> Result, DomainError>; async fn find_by_username(&self, username: &Username) -> Result, DomainError>; async fn save(&self, user: &User) -> Result<(), DomainError>; @@ -25,6 +27,19 @@ pub trait UserRepository: Send + Sync { profile: &crate::models::UserProfile, events: &[crate::events::DomainEvent], ) -> Result<(), DomainError>; + /// Tombstones the account in one transaction: stamps `deleted_at`, + /// retires the username, drops the user's refresh sessions and records + /// `events`. Afterwards every reader below behaves as if the user is gone. + async fn mark_deleted( + &self, + user_id: &UserId, + events: &[crate::events::DomainEvent], + ) -> Result<(), DomainError>; + /// The tombstoned user, for the purge. Live users are not returned. + async fn find_tombstoned_by_id(&self, id: &UserId) -> Result, DomainError>; + /// Whether the name belonged to a deleted account (case-insensitive). + async fn is_username_retired(&self, username: &Username) -> Result; + async fn count_active_admins(&self) -> Result; } #[async_trait] diff --git a/crates/domain/src/testing/fakes/panics.rs b/crates/domain/src/testing/fakes/panics.rs index d2f754a2..5f7ee6c3 100644 --- a/crates/domain/src/testing/fakes/panics.rs +++ b/crates/domain/src/testing/fakes/panics.rs @@ -668,6 +668,24 @@ impl UserRepository for PanicUserRepository { ) -> Result<(), DomainError> { panic!("PanicUserRepository called") } + async fn mark_deleted(&self, _: &UserId, _: &[DomainEvent]) -> Result<(), DomainError> { + panic!("PanicUserRepository called") + } + async fn find_tombstoned_by_id( + &self, + _: &UserId, + ) -> Result, DomainError> { + panic!("PanicUserRepository called") + } + async fn is_username_retired( + &self, + _: &crate::value_objects::Username, + ) -> Result { + panic!("PanicUserRepository called") + } + async fn count_active_admins(&self) -> Result { + panic!("PanicUserRepository called") + } } pub struct PanicEventPublisher; diff --git a/crates/domain/src/testing/fakes/store.rs b/crates/domain/src/testing/fakes/store.rs index 71e9c6ca..91d5efae 100644 --- a/crates/domain/src/testing/fakes/store.rs +++ b/crates/domain/src/testing/fakes/store.rs @@ -59,6 +59,10 @@ pub struct InMemoryStore { pub(super) movie_profiles: RwLock>, pub(super) reviews: RwLock>, pub(super) users: RwLock>, + /// Ids of `users` rows carrying a `deleted_at` tombstone. + pub(super) tombstoned: RwLock>, + /// Lower-cased `retired_usernames` (the column is NOCASE). + pub(super) retired_usernames: RwLock>, pub(super) profile_fields: RwLock>>, pub(super) user_settings: RwLock>, pub(super) refresh_sessions: RwLock>, @@ -82,6 +86,8 @@ impl InMemoryStore { movie_profiles: RwLock::default(), reviews: RwLock::default(), users: RwLock::default(), + tombstoned: RwLock::default(), + retired_usernames: RwLock::default(), profile_fields: RwLock::default(), user_settings: RwLock::default(), refresh_sessions: RwLock::default(), diff --git a/crates/domain/src/testing/fakes/store_user.rs b/crates/domain/src/testing/fakes/store_user.rs index 83f07176..c6092c1b 100644 --- a/crates/domain/src/testing/fakes/store_user.rs +++ b/crates/domain/src/testing/fakes/store_user.rs @@ -16,16 +16,20 @@ use super::InMemoryStore; impl UserRepository for InMemoryStore { async fn find_by_email(&self, email: &Email) -> Result, DomainError> { let store = self.users.read().unwrap(); + let gone = self.tombstoned.read().unwrap(); Ok(store .values() + .filter(|u| !gone.contains(&u.id().value())) .find(|u| u.email().value() == email.value()) .cloned()) } async fn find_by_username(&self, username: &Username) -> Result, DomainError> { let store = self.users.read().unwrap(); + let gone = self.tombstoned.read().unwrap(); Ok(store .values() + .filter(|u| !gone.contains(&u.id().value())) .find(|u| u.username().value() == username.value()) .cloned()) } @@ -55,14 +59,69 @@ impl UserRepository for InMemoryStore { } async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + if self.tombstoned.read().unwrap().contains(&id.value()) { + return Ok(None); + } Ok(self.users.read().unwrap().get(&id.value()).cloned()) } + async fn find_tombstoned_by_id(&self, id: &UserId) -> Result, DomainError> { + if !self.tombstoned.read().unwrap().contains(&id.value()) { + return Ok(None); + } + Ok(self.users.read().unwrap().get(&id.value()).cloned()) + } + + async fn mark_deleted( + &self, + user_id: &UserId, + events: &[crate::events::DomainEvent], + ) -> Result<(), DomainError> { + let id = user_id.value(); + if let Some(user) = self.users.read().unwrap().get(&id) { + self.retired_usernames + .write() + .unwrap() + .insert(user.username().value().to_lowercase()); + self.tombstoned.write().unwrap().insert(id); + } + self.refresh_sessions + .write() + .unwrap() + .retain(|s| s.user_id != *user_id); + self.record_events(events); + Ok(()) + } + + async fn is_username_retired(&self, username: &Username) -> Result { + Ok(self + .retired_usernames + .read() + .unwrap() + .contains(&username.value().to_lowercase())) + } + + async fn count_active_admins(&self) -> Result { + let gone = self.tombstoned.read().unwrap(); + Ok(self + .users + .read() + .unwrap() + .values() + .filter(|u| matches!(u.role(), crate::models::UserRole::Admin)) + .filter(|u| !gone.contains(&u.id().value())) + .count() as u64) + } + /// Ordered by email ascending like the real query. Diary stats are stubbed /// (review count 0, no last activity); only the identity fields are real. async fn list_with_stats(&self) -> Result, DomainError> { let store = self.users.read().unwrap(); - let mut users: Vec<_> = store.values().collect(); + let gone = self.tombstoned.read().unwrap(); + let mut users: Vec<_> = store + .values() + .filter(|u| !gone.contains(&u.id().value())) + .collect(); users.sort_by(|a, b| a.email().value().cmp(b.email().value())); Ok(users .into_iter() @@ -161,6 +220,7 @@ impl InMemoryStore { pub fn delete_user(&self, user_id: &UserId) { let id = user_id.value(); self.users.write().unwrap().remove(&id); + self.tombstoned.write().unwrap().remove(&id); self.watchlist .write() .unwrap() diff --git a/crates/domain/tests/testing/in_memory_store_test.rs b/crates/domain/tests/testing/in_memory_store_test.rs index 2d93af6e..833c7e00 100644 --- a/crates/domain/tests/testing/in_memory_store_test.rs +++ b/crates/domain/tests/testing/in_memory_store_test.rs @@ -575,3 +575,56 @@ async fn deleting_a_review_while_another_remains_keeps_the_movie_and_records_one assert_eq!(store.movie_count(), 1); assert_eq!(store.recorded_events().len(), 1); } + +#[tokio::test] +async fn fake_mark_deleted_mirrors_the_sqlite_tombstone() { + let store = InMemoryStore::new(); + let user = test_user("ghost"); + let other = test_user("other"); + UserRepository::save(&*store, &user).await.unwrap(); + UserRepository::save(&*store, &other).await.unwrap(); + RefreshSessionRepository::create( + &*store, + &RefreshSession { + id: Uuid::new_v4(), + user_id: user.id().clone(), + token: "t".into(), + expires_at: Utc::now(), + created_at: Utc::now(), + }, + ) + .await + .unwrap(); + let event = domain::events::DomainEvent::UserDeleted { + user_id: user.id().clone(), + }; + + store + .mark_deleted(user.id(), std::slice::from_ref(&event)) + .await + .unwrap(); + + assert!(store.find_by_id(user.id()).await.unwrap().is_none()); + assert!(store.find_by_email(user.email()).await.unwrap().is_none()); + assert!( + store + .find_by_username(user.username()) + .await + .unwrap() + .is_none() + ); + assert_eq!(store.list_with_stats().await.unwrap().len(), 1); + assert!( + store + .find_tombstoned_by_id(user.id()) + .await + .unwrap() + .is_some() + ); + assert!(store.is_username_retired(user.username()).await.unwrap()); + let upper = + domain::value_objects::Username::new(user.username().value().to_uppercase()).unwrap(); + assert!(store.is_username_retired(&upper).await.unwrap()); + assert_eq!(store.refresh_session_count(), 0); + assert_eq!(store.recorded_events().len(), 1); +} -- 2.49.1 From 83bb5d9847f7179845b36471150165a4dc9436d3 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 02:04:08 +0100 Subject: [PATCH 076/104] chore: lockfile for activitypub tokio dev-dep --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index a0b17504..16709e36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,6 +16,7 @@ dependencies = [ "k-ap", "serde", "serde_json", + "tokio", "tracing", "url", "uuid", -- 2.49.1 From 0b5d18c4ee31eb7e74d5a8632b273e1156a161d6 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 02:11:44 +0100 Subject: [PATCH 077/104] feat: account deletion request and active-user check --- crates/adapters/http-axum/src/errors.rs | 3 + .../adapters/http-axum/src/extractors/mod.rs | 18 ++ .../sqlite/src/repositories/image_ref/mod.rs | 4 + .../sqlite/src/repositories/users/mod.rs | 2 +- .../repositories/social_repository_test.rs | 37 ++++ crates/application/src/auth/register.rs | 4 +- crates/application/src/deps.rs | 10 +- .../application/src/users/delete_account.rs | 19 -- crates/application/src/users/deps.rs | 10 +- crates/application/src/users/ensure_active.rs | 12 ++ crates/application/src/users/mod.rs | 3 +- .../src/users/request_account_deletion.rs | 60 ++++++ .../application/tests/auth/register_test.rs | 31 +++ crates/application/tests/users.rs | 6 +- .../tests/users/delete_account_test.rs | 87 -------- .../tests/users/ensure_active_test.rs | 84 ++++++++ .../users/request_account_deletion_test.rs | 185 ++++++++++++++++++ crates/bootstrap/src/build.rs | 13 +- crates/bootstrap/tests/bootstrap_test.rs | 38 ++++ .../tests/http_handlers/api_handlers_test.rs | 19 +- .../tests/http_handlers/extractors_test.rs | 7 +- .../bootstrap/tests/http_handlers/support.rs | 14 +- crates/domain/src/errors/mod.rs | 3 + crates/domain/src/testing/fakes/store_user.rs | 19 +- .../tests/testing/in_memory_store_test.rs | 30 +++ docs/adr/0005-single-composition-root.md | 8 +- ...0014-http-adapter-and-one-refusal-shape.md | 2 +- 27 files changed, 577 insertions(+), 151 deletions(-) delete mode 100644 crates/application/src/users/delete_account.rs create mode 100644 crates/application/src/users/ensure_active.rs create mode 100644 crates/application/src/users/request_account_deletion.rs delete mode 100644 crates/application/tests/users/delete_account_test.rs create mode 100644 crates/application/tests/users/ensure_active_test.rs create mode 100644 crates/application/tests/users/request_account_deletion_test.rs diff --git a/crates/adapters/http-axum/src/errors.rs b/crates/adapters/http-axum/src/errors.rs index d4ed68ce..16f9fa29 100644 --- a/crates/adapters/http-axum/src/errors.rs +++ b/crates/adapters/http-axum/src/errors.rs @@ -9,6 +9,7 @@ pub const NOT_FOUND: &str = "NOT_FOUND"; pub const INVALID_INPUT: &str = "INVALID_INPUT"; pub const UNAUTHORIZED: &str = "UNAUTHORIZED"; pub const FORBIDDEN: &str = "FORBIDDEN"; +pub const CONFLICT: &str = "CONFLICT"; pub const RATE_LIMITED: &str = "RATE_LIMITED"; pub const INTERNAL: &str = "INTERNAL"; @@ -32,6 +33,7 @@ pub fn domain_error_status(e: &DomainError) -> StatusCode { DomainError::NotFound(_) => StatusCode::NOT_FOUND, DomainError::Unauthorized(_) => StatusCode::UNAUTHORIZED, DomainError::Forbidden(_) => StatusCode::FORBIDDEN, + DomainError::Conflict(_) => StatusCode::CONFLICT, DomainError::InfrastructureError(_) => StatusCode::INTERNAL_SERVER_ERROR, } } @@ -64,6 +66,7 @@ impl IntoResponse for ApiError { DomainError::NotFound(_) => refuse(status, NOT_FOUND, self.0.to_string()), DomainError::Unauthorized(_) => refuse(status, UNAUTHORIZED, self.0.to_string()), DomainError::Forbidden(_) => refuse(status, FORBIDDEN, self.0.to_string()), + DomainError::Conflict(_) => refuse(status, CONFLICT, self.0.to_string()), DomainError::InfrastructureError(_) => { tracing::error!("Internal error: {:?}", self.0); refuse(status, INTERNAL, "internal server error") diff --git a/crates/adapters/http-axum/src/extractors/mod.rs b/crates/adapters/http-axum/src/extractors/mod.rs index 539c7222..3d01efce 100644 --- a/crates/adapters/http-axum/src/extractors/mod.rs +++ b/crates/adapters/http-axum/src/extractors/mod.rs @@ -8,6 +8,11 @@ use axum::{ }; use domain::{errors::DomainError, value_objects::UserId}; +async fn ensure_active(state: &AppState, user_id: &UserId) -> Result<(), DomainError> { + application::users::ensure_active::execute(&state.app_ctx.deps.users.ensure_active, user_id) + .await +} + use crate::{errors::ApiError, state::AppState}; pub struct AuthenticatedUser(pub UserId); @@ -37,6 +42,7 @@ where .auth .validate_token(token) .await?; + ensure_active(&app_state, &user_id).await?; Ok(AuthenticatedUser(user_id)) } } @@ -75,6 +81,10 @@ where .validate_token(&token) .await .ok(); + let user_id = match user_id { + Some(id) if ensure_active(&app_state, &id).await.is_ok() => Some(id), + _ => None, + }; Ok(OptionalCookieUser(user_id)) } } @@ -97,6 +107,14 @@ where .validate_token(&token) .await .map_err(|_| Redirect::to("/login").into_response())?; + ensure_active(&app_state, &user_id) + .await + .map_err(|e| match e { + DomainError::InfrastructureError(_) => { + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + _ => Redirect::to("/login").into_response(), + })?; Ok(RequiredCookieUser(user_id)) } } diff --git a/crates/adapters/sqlite/src/repositories/image_ref/mod.rs b/crates/adapters/sqlite/src/repositories/image_ref/mod.rs index 0a1397c5..5601b667 100644 --- a/crates/adapters/sqlite/src/repositories/image_ref/mod.rs +++ b/crates/adapters/sqlite/src/repositories/image_ref/mod.rs @@ -26,6 +26,8 @@ pub fn create_image_ref(pool: SqlitePool) -> (Arc, Arc Result<(), DomainError> { let mut tx = self .pool @@ -52,6 +54,8 @@ impl ImageRefCommand for SqliteImageRefAdapter { #[async_trait] impl ImageRefQuery for SqliteImageRefAdapter { + // Tombstoned users are intentionally visible here until the purge removes + // the row, so their avatar is not garbage-collected early. async fn list_keys(&self) -> Result, DomainError> { let rows: Vec<(String,)> = sqlx::query_as( "SELECT avatar_path FROM users WHERE avatar_path IS NOT NULL diff --git a/crates/adapters/sqlite/src/repositories/users/mod.rs b/crates/adapters/sqlite/src/repositories/users/mod.rs index 4abb6cf7..3ffd2d10 100644 --- a/crates/adapters/sqlite/src/repositories/users/mod.rs +++ b/crates/adapters/sqlite/src/repositories/users/mod.rs @@ -171,7 +171,7 @@ impl UserRepository for SqliteUserRepository { .map_err(crate::shared::map_sqlx_error)?; let stamped = sqlx::query("UPDATE users SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL") - .bind(Utc::now().to_rfc3339()) + .bind(Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()) .bind(&id_str) .execute(&mut *tx) .await diff --git a/crates/adapters/sqlite/tests/repositories/social_repository_test.rs b/crates/adapters/sqlite/tests/repositories/social_repository_test.rs index b27ba033..6e805266 100644 --- a/crates/adapters/sqlite/tests/repositories/social_repository_test.rs +++ b/crates/adapters/sqlite/tests/repositories/social_repository_test.rs @@ -288,3 +288,40 @@ async fn follow_lists_omit_deleted_local_users() { |a| a.identity == SocialIdentity::Local(domain::value_objects::UserId::from_uuid(gone)) )); } + +#[tokio::test] +async fn follower_lists_omit_deleted_local_users() { + let pool = test_pool().await; + let viewer = uuid::Uuid::new_v4(); + let gone = uuid::Uuid::new_v4(); + let live = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO users (id, username, deleted_at) VALUES (?1, 'gone', '2026-01-01T00:00:00Z'), (?2, 'live', NULL)", + ) + .bind(gone.to_string()) + .bind(live.to_string()) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status) + VALUES (?1, 'https://md.example/users/' || ?2, '', 'accepted'), + (?1, 'https://md.example/users/' || ?3, '', 'accepted'), + (?1, 'https://other.example/users/remote', '', 'accepted')", + ) + .bind(viewer.to_string()) + .bind(gone.to_string()) + .bind(live.to_string()) + .execute(&pool) + .await + .unwrap(); + + let actors = FollowQuery::get_followers(&repo(pool), viewer) + .await + .unwrap(); + + assert_eq!(actors.len(), 2, "deleted local follower must not be listed"); + assert!(!actors.iter().any( + |a| a.identity == SocialIdentity::Local(domain::value_objects::UserId::from_uuid(gone)) + )); +} diff --git a/crates/application/src/auth/register.rs b/crates/application/src/auth/register.rs index b19ae607..8cf3cb1b 100644 --- a/crates/application/src/auth/register.rs +++ b/crates/application/src/auth/register.rs @@ -21,7 +21,9 @@ pub async fn execute(deps: &RegisterDeps, cmd: RegisterCommand) -> Result<(), Do )); } - if deps.user.find_by_username(&username).await?.is_some() { + if deps.user.find_by_username(&username).await?.is_some() + || deps.user.is_username_retired(&username).await? + { return Err(DomainError::ValidationError( "Username already taken".into(), )); diff --git a/crates/application/src/deps.rs b/crates/application/src/deps.rs index d5233cf2..cb305d6c 100644 --- a/crates/application/src/deps.rs +++ b/crates/application/src/deps.rs @@ -26,10 +26,10 @@ use crate::person::deps::{EnrichPersonDeps, GetPersonDeps}; use crate::search::deps::SearchDeps; use crate::social::deps::{SocialCommandDeps, SocialQueryDeps}; use crate::users::deps::{ - AuthorizeAdminDeps, DeleteAccountDeps, GetCurrentProfileDeps, GetFederatedProfileDeps, + AuthorizeAdminDeps, EnsureActiveDeps, GetCurrentProfileDeps, GetFederatedProfileDeps, GetFederatedProfileStatsDeps, GetLocalProfileDeps, GetPageViewerDeps, GetProfileSettingsDeps, - GetSettingsDeps, GetUsersListDeps, ResolveUsernameDeps, UpdateProfileDeps, - UpdateProfileFieldsDeps, UpdateSettingsDeps, + GetSettingsDeps, GetUsersListDeps, RequestAccountDeletionDeps, ResolveUsernameDeps, + UpdateProfileDeps, UpdateProfileFieldsDeps, UpdateSettingsDeps, }; use crate::watchlist::deps::{ GetWatchlistDeps, GetWatchlistForOwnerDeps, IsOnWatchlistDeps, RemoveFromWatchlistDeps, @@ -124,8 +124,8 @@ pub struct UsersGroup { pub get_profile_settings: GetProfileSettingsDeps, pub get_users_list: GetUsersListDeps, pub update_profile: UpdateProfileDeps, - /// Not reachable from the server binary; see the `Deps`-level note above. Pre-existing dead use case: `users::delete_account::execute` has zero callers anywhere in the workspace, worker included. - pub delete_account: DeleteAccountDeps, + pub request_account_deletion: RequestAccountDeletionDeps, + pub ensure_active: EnsureActiveDeps, pub get_current_profile: GetCurrentProfileDeps, pub update_profile_fields: UpdateProfileFieldsDeps, pub get_settings: GetSettingsDeps, diff --git a/crates/application/src/users/delete_account.rs b/crates/application/src/users/delete_account.rs deleted file mode 100644 index 51b1e130..00000000 --- a/crates/application/src/users/delete_account.rs +++ /dev/null @@ -1,19 +0,0 @@ -use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; - -use crate::users::deps::DeleteAccountDeps; - -pub async fn execute(deps: &DeleteAccountDeps, user_id: uuid::Uuid) -> Result<(), DomainError> { - let uid = UserId::from_uuid(user_id); - - deps.user - .find_by_id(&uid) - .await? - .ok_or_else(|| DomainError::NotFound("User not found".into()))?; - - // Notify federation peers before any data is removed so they can process the tombstone. - deps.event_publisher - .publish(&DomainEvent::UserDeleted { user_id: uid }) - .await?; - - Ok(()) -} diff --git a/crates/application/src/users/deps.rs b/crates/application/src/users/deps.rs index f60dd06f..7fe09032 100644 --- a/crates/application/src/users/deps.rs +++ b/crates/application/src/users/deps.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use domain::ports::{ DiaryQuery, EventPublisher, FederatedProfileQuery, FederationAdminQuery, FollowGraphQuery, - ObjectStorage, StatsRepository, UserProfileFieldsRepository, UserRepository, + ObjectStorage, PasswordHasher, StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository, }; use domain::value_objects::InstanceIdentity; @@ -47,9 +47,13 @@ pub struct UpdateProfileDeps { pub event_publisher: Arc, } -pub struct DeleteAccountDeps { +pub struct RequestAccountDeletionDeps { + pub user: Arc, + pub password_hasher: Arc, +} + +pub struct EnsureActiveDeps { pub user: Arc, - pub event_publisher: Arc, } pub struct GetCurrentProfileDeps { diff --git a/crates/application/src/users/ensure_active.rs b/crates/application/src/users/ensure_active.rs new file mode 100644 index 00000000..6a448247 --- /dev/null +++ b/crates/application/src/users/ensure_active.rs @@ -0,0 +1,12 @@ +use domain::{errors::DomainError, value_objects::UserId}; + +use crate::users::deps::EnsureActiveDeps; + +/// A token outlives its account; this is the check that stops a deleted +/// (or vanished) user from acting on one. +pub async fn execute(deps: &EnsureActiveDeps, user: &UserId) -> Result<(), DomainError> { + match deps.user.find_by_id(user).await? { + Some(_) => Ok(()), + None => Err(DomainError::Unauthorized("account is not active".into())), + } +} diff --git a/crates/application/src/users/mod.rs b/crates/application/src/users/mod.rs index b60d9f7c..d83e3799 100644 --- a/crates/application/src/users/mod.rs +++ b/crates/application/src/users/mod.rs @@ -1,8 +1,8 @@ pub mod authorize_admin; pub mod commands; -pub mod delete_account; pub mod deps; mod diary_filter; +pub mod ensure_active; pub mod get_current_profile; pub mod get_federated_profile; pub mod get_federated_profile_stats; @@ -12,6 +12,7 @@ pub mod get_profile_settings; pub mod get_settings; pub mod get_users; pub mod queries; +pub mod request_account_deletion; pub mod resolve_username_to_id; pub mod update_profile; pub mod update_profile_fields; diff --git a/crates/application/src/users/request_account_deletion.rs b/crates/application/src/users/request_account_deletion.rs new file mode 100644 index 00000000..2da7e595 --- /dev/null +++ b/crates/application/src/users/request_account_deletion.rs @@ -0,0 +1,60 @@ +use domain::{errors::DomainError, models::UserRole, value_objects::UserId}; + +use crate::users::deps::RequestAccountDeletionDeps; + +/// Tombstones `target`. A user may delete themselves; an admin may delete +/// anyone. Either way the *actor's* password is the proof, and the last +/// active admin can never be removed. `UserDeleted` rides the repository +/// write, so the tombstone and its event commit together. +pub async fn execute( + deps: &RequestAccountDeletionDeps, + actor: UserId, + target: UserId, + password: &str, +) -> Result<(), DomainError> { + let actor_user = deps + .user + .find_by_id(&actor) + .await? + .ok_or_else(|| DomainError::Unauthorized("account is not active".into()))?; + + let is_self = actor == target; + if !is_self && !matches!(actor_user.role(), UserRole::Admin) { + return Err(DomainError::Forbidden( + "only an admin can delete another account".into(), + )); + } + + if !deps + .password_hasher + .verify(password, actor_user.password_hash()) + .await? + { + return Err(DomainError::Unauthorized("incorrect password".into())); + } + + let target_user = if is_self { + actor_user + } else { + deps.user + .find_by_id(&target) + .await? + .ok_or_else(|| DomainError::NotFound("User not found".into()))? + }; + + if matches!(target_user.role(), UserRole::Admin) && deps.user.count_active_admins().await? <= 1 + { + return Err(DomainError::Conflict( + "the last active admin cannot be deleted".into(), + )); + } + + deps.user + .mark_deleted( + &target, + &[domain::events::DomainEvent::UserDeleted { + user_id: target.clone(), + }], + ) + .await +} diff --git a/crates/application/tests/auth/register_test.rs b/crates/application/tests/auth/register_test.rs index e28f30be..cb93db58 100644 --- a/crates/application/tests/auth/register_test.rs +++ b/crates/application/tests/auth/register_test.rs @@ -71,3 +71,34 @@ async fn registering_with_a_short_password_fails() { "expected password length error, got: {err}" ); } + +#[tokio::test] +async fn registration_refuses_a_retired_username() { + let users = InMemoryStore::new(); + let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); + let deps = RegisterDeps { + user: b.user_repo.clone(), + password_hasher: b.password_hasher.clone(), + config: b.config.clone(), + }; + register::execute(&deps, register_command("old@example.com", "ghost")) + .await + .unwrap(); + let old = users + .find_by_email(&Email::new("old@example.com".into()).unwrap()) + .await + .unwrap() + .unwrap(); + users.mark_deleted(old.id(), &[]).await.unwrap(); + // The purge removes the row; only the retired name keeps the username taken. + users.delete_user(old.id()); + + let err = register::execute(&deps, register_command("new@example.com", "Ghost")) + .await + .unwrap_err(); + + assert!( + matches!(&err, domain::errors::DomainError::ValidationError(m) if m == "Username already taken"), + "got {err:?}" + ); +} diff --git a/crates/application/tests/users.rs b/crates/application/tests/users.rs index 59c516c3..4cd6ff02 100644 --- a/crates/application/tests/users.rs +++ b/crates/application/tests/users.rs @@ -2,8 +2,8 @@ mod support; #[path = "users/authorize_admin_test.rs"] mod authorize_admin_test; -#[path = "users/delete_account_test.rs"] -mod delete_account_test; +#[path = "users/ensure_active_test.rs"] +mod ensure_active_test; #[path = "users/get_current_profile_test.rs"] mod get_current_profile_test; #[path = "users/get_federated_profile_stats_test.rs"] @@ -20,6 +20,8 @@ mod get_profile_settings_test; mod get_settings_test; #[path = "users/get_users_test.rs"] mod get_users_test; +#[path = "users/request_account_deletion_test.rs"] +mod request_account_deletion_test; #[path = "users/resolve_username_to_id_test.rs"] mod resolve_username_to_id_test; #[path = "users/update_profile_fields_test.rs"] diff --git a/crates/application/tests/users/delete_account_test.rs b/crates/application/tests/users/delete_account_test.rs deleted file mode 100644 index d65e7e44..00000000 --- a/crates/application/tests/users/delete_account_test.rs +++ /dev/null @@ -1,87 +0,0 @@ -use crate::support::{TestContextBuilder, register_command}; -use std::sync::Arc; - -use domain::{ - errors::DomainError, - events::DomainEvent, - ports::UserRepository, - testing::{InMemoryStore, NoopEventPublisher, RefusingEventPublisher}, - value_objects::{Email, UserId}, -}; -use uuid::Uuid; - -use application::{ - auth::{deps::RegisterDeps, register}, - users::{delete_account, deps::DeleteAccountDeps}, -}; - -async fn register_user(b: &TestContextBuilder, users: &Arc) -> Uuid { - let reg_deps = RegisterDeps { - user: b.user_repo.clone(), - password_hasher: b.password_hasher.clone(), - config: b.config.clone(), - }; - register::execute(®_deps, register_command("alice@example.com", "alice")) - .await - .unwrap(); - users - .find_by_email(&Email::new("alice@example.com".into()).unwrap()) - .await - .unwrap() - .unwrap() - .id() - .value() -} - -#[tokio::test] -async fn deleting_an_unknown_user_is_not_found() { - let events = NoopEventPublisher::new(); - let b = TestContextBuilder::new(); - let deps = DeleteAccountDeps { - user: b.user_repo.clone(), - event_publisher: Arc::clone(&events) as _, - }; - - let err = delete_account::execute(&deps, Uuid::new_v4()) - .await - .unwrap_err(); - - assert!(matches!(err, DomainError::NotFound(_))); - assert!(events.published().is_empty()); -} - -#[tokio::test] -async fn deleting_an_existing_user_publishes_user_deleted() { - let users = InMemoryStore::new(); - let events = NoopEventPublisher::new(); - let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); - let deps = DeleteAccountDeps { - user: b.user_repo.clone(), - event_publisher: Arc::clone(&events) as _, - }; - let uid = register_user(&b, &users).await; - - delete_account::execute(&deps, uid).await.unwrap(); - - let published = events.published(); - assert!( - matches!( - published.as_slice(), - [DomainEvent::UserDeleted { user_id }] if *user_id == UserId::from_uuid(uid) - ), - "published: {published:?}" - ); -} - -#[tokio::test] -async fn deleting_fails_when_the_publisher_refuses() { - let users = InMemoryStore::new(); - let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); - let deps = DeleteAccountDeps { - user: b.user_repo.clone(), - event_publisher: Arc::new(RefusingEventPublisher), - }; - let uid = register_user(&b, &users).await; - - assert!(delete_account::execute(&deps, uid).await.is_err()); -} diff --git a/crates/application/tests/users/ensure_active_test.rs b/crates/application/tests/users/ensure_active_test.rs new file mode 100644 index 00000000..ab8d4a0f --- /dev/null +++ b/crates/application/tests/users/ensure_active_test.rs @@ -0,0 +1,84 @@ +use std::sync::Arc; + +use domain::{ + errors::DomainError, + ports::UserRepository, + testing::InMemoryStore, + value_objects::{Email, UserId}, +}; + +use application::{ + auth::{deps::RegisterDeps, register}, + users::{deps::EnsureActiveDeps, ensure_active}, +}; + +use crate::support::{TestContextBuilder, register_command}; + +#[tokio::test] +async fn a_live_user_is_active() { + let store = InMemoryStore::new(); + let b = TestContextBuilder::new().with_users(Arc::clone(&store) as _); + let reg = RegisterDeps { + user: b.user_repo.clone(), + password_hasher: b.password_hasher.clone(), + config: b.config.clone(), + }; + register::execute(®, register_command("alice@example.com", "alice")) + .await + .unwrap(); + let id = store + .find_by_email(&Email::new("alice@example.com".into()).unwrap()) + .await + .unwrap() + .unwrap() + .id() + .clone(); + let deps = EnsureActiveDeps { + user: b.user_repo.clone(), + }; + + ensure_active::execute(&deps, &id).await.unwrap(); +} + +#[tokio::test] +async fn a_deleted_user_is_rejected() { + let store = InMemoryStore::new(); + let b = TestContextBuilder::new().with_users(Arc::clone(&store) as _); + let reg = RegisterDeps { + user: b.user_repo.clone(), + password_hasher: b.password_hasher.clone(), + config: b.config.clone(), + }; + register::execute(®, register_command("alice@example.com", "alice")) + .await + .unwrap(); + let id = store + .find_by_email(&Email::new("alice@example.com".into()).unwrap()) + .await + .unwrap() + .unwrap() + .id() + .clone(); + store.mark_deleted(&id, &[]).await.unwrap(); + let deps = EnsureActiveDeps { + user: b.user_repo.clone(), + }; + + let err = ensure_active::execute(&deps, &id).await.unwrap_err(); + + assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}"); +} + +#[tokio::test] +async fn an_unknown_user_is_rejected() { + let b = TestContextBuilder::new(); + let deps = EnsureActiveDeps { + user: b.user_repo.clone(), + }; + + let err = ensure_active::execute(&deps, &UserId::generate()) + .await + .unwrap_err(); + + assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}"); +} diff --git a/crates/application/tests/users/request_account_deletion_test.rs b/crates/application/tests/users/request_account_deletion_test.rs new file mode 100644 index 00000000..3bb78f94 --- /dev/null +++ b/crates/application/tests/users/request_account_deletion_test.rs @@ -0,0 +1,185 @@ +use std::sync::Arc; + +use domain::{ + errors::DomainError, + events::DomainEvent, + models::UserRole, + ports::UserRepository, + testing::InMemoryStore, + value_objects::{Email, UserId}, +}; + +use application::{ + auth::{deps::RegisterDeps, register}, + users::{deps::RequestAccountDeletionDeps, request_account_deletion}, +}; + +use crate::support::{TestContextBuilder, register_command}; + +const PASSWORD: &str = "password123"; + +struct Fixture { + store: Arc, + deps: RequestAccountDeletionDeps, + register: RegisterDeps, +} + +fn fixture() -> Fixture { + let store = InMemoryStore::new(); + let b = TestContextBuilder::new().with_users(Arc::clone(&store) as _); + Fixture { + deps: RequestAccountDeletionDeps { + user: b.user_repo.clone(), + password_hasher: b.password_hasher.clone(), + }, + register: RegisterDeps { + user: b.user_repo.clone(), + password_hasher: b.password_hasher.clone(), + config: b.config.clone(), + }, + store, + } +} + +impl Fixture { + async fn user(&self, name: &str, role: UserRole) -> UserId { + let email = format!("{name}@example.com"); + let mut cmd = register_command(email.clone(), name); + cmd.role = role; + register::execute(&self.register, cmd).await.unwrap(); + self.store + .find_by_email(&Email::new(email).unwrap()) + .await + .unwrap() + .unwrap() + .id() + .clone() + } + + async fn is_live(&self, id: &UserId) -> bool { + self.store.find_by_id(id).await.unwrap().is_some() + } +} + +#[tokio::test] +async fn a_user_deletes_their_own_account_with_the_right_password() { + let f = fixture(); + let alice = f.user("alice", UserRole::Standard).await; + + request_account_deletion::execute(&f.deps, alice.clone(), alice.clone(), PASSWORD) + .await + .unwrap(); + + assert!(!f.is_live(&alice).await); + assert!( + matches!( + f.store.recorded_events().as_slice(), + [DomainEvent::UserDeleted { user_id }] if *user_id == alice + ), + "events: {:?}", + f.store.recorded_events() + ); +} + +#[tokio::test] +async fn a_wrong_password_is_unauthorized_and_changes_nothing() { + let f = fixture(); + let alice = f.user("alice", UserRole::Standard).await; + + let err = request_account_deletion::execute(&f.deps, alice.clone(), alice.clone(), "nope") + .await + .unwrap_err(); + + assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}"); + assert!(f.is_live(&alice).await); + assert!(f.store.recorded_events().is_empty()); +} + +#[tokio::test] +async fn a_non_admin_cannot_delete_someone_else() { + let f = fixture(); + let alice = f.user("alice", UserRole::Standard).await; + let bob = f.user("bob", UserRole::Standard).await; + + let err = request_account_deletion::execute(&f.deps, alice, bob.clone(), PASSWORD) + .await + .unwrap_err(); + + assert!(matches!(err, DomainError::Forbidden(_)), "got {err:?}"); + assert!(f.is_live(&bob).await); +} + +#[tokio::test] +async fn an_admin_deletes_another_user_with_their_own_password() { + let f = fixture(); + let admin = f.user("root", UserRole::Admin).await; + let bob = f.user("bob", UserRole::Standard).await; + + let err = request_account_deletion::execute(&f.deps, admin.clone(), bob.clone(), "bobs-pw") + .await + .unwrap_err(); + assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}"); + assert!(f.is_live(&bob).await); + + request_account_deletion::execute(&f.deps, admin.clone(), bob.clone(), PASSWORD) + .await + .unwrap(); + + assert!(!f.is_live(&bob).await); + assert!(f.is_live(&admin).await); +} + +#[tokio::test] +async fn the_last_active_admin_cannot_delete_themselves() { + let f = fixture(); + let admin = f.user("root", UserRole::Admin).await; + + let err = request_account_deletion::execute(&f.deps, admin.clone(), admin.clone(), PASSWORD) + .await + .unwrap_err(); + + assert!(matches!(err, DomainError::Conflict(_)), "got {err:?}"); + assert!(f.is_live(&admin).await); + assert!(f.store.recorded_events().is_empty()); +} + +#[tokio::test] +async fn an_admin_can_delete_themselves_when_another_admin_remains() { + let f = fixture(); + let first = f.user("root", UserRole::Admin).await; + let _second = f.user("root2", UserRole::Admin).await; + + request_account_deletion::execute(&f.deps, first.clone(), first.clone(), PASSWORD) + .await + .unwrap(); + + assert!(!f.is_live(&first).await); +} + +#[tokio::test] +async fn an_unknown_target_is_not_found() { + let f = fixture(); + let admin = f.user("root", UserRole::Admin).await; + + let err = request_account_deletion::execute(&f.deps, admin, UserId::generate(), PASSWORD) + .await + .unwrap_err(); + + assert!(matches!(err, DomainError::NotFound(_)), "got {err:?}"); +} + +#[tokio::test] +async fn an_already_deleted_target_is_not_found() { + let f = fixture(); + let admin = f.user("root", UserRole::Admin).await; + let bob = f.user("bob", UserRole::Standard).await; + request_account_deletion::execute(&f.deps, admin.clone(), bob.clone(), PASSWORD) + .await + .unwrap(); + + let err = request_account_deletion::execute(&f.deps, admin, bob, PASSWORD) + .await + .unwrap_err(); + + assert!(matches!(err, DomainError::NotFound(_)), "got {err:?}"); +} diff --git a/crates/bootstrap/src/build.rs b/crates/bootstrap/src/build.rs index 34a3e562..2a1a6f69 100644 --- a/crates/bootstrap/src/build.rs +++ b/crates/bootstrap/src/build.rs @@ -29,10 +29,10 @@ use application::person::deps::{EnrichPersonDeps, GetPersonDeps}; use application::search::deps::SearchDeps; use application::social::deps::{SocialCommandDeps, SocialQueryDeps}; use application::users::deps::{ - AuthorizeAdminDeps, DeleteAccountDeps, GetCurrentProfileDeps, GetFederatedProfileDeps, + AuthorizeAdminDeps, EnsureActiveDeps, GetCurrentProfileDeps, GetFederatedProfileDeps, GetFederatedProfileStatsDeps, GetLocalProfileDeps, GetPageViewerDeps, GetProfileSettingsDeps, - GetSettingsDeps, GetUsersListDeps, ResolveUsernameDeps, UpdateProfileDeps, - UpdateProfileFieldsDeps, UpdateSettingsDeps, + GetSettingsDeps, GetUsersListDeps, RequestAccountDeletionDeps, ResolveUsernameDeps, + UpdateProfileDeps, UpdateProfileFieldsDeps, UpdateSettingsDeps, }; use application::watchlist::deps::{ GetWatchlistDeps, GetWatchlistForOwnerDeps, IsOnWatchlistDeps, RemoveFromWatchlistDeps, @@ -280,9 +280,12 @@ pub fn build_deps( object_storage: services.object_storage.clone(), event_publisher: services.event_publisher.clone(), }, - delete_account: DeleteAccountDeps { + request_account_deletion: RequestAccountDeletionDeps { + user: repos.user.clone(), + password_hasher: services.password_hasher.clone(), + }, + ensure_active: EnsureActiveDeps { user: repos.user.clone(), - event_publisher: services.event_publisher.clone(), }, get_current_profile: GetCurrentProfileDeps { user: repos.user.clone(), diff --git a/crates/bootstrap/tests/bootstrap_test.rs b/crates/bootstrap/tests/bootstrap_test.rs index 318b8161..0c1fdb8d 100644 --- a/crates/bootstrap/tests/bootstrap_test.rs +++ b/crates/bootstrap/tests/bootstrap_test.rs @@ -215,3 +215,41 @@ async fn a_remote_block_publishes_its_event_after_the_k_ap_write() { assert_eq!(blocked, 1); assert_eq!(queued(&pool, "ActorBlocked").await, 1); } + +/// A token outlives the account that owns it, so every authenticated route +/// must re-check the account: a deleted user gets exactly what an anonymous +/// caller gets. +#[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"); +} diff --git a/crates/bootstrap/tests/http_handlers/api_handlers_test.rs b/crates/bootstrap/tests/http_handlers/api_handlers_test.rs index 29ae5b92..700725e2 100644 --- a/crates/bootstrap/tests/http_handlers/api_handlers_test.rs +++ b/crates/bootstrap/tests/http_handlers/api_handlers_test.rs @@ -1,4 +1,4 @@ -use crate::support::{AcceptingAuth, test_app_state, test_app_state_with_follow_graph}; +use crate::support::{AcceptingAuth, test_app_state, test_app_state_with_repos}; use axum::{ Router, body::Body, @@ -6,6 +6,8 @@ use axum::{ routing::get, }; use domain::errors::DomainError; +use domain::ports::UserRepository; +use domain::testing::InMemoryStore; use std::sync::Arc; use tower::ServiceExt; use uuid::Uuid; @@ -257,8 +259,19 @@ impl domain::ports::FollowGraphQuery for FixedPendingCount { #[tokio::test] async fn pending_count_endpoint_reports_the_use_case_result() { let uid = domain::value_objects::UserId::from_uuid(Uuid::new_v4()); - let state = test_app_state_with_follow_graph( - Arc::new(FixedPendingCount(3)), + 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() diff --git a/crates/bootstrap/tests/http_handlers/extractors_test.rs b/crates/bootstrap/tests/http_handlers/extractors_test.rs index d2d727e5..15d428a2 100644 --- a/crates/bootstrap/tests/http_handlers/extractors_test.rs +++ b/crates/bootstrap/tests/http_handlers/extractors_test.rs @@ -246,7 +246,8 @@ async fn admin_api_user_rejects_an_unknown_user() { ) .await .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); + // An account the store does not hold is inactive, so it is unauthenticated. + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] @@ -337,7 +338,9 @@ async fn admin_user_rejects_an_unknown_user() { ) .await .unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + // An account the store does not hold is inactive: back to the login page. + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert_eq!(resp.headers().get("location").unwrap(), "/login"); } #[tokio::test] diff --git a/crates/bootstrap/tests/http_handlers/support.rs b/crates/bootstrap/tests/http_handlers/support.rs index 0999592f..e53faeb1 100644 --- a/crates/bootstrap/tests/http_handlers/support.rs +++ b/crates/bootstrap/tests/http_handlers/support.rs @@ -7,7 +7,7 @@ use bootstrap::Repositories; use domain::{ errors::DomainError, models::GeneratedToken, - ports::{AuthService, FollowGraphQuery, PersonQuery, SearchPort, UserRepository}, + ports::{AuthService, PersonQuery, SearchPort, UserRepository}, testing::{ PanicAuthService, PanicDiaryExporter, PanicDiaryQuery, PanicDocumentParser, PanicEventPublisher, PanicGoalCommand, PanicGoalQuery, PanicImportProfileRepository, @@ -168,15 +168,15 @@ pub fn test_app_state_with_search_port( build_test_state_from(repos, Arc::new(PanicAuthService)) } -/// Test state with `follow_graph` overridden BEFORE `build_deps` runs, so the -/// stub is visible from both `app_ctx.repos.follow_graph` and the prebuilt -/// `deps.social.query` that the social handlers actually read from. -pub fn test_app_state_with_follow_graph( - follow_graph: Arc, +/// Test state whose repositories are adjusted by `adjust` BEFORE `build_deps` +/// runs, for tests that need more than one stub swapped in (e.g. a follow graph +/// plus the live user that authenticated routes now look up). +pub fn test_app_state_with_repos( + adjust: impl FnOnce(&mut Repositories), auth_service: Arc, ) -> http_axum::state::AppState { let mut repos = test_repositories(); - repos.follow_graph = follow_graph; + adjust(&mut repos); build_test_state_from(repos, auth_service) } diff --git a/crates/domain/src/errors/mod.rs b/crates/domain/src/errors/mod.rs index b09e30c6..1dbda26f 100644 --- a/crates/domain/src/errors/mod.rs +++ b/crates/domain/src/errors/mod.rs @@ -19,6 +19,9 @@ pub enum DomainError { #[error("Forbidden: {0}")] Forbidden(String), + + #[error("Conflict: {0}")] + Conflict(String), } impl DomainError { diff --git a/crates/domain/src/testing/fakes/store_user.rs b/crates/domain/src/testing/fakes/store_user.rs index c6092c1b..28c222d6 100644 --- a/crates/domain/src/testing/fakes/store_user.rs +++ b/crates/domain/src/testing/fakes/store_user.rs @@ -78,13 +78,18 @@ impl UserRepository for InMemoryStore { events: &[crate::events::DomainEvent], ) -> Result<(), DomainError> { let id = user_id.value(); - if let Some(user) = self.users.read().unwrap().get(&id) { - self.retired_usernames - .write() - .unwrap() - .insert(user.username().value().to_lowercase()); - self.tombstoned.write().unwrap().insert(id); - } + let users = self.users.read().unwrap(); + let mut gone = self.tombstoned.write().unwrap(); + let Some(user) = users.get(&id).filter(|_| !gone.contains(&id)) else { + return Err(DomainError::NotFound("User not found".into())); + }; + self.retired_usernames + .write() + .unwrap() + .insert(user.username().value().to_lowercase()); + gone.insert(id); + drop(gone); + drop(users); self.refresh_sessions .write() .unwrap() diff --git a/crates/domain/tests/testing/in_memory_store_test.rs b/crates/domain/tests/testing/in_memory_store_test.rs index 833c7e00..3e8ed07f 100644 --- a/crates/domain/tests/testing/in_memory_store_test.rs +++ b/crates/domain/tests/testing/in_memory_store_test.rs @@ -628,3 +628,33 @@ async fn fake_mark_deleted_mirrors_the_sqlite_tombstone() { assert_eq!(store.refresh_session_count(), 0); assert_eq!(store.recorded_events().len(), 1); } + +#[tokio::test] +async fn fake_mark_deleted_refuses_unknown_and_already_deleted_users() { + let store = InMemoryStore::new(); + let user = test_user("ghost"); + UserRepository::save(&*store, &user).await.unwrap(); + let event = domain::events::DomainEvent::UserDeleted { + user_id: user.id().clone(), + }; + + let unknown = store + .mark_deleted(&UserId::generate(), std::slice::from_ref(&event)) + .await; + assert!(matches!(unknown, Err(DomainError::NotFound(_)))); + assert!(store.recorded_events().is_empty()); + + store + .mark_deleted(user.id(), std::slice::from_ref(&event)) + .await + .unwrap(); + let again = store + .mark_deleted(user.id(), std::slice::from_ref(&event)) + .await; + assert!(matches!(again, Err(DomainError::NotFound(_)))); + assert_eq!( + store.recorded_events().len(), + 1, + "second call records nothing" + ); +} diff --git a/docs/adr/0005-single-composition-root.md b/docs/adr/0005-single-composition-root.md index d76f1948..daf79327 100644 --- a/docs/adr/0005-single-composition-root.md +++ b/docs/adr/0005-single-composition-root.md @@ -26,8 +26,7 @@ and each then builds only the deps it can honestly use from it. with no server-reachable consumer — `enrich_movie`, `reindex_search`, `merge_duplicates`, `enrich_person`, `handle_requested` — moved into a new `application::WorkerDeps`, built by `composition::build_worker_deps` and consumed only by `crates/worker`. Every field of `Deps` is - now reachable from `presentation` except `users.delete_account` — see the dead-code exception - recorded below — and every field of `WorkerDeps` is reachable from `worker`. No field needs an + now reachable from `presentation` and every field of `WorkerDeps` is reachable from `worker`. No field needs an `Option` or a Noop port to express "not for this binary" — which container it lives in already says that. - `application::WorkerServices` is the analogous split on the services side: the strict subset of @@ -42,11 +41,6 @@ and each then builds only the deps it can honestly use from it. sixth, `federated_profile`, is already `Option` in `Repositories` for unrelated reasons) have no worker-side adapter. `DatabaseOutput` only carries fields both binaries can genuinely fill, so passing it instead of `Repositories` needed no new `Option` and no fake adapter. -- Exactly one field is unreachable from its own binary, and it is called out by name rather than - folded into a group comment: `Deps.users.delete_account` (`DeleteAccountDeps`) has zero callers - anywhere in the workspace — a pre-existing dead use case, not a consequence of this split. Every - other field that used to be described as "worker-only" or "not reachable" is now reachable from - the binary that reaches it, because it lives in that binary's own container. - Both binaries were booted under the default feature set (`sqlite`, `sqlite-federation`) to prove this: the worker reached its steady polling state without panicking, and the server served both a Bearer-authenticated API call and a cookie-authenticated HTML page. A container wired to the diff --git a/docs/adr/0014-http-adapter-and-one-refusal-shape.md b/docs/adr/0014-http-adapter-and-one-refusal-shape.md index 88928e42..2b45a18e 100644 --- a/docs/adr/0014-http-adapter-and-one-refusal-shape.md +++ b/docs/adr/0014-http-adapter-and-one-refusal-shape.md @@ -16,7 +16,7 @@ rate limiter's own 429 text. The refusals were documented without a body schema. **One body.** `api_types::ErrorResponse { error, code }`. `error` keeps its name and meaning (the SPA and existing clients read it); `code` is an additive, stable machine-readable field: -`NOT_FOUND`, `INVALID_INPUT`, `UNAUTHORIZED`, `FORBIDDEN`, `RATE_LIMITED`, `INTERNAL`. +`NOT_FOUND`, `INVALID_INPUT`, `UNAUTHORIZED`, `FORBIDDEN`, `CONFLICT`, `RATE_LIMITED`, `INTERNAL`. **One constructor.** `errors::refuse(status, code, message) -> Response` is the only place a JSON error body is built. `ApiError` maps every `DomainError` variant to it with the status mapping that -- 2.49.1 From 9a03de89560933ce942d319c6ca89ba939d4e39a Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 02:16:35 +0100 Subject: [PATCH 078/104] mark_deleted: atomic last-admin guard; review minors --- .../adapters/http-axum/src/extractors/mod.rs | 18 +++- .../sqlite/src/repositories/users/mod.rs | 33 +++++-- .../repositories/user_repository_test.rs | 67 ++++++++++++- .../users/request_account_deletion_test.rs | 98 +++++++++++++++++++ crates/domain/src/testing/fakes/store_user.rs | 11 +++ 5 files changed, 212 insertions(+), 15 deletions(-) diff --git a/crates/adapters/http-axum/src/extractors/mod.rs b/crates/adapters/http-axum/src/extractors/mod.rs index 3d01efce..38d4ec45 100644 --- a/crates/adapters/http-axum/src/extractors/mod.rs +++ b/crates/adapters/http-axum/src/extractors/mod.rs @@ -8,13 +8,13 @@ use axum::{ }; use domain::{errors::DomainError, value_objects::UserId}; +use crate::{errors::ApiError, state::AppState}; + async fn ensure_active(state: &AppState, user_id: &UserId) -> Result<(), DomainError> { application::users::ensure_active::execute(&state.app_ctx.deps.users.ensure_active, user_id) .await } -use crate::{errors::ApiError, state::AppState}; - pub struct AuthenticatedUser(pub UserId); impl FromRequestParts for AuthenticatedUser @@ -82,8 +82,16 @@ where .await .ok(); let user_id = match user_id { - Some(id) if ensure_active(&app_state, &id).await.is_ok() => Some(id), - _ => None, + Some(id) => match ensure_active(&app_state, &id).await { + Ok(()) => Some(id), + Err(e) => { + if e.is_transient() { + tracing::warn!("ensure_active failed, treating as anonymous: {e:?}"); + } + None + } + }, + None => None, }; Ok(OptionalCookieUser(user_id)) } @@ -138,6 +146,7 @@ where ) .await .map_err(ApiError)? + // `None` only if the account vanished between `ensure_active` and here. .ok_or_else(|| ApiError(DomainError::NotFound("user not found".into())))?; if is_admin { Ok(AdminApiUser(user_id)) @@ -166,6 +175,7 @@ where ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())? + // `None` only if the account vanished between `ensure_active` and here. .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; if is_admin { Ok(AdminUser(user_id)) diff --git a/crates/adapters/sqlite/src/repositories/users/mod.rs b/crates/adapters/sqlite/src/repositories/users/mod.rs index 3ffd2d10..ef91e249 100644 --- a/crates/adapters/sqlite/src/repositories/users/mod.rs +++ b/crates/adapters/sqlite/src/repositories/users/mod.rs @@ -169,15 +169,32 @@ impl UserRepository for SqliteUserRepository { .begin() .await .map_err(crate::shared::map_sqlx_error)?; - let stamped = - sqlx::query("UPDATE users SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL") - .bind(Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()) - .bind(&id_str) - .execute(&mut *tx) - .await - .map_err(crate::shared::map_sqlx_error)?; + // The last-admin guard lives in this single statement, so two admins + // deleting each other concurrently cannot both pass it: the write lock + // serialises them and the loser sees the winner's tombstone. + let stamped = sqlx::query( + "UPDATE users SET deleted_at = ?1 WHERE id = ?2 AND deleted_at IS NULL + AND (role != 'admin' OR EXISTS ( + SELECT 1 FROM users WHERE role = 'admin' AND deleted_at IS NULL AND id != ?2))", + ) + .bind(Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .bind(&id_str) + .execute(&mut *tx) + .await + .map_err(crate::shared::map_sqlx_error)?; if stamped.rows_affected() == 0 { - return Err(DomainError::NotFound("User not found".into())); + let live: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM users WHERE id = ? AND deleted_at IS NULL", + ) + .bind(&id_str) + .fetch_one(&mut *tx) + .await + .map_err(crate::shared::map_sqlx_error)?; + return Err(if live == 0 { + DomainError::NotFound("User not found".into()) + } else { + DomainError::Conflict("the last active admin cannot be deleted".into()) + }); } sqlx::query( "INSERT OR IGNORE INTO retired_usernames (username) SELECT username FROM users WHERE id = ?", diff --git a/crates/adapters/sqlite/tests/repositories/user_repository_test.rs b/crates/adapters/sqlite/tests/repositories/user_repository_test.rs index 5c86355e..9798efc9 100644 --- a/crates/adapters/sqlite/tests/repositories/user_repository_test.rs +++ b/crates/adapters/sqlite/tests/repositories/user_repository_test.rs @@ -114,13 +114,19 @@ async fn add_session(pool: &SqlitePool, user_id: &UserId, token: &str) { #[tokio::test] async fn marking_a_user_deleted_hides_them_from_every_lookup() { - let (_, repo) = setup().await; + let (pool, repo) = setup().await; let admin = domain::testing::test_user_with_role(UserId::generate(), domain::models::UserRole::Admin); repo.save(&admin).await.unwrap(); let other = test_user("other"); repo.save(&other).await.unwrap(); - assert_eq!(repo.count_active_admins().await.unwrap(), 1); + // A second admin, so the first is not the last one standing. + sqlx::query("UPDATE users SET role = 'admin' WHERE id = ?") + .bind(other.id().value().to_string()) + .execute(&pool) + .await + .unwrap(); + assert_eq!(repo.count_active_admins().await.unwrap(), 2); repo.mark_deleted(admin.id(), &[deleted_event(&admin)]) .await @@ -138,7 +144,7 @@ async fn marking_a_user_deleted_hides_them_from_every_lookup() { let emails: Vec<_> = listed.iter().map(|u| u.email()).collect(); assert!(emails.contains(&other.email().value())); assert!(!emails.contains(&admin.email().value())); - assert_eq!(repo.count_active_admins().await.unwrap(), 0); + assert_eq!(repo.count_active_admins().await.unwrap(), 1); // The purge can still read the tombstone; a live user is not one. assert!( repo.find_tombstoned_by_id(admin.id()) @@ -214,3 +220,58 @@ async fn retired_names_match_case_insensitively() { assert!(repo.is_username_retired(&lower).await.unwrap()); assert!(repo.is_username_retired(&upper).await.unwrap()); } + +#[tokio::test] +async fn marking_the_last_admin_deleted_is_refused_atomically() { + let dir = tempfile::tempdir().unwrap(); + let opts = sqlx::sqlite::SqliteConnectOptions::new() + .filename(dir.path().join("u.db")) + .create_if_missing(true) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) + .busy_timeout(std::time::Duration::from_secs(5)); + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(4) + .connect_with(opts) + .await + .unwrap(); + sqlite::migrate(&pool).await.unwrap(); + let repo = std::sync::Arc::new(SqliteUserRepository::new(pool.clone())); + let a = test_user("admin_a"); + let b = test_user("admin_b"); + repo.save(&a).await.unwrap(); + repo.save(&b).await.unwrap(); + sqlx::query("UPDATE users SET role = 'admin' WHERE id IN (?, ?)") + .bind(a.id().value().to_string()) + .bind(b.id().value().to_string()) + .execute(&pool) + .await + .unwrap(); + assert_eq!(repo.count_active_admins().await.unwrap(), 2); + + let (ra, rb) = (repo.clone(), repo.clone()); + let (ida, idb) = (a.id().clone(), b.id().clone()); + let (ea, eb) = (deleted_event(&a), deleted_event(&b)); + let (x, y) = tokio::join!( + tokio::spawn(async move { ra.mark_deleted(&ida, &[ea]).await }), + tokio::spawn(async move { rb.mark_deleted(&idb, &[eb]).await }), + ); + let results = [x.unwrap(), y.unwrap()]; + + assert_eq!( + results.iter().filter(|r| r.is_ok()).count(), + 1, + "{results:?}" + ); + assert!( + results + .iter() + .any(|r| matches!(r, Err(domain::errors::DomainError::Conflict(_)))), + "{results:?}" + ); + assert_eq!(repo.count_active_admins().await.unwrap(), 1); + assert_eq!( + count(&pool, "event_queue").await, + 1, + "loser enqueued nothing" + ); +} diff --git a/crates/application/tests/users/request_account_deletion_test.rs b/crates/application/tests/users/request_account_deletion_test.rs index 3bb78f94..456971fc 100644 --- a/crates/application/tests/users/request_account_deletion_test.rs +++ b/crates/application/tests/users/request_account_deletion_test.rs @@ -16,6 +16,54 @@ use application::{ use crate::support::{TestContextBuilder, register_command}; +use async_trait::async_trait; +use domain::models::{User, UserSummary}; +use domain::ports::UserRepository as UserRepositoryPort; +use domain::value_objects::Username; + +/// Delegates to a store but reports a stale admin count, as a concurrent +/// deletion would leave the use case's pre-check. +struct StaleAdminCount(Arc); + +#[async_trait] +impl UserRepositoryPort for StaleAdminCount { + async fn find_by_email(&self, e: &Email) -> Result, DomainError> { + self.0.find_by_email(e).await + } + async fn find_by_username(&self, u: &Username) -> Result, DomainError> { + self.0.find_by_username(u).await + } + async fn save(&self, u: &User) -> Result<(), DomainError> { + UserRepositoryPort::save(&*self.0, u).await + } + async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + self.0.find_by_id(id).await + } + async fn list_with_stats(&self) -> Result, DomainError> { + self.0.list_with_stats().await + } + async fn update_profile( + &self, + id: &UserId, + p: &domain::models::UserProfile, + ev: &[DomainEvent], + ) -> Result<(), DomainError> { + self.0.update_profile(id, p, ev).await + } + async fn mark_deleted(&self, id: &UserId, ev: &[DomainEvent]) -> Result<(), DomainError> { + self.0.mark_deleted(id, ev).await + } + async fn find_tombstoned_by_id(&self, id: &UserId) -> Result, DomainError> { + self.0.find_tombstoned_by_id(id).await + } + async fn is_username_retired(&self, u: &Username) -> Result { + self.0.is_username_retired(u).await + } + async fn count_active_admins(&self) -> Result { + Ok(2) + } +} + const PASSWORD: &str = "password123"; struct Fixture { @@ -183,3 +231,53 @@ async fn an_already_deleted_target_is_not_found() { assert!(matches!(err, DomainError::NotFound(_)), "got {err:?}"); } + +#[tokio::test] +async fn a_conflict_from_the_repository_is_propagated() { + let f = fixture(); + let admin = f.user("root", UserRole::Admin).await; + let deps = RequestAccountDeletionDeps { + user: Arc::new(StaleAdminCount(Arc::clone(&f.store))), + password_hasher: f.deps.password_hasher.clone(), + }; + + let err = request_account_deletion::execute(&deps, admin.clone(), admin.clone(), PASSWORD) + .await + .unwrap_err(); + + assert!(matches!(err, DomainError::Conflict(_)), "got {err:?}"); + assert!(f.is_live(&admin).await); +} + +#[tokio::test] +async fn an_unknown_or_deleted_actor_is_unauthorized() { + let f = fixture(); + let admin = f.user("root", UserRole::Admin).await; + let bob = f.user("bob", UserRole::Standard).await; + + let err = request_account_deletion::execute(&f.deps, UserId::generate(), bob.clone(), PASSWORD) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}"); + + request_account_deletion::execute(&f.deps, admin.clone(), bob.clone(), PASSWORD) + .await + .unwrap(); + let err = request_account_deletion::execute(&f.deps, bob.clone(), bob, PASSWORD) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}"); +} + +#[tokio::test] +async fn a_non_admin_gets_forbidden_before_any_password_check() { + let f = fixture(); + let alice = f.user("alice", UserRole::Standard).await; + let bob = f.user("bob", UserRole::Standard).await; + + let err = request_account_deletion::execute(&f.deps, alice, bob, "wrong") + .await + .unwrap_err(); + + assert!(matches!(err, DomainError::Forbidden(_)), "got {err:?}"); +} diff --git a/crates/domain/src/testing/fakes/store_user.rs b/crates/domain/src/testing/fakes/store_user.rs index 28c222d6..4e59b0c4 100644 --- a/crates/domain/src/testing/fakes/store_user.rs +++ b/crates/domain/src/testing/fakes/store_user.rs @@ -83,6 +83,17 @@ impl UserRepository for InMemoryStore { let Some(user) = users.get(&id).filter(|_| !gone.contains(&id)) else { return Err(DomainError::NotFound("User not found".into())); }; + if matches!(user.role(), crate::models::UserRole::Admin) + && !users.values().any(|u| { + matches!(u.role(), crate::models::UserRole::Admin) + && u.id().value() != id + && !gone.contains(&u.id().value()) + }) + { + return Err(DomainError::Conflict( + "the last active admin cannot be deleted".into(), + )); + } self.retired_usernames .write() .unwrap() -- 2.49.1 From 51a70b526065087c14cb7d2408bed7c20e859e61 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 02:30:09 +0100 Subject: [PATCH 079/104] feat: account purge handler, tombstone cleanup, AP tombstone signing + 410 --- .../activitypub/src/deletion_notifier.rs | 38 ++ .../adapters/activitypub/src/event_handler.rs | 7 - .../activitypub/src/federation_ports.rs | 15 +- crates/adapters/activitypub/src/lib.rs | 19 +- .../activitypub/src/tombstone_gate.rs | 36 ++ .../adapters/activitypub/src/user_adapter.rs | 9 +- .../activitypub/tests/user_adapter.rs | 59 ++ .../http-axum/src/federation/collections.rs | 18 + .../http-axum/src/federation/negotiation.rs | 9 + .../migrations/0102_account_deletion.sql | 3 + crates/adapters/sqlite/src/lib.rs | 7 +- .../src/repositories/account_purge/mod.rs | 181 ++++++ .../adapters/sqlite/src/repositories/mod.rs | 4 + .../sqlite/tests/account_purge_test.rs | 605 ++++++++++++++++++ .../tests/fixtures/migration_ledger.txt | 2 +- crates/application/src/jobs/mod.rs | 2 + .../application/src/jobs/tombstone_cleanup.rs | 40 ++ crates/application/src/users/mod.rs | 1 + crates/application/src/users/purge_account.rs | 77 +++ crates/application/tests/jobs.rs | 2 + .../tests/jobs/tombstone_cleanup_test.rs | 59 ++ crates/application/tests/users.rs | 2 + .../tests/users/purge_account_test.rs | 253 ++++++++ crates/bootstrap/src/database.rs | 5 + crates/bootstrap/src/federation.rs | 25 +- crates/bootstrap/src/worker.rs | 17 +- crates/bootstrap/tests/account_purge_test.rs | 130 ++++ crates/bootstrap/tests/bootstrap_test.rs | 47 ++ crates/domain/src/ports/account_purge.rs | 44 ++ crates/domain/src/ports/federation.rs | 5 + crates/domain/src/ports/mod.rs | 2 + crates/domain/src/ports/noop.rs | 12 + 32 files changed, 1708 insertions(+), 27 deletions(-) create mode 100644 crates/adapters/activitypub/src/deletion_notifier.rs create mode 100644 crates/adapters/activitypub/src/tombstone_gate.rs create mode 100644 crates/adapters/activitypub/tests/user_adapter.rs create mode 100644 crates/adapters/sqlite/src/repositories/account_purge/mod.rs create mode 100644 crates/adapters/sqlite/tests/account_purge_test.rs create mode 100644 crates/application/src/jobs/tombstone_cleanup.rs create mode 100644 crates/application/src/users/purge_account.rs create mode 100644 crates/application/tests/jobs/tombstone_cleanup_test.rs create mode 100644 crates/application/tests/users/purge_account_test.rs create mode 100644 crates/bootstrap/tests/account_purge_test.rs create mode 100644 crates/domain/src/ports/account_purge.rs diff --git a/crates/adapters/activitypub/src/deletion_notifier.rs b/crates/adapters/activitypub/src/deletion_notifier.rs new file mode 100644 index 00000000..dcf368f9 --- /dev/null +++ b/crates/adapters/activitypub/src/deletion_notifier.rs @@ -0,0 +1,38 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::FederationDeletionNotifier, + value_objects::{InstanceIdentity, UserId}, +}; +use k_ap::ActivityPubService; + +use crate::urls::actor_url; + +/// Broadcasts the actor `Delete` to the user's accepted followers. +/// +/// k-ap only enqueues the per-inbox deliveries; they are signed later from the +/// tombstoned user row and keypair, which the purge keeps until the tombstone +/// cleanup (see `AccountPurger`). +pub struct ApDeletionNotifier { + service: Arc, + instance: InstanceIdentity, +} + +impl ApDeletionNotifier { + pub fn new(service: Arc, instance: InstanceIdentity) -> Self { + Self { service, instance } + } +} + +#[async_trait] +impl FederationDeletionNotifier for ApDeletionNotifier { + async fn notify_deleted(&self, user: &UserId) -> Result<(), DomainError> { + let ap_id = actor_url(&self.instance, user.value()); + self.service + .broadcast_delete_to_followers(user.value(), ap_id) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string())) + } +} diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index 7ee4e60f..4ac4a962 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -143,13 +143,6 @@ impl EventHandler for ActivityPubEventHandler { .on_goal_deleted(user_id, *year) .await .map_err(|e| DomainError::InfrastructureError(e.to_string())), - DomainEvent::UserDeleted { user_id } => { - let ap_id = actor_url(&self.instance, user_id.value()); - self.ap_service - .broadcast_delete_to_followers(user_id.value(), ap_id) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string())) - } DomainEvent::UserAccountMoved { user_id, new_actor_url, diff --git a/crates/adapters/activitypub/src/federation_ports.rs b/crates/adapters/activitypub/src/federation_ports.rs index 617a498a..f9ab121c 100644 --- a/crates/adapters/activitypub/src/federation_ports.rs +++ b/crates/adapters/activitypub/src/federation_ports.rs @@ -4,18 +4,20 @@ use async_trait::async_trait; use domain::{ errors::DomainError, models::{BlockedDomainInfo, FollowedActorInfo}, - ports::{ApBackfillPort, ApDocumentPort, InstanceBlocklistPort}, + ports::{ApBackfillPort, ApDocumentPort, InstanceBlocklistPort, UserRepository}, + value_objects::UserId, }; use k_ap::ActivityPubService; use uuid::Uuid; pub struct ApServiceAdapter { service: Arc, + users: Arc, } impl ApServiceAdapter { - pub fn new(service: Arc) -> Self { - Self { service } + pub fn new(service: Arc, users: Arc) -> Self { + Self { service, users } } } @@ -26,6 +28,13 @@ fn ap_err(e: anyhow::Error) -> DomainError { #[async_trait] impl ApDocumentPort for ApServiceAdapter { + async fn actor_is_gone(&self, user_id: Uuid) -> Result { + Ok(self + .users + .find_tombstoned_by_id(&UserId::from_uuid(user_id)) + .await? + .is_some()) + } async fn actor_json(&self, user_id: &str) -> Result { self.service.actor_json(user_id).await.map_err(ap_err) } diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index 33576e4c..62b834a3 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -1,4 +1,5 @@ pub mod composite_handler; +pub mod deletion_notifier; pub mod event_handler; pub mod federation_event_bridge; pub mod federation_ports; @@ -7,6 +8,7 @@ pub mod objects; pub mod remote_review_repository; pub mod review_handler; pub mod social_adapter; +pub mod tombstone_gate; pub(crate) mod urls; pub mod user_adapter; pub mod watchlist_handler; @@ -20,6 +22,7 @@ pub use k_ap::{ FollowRepository, Follower, FollowerStatus, FollowingStatus, LocalObject, RemoteActor, }; +pub use deletion_notifier::ApDeletionNotifier; pub use event_handler::ActivityPubEventHandler; pub use federation_ports::ApServiceAdapter; pub use remote_review_repository::{NewRemoteReview, RemoteReviewRepository, RemoteReviewUpdate}; @@ -46,6 +49,7 @@ pub struct ActivityPubWire { pub service: std::sync::Arc, pub router: axum::Router, pub event_handler: std::sync::Arc, + pub deletion_notifier: std::sync::Arc, } pub struct ActivityPubDeps { @@ -139,7 +143,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { .actor_repo(actor_repo) .blocklist_repo(blocklist_repo) .user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new( - user_repo, + std::sync::Arc::clone(&user_repo), instance.clone(), ))) .signed_fetch_actor_id(INSTANCE_ACTOR_ID) @@ -157,7 +161,16 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { .await?, ); - let router = concrete.router(); + let router = concrete + .router() + .layer(axum::middleware::from_fn_with_state( + std::sync::Arc::clone(&user_repo), + tombstone_gate::tombstone_gate, + )); + let deletion_notifier = std::sync::Arc::new(ApDeletionNotifier::new( + std::sync::Arc::clone(&concrete), + instance.clone(), + )) as std::sync::Arc; let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new( std::sync::Arc::clone(&concrete), local_ap_content, @@ -171,6 +184,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { let ports = std::sync::Arc::new(federation_ports::ApServiceAdapter::new( std::sync::Arc::clone(&concrete), + std::sync::Arc::clone(&user_repo), )); Ok(ActivityPubWire { @@ -182,5 +196,6 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { service: concrete, router, event_handler, + deletion_notifier, }) } diff --git a/crates/adapters/activitypub/src/tombstone_gate.rs b/crates/adapters/activitypub/src/tombstone_gate.rs new file mode 100644 index 00000000..83701a93 --- /dev/null +++ b/crates/adapters/activitypub/src/tombstone_gate.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::{IntoResponse, Response}, +}; +use domain::{ports::UserRepository, value_objects::UserId}; + +/// Answers `410 Gone` for the per-user ActivityPub endpoints of a deleted +/// account. k-ap resolves tombstones so it can sign the account's `Delete` +/// deliveries; this keeps that from making the actor look alive. +pub async fn tombstone_gate( + State(users): State>, + request: Request, + next: Next, +) -> Response { + if let Some(id) = gated_user(request.uri().path()) + && matches!( + users.find_tombstoned_by_id(&UserId::from_uuid(id)).await, + Ok(Some(_)) + ) + { + return StatusCode::GONE.into_response(); + } + next.run(request).await +} + +/// The user id in `/users/{id}/outbox|featured|inbox`. +pub fn gated_user(path: &str) -> Option { + let rest = path.strip_prefix("/users/")?; + let (id, endpoint) = rest.split_once('/')?; + matches!(endpoint, "outbox" | "featured" | "inbox").then_some(())?; + uuid::Uuid::parse_str(id).ok() +} diff --git a/crates/adapters/activitypub/src/user_adapter.rs b/crates/adapters/activitypub/src/user_adapter.rs index 86ea3300..ff5ec6f1 100644 --- a/crates/adapters/activitypub/src/user_adapter.rs +++ b/crates/adapters/activitypub/src/user_adapter.rs @@ -64,9 +64,16 @@ impl DomainUserRepoAdapter { impl ApUserRepository for DomainUserRepoAdapter { async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result> { let user_id = UserId::from_uuid(id); + // A deleted account still resolves here, so k-ap can sign the queued + // `Delete` deliveries until the tombstone cleanup. Username lookups + // below stay live-only, and the AP endpoints answer 410 for it + // (`tombstone_gate`, `actor_document_response`). let user = match self.repo.find_by_id(&user_id).await? { Some(u) => u, - None => return Ok(None), + None => match self.repo.find_tombstoned_by_id(&user_id).await? { + Some(u) => u, + None => return Ok(None), + }, }; Ok(Some(self.build_user(&user))) } diff --git a/crates/adapters/activitypub/tests/user_adapter.rs b/crates/adapters/activitypub/tests/user_adapter.rs new file mode 100644 index 00000000..03787492 --- /dev/null +++ b/crates/adapters/activitypub/tests/user_adapter.rs @@ -0,0 +1,59 @@ +use domain::{ + ports::UserRepository, + testing::{InMemoryStore, test_user}, + value_objects::InstanceIdentity, +}; +use k_ap::ApUserRepository; + +use activitypub::DomainUserRepoAdapter; + +async fn adapter_with_deleted_alice() -> (DomainUserRepoAdapter, uuid::Uuid) { + let store = InMemoryStore::new(); + let alice = test_user("alice"); + store.save(&alice).await.unwrap(); + store.mark_deleted(alice.id(), &[]).await.unwrap(); + let adapter = DomainUserRepoAdapter::new(store, InstanceIdentity::new("https://md.example")); + (adapter, alice.id().value()) +} + +#[tokio::test] +async fn tombstoned_actor_can_still_sign() { + let (adapter, id) = adapter_with_deleted_alice().await; + + let user = adapter.find_by_id(id).await.unwrap(); + + assert_eq!(user.expect("k-ap resolves the tombstone").username, "alice"); +} + +#[tokio::test] +async fn a_tombstone_is_not_found_by_username() { + let (adapter, _) = adapter_with_deleted_alice().await; + + assert!(adapter.find_by_username("alice").await.unwrap().is_none()); +} + +#[tokio::test] +async fn an_unknown_user_is_none() { + let (adapter, _) = adapter_with_deleted_alice().await; + + assert!( + adapter + .find_by_id(uuid::Uuid::new_v4()) + .await + .unwrap() + .is_none() + ); +} + +#[test] +fn only_the_per_user_endpoints_are_gated() { + use activitypub::tombstone_gate::gated_user; + let id = uuid::Uuid::new_v4(); + for endpoint in ["outbox", "featured", "inbox"] { + assert_eq!(gated_user(&format!("/users/{id}/{endpoint}")), Some(id)); + } + assert_eq!(gated_user(&format!("/users/{id}")), None); + assert_eq!(gated_user(&format!("/users/{id}/followers")), None); + assert_eq!(gated_user("/inbox"), None); + assert_eq!(gated_user("/users/not-a-uuid/outbox"), None); +} diff --git a/crates/adapters/http-axum/src/federation/collections.rs b/crates/adapters/http-axum/src/federation/collections.rs index 2f4cca72..65a2d2f2 100644 --- a/crates/adapters/http-axum/src/federation/collections.rs +++ b/crates/adapters/http-axum/src/federation/collections.rs @@ -17,6 +17,15 @@ pub async fn get_followers_collection( .and_then(|v| v.to_str().ok()) .unwrap_or(""); if accept.contains("application/activity+json") || accept.contains("application/ld+json") { + if state + .app_ctx + .ap_document + .actor_is_gone(user_id) + .await + .unwrap_or(false) + { + return StatusCode::GONE.into_response(); + } let page = params.get("page").and_then(|p| p.parse::().ok()); return match state .app_ctx @@ -52,6 +61,15 @@ pub async fn get_following_collection( .and_then(|v| v.to_str().ok()) .unwrap_or(""); if accept.contains("application/activity+json") || accept.contains("application/ld+json") { + if state + .app_ctx + .ap_document + .actor_is_gone(user_id) + .await + .unwrap_or(false) + { + return StatusCode::GONE.into_response(); + } let page = params.get("page").and_then(|p| p.parse::().ok()); return match state .app_ctx diff --git a/crates/adapters/http-axum/src/federation/negotiation.rs b/crates/adapters/http-axum/src/federation/negotiation.rs index 3c3a9382..831b9775 100644 --- a/crates/adapters/http-axum/src/federation/negotiation.rs +++ b/crates/adapters/http-axum/src/federation/negotiation.rs @@ -20,6 +20,15 @@ pub async fn actor_document_response( if !(accept.contains("application/activity+json") || accept.contains("application/ld+json")) { return None; } + if state + .app_ctx + .ap_document + .actor_is_gone(user_id) + .await + .unwrap_or(false) + { + return Some(StatusCode::GONE.into_response()); + } Some( match state .app_ctx diff --git a/crates/adapters/sqlite/migrations/0102_account_deletion.sql b/crates/adapters/sqlite/migrations/0102_account_deletion.sql index 7918be5c..edc2116b 100644 --- a/crates/adapters/sqlite/migrations/0102_account_deletion.sql +++ b/crates/adapters/sqlite/migrations/0102_account_deletion.sql @@ -1,4 +1,7 @@ ALTER TABLE users ADD COLUMN deleted_at TEXT; +-- Set once the account's data is purged; the stripped row (and the signing +-- keypair) stays until the tombstone cleanup removes it. +ALTER TABLE users ADD COLUMN purged_at TEXT; CREATE TABLE retired_usernames ( username TEXT PRIMARY KEY COLLATE NOCASE, diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 0acc52e4..82bfac2e 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -8,15 +8,16 @@ mod shared; pub use event_queue::{DbEventQueueConfig, SqliteEventQueue}; use repositories::SqliteUserSettingsRepository; pub use repositories::{ - SqliteApContentQuery, SqliteDiaryRepository, SqliteGoalRepository, SqliteImageRefAdapter, + PURGED_USER_TABLES, RETAINED_USER_TABLES, SqliteAccountPurger, SqliteApContentQuery, + SqliteDiaryRepository, SqliteGoalRepository, SqliteImageRefAdapter, SqliteImportProfileRepository, SqliteImportSessionRepository, SqliteInsightsQuery, SqliteMovieDeduplicator, SqliteMovieProfileRepository, SqliteMovieRepository, SqlitePersonAdapter, SqliteProcessedEventLedger, SqliteProfileFieldsRepository, SqliteRefreshSessionAdapter, SqliteRemoteGoalRepository, SqliteReviewRepository, SqliteSocialRepository, SqliteStatsRepository, SqliteUserRepository, SqliteWatchEventRepository, SqliteWatchlistRepository, SqliteWebhookTokenRepository, - SqliteWrapUpRepository, SqliteWrapUpStatsQuery, create_federated_profile_query, - create_image_ref, create_person_adapter, create_search_adapter, + SqliteWrapUpRepository, SqliteWrapUpStatsQuery, URL_KEYED_USER_TABLES, + create_federated_profile_query, create_image_ref, create_person_adapter, create_search_adapter, }; /// Row helpers shared with the `sqlite-federation` crate (which cannot live diff --git a/crates/adapters/sqlite/src/repositories/account_purge/mod.rs b/crates/adapters/sqlite/src/repositories/account_purge/mod.rs new file mode 100644 index 00000000..ff48f97a --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/account_purge/mod.rs @@ -0,0 +1,181 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, ports::AccountPurger, value_objects::InstanceIdentity, + value_objects::UserId, +}; +use sqlx::SqlitePool; + +use crate::shared::map_sqlx_error; + +const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ"; + +/// Every table the purge empties for the account, with the column that holds +/// the user id. `reviews` is further restricted to the user's own (non-remote) +/// rows. A guard test compares this list with the schema, so a new +/// user-owned table cannot be forgotten. +pub const PURGED_USER_TABLES: &[(&str, &str)] = &[ + ("reviews", "user_id"), + ("watchlist_entries", "user_id"), + ("goals", "user_id"), + ("user_settings", "user_id"), + ("user_profile_fields", "user_id"), + ("webhook_tokens", "user_id"), + ("watch_events", "user_id"), + ("wrap_up_records", "user_id"), + ("import_sessions", "user_id"), + ("import_profiles", "user_id"), + ("refresh_sessions", "user_id"), + ("ap_followers", "local_user_id"), + ("ap_following", "local_user_id"), + ("blocked_actors", "local_user_id"), +]; + +/// Tables that reference a user but are kept until the tombstone cleanup: +/// the keypair is needed to sign queued federation deliveries. +pub const RETAINED_USER_TABLES: &[&str] = &["ap_local_actors"]; + +/// Tables with no user column whose rows hang off the user's content or actor +/// URL; the purge clears them by URL. +pub const URL_KEYED_USER_TABLES: &[&str] = &["ap_announces", "ap_followers", "ap_following"]; + +pub struct SqliteAccountPurger { + pool: SqlitePool, + instance: InstanceIdentity, +} + +impl SqliteAccountPurger { + pub fn new(pool: SqlitePool, instance: InstanceIdentity) -> Self { + Self { pool, instance } + } +} + +#[async_trait] +impl AccountPurger for SqliteAccountPurger { + async fn is_purged(&self, user: &UserId) -> Result { + sqlx::query_scalar::<_, i64>( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = ? AND purged_at IS NOT NULL)", + ) + .bind(user.value().to_string()) + .fetch_one(&self.pool) + .await + .map(|n| n != 0) + .map_err(map_sqlx_error) + } + + async fn owned_image_keys(&self, user: &UserId) -> Result, DomainError> { + let row: Option<(Option, Option)> = sqlx::query_as( + "SELECT avatar_path, banner_path FROM users WHERE id = ? AND deleted_at IS NOT NULL", + ) + .bind(user.value().to_string()) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row + .map(|(avatar, banner)| avatar.into_iter().chain(banner).collect()) + .unwrap_or_default()) + } + + async fn purge(&self, user: &UserId) -> Result<(), DomainError> { + let id = user.value().to_string(); + let actor_url = self.instance.actor_url_for(user); + let base = self.instance.base_url().to_string(); + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + + // Strip first: it claims the purge. Only a tombstoned, not yet purged + // account matches, so a live account (or a re-run) changes nothing. + let claimed = sqlx::query( + "UPDATE users SET email = 'deleted-' || id || '@deleted.invalid', + password_hash = '!purged', display_name = NULL, bio = NULL, + also_known_as = NULL, purged_at = ? + WHERE id = ? AND deleted_at IS NOT NULL AND purged_at IS NULL", + ) + .bind(chrono::Utc::now().format(TIMESTAMP_FORMAT).to_string()) + .bind(&id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)? + .rows_affected(); + if claimed == 0 { + return Ok(()); + } + + // Announces of the user's content, matched before the reviews go. + sqlx::query( + "DELETE FROM ap_announces + WHERE object_url IN ( + SELECT ?1 || '/reviews/' || id FROM reviews + WHERE user_id = ?2 AND remote_actor_url IS NULL) + OR substr(object_url, 1, length(?3)) = ?3 + OR actor_url = ?4", + ) + .bind(&base) + .bind(&id) + .bind(format!("{base}/users/{id}/")) + .bind(&actor_url) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + // Other accounts' follows of this one. + for table in ["ap_followers", "ap_following"] { + sqlx::query(&format!("DELETE FROM {table} WHERE remote_actor_url = ?")) + .bind(&actor_url) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + } + + for (table, column) in PURGED_USER_TABLES { + let extra = if *table == "reviews" { + " AND remote_actor_url IS NULL" + } else { + "" + }; + sqlx::query(&format!("DELETE FROM {table} WHERE {column} = ?{extra}")) + .bind(&id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + } + tx.commit().await.map_err(map_sqlx_error) + } + + async fn forget_images(&self, user: &UserId) -> Result<(), DomainError> { + sqlx::query( + "UPDATE users SET avatar_path = NULL, banner_path = NULL + WHERE id = ? AND purged_at IS NOT NULL", + ) + .bind(user.value().to_string()) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(map_sqlx_error) + } + + async fn purge_expired_tombstones( + &self, + cutoff: chrono::NaiveDateTime, + ) -> Result { + let cutoff = cutoff.format(TIMESTAMP_FORMAT).to_string(); + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + for table in RETAINED_USER_TABLES { + sqlx::query(&format!( + "DELETE FROM {table} WHERE user_id IN ( + SELECT id FROM users WHERE purged_at IS NOT NULL AND purged_at < ?)" + )) + .bind(&cutoff) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + } + let removed = + sqlx::query("DELETE FROM users WHERE purged_at IS NOT NULL AND purged_at < ?") + .bind(&cutoff) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)? + .rows_affected(); + tx.commit().await.map_err(map_sqlx_error)?; + Ok(removed) + } +} diff --git a/crates/adapters/sqlite/src/repositories/mod.rs b/crates/adapters/sqlite/src/repositories/mod.rs index ac467d51..5d67dadf 100644 --- a/crates/adapters/sqlite/src/repositories/mod.rs +++ b/crates/adapters/sqlite/src/repositories/mod.rs @@ -1,3 +1,4 @@ +mod account_purge; mod ap_content; mod diary; mod goals; @@ -23,6 +24,9 @@ mod watch_event; mod watchlist; mod wrapup; +pub use account_purge::{ + PURGED_USER_TABLES, RETAINED_USER_TABLES, SqliteAccountPurger, URL_KEYED_USER_TABLES, +}; pub use ap_content::SqliteApContentQuery; pub use diary::SqliteDiaryRepository; pub use goals::SqliteGoalRepository; diff --git a/crates/adapters/sqlite/tests/account_purge_test.rs b/crates/adapters/sqlite/tests/account_purge_test.rs new file mode 100644 index 00000000..df0b72d9 --- /dev/null +++ b/crates/adapters/sqlite/tests/account_purge_test.rs @@ -0,0 +1,605 @@ +//! The purge removes every row a deleted account owns, strips the user row to +//! a data-free tombstone, and the cleanup later drops the tombstone itself. + +use std::collections::BTreeSet; +use std::str::FromStr; + +use chrono::Utc; +use domain::models::{ProfileField, UserProfile, UserSettings, WatchEvent, WatchEventSource}; +use domain::models::{WatchlistEntry, WebhookToken}; +use domain::ports::{ + AccountPurger, FollowCommand, FollowWrite, UserProfileFieldsRepository, WatchEventCommand, + WebhookTokenRepository, +}; +use domain::testing::{test_goal, test_movie, test_review, test_user, test_watched_at}; +use domain::value_objects::{FollowStatus, InstanceIdentity, UserId}; +use sqlite::{ + PURGED_USER_TABLES, RETAINED_USER_TABLES, SqliteAccountPurger, SqliteProfileFieldsRepository, + SqliteSocialRepository, SqliteWatchEventRepository, SqliteWebhookTokenRepository, + URL_KEYED_USER_TABLES, +}; +use sqlx::SqlitePool; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + +const BASE: &str = "https://md.example"; +const REMOTE_ACTOR: &str = "https://remote.example/users/ann"; + +async fn migrated_pool() -> SqlitePool { + let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(opts) + .await + .unwrap(); + sqlite::migrate(&pool).await.unwrap(); + pool +} + +fn instance() -> InstanceIdentity { + InstanceIdentity::new(BASE) +} + +fn purger(pool: &SqlitePool) -> SqliteAccountPurger { + SqliteAccountPurger::new(pool.clone(), instance()) +} + +async fn count(pool: &SqlitePool, sql: &str) -> i64 { + sqlx::query_scalar(sql).fetch_one(pool).await.unwrap() +} + +async fn count_for(pool: &SqlitePool, table: &str, column: &str, user: &UserId) -> i64 { + sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table} WHERE {column} = ?")) + .bind(user.value().to_string()) + .fetch_one(pool) + .await + .unwrap() +} + +async fn exec(pool: &SqlitePool, sql: &str, binds: &[&str]) { + let mut q = sqlx::query(sql); + for b in binds { + q = q.bind(*b); + } + q.execute(pool).await.unwrap(); +} + +struct Seeded { + user: UserId, + other: UserId, + movie_id: String, +} + +/// Alice owns a row in every table; bob has a little of everything, including +/// a follow of alice and an announce of his own review, which must survive. +async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded { + let alice = test_user("alice"); + let bob = test_user("bob"); + let a = alice.id().clone(); + let b = bob.id().clone(); + let a_str = a.value().to_string(); + let b_str = b.value().to_string(); + parts.user.save(&alice).await.unwrap(); + parts.user.save(&bob).await.unwrap(); + let movie = test_movie("Heat"); + parts.movie_command.upsert_movie(&movie, &[]).await.unwrap(); + + for u in [&a, &b] { + parts + .watchlist + .add(&WatchlistEntry::new(u.clone(), movie.id().clone()), &[]) + .await + .unwrap(); + parts + .goal_command + .save(&test_goal(u, 2024, 50), &[]) + .await + .unwrap(); + parts + .user_settings + .save(&UserSettings::new(u.clone())) + .await + .unwrap(); + SqliteProfileFieldsRepository::new(pool.clone()) + .set_fields( + u, + vec![ProfileField { + name: "Site".into(), + value: "https://example.com".into(), + }], + &[], + ) + .await + .unwrap(); + SqliteWebhookTokenRepository::new(pool.clone()) + .save(&WebhookToken::new( + u.clone(), + format!("hash-{}", u.value()), + WatchEventSource::Jellyfin, + None, + )) + .await + .unwrap(); + SqliteWatchEventRepository::new(pool.clone()) + .save( + &WatchEvent::new( + u.clone(), + "Heat".into(), + Some(1995), + None, + WatchEventSource::Jellyfin, + test_watched_at(), + None, + ), + &[], + ) + .await + .unwrap(); + parts + .review + .save_review(&test_review(movie.id(), u, 4), &[]) + .await + .unwrap(); + exec( + pool, + "INSERT INTO import_sessions (id, user_id, parsed_data, created_at, expires_at) VALUES (?, ?, '{}', 'now', 'later')", + &[&uuid::Uuid::new_v4().to_string(), &u.value().to_string()], + ) + .await; + exec( + pool, + "INSERT INTO import_profiles (id, user_id, name, field_mappings, created_at) VALUES (?, ?, 'p', '{}', 'now')", + &[&uuid::Uuid::new_v4().to_string(), &u.value().to_string()], + ) + .await; + exec( + pool, + "INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, 'later', 'now')", + &[ + &uuid::Uuid::new_v4().to_string(), + &u.value().to_string(), + &uuid::Uuid::new_v4().to_string(), + ], + ) + .await; + exec( + pool, + "INSERT INTO wrap_up_records (id, user_id, start_date, end_date) VALUES (?, ?, 'a', 'b')", + &[&uuid::Uuid::new_v4().to_string(), &u.value().to_string()], + ) + .await; + exec( + pool, + "INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES (?, ?, 'now')", + &[&u.value().to_string(), REMOTE_ACTOR], + ) + .await; + exec( + pool, + "INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) VALUES (?, 'pub', 'priv', 'now')", + &[&u.value().to_string()], + ) + .await; + } + let social = SqliteSocialRepository::new(pool.clone(), instance()); + let alice_actor = instance().actor_url_for(&a); + social + .apply( + &[ + FollowWrite::AddFollow { + follower_id: a.value(), + target_actor_url: REMOTE_ACTOR.into(), + status: FollowStatus::Accepted, + }, + FollowWrite::AddFollower { + local_user_id: a.value(), + follower_actor_url: REMOTE_ACTOR.into(), + status: FollowStatus::Accepted, + }, + FollowWrite::AddFollow { + follower_id: b.value(), + target_actor_url: REMOTE_ACTOR.into(), + status: FollowStatus::Accepted, + }, + FollowWrite::AddFollower { + local_user_id: b.value(), + follower_actor_url: REMOTE_ACTOR.into(), + status: FollowStatus::Accepted, + }, + // bob follows alice, and alice follows bob + FollowWrite::AddFollow { + follower_id: b.value(), + target_actor_url: alice_actor.clone(), + status: FollowStatus::Accepted, + }, + FollowWrite::AddFollower { + local_user_id: b.value(), + follower_actor_url: alice_actor, + status: FollowStatus::Accepted, + }, + ], + &[], + ) + .await + .unwrap(); + // A remote review attributed to the same account id is not the user's. + exec( + pool, + "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url) + SELECT ?, movie_id, ?, rating, comment, watched_at, created_at, ? FROM reviews WHERE user_id = ? LIMIT 1", + &[ + &uuid::Uuid::new_v4().to_string(), + &a_str, + REMOTE_ACTOR, + &a_str, + ], + ) + .await; + // Announces of alice's review and goal, of bob's review, and one by alice. + let alice_review: String = + sqlx::query_scalar("SELECT id FROM reviews WHERE user_id = ? AND remote_actor_url IS NULL") + .bind(&a_str) + .fetch_one(pool) + .await + .unwrap(); + let bob_review: String = sqlx::query_scalar("SELECT id FROM reviews WHERE user_id = ?") + .bind(&b_str) + .fetch_one(pool) + .await + .unwrap(); + for (id, object, actor) in [ + ( + "an1", + format!("{BASE}/reviews/{alice_review}"), + REMOTE_ACTOR, + ), + ( + "an2", + format!("{BASE}/users/{a_str}/goals/2024"), + REMOTE_ACTOR, + ), + ("an3", format!("{BASE}/reviews/{bob_review}"), REMOTE_ACTOR), + ] { + exec( + pool, + "INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES (?, ?, ?, 'now')", + &[id, &object, actor], + ) + .await; + } + Seeded { + user: a, + other: b, + movie_id: movie.id().value().to_string(), + } +} + +async fn tombstone(pool: &SqlitePool, user: &UserId) { + exec( + pool, + "UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE id = ?", + &[&user.value().to_string()], + ) + .await; +} + +#[tokio::test] +async fn purge_removes_every_user_owned_row() { + let pool = migrated_pool().await; + let parts = sqlite::wire_pool(pool.clone()).await.unwrap(); + let s = seed(&pool, &parts).await; + for (table, column) in PURGED_USER_TABLES { + assert!( + count_for(&pool, table, column, &s.user).await > 0, + "{table} should be seeded" + ); + } + tombstone(&pool, &s.user).await; + + purger(&pool).purge(&s.user).await.unwrap(); + + for (table, column) in PURGED_USER_TABLES { + // Reviews: only the remote one, asserted below, is left. + let left = if *table == "reviews" { 1 } else { 0 }; + assert_eq!( + count_for(&pool, table, column, &s.user).await, + left, + "{table} should be purged" + ); + } + let alice_actor = instance().actor_url_for(&s.user); + for table in ["ap_followers", "ap_following"] { + let pointing: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {table} WHERE remote_actor_url = ?" + )) + .bind(&alice_actor) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(pointing, 0, "{table} rows pointing at the user are gone"); + } + let reviews: i64 = count( + &pool, + &format!( + "SELECT COUNT(*) FROM reviews WHERE user_id = '{}'", + s.user.value() + ), + ) + .await; + assert_eq!(reviews, 1, "the remote review is not the user's to purge"); + let announces: Vec = sqlx::query_scalar("SELECT id FROM ap_announces ORDER BY id") + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(announces, vec!["an3"], "only bob's announce survives"); + + // The other account is untouched. + for (table, column) in PURGED_USER_TABLES { + assert!( + count_for(&pool, table, column, &s.other).await > 0, + "{table} of the other user must survive" + ); + } + // The keypair stays for queued deliveries. + for table in RETAINED_USER_TABLES { + assert_eq!(count_for(&pool, table, "user_id", &s.user).await, 1); + } +} + +#[tokio::test] +async fn the_purge_table_lists_cover_every_user_referencing_column_in_the_schema() { + let pool = migrated_pool().await; + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT m.name, c.name FROM sqlite_master m, pragma_table_info(m.name) c + WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' AND m.name != 'users' + AND (c.name LIKE '%user_id%' OR c.name LIKE 'owner%')", + ) + .fetch_all(&pool) + .await + .unwrap(); + let fk_tables: Vec = sqlx::query_scalar( + "SELECT m.name FROM sqlite_master m, pragma_foreign_key_list(m.name) f + WHERE m.type = 'table' AND f.\"table\" = 'users'", + ) + .fetch_all(&pool) + .await + .unwrap(); + + let found: BTreeSet<(String, String)> = rows.into_iter().collect(); + let mut covered: BTreeSet<(String, String)> = PURGED_USER_TABLES + .iter() + .map(|(t, c)| (t.to_string(), c.to_string())) + .collect(); + covered.extend( + RETAINED_USER_TABLES + .iter() + .map(|t| (t.to_string(), "user_id".to_string())), + ); + assert_eq!( + found, covered, + "a table references a user but the purge neither empties nor retains it" + ); + for t in fk_tables { + assert!( + covered.iter().any(|(table, _)| *table == t), + "{t} has a foreign key to users but is not in the purge lists" + ); + } + // URL-keyed tables must exist, so a rename cannot silently drop them. + for t in URL_KEYED_USER_TABLES { + let n: i64 = count( + &pool, + &format!("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='{t}'"), + ) + .await; + assert_eq!(n, 1, "{t} is listed as URL-keyed but missing"); + } +} + +#[tokio::test] +async fn purge_keeps_movies_and_metadata() { + let pool = migrated_pool().await; + let parts = sqlite::wire_pool(pool.clone()).await.unwrap(); + let s = seed(&pool, &parts).await; + let movies = count(&pool, "SELECT COUNT(*) FROM movies").await; + tombstone(&pool, &s.user).await; + + purger(&pool).purge(&s.user).await.unwrap(); + + assert_eq!(count(&pool, "SELECT COUNT(*) FROM movies").await, movies); + assert_eq!( + count( + &pool, + &format!("SELECT COUNT(*) FROM movies WHERE id = '{}'", s.movie_id) + ) + .await, + 1 + ); +} + +#[tokio::test] +async fn purge_strips_the_user_row_but_keeps_the_username_and_state() { + let pool = migrated_pool().await; + let parts = sqlite::wire_pool(pool.clone()).await.unwrap(); + let s = seed(&pool, &parts).await; + parts + .user + .update_profile( + &s.user, + &UserProfile { + display_name: Some("Alice".into()), + bio: Some("bio".into()), + avatar_path: Some("avatars/a.webp".into()), + banner_path: Some("banners/a.webp".into()), + also_known_as: Some("https://x.example/a".into()), + ..Default::default() + }, + &[], + ) + .await + .unwrap(); + parts.user.mark_deleted(&s.user, &[]).await.unwrap(); + let p = purger(&pool); + assert!(!p.is_purged(&s.user).await.unwrap()); + + p.purge(&s.user).await.unwrap(); + + assert!(p.is_purged(&s.user).await.unwrap()); + let tomb = parts + .user + .find_tombstoned_by_id(&s.user) + .await + .unwrap() + .unwrap(); + assert_eq!(tomb.username().value(), "alice"); + assert_eq!(tomb.display_name(), None); + assert_eq!(tomb.bio(), None); + assert_eq!(tomb.also_known_as(), None); + assert_ne!(tomb.email().value(), "alice@example.com"); + assert!( + parts + .user + .is_username_retired(tomb.username()) + .await + .unwrap() + ); + // Images stay referenced until the objects are gone. + let mut keys = p.owned_image_keys(&s.user).await.unwrap(); + keys.sort(); + assert_eq!(keys, vec!["avatars/a.webp", "banners/a.webp"]); + p.forget_images(&s.user).await.unwrap(); + assert!(p.owned_image_keys(&s.user).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn purging_twice_is_a_no_op() { + let pool = migrated_pool().await; + let parts = sqlite::wire_pool(pool.clone()).await.unwrap(); + let s = seed(&pool, &parts).await; + parts.user.mark_deleted(&s.user, &[]).await.unwrap(); + let p = purger(&pool); + p.purge(&s.user).await.unwrap(); + // New data cannot appear for a purged account, but a re-run must not + // touch anything else or fail. + let before: i64 = count(&pool, "SELECT COUNT(*) FROM reviews").await; + let stamp: String = sqlx::query_scalar("SELECT purged_at FROM users WHERE id = ?") + .bind(s.user.value().to_string()) + .fetch_one(&pool) + .await + .unwrap(); + + p.purge(&s.user).await.unwrap(); + + assert_eq!(count(&pool, "SELECT COUNT(*) FROM reviews").await, before); + let again: String = sqlx::query_scalar("SELECT purged_at FROM users WHERE id = ?") + .bind(s.user.value().to_string()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stamp, again); + // And an id that never existed is fine too. + p.purge(&UserId::generate()).await.unwrap(); +} + +#[tokio::test] +async fn purge_never_touches_a_live_account() { + let pool = migrated_pool().await; + let parts = sqlite::wire_pool(pool.clone()).await.unwrap(); + let s = seed(&pool, &parts).await; + + purger(&pool).purge(&s.user).await.unwrap(); + + for (table, column) in PURGED_USER_TABLES { + assert!( + count_for(&pool, table, column, &s.user).await > 0, + "{table}" + ); + } + assert!(parts.user.find_by_id(&s.user).await.unwrap().is_some()); +} + +#[tokio::test] +async fn purge_frees_the_email_but_the_username_stays_retired() { + let pool = migrated_pool().await; + let parts = sqlite::wire_pool(pool.clone()).await.unwrap(); + let s = seed(&pool, &parts).await; + let again = test_user("alice"); + assert!( + parts.user.save(&again).await.is_err(), + "email is held while tombstoned" + ); + parts.user.mark_deleted(&s.user, &[]).await.unwrap(); + assert!(parts.user.save(&again).await.is_err()); + + purger(&pool).purge(&s.user).await.unwrap(); + + let reuse_email = domain::models::User::new( + domain::value_objects::Email::new("alice@example.com".into()).unwrap(), + domain::value_objects::Username::new("alice2".into()).unwrap(), + domain::value_objects::PasswordHash::new("h".into()).unwrap(), + domain::models::UserRole::Standard, + ); + parts.user.save(&reuse_email).await.unwrap(); + assert!( + parts + .user + .is_username_retired(&domain::value_objects::Username::new("alice".into()).unwrap()) + .await + .unwrap() + ); + assert!( + parts.user.save(&again).await.is_err(), + "the username is never reusable" + ); +} + +#[tokio::test] +async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_ones() { + let pool = migrated_pool().await; + let parts = sqlite::wire_pool(pool.clone()).await.unwrap(); + let s = seed(&pool, &parts).await; + parts.user.mark_deleted(&s.user, &[]).await.unwrap(); + let p = purger(&pool); + p.purge(&s.user).await.unwrap(); + let now = Utc::now().naive_utc(); + let stamp = |days: i64| { + (now - chrono::Duration::days(days)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string() + }; + exec( + &pool, + "UPDATE users SET purged_at = ? WHERE id = ?", + &[&stamp(8), &s.user.value().to_string()], + ) + .await; + + // Younger than the cutoff: kept. + let removed = p + .purge_expired_tombstones(now - chrono::Duration::days(9)) + .await + .unwrap(); + assert_eq!(removed, 0); + assert_eq!(count_for(&pool, "users", "id", &s.user).await, 1); + + let removed = p + .purge_expired_tombstones(now - chrono::Duration::days(7)) + .await + .unwrap(); + assert_eq!(removed, 1); + assert_eq!(count_for(&pool, "users", "id", &s.user).await, 0); + assert_eq!( + count_for(&pool, "ap_local_actors", "user_id", &s.user).await, + 0 + ); + assert_eq!(count_for(&pool, "users", "id", &s.other).await, 1); + assert_eq!( + count_for(&pool, "ap_local_actors", "user_id", &s.other).await, + 1 + ); + let retired: i64 = count( + &pool, + "SELECT COUNT(*) FROM retired_usernames WHERE username = 'alice'", + ) + .await; + assert_eq!(retired, 1, "the username stays retired for good"); +} diff --git a/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt index fedc5c9c..2a339efe 100644 --- a/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt +++ b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt @@ -30,4 +30,4 @@ 30 DAAA0AF35B4ACB271A9D8068B82F02A72EEC62F3AC07ED504C7A7887976C2C8BACB35FFF477B63A931DF15F79018390D 100 310ADC9ABF0CC2F8E9EDAB33168D009D64228FF5BD8D2A14185513699EBB782606A1EEA8FEE6EC6AF65D72396153F13F 101 19A2542D4343F3921A8BF0A10E195DA1915E3EA97A2BCF2D3BF9079504DB2053E4BD2175D94C95BCA363BC2FDF14AA88 -102 A2118D59757F99A902B613046CE93C53927FE64330684A8703B6E62A8588B87D37ACD55451566F6248A67B7BD9B779EF +102 A3995D574BDDDB634F9D7FEF5FAEE658F272C3E0CABA182080425ED5522E73DBF44EDEF28F35BDCF4669F722E4E2AA4B diff --git a/crates/application/src/jobs/mod.rs b/crates/application/src/jobs/mod.rs index 58115c4a..34020b55 100644 --- a/crates/application/src/jobs/mod.rs +++ b/crates/application/src/jobs/mod.rs @@ -3,6 +3,7 @@ mod import_cleanup; mod movie_dedup; mod processed_events_cleanup; mod refresh_session_cleanup; +mod tombstone_cleanup; mod watch_event_cleanup; mod wrapup; @@ -11,5 +12,6 @@ pub use import_cleanup::ImportSessionCleanupJob; pub use movie_dedup::MovieDeduplicationJob; pub use processed_events_cleanup::{PROCESSED_EVENTS_RETENTION_DAYS, ProcessedEventsCleanupJob}; pub use refresh_session_cleanup::RefreshSessionCleanupJob; +pub use tombstone_cleanup::{TOMBSTONE_RETENTION_DAYS, TombstoneCleanupJob}; pub use watch_event_cleanup::WatchEventCleanupJob; pub use wrapup::{WrapUpAutoGenerateJob, WrapUpCleanupJob}; diff --git a/crates/application/src/jobs/tombstone_cleanup.rs b/crates/application/src/jobs/tombstone_cleanup.rs new file mode 100644 index 00000000..76952d4e --- /dev/null +++ b/crates/application/src/jobs/tombstone_cleanup.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::{AccountPurger, PeriodicJob}, +}; + +/// How long a purged account's stripped user row and signing keypair stay. +/// Federation deliveries of the account's `Delete` are signed from them, and +/// their retries are long over by then. +pub const TOMBSTONE_RETENTION_DAYS: i64 = 7; + +pub struct TombstoneCleanupJob { + purger: Arc, +} + +impl TombstoneCleanupJob { + pub fn new(purger: Arc) -> Self { + Self { purger } + } +} + +#[async_trait] +impl PeriodicJob for TombstoneCleanupJob { + fn interval(&self) -> Duration { + Duration::from_secs(24 * 3600) + } + + async fn run(&self) -> Result<(), DomainError> { + let cutoff = + chrono::Utc::now().naive_utc() - chrono::Duration::days(TOMBSTONE_RETENTION_DAYS); + let n = self.purger.purge_expired_tombstones(cutoff).await?; + if n > 0 { + tracing::info!("tombstone cleanup: removed {n} purged accounts"); + } + Ok(()) + } +} diff --git a/crates/application/src/users/mod.rs b/crates/application/src/users/mod.rs index d83e3799..804075eb 100644 --- a/crates/application/src/users/mod.rs +++ b/crates/application/src/users/mod.rs @@ -11,6 +11,7 @@ pub mod get_page_viewer; pub mod get_profile_settings; pub mod get_settings; pub mod get_users; +pub mod purge_account; pub mod queries; pub mod request_account_deletion; pub mod resolve_username_to_id; diff --git a/crates/application/src/users/purge_account.rs b/crates/application/src/users/purge_account.rs new file mode 100644 index 00000000..99d0ccae --- /dev/null +++ b/crates/application/src/users/purge_account.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + events::DomainEvent, + ports::{ + AccountPurger, EventHandler, FederationDeletionNotifier, ObjectStorage, UserRepository, + }, +}; + +/// Purges a deleted account once its `UserDeleted` event arrives. +/// +/// Order matters. Federation is told first (deliveries are only enqueued, and +/// signed later from the tombstone the purge leaves behind). A notifier +/// failure never blocks the purge: unreachable servers must not keep a +/// person's data. Then the database rows go in one transaction, and the +/// image objects after the commit. Image references are dropped only once the +/// objects are gone, so a crash in between is recovered by the redelivery. +/// +/// Idempotent: every step is a no-op on a re-run, and the notification is +/// skipped once the account is purged. +pub struct AccountPurgeHandler { + user: Arc, + notifier: Arc, + purger: Arc, + object_storage: Arc, +} + +impl AccountPurgeHandler { + pub fn new( + user: Arc, + notifier: Arc, + purger: Arc, + object_storage: Arc, + ) -> Self { + Self { + user, + notifier, + purger, + object_storage, + } + } +} + +#[async_trait] +impl EventHandler for AccountPurgeHandler { + fn name(&self) -> &'static str { + "account_purge" + } + + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { + let DomainEvent::UserDeleted { user_id } = event else { + return Ok(()); + }; + // Gone already (removed by the tombstone cleanup): nothing left to do. + if self.user.find_tombstoned_by_id(user_id).await?.is_none() { + return Ok(()); + } + + if !self.purger.is_purged(user_id).await? + && let Err(e) = self.notifier.notify_deleted(user_id).await + { + tracing::warn!(user = %user_id.value(), "federation delete notice failed, purging anyway: {e}"); + } + + let keys = self.purger.owned_image_keys(user_id).await?; + self.purger.purge(user_id).await?; + for key in &keys { + self.object_storage.delete(key).await?; + } + if !keys.is_empty() { + self.purger.forget_images(user_id).await?; + } + Ok(()) + } +} diff --git a/crates/application/tests/jobs.rs b/crates/application/tests/jobs.rs index e61924a4..9be4b444 100644 --- a/crates/application/tests/jobs.rs +++ b/crates/application/tests/jobs.rs @@ -1,2 +1,4 @@ #[path = "jobs/processed_events_cleanup_test.rs"] mod processed_events_cleanup_test; +#[path = "jobs/tombstone_cleanup_test.rs"] +mod tombstone_cleanup_test; diff --git a/crates/application/tests/jobs/tombstone_cleanup_test.rs b/crates/application/tests/jobs/tombstone_cleanup_test.rs new file mode 100644 index 00000000..52c7668a --- /dev/null +++ b/crates/application/tests/jobs/tombstone_cleanup_test.rs @@ -0,0 +1,59 @@ +use std::sync::{Arc, Mutex}; + +use application::jobs::{TOMBSTONE_RETENTION_DAYS, TombstoneCleanupJob}; +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::{AccountPurger, PeriodicJob}, + value_objects::UserId, +}; + +#[derive(Default)] +struct Purger { + cutoffs: Mutex>, +} + +#[async_trait] +impl AccountPurger for Purger { + async fn is_purged(&self, _: &UserId) -> Result { + unimplemented!() + } + async fn owned_image_keys(&self, _: &UserId) -> Result, DomainError> { + unimplemented!() + } + async fn purge(&self, _: &UserId) -> Result<(), DomainError> { + unimplemented!() + } + async fn forget_images(&self, _: &UserId) -> Result<(), DomainError> { + unimplemented!() + } + async fn purge_expired_tombstones( + &self, + cutoff: chrono::NaiveDateTime, + ) -> Result { + self.cutoffs.lock().unwrap().push(cutoff); + Ok(0) + } +} + +#[tokio::test] +async fn removes_tombstones_purged_before_the_retention_window() { + assert_eq!(TOMBSTONE_RETENTION_DAYS, 7); + let purger = Arc::new(Purger::default()); + + TombstoneCleanupJob::new(purger.clone()) + .run() + .await + .unwrap(); + + let cutoffs = purger.cutoffs.lock().unwrap(); + let expected = chrono::Utc::now().naive_utc() - chrono::Duration::days(7); + assert_eq!(cutoffs.len(), 1); + assert!((cutoffs[0] - expected).num_seconds().abs() < 5); +} + +#[test] +fn runs_daily() { + let job = TombstoneCleanupJob::new(Arc::new(Purger::default())); + assert_eq!(job.interval(), std::time::Duration::from_secs(86_400)); +} diff --git a/crates/application/tests/users.rs b/crates/application/tests/users.rs index 4cd6ff02..dd227b63 100644 --- a/crates/application/tests/users.rs +++ b/crates/application/tests/users.rs @@ -20,6 +20,8 @@ mod get_profile_settings_test; mod get_settings_test; #[path = "users/get_users_test.rs"] mod get_users_test; +#[path = "users/purge_account_test.rs"] +mod purge_account_test; #[path = "users/request_account_deletion_test.rs"] mod request_account_deletion_test; #[path = "users/resolve_username_to_id_test.rs"] diff --git a/crates/application/tests/users/purge_account_test.rs b/crates/application/tests/users/purge_account_test.rs new file mode 100644 index 00000000..5c2103c2 --- /dev/null +++ b/crates/application/tests/users/purge_account_test.rs @@ -0,0 +1,253 @@ +use std::sync::{Arc, Mutex}; + +use application::users::purge_account::AccountPurgeHandler; +use async_trait::async_trait; +use domain::{ + errors::DomainError, + events::{DomainEvent, EventEnvelope, EventId}, + ports::{ + AccountPurger, EventConsumer, EventHandler, FederationDeletionNotifier, ObjectStorage, + UserRepository, + }, + testing::{InMemoryStore, NoopAckHandle, test_user}, + value_objects::UserId, +}; +use futures::stream::{self, BoxStream}; + +type Log = Arc>>; + +struct Notifier { + log: Log, + fail: bool, +} + +#[async_trait] +impl FederationDeletionNotifier for Notifier { + async fn notify_deleted(&self, _: &UserId) -> Result<(), DomainError> { + self.log.lock().unwrap().push("notify".into()); + if self.fail { + Err(DomainError::InfrastructureError("peer unreachable".into())) + } else { + Ok(()) + } + } +} + +struct Purger { + log: Log, + purged: Mutex, + keys: Vec, +} + +#[async_trait] +impl AccountPurger for Purger { + async fn is_purged(&self, _: &UserId) -> Result { + Ok(*self.purged.lock().unwrap()) + } + async fn owned_image_keys(&self, _: &UserId) -> Result, DomainError> { + Ok(self.keys.clone()) + } + async fn purge(&self, _: &UserId) -> Result<(), DomainError> { + self.log.lock().unwrap().push("purge".into()); + *self.purged.lock().unwrap() = true; + Ok(()) + } + async fn forget_images(&self, _: &UserId) -> Result<(), DomainError> { + self.log.lock().unwrap().push("forget".into()); + Ok(()) + } + async fn purge_expired_tombstones(&self, _: chrono::NaiveDateTime) -> Result { + Ok(0) + } +} + +struct Storage { + log: Log, +} + +#[async_trait] +impl ObjectStorage for Storage { + async fn store(&self, _: &str, _: &[u8]) -> Result { + unimplemented!() + } + async fn get(&self, _: &str) -> Result, DomainError> { + unimplemented!() + } + async fn get_stream( + &self, + _: &str, + ) -> Result>, DomainError> { + unimplemented!() + } + async fn delete(&self, key: &str) -> Result<(), DomainError> { + self.log.lock().unwrap().push(format!("delete {key}")); + Ok(()) + } + async fn exists(&self, _: &str) -> Result { + unimplemented!() + } +} + +struct Fixture { + handler: Arc, + log: Log, + store: Arc, + user: UserId, +} + +async fn fixture(notify_fails: bool, tombstoned: bool) -> Fixture { + let store = InMemoryStore::new(); + let alice = test_user("alice"); + store.save(&alice).await.unwrap(); + if tombstoned { + store.mark_deleted(alice.id(), &[]).await.unwrap(); + } + let log: Log = Arc::default(); + let handler = Arc::new(AccountPurgeHandler::new( + store.clone(), + Arc::new(Notifier { + log: log.clone(), + fail: notify_fails, + }), + Arc::new(Purger { + log: log.clone(), + purged: Mutex::new(false), + keys: vec!["avatars/a.webp".into(), "banners/a.webp".into()], + }), + Arc::new(Storage { log: log.clone() }), + )); + Fixture { + handler, + log, + store, + user: alice.id().clone(), + } +} + +fn deleted(user: &UserId) -> DomainEvent { + DomainEvent::UserDeleted { + user_id: user.clone(), + } +} + +fn entries(log: &Log) -> Vec { + log.lock().unwrap().clone() +} + +#[test] +fn the_handler_name_is_stable() { + // The name keys the processed-event ledger; renaming re-runs events. + let f = tokio::runtime::Runtime::new() + .unwrap() + .block_on(fixture(false, true)); + assert_eq!(f.handler.name(), "account_purge"); +} + +#[tokio::test] +async fn notifies_first_then_purges_then_removes_images_then_forgets_them() { + let f = fixture(false, true).await; + + f.handler.handle(&deleted(&f.user)).await.unwrap(); + + assert_eq!( + entries(&f.log), + [ + "notify", + "purge", + "delete avatars/a.webp", + "delete banners/a.webp", + "forget" + ] + ); +} + +#[tokio::test] +async fn purge_continues_when_federation_notify_fails() { + let f = fixture(true, true).await; + + f.handler.handle(&deleted(&f.user)).await.unwrap(); + + let log = entries(&f.log); + assert_eq!(log[0], "notify"); + assert!(log.contains(&"purge".to_string()), "{log:?}"); + assert!(log.contains(&"forget".to_string()), "{log:?}"); +} + +#[tokio::test] +async fn a_rerun_does_not_notify_again() { + let f = fixture(false, true).await; + f.handler.handle(&deleted(&f.user)).await.unwrap(); + + f.handler.handle(&deleted(&f.user)).await.unwrap(); + + let notices = entries(&f.log).iter().filter(|e| *e == "notify").count(); + assert_eq!(notices, 1); +} + +#[tokio::test] +async fn does_nothing_when_the_account_is_not_tombstoned() { + let live = fixture(false, false).await; + live.handler.handle(&deleted(&live.user)).await.unwrap(); + assert!(entries(&live.log).is_empty(), "a live user is never purged"); + + let unknown = fixture(false, true).await; + unknown + .handler + .handle(&deleted(&UserId::generate())) + .await + .unwrap(); + assert!(entries(&unknown.log).is_empty()); +} + +#[tokio::test] +async fn ignores_other_events() { + let f = fixture(false, true).await; + f.handler + .handle(&DomainEvent::UserUpdated { + user_id: f.user.clone(), + }) + .await + .unwrap(); + assert!(entries(&f.log).is_empty()); +} + +struct TwiceConsumer { + id: EventId, + event: DomainEvent, +} + +impl EventConsumer for TwiceConsumer { + fn consume(&self) -> BoxStream<'_, Result> { + let deliveries: Vec> = (0..2) + .map(|_| { + Ok(EventEnvelope::new( + Some(self.id), + self.event.clone(), + Box::new(NoopAckHandle), + )) + }) + .collect(); + Box::pin(stream::iter(deliveries)) + } +} + +#[tokio::test] +async fn a_redelivered_event_is_skipped_by_the_ledger() { + let f = fixture(false, true).await; + let consumer = TwiceConsumer { + id: EventId::new(), + event: deleted(&f.user), + }; + + application::worker::WorkerService::new( + Arc::new(consumer), + vec![f.handler.clone() as Arc], + f.store.clone(), + ) + .unwrap() + .run(tokio::sync::watch::channel(false).1) + .await; + + let purges = entries(&f.log).iter().filter(|e| *e == "purge").count(); + assert_eq!(purges, 1); +} diff --git a/crates/bootstrap/src/database.rs b/crates/bootstrap/src/database.rs index a2cc4c00..eb795d6f 100644 --- a/crates/bootstrap/src/database.rs +++ b/crates/bootstrap/src/database.rs @@ -40,6 +40,7 @@ pub struct DatabaseOutput { pub refresh_session: Arc, pub processed_events: Arc, pub deduplicator: Arc, + pub account_purger: Arc, pub image_ref_command: Arc, pub image_ref_query: Arc, pub follow_command: Arc, @@ -117,6 +118,10 @@ fn assemble( 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 _, diff --git a/crates/bootstrap/src/federation.rs b/crates/bootstrap/src/federation.rs index 7fbf733b..64aba88d 100644 --- a/crates/bootstrap/src/federation.rs +++ b/crates/bootstrap/src/federation.rs @@ -123,15 +123,19 @@ pub async fn wire_server_social( }) } -/// The worker's federation event handlers: the ActivityPub delivery handler and -/// the follow-accepted outbox backfill. +/// The worker's federation event handlers (the ActivityPub delivery handler and +/// the follow-accepted outbox backfill) and the notifier that tells followers +/// an account was deleted. #[cfg(feature = "federation")] pub async fn wire_worker_handlers( db: &DatabaseOutput, instance: &InstanceIdentity, config: &config::AppConfig, event_publisher: &Arc, -) -> anyhow::Result>> { +) -> anyhow::Result<( + Vec>, + Arc, +)> { let fed_repos = sqlite_federation::wire(db.db_pool.clone(), instance.clone()); let ap_wire = activitypub::wire(activitypub::ActivityPubDeps { @@ -160,10 +164,13 @@ pub async fn wire_worker_handlers( .await?; tracing::info!("federation event handler registered"); - Ok(vec![ - ap_wire.event_handler, - Arc::new(crate::follow_backfill_handler::FollowBackfillHandler { - backfill: ap_wire.backfill, - }), - ]) + Ok(( + vec![ + ap_wire.event_handler, + Arc::new(crate::follow_backfill_handler::FollowBackfillHandler { + backfill: ap_wire.backfill, + }), + ], + ap_wire.deletion_notifier, + )) } diff --git a/crates/bootstrap/src/worker.rs b/crates/bootstrap/src/worker.rs index 5826e2e0..5a89728a 100644 --- a/crates/bootstrap/src/worker.rs +++ b/crates/bootstrap/src/worker.rs @@ -133,9 +133,12 @@ async fn assemble( let image_ref_query = Arc::clone(&db.image_ref_query); #[cfg(feature = "federation")] - let federation_handlers = + 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 = + Arc::new(domain::ports::noop::NoopFederationDeletionNotifier); let movie_command = db.movie_command; let movie_query = db.movie_query; @@ -148,6 +151,7 @@ async fn assemble( 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; @@ -225,6 +229,9 @@ async fn assemble( 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); @@ -282,7 +289,15 @@ async fn assemble( let reindex_handler = Arc::new(SearchReindexHandler::new(worker_deps.reindex_search)) as Arc; + 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; + let mut h = vec![ + account_purge, poster, cleanup, search_cleanup, diff --git a/crates/bootstrap/tests/account_purge_test.rs b/crates/bootstrap/tests/account_purge_test.rs new file mode 100644 index 00000000..ffd156c0 --- /dev/null +++ b/crates/bootstrap/tests/account_purge_test.rs @@ -0,0 +1,130 @@ +//! End to end on the real wiring: a `UserDeleted` event in the outbox makes +//! the worker tell followers, purge the account, and leave the tombstone that +//! the queued deliveries are signed from. + +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"); +} + +/// With a follower, the Delete is enqueued for delivery even though the +/// account is already tombstoned, and the keypair outlives the purge. +#[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; + // Deliveries are enqueued from a spawned task, so wait for them too. + 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"); +} diff --git a/crates/bootstrap/tests/bootstrap_test.rs b/crates/bootstrap/tests/bootstrap_test.rs index 0c1fdb8d..8bcd9ef5 100644 --- a/crates/bootstrap/tests/bootstrap_test.rs +++ b/crates/bootstrap/tests/bootstrap_test.rs @@ -129,6 +129,7 @@ async fn handler_names_are_unique() { // Names key the ledger: a rename re-runs events inside the retention // window, so changing this list must be a deliberate edit. let mut expected = vec![ + "account_purge", "image_cleanup", "movie_discovery_indexer", "movie_enrichment", @@ -253,3 +254,49 @@ async fn a_deleted_users_token_is_rejected() { assert_eq!(headers.get("location"), anon_headers.get("location")); assert_eq!(headers.get("location").unwrap(), "/login"); } + +/// A deleted account stays resolvable inside k-ap (to sign its `Delete`), but +/// every ActivityPub document for it answers 410, while the classic profile +/// page stays 404. +#[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"); +} diff --git a/crates/domain/src/ports/account_purge.rs b/crates/domain/src/ports/account_purge.rs new file mode 100644 index 00000000..ec991468 --- /dev/null +++ b/crates/domain/src/ports/account_purge.rs @@ -0,0 +1,44 @@ +use async_trait::async_trait; + +use crate::{errors::DomainError, value_objects::UserId}; + +/// Tells the fediverse that a local account is gone (an ActivityPub `Delete` +/// to followers). Federation-off builds wire a no-op. +/// +/// Delivery is asynchronous: the implementation may only enqueue deliveries. +/// Those are signed later with the actor's keypair and the tombstoned user row, +/// which is why the purge keeps both until the tombstone cleanup. +#[async_trait] +pub trait FederationDeletionNotifier: Send + Sync { + async fn notify_deleted(&self, user: &UserId) -> Result<(), DomainError>; +} + +/// Removes everything a deleted account owns, in one transaction, and strips +/// the user row down to a data-free tombstone. +/// +/// What stays: the username (retired for good), role, `deleted_at`, the +/// `purged_at` stamp and the signing keypair, so queued federation deliveries +/// can still be signed. `purge_expired_tombstones` removes those later. +#[async_trait] +pub trait AccountPurger: Send + Sync { + /// Whether the account's data has already been purged. + async fn is_purged(&self, user: &UserId) -> Result; + + /// Object-storage keys the account owns (avatar, banner). Still readable + /// after `purge`, until `forget_images`. + async fn owned_image_keys(&self, user: &UserId) -> Result, DomainError>; + + /// One transaction; every delete is "if present", so a re-run is a no-op. + /// Frees the email. Does nothing for an account that is gone or purged. + async fn purge(&self, user: &UserId) -> Result<(), DomainError>; + + /// Drops the image references once their objects are deleted. + async fn forget_images(&self, user: &UserId) -> Result<(), DomainError>; + + /// Deletes tombstones (user row and keypair) purged before `cutoff`. + /// Returns how many were removed. + async fn purge_expired_tombstones( + &self, + cutoff: chrono::NaiveDateTime, + ) -> Result; +} diff --git a/crates/domain/src/ports/federation.rs b/crates/domain/src/ports/federation.rs index c71ff2ee..b81d9f42 100644 --- a/crates/domain/src/ports/federation.rs +++ b/crates/domain/src/ports/federation.rs @@ -8,6 +8,11 @@ use crate::{ #[async_trait] pub trait ApDocumentPort: Send + Sync { + /// Whether the actor belonged to a deleted account: its documents are + /// answered `410 Gone` instead of being served. + async fn actor_is_gone(&self, _user_id: Uuid) -> Result { + Ok(false) + } async fn actor_json(&self, user_id: &str) -> Result; async fn followers_collection_json( &self, diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs index f25cec7d..1d29026a 100644 --- a/crates/domain/src/ports/mod.rs +++ b/crates/domain/src/ports/mod.rs @@ -1,3 +1,4 @@ +pub mod account_purge; pub mod auth; pub mod diary; pub mod events; @@ -21,6 +22,7 @@ pub mod social; pub mod watchlist; pub mod wrapup; +pub use account_purge::*; pub use auth::*; pub use diary::*; pub use events::*; diff --git a/crates/domain/src/ports/noop.rs b/crates/domain/src/ports/noop.rs index 3c8e0de8..5a1e13fb 100644 --- a/crates/domain/src/ports/noop.rs +++ b/crates/domain/src/ports/noop.rs @@ -34,6 +34,18 @@ impl super::RemoteWatchlistRepository for NoopRemoteWatchlistRepository { } } +// ── NoopFederationDeletionNotifier ─────────────────────────────────────────── + +/// Federation compiled out: nobody remote to tell. +pub struct NoopFederationDeletionNotifier; + +#[async_trait] +impl super::FederationDeletionNotifier for NoopFederationDeletionNotifier { + async fn notify_deleted(&self, _: &UserId) -> Result<(), DomainError> { + Ok(()) + } +} + // ── NoopSocialCommand ──────────────────────────────────────────────────────── pub struct NoopSocialCommand; -- 2.49.1 From 80dbdec1074f1464abb4cae7674f54034753b66e Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 02:39:06 +0100 Subject: [PATCH 080/104] purge: skip broadcasts of deleted authors, purge outbox log, guards, 410 inbox test --- .../adapters/activitypub/src/event_handler.rs | 46 ++++++++- crates/adapters/activitypub/src/lib.rs | 1 + .../activitypub/tests/activity_ids.rs | 79 ++++++++++++++- crates/adapters/sqlite/src/lib.rs | 6 +- .../src/repositories/account_purge/mod.rs | 48 ++++++++- .../adapters/sqlite/src/repositories/mod.rs | 3 +- .../sqlite/tests/account_purge_test.rs | 98 ++++++++++++++++--- .../tests/users/purge_account_test.rs | 35 ++++++- crates/bootstrap/tests/bootstrap_test.rs | 29 ++++++ 9 files changed, 318 insertions(+), 27 deletions(-) diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index 4ac4a962..c08af08a 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -6,7 +6,7 @@ use domain::{ events::DomainEvent, ports::{ GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository, - UserFederationSettingsQuery, + UserFederationSettingsQuery, UserRepository, }, value_objects::{InstanceIdentity, MovieId, ReviewId, UserId}, }; @@ -25,6 +25,7 @@ pub struct ActivityPubEventHandler { goal_repo: Arc, stats_repo: Arc, federation_settings: Arc, + user_repo: Arc, instance: InstanceIdentity, } @@ -38,6 +39,7 @@ impl ActivityPubEventHandler { goal_repo: Arc, stats_repo: Arc, federation_settings: Arc, + user_repo: Arc, instance: InstanceIdentity, ) -> Self { Self { @@ -48,6 +50,7 @@ impl ActivityPubEventHandler { goal_repo, stats_repo, federation_settings, + user_repo, instance, } } @@ -60,6 +63,15 @@ impl EventHandler for ActivityPubEventHandler { } async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { + // A deleted account must not speak again: its queued events would + // otherwise sign and send from the tombstone after the Delete. Only + // the Delete itself (sent by the deletion notifier) goes out. + if let Some(author) = self.outbound_author(event) + && self.is_tombstoned(&author).await + { + tracing::debug!(user = %author.value(), "skipping federation event of a deleted account"); + return Ok(()); + } match event { DomainEvent::ReviewLogged { review_id, user_id, .. @@ -161,6 +173,38 @@ impl EventHandler for ActivityPubEventHandler { } impl ActivityPubEventHandler { + /// The local account an outbound activity would be sent as. A queued + /// delivery counts unless it is the account's `Delete`. + fn outbound_author(&self, event: &DomainEvent) -> Option { + match event { + DomainEvent::ReviewLogged { user_id, .. } + | DomainEvent::ReviewUpdated { user_id, .. } + | DomainEvent::ReviewDeleted { user_id, .. } + | DomainEvent::UserUpdated { user_id } + | DomainEvent::WatchlistEntryAdded { user_id, .. } + | DomainEvent::WatchlistEntryRemoved { user_id, .. } + | DomainEvent::GoalCreated { user_id, .. } + | DomainEvent::GoalUpdated { user_id, .. } + | DomainEvent::GoalDeleted { user_id, .. } + | DomainEvent::UserAccountMoved { user_id, .. } => Some(user_id.clone()), + DomainEvent::FederationDeliveryRequested { + activity_json, + signing_actor_id, + .. + } if activity_json.get("type").and_then(|t| t.as_str()) != Some("Delete") => { + Some(UserId::from_uuid(*signing_actor_id)) + } + _ => None, + } + } + + async fn is_tombstoned(&self, user: &UserId) -> bool { + matches!( + self.user_repo.find_tombstoned_by_id(user).await, + Ok(Some(_)) + ) + } + async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> { let flags = self .federation_settings diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index 62b834a3..50ad31a9 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -179,6 +179,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { goal_repo, stats_repo, federation_settings, + std::sync::Arc::clone(&user_repo), instance, )) as std::sync::Arc; diff --git a/crates/adapters/activitypub/tests/activity_ids.rs b/crates/adapters/activitypub/tests/activity_ids.rs index 4a52d46e..d160376f 100644 --- a/crates/adapters/activitypub/tests/activity_ids.rs +++ b/crates/adapters/activitypub/tests/activity_ids.rs @@ -16,7 +16,9 @@ use domain::{ errors::DomainError, events::DomainEvent, models::{DiaryEntry, WatchlistWithMovie}, - ports::{EventHandler, LocalApContentQuery, MovieCommand}, + ports::{ + EventHandler, FederationDeletionNotifier, LocalApContentQuery, MovieCommand, UserRepository, + }, testing::{ InMemoryStore, PanicGoalQuery, PanicReviewRepository, PanicStatsRepository, test_movie, }, @@ -71,6 +73,8 @@ struct Harness { delivered: mpsc::UnboundedReceiver, user: UserId, movie_id: MovieId, + store: Arc, + service: Arc, } async fn harness() -> Harness { @@ -119,14 +123,23 @@ async fn harness() -> Harness { let store = InMemoryStore::new(); let movie = test_movie("Heat"); store.upsert_movie(&movie, &[]).await.unwrap(); + let service = Arc::new(service); + store + .save(&domain::testing::test_user_with_role( + user.clone(), + domain::models::UserRole::Standard, + )) + .await + .unwrap(); let handler = ActivityPubEventHandler::new( - Arc::new(service), + Arc::clone(&service), Arc::new(NoContent), Arc::new(PanicReviewRepository), store.clone(), Arc::new(PanicGoalQuery), Arc::new(PanicStatsRepository), - store, + store.clone(), + store.clone(), InstanceIdentity::new("https://diary.example"), ); Harness { @@ -134,6 +147,8 @@ async fn harness() -> Harness { delivered: rx, user, movie_id: movie.id().clone(), + store, + service, } } @@ -191,3 +206,61 @@ async fn redelivered_delete_gets_a_fresh_activity_id_known_k_ap_limitation() { assert_ne!(first, second); } + +/// Queued events of a deleted account must not sign and send from its +/// tombstone; the account's own Delete still goes out. +#[tokio::test] +async fn a_deleted_account_sends_nothing_but_its_delete() { + let mut h = harness().await; + h.store.mark_deleted(&h.user, &[]).await.unwrap(); + let logged = DomainEvent::ReviewLogged { + review_id: domain::value_objects::ReviewId::generate(), + user_id: h.user.clone(), + movie_id: h.movie_id.clone(), + rating: domain::value_objects::Rating::new(4).unwrap(), + watched_at: NaiveDate::from_ymd_opt(2026, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(), + }; + let added = DomainEvent::WatchlistEntryAdded { + user_id: h.user.clone(), + movie_id: h.movie_id.clone(), + movie_title: "Heat".into(), + release_year: 1995, + external_metadata_id: None, + added_at: NaiveDate::from_ymd_opt(2026, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(), + }; + let queued = DomainEvent::FederationDeliveryRequested { + inbox_url: "https://remote.example/inbox".into(), + activity_json: serde_json::json!({"type": "Create"}), + signing_actor_id: h.user.value(), + }; + + // The review repository panics if the handler gets past the guard. + for event in [&logged, &added, &queued] { + h.handler.handle(event).await.unwrap(); + } + assert!( + tokio::time::timeout(Duration::from_millis(300), h.delivered.recv()) + .await + .is_err(), + "nothing is delivered for a deleted account" + ); + + activitypub::ApDeletionNotifier::new( + Arc::clone(&h.service), + InstanceIdentity::new("https://diary.example"), + ) + .notify_deleted(&h.user) + .await + .unwrap(); + let delete = tokio::time::timeout(Duration::from_secs(5), h.delivered.recv()) + .await + .expect("the Delete is delivered") + .unwrap(); + assert_eq!(delete["type"], "Delete"); +} diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 82bfac2e..46e00d49 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -8,15 +8,15 @@ mod shared; pub use event_queue::{DbEventQueueConfig, SqliteEventQueue}; use repositories::SqliteUserSettingsRepository; pub use repositories::{ - PURGED_USER_TABLES, RETAINED_USER_TABLES, SqliteAccountPurger, SqliteApContentQuery, - SqliteDiaryRepository, SqliteGoalRepository, SqliteImageRefAdapter, + PAYLOAD_KEYED_USER_COLUMNS, PURGED_USER_TABLES, RETAINED_USER_TABLES, SqliteAccountPurger, + SqliteApContentQuery, SqliteDiaryRepository, SqliteGoalRepository, SqliteImageRefAdapter, SqliteImportProfileRepository, SqliteImportSessionRepository, SqliteInsightsQuery, SqliteMovieDeduplicator, SqliteMovieProfileRepository, SqliteMovieRepository, SqlitePersonAdapter, SqliteProcessedEventLedger, SqliteProfileFieldsRepository, SqliteRefreshSessionAdapter, SqliteRemoteGoalRepository, SqliteReviewRepository, SqliteSocialRepository, SqliteStatsRepository, SqliteUserRepository, SqliteWatchEventRepository, SqliteWatchlistRepository, SqliteWebhookTokenRepository, - SqliteWrapUpRepository, SqliteWrapUpStatsQuery, URL_KEYED_USER_TABLES, + SqliteWrapUpRepository, SqliteWrapUpStatsQuery, URL_KEYED_USER_COLUMNS, create_federated_profile_query, create_image_ref, create_person_adapter, create_search_adapter, }; diff --git a/crates/adapters/sqlite/src/repositories/account_purge/mod.rs b/crates/adapters/sqlite/src/repositories/account_purge/mod.rs index ff48f97a..389667ee 100644 --- a/crates/adapters/sqlite/src/repositories/account_purge/mod.rs +++ b/crates/adapters/sqlite/src/repositories/account_purge/mod.rs @@ -34,9 +34,19 @@ pub const PURGED_USER_TABLES: &[(&str, &str)] = &[ /// the keypair is needed to sign queued federation deliveries. pub const RETAINED_USER_TABLES: &[&str] = &["ap_local_actors"]; -/// Tables with no user column whose rows hang off the user's content or actor -/// URL; the purge clears them by URL. -pub const URL_KEYED_USER_TABLES: &[&str] = &["ap_announces", "ap_followers", "ap_following"]; +/// Columns that identify the user by actor or content URL rather than by id. +/// The purge matches rows on them; the guard test asserts each still exists so +/// a rename cannot silently stop the purge from clearing them. +pub const URL_KEYED_USER_COLUMNS: &[(&str, &str)] = &[ + ("ap_announces", "object_url"), + ("ap_announces", "actor_url"), + ("ap_followers", "remote_actor_url"), + ("ap_following", "remote_actor_url"), +]; + +/// Columns holding serialized events and activities that carry the user's id +/// (the outbox log keeps finished rows, including authored activity JSON). +pub const PAYLOAD_KEYED_USER_COLUMNS: &[(&str, &str)] = &[("event_queue", "payload")]; pub struct SqliteAccountPurger { pool: SqlitePool, @@ -116,6 +126,22 @@ impl AccountPurger for SqliteAccountPurger { .await .map_err(map_sqlx_error)?; + // The outbox log: events and signed activities authored as this user. + // The `UserDeleted` row (which triggered this run) and the pending + // `Delete` deliveries must stay; both go with the tombstone. + sqlx::query( + "DELETE FROM event_queue + WHERE instr(payload, ?1) > 0 + AND event_type != 'UserDeleted' + AND NOT (event_type = 'FederationDeliveryRequested' + AND status IN ('pending', 'processing') + AND instr(payload, '\"type\":\"Delete\"') > 0)", + ) + .bind(&id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + // Other accounts' follows of this one. for table in ["ap_followers", "ap_following"] { sqlx::query(&format!("DELETE FROM {table} WHERE remote_actor_url = ?")) @@ -158,6 +184,22 @@ impl AccountPurger for SqliteAccountPurger { ) -> Result { let cutoff = cutoff.format(TIMESTAMP_FORMAT).to_string(); let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + let expiring: Vec = sqlx::query_scalar( + "SELECT id FROM users WHERE purged_at IS NOT NULL AND purged_at < ?", + ) + .bind(&cutoff) + .fetch_all(&mut *tx) + .await + .map_err(map_sqlx_error)?; + for id in &expiring { + // What the purge had to leave: the `UserDeleted` row and the + // `Delete` deliveries. + sqlx::query("DELETE FROM event_queue WHERE instr(payload, ?) > 0") + .bind(id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + } for table in RETAINED_USER_TABLES { sqlx::query(&format!( "DELETE FROM {table} WHERE user_id IN ( diff --git a/crates/adapters/sqlite/src/repositories/mod.rs b/crates/adapters/sqlite/src/repositories/mod.rs index 5d67dadf..f51576b4 100644 --- a/crates/adapters/sqlite/src/repositories/mod.rs +++ b/crates/adapters/sqlite/src/repositories/mod.rs @@ -25,7 +25,8 @@ mod watchlist; mod wrapup; pub use account_purge::{ - PURGED_USER_TABLES, RETAINED_USER_TABLES, SqliteAccountPurger, URL_KEYED_USER_TABLES, + PAYLOAD_KEYED_USER_COLUMNS, PURGED_USER_TABLES, RETAINED_USER_TABLES, SqliteAccountPurger, + URL_KEYED_USER_COLUMNS, }; pub use ap_content::SqliteApContentQuery; pub use diary::SqliteDiaryRepository; diff --git a/crates/adapters/sqlite/tests/account_purge_test.rs b/crates/adapters/sqlite/tests/account_purge_test.rs index df0b72d9..59e91cda 100644 --- a/crates/adapters/sqlite/tests/account_purge_test.rs +++ b/crates/adapters/sqlite/tests/account_purge_test.rs @@ -14,9 +14,9 @@ use domain::ports::{ use domain::testing::{test_goal, test_movie, test_review, test_user, test_watched_at}; use domain::value_objects::{FollowStatus, InstanceIdentity, UserId}; use sqlite::{ - PURGED_USER_TABLES, RETAINED_USER_TABLES, SqliteAccountPurger, SqliteProfileFieldsRepository, - SqliteSocialRepository, SqliteWatchEventRepository, SqliteWebhookTokenRepository, - URL_KEYED_USER_TABLES, + PAYLOAD_KEYED_USER_COLUMNS, PURGED_USER_TABLES, RETAINED_USER_TABLES, SqliteAccountPurger, + SqliteProfileFieldsRepository, SqliteSocialRepository, SqliteWatchEventRepository, + SqliteWebhookTokenRepository, URL_KEYED_USER_COLUMNS, }; use sqlx::SqlitePool; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; @@ -266,6 +266,45 @@ async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded { ) .await; } + for (event_type, status, payload) in [ + ( + "ReviewLogged", + "done", + format!(r#"{{"user_id":"{a_str}","comment":"secret"}}"#), + ), + ( + "FederationDeliveryRequested", + "done", + format!( + r#"{{"signing_actor_id":"{a_str}","activity_json":{{"type":"Create","content":"secret"}}}}"# + ), + ), + ( + "FederationDeliveryRequested", + "pending", + format!( + r#"{{"signing_actor_id":"{a_str}","activity_json":{{"type":"Delete","id":"x"}}}}"# + ), + ), + ( + "UserDeleted", + "processing", + format!(r#"{{"user_id":"{a_str}"}}"#), + ), + ( + "ReviewLogged", + "done", + format!(r#"{{"user_id":"{b_str}"}}"#), + ), + ] { + sqlx::query("INSERT INTO event_queue (event_type, payload, status) VALUES (?, ?, ?)") + .bind(event_type) + .bind(payload) + .bind(status) + .execute(pool) + .await + .unwrap(); + } Seeded { user: a, other: b, @@ -332,6 +371,24 @@ async fn purge_removes_every_user_owned_row() { .unwrap(); assert_eq!(announces, vec!["an3"], "only bob's announce survives"); + let queue: Vec<(String, String)> = + sqlx::query_as("SELECT event_type, status FROM event_queue ORDER BY id") + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + queue, + vec![ + ( + "FederationDeliveryRequested".to_string(), + "pending".to_string() + ), + ("UserDeleted".to_string(), "processing".to_string()), + ("ReviewLogged".to_string(), "done".to_string()), + ], + "the user's authored events and activities are gone; the pending Delete, the trigger and bob's event stay" + ); + // The other account is untouched. for (table, column) in PURGED_USER_TABLES { assert!( @@ -384,17 +441,36 @@ async fn the_purge_table_lists_cover_every_user_referencing_column_in_the_schema "{t} has a foreign key to users but is not in the purge lists" ); } - // URL-keyed tables must exist, so a rename cannot silently drop them. - for t in URL_KEYED_USER_TABLES { - let n: i64 = count( - &pool, - &format!("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='{t}'"), - ) - .await; - assert_eq!(n, 1, "{t} is listed as URL-keyed but missing"); + // Columns the purge matches by URL or payload must still exist, so a + // rename fails here instead of silently leaving rows behind. + for (table, column) in URL_KEYED_USER_COLUMNS + .iter() + .chain(PAYLOAD_KEYED_USER_COLUMNS) + { + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?") + .bind(table) + .bind(column) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(n, 1, "{table}.{column} is matched by the purge but missing"); } } +/// `ap_activities` (k-ap's inbound dedup) holds only remote activity ids and a +/// timestamp: nothing in it is attributable to a local user. If it ever grows a +/// column, the purge must start clearing it. +#[tokio::test] +async fn ap_activities_holds_nothing_attributable_to_a_local_user() { + let pool = migrated_pool().await; + let columns: Vec = + sqlx::query_scalar("SELECT name FROM pragma_table_info('ap_activities') ORDER BY name") + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(columns, vec!["id", "processed_at"]); +} + #[tokio::test] async fn purge_keeps_movies_and_metadata() { let pool = migrated_pool().await; diff --git a/crates/application/tests/users/purge_account_test.rs b/crates/application/tests/users/purge_account_test.rs index 5c2103c2..e29f6b6c 100644 --- a/crates/application/tests/users/purge_account_test.rs +++ b/crates/application/tests/users/purge_account_test.rs @@ -90,6 +90,7 @@ impl ObjectStorage for Storage { struct Fixture { handler: Arc, + purger: Arc, log: Log, store: Arc, user: UserId, @@ -103,21 +104,23 @@ async fn fixture(notify_fails: bool, tombstoned: bool) -> Fixture { store.mark_deleted(alice.id(), &[]).await.unwrap(); } let log: Log = Arc::default(); + let purger = Arc::new(Purger { + log: log.clone(), + purged: Mutex::new(false), + keys: vec!["avatars/a.webp".into(), "banners/a.webp".into()], + }); let handler = Arc::new(AccountPurgeHandler::new( store.clone(), Arc::new(Notifier { log: log.clone(), fail: notify_fails, }), - Arc::new(Purger { - log: log.clone(), - purged: Mutex::new(false), - keys: vec!["avatars/a.webp".into(), "banners/a.webp".into()], - }), + purger.clone(), Arc::new(Storage { log: log.clone() }), )); Fixture { handler, + purger, log, store, user: alice.id().clone(), @@ -251,3 +254,25 @@ async fn a_redelivered_event_is_skipped_by_the_ledger() { let purges = entries(&f.log).iter().filter(|e| *e == "purge").count(); assert_eq!(purges, 1); } + +/// The rows were committed but the worker died before deleting the image +/// objects: the redelivery finds the account purged, still sees the image +/// references, deletes the objects and clears the references. +#[tokio::test] +async fn a_rerun_after_a_crash_following_the_commit_deletes_the_images() { + let f = fixture(false, true).await; + *f.purger.purged.lock().unwrap() = true; + + f.handler.handle(&deleted(&f.user)).await.unwrap(); + + assert_eq!( + entries(&f.log), + [ + "purge", + "delete avatars/a.webp", + "delete banners/a.webp", + "forget" + ], + "no second federation notice, images removed and forgotten" + ); +} diff --git a/crates/bootstrap/tests/bootstrap_test.rs b/crates/bootstrap/tests/bootstrap_test.rs index 8bcd9ef5..175f672c 100644 --- a/crates/bootstrap/tests/bootstrap_test.rs +++ b/crates/bootstrap/tests/bootstrap_test.rs @@ -300,3 +300,32 @@ async fn actor_document_for_a_tombstone_is_gone() { .await; assert_eq!(status, 404, "the classic profile page stays 404"); } + +/// Remote servers posting to a deleted account's inbox are told it is gone, +/// before any signature check. +#[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}"); +} -- 2.49.1 From 54c8433c786a707f39e8d8b59a8a7d20e2c37630 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 02:43:11 +0100 Subject: [PATCH 081/104] purge: erase only finished outbox rows; cleanup waits for unfinished events --- .../src/repositories/account_purge/mod.rs | 54 ++++++++++--------- .../sqlite/tests/account_purge_test.rs | 51 +++++++++++++++++- 2 files changed, 78 insertions(+), 27 deletions(-) diff --git a/crates/adapters/sqlite/src/repositories/account_purge/mod.rs b/crates/adapters/sqlite/src/repositories/account_purge/mod.rs index 389667ee..ee0fd676 100644 --- a/crates/adapters/sqlite/src/repositories/account_purge/mod.rs +++ b/crates/adapters/sqlite/src/repositories/account_purge/mod.rs @@ -126,16 +126,16 @@ impl AccountPurger for SqliteAccountPurger { .await .map_err(map_sqlx_error)?; - // The outbox log: events and signed activities authored as this user. - // The `UserDeleted` row (which triggered this run) and the pending - // `Delete` deliveries must stay; both go with the tombstone. + // The outbox log: finished events and activities authored as, or + // mentioning, this user. Unfinished rows are never touched (they + // belong to other users' work too and finish or dead-letter on their + // own); the tombstone cleanup removes their history later. The + // `UserDeleted` row that triggered this run stays until then as well. sqlx::query( "DELETE FROM event_queue WHERE instr(payload, ?1) > 0 AND event_type != 'UserDeleted' - AND NOT (event_type = 'FederationDeliveryRequested' - AND status IN ('pending', 'processing') - AND instr(payload, '\"type\":\"Delete\"') > 0)", + AND status IN ('done', 'dead_lettered')", ) .bind(&id) .execute(&mut *tx) @@ -184,40 +184,42 @@ impl AccountPurger for SqliteAccountPurger { ) -> Result { let cutoff = cutoff.format(TIMESTAMP_FORMAT).to_string(); let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + // A tombstone whose id is still named by an unfinished event waits for + // the next run: that event may yet be handled, and its history row + // must be erased with the rest. let expiring: Vec = sqlx::query_scalar( - "SELECT id FROM users WHERE purged_at IS NOT NULL AND purged_at < ?", + "SELECT id FROM users u + WHERE purged_at IS NOT NULL AND purged_at < ? + AND NOT EXISTS ( + SELECT 1 FROM event_queue q + WHERE q.status NOT IN ('done', 'dead_lettered') + AND instr(q.payload, u.id) > 0)", ) .bind(&cutoff) .fetch_all(&mut *tx) .await .map_err(map_sqlx_error)?; for id in &expiring { - // What the purge had to leave: the `UserDeleted` row and the - // `Delete` deliveries. + // Includes the `UserDeleted` row and the `Delete` deliveries. sqlx::query("DELETE FROM event_queue WHERE instr(payload, ?) > 0") .bind(id) .execute(&mut *tx) .await .map_err(map_sqlx_error)?; - } - for table in RETAINED_USER_TABLES { - sqlx::query(&format!( - "DELETE FROM {table} WHERE user_id IN ( - SELECT id FROM users WHERE purged_at IS NOT NULL AND purged_at < ?)" - )) - .bind(&cutoff) - .execute(&mut *tx) - .await - .map_err(map_sqlx_error)?; - } - let removed = - sqlx::query("DELETE FROM users WHERE purged_at IS NOT NULL AND purged_at < ?") - .bind(&cutoff) + for table in RETAINED_USER_TABLES { + sqlx::query(&format!("DELETE FROM {table} WHERE user_id = ?")) + .bind(id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + } + sqlx::query("DELETE FROM users WHERE id = ?") + .bind(id) .execute(&mut *tx) .await - .map_err(map_sqlx_error)? - .rows_affected(); + .map_err(map_sqlx_error)?; + } tx.commit().await.map_err(map_sqlx_error)?; - Ok(removed) + Ok(expiring.len() as u64) } } diff --git a/crates/adapters/sqlite/tests/account_purge_test.rs b/crates/adapters/sqlite/tests/account_purge_test.rs index 59e91cda..0e5a40fa 100644 --- a/crates/adapters/sqlite/tests/account_purge_test.rs +++ b/crates/adapters/sqlite/tests/account_purge_test.rs @@ -296,6 +296,21 @@ async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded { "done", format!(r#"{{"user_id":"{b_str}"}}"#), ), + ( + "FollowRequested", + "pending", + format!(r#"{{"follower_id":"{b_str}","target":"{a_str}"}}"#), + ), + ( + "ActorBlocked", + "processing", + format!(r#"{{"user_id":"{b_str}","actor":"{a_str}"}}"#), + ), + ( + "Unfollowed", + "done", + format!(r#"{{"follower_id":"{b_str}","target":"{a_str}"}}"#), + ), ] { sqlx::query("INSERT INTO event_queue (event_type, payload, status) VALUES (?, ?, ?)") .bind(event_type) @@ -385,8 +400,10 @@ async fn purge_removes_every_user_owned_row() { ), ("UserDeleted".to_string(), "processing".to_string()), ("ReviewLogged".to_string(), "done".to_string()), + ("FollowRequested".to_string(), "pending".to_string()), + ("ActorBlocked".to_string(), "processing".to_string()), ], - "the user's authored events and activities are gone; the pending Delete, the trigger and bob's event stay" + "finished rows mentioning the user are gone; pending/processing rows (incl. other users' events about her) and bob's own rows stay" ); // The other account is untouched. @@ -657,12 +674,44 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o assert_eq!(removed, 0); assert_eq!(count_for(&pool, "users", "id", &s.user).await, 1); + // Unfinished events still name the user (the pending Delete, bob's + // pending FollowRequested, a processing row): the tombstone waits and no + // unfinished row is ever deleted. + let alice = s.user.value().to_string(); + let unfinished = || async { + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM event_queue WHERE status IN ('pending', 'processing')", + ) + .fetch_one(&pool) + .await + .unwrap() + }; + let before = unfinished().await; + assert!(before >= 3); + let removed = p + .purge_expired_tombstones(now - chrono::Duration::days(7)) + .await + .unwrap(); + assert_eq!(removed, 0, "an unfinished event still names the user"); + assert_eq!(count_for(&pool, "users", "id", &s.user).await, 1); + assert_eq!(unfinished().await, before); + + // Once they finish, the cleanup takes the tombstone and the history. + exec(&pool, "UPDATE event_queue SET status = 'done'", &[]).await; let removed = p .purge_expired_tombstones(now - chrono::Duration::days(7)) .await .unwrap(); assert_eq!(removed, 1); assert_eq!(count_for(&pool, "users", "id", &s.user).await, 0); + let left: Vec = sqlx::query_scalar("SELECT payload FROM event_queue") + .fetch_all(&pool) + .await + .unwrap(); + assert!( + left.iter().all(|p| !p.contains(&alice)), + "no history of the user remains: {left:?}" + ); assert_eq!( count_for(&pool, "ap_local_actors", "user_id", &s.user).await, 0 -- 2.49.1 From 0beedfbf0a5759302a6bc257808bc0e0b2f168af Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 02:49:07 +0100 Subject: [PATCH 082/104] feat: account deletion endpoints --- crates/adapters/http-axum/src/api/mod.rs | 6 +- crates/adapters/http-axum/src/api/users.rs | 66 +++++- crates/adapters/http-axum/src/html/auth.rs | 2 +- crates/adapters/http-axum/src/html/forms.rs | 7 + crates/adapters/http-axum/src/html/mod.rs | 5 + .../http-axum/src/html/templates/users.rs | 1 + crates/adapters/http-axum/src/html/users.rs | 68 +++++- .../adapters/http-axum/src/openapi/users.rs | 8 +- .../adapters/http-axum/templates/profile.html | 14 ++ .../http-axum/templates/profile_settings.html | 18 ++ crates/api-types/src/users.rs | 6 + .../bootstrap/tests/account_deletion_test.rs | 223 ++++++++++++++++++ .../tests/fixtures/openapi_paths.txt | 2 + .../fixtures/openapi_paths_federation_off.txt | 2 + 14 files changed, 420 insertions(+), 8 deletions(-) create mode 100644 crates/bootstrap/tests/account_deletion_test.rs diff --git a/crates/adapters/http-axum/src/api/mod.rs b/crates/adapters/http-axum/src/api/mod.rs index 95440c08..5ab3d146 100644 --- a/crates/adapters/http-axum/src/api/mod.rs +++ b/crates/adapters/http-axum/src/api/mod.rs @@ -48,7 +48,11 @@ pub fn router() -> Router { .route("/diary/export", routing::get(diary::export_diary)) .route("/activity-feed", routing::get(diary::get_activity_feed)) .route("/users", routing::get(users::list_users)) - .route("/users/{id}", routing::get(user_profile::get_user_profile)) + .route("/users/me", routing::delete(users::delete_me)) + .route( + "/users/{id}", + routing::get(user_profile::get_user_profile).delete(users::delete_user), + ) .route("/import/sessions", routing::post(import::api_post_session)) .route( "/import/sessions/{id}", diff --git a/crates/adapters/http-axum/src/api/users.rs b/crates/adapters/http-axum/src/api/users.rs index bbf4d737..f7d7df1e 100644 --- a/crates/adapters/http-axum/src/api/users.rs +++ b/crates/adapters/http-axum/src/api/users.rs @@ -1,10 +1,12 @@ -use crate::extractors::{Body, Upload}; +use crate::extractors::{Body, PathId, Upload}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; -use api_types::{ProfileResponse, UserSummaryDto, UsersResponse}; +use api_types::{DeleteAccountRequest, ProfileResponse, UserSummaryDto, UsersResponse}; use application::users::{ - get_users, queries::GetUsersQuery, update_profile, update_profile_fields, + get_users, queries::GetUsersQuery, request_account_deletion, update_profile, + update_profile_fields, }; use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; +use domain::value_objects::UserId; #[utoipa::path( get, path = "/api/v1/profile", @@ -155,3 +157,61 @@ pub async fn list_users(State(state): State) -> Result Result { + request_account_deletion::execute( + &state.app_ctx.deps.users.request_account_deletion, + actor, + target, + password, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + delete, path = "/api/v1/users/me", + request_body = DeleteAccountRequest, + responses( + (status = 204, description = "Account deleted"), + (status = 400, description = "Invalid input", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized or incorrect password", body = api_types::ErrorResponse), + (status = 409, description = "The last admin cannot be deleted", body = api_types::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_me( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Body(req): Body, +) -> Result { + delete_account(&state, user_id.clone(), user_id, &req.password).await +} + +#[utoipa::path( + delete, path = "/api/v1/users/{id}", + params(("id" = uuid::Uuid, Path, description = "User ID")), + request_body = DeleteAccountRequest, + responses( + (status = 204, description = "Account deleted"), + (status = 400, description = "Invalid input", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized or incorrect password", body = api_types::ErrorResponse), + (status = 403, description = "Admin only", body = api_types::ErrorResponse), + (status = 404, description = "User not found", body = api_types::ErrorResponse), + (status = 409, description = "The last admin cannot be deleted", body = api_types::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_user( + State(state): State, + AuthenticatedUser(actor): AuthenticatedUser, + PathId(target): PathId, + Body(req): Body, +) -> Result { + delete_account(&state, actor, UserId::from_uuid(target), &req.password).await +} diff --git a/crates/adapters/http-axum/src/html/auth.rs b/crates/adapters/http-axum/src/html/auth.rs index b3f02252..fcc82622 100644 --- a/crates/adapters/http-axum/src/html/auth.rs +++ b/crates/adapters/http-axum/src/html/auth.rs @@ -82,7 +82,7 @@ pub async fn post_login( } } -pub async fn get_logout(State(state): State) -> impl IntoResponse { +pub(super) async fn get_logout(State(state): State) -> impl IntoResponse { let val = format!( "token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0{}", secure_flag(state.app_ctx.config.secure_cookies) diff --git a/crates/adapters/http-axum/src/html/forms.rs b/crates/adapters/http-axum/src/html/forms.rs index 835b18e9..7cb8b8d5 100644 --- a/crates/adapters/http-axum/src/html/forms.rs +++ b/crates/adapters/http-axum/src/html/forms.rs @@ -62,6 +62,13 @@ pub struct RegisterForm { pub csrf_token: String, } +#[derive(Deserialize)] +pub struct DeleteAccountForm { + pub password: String, + #[serde(rename = "_csrf", default)] + pub csrf_token: String, +} + #[derive(Deserialize)] pub struct ErrorQuery { pub error: Option, diff --git a/crates/adapters/http-axum/src/html/mod.rs b/crates/adapters/http-axum/src/html/mod.rs index e735855e..52e40020 100644 --- a/crates/adapters/http-axum/src/html/mod.rs +++ b/crates/adapters/http-axum/src/html/mod.rs @@ -73,6 +73,11 @@ pub fn router() -> Router { "/settings/profile", routing::get(users::get_profile_settings).post(users::post_profile_settings), ) + .route( + "/settings/account/delete", + routing::post(users::post_delete_own_account), + ) + .route("/users/{id}/delete", routing::post(users::post_delete_user)) .route("/tags/{tag}", routing::get(tags::get_tag)) .route( "/users/{id}/watchlist", diff --git a/crates/adapters/http-axum/src/html/templates/users.rs b/crates/adapters/http-axum/src/html/templates/users.rs index 0e514ea9..cd7fbf15 100644 --- a/crates/adapters/http-axum/src/html/templates/users.rs +++ b/crates/adapters/http-axum/src/html/templates/users.rs @@ -189,5 +189,6 @@ pub struct ProfileSettingsTemplate<'a> { pub also_known_as: Option<&'a str>, pub profile_fields: &'a [(String, String)], pub saved: bool, + pub error: Option<&'a str>, pub embed_url: String, } diff --git a/crates/adapters/http-axum/src/html/users.rs b/crates/adapters/http-axum/src/html/users.rs index b6621b70..a2950f1b 100644 --- a/crates/adapters/http-axum/src/html/users.rs +++ b/crates/adapters/http-axum/src/html/users.rs @@ -1,3 +1,4 @@ +use crate::html::forms::DeleteAccountForm; use crate::html::helpers::build_page_context; use crate::html::templates::{ ProfileSettingsTemplate, RemoteActorDisplay, UserSummaryView, UsersTemplate, @@ -13,11 +14,16 @@ use application::users::{ update_profile_fields, }; use axum::{ + Form, extract::{Extension, Multipart, Path, Query, State}, http::StatusCode, response::IntoResponse, }; -use domain::{errors::DomainError, value_objects::Username}; +use domain::{ + errors::DomainError, + value_objects::{UserId, Username}, +}; +use uuid::Uuid; pub async fn get_users_list( OptionalCookieUser(user_id): OptionalCookieUser, @@ -76,6 +82,7 @@ pub async fn get_user_by_username( #[derive(serde::Deserialize, Default)] pub struct SavedQuery { pub saved: Option, + pub error: Option, } pub async fn get_profile_settings( @@ -109,6 +116,7 @@ pub async fn get_profile_settings( also_known_as: settings.also_known_as.as_deref(), profile_fields: &settings.fields, saved, + error: params.error.as_deref(), embed_url: format!( "{}/users/{}?embed=true", state.app_ctx.config.base_url, @@ -177,3 +185,61 @@ pub async fn post_profile_settings( axum::response::Redirect::to("/settings/profile?saved=1").into_response() } + +async fn delete_account( + state: &AppState, + actor: UserId, + target: UserId, + password: &str, +) -> Result<(), DomainError> { + application::users::request_account_deletion::execute( + &state.app_ctx.deps.users.request_account_deletion, + actor, + target, + password, + ) + .await +} + +pub async fn post_delete_own_account( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match delete_account(&state, user_id.clone(), user_id, &form.password).await { + Ok(()) => super::auth::get_logout(State(state)).await.into_response(), + Err(e) => axum::response::Redirect::to(&format!( + "/settings/profile?error={}", + crate::html::helpers::encode_domain_error(&e) + )) + .into_response(), + } +} + +pub async fn post_delete_user( + RequiredCookieUser(actor): RequiredCookieUser, + State(state): State, + Extension(csrf): Extension, + Path(target): Path, + Form(form): Form, +) -> impl IntoResponse { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let target_id = UserId::from_uuid(target); + let own = actor == target_id; + match delete_account(&state, actor, target_id, &form.password).await { + // An admin removing their own account ends their own session too. + Ok(()) if own => super::auth::get_logout(State(state)).await.into_response(), + Ok(()) => axum::response::Redirect::to("/users").into_response(), + Err(e) => axum::response::Redirect::to(&format!( + "/users/{target}?error={}", + crate::html::helpers::encode_domain_error(&e) + )) + .into_response(), + } +} diff --git a/crates/adapters/http-axum/src/openapi/users.rs b/crates/adapters/http-axum/src/openapi/users.rs index 6bbd4961..7c297909 100644 --- a/crates/adapters/http-axum/src/openapi/users.rs +++ b/crates/adapters/http-axum/src/openapi/users.rs @@ -1,6 +1,7 @@ use api_types::{ - ProfileFieldDto, ProfileResponse, ProfileViewData, UpdateProfileFieldsRequest, UserProfileBase, - UserProfileResponse, UserStatsDto, UserSummaryDto, UsersResponse, + DeleteAccountRequest, ProfileFieldDto, ProfileResponse, ProfileViewData, + UpdateProfileFieldsRequest, UserProfileBase, UserProfileResponse, UserStatsDto, UserSummaryDto, + UsersResponse, }; use utoipa::OpenApi; @@ -12,6 +13,8 @@ use utoipa::OpenApi; crate::api::users::get_profile, crate::api::users::update_profile_handler, crate::api::users::update_profile_fields_handler, + crate::api::users::delete_me, + crate::api::users::delete_user, ), components(schemas( UsersResponse, @@ -23,6 +26,7 @@ use utoipa::OpenApi; ProfileResponse, UpdateProfileFieldsRequest, ProfileFieldDto, + DeleteAccountRequest, )) )] pub struct UsersDoc; diff --git a/crates/adapters/http-axum/templates/profile.html b/crates/adapters/http-axum/templates/profile.html index a4111264..bfbbd4fd 100644 --- a/crates/adapters/http-axum/templates/profile.html +++ b/crates/adapters/http-axum/templates/profile.html @@ -97,6 +97,20 @@ {% endif %} + {% if ctx.is_admin && !is_own_profile %} +
+

Admin

+ {% if let Some(err) = error %} +

{{ err }}

+ {% endif %} +
+ + + +
+
+ {% endif %} +
Recent Top Rated diff --git a/crates/adapters/http-axum/templates/profile_settings.html b/crates/adapters/http-axum/templates/profile_settings.html index 86be4691..6d24c2f2 100644 --- a/crates/adapters/http-axum/templates/profile_settings.html +++ b/crates/adapters/http-axum/templates/profile_settings.html @@ -7,6 +7,9 @@ {% if saved %}

Saved.

{% endif %} +{% if let Some(err) = error %} +

{{ err }}

+{% endif %}
@@ -62,4 +65,19 @@

Add your diary to your blog or website:

<iframe src="{{ embed_url }}" width="100%" height="600" style="border:none;border-radius:8px" loading="lazy"></iframe>
+ +
+

Delete account

+

+ Permanently removes your account and everything on it: reviews, watchlist, goals, follows and uploaded images. This cannot be undone. +

+ + + + + +
{% endblock %} diff --git a/crates/api-types/src/users.rs b/crates/api-types/src/users.rs index 17f783c5..5e8fbe8a 100644 --- a/crates/api-types/src/users.rs +++ b/crates/api-types/src/users.rs @@ -153,6 +153,12 @@ pub struct UpdateProfileFieldsRequest { pub fields: Vec, } +/// Proof of intent for account deletion: the acting user's own password. +#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] +pub struct DeleteAccountRequest { + pub password: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserSettingsDto { pub federate_goals: bool, diff --git a/crates/bootstrap/tests/account_deletion_test.rs b/crates/bootstrap/tests/account_deletion_test.rs new file mode 100644 index 00000000..d87ab695 --- /dev/null +++ b/crates/bootstrap/tests/account_deletion_test.rs @@ -0,0 +1,223 @@ +//! Account deletion over HTTP: JSON and classic endpoints on the real wiring. + +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 { + json_request( + Method::DELETE, + uri, + Some(token), + serde_json::json!({ "password": password }), + ) +} + +fn classic_post(uri: &str, cookie: &str, password: &str) -> Request { + 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}"); + 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, _, _) = send(&router, delete_json(&uri, &alice.token, "nope")).await; + assert_eq!(status, 401, "wrong admin password"); + + 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, 303); + assert!( + location(&headers).contains("error="), + "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); +} diff --git a/crates/bootstrap/tests/fixtures/openapi_paths.txt b/crates/bootstrap/tests/fixtures/openapi_paths.txt index 851c39a0..4d8d7a84 100644 --- a/crates/bootstrap/tests/fixtures/openapi_paths.txt +++ b/crates/bootstrap/tests/fixtures/openapi_paths.txt @@ -3,6 +3,8 @@ 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 diff --git a/crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt b/crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt index ea1b1b54..6687aaab 100644 --- a/crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt +++ b/crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt @@ -2,6 +2,8 @@ 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 -- 2.49.1 From 6aebad1f0fac5a78b7afaa6a3c1fe62d2985ff9c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 02:51:52 +0100 Subject: [PATCH 083/104] feat: classic ui account deletion --- crates/adapters/http-axum/src/html/users.rs | 3 + .../adapters/http-axum/templates/profile.html | 3 +- .../http-axum/templates/profile_settings.html | 4 +- .../bootstrap/tests/account_deletion_test.rs | 96 ++++++++++++++++++- 4 files changed, 98 insertions(+), 8 deletions(-) diff --git a/crates/adapters/http-axum/src/html/users.rs b/crates/adapters/http-axum/src/html/users.rs index a2950f1b..fce35519 100644 --- a/crates/adapters/http-axum/src/html/users.rs +++ b/crates/adapters/http-axum/src/html/users.rs @@ -236,6 +236,9 @@ pub async fn post_delete_user( // An admin removing their own account ends their own session too. Ok(()) if own => super::auth::get_logout(State(state)).await.into_response(), Ok(()) => axum::response::Redirect::to("/users").into_response(), + // Same as other forbidden classic actions: a plain 403, not a redirect + // to a page that would not show the error. + Err(e @ DomainError::Forbidden(_)) => crate::errors::domain_error_response(e), Err(e) => axum::response::Redirect::to(&format!( "/users/{target}?error={}", crate::html::helpers::encode_domain_error(&e) diff --git a/crates/adapters/http-axum/templates/profile.html b/crates/adapters/http-axum/templates/profile.html index bfbbd4fd..f8ba0a75 100644 --- a/crates/adapters/http-axum/templates/profile.html +++ b/crates/adapters/http-axum/templates/profile.html @@ -103,10 +103,11 @@ {% if let Some(err) = error %}

{{ err }}

{% endif %} +

This user and all their data will be permanently deleted. Enter your own password to confirm.

- +
{% endif %} diff --git a/crates/adapters/http-axum/templates/profile_settings.html b/crates/adapters/http-axum/templates/profile_settings.html index 6d24c2f2..0dcdc9c7 100644 --- a/crates/adapters/http-axum/templates/profile_settings.html +++ b/crates/adapters/http-axum/templates/profile_settings.html @@ -69,7 +69,7 @@

Delete account

- Permanently removes your account and everything on it: reviews, watchlist, goals, follows and uploaded images. This cannot be undone. + Deleting your account is permanent and cannot be undone. Your reviews, watchlist, goals and follows are removed, fediverse servers are notified, and your username can never be reused.

@@ -77,7 +77,7 @@ Confirm with your password
- +
{% endblock %} diff --git a/crates/bootstrap/tests/account_deletion_test.rs b/crates/bootstrap/tests/account_deletion_test.rs index d87ab695..cfbb758d 100644 --- a/crates/bootstrap/tests/account_deletion_test.rs +++ b/crates/bootstrap/tests/account_deletion_test.rs @@ -206,11 +206,7 @@ async fn classic_admin_delete_redirects_to_the_member_list() { let uri = format!("/users/{}/delete", bob.user_id); let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await; - assert_eq!(status, 303); - assert!( - location(&headers).contains("error="), - "non-admin: {headers:?}" - ); + 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; @@ -221,3 +217,93 @@ async fn classic_admin_delete_redirects_to_the_member_list() { 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); + + // Sole admin: refused, redirected back with an error, still logged in. + 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); + + // With a second admin the self-delete goes through and ends the session. + 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"); + + // A non-admin viewing someone else, and anonymous viewers, see no form. + let bob_page = format!("/users/{}", bob.user_id); + 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"); +} -- 2.49.1 From 471421609364ee15ac3400617d09947b43acac3b Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 02:54:36 +0100 Subject: [PATCH 084/104] feat: spa account deletion --- spa/src/components/delete-account-dialog.tsx | 110 +++++++++++++++++++ spa/src/features/users.ts | 22 +++- spa/src/lib/api/client.ts | 11 ++ spa/src/locales/en.json | 24 +++- spa/src/routes/_app/settings/index.tsx | 68 +++++++++++- spa/src/routes/_app/users.$id.tsx | 50 ++++++++- 6 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 spa/src/components/delete-account-dialog.tsx diff --git a/spa/src/components/delete-account-dialog.tsx b/spa/src/components/delete-account-dialog.tsx new file mode 100644 index 00000000..d9c670c5 --- /dev/null +++ b/spa/src/components/delete-account-dialog.tsx @@ -0,0 +1,110 @@ +import { useState } from "react" +import { useTranslation } from "react-i18next" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { ApiError } from "@/lib/api/client" + +function deletionErrorKey(err: unknown): string { + if (err instanceof ApiError) { + switch (err.status) { + case 401: + return "deleteUser.errors.wrongPassword" + case 403: + return "deleteUser.errors.forbidden" + case 404: + return "deleteUser.errors.notFound" + case 409: + return "deleteUser.errors.lastAdmin" + } + } + return "deleteUser.errors.generic" +} + +type Props = { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: string + passwordLabel: string + confirmLabel: string + pending: boolean + error: unknown + onSubmit: (password: string) => void +} + +export function DeleteAccountDialog({ + open, + onOpenChange, + title, + description, + passwordLabel, + confirmLabel, + pending, + error, + onSubmit, +}: Props) { + const { t } = useTranslation() + const [password, setPassword] = useState("") + + const handleOpenChange = (next: boolean) => { + if (!next) setPassword("") + onOpenChange(next) + } + + return ( + + +
{ + e.preventDefault() + if (password) onSubmit(password) + }} + > + + {title} + {description} + +
+ + setPassword(e.target.value)} + disabled={pending} + /> + {error != null && ( +

+ {t(deletionErrorKey(error))} +

+ )} +
+ + + + +
+
+
+ ) +} diff --git a/spa/src/features/users.ts b/spa/src/features/users.ts index fa370de9..1e17d0a5 100644 --- a/spa/src/features/users.ts +++ b/spa/src/features/users.ts @@ -1,7 +1,7 @@ import { z } from "zod" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { diaryEntryDtoSchema, paginatedSchema } from "@/lib/api/common" -import { get, post, put, putForm } from "@/lib/api/client" +import { delWithBody, get, post, put, putForm } from "@/lib/api/client" export const userSummaryDtoSchema = z.object({ id: z.string().uuid(), @@ -228,3 +228,23 @@ export function useUpdateProfileFields() { }, }) } + +export type DeleteAccountRequest = { password: string } + +export function useDeleteOwnAccount() { + return useMutation({ + mutationFn: (data: DeleteAccountRequest) => delWithBody("/users/me", data), + }) +} + +export function useAdminDeleteUser() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, password }: DeleteAccountRequest & { id: string }) => + delWithBody(`/users/${id}`, { password }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: userKeys.all }) + qc.invalidateQueries({ queryKey: ["activity-feed"] }) + }, + }) +} diff --git a/spa/src/lib/api/client.ts b/spa/src/lib/api/client.ts index 7ec90614..c07fac1b 100644 --- a/spa/src/lib/api/client.ts +++ b/spa/src/lib/api/client.ts @@ -172,6 +172,17 @@ export async function del(path: string): Promise { return request(buildUrl(path), { method: "DELETE" }) } +export async function delWithBody( + path: string, + body: unknown, +): Promise { + return request(buildUrl(path), { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) +} + export async function upload( path: string, file: File, diff --git a/spa/src/locales/en.json b/spa/src/locales/en.json index c3818df8..87908e25 100644 --- a/spa/src/locales/en.json +++ b/spa/src/locales/en.json @@ -193,7 +193,29 @@ "exportDesc": "Download your diary", "exportCsv": "CSV", "exportJson": "JSON", - "exporting": "Exporting..." + "exporting": "Exporting...", + "dangerZone": "Danger zone", + "deleteAccount": "Delete account", + "deleteAccountDesc": "Permanently delete your account and everything in it", + "deleteAccountWarning": "This is permanent and cannot be undone. Your reviews, watchlist, goals and follows will be removed, fediverse servers will be notified that your account is gone, and your username can never be reused.", + "deleteAccountPassword": "Enter your password to confirm", + "deleteAccountConfirm": "Delete my account" + }, + "deleteUser": { + "action": "Delete user", + "title": "Delete {{name}}?", + "warning": "This permanently removes this user, their reviews, watchlist, goals and follows, and notifies fediverse servers. Their username can never be reused.", + "adminPassword": "Enter your admin password to confirm", + "confirm": "Delete user", + "password": "Password", + "done": "User deleted", + "errors": { + "wrongPassword": "Wrong password", + "lastAdmin": "You are the last admin and cannot be deleted", + "forbidden": "Only admins can delete other users", + "notFound": "User not found", + "generic": "Could not delete account" + } }, "goals": { "yearGoal": "{{year}} Goal", diff --git a/spa/src/routes/_app/settings/index.tsx b/spa/src/routes/_app/settings/index.tsx index d0281c64..e787537f 100644 --- a/spa/src/routes/_app/settings/index.tsx +++ b/spa/src/routes/_app/settings/index.tsx @@ -1,4 +1,5 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" +import { useState } from "react" import { useTranslation } from "react-i18next" import { useMutation } from "@tanstack/react-query" import { @@ -13,13 +14,15 @@ import { ShieldBan, Sparkles, Target, + Trash2, Upload, User, } from "lucide-react" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { useAuth, useIsAdmin } from "@/components/auth-provider" -import { reindexSearch } from "@/features/users" +import { DeleteAccountDialog } from "@/components/delete-account-dialog" +import { reindexSearch, useDeleteOwnAccount } from "@/features/users" import { useSettings, useUpdateSettings } from "@/features/goals" import type { UpdateUserSettingsRequest } from "@/features/goals" import { useDocumentTitle } from "@/hooks/use-document-title" @@ -121,6 +124,69 @@ function SettingsPage() { {t("settings.logOut")} + + +
+ ) +} + +function DangerZone() { + const { t } = useTranslation() + const { logout } = useAuth() + const navigate = useNavigate() + const [open, setOpen] = useState(false) + const del = useDeleteOwnAccount() + + const handleOpenChange = (next: boolean) => { + if (!next) del.reset() + setOpen(next) + } + + const submit = (password: string) => { + del.mutate( + { password }, + { + onSuccess: () => { + logout() + navigate({ to: "/login" }) + }, + }, + ) + } + + return ( +
+

{t("settings.dangerZone")}

+
+
+ + + +
+

{t("settings.deleteAccount")}

+

{t("settings.deleteAccountDesc")}

+
+ +
+
+
) } diff --git a/spa/src/routes/_app/users.$id.tsx b/spa/src/routes/_app/users.$id.tsx index 9fb99f1f..2c70fd21 100644 --- a/spa/src/routes/_app/users.$id.tsx +++ b/spa/src/routes/_app/users.$id.tsx @@ -1,14 +1,16 @@ -import { createFileRoute } from "@tanstack/react-router" +import { createFileRoute, useNavigate } from "@tanstack/react-router" import { useState } from "react" import { useTranslation } from "react-i18next" -import { Clock, ExternalLink, UserCheck, UserPlus } from "lucide-react" +import { Clock, ExternalLink, Trash2, UserCheck, UserPlus } from "lucide-react" import { BackButton } from "@/components/back-button" import { Button } from "@/components/ui/button" import { ConfirmDialog } from "@/components/confirm-dialog" import { ProfileView, ProfileSkeleton } from "@/components/profile-view" import { GoalCard } from "@/components/goal-card" -import { useAuth } from "@/components/auth-provider" -import { useUserProfile } from "@/features/users" +import { useAuth, useIsAdmin } from "@/components/auth-provider" +import { DeleteAccountDialog } from "@/components/delete-account-dialog" +import { toast } from "sonner" +import { useAdminDeleteUser, useUserProfile } from "@/features/users" import { useFollow, useUnfollow, useCancelFollow, useRelationship } from "@/features/social" import { useDocumentTitle } from "@/hooks/use-document-title" import type { FollowState } from "@/features/social" @@ -35,6 +37,10 @@ function UserProfilePage() { const [search, setSearch] = useState("") const [confirmOpen, setConfirmOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + const isAdmin = useIsAdmin() + const navigate = useNavigate() + const adminDelete = useAdminDeleteUser() useDocumentTitle(data?.username) if (isPending) return @@ -142,6 +148,42 @@ function UserProfilePage() { )} + {isAdmin && !data.is_federated && ( + + )} + { + if (!o) adminDelete.reset() + setDeleteOpen(o) + }} + title={t("deleteUser.title", { name: data.display_name ?? data.username })} + description={t("deleteUser.warning")} + passwordLabel={t("deleteUser.adminPassword")} + confirmLabel={t("deleteUser.confirm")} + pending={adminDelete.isPending} + error={adminDelete.error} + onSubmit={(password) => + adminDelete.mutate( + { id, password }, + { + onSuccess: () => { + toast.success(t("deleteUser.done")) + setDeleteOpen(false) + navigate({ to: "/social" }) + }, + }, + ) + } + /> Date: Mon, 26 Oct 2026 02:58:55 +0100 Subject: [PATCH 085/104] fix: invalid-credentials code, no refresh on wrong pw --- crates/adapters/http-axum/src/errors.rs | 8 +++++++- .../src/users/request_account_deletion.rs | 2 +- .../tests/users/request_account_deletion_test.rs | 10 ++++++++-- crates/bootstrap/tests/account_deletion_test.rs | 4 +++- crates/domain/src/errors/mod.rs | 3 +++ .../adr/0014-http-adapter-and-one-refusal-shape.md | 3 ++- spa/src/components/delete-account-dialog.tsx | 1 + spa/src/features/users.ts | 5 ++++- spa/src/lib/api/client.ts | 14 ++++++++++++++ spa/src/locales/en.json | 2 +- 10 files changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/adapters/http-axum/src/errors.rs b/crates/adapters/http-axum/src/errors.rs index 16f9fa29..d9b6b44e 100644 --- a/crates/adapters/http-axum/src/errors.rs +++ b/crates/adapters/http-axum/src/errors.rs @@ -8,6 +8,7 @@ use domain::errors::DomainError; pub const NOT_FOUND: &str = "NOT_FOUND"; pub const INVALID_INPUT: &str = "INVALID_INPUT"; pub const UNAUTHORIZED: &str = "UNAUTHORIZED"; +pub const INVALID_CREDENTIALS: &str = "INVALID_CREDENTIALS"; pub const FORBIDDEN: &str = "FORBIDDEN"; pub const CONFLICT: &str = "CONFLICT"; pub const RATE_LIMITED: &str = "RATE_LIMITED"; @@ -31,7 +32,9 @@ pub fn domain_error_status(e: &DomainError) -> StatusCode { StatusCode::BAD_REQUEST } DomainError::NotFound(_) => StatusCode::NOT_FOUND, - DomainError::Unauthorized(_) => StatusCode::UNAUTHORIZED, + DomainError::Unauthorized(_) | DomainError::InvalidCredentials(_) => { + StatusCode::UNAUTHORIZED + } DomainError::Forbidden(_) => StatusCode::FORBIDDEN, DomainError::Conflict(_) => StatusCode::CONFLICT, DomainError::InfrastructureError(_) => StatusCode::INTERNAL_SERVER_ERROR, @@ -65,6 +68,9 @@ impl IntoResponse for ApiError { } DomainError::NotFound(_) => refuse(status, NOT_FOUND, self.0.to_string()), DomainError::Unauthorized(_) => refuse(status, UNAUTHORIZED, self.0.to_string()), + DomainError::InvalidCredentials(_) => { + refuse(status, INVALID_CREDENTIALS, self.0.to_string()) + } DomainError::Forbidden(_) => refuse(status, FORBIDDEN, self.0.to_string()), DomainError::Conflict(_) => refuse(status, CONFLICT, self.0.to_string()), DomainError::InfrastructureError(_) => { diff --git a/crates/application/src/users/request_account_deletion.rs b/crates/application/src/users/request_account_deletion.rs index 2da7e595..de1546c0 100644 --- a/crates/application/src/users/request_account_deletion.rs +++ b/crates/application/src/users/request_account_deletion.rs @@ -30,7 +30,7 @@ pub async fn execute( .verify(password, actor_user.password_hash()) .await? { - return Err(DomainError::Unauthorized("incorrect password".into())); + return Err(DomainError::InvalidCredentials("incorrect password".into())); } let target_user = if is_self { diff --git a/crates/application/tests/users/request_account_deletion_test.rs b/crates/application/tests/users/request_account_deletion_test.rs index 456971fc..5fff6089 100644 --- a/crates/application/tests/users/request_account_deletion_test.rs +++ b/crates/application/tests/users/request_account_deletion_test.rs @@ -138,7 +138,10 @@ async fn a_wrong_password_is_unauthorized_and_changes_nothing() { .await .unwrap_err(); - assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}"); + assert!( + matches!(err, DomainError::InvalidCredentials(_)), + "got {err:?}" + ); assert!(f.is_live(&alice).await); assert!(f.store.recorded_events().is_empty()); } @@ -166,7 +169,10 @@ async fn an_admin_deletes_another_user_with_their_own_password() { let err = request_account_deletion::execute(&f.deps, admin.clone(), bob.clone(), "bobs-pw") .await .unwrap_err(); - assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}"); + assert!( + matches!(err, DomainError::InvalidCredentials(_)), + "got {err:?}" + ); assert!(f.is_live(&bob).await); request_account_deletion::execute(&f.deps, admin.clone(), bob.clone(), PASSWORD) diff --git a/crates/bootstrap/tests/account_deletion_test.rs b/crates/bootstrap/tests/account_deletion_test.rs index cfbb758d..4b285c02 100644 --- a/crates/bootstrap/tests/account_deletion_test.rs +++ b/crates/bootstrap/tests/account_deletion_test.rs @@ -62,6 +62,7 @@ async fn self_delete_with_a_wrong_password_is_refused_and_keeps_the_account() { ) .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); } @@ -109,8 +110,9 @@ async fn an_admin_deletes_another_account() { promote(&pool, &alice.user_id).await; let uri = format!("/api/v1/users/{}", bob.user_id); - let (status, _, _) = send(&router, delete_json(&uri, &alice.token, "nope")).await; + 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}"); diff --git a/crates/domain/src/errors/mod.rs b/crates/domain/src/errors/mod.rs index 1dbda26f..a84f8998 100644 --- a/crates/domain/src/errors/mod.rs +++ b/crates/domain/src/errors/mod.rs @@ -17,6 +17,9 @@ pub enum DomainError { #[error("Unauthorized: {0}")] Unauthorized(String), + #[error("Unauthorized: {0}")] + InvalidCredentials(String), + #[error("Forbidden: {0}")] Forbidden(String), diff --git a/docs/adr/0014-http-adapter-and-one-refusal-shape.md b/docs/adr/0014-http-adapter-and-one-refusal-shape.md index 2b45a18e..9d03fbed 100644 --- a/docs/adr/0014-http-adapter-and-one-refusal-shape.md +++ b/docs/adr/0014-http-adapter-and-one-refusal-shape.md @@ -16,7 +16,8 @@ rate limiter's own 429 text. The refusals were documented without a body schema. **One body.** `api_types::ErrorResponse { error, code }`. `error` keeps its name and meaning (the SPA and existing clients read it); `code` is an additive, stable machine-readable field: -`NOT_FOUND`, `INVALID_INPUT`, `UNAUTHORIZED`, `FORBIDDEN`, `CONFLICT`, `RATE_LIMITED`, `INTERNAL`. +`NOT_FOUND`, `INVALID_INPUT`, `UNAUTHORIZED`, `INVALID_CREDENTIALS` (wrong password on a re-auth +check; unlike `UNAUTHORIZED` it must not trigger a token refresh), `FORBIDDEN`, `CONFLICT`, `RATE_LIMITED`, `INTERNAL`. **One constructor.** `errors::refuse(status, code, message) -> Response` is the only place a JSON error body is built. `ApiError` maps every `DomainError` variant to it with the status mapping that diff --git a/spa/src/components/delete-account-dialog.tsx b/spa/src/components/delete-account-dialog.tsx index d9c670c5..fa6b87b4 100644 --- a/spa/src/components/delete-account-dialog.tsx +++ b/spa/src/components/delete-account-dialog.tsx @@ -80,6 +80,7 @@ export function DeleteAccountDialog({ setPassword(e.target.value)} diff --git a/spa/src/features/users.ts b/spa/src/features/users.ts index 1e17d0a5..852297e3 100644 --- a/spa/src/features/users.ts +++ b/spa/src/features/users.ts @@ -242,7 +242,10 @@ export function useAdminDeleteUser() { return useMutation({ mutationFn: ({ id, password }: DeleteAccountRequest & { id: string }) => delWithBody(`/users/${id}`, { password }), - onSuccess: () => { + onSuccess: (_data, { id }) => { + qc.removeQueries({ + predicate: (q) => q.queryKey[0] === userKeys.all[0] && q.queryKey[1] === id, + }) qc.invalidateQueries({ queryKey: userKeys.all }) qc.invalidateQueries({ queryKey: ["activity-feed"] }) }, diff --git a/spa/src/lib/api/client.ts b/spa/src/lib/api/client.ts index c07fac1b..30ba0416 100644 --- a/spa/src/lib/api/client.ts +++ b/spa/src/lib/api/client.ts @@ -68,6 +68,15 @@ async function tryRefresh(): Promise { } } +async function isInvalidCredentials(res: Response): Promise { + try { + const body = await res.clone().json() + return body?.code === "INVALID_CREDENTIALS" + } catch { + return false + } +} + async function request( url: string, options?: RequestInit, @@ -81,6 +90,11 @@ async function request( }) if (!res.ok) { if (res.status === 401) { + // A failed re-auth check (wrong password) is not an expired session: + // no refresh, no retry, no logout. + if (await isInvalidCredentials(res)) { + throw new ApiError(res.status, await res.text()) + } if (url.includes("/auth/refresh") || url.includes("/auth/login")) { clearAuth() window.location.href = "/app/login" diff --git a/spa/src/locales/en.json b/spa/src/locales/en.json index 87908e25..27e2279b 100644 --- a/spa/src/locales/en.json +++ b/spa/src/locales/en.json @@ -211,7 +211,7 @@ "done": "User deleted", "errors": { "wrongPassword": "Wrong password", - "lastAdmin": "You are the last admin and cannot be deleted", + "lastAdmin": "The last admin account cannot be deleted", "forbidden": "Only admins can delete other users", "notFound": "User not found", "generic": "Could not delete account" -- 2.49.1 From 9fd1eadc95642968a3103fb0d4ed1ce2f5373a8a Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 03:03:18 +0100 Subject: [PATCH 086/104] docs: adr 0016 account deletion --- CONTEXT.md | 12 +++ README.md | 1 + ...0014-http-adapter-and-one-refusal-shape.md | 2 +- docs/adr/0016-account-deletion.md | 87 +++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0016-account-deletion.md diff --git a/CONTEXT.md b/CONTEXT.md index 62631c60..a1c3c60f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -15,3 +15,15 @@ _Avoid_: Review (that is the record itself) **Spoiler Flag**: An author-set marker on a Review stating that its entire comment reveals plot details. _Avoid_: Spoiler warning, spoiler tag, spoiler markup + +**Account deletion**: +Two-phase removal of a user. The request tombstones the account immediately; a background purge then erases its data and notifies the fediverse. +_Avoid_: Account removal, deactivation + +**Tombstone**: +The data-free users row (plus signing keypair) an Account deletion leaves behind so queued fediverse `Delete` deliveries can still be signed; removed after a retention period. +_Avoid_: Soft delete + +**Retired Username**: +A username of a deleted account, refused for every future registration. +_Avoid_: Reserved username, banned username diff --git a/README.md b/README.md index dae48a09..c3f3ac07 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth - Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, watch medium breakdown, rewatches, budget analysis); directors/actors filtered by minimum watch count for statistical relevance; shareable HTML page at `/wrapups/{user_id}/{year}`; admin-triggered or auto-generated in January - Goals — set a "watch N movies in YEAR" target with a progress bar; progress computed from existing reviews (backwards compatible); per-user federation toggle in settings; displayed on profile (SPA: interactive with create/edit/delete, classic HTML: read-only glassmorphic card) - Profile trends — top directors, genre breakdown, rating distribution histogram, watch medium breakdown, monthly activity chart; all computed from the user's review history +- Account deletion — self-service (own password) or admin (admin's password) from the settings page, profile page, or `DELETE /api/v1/users/me` / `DELETE /api/v1/users/{id}`; the account is tombstoned at once, then a background purge erases its data and tells the fediverse; usernames are never reused (ADR-0016) - CSV and JSON diary export - File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports - REST API v1 (`/api/v1/`) with full feature parity with the HTML interface diff --git a/docs/adr/0014-http-adapter-and-one-refusal-shape.md b/docs/adr/0014-http-adapter-and-one-refusal-shape.md index 9d03fbed..c921e66d 100644 --- a/docs/adr/0014-http-adapter-and-one-refusal-shape.md +++ b/docs/adr/0014-http-adapter-and-one-refusal-shape.md @@ -22,7 +22,7 @@ check; unlike `UNAUTHORIZED` it must not trigger a token refresh), `FORBIDDEN`, **One constructor.** `errors::refuse(status, code, message) -> Response` is the only place a JSON error body is built. `ApiError` maps every `DomainError` variant to it with the status mapping that existed before (`InvalidRating`/`ValidationError` 400, `NotFound` 404, `Unauthorized` 401, -`Forbidden` 403, `InfrastructureError` 500 with the generic message `internal server error` and the +`Forbidden` 403, `InvalidCredentials` 401 (`INVALID_CREDENTIALS`), `InfrastructureError` 500 with the generic message `internal server error` and the cause logged, never sent). **Extractors.** `extractors::Body`, `Params` and `PathId` wrap axum's `Json`, `Query` and diff --git a/docs/adr/0016-account-deletion.md b/docs/adr/0016-account-deletion.md new file mode 100644 index 00000000..8936268b --- /dev/null +++ b/docs/adr/0016-account-deletion.md @@ -0,0 +1,87 @@ +# Account deletion is a tombstone now and a purge later + +Deleting an account has to take effect at once (the person is gone from the instance and the +fediverse) yet erase a lot: dozens of tables, images in object storage, follows on other servers, +and signed `Delete` deliveries that need the account's key long after the user row would +normally be gone. Doing it in one request would hold a transaction over federation and storage +calls, and a crash halfway would leave a half-deleted person. + +## Decision + +**Two phases.** + +1. *Request* (`request_account_deletion`). `UserRepository::mark_deleted` runs one transaction + that stamps `users.deleted_at` (the tombstone), inserts the username into `retired_usernames`, + deletes the user's `refresh_sessions`, and writes a `UserDeleted` event to the outbox + (ADR-0015). Migration `0102_account_deletion.sql` adds `users.deleted_at`, `users.purged_at` + and `retired_usernames` (`username` primary key, `COLLATE NOCASE`, `retired_at`). +2. *Purge* (`AccountPurgeHandler`, handler name `account_purge`, runs in the worker on + `UserDeleted`). + +**Authorization.** A user deletes themselves, an admin deletes anyone (others get 403; +`DELETE /api/v1/users/{id}` takes `AuthenticatedUser` and the use case enforces the role, so +there is one policy point). The proof is always the *actor's* password: the user's own, or the +admin's. A wrong password is `DomainError::InvalidCredentials`: 401 with code +`INVALID_CREDENTIALS` (ADR-0014), distinct from `UNAUTHORIZED` so the SPA does not mistake it for +an expired session and refresh and retry. The last active admin cannot be deleted. The guard +is in the `mark_deleted` `UPDATE` itself (the row is stamped only if another non-deleted admin +exists), so two admins deleting each other concurrently cannot both succeed; the use case's +`count_active_admins` check is only the early, friendly refusal (409). + +**Immediate effects, before the purge runs.** Login is refused and tokens are rejected +(`ensure_active` in the extractors; the user lookups filter `deleted_at IS NULL`). The profile +and `?embed=true` pages 404. The ActivityPub actor document, inbox, outbox and featured answer +410 (`tombstone_gate` for the collection endpoints, `actor_is_gone` for the actor). WebFinger +answers 404 (owned by k-ap). + +**Purge.** Order matters and every step is idempotent: + +1. Notify the fediverse through `FederationDeletionNotifier`, implemented over k-ap's + `broadcast_delete`. That only queues signed deliveries. A notifier failure is logged and never + blocks the purge, and the notice is skipped once the account is purged. +2. `AccountPurger::purge`: one transaction first strips the `users` row (which also claims the + purge: only a tombstoned, unpurged account matches), then deletes `ap_announces` of the + user's content and by the user, the *finished* `event_queue` rows whose payload names the + user, other accounts' follows of this actor (`ap_followers`/`ap_following` by actor URL), and + every row in `PURGED_USER_TABLES` (including tables with no foreign key to `users`, such as + `watch_events`, `wrap_up_records`, `import_*`, `blocked_actors`). `PURGED_USER_TABLES`, + `URL_KEYED_USER_COLUMNS` and `PAYLOAD_KEYED_USER_COLUMNS` are checked against the schema by a + guard test, so a new user-owned table cannot be forgotten. +3. Image objects (avatar, banner) are deleted after the commit, then the references are dropped + (`forget_images`), so a crash in between is repaired by redelivery. + +**The tombstone.** The stripped `users` row keeps `id`, `username`, `role` and the `ap_local_actors` +keypair (`RETAINED_USER_TABLES`), and nothing else: the email becomes the placeholder +`deleted-@deleted.invalid` (which frees the real address for a new registration), the +password hash becomes `!purged`, display name, bio, `also_known_as` and image paths are cleared, +and `purged_at` is set. The row stays so queued `Delete` deliveries can still be signed. +Broadcasts for a tombstoned author are suppressed (the handler skips them and succeeds) except +the `Delete` itself. + +**Cleanup.** `TombstoneCleanupJob` runs daily and removes tombstones whose `purged_at` is older +than `TOMBSTONE_RETENTION_DAYS` (7): all remaining `event_queue` rows naming the user (the +`UserDeleted` trigger and the `Delete` deliveries), the keypair, and the `users` row. A tombstone +that an unfinished (pending/processing) event still names waits for the next run; unfinished +events are never deleted. + +**Usernames are never reused.** `retired_usernames` is permanent and survives cleanup; the +registration path refuses a retired username, so a deleted name cannot be impersonated. + +## Known gaps + +- Events already relayed to NATS JetStream are not erased; they live as long as the stream's + retention allows. +- A Follow arriving on the shared inbox can create a `Pending` `ap_followers` row for the + tombstoned user; it is removed by the cleanup. +- Remote servers that stay down through the delivery retries, or that ignore `Delete`, may keep + stale copies. +- A handler that defers a tombstone-naming event forever delays the tombstone (and keypair) + removal. No event is lost, and nothing logs this case. + +## Consequences + +- Deletion is visible instantly and has no undo; once the purge has run the data is gone. +- Federation and storage failures cannot strand an account half-deleted: the purge is + redelivered and each step tolerates a re-run. +- The worker owns the destructive work, so the server needs no new infrastructure beyond the + outbox it already writes to. -- 2.49.1 From 3861d99e1f3e2cb49c0be9407db2b843f2d71227 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 03:03:18 +0100 Subject: [PATCH 087/104] admin delete copy, anon form assertion --- crates/adapters/http-axum/templates/profile.html | 2 +- crates/bootstrap/tests/account_deletion_test.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/adapters/http-axum/templates/profile.html b/crates/adapters/http-axum/templates/profile.html index f8ba0a75..1cd77b55 100644 --- a/crates/adapters/http-axum/templates/profile.html +++ b/crates/adapters/http-axum/templates/profile.html @@ -103,7 +103,7 @@ {% if let Some(err) = error %}

{{ err }}

{% endif %} -

This user and all their data will be permanently deleted. Enter your own password to confirm.

+

This user and all their data will be permanently deleted, fediverse servers are notified, and the username is retired for good. Enter your own password to confirm.

diff --git a/crates/bootstrap/tests/account_deletion_test.rs b/crates/bootstrap/tests/account_deletion_test.rs index 4b285c02..1b11a922 100644 --- a/crates/bootstrap/tests/account_deletion_test.rs +++ b/crates/bootstrap/tests/account_deletion_test.rs @@ -285,6 +285,8 @@ async fn delete_forms_render_only_where_they_apply() { // A non-admin viewing someone else, and anonymous viewers, see no form. 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"); -- 2.49.1 From 9d0166f5cb36e642d58302c5b0330836ed07e991 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 03:15:27 +0100 Subject: [PATCH 088/104] service actor undeletable: domain const, 403, hide delete UI --- crates/adapters/activitypub/src/lib.rs | 3 +- .../http-axum/src/api/user_profile.rs | 2 + crates/adapters/http-axum/src/html/profile.rs | 2 + .../http-axum/src/html/templates/users.rs | 1 + .../adapters/http-axum/templates/profile.html | 2 +- crates/api-types/src/users.rs | 3 ++ .../src/users/request_account_deletion.rs | 6 +++ .../users/request_account_deletion_test.rs | 15 ++++++++ .../bootstrap/tests/account_deletion_test.rs | 38 +++++++++++++++++++ crates/domain/src/value_objects/ids.rs | 14 +++++++ spa/src/features/users.ts | 1 + spa/src/routes/_app/users.$id.tsx | 2 +- 12 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index 50ad31a9..4b463eb7 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -13,8 +13,7 @@ pub(crate) mod urls; pub mod user_adapter; pub mod watchlist_handler; -pub const INSTANCE_ACTOR_ID: uuid::Uuid = - uuid::Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0]); +pub const INSTANCE_ACTOR_ID: uuid::Uuid = domain::value_objects::SERVICE_ACTOR_UUID; pub use k_ap::{ ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig, diff --git a/crates/adapters/http-axum/src/api/user_profile.rs b/crates/adapters/http-axum/src/api/user_profile.rs index c12874ef..bac314cb 100644 --- a/crates/adapters/http-axum/src/api/user_profile.rs +++ b/crates/adapters/http-axum/src/api/user_profile.rs @@ -151,6 +151,7 @@ pub async fn get_user_profile( } }, is_federated: false, + is_service_account: domain::value_objects::UserId::from_uuid(user_id).is_service_actor(), handle: Some(profile.identity.handle.clone()), actor_url: Some(profile.identity.actor_url.clone()), }) @@ -227,6 +228,7 @@ async fn build_federated_profile_response( view_data, goals: None, is_federated: true, + is_service_account: false, handle: Some(fed.handle), actor_url: Some(fed.actor_url), }) diff --git a/crates/adapters/http-axum/src/html/profile.rs b/crates/adapters/http-axum/src/html/profile.rs index a27e99d3..b405fd60 100644 --- a/crates/adapters/http-axum/src/html/profile.rs +++ b/crates/adapters/http-axum/src/html/profile.rs @@ -243,6 +243,8 @@ pub async fn get_user_profile_html( heatmap, page_items: pag.page_items, is_own_profile, + is_service_account: domain::value_objects::UserId::from_uuid(profile_user_uuid) + .is_service_actor(), error: params.error, following_count: profile.following_count, followers_count: profile.followers_count, diff --git a/crates/adapters/http-axum/src/html/templates/users.rs b/crates/adapters/http-axum/src/html/templates/users.rs index cd7fbf15..f3ac8019 100644 --- a/crates/adapters/http-axum/src/html/templates/users.rs +++ b/crates/adapters/http-axum/src/html/templates/users.rs @@ -56,6 +56,7 @@ pub struct ProfileTemplate<'a> { pub heatmap: Vec, pub page_items: Vec, pub is_own_profile: bool, + pub is_service_account: bool, pub error: Option, pub following_count: usize, pub followers_count: usize, diff --git a/crates/adapters/http-axum/templates/profile.html b/crates/adapters/http-axum/templates/profile.html index 1cd77b55..e48e25ef 100644 --- a/crates/adapters/http-axum/templates/profile.html +++ b/crates/adapters/http-axum/templates/profile.html @@ -97,7 +97,7 @@ {% endif %} - {% if ctx.is_admin && !is_own_profile %} + {% if ctx.is_admin && !is_own_profile && !is_service_account %}

Admin

{% if let Some(err) = error %} diff --git a/crates/api-types/src/users.rs b/crates/api-types/src/users.rs index 5e8fbe8a..4ccef828 100644 --- a/crates/api-types/src/users.rs +++ b/crates/api-types/src/users.rs @@ -126,6 +126,9 @@ pub struct UserProfileResponse { pub goals: Option>, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub is_federated: bool, + /// The instance's service account: never deletable, so UIs hide the action. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub is_service_account: bool, #[serde(skip_serializing_if = "Option::is_none")] pub handle: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/application/src/users/request_account_deletion.rs b/crates/application/src/users/request_account_deletion.rs index de1546c0..c018b83b 100644 --- a/crates/application/src/users/request_account_deletion.rs +++ b/crates/application/src/users/request_account_deletion.rs @@ -12,6 +12,12 @@ pub async fn execute( target: UserId, password: &str, ) -> Result<(), DomainError> { + if target.is_service_actor() { + return Err(DomainError::Forbidden( + "the instance service account cannot be deleted".into(), + )); + } + let actor_user = deps .user .find_by_id(&actor) diff --git a/crates/application/tests/users/request_account_deletion_test.rs b/crates/application/tests/users/request_account_deletion_test.rs index 5fff6089..94eb8b0c 100644 --- a/crates/application/tests/users/request_account_deletion_test.rs +++ b/crates/application/tests/users/request_account_deletion_test.rs @@ -287,3 +287,18 @@ async fn a_non_admin_gets_forbidden_before_any_password_check() { assert!(matches!(err, DomainError::Forbidden(_)), "got {err:?}"); } + +#[tokio::test] +async fn the_service_actor_cannot_be_deleted_even_by_an_admin() { + let f = fixture(); + let admin = f.user("root", UserRole::Admin).await; + let other = f.user("second", UserRole::Admin).await; + let _ = other; + + let err = request_account_deletion::execute(&f.deps, admin, UserId::service_actor(), PASSWORD) + .await + .unwrap_err(); + + assert!(matches!(err, DomainError::Forbidden(_)), "got {err:?}"); + assert!(f.store.recorded_events().is_empty()); +} diff --git a/crates/bootstrap/tests/account_deletion_test.rs b/crates/bootstrap/tests/account_deletion_test.rs index 1b11a922..b592df10 100644 --- a/crates/bootstrap/tests/account_deletion_test.rs +++ b/crates/bootstrap/tests/account_deletion_test.rs @@ -311,3 +311,41 @@ async fn delete_forms_render_only_where_they_apply() { .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}"); +} diff --git a/crates/domain/src/value_objects/ids.rs b/crates/domain/src/value_objects/ids.rs index 930a95d3..293b67c5 100644 --- a/crates/domain/src/value_objects/ids.rs +++ b/crates/domain/src/value_objects/ids.rs @@ -32,6 +32,20 @@ impl MovieId { uuid_id!(ReviewId); uuid_id!(UserId); + +/// Id of the instance's service actor (seeded by migration 0022). It signs +/// fetches for the whole instance and is never a deletable account. +pub const SERVICE_ACTOR_UUID: Uuid = + Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0]); + +impl UserId { + pub const fn service_actor() -> Self { + Self(SERVICE_ACTOR_UUID) + } + pub fn is_service_actor(&self) -> bool { + self.0 == SERVICE_ACTOR_UUID + } +} uuid_id!(ImportSessionId); uuid_id!(ImportProfileId); uuid_id!(WatchlistEntryId); diff --git a/spa/src/features/users.ts b/spa/src/features/users.ts index 852297e3..e5570194 100644 --- a/spa/src/features/users.ts +++ b/spa/src/features/users.ts @@ -112,6 +112,7 @@ export const userProfileResponseSchema = z.object({ trends: userTrendsDtoSchema.optional(), goals: z.array(goalDtoSchema).optional(), is_federated: z.boolean().optional().default(false), + is_service_account: z.boolean().optional().default(false), handle: z.string().optional(), display_name: z.string().optional(), bio: z.string().optional(), diff --git a/spa/src/routes/_app/users.$id.tsx b/spa/src/routes/_app/users.$id.tsx index 2c70fd21..0557b394 100644 --- a/spa/src/routes/_app/users.$id.tsx +++ b/spa/src/routes/_app/users.$id.tsx @@ -148,7 +148,7 @@ function UserProfilePage() { )} - {isAdmin && !data.is_federated && ( + {isAdmin && !data.is_federated && !data.is_service_account && (