diff --git a/.cargo/config.toml b/.cargo/config.toml index 9c902c53..fadf7180 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,5 +1,2 @@ -[env] -SQLX_OFFLINE = "true" - [registries.gitea] index = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/" diff --git a/.dockerignore b/.dockerignore index 77ff21c2..2ea49f04 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,10 +4,20 @@ target/ *.db *.db-shm *.db-wal -# .cargo and .sqlx are needed at build time (SQLX_OFFLINE mode) +# .cargo is needed at build time (registry config) docs/ dev.db spa/node_modules/ spa/dist/ spa/.env spa/.env.local +.sqlx/ +.superpowers/ +.worktrees/ +.claude/ +.github/ +.gitea/ +imgs/ +screenshots/ +test_data/ +*.md diff --git a/.env.example b/.env.example index 0fdad9f4..49230c82 100644 --- a/.env.example +++ b/.env.example @@ -45,11 +45,12 @@ ALLOW_REGISTRATION=true # PORT=3000 # RATE_LIMIT=60 # SECURE_COOKIES=true -# Handler-level logs come from the `presentation` crate; startup/wiring logs come -# from `server`. Include both — `server=info` alone silently drops handler logs. +# Handler-level logs come from the `http_axum` crate; startup/wiring logs come +# 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,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,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/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d649966d..08b7564a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -23,25 +23,21 @@ jobs: components: rustfmt, clippy - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ runner.os }}-cargo- + uses: Swatinem/rust-cache@v2 - name: fmt 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 + run: cargo test --workspace + + - 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 - 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-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7291dc9c..48b0ea2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,28 +23,24 @@ jobs: components: rustfmt, clippy - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ runner.os }}-cargo- + uses: Swatinem/rust-cache@v2 - name: fmt 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 + run: cargo test --workspace + + - 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 - 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-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack docker: name: Build & Push Docker Image @@ -58,6 +54,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR uses: docker/login-action@v3 with: @@ -83,4 +82,6 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - build-args: FEATURES=sqlite,sqlite-federation + build-args: FEATURES=federation + cache-from: type=gha + cache-to: type=gha,mode=max 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/CODE_STYLE.md b/CODE_STYLE.md new file mode 100644 index 00000000..73dea40b --- /dev/null +++ b/CODE_STYLE.md @@ -0,0 +1,295 @@ +# 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 + +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 +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`). 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 + +### 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 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. + +```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; 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 + +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` (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 + +| Crate | Approach | Target | +|---|---|---| +| Domain | TDD (red-green-refactor) | 100% | +| Application | TDD | High coverage | +| Adapters | Integration tests where practical | Best effort | +| Bootstrap | Route smoke, API and handler tests over the real wiring | Every route reachable | + +### 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/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/CONTRIBUTING.md b/CONTRIBUTING.md index 75fb9d1e..bd80ea59 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,55 +23,65 @@ 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-federation-off-deps`, `check-nats-isolated`, `check-no-second-web-stack`). 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 (`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 +- 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 -presentation → Axum handlers, routes, OpenAPI -worker → event consumer, background jobs -adapters/* → implements domain ports (sqlite, postgres, 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. + +**Dependencies.** Every external crate is declared once in the root `[workspace.dependencies]`, usually with `default-features = false`; member crates write `foo = { workspace = true, features = [...] }` and enable only the features they use. A new crate goes into both `members` and `default-members` in the root `Cargo.toml` (only `tui` is left out of `default-members`). `cargo machete` should stay clean, and each crate must compile on its own (`cargo check -p --all-targets`), not just through feature unification in the binaries. + ### 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 -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/`, 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 -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 `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. +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 @@ -83,5 +93,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/Cargo.lock b/Cargo.lock index b4736427..1995fae5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,10 +11,12 @@ dependencies = [ "async-trait", "axum", "chrono", + "config", "domain", "k-ap", "serde", "serde_json", + "tokio", "tracing", "url", "uuid", @@ -27,7 +29,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20222f29f14358d3baeb0ffdec08f99bd0f56b6b59504f33556d97db720de748" dependencies = [ "activitystreams-kinds", - "actix-web", "async-trait", "axum", "base64", @@ -39,8 +40,7 @@ dependencies = [ "enum_delegate", "futures", "futures-core", - "http 0.2.12", - "http 1.4.0", + "http", "http-signature-normalization", "http-signature-normalization-reqwest", "httpdate", @@ -72,165 +72,6 @@ dependencies = [ "url", ] -[[package]] -name = "actix-codec" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" -dependencies = [ - "bitflags 2.11.1", - "bytes", - "futures-core", - "futures-sink", - "memchr", - "pin-project-lite", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "actix-http" -version = "3.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93acb4a42f64936f9b8cae4a433b237599dd6eb6ed06124eb67132ef8cc90662" -dependencies = [ - "actix-codec", - "actix-rt", - "actix-service", - "actix-utils", - "bitflags 2.11.1", - "bytes", - "bytestring", - "derive_more", - "encoding_rs", - "foldhash 0.1.5", - "futures-core", - "http 0.2.12", - "httparse", - "httpdate", - "itoa", - "language-tags", - "mime", - "percent-encoding", - "pin-project-lite", - "smallvec", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "actix-router" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" -dependencies = [ - "bytestring", - "cfg-if", - "http 0.2.12", - "regex-lite", - "serde", - "tracing", -] - -[[package]] -name = "actix-rt" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" -dependencies = [ - "futures-core", - "tokio", -] - -[[package]] -name = "actix-server" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" -dependencies = [ - "actix-rt", - "actix-service", - "actix-utils", - "futures-core", - "futures-util", - "mio", - "socket2 0.5.10", - "tokio", - "tracing", -] - -[[package]] -name = "actix-service" -version = "2.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "actix-utils" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" -dependencies = [ - "local-waker", - "pin-project-lite", -] - -[[package]] -name = "actix-web" -version = "4.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" -dependencies = [ - "actix-codec", - "actix-http", - "actix-router", - "actix-rt", - "actix-server", - "actix-service", - "actix-utils", - "bytes", - "bytestring", - "cfg-if", - "derive_more", - "encoding_rs", - "foldhash 0.1.5", - "futures-core", - "futures-util", - "impl-more", - "itoa", - "language-tags", - "log", - "mime", - "once_cell", - "pin-project-lite", - "regex-lite", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "socket2 0.6.3", - "time", - "tracing", - "url", -] - -[[package]] -name = "adapter-common" -version = "0.1.0" -dependencies = [ - "chrono", - "domain", - "sqlx", - "tracing", - "uuid", -] - [[package]] name = "adler2" version = "2.0.1" @@ -329,12 +170,11 @@ dependencies = [ "async-trait", "bytes", "chrono", + "config", "domain", "futures", "hex", - "infra-wiring", "rand 0.9.4", - "serde_json", "sha2", "tokio", "tracing", @@ -674,6 +514,7 @@ dependencies = [ "argon2", "async-trait", "chrono", + "config", "domain", "jsonwebtoken", "rand_core 0.6.4", @@ -710,28 +551,6 @@ dependencies = [ "arrayvec", ] -[[package]] -name = "aws-lc-rs" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.8.9" @@ -743,7 +562,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.0", + "http", "http-body", "http-body-util", "hyper", @@ -775,7 +594,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http", "http-body", "http-body-util", "mime", @@ -796,7 +615,7 @@ dependencies = [ "dashmap", "forwarded-header-value", "governor", - "http 1.4.0", + "http", "ipnet", "nonzero_ext", "pin-project-lite", @@ -914,6 +733,44 @@ dependencies = [ "piper", ] +[[package]] +name = "bootstrap" +version = "0.1.0" +dependencies = [ + "activitypub", + "anyhow", + "application", + "async-trait", + "auth", + "axum", + "chrono", + "config", + "domain", + "export", + "futures", + "http-axum", + "image-converter", + "importer", + "media-server", + "metadata", + "nats", + "object-storage", + "poster-fetcher", + "reqwest 0.13.3", + "rss-adapter", + "rustls", + "serde_json", + "sqlite", + "sqlite-federation", + "sqlx", + "tmdb-enrichment", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "built" version = "0.7.7" @@ -953,15 +810,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bytestring" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" -dependencies = [ - "bytes", -] - [[package]] name = "calamine" version = "0.35.0" @@ -1087,15 +935,6 @@ dependencies = [ "inout", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "codepage" version = "0.1.2" @@ -1129,39 +968,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "composition" -version = "0.1.0" -dependencies = [ - "activitypub", - "anyhow", - "application", - "async-trait", - "auth", - "chrono", - "domain", - "infra-wiring", - "jellyfin", - "metadata", - "nats", - "object-storage", - "plex", - "poster-fetcher", - "postgres", - "postgres-event-queue", - "postgres-federation", - "postgres-search", - "postgres-social", - "sqlite", - "sqlite-event-queue", - "sqlite-federation", - "sqlite-search", - "sqlite-social", - "sqlx", - "tokio", - "uuid", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1171,6 +977,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" @@ -1588,7 +1404,6 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.117", - "unicode-xid", ] [[package]] @@ -1660,6 +1475,7 @@ dependencies = [ "async-trait", "bytes", "chrono", + "domain", "email_address", "futures", "serde", @@ -1690,12 +1506,6 @@ dependencies = [ "dtoa", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "dyn-clone" version = "1.0.20" @@ -1895,6 +1705,7 @@ name = "event-publisher" version = "0.1.0" dependencies = [ "async-trait", + "config", "domain", "futures", "tokio", @@ -1905,9 +1716,7 @@ name = "export" version = "0.1.0" dependencies = [ "async-stream", - "async-trait", "bytes", - "chrono", "domain", "futures", "serde_json", @@ -1952,6 +1761,21 @@ 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", + "serde", + "tempfile", + "toml", + "uncased", + "version_check", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -2040,12 +1864,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futf" version = "0.1.5" @@ -2204,11 +2022,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -2218,11 +2034,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -2262,7 +2080,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http", "indexmap", "slab", "tokio", @@ -2369,17 +2187,6 @@ dependencies = [ "match_token", ] -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.4.0" @@ -2390,6 +2197,33 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-axum" +version = "0.1.0" +dependencies = [ + "api-types", + "application", + "askama", + "axum", + "axum-governor", + "bytes", + "chrono", + "chrono-tz", + "domain", + "futures", + "infer", + "percent-encoding", + "serde", + "serde_json", + "tokio", + "tower", + "tower-http", + "tracing", + "utoipa", + "utoipa-swagger-ui", + "uuid", +] + [[package]] name = "http-body" version = "1.0.1" @@ -2397,7 +2231,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http", ] [[package]] @@ -2408,7 +2242,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http", "http-body", "pin-project-lite", ] @@ -2473,7 +2307,7 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http 1.4.0", + "http", "http-body", "httparse", "httpdate", @@ -2490,7 +2324,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.0", + "http", "hyper", "hyper-util", "rustls", @@ -2510,14 +2344,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.0", + "http", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2", "system-configuration", "tokio", "tower-service", @@ -2686,6 +2520,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "config", "domain", "image", "object-storage", @@ -2713,12 +2548,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" -[[package]] -name = "impl-more" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" - [[package]] name = "importer" version = "0.1.0" @@ -2759,14 +2588,6 @@ dependencies = [ "cfb", ] -[[package]] -name = "infra-wiring" -version = "0.1.0" -dependencies = [ - "anyhow", - "sqlx", -] - [[package]] name = "inout" version = "0.1.4" @@ -2840,15 +2661,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" @@ -2937,9 +2749,9 @@ dependencies = [ [[package]] name = "k-ap" -version = "0.5.0" +version = "0.5.1" source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/" -checksum = "ab6066cccc6ae8aaa2f6262ac7d471e58930a04be3266a2e367d6bdd8aaaba29" +checksum = "261f5d721fb582f029d892cbcf28de9fc3a39547149bc4b52e734b46842a6d4d" dependencies = [ "activitypub_federation", "anyhow", @@ -2950,6 +2762,7 @@ dependencies = [ "futures", "paste", "reqwest 0.13.3", + "rustls", "serde", "serde_json", "thiserror 2.0.18", @@ -2986,12 +2799,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" -[[package]] -name = "language-tags" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" - [[package]] name = "lazy_static" version = "1.5.0" @@ -3089,12 +2896,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" -[[package]] -name = "local-waker" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" - [[package]] name = "lock_api" version = "0.4.14" @@ -3130,9 +2931,9 @@ dependencies = [ [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "mac" @@ -3212,6 +3013,15 @@ dependencies = [ "digest", ] +[[package]] +name = "media-server" +version = "0.1.0" +dependencies = [ + "domain", + "serde", + "serde_json", +] + [[package]] name = "memchr" version = "2.8.0" @@ -3239,8 +3049,10 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "config", "domain", "reqwest 0.13.3", + "rustls", "serde", ] @@ -3327,7 +3139,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.4.0", + "http", "httparse", "memchr", "mime", @@ -3343,6 +3155,7 @@ dependencies = [ "async-nats", "async-trait", "chrono", + "config", "domain", "event-payload", "futures", @@ -3564,9 +3377,9 @@ dependencies = [ "anyhow", "async-trait", "bytes", + "config", "domain", "futures", - "infer", "object_store", "tokio", "tracing", @@ -3894,15 +3707,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" @@ -3942,95 +3746,10 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "config", "domain", "reqwest 0.13.3", -] - -[[package]] -name = "poster-sync" -version = "0.1.0" -dependencies = [ - "async-trait", - "domain", - "tokio", - "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", + "rustls", ] [[package]] @@ -4063,39 +3782,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", - "bytes", - "chrono", - "chrono-tz", - "composition", - "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" @@ -4177,9 +3863,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -4188,7 +3874,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -4197,15 +3883,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ - "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.2", "lru-slab", - "rand 0.9.4", + "rand 0.10.1", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -4219,16 +3905,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4328,6 +4014,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "ratatui" version = "0.30.0" @@ -4514,12 +4209,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "regex-lite" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" - [[package]] name = "regex-syntax" version = "0.8.10" @@ -4537,7 +4226,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 1.4.0", + "http", "http-body", "http-body-util", "hyper", @@ -4580,7 +4269,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 1.4.0", + "http", "http-body", "http-body-util", "hyper", @@ -4591,7 +4280,6 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -4620,7 +4308,7 @@ checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" dependencies = [ "anyhow", "async-trait", - "http 1.4.0", + "http", "reqwest 0.13.3", "thiserror 2.0.18", "tower-service", @@ -4666,15 +4354,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rss" -version = "0.1.0" -dependencies = [ - "chrono", - "domain", - "rss 2.0.13", -] - [[package]] name = "rss" version = "2.0.13" @@ -4686,6 +4365,15 @@ dependencies = [ "quick-xml 0.39.4", ] +[[package]] +name = "rss-adapter" +version = "0.1.0" +dependencies = [ + "chrono", + "domain", + "rss", +] + [[package]] name = "rust-embed" version = "8.11.0" @@ -4754,7 +4442,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ - "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -4818,7 +4506,6 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -5008,44 +4695,13 @@ dependencies = [ name = "server" version = "0.1.0" dependencies = [ - "activitypub", "anyhow", - "application", - "async-trait", - "auth", "axum", - "bytes", - "composition", - "domain", + "bootstrap", + "config", "dotenvy", - "export", - "futures", - "http-body-util", - "importer", - "infra-wiring", - "metadata", - "nats", - "object-storage", - "poster-fetcher", - "postgres", - "postgres-event-queue", - "postgres-federation", - "postgres-search", - "postgres-social", - "presentation", - "rss 0.1.0", - "serde_json", - "sqlite", - "sqlite-event-queue", - "sqlite-federation", - "sqlite-search", - "sqlite-social", - "sqlx", "tokio", - "tower", "tracing", - "tracing-subscriber", - "uuid", ] [[package]] @@ -5229,16 +4885,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.3" @@ -5281,36 +4927,21 @@ dependencies = [ name = "sqlite" version = "0.1.0" dependencies = [ - "adapter-common", "anyhow", "async-stream", "async-trait", - "bytes", "chrono", - "domain", - "futures", - "serde", - "serde_json", - "sqlite-social", - "sqlx", - "tokio", - "tracing", - "uuid", -] - -[[package]] -name = "sqlite-event-queue" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", + "config", "domain", "event-payload", "futures", + "serde", "serde_json", "sqlx", + "tempfile", "tokio", "tracing", + "uuid", ] [[package]] @@ -5318,45 +4949,19 @@ 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" @@ -5394,7 +4999,6 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls", "serde", "serde_json", "sha2", @@ -5405,7 +5009,6 @@ dependencies = [ "tracing", "url", "uuid", - "webpki-roots 0.26.11", ] [[package]] @@ -5748,17 +5351,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" @@ -5875,9 +5467,9 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -5944,14 +5536,13 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" name = "tmdb-enrichment" version = "0.1.0" dependencies = [ - "anyhow", "async-trait", "chrono", + "config", "domain", "reqwest 0.13.3", + "rustls", "serde", - "serde_json", - "tracing", ] [[package]] @@ -5963,10 +5554,9 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "tracing", "windows-sys 0.61.2", @@ -6027,7 +5617,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", - "http 1.4.0", + "http", "httparse", "rand 0.8.6", "ring", @@ -6078,6 +5668,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime 0.6.11", + "toml_write", "winnow 0.7.15", ] @@ -6102,6 +5693,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" @@ -6128,7 +5725,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.0", + "http", "http-body", "http-body-util", "http-range-header", @@ -6248,6 +5845,7 @@ dependencies = [ "keyring-core", "ratatui", "reqwest 0.13.3", + "rustls", "serde", "serde_json", "thiserror 2.0.18", @@ -6286,6 +5884,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" @@ -6410,18 +6017,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "utoipa-scalar" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" -dependencies = [ - "axum", - "serde", - "serde_json", - "utoipa", -] - [[package]] name = "utoipa-swagger-ui" version = "9.0.2" @@ -6952,15 +6547,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -6994,30 +6580,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -7030,12 +6599,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -7048,12 +6611,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -7066,24 +6623,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -7096,12 +6641,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -7114,12 +6653,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -7132,12 +6665,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -7150,12 +6677,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.15" @@ -7272,36 +6793,12 @@ dependencies = [ name = "worker" version = "0.1.0" dependencies = [ - "activitypub", "anyhow", - "application", - "async-trait", - "auth", - "composition", - "domain", + "bootstrap", + "config", "dotenvy", - "export", - "image-converter", - "importer", - "infra-wiring", - "metadata", - "nats", - "object-storage", - "poster-fetcher", - "poster-sync", - "postgres", - "postgres-event-queue", - "postgres-federation", - "postgres-search", - "sqlite", - "sqlite-event-queue", - "sqlite-federation", - "sqlite-search", - "sqlx", - "tmdb-enrichment", "tokio", "tracing", - "tracing-subscriber", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 59e44179..acaa465a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,111 +1,152 @@ [workspace] +default-members = [ + "crates/adapters/auth", + "crates/adapters/event-publisher", + "crates/adapters/metadata", + "crates/adapters/poster-fetcher", + "crates/adapters/object-storage", + "crates/adapters/rss-adapter", + "crates/adapters/sqlite", + "crates/adapters/sqlite-federation", + "crates/adapters/activitypub", + "crates/adapters/export", + "crates/adapters/nats", + "crates/adapters/tmdb-enrichment", + "crates/adapters/image-converter", + "crates/adapters/http-axum", + "crates/adapters/importer", + "crates/adapters/media-server", + "crates/event-payload", + "crates/api-types", + "crates/application", + "crates/domain", + "crates/bootstrap", + "crates/server", + "crates/worker", + "crates/config", +] members = [ "crates/adapters/auth", "crates/adapters/event-publisher", "crates/adapters/metadata", "crates/adapters/poster-fetcher", "crates/adapters/object-storage", - "crates/adapters/poster-sync", - "crates/adapters/rss", + "crates/adapters/rss-adapter", "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", - "crates/adapters/event-payload", + "crates/event-payload", "crates/adapters/nats", "crates/api-types", "crates/application", "crates/adapters/tmdb-enrichment", "crates/adapters/image-converter", "crates/domain", - "crates/composition", - "crates/presentation", + "crates/bootstrap", + "crates/adapters/http-axum", "crates/server", "crates/tui", "crates/worker", "crates/adapters/importer", - "crates/adapters/jellyfin", - "crates/adapters/plex", - "crates/adapters/sqlite-search", - "crates/adapters/postgres-search", - "crates/adapters/adapter-common", - "crates/infra-wiring", + "crates/adapters/media-server", + "crates/config", ] resolver = "2" +[workspace.package] +edition = "2024" + [workspace.dependencies] -tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] } +tokio = { version = "1.0", default-features = false } bytes = "1" futures = "0.3" async-stream = "0.3" dotenvy = "0.15" -serde = { version = "1.0", features = ["derive"] } +serde = { version = "1.0", default-features = false, features = ["std"] } serde_json = "1.0" anyhow = "1.0" thiserror = "2.0" tracing = "0.1" -tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +tracing-subscriber = { version = "0.3.23", default-features = false } async-trait = "0.1.92" -uuid = { version = "1.23.0", features = ["v4", "v5", "serde"] } -chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1.23.0", default-features = false, features = ["std"] } +chrono = { version = "0.4", default-features = false, features = ["std"] } chrono-tz = "0.10" -sqlx = { version = "0.8.6", features = [ - "runtime-tokio-rustls", +sqlx = { version = "0.8.6", default-features = false, features = [ + "runtime-tokio", "sqlite", "uuid", + "derive", + "migrate", ] } rand = "0.9" -reqwest = { version = "0.13", features = ["json", "query"] } +tempfile = "3" +reqwest = { version = "0.13", default-features = false, features = ["json", "query", "charset", "http2", "system-proxy", "rustls-no-provider"] } sha2 = "0.10" hex = "0.4" -object_store = { version = "0.11", features = ["aws"] } -axum = { version = "0.8.8", features = ["macros", "multipart"] } +object_store = { version = "0.11", default-features = false, features = ["aws"] } +axum = { version = "0.8.8", default-features = false } csv = "1" +figment = { version = "0.10", features = ["toml"] } +rustls = { version = "0.23", default-features = false } +tower = { version = "0.5", default-features = false } +tower-http = { version = "0.6.8", default-features = false } +utoipa = "5.5.0" +utoipa-swagger-ui = "9.0.2" +askama = "0.16.0" +infer = "0.19.0" +percent-encoding = "2" +axum-governor = "2" +email_address = "0.2.9" +jsonwebtoken = "9" +argon2 = { version = "0.5", default-features = false } +rand_core = { version = "0.6", default-features = false } +async-nats = "0.48.0" +k-ap = { version = "0.5.1", registry = "gitea" } +url = "2" +ammonia = "4" +calamine = "0.35" +image = { version = "0.25", default-features = false } +ravif = { version = "0.11", default-features = false } +webp = "0.3" +rss = { version = "2" } +ratatui = "0.30.0" +keyring-core = "1.0.0" +apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] } +zbus-secret-service-keyring-store = { version = "1.0.0" } +windows-native-keyring-store = { version = "1.0.0" } +directories = "6" 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" } -presentation = { path = "crates/presentation" } +bootstrap = { path = "crates/bootstrap" } +http-axum = { path = "crates/adapters/http-axum" } 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" } +rss-adapter = { path = "crates/adapters/rss-adapter" } 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" } +event-payload = { path = "crates/event-payload" } +media-server = { path = "crates/adapters/media-server" } 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" } +config = { path = "crates/config" } [profile.dev] debug = 1 # line tables only — still debuggable, much faster linking [profile.dev.package."*"] opt-level = 2 # compile deps faster at runtime; paid once, cached after + +[profile.release] +codegen-units = 1 +strip = true diff --git a/Dockerfile b/Dockerfile index d136920e..f3370e67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,80 +6,41 @@ RUN npm ci COPY spa/ . RUN npm run build -# ----- build ----- -FROM rust:slim-bookworm AS builder - -WORKDIR /build - -# Cache dependency compilation separately from source -# -# Every workspace member's Cargo.toml must be listed here by hand — `cargo fetch` -# below reads the whole workspace graph, so a missing manifest fails the build with -# "failed to read .../Cargo.toml". Adding a crate to crates/ means adding a line here. -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/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/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/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 -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/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/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 - -# Stub every crate so cargo can resolve and fetch deps -RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \ - xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && echo "" > {}/src/lib.rs' - +# ----- chef base ----- +FROM rust:slim-bookworm AS chef # libwebp-dev: required at build time by the `webp` crate (C bindings) RUN apt-get update && apt-get install -y --no-install-recommends \ libwebp-dev \ pkg-config \ && rm -rf /var/lib/apt/lists/* +RUN cargo install cargo-chef --locked --version 0.1.78 +WORKDIR /build -RUN cargo fetch +FROM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json -# Now copy real sources (invalidates cache only on source changes) -COPY crates ./crates +# ----- build ----- +FROM chef AS builder + +# 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 + +COPY .cargo ./.cargo +COPY --from=planner /build/recipe.json recipe.json +RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \ + cargo chef cook --release -p server -p worker --no-default-features --features "${FEATURES}" --recipe-path recipe.json # 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 -RUN cargo build --release -p server -p worker --no-default-features --features "${FEATURES}" +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \ + cargo build --release -p server -p worker --no-default-features --features "${FEATURES}" # ----- runtime ----- FROM debian:bookworm-slim @@ -100,6 +61,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/Makefile b/Makefile index 435ea944..14fa0b5d 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 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-federation-off-deps check-nats-isolated check-no-second-web-stack @echo "✅ All checks passed" check-appcontext: @@ -12,60 +12,118 @@ 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 \ - 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; \ + @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/presentation/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; \ + @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 := template-askama +HTTP_ADAPTER_ALLOWED_ADAPTERS := -check-presentation-adapters: - @deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/presentation/Cargo.toml \ +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 "✅ presentation depends on no adapter crate but $(PRESENTATION_ALLOWED_ADAPTERS)" + echo "✅ http-axum depends on no other adapter crate" 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" -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; \ +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: + @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" + +CARGO_TREE_NAMES := cargo tree -p server -p worker -e normal --prefix none + +check-federation-off-deps: + @tree=$$($(CARGO_TREE_NAMES) --no-default-features) || exit 1; \ + hits=$$(echo "$$tree" | grep -E '^(k-ap|activitypub_federation|activitypub|sqlite-federation) ' | sort -u); \ + if [ -n "$$hits" ]; then \ + echo "❌ federation-only crates in the --no-default-features server/worker tree:"; \ + echo "$$hits"; \ + exit 1; \ + fi; \ + echo "✅ federation-off tree has no k-ap, activitypub_federation, activitypub, or sqlite-federation" + +check-nats-isolated: + @tree=$$($(CARGO_TREE_NAMES)) || exit 1; \ + nats_tree=$$($(CARGO_TREE_NAMES) --features nats) || exit 1; \ + if echo "$$tree" | grep -qE '^(async-nats|nats) '; then \ + echo "❌ nats crates in the default server/worker tree:"; \ + echo "$$tree" | grep -E '^(async-nats|nats) ' | sort -u; \ + exit 1; \ + fi; \ + if ! echo "$$nats_tree" | grep -qE '^async-nats '; then \ + echo "❌ async-nats missing from the --features nats server/worker tree — feature is not wired"; \ + exit 1; \ + fi; \ + echo "✅ async-nats only appears with --features nats" + +check-no-second-web-stack: + @for f in "" "--features nats"; do \ + tree=$$($(CARGO_TREE_NAMES) $$f) || exit 1; \ + hits=$$(echo "$$tree" | grep -E '^(actix|aws-lc)' | sort -u); \ + if [ -n "$$hits" ]; then \ + echo "❌ actix/aws-lc crates in the server/worker tree ($${f:-default}):"; \ + echo "$$hits"; \ + exit 1; \ + fi; \ + done; \ + echo "✅ no actix-* or aws-lc-* in the default or nats server/worker tree" fmt: cargo fmt @@ -74,10 +132,13 @@ fmt-check: cargo fmt --check clippy: - cargo clippy -- -D warnings + cargo clippy --workspace --all-targets -- -D warnings test: - cargo test + cargo test --workspace + +test-federation-off: + cargo test --workspace --no-default-features fix: cargo fmt @@ -106,4 +167,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 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-federation-off-deps check-nats-isolated check-no-second-web-stack diff --git a/README.md b/README.md index d09ccdaa..6c15ed7e 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) @@ -64,10 +64,11 @@ 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 -- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar) +- OpenAPI documentation at `/docs` (Swagger UI) - CSRF protection on all HTML form routes (double-submit cookie, defense-in-depth on top of `SameSite=Strict`) - Per-IP rate limiting via token bucket (production-grade, backed by `axum-governor`) - Single-page app at `/app/` — React + TanStack Router + shadcn/ui, built with Vite, served from the backend with client-side routing fallback @@ -90,42 +91,32 @@ 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 -infra-wiring — shared infrastructure types (DbPool, EventBusBackend, AppConfig) used by both server and worker binaries +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 -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 -worker — standalone worker binary (event consumer, poster sync, federation) +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/ - adapter-common — shared row-to-domain conversions, sqlx error mapping, date/uuid parsing utils + http-axum — Axum HTTP adapter: JSON API, classic HTML UI (askama templates), OpenAPI spec, Swagger UI, federation-gated routes; library crate, no binary of its own auth — JWT issuance and validation (Argon2 passwords) - sqlite — SQLite repository + connection factory - postgres — PostgreSQL repository + connection factory + 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 - 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) - 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) + media-server — Jellyfin and Plex webhook payload parsers (MediaServerParser adapter; Plex requires Plex Pass) + event-publisher — in-memory event channel 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 + 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 ``` @@ -138,34 +129,58 @@ 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 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 or PostgreSQL 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`; 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` | `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,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`) | +`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 + +| 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. + +### Build notes + +- `cargo build` / `cargo test` build every crate except the deprecated `tui`, which sits outside `default-members`. Use `-p tui` for it; `make check` and CI use `--workspace`, so it is still linted and tested. +- Release builds use `codegen-units = 1` and `strip = true` (smaller binaries; thin LTO was measured and made builds slower and binaries larger, so it is off). +- SQLite is compiled from source (bundled `libsqlite3-sys`), so the first clean build spends about a minute on it; incremental builds and the Docker/CI caches don't repeat that. +- TLS uses rustls with the `ring` provider only; `aws-lc` and `actix` are kept out of the graph by `make check-no-second-web-stack`. + The `worker` binary must run alongside `server` to process events: ```bash @@ -181,6 +196,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`. @@ -188,7 +205,6 @@ All REST endpoints are under `/api/v1/`. Authentication uses `Authorization: Bea Interactive API documentation is available at runtime: - **Swagger UI** — `http://localhost:3000/docs` -- **Scalar** — `http://localhost:3000/scalar` An [Insomnia](https://insomnia.rest/) collection covering all endpoints is included at [`movies-diary.insomnia.json`](movies-diary.insomnia.json). Import it via **File → Import**, set `base_url` and `token` in the environment, and you're ready to go. @@ -219,23 +235,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. +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 @@ -260,7 +279,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`. The Dockerfile uses [cargo-chef](https://github.com/LukeMathWalker/cargo-chef): dependencies are compiled in their own layer keyed on the manifests, so a source-only change rebuilds just the workspace crates. BuildKit cache mounts keep the cargo registry between builds. No Dockerfile edit is needed when a crate is added. 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..eff20264 100644 --- a/architecture.mmd +++ b/architecture.mmd @@ -2,15 +2,19 @@ title: Movies Diary — Hexagonal Architecture --- graph TB - subgraph Binaries["Binaries (Composition Root)"] - WEB["presentation
Axum web server
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"] @@ -77,66 +81,68 @@ graph TB DTO["DTOs
MovieDto, ReviewDto,
FeedEntryDto, UserSummaryDto,
HtmlPageContext, ...
"] end - subgraph InfraWiring["infra-wiring"] - IW["DbPool, EventBusBackend,
AppConfig
Shared infra types"] + subgraph Bootstrap["bootstrap"] + 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_PG["postgres
PostgreSQL repos"] - A_SQLITE_SEARCH["sqlite-search
FTS5"] - A_PG_SEARCH["postgres-search
tsvector/GIN"] + 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_PG_QUEUE["postgres-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_PG_FED["postgres-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"] - A_TEMPLATE["template-askama
HTML templates"] + 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 + A_NATS -.->|"serialises with"| EventPayload + A_SQLITE -.->|"event queue serialises with"| EventPayload %% Key data flows - WEB ===|"HTTP"| DTO + A_HTTP ===|"HTTP"| DTO WORKER ===|"Events"| EVENTS classDef domain fill:#1a1a2e,stroke:#e94560,color:#fff @@ -150,4 +156,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/activitypub/Cargo.toml b/crates/adapters/activitypub/Cargo.toml index 4fad9e32..ece4a234 100644 --- a/crates/adapters/activitypub/Cargo.toml +++ b/crates/adapters/activitypub/Cargo.toml @@ -1,19 +1,26 @@ [package] name = "activitypub" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] -k-ap = { version = "0.5.0", registry = "gitea" } +config = { workspace = true } +k-ap = { workspace = true } domain = { workspace = true } axum = { workspace = true } -serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } +uuid = { workspace = true, features = ["v4", "serde"] } +chrono = { workspace = true, features = ["clock", "serde"] } anyhow = { workspace = true } tracing = { workspace = true } async-trait = { workspace = true } -url = { version = "2", features = ["serde"] } -ammonia = "4" +url = { workspace = true, features = ["serde"] } +ammonia = { workspace = true } + +tokio = { workspace = true, features = ["time", "sync"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros"] } +domain = { workspace = true, features = ["test-helpers"] } diff --git a/crates/adapters/activitypub/src/deletion_notifier.rs b/crates/adapters/activitypub/src/deletion_notifier.rs new file mode 100644 index 00000000..2254c94f --- /dev/null +++ b/crates/adapters/activitypub/src/deletion_notifier.rs @@ -0,0 +1,33 @@ +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; + +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 09f6b829..c2dda4d5 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, } } @@ -55,7 +58,17 @@ impl ActivityPubEventHandler { #[async_trait] impl EventHandler for ActivityPubEventHandler { + fn name(&self) -> &'static str { + "activitypub" + } + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { + 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, .. @@ -139,13 +152,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, @@ -164,6 +170,36 @@ impl EventHandler for ActivityPubEventHandler { } impl ActivityPubEventHandler { + 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/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 ee8e3531..4b463eb7 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,12 +8,12 @@ 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; -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, @@ -20,9 +21,10 @@ 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::{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; @@ -46,6 +48,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 { @@ -68,6 +71,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 +96,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 { @@ -99,7 +104,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 { @@ -118,9 +122,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!( @@ -140,7 +142,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) @@ -158,7 +160,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, @@ -167,11 +178,13 @@ 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; let ports = std::sync::Arc::new(federation_ports::ApServiceAdapter::new( std::sync::Arc::clone(&concrete), + std::sync::Arc::clone(&user_repo), )); Ok(ActivityPubWire { @@ -183,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/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/remote_review_repository.rs b/crates/adapters/activitypub/src/remote_review_repository.rs index 682d4384..88df1a06 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, @@ -14,17 +14,19 @@ pub struct RemoteReviewUpdate<'a> { pub spoilers: bool, } +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 { - 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<()>; + 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 794bc8fb..b9a42f8f 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, }, @@ -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 { @@ -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,28 +159,30 @@ impl ApObjectHandler for ReviewObjectHandler { spoilers: obj.contains_spoilers, }); - 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(), - ) - .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 { + 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, - }) - .await; - } + }, + ) + .into_iter() + .collect(); + + self.review_store + .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/activitypub/src/social_adapter.rs b/crates/adapters/activitypub/src/social_adapter.rs index c0a16c78..e90dff9e 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,22 @@ impl CompositeSocialAdapter { ap_service: Arc, user_repo: Arc, instance: InstanceIdentity, + event_publisher: Arc, ) -> Self { Self { local, ap_service, user_repo, instance, + event_publisher, + } + } + + 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 +54,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 { @@ -64,21 +86,27 @@ impl SocialCommand for CompositeSocialAdapter { 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, + 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 +114,18 @@ 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, + 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 +133,18 @@ 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, + 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 +152,49 @@ 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, + 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> { + 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> { + 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/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/src/tombstone_gate.rs b/crates/adapters/activitypub/src/tombstone_gate.rs new file mode 100644 index 00000000..100471bb --- /dev/null +++ b/crates/adapters/activitypub/src/tombstone_gate.rs @@ -0,0 +1,32 @@ +use std::sync::Arc; + +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::{IntoResponse, Response}, +}; +use domain::{ports::UserRepository, value_objects::UserId}; + +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 +} + +pub fn gated_user(path: &str) -> Option { + let rest = path.strip_prefix("/users/")?; + let (id, endpoint) = rest.split_once('/')?; + (!endpoint.is_empty()).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..4a5e298a 100644 --- a/crates/adapters/activitypub/src/user_adapter.rs +++ b/crates/adapters/activitypub/src/user_adapter.rs @@ -66,7 +66,10 @@ impl ApUserRepository for DomainUserRepoAdapter { let user_id = UserId::from_uuid(id); 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/activity_ids.rs b/crates/adapters/activitypub/tests/activity_ids.rs new file mode 100644 index 00000000..303096ac --- /dev/null +++ b/crates/adapters/activitypub/tests/activity_ids.rs @@ -0,0 +1,253 @@ +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, FederationDeletionNotifier, LocalApContentQuery, MovieCommand, UserRepository, + }, + 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, + store: Arc, + service: Arc, +} + +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 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::clone(&service), + Arc::new(NoContent), + Arc::new(PanicReviewRepository), + store.clone(), + Arc::new(PanicGoalQuery), + Arc::new(PanicStatsRepository), + store.clone(), + store.clone(), + InstanceIdentity::new("https://diary.example"), + ); + Harness { + handler, + delivered: rx, + user, + movie_id: movie.id().clone(), + store, + service, + } +} + +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"); +} + +#[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); +} + +#[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(), + }; + + 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/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"]) + ); +} diff --git a/crates/adapters/activitypub/tests/user_adapter.rs b/crates/adapters/activitypub/tests/user_adapter.rs new file mode 100644 index 00000000..16abb493 --- /dev/null +++ b/crates/adapters/activitypub/tests/user_adapter.rs @@ -0,0 +1,67 @@ +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 every_per_user_path_is_gated() { + use activitypub::tombstone_gate::gated_user; + let id = uuid::Uuid::new_v4(); + let rid = uuid::Uuid::new_v4(); + for endpoint in [ + "outbox", + "featured", + "inbox", + "followers", + "following", + &format!("reviews/{rid}"), + ] { + 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}/")), None); + assert_eq!(gated_user("/inbox"), None); + assert_eq!(gated_user("/users/not-a-uuid/outbox"), None); +} diff --git a/crates/adapters/adapter-common/Cargo.toml b/crates/adapters/adapter-common/Cargo.toml deleted file mode 100644 index 0951f37c..00000000 --- a/crates/adapters/adapter-common/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "adapter-common" -version = "0.1.0" -edition = "2024" - -[dependencies] -domain = { workspace = true } -sqlx = { workspace = true } -tracing = { workspace = true } -chrono = { workspace = true } -uuid = { workspace = true } diff --git a/crates/adapters/auth/Cargo.toml b/crates/adapters/auth/Cargo.toml index 437ffc67..5e70132d 100644 --- a/crates/adapters/auth/Cargo.toml +++ b/crates/adapters/auth/Cargo.toml @@ -1,15 +1,16 @@ [package] name = "auth" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] +config = { workspace = true } async-trait = { workspace = true } domain = { workspace = true } anyhow = { workspace = true } -chrono = { workspace = true } +chrono = { workspace = true, features = ["clock"] } uuid = { workspace = true } -serde = { version = "1.0", features = ["derive"] } -jsonwebtoken = "9" -argon2 = { version = "0.5", features = ["std"] } -rand_core = { version = "0.6", features = ["getrandom"] } +serde = { workspace = true, features = ["derive"] } +jsonwebtoken = { workspace = true } +argon2 = { workspace = true, features = ["std"] } +rand_core = { workspace = true, features = ["getrandom"] } 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-payload/Cargo.toml b/crates/adapters/event-payload/Cargo.toml deleted file mode 100644 index f06e5364..00000000 --- a/crates/adapters/event-payload/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "event-payload" -version = "0.1.0" -edition = "2024" - -[dependencies] -domain = { workspace = true } -serde = { workspace = true } -chrono = { workspace = true } -uuid = { workspace = true } -serde_json = { workspace = true } diff --git a/crates/adapters/event-payload/src/tests/lib.rs b/crates/adapters/event-payload/src/tests/lib.rs deleted file mode 100644 index 6eb8b5c0..00000000 --- a/crates/adapters/event-payload/src/tests/lib.rs +++ /dev/null @@ -1,101 +0,0 @@ -use super::*; - -fn fixed_dt() -> NaiveDateTime { - chrono::DateTime::from_timestamp(1_700_000_000, 0) - .unwrap() - .naive_utc() -} - -fn review_logged() -> DomainEvent { - DomainEvent::ReviewLogged { - review_id: ReviewId::from_uuid(Uuid::new_v4()), - movie_id: MovieId::from_uuid(Uuid::new_v4()), - user_id: UserId::from_uuid(Uuid::new_v4()), - rating: Rating::new(4).unwrap(), - watched_at: fixed_dt(), - } -} - -fn review_updated() -> DomainEvent { - DomainEvent::ReviewUpdated { - review_id: ReviewId::from_uuid(Uuid::new_v4()), - movie_id: MovieId::from_uuid(Uuid::new_v4()), - user_id: UserId::from_uuid(Uuid::new_v4()), - rating: Rating::new(3).unwrap(), - watched_at: fixed_dt(), - } -} - -fn movie_discovered() -> DomainEvent { - DomainEvent::MovieDiscovered { - movie_id: MovieId::from_uuid(Uuid::new_v4()), - external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(), - } -} - -fn round_trip(event: DomainEvent) { - let payload = EventPayload::from(&event); - let json = serde_json::to_string(&payload).expect("serialize"); - let back: EventPayload = serde_json::from_str(&json).expect("deserialize"); - let recovered = DomainEvent::try_from(back).expect("try_from"); - assert_eq!(EventPayload::from(&event), EventPayload::from(&recovered)); -} - -#[test] -fn round_trip_review_logged() { - round_trip(review_logged()); -} - -#[test] -fn round_trip_review_updated() { - round_trip(review_updated()); -} - -#[test] -fn round_trip_movie_discovered() { - round_trip(movie_discovered()); -} - -#[test] -fn serialized_format_is_tagged() { - let payload = EventPayload::from(&movie_discovered()); - let json = serde_json::to_string(&payload).unwrap(); - assert!(json.contains(r#""type":"MovieDiscovered""#)); - assert!(json.contains(r#""data":"#)); -} - -#[test] -fn event_type_strings() { - assert_eq!( - EventPayload::from(&review_logged()).event_type(), - "ReviewLogged" - ); - assert_eq!( - EventPayload::from(&review_updated()).event_type(), - "ReviewUpdated" - ); - assert_eq!( - EventPayload::from(&movie_discovered()).event_type(), - "MovieDiscovered" - ); -} - -#[test] -fn round_trip_image_stored() { - let event = DomainEvent::ImageStored { - key: "avatars/abc123".into(), - }; - let payload = EventPayload::from(&event); - let json = serde_json::to_string(&payload).unwrap(); - let back: EventPayload = serde_json::from_str(&json).unwrap(); - let recovered = DomainEvent::try_from(back).unwrap(); - assert_eq!(EventPayload::from(&event), EventPayload::from(&recovered)); -} - -#[test] -fn image_stored_event_type() { - let payload = EventPayload::from(&DomainEvent::ImageStored { - key: "posters/x".into(), - }); - assert_eq!(payload.event_type(), "ImageStored"); -} diff --git a/crates/adapters/event-publisher/Cargo.toml b/crates/adapters/event-publisher/Cargo.toml index 33f40e12..1a6aef61 100644 --- a/crates/adapters/event-publisher/Cargo.toml +++ b/crates/adapters/event-publisher/Cargo.toml @@ -1,10 +1,14 @@ [package] name = "event-publisher" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] +config = { workspace = true } domain = { workspace = true } async-trait = { workspace = true } -tokio = { workspace = true } +tokio = { workspace = true, features = ["sync"] } futures = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/crates/adapters/event-publisher/src/lib.rs b/crates/adapters/event-publisher/src/lib.rs index 302ed8f5..e3c6289b 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, + } } } @@ -49,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 { @@ -60,7 +61,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)) })) } @@ -86,7 +87,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/Cargo.toml b/crates/adapters/export/Cargo.toml index 2b10cf48..7ae1a5c2 100644 --- a/crates/adapters/export/Cargo.toml +++ b/crates/adapters/export/Cargo.toml @@ -1,17 +1,16 @@ [package] name = "export" version = "0.1.0" -edition = "2024" +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 } [dev-dependencies] +domain = { workspace = true, features = ["test-helpers"] } uuid = { workspace = true } -tokio = { workspace = true } +tokio = { workspace = true, features = ["rt", "macros"] } 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 68% rename from crates/adapters/export/src/tests/lib.rs rename to crates/adapters/export/tests/export_adapter_test.rs index 83b82b83..46655597 100644 --- a/crates/adapters/export/src/tests/lib.rs +++ b/crates/adapters/export/tests/export_adapter_test.rs @@ -1,9 +1,12 @@ -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}, }; +use export::ExportAdapter; async fn collect_stream( stream: futures::stream::BoxStream<'static, Result>, @@ -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/Cargo.toml b/crates/adapters/http-axum/Cargo.toml new file mode 100644 index 00000000..952f7c6e --- /dev/null +++ b/crates/adapters/http-axum/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "http-axum" +version = "0.1.0" +edition.workspace = true +description = "Axum HTTP adapter: JSON API, classic HTML UI, optional ActivityPub-facing routes" +license = "MIT" + +[features] +default = ["federation"] +federation = ["application/federation"] + +[dependencies] +tower-http = { workspace = true, features = ["cors", "fs", "trace", "tracing"] } +infer = { workspace = true } +percent-encoding = { workspace = true } +axum-governor = { workspace = true } + +axum = { workspace = true, features = ["macros", "multipart", "http1", "json", "query", "form", "tokio", "matched-path", "original-uri", "tower-log", "tracing"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true, features = ["v4", "serde"] } +chrono = { workspace = true, features = ["clock", "serde"] } +chrono-tz = { workspace = true } +futures = { workspace = true } + +api-types = { workspace = true } +domain = { workspace = true } +application = { workspace = true } +askama = { workspace = true } +utoipa = { workspace = true, features = ["axum_extras", "uuid"] } +utoipa-swagger-ui = { workspace = true, features = ["axum", "vendored"] } + +[dev-dependencies] +bytes = { workspace = true } +futures = { workspace = true } +tower = { workspace = true, features = ["util"] } +tokio = { workspace = true, features = ["rt", "macros"] } +domain = { workspace = true, features = ["test-helpers"] } 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..1c7955ea --- /dev/null +++ b/crates/adapters/http-axum/src/api/auth.rs @@ -0,0 +1,102 @@ +use crate::extractors::Body; +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", body = api_types::ErrorResponse), + ) +)] +pub async fn login( + State(state): State, + Body(req): Body, +) -> 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", body = api_types::ErrorResponse), + ) +)] +pub async fn register( + State(state): State, + Body(req): Body, +) -> 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", body = api_types::ErrorResponse), + ) +)] +pub async fn refresh( + State(state): State, + Body(req): Body, +) -> 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, + Body(req): Body, +) -> 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..6413acad --- /dev/null +++ b/crates/adapters/http-axum/src/api/diary.rs @@ -0,0 +1,197 @@ +use crate::extractors::{Body, Params, PathId}; +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::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", body = api_types::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_diary( + State(state): State, + Params(params): Params, +) -> 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", 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, + Body(req): Body, +) -> 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", 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, + PathId(review_id): PathId, +) -> 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", 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, + PathId(review_id): PathId, + Body(req): Body, +) -> 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", 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, + 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), + (status = 400, description = "Invalid query", body = api_types::ErrorResponse), + ), +)] +pub async fn get_activity_feed( + State(state): State, + Params(params): Params, +) -> 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/presentation/src/handlers/goals.rs b/crates/adapters/http-axum/src/api/goals.rs similarity index 73% rename from crates/presentation/src/handlers/goals.rs rename to crates/adapters/http-axum/src/api/goals.rs index 2e1778c3..ceaac9df 100644 --- a/crates/presentation/src/handlers/goals.rs +++ b/crates/adapters/http-axum/src/api/goals.rs @@ -1,35 +1,18 @@ -use axum::{ - Json, - extract::{Path, State}, - http::StatusCode, -}; -use uuid::Uuid; - +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, }; -// ── 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 ──────────────────────────────────────────────────────────────── +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" = [])) )] @@ -54,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, @@ -80,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, @@ -107,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, @@ -132,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, @@ -151,13 +134,11 @@ pub async fn get_user_goals( })) } -// ── User Settings ──────────────────────────────────────────────────────────── - #[utoipa::path( 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" = [])) )] @@ -182,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/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..4b631f6c --- /dev/null +++ b/crates/adapters/http-axum/src/api/import/profiles.rs @@ -0,0 +1,102 @@ +use crate::extractors::{Body, PathId}; +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::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", body = api_types::ErrorResponse), + ), + 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", 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, + Body(body): Body, +) -> 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", 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, + PathId(profile_id_str): PathId, +) -> 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..db67a1af --- /dev/null +++ b/crates/adapters/http-axum/src/api/import/sessions.rs @@ -0,0 +1,307 @@ +use crate::extractors::{Body, PathId, Upload}; +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::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", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 422, description = "Parse error", body = api_types::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_post_session( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Upload(mut multipart): Upload, +) -> 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", 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, + PathId(session_id_str): PathId, +) -> 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", 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, + PathId(session_id_str): PathId, + Body(body): Body, +) -> 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, + PathId(session_id_str): PathId, +) -> 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", 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, + PathId(session_id_str): PathId, + Body(body): Body, +) -> 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", 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, + PathId((session_id_str, profile_id_str)): PathId<(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/presentation/src/handlers/insights.rs b/crates/adapters/http-axum/src/api/insights.rs similarity index 89% rename from crates/presentation/src/handlers/insights.rs rename to crates/adapters/http-axum/src/api/insights.rs index c71ccad8..918b6c66 100644 --- a/crates/presentation/src/handlers/insights.rs +++ b/crates/adapters/http-axum/src/api/insights.rs @@ -1,13 +1,9 @@ -use axum::{ - Json, - extract::{Query, State}, -}; - -use application::insights::get as get_insights_uc; -use domain::{errors::DomainError, models::Insight}; - +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::State}; +use domain::{errors::DomainError, models::Insight}; fn to_dto(insight: Insight) -> InsightDto { match insight { @@ -70,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/mod.rs b/crates/adapters/http-axum/src/api/mod.rs new file mode 100644 index 00000000..ef9f44ac --- /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; + +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/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}", + 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()) +} + +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/presentation/src/handlers/movies.rs b/crates/adapters/http-axum/src/api/movies.rs similarity index 66% rename from crates/presentation/src/handlers/movies.rs rename to crates/adapters/http-axum/src/api/movies.rs index ab54d045..b7b50d3a 100644 --- a/crates/presentation/src/handlers/movies.rs +++ b/crates/adapters/http-axum/src/api/movies.rs @@ -1,11 +1,10 @@ -use axum::{ - Json, - extract::{Extension, Path, Query, State}, - http::StatusCode, - response::IntoResponse, +use crate::extractors::{Params, PathId}; +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,38 +12,22 @@ 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::State, http::StatusCode, response::IntoResponse}; use domain::services::review_history::Trend; - -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 template_askama::MovieDetailTemplate; - -use super::helpers::build_page_context; - -// ── API ────────────────────────────────────────────────────────────────────── +use uuid::Uuid; #[utoipa::path( get, path = "/api/v1/movies", 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, @@ -62,7 +45,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, @@ -75,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, @@ -89,11 +72,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", @@ -109,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, @@ -132,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); @@ -154,7 +137,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, @@ -189,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 }; @@ -257,72 +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), - } -} - -// ── 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() + 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/presentation/src/handlers/search.rs b/crates/adapters/http-axum/src/api/search.rs similarity index 77% rename from crates/presentation/src/handlers/search.rs rename to crates/adapters/http-axum/src/api/search.rs index 4f39f9bc..e0c28512 100644 --- a/crates/presentation/src/handlers/search.rs +++ b/crates/adapters/http-axum/src/api/search.rs @@ -1,34 +1,28 @@ -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::extractors::{Params, PathId}; 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::State, http::StatusCode, response::IntoResponse}; +use domain::models::{PersonId, collections::PageParams}; #[utoipa::path( get, path = "/api/v1/search", 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, @@ -84,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(), } } @@ -93,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, @@ -107,11 +101,11 @@ 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) => { + crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found") } - Ok(None) => StatusCode::NOT_FOUND.into_response(), - Err(e) => crate::errors::domain_error_response(e), + Err(e) => crate::errors::ApiError(e).into_response(), } } @@ -120,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, @@ -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() @@ -161,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(), } } @@ -171,20 +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", + ) } } } - -// ── 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..02cf5bb3 --- /dev/null +++ b/crates/adapters/http-axum/src/api/social.rs @@ -0,0 +1,344 @@ +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::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", body = api_types::ErrorResponse), + ), + 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", body = api_types::ErrorResponse), + ), + 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, + PathId(user_id): PathId, +) -> 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, + PathId(user_id): PathId, +) -> 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", body = api_types::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn follow( + State(state): State, + user: AuthenticatedUser, + Body(body): Body, +) -> 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", body = api_types::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn unfollow( + State(state): State, + user: AuthenticatedUser, + Body(body): Body, +) -> 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", body = api_types::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn accept_follower( + State(state): State, + user: AuthenticatedUser, + Body(body): Body, +) -> 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", body = api_types::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn reject_follower( + State(state): State, + user: AuthenticatedUser, + Body(body): Body, +) -> 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", body = api_types::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn remove_follower( + State(state): State, + user: AuthenticatedUser, + Body(body): Body, +) -> 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", body = api_types::ErrorResponse), + ), + 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", body = api_types::ErrorResponse), + ), + 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", body = api_types::ErrorResponse), + ), + 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", body = api_types::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_relationship( + State(state): State, + user: AuthenticatedUser, + Params(q): Params, +) -> 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/api/social/tests.rs b/crates/adapters/http-axum/src/api/social/tests.rs new file mode 100644 index 00000000..f078380f --- /dev/null +++ b/crates/adapters/http-axum/src/api/social/tests.rs @@ -0,0 +1,69 @@ +use super::*; +use crate::test_support::local_actor; +use domain::value_objects::{InstanceIdentity, SocialActor, SocialIdentity, UserId}; + +#[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" + ); +} + +#[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/adapters/http-axum/src/api/user_profile.rs b/crates/adapters/http-axum/src/api/user_profile.rs new file mode 100644 index 00000000..bac314cb --- /dev/null +++ b/crates/adapters/http-axum/src/api/user_profile.rs @@ -0,0 +1,287 @@ +use crate::extractors::{Params, PathId}; +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::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", body = api_types::ErrorResponse), + ) +)] +pub async fn get_user_profile( + State(state): State, + AuthenticatedUser(viewer_id): AuthenticatedUser, + 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 crate::errors::refuse( + StatusCode::BAD_REQUEST, + crate::errors::INVALID_INPUT, + "invalid profile view", + ); + } + }; + + 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 crate::errors::refuse( + StatusCode::NOT_FOUND, + crate::errors::NOT_FOUND, + "not found", + ); + } + Err(e) => return crate::errors::ApiError(e).into_response(), + }; + + 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, + 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()), + }) + .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::ApiError(e).into_response(), + }; + + 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, + is_service_account: false, + 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..f7d7df1e --- /dev/null +++ b/crates/adapters/http-axum/src/api/users.rs @@ -0,0 +1,217 @@ +use crate::extractors::{Body, PathId, Upload}; +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use api_types::{DeleteAccountRequest, ProfileResponse, UserSummaryDto, UsersResponse}; +use application::users::{ + 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", + responses( + (status = 200, body = ProfileResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "User not found", body = api_types::ErrorResponse), + ), + 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", 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_handler( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Upload(multipart): Upload, +) -> 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::ApiError(e).into_response(), + } +} + +#[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", 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, + Body(body): Body, +) -> impl IntoResponse { + let raw_fields = match body.get("fields").and_then(|f| f.as_array()) { + Some(arr) => arr.clone(), + None => { + return crate::errors::refuse( + StatusCode::BAD_REQUEST, + crate::errors::INVALID_INPUT, + "body must contain a fields array", + ); + } + }; + + 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::ApiError(e).into_response(), + } +} + +#[utoipa::path( + get, path = "/api/v1/users", + 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 = + 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(), + })) +} + +async fn delete_account( + state: &AppState, + actor: UserId, + target: UserId, + password: &str, +) -> 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/api/watchlist.rs b/crates/adapters/http-axum/src/api/watchlist.rs new file mode 100644 index 00000000..18a24234 --- /dev/null +++ b/crates/adapters/http-axum/src/api/watchlist.rs @@ -0,0 +1,144 @@ +use crate::extractors::{Body, Params, PathId}; +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::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", body = api_types::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_watchlist_handler( + State(state): State, + user: AuthenticatedUser, + Params(params): Params, +) -> 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", 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, + Body(req): Body, +) -> 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", 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, + PathId(movie_id): PathId, +) -> 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", body = api_types::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_watchlist_status( + State(state): State, + user: AuthenticatedUser, + PathId(movie_id): PathId, +) -> 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/presentation/src/handlers/webhook.rs b/crates/adapters/http-axum/src/api/webhook.rs similarity index 82% rename from crates/presentation/src/handlers/webhook.rs rename to crates/adapters/http-axum/src/api/webhook.rs index de53ea42..f0201ef1 100644 --- a/crates/presentation/src/handlers/webhook.rs +++ b/crates/adapters/http-axum/src/api/webhook.rs @@ -1,11 +1,5 @@ -use axum::{ - Json, - extract::{Multipart, Path, State}, - http::{HeaderMap, StatusCode}, - response::IntoResponse, -}; -use uuid::Uuid; - +use crate::extractors::{Body, Params, PathId, Upload}; +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse, GenerateTokenRequest, GenerateTokenResponse, WatchQueueEntryDto, WebhookTokenDto, @@ -21,9 +15,14 @@ use application::integrations::{ queries::{GetWatchQueueQuery, GetWebhookTokensQuery}, revoke_token as revoke_webhook_token, }; +use axum::{ + Json, + extract::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 @@ -38,31 +37,37 @@ 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()) } -// ── 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"), 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 { @@ -79,27 +84,25 @@ 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"), 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, - mut multipart: Multipart, + Params(query): Params, + Upload(mut multipart): Upload, ) -> 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; @@ -114,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 { @@ -130,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, @@ -138,26 +147,24 @@ 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(), } } -// ── Token management (JWT-authenticated) ────────────────────────────────────── - #[utoipa::path( post, path = "/api/v1/settings/webhook-tokens", 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 @@ -190,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" = [])) )] @@ -224,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(), @@ -243,13 +250,11 @@ pub async fn delete_webhook_token( Ok(StatusCode::NO_CONTENT) } -// ── Watch queue (JWT-authenticated) ─────────────────────────────────────────── - #[utoipa::path( 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" = [])) )] @@ -283,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(), @@ -317,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/presentation/src/handlers/wrapup.rs b/crates/adapters/http-axum/src/api/wrapup.rs similarity index 50% rename from crates/presentation/src/handlers/wrapup.rs rename to crates/adapters/http-axum/src/api/wrapup.rs index 0bb6545a..4b3a6df5 100644 --- a/crates/presentation/src/handlers/wrapup.rs +++ b/crates/adapters/http-axum/src/api/wrapup.rs @@ -1,31 +1,23 @@ -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::extractors::{Body, PathId}; 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::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 { @@ -45,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()))?; @@ -76,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" = [])) )] @@ -101,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, @@ -124,17 +116,17 @@ 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"), - (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, @@ -147,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(), } } @@ -160,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, @@ -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 = template_askama::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/presentation/src/context.rs b/crates/adapters/http-axum/src/context.rs similarity index 85% rename from crates/presentation/src/context.rs rename to crates/adapters/http-axum/src/context.rs index 11f7c6b3..d1fc24ba 100644 --- a/crates/presentation/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/adapters/http-axum/src/errors.rs b/crates/adapters/http-axum/src/errors.rs new file mode 100644 index 00000000..7f7e6728 --- /dev/null +++ b/crates/adapters/http-axum/src/errors.rs @@ -0,0 +1,81 @@ +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 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"; +pub const INTERNAL: &str = "INTERNAL"; + +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(_) => { + StatusCode::BAD_REQUEST + } + DomainError::NotFound(_) => StatusCode::NOT_FOUND, + DomainError::Unauthorized(_) | DomainError::InvalidCredentials(_) => { + StatusCode::UNAUTHORIZED + } + DomainError::Forbidden(_) => StatusCode::FORBIDDEN, + DomainError::Conflict(_) => StatusCode::CONFLICT, + DomainError::InfrastructureError(_) => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +pub fn domain_error_response(e: DomainError) -> Response { + match &e { + DomainError::InfrastructureError(_) => { + tracing::error!("Internal error: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + _ => (domain_error_status(&e), e.to_string()).into_response(), + } +} + +pub struct ApiError(pub DomainError); + +impl From for ApiError { + fn from(err: DomainError) -> Self { + Self(err) + } +} + +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::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(_) => { + tracing::error!("Internal error: {:?}", self.0); + refuse(status, INTERNAL, "internal server error") + } + } + } +} 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..1eb12f66 --- /dev/null +++ b/crates/adapters/http-axum/src/extractors/json.rs @@ -0,0 +1,122 @@ +use axum::{ + extract::{ + FromRequest, FromRequestParts, Multipart, Path, Query, Request, + multipart::MultipartRejection, + rejection::{JsonRejection, PathRejection, QueryRejection}, + }, + http::{StatusCode, request::Parts}, + response::{IntoResponse, Response}, +}; +use serde::de::DeserializeOwned; + +use crate::errors::{INTERNAL, INVALID_INPUT, refuse}; + +pub struct MalformedRequest { + status: StatusCode, + message: String, +} + +impl IntoResponse for MalformedRequest { + fn into_response(self) -> Response { + let code = if self.status.is_server_error() { + INTERNAL + } else { + INVALID_INPUT + }; + refuse(self.status, code, 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(), + } + } +} + +impl From for MalformedRequest { + fn from(r: MultipartRejection) -> Self { + Self { + status: r.status(), + message: r.body_text(), + } + } +} + +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?)) + } +} + +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)) + } +} + +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)) + } +} + +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/presentation/src/extractors.rs b/crates/adapters/http-axum/src/extractors/mod.rs similarity index 81% rename from crates/presentation/src/extractors.rs rename to crates/adapters/http-axum/src/extractors/mod.rs index b10d0a22..7000f100 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/adapters/http-axum/src/extractors/mod.rs @@ -1,3 +1,6 @@ +mod json; +pub use json::{Body, MalformedRequest, Params, PathId, Upload}; + use axum::{ extract::{FromRef, FromRequestParts}, http::{StatusCode, header, header::AUTHORIZATION, request::Parts}, @@ -7,6 +10,11 @@ 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 +} + pub struct AuthenticatedUser(pub UserId); impl FromRequestParts for AuthenticatedUser @@ -34,6 +42,7 @@ where .auth .validate_token(token) .await?; + ensure_active(&app_state, &user_id).await?; Ok(AuthenticatedUser(user_id)) } } @@ -72,6 +81,18 @@ where .validate_token(&token) .await .ok(); + let user_id = match user_id { + 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)) } } @@ -94,6 +115,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)) } } @@ -153,7 +182,3 @@ where } } } - -#[cfg(test)] -#[path = "tests/extractors.rs"] -pub(crate) mod tests; 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..6d7a950c --- /dev/null +++ b/crates/adapters/http-axum/src/federation/blocklist_api.rs @@ -0,0 +1,170 @@ +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}; +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", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse), + ), + 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", 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, + Body(body): Body, +) -> 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", 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, + PathId(domain): PathId, +) -> 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", body = api_types::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn block_actor_api( + State(state): State, + user: AuthenticatedUser, + Body(body): Body, +) -> 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", body = api_types::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn unblock_actor_api( + State(state): State, + user: AuthenticatedUser, + Body(body): Body, +) -> 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", body = api_types::ErrorResponse), + ), + 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..2d1a98c7 --- /dev/null +++ b/crates/adapters/http-axum/src/federation/blocklist_api/tests.rs @@ -0,0 +1,14 @@ +use super::*; +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() { + 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..65a2d2f2 --- /dev/null +++ b/crates/adapters/http-axum/src/federation/collections.rs @@ -0,0 +1,95 @@ +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") { + 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 + .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") { + 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 + .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..3f205b2f --- /dev/null +++ b/crates/adapters/http-axum/src/federation/mod.rs @@ -0,0 +1,110 @@ +#[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; + +#[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), + ) +} + +#[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), + ) +} + +#[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() +} + +#[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..64cb5ecc --- /dev/null +++ b/crates/adapters/http-axum/src/federation/negotiation.rs @@ -0,0 +1,43 @@ +use axum::{ + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use uuid::Uuid; + +use crate::state::AppState; + +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; + } + 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 + .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/html/auth.rs b/crates/adapters/http-axum/src/html/auth.rs new file mode 100644 index 00000000..fcc82622 --- /dev/null +++ b/crates/adapters/http-axum/src/html/auth.rs @@ -0,0 +1,155 @@ +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, + extract::{Extension, Query, State}, + http::{HeaderValue, StatusCode, header::SET_COOKIE}, + response::{IntoResponse, Redirect}, +}; +use chrono::Utc; + +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_cookies) + ); + ( + SET_COOKIE, + HeaderValue::from_str(&val).expect("valid cookie"), + ) +} + +pub async fn get_login_page( + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let ctx = HtmlPageContext { + user_email: None, + user_id: None, + is_admin: false, + register_enabled: state.app_ctx.config.allow_registration, + rss_url: "/feed.rss".to_string(), + page_title: "Login — Movies Diary".to_string(), + canonical_url: format!("{}/login", state.app_ctx.config.base_url), + csrf_token: csrf.0, + page_rss_url: None, + pending_follow_count: 0, + }; + render_page(LoginTemplate { + ctx: &ctx, + error: params.error.as_deref(), + }) +} + +pub async fn post_login( + 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 login_uc::execute( + &state.app_ctx.deps.auth.login, + LoginCommand { + email: form.email, + password: form.password, + }, + ) + .await + { + Ok(result) => { + let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); + 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(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) + ); + let cookie = ( + SET_COOKIE, + HeaderValue::from_str(&val).expect("valid cookie"), + ); + ([cookie], Redirect::to("/")).into_response() +} + +pub async fn get_register_page( + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + if !state.app_ctx.config.allow_registration { + return Redirect::to("/").into_response(); + } + let ctx = HtmlPageContext { + user_email: None, + user_id: None, + is_admin: false, + register_enabled: true, + rss_url: "/feed.rss".to_string(), + page_title: "Register — Movies Diary".to_string(), + canonical_url: format!("{}/register", state.app_ctx.config.base_url), + csrf_token: csrf.0, + page_rss_url: None, + pending_follow_count: 0, + }; + render_page(RegisterTemplate { + ctx: &ctx, + error: params.error.as_deref(), + }) + .into_response() +} + +pub async fn post_register( + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if !state.app_ctx.config.allow_registration { + return Redirect::to("/").into_response(); + } + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match application::auth::register_and_login::execute( + &state.app_ctx.deps.auth.register_and_login, + application::auth::commands::RegisterAndLoginCommand { + email: form.email, + username: form.username, + password: form.password, + }, + ) + .await + { + Ok(result) => { + let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); + let cookie = + set_cookie_header(&result.token, max_age, state.app_ctx.config.secure_cookies); + ([cookie], Redirect::to("/")).into_response() + } + Err(_) => { + Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response() + } + } +} diff --git a/crates/presentation/src/csrf.rs b/crates/adapters/http-axum/src/html/csrf.rs similarity index 75% rename from crates/presentation/src/csrf.rs rename to crates/adapters/http-axum/src/html/csrf.rs index 70464fd4..52fd9ba9 100644 --- a/crates/presentation/src/csrf.rs +++ b/crates/adapters/http-axum/src/html/csrf.rs @@ -1,5 +1,5 @@ use axum::{ - extract::Request, + extract::{Request, State}, http::{HeaderValue, header}, middleware::Next, response::Response, @@ -19,15 +19,18 @@ 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 { - "" - } +#[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 +45,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/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/presentation/src/forms.rs b/crates/adapters/http-axum/src/html/forms.rs similarity index 98% rename from crates/presentation/src/forms.rs rename to crates/adapters/http-axum/src/html/forms.rs index ef32ac3e..7cb8b8d5 100644 --- a/crates/presentation/src/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, @@ -310,7 +317,3 @@ pub struct DismissWatchForm { #[serde(rename = "_csrf", default)] pub csrf_token: String, } - -#[cfg(test)] -#[path = "tests/forms.rs"] -mod tests; 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/presentation/src/handlers/images.rs b/crates/adapters/http-axum/src/html/images.rs similarity index 99% rename from crates/presentation/src/handlers/images.rs rename to crates/adapters/http-axum/src/html/images.rs index 04820aaf..6595f5f5 100644 --- a/crates/presentation/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/presentation/src/handlers/integrations.rs b/crates/adapters/http-axum/src/html/integrations.rs similarity index 80% rename from crates/presentation/src/handlers/integrations.rs rename to crates/adapters/http-axum/src/html/integrations.rs index 6325461b..8e98de87 100644 --- a/crates/presentation/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::{ - 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 template_askama::{IntegrationsTemplate, WatchQueueTemplate}; - -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; @@ -46,9 +41,9 @@ 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) + .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, @@ -146,9 +139,9 @@ 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) + .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/presentation/src/mappers/import.rs b/crates/adapters/http-axum/src/html/mappers/import.rs similarity index 94% rename from crates/presentation/src/mappers/import.rs rename to crates/adapters/http-axum/src/html/mappers/import.rs index f5ce7022..e1230b46 100644 --- a/crates/presentation/src/mappers/import.rs +++ b/crates/adapters/http-axum/src/html/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/html/mappers/integrations.rs similarity index 92% rename from crates/presentation/src/mappers/integrations.rs rename to crates/adapters/http-axum/src/html/mappers/integrations.rs index f2477fb7..4282eb86 100644 --- a/crates/presentation/src/mappers/integrations.rs +++ b/crates/adapters/http-axum/src/html/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/html/mappers/mod.rs similarity index 60% rename from crates/presentation/src/mappers/mod.rs rename to crates/adapters/http-axum/src/html/mappers/mod.rs index 12a30a15..b1c2872f 100644 --- a/crates/presentation/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/presentation/src/mappers/users.rs b/crates/adapters/http-axum/src/html/mappers/users.rs similarity index 94% rename from crates/presentation/src/mappers/users.rs rename to crates/adapters/http-axum/src/html/mappers/users.rs index ee3c0fd8..b9952a9c 100644 --- a/crates/presentation/src/mappers/users.rs +++ b/crates/adapters/http-axum/src/html/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/html/mappers/watchlist.rs similarity index 97% rename from crates/presentation/src/mappers/watchlist.rs rename to crates/adapters/http-axum/src/html/mappers/watchlist.rs index 9ff5b830..00752b47 100644 --- a/crates/presentation/src/mappers/watchlist.rs +++ b/crates/adapters/http-axum/src/html/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/adapters/http-axum/src/html/mod.rs b/crates/adapters/http-axum/src/html/mod.rs new file mode 100644 index 00000000..5691afde --- /dev/null +++ b/crates/adapters/http-axum/src/html/mod.rs @@ -0,0 +1,165 @@ +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; + +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( + "/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", + 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()) +} + +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..b405fd60 --- /dev/null +++ b/crates/adapters/http-axum/src/html/profile.rs @@ -0,0 +1,262 @@ +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, + 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, + 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/presentation/src/render.rs b/crates/adapters/http-axum/src/html/render.rs similarity index 79% rename from crates/presentation/src/render.rs rename to crates/adapters/http-axum/src/html/render.rs index b54e5220..6cc026eb 100644 --- a/crates/presentation/src/render.rs +++ b/crates/adapters/http-axum/src/html/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/handlers/rss.rs b/crates/adapters/http-axum/src/html/rss.rs similarity index 85% rename from crates/presentation/src/handlers/rss.rs rename to crates/adapters/http-axum/src/html/rss.rs index ed4d13e4..94b35d5e 100644 --- a/crates/presentation/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::{errors::DomainError, 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, @@ -29,7 +29,7 @@ pub async fn get_feed(State(state): State) -> Result 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..e03ca953 --- /dev/null +++ b/crates/adapters/http-axum/src/html/social/tests.rs @@ -0,0 +1,14 @@ +use super::*; +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() { + 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/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..f3ac8019 --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/users.rs @@ -0,0 +1,195 @@ +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 is_service_account: 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 error: Option<&'a str>, + 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..50785572 --- /dev/null +++ b/crates/adapters/http-axum/src/html/users.rs @@ -0,0 +1,245 @@ +use crate::html::forms::DeleteAccountForm; +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::{ + Form, + extract::{Extension, Multipart, Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use domain::{ + errors::DomainError, + value_objects::{UserId, Username}, +}; +use uuid::Uuid; + +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 error: 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, + error: params.error.as_deref(), + 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() +} + +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 { + Ok(()) if own => super::auth::get_logout(State(state)).await.into_response(), + Ok(()) => axum::response::Redirect::to("/users").into_response(), + 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) + )) + .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 new file mode 100644 index 00000000..6afdf869 --- /dev/null +++ b/crates/adapters/http-axum/src/lib.rs @@ -0,0 +1,12 @@ +pub mod api; +pub mod context; +pub mod errors; +pub mod extractors; +pub mod federation; +pub mod html; +pub mod openapi; +pub mod router; +pub mod state; +mod support; +#[cfg(test)] +mod test_support; diff --git a/crates/presentation/src/openapi/auth.rs b/crates/adapters/http-axum/src/openapi/auth.rs similarity index 73% rename from crates/presentation/src/openapi/auth.rs rename to crates/adapters/http-axum/src/openapi/auth.rs index 8e9ff839..f727a70a 100644 --- a/crates/presentation/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/presentation/src/openapi/diary.rs b/crates/adapters/http-axum/src/openapi/diary.rs similarity index 62% rename from crates/presentation/src/openapi/diary.rs rename to crates/adapters/http-axum/src/openapi/diary.rs index 971cf3d7..a91c5ec5 100644 --- a/crates/presentation/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/presentation/src/openapi/goals.rs b/crates/adapters/http-axum/src/openapi/goals.rs similarity index 56% rename from crates/presentation/src/openapi/goals.rs rename to crates/adapters/http-axum/src/openapi/goals.rs index 889e08a3..e55456d5 100644 --- a/crates/presentation/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/presentation/src/openapi/import.rs b/crates/adapters/http-axum/src/openapi/import.rs similarity index 55% rename from crates/presentation/src/openapi/import.rs rename to crates/adapters/http-axum/src/openapi/import.rs index c14ffc37..5bb1ccba 100644 --- a/crates/presentation/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/presentation/src/openapi/insights.rs b/crates/adapters/http-axum/src/openapi/insights.rs similarity index 76% rename from crates/presentation/src/openapi/insights.rs rename to crates/adapters/http-axum/src/openapi/insights.rs index b84e841f..19680b2a 100644 --- a/crates/presentation/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/presentation/src/openapi/mod.rs b/crates/adapters/http-axum/src/openapi/mod.rs similarity index 79% rename from crates/presentation/src/openapi/mod.rs rename to crates/adapters/http-axum/src/openapi/mod.rs index 79401054..b42ca585 100644 --- a/crates/presentation/src/openapi/mod.rs +++ b/crates/adapters/http-axum/src/openapi/mod.rs @@ -16,7 +16,6 @@ use utoipa::{ Modify, OpenApi, openapi::security::{Http, HttpAuthScheme, SecurityScheme}, }; -use utoipa_scalar::{Scalar, Servable}; use utoipa_swagger_ui::SwaggerUi; struct SecurityAddon; @@ -31,8 +30,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") @@ -55,9 +61,7 @@ fn build() -> utoipa::openapi::OpenApi { } pub fn serve(router: Router) -> Router { - tracing::info!("API docs at /docs (Swagger) and /scalar"); - let spec = build(); - router - .merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone())) - .merge(Scalar::with_url("/scalar", spec)) + tracing::info!("API docs at /docs (Swagger)"); + let spec = spec(); + router.merge(SwaggerUi::new("/docs").url("/openapi.json", spec)) } diff --git a/crates/presentation/src/openapi/movies.rs b/crates/adapters/http-axum/src/openapi/movies.rs similarity index 76% rename from crates/presentation/src/openapi/movies.rs rename to crates/adapters/http-axum/src/openapi/movies.rs index edcecb1c..b76529f1 100644 --- a/crates/presentation/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/presentation/src/openapi/search.rs b/crates/adapters/http-axum/src/openapi/search.rs similarity index 80% rename from crates/presentation/src/openapi/search.rs rename to crates/adapters/http-axum/src/openapi/search.rs index 8c9fa017..a3d6c794 100644 --- a/crates/presentation/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 new file mode 100644 index 00000000..22065985 --- /dev/null +++ b/crates/adapters/http-axum/src/openapi/social.rs @@ -0,0 +1,44 @@ +#[cfg(feature = "federation")] +use api_types::{ + ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse, + BlockedDomainResponse, FollowRelationResponse, FollowRequest, PendingCountResponse, + RemoteActorDto, +}; +#[cfg(feature = "federation")] +use utoipa::OpenApi; + +#[cfg(feature = "federation")] +#[derive(OpenApi)] +#[openapi( + paths( + 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, + RemoteActorDto, + FollowRequest, + ActorUrlRequest, + BlockedDomainResponse, + AddBlockedDomainRequest, + BlockedActorResponse, + FollowRelationResponse, + PendingCountResponse, + )) +)] +pub struct SocialDoc; diff --git a/crates/adapters/http-axum/src/openapi/users.rs b/crates/adapters/http-axum/src/openapi/users.rs new file mode 100644 index 00000000..7c297909 --- /dev/null +++ b/crates/adapters/http-axum/src/openapi/users.rs @@ -0,0 +1,32 @@ +use api_types::{ + DeleteAccountRequest, ProfileFieldDto, ProfileResponse, ProfileViewData, + UpdateProfileFieldsRequest, UserProfileBase, UserProfileResponse, UserStatsDto, UserSummaryDto, + UsersResponse, +}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + 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, + crate::api::users::delete_me, + crate::api::users::delete_user, + ), + components(schemas( + UsersResponse, + UserSummaryDto, + UserProfileBase, + UserProfileResponse, + ProfileViewData, + UserStatsDto, + ProfileResponse, + UpdateProfileFieldsRequest, + ProfileFieldDto, + DeleteAccountRequest, + )) +)] +pub struct UsersDoc; diff --git a/crates/presentation/src/openapi/watchlist.rs b/crates/adapters/http-axum/src/openapi/watchlist.rs similarity index 60% rename from crates/presentation/src/openapi/watchlist.rs rename to crates/adapters/http-axum/src/openapi/watchlist.rs index 5573c28a..b2f0f1ca 100644 --- a/crates/presentation/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/presentation/src/openapi/webhook.rs b/crates/adapters/http-axum/src/openapi/webhook.rs similarity index 57% rename from crates/presentation/src/openapi/webhook.rs rename to crates/adapters/http-axum/src/openapi/webhook.rs index c1d5f6a7..f48e72b0 100644 --- a/crates/presentation/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/presentation/src/openapi/wrapup.rs b/crates/adapters/http-axum/src/openapi/wrapup.rs similarity index 57% rename from crates/presentation/src/openapi/wrapup.rs rename to crates/adapters/http-axum/src/openapi/wrapup.rs index ba6e0093..f93a8ed9 100644 --- a/crates/presentation/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..57448687 --- /dev/null +++ b/crates/adapters/http-axum/src/router.rs @@ -0,0 +1,196 @@ +use std::num::NonZeroU32; + +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, + 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; + 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 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 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) +} + +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 = api_governor(per_minute(rate_limit)); + + let base = api::router().merge(federation::api_routes()); + + let webhook_cfg = api_governor(per_minute(rate_limit / 4)); + 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)) +} + +#[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/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/handlers/helpers.rs b/crates/adapters/http-axum/src/support.rs similarity index 66% rename from crates/presentation/src/handlers/helpers.rs rename to crates/adapters/http-axum/src/support.rs index 7a0daf06..1461ab17 100644 --- a/crates/presentation/src/handlers/helpers.rs +++ b/crates/adapters/http-axum/src/support.rs @@ -1,25 +1,5 @@ -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()), - } -} +use domain::models::import::DomainField; pub(crate) fn build_export_response( format_str: &str, @@ -149,42 +129,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/test_support.rs b/crates/adapters/http-axum/src/test_support.rs new file mode 100644 index 00000000..ff1300b8 --- /dev/null +++ b/crates/adapters/http-axum/src/test_support.rs @@ -0,0 +1,10 @@ +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/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 92% rename from crates/adapters/template-askama/templates/profile.html rename to crates/adapters/http-axum/templates/profile.html index a4111264..e48e25ef 100644 --- a/crates/adapters/template-askama/templates/profile.html +++ b/crates/adapters/http-axum/templates/profile.html @@ -97,6 +97,21 @@ {% endif %} + {% if ctx.is_admin && !is_own_profile && !is_service_account %} +
+

Admin

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

{{ err }}

+ {% endif %} +

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.

+
+ + + +
+
+ {% endif %} +
Recent Top Rated diff --git a/crates/adapters/template-askama/templates/profile_settings.html b/crates/adapters/http-axum/templates/profile_settings.html similarity index 73% rename from crates/adapters/template-askama/templates/profile_settings.html rename to crates/adapters/http-axum/templates/profile_settings.html index 86be4691..0dcdc9c7 100644 --- a/crates/adapters/template-askama/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

+

+ 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. +

+ + + + + +
{% endblock %} 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/presentation/src/tests/forms.rs b/crates/adapters/http-axum/tests/log_review_form_test.rs similarity index 96% rename from crates/presentation/src/tests/forms.rs rename to crates/adapters/http-axum/tests/log_review_form_test.rs index a7ef08a4..2e1c3da7 100644 --- a/crates/presentation/src/tests/forms.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/adapters/http-axum/tests/openapi_contract_test.rs b/crates/adapters/http-axum/tests/openapi_contract_test.rs new file mode 100644 index 00000000..67e6d6f5 --- /dev/null +++ b/crates/adapters/http-axum/tests/openapi_contract_test.rs @@ -0,0 +1,94 @@ +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 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) { + 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 JSON 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/adapters/image-converter/Cargo.toml b/crates/adapters/image-converter/Cargo.toml index 28a1a2dc..1d86b2e7 100644 --- a/crates/adapters/image-converter/Cargo.toml +++ b/crates/adapters/image-converter/Cargo.toml @@ -1,19 +1,20 @@ [package] name = "image-converter" version = "0.1.0" -edition = "2021" +edition.workspace = true [dependencies] +config = { workspace = true } domain = { workspace = true } async-trait = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } -tokio = { workspace = true } -image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } -ravif = { version = "0.11", default-features = false } -webp = "0.3" +tokio = { workspace = true, features = ["rt"] } +image = { workspace = true, features = ["jpeg", "png", "webp"] } +ravif = { workspace = true } +webp = { workspace = true } [dev-dependencies] object-storage = { workspace = true } -object_store = "0.11" -uuid = { workspace = true } +object_store = { workspace = true } +uuid = { workspace = true, features = ["v4"] } 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 04bf3c69..8241ff9a 100644 --- a/crates/adapters/image-converter/src/config.rs +++ b/crates/adapters/image-converter/src/config.rs @@ -18,34 +18,22 @@ 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(), - ) - } - - fn from_vars(enabled: Option<&str>, format: Option<&str>) -> anyhow::Result> { - if enabled != Some("true") { + pub fn from_config(config: &::config::ImageConversionConfig) -> anyhow::Result> { + 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 })) } } - -#[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..17d1c3ae 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(), @@ -41,6 +45,10 @@ impl EventHandler for ImageConversionHandler { return Ok(()); } + 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; @@ -99,7 +107,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/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 deleted file mode 100644 index 65941a71..00000000 --- a/crates/adapters/image-converter/src/tests/config.rs +++ /dev/null @@ -1,45 +0,0 @@ -use super::*; - -#[test] -fn disabled_by_default() { - assert!(ConversionConfig::from_vars(None, None).unwrap().is_none()); - assert!(ConversionConfig::from_vars(Some("false"), None) - .unwrap() - .is_none()); -} - -#[test] -fn enabled_avif() { - let cfg = ConversionConfig::from_vars(Some("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(); - assert_eq!(cfg.format, Format::Webp); -} - -#[test] -fn unknown_format_is_error() { - assert!(ConversionConfig::from_vars(Some("true"), Some("gif")).is_err()); -} - -#[test] -fn missing_format_when_enabled_is_error() { - assert!(ConversionConfig::from_vars(Some("true"), None).is_err()); -} - -#[test] -fn avif_extension() { - assert_eq!(Format::Avif.extension(), ".avif"); -} - -#[test] -fn webp_extension() { - assert_eq!(Format::Webp.extension(), ".webp"); -} 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/tests/conversion_config_test.rs b/crates/adapters/image-converter/tests/conversion_config_test.rs new file mode 100644 index 00000000..bf4022ad --- /dev/null +++ b/crates/adapters/image-converter/tests/conversion_config_test.rs @@ -0,0 +1,54 @@ +use image_converter::{ConversionConfig, Format}; + +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 is_disabled_by_default() { + assert!( + ConversionConfig::from_config(&config::ImageConversionConfig::default()) + .unwrap() + .is_none() + ); + assert!(convert(false, Some("webp")).unwrap().is_none()); +} + +#[test] +fn enabled_with_avif_format() { + let cfg = convert(true, Some("avif")).unwrap().unwrap(); + assert_eq!(cfg.format, Format::Avif); +} + +#[test] +fn enabled_with_webp_format() { + let cfg = convert(true, Some("webp")).unwrap().unwrap(); + assert_eq!(cfg.format, Format::Webp); +} + +#[test] +fn unknown_format_is_rejected() { + assert!(convert(true, Some("gif")).is_err()); +} + +#[test] +fn missing_format_when_enabled_is_rejected() { + assert!(convert(true, None).is_err()); +} + +#[test] +fn avif_extension_is_dot_avif() { + assert_eq!(Format::Avif.extension(), ".avif"); +} + +#[test] +fn webp_extension_is_dot_webp() { + assert_eq!(Format::Webp.extension(), ".webp"); +} diff --git a/crates/adapters/image-converter/src/tests/handler.rs b/crates/adapters/image-converter/tests/conversion_handler_test.rs similarity index 77% rename from crates/adapters/image-converter/src/tests/handler.rs rename to crates/adapters/image-converter/tests/conversion_handler_test.rs index 033a929b..bbd0cc12 100644 --- a/crates/adapters/image-converter/src/tests/handler.rs +++ b/crates/adapters/image-converter/tests/conversion_handler_test.rs @@ -1,7 +1,13 @@ -use super::*; +use std::sync::{Arc, Mutex}; + +use domain::{ + errors::DomainError, + events::DomainEvent, + ports::{EventHandler, ImageRefCommand, ObjectStorage}, +}; +use image_converter::{Format, ImageConversionHandler}; use object_storage::ObjectStorageAdapter; use object_store::memory::InMemory; -use std::sync::Mutex; struct MockImageRef { swaps: Mutex>, @@ -156,3 +162,32 @@ 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(); + 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/importer/Cargo.toml b/crates/adapters/importer/Cargo.toml index f4d0bc94..7caacc21 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"] @@ -10,4 +10,4 @@ xlsx = ["dep:calamine"] domain = { workspace = true } serde_json = { workspace = true } csv = { workspace = true } -calamine = { version = "0.35", optional = true } +calamine = { workspace = true, optional = true } 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/src/tests/mapper.rs b/crates/adapters/importer/tests/mapping_test.rs similarity index 93% rename from crates/adapters/importer/src/tests/mapper.rs rename to crates/adapters/importer/tests/mapping_test.rs index fec3bb94..c1482dea 100644 --- a/crates/adapters/importer/src/tests/mapper.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/src/parsers/tests.rs b/crates/adapters/importer/tests/parsing_test.rs similarity index 72% rename from crates/adapters/importer/src/parsers/tests.rs rename to crates/adapters/importer/tests/parsing_test.rs index 0d44c4b0..6d4b713b 100644 --- a/crates/adapters/importer/src/parsers/tests.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() { diff --git a/crates/adapters/jellyfin/src/lib.rs b/crates/adapters/jellyfin/src/lib.rs deleted file mode 100644 index 1ee790bf..00000000 --- a/crates/adapters/jellyfin/src/lib.rs +++ /dev/null @@ -1,132 +0,0 @@ -use domain::{errors::DomainError, models::ParsedPlaybackEvent, ports::MediaServerParser}; -use serde::Deserialize; - -pub struct JellyfinParser; - -impl MediaServerParser for JellyfinParser { - fn parse_playback_event( - &self, - body: &[u8], - ) -> Result, DomainError> { - let payload: JellyfinPayload = serde_json::from_slice(body) - .map_err(|e| DomainError::ValidationError(format!("invalid Jellyfin payload: {e}")))?; - - if payload.notification_type != "PlaybackStop" { - return Ok(None); - } - - let item_type = payload.item_type.as_deref().unwrap_or(""); - if item_type != "Movie" { - return Ok(None); - } - - if !payload.played_to_completion.unwrap_or(false) { - return Ok(None); - } - - let title = match payload.name { - Some(t) if !t.is_empty() => t, - _ => return Ok(None), - }; - - let tmdb_id = payload.provider_tmdb.map(|id| format!("tmdb:{id}")); - let imdb_id = payload.provider_imdb; - - Ok(Some(ParsedPlaybackEvent { - title, - year: payload.year, - tmdb_id, - imdb_id, - })) - } -} - -#[derive(Deserialize)] -struct JellyfinPayload { - #[serde(rename = "NotificationType")] - notification_type: String, - #[serde(rename = "ItemType")] - item_type: Option, - #[serde(rename = "Name")] - name: Option, - #[serde(rename = "Year")] - year: Option, - #[serde(rename = "PlayedToCompletion")] - played_to_completion: Option, - #[serde(rename = "Provider_tmdb")] - provider_tmdb: Option, - #[serde(rename = "Provider_imdb")] - provider_imdb: Option, -} - -#[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()); - } -} diff --git a/crates/adapters/jellyfin/Cargo.toml b/crates/adapters/media-server/Cargo.toml similarity index 52% rename from crates/adapters/jellyfin/Cargo.toml rename to crates/adapters/media-server/Cargo.toml index 78368268..bca357a9 100644 --- a/crates/adapters/jellyfin/Cargo.toml +++ b/crates/adapters/media-server/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "jellyfin" +name = "media-server" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] domain = { workspace = true } -serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/adapters/media-server/src/jellyfin.rs b/crates/adapters/media-server/src/jellyfin.rs new file mode 100644 index 00000000..a139ae4a --- /dev/null +++ b/crates/adapters/media-server/src/jellyfin.rs @@ -0,0 +1,60 @@ +use domain::{errors::DomainError, models::ParsedPlaybackEvent, ports::MediaServerParser}; +use serde::Deserialize; + +pub struct JellyfinParser; + +impl MediaServerParser for JellyfinParser { + fn parse_playback_event( + &self, + body: &[u8], + ) -> Result, DomainError> { + let payload: JellyfinPayload = serde_json::from_slice(body) + .map_err(|e| DomainError::ValidationError(format!("invalid Jellyfin payload: {e}")))?; + + if payload.notification_type != "PlaybackStop" { + return Ok(None); + } + + let item_type = payload.item_type.as_deref().unwrap_or(""); + if item_type != "Movie" { + return Ok(None); + } + + if !payload.played_to_completion.unwrap_or(false) { + return Ok(None); + } + + let title = match payload.name { + Some(t) if !t.is_empty() => t, + _ => return Ok(None), + }; + + let tmdb_id = payload.provider_tmdb.map(|id| format!("tmdb:{id}")); + let imdb_id = payload.provider_imdb; + + Ok(Some(ParsedPlaybackEvent { + title, + year: payload.year, + tmdb_id, + imdb_id, + })) + } +} + +#[derive(Deserialize)] +struct JellyfinPayload { + #[serde(rename = "NotificationType")] + notification_type: String, + #[serde(rename = "ItemType")] + item_type: Option, + #[serde(rename = "Name")] + name: Option, + #[serde(rename = "Year")] + year: Option, + #[serde(rename = "PlayedToCompletion")] + played_to_completion: Option, + #[serde(rename = "Provider_tmdb")] + provider_tmdb: Option, + #[serde(rename = "Provider_imdb")] + provider_imdb: Option, +} 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/media-server/src/plex.rs b/crates/adapters/media-server/src/plex.rs new file mode 100644 index 00000000..4b59b76b --- /dev/null +++ b/crates/adapters/media-server/src/plex.rs @@ -0,0 +1,70 @@ +use domain::{errors::DomainError, models::ParsedPlaybackEvent, ports::MediaServerParser}; +use serde::Deserialize; + +pub struct PlexParser; + +impl MediaServerParser for PlexParser { + fn parse_playback_event( + &self, + body: &[u8], + ) -> Result, DomainError> { + let payload: PlexPayload = serde_json::from_slice(body) + .map_err(|e| DomainError::ValidationError(format!("invalid Plex payload: {e}")))?; + + if payload.event != "media.scrobble" { + return Ok(None); + } + + let metadata = match payload.metadata { + Some(m) => m, + None => return Ok(None), + }; + + if metadata.media_type != "movie" { + return Ok(None); + } + + if metadata.title.is_empty() { + return Ok(None); + } + + let mut tmdb_id = None; + let mut imdb_id = None; + for guid in &metadata.guids { + if let Some(id) = guid.id.strip_prefix("tmdb://") { + tmdb_id = Some(format!("tmdb:{id}")); + } else if let Some(id) = guid.id.strip_prefix("imdb://") { + imdb_id = Some(id.to_string()); + } + } + + Ok(Some(ParsedPlaybackEvent { + title: metadata.title, + year: metadata.year.map(|y| y as u16), + tmdb_id, + imdb_id, + })) + } +} + +#[derive(Deserialize)] +struct PlexPayload { + event: String, + #[serde(rename = "Metadata")] + metadata: Option, +} + +#[derive(Deserialize)] +struct PlexMetadata { + #[serde(rename = "type")] + media_type: String, + title: String, + year: Option, + #[serde(rename = "Guid", default)] + guids: Vec, +} + +#[derive(Deserialize)] +struct PlexGuid { + id: String, +} diff --git a/crates/adapters/media-server/tests/jellyfin_parser_test.rs b/crates/adapters/media-server/tests/jellyfin_parser_test.rs new file mode 100644 index 00000000..b02c1213 --- /dev/null +++ b/crates/adapters/media-server/tests/jellyfin_parser_test.rs @@ -0,0 +1,69 @@ +use domain::ports::MediaServerParser; +use media_server::JellyfinParser; + +#[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/media-server/tests/plex_parser_test.rs b/crates/adapters/media-server/tests/plex_parser_test.rs new file mode 100644 index 00000000..ae90056f --- /dev/null +++ b/crates/adapters/media-server/tests/plex_parser_test.rs @@ -0,0 +1,96 @@ +use domain::ports::MediaServerParser; +use media_server::PlexParser; + +#[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/metadata/Cargo.toml b/crates/adapters/metadata/Cargo.toml index 3ae18512..e4db888f 100644 --- a/crates/adapters/metadata/Cargo.toml +++ b/crates/adapters/metadata/Cargo.toml @@ -1,11 +1,13 @@ [package] name = "metadata" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] +config = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } reqwest = { workspace = true } -serde = { workspace = true } +rustls = { workspace = true, features = ["ring", "std"] } +serde = { workspace = true, features = ["derive"] } domain = { 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/metadata/src/omdb.rs b/crates/adapters/metadata/src/omdb.rs index b5c4f7a8..39c64767 100644 --- a/crates/adapters/metadata/src/omdb.rs +++ b/crates/adapters/metadata/src/omdb.rs @@ -16,6 +16,7 @@ pub(crate) struct OmdbProvider { impl OmdbProvider { pub(crate) fn new(api_key: String) -> Self { + let _ = rustls::crypto::ring::default_provider().install_default(); Self { client: reqwest::Client::new(), api_key, diff --git a/crates/adapters/metadata/src/tmdb.rs b/crates/adapters/metadata/src/tmdb.rs index ec5e776a..855d5af4 100644 --- a/crates/adapters/metadata/src/tmdb.rs +++ b/crates/adapters/metadata/src/tmdb.rs @@ -15,6 +15,7 @@ pub(crate) struct TmdbProvider { impl TmdbProvider { pub(crate) fn new(api_key: String) -> Self { + let _ = rustls::crypto::ring::default_provider().install_default(); Self { client: reqwest::Client::new(), api_key, diff --git a/crates/adapters/metadata/tests/client_tls_omdb.rs b/crates/adapters/metadata/tests/client_tls_omdb.rs new file mode 100644 index 00000000..d2a9ec54 --- /dev/null +++ b/crates/adapters/metadata/tests/client_tls_omdb.rs @@ -0,0 +1,4 @@ +#[test] +fn omdb_client_builds_without_bootstrap() { + let _ = metadata::MetadataClientImpl::new_omdb("key".into()); +} diff --git a/crates/adapters/metadata/tests/client_tls_tmdb.rs b/crates/adapters/metadata/tests/client_tls_tmdb.rs new file mode 100644 index 00000000..f05c8190 --- /dev/null +++ b/crates/adapters/metadata/tests/client_tls_tmdb.rs @@ -0,0 +1,4 @@ +#[test] +fn tmdb_client_builds_without_bootstrap() { + let _ = metadata::MetadataClientImpl::new_tmdb("key".into()); +} diff --git a/crates/adapters/nats/Cargo.toml b/crates/adapters/nats/Cargo.toml index 0e5fd467..9bc4fdc1 100644 --- a/crates/adapters/nats/Cargo.toml +++ b/crates/adapters/nats/Cargo.toml @@ -1,10 +1,11 @@ [package] name = "nats" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] -async-nats = "0.48.0" +config = { workspace = true } +async-nats = { workspace = true } domain = { workspace = true } event-payload = { workspace = true } @@ -12,7 +13,7 @@ async-trait = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } serde_json = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -tokio = { workspace = true } +uuid = { workspace = true, features = ["serde"] } +chrono = { workspace = true, features = ["serde"] } +tokio = { workspace = true, features = ["rt", "sync"] } futures = { workspace = true } diff --git a/crates/adapters/nats/src/config.rs b/crates/adapters/nats/src/config.rs index 6395af52..e63a7023 100644 --- a/crates/adapters/nats/src/config.rs +++ b/crates/adapters/nats/src/config.rs @@ -11,48 +11,32 @@ pub struct NatsConfig { pub subject_prefix: String, pub stream_name: String, pub consumer_name: String, + pub max_deliver: i64, } impl NatsConfig { - pub fn from_env() -> 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(), - ) - } - - pub(crate) fn from_vars( - url: Option<&str>, - mode: Option<&str>, - subject_prefix: Option<&str>, - stream_name: Option<&str>, - consumer_name: Option<&str>, + pub fn from_config( + config: &::config::NatsConfig, + events: &::config::EventsConfig, ) -> anyhow::Result { - let url = url.ok_or_else(|| anyhow::anyhow!("NATS_URL is not set"))?; + let url = config + .url + .clone() + .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(), + max_deliver: i64::from(events.queue_max_attempts), }) } } - -#[cfg(test)] -#[path = "tests/config.rs"] -mod tests; diff --git a/crates/adapters/nats/src/consumer.rs b/crates/adapters/nats/src/consumer.rs index 278fc83e..5259f2a1 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, retry_backoff}, ports::EventConsumer, }; use futures::{ @@ -15,12 +15,26 @@ 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 ───────────────────────────────────────────────────── struct NatsJetStreamAckHandle { message: async_nats::jetstream::Message, + event_id: Option, + max_deliver: i64, +} + +struct NakPlan { + delay: std::time::Duration, + 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 +52,36 @@ 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())) + } + + 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())) } @@ -62,25 +99,47 @@ impl AckHandle for NoopAck { async fn nack(&self) -> Result<(), DomainError> { Ok(()) } + async fn defer(&self, _: std::time::Duration) -> Result<(), DomainError> { + Ok(()) + } } // ── 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, + 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, + }), )) } 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 ────────────────────────────────── @@ -94,6 +153,31 @@ fn consume_from_rx(rx: EnvelopeRx) -> BoxStream<'static, Result Option { + (existing.duplicate_window != DEDUP_WINDOW).then(|| StreamConfig { + duplicate_window: DEDUP_WINDOW, + ..existing.clone() + }) +} + +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(jetstream::consumer::Config { + ack_wait: ACK_WAIT, + max_deliver: desired_max_deliver, + ..existing.clone() + }) +} + // ── JetStream consumer ──────────────────────────────────────────────────────── pub struct NatsJetStreamConsumer { @@ -109,9 +193,25 @@ impl NatsJetStreamConsumer { name: cfg.stream_name.clone(), subjects: vec![consumer_subject_filter(&cfg.subject_prefix)], max_messages: 100_000, + duplicate_window: DEDUP_WINDOW, ..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 + ); + 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); let consumer = stream @@ -120,14 +220,34 @@ 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 + ); + 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); + let max_deliver = cfg.max_deliver; tokio::spawn(async move { loop { let mut messages = match consumer.messages().await { @@ -144,7 +264,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"); @@ -211,3 +331,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..29ba505e 100644 --- a/crates/adapters/nats/src/lib.rs +++ b/crates/adapters/nats/src/lib.rs @@ -1,52 +1,38 @@ 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( +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)), + ) + } + 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 2ca19ecc..20248847 100644 --- a/crates/adapters/nats/src/payload.rs +++ b/crates/adapters/nats/src/payload.rs @@ -1 +1 @@ -pub use event_payload::EventPayload as NatsEventPayload; +pub use event_payload::EventMessage as NatsEventMessage; 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..5cf26c18 --- /dev/null +++ b/crates/adapters/nats/src/relay.rs @@ -0,0 +1,98 @@ +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}; + +pub(crate) struct RelayMessage { + pub subject: String, + pub payload: Vec, + 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), +} + +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/config.rs b/crates/adapters/nats/src/tests/config.rs deleted file mode 100644 index af9ed729..00000000 --- a/crates/adapters/nats/src/tests/config.rs +++ /dev/null @@ -1,30 +0,0 @@ -use super::*; - -#[test] -fn errors_without_nats_url() { - assert!(NatsConfig::from_vars(None, None, None, None, None).is_err()); -} - -#[test] -fn defaults_with_only_url() { - let cfg = NatsConfig::from_vars(Some("nats://localhost:4222"), None, None, None, None).unwrap(); - assert_eq!(cfg.url, "nats://localhost:4222"); - assert_eq!(cfg.mode, NatsMode::JetStream); - assert_eq!(cfg.subject_prefix, "movies-diary.events"); - assert_eq!(cfg.stream_name, "MOVIES_DIARY_EVENTS"); - assert_eq!(cfg.consumer_name, "worker"); -} - -#[test] -fn core_mode_parsed() { - let cfg = - NatsConfig::from_vars(Some("nats://test:4222"), Some("core"), None, None, None).unwrap(); - 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() - ); -} diff --git a/crates/adapters/nats/src/tests/consumer.rs b/crates/adapters/nats/src/tests/consumer.rs new file mode 100644 index 00000000..098e3b1c --- /dev/null +++ b/crates/adapters/nats/src/tests/consumer.rs @@ -0,0 +1,108 @@ +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()); +} + +#[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, + 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 + }; + 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/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/adapters/nats/tests/nats_config_test.rs b/crates/adapters/nats/tests/nats_config_test.rs new file mode 100644 index 00000000..54c6a967 --- /dev/null +++ b/crates/adapters/nats/tests/nats_config_test.rs @@ -0,0 +1,70 @@ +use nats::{NatsConfig, NatsMode}; + +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_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() + }, + &config::EventsConfig::default(), + ) + .unwrap(); + assert_eq!(cfg.url, "nats://localhost:4222"); + assert_eq!(cfg.mode, NatsMode::JetStream); + assert_eq!(cfg.subject_prefix, "movies-diary.events"); + assert_eq!(cfg.stream_name, "MOVIES_DIARY_EVENTS"); + assert_eq!(cfg.consumer_name, "worker"); +} + +#[test] +fn core_mode_parsed() { + 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"), + &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/object-storage/Cargo.toml b/crates/adapters/object-storage/Cargo.toml index fa791461..e551442f 100644 --- a/crates/adapters/object-storage/Cargo.toml +++ b/crates/adapters/object-storage/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "object-storage" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] +config = { workspace = true } domain = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } @@ -11,8 +12,7 @@ tracing = { workspace = true } bytes = { workspace = true } futures = { workspace = true } object_store = { workspace = true } -infer = "0.19.0" [dev-dependencies] -tokio = { workspace = true } +tokio = { workspace = true, features = ["rt", "macros"] } uuid = { workspace = true } diff --git a/crates/adapters/object-storage/src/config.rs b/crates/adapters/object-storage/src/config.rs index 73dc5467..053d57a2 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") } @@ -58,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 c183ba10..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 { @@ -112,6 +120,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, @@ -127,12 +139,34 @@ 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)?, ))) } #[cfg(test)] -#[path = "tests/lib.rs"] -mod tests; +mod tests { + use super::*; + use domain::value_objects::{MovieId, PosterPath}; + use object_store::memory::InMemory; + + #[tokio::test] + async fn image_cleanup_is_idempotent_when_run_twice() { + let storage = Arc::new(ObjectStorageAdapter::new(Arc::new(InMemory::new()))); + storage.store("posters/m1.webp", b"img").await.unwrap(); + let handler = ImageCleanupHandler::new(storage.clone()); + let event = DomainEvent::MovieDeleted { + movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()), + poster_path: Some(PosterPath::new("posters/m1.webp".into()).unwrap()), + }; + + handler.handle(&event).await.unwrap(); + handler.handle(&event).await.unwrap(); + + assert!(matches!( + storage.get("posters/m1.webp").await, + Err(DomainError::NotFound(_)) + )); + } +} diff --git a/crates/adapters/object-storage/src/tests/config.rs b/crates/adapters/object-storage/src/tests/config.rs deleted file mode 100644 index f0ee8cc1..00000000 --- a/crates/adapters/object-storage/src/tests/config.rs +++ /dev/null @@ -1,17 +0,0 @@ -use super::*; - -#[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()); - assert!(result.is_ok(), "expected Ok, got: {:?}", result.err()); - assert!(dir.exists(), "directory should have been created"); -} - -#[test] -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()); - assert!(result.is_ok()); -} diff --git a/crates/adapters/object-storage/src/tests/lib.rs b/crates/adapters/object-storage/tests/object_storage_adapter_test.rs similarity index 91% rename from crates/adapters/object-storage/src/tests/lib.rs rename to crates/adapters/object-storage/tests/object_storage_adapter_test.rs index 6c34fe11..44e79273 100644 --- a/crates/adapters/object-storage/src/tests/lib.rs +++ b/crates/adapters/object-storage/tests/object_storage_adapter_test.rs @@ -1,4 +1,10 @@ -use super::*; +use std::sync::Arc; + +use domain::{ + errors::DomainError, + ports::{EventHandler, ObjectStorage}, +}; +use object_storage::{ImageCleanupHandler, ObjectStorageAdapter}; use object_store::memory::InMemory; fn adapter() -> ObjectStorageAdapter { diff --git a/crates/adapters/object-storage/tests/storage_config_test.rs b/crates/adapters/object-storage/tests/storage_config_test.rs new file mode 100644 index 00000000..34450003 --- /dev/null +++ b/crates/adapters/object-storage/tests/storage_config_test.rs @@ -0,0 +1,44 @@ +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 = StorageConfig::from_config(&local_section(&dir)); + assert!(result.is_ok(), "expected Ok, got: {:?}", result.err()); + assert!(dir.exists(), "directory should have been created"); +} + +#[test] +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 = StorageConfig::from_config(&local_section(&dir)); + 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/plex/Cargo.toml b/crates/adapters/plex/Cargo.toml deleted file mode 100644 index 1dbb842b..00000000 --- a/crates/adapters/plex/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "plex" -version = "0.1.0" -edition = "2024" - -[dependencies] -domain = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } diff --git a/crates/adapters/plex/src/lib.rs b/crates/adapters/plex/src/lib.rs deleted file mode 100644 index 685b4108..00000000 --- a/crates/adapters/plex/src/lib.rs +++ /dev/null @@ -1,169 +0,0 @@ -use domain::{errors::DomainError, models::ParsedPlaybackEvent, ports::MediaServerParser}; -use serde::Deserialize; - -pub struct PlexParser; - -impl MediaServerParser for PlexParser { - fn parse_playback_event( - &self, - body: &[u8], - ) -> Result, DomainError> { - let payload: PlexPayload = serde_json::from_slice(body) - .map_err(|e| DomainError::ValidationError(format!("invalid Plex payload: {e}")))?; - - if payload.event != "media.scrobble" { - return Ok(None); - } - - let metadata = match payload.metadata { - Some(m) => m, - None => return Ok(None), - }; - - if metadata.media_type != "movie" { - return Ok(None); - } - - if metadata.title.is_empty() { - return Ok(None); - } - - let mut tmdb_id = None; - let mut imdb_id = None; - for guid in &metadata.guids { - if let Some(id) = guid.id.strip_prefix("tmdb://") { - tmdb_id = Some(format!("tmdb:{id}")); - } else if let Some(id) = guid.id.strip_prefix("imdb://") { - imdb_id = Some(id.to_string()); - } - } - - Ok(Some(ParsedPlaybackEvent { - title: metadata.title, - year: metadata.year.map(|y| y as u16), - tmdb_id, - imdb_id, - })) - } -} - -#[derive(Deserialize)] -struct PlexPayload { - event: String, - #[serde(rename = "Metadata")] - metadata: Option, -} - -#[derive(Deserialize)] -struct PlexMetadata { - #[serde(rename = "type")] - media_type: String, - title: String, - year: Option, - #[serde(rename = "Guid", default)] - guids: Vec, -} - -#[derive(Deserialize)] -struct PlexGuid { - id: String, -} - -#[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()); - } -} diff --git a/crates/adapters/poster-fetcher/Cargo.toml b/crates/adapters/poster-fetcher/Cargo.toml index 4d8ab67b..f72c22ee 100644 --- a/crates/adapters/poster-fetcher/Cargo.toml +++ b/crates/adapters/poster-fetcher/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "poster-fetcher" version = "0.1.0" -edition = "2021" +edition.workspace = true [dependencies] +config = { workspace = true } domain = { workspace = true } async-trait = { workspace = true } reqwest = { workspace = true } +rustls = { workspace = true, features = ["ring", "std"] } anyhow = { 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..ff8f0d30 100644 --- a/crates/adapters/poster-fetcher/src/lib.rs +++ b/crates/adapters/poster-fetcher/src/lib.rs @@ -16,6 +16,7 @@ pub struct ReqwestPosterFetcher { impl ReqwestPosterFetcher { pub fn new(config: PosterFetcherConfig) -> anyhow::Result { + let _ = rustls::crypto::ring::default_provider().install_default(); let client = reqwest::Client::builder() .timeout(Duration::from_secs(config.timeout_seconds)) .build()?; @@ -59,14 +60,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/poster-fetcher/tests/client_tls.rs b/crates/adapters/poster-fetcher/tests/client_tls.rs new file mode 100644 index 00000000..4db6a8b0 --- /dev/null +++ b/crates/adapters/poster-fetcher/tests/client_tls.rs @@ -0,0 +1,6 @@ +use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher}; + +#[test] +fn poster_fetcher_builds_without_bootstrap() { + ReqwestPosterFetcher::new(PosterFetcherConfig { timeout_seconds: 5 }).expect("client builds"); +} diff --git a/crates/adapters/poster-sync/Cargo.toml b/crates/adapters/poster-sync/Cargo.toml deleted file mode 100644 index 4ad9be58..00000000 --- a/crates/adapters/poster-sync/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "poster-sync" -version = "0.1.0" -edition = "2024" - -[dependencies] -domain = { workspace = true } -async-trait = { workspace = true } -tracing = { workspace = true } -tokio = { workspace = true } diff --git a/crates/adapters/postgres-event-queue/Cargo.toml b/crates/adapters/postgres-event-queue/Cargo.toml deleted file mode 100644 index e9cd111d..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 = "2024" - -[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-federation/Cargo.toml b/crates/adapters/postgres-federation/Cargo.toml deleted file mode 100644 index d130e25a..00000000 --- a/crates/adapters/postgres-federation/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "postgres-federation" -version = "0.1.0" -edition = "2024" - -[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 d90e9bf8..00000000 --- a/crates/adapters/postgres-social/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "postgres-social" -version = "0.1.0" -edition = "2024" - -[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 38402ba4..00000000 --- a/crates/adapters/postgres/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "postgres" -version = "0.1.0" -edition = "2024" - -[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/adapters/rss-adapter/Cargo.toml b/crates/adapters/rss-adapter/Cargo.toml new file mode 100644 index 00000000..0aa00aac --- /dev/null +++ b/crates/adapters/rss-adapter/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "rss-adapter" +version = "0.1.0" +edition.workspace = true + +[dependencies] +rss = { workspace = true, features = ["atom"] } +domain = { workspace = true } + +[dev-dependencies] +domain = { workspace = true, features = ["test-helpers"] } +chrono = { workspace = true } diff --git a/crates/adapters/rss/src/lib.rs b/crates/adapters/rss-adapter/src/lib.rs similarity index 93% rename from crates/adapters/rss/src/lib.rs rename to crates/adapters/rss-adapter/src/lib.rs index d11d5ff8..ab94efaf 100644 --- a/crates/adapters/rss/src/lib.rs +++ b/crates/adapters/rss-adapter/src/lib.rs @@ -1,7 +1,8 @@ +use domain::errors::DomainError; use domain::models::DiaryEntry; use domain::ports::{FeedChannel, RssFeedRenderer}; -use rss_feed::extension::atom::{self, AtomExtensionBuilder, Link}; -use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder}; +use rss::extension::atom::{self, AtomExtensionBuilder, Link}; +use rss::{ChannelBuilder, GuidBuilder, ItemBuilder}; pub struct RssAdapter { base_url: String, @@ -24,7 +25,7 @@ impl RssFeedRenderer for RssAdapter { &self, entries: &[DiaryEntry], channel: &FeedChannel<'_>, - ) -> Result { + ) -> Result { let items = entries .iter() .map(|e| { @@ -87,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-adapter/tests/rss_adapter_test.rs similarity index 74% rename from crates/adapters/rss/src/tests/lib.rs rename to crates/adapters/rss-adapter/tests/rss_adapter_test.rs index 0fe98bf4..00ac7dc2 100644 --- a/crates/adapters/rss/src/tests/lib.rs +++ b/crates/adapters/rss-adapter/tests/rss_adapter_test.rs @@ -1,6 +1,9 @@ -use super::*; -use domain::models::{Movie, Review}; -use domain::value_objects::{Comment, MovieTitle, Rating, ReleaseYear, UserId}; +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_adapter::RssAdapter; fn channel<'a>(title: &'a str) -> FeedChannel<'a> { FeedChannel { @@ -11,23 +14,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) } @@ -60,7 +52,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/rss/Cargo.toml b/crates/adapters/rss/Cargo.toml deleted file mode 100644 index 2de1ac45..00000000 --- a/crates/adapters/rss/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "rss" -version = "0.1.0" -edition = "2024" - -[dependencies] -rss-feed = { package = "rss", version = "2", features = ["atom"] } -domain = { workspace = true } - -[dev-dependencies] -chrono = { 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 30604ac8..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 = "2024" - -[dependencies] -sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "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/lib.rs b/crates/adapters/sqlite-event-queue/src/lib.rs deleted file mode 100644 index 5cb3cfd7..00000000 --- a/crates/adapters/sqlite-event-queue/src/lib.rs +++ /dev/null @@ -1,252 +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::SqlitePool; -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 SqliteEventQueue { - pool: SqlitePool, - config: Arc, -} - -impl SqliteEventQueue { - pub async fn create(pool: SqlitePool, config: DbEventQueueConfig) -> anyhow::Result { - migrations::run(&pool).await?; - Ok(Self { - pool, - config: Arc::new(config), - }) - } - - pub async fn create_publisher(pool: SqlitePool) -> anyhow::Result> { - let q = Self::create(pool, DbEventQueueConfig::from_env()).await?; - Ok(Arc::new(q)) - } - - pub async fn create_channel( - pool: SqlitePool, - ) -> 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 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) - .await - .map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?; - - Ok(()) - } -} - -impl EventConsumer for SqliteEventQueue { - 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!("sqlite 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!("sqlite event queue consumer closed"); - return; - } - } - // no sleep — re-poll immediately when batch was non-empty - } - } - } - }); - - 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: &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 ?", - ) - .bind(config.batch_size) - .fetch_all(&mut *tx) - .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}")))?; - - Ok(rows) -} - -fn decode_row( - pool: &SqlitePool, - 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: SqlitePool, - 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 = ?") - .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 = ?, 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 = ?" - ); - sqlx::query(&sql) - .bind(new_attempts) - .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/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-event-queue/src/payload.rs b/crates/adapters/sqlite-event-queue/src/payload.rs deleted file mode 100644 index 6df07ad8..00000000 --- a/crates/adapters/sqlite-event-queue/src/payload.rs +++ /dev/null @@ -1 +0,0 @@ -pub use event_payload::EventPayload as DbEventPayload; diff --git a/crates/adapters/sqlite-federation/Cargo.toml b/crates/adapters/sqlite-federation/Cargo.toml index df18d5e8..a7f770d9 100644 --- a/crates/adapters/sqlite-federation/Cargo.toml +++ b/crates/adapters/sqlite-federation/Cargo.toml @@ -1,22 +1,22 @@ [package] name = "sqlite-federation" version = "0.1.0" -edition = "2024" +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 } +k-ap = { workspace = true } +sqlite = { workspace = true } domain = { workspace = true } anyhow = { workspace = true } serde_json = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } +uuid = { workspace = true, features = ["v4", "serde"] } +chrono = { workspace = true, features = ["clock", "serde"] } tracing = { workspace = true } async-trait = { workspace = true } [dev-dependencies] -tokio = { workspace = true } +tokio = { workspace = true, features = ["rt", "macros"] } +domain = { workspace = true, features = ["test-helpers"] } uuid = { 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..e3d13bed 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 _, @@ -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/src/review.rs b/crates/adapters/sqlite-federation/src/review.rs index ebbfd300..c40b9cd0 100644 --- a/crates/adapters/sqlite-federation/src/review.rs +++ b/crates/adapters/sqlite-federation/src/review.rs @@ -1,22 +1,23 @@ -use activitypub::{RemoteReviewRepository, RemoteReviewUpdate}; +use activitypub::{NewRemoteReview, RemoteReviewRepository, RemoteReviewUpdate}; use anyhow::{Result, anyhow}; use async_trait::async_trait; -use domain::models::{Review, ReviewSource}; +use domain::models::ReviewSource; use super::SqliteFederationRepository; -use adapter_common::datetime_to_str; +use sqlite::persistence::{datetime_to_str, enqueue_events}; #[async_trait] impl RemoteReviewRepository for SqliteFederationRepository { - 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<()> { + 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 => { @@ -24,6 +25,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 +38,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 +61,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 new file mode 100644 index 00000000..e44fcd1d --- /dev/null +++ b/crates/adapters/sqlite-federation/tests/federation.rs @@ -0,0 +1,8 @@ +#[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; +#[path = "federation/remote_review_test.rs"] +mod remote_review_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 { 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..6dffaba5 --- /dev/null +++ b/crates/adapters/sqlite-federation/tests/federation/remote_review_test.rs @@ -0,0 +1,113 @@ +use activitypub::{NewRemoteReview, 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(NewRemoteReview { + review, + ap_id: AP_ID, + movie_title: "The Matrix", + release_year: 1999, + external_metadata_id: Some("tmdb:603"), + poster_url: 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-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 d853a233..00000000 --- a/crates/adapters/sqlite-social/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "sqlite-social" -version = "0.1.0" -edition = "2024" - -[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 74079173..281968a2 100644 --- a/crates/adapters/sqlite/Cargo.toml +++ b/crates/adapters/sqlite/Cargo.toml @@ -1,27 +1,25 @@ [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", -] } +config = { workspace = true } +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 } -uuid = { workspace = true } -chrono = { workspace = true } +uuid = { workspace = true, features = ["v4", "serde"] } +chrono = { workspace = true, features = ["clock", "serde"] } tracing = { workspace = true } async-trait = { workspace = true } -tokio = { workspace = true } +tokio = { workspace = true, features = ["rt", "sync", "time", "macros", "rt-multi-thread"] } futures = { workspace = true } -bytes = { workspace = true } async-stream = { workspace = true } + +[dev-dependencies] +domain = { workspace = true, features = ["test-helpers"] } +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/migrations/0101_outbox_identity.sql b/crates/adapters/sqlite/migrations/0101_outbox_identity.sql new file mode 100644 index 00000000..a7aa1f41 --- /dev/null +++ b/crates/adapters/sqlite/migrations/0101_outbox_identity.sql @@ -0,0 +1,29 @@ +-- 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. +ALTER TABLE event_queue ADD COLUMN claimed_at 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, + 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/migrations/0102_account_deletion.sql b/crates/adapters/sqlite/migrations/0102_account_deletion.sql new file mode 100644 index 00000000..edc2116b --- /dev/null +++ b/crates/adapters/sqlite/migrations/0102_account_deletion.sql @@ -0,0 +1,9 @@ +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, + retired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); diff --git a/crates/adapters/sqlite/src/event_queue/mod.rs b/crates/adapters/sqlite/src/event_queue/mod.rs new file mode 100644 index 00000000..2a1cb46a --- /dev/null +++ b/crates/adapters/sqlite/src/event_queue/mod.rs @@ -0,0 +1,296 @@ +mod payload; + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + events::{AckHandle, DomainEvent, EventEnvelope, EventId}, + ports::{EventConsumer, EventPublisher}, +}; +use futures::stream::{self, BoxStream}; +use sqlx::SqlitePool; +use tokio::sync::{Mutex, mpsc}; + +use crate::shared::enqueue_events; +pub(crate) use payload::DbEventPayload; + +pub(crate) const CLAIM_LEASE_SECONDS: i64 = 600; + +pub struct DbEventQueueConfig { + pub poll_interval_ms: u64, + pub batch_size: i64, + pub max_attempts: i32, +} + +impl DbEventQueueConfig { + pub fn from_config(config: &config::EventsConfig) -> Self { + Self { + poll_interval_ms: config.queue_poll_interval_ms, + batch_size: config.queue_batch_size, + max_attempts: config.queue_max_attempts, + } + } +} + +#[derive(Clone)] +pub struct SqliteEventQueue { + pool: SqlitePool, + config: Arc, +} + +impl SqliteEventQueue { + 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)); + 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_config(config)); + Ok((Arc::new(q.clone()), Arc::new(q))) + } +} + +#[async_trait] +impl EventPublisher for SqliteEventQueue { + async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { + let mut tx = self + .pool + .begin() + .await + .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(()) + } +} + +impl EventConsumer for SqliteEventQueue { + 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!("sqlite 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 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; + } + } + // no sleep — re-poll immediately when batch was non-empty + } + } + } + }); + + 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, + event_id: Option, + attempts: i32, + next_attempt_at: String, + claimed_at: Option, +} + +async fn claim_batch( + pool: &SqlitePool, + config: &DbEventQueueConfig, +) -> Result, DomainError> { + 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')) + 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, claimed_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}")))?; + + rows.sort_by(|a, b| (&a.next_attempt_at, a.id).cmp(&(&b.next_attempt_at, b.id))); + Ok(rows) +} + +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, + 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)?; + 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(), + row_id: row.id, + attempts: row.attempts, + max_attempts, + claimed_at: row.claimed_at, + }), + )) +} + +struct DbAckHandle { + pool: SqlitePool, + row_id: i64, + attempts: i32, + max_attempts: i32, + claimed_at: Option, +} + +impl DbAckHandle { + 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> { + 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 { + tracing::error!( + row_id = self.row_id, + attempts = new_attempts, + "event exhausted max attempts, dead-lettering" + ); + 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 + } + } + + 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 = ? AND status = 'processing' AND claimed_at IS ?" + ); + self.settle("defer", sqlx::query(&sql)).await + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/adapters/postgres-event-queue/src/payload.rs b/crates/adapters/sqlite/src/event_queue/payload.rs similarity index 100% rename from crates/adapters/postgres-event-queue/src/payload.rs rename to crates/adapters/sqlite/src/event_queue/payload.rs 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..24a6c731 --- /dev/null +++ b/crates/adapters/sqlite/src/event_queue/tests.rs @@ -0,0 +1,377 @@ +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 { + 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(busy_secs)); + 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_with_timeout(&path, 30).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() { + let batch = claim_batch(&claimer, &cfg) + .await + .expect("claim must not fail while another connection writes"); + if batch.is_empty() { + tokio::task::yield_now().await; + } + claimed += batch.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"); +} + +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()); + } + + #[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 { + 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")); + } +} + +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}; + + 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); + 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)); + } +} diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 05a59f60..b33e09ee 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -1,45 +1,28 @@ use sqlx::SqlitePool; -mod diary; -mod goals; -mod image_ref; -mod import_profile; -mod import_session; -mod insights; +mod event_queue; mod migrations; -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; +mod repositories; +mod shared; -pub use diary::SqliteDiaryRepository; -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 sqlite_social::SqliteApContentQuery; -pub use stats::SqliteStatsRepository; -pub use users::SqliteUserRepository; -pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository}; -pub use watchlist::SqliteWatchlistRepository; -pub use wrapup::{SqliteWrapUpRepository, SqliteWrapUpStatsQuery}; +pub use event_queue::{DbEventQueueConfig, SqliteEventQueue}; +use repositories::SqliteUserSettingsRepository; +pub use repositories::{ + 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_COLUMNS, + create_federated_profile_query, create_image_ref, create_person_adapter, create_search_adapter, +}; + +pub mod persistence { + pub use crate::shared::{datetime_to_str, enqueue_events}; +} pub fn create_profile_fields_repo( pool: sqlx::SqlitePool, @@ -91,14 +74,18 @@ pub async fn wire(database_url: &str) -> anyhow::Result { .await .context("Failed to connect to SQLite database")?; + wire_pool(pool).await +} + +pub async fn wire_pool(pool: SqlitePool) -> anyhow::Result { + use anyhow::Context; + migrate(&pool) .await .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())); @@ -118,13 +105,11 @@ 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(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/migrations.rs b/crates/adapters/sqlite/src/migrations.rs index 3df60715..37d141e8 100644 --- a/crates/adapters/sqlite/src/migrations.rs +++ b/crates/adapters/sqlite/src/migrations.rs @@ -1,10 +1,30 @@ +use std::time::Duration; + use domain::errors::DomainError; use sqlx::SqlitePool; +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/src/models.rs b/crates/adapters/sqlite/src/models.rs deleted file mode 100644 index e4b8a5da..00000000 --- a/crates/adapters/sqlite/src/models.rs +++ /dev/null @@ -1,325 +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, 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 e4cf30c3..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(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 - 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(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 (?, ?, ?, ?, ?, ?) - 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().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(adapter_common::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(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 = ?", - ) - .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 = ?") - .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![], - }); - }; - - 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(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 = ? - 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 ? OFFSET ?", - ) - .bind(limit) - .bind(offset) - .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, - ) - } -} - -#[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/account_purge/mod.rs b/crates/adapters/sqlite/src/repositories/account_purge/mod.rs new file mode 100644 index 00000000..694a6657 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/account_purge/mod.rs @@ -0,0 +1,222 @@ +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"; + +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"), +]; + +pub const RETAINED_USER_TABLES: &[&str] = &["ap_local_actors"]; + +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"), +]; + +pub const PAYLOAD_KEYED_USER_COLUMNS: &[(&str, &str)] = &[ + ("event_queue", "payload"), + ("wrap_up_records", "report_json"), +]; + +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)?; + + 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(()); + } + + 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)?; + + sqlx::query( + "DELETE FROM event_queue + WHERE instr(payload, ?1) > 0 + AND event_type != 'UserDeleted' + AND status IN ('done', 'dead_lettered')", + ) + .bind(&id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query( + "UPDATE wrap_up_records + SET report_json = json_set(report_json, '$.most_active_user', NULL) + WHERE user_id IS NULL AND report_json IS NOT NULL + AND instr(report_json, ?) > 0", + ) + .bind(&id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + 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)?; + let expiring: Vec = sqlx::query_scalar( + "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 { + sqlx::query("DELETE FROM event_queue WHERE instr(payload, ?) > 0") + .bind(id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + for table in ["ap_followers", "ap_following"] { + sqlx::query(&format!("DELETE FROM {table} WHERE local_user_id = ?")) + .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 = ?")) + .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)?; + } + tx.commit().await.map_err(map_sqlx_error)?; + Ok(expiring.len() as u64) + } +} 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-social/src/ap_content.rs b/crates/adapters/sqlite/src/repositories/ap_content/rows.rs similarity index 51% rename from crates/adapters/sqlite-social/src/ap_content.rs rename to crates/adapters/sqlite/src/repositories/ap_content/rows.rs index aeb412f9..6880e659 100644 --- a/crates/adapters/sqlite-social/src/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 adapter_common::{parse_datetime, parse_uuid}; +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(adapter_common::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(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, 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(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, 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(adapter_common::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..62aa7dfd --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/diary/mod.rs @@ -0,0 +1,171 @@ +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) + } +} diff --git a/crates/adapters/sqlite/src/diary.rs b/crates/adapters/sqlite/src/repositories/diary/query.rs similarity index 63% rename from crates/adapters/sqlite/src/diary.rs rename to crates/adapters/sqlite/src/repositories/diary/query.rs index 26788b96..997fe139 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(adapter_common::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) - } - } - } - - 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(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_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(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 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(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 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(adapter_common::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 { @@ -293,11 +131,14 @@ impl DiaryQuery for SqliteDiaryRepository { FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC", }; + 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 ); @@ -340,7 +181,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 +189,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 +214,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 +225,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 +246,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 +271,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 +294,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) } @@ -466,12 +307,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(adapter_common::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, @@ -483,7 +326,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 ?", ) @@ -492,7 +335,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,11 +355,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) } } - -#[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/goals.rs b/crates/adapters/sqlite/src/repositories/goals/mod.rs similarity index 78% rename from crates/adapters/sqlite/src/goals.rs rename to crates/adapters/sqlite/src/repositories/goals/mod.rs index 8ceff8e5..d4c82f7b 100644 --- a/crates/adapters/sqlite/src/goals.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(adapter_common::map_sqlx_error)?; - - Ok(()) + .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 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(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())); } - 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(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())); } - Ok(()) + enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) } } @@ -97,7 +122,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 +137,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/image_ref.rs b/crates/adapters/sqlite/src/repositories/image_ref/mod.rs similarity index 97% rename from crates/adapters/sqlite/src/image_ref.rs rename to crates/adapters/sqlite/src/repositories/image_ref/mod.rs index 0da8a0b2..0a1397c5 100644 --- a/crates/adapters/sqlite/src/image_ref.rs +++ b/crates/adapters/sqlite/src/repositories/image_ref/mod.rs @@ -64,7 +64,3 @@ impl ImageRefQuery for SqliteImageRefAdapter { Ok(rows.into_iter().map(|(k,)| k).collect()) } } - -#[cfg(test)] -#[path = "tests/image_ref.rs"] -mod tests; diff --git a/crates/adapters/sqlite/src/import_profile.rs b/crates/adapters/sqlite/src/repositories/import_profile/mod.rs similarity index 59% rename from crates/adapters/sqlite/src/import_profile.rs rename to crates/adapters/sqlite/src/repositories/import_profile/mod.rs index 535f01cd..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, @@ -125,7 +48,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 +59,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 +101,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 +133,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/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 52% rename from crates/adapters/sqlite/src/import_session.rs rename to crates/adapters/sqlite/src/repositories/import_session/mod.rs index f978ece3..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 ── @@ -311,7 +131,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 +149,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 +176,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 +186,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 +206,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/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 81% rename from crates/adapters/sqlite/src/insights.rs rename to crates/adapters/sqlite/src/repositories/insights/mod.rs index 2224901f..752a7153 100644 --- a/crates/adapters/sqlite/src/insights.rs +++ b/crates/adapters/sqlite/src/repositories/insights/mod.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/repositories/mod.rs b/crates/adapters/sqlite/src/repositories/mod.rs new file mode 100644 index 00000000..f51576b4 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/mod.rs @@ -0,0 +1,54 @@ +mod account_purge; +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 processed_events; +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 account_purge::{ + PAYLOAD_KEYED_USER_COLUMNS, PURGED_USER_TABLES, RETAINED_USER_TABLES, SqliteAccountPurger, + URL_KEYED_USER_COLUMNS, +}; +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 processed_events::SqliteProcessedEventLedger; +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..f417676d --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/movie/command.rs @@ -0,0 +1,69 @@ +use async_trait::async_trait; +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, 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(); + 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()); + + 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 (?, ?, ?, ?, ?, ?) + 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(&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 delete_movie( + &self, + movie_id: &MovieId, + events: &[DomainEvent], + ) -> Result<(), DomainError> { + let id = 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 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/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 74% rename from crates/adapters/sqlite/src/movie.rs rename to crates/adapters/sqlite/src/repositories/movie/query.rs index 84f2352b..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(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 = ?") - .bind(&id) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } -} +use super::SqliteMovieRepository; +use super::rows::{MovieRow, MovieSummaryRow}; #[async_trait] impl MovieQuery for SqliteMovieRepository { @@ -80,7 +26,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 +40,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 +60,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 +85,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 +112,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 +164,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 +182,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 +206,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/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 80% rename from crates/adapters/sqlite/src/movie_dedup.rs rename to crates/adapters/sqlite/src/repositories/movie_dedup/mod.rs index 689ca920..6e1d0697 100644 --- a/crates/adapters/sqlite/src/movie_dedup.rs +++ b/crates/adapters/sqlite/src/repositories/movie_dedup/mod.rs @@ -35,7 +35,13 @@ impl MovieDeduplicator for SqliteMovieDeduplicator { .pool .begin() .await - .map_err(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; + + 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) @@ -45,14 +51,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 +66,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 +74,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 +82,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 +93,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 +108,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/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..38055c2b --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/persons/mod.rs @@ -0,0 +1,25 @@ +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, + ) +} 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/repositories/processed_events/mod.rs b/crates/adapters/sqlite/src/repositories/processed_events/mod.rs new file mode 100644 index 00000000..6e410fc5 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/processed_events/mod.rs @@ -0,0 +1,346 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + events::EventId, + ports::{Begin, ClaimToken, HANDLER_LEASE_SECONDS, ProcessedEventLedger}, +}; +use sqlx::SqlitePool; + +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::with_lease( + pool, + std::time::Duration::from_secs(HANDLER_LEASE_SECONDS as u64), + ) + } + + 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"), + } + } +} + +fn stamp(at: chrono::NaiveDateTime) -> String { + at.format(TIMESTAMP_FORMAT).to_string() +} + +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 { + for _ in 0..2 { + let now = chrono::Utc::now().naive_utc(); + let started = claim_stamp(now); + 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, 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(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(ClaimToken::new(started))); + } + + 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 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 { + retry_after: std::time::Duration::from_secs(1), + }) + } + + 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 = ? 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, + token: &ClaimToken, + ) -> Result<(), DomainError> { + sqlx::query( + "DELETE FROM processed_events + 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)?; + Ok(()) + } + + async fn delete_older_than(&self, cutoff: chrono::NaiveDateTime) -> Result { + let result = sqlx::query("DELETE FROM processed_events WHERE processed_at < ?") + .bind(stamp(cutoff)) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(result.rows_affected()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + 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) + } + + 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(); + } + + 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(); + + 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()); + + 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(); + let t = token(ledger.begin(&id, "a").await.unwrap()); + + ledger.complete(&id, "a", &t).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(); + let t = token(ledger.begin(&id, "a").await.unwrap()); + + ledger.release(&id, "a", &t).await.unwrap(); + + 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(); + let t = token(ledger.begin(&id, "a").await.unwrap()); + ledger.complete(&id, "a", &t).await.unwrap(); + + ledger.release(&id, "a", &t).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(); + insert(&pool, &id, "in_progress", "2020-01-01T00:00:00Z").await; + + token(ledger.begin(&id, "h").await.unwrap()); + 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" + ); + } + + #[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 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(); + + 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(); + 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(); + 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 matches!(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_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) + .unwrap(); + + let removed = ledger.delete_older_than(cutoff).await.unwrap(); + + assert_eq!(removed, 2); + assert_eq!( + ledger.begin(&recent, "h").await.unwrap(), + Begin::AlreadyDone + ); + } +} diff --git a/crates/adapters/sqlite/src/profile.rs b/crates/adapters/sqlite/src/repositories/profile/mod.rs similarity index 91% rename from crates/adapters/sqlite/src/profile.rs rename to crates/adapters/sqlite/src/repositories/profile/mod.rs index f6437efa..348ff85a 100644 --- a/crates/adapters/sqlite/src/profile.rs +++ b/crates/adapters/sqlite/src/repositories/profile/mod.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() @@ -298,7 +298,3 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { .collect()) } } - -#[cfg(test)] -#[path = "tests/profile.rs"] -mod tests; diff --git a/crates/adapters/sqlite/src/profile_fields.rs b/crates/adapters/sqlite/src/repositories/profile_fields/mod.rs similarity index 71% rename from crates/adapters/sqlite/src/profile_fields.rs rename to crates/adapters/sqlite/src/repositories/profile_fields/mod.rs index 6d5ce907..9dd32339 100644 --- a/crates/adapters/sqlite/src/profile_fields.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, } @@ -26,7 +28,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() @@ -41,14 +43,20 @@ 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(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(); @@ -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(adapter_common::map_sqlx_error)?; + .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/refresh_sessions.rs b/crates/adapters/sqlite/src/repositories/refresh_sessions/mod.rs similarity index 92% rename from crates/adapters/sqlite/src/refresh_sessions.rs rename to crates/adapters/sqlite/src/repositories/refresh_sessions/mod.rs index c2124ebd..27ac5790 100644 --- a/crates/adapters/sqlite/src/refresh_sessions.rs +++ b/crates/adapters/sqlite/src/repositories/refresh_sessions/mod.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-social/src/remote_goals.rs b/crates/adapters/sqlite/src/repositories/remote_goals/mod.rs similarity index 92% rename from crates/adapters/sqlite-social/src/remote_goals.rs rename to crates/adapters/sqlite/src/repositories/remote_goals/mod.rs index 73252009..cfc2fc22 100644 --- a/crates/adapters/sqlite-social/src/remote_goals.rs +++ b/crates/adapters/sqlite/src/repositories/remote_goals/mod.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/src/review.rs b/crates/adapters/sqlite/src/repositories/review/mod.rs similarity index 60% rename from crates/adapters/sqlite/src/review.rs rename to crates/adapters/sqlite/src/repositories/review/mod.rs index 4768a496..122f617a 100644 --- a/crates/adapters/sqlite/src/review.rs +++ b/crates/adapters/sqlite/src/repositories/review/mod.rs @@ -1,14 +1,17 @@ use async_trait::async_trait; use domain::{ errors::DomainError, + events::DomainEvent, models::{Review, ReviewSource}, ports::ReviewRepository, - value_objects::{ReviewId, UserId}, + value_objects::{MovieId, ReviewId, UserId}, }; use sqlx::SqlitePool; -use crate::models::ReviewRow; -use adapter_common::datetime_to_str; +use rows::ReviewRow; + +pub(crate) mod rows; +use crate::shared::{datetime_to_str, enqueue_events}; pub struct SqliteReviewRepository { pool: SqlitePool, @@ -22,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(); @@ -35,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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", @@ -49,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(adapter_common::map_sqlx_error)?; - - Ok(()) + .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_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { @@ -65,18 +77,27 @@ 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() } - 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 = ?", ) @@ -86,21 +107,46 @@ impl ReviewRepository for SqliteReviewRepository { .bind(&watch_medium) .bind(review.spoilers()) .bind(&id) - .execute(&self.pool) + .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) + .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 delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> { + async fn delete_review_and_orphaned_movie( + &self, + review_id: &ReviewId, + 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() + .await + .map_err(crate::shared::map_sqlx_error)?; sqlx::query("DELETE FROM reviews WHERE id = ?") .bind(&id) - .execute(&self.pool) + .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) + .map_err(crate::shared::map_sqlx_error)?; + 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) } async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError> { @@ -112,7 +158,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/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..48e876d4 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/search/mod.rs @@ -0,0 +1,24 @@ +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, + ) +} diff --git a/crates/adapters/sqlite-search/src/lib.rs b/crates/adapters/sqlite/src/repositories/search/query.rs similarity index 58% rename from crates/adapters/sqlite-search/src/lib.rs rename to crates/adapters/sqlite/src/repositories/search/query.rs index 36e95e5d..989a6f5c 100644 --- a/crates/adapters/sqlite-search/src/lib.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::{ - collections::Paginated, EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, - SearchQuery, SearchResults, - }, - 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(adapter_common::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(adapter_common::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(adapter_common::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(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_fts WHERE rowid = (SELECT rowid FROM movies_fts WHERE movie_id = ? LIMIT 1)", - ) - .bind(id) - .execute(&self.pool) - .await - .map_err(adapter_common::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(adapter_common::map_sqlx_error)?; - } - } - Ok(()) - } -} +use super::SqliteSearchAdapter; #[async_trait] impl SearchPort for SqliteSearchAdapter { @@ -201,7 +54,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 +70,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 +98,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 +119,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 +170,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 +195,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 +204,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 { @@ -389,7 +242,3 @@ impl SqliteSearchAdapter { }) } } - -#[cfg(test)] -#[path = "tests/lib.rs"] -mod tests; diff --git a/crates/adapters/sqlite-social/src/federated_profile.rs b/crates/adapters/sqlite/src/repositories/social/federated_profile.rs similarity index 100% rename from crates/adapters/sqlite-social/src/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..aa859e7b --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/social/follow/command.rs @@ -0,0 +1,126 @@ +use async_trait::async_trait; +use chrono::Utc; +use domain::{ + errors::DomainError, + events::DomainEvent, + ports::{FollowCommand, FollowWrite}, +}; + +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 FollowCommand for SqliteSocialRepository { + async fn apply( + &self, + writes: &[FollowWrite], + events: &[DomainEvent], + ) -> Result<(), DomainError> { + 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/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-social/src/follow_repository.rs b/crates/adapters/sqlite/src/repositories/social/follow/query.rs similarity index 51% rename from crates/adapters/sqlite-social/src/follow_repository.rs rename to crates/adapters/sqlite/src/repositories/social/follow/query.rs index 51dde13d..92ceaa27 100644 --- a/crates/adapters/sqlite-social/src/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 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 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 { @@ -204,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) @@ -226,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) @@ -251,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) @@ -276,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/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-social/src/social.rs b/crates/adapters/sqlite/src/repositories/social/local_social.rs similarity index 100% rename from crates/adapters/sqlite-social/src/social.rs rename to crates/adapters/sqlite/src/repositories/social/local_social.rs diff --git a/crates/adapters/sqlite-social/src/lib.rs b/crates/adapters/sqlite/src/repositories/social/mod.rs similarity index 70% rename from crates/adapters/sqlite-social/src/lib.rs rename to crates/adapters/sqlite/src/repositories/social/mod.rs index 06f3e466..4e6042c8 100644 --- a/crates/adapters/sqlite-social/src/lib.rs +++ b/crates/adapters/sqlite/src/repositories/social/mod.rs @@ -1,14 +1,8 @@ mod federated_profile; -mod follow_repository; -mod social; +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 { @@ -28,7 +22,3 @@ pub fn create_federated_profile_query( ) -> std::sync::Arc { std::sync::Arc::new(SqliteSocialRepository::new(pool, instance)) } - -#[cfg(test)] -#[path = "tests/follow_relation_tests.rs"] -mod follow_relation_tests; diff --git a/crates/adapters/sqlite-social/src/watchlist.rs b/crates/adapters/sqlite/src/repositories/social/watchlist.rs similarity index 100% rename from crates/adapters/sqlite-social/src/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 94% rename from crates/adapters/sqlite/src/stats.rs rename to crates/adapters/sqlite/src/repositories/stats/mod.rs index 937ebd03..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, } @@ -32,7 +34,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 +53,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 +72,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 +88,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, @@ -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 { @@ -168,14 +170,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/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 95% rename from crates/adapters/sqlite/src/user_settings.rs rename to crates/adapters/sqlite/src/repositories/user_settings/mod.rs index 63868141..73af4174 100644 --- a/crates/adapters/sqlite/src/user_settings.rs +++ b/crates/adapters/sqlite/src/repositories/user_settings/mod.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/repositories/users/mod.rs similarity index 53% rename from crates/adapters/sqlite/src/users.rs rename to crates/adapters/sqlite/src/repositories/users/mod.rs index 9c6abd6a..776e291e 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}, @@ -64,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(adapter_common::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![])) @@ -77,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(adapter_common::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![])) @@ -89,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(), )); @@ -121,25 +127,181 @@ 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(()) } 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) - .await - .map_err(adapter_common::map_sqlx_error)?; + 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 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 { + 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 = ?", + ) + .bind(&id_str) + .execute(&mut *tx) + .await + .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) + } + + 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) + } + + 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( + &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 = ?", + ) + .bind(&profile.display_name) + .bind(&profile.bio) + .bind(&profile.avatar_path) + .bind(&profile.banner_path) + .bind(&profile.also_known_as) + .bind(&id_str) + .execute(&mut *tx) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + 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> { + sqlx::query_as::<_, UserSummaryRow>( + r#"SELECT u.id, u.email, u.username, u.display_name, + COUNT(DISTINCT r.movie_id) AS total_movies, + AVG(CAST(r.rating AS REAL)) 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 + 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"#, + ) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)? + .into_iter() + .map(UserSummaryRow::into_domain) + .collect() + } +} + +impl SqliteUserRepository { + 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) + .bind(id_str) .fetch_all(&self.pool) .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; @@ -154,48 +316,4 @@ impl UserRepository for SqliteUserRepository { 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 = ?, bio = ?, avatar_path = ?, banner_path = ?, also_known_as = ? WHERE id = ?", - ) - .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(CAST(r.rating AS REAL)) 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() - } } - -#[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/repositories/watch_event/command.rs b/crates/adapters/sqlite/src/repositories/watch_event/command.rs new file mode 100644 index 00000000..a6c59f92 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/watch_event/command.rs @@ -0,0 +1,107 @@ +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, enqueue_events}; + +use super::SqliteWatchEventRepository; + +#[async_trait] +impl WatchEventCommand for SqliteWatchEventRepository { + 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()); + 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()); + + 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) \ + 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(&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 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..ba6353c9 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/watch_event/webhook_token.rs @@ -0,0 +1,111 @@ +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 t.id, t.user_id, t.token_hash, t.provider, t.label, t.created_at, t.last_used_at \ + FROM webhook_tokens t JOIN users u ON u.id = t.user_id \ + WHERE t.token_hash = ? AND u.deleted_at IS NULL", + ) + .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 67% rename from crates/adapters/sqlite/src/watchlist.rs rename to crates/adapters/sqlite/src/repositories/watchlist/mod.rs index 934717c7..78c96abf 100644 --- a/crates/adapters/sqlite/src/watchlist.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}, @@ -10,8 +11,10 @@ use domain::{ }; use sqlx::{Row, SqlitePool}; -use crate::models::WatchlistRow; -use adapter_common::datetime_to_str; +use rows::WatchlistRow; + +mod rows; +use crate::shared::{datetime_to_str, enqueue_events}; pub struct SqliteWatchlistRepository { pool: SqlitePool, @@ -25,13 +28,18 @@ 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); - sqlx::query( + let mut tx = self + .pool + .begin() + .await + .map_err(crate::shared::map_sqlx_error)?; + let inserted = sqlx::query( "INSERT OR IGNORE INTO watchlist_entries (id, user_id, movie_id, added_at) \ VALUES (?, ?, ?, ?)", ) @@ -39,24 +47,38 @@ impl WatchlistRepository for SqliteWatchlistRepository { .bind(&user_id) .bind(&movie_id) .bind(&added_at) - .execute(&self.pool) + .execute(&mut *tx) .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) + .map_err(crate::shared::map_sqlx_error)? + .rows_affected() + > 0; + if inserted { + 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(adapter_common::map_sqlx_error)?; + .map_err(crate::shared::map_sqlx_error)?; if result.rows_affected() == 0 { return Err(DomainError::NotFound(format!( @@ -64,24 +86,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(adapter_common::map_sqlx_error)?; - Ok(result.rows_affected() > 0) + .map_err(crate::shared::map_sqlx_error)?; + 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( @@ -108,13 +142,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 +175,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/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..4af56488 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/wrapup/mod.rs @@ -0,0 +1,224 @@ +use async_trait::async_trait; +use chrono::NaiveDate; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpStatus}, + ports::WrapUpRepository, + value_objects::WrapUpId, +}; +use sqlx::SqlitePool; +use uuid::Uuid; + +use crate::shared::enqueue_events; +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, + 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); + 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()); + + 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) \ + 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(&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 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, + 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()))?; + + 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(&mut *tx) + .await + .map_err(crate::shared::map_sqlx_error)?; + 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> { + 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/repositories/wrapup/query.rs b/crates/adapters/sqlite/src/repositories/wrapup/query.rs new file mode 100644 index 00000000..95416ae9 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/wrapup/query.rs @@ -0,0 +1,288 @@ +use std::collections::HashMap; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::wrapup::{DateRange, WrapUpMovieRow, WrapUpScope}, + ports::WrapUpStatsQuery, +}; +use sqlx::{Row, SqlitePool}; + +use crate::shared::{parse_datetime, parse_uuid}; + +// ── WrapUpStatsQuery ───────────────────────────────────────────────────────── + +pub struct SqliteWrapUpStatsQuery { + pool: SqlitePool, +} + +impl SqliteWrapUpStatsQuery { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl WrapUpStatsQuery for SqliteWrapUpStatsQuery { + async fn get_reviews_with_profiles( + &self, + scope: &WrapUpScope, + range: &DateRange, + ) -> Result, DomainError> { + let start_str = range.start().format("%Y-%m-%d").to_string(); + let end_str = range.end().format("%Y-%m-%d").to_string(); + + // 1) Main query + let (scope_clause, scope_bind) = match scope { + WrapUpScope::User(uid) => ("AND r.user_id = ?", 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, r.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 >= ? AND r.watched_at < ? {scope_clause} \ + ORDER BY r.watched_at ASC" + ); + + let mut q = sqlx::query(&sql).bind(&start_str).bind(&end_str); + if let Some(ref uid) = scope_bind { + q = q.bind(uid); + } + + let rows = q + .fetch_all(&self.pool) + .await + .map_err(crate::shared::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(crate::shared::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_sqlite(&self.pool, &movie_ids), + fetch_keywords_sqlite(&self.pool, &movie_ids), + fetch_cast_sqlite(&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(crate::shared::map_sqlx_error)?; + let title: String = row + .try_get("title") + .map_err(crate::shared::map_sqlx_error)?; + let release_year: i64 = row + .try_get("release_year") + .map_err(crate::shared::map_sqlx_error)?; + let director: Option = row + .try_get("director") + .map_err(crate::shared::map_sqlx_error)?; + let poster_path: Option = row + .try_get("poster_path") + .map_err(crate::shared::map_sqlx_error)?; + let rating: i64 = row + .try_get("rating") + .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 user_id_str: String = row + .try_get("user_id") + .map_err(crate::shared::map_sqlx_error)?; + let runtime_minutes: Option = row + .try_get("runtime_minutes") + .map_err(crate::shared::map_sqlx_error)?; + let budget_usd: Option = row + .try_get("budget_usd") + .map_err(crate::shared::map_sqlx_error)?; + let original_language: Option = row + .try_get("original_language") + .map_err(crate::shared::map_sqlx_error)?; + let watch_medium: Option = row + .try_get("watch_medium") + .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(); + 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, +} + +fn in_placeholders(n: usize) -> String { + let mut s = String::new(); + for i in 0..n { + if i > 0 { + s.push(','); + } + s.push('?'); + } + s +} + +/// Shared by WrapUp and Insights: movie-id → genre names. +pub(crate) async fn fetch_genres_sqlite( + pool: &SqlitePool, + movie_ids: &[String], +) -> Result>, DomainError> { + if movie_ids.is_empty() { + return Ok(HashMap::new()); + } + let sql = format!( + "SELECT movie_id, name FROM movie_genres WHERE movie_id IN ({}) ORDER BY name", + in_placeholders(movie_ids.len()) + ); + let mut q = sqlx::query(&sql); + for id in movie_ids { + q = q.bind(id); + } + let rows = q + .fetch_all(pool) + .await + .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(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) +} + +async fn fetch_keywords_sqlite( + pool: &SqlitePool, + movie_ids: &[String], +) -> Result>, DomainError> { + if movie_ids.is_empty() { + return Ok(HashMap::new()); + } + let sql = format!( + "SELECT movie_id, name FROM movie_keywords WHERE movie_id IN ({}) ORDER BY name", + in_placeholders(movie_ids.len()) + ); + let mut q = sqlx::query(&sql); + for id in movie_ids { + q = q.bind(id); + } + let rows = q + .fetch_all(pool) + .await + .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(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) +} + +async fn fetch_cast_sqlite( + pool: &SqlitePool, + movie_ids: &[String], +) -> Result>, DomainError> { + if movie_ids.is_empty() { + return Ok(HashMap::new()); + } + let sql = format!( + "SELECT movie_id, name, billing_order, tmdb_person_id, profile_path \ + FROM movie_cast \ + WHERE movie_id IN ({}) AND billing_order <= 3 \ + ORDER BY billing_order ASC", + in_placeholders(movie_ids.len()) + ); + let mut q = sqlx::query(&sql); + for id in movie_ids { + q = q.bind(id); + } + let rows = q + .fetch_all(pool) + .await + .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(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(crate::shared::map_sqlx_error)?; + let tmdb_person_id: i64 = row + .try_get("tmdb_person_id") + .map_err(crate::shared::map_sqlx_error)?; + let profile_path: Option = row + .try_get("profile_path") + .map_err(crate::shared::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/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/adapter-common/src/lib.rs b/crates/adapters/sqlite/src/shared.rs similarity index 78% rename from crates/adapters/adapter-common/src/lib.rs rename to crates/adapters/sqlite/src/shared.rs index f0b75266..eb79feb9 100644 --- a/crates/adapters/adapter-common/src/lib.rs +++ b/crates/adapters/sqlite/src/shared.rs @@ -10,17 +10,36 @@ use domain::{ }, }; -pub fn map_sqlx_error(e: sqlx::Error) -> DomainError { +pub 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()) } -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 +48,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 +72,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 +98,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 +136,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 +154,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 +175,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 +195,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 +209,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/src/tests/users.rs b/crates/adapters/sqlite/src/tests/users.rs deleted file mode 100644 index c6fd2faa..00000000 --- a/crates/adapters/sqlite/src/tests/users.rs +++ /dev/null @@ -1,109 +0,0 @@ -use super::*; -use domain::models::UserRole; -use domain::value_objects::{Email, PasswordHash, Username}; -use sqlx::SqlitePool; - -async fn setup() -> (SqlitePool, SqliteUserRepository) { - let pool = SqlitePool::connect(":memory:").await.unwrap(); - sqlx::query( - "CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', display_name TEXT, bio TEXT, avatar_path TEXT, banner_path TEXT, also_known_as TEXT)" - ) - .execute(&pool) - .await - .unwrap(); - sqlx::query( - "CREATE TABLE user_profile_fields (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, value TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0)" - ) - .execute(&pool) - .await - .unwrap(); - let repo = SqliteUserRepository::new(pool.clone()); - (pool, repo) -} - -#[tokio::test] -async fn find_by_id_returns_none_when_not_found() { - let (_, repo) = setup().await; - let result = repo - .find_by_id(&UserId::from_uuid(uuid::Uuid::new_v4())) - .await - .unwrap(); - assert!(result.is_none()); -} - -#[tokio::test] -async fn find_by_id_returns_user_when_found() { - let (pool, repo) = setup().await; - let id = uuid::Uuid::new_v4(); - sqlx::query( - "INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)", - ) - .bind(id.to_string()) - .bind("test@example.com") - .bind("test") - .bind("$argon2id$v=19$m=65536,t=2,p=1$fakesalt$fakehash") - .bind("2026-01-01T00:00:00Z") - .execute(&pool) - .await - .unwrap(); - - let result = repo.find_by_id(&UserId::from_uuid(id)).await.unwrap(); - assert!(result.is_some()); - assert_eq!(result.unwrap().email().value(), "test@example.com"); -} - -#[tokio::test] -async fn update_profile_persists_bio_and_avatar() { - let (_, repo) = setup().await; - let user = 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, - ); - repo.save(&user).await.unwrap(); - - repo.update_profile( - user.id(), - &domain::models::UserProfile { - bio: Some("My biography".to_string()), - avatar_path: Some("avatars/user1".to_string()), - ..Default::default() - }, - ) - .await - .unwrap(); - - let found = repo.find_by_id(user.id()).await.unwrap().unwrap(); - assert_eq!(found.bio(), Some("My biography")); - assert_eq!(found.avatar_path(), Some("avatars/user1")); -} - -#[tokio::test] -async fn update_profile_clears_fields_with_none() { - let (_, repo) = setup().await; - let user = 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, - ); - repo.save(&user).await.unwrap(); - repo.update_profile( - user.id(), - &domain::models::UserProfile { - bio: Some("bio".to_string()), - avatar_path: Some("path".to_string()), - ..Default::default() - }, - ) - .await - .unwrap(); - repo.update_profile(user.id(), &domain::models::UserProfile::default()) - .await - .unwrap(); - - let found = repo.find_by_id(user.id()).await.unwrap().unwrap(); - assert_eq!(found.bio(), None); - assert_eq!(found.avatar_path(), None); -} diff --git a/crates/adapters/sqlite/src/watch_event.rs b/crates/adapters/sqlite/src/watch_event.rs deleted file mode 100644 index 8864aa91..00000000 --- a/crates/adapters/sqlite/src/watch_event.rs +++ /dev/null @@ -1,401 +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 adapter_common::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(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 = ? WHERE id = ?") - .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 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(adapter_common::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(adapter_common::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(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, watched_at, status, created_at \ - FROM watch_events WHERE id = ?", - ) - .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 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(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 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(adapter_common::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(adapter_common::map_sqlx_error)?; - let user_id_str: &str = row - .try_get("user_id") - .map_err(adapter_common::map_sqlx_error)?; - let movie_id_str: Option<&str> = 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 - .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(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, created_at, last_used_at \ - FROM webhook_tokens WHERE token_hash = ?", - ) - .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, 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(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 = ? AND user_id = ?") - .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(); - 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(adapter_common::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 user_id_str: &str = 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.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/wrapup.rs b/crates/adapters/sqlite/src/wrapup.rs deleted file mode 100644 index 521c3d1f..00000000 --- a/crates/adapters/sqlite/src/wrapup.rs +++ /dev/null @@ -1,555 +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::{Row, SqlitePool}; -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}" - ))), - } -} - -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(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 = ?, error_message = ? WHERE id = ?") - .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 = ?, completed_at = strftime('%Y-%m-%d %H:%M:%S', 'now') \ - WHERE id = ?", - ) - .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, \ - created_at, completed_at \ - FROM wrap_up_records WHERE id = ?", - ) - .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, \ - 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(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, \ - 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(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 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(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 = ?") - .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 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(adapter_common::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 user_id_str: Option = row - .try_get("user_id") - .map_err(adapter_common::map_sqlx_error)?; - let start_date_str: String = row - .try_get("start_date") - .map_err(adapter_common::map_sqlx_error)?; - let end_date_str: String = 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: 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 { - pool: SqlitePool, -} - -impl SqliteWrapUpStatsQuery { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WrapUpStatsQuery for SqliteWrapUpStatsQuery { - async fn get_reviews_with_profiles( - &self, - scope: &WrapUpScope, - range: &DateRange, - ) -> Result, DomainError> { - let start_str = range.start().format("%Y-%m-%d").to_string(); - let end_str = range.end().format("%Y-%m-%d").to_string(); - - // 1) Main query - let (scope_clause, scope_bind) = match scope { - WrapUpScope::User(uid) => ("AND r.user_id = ?", 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, r.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 >= ? AND r.watched_at < ? {scope_clause} \ - ORDER BY r.watched_at ASC" - ); - - let mut q = sqlx::query(&sql).bind(&start_str).bind(&end_str); - 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_sqlite(&self.pool, &movie_ids), - fetch_keywords_sqlite(&self.pool, &movie_ids), - fetch_cast_sqlite(&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, -} - -fn in_placeholders(n: usize) -> String { - let mut s = String::new(); - for i in 0..n { - if i > 0 { - s.push(','); - } - s.push('?'); - } - s -} - -/// Shared by WrapUp and Insights: movie-id → genre names. -pub(crate) async fn fetch_genres_sqlite( - pool: &SqlitePool, - movie_ids: &[String], -) -> Result>, DomainError> { - if movie_ids.is_empty() { - return Ok(HashMap::new()); - } - let sql = format!( - "SELECT movie_id, name FROM movie_genres WHERE movie_id IN ({}) ORDER BY name", - in_placeholders(movie_ids.len()) - ); - let mut q = sqlx::query(&sql); - for id in movie_ids { - q = q.bind(id); - } - let rows = q - .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_sqlite( - pool: &SqlitePool, - movie_ids: &[String], -) -> Result>, DomainError> { - if movie_ids.is_empty() { - return Ok(HashMap::new()); - } - let sql = format!( - "SELECT movie_id, name FROM movie_keywords WHERE movie_id IN ({}) ORDER BY name", - in_placeholders(movie_ids.len()) - ); - let mut q = sqlx::query(&sql); - for id in movie_ids { - q = q.bind(id); - } - let rows = q - .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_sqlite( - pool: &SqlitePool, - movie_ids: &[String], -) -> Result>, DomainError> { - if movie_ids.is_empty() { - return Ok(HashMap::new()); - } - let sql = format!( - "SELECT movie_id, name, billing_order, tmdb_person_id, profile_path \ - FROM movie_cast \ - WHERE movie_id IN ({}) AND billing_order <= 3 \ - ORDER BY billing_order ASC", - in_placeholders(movie_ids.len()) - ); - let mut q = sqlx::query(&sql); - for id in movie_ids { - q = q.bind(id); - } - let rows = q - .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/adapters/sqlite/tests/account_purge_test.rs b/crates/adapters/sqlite/tests/account_purge_test.rs new file mode 100644 index 00000000..ec2a5e29 --- /dev/null +++ b/crates/adapters/sqlite/tests/account_purge_test.rs @@ -0,0 +1,762 @@ +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::{ + 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}; + +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, +} + +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, + }, + 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(); + 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; + 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; + } + 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}"}}"#), + ), + ( + "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) + .bind(payload) + .bind(status) + .execute(pool) + .await + .unwrap(); + } + 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 { + 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"); + + 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()), + ("FollowRequested".to_string(), "pending".to_string()), + ("ActorBlocked".to_string(), "processing".to_string()), + ], + "finished rows mentioning the user are gone; pending/processing rows (incl. other users' events about her) and bob's own rows stay" + ); + + for (table, column) in PURGED_USER_TABLES { + assert!( + count_for(&pool, table, column, &s.other).await > 0, + "{table} of the other user must survive" + ); + } + 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" + ); + } + 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"); + } +} + +#[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; + 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() + ); + 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(); + 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); + 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; + + 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 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); + + exec( + &pool, + "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at) + VALUES (?, 'https://late.example/users/zed', 'pending', '2026-01-01T00:00:00Z')", + &[&alice], + ) + .await; + 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_followers", "local_user_id", &s.user).await, + 0, + "late shared-inbox follower row" + ); + 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"); +} + +#[tokio::test] +async fn purge_redacts_the_user_from_global_wrap_up_reports_only() { + let pool = migrated_pool().await; + let parts = sqlite::wire_pool(pool.clone()).await.unwrap(); + let s = seed(&pool, &parts).await; + let (a, b) = (s.user.value().to_string(), s.other.value().to_string()); + let report = |id: &str, name: &str| { + format!( + r#"{{"total_watched":3,"most_active_user":{{"user_id":"{id}","display_name":"{name}"}},"total_users_active":2}}"# + ) + }; + for (rid, id, name) in [("g-alice", &a, "Zelda Quux"), ("g-bob", &b, "Bobby Tables")] { + exec( + &pool, + "INSERT INTO wrap_up_records (id, user_id, start_date, end_date, status, report_json) + VALUES (?, NULL, '2024-01-01', '2024-12-31', 'completed', ?)", + &[rid, &report(id, name)], + ) + .await; + } + tombstone(&pool, &s.user).await; + + purger(&pool).purge(&s.user).await.unwrap(); + + let all: Vec<(String, String)> = + sqlx::query_as("SELECT id, report_json FROM wrap_up_records WHERE user_id IS NULL") + .fetch_all(&pool) + .await + .unwrap(); + for (_, json) in &all { + assert!(!json.contains(&a), "user id left in {json}"); + assert!(!json.contains("Zelda Quux"), "display name left in {json}"); + } + let alice_row = all.iter().find(|(id, _)| id == "g-alice").unwrap(); + let v: serde_json::Value = serde_json::from_str(&alice_row.1).unwrap(); + assert!(v["most_active_user"].is_null(), "{v}"); + assert_eq!(v["total_watched"], 3, "rest of the report is kept"); + let bob_row = all.iter().find(|(id, _)| id == "g-bob").unwrap(); + assert_eq!( + bob_row.1, + report(&b, "Bobby Tables"), + "other user untouched" + ); +} diff --git a/crates/adapters/sqlite/tests/cascade_test.rs b/crates/adapters/sqlite/tests/cascade_test.rs new file mode 100644 index 00000000..66500f0d --- /dev/null +++ b/crates/adapters/sqlite/tests/cascade_test.rs @@ -0,0 +1,338 @@ +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, FollowWrite, 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"; + +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 + .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(); + 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/adapters/sqlite/tests/fixtures/migration_ledger.txt b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt new file mode 100644 index 00000000..2a339efe --- /dev/null +++ b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt @@ -0,0 +1,33 @@ +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 +101 19A2542D4343F3921A8BF0A10E195DA1915E3EA97A2BCF2D3BF9079504DB2053E4BD2175D94C95BCA363BC2FDF14AA88 +102 A3995D574BDDDB634F9D7FEF5FAEE658F272C3E0CABA182080425ED5522E73DBF44EDEF28F35BDCF4669F722E4E2AA4B 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 00000000..50ee3952 Binary files /dev/null and b/crates/adapters/sqlite/tests/fixtures/pre_merge.db differ 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..fbc1c75d --- /dev/null +++ b/crates/adapters/sqlite/tests/migration_ledger_test.rs @@ -0,0 +1,102 @@ +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(); +} + +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); +} + +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/crates/adapters/sqlite/tests/repositories.rs b/crates/adapters/sqlite/tests/repositories.rs new file mode 100644 index 00000000..3b49c84d --- /dev/null +++ b/crates/adapters/sqlite/tests/repositories.rs @@ -0,0 +1,22 @@ +#[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_dedup_test.rs"] +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"] +mod review_repository_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/tests/diary.rs b/crates/adapters/sqlite/tests/repositories/diary_repository_test.rs similarity index 82% rename from crates/adapters/sqlite/src/tests/diary.rs rename to crates/adapters/sqlite/tests/repositories/diary_repository_test.rs index 477beaf6..645ebe40 100644 --- a/crates/adapters/sqlite/src/tests/diary.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); @@ -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/src/tests/image_ref.rs b/crates/adapters/sqlite/tests/repositories/image_ref_adapter_test.rs similarity index 96% rename from crates/adapters/sqlite/src/tests/image_ref.rs rename to crates/adapters/sqlite/tests/repositories/image_ref_adapter_test.rs index e71c9d38..7a49eca4 100644 --- a/crates/adapters/sqlite/src/tests/image_ref.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/tests/repositories/movie_dedup_test.rs b/crates/adapters/sqlite/tests/repositories/movie_dedup_test.rs new file mode 100644 index 00000000..15cc38e4 --- /dev/null +++ b/crates/adapters/sqlite/tests/repositories/movie_dedup_test.rs @@ -0,0 +1,161 @@ +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(); +} + +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_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(); + 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") + ); +} diff --git a/crates/adapters/sqlite/src/tests/profile.rs b/crates/adapters/sqlite/tests/repositories/movie_profile_repository_test.rs similarity index 98% rename from crates/adapters/sqlite/src/tests/profile.rs rename to crates/adapters/sqlite/tests/repositories/movie_profile_repository_test.rs index bf19abc0..116e6e17 100644 --- a/crates/adapters/sqlite/src/tests/profile.rs +++ b/crates/adapters/sqlite/tests/repositories/movie_profile_repository_test.rs @@ -1,5 +1,5 @@ -use super::super::profile::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/tests/repositories/outbox_support.rs b/crates/adapters/sqlite/tests/repositories/outbox_support.rs new file mode 100644 index 00000000..7c3416be --- /dev/null +++ b/crates/adapters/sqlite/tests/repositories/outbox_support.rs @@ -0,0 +1,34 @@ +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() +} + +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..c69e9144 --- /dev/null +++ b/crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs @@ -0,0 +1,810 @@ +use domain::{ + events::DomainEvent, + models::{ProfileField, UserProfile, WatchlistEntry}, + ports::{ + GoalCommand, MovieCommand, UserProfileFieldsRepository, UserRepository, WatchEventCommand, + WatchlistRepository, WrapUpRepository, + }, + 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 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; + 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" + ); +} + +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); +} + +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()); + 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/adapters/sqlite/src/tests/persons.rs b/crates/adapters/sqlite/tests/repositories/person_adapter_test.rs similarity index 85% rename from crates/adapters/sqlite/src/tests/persons.rs rename to crates/adapters/sqlite/tests/repositories/person_adapter_test.rs index 1589b1d2..da08eb6d 100644 --- a/crates/adapters/sqlite/src/tests/persons.rs +++ b/crates/adapters/sqlite/tests/repositories/person_adapter_test.rs @@ -1,8 +1,9 @@ -use super::super::persons::SqlitePersonAdapter; +use domain::testing::test_person_with_tmdb; use domain::{ - models::{ExternalPersonId, Person, PersonId}, + models::{ExternalPersonId, PersonId}, ports::{PersonCommand, PersonQuery}, }; +use sqlite::SqlitePersonAdapter; use sqlx::SqlitePool; async fn pool_with_schema() -> 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/tests/repositories/review_repository_test.rs b/crates/adapters/sqlite/tests/repositories/review_repository_test.rs new file mode 100644 index 00000000..76ca0a7d --- /dev/null +++ b/crates/adapters/sqlite/tests/repositories/review_repository_test.rs @@ -0,0 +1,219 @@ +use domain::{ + events::DomainEvent, + ports::{MovieCommand, ReviewRepository, UserRepository}, + testing::{test_movie, test_review, test_user}, +}; +use sqlite::{SqliteMovieRepository, SqliteReviewRepository, SqliteUserRepository}; +use sqlx::SqlitePool; + +use super::outbox_support::{count, fail_event_inserts, migrated_pool}; + +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); +} + +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 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(&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); +} + +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_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(); + + 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_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 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, + "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/adapters/sqlite-search/src/tests/lib.rs b/crates/adapters/sqlite/tests/repositories/search_adapter_test.rs similarity index 97% rename from crates/adapters/sqlite-search/src/tests/lib.rs rename to crates/adapters/sqlite/tests/repositories/search_adapter_test.rs index 25771dc9..874d3f1c 100644 --- a/crates/adapters/sqlite-search/src/tests/lib.rs +++ b/crates/adapters/sqlite/tests/repositories/search_adapter_test.rs @@ -1,10 +1,10 @@ -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}, }; +use sqlite::create_search_adapter; use sqlx::SqlitePool; async fn pool_with_schema() -> SqlitePool { diff --git a/crates/adapters/sqlite-social/src/tests/follow_relation_tests.rs b/crates/adapters/sqlite/tests/repositories/social_repository_test.rs similarity index 75% rename from crates/adapters/sqlite-social/src/tests/follow_relation_tests.rs rename to crates/adapters/sqlite/tests/repositories/social_repository_test.rs index dcb18b64..6e805266 100644 --- a/crates/adapters/sqlite-social/src/tests/follow_relation_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 { @@ -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)", ] { @@ -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 = @@ -251,3 +251,77 @@ async fn test_list_all_followed_remote_actors_deduplicates() { 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)) + )); +} + +#[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/adapters/sqlite/tests/repositories/user_repository_test.rs b/crates/adapters/sqlite/tests/repositories/user_repository_test.rs new file mode 100644 index 00000000..7897f16b --- /dev/null +++ b/crates/adapters/sqlite/tests/repositories/user_repository_test.rs @@ -0,0 +1,275 @@ +use domain::models::UserProfile; +use domain::ports::UserRepository; +use domain::testing::test_user; +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 = migrated_pool().await; + let repo = SqliteUserRepository::new(pool.clone()); + (pool, repo) +} + +#[tokio::test] +async fn find_by_id_returns_none_when_not_found() { + let (_, repo) = setup().await; + let result = repo + .find_by_id(&UserId::from_uuid(uuid::Uuid::new_v4())) + .await + .unwrap(); + assert!(result.is_none()); +} + +#[tokio::test] +async fn find_by_id_returns_user_when_found() { + let (pool, repo) = setup().await; + let id = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(id.to_string()) + .bind("test@example.com") + .bind("test") + .bind("$argon2id$v=19$m=65536,t=2,p=1$fakesalt$fakehash") + .bind("2026-01-01T00:00:00Z") + .execute(&pool) + .await + .unwrap(); + + let result = repo.find_by_id(&UserId::from_uuid(id)).await.unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().email().value(), "test@example.com"); +} + +#[tokio::test] +async fn update_profile_persists_bio_and_avatar() { + let (_, repo) = setup().await; + let user = test_user("testuser"); + repo.save(&user).await.unwrap(); + + repo.update_profile( + user.id(), + &UserProfile { + bio: Some("My biography".to_string()), + avatar_path: Some("avatars/user1".to_string()), + ..Default::default() + }, + &[], + ) + .await + .unwrap(); + + let found = repo.find_by_id(user.id()).await.unwrap().unwrap(); + assert_eq!(found.bio(), Some("My biography")); + assert_eq!(found.avatar_path(), Some("avatars/user1")); +} + +#[tokio::test] +async fn update_profile_clears_fields_with_none() { + let (_, repo) = setup().await; + let user = test_user("testuser2"); + repo.save(&user).await.unwrap(); + repo.update_profile( + user.id(), + &UserProfile { + bio: Some("bio".to_string()), + avatar_path: Some("path".to_string()), + ..Default::default() + }, + &[], + ) + .await + .unwrap(); + repo.update_profile(user.id(), &UserProfile::default(), &[]) + .await + .unwrap(); + + let found = repo.find_by_id(user.id()).await.unwrap().unwrap(); + assert_eq!(found.bio(), None); + assert_eq!(found.avatar_path(), None); +} + +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 (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(); + 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 + .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(), 1); + 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()); +} + +#[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/adapters/template-askama/Cargo.toml b/crates/adapters/template-askama/Cargo.toml deleted file mode 100644 index 9b037548..00000000 --- a/crates/adapters/template-askama/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "template-askama" -version = "0.1.0" -edition = "2024" - -[dependencies] -askama = { version = "0.16.0" } - -chrono = { workspace = true } -uuid = { workspace = true } - -domain = { workspace = true } -api-types = { workspace = true } diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs deleted file mode 100644 index 5a2b37b6..00000000 --- a/crates/adapters/template-askama/src/lib.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 [crate::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/tmdb-enrichment/Cargo.toml b/crates/adapters/tmdb-enrichment/Cargo.toml index b6d28d49..cce5b2fd 100644 --- a/crates/adapters/tmdb-enrichment/Cargo.toml +++ b/crates/adapters/tmdb-enrichment/Cargo.toml @@ -1,14 +1,13 @@ [package] name = "tmdb-enrichment" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] +config = { workspace = true } domain = { workspace = true } reqwest = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -anyhow = { workspace = true } +rustls = { workspace = true, features = ["ring", "std"] } +serde = { workspace = true, features = ["derive"] } async-trait = { workspace = true } -tracing = { workspace = true } chrono = { workspace = true } diff --git a/crates/adapters/tmdb-enrichment/src/client.rs b/crates/adapters/tmdb-enrichment/src/client.rs index 3fc468ef..11f0568a 100644 --- a/crates/adapters/tmdb-enrichment/src/client.rs +++ b/crates/adapters/tmdb-enrichment/src/client.rs @@ -7,9 +7,12 @@ 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()))?; + let _ = rustls::crypto::ring::default_provider().install_default(); Ok(Self { api_key, http: reqwest::Client::new(), diff --git a/crates/adapters/tmdb-enrichment/tests/client_tls.rs b/crates/adapters/tmdb-enrichment/tests/client_tls.rs new file mode 100644 index 00000000..3f554897 --- /dev/null +++ b/crates/adapters/tmdb-enrichment/tests/client_tls.rs @@ -0,0 +1,10 @@ +use tmdb_enrichment::TmdbEnrichmentClient; + +#[test] +fn tmdb_enrichment_builds_without_bootstrap() { + let cfg = config::MetadataConfig { + tmdb_api_key: Some("key".into()), + ..Default::default() + }; + TmdbEnrichmentClient::from_config(&cfg).expect("client builds"); +} diff --git a/crates/api-types/Cargo.toml b/crates/api-types/Cargo.toml index 322565af..b967e39f 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 } +serde = { workspace = true, features = ["derive"] } uuid = { workspace = true } -utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] } -domain = { path = "../domain" } +utoipa = { workspace = true, features = ["axum_extras", "uuid"] } +domain = { workspace = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/api-types/src/common.rs b/crates/api-types/src/common.rs index e2e7a42c..0025fa13 100644 --- a/crates/api-types/src/common.rs +++ b/crates/api-types/src/common.rs @@ -21,3 +21,9 @@ pub type ActivityFeedResponse = PaginatedResponse; pub type WatchlistResponse = PaginatedResponse; pub type PaginatedMovieHits = PaginatedResponse; pub type PaginatedPersonHits = PaginatedResponse; + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ErrorResponse { + pub error: String, + pub code: String, +} 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/presentation/src/mappers/diary.rs b/crates/api-types/src/mappers/diary.rs similarity index 95% rename from crates/presentation/src/mappers/diary.rs rename to crates/api-types/src/mappers/diary.rs index e38c24f5..9aa851ca 100644 --- a/crates/presentation/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..3221636e --- /dev/null +++ b/crates/api-types/src/mappers/mod.rs @@ -0,0 +1,9 @@ +mod diary; +mod goals; +mod movies; +mod search; + +pub use diary::*; +pub use goals::*; +pub use movies::*; +pub use search::*; diff --git a/crates/presentation/src/mappers/movies.rs b/crates/api-types/src/mappers/movies.rs similarity index 97% rename from crates/presentation/src/mappers/movies.rs rename to crates/api-types/src/mappers/movies.rs index 4bca1606..9b6b729d 100644 --- a/crates/presentation/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/presentation/src/mappers/search.rs b/crates/api-types/src/mappers/search.rs similarity index 96% rename from crates/presentation/src/mappers/search.rs rename to crates/api-types/src/mappers/search.rs index 874dc755..74ee04ae 100644 --- a/crates/presentation/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/api-types/src/social.rs b/crates/api-types/src/social.rs index 94af24e0..d899710f 100644 --- a/crates/api-types/src/social.rs +++ b/crates/api-types/src/social.rs @@ -77,29 +77,3 @@ pub struct FollowRelationResponse { 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\"" - ); - } -} diff --git a/crates/api-types/src/users.rs b/crates/api-types/src/users.rs index 17f783c5..b1266671 100644 --- a/crates/api-types/src/users.rs +++ b/crates/api-types/src/users.rs @@ -126,6 +126,8 @@ pub struct UserProfileResponse { pub goals: Option>, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub is_federated: bool, + #[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")] @@ -153,6 +155,11 @@ pub struct UpdateProfileFieldsRequest { pub fields: Vec, } +#[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/api-types/tests/social_dto_test.rs b/crates/api-types/tests/social_dto_test.rs new file mode 100644 index 00000000..229b1195 --- /dev/null +++ b/crates/api-types/tests/social_dto_test.rs @@ -0,0 +1,21 @@ +use api_types::social::FollowStateDto; + +#[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/Cargo.toml b/crates/application/Cargo.toml index 50d8e29e..83ac9362 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -1,21 +1,20 @@ [package] name = "application" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] async-trait = { workspace = true } domain = { workspace = true } -infra-wiring = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } +config = { workspace = true } +uuid = { workspace = true, features = ["v4"] } +chrono = { workspace = true, features = ["clock"] } tracing = { workspace = true } futures = { workspace = true } -tokio = { workspace = true } +tokio = { workspace = true, features = ["rt", "sync", "time", "macros"] } sha2 = { workspace = true } rand = { workspace = true } hex = { workspace = true } -serde_json = { workspace = true } bytes = { workspace = true } [features] @@ -23,5 +22,5 @@ xlsx = [] federation = [] [dev-dependencies] -tokio = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread"] } domain = { workspace = true, features = ["test-helpers"] } 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/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..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(), )); @@ -32,7 +34,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/config.rs b/crates/application/src/config.rs index 38eb0b15..9ea9cedf 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 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: WrapUpSettings, +} + +#[derive(Clone)] +pub struct WrapUpSettings { + pub font_path: Option, + pub logo_path: Option, + pub bg_dir: Option, +} + +impl From<&::config::AppConfig> for AppSettings { + 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: WrapUpSettings::from(&config.wrapup), + } + } +} + +impl From<&::config::WrapUpConfig> for WrapUpSettings { + 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..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, @@ -150,13 +150,13 @@ 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`. /// -/// `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/application/src/diary/delete_review.rs b/crates/application/src/diary/delete_review.rs index 0b804236..e5573b46 100644 --- a/crates/application/src/diary/delete_review.rs +++ b/crates/application/src/diary/delete_review.rs @@ -20,39 +20,26 @@ pub async fn execute(deps: &DeleteReviewDeps, cmd: DeleteReviewCommand) -> Resul } let movie_id = review.movie_id().clone(); - deps.review.delete_review(&review_id).await?; + let poster_path = deps + .diary + .get_review_history(&movie_id) + .await? + .movie() + .poster_path() + .cloned(); - 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}"); - } - - 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, + deps.review + .delete_review_and_orphaned_movie( + &review_id, + &movie_id, + &[DomainEvent::ReviewDeleted { + review_id: review_id.clone(), + user_id: requesting_user_id, + }], + &[DomainEvent::MovieDeleted { + movie_id: movie_id.clone(), poster_path, - }) - .await - { - tracing::warn!("failed to publish MovieDeleted event: {e}"); - } - } - - Ok(()) + }], + ) + .await } - -#[cfg(test)] -#[path = "tests/delete_review.rs"] -mod tests; diff --git a/crates/application/src/diary/deps.rs b/crates/application/src/diary/deps.rs index 0309cd05..317af9ea 100644 --- a/crates/application/src/diary/deps.rs +++ b/crates/application/src/diary/deps.rs @@ -1,22 +1,19 @@ use std::sync::Arc; use domain::ports::{ - DiaryExporter, DiaryQuery, EventPublisher, FollowGraphQuery, MovieCommand, - MovieProfileRepository, MovieQuery, ReviewRepository, UserRepository, + DiaryExporter, DiaryQuery, FollowGraphQuery, MovieProfileRepository, MovieQuery, + ReviewRepository, UserRepository, }; -use crate::config::AppConfig; +use crate::config::AppSettings; pub struct DeleteReviewDeps { pub review: Arc, pub diary: Arc, - pub movie_command: Arc, - pub event_publisher: Arc, } pub struct EditReviewDeps { pub review: Arc, - pub event_publisher: Arc, } pub struct GetMovieSocialPageDeps { @@ -28,7 +25,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/src/diary/edit_review.rs b/crates/application/src/diary/edit_review.rs index c1d21770..9e6c7224 100644 --- a/crates/application/src/diary/edit_review.rs +++ b/crates/application/src/diary/edit_review.rs @@ -37,25 +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 } - -#[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..7e5ed39e 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( @@ -75,7 +68,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,33 +76,27 @@ impl ReviewLogger for DefaultReviewLogger { watched_at: *review.watched_at(), }; - 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 { + 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?; + + 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?; - 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(()) } } - -#[cfg(test)] -#[path = "tests/review_logger.rs"] -mod tests; diff --git a/crates/application/src/diary/tests/delete_review.rs b/crates/application/src/diary/tests/delete_review.rs deleted file mode 100644 index 3c36f9f2..00000000 --- a/crates/application/src/diary/tests/delete_review.rs +++ /dev/null @@ -1,112 +0,0 @@ -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}, -}; - -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(); - let reviews = InMemoryReviewRepository::new(); - let diary = FakeDiaryQuery::new(); - let events = NoopEventPublisher::new(); - - let movie = make_movie(); - let user_id = UserId::from_uuid(uuid::Uuid::new_v4()); - let review = make_review(movie.id().clone(), user_id.clone()); - - movies.upsert_movie(&movie).await.unwrap(); - reviews.save_review(&review).await.unwrap(); - diary.seed_history(movie.clone(), vec![]); - - let deps = DeleteReviewDeps { - review: Arc::clone(&reviews) 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!(reviews.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" - ); -} - -#[tokio::test] -async fn test_delete_review_wrong_user_is_unauthorized() { - let reviews = InMemoryReviewRepository::new(); - let diary = FakeDiaryQuery::new(); - let movies = InMemoryMovieRepository::new(); - let events = NoopEventPublisher::new(); - - 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); - - reviews.save_review(&review).await.unwrap(); - - let deps = DeleteReviewDeps { - review: Arc::clone(&reviews) as _, - diary: diary as _, - movie_command: movies as _, - event_publisher: Arc::clone(&events) as _, - }; - - let result = delete_review::execute( - &deps, - DeleteReviewCommand { - review_id: review.id().value(), - requesting_user_id: other_id, - }, - ) - .await; - - assert!(result.is_err(), "wrong user should not be able to delete"); - assert_eq!(reviews.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 deleted file mode 100644 index 37ace682..00000000 --- a/crates/application/src/diary/tests/edit_review.rs +++ /dev/null @@ -1,171 +0,0 @@ -use std::sync::Arc; - -use chrono::Utc; -use uuid::Uuid; - -use domain::{ - models::Review, - ports::ReviewRepository, - testing::{InMemoryReviewRepository, NoopEventPublisher}, - value_objects::{Comment, MovieId, Rating, 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, - ReviewId, - UserId, -) { - let reviews = InMemoryReviewRepository::new(); - let events = NoopEventPublisher::new(); - let user_id = UserId::generate(); - let review = make_review(user_id.clone()); - let review_id = review.id().clone(); - reviews.save_review(&review).await.unwrap(); - (reviews, events, review_id, user_id) -} - -fn deps( - reviews: &Arc, - events: &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; - - edit_review::execute( - &deps(&reviews, &events), - 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, - }, - ) - .await - .unwrap(); - - let updated = reviews.get_review_by_id(&review_id).await.unwrap().unwrap(); - assert_eq!(updated.rating().value(), 5); - assert_eq!(updated.comment().unwrap().value(), "original comment"); - assert_eq!(updated.watch_medium(), Some(&WatchMedium::Streaming)); -} - -#[tokio::test] -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(); - - assert!(matches!(err, domain::errors::DomainError::NotFound(_))); -} - -#[tokio::test] -async fn edit_other_users_review_returns_forbidden() { - let (reviews, events, review_id, _user_id) = setup().await; - - let err = edit_review::execute( - &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, - }, - ) - .await - .unwrap_err(); - - assert!(matches!(err, domain::errors::DomainError::Forbidden(_))); -} - -#[tokio::test] -async fn edit_publishes_review_updated_event() { - let (reviews, events, review_id, user_id) = setup().await; - - edit_review::execute( - &deps(&reviews, &events), - 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, - }, - ) - .await - .unwrap(); - - let published = events.published(); - assert_eq!(published.len(), 1); - assert!(matches!( - published[0], - domain::events::DomainEvent::ReviewUpdated { .. } - )); -} - -#[tokio::test] -async fn edit_sets_watch_medium() { - let (reviews, events, review_id, user_id) = setup().await; - - edit_review::execute( - &deps(&reviews, &events), - EditReviewCommand { - 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, - }, - ) - .await - .unwrap(); - - let updated = reviews.get_review_by_id(&review_id).await.unwrap().unwrap(); - assert_eq!(updated.watch_medium(), Some(&WatchMedium::Cinema)); -} diff --git a/crates/application/src/diary/tests/log_review.rs b/crates/application/src/diary/tests/log_review.rs deleted file mode 100644 index a4696a17..00000000 --- a/crates/application/src/diary/tests/log_review.rs +++ /dev/null @@ -1,131 +0,0 @@ -use std::sync::Arc; - -use chrono::Utc; - -use domain::{ - models::Movie, - ports::MovieCommand, - value_objects::{MovieTitle, ReleaseYear}, -}; - -use domain::testing::{InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher}; - -use crate::{ - diary::commands::{LogReviewCommand, MovieInput}, - diary::log_review, - diary::review_logger::DefaultReviewLogger, - test_helpers::TestContextBuilder, -}; - -fn build_logger( - movies: &Arc, - reviews: &Arc, - events: &Arc, -) -> Arc { - Arc::new(DefaultReviewLogger::new( - Arc::clone(movies) as _, - Arc::clone(movies) as _, - Arc::clone(reviews) as _, - TestContextBuilder::new().watchlist_repo, - Arc::new(domain::testing::FakeMetadataClient) as _, - Arc::clone(events) as _, - )) -} - -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(); - let reviews = InMemoryReviewRepository::new(); - let events = NoopEventPublisher::new(); - 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, - }; - - log_review::execute(&logger, cmd).await.unwrap(); - - assert_eq!(reviews.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 existing_movie = Movie::new( - None, - MovieTitle::new("Alien".into()).unwrap(), - ReleaseYear::new(1979).unwrap(), - None, - None, - ); - let movie_uuid = existing_movie.id().value(); - movies.upsert_movie(&existing_movie).await.unwrap(); - - let events = NoopEventPublisher::new(); - 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::execute(&logger, cmd).await.unwrap(); - - assert_eq!(movies.count(), 1, "no duplicate movie"); - assert_eq!(reviews.count(), 1); -} - -#[tokio::test] -async fn test_log_review_with_invalid_rating_fails() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); - let events = NoopEventPublisher::new(); - 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, - }; - let result = log_review::execute(&logger, cmd).await; - assert!(result.is_err(), "rating > 5 should fail"); -} diff --git a/crates/application/src/diary/tests/review_logger.rs b/crates/application/src/diary/tests/review_logger.rs deleted file mode 100644 index ac2ea4e7..00000000 --- a/crates/application/src/diary/tests/review_logger.rs +++ /dev/null @@ -1,324 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; -use chrono::Utc; -use domain::{ - errors::DomainError, - models::WatchlistEntry, - models::{MetadataSearchCriteria, Movie}, - ports::{MetadataClient, MovieCommand, WatchlistRepository}, - testing::{ - FakeMetadataClient, InMemoryMovieRepository, InMemoryReviewRepository, - InMemoryWatchlistRepository, NoopEventPublisher, - }, - value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear, UserId}, -}; -use uuid::Uuid; - -use crate::diary::commands::{LogReviewCommand, MovieInput}; -use crate::diary::review_logger::DefaultReviewLogger; -use crate::ports::ReviewLogger; - -fn make_logger( - movies: &Arc, - reviews: &Arc, - watchlist: &Arc, - events: &Arc, -) -> DefaultReviewLogger { - DefaultReviewLogger::new( - Arc::clone(movies) as _, - Arc::clone(movies) as _, - Arc::clone(reviews) as _, - Arc::clone(watchlist) as _, - Arc::new(FakeMetadataClient) as _, - Arc::clone(events) as _, - ) -} - -#[tokio::test] -async fn logs_review_with_manual_movie() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); - let events = NoopEventPublisher::new(); - 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, - }; - - logger.log_review(cmd).await.unwrap(); - - assert_eq!(movies.count(), 1); - assert_eq!(reviews.count(), 1); -} - -#[tokio::test] -async fn removes_from_watchlist_on_review() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); - let events = NoopEventPublisher::new(); - let logger = make_logger(&movies, &reviews, &watchlist, &events); - - let uid = Uuid::new_v4(); - 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_id = movie.id().value(); - movies.upsert_movie(&movie).await.unwrap(); - - // 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); - - // 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, - }; - - logger.log_review(cmd).await.unwrap(); - - assert_eq!(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 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_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, - }; - - logger.log_review(cmd).await.unwrap(); - - assert_eq!(movies.count(), 1); - assert_eq!(reviews.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 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, - }; - - assert!(logger.log_review(cmd).await.is_err()); -} - -#[tokio::test] -async fn invalid_rating_returns_error() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); - 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("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, - }; - - 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); -} - -#[tokio::test] -async fn watchlist_not_present_does_not_publish_removed() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); - 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, - }; - - logger.log_review(cmd).await.unwrap(); - - let published = events.published(); - assert!( - !published - .iter() - .any(|e| matches!(e, domain::events::DomainEvent::WatchlistEntryRemoved { .. })), - "should not publish WatchlistEntryRemoved when not on watchlist" - ); -} - -struct MetadataClientWithExternalId; - -#[async_trait] -impl MetadataClient for MetadataClientWithExternalId { - async fn fetch_movie_metadata( - &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, - )) - } - - async fn get_poster_url( - &self, - _external_metadata_id: &ExternalMetadataId, - ) -> Result, DomainError> { - Ok(None) - } -} - -#[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 events = NoopEventPublisher::new(); - - let logger = DefaultReviewLogger::new( - Arc::clone(&movies) as _, - Arc::clone(&movies) as _, - Arc::clone(&reviews) as _, - Arc::clone(&watchlist) as _, - Arc::new(MetadataClientWithExternalId) as _, - Arc::clone(&events) as _, - ); - - 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, - }; - - logger.log_review(cmd).await.unwrap(); - - let published = events.published(); - assert!( - published - .iter() - .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" - ); -} diff --git a/crates/application/src/goals/create.rs b/crates/application/src/goals/create.rs index e3c6f677..05a7815e 100644 --- a/crates/application/src/goals/create.rs +++ b/crates/application/src/goals/create.rs @@ -29,25 +29,17 @@ pub async fn execute( cmd.target_count, GoalType::Movies, )?; - deps.goal_command.save(&g).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?; + 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?; Ok(GoalWithProgress { goal: g, 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..41529167 100644 --- a/crates/application/src/goals/delete.rs +++ b/crates/application/src/goals/delete.rs @@ -11,19 +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(()) } - -#[cfg(test)] -#[path = "tests/delete.rs"] -mod tests; 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/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/tests/create.rs b/crates/application/src/goals/tests/create.rs deleted file mode 100644 index 7a9f9783..00000000 --- a/crates/application/src/goals/tests/create.rs +++ /dev/null @@ -1,170 +0,0 @@ -use std::sync::Arc; - -use domain::events::DomainEvent; -use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher}; -use uuid::Uuid; - -use crate::goals::deps::GoalCommandDeps; -use crate::goals::{commands::CreateGoalCommand, create}; -use crate::test_helpers::TestContextBuilder; - -#[tokio::test] -async fn creates_goal_and_returns_progress() { - let goals = InMemoryGoalRepository::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, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 50, - }, - ) - .await - .unwrap(); - - assert_eq!(result.goal.year(), 2025); - assert_eq!(result.goal.target_count(), 50); - assert_eq!(result.current_count, 0); - assert_eq!(goals.count(), 1); -} - -#[tokio::test] -async fn creates_goal_with_review_count() { - let goals = InMemoryGoalRepository::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, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 50, - }, - ) - .await - .unwrap(); - - assert_eq!(result.current_count, 5); - assert_eq!(goals.count(), 1); -} - -#[tokio::test] -async fn emits_goal_created_event() { - let b = TestContextBuilder::new(); - let events = NoopEventPublisher::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 _, - }; - - create::execute( - &deps, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 10, - }, - ) - .await - .unwrap(); - - let published = events.published(); - assert!( - published - .iter() - .any(|e| matches!(e, DomainEvent::GoalCreated { year: 2025, .. })) - ); -} - -#[tokio::test] -async fn rejects_duplicate_year() { - let b = TestContextBuilder::new(); - let deps = GoalCommandDeps { - 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 = CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 10, - }; - - create::execute(&deps, cmd).await.unwrap(); - - let result = create::execute( - &deps, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 20, - }, - ) - .await; - - assert!(result.is_err()); -} - -#[tokio::test] -async fn rejects_year_before_2020() { - let b = TestContextBuilder::new(); - let deps = GoalCommandDeps { - 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, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2019, - target_count: 10, - }, - ) - .await; - - assert!(result.is_err()); -} - -#[tokio::test] -async fn rejects_zero_target() { - let b = TestContextBuilder::new(); - let deps = GoalCommandDeps { - 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, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 0, - }, - ) - .await; - - assert!(result.is_err()); -} diff --git a/crates/application/src/goals/tests/delete.rs b/crates/application/src/goals/tests/delete.rs deleted file mode 100644 index 408cbdf2..00000000 --- a/crates/application/src/goals/tests/delete.rs +++ /dev/null @@ -1,69 +0,0 @@ -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::test_helpers::TestContextBuilder; - -#[tokio::test] -async fn deletes_existing_goal() { - let goals = InMemoryGoalRepository::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, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 10, - }, - ) - .await - .unwrap(); - assert_eq!(goals.count(), 1); - - delete::execute( - &deps, - DeleteGoalCommand { - user_id: Uuid::nil(), - year: 2025, - }, - ) - .await - .unwrap(); - - assert_eq!(goals.count(), 0); -} - -#[tokio::test] -async fn fails_when_not_found() { - let b = TestContextBuilder::new(); - let deps = GoalCommandDeps { - 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, - DeleteGoalCommand { - user_id: Uuid::nil(), - year: 2025, - }, - ) - .await; - - assert!(result.is_err()); -} diff --git a/crates/application/src/goals/tests/update.rs b/crates/application/src/goals/tests/update.rs deleted file mode 100644 index e973ee55..00000000 --- a/crates/application/src/goals/tests/update.rs +++ /dev/null @@ -1,99 +0,0 @@ -use uuid::Uuid; - -use crate::goals::deps::GoalCommandDeps; -use crate::goals::{ - commands::{CreateGoalCommand, UpdateGoalCommand}, - create, update, -}; -use crate::test_helpers::TestContextBuilder; - -#[tokio::test] -async fn updates_target_count() { - let b = TestContextBuilder::new(); - let deps = GoalCommandDeps { - 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, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 10, - }, - ) - .await - .unwrap(); - - let result = update::execute( - &deps, - UpdateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 100, - }, - ) - .await - .unwrap(); - - assert_eq!(result.goal.target_count(), 100); -} - -#[tokio::test] -async fn fails_when_goal_not_found() { - let b = TestContextBuilder::new(); - let deps = GoalCommandDeps { - 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, - UpdateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 10, - }, - ) - .await; - - assert!(result.is_err()); -} - -#[tokio::test] -async fn rejects_zero_target() { - let b = TestContextBuilder::new(); - let deps = GoalCommandDeps { - 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, - CreateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 10, - }, - ) - .await - .unwrap(); - - let result = update::execute( - &deps, - UpdateGoalCommand { - user_id: Uuid::nil(), - year: 2025, - target_count: 0, - }, - ) - .await; - - assert!(result.is_err()); -} diff --git a/crates/application/src/goals/update.rs b/crates/application/src/goals/update.rs index f91db407..52c2c5a9 100644 --- a/crates/application/src/goals/update.rs +++ b/crates/application/src/goals/update.rs @@ -17,25 +17,17 @@ 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 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?; + 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?; Ok(GoalWithProgress { goal: g, 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..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 @@ -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 6480cde3..f29bb51c 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())), } } @@ -87,24 +88,35 @@ pub async fn execute( }) } -#[cfg(test)] -#[path = "tests/execute.rs"] -mod tests; +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 { +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/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..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). @@ -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/import/tests/create_session.rs b/crates/application/src/import/tests/create_session.rs deleted file mode 100644 index bc7f3c66..00000000 --- a/crates/application/src/import/tests/create_session.rs +++ /dev/null @@ -1,33 +0,0 @@ -use std::sync::Arc; - -use uuid::Uuid; - -use domain::testing::InMemoryImportSessionRepository; - -use crate::import::deps::CreateSessionDeps; -use crate::import::{commands::CreateImportSessionCommand, create_session}; -use crate::test_helpers::TestContextBuilder; - -#[tokio::test] -async fn creates_session_with_parsed_file() { - let sessions = InMemoryImportSessionRepository::new(); - let b = TestContextBuilder::new(); - - let deps = CreateSessionDeps { - import_session: Arc::clone(&sessions) as _, - document_parser: b.document_parser.clone(), - }; - - let result = create_session::execute( - &deps, - CreateImportSessionCommand { - user_id: Uuid::new_v4(), - bytes: b"col1\nval1".to_vec(), - format: domain::models::FileFormat::Csv, - }, - ) - .await - .unwrap(); - - assert!(!result.columns.is_empty()); -} 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/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/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..7fc3755a 100644 --- a/crates/application/src/integrations/ingest.rs +++ b/crates/application/src/integrations/ingest.rs @@ -49,20 +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(()) } - -#[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/integrations/tests/ingest.rs b/crates/application/src/integrations/tests/ingest.rs deleted file mode 100644 index 04099b92..00000000 --- a/crates/application/src/integrations/tests/ingest.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::sync::Arc; - -use domain::models::WatchEventSource; -use domain::ports::{EventPublisher, WebhookTokenRepository}; -use domain::testing::{ - InMemoryWatchEventRepository, InMemoryWebhookTokenRepository, NoopEventPublisher, -}; -use uuid::Uuid; - -use crate::integrations::commands::{GenerateWebhookTokenCommand, IngestWatchEventCommand}; -use crate::integrations::deps::{GenerateWebhookTokenDeps, IngestWatchEventDeps}; -use crate::integrations::{generate_token, ingest}; - -struct FakeParser; - -impl domain::ports::MediaServerParser for FakeParser { - fn parse_playback_event( - &self, - _: &[u8], - ) -> Result, domain::errors::DomainError> { - Ok(Some(domain::models::ParsedPlaybackEvent { - title: "Test".into(), - year: Some(2024), - tmdb_id: None, - imdb_id: None, - })) - } -} - -#[tokio::test] -async fn ingests_watch_event() { - let tokens: Arc = InMemoryWebhookTokenRepository::new(); - let watch_events = InMemoryWatchEventRepository::new(); - let event_publisher: Arc = NoopEventPublisher::new(); - - let user_id = Uuid::new_v4(); - let generated = generate_token::execute( - &GenerateWebhookTokenDeps { - webhook_token: Arc::clone(&tokens), - }, - GenerateWebhookTokenCommand { - user_id, - provider: WatchEventSource::Jellyfin, - label: None, - }, - ) - .await - .unwrap(); - - let deps = IngestWatchEventDeps { - webhook_token: Arc::clone(&tokens), - 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( - &deps, - IngestWatchEventCommand { - token: generated.token_plaintext, - raw_payload: vec![], - source: WatchEventSource::Jellyfin, - }, - &FakeParser, - ) - .await; - - assert!(result.is_ok()); -} - -#[tokio::test] -async fn rejects_invalid_token() { - let tokens: Arc = InMemoryWebhookTokenRepository::new(); - let watch_events = InMemoryWatchEventRepository::new(); - let event_publisher: Arc = NoopEventPublisher::new(); - - let deps = IngestWatchEventDeps { - webhook_token: Arc::clone(&tokens), - 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( - &deps, - IngestWatchEventCommand { - token: "bad-token".into(), - raw_payload: vec![], - source: WatchEventSource::Jellyfin, - }, - &FakeParser, - ) - .await; - - assert!(result.is_err()); -} diff --git a/crates/application/src/jobs/mod.rs b/crates/application/src/jobs/mod.rs index 93fab650..34020b55 100644 --- a/crates/application/src/jobs/mod.rs +++ b/crates/application/src/jobs/mod.rs @@ -1,13 +1,17 @@ mod enrichment_staleness; mod import_cleanup; mod movie_dedup; +mod processed_events_cleanup; mod refresh_session_cleanup; +mod tombstone_cleanup; mod watch_event_cleanup; 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 tombstone_cleanup::{TOMBSTONE_RETENTION_DAYS, TombstoneCleanupJob}; 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..e85e0563 --- /dev/null +++ b/crates/application/src/jobs/processed_events_cleanup.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::{PeriodicJob, ProcessedEventLedger}, +}; + +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/jobs/tombstone_cleanup.rs b/crates/application/src/jobs/tombstone_cleanup.rs new file mode 100644 index 00000000..d8816136 --- /dev/null +++ b/crates/application/src/jobs/tombstone_cleanup.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::{AccountPurger, PeriodicJob}, +}; + +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/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/lib.rs b/crates/application/src/lib.rs index 76345c55..e3eaeca3 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -19,13 +19,6 @@ pub mod users; pub mod watchlist; pub mod wrapup; -#[cfg(test)] -pub mod test_helpers; - -#[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/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/enrich_movie.rs b/crates/application/src/movies/enrich_movie.rs index f95d728c..caa08302 100644 --- a/crates/application/src/movies/enrich_movie.rs +++ b/crates/application/src/movies/enrich_movie.rs @@ -8,15 +8,13 @@ use domain::{ use crate::movies::{commands::EnrichMovieCommand, deps::EnrichMovieDeps}; pub async fn execute(deps: &EnrichMovieDeps, cmd: EnrichMovieCommand) -> 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, skipping"); return Ok(()); }; @@ -37,10 +35,12 @@ pub async fn execute(deps: &EnrichMovieDeps, cmd: EnrichMovieCommand) -> Result< .await?; } + 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(()) } @@ -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/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/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/mod.rs b/crates/application/src/movies/mod.rs index 6ca52485..36287631 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_handler; 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_handler.rs similarity index 81% rename from crates/adapters/poster-sync/src/lib.rs rename to crates/application/src/movies/poster_sync_handler.rs index c7583013..e63323d5 100644 --- a/crates/adapters/poster-sync/src/lib.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(()) } @@ -106,12 +100,36 @@ 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 { movie_id, external_metadata_id, - } => (movie_id.value(), external_metadata_id.value().to_owned()), + } => { + 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| canonical.iter().any(|k| k == p.value())) + }) + .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/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/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/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/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/movies/sync_poster.rs b/crates/application/src/movies/sync_poster.rs index 99a75a47..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. @@ -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/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/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/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 b5d97f39..0b428bf5 100644 --- a/crates/application/src/social/execute.rs +++ b/crates/application/src/social/execute.rs @@ -3,64 +3,75 @@ use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; use super::{commands::SocialCmd, deps::SocialCommandDeps}; 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 + } } - -#[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..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,15 +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 } } - -#[cfg(test)] -#[path = "tests/local_service.rs"] -mod tests; diff --git a/crates/application/src/tests/worker.rs b/crates/application/src/tests/worker.rs deleted file mode 100644 index 2e156a7d..00000000 --- a/crates/application/src/tests/worker.rs +++ /dev/null @@ -1,313 +0,0 @@ -use super::*; -use async_trait::async_trait; -use domain::value_objects::{ExternalMetadataId, MovieId}; -use domain::{ - errors::DomainError, - events::{AckHandle, DomainEvent}, -}; -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>, -} - -#[async_trait] -impl AckHandle for TrackingAck { - async fn ack(&self) -> Result<(), DomainError> { - *self.acked.lock().unwrap() = true; - Ok(()) - } - async fn nack(&self) -> Result<(), DomainError> { - *self.nacked.lock().unwrap() = true; - Ok(()) - } -} - -struct VecConsumer { - events: Vec, -} - -impl EventConsumer for VecConsumer { - fn consume(&self) -> BoxStream<'_, Result> { - let envelopes: Vec> = self - .events - .iter() - .cloned() - .map(|e| Ok(EventEnvelope::new(e, Box::new(NoopAck)))) - .collect(); - Box::pin(stream::iter(envelopes)) - } -} - -struct RecordingHandler { - calls: Arc>>, -} - -#[async_trait] -impl EventHandler for RecordingHandler { - async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { - let label = match event { - DomainEvent::MovieDiscovered { .. } => "movie_discovered", - DomainEvent::ReviewLogged { .. } => "review_logged", - DomainEvent::ReviewUpdated { .. } => "review_updated", - DomainEvent::ReviewDeleted { .. } => "review_deleted", - DomainEvent::MovieDeleted { .. } => "movie_deleted", - DomainEvent::UserUpdated { .. } => "user_updated", - DomainEvent::MovieEnrichmentRequested { .. } => "movie_enrichment_requested", - DomainEvent::ImageStored { .. } => "image_stored", - DomainEvent::WatchlistEntryAdded { .. } | DomainEvent::WatchlistEntryRemoved { .. } => { - "watchlist" - } - DomainEvent::FollowRequested { .. } => "follow_requested", - DomainEvent::FollowAccepted { .. } => "follow_accepted", - DomainEvent::FollowRejected { .. } => "follow_rejected", - DomainEvent::Unfollowed { .. } => "unfollowed", - DomainEvent::FollowerRemoved { .. } => "follower_removed", - DomainEvent::ActorBlocked { .. } => "actor_blocked", - DomainEvent::ActorUnblocked { .. } => "actor_unblocked", - DomainEvent::BackfillFollower { .. } => "backfill_follower", - DomainEvent::FederationDeliveryRequested { .. } => "federation_delivery", - DomainEvent::WatchEventIngested { .. } => "watch_event_ingested", - DomainEvent::WrapUpRequested { .. } => "wrapup_requested", - DomainEvent::WrapUpCompleted { .. } => "wrapup_completed", - DomainEvent::SearchReindexRequested => "search_reindex", - DomainEvent::PosterSynced { .. } => "poster_synced", - DomainEvent::GoalCreated { .. } - | DomainEvent::GoalUpdated { .. } - | DomainEvent::GoalDeleted { .. } => "goal", - DomainEvent::PersonEnrichmentRequested { .. } => "person_enrichment_requested", - DomainEvent::UserDeleted { .. } | DomainEvent::UserAccountMoved { .. } => { - "user_lifecycle" - } - }; - self.calls.lock().unwrap().push(label); - Ok(()) - } -} - -fn movie_discovered() -> DomainEvent { - DomainEvent::MovieDiscovered { - movie_id: MovieId::generate(), - external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(), - } -} - -#[tokio::test] -async fn dispatches_to_all_handlers() { - let calls = Arc::new(Mutex::new(vec![])); - let consumer = VecConsumer { - events: vec![movie_discovered()], - }; - let handler = RecordingHandler { - calls: Arc::clone(&calls), - }; - - WorkerService::new(Arc::new(consumer), vec![Arc::new(handler)]) - .run(tokio::sync::watch::channel(false).1) - .await; - - assert_eq!(*calls.lock().unwrap(), vec!["movie_discovered"]); -} - -#[tokio::test] -async fn acks_when_all_handlers_succeed() { - let acked = Arc::new(Mutex::new(false)); - let nacked = Arc::new(Mutex::new(false)); - - struct TrackingConsumer { - event: DomainEvent, - acked: Arc>, - nacked: Arc>, - } - - impl EventConsumer for TrackingConsumer { - fn consume(&self) -> BoxStream<'_, Result> { - let envelope = EventEnvelope::new( - self.event.clone(), - Box::new(TrackingAck { - acked: Arc::clone(&self.acked), - nacked: Arc::clone(&self.nacked), - }), - ); - Box::pin(stream::iter(vec![Ok(envelope)])) - } - } - - let consumer = TrackingConsumer { - event: movie_discovered(), - acked: Arc::clone(&acked), - nacked: Arc::clone(&nacked), - }; - - WorkerService::new(Arc::new(consumer), vec![]) - .run(tokio::sync::watch::channel(false).1) - .await; - - assert!(*acked.lock().unwrap()); - assert!(!*nacked.lock().unwrap()); -} - -#[tokio::test] -async fn nacks_on_transient_handler_failure() { - let acked = Arc::new(Mutex::new(false)); - let nacked = Arc::new(Mutex::new(false)); - - struct TrackingConsumer { - event: DomainEvent, - acked: Arc>, - nacked: Arc>, - } - impl EventConsumer for TrackingConsumer { - fn consume(&self) -> BoxStream<'_, Result> { - let envelope = EventEnvelope::new( - self.event.clone(), - Box::new(TrackingAck { - acked: Arc::clone(&self.acked), - nacked: Arc::clone(&self.nacked), - }), - ); - Box::pin(stream::iter(vec![Ok(envelope)])) - } - } - struct TransientHandler; - #[async_trait] - impl EventHandler for TransientHandler { - async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> { - Err(DomainError::InfrastructureError("network timeout".into())) - } - } - - WorkerService::new( - Arc::new(TrackingConsumer { - event: movie_discovered(), - acked: Arc::clone(&acked), - nacked: Arc::clone(&nacked), - }), - vec![Arc::new(TransientHandler)], - ) - .run(tokio::sync::watch::channel(false).1) - .await; - - assert!(!*acked.lock().unwrap(), "should not ack on transient error"); - assert!(*nacked.lock().unwrap(), "should nack on transient error"); -} - -#[tokio::test] -async fn acks_on_permanent_handler_failure() { - let acked = Arc::new(Mutex::new(false)); - let nacked = Arc::new(Mutex::new(false)); - - struct TrackingConsumer { - event: DomainEvent, - acked: Arc>, - nacked: Arc>, - } - impl EventConsumer for TrackingConsumer { - fn consume(&self) -> BoxStream<'_, Result> { - let envelope = EventEnvelope::new( - self.event.clone(), - Box::new(TrackingAck { - acked: Arc::clone(&self.acked), - nacked: Arc::clone(&self.nacked), - }), - ); - Box::pin(stream::iter(vec![Ok(envelope)])) - } - } - struct PermanentHandler; - #[async_trait] - impl EventHandler for PermanentHandler { - async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> { - Err(DomainError::NotFound("movie not on tmdb".into())) - } - } - - WorkerService::new( - Arc::new(TrackingConsumer { - event: movie_discovered(), - acked: Arc::clone(&acked), - nacked: Arc::clone(&nacked), - }), - vec![Arc::new(PermanentHandler)], - ) - .run(tokio::sync::watch::channel(false).1) - .await; - - assert!(*acked.lock().unwrap(), "should ack on permanent error"); - assert!( - !*nacked.lock().unwrap(), - "should not nack on permanent error" - ); -} - -#[tokio::test] -async fn nacks_if_any_handler_is_transient() { - let acked = Arc::new(Mutex::new(false)); - let nacked = Arc::new(Mutex::new(false)); - - struct TrackingConsumer { - event: DomainEvent, - acked: Arc>, - nacked: Arc>, - } - impl EventConsumer for TrackingConsumer { - fn consume(&self) -> BoxStream<'_, Result> { - let envelope = EventEnvelope::new( - self.event.clone(), - Box::new(TrackingAck { - acked: Arc::clone(&self.acked), - nacked: Arc::clone(&self.nacked), - }), - ); - Box::pin(stream::iter(vec![Ok(envelope)])) - } - } - struct OkHandler; - #[async_trait] - impl EventHandler for OkHandler { - async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> { - Ok(()) - } - } - struct TransientHandler; - #[async_trait] - impl EventHandler for TransientHandler { - async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> { - Err(DomainError::InfrastructureError("db gone".into())) - } - } - - WorkerService::new( - Arc::new(TrackingConsumer { - event: movie_discovered(), - acked: Arc::clone(&acked), - nacked: Arc::clone(&nacked), - }), - vec![Arc::new(OkHandler), Arc::new(TransientHandler)], - ) - .run(tokio::sync::watch::channel(false).1) - .await; - - assert!( - !*acked.lock().unwrap(), - "should not ack when any handler is transient" - ); - assert!( - *nacked.lock().unwrap(), - "should nack when any handler is transient" - ); -} 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/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 4394599c..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 { @@ -58,7 +62,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/ensure_active.rs b/crates/application/src/users/ensure_active.rs new file mode 100644 index 00000000..03411f6c --- /dev/null +++ b/crates/application/src/users/ensure_active.rs @@ -0,0 +1,10 @@ +use domain::{errors::DomainError, value_objects::UserId}; + +use crate::users::deps::EnsureActiveDeps; + +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/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..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 @@ -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..df3f8c6e 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 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. @@ -38,7 +38,3 @@ pub async fn execute(deps: &GetPageViewerDeps, user_id: Uuid) -> Result, + 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(()); + }; + 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/src/users/request_account_deletion.rs b/crates/application/src/users/request_account_deletion.rs new file mode 100644 index 00000000..f91e26f2 --- /dev/null +++ b/crates/application/src/users/request_account_deletion.rs @@ -0,0 +1,62 @@ +use domain::{errors::DomainError, models::UserRole, value_objects::UserId}; + +use crate::users::deps::RequestAccountDeletionDeps; + +pub async fn execute( + deps: &RequestAccountDeletionDeps, + actor: UserId, + 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) + .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::InvalidCredentials("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/src/users/resolve_username_to_id.rs b/crates/application/src/users/resolve_username_to_id.rs index 223c21d6..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. @@ -17,7 +17,3 @@ pub async fn execute( let user = deps.user.find_by_username(username).await?; Ok(user.map(|u| u.id().clone())) } - -#[cfg(test)] -#[path = "tests/resolve_username_to_id.rs"] -mod tests; diff --git a/crates/application/src/users/tests/get_users.rs b/crates/application/src/users/tests/get_users.rs deleted file mode 100644 index a6281db3..00000000 --- a/crates/application/src/users/tests/get_users.rs +++ /dev/null @@ -1,17 +0,0 @@ -use crate::test_helpers::TestContextBuilder; -use crate::users::get_users; -use crate::users::queries::GetUsersQuery; - -#[tokio::test] -async fn returns_empty_when_no_users() { - let b = TestContextBuilder::new(); - let user = b.user_repo.clone(); - let social_query = b.social_query.clone(); - - let result = get_users::execute(user, social_query, GetUsersQuery) - .await - .unwrap(); - - assert!(result.users.is_empty()); - assert!(result.remote_actors.is_empty()); -} diff --git a/crates/application/src/users/update_profile.rs b/crates/application/src/users/update_profile.rs index ebfb04e6..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,28 +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(()) } - -#[cfg(test)] -#[path = "tests/update_profile.rs"] -mod tests; diff --git a/crates/application/src/users/update_profile_fields.rs b/crates/application/src/users/update_profile_fields.rs index 79296691..c1a5cd1d 100644 --- a/crates/application/src/users/update_profile_fields.rs +++ b/crates/application/src/users/update_profile_fields.rs @@ -11,13 +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 } - -#[cfg(test)] -#[path = "tests/update_profile_fields.rs"] -mod tests; diff --git a/crates/application/src/users/update_settings.rs b/crates/application/src/users/update_settings.rs index 1bb7371b..8f1bb1c1 100644 --- a/crates/application/src/users/update_settings.rs +++ b/crates/application/src/users/update_settings.rs @@ -20,7 +20,3 @@ pub async fn execute( settings.set_federate_watchlist(cmd.federate_watchlist); deps.user_settings.save(&settings).await } - -#[cfg(test)] -#[path = "tests/update_settings.rs"] -mod tests; diff --git a/crates/application/src/watchlist/add.rs b/crates/application/src/watchlist/add.rs index 3ef3867a..8d0d7432 100644 --- a/crates/application/src/watchlist/add.rs +++ b/crates/application/src/watchlist/add.rs @@ -18,28 +18,19 @@ pub async fn execute( deps.movie_command.as_ref(), deps.movie_query.as_ref(), deps.metadata.as_ref(), - deps.event_publisher.as_ref(), ) .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(()) } - -#[cfg(test)] -#[path = "tests/add.rs"] -mod tests; diff --git a/crates/application/src/watchlist/deps.rs b/crates/application/src/watchlist/deps.rs index acb4ef30..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 { @@ -29,5 +28,4 @@ pub struct IsOnWatchlistDeps { pub struct RemoveFromWatchlistDeps { pub watchlist: Arc, - pub event_publisher: Arc, } diff --git a/crates/application/src/watchlist/get.rs b/crates/application/src/watchlist/get.rs index 5b94bd86..6b38a13b 100644 --- a/crates/application/src/watchlist/get.rs +++ b/crates/application/src/watchlist/get.rs @@ -18,7 +18,3 @@ pub async fn execute( let page = PageParams::new(query.limit, query.offset)?; deps.watchlist.get_for_user(&user_id, &page).await } - -#[cfg(test)] -#[path = "tests/get.rs"] -mod tests; diff --git a/crates/application/src/watchlist/get_watchlist_for_owner.rs b/crates/application/src/watchlist/get_watchlist_for_owner.rs index 92f32c75..710c2b05 100644 --- a/crates/application/src/watchlist/get_watchlist_for_owner.rs +++ b/crates/application/src/watchlist/get_watchlist_for_owner.rs @@ -52,7 +52,3 @@ pub async fn execute( Ok(WatchlistView::Remote(entries)) } } - -#[cfg(test)] -#[path = "tests/get_watchlist_for_owner.rs"] -mod tests; diff --git a/crates/application/src/watchlist/is_on.rs b/crates/application/src/watchlist/is_on.rs index 4924af92..79de2799 100644 --- a/crates/application/src/watchlist/is_on.rs +++ b/crates/application/src/watchlist/is_on.rs @@ -14,7 +14,3 @@ pub async fn execute( let movie_id = MovieId::from_uuid(query.movie_id); deps.watchlist.contains(&user_id, &movie_id).await } - -#[cfg(test)] -#[path = "tests/is_on.rs"] -mod tests; diff --git a/crates/application/src/watchlist/remove.rs b/crates/application/src/watchlist/remove.rs index d8714870..7c0980e2 100644 --- a/crates/application/src/watchlist/remove.rs +++ b/crates/application/src/watchlist/remove.rs @@ -13,16 +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(()) } - -#[cfg(test)] -#[path = "tests/remove.rs"] -mod tests; diff --git a/crates/application/src/watchlist/tests/add.rs b/crates/application/src/watchlist/tests/add.rs deleted file mode 100644 index d8887132..00000000 --- a/crates/application/src/watchlist/tests/add.rs +++ /dev/null @@ -1,139 +0,0 @@ -use std::sync::Arc; - -use domain::{ - models::Movie, - ports::MovieCommand, - testing::{InMemoryMovieRepository, InMemoryWatchlistRepository, NoopEventPublisher}, - value_objects::{MovieTitle, ReleaseYear}, -}; - -use crate::{ - diary::commands::MovieInput, - watchlist::{add, commands::AddToWatchlistCommand, deps::WatchlistAddDeps}, -}; - -fn make_deps( - movies: Arc, - watchlist: Arc, -) -> WatchlistAddDeps { - WatchlistAddDeps { - movie_command: Arc::clone(&movies) as _, - movie_query: movies, - metadata: Arc::new(domain::testing::FakeMetadataClient), - watchlist, - event_publisher: NoopEventPublisher::new(), - } -} - -#[tokio::test] -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_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, - }, - }; - - add::execute(&deps, cmd).await.unwrap(); - - assert_eq!(watchlist.count(), 1); -} - -#[tokio::test] -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_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, - }, - }; - - add::execute(&deps, make_cmd()).await.unwrap(); - add::execute(&deps, make_cmd()).await.unwrap(); - - assert_eq!(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 deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist)); - - 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, - }, - }; - - add::execute(&deps, cmd).await.unwrap(); - - assert_eq!(watchlist.count(), 1); - assert_eq!(movies.count(), 1); -} - -#[tokio::test] -async fn test_add_to_watchlist_movie_not_found_by_id() { - let movies = InMemoryMovieRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); - - 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, - }, - }; - - assert!(add::execute(&deps, cmd).await.is_err()); -} diff --git a/crates/application/src/watchlist/tests/remove.rs b/crates/application/src/watchlist/tests/remove.rs deleted file mode 100644 index 0a58d27d..00000000 --- a/crates/application/src/watchlist/tests/remove.rs +++ /dev/null @@ -1,69 +0,0 @@ -use std::sync::Arc; - -use domain::events::DomainEvent; -use domain::models::WatchlistEntry; -use domain::ports::WatchlistRepository; -use domain::testing::{InMemoryWatchlistRepository, NoopEventPublisher}; -use domain::value_objects::{MovieId, UserId}; -use uuid::Uuid; - -use crate::test_helpers::TestContextBuilder; -use crate::watchlist::{ - commands::RemoveFromWatchlistCommand, deps::RemoveFromWatchlistDeps, remove, -}; - -#[tokio::test] -async fn removes_entry_and_emits_event() { - let watchlist = InMemoryWatchlistRepository::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), - )) - .await - .unwrap(); - - let deps = RemoveFromWatchlistDeps { - watchlist: Arc::clone(&watchlist) as _, - event_publisher: Arc::clone(&events) as _, - }; - remove::execute( - &deps, - RemoveFromWatchlistCommand { - user_id: uid, - movie_id: mid, - }, - ) - .await - .unwrap(); - - assert_eq!(watchlist.count(), 0); - let published = events.published(); - assert!( - published - .iter() - .any(|e| matches!(e, DomainEvent::WatchlistEntryRemoved { .. })) - ); -} - -#[tokio::test] -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, - RemoveFromWatchlistCommand { - user_id: Uuid::new_v4(), - movie_id: Uuid::new_v4(), - }, - ) - .await; - - assert!(result.is_err()); -} diff --git a/crates/application/src/worker.rs b/crates/application/src/worker.rs index 7daae4bc..6279ab7d 100644 --- a/crates/application/src/worker.rs +++ b/crates/application/src/worker.rs @@ -1,8 +1,10 @@ use std::sync::Arc; +use std::time::Duration; use domain::{ + errors::DomainError, events::EventEnvelope, - ports::{EventConsumer, EventHandler}, + ports::{Begin, EventConsumer, EventHandler, ProcessedEventLedger}, }; use futures::StreamExt; use tokio::sync::Semaphore; @@ -12,20 +14,40 @@ 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 { + 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 +65,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,19 +87,73 @@ impl WorkerService { } } -async fn dispatch(handlers: Arc>>, envelope: EventEnvelope) { +const DEFER_MARGIN: Duration = Duration::from_secs(5); + +async fn dispatch( + handlers: Arc>>, + ledger: Arc, + envelope: EventEnvelope, +) { + let id = envelope.id().copied(); let mut any_transient = false; + let mut blocked_for: Option = None; 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}"); - any_transient = true; - } else { - tracing::warn!("permanent handler error (not retrying): {e}"); + let name = handler.name(); + let mut claim = None; + if let Some(id) = &id { + match ledger.begin(id, name).await { + Ok(Begin::Run(token)) => claim = Some(token), + Ok(Begin::AlreadyDone) => { + tracing::debug!(handler = name, "already processed, skipping"); + continue; + } + 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) => { + tracing::warn!(handler = name, "ledger claim failed, nacking delivery: {e}"); + any_transient = true; + continue; + } + } + } + let result = handler.handle(&envelope.event).await; + match &result { + Ok(()) => { + if let (Some(id), Some(token)) = (&id, &claim) + && let Err(e) = ledger.complete(id, name, token).await + { + tracing::warn!(handler = name, "completing ledger claim failed: {e}"); + } + } + Err(e) => { + 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}"); + } + if e.is_transient() { + tracing::warn!( + handler = name, + "transient handler error, nacking delivery (the backend logs if attempts are exhausted): {e}" + ); + any_transient = true; + } else { + tracing::warn!( + handler = name, + "permanent handler error (not retrying): {e}" + ); + } } } } - if any_transient { + 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}"); } @@ -84,7 +161,3 @@ async fn dispatch(handlers: Arc>>, 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/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 351b7693..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)), @@ -32,6 +30,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/src/wrapup/generate.rs b/crates/application/src/wrapup/generate.rs index 01dd5d1c..2f136bc9 100644 --- a/crates/application/src/wrapup/generate.rs +++ b/crates/application/src/wrapup/generate.rs @@ -46,20 +46,17 @@ 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) } - -#[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..6709fff9 100644 --- a/crates/application/src/wrapup/handle_requested.rs +++ b/crates/application/src/wrapup/handle_requested.rs @@ -12,13 +12,9 @@ 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(()); } @@ -37,10 +33,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(()) } @@ -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 58% rename from crates/application/src/auth/tests/login.rs rename to crates/application/tests/auth/login_test.rs index c60fd40a..492bdbd9 100644 --- a/crates/application/src/auth/tests/login.rs +++ b/crates/application/tests/auth/login_test.rs @@ -1,17 +1,13 @@ +use crate::support::TestContextBuilder; +use crate::support::{login_command, register_command}; use std::sync::Arc; -use domain::models::UserRole; -use domain::testing::InMemoryUserRepository; +use domain::testing::InMemoryStore; -use crate::{ - auth::{ - commands::RegisterCommand, - deps::{LoginDeps, RegisterDeps}, - login, - queries::LoginCommand, - register, - }, - test_helpers::TestContextBuilder, +use application::auth::{ + commands::RegisterCommand, + deps::{LoginDeps, RegisterDeps}, + login, register, }; async fn setup_user(b: &TestContextBuilder, email: &str, password: &str) { @@ -23,10 +19,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 @@ -34,8 +28,8 @@ async fn setup_user(b: &TestContextBuilder, email: &str, password: &str) { } #[tokio::test] -async fn test_login_valid_credentials_returns_token() { - let users = InMemoryUserRepository::new(); +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; @@ -46,15 +40,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()); @@ -62,8 +50,8 @@ async fn test_login_valid_credentials_returns_token() { } #[tokio::test] -async fn test_login_wrong_password_fails() { - let users = InMemoryUserRepository::new(); +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; @@ -74,20 +62,13 @@ 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()); } #[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(), @@ -96,14 +77,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/tests/auth/logout_test.rs similarity index 61% rename from crates/application/src/auth/tests/logout.rs rename to crates/application/tests/auth/logout_test.rs index ed4a6ff2..fec5de05 100644 --- a/crates/application/src/auth/tests/logout.rs +++ b/crates/application/tests/auth/logout_test.rs @@ -1,22 +1,17 @@ +use crate::support::TestContextBuilder; +use crate::support::{login_command, register_command}; use std::sync::Arc; -use domain::models::UserRole; -use domain::testing::InMemoryUserRepository; +use domain::testing::InMemoryStore; -use crate::{ - auth::{ - commands::RegisterCommand, - deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterDeps}, - login, logout, - queries::LoginCommand, - refresh, register, - }, - test_helpers::TestContextBuilder, +use application::auth::{ + deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterDeps}, + login, logout, refresh, register, }; #[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 { @@ -24,17 +19,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 +30,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/tests/auth/refresh_test.rs similarity index 75% rename from crates/application/src/auth/tests/refresh.rs rename to crates/application/tests/auth/refresh_test.rs index 09191959..7161731f 100644 --- a/crates/application/src/auth/tests/refresh.rs +++ b/crates/application/tests/auth/refresh_test.rs @@ -1,17 +1,12 @@ +use crate::support::TestContextBuilder; +use crate::support::{login_command, register_command}; use std::sync::Arc; -use domain::models::UserRole; -use domain::testing::InMemoryUserRepository; +use domain::testing::InMemoryStore; -use crate::{ - auth::{ - commands::RegisterCommand, - deps::{LoginDeps, RefreshDeps, RegisterDeps}, - login, - queries::LoginCommand, - refresh, register, - }, - test_helpers::TestContextBuilder, +use application::auth::{ + deps::{LoginDeps, RefreshDeps, RegisterDeps}, + login, refresh, register, }; async fn login_user(b: &TestContextBuilder) -> login::LoginResult { @@ -20,17 +15,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 +28,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() @@ -52,7 +36,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; @@ -72,7 +56,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(); @@ -90,7 +74,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_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 54% rename from crates/application/src/auth/tests/register.rs rename to crates/application/tests/auth/register_test.rs index c67c7f96..cda0b887 100644 --- a/crates/application/src/auth/tests/register.rs +++ b/crates/application/tests/auth/register_test.rs @@ -1,27 +1,20 @@ +use crate::support::TestContextBuilder; +use crate::support::register_command; use std::sync::Arc; -use domain::models::UserRole; use domain::ports::UserRepository; -use domain::testing::InMemoryUserRepository; +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 { - RegisterCommand { - email: email.to_string(), - username: "alice".to_string(), - password: "password123".to_string(), - role: UserRole::Standard, - } + register_command(email, "alice") } #[tokio::test] -async fn test_register_creates_user() { - let users = InMemoryUserRepository::new(); +async fn registering_creates_the_user() { + let users = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); let deps = RegisterDeps { user: b.user_repo.clone(), @@ -40,8 +33,8 @@ async fn test_register_creates_user() { } #[tokio::test] -async fn test_register_duplicate_email_fails() { - let users = InMemoryUserRepository::new(); +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 { user: b.user_repo.clone(), @@ -57,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(), @@ -67,10 +60,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; @@ -80,3 +71,33 @@ async fn test_register_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(); + 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/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/tests/diary/delete_review_test.rs b/crates/application/tests/diary/delete_review_test.rs new file mode 100644 index 00000000..f6133104 --- /dev/null +++ b/crates/application/tests/diary/delete_review_test.rs @@ -0,0 +1,249 @@ +use domain::testing::{test_movie_with_year, test_review}; +use std::sync::Arc; + +use domain::{ + ports::{MovieCommand, MovieQuery, ReviewRepository}, + testing::{FakeDiaryQuery, InMemoryStore}, + value_objects::{MovieId, UserId}, +}; + +use application::{ + diary::commands::DeleteReviewCommand, diary::delete_review, diary::deps::DeleteReviewDeps, +}; + +#[tokio::test] +async fn deleting_a_review_removes_it() { + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + 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(); + reviews.save_review(&review, &[]).await.unwrap(); + diary.seed_history(movie.clone(), vec![]); + + let deps = DeleteReviewDeps { + review: Arc::clone(&reviews) as _, + diary: diary.clone() as _, + }; + + delete_review::execute( + &deps, + DeleteReviewCommand { + review_id: review.id().value(), + requesting_user_id: user_id.value(), + }, + ) + .await + .unwrap(); + + 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" + ); +} + +#[tokio::test] +async fn deleting_another_users_review_is_unauthorized() { + let reviews = InMemoryStore::new(); + let diary = FakeDiaryQuery::new(); + + 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 = test_review(&movie_id, &owner_id, 4); + + reviews.save_review(&review, &[]).await.unwrap(); + + let deps = DeleteReviewDeps { + review: Arc::clone(&reviews) as _, + diary: diary as _, + }; + + let result = delete_review::execute( + &deps, + DeleteReviewCommand { + review_id: review.id().value(), + requesting_user_id: other_id, + }, + ) + .await; + + 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 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(); + 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 _, + }; + + delete_review::execute( + &deps, + DeleteReviewCommand { + review_id: review.id().value(), + requesting_user_id: user_id.value(), + }, + ) + .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::ReviewDeleted { .. } + )); +} + +#[tokio::test] +async fn a_refused_delete_propagates_and_records_nothing() { + let movies = InMemoryStore::new(); + let diary = FakeDiaryQuery::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(); + 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 _, + }; + + 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()); +} + +#[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 _, + }; + + 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 _, + }; + + 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_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); + 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::new(crate::edit_review_test::WriteRefusingReviews(Arc::clone( + &movies, + ))), + diary: diary 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.get_movie_by_id(movie.id()).await.unwrap().is_some()); + 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 new file mode 100644 index 00000000..328ddb69 --- /dev/null +++ b/crates/application/tests/diary/edit_review_test.rs @@ -0,0 +1,219 @@ +use crate::support::edit_review_command; +use std::sync::Arc; + +use domain::{ + models::ReviewEdit, + ports::ReviewRepository, + testing::{InMemoryStore, test_review}, + value_objects::{Comment, MovieId, ReviewId, UserId, WatchMedium}, +}; + +use application::diary::{commands::EditReviewCommand, deps::EditReviewDeps, edit_review}; + +async fn setup() -> (Arc, ReviewId, UserId) { + let reviews = InMemoryStore::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())), + watch_medium: Some(Some(WatchMedium::Streaming)), + ..Default::default() + }); + let review_id = review.id().clone(); + reviews.save_review(&review, &[]).await.unwrap(); + (reviews, review_id, user_id) +} + +fn deps(reviews: &Arc) -> EditReviewDeps { + EditReviewDeps { + review: Arc::clone(reviews) as _, + } +} + +#[tokio::test] +async fn edit_own_review_updates_rating() { + let (reviews, review_id, user_id) = setup().await; + + edit_review::execute( + &deps(&reviews), + EditReviewCommand { + review_id: review_id.value(), + requesting_user_id: user_id.value(), + ..edit_review_command(5) + }, + ) + .await + .unwrap(); + + let updated = reviews.get_review_by_id(&review_id).await.unwrap().unwrap(); + assert_eq!(updated.rating().value(), 5); + assert_eq!(updated.comment().unwrap().value(), "original comment"); + assert_eq!(updated.watch_medium(), Some(&WatchMedium::Streaming)); +} + +#[tokio::test] +async fn edit_nonexistent_review_returns_not_found() { + let reviews = InMemoryStore::new(); + + let err = edit_review::execute(&deps(&reviews), edit_review_command(5)) + .await + .unwrap_err(); + + assert!(matches!(err, domain::errors::DomainError::NotFound(_))); +} + +#[tokio::test] +async fn edit_other_users_review_returns_forbidden() { + let (reviews, review_id, _user_id) = setup().await; + + let err = edit_review::execute( + &deps(&reviews), + EditReviewCommand { + review_id: review_id.value(), + ..edit_review_command(5) + }, + ) + .await + .unwrap_err(); + + assert!(matches!(err, domain::errors::DomainError::Forbidden(_))); +} + +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_and_orphaned_movie( + &self, + _: &ReviewId, + _: &domain::value_objects::MovieId, + _: &[domain::events::DomainEvent], + _: &[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 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), + EditReviewCommand { + review_id: review_id.value(), + requesting_user_id: user_id.value(), + ..edit_review_command(1) + }, + ) + .await + .unwrap(); + + 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!( + recorded[0], + domain::events::DomainEvent::ReviewUpdated { .. } + )); +} + +#[tokio::test] +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))), + }; + + 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, review_id, user_id) = setup().await; + + edit_review::execute( + &deps(&reviews), + EditReviewCommand { + rating: None, + review_id: review_id.value(), + requesting_user_id: user_id.value(), + watch_medium: Some(Some(WatchMedium::Cinema)), + ..edit_review_command(1) + }, + ) + .await + .unwrap(); + + 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_to_load_it() { + let (_, review_id, user_id) = setup().await; + let deps = EditReviewDeps { + review: Arc::new(domain::testing::RefusingReviewRepository), + }; + + 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/get_activity_feed.rs b/crates/application/tests/diary/get_activity_feed_test.rs similarity index 91% rename from crates/application/src/diary/tests/get_activity_feed.rs rename to crates/application/tests/diary/get_activity_feed_test.rs index 89471eea..5d53f440 100644 --- a/crates/application/src/diary/tests/get_activity_feed.rs +++ b/crates/application/tests/diary/get_activity_feed_test.rs @@ -1,19 +1,20 @@ +use crate::support::TestContextBuilder; 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::{ - config::AppConfig, diary::deps::GetActivityFeedDeps, diary::get_activity_feed, - diary::queries::GetActivityFeedQuery, test_helpers::TestContextBuilder, +use application::{ + config::AppSettings, diary::deps::GetActivityFeedDeps, diary::get_activity_feed, + diary::queries::GetActivityFeedQuery, }; 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, } } @@ -124,12 +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, - wrapup: crate::config::WrapUpConfig { + secure_cookies: false, + cors_origins: String::new(), + wrapup: application::config::WrapUpSettings { 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 65% 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 c3780679..0742c0e0 100644 --- a/crates/application/src/diary/tests/get_movie_social_page.rs +++ b/crates/application/tests/diary/get_movie_social_page_test.rs @@ -1,15 +1,14 @@ +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}, + testing::{FakeDiaryQuery, InMemoryStore}, }; -use crate::{ +use application::{ diary::deps::GetMovieSocialPageDeps, diary::get_movie_social_page, diary::queries::GetMovieSocialPageQuery, }; @@ -17,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( @@ -37,22 +36,16 @@ 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 = 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(); + movies.upsert_movie(&movie, &[]).await.unwrap(); 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/get_review_history.rs b/crates/application/tests/diary/get_review_history_test.rs similarity index 66% rename from crates/application/src/diary/tests/get_review_history.rs rename to crates/application/tests/diary/get_review_history_test.rs index b0131a7c..774826b9 100644 --- a/crates/application/src/diary/tests/get_review_history.rs +++ b/crates/application/tests/diary/get_review_history_test.rs @@ -1,26 +1,16 @@ +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::{ +use application::{ diary::deps::GetReviewHistoryDeps, diary::get_review_history, diary::queries::GetReviewHistoryQuery, }; #[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/tests/diary/get_user_feed_test.rs similarity index 60% rename from crates/application/src/diary/tests/get_user_feed.rs rename to crates/application/tests/diary/get_user_feed_test.rs index 76e06879..7b890a9c 100644 --- a/crates/application/src/diary/tests/get_user_feed.rs +++ b/crates/application/tests/diary/get_user_feed_test.rs @@ -1,18 +1,19 @@ +use crate::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; -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 { @@ -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/tests/diary/log_review_test.rs b/crates/application/tests/diary/log_review_test.rs new file mode 100644 index 00000000..95b2b364 --- /dev/null +++ b/crates/application/tests/diary/log_review_test.rs @@ -0,0 +1,79 @@ +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; + +use domain::ports::MovieCommand; + +use domain::testing::InMemoryStore; + +use application::{ + diary::commands::LogReviewCommand, diary::log_review, diary::review_logger::DefaultReviewLogger, +}; + +fn build_logger( + movies: &Arc, + reviews: &Arc, +) -> Arc { + Arc::new(DefaultReviewLogger::new( + Arc::clone(movies) as _, + Arc::clone(movies) as _, + Arc::clone(reviews) as _, + TestContextBuilder::new().watchlist_repo, + Arc::new(domain::testing::FakeMetadataClient) as _, + )) +} + +#[tokio::test] +async fn logging_a_review_creates_the_movie_and_the_review() { + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + let logger = build_logger(&movies, &reviews); + + let user_id = uuid::Uuid::new_v4(); + let cmd = log_review_command_manual(user_id, "Blade Runner", 1982); + + log_review::execute(&logger, cmd).await.unwrap(); + + assert_eq!(reviews.review_count(), 1, "review should be saved"); + assert!( + !movies.recorded_events().is_empty(), + "events should be recorded with the review" + ); +} + +#[tokio::test] +async fn logging_a_review_reuses_an_existing_movie() { + 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(); + movies.upsert_movie(&existing_movie, &[]).await.unwrap(); + + let logger = build_logger(&movies, &reviews); + + let cmd = LogReviewCommand { + rating: 5, + ..log_review_command(uuid::Uuid::new_v4(), movie_uuid) + }; + + log_review::execute(&logger, cmd).await.unwrap(); + + assert_eq!(movies.movie_count(), 1, "no duplicate movie"); + assert_eq!(reviews.review_count(), 1); +} + +#[tokio::test] +async fn logging_a_review_with_an_invalid_rating_fails() { + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + let logger = build_logger(&movies, &reviews); + + let cmd = LogReviewCommand { + rating: 6, + ..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/tests/diary/movie_resolver_test.rs similarity index 96% rename from crates/application/src/diary/tests/movie_resolver.rs rename to crates/application/tests/diary/movie_resolver_test.rs index b9416a83..53b38482 100644 --- a/crates/application/src/diary/tests/movie_resolver.rs +++ b/crates/application/tests/diary/movie_resolver_test.rs @@ -1,5 +1,7 @@ -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, models::{MetadataSearchCriteria, Movie}, @@ -17,16 +19,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 +205,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 +219,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 +260,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 +301,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 +357,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/tests/diary/review_logger_test.rs b/crates/application/tests/diary/review_logger_test.rs new file mode 100644 index 00000000..af524b7d --- /dev/null +++ b/crates/application/tests/diary/review_logger_test.rs @@ -0,0 +1,306 @@ +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; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::WatchlistEntry, + models::{MetadataSearchCriteria, Movie}, + ports::{MetadataClient, MovieCommand, WatchlistRepository}, + testing::{FakeMetadataClient, InMemoryStore}, + value_objects::{ExternalMetadataId, MovieId, PosterUrl, UserId}, +}; +use uuid::Uuid; + +use application::diary::commands::LogReviewCommand; +use application::diary::review_logger::DefaultReviewLogger; +use application::ports::ReviewLogger; + +fn make_logger( + movies: &Arc, + reviews: &Arc, + watchlist: &Arc, +) -> DefaultReviewLogger { + DefaultReviewLogger::new( + Arc::clone(movies) as _, + Arc::clone(movies) as _, + Arc::clone(reviews) as _, + Arc::clone(watchlist) as _, + Arc::new(FakeMetadataClient) as _, + ) +} + +#[tokio::test] +async fn logs_review_with_manual_movie() { + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + let watchlist = Arc::clone(&movies); + let logger = make_logger(&movies, &reviews, &watchlist); + + let uid = Uuid::new_v4(); + let cmd = log_review_command_manual(uid, "Test Film", 2024); + + logger.log_review(cmd).await.unwrap(); + + assert_eq!(movies.movie_count(), 1); + assert_eq!(reviews.review_count(), 1); +} + +#[tokio::test] +async fn removes_from_watchlist_on_review() { + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + let watchlist = Arc::clone(&movies); + let logger = make_logger(&movies, &reviews, &watchlist); + + let uid = Uuid::new_v4(); + let user_id = UserId::from_uuid(uid); + + // 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(); + + // Add to watchlist + let entry = WatchlistEntry::new(user_id.clone(), MovieId::from_uuid(movie_id)); + watchlist.add(&entry, &[]).await.unwrap(); + assert_eq!(watchlist.watchlist_count(), 1); + + // Log review for same movie + let cmd = LogReviewCommand { + rating: 5, + ..log_review_command(uid, movie_id) + }; + + 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" + ); +} + +#[tokio::test] +async fn logs_review_with_existing_movie_by_id() { + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + let watchlist = Arc::clone(&movies); + 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(); + + let cmd = LogReviewCommand { + rating: 3, + ..log_review_command(Uuid::new_v4(), movie_uuid) + }; + + logger.log_review(cmd).await.unwrap(); + + assert_eq!(movies.movie_count(), 1); + assert_eq!(reviews.review_count(), 1); +} + +#[tokio::test] +async fn existing_movie_not_found_returns_error() { + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + let watchlist = Arc::clone(&movies); + let logger = make_logger(&movies, &reviews, &watchlist); + + let cmd = log_review_command(Uuid::new_v4(), Uuid::new_v4()); + + assert!(logger.log_review(cmd).await.is_err()); +} + +#[tokio::test] +async fn invalid_rating_returns_error() { + let movies = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + let watchlist = Arc::clone(&movies); + let logger = make_logger(&movies, &reviews, &watchlist); + + let cmd = LogReviewCommand { + rating: 6, + ..log_review_command_manual(Uuid::new_v4(), "Film", 2024) + }; + + let result = logger.log_review(cmd).await; + assert!(result.is_err()); + // No repo calls should have happened + 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 = InMemoryStore::new(); + let reviews = Arc::clone(&movies); + let watchlist = Arc::clone(&movies); + let logger = make_logger(&movies, &reviews, &watchlist); + + let cmd = log_review_command_manual(Uuid::new_v4(), "No Watchlist Film", 2024); + + logger.log_review(cmd).await.unwrap(); + + let published = movies.recorded_events(); + assert!( + !published + .iter() + .any(|e| matches!(e, domain::events::DomainEvent::WatchlistEntryRemoved { .. })), + "should not publish WatchlistEntryRemoved when not on watchlist" + ); +} + +struct MetadataClientWithExternalId; + +#[async_trait] +impl MetadataClient for MetadataClientWithExternalId { + async fn fetch_movie_metadata( + &self, + _criteria: &MetadataSearchCriteria, + ) -> Result { + Ok(test_movie_with_external_id( + "Discovered Film", + 2024, + ExternalMetadataId::new("tmdb:99999".into()).unwrap(), + )) + } + + async fn get_poster_url( + &self, + _external_metadata_id: &ExternalMetadataId, + ) -> Result, DomainError> { + Ok(None) + } +} + +#[tokio::test] +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 logger = DefaultReviewLogger::new( + Arc::clone(&movies) as _, + Arc::clone(&movies) as _, + Arc::clone(&reviews) as _, + Arc::clone(&watchlist) as _, + Arc::new(MetadataClientWithExternalId) as _, + ); + + let cmd = LogReviewCommand { + rating: 5, + ..log_review_command_manual(Uuid::new_v4(), "Discovered Film", 2024) + }; + + logger.log_review(cmd).await.unwrap(); + + let published = movies.recorded_events(); + assert!( + published + .iter() + .any(|e| matches!(e, domain::events::DomainEvent::MovieDiscovered { .. })), + "MovieDiscovered must be recorded with the movie write" + ); +} + +#[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), + ); + + 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_saves_the_review_and_its_event_together() { + 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), + ); + + 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_propagates_and_records_nothing() { + 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), + ); + + let result = logger + .log_review(log_review_command_manual(Uuid::new_v4(), "Test Film", 2024)) + .await; + + assert!(result.is_err()); + 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), + ); + + 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/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/tests/goals/create_test.rs b/crates/application/tests/goals/create_test.rs new file mode 100644 index 00000000..38a3b3a8 --- /dev/null +++ b/crates/application/tests/goals/create_test.rs @@ -0,0 +1,135 @@ +use crate::support::create_goal_command; +use std::sync::Arc; + +use domain::errors::DomainError; +use domain::events::DomainEvent; +use domain::testing::{FakeStatsRepository, InMemoryStore, RefusingGoalCommand}; +use uuid::Uuid; + +use crate::support::TestContextBuilder; +use application::goals::create; +use application::goals::deps::GoalCommandDeps; + +#[tokio::test] +async fn creates_goal_and_returns_progress() { + let goals = InMemoryStore::new(); + let stats = FakeStatsRepository::new(); + let deps = GoalCommandDeps { + goal_command: Arc::clone(&goals) as _, + goal_query: Arc::clone(&goals) as _, + stats: Arc::clone(&stats) as _, + }; + + 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); + assert_eq!(result.current_count, 0); + assert_eq!(goals.goal_count(), 1); +} + +#[tokio::test] +async fn creates_goal_with_review_count() { + let goals = InMemoryStore::new(); + let stats = FakeStatsRepository::new(); + stats.set_review_count(Uuid::nil(), 2025, 5); + let deps = GoalCommandDeps { + goal_command: Arc::clone(&goals) as _, + goal_query: Arc::clone(&goals) as _, + stats: Arc::clone(&stats) as _, + }; + + let result = create::execute(&deps, create_goal_command(2025, 50)) + .await + .unwrap(); + + assert_eq!(result.current_count, 5); + assert_eq!(goals.goal_count(), 1); +} + +#[tokio::test] +async fn creating_a_goal_records_the_goal_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(); + + 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] +async fn rejects_duplicate_year() { + let b = TestContextBuilder::new(); + let deps = GoalCommandDeps { + goal_command: b.goal_command.clone(), + goal_query: b.goal_query.clone(), + stats: b.stats_repo.clone(), + }; + let cmd = create_goal_command(2025, 10); + + create::execute(&deps, cmd).await.unwrap(); + + let result = create::execute(&deps, create_goal_command(2025, 20)).await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn rejects_year_before_2020() { + let b = TestContextBuilder::new(); + let deps = GoalCommandDeps { + goal_command: b.goal_command.clone(), + goal_query: b.goal_query.clone(), + stats: b.stats_repo.clone(), + }; + let result = create::execute(&deps, create_goal_command(2019, 10)).await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn rejects_zero_target() { + let b = TestContextBuilder::new(); + let deps = GoalCommandDeps { + goal_command: b.goal_command.clone(), + goal_query: b.goal_query.clone(), + stats: b.stats_repo.clone(), + }; + let result = create::execute(&deps, create_goal_command(2025, 0)).await; + + assert!(result.is_err()); +} diff --git a/crates/application/tests/goals/delete_test.rs b/crates/application/tests/goals/delete_test.rs new file mode 100644 index 00000000..415d6fb1 --- /dev/null +++ b/crates/application/tests/goals/delete_test.rs @@ -0,0 +1,121 @@ +use crate::support::create_goal_command; +use std::sync::Arc; + +use domain::errors::DomainError; +use domain::events::DomainEvent; +use domain::testing::{FakeStatsRepository, InMemoryStore, RefusingGoalCommand}; +use uuid::Uuid; + +use crate::support::TestContextBuilder; +use application::goals::deps::GoalCommandDeps; +use application::goals::{commands::DeleteGoalCommand, create, delete}; + +#[tokio::test] +async fn deletes_existing_goal() { + let goals = InMemoryStore::new(); + let stats = FakeStatsRepository::new(); + let deps = GoalCommandDeps { + goal_command: Arc::clone(&goals) as _, + goal_query: Arc::clone(&goals) as _, + stats: Arc::clone(&stats) as _, + }; + + create::execute(&deps, create_goal_command(2025, 10)) + .await + .unwrap(); + assert_eq!(goals.goal_count(), 1); + + delete::execute( + &deps, + DeleteGoalCommand { + user_id: Uuid::nil(), + year: 2025, + }, + ) + .await + .unwrap(); + + assert_eq!(goals.goal_count(), 0); +} + +#[tokio::test] +async fn fails_when_not_found() { + let b = TestContextBuilder::new(); + let deps = GoalCommandDeps { + goal_command: b.goal_command.clone(), + goal_query: b.goal_query.clone(), + stats: b.stats_repo.clone(), + }; + let result = delete::execute( + &deps, + DeleteGoalCommand { + user_id: Uuid::nil(), + year: 2025, + }, + ) + .await; + + 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/src/goals/tests/get.rs b/crates/application/tests/goals/get_test.rs similarity index 71% rename from crates/application/src/goals/tests/get.rs rename to crates/application/tests/goals/get_test.rs index 8ffb37b9..b001896e 100644 --- a/crates/application/src/goals/tests/get.rs +++ b/crates/application/tests/goals/get_test.rs @@ -1,8 +1,9 @@ +use crate::support::create_goal_command; use uuid::Uuid; -use crate::goals::deps::{GoalCommandDeps, GoalQueryDeps}; -use crate::goals::{commands::CreateGoalCommand, 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() { @@ -11,23 +12,15 @@ 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(), 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/tests/goals/list_test.rs similarity index 68% rename from crates/application/src/goals/tests/list.rs rename to crates/application/tests/goals/list_test.rs index 3edb380f..2ae07992 100644 --- a/crates/application/src/goals/tests/list.rs +++ b/crates/application/tests/goals/list_test.rs @@ -1,8 +1,9 @@ +use crate::support::create_goal_command; use uuid::Uuid; -use crate::goals::deps::{GoalCommandDeps, GoalQueryDeps}; -use crate::goals::{commands::CreateGoalCommand, 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() { @@ -30,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(), @@ -38,16 +38,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/tests/goals/update_test.rs b/crates/application/tests/goals/update_test.rs new file mode 100644 index 00000000..48643cfe --- /dev/null +++ b/crates/application/tests/goals/update_test.rs @@ -0,0 +1,113 @@ +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}; + +#[tokio::test] +async fn updates_target_count() { + let b = TestContextBuilder::new(); + let deps = GoalCommandDeps { + goal_command: b.goal_command.clone(), + goal_query: b.goal_query.clone(), + stats: b.stats_repo.clone(), + }; + + create::execute(&deps, create_goal_command(2025, 10)) + .await + .unwrap(); + + let result = update::execute(&deps, update_goal_command(2025, 100)) + .await + .unwrap(); + + assert_eq!(result.goal.target_count(), 100); +} + +#[tokio::test] +async fn fails_when_goal_not_found() { + let b = TestContextBuilder::new(); + let deps = GoalCommandDeps { + goal_command: b.goal_command.clone(), + goal_query: b.goal_query.clone(), + stats: b.stats_repo.clone(), + }; + let result = update::execute(&deps, update_goal_command(2025, 10)).await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn rejects_zero_target() { + let b = TestContextBuilder::new(); + let deps = GoalCommandDeps { + goal_command: b.goal_command.clone(), + goal_query: b.goal_query.clone(), + stats: b.stats_repo.clone(), + }; + + create::execute(&deps, create_goal_command(2025, 10)) + .await + .unwrap(); + + let result = update::execute(&deps, update_goal_command(2025, 0)).await; + + 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/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..c77aac7d --- /dev/null +++ b/crates/application/tests/handlers/idempotency_test.rs @@ -0,0 +1,538 @@ +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, PosterPath, 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 ─────────────────────────────────────────────────────────────────── + +#[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(()) + } +} + +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) +} + +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 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] +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()); + 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(); + let (movie, _) = 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.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); +} + +// ── 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(KeyStorage), + 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"); +} + +#[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, + }; + + 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()); + + 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/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 64% rename from crates/application/src/import/tests/apply_mapping.rs rename to crates/application/tests/import/apply_mapping_test.rs index 86d3417a..be7cf6fc 100644 --- a/crates/application/src/import/tests/apply_mapping.rs +++ b/crates/application/tests/import/apply_mapping_test.rs @@ -1,28 +1,26 @@ +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; use uuid::Uuid; use domain::{ models::{ - AnnotatedRow, Movie, + AnnotatedRow, import::{ImportRow, ParsedFile, RowResult}, }, ports::{DocumentParser, MovieCommand}, - testing::{InMemoryImportSessionRepository, InMemoryMovieRepository}, - value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear}, + testing::InMemoryStore, + value_objects::ExternalMetadataId, }; -use crate::import::deps::{ApplyMappingDeps, CreateSessionDeps}; -use crate::import::{ - apply_mapping, - commands::{ApplyImportMappingCommand, CreateImportSessionCommand}, - 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() { - let sessions = InMemoryImportSessionRepository::new(); + let sessions = InMemoryStore::new(); let b = TestContextBuilder::new(); let user_id = Uuid::new_v4(); @@ -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(); @@ -64,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 { @@ -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; @@ -119,18 +105,12 @@ 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 = Movie::new( - Some(ext_id), - MovieTitle::new("Known Movie".into()).unwrap(), - ReleaseYear::new(2020).unwrap(), - None, - None, - ); - movies.upsert_movie(&movie).await.unwrap(); + let movie = test_movie_with_external_id("Known Movie", 2020, ext_id); + movies.upsert_movie(&movie, &[]).await.unwrap(); let parser = Arc::new(DuplicateTestParser { rows: vec![ImportRow { @@ -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(); @@ -182,17 +154,11 @@ 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 = Movie::new( - None, - MovieTitle::new("Duplicate Film".into()).unwrap(), - ReleaseYear::new(2022).unwrap(), - None, - None, - ); - movies.upsert_movie(&movie).await.unwrap(); + let movie = test_movie_with_year("Duplicate Film", 2022); + movies.upsert_movie(&movie, &[]).await.unwrap(); let parser = Arc::new(DuplicateTestParser { rows: vec![ImportRow { @@ -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_and_map.rs b/crates/application/tests/import/apply_profile_and_map_test.rs similarity index 71% 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 a5ccb6b1..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,25 +1,25 @@ +use crate::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::testing::InMemoryStore; 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::support::TestContextBuilder; +use application::import::deps::{ApplyProfileAndMapDeps, CreateSessionDeps}; +use application::import::{ + apply_profile_and_map, commands::ApplyProfileAndMapCommand, create_session, }; -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 { @@ -44,8 +44,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(); @@ -69,11 +69,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(); @@ -98,11 +94,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/apply_profile.rs b/crates/application/tests/import/apply_profile_test.rs similarity index 66% rename from crates/application/src/import/tests/apply_profile.rs rename to crates/application/tests/import/apply_profile_test.rs index 59398e7a..1a6068cd 100644 --- a/crates/application/src/import/tests/apply_profile.rs +++ b/crates/application/tests/import/apply_profile_test.rs @@ -1,19 +1,20 @@ +use crate::support::apply_import_profile_command; 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; -use crate::import::deps::ApplyProfileDeps; -use crate::import::{apply_profile, commands::ApplyImportProfileCommand}; +use application::import::apply_profile; +use application::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 _, @@ -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; @@ -35,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( @@ -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; @@ -69,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( @@ -98,21 +91,17 @@ 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(); // 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/cleanup.rs b/crates/application/tests/import/cleanup_test.rs similarity index 57% rename from crates/application/src/import/tests/cleanup.rs rename to crates/application/tests/import/cleanup_test.rs index 9eb3b811..90ef0787 100644 --- a/crates/application/src/import/tests/cleanup.rs +++ b/crates/application/tests/import/cleanup_test.rs @@ -1,12 +1,12 @@ use std::sync::Arc; -use domain::testing::InMemoryImportSessionRepository; +use domain::testing::InMemoryStore; -use crate::import::cleanup; +use application::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/tests/import/create_session_test.rs b/crates/application/tests/import/create_session_test.rs new file mode 100644 index 00000000..39f3ed4d --- /dev/null +++ b/crates/application/tests/import/create_session_test.rs @@ -0,0 +1,30 @@ +use crate::support::create_import_session_command; +use std::sync::Arc; + +use uuid::Uuid; + +use domain::testing::InMemoryStore; + +use crate::support::TestContextBuilder; +use application::import::create_session; +use application::import::deps::CreateSessionDeps; + +#[tokio::test] +async fn creates_session_with_parsed_file() { + let sessions = InMemoryStore::new(); + let b = TestContextBuilder::new(); + + let deps = CreateSessionDeps { + import_session: Arc::clone(&sessions) as _, + document_parser: b.document_parser.clone(), + }; + + let result = create_session::execute( + &deps, + create_import_session_command(Uuid::new_v4(), b"col1\nval1"), + ) + .await + .unwrap(); + + assert!(!result.columns.is_empty()); +} diff --git a/crates/application/src/import/tests/delete_profile.rs b/crates/application/tests/import/delete_profile_test.rs similarity index 79% rename from crates/application/src/import/tests/delete_profile.rs rename to crates/application/tests/import/delete_profile_test.rs index 32568636..091fca72 100644 --- a/crates/application/src/import/tests/delete_profile.rs +++ b/crates/application/tests/import/delete_profile_test.rs @@ -1,15 +1,15 @@ use std::sync::Arc; -use domain::testing::InMemoryImportProfileRepository; +use domain::testing::InMemoryStore; use uuid::Uuid; -use crate::import::{ +use application::import::{ commands::DeleteImportProfileCommand, delete_profile, deps::DeleteImportProfileDeps, }; #[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/tests/import/execute_test.rs similarity index 77% rename from crates/application/src/import/tests/execute.rs rename to crates/application/tests/import/execute_test.rs index 197b7061..45b2349a 100644 --- a/crates/application/src/import/tests/execute.rs +++ b/crates/application/tests/import/execute_test.rs @@ -1,15 +1,16 @@ +use crate::support::execute_import_command; 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; -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); @@ -44,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)); @@ -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 @@ -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)); @@ -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); @@ -103,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 _, @@ -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; @@ -125,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)); @@ -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()); @@ -166,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)); @@ -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); @@ -207,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)); @@ -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); @@ -248,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)); @@ -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()); @@ -289,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)); @@ -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()); @@ -330,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)); @@ -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()); @@ -371,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)); @@ -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); @@ -409,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)); @@ -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); @@ -451,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)); @@ -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); @@ -493,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)); @@ -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()); @@ -534,32 +456,25 @@ 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 _, 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(), + sessions.import_session_count(), 0, "session should be deleted after import" ); @@ -567,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) @@ -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/import/tests/get_mapping_stage.rs b/crates/application/tests/import/get_mapping_stage_test.rs similarity index 84% rename from crates/application/src/import/tests/get_mapping_stage.rs rename to crates/application/tests/import/get_mapping_stage_test.rs index 11743d62..6c7b8855 100644 --- a/crates/application/src/import/tests/get_mapping_stage.rs +++ b/crates/application/tests/import/get_mapping_stage_test.rs @@ -5,15 +5,15 @@ 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; -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() { - 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/tests/import/get_preview_stage_test.rs similarity index 86% rename from crates/application/src/import/tests/get_preview_stage.rs rename to crates/application/tests/import/get_preview_stage_test.rs index 7467c60a..d3c9585e 100644 --- a/crates/application/src/import/tests/get_preview_stage.rs +++ b/crates/application/tests/import/get_preview_stage_test.rs @@ -5,15 +5,15 @@ 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; -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() { - 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/tests/import/get_session_state_test.rs similarity index 83% rename from crates/application/src/import/tests/get_session_state.rs rename to crates/application/tests/import/get_session_state_test.rs index ac02dbd7..5cb513fd 100644 --- a/crates/application/src/import/tests/get_session_state.rs +++ b/crates/application/tests/import/get_session_state_test.rs @@ -5,15 +5,15 @@ 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; -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() { - 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/tests/import/list_profiles_test.rs similarity index 68% rename from crates/application/src/import/tests/list_profiles.rs rename to crates/application/tests/import/list_profiles_test.rs index 79f20a7a..384af871 100644 --- a/crates/application/src/import/tests/list_profiles.rs +++ b/crates/application/tests/import/list_profiles_test.rs @@ -1,14 +1,14 @@ use std::sync::Arc; -use domain::testing::InMemoryImportProfileRepository; +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() { - 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/tests/import/save_profile_test.rs similarity index 74% rename from crates/application/src/import/tests/save_profile.rs rename to crates/application/tests/import/save_profile_test.rs index 41698fd5..ea13603a 100644 --- a/crates/application/src/import/tests/save_profile.rs +++ b/crates/application/tests/import/save_profile_test.rs @@ -2,17 +2,17 @@ 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; -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() { - 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/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 52% rename from crates/application/src/integrations/tests/cleanup.rs rename to crates/application/tests/integrations/cleanup_test.rs index df614ae7..0209b4ba 100644 --- a/crates/application/src/integrations/tests/cleanup.rs +++ b/crates/application/tests/integrations/cleanup_test.rs @@ -1,10 +1,10 @@ -use domain::testing::InMemoryWatchEventRepository; +use domain::testing::InMemoryStore; -use crate::integrations::cleanup; +use application::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/tests/integrations/confirm_test.rs similarity index 67% rename from crates/application/src/integrations/tests/confirm.rs rename to crates/application/tests/integrations/confirm_test.rs index ce18f3b5..7c9f0a8a 100644 --- a/crates/application/src/integrations/tests/confirm.rs +++ b/crates/application/tests/integrations/confirm_test.rs @@ -1,23 +1,24 @@ +use crate::support::confirm_watch_events_command; use std::sync::Arc; use domain::models::{WatchEvent, WatchEventSource}; use domain::ports::{MovieCommand, WatchEventCommand}; -use domain::testing::{InMemoryWatchEventRepository, NoopEventPublisher}; +use domain::testing::InMemoryStore; 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, + watch_events: &Arc, + review_logger: Arc, ) -> ConfirmWatchEventsDeps { ConfirmWatchEventsDeps { watch_event_command: Arc::clone(watch_events) as _, @@ -28,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( @@ -41,18 +42,11 @@ 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()), - 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(); @@ -62,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()), @@ -79,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( @@ -92,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()), @@ -113,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(); @@ -127,18 +121,11 @@ 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()), - 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; @@ -147,18 +134,11 @@ 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()), - 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; @@ -167,8 +147,7 @@ async fn fails_when_event_not_found() { #[tokio::test] async fn confirms_event_with_movie_id() { - let watch_events = InMemoryWatchEventRepository::new(); - let events = NoopEventPublisher::new(); + let watch_events = InMemoryStore::new(); let uid = Uuid::new_v4(); let movie_uuid = Uuid::new_v4(); @@ -182,10 +161,10 @@ 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::InMemoryMovieRepository::new(); + let movies = domain::testing::InMemoryStore::new(); let movie = domain::models::Movie::from_persistence( domain::value_objects::MovieId::from_uuid(movie_uuid), None, @@ -194,31 +173,23 @@ 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::InMemoryReviewRepository::new(); - let watchlist = domain::testing::InMemoryWatchlistRepository::new(); - let review_logger: Arc = - Arc::new(crate::diary::review_logger::DefaultReviewLogger::new( + let reviews = domain::testing::InMemoryStore::new(); + let watchlist = domain::testing::InMemoryStore::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 _, Arc::clone(&watchlist) as _, Arc::new(domain::testing::FakeMetadataClient) as _, - Arc::clone(&events) as _, )); 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(); @@ -228,7 +199,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( @@ -241,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()), @@ -262,7 +233,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( @@ -287,13 +258,12 @@ 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()), ConfirmWatchEventsCommand { - user_id: uid, confirmations: vec![ WatchEventConfirmation { watch_event_id: id1, @@ -306,6 +276,7 @@ async fn confirms_multiple_events() { comment: None, }, ], + ..confirm_watch_events_command(uid, id1, 3) }, ) .await @@ -316,7 +287,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( @@ -329,18 +300,11 @@ 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()), - 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/tests/integrations/dismiss_test.rs similarity index 61% rename from crates/application/src/integrations/tests/dismiss.rs rename to crates/application/tests/integrations/dismiss_test.rs index 3db9f356..7b0bfc50 100644 --- a/crates/application/src/integrations/tests/dismiss.rs +++ b/crates/application/tests/integrations/dismiss_test.rs @@ -1,15 +1,16 @@ +use crate::support::dismiss_watch_events_command; 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::{commands::DismissWatchEventsCommand, dismiss}; +use application::integrations::deps::DismissWatchEventsDeps; +use application::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 _, @@ -18,14 +19,11 @@ 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), - DismissWatchEventsCommand { - user_id: Uuid::new_v4(), - event_ids: vec![], - }, + dismiss_watch_events_command(Uuid::new_v4(), vec![]), ) .await .unwrap(); @@ -35,14 +33,11 @@ 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), - DismissWatchEventsCommand { - user_id: Uuid::new_v4(), - event_ids: vec![Uuid::new_v4()], - }, + dismiss_watch_events_command(Uuid::new_v4(), vec![Uuid::new_v4()]), ) .await; @@ -51,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); @@ -75,15 +70,12 @@ 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), - 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/tests/integrations/generate_token_test.rs similarity index 57% rename from crates/application/src/integrations/tests/generate_token.rs rename to crates/application/tests/integrations/generate_token_test.rs index 2b038efc..3867d53e 100644 --- a/crates/application/src/integrations/tests/generate_token.rs +++ b/crates/application/tests/integrations/generate_token_test.rs @@ -1,27 +1,23 @@ +use crate::support::generate_webhook_token_command; 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; -use crate::integrations::{commands::GenerateWebhookTokenCommand, generate_token}; +use application::integrations::deps::GenerateWebhookTokenDeps; +use application::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( &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_queue.rs b/crates/application/tests/integrations/get_queue_test.rs similarity index 71% rename from crates/application/src/integrations/tests/get_queue.rs rename to crates/application/tests/integrations/get_queue_test.rs index 2169cbc2..886a318a 100644 --- a/crates/application/src/integrations/tests/get_queue.rs +++ b/crates/application/tests/integrations/get_queue_test.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}; +use application::integrations::deps::GetWatchQueueDeps; +use application::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( @@ -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/src/integrations/tests/get_tokens.rs b/crates/application/tests/integrations/get_tokens_test.rs similarity index 73% rename from crates/application/src/integrations/tests/get_tokens.rs rename to crates/application/tests/integrations/get_tokens_test.rs index 43474440..09fe4393 100644 --- a/crates/application/src/integrations/tests/get_tokens.rs +++ b/crates/application/tests/integrations/get_tokens_test.rs @@ -1,12 +1,13 @@ +use crate::support::generate_webhook_token_command; 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}; -use crate::integrations::{ +use application::integrations::deps::{GenerateWebhookTokenDeps, GetWebhookTokensDeps}; +use application::integrations::{ commands::GenerateWebhookTokenCommand, generate_token, get_tokens, queries::GetWebhookTokensQuery, }; @@ -25,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), @@ -41,17 +42,13 @@ 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(); 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/tests/integrations/ingest_test.rs b/crates/application/tests/integrations/ingest_test.rs new file mode 100644 index 00000000..b3f67109 --- /dev/null +++ b/crates/application/tests/integrations/ingest_test.rs @@ -0,0 +1,127 @@ +use crate::support::generate_webhook_token_command; +use std::sync::Arc; + +use domain::models::WatchEventSource; +use domain::testing::{InMemoryStore, RefusingWatchEventCommand}; +use uuid::Uuid; + +use application::integrations::commands::IngestWatchEventCommand; +use application::integrations::deps::{GenerateWebhookTokenDeps, IngestWatchEventDeps}; +use application::integrations::{generate_token, ingest}; + +struct FakeParser; + +impl domain::ports::MediaServerParser for FakeParser { + fn parse_playback_event( + &self, + _: &[u8], + ) -> Result, domain::errors::DomainError> { + Ok(Some(domain::models::ParsedPlaybackEvent { + title: "Test".into(), + year: Some(2024), + tmdb_id: None, + imdb_id: None, + })) + } +} + +#[tokio::test] +async fn ingesting_saves_the_watch_event_and_records_its_event_together() { + let tokens = InMemoryStore::new(); + let watch_events = Arc::clone(&tokens); + + 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::clone(&watch_events) as _, + watch_event_query: Arc::clone(&watch_events) as _, + }; + + let result = ingest::execute( + &deps, + IngestWatchEventCommand { + token: generated.token_plaintext, + raw_payload: vec![], + source: WatchEventSource::Jellyfin, + }, + &FakeParser, + ) + .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 deps = IngestWatchEventDeps { + webhook_token: Arc::clone(&tokens) as _, + watch_event_command: Arc::clone(&watch_events) as _, + watch_event_query: Arc::clone(&watch_events) as _, + }; + + let result = ingest::execute( + &deps, + IngestWatchEventCommand { + token: "bad-token".into(), + raw_payload: vec![], + source: WatchEventSource::Jellyfin, + }, + &FakeParser, + ) + .await; + + assert!(result.is_err()); +} diff --git a/crates/application/src/integrations/tests/revoke_token.rs b/crates/application/tests/integrations/revoke_token_test.rs similarity index 63% rename from crates/application/src/integrations/tests/revoke_token.rs rename to crates/application/tests/integrations/revoke_token_test.rs index a89d701f..65510f73 100644 --- a/crates/application/src/integrations/tests/revoke_token.rs +++ b/crates/application/tests/integrations/revoke_token_test.rs @@ -1,23 +1,21 @@ +use crate::support::generate_webhook_token_command; 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::{ +use application::integrations::deps::{ GenerateWebhookTokenDeps, GetWebhookTokensDeps, RevokeWebhookTokenDeps, }; -use crate::integrations::{ - commands::{GenerateWebhookTokenCommand, RevokeWebhookTokenCommand}, - generate_token, get_tokens, - queries::GetWebhookTokensQuery, - revoke_token, +use application::integrations::{ + commands::RevokeWebhookTokenCommand, generate_token, get_tokens, + queries::GetWebhookTokensQuery, revoke_token, }; #[tokio::test] async fn revokes_existing_token() { - let tokens: Arc = InMemoryWebhookTokenRepository::new(); + let tokens: Arc = InMemoryStore::new(); let user_id = Uuid::new_v4(); @@ -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/tests/jobs.rs b/crates/application/tests/jobs.rs new file mode 100644 index 00000000..9be4b444 --- /dev/null +++ b/crates/application/tests/jobs.rs @@ -0,0 +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/processed_events_cleanup_test.rs b/crates/application/tests/jobs/processed_events_cleanup_test.rs new file mode 100644 index 00000000..cc685db3 --- /dev/null +++ b/crates/application/tests/jobs/processed_events_cleanup_test.rs @@ -0,0 +1,40 @@ +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", + 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), + ); + + ProcessedEventsCleanupJob::new(store.clone()) + .run() + .await + .unwrap(); + + use domain::ports::{Begin, ProcessedEventLedger}; + assert!(matches!( + store.begin(&stale, "h").await.unwrap(), + Begin::Run(_) + )); + assert_eq!(store.begin(&fresh, "h").await.unwrap(), Begin::AlreadyDone); +} + +#[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/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/movies.rs b/crates/application/tests/movies.rs new file mode 100644 index 00000000..52328748 --- /dev/null +++ b/crates/application/tests/movies.rs @@ -0,0 +1,16 @@ +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/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/src/movies/tests/enrich_movie.rs b/crates/application/tests/movies/enrich_movie_test.rs similarity index 61% rename from crates/application/src/movies/tests/enrich_movie.rs rename to crates/application/tests/movies/enrich_movie_test.rs index 90982582..feb36bf1 100644 --- a/crates/application/src/movies/tests/enrich_movie.rs +++ b/crates/application/tests/movies/enrich_movie_test.rs @@ -1,33 +1,25 @@ +use domain::testing::{NoopPersonCommand, 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}, + testing::{FakeSearchCommand, InMemoryStore, PanicPersonCommand}, + 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() { - 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 = 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(); + movie_repo.upsert_movie(&movie, &[]).await.unwrap(); let profile = MovieProfile { movie_id: movie_id.clone(), @@ -66,48 +58,17 @@ async fn stores_profile_and_indexes() { .await .unwrap(); - assert_eq!(profile_repo.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(()) - } + assert_eq!(profile_repo.movie_profile_count(), 1); } #[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 = 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(); + movie_repo.upsert_movie(&movie, &[]).await.unwrap(); let profile = MovieProfile { movie_id: movie_id.clone(), @@ -158,5 +119,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/tests/movies/get_movie_profile_test.rs similarity index 88% rename from crates/application/src/movies/tests/get_movie_profile.rs rename to crates/application/tests/movies/get_movie_profile_test.rs index 577ea3d4..b457d6ba 100644 --- a/crates/application/src/movies/tests/get_movie_profile.rs +++ b/crates/application/tests/movies/get_movie_profile_test.rs @@ -4,16 +4,16 @@ use uuid::Uuid; use domain::{ models::{CastMember, CrewMember, MovieProfile}, ports::MovieProfileRepository, - testing::InMemoryMovieProfileRepository, + testing::InMemoryStore, 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() { - 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/tests/movies/get_movies_test.rs similarity index 69% rename from crates/application/src/movies/tests/get_movies.rs rename to crates/application/tests/movies/get_movies_test.rs index 9ac9c9e0..45325c27 100644 --- a/crates/application/src/movies/tests/get_movies.rs +++ b/crates/application/tests/movies/get_movies_test.rs @@ -1,10 +1,10 @@ -use domain::testing::InMemoryMovieRepository; +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() { - let movie = InMemoryMovieRepository::new(); + let movie = InMemoryStore::new(); let deps = GetMoviesDeps { movie }; let result = get_movies::execute( 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/src/movies/tests/request_enrichment.rs b/crates/application/tests/movies/request_enrichment_test.rs similarity index 89% rename from crates/application/src/movies/tests/request_enrichment.rs rename to crates/application/tests/movies/request_enrichment_test.rs index 1de0be2d..b2dc79cc 100644 --- a/crates/application/src/movies/tests/request_enrichment.rs +++ b/crates/application/tests/movies/request_enrichment_test.rs @@ -6,16 +6,16 @@ use domain::{ errors::DomainError, models::MovieProfile, ports::{MovieEnrichmentClient, MovieProfileRepository}, - testing::{FakeMovieEnrichmentClient, InMemoryMovieProfileRepository}, + testing::{FakeMovieEnrichmentClient, InMemoryStore}, value_objects::MovieId, }; -use crate::movies::request_enrichment; +use application::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/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/src/movies/tests/sync_poster.rs b/crates/application/tests/movies/sync_poster_test.rs similarity index 60% rename from crates/application/src/movies/tests/sync_poster.rs rename to crates/application/tests/movies/sync_poster_test.rs index 97245209..b8ebff01 100644 --- a/crates/application/src/movies/tests/sync_poster.rs +++ b/crates/application/tests/movies/sync_poster_test.rs @@ -1,3 +1,5 @@ +use crate::support::sync_poster_command; +use domain::testing::{test_movie_with_external_id, test_movie_with_year}; use std::sync::Arc; use uuid::Uuid; @@ -6,23 +8,17 @@ use domain::{ errors::DomainError, models::Movie, ports::{MetadataClient, MovieCommand, MovieQuery}, - testing::{ - FakeSearchCommand, InMemoryMovieProfileRepository, InMemoryMovieRepository, - NoopEventPublisher, NoopObjectStorage, - }, - value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear}, + testing::{FakeSearchCommand, InMemoryStore, NoopEventPublisher, NoopObjectStorage}, + value_objects::{ExternalMetadataId, PosterUrl}, }; -use crate::{ - diary::commands::SyncPosterCommand, - movies::{deps::SyncPosterDeps, sync_poster}, -}; +use application::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), @@ -35,29 +31,17 @@ 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()); } #[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 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 _, @@ -65,7 +49,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()); } @@ -92,17 +76,11 @@ 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 = 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(); + movies.upsert_movie(&movie, &[]).await.unwrap(); let deps = SyncPosterDeps { movie_command: Arc::clone(&movies) as _, @@ -111,7 +89,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/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 81% rename from crates/application/src/social/tests/execute.rs rename to crates/application/tests/social/execute_test.rs index 724afa00..f246e0f2 100644 --- a/crates/application/src/social/tests/execute.rs +++ b/crates/application/tests/social/execute_test.rs @@ -2,36 +2,30 @@ use std::sync::Arc; use domain::{ events::DomainEvent, - testing::{InMemorySocialRepository, NoopEventPublisher}, + testing::{InMemoryStore, RefusingSocialCommand}, value_objects::{FollowStatus, FollowTarget, SocialIdentity, UserId}, }; use uuid::Uuid; -use crate::social::{ +use application::social::{ commands::SocialCmd, deps::{SocialCommandDeps, SocialQueryDeps}, execute::execute_command, }; -fn make_cmd_deps() -> ( - Arc, - Arc, - SocialCommandDeps, -) { - let social = InMemorySocialRepository::new(); - let events = NoopEventPublisher::new(); +fn make_cmd_deps() -> (Arc, SocialCommandDeps) { + let social = InMemoryStore::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 _, @@ -329,7 +323,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 +341,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(); @@ -359,11 +353,9 @@ async fn get_following_returns_accepted_targets() { #[tokio::test] async fn returns_accepted_followers() { - let social = InMemorySocialRepository::new(); - let events = NoopEventPublisher::new(); + let social = InMemoryStore::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 _, @@ -393,7 +385,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); @@ -403,11 +395,9 @@ async fn returns_accepted_followers() { #[tokio::test] async fn returns_only_pending_followers() { - let social = InMemorySocialRepository::new(); - let events = NoopEventPublisher::new(); + let social = InMemoryStore::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 _, @@ -427,7 +417,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); @@ -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 _, @@ -580,10 +570,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(); @@ -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 _, @@ -620,7 +610,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 +625,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!( @@ -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 _, @@ -666,10 +656,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, @@ -678,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/src/social/tests/local_service.rs b/crates/application/tests/social/local_service_test.rs similarity index 67% rename from crates/application/src/social/tests/local_service.rs rename to crates/application/tests/social/local_service_test.rs index 53615d81..9bacd900 100644 --- a/crates/application/src/social/tests/local_service.rs +++ b/crates/application/tests/social/local_service_test.rs @@ -1,25 +1,27 @@ +use domain::testing::test_user; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::{ errors::DomainError, - models::{User, UserRole}, + events::DomainEvent, ports::{ - BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, SocialCommand, UserRepository, + BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, FollowWrite, SocialCommand, + UserRepository, }, - testing::InMemoryUserRepository, + testing::InMemoryStore, value_objects::{ - Email, FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, PasswordHash, - SocialActor, SocialIdentity, UserId, Username, + FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, + UserId, }, }; use uuid::Uuid; -use crate::social::local_service::LocalSocialService; +use application::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 @@ -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(()) } } @@ -246,20 +242,15 @@ fn instance() -> InstanceIdentity { InstanceIdentity::new("http://md.example") } -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, - ); +async fn register_user(repo: &Arc, username: &str) -> UserId { + let user = test_user(username); let id = user.id().clone(); repo.save(&user).await.unwrap(); id } fn service( - user_repo: Arc, + user_repo: Arc, follow_store: &Arc, instance: InstanceIdentity, ) -> LocalSocialService { @@ -275,7 +266,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(); @@ -285,6 +276,7 @@ async fn follow_local_user_writes_both_sides() { svc.follow( &alice_id, &FollowTarget::Handle("@bob@md.example".to_string()), + &[], ) .await .unwrap(); @@ -307,7 +299,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()); @@ -316,6 +308,7 @@ async fn follow_self_is_rejected() { .follow( &alice_id, &FollowTarget::Handle("@alice@md.example".to_string()), + &[], ) .await; @@ -340,7 +333,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()); @@ -349,6 +342,7 @@ async fn follow_remote_target_errors_rather_than_silently_succeeding() { .follow( &alice_id, &FollowTarget::Handle("@carol@other.example".to_string()), + &[], ) .await; @@ -381,7 +375,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(); @@ -391,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(); @@ -427,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()); @@ -436,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/support/commands.rs b/crates/application/tests/support/commands.rs new file mode 100644 index 00000000..4a70acc2 --- /dev/null +++ b/crates/application/tests/support/commands.rs @@ -0,0 +1,214 @@ +use chrono::NaiveDate; +use uuid::Uuid; + +use domain::models::{FileFormat, UserRole, WatchEventSource}; +use domain::testing::test_watched_at; + +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 application::integrations::commands::{ + ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand, + WatchEventConfirmation, +}; +use application::users::commands::UpdateProfileCommand; +use application::users::update_settings::UpdateUserSettingsCommand; +use application::watchlist::commands::AddToWatchlistCommand; +use application::wrapup::commands::RequestWrapUpCommand; + +pub 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 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 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: test_watched_at(), + watch_medium: None, + spoilers: false, + } +} + +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()) + } +} + +pub 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 fn sync_poster_command(movie_id: Uuid) -> SyncPosterCommand { + SyncPosterCommand { movie_id } +} + +pub 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 fn login_command(email: impl Into, password: impl Into) -> LoginCommand { + LoginCommand { + email: email.into(), + password: password.into(), + } +} + +pub fn create_goal_command(year: u16, target_count: u32) -> CreateGoalCommand { + CreateGoalCommand { + user_id: Uuid::nil(), + year, + target_count, + } +} + +pub fn update_goal_command(year: u16, target_count: u32) -> UpdateGoalCommand { + UpdateGoalCommand { + user_id: Uuid::nil(), + year, + target_count, + } +} + +pub fn execute_import_command(user_id: Uuid, session_id: Uuid) -> ExecuteImportCommand { + ExecuteImportCommand { + user_id, + session_id, + confirmed_indices: vec![0], + } +} + +pub fn create_import_session_command(user_id: Uuid, csv: &[u8]) -> CreateImportSessionCommand { + CreateImportSessionCommand { + user_id, + bytes: csv.to_vec(), + format: FileFormat::Csv, + } +} + +pub fn apply_import_mapping_command(user_id: Uuid, session_id: Uuid) -> ApplyImportMappingCommand { + ApplyImportMappingCommand { + user_id, + session_id, + mappings: vec![], + } +} + +pub fn apply_import_profile_command( + user_id: Uuid, + session_id: Uuid, + profile_id: Uuid, +) -> ApplyImportProfileCommand { + ApplyImportProfileCommand { + user_id, + session_id, + profile_id, + } +} + +pub 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 fn dismiss_watch_events_command( + user_id: Uuid, + event_ids: Vec, +) -> DismissWatchEventsCommand { + DismissWatchEventsCommand { user_id, event_ids } +} + +pub fn generate_webhook_token_command(user_id: Uuid) -> GenerateWebhookTokenCommand { + GenerateWebhookTokenCommand { + user_id, + provider: WatchEventSource::Jellyfin, + label: None, + } +} + +pub 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, + } +} + +pub fn update_user_settings_command(user_id: Uuid) -> UpdateUserSettingsCommand { + UpdateUserSettingsCommand { + user_id, + federate_goals: true, + federate_reviews: true, + federate_watchlist: true, + } +} + +pub fn add_to_watchlist_command(user_id: Uuid, movie_id: Uuid) -> AddToWatchlistCommand { + AddToWatchlistCommand { + user_id, + input: movie_input_by_id(movie_id), + } +} + +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(), + end_date: NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap(), + } +} diff --git a/crates/application/src/test_helpers.rs b/crates/application/tests/support/context.rs similarity index 78% rename from crates/application/src/test_helpers.rs rename to crates/application/tests/support/context.rs index aa18b5ea..eccfaffb 100644 --- a/crates/application/src/test_helpers.rs +++ b/crates/application/tests/support/context.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, }, }; @@ -29,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::AppSettings, diary::commands::LogReviewCommand, ports::ReviewLogger}; pub struct NoopReviewLogger; @@ -78,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 { @@ -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,36 +99,38 @@ 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(), - config: AppConfig { + refresh_session_repo: Arc::clone(&store) as _, + config: AppSettings { allow_registration: true, base_url: "http://localhost:3000".into(), rate_limit: 20, refresh_ttl_seconds: 2_592_000, - wrapup: crate::config::WrapUpConfig { + secure_cookies: false, + cors_origins: String::new(), + wrapup: application::config::WrapUpSettings { font_path: None, logo_path: None, bg_dir: None, @@ -280,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/application/tests/support/mod.rs b/crates/application/tests/support/mod.rs new file mode 100644 index 00000000..8c1464e8 --- /dev/null +++ b/crates/application/tests/support/mod.rs @@ -0,0 +1,7 @@ +#![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..dd227b63 --- /dev/null +++ b/crates/application/tests/users.rs @@ -0,0 +1,34 @@ +mod support; + +#[path = "users/authorize_admin_test.rs"] +mod authorize_admin_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"] +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/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"] +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 87% rename from crates/application/src/users/tests/authorize_admin.rs rename to crates/application/tests/users/authorize_admin_test.rs index 7e1810b9..8e13cee0 100644 --- a/crates/application/src/users/tests/authorize_admin.rs +++ b/crates/application/tests/users/authorize_admin_test.rs @@ -1,11 +1,12 @@ +use crate::support::TestContextBuilder; +use crate::support::register_command; use std::sync::Arc; use domain::models::UserRole; -use domain::testing::InMemoryUserRepository; +use domain::testing::InMemoryStore; -use crate::{ +use application::{ auth::{commands::RegisterCommand, deps::RegisterDeps, register}, - test_helpers::TestContextBuilder, users::{authorize_admin, deps::AuthorizeAdminDeps}, }; @@ -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 @@ -41,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; @@ -57,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/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/src/users/tests/get_current_profile.rs b/crates/application/tests/users/get_current_profile_test.rs similarity index 83% rename from crates/application/src/users/tests/get_current_profile.rs rename to crates/application/tests/users/get_current_profile_test.rs index 7aa383cb..f88e9194 100644 --- a/crates/application/src/users/tests/get_current_profile.rs +++ b/crates/application/tests/users/get_current_profile_test.rs @@ -1,20 +1,21 @@ +use crate::support::TestContextBuilder; +use crate::support::register_command; 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; -use crate::{ - auth::{commands::RegisterCommand, deps::RegisterDeps, register}, - test_helpers::TestContextBuilder, +use application::{ + auth::{deps::RegisterDeps, register}, users::{deps::GetCurrentProfileDeps, get_current_profile, queries::GetCurrentProfileQuery}, }; #[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 { @@ -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()) @@ -73,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( @@ -94,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/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 91% rename from crates/application/src/users/tests/get_local_profile.rs rename to crates/application/tests/users/get_local_profile_test.rs index 27e4b9b2..338df9a1 100644 --- a/crates/application/src/users/tests/get_local_profile.rs +++ b/crates/application/tests/users/get_local_profile_test.rs @@ -1,16 +1,15 @@ +use crate::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; -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 { @@ -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/tests/users/get_page_viewer_test.rs similarity index 68% rename from crates/application/src/users/tests/get_page_viewer.rs rename to crates/application/tests/users/get_page_viewer_test.rs index 7667b4b0..cf5b8bbe 100644 --- a/crates/application/src/users/tests/get_page_viewer.rs +++ b/crates/application/tests/users/get_page_viewer_test.rs @@ -1,12 +1,11 @@ -use domain::models::UserRole; +use crate::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; -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 { @@ -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()) @@ -44,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/application/src/users/tests/get_profile_settings.rs b/crates/application/tests/users/get_profile_settings_test.rs similarity index 71% rename from crates/application/src/users/tests/get_profile_settings.rs rename to crates/application/tests/users/get_profile_settings_test.rs index a5c3a98b..58496c12 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 domain::models::{ProfileField, UserRole}; +use crate::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; -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() { @@ -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(); @@ -42,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/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/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/application/tests/users/purge_account_test.rs b/crates/application/tests/users/purge_account_test.rs new file mode 100644 index 00000000..24652256 --- /dev/null +++ b/crates/application/tests/users/purge_account_test.rs @@ -0,0 +1,274 @@ +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, + purger: 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 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, + }), + purger.clone(), + Arc::new(Storage { log: log.clone() }), + )); + Fixture { + handler, + purger, + 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() { + 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); +} + +#[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/application/tests/users/request_account_deletion_test.rs b/crates/application/tests/users/request_account_deletion_test.rs new file mode 100644 index 00000000..f0c5e5e9 --- /dev/null +++ b/crates/application/tests/users/request_account_deletion_test.rs @@ -0,0 +1,302 @@ +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}; + +use async_trait::async_trait; +use domain::models::{User, UserSummary}; +use domain::ports::UserRepository as UserRepositoryPort; +use domain::value_objects::Username; + +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 { + 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::InvalidCredentials(_)), + "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::InvalidCredentials(_)), + "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:?}"); +} + +#[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:?}"); +} + +#[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/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 54% rename from crates/application/src/users/tests/update_profile_fields.rs rename to crates/application/tests/users/update_profile_fields_test.rs index b33a89ff..c406829a 100644 --- a/crates/application/src/users/tests/update_profile_fields.rs +++ b/crates/application/tests/users/update_profile_fields_test.rs @@ -1,27 +1,20 @@ +use crate::support::TestContextBuilder; use std::sync::Arc; use domain::events::DomainEvent; use domain::models::ProfileField; -use domain::testing::{InMemoryProfileFieldsRepo, NoopEventPublisher}; +use domain::testing::{InMemoryStore, RefusingProfileFieldsRepository}; 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] -async fn saves_profile_fields() { - let fields_repo = InMemoryProfileFieldsRepo::new(); - let events = NoopEventPublisher::new(); - let b = TestContextBuilder::new() - .with_profile_fields(Arc::clone(&fields_repo) as _) - .with_event_publisher(Arc::clone(&events) as _); +async fn saving_profile_fields_records_the_change_and_its_event_together() { + let fields_repo = InMemoryStore::new(); 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( @@ -43,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] @@ -56,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) @@ -77,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/src/users/tests/update_profile.rs b/crates/application/tests/users/update_profile_test.rs similarity index 52% rename from crates/application/src/users/tests/update_profile.rs rename to crates/application/tests/users/update_profile_test.rs index 157171e3..cb92f595 100644 --- a/crates/application/src/users/tests/update_profile.rs +++ b/crates/application/tests/users/update_profile_test.rs @@ -1,34 +1,26 @@ +use crate::support::TestContextBuilder; +use crate::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 domain::testing::{InMemoryStore, NoopEventPublisher}; use uuid::Uuid; -use crate::{ - auth::{commands::RegisterCommand, deps::RegisterDeps, register}, - test_helpers::TestContextBuilder, +use application::{ + auth::{deps::RegisterDeps, register}, 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(), 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()) @@ -39,8 +31,8 @@ async fn register_user(b: &TestContextBuilder, users: &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(), + )) + } + 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] +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/src/users/tests/update_settings.rs b/crates/application/tests/users/update_settings_test.rs similarity index 70% rename from crates/application/src/users/tests/update_settings.rs rename to crates/application/tests/users/update_settings_test.rs index 58caac98..cd4b51b4 100644 --- a/crates/application/src/users/tests/update_settings.rs +++ b/crates/application/tests/users/update_settings_test.rs @@ -1,33 +1,30 @@ +use crate::support::TestContextBuilder; +use crate::support::update_user_settings_command; use std::sync::Arc; -use domain::testing::InMemoryUserSettingsRepository; +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] async fn updates_federate_goals() { - let settings_repo = InMemoryUserSettingsRepository::new(); + let settings_repo = InMemoryStore::new(); let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _); 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(), }, UpdateUserSettingsCommand { - user_id: uid, federate_goals: false, - federate_reviews: true, - federate_watchlist: true, + ..update_user_settings_command(uid) }, ) .await @@ -43,20 +40,18 @@ async fn updates_federate_goals() { #[tokio::test] async fn updates_federate_reviews() { - let settings_repo = InMemoryUserSettingsRepository::new(); + let settings_repo = InMemoryStore::new(); let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _); 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(), }, UpdateUserSettingsCommand { - user_id: uid, - federate_goals: true, federate_reviews: false, - federate_watchlist: true, + ..update_user_settings_command(uid) }, ) .await @@ -72,20 +67,18 @@ async fn updates_federate_reviews() { #[tokio::test] async fn updates_federate_watchlist() { - let settings_repo = InMemoryUserSettingsRepository::new(); + let settings_repo = InMemoryStore::new(); let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _); 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(), }, UpdateUserSettingsCommand { - user_id: uid, - federate_goals: true, - federate_reviews: true, federate_watchlist: false, + ..update_user_settings_command(uid) }, ) .await 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/tests/watchlist/add_test.rs b/crates/application/tests/watchlist/add_test.rs new file mode 100644 index 00000000..d297cdec --- /dev/null +++ b/crates/application/tests/watchlist/add_test.rs @@ -0,0 +1,162 @@ +use crate::support::{add_to_watchlist_command, movie_input_manual}; +use domain::testing::test_movie_with_year; +use std::sync::Arc; + +use domain::{ + events::DomainEvent, + ports::MovieCommand, + testing::{InMemoryStore, RefusingWatchlistRepository}, +}; + +use application::watchlist::{add, commands::AddToWatchlistCommand, deps::WatchlistAddDeps}; + +fn make_deps(movies: Arc, watchlist: Arc) -> WatchlistAddDeps { + WatchlistAddDeps { + movie_command: Arc::clone(&movies) as _, + movie_query: movies, + metadata: Arc::new(domain::testing::FakeMetadataClient), + watchlist, + } +} + +#[tokio::test] +async fn adding_to_the_watchlist_resolves_the_movie_and_saves_the_entry() { + let movies = InMemoryStore::new(); + let watchlist = Arc::clone(&movies); + + 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 = add_to_watchlist_command(uuid::Uuid::new_v4(), movie_uuid); + + add::execute(&deps, cmd).await.unwrap(); + + assert_eq!(watchlist.watchlist_count(), 1); +} + +#[tokio::test] +async fn adding_a_movie_already_on_the_watchlist_is_idempotent() { + let movies = InMemoryStore::new(); + let watchlist = Arc::clone(&movies); + + 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 = || add_to_watchlist_command(user_id, movie_uuid); + + 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, + "idempotent add should not duplicate" + ); +} + +#[tokio::test] +async fn adding_a_manual_movie_to_the_watchlist_creates_and_saves_it() { + let movies = InMemoryStore::new(); + let watchlist = Arc::clone(&movies); + + let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist)); + + let cmd = AddToWatchlistCommand { + user_id: uuid::Uuid::new_v4(), + input: movie_input_manual("New Manual Movie", 2024), + }; + + add::execute(&deps, cmd).await.unwrap(); + + assert_eq!(watchlist.watchlist_count(), 1); + assert_eq!(movies.movie_count(), 1); +} + +#[tokio::test] +async fn adding_an_unknown_movie_id_to_the_watchlist_fails() { + let movies = InMemoryStore::new(); + let watchlist = Arc::clone(&movies); + + let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist)); + + let cmd = add_to_watchlist_command(uuid::Uuid::new_v4(), uuid::Uuid::new_v4()); + + 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); +} + +#[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/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 66% 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 72527542..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,20 +1,16 @@ +use crate::support::register_command; use std::sync::Arc; use uuid::Uuid; -use domain::models::UserRole; -use domain::testing::{ - InMemoryRemoteWatchlistRepository, InMemoryUserRepository, InMemoryWatchlistRepository, - PanicRemoteWatchlistRepository, PanicWatchlistRepository, -}; +use domain::testing::{InMemoryStore, 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; -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 { @@ -22,17 +18,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] @@ -46,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), }; @@ -61,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, }; @@ -103,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), }; @@ -120,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/tests/watchlist/is_on_test.rs similarity index 74% rename from crates/application/src/watchlist/tests/is_on.rs rename to crates/application/tests/watchlist/is_on_test.rs index 7ec7e09a..55650397 100644 --- a/crates/application/src/watchlist/tests/is_on.rs +++ b/crates/application/tests/watchlist/is_on_test.rs @@ -2,23 +2,23 @@ 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; -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() { - let watchlist = InMemoryWatchlistRepository::new(); + let watchlist = InMemoryStore::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(); diff --git a/crates/application/tests/watchlist/remove_test.rs b/crates/application/tests/watchlist/remove_test.rs new file mode 100644 index 00000000..d0e85fe8 --- /dev/null +++ b/crates/application/tests/watchlist/remove_test.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +use domain::events::DomainEvent; +use domain::models::WatchlistEntry; +use domain::ports::WatchlistRepository; +use domain::testing::{InMemoryStore, RefusingWatchlistRepository}; +use domain::value_objects::{MovieId, UserId}; +use uuid::Uuid; + +use crate::support::TestContextBuilder; +use application::watchlist::{ + commands::RemoveFromWatchlistCommand, deps::RemoveFromWatchlistDeps, remove, +}; + +#[tokio::test] +async fn removing_an_entry_records_the_removal_and_its_event_together() { + let watchlist = InMemoryStore::new(); + let uid = Uuid::new_v4(); + let mid = Uuid::new_v4(); + watchlist + .add( + &WatchlistEntry::new(UserId::from_uuid(uid), MovieId::from_uuid(mid)), + &[], + ) + .await + .unwrap(); + + let deps = RemoveFromWatchlistDeps { + watchlist: Arc::clone(&watchlist) as _, + }; + remove::execute( + &deps, + RemoveFromWatchlistCommand { + user_id: uid, + movie_id: mid, + }, + ) + .await + .unwrap(); + + assert_eq!(watchlist.watchlist_count(), 0); + let recorded = watchlist.recorded_events(); + assert_eq!(recorded.len(), 1); + assert!(matches!( + recorded[0], + DomainEvent::WatchlistEntryRemoved { .. } + )); +} + +#[tokio::test] +async fn fails_when_not_on_watchlist() { + let b = TestContextBuilder::new(); + let deps = RemoveFromWatchlistDeps { + watchlist: b.watchlist_repo.clone(), + }; + let result = remove::execute( + &deps, + RemoveFromWatchlistCommand { + user_id: Uuid::new_v4(), + movie_id: Uuid::new_v4(), + }, + ) + .await; + + 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/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/tests/worker/worker_test.rs b/crates/application/tests/worker/worker_test.rs new file mode 100644 index 00000000..399cbb91 --- /dev/null +++ b/crates/application/tests/worker/worker_test.rs @@ -0,0 +1,651 @@ +use application::worker::*; +use async_trait::async_trait; +use domain::testing::{InMemoryStore, NoopAckHandle}; +use domain::value_objects::{ExternalMetadataId, MovieId}; +use domain::{ + errors::DomainError, + events::{AckHandle, DomainEvent}, +}; +use domain::{ + events::EventEnvelope, + ports::{EventConsumer, EventHandler}, +}; +use futures::{StreamExt as _, stream, stream::BoxStream}; +use std::sync::{Arc, Mutex}; + +struct TrackingAck { + acked: Arc>, + nacked: Arc>, +} + +#[async_trait] +impl AckHandle for TrackingAck { + async fn ack(&self) -> Result<(), DomainError> { + *self.acked.lock().unwrap() = true; + Ok(()) + } + async fn nack(&self) -> Result<(), DomainError> { + *self.nacked.lock().unwrap() = true; + Ok(()) + } + async fn defer(&self, _: std::time::Duration) -> Result<(), DomainError> { + Ok(()) + } +} + +struct VecConsumer { + events: Vec, +} + +impl EventConsumer for VecConsumer { + fn consume(&self) -> BoxStream<'_, Result> { + let envelopes: Vec> = self + .events + .iter() + .cloned() + .map(|e| Ok(EventEnvelope::new(None, e, Box::new(NoopAckHandle)))) + .collect(); + Box::pin(stream::iter(envelopes)) + } +} + +struct RecordingHandler { + calls: Arc>>, +} + +#[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", + DomainEvent::ReviewLogged { .. } => "review_logged", + DomainEvent::ReviewUpdated { .. } => "review_updated", + DomainEvent::ReviewDeleted { .. } => "review_deleted", + DomainEvent::MovieDeleted { .. } => "movie_deleted", + DomainEvent::UserUpdated { .. } => "user_updated", + DomainEvent::MovieEnrichmentRequested { .. } => "movie_enrichment_requested", + DomainEvent::ImageStored { .. } => "image_stored", + DomainEvent::WatchlistEntryAdded { .. } | DomainEvent::WatchlistEntryRemoved { .. } => { + "watchlist" + } + DomainEvent::FollowRequested { .. } => "follow_requested", + DomainEvent::FollowAccepted { .. } => "follow_accepted", + DomainEvent::FollowRejected { .. } => "follow_rejected", + DomainEvent::Unfollowed { .. } => "unfollowed", + DomainEvent::FollowerRemoved { .. } => "follower_removed", + DomainEvent::ActorBlocked { .. } => "actor_blocked", + DomainEvent::ActorUnblocked { .. } => "actor_unblocked", + DomainEvent::BackfillFollower { .. } => "backfill_follower", + DomainEvent::FederationDeliveryRequested { .. } => "federation_delivery", + DomainEvent::WatchEventIngested { .. } => "watch_event_ingested", + DomainEvent::WrapUpRequested { .. } => "wrapup_requested", + DomainEvent::WrapUpCompleted { .. } => "wrapup_completed", + DomainEvent::SearchReindexRequested => "search_reindex", + DomainEvent::PosterSynced { .. } => "poster_synced", + DomainEvent::GoalCreated { .. } + | DomainEvent::GoalUpdated { .. } + | DomainEvent::GoalDeleted { .. } => "goal", + DomainEvent::PersonEnrichmentRequested { .. } => "person_enrichment_requested", + DomainEvent::UserDeleted { .. } | DomainEvent::UserAccountMoved { .. } => { + "user_lifecycle" + } + }; + self.calls.lock().unwrap().push(label); + Ok(()) + } +} + +fn movie_discovered() -> DomainEvent { + DomainEvent::MovieDiscovered { + movie_id: MovieId::generate(), + external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(), + } +} + +#[tokio::test] +async fn dispatches_to_all_handlers() { + let calls = Arc::new(Mutex::new(vec![])); + let consumer = VecConsumer { + events: vec![movie_discovered()], + }; + let handler = RecordingHandler { + calls: Arc::clone(&calls), + }; + + 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"]); +} + +#[tokio::test] +async fn acks_when_all_handlers_succeed() { + let acked = Arc::new(Mutex::new(false)); + let nacked = Arc::new(Mutex::new(false)); + + struct TrackingConsumer { + event: DomainEvent, + acked: Arc>, + nacked: Arc>, + } + + 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), + nacked: Arc::clone(&self.nacked), + }), + ); + Box::pin(stream::iter(vec![Ok(envelope)])) + } + } + + let consumer = TrackingConsumer { + event: movie_discovered(), + acked: Arc::clone(&acked), + nacked: Arc::clone(&nacked), + }; + + WorkerService::new(Arc::new(consumer), vec![], InMemoryStore::new()) + .unwrap() + .run(tokio::sync::watch::channel(false).1) + .await; + + assert!(*acked.lock().unwrap()); + assert!(!*nacked.lock().unwrap()); +} + +#[tokio::test] +async fn nacks_on_transient_handler_failure() { + let acked = Arc::new(Mutex::new(false)); + let nacked = Arc::new(Mutex::new(false)); + + struct TrackingConsumer { + event: DomainEvent, + acked: Arc>, + nacked: Arc>, + } + 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), + nacked: Arc::clone(&self.nacked), + }), + ); + Box::pin(stream::iter(vec![Ok(envelope)])) + } + } + 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())) + } + } + + WorkerService::new( + Arc::new(TrackingConsumer { + event: movie_discovered(), + acked: Arc::clone(&acked), + nacked: Arc::clone(&nacked), + }), + vec![Arc::new(TransientHandler)], + InMemoryStore::new(), + ) + .unwrap() + .run(tokio::sync::watch::channel(false).1) + .await; + + assert!(!*acked.lock().unwrap(), "should not ack on transient error"); + assert!(*nacked.lock().unwrap(), "should nack on transient error"); +} + +#[tokio::test] +async fn acks_on_permanent_handler_failure() { + let acked = Arc::new(Mutex::new(false)); + let nacked = Arc::new(Mutex::new(false)); + + struct TrackingConsumer { + event: DomainEvent, + acked: Arc>, + nacked: Arc>, + } + 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), + nacked: Arc::clone(&self.nacked), + }), + ); + Box::pin(stream::iter(vec![Ok(envelope)])) + } + } + 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())) + } + } + + WorkerService::new( + Arc::new(TrackingConsumer { + event: movie_discovered(), + acked: Arc::clone(&acked), + nacked: Arc::clone(&nacked), + }), + vec![Arc::new(PermanentHandler)], + InMemoryStore::new(), + ) + .unwrap() + .run(tokio::sync::watch::channel(false).1) + .await; + + assert!(*acked.lock().unwrap(), "should ack on permanent error"); + assert!( + !*nacked.lock().unwrap(), + "should not nack on permanent error" + ); +} + +#[tokio::test] +async fn nacks_if_any_handler_is_transient() { + let acked = Arc::new(Mutex::new(false)); + let nacked = Arc::new(Mutex::new(false)); + + struct TrackingConsumer { + event: DomainEvent, + acked: Arc>, + nacked: Arc>, + } + 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), + nacked: Arc::clone(&self.nacked), + }), + ); + Box::pin(stream::iter(vec![Ok(envelope)])) + } + } + struct OkHandler; + #[async_trait] + impl EventHandler for OkHandler { + fn name(&self) -> &'static str { + "ok" + } + async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> { + Ok(()) + } + } + 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())) + } + } + + WorkerService::new( + Arc::new(TrackingConsumer { + event: movie_discovered(), + acked: Arc::clone(&acked), + nacked: Arc::clone(&nacked), + }), + vec![Arc::new(OkHandler), Arc::new(TransientHandler)], + InMemoryStore::new(), + ) + .unwrap() + .run(tokio::sync::watch::channel(false).1) + .await; + + assert!( + !*acked.lock().unwrap(), + "should not ack when any handler is transient" + ); + assert!( + *nacked.lock().unwrap(), + "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(()) + } +} + +struct RedeliveringConsumer { + id: Option, + event: DomainEvent, + times: usize, + nacks: Arc>, + defers: Arc>>, +} + +struct CountNack(Arc>, 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(()) + } + 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), Arc::clone(&defers))), + ); + async move { + 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), + defers: Default::default(), + }; + + 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)), + defers: Default::default(), + }; + + 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}"); +} + +#[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(); + 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, + "must not run beside the claim holder" + ); + 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); + 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"); + }; + 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] +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), + defers: Default::default(), + }; + + 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), + defers: Default::default(), + }; + + WorkerService::new(Arc::new(consumer), vec![handler], store.clone()) + .unwrap() + .run(tokio::sync::watch::channel(false).1) + .await; + + assert_eq!(*calls.lock().unwrap(), 2); + assert_eq!(*nacks.lock().unwrap(), 1); +} 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 57% rename from crates/application/src/wrapup/tests/generate.rs rename to crates/application/tests/wrapup/generate_test.rs index 1212d49a..299174e9 100644 --- a/crates/application/src/wrapup/tests/generate.rs +++ b/crates/application/tests/wrapup/generate_test.rs @@ -1,53 +1,43 @@ +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; 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, - events: &Arc, -) -> GenerateWrapUpDeps { +fn deps(repo: &Arc) -> GenerateWrapUpDeps { GenerateWrapUpDeps { wrapup_repo: Arc::clone(repo) as _, - event_publisher: Arc::clone(events) as _, } } 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] -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] @@ -65,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] @@ -89,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); @@ -104,17 +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), - 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), 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/src/wrapup/tests/get_ready_report.rs b/crates/application/tests/wrapup/get_ready_report_test.rs similarity index 90% rename from crates/application/src/wrapup/tests/get_ready_report.rs rename to crates/application/tests/wrapup/get_ready_report_test.rs index 5fc46c62..dc05e673 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( @@ -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/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 51% rename from crates/application/src/wrapup/tests/handle_requested.rs rename to crates/application/tests/wrapup/handle_requested_test.rs index e436b96e..0ca398ce 100644 --- a/crates/application/src/wrapup/tests/handle_requested.rs +++ b/crates/application/tests/wrapup/handle_requested_test.rs @@ -1,10 +1,10 @@ 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 crate::wrapup::{deps::HandleWrapUpRequestedDeps, handle_requested}; +use application::wrapup::{deps::HandleWrapUpRequestedDeps, handle_requested}; #[tokio::test] async fn skips_if_already_ready() { @@ -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,17 +82,16 @@ 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 { .. })) - ); + 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] -async fn skips_if_already_generating() { +async fn a_wrapup_left_generating_is_completed_on_redelivery() { let repo = InMemoryWrapUpRepository::new(); let wrapup_id = WrapUpId::generate(); @@ -110,11 +106,89 @@ 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(), + }; + + 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 final_rec = repo.get_by_id(&wrapup_id).await.unwrap().unwrap(); + 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] +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(), }; @@ -127,9 +201,9 @@ async fn skips_if_already_generating() { ) .await; - 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!(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/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 { diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml new file mode 100644 index 00000000..04f70636 --- /dev/null +++ b/crates/bootstrap/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "bootstrap" +version = "0.1.0" +edition.workspace = true + +[features] +default = [] +nats = ["dep:nats"] +federation = [ + "application/federation", + "http-axum/federation", + "dep:sqlite-federation", + "dep:activitypub", +] + +[dependencies] +config = { workspace = true } +domain = { workspace = true } +application = { workspace = true } +http-axum = { workspace = true, default-features = false } +auth = { workspace = true } +metadata = { workspace = true } +poster-fetcher = { workspace = true } +object-storage = { workspace = true } +tmdb-enrichment = { workspace = true } +image-converter = { workspace = true } +export = { workspace = true } +importer = { workspace = true } +rss-adapter = { workspace = true } +media-server = { workspace = true } +sqlite = { workspace = true } +sqlx = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } +axum = { workspace = true } +tokio = { workspace = true, features = ["rt", "macros", "sync", "time", "net"] } +futures = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "ansi", "tracing-log", "smallvec"] } +rustls = { workspace = true, features = ["ring", "std", "tls12", "logging"] } + +nats = { workspace = true, optional = true } +activitypub = { workspace = true, optional = true } +sqlite-federation = { workspace = true, optional = true } + +[dev-dependencies] +reqwest = { workspace = true } +chrono = { workspace = true, features = ["clock"] } +uuid = { workspace = true, features = ["v4"] } +serde_json = { workspace = true } +tower = { workspace = true, features = ["util"] } +domain = { workspace = true, features = ["test-helpers"] } diff --git a/crates/composition/src/build.rs b/crates/bootstrap/src/build.rs similarity index 92% rename from crates/composition/src/build.rs rename to crates/bootstrap/src/build.rs index 98f534cd..2a1a6f69 100644 --- a/crates/composition/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, @@ -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 @@ -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 { @@ -89,12 +89,9 @@ pub fn build_deps( delete_review: DeleteReviewDeps { 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(), - event_publisher: services.event_publisher.clone(), }, get_movie_social_page: GetMovieSocialPageDeps { movie_query: repos.movie_query.clone(), @@ -126,7 +123,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(), @@ -187,7 +183,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(), @@ -210,8 +205,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 { @@ -245,7 +240,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(), @@ -286,16 +280,18 @@ 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(), }, 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(), @@ -316,7 +312,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(), @@ -331,7 +326,6 @@ pub fn build_deps( }, remove_from_watchlist: RemoveFromWatchlistDeps { watchlist: repos.watchlist.clone(), - event_publisher: services.event_publisher.clone(), }, }, wrapup: WrapupGroup { @@ -343,7 +337,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(), @@ -391,7 +384,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/database.rs b/crates/bootstrap/src/database.rs new file mode 100644 index 00000000..fcfab37f --- /dev/null +++ b/crates/bootstrap/src/database.rs @@ -0,0 +1,161 @@ +use std::sync::Arc; + +use anyhow::Context; +use domain::ports::{ + AuthService, ImageRefCommand, ImageRefQuery, MetadataClient, MovieDeduplicator, ObjectStorage, + PasswordHasher, PosterFetcherClient, RefreshSessionRepository, UserProfileFieldsRepository, + WatchEventCommand, WatchEventQuery, WebhookTokenRepository, +}; + +pub struct DatabaseOutput { + pub movie_command: Arc, + pub movie_query: Arc, + pub review: Arc, + pub diary: Arc, + pub stats: Arc, + pub user: Arc, + pub import_session: Arc, + pub import_profile: Arc, + pub movie_profile: Arc, + pub watchlist: Arc, + pub watch_event_command: Arc, + pub watch_event_query: Arc, + pub webhook_token: Arc, + pub person_command: Arc, + pub person_query: Arc, + pub search_port: Arc, + pub search_command: Arc, + pub profile_fields: 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, + 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, + pub follow_query: Arc, + pub db_pool: sqlx::SqlitePool, +} + +pub async fn build_database_adapters( + url: &str, + instance: &domain::value_objects::InstanceIdentity, +) -> anyhow::Result { + let w = sqlite::wire(url) + .await + .context("SQLite connection failed")?; + assemble(w, instance) +} + +pub async fn build_database_adapters_on( + pool: sqlx::SqlitePool, + instance: &domain::value_objects::InstanceIdentity, +) -> anyhow::Result { + 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()); + 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::SqliteSocialRepository::new( + w.pool.clone(), + instance.clone(), + )); + Ok(DatabaseOutput { + movie_command: w.movie_command, + movie_query: w.movie_query, + review: w.review, + diary: w.diary, + stats: w.stats, + user: w.user, + import_session: w.import_session, + import_profile: w.import_profile, + movie_profile: w.movie_profile, + watchlist: w.watchlist, + watch_event_command: we.clone() as _, + watch_event_query: we as _, + webhook_token: wt, + person_command: pc, + person_query: pq, + search_port: sp, + search_command: sc, + profile_fields: pf, + #[cfg(feature = "federation")] + ap_content: w.ap_content, + wrapup_stats: w.wrapup_stats, + wrapup_repo: w.wrapup_repo, + insights: w.insights, + goal_command: w.goal_command, + goal_query: w.goal_query, + user_settings: w.user_settings, + #[cfg(feature = "federation")] + federation_settings: w.federation_settings, + remote_goal: w.remote_goal, + refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone())) as _, + processed_events: Arc::new(sqlite::SqliteProcessedEventLedger::new(w.pool.clone())) as _, + deduplicator: w.deduplicator, + account_purger: Arc::new(sqlite::SqliteAccountPurger::new( + w.pool.clone(), + instance.clone(), + )) as _, + image_ref_command, + image_ref_query, + follow_command: Arc::clone(&social) as _, + follow_query: social as _, + db_pool: w.pool, + }) +} + +pub fn build_auth_adapters( + config: &config::AuthConfig, +) -> anyhow::Result<(Arc, Arc)> { + auth::create(config) +} + +pub fn build_metadata_client( + config: &config::MetadataConfig, +) -> anyhow::Result> { + metadata::create(config) +} + +pub fn build_poster_fetcher( + config: &config::MetadataConfig, +) -> anyhow::Result> { + poster_fetcher::create(config) +} + +pub fn build_object_storage( + config: &config::StorageConfig, +) -> anyhow::Result> { + object_storage::create(config) +} + +pub fn warn_if_database_backend_set() { + if let Ok(v) = std::env::var("DATABASE_BACKEND") + && v != "sqlite" + { + tracing::warn!("DATABASE_BACKEND is ignored; SQLite is the only backend"); + } +} diff --git a/crates/bootstrap/src/event_bus.rs b/crates/bootstrap/src/event_bus.rs new file mode 100644 index 00000000..da337d30 --- /dev/null +++ b/crates/bootstrap/src/event_bus.rs @@ -0,0 +1,141 @@ +use std::sync::Arc; + +use crate::outbox_relay::OutboxRelay; +#[cfg(feature = "nats")] +use anyhow::Context; +use domain::ports::{EventConsumer, EventPublisher, EventRelaySink}; + +#[derive(Clone, Copy)] +pub enum EventBusBackend { + Db, + #[cfg(feature = "nats")] + Nats, +} + +impl EventBusBackend { + pub fn from_config(config: &config::EventsConfig) -> anyhow::Result { + match config.bus_backend.as_str() { + "db" => Ok(Self::Db), + #[cfg(feature = "nats")] + "nats" => Ok(Self::Nats), + #[cfg(not(feature = "nats"))] + "nats" => { + anyhow::bail!("EVENT_BUS_BACKEND=nats requires the nats feature to be compiled in") + } + other => anyhow::bail!("unknown EVENT_BUS_BACKEND={other}, expected 'db' or 'nats'"), + } + } +} + +pub async fn create_publisher( + db_pool: &sqlx::SqlitePool, + config: &config::AppConfig, +) -> anyhow::Result> { + tracing::info!("event publisher: outbox (DB queue)"); + sqlite::SqliteEventQueue::create_publisher(db_pool.clone(), &config.events).await +} + +struct Wiring { + publisher: Arc, + consumer: Arc, + relay: Option, +} + +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(OutboxRelay::new(outbox, sink)), + }, + } +} + +pub async fn create_channel( + db_pool: &sqlx::SqlitePool, + config: &config::AppConfig, +) -> 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 => { + tracing::info!("event bus: DB queue"); + compose(outbox, None) + } + #[cfg(feature = "nats")] + EventBusBackend::Nats => { + let cfg = nats::NatsConfig::from_config(&config.nats, &config.events) + .context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?; + tracing::info!("event bus: NATS ({}), relaying the outbox", cfg.url); + compose(outbox, Some(nats::create_bus(cfg).await?)) + } + }; + Ok((wiring.publisher, wiring.consumer, wiring.relay)) +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::{ + errors::DomainError, + events::{DomainEvent, EventEnvelope, EventId}, + }; + use futures::stream::BoxStream; + + struct Nop; + + #[async_trait::async_trait] + impl EventPublisher for Nop { + async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { + Ok(()) + } + } + #[async_trait::async_trait] + impl EventRelaySink for Nop { + async fn relay(&self, _: Option<&EventId>, _: &DomainEvent) -> Result<(), DomainError> { + Ok(()) + } + } + impl EventConsumer for Nop { + fn consume(&self) -> BoxStream<'_, Result> { + 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 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))); + + 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/federation.rs b/crates/bootstrap/src/federation.rs new file mode 100644 index 00000000..801bb45d --- /dev/null +++ b/crates/bootstrap/src/federation.rs @@ -0,0 +1,168 @@ +use std::sync::Arc; + +use domain::ports::{ + BlockQuery, EventPublisher, FederatedProfileQuery, FederationAdminQuery, FollowGraphQuery, + RemoteWatchlistRepository, SocialCommand, +}; +use domain::value_objects::InstanceIdentity; + +use crate::database::DatabaseOutput; + +pub struct ServerSocial { + pub ap_router: axum::Router, + #[cfg(feature = "federation")] + pub ap_document: Arc, + #[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 fed_repos = sqlite_federation::wire(db.db_pool.clone(), instance.clone()); + + let ap = activitypub::wire(activitypub::ActivityPubDeps { + activity_repo: fed_repos.activity, + follow_repo: fed_repos.follow, + actor_repo: fed_repos.actor, + blocklist_repo: fed_repos.blocklist, + review_store: fed_repos.review_store, + remote_watchlist_repo: fed_repos.remote_watchlist.clone(), + remote_goal_repo: Arc::clone(&db.remote_goal), + local_ap_content: Arc::clone(&db.ap_content), + movie_repo: Arc::clone(&db.movie_query), + review_repo: Arc::clone(&db.review), + diary_repo: Arc::clone(&db.diary), + goal_repo: Arc::clone(&db.goal_query), + stats_repo: Arc::clone(&db.stats), + user_repo: Arc::clone(&db.user), + federation_settings: Arc::clone(&db.federation_settings), + follow_command: Arc::clone(&fed_repos.follow_command), + follow_query: Arc::clone(&fed_repos.follow_query), + instance: instance.clone(), + allow_registration: config.server.allow_registration, + federation: config.federation.clone(), + event_publisher: Arc::clone(event_publisher), + }) + .await?; + + let local_social: Arc = + Arc::new(application::social::local_service::LocalSocialService::new( + Arc::clone(&db.user), + Arc::clone(&db.follow_command), + Arc::clone(&db.follow_query), + instance.clone(), + )); + + let composite_social = Arc::new(activitypub::CompositeSocialAdapter::new( + local_social, + ap.service, + Arc::clone(&db.user), + instance.clone(), + Arc::clone(event_publisher), + )); + + Ok(ServerSocial { + ap_router: ap.router, + ap_document: ap.document, + ap_blocklist: ap.blocklist, + social_command: composite_social.clone() as Arc, + 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( + db.db_pool.clone(), + instance.clone(), + )), + }) +} + +#[cfg(feature = "federation")] +pub async fn wire_worker_handlers( + db: &DatabaseOutput, + instance: &InstanceIdentity, + config: &config::AppConfig, + event_publisher: &Arc, +) -> anyhow::Result<( + Vec>, + Arc, +)> { + let fed_repos = sqlite_federation::wire(db.db_pool.clone(), instance.clone()); + + let ap_wire = activitypub::wire(activitypub::ActivityPubDeps { + activity_repo: fed_repos.activity, + follow_repo: fed_repos.follow, + actor_repo: fed_repos.actor, + blocklist_repo: fed_repos.blocklist, + review_store: fed_repos.review_store, + remote_watchlist_repo: fed_repos.remote_watchlist, + remote_goal_repo: Arc::clone(&db.remote_goal), + local_ap_content: Arc::clone(&db.ap_content), + movie_repo: Arc::clone(&db.movie_query), + review_repo: Arc::clone(&db.review), + diary_repo: Arc::clone(&db.diary), + goal_repo: Arc::clone(&db.goal_query), + stats_repo: Arc::clone(&db.stats), + user_repo: Arc::clone(&db.user), + follow_command: fed_repos.follow_command, + follow_query: fed_repos.follow_query, + instance: instance.clone(), + allow_registration: config.server.allow_registration, + federation: config.federation.clone(), + event_publisher: Arc::clone(event_publisher), + federation_settings: Arc::clone(&db.federation_settings), + }) + .await?; + + tracing::info!("federation event handler registered"); + Ok(( + vec![ + ap_wire.event_handler, + Arc::new(crate::follow_backfill_handler::FollowBackfillHandler { + backfill: ap_wire.backfill, + }), + ], + ap_wire.deletion_notifier, + )) +} diff --git a/crates/bootstrap/src/follow_backfill_handler.rs b/crates/bootstrap/src/follow_backfill_handler.rs new file mode 100644 index 00000000..bd3db1b1 --- /dev/null +++ b/crates/bootstrap/src/follow_backfill_handler.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + events::DomainEvent, + ports::{ApBackfillPort, EventHandler}, + value_objects::SocialIdentity, +}; + +pub struct FollowBackfillHandler { + pub backfill: Arc, +} + +#[async_trait] +impl EventHandler for FollowBackfillHandler { + fn name(&self) -> &'static str { + "follow_backfill" + } + + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { + match event { + DomainEvent::FollowAccepted { + owner, + requester: SocialIdentity::Remote { actor_url }, + } => { + tracing::info!(actor = %actor_url, "follow accepted — looking up outbox for import"); + let following = self.backfill.get_following(owner.value()).await?; + if let Some(actor) = following.iter().find(|a| a.url == *actor_url) { + if let Some(outbox_url) = &actor.outbox_url { + tracing::info!(outbox = %outbox_url, actor = %actor_url, "importing remote outbox"); + self.backfill + .import_remote_outbox(outbox_url, actor_url) + .await?; + } else { + tracing::warn!(actor = %actor_url, "no outbox URL for accepted follow — skipping import"); + } + } + Ok(()) + } + DomainEvent::BackfillFollower { + owner_user_id, + follower_inbox_url, + } => { + tracing::info!(owner = %owner_user_id.value(), inbox = %follower_inbox_url, "backfilling local content to new follower"); + self.backfill + .run_backfill_for_follower(owner_user_id.value(), follower_inbox_url.clone()) + .await + } + _ => Ok(()), + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use domain::models::FollowedActorInfo; + use domain::value_objects::UserId; + use uuid::Uuid; + + use super::*; + + #[derive(Default)] + struct CallLog(Mutex>); + + #[async_trait] + impl ApBackfillPort for CallLog { + 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.0 + .lock() + .unwrap() + .push(format!("import {outbox} {actor}")); + Ok(()) + } + async fn run_backfill_for_follower( + &self, + owner: Uuid, + inbox: String, + ) -> Result<(), DomainError> { + self.0 + .lock() + .unwrap() + .push(format!("backfill {owner} {inbox}")); + Ok(()) + } + } + + #[tokio::test] + async fn follow_backfill_replays_identical_port_calls_when_run_twice() { + let port = Arc::new(CallLog::default()); + let handler = FollowBackfillHandler { + backfill: port.clone(), + }; + let owner = UserId::from_uuid(Uuid::new_v4()); + let events = [ + DomainEvent::FollowAccepted { + owner: owner.clone(), + requester: SocialIdentity::Remote { + actor_url: "https://remote.example/users/a".into(), + }, + }, + DomainEvent::BackfillFollower { + owner_user_id: owner, + follower_inbox_url: "https://remote.example/inbox".into(), + }, + ]; + + for event in &events { + let start = port.0.lock().unwrap().len(); + handler.handle(event).await.unwrap(); + let mid = port.0.lock().unwrap().len(); + handler.handle(event).await.unwrap(); + let log = port.0.lock().unwrap().clone(); + assert!(mid > start, "the event reaches the port"); + assert_eq!(log[start..mid], log[mid..]); + } + } +} diff --git a/crates/bootstrap/src/lib.rs b/crates/bootstrap/src/lib.rs new file mode 100644 index 00000000..c81e81c0 --- /dev/null +++ b/crates/bootstrap/src/lib.rs @@ -0,0 +1,19 @@ +mod build; +mod database; +mod event_bus; +mod federation; +#[cfg(feature = "federation")] +mod follow_backfill_handler; +pub mod outbox_relay; +pub mod repositories; +mod server; +mod tls; +mod tracing_setup; +mod worker; + +pub use build::build_deps; +pub use repositories::Repositories; +pub use server::{ServerApp, build_server, build_server_on}; +pub use tls::install_crypto_provider; +pub use tracing_setup::init_tracing; +pub use worker::{WorkerApp, build_worker, build_worker_on}; diff --git a/crates/bootstrap/src/outbox_relay.rs b/crates/bootstrap/src/outbox_relay.rs new file mode 100644 index 00000000..9c087e4a --- /dev/null +++ b/crates/bootstrap/src/outbox_relay.rs @@ -0,0 +1,97 @@ +use std::{sync::Arc, time::Duration}; + +use domain::{ + events::{EventEnvelope, retry_backoff}, + ports::{EventConsumer, EventRelaySink}, +}; +use futures::StreamExt; +use tokio::sync::watch; + +pub struct OutboxRelay { + source: Arc, + sink: Arc, + backoff: fn(u32) -> Duration, +} + +impl OutboxRelay { + pub fn new(source: Arc, sink: Arc) -> Self { + Self { + source, + sink, + backoff: retry_backoff, + } + } + + pub fn with_backoff(mut self, backoff: fn(u32) -> Duration) -> Self { + self.backoff = backoff; + self + } + + pub async fn run(self, mut shutdown: watch::Receiver) { + let mut rows = self.source.consume(); + let mut sink_failures: u32 = 0; + loop { + tokio::select! { + biased; + _ = shutdown.changed() => { + tracing::info!("outbox relay: shutdown signal received, stopping"); + return; + } + next = rows.next() => match next { + Some(Ok(envelope)) => { + let pause = relay_envelope( + envelope, + self.sink.as_ref(), + &mut sink_failures, + self.backoff, + ) + .await; + if let Some(pause) = pause { + tokio::select! { + _ = tokio::time::sleep(pause) => {} + _ = shutdown.changed() => { + tracing::info!("outbox relay: shutdown signal received, stopping"); + return; + } + } + } + } + Some(Err(e)) => tracing::error!("outbox relay: skipped undecodable row: {e}"), + None => { + tracing::error!("outbox relay: source closed"); + return; + } + }, + } + } + } +} + +async fn relay_envelope( + envelope: EventEnvelope, + sink: &dyn EventRelaySink, + sink_failures: &mut u32, + backoff: fn(u32) -> Duration, +) -> Option { + match sink.relay(envelope.id(), &envelope.event).await { + Ok(()) => { + *sink_failures = 0; + if let Err(e) = envelope.ack().await { + tracing::error!("outbox relay: published but could not mark done: {e}"); + } + None + } + Err(e) => { + *sink_failures = sink_failures.saturating_add(1); + let wait = backoff(*sink_failures); + tracing::warn!( + consecutive_failures = *sink_failures, + "outbox relay: bus unavailable, deferring row {wait:?} without spending an attempt: {e}" + ); + if let Err(e) = envelope.defer(wait).await { + tracing::error!("outbox relay: could not defer: {e}"); + } + Some(wait) + } + } +} 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..604ef088 --- /dev/null +++ b/crates/bootstrap/src/server.rs @@ -0,0 +1,143 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::Context; +use application::config::AppSettings; +use domain::ports::{DiaryExporter, DocumentParser, EventPublisher}; +use domain::value_objects::InstanceIdentity; +use export::ExportAdapter; +use http_axum::context::AppContext; +use http_axum::{openapi, router, state::AppState}; +use importer::ImporterDocumentParser; +use rss_adapter::RssAdapter; + +use crate::database::{self, DatabaseOutput}; +use crate::{Repositories, event_bus, federation}; + +pub struct ServerApp { + pub router: axum::Router, + pub addr: SocketAddr, +} + +pub async fn build_server(config: &config::AppConfig) -> anyhow::Result { + database::warn_if_database_backend_set(); + let instance = InstanceIdentity::new(config.server.base_url.clone()); + let db = database::build_database_adapters(&config.database.url, &instance).await?; + let router = assemble(config, db, instance) + .await + .context("Failed to wire dependencies")?; + + let bind = format!("{}:{}", config.server.host, config.server.port); + let addr = tokio::net::lookup_host(&bind) + .await + .with_context(|| format!("Invalid HOST/PORT {bind}"))? + .next() + .with_context(|| format!("HOST/PORT {bind} resolved to no address"))?; + Ok(ServerApp { router, addr }) +} + +pub async fn build_server_on( + config: &config::AppConfig, + pool: sqlx::SqlitePool, +) -> anyhow::Result { + 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 { + crate::tls::install_crypto_provider(); + let app_config = AppSettings::from(config); + + let (auth_service, password_hasher) = database::build_auth_adapters(&config.auth)?; + let metadata_client = database::build_metadata_client(&config.metadata)?; + let poster_fetcher = database::build_poster_fetcher(&config.metadata)?; + let object_storage = database::build_object_storage(&config.storage)?; + + let event_publisher: Arc = + event_bus::create_publisher(&db.db_pool, config).await?; + + let social = federation::wire_server_social(&db, &instance, config, &event_publisher).await?; + + let review_logger = Arc::new(application::diary::review_logger::DefaultReviewLogger::new( + Arc::clone(&db.movie_command), + Arc::clone(&db.movie_query), + Arc::clone(&db.review), + Arc::clone(&db.watchlist), + Arc::clone(&metadata_client), + )); + + let repos = Repositories { + movie_command: db.movie_command, + movie_query: db.movie_query, + review: db.review, + diary: db.diary, + stats: db.stats, + user: db.user, + import_session: db.import_session, + import_profile: db.import_profile, + movie_profile: db.movie_profile, + watchlist: db.watchlist, + watch_event_command: db.watch_event_command, + watch_event_query: db.watch_event_query, + webhook_token: db.webhook_token, + person_command: db.person_command, + person_query: db.person_query, + search_port: db.search_port, + search_command: db.search_command, + profile_fields: db.profile_fields, + remote_watchlist: social.remote_watchlist, + social_command: social.social_command, + follow_graph: social.follow_graph, + block_query: social.block_query, + federation_admin: social.federation_admin, + wrapup_stats: db.wrapup_stats, + wrapup_repo: db.wrapup_repo, + insights: db.insights, + goal_command: db.goal_command, + goal_query: db.goal_query, + user_settings: db.user_settings, + remote_goal: db.remote_goal, + refresh_session: db.refresh_session, + federated_profile: social.federated_profile, + }; + + let services = application::Services { + auth: auth_service, + password_hasher, + metadata: metadata_client, + poster_fetcher, + object_storage, + event_publisher, + diary_exporter: Arc::new(ExportAdapter) as Arc, + 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(router::build_router( + state, + social.ap_router, + ))) +} diff --git a/crates/bootstrap/src/tls.rs b/crates/bootstrap/src/tls.rs new file mode 100644 index 00000000..da018bea --- /dev/null +++ b/crates/bootstrap/src/tls.rs @@ -0,0 +1,23 @@ +use std::sync::Once; + +pub fn install_crypto_provider() { + static INSTALL: Once = Once::new(); + INSTALL.call_once(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn https_client_builds_after_install() { + install_crypto_provider(); + install_crypto_provider(); + reqwest::Client::builder() + .build() + .expect("reqwest client builds"); + assert!(rustls::crypto::CryptoProvider::get_default().is_some()); + } +} diff --git a/crates/bootstrap/src/tracing_setup.rs b/crates/bootstrap/src/tracing_setup.rs new file mode 100644 index 00000000..66b73add --- /dev/null +++ b/crates/bootstrap/src/tracing_setup.rs @@ -0,0 +1,24 @@ +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +pub fn init_tracing(default_directives: &str) { + tracing_subscriber::registry() + .with(log_filter(default_directives)) + .with(tracing_subscriber::fmt::layer()) + .init(); +} + +/// `RUST_LOG` names the crates to trace in detail; anything it doesn't name +/// still reports warnings and errors. Without a baseline, a filter like +/// `http_axum=debug` silently drops adapter errors (e.g. the sqlx detail +/// behind "Database operation failed"). +fn log_filter(default: &str) -> tracing_subscriber::EnvFilter { + let directives = std::env::var("RUST_LOG").unwrap_or_else(|_| default.to_string()); + let has_baseline = directives + .split(',') + .any(|d| !d.trim().is_empty() && !d.contains('=')); + if has_baseline { + tracing_subscriber::EnvFilter::new(directives) + } else { + tracing_subscriber::EnvFilter::new(format!("warn,{directives}")) + } +} diff --git a/crates/bootstrap/src/worker.rs b/crates/bootstrap/src/worker.rs new file mode 100644 index 00000000..97245b5f --- /dev/null +++ b/crates/bootstrap/src/worker.rs @@ -0,0 +1,317 @@ +use std::sync::Arc; + +use application::{ + MovieDiscoveryIndexer, SearchCleanupHandler, SearchReindexHandler, worker::WorkerService, +}; +use domain::ports::{EventHandler, MovieEnrichmentClient, PeriodicJob, PersonEnrichmentClient}; +use domain::value_objects::InstanceIdentity; + +use crate::database::{self, DatabaseOutput}; +use crate::event_bus; +use crate::outbox_relay::OutboxRelay; +use tokio::sync::watch; + +pub struct WorkerApp { + service: WorkerService, + relay: Option, +} + +impl WorkerApp { + pub fn handler_names(&self) -> Vec<&'static str> { + self.service.handler_names() + } + + 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(()), + } + } +} + +pub async fn build_worker(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?; + assemble(config, db, instance).await +} + +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 { + crate::tls::install_crypto_provider(); + let metadata_client = metadata::create(&config.metadata)?; + let poster_fetcher = poster_fetcher::create(&config.metadata)?; + let object_storage = object_storage::create(&config.storage)?; + + // TMDb client detection happens before `build_worker_deps` so the optional + // person-enrichment port can be handed to `WorkerServices` up front. + let tmdb_client = match tmdb_enrichment::TmdbEnrichmentClient::from_config(&config.metadata) { + Ok(client) => { + tracing::info!("TMDb enrichment enabled"); + Some(Arc::new(client)) + } + Err(e) => { + tracing::warn!("TMDb enrichment disabled: {e}"); + None + } + }; + let person_enrichment: Option> = tmdb_client + .clone() + .map(|c| c as Arc); + + let (event_publisher_arc, consumer_arc, relay) = + event_bus::create_channel(&db.db_pool, config).await?; + + let worker_services = application::WorkerServices { + object_storage: Arc::clone(&object_storage), + event_publisher: Arc::clone(&event_publisher_arc), + person_enrichment, + }; + let worker_deps = crate::build::build_worker_deps(&db, &worker_services); + + let image_ref_command = Arc::clone(&db.image_ref_command); + let image_ref_query = Arc::clone(&db.image_ref_query); + + #[cfg(feature = "federation")] + let (federation_handlers, deletion_notifier) = + crate::federation::wire_worker_handlers(&db, &instance, config, &event_publisher_arc) + .await?; + #[cfg(not(feature = "federation"))] + let deletion_notifier: Arc = + Arc::new(domain::ports::noop::NoopFederationDeletionNotifier); + + let movie_command = db.movie_command; + let movie_query = db.movie_query; + let user = db.user; + let import_session = db.import_session; + let movie_profile = db.movie_profile; + let watch_event_command = db.watch_event_command; + let person_query = db.person_query; + let search_command = db.search_command; + let wrapup_repo = db.wrapup_repo; + let refresh_session = db.refresh_session; + let processed_events = db.processed_events; + let account_purger = db.account_purger; + + let event_publisher = event_publisher_arc; + let metadata = metadata_client; + + // ── Enrichment ──────────────────────────────────────────────────────────── + // Both the event handler and the staleness job are gated on TMDB_API_KEY. + // Without a key, no MovieEnrichmentRequested events are produced or handled. + + type EnrichmentParts = ( + Option>, + 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::new(application::jobs::WrapUpCleanupJob::new(Arc::clone( + &wrapup_repo, + ))), + Arc::new(application::jobs::RefreshSessionCleanupJob::new( + Arc::clone(&refresh_session), + )), + Arc::new(application::jobs::ProcessedEventsCleanupJob::new( + Arc::clone(&processed_events), + )), + Arc::new(application::jobs::TombstoneCleanupJob::new(Arc::clone( + &account_purger, + ))), + ]; + if let Some(job) = enrichment_job { + periodic_jobs.push(job); + } + if let Some((_, ref conv_job)) = conversion { + periodic_jobs.push(Arc::clone(conv_job)); + } + + for job in periodic_jobs { + tokio::spawn(async move { + let mut tick = tokio::time::interval(job.interval()); + loop { + tick.tick().await; + if let Err(e) = job.run().await { + tracing::error!("periodic job failed: {e}"); + } + } + }); + } + + // ── Event handlers ──────────────────────────────────────────────────────── + + let handlers: Vec> = { + 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, + ))) 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.wrapup_stats.clone(), + )) as Arc; + + 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, + discovery_indexer, + wrapup_handler, + reindex_handler, + ]; + + #[cfg(feature = "federation")] + h.extend(federation_handlers); + + if let Some(e) = enrichment_handler { + h.push(e); + } + if let Some(e) = person_enrichment_handler { + h.push(e); + } + if let Some((ref conv_handler, _)) = conversion { + h.push(Arc::clone(conv_handler)); + } + + h + }; + + Ok(WorkerApp { + service: WorkerService::new(consumer_arc, handlers, processed_events)?, + relay, + }) +} diff --git a/crates/bootstrap/tests/account_deletion_test.rs b/crates/bootstrap/tests/account_deletion_test.rs new file mode 100644 index 00000000..f09f479a --- /dev/null +++ b/crates/bootstrap/tests/account_deletion_test.rs @@ -0,0 +1,404 @@ +mod support; + +use axum::{ + body::Body, + http::{Method, Request, header}, +}; +use support::{PASSWORD, Session, get, json_request, send}; + +async fn promote(pool: &sqlx::SqlitePool, user_id: &str) { + sqlx::query("UPDATE users SET role = 'admin' WHERE id = ?") + .bind(user_id) + .execute(pool) + .await + .unwrap(); +} + +fn delete_json(uri: &str, token: &str, password: &str) -> Request { + 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}"); + assert!(body.contains("\"INVALID_CREDENTIALS\""), "{body}"); + let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await; + assert_eq!(status, 200); +} + +#[tokio::test] +async fn self_delete_removes_the_account_and_revokes_the_token() { + let (router, _pool, alice, _bob) = two_users().await; + let (status, _, body) = send( + &router, + delete_json("/api/v1/users/me", &alice.token, PASSWORD), + ) + .await; + assert_eq!(status, 204, "{body}"); + + let req = Request::builder() + .uri("/api/v1/profile") + .header(header::AUTHORIZATION, format!("Bearer {}", alice.token)) + .body(Body::empty()) + .unwrap(); + let (status, _, _) = send(&router, req).await; + assert_eq!(status, 401, "token of a deleted account"); + + for uri in [ + format!("/users/{}", alice.user_id), + format!("/users/{}?embed=true", alice.user_id), + ] { + let (status, _, _) = send(&router, get(&uri, None)).await; + assert_eq!(status, 404, "{uri}"); + } +} + +#[tokio::test] +async fn a_standard_user_cannot_delete_someone_else() { + let (router, _pool, alice, bob) = two_users().await; + let uri = format!("/api/v1/users/{}", bob.user_id); + let (status, _, body) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await; + assert_eq!(status, 403, "{body}"); + let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await; + assert_eq!(status, 200); +} + +#[tokio::test] +async fn an_admin_deletes_another_account() { + let (router, pool, alice, bob) = two_users().await; + promote(&pool, &alice.user_id).await; + let uri = format!("/api/v1/users/{}", bob.user_id); + + let (status, _, body) = send(&router, delete_json(&uri, &alice.token, "nope")).await; + assert_eq!(status, 401, "wrong admin password"); + assert!(body.contains("\"INVALID_CREDENTIALS\""), "{body}"); + + let (status, _, body) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await; + assert_eq!(status, 204, "{body}"); + + for uri in [ + format!("/users/{}", bob.user_id), + format!("/users/{}?embed=true", bob.user_id), + ] { + let (status, _, _) = send(&router, get(&uri, None)).await; + assert_eq!(status, 404, "{uri}"); + } + let (status, _, _) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await; + assert_eq!(status, 404, "already deleted"); + + #[cfg(feature = "federation")] + { + let req = Request::builder() + .uri(format!("/users/{}", bob.user_id)) + .header(header::ACCEPT, "application/activity+json") + .body(Body::empty()) + .unwrap(); + let (status, _, _) = send(&router, req).await; + assert_eq!(status, 410, "actor endpoint of a deleted account"); + } +} + +#[tokio::test] +async fn the_last_admin_cannot_be_deleted() { + let (router, pool, alice, _bob) = two_users().await; + promote(&pool, &alice.user_id).await; + let (status, _, body) = send( + &router, + delete_json("/api/v1/users/me", &alice.token, PASSWORD), + ) + .await; + assert_eq!(status, 409, "{body}"); + let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await; + assert_eq!(status, 200); +} + +#[tokio::test] +async fn classic_self_delete_logs_out_and_redirects_home() { + let (router, _pool, alice, _bob) = two_users().await; + + let (status, headers, _) = send( + &router, + classic_post("/settings/account/delete", &alice.cookie, "nope"), + ) + .await; + assert_eq!(status, 303); + assert!( + location(&headers).starts_with("/settings/profile?error="), + "{headers:?}" + ); + assert!( + support::cookie_value(&headers, "token").is_none(), + "a wrong password must not log out" + ); + + let (status, headers, _) = send( + &router, + classic_post("/settings/account/delete", &alice.cookie, PASSWORD), + ) + .await; + assert_eq!(status, 303); + assert_eq!(location(&headers), "/"); + assert_eq!( + support::cookie_value(&headers, "token").as_deref(), + Some("") + ); + + let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await; + assert_eq!(status, 404); +} + +#[tokio::test] +async fn classic_self_delete_requires_the_csrf_token() { + let (router, _pool, alice, _bob) = two_users().await; + let req = Request::builder() + .method(Method::POST) + .uri("/settings/account/delete") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, &alice.cookie) + .body(Body::from(format!("password={PASSWORD}&_csrf=wrong"))) + .unwrap(); + let (status, _, _) = send(&router, req).await; + assert_eq!(status, 403); +} + +#[tokio::test] +async fn classic_admin_delete_redirects_to_the_member_list() { + let (router, pool, alice, bob) = two_users().await; + let uri = format!("/users/{}/delete", bob.user_id); + + let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await; + assert_eq!(status, 403, "non-admin: {headers:?}"); + + promote(&pool, &alice.user_id).await; + let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await; + assert_eq!(status, 303); + assert_eq!(location(&headers), "/users"); + assert!(support::cookie_value(&headers, "token").is_none()); + + let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await; + assert_eq!(status, 404); +} + +#[tokio::test] +async fn classic_admin_delete_requires_the_csrf_token() { + let (router, pool, alice, bob) = two_users().await; + promote(&pool, &alice.user_id).await; + let req = Request::builder() + .method(Method::POST) + .uri(format!("/users/{}/delete", bob.user_id)) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, &alice.cookie) + .body(Body::from(format!("password={PASSWORD}&_csrf=wrong"))) + .unwrap(); + let (status, _, _) = send(&router, req).await; + assert_eq!(status, 403); + let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await; + assert_eq!(status, 200, "target must survive a CSRF rejection"); +} + +#[tokio::test] +async fn a_non_admin_classic_delete_of_someone_else_is_forbidden() { + let (router, _pool, alice, bob) = two_users().await; + let uri = format!("/users/{}/delete", bob.user_id); + let (status, _, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await; + assert_eq!(status, 403); + let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await; + assert_eq!(status, 200); +} + +#[tokio::test] +async fn classic_admin_self_delete_logs_out_unless_last_admin() { + let (router, pool, alice, bob) = two_users().await; + promote(&pool, &alice.user_id).await; + let uri = format!("/users/{}/delete", alice.user_id); + + let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await; + assert_eq!(status, 303); + assert!(location(&headers).contains("error="), "{headers:?}"); + assert!(support::cookie_value(&headers, "token").is_none()); + let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await; + assert_eq!(status, 200); + + promote(&pool, &bob.user_id).await; + let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await; + assert_eq!(status, 303); + assert_eq!(location(&headers), "/"); + assert_eq!( + support::cookie_value(&headers, "token").as_deref(), + Some("") + ); + let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await; + assert_eq!(status, 404); +} + +#[tokio::test] +async fn delete_forms_render_only_where_they_apply() { + let (router, pool, alice, bob) = two_users().await; + let own_delete = "action=\"/settings/account/delete\""; + let user_delete = |id: &str| format!("action=\"/users/{id}/delete\""); + + let (_, _, body) = send(&router, get("/settings/profile", Some(&alice.cookie))).await; + assert!(body.contains(own_delete), "owner settings page"); + + let bob_page = format!("/users/{}", bob.user_id); + let (_, _, body) = send(&router, get(&bob_page, None)).await; + assert!(!body.contains(&user_delete(&bob.user_id)), "anonymous"); + let (_, _, body) = send(&router, get(&bob_page, Some(&alice.cookie))).await; + assert!(!body.contains(&user_delete(&bob.user_id)), "non-admin"); + + promote(&pool, &alice.user_id).await; + let (_, _, body) = send(&router, get(&bob_page, Some(&alice.cookie))).await; + assert!(body.contains(&user_delete(&bob.user_id)), "admin on other"); + + let (_, _, body) = send( + &router, + get(&format!("/users/{}", alice.user_id), Some(&alice.cookie)), + ) + .await; + assert!( + !body.contains(&user_delete(&alice.user_id)), + "admin on self" + ); + + let (_, _, body) = send( + &router, + get(&format!("{bob_page}?embed=true"), Some(&alice.cookie)), + ) + .await; + assert!(!body.contains("/delete"), "embed page"); +} + +const SERVICE_ACTOR: &str = "00000000-0000-4000-8000-000000000000"; + +#[tokio::test] +async fn the_service_actor_is_not_deletable_and_offers_no_delete_ui() { + let (router, pool, alice, bob) = two_users().await; + promote(&pool, &alice.user_id).await; + promote(&pool, &bob.user_id).await; + + let page = format!("/users/{SERVICE_ACTOR}"); + let (status, _, body) = send(&router, get(&page, Some(&alice.cookie))).await; + assert_eq!(status, 200, "{body}"); + assert!( + !body.contains(&format!("action=\"/users/{SERVICE_ACTOR}/delete\"")), + "admin sees a delete form for the service actor" + ); + + let uri = format!("/api/v1/users/{SERVICE_ACTOR}"); + let (status, _, body) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await; + assert_eq!(status, 403, "{body}"); + + let req = Request::builder() + .uri(&uri) + .header(header::AUTHORIZATION, format!("Bearer {}", alice.token)) + .body(Body::empty()) + .unwrap(); + let (status, _, body) = send(&router, req).await; + assert_eq!(status, 200, "{body}"); + assert!(body.contains("\"is_service_account\":true"), "{body}"); + + let req = Request::builder() + .uri(format!("/api/v1/users/{}", bob.user_id)) + .header(header::AUTHORIZATION, format!("Bearer {}", alice.token)) + .body(Body::empty()) + .unwrap(); + let (_, _, body) = send(&router, req).await; + assert!(!body.contains("is_service_account"), "{body}"); +} + +#[tokio::test] +async fn a_deleted_users_webhook_token_stops_ingesting_at_once() { + let (router, pool, alice, _bob) = two_users().await; + let (status, _, body) = send( + &router, + json_request( + Method::POST, + "/api/v1/settings/webhook-tokens", + Some(&alice.token), + serde_json::json!({ "provider": "jellyfin", "label": null }), + ), + ) + .await; + assert_eq!(status, 200, "{body}"); + let token = serde_json::from_str::(&body).unwrap()["token"] + .as_str() + .unwrap() + .to_string(); + + let hook = |token: &str| { + Request::builder() + .method(Method::POST) + .uri("/api/v1/webhooks/jellyfin") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({ + "NotificationType": "PlaybackStop", "ItemType": "Movie", + "Name": "Heat", "Year": 1995, "PlayedToCompletion": true, + "Provider_tmdb": "949" + }) + .to_string(), + )) + .unwrap() + }; + let events = || async { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM watch_events") + .fetch_one(&pool) + .await + .unwrap() + }; + + let (status, _, body) = send(&router, hook(&token)).await; + assert_eq!(status, 200, "live token: {body}"); + assert_eq!(events().await, 1); + + let (status, _, _) = send( + &router, + delete_json("/api/v1/users/me", &alice.token, PASSWORD), + ) + .await; + assert_eq!(status, 204); + + let (status, _, body) = send(&router, hook(&token)).await; + assert_eq!(status, 401, "{body}"); + assert_eq!(events().await, 1, "no event ingested after deletion"); +} diff --git a/crates/bootstrap/tests/account_purge_test.rs b/crates/bootstrap/tests/account_purge_test.rs new file mode 100644 index 00000000..5d441283 --- /dev/null +++ b/crates/bootstrap/tests/account_purge_test.rs @@ -0,0 +1,123 @@ +mod support; + +use std::time::Duration; + +use domain::{events::DomainEvent, ports::UserRepository, value_objects::UserId}; + +async fn wait_for(pool: &sqlx::SqlitePool, sql: &str) -> bool { + for _ in 0..100 { + let n: i64 = sqlx::query_scalar(sql).fetch_one(pool).await.unwrap(); + if n > 0 { + return true; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + false +} + +#[tokio::test] +async fn the_worker_purges_a_deleted_account_and_frees_its_email() { + let (router, pool) = support::server().await; + let alice = support::register(&router, "alice", "alice@example.com").await; + let id = UserId::from_uuid(uuid::Uuid::parse_str(&alice.user_id).unwrap()); + let users = sqlite::SqliteUserRepository::new(pool.clone()); + users + .mark_deleted( + &id, + &[DomainEvent::UserDeleted { + user_id: id.clone(), + }], + ) + .await + .unwrap(); + + let worker = bootstrap::build_worker_on(&support::test_config(), pool.clone()) + .await + .unwrap(); + let (stop, stopped) = tokio::sync::watch::channel(false); + let running = tokio::spawn(worker.run(stopped)); + + let purged = wait_for( + &pool, + "SELECT COUNT(*) FROM users WHERE username = 'alice' AND purged_at IS NOT NULL", + ) + .await; + stop.send(true).unwrap(); + running.await.unwrap().unwrap(); + assert!(purged, "the worker should purge the tombstoned account"); + + let email: String = sqlx::query_scalar("SELECT email FROM users WHERE username = 'alice'") + .fetch_one(&pool) + .await + .unwrap(); + assert_ne!(email, "alice@example.com"); + let again = support::register(&router, "alice2", "alice@example.com").await; + assert!(!again.user_id.is_empty(), "the email can register again"); +} + +#[cfg(feature = "federation")] +#[tokio::test] +async fn followers_are_told_before_the_purge_and_the_keypair_survives_it() { + let (router, pool) = support::server().await; + let alice = support::register(&router, "alice", "alice@example.com").await; + let id = UserId::from_uuid(uuid::Uuid::parse_str(&alice.user_id).unwrap()); + for sql in [ + "INSERT INTO ap_remote_actors (url, handle, inbox_url, fetched_at) + VALUES ('http://127.0.0.1:1/users/ann', 'ann@127.0.0.1', 'http://127.0.0.1:1/inbox', 'now')", + ] { + sqlx::query(sql).execute(&pool).await.unwrap(); + } + sqlx::query( + "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at) + VALUES (?, 'http://127.0.0.1:1/users/ann', 'accepted', 'now')", + ) + .bind(&alice.user_id) + .execute(&pool) + .await + .unwrap(); + let users = sqlite::SqliteUserRepository::new(pool.clone()); + users + .mark_deleted( + &id, + &[DomainEvent::UserDeleted { + user_id: id.clone(), + }], + ) + .await + .unwrap(); + + let worker = bootstrap::build_worker_on(&support::test_config(), pool.clone()) + .await + .unwrap(); + let (stop, stopped) = tokio::sync::watch::channel(false); + let running = tokio::spawn(worker.run(stopped)); + + let purged = wait_for( + &pool, + "SELECT COUNT(*) FROM users WHERE username = 'alice' AND purged_at IS NOT NULL", + ) + .await; + let enqueued = wait_for( + &pool, + "SELECT COUNT(*) FROM event_queue WHERE event_type = 'FederationDeliveryRequested'", + ) + .await; + stop.send(true).unwrap(); + running.await.unwrap().unwrap(); + assert!(purged); + assert!(enqueued, "the Delete should be enqueued for the follower"); + + let deliveries: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM event_queue WHERE event_type = 'FederationDeliveryRequested'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(deliveries, 1, "the Delete was enqueued for the follower"); + let keys: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_local_actors WHERE user_id = ?") + .bind(&alice.user_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(keys, 1, "the keypair stays until the tombstone cleanup"); +} diff --git a/crates/bootstrap/tests/api_test.rs b/crates/bootstrap/tests/api_test.rs new file mode 100644 index 00000000..27b81abf --- /dev/null +++ b/crates/bootstrap/tests/api_test.rs @@ -0,0 +1,386 @@ +mod support; + +use axum::{ + Router, + body::Body, + http::{Request, StatusCode}, +}; +use sqlx::SqlitePool; +use tower::ServiceExt; +use uuid::Uuid; + +async fn test_app() -> Router { + support::server().await.0 +} + +async fn test_app_with_user() -> (Router, SqlitePool, Uuid, String) { + let (app, pool) = support::server().await; + let session = support::register(&app, "insights", "insights@example.com").await; + let user_id = session.user_id.parse().unwrap(); + (app, pool, user_id, session.token) +} + +/// Inject a fake peer IP so the GovernorLayer can extract ConnectInfo. +fn with_ip(req: Request) -> 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..c74ea805 --- /dev/null +++ b/crates/bootstrap/tests/bootstrap_test.rs @@ -0,0 +1,307 @@ +mod support; + +#[tokio::test] +async fn server_builds_on_a_fresh_database() { + let pool = support::memory_pool().await; + let router = bootstrap::build_server_on(&support::test_config(), pool) + .await + .expect("server builds"); + + let (status, _, _) = support::send(&router, support::get("/health", None)).await; + assert_eq!(status, 200); +} + +#[tokio::test] +async fn worker_builds_on_a_fresh_database() { + let pool = support::memory_pool().await; + bootstrap::build_worker_on(&support::test_config(), pool) + .await + .expect("worker builds"); +} + +#[cfg(not(feature = "federation"))] +#[tokio::test] +async fn federation_off_build_wires_local_social() { + use axum::http::Method; + + let (router, _pool) = support::server().await; + let alice = support::register(&router, "alice", "alice@example.com").await; + let bob = support::register(&router, "bob", "bob@example.com").await; + + let (status, _, body) = support::send( + &router, + support::json_request( + Method::POST, + "/api/v1/social/follow", + Some(&alice.token), + serde_json::json!({ "handle": "bob@localhost:3000" }), + ), + ) + .await; + assert_eq!(status, 200, "follow: {body}"); + + let (status, _, body) = support::send( + &router, + axum::http::Request::builder() + .uri("/api/v1/social/followers/pending") + .header("authorization", format!("Bearer {}", bob.token)) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(status, 200, "pending followers: {body}"); + let followers: serde_json::Value = serde_json::from_str(&body).unwrap(); + let actors = followers["actors"].as_array().unwrap(); + assert_eq!( + actors.len(), + 1, + "bob should have one pending follower: {body}" + ); + assert!( + body.contains(&alice.user_id) || body.contains("alice"), + "follower should be alice: {body}" + ); +} + +#[tokio::test] +async fn server_publishes_to_the_outbox_even_when_bus_is_nats() { + let pool = support::memory_pool().await; + let mut config = support::test_config(); + config.events.bus_backend = "nats".into(); + config.nats.url = Some("nats://127.0.0.1:1".into()); + let router = bootstrap::build_server_on(&config, pool.clone()) + .await + .expect("server builds without a reachable NATS"); + let session = support::register(&router, "alice", "alice@example.com").await; + + let (status, _, body) = support::send( + &router, + support::json_request( + axum::http::Method::POST, + "/api/v1/reviews", + Some(&session.token), + serde_json::json!({ + "manual_title": "Outbox", + "manual_release_year": 2001, + "rating": 4, + "comment": "queued", + "watched_at": "2024-01-01" + }), + ), + ) + .await; + assert_eq!(status, 201, "log review: {body}"); + + let queued: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM event_queue WHERE event_type = 'ReviewLogged' AND event_id IS NOT NULL", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(queued, 1); +} + +#[tokio::test] +async fn handler_names_are_unique() { + let pool = support::memory_pool().await; + let worker = bootstrap::build_worker_on(&support::test_config(), pool) + .await + .expect("worker builds"); + + let names = worker.handler_names(); + let unique: std::collections::HashSet<_> = names.iter().collect(); + assert_eq!( + unique.len(), + names.len(), + "duplicate handler name in {names:?}" + ); + let mut sorted = names.clone(); + sorted.sort_unstable(); + let mut expected = vec![ + "account_purge", + "image_cleanup", + "movie_discovery_indexer", + "movie_enrichment", + "person_enrichment", + "poster_sync", + "search_cleanup", + "search_reindex", + "wrapup", + ]; + if cfg!(feature = "federation") { + expected.extend(["activitypub", "follow_backfill"]); + } + expected.sort_unstable(); + assert_eq!(sorted, expected); +} + +async fn queued(pool: &sqlx::SqlitePool, event_type: &str) -> i64 { + sqlx::query_scalar( + "SELECT COUNT(*) FROM event_queue WHERE event_type = ? AND event_id IS NOT NULL", + ) + .bind(event_type) + .fetch_one(pool) + .await + .unwrap() +} + +#[tokio::test] +async fn a_local_follow_lands_with_its_event_in_the_outbox() { + let (router, pool) = support::server().await; + let alice = support::register(&router, "alice", "alice@example.com").await; + let _bob = support::register(&router, "bob", "bob@example.com").await; + + let (status, _, body) = support::send( + &router, + support::json_request( + axum::http::Method::POST, + "/api/v1/social/follow", + Some(&alice.token), + serde_json::json!({ "handle": "bob@localhost:3000" }), + ), + ) + .await; + assert_eq!(status, 200, "follow: {body}"); + + let following: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_following") + .fetch_one(&pool) + .await + .unwrap(); + let followers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_followers") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!((following, followers), (1, 1)); + assert_eq!(queued(&pool, "FollowRequested").await, 1); +} + +#[cfg(feature = "federation")] +#[tokio::test] +async fn a_remote_block_publishes_its_event_after_the_k_ap_write() { + let (router, pool) = support::server().await; + let alice = support::register(&router, "alice", "alice@example.com").await; + + let (status, _, body) = support::send( + &router, + support::json_request( + axum::http::Method::POST, + "/api/v1/social/block", + Some(&alice.token), + serde_json::json!({ "actor_url": "https://remote.example/users/ann" }), + ), + ) + .await; + assert_eq!(status, 204, "block: {body}"); + + let blocked: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocked_actors") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(blocked, 1); + assert_eq!(queued(&pool, "ActorBlocked").await, 1); +} + +#[tokio::test] +async fn a_deleted_users_token_is_rejected() { + let (router, pool) = support::server().await; + let alice = support::register(&router, "alice", "alice@example.com").await; + let bearer = |token: &str| { + axum::http::Request::builder() + .uri("/api/v1/diary/export?format=json") + .header("authorization", format!("Bearer {token}")) + .body(axum::body::Body::empty()) + .unwrap() + }; + + let (status, _, body) = support::send(&router, bearer(&alice.token)).await; + assert_eq!(status, 200, "token works while the account is live: {body}"); + let (status, _, _) = + support::send(&router, support::get("/diary/export", Some(&alice.cookie))).await; + assert_eq!(status, 200, "cookie works while the account is live"); + + sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE id = ?") + .bind(&alice.user_id) + .execute(&pool) + .await + .unwrap(); + + let (status, _, body) = support::send(&router, bearer(&alice.token)).await; + assert_eq!(status, 401, "json route: {body}"); + let (anon_status, anon_headers, _) = + support::send(&router, support::get("/diary/export", None)).await; + let (status, headers, _) = + support::send(&router, support::get("/diary/export", Some(&alice.cookie))).await; + assert_eq!(status, anon_status, "classic owner page"); + assert_eq!(headers.get("location"), anon_headers.get("location")); + assert_eq!(headers.get("location").unwrap(), "/login"); +} + +#[cfg(feature = "federation")] +#[tokio::test] +async fn actor_document_for_a_tombstone_is_gone() { + let (router, pool) = support::server().await; + let alice = support::register(&router, "alice", "alice@example.com").await; + let ap_get = |path: String| { + axum::http::Request::builder() + .uri(path) + .header("accept", "application/activity+json") + .body(axum::body::Body::empty()) + .unwrap() + }; + let paths = |id: &str| { + [ + format!("/users/{id}"), + format!("/users/{id}/followers"), + format!("/users/{id}/following"), + format!("/users/{id}/outbox"), + format!("/users/{id}/featured"), + ] + }; + let (status, _, body) = + support::send(&router, ap_get(format!("/users/{}", alice.user_id))).await; + assert_eq!(status, 200, "live actor: {body}"); + + sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE id = ?") + .bind(&alice.user_id) + .execute(&pool) + .await + .unwrap(); + + for path in paths(&alice.user_id) { + let (status, _, body) = support::send(&router, ap_get(path.clone())).await; + assert_eq!(status, 410, "{path}: {body}"); + } + let (status, _, _) = support::send( + &router, + support::get(&format!("/users/{}", alice.user_id), None), + ) + .await; + assert_eq!(status, 404, "the classic profile page stays 404"); +} + +#[cfg(feature = "federation")] +#[tokio::test] +async fn posting_to_a_tombstones_inbox_is_gone() { + let (router, pool) = support::server().await; + let alice = support::register(&router, "alice", "alice@example.com").await; + let post = |path: String| { + axum::http::Request::builder() + .method("POST") + .uri(path) + .header("content-type", "application/activity+json") + .body(axum::body::Body::from("{}")) + .unwrap() + }; + let path = format!("/users/{}/inbox", alice.user_id); + let (live, _, _) = support::send(&router, post(path.clone())).await; + assert_ne!(live, 410, "a live user's inbox is not gone"); + + sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE id = ?") + .bind(&alice.user_id) + .execute(&pool) + .await + .unwrap(); + + let (status, _, body) = support::send(&router, post(path)).await; + assert_eq!(status, 410, "{body}"); +} diff --git a/crates/bootstrap/tests/config_propagation_test.rs b/crates/bootstrap/tests/config_propagation_test.rs new file mode 100644 index 00000000..3f7f21ac --- /dev/null +++ b/crates/bootstrap/tests/config_propagation_test.rs @@ -0,0 +1,101 @@ +mod support; + +use axum::{ + body::Body, + http::{Method, Request, header}, +}; + +async fn server_with(config: config::AppConfig) -> axum::Router { + bootstrap::build_server_on(&config, support::memory_pool().await) + .await + .expect("server builds") +} + +fn set_cookies(headers: &axum::http::HeaderMap) -> Vec { + 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/fixtures/openapi_paths.txt b/crates/bootstrap/tests/fixtures/openapi_paths.txt new file mode 100644 index 00000000..4d8d7a84 --- /dev/null +++ b/crates/bootstrap/tests/fixtures/openapi_paths.txt @@ -0,0 +1,71 @@ +DELETE /api/v1/admin/blocked-domains/{domain} +DELETE /api/v1/goals/{year} +DELETE /api/v1/import/profiles/{id} +DELETE /api/v1/reviews/{id} +DELETE /api/v1/settings/webhook-tokens/{id} +DELETE /api/v1/users/me +DELETE /api/v1/users/{id} +DELETE /api/v1/watchlist/{movie_id} +DELETE /api/v1/wrapups/{id} +GET /api/v1/activity-feed +GET /api/v1/admin/blocked-domains +GET /api/v1/diary +GET /api/v1/diary/export +GET /api/v1/goals +GET /api/v1/import/profiles +GET /api/v1/import/sessions/{id} +GET /api/v1/insights +GET /api/v1/movies +GET /api/v1/movies/{id}/history +GET /api/v1/movies/{id}/profile +GET /api/v1/movies/{movie_id} +GET /api/v1/people/{id} +GET /api/v1/people/{id}/credits +GET /api/v1/profile +GET /api/v1/search +GET /api/v1/settings +GET /api/v1/settings/webhook-tokens +GET /api/v1/social/blocked +GET /api/v1/social/followers +GET /api/v1/social/followers/pending +GET /api/v1/social/followers/pending/count +GET /api/v1/social/following +GET /api/v1/social/following/pending +GET /api/v1/social/relationship +GET /api/v1/users +GET /api/v1/users/{id} +GET /api/v1/users/{id}/goals +GET /api/v1/watch-queue +GET /api/v1/watchlist +GET /api/v1/watchlist/{movie_id} +GET /api/v1/wrapups +GET /api/v1/wrapups/{id} +GET /api/v1/wrapups/{id}/report +POST /api/v1/admin/blocked-domains +POST /api/v1/auth/login +POST /api/v1/auth/register +POST /api/v1/goals +POST /api/v1/import/profiles +POST /api/v1/import/sessions +POST /api/v1/import/sessions/{id}/confirm +POST /api/v1/movies/{id}/sync-poster +POST /api/v1/reviews +POST /api/v1/settings/webhook-tokens +POST /api/v1/social/block +POST /api/v1/social/follow +POST /api/v1/social/followers/accept +POST /api/v1/social/followers/reject +POST /api/v1/social/followers/remove +POST /api/v1/social/unblock +POST /api/v1/social/unfollow +POST /api/v1/watch-queue/confirm +POST /api/v1/watch-queue/dismiss +POST /api/v1/watchlist +POST /api/v1/webhooks/jellyfin +POST /api/v1/webhooks/plex +POST /api/v1/wrapups/generate +PUT /api/v1/goals/{year} +PUT /api/v1/import/sessions/{id}/mapping +PUT /api/v1/profile +PUT /api/v1/profile/fields +PUT /api/v1/settings 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..6687aaab --- /dev/null +++ b/crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt @@ -0,0 +1,54 @@ +DELETE /api/v1/goals/{year} +DELETE /api/v1/import/profiles/{id} +DELETE /api/v1/reviews/{id} +DELETE /api/v1/settings/webhook-tokens/{id} +DELETE /api/v1/users/me +DELETE /api/v1/users/{id} +DELETE /api/v1/watchlist/{movie_id} +DELETE /api/v1/wrapups/{id} +GET /api/v1/activity-feed +GET /api/v1/diary +GET /api/v1/diary/export +GET /api/v1/goals +GET /api/v1/import/profiles +GET /api/v1/import/sessions/{id} +GET /api/v1/insights +GET /api/v1/movies +GET /api/v1/movies/{id}/history +GET /api/v1/movies/{id}/profile +GET /api/v1/movies/{movie_id} +GET /api/v1/people/{id} +GET /api/v1/people/{id}/credits +GET /api/v1/profile +GET /api/v1/search +GET /api/v1/settings +GET /api/v1/settings/webhook-tokens +GET /api/v1/users +GET /api/v1/users/{id} +GET /api/v1/users/{id}/goals +GET /api/v1/watch-queue +GET /api/v1/watchlist +GET /api/v1/watchlist/{movie_id} +GET /api/v1/wrapups +GET /api/v1/wrapups/{id} +GET /api/v1/wrapups/{id}/report +POST /api/v1/auth/login +POST /api/v1/auth/register +POST /api/v1/goals +POST /api/v1/import/profiles +POST /api/v1/import/sessions +POST /api/v1/import/sessions/{id}/confirm +POST /api/v1/movies/{id}/sync-poster +POST /api/v1/reviews +POST /api/v1/settings/webhook-tokens +POST /api/v1/watch-queue/confirm +POST /api/v1/watch-queue/dismiss +POST /api/v1/watchlist +POST /api/v1/webhooks/jellyfin +POST /api/v1/webhooks/plex +POST /api/v1/wrapups/generate +PUT /api/v1/goals/{year} +PUT /api/v1/import/sessions/{id}/mapping +PUT /api/v1/profile +PUT /api/v1/profile/fields +PUT /api/v1/settings 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/presentation/src/tests/api_handlers.rs b/crates/bootstrap/tests/http_handlers/api_handlers_test.rs similarity index 61% rename from crates/presentation/src/tests/api_handlers.rs rename to crates/bootstrap/tests/http_handlers/api_handlers_test.rs index f79e7fe8..62bf6074 100644 --- a/crates/presentation/src/tests/api_handlers.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_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; @@ -73,9 +75,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::handlers::search::get_search)) + .route("/api/v1/search", get(http_axum::api::search::get_search)) .with_state(state); let resp = app @@ -93,9 +95,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::handlers::search::get_search)) + .route("/api/v1/search", get(http_axum::api::search::get_search)) .with_state(state); let resp = app @@ -115,12 +117,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::handlers::search::get_person_handler), + get(http_axum::api::search::get_person_handler), ) .with_state(state); @@ -140,12 +141,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::handlers::search::get_person_credits_handler), + get(http_axum::api::search::get_person_credits_handler), ) .with_state(state); @@ -166,7 +166,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 +183,7 @@ async fn get_watchlist_requires_auth() { let app = Router::new() .route( "/api/v1/watchlist", - get(crate::handlers::watchlist::get_watchlist_handler), + get(http_axum::api::watchlist::get_watchlist_handler), ) .with_state(state); @@ -259,14 +259,25 @@ 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() .route( "/api/v1/social/followers/pending/count", - get(crate::handlers::social::get_pending_follower_count), + get(http_axum::api::social::get_pending_follower_count), ) .with_state(state); @@ -295,7 +306,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(http_axum::api::watchlist::get_watchlist_status), ) .with_state(state); @@ -340,7 +351,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 +360,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::handlers::social::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 +379,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::handlers::social::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( @@ -393,3 +394,160 @@ async fn following_collection_returns_500_on_federation_error() { assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } + +async fn refusal_body(resp: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice(&bytes).expect("refusal body is JSON") +} + +#[tokio::test] +async fn a_missing_resource_is_refused_with_not_found_code() { + let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub)); + let app = Router::new() + .route( + "/api/v1/people/{id}", + get(http_axum::api::search::get_person_handler), + ) + .with_state(state); + + let resp = app + .oneshot( + Request::builder() + .uri(format!("/api/v1/people/{}", Uuid::new_v4())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + let body = refusal_body(resp).await; + assert_eq!(body["code"], "NOT_FOUND"); + assert!(body["error"].is_string()); +} + +#[tokio::test] +async fn a_missing_credential_is_refused_with_unauthorized_code() { + let app = Router::new() + .route( + "/api/v1/watchlist", + get(http_axum::api::watchlist::get_watchlist_handler), + ) + .with_state(test_app_state()); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/v1/watchlist") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert_eq!(refusal_body(resp).await["code"], "UNAUTHORIZED"); +} + +#[tokio::test] +async fn a_malformed_path_parameter_is_refused_with_invalid_input_code() { + let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub)); + let app = Router::new() + .route( + "/api/v1/people/{id}", + get(http_axum::api::search::get_person_handler), + ) + .with_state(state); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/v1/people/not-a-uuid") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT"); +} + +#[tokio::test] +async fn a_malformed_json_body_is_refused_with_invalid_input_code() { + let app = Router::new() + .route( + "/api/v1/auth/login", + axum::routing::post(http_axum::api::auth::login), + ) + .with_state(test_app_state()); + + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header("content-type", "application/json") + .body(Body::from("{not json")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = refusal_body(resp).await; + assert_eq!(body["code"], "INVALID_INPUT"); + assert!(body["error"].is_string()); +} + +#[tokio::test] +async fn a_json_body_of_the_wrong_shape_is_refused_as_unprocessable() { + let app = Router::new() + .route( + "/api/v1/auth/login", + axum::routing::post(http_axum::api::auth::login), + ) + .with_state(test_app_state()); + + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT"); +} + +#[tokio::test] +async fn a_non_multipart_upload_is_refused_with_invalid_input_code() { + let app = Router::new() + .route( + "/api/v1/webhooks/plex", + axum::routing::post(http_axum::api::webhook::post_plex_webhook), + ) + .with_state(test_app_state()); + + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/webhooks/plex?token=t") + .header("content-type", "text/plain") + .body(Body::from("hello")) + .unwrap(), + ) + .await + .unwrap(); + + assert!(resp.status().is_client_error()); + assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT"); +} diff --git a/crates/presentation/src/tests/context.rs b/crates/bootstrap/tests/http_handlers/app_context_test.rs similarity index 79% rename from crates/presentation/src/tests/context.rs rename to crates/bootstrap/tests/http_handlers/app_context_test.rs index f555730e..f8998d4c 100644 --- a/crates/presentation/src/tests/context.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(crate::extractors::tests::Panic), - ); + 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 new file mode 100644 index 00000000..cdd4696b --- /dev/null +++ b/crates/bootstrap/tests/http_handlers/extractors_test.rs @@ -0,0 +1,408 @@ +use std::sync::Arc; + +use axum::{ + Router, + body::Body, + http::{Request, StatusCode}, + routing::get, +}; +use domain::{ + errors::DomainError, models::GeneratedToken, ports::AuthService, testing::InMemoryStore, + value_objects::UserId, +}; +use http_axum::extractors::{ + AdminApiUser, AdminUser, AuthenticatedUser, OptionalCookieUser, RequiredCookieUser, +}; +use tower::ServiceExt; + +use crate::support::{ + AcceptingAuth, ErroringUserRepo, make_test_state, test_app_state, test_app_state_with_user, +}; + +struct RejectingAuth; +#[async_trait::async_trait] +impl AuthService for RejectingAuth { + async fn generate_token(&self, _: &UserId) -> Result { + panic!() + } + async fn validate_token(&self, _: &str) -> Result { + Err(DomainError::Unauthorized("bad token".into())) + } +} + +// --- Routers --- + +async fn protected_handler(user: AuthenticatedUser) -> String { + user.0.value().to_string() +} +async fn optional_cookie_handler(user: OptionalCookieUser) -> String { + match user.0 { + Some(id) => id.value().to_string(), + None => "none".to_string(), + } +} +async fn required_cookie_handler(user: RequiredCookieUser) -> String { + user.0.value().to_string() +} +async fn admin_api_handler(user: AdminApiUser) -> String { + user.0.value().to_string() +} +async fn admin_cookie_handler(user: AdminUser) -> String { + user.0.value().to_string() +} + +fn router_protected(state: http_axum::state::AppState) -> Router { + Router::new() + .route("/protected", get(protected_handler)) + .with_state(state) +} +fn router_optional(state: http_axum::state::AppState) -> Router { + Router::new() + .route("/optional", get(optional_cookie_handler)) + .with_state(state) +} +fn router_admin_api(state: http_axum::state::AppState) -> Router { + Router::new() + .route("/admin-api", get(admin_api_handler)) + .with_state(state) +} +fn router_admin_cookie(state: http_axum::state::AppState) -> Router { + Router::new() + .route("/admin-cookie", get(admin_cookie_handler)) + .with_state(state) +} +fn router_required(state: http_axum::state::AppState) -> Router { + Router::new() + .route("/required", get(required_cookie_handler)) + .with_state(state) +} + +// --- Tests --- + +#[tokio::test] +async fn missing_auth_header_returns_401() { + let app = router_protected(test_app_state()); + let resp = app + .oneshot( + Request::builder() + .uri("/protected") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn optional_cookie_user_returns_none_without_cookie() { + let app = router_optional(test_app_state()); + let resp = app + .oneshot( + Request::builder() + .uri("/optional") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&body[..], b"none"); +} + +#[tokio::test] +async fn optional_cookie_user_returns_none_with_invalid_token() { + let app = router_optional(make_test_state(Arc::new(RejectingAuth))); + let resp = app + .oneshot( + Request::builder() + .uri("/optional") + .header("cookie", "token=bad.token.here") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&body[..], b"none"); +} + +#[tokio::test] +async fn required_cookie_user_redirects_without_cookie() { + let app = router_required(test_app_state()); + let resp = app + .oneshot( + Request::builder() + .uri("/required") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert_eq!(resp.headers().get("location").unwrap(), "/login"); +} + +#[tokio::test] +async fn required_cookie_user_redirects_with_invalid_token() { + let app = router_required(make_test_state(Arc::new(RejectingAuth))); + let resp = app + .oneshot( + Request::builder() + .uri("/required") + .header("cookie", "token=bad.token.here") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert_eq!(resp.headers().get("location").unwrap(), "/login"); +} + +// --- AdminApiUser / AdminUser: per-path rejection pinning --- +// +// These pin the exact rejection for every path `authorize_admin::execute` can +// produce, now that both extractors go through it instead of calling +// `repos.user.find_by_id` directly. Each test name states the path; the +// asserted status is the byte-identical-to-before-this-task value recorded in +// the task-4 report's before/after table. + +fn admin_id() -> UserId { + UserId::generate() +} + +#[tokio::test] +async fn admin_api_user_allows_an_admin_user() { + let id = admin_id(); + let user_repo = InMemoryStore::new(); + user_repo.seed_user(domain::testing::test_user_with_role( + id.clone(), + domain::models::UserRole::Admin, + )); + let app = router_admin_api(test_app_state_with_user( + user_repo as _, + Arc::new(AcceptingAuth(id)), + )); + let resp = app + .oneshot( + Request::builder() + .uri("/admin-api") + .header("authorization", "Bearer anything") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn admin_api_user_rejects_a_non_admin_user() { + let id = admin_id(); + let user_repo = InMemoryStore::new(); + user_repo.seed_user(domain::testing::test_user_with_role( + id.clone(), + domain::models::UserRole::Standard, + )); + let app = router_admin_api(test_app_state_with_user( + user_repo as _, + Arc::new(AcceptingAuth(id)), + )); + let resp = app + .oneshot( + Request::builder() + .uri("/admin-api") + .header("authorization", "Bearer anything") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn admin_api_user_rejects_an_unknown_user() { + let id = admin_id(); + let user_repo = InMemoryStore::new(); // empty — id is not in the store + let app = router_admin_api(test_app_state_with_user( + user_repo as _, + Arc::new(AcceptingAuth(id)), + )); + let resp = app + .oneshot( + Request::builder() + .uri("/admin-api") + .header("authorization", "Bearer anything") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn admin_api_user_surfaces_a_repository_error_as_500() { + let id = admin_id(); + let app = router_admin_api(test_app_state_with_user( + Arc::new(ErroringUserRepo), + Arc::new(AcceptingAuth(id)), + )); + let resp = app + .oneshot( + Request::builder() + .uri("/admin-api") + .header("authorization", "Bearer anything") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test] +async fn admin_user_allows_an_admin_user() { + let id = admin_id(); + let user_repo = InMemoryStore::new(); + user_repo.seed_user(domain::testing::test_user_with_role( + id.clone(), + domain::models::UserRole::Admin, + )); + let app = router_admin_cookie(test_app_state_with_user( + user_repo as _, + Arc::new(AcceptingAuth(id)), + )); + let resp = app + .oneshot( + Request::builder() + .uri("/admin-cookie") + .header("cookie", "token=anything") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn admin_user_rejects_a_non_admin_user() { + let id = admin_id(); + let user_repo = InMemoryStore::new(); + user_repo.seed_user(domain::testing::test_user_with_role( + id.clone(), + domain::models::UserRole::Standard, + )); + let app = router_admin_cookie(test_app_state_with_user( + user_repo as _, + Arc::new(AcceptingAuth(id)), + )); + let resp = app + .oneshot( + Request::builder() + .uri("/admin-cookie") + .header("cookie", "token=anything") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn admin_user_rejects_an_unknown_user() { + let id = admin_id(); + let user_repo = InMemoryStore::new(); // empty — id is not in the store + let app = router_admin_cookie(test_app_state_with_user( + user_repo as _, + Arc::new(AcceptingAuth(id)), + )); + let resp = app + .oneshot( + Request::builder() + .uri("/admin-cookie") + .header("cookie", "token=anything") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert_eq!(resp.headers().get("location").unwrap(), "/login"); +} + +#[tokio::test] +async fn admin_user_surfaces_a_repository_error_as_500() { + let id = admin_id(); + let app = router_admin_cookie(test_app_state_with_user( + Arc::new(ErroringUserRepo), + Arc::new(AcceptingAuth(id)), + )); + let resp = app + .oneshot( + Request::builder() + .uri("/admin-cookie") + .header("cookie", "token=anything") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +async fn path_id_handler( + http_axum::extractors::PathId(id): http_axum::extractors::PathId, +) -> 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/crates/bootstrap/tests/http_handlers/support.rs b/crates/bootstrap/tests/http_handlers/support.rs new file mode 100644 index 00000000..8beb3235 --- /dev/null +++ b/crates/bootstrap/tests/http_handlers/support.rs @@ -0,0 +1,259 @@ +#![allow(dead_code)] + +use application::config::AppSettings; +use bootstrap::Repositories; +use domain::{ + errors::DomainError, + models::GeneratedToken, + ports::{AuthService, PersonQuery, SearchPort, UserRepository}, + testing::{ + PanicAuthService, PanicDiaryExporter, PanicDiaryQuery, PanicDocumentParser, + PanicEventPublisher, PanicGoalCommand, PanicGoalQuery, PanicImportProfileRepository, + PanicImportSessionRepository, PanicInsightsQuery, PanicMetadataClient, PanicMovieCommand, + PanicMovieProfileRepository, PanicMovieQuery, PanicObjectStorage, PanicPasswordHasher, + PanicPersonCommand, PanicPersonQuery, PanicPosterFetcher, PanicProfileFieldsRepo, + PanicRefreshSessionRepository, PanicRemoteGoalRepository, PanicRemoteWatchlistRepository, + PanicReviewRepository, PanicRssFeedRenderer, PanicSearchCommand, PanicSearchPort, + PanicStatsRepository, PanicUserRepository, PanicUserSettingsRepository, + PanicWatchEventCommand, PanicWatchEventQuery, PanicWatchlistRepository, + PanicWebhookTokenRepository, PanicWrapUpRepository, PanicWrapUpStatsQuery, + }, + value_objects::UserId, +}; +use http_axum::context::AppContext; +use std::sync::Arc; + +struct PanicReviewLogger; + +#[async_trait::async_trait] +impl application::ports::ReviewLogger for PanicReviewLogger { + async fn log_review( + &self, + _: application::diary::commands::LogReviewCommand, + ) -> Result<(), DomainError> { + panic!() + } +} + +// --- Repositories factory — `Panic*` stubs everywhere --- + +pub fn test_repositories() -> Repositories { + Repositories { + movie_command: Arc::new(PanicMovieCommand) as _, + movie_query: Arc::new(PanicMovieQuery) as _, + review: Arc::new(PanicReviewRepository) as _, + diary: Arc::new(PanicDiaryQuery) as _, + stats: Arc::new(PanicStatsRepository) as _, + user: Arc::new(PanicUserRepository) as _, + import_session: Arc::new(PanicImportSessionRepository) as _, + import_profile: Arc::new(PanicImportProfileRepository) as _, + movie_profile: Arc::new(PanicMovieProfileRepository) as _, + watchlist: Arc::new(PanicWatchlistRepository) as _, + watch_event_command: Arc::new(PanicWatchEventCommand) as _, + watch_event_query: Arc::new(PanicWatchEventQuery) as _, + webhook_token: Arc::new(PanicWebhookTokenRepository) as _, + profile_fields: Arc::new(PanicProfileFieldsRepo) as _, + person_command: Arc::new(PanicPersonCommand) as _, + person_query: Arc::new(PanicPersonQuery) as _, + search_port: Arc::new(PanicSearchPort) as _, + search_command: Arc::new(PanicSearchCommand) as _, + remote_watchlist: Arc::new(PanicRemoteWatchlistRepository) as _, + social_command: Arc::new(domain::ports::noop::NoopSocialCommand) as _, + follow_graph: Arc::new(domain::ports::noop::NoopSocialQuery) as _, + block_query: Arc::new(domain::ports::noop::NoopSocialQuery) as _, + federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _, + wrapup_stats: Arc::new(PanicWrapUpStatsQuery) as _, + wrapup_repo: Arc::new(PanicWrapUpRepository) as _, + insights: Arc::new(PanicInsightsQuery) as _, + goal_command: Arc::new(PanicGoalCommand) as _, + goal_query: Arc::new(PanicGoalQuery) as _, + user_settings: Arc::new(PanicUserSettingsRepository) as _, + remote_goal: Arc::new(PanicRemoteGoalRepository) as _, + refresh_session: Arc::new(PanicRefreshSessionRepository) as _, + federated_profile: None, + } +} + +// --- Single state factory — builds `deps` from whatever `repos` it is given --- + +/// Builds an `AppState` from caller-supplied `repos`, running `bootstrap::build_deps` +/// AFTER any overrides the caller already applied to `repos`. This is the only place +/// `deps` gets built for tests, so `repos` and `deps` can never disagree. +pub fn build_test_state_from( + repos: Repositories, + auth_service: Arc, +) -> http_axum::state::AppState { + let services = application::Services { + auth: auth_service, + password_hasher: Arc::new(PanicPasswordHasher) as _, + metadata: Arc::new(PanicMetadataClient) as _, + poster_fetcher: Arc::new(PanicPosterFetcher) as _, + object_storage: Arc::new(PanicObjectStorage) as _, + event_publisher: Arc::new(PanicEventPublisher) as _, + diary_exporter: Arc::new(PanicDiaryExporter) as _, + document_parser: Arc::new(PanicDocumentParser) as _, + review_logger: Arc::new(PanicReviewLogger) as _, + person_enrichment: None, + }; + + let config = AppSettings { + allow_registration: false, + base_url: "http://localhost:3000".to_string(), + rate_limit: 20, + refresh_ttl_seconds: 2_592_000, + secure_cookies: false, + cors_origins: String::new(), + wrapup: application::config::WrapUpSettings { + font_path: None, + logo_path: None, + bg_dir: None, + }, + }; + + let instance = domain::value_objects::InstanceIdentity::new("http://localhost:3000"); + + let deps = Arc::new(bootstrap::build_deps(&repos, &services, &config, &instance)); + + http_axum::state::AppState { + app_ctx: AppContext { + deps, + services, + config, + instance, + #[cfg(feature = "federation")] + ap_document: Arc::new(domain::ports::noop::NoopApDocument), + #[cfg(feature = "federation")] + ap_blocklist: Arc::new(domain::ports::noop::NoopInstanceBlocklist), + }, + rss_renderer: Arc::new(PanicRssFeedRenderer), + } +} + +pub fn make_test_state(auth_service: Arc) -> 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)) +} + +pub fn test_app_state_with_repos( + adjust: impl FnOnce(&mut Repositories), + auth_service: Arc, +) -> http_axum::state::AppState { + let mut repos = test_repositories(); + adjust(&mut repos); + build_test_state_from(repos, auth_service) +} + +/// Test state with `user` overridden BEFORE `build_deps` runs, so the stub is +/// visible from both `app_ctx.repos.user` and `deps.users.authorize_admin.user` +/// — the field the `AdminApiUser`/`AdminUser` extractors now read through. +pub fn test_app_state_with_user( + user: Arc, + 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, + _: &[domain::events::DomainEvent], + ) -> Result<(), DomainError> { + panic!() + } + async fn mark_deleted( + &self, + _: &UserId, + _: &[domain::events::DomainEvent], + ) -> Result<(), DomainError> { + panic!() + } + async fn find_tombstoned_by_id( + &self, + _: &UserId, + ) -> Result, 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, +/// 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()) + } +} diff --git a/crates/bootstrap/tests/ledger_recovery_test.rs b/crates/bootstrap/tests/ledger_recovery_test.rs new file mode 100644 index 00000000..d84d0eed --- /dev/null +++ b/crates/bootstrap/tests/ledger_recovery_test.rs @@ -0,0 +1,99 @@ +mod support; + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use application::worker::WorkerService; +use async_trait::async_trait; +use domain::{ + errors::DomainError, + events::{AckHandle, DomainEvent, EventEnvelope, EventId}, + ports::{Begin, EventConsumer, EventHandler, ProcessedEventLedger}, +}; +use futures::stream::BoxStream; +use sqlite::SqliteProcessedEventLedger; + +struct Counting(Arc); + +#[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)) + } +} + +#[tokio::test] +async fn an_abandoned_claim_defers_deliveries_until_the_lease_lapses_then_runs_once() { + let pool = support::memory_pool().await; + sqlite::migrate(&pool).await.unwrap(); + let ledger = Arc::new(SqliteProcessedEventLedger::with_lease( + pool, + Duration::from_secs(1), + )); + let id = EventId::new(); + assert!(matches!( + ledger.begin(&id, "crashy").await.unwrap(), + Begin::Run(_) + )); + + let runs = Arc::new(AtomicU32::new(0)); + let outcomes = Arc::new(Mutex::new(Vec::new())); + let deliver = || { + let envelope = EventEnvelope::new( + Some(id), + DomainEvent::SearchReindexRequested, + Box::new(Outcome(outcomes.clone())), + ); + let service = WorkerService::new( + Arc::new(Once(Mutex::new(Some(envelope)))), + vec![Arc::new(Counting(runs.clone()))], + ledger.clone(), + ) + .unwrap(); + async move { service.run(tokio::sync::watch::channel(false).1).await } + }; + + deliver().await; + assert_eq!(*outcomes.lock().unwrap(), ["defer"]); + assert_eq!(runs.load(Ordering::SeqCst), 0); + + tokio::time::sleep(Duration::from_millis(2100)).await; + deliver().await; + assert_eq!(*outcomes.lock().unwrap(), ["defer", "ack"]); + assert_eq!(runs.load(Ordering::SeqCst), 1); + + deliver().await; + assert_eq!(runs.load(Ordering::SeqCst), 1, "done claims never re-run"); +} diff --git a/crates/bootstrap/tests/outbox_relay_test.rs b/crates/bootstrap/tests/outbox_relay_test.rs new file mode 100644 index 00000000..c1d9d5cd --- /dev/null +++ b/crates/bootstrap/tests/outbox_relay_test.rs @@ -0,0 +1,209 @@ +mod support; + +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::Notify; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + events::{DomainEvent, EventId}, + ports::{EventPublisher, EventRelaySink}, + value_objects::UserId, +}; +use sqlite::SqliteEventQueue; +use uuid::Uuid; + +#[derive(Default)] +struct FakeSink { + fail: bool, + fail_first: std::sync::atomic::AtomicUsize, + slow: Option, + started: Notify, + 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 let Some(delay) = self.slow { + self.started.notify_one(); + tokio::time::sleep(delay).await; + } + let outage = self + .fail_first + .try_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |n| n.checked_sub(1), + ) + .is_ok(); + if self.fail || outage { + Err(DomainError::InfrastructureError("bus down".into())) + } else { + Ok(()) + } + } +} + +async fn outbox() -> (sqlx::SqlitePool, Arc, 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( + 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..800 { + if row(pool).await.0 == status { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("row never reached {status}: {:?}", row(pool).await); +} + +fn start( + queue: SqliteEventQueue, + sink: Arc, +) -> ( + 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) + .with_backoff(|_| Duration::from_millis(10)); + (stop, tokio::spawn(relay.run(rx))) +} + +fn event() -> DomainEvent { + DomainEvent::UserUpdated { + user_id: UserId::from_uuid(Uuid::new_v4()), + } +} + +#[tokio::test] +async fn relay_marks_rows_done_only_after_publish_succeeds() { + let (pool, publisher, queue) = outbox().await; + publisher.publish(&event()).await.unwrap(); + let sink = Arc::new(FakeSink::default()); + + let (stop, relay) = start(queue, sink.clone()); + wait_for(&pool, "done").await; + relay.abort(); + drop(stop); + + let (_, attempts, event_id) = row(&pool).await; + assert_eq!(attempts, 0); + let seen = sink.seen.lock().unwrap(); + assert_eq!(seen.len(), 1); + assert_eq!( + seen[0].as_ref().map(|id| id.value().to_string()), + event_id, + "the sink gets the row's own event id" + ); +} + +#[tokio::test] +async fn a_failing_bus_never_spends_the_rows_attempts_and_the_row_relays_on_recovery() { + let (pool, publisher, queue) = outbox_with_attempts(2).await; + publisher.publish(&event()).await.unwrap(); + let sink = Arc::new(FakeSink { + fail_first: std::sync::atomic::AtomicUsize::new(3), + ..Default::default() + }); + + let (stop, relay) = start(queue, sink.clone()); + wait_for(&pool, "done").await; + relay.abort(); + drop(stop); + + let (status, attempts, _) = row(&pool).await; + assert_eq!((status.as_str(), attempts), ("done", 0)); + assert_eq!( + sink.seen.lock().unwrap().len(), + 4, + "3 failures then success" + ); +} + +#[tokio::test] +async fn a_failed_publish_defers_the_row_without_counting_an_attempt() { + let (pool, publisher, queue) = outbox().await; + publisher.publish(&event()).await.unwrap(); + let sink = Arc::new(FakeSink { + fail: true, + ..Default::default() + }); + + let (stop, relay) = start(queue, sink.clone()); + for _ in 0..200 { + let last_error: Option = sqlx::query_scalar("SELECT last_error FROM event_queue") + .fetch_one(&pool) + .await + .unwrap(); + if last_error.as_deref() == Some("deferred") { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + relay.abort(); + drop(stop); + + let (status, attempts, _) = row(&pool).await; + assert_eq!((status.as_str(), attempts), ("pending", 0)); + let last_error: Option = sqlx::query_scalar("SELECT last_error FROM event_queue") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(last_error.as_deref(), Some("deferred")); +} + +#[tokio::test] +async fn shutdown_lets_the_in_flight_row_finish_then_stops_the_relay() { + let (pool, publisher, queue) = outbox().await; + publisher.publish(&event()).await.unwrap(); + publisher.publish(&event()).await.unwrap(); + let sink = Arc::new(FakeSink { + slow: Some(Duration::from_millis(200)), + ..Default::default() + }); + let (stop, relay) = start(queue, sink.clone()); + + sink.started.notified().await; + stop.send(true).unwrap(); + tokio::time::timeout(Duration::from_secs(5), relay) + .await + .expect("relay stops on shutdown") + .unwrap(); + + let statuses: Vec = 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/bootstrap/tests/route_smoke_test.rs b/crates/bootstrap/tests/route_smoke_test.rs new file mode 100644 index 00000000..e99ba9d8 --- /dev/null +++ b/crates/bootstrap/tests/route_smoke_test.rs @@ -0,0 +1,298 @@ +mod support; + +use axum::{ + Router, + body::Body, + http::{Method, Request, header}, +}; +use support::{get, json_request, send}; + +const EMAIL: &str = "owner@example.com"; +const USERNAME: &str = "owner"; + +const CLASSIC_VISITOR_PAGES: &[(&str, u16)] = &[ + ("/", 200), + ("/users", 200), + ("/u/{username}", 308), + ("/users/{id}", 200), + ("/users/{id}?view=ratings", 200), + ("/users/{id}?view=history", 200), + ("/movies/{movie}", 200), + ("/login", 200), + ("/register", 200), + ("/logout", 303), + ("/tags/drama", 307), + ("/users/{id}/watchlist", 200), + ("/wrapups/{id}/2025", 200), + ("/wrapups/global/2025", 200), + ("/users/{id}/following-list", 303), + ("/users/{id}/followers-list", 303), + ("/posters/abc.jpg", 308), + ("/images/missing.jpg", 404), + ("/health", 200), +]; + +const CLASSIC_OWNER_PAGES: &[(&str, u16)] = &[ + ("/reviews/new", 200), + ("/diary/export", 200), + ("/import", 200), + ("/import/done", 200), + ("/import/00000000-0000-0000-0000-000000000000/mapping", 303), + ("/import/00000000-0000-0000-0000-000000000000/preview", 303), + ("/settings/profile", 200), + ("/settings/integrations", 200), + ("/watch-queue", 200), + ("/users/{id}/watchlist", 200), + ("/users/{id}/following-list", 200), + ("/users/{id}/followers-list", 200), + ("/movies/{movie}", 200), + ("/users/{id}?embed=true", 200), +]; + +#[cfg(feature = "federation")] +const FEDERATION_VISITOR_PAGES: &[(&str, u16)] = &[ + ("/users/{id}/followers", 303), + ("/users/{id}/following", 303), +]; + +#[cfg(feature = "federation")] +const FEDERATION_OWNER_PAGES: &[(&str, u16)] = &[ + ("/users/{id}/followers", 303), + ("/users/{id}/following", 303), + ("/admin/blocked-domains", 403), + ("/social/blocked", 200), +]; + +#[cfg(not(feature = "federation"))] +const FEDERATION_OFF_PAGES: &[(&str, u16)] = &[ + ("/users/{id}/followers", 308), + ("/users/{id}/following", 308), +]; + +struct Seed { + router: Router, + user_id: String, + movie_id: String, + owner_cookie: String, +} + +async fn seed() -> Seed { + let (router, pool) = support::server().await; + let session = support::register(&router, USERNAME, EMAIL).await; + let (token, user_id, owner_cookie) = (session.token, session.user_id, session.cookie); + + let (status, _, body) = send( + &router, + json_request( + Method::POST, + "/api/v1/reviews", + Some(&token), + serde_json::json!({ + "manual_title": "Seeded Movie", + "manual_release_year": 1999, + "manual_director": "Some Director", + "rating": 4, + "comment": "the butler did it", + "watched_at": "2025-03-01", + "spoilers": true, + }), + ), + ) + .await; + assert_eq!(status, 201, "log review: {body}"); + + let (status, _, body) = send( + &router, + json_request( + Method::POST, + "/api/v1/goals", + Some(&token), + serde_json::json!({ "year": 2025, "target_count": 12 }), + ), + ) + .await; + assert_eq!(status, 200, "create goal: {body}"); + + let (status, _, body) = send( + &router, + Request::builder() + .uri("/api/v1/movies") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(status, 200, "list movies: {body}"); + let movies: serde_json::Value = serde_json::from_str(&body).unwrap(); + let movie_id = movies["items"][0]["id"] + .as_str() + .unwrap_or_else(|| panic!("no movie id in {body}")) + .to_string(); + + seed_ready_wrapup(&pool, Some(&user_id)).await; + seed_ready_wrapup(&pool, None).await; + + Seed { + router, + user_id, + movie_id, + owner_cookie, + } +} + +async fn seed_ready_wrapup(pool: &sqlx::SqlitePool, user_id: Option<&str>) { + use chrono::NaiveDate; + use domain::models::wrapup::{DateRange, WrapUpScope}; + + let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + let end = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); + let scope = match user_id { + Some(id) => WrapUpScope::User(id.parse().unwrap()), + None => WrapUpScope::Global, + }; + let report = domain::services::wrapup_analyzer::build_report( + scope, + DateRange::new(start, end).unwrap(), + &[], + ); + sqlx::query( + "INSERT INTO wrap_up_records (id, user_id, start_date, end_date, status, report_json) \ + VALUES (?, ?, '2025-01-01', '2026-01-01', 'ready', ?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(user_id) + .bind(serde_json::to_string(&report).unwrap()) + .execute(pool) + .await + .unwrap(); +} + +fn expand(seed: &Seed, template: &str) -> String { + template + .replace("{id}", &seed.user_id) + .replace("{username}", USERNAME) + .replace("{movie}", &seed.movie_id) +} + +async fn check_table(seed: &Seed, table: &[(&str, u16)], cookie: Option<&str>) { + let mut failures = Vec::new(); + for (template, expected) in table { + let uri = expand(seed, template); + let (status, _, _) = send(&seed.router, get(&uri, cookie)).await; + if status != *expected { + failures.push(format!("{uri}: expected {expected}, got {status}")); + } + } + assert!( + failures.is_empty(), + "route status drift:\n{}", + failures.join("\n") + ); +} + +#[tokio::test] +async fn every_classic_page_renders_for_a_visitor() { + let seed = seed().await; + check_table(&seed, CLASSIC_VISITOR_PAGES, None).await; + #[cfg(feature = "federation")] + check_table(&seed, FEDERATION_VISITOR_PAGES, None).await; + #[cfg(not(feature = "federation"))] + check_table(&seed, FEDERATION_OFF_PAGES, None).await; + + for template in ["/wrapups/{id}/2025", "/wrapups/global/2025"] { + let (status, _, body) = send(&seed.router, get(&expand(&seed, template), None)).await; + assert_eq!(status, 200, "{template}"); + assert!( + body.contains("class=\"wu-container\""), + "wrapup template not rendered for {template}" + ); + } + + let (status, _, body) = send( + &seed.router, + get(&expand(&seed, "/users/{id}?embed=true"), None), + ) + .await; + assert_eq!(status, 200); + assert!( + body.contains("class=\"embed-profile\""), + "embed template not rendered: {body}" + ); +} + +#[tokio::test] +async fn every_classic_page_renders_for_the_signed_in_owner() { + let seed = seed().await; + let cookie = seed.owner_cookie.clone(); + check_table(&seed, CLASSIC_OWNER_PAGES, Some(&cookie)).await; + #[cfg(feature = "federation")] + check_table(&seed, FEDERATION_OWNER_PAGES, Some(&cookie)).await; +} + +#[tokio::test] +async fn feeds_and_openapi_are_served() { + let seed = seed().await; + for (template, expected) in [ + ("/feed.rss", 200), + ("/users/{id}/feed.rss", 200), + ("/openapi.json", 200), + ("/docs", 303), + ("/docs/", 200), + ] { + let uri = expand(&seed, template); + let (status, _, _) = send(&seed.router, get(&uri, None)).await; + assert_eq!(status, expected, "{uri}"); + } +} + +#[tokio::test] +async fn openapi_paths_match_the_recorded_list() { + let seed = seed().await; + let (status, _, body) = send(&seed.router, get("/openapi.json", None)).await; + assert_eq!(status, 200); + let spec: serde_json::Value = serde_json::from_str(&body).unwrap(); + let mut lines = Vec::new(); + for (path, item) in spec["paths"].as_object().unwrap() { + for method in item.as_object().unwrap().keys() { + if ["get", "put", "post", "delete", "patch", "head", "options"] + .contains(&method.as_str()) + { + lines.push(format!("{} {path}", method.to_uppercase())); + } + } + } + lines.sort(); + let actual = lines.join("\n") + "\n"; + + let fixture = if cfg!(feature = "federation") { + "tests/fixtures/openapi_paths.txt" + } else { + "tests/fixtures/openapi_paths_federation_off.txt" + }; + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(fixture); + if std::env::var("UPDATE_OPENAPI_FIXTURE").is_ok() { + std::fs::write(&path, &actual).unwrap(); + } + let expected = std::fs::read_to_string(&path).unwrap(); + assert_eq!(actual, expected, "OpenAPI paths drifted from {fixture}"); +} + +#[cfg(feature = "federation")] +#[tokio::test] +async fn profile_page_serves_the_actor_document_to_activitypub_clients() { + let seed = seed().await; + let req = Request::builder() + .method(Method::GET) + .uri(format!("/users/{}", seed.user_id)) + .header(header::ACCEPT, "application/activity+json") + .body(Body::empty()) + .unwrap(); + let (status, headers, body) = send(&seed.router, req).await; + assert_eq!(status, 200, "{body}"); + assert_eq!( + headers.get(header::CONTENT_TYPE).unwrap(), + "application/activity+json" + ); + let actor: serde_json::Value = serde_json::from_str(&body).expect("actor JSON"); + assert_eq!(actor["type"], "Person"); +} diff --git a/crates/bootstrap/tests/support/mod.rs b/crates/bootstrap/tests/support/mod.rs new file mode 100644 index 00000000..089a009a --- /dev/null +++ b/crates/bootstrap/tests/support/mod.rs @@ -0,0 +1,153 @@ +#![allow(dead_code)] + +use axum::{ + Router, + body::Body, + http::{Method, Request, header}, +}; +use tower::ServiceExt; + +pub const PASSWORD: &str = "correct-horse-battery"; + +pub fn test_config() -> config::AppConfig { + config::AppConfig { + server: config::ServerConfig { + allow_registration: true, + base_url: "http://localhost:3000".into(), + ..Default::default() + }, + database: config::DatabaseConfig { + url: "sqlite::memory:".into(), + }, + auth: config::AuthConfig { + jwt_secret: Some("route-smoke-test-secret-0123456789abcdef".into()), + ..Default::default() + }, + events: Default::default(), + nats: Default::default(), + storage: Default::default(), + image_conversion: Default::default(), + metadata: config::MetadataConfig { + tmdb_api_key: Some("test-key".into()), + ..Default::default() + }, + federation: Default::default(), + wrapup: Default::default(), + } +} + +pub async fn send(router: &Router, mut req: Request) -> (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() +} + +pub async fn memory_pool() -> sqlx::SqlitePool { + sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap() +} + +pub async fn server() -> (Router, sqlx::SqlitePool) { + let pool = memory_pool().await; + let router = bootstrap::build_server_on(&test_config(), pool.clone()) + .await + .unwrap(); + (router, pool) +} + +pub struct Session { + pub user_id: String, + pub token: String, + pub cookie: String, +} + +pub async fn register(router: &Router, username: &str, email: &str) -> Session { + let (status, headers, _) = send(router, get("/register", None)).await; + assert_eq!(status, 200, "GET /register"); + let csrf = cookie_value(&headers, "csrf").expect("csrf cookie"); + let form = format!( + "email={}&username={username}&password={PASSWORD}&_csrf={csrf}", + email.replace('@', "%40") + ); + let req = Request::builder() + .method(Method::POST) + .uri("/register") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, format!("csrf={csrf}")) + .body(Body::from(form)) + .unwrap(); + let (status, headers, _) = send(router, req).await; + assert_eq!(status, 303, "POST /register"); + let session = cookie_value(&headers, "token").expect("session cookie after register"); + let cookie = format!("csrf={csrf}; token={session}"); + + let (status, _, body) = send( + router, + json_request( + Method::POST, + "/api/v1/auth/login", + None, + serde_json::json!({ "email": email, "password": PASSWORD }), + ), + ) + .await; + assert_eq!(status, 200, "login: {body}"); + let login: serde_json::Value = serde_json::from_str(&body).unwrap(); + Session { + user_id: login["user_id"].as_str().unwrap().to_string(), + token: login["token"].as_str().unwrap().to_string(), + cookie, + } +} diff --git a/crates/composition/Cargo.toml b/crates/composition/Cargo.toml deleted file mode 100644 index e4dab622..00000000 --- a/crates/composition/Cargo.toml +++ /dev/null @@ -1,46 +0,0 @@ -[package] -name = "composition" -version = "0.1.0" -edition = "2024" - -[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 } -application = { workspace = true } -infra-wiring = { workspace = true } -auth = { workspace = true } -metadata = { workspace = true } -poster-fetcher = { workspace = true } -object-storage = { workspace = true } -jellyfin = { workspace = true } -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 } -chrono = { workspace = true } -domain = { workspace = true, features = ["test-helpers"] } -sqlx = { workspace = true } -tokio = { workspace = true } -uuid = { workspace = true } diff --git a/crates/composition/src/factory.rs b/crates/composition/src/factory.rs deleted file mode 100644 index 6e36146e..00000000 --- a/crates/composition/src/factory.rs +++ /dev/null @@ -1,200 +0,0 @@ -use std::sync::Arc; - -use anyhow::Context; -use domain::ports::{ - AuthService, ImageRefCommand, ImageRefQuery, LocalApContentQuery, MetadataClient, - MovieDeduplicator, ObjectStorage, PasswordHasher, PosterFetcherClient, - RefreshSessionRepository, UserProfileFieldsRepository, WatchEventCommand, WatchEventQuery, - WebhookTokenRepository, -}; - -pub use infra_wiring::DbPool; - -pub struct DatabaseOutput { - pub movie_command: Arc, - pub movie_query: Arc, - pub review: Arc, - pub diary: Arc, - pub stats: Arc, - pub user: Arc, - pub import_session: Arc, - pub import_profile: Arc, - pub movie_profile: Arc, - pub watchlist: Arc, - pub watch_event_command: Arc, - pub watch_event_query: Arc, - pub webhook_token: Arc, - pub person_command: Arc, - pub person_query: Arc, - pub search_port: Arc, - pub search_command: Arc, - pub profile_fields: Arc, - 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, - pub federation_settings: std::sync::Arc, - pub remote_goal: Arc, - pub refresh_session: Arc, - pub deduplicator: Arc, - pub image_ref_command: Arc, - pub image_ref_query: Arc, - pub follow_command: Arc, - pub follow_query: Arc, - pub db_pool: DbPool, -} - -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)" - ), - } -} - -pub fn build_auth_adapters() -> anyhow::Result<(Arc, Arc)> { - auth::create() -} - -pub fn build_metadata_client() -> anyhow::Result> { - metadata::create() -} - -pub fn build_poster_fetcher() -> anyhow::Result> { - poster_fetcher::create() -} - -pub fn build_object_storage() -> anyhow::Result> { - object_storage::create() -} - -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"))] - _ => anyhow::bail!("no profile fields repo for this backend"), - } -} 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/composition/src/tests/build.rs b/crates/composition/src/tests/build.rs deleted file mode 100644 index 6a506570..00000000 --- a/crates/composition/src/tests/build.rs +++ /dev/null @@ -1,1089 +0,0 @@ -use std::sync::Arc; - -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, - 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 -/// `InMemorySocialRepository` (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 = InMemoryMovieRepository::new(); - let watch_events = InMemoryWatchEventRepository::new(); - let goals = InMemoryGoalRepository::new(); - let social = InMemorySocialRepository::new(); - - Repositories { - movie_command: Arc::clone(&movies) as _, - movie_query: movies as _, - review: InMemoryReviewRepository::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(), - watch_event_command: Arc::clone(&watch_events) as _, - watch_event_query: watch_events as _, - webhook_token: InMemoryWebhookTokenRepository::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(), - 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: InMemoryUserSettingsRepository::new(), - remote_goal: Arc::new(NoopRemoteGoalRepository), - refresh_session: InMemoryRefreshSessionRepository::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`. 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")] -fn test_db_pool() -> infra_wiring::DbPool { - let pool = sqlx::sqlite::SqlitePoolOptions::new() - .connect_lazy("sqlite::memory:") - .expect("lazy sqlite pool"); - 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(); - let goals = InMemoryGoalRepository::new(); - let user_settings = InMemoryUserSettingsRepository::new(); - - DatabaseOutput { - movie_command: Arc::clone(&movies) as _, - movie_query: movies as _, - review: InMemoryReviewRepository::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(), - watch_event_command: Arc::clone(&watch_events) as _, - watch_event_query: watch_events as _, - webhook_token: InMemoryWebhookTokenRepository::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(), - 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: InMemoryRefreshSessionRepository::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, - wrapup: infra_wiring::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/config/Cargo.toml b/crates/config/Cargo.toml new file mode 100644 index 00000000..1d2e7bc1 --- /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, features = ["derive"] } +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..4401ef8e --- /dev/null +++ b/crates/config/src/lenient.rs @@ -0,0 +1,74 @@ +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) + } +} + +pub fn true_or_one<'de, D: Deserializer<'de>>(d: D) -> Result { + d.deserialize_any(Flag { accepts_one: 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..a06a4d4d --- /dev/null +++ b/crates/config/src/loader.rs @@ -0,0 +1,141 @@ +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"; + +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"), +]; + +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")))?; + 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()) + .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); + + figment + .data() + .map_err(|e| ConfigError::Unreadable(Box::new(e)))?; + 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..1192ace4 --- /dev/null +++ b/crates/config/tests/loader_test.rs @@ -0,0 +1,422 @@ +#![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 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 [ + ("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(()) + }); +} + +#[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(()) + }); +} + +#[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/domain/Cargo.toml b/crates/domain/Cargo.toml index 1e2cc03e..bcc201e8 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -1,22 +1,23 @@ [package] name = "domain" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] -uuid = { workspace = true } -chrono = { workspace = true } +uuid = { workspace = true, features = ["v4", "v5", "serde"] } +chrono = { workspace = true, features = ["clock", "serde"] } async-trait = { workspace = true } thiserror = { workspace = true } bytes = { workspace = true } futures = { workspace = true } -serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -email_address = "0.2.9" +email_address = { workspace = true } [dev-dependencies] -tokio = { workspace = true } +domain = { path = ".", features = ["test-helpers"] } +tokio = { workspace = true, features = ["rt", "macros"] } [features] test-helpers = [] diff --git a/crates/domain/src/errors/mod.rs b/crates/domain/src/errors/mod.rs index 4e1dbf1d..a84f8998 100644 --- a/crates/domain/src/errors/mod.rs +++ b/crates/domain/src/errors/mod.rs @@ -17,8 +17,14 @@ pub enum DomainError { #[error("Unauthorized: {0}")] Unauthorized(String), + #[error("Unauthorized: {0}")] + InvalidCredentials(String), + #[error("Forbidden: {0}")] Forbidden(String), + + #[error("Conflict: {0}")] + Conflict(String), } impl DomainError { @@ -26,7 +32,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..ac6f31a5 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -148,27 +148,61 @@ pub enum DomainEvent { pub trait AckHandle: Send + Sync { async fn ack(&self) -> Result<(), DomainError>; async fn nack(&self) -> Result<(), DomainError>; + async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError>; +} + +pub fn retry_backoff(attempt: u32) -> std::time::Duration { + let secs = 5u64 * (1u64 << attempt.min(6)); + std::time::Duration::from_secs(secs.min(300)) +} + +#[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 } + 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> { 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 } } - -#[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/account_purge.rs b/crates/domain/src/ports/account_purge.rs new file mode 100644 index 00000000..e1c8f563 --- /dev/null +++ b/crates/domain/src/ports/account_purge.rs @@ -0,0 +1,24 @@ +use async_trait::async_trait; + +use crate::{errors::DomainError, value_objects::UserId}; + +#[async_trait] +pub trait FederationDeletionNotifier: Send + Sync { + async fn notify_deleted(&self, user: &UserId) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait AccountPurger: Send + Sync { + async fn is_purged(&self, user: &UserId) -> Result; + + async fn owned_image_keys(&self, user: &UserId) -> Result, DomainError>; + + async fn purge(&self, user: &UserId) -> Result<(), DomainError>; + + async fn forget_images(&self, user: &UserId) -> Result<(), DomainError>; + + async fn purge_expired_tombstones( + &self, + cutoff: chrono::NaiveDateTime, + ) -> Result; +} diff --git a/crates/domain/src/ports/auth.rs b/crates/domain/src/ports/auth.rs index a1e95bfd..352d1198 100644 --- a/crates/domain/src/ports/auth.rs +++ b/crates/domain/src/ports/auth.rs @@ -23,7 +23,16 @@ pub trait UserRepository: Send + Sync { &self, user_id: &UserId, profile: &crate::models::UserProfile, + events: &[crate::events::DomainEvent], ) -> Result<(), DomainError>; + async fn mark_deleted( + &self, + user_id: &UserId, + events: &[crate::events::DomainEvent], + ) -> Result<(), DomainError>; + async fn find_tombstoned_by_id(&self, id: &UserId) -> Result, DomainError>; + async fn is_username_retired(&self, username: &Username) -> Result; + async fn count_active_admins(&self) -> Result; } #[async_trait] @@ -36,6 +45,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/diary.rs b/crates/domain/src/ports/diary.rs index 5c742f4c..15c2af5a 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,21 @@ 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_and_orphaned_movie( + &self, + review_id: &ReviewId, + 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/ports/events.rs b/crates/domain/src/ports/events.rs index 99df3530..904abc69 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,11 +10,17 @@ pub trait EventPublisher: Send + Sync { async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>; } +#[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>; } #[async_trait] pub trait EventHandler: Send + Sync { + fn name(&self) -> &'static str; async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>; } diff --git a/crates/domain/src/ports/federation.rs b/crates/domain/src/ports/federation.rs index 6ec92eb4..064af5f7 100644 --- a/crates/domain/src/ports/federation.rs +++ b/crates/domain/src/ports/federation.rs @@ -8,6 +8,9 @@ use crate::{ #[async_trait] pub trait ApDocumentPort: Send + Sync { + 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, @@ -49,49 +52,3 @@ pub trait ApBackfillPort: Send + Sync { follower_inbox_url: String, ) -> Result<(), DomainError>; } - -#[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(); - } -} diff --git a/crates/domain/src/ports/follow.rs b/crates/domain/src/ports/follow.rs index 427ed160..1af12c17 100644 --- a/crates/domain/src/ports/follow.rs +++ b/crates/domain/src/ports/follow.rs @@ -2,49 +2,48 @@ use async_trait::async_trait; use crate::{ errors::DomainError, + events::DomainEvent, value_objects::{FollowRelation, FollowStatus, SocialActor}, }; +#[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( + 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/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/images.rs b/crates/domain/src/ports/images.rs index a16fe31b..4e0e5ee8 100644 --- a/crates/domain/src/ports/images.rs +++ b/crates/domain/src/ports/images.rs @@ -11,6 +11,7 @@ pub trait ObjectStorage: Send + Sync { key: &str, ) -> Result>, DomainError>; async fn delete(&self, key: &str) -> Result<(), DomainError>; + async fn exists(&self, key: &str) -> Result; } #[async_trait] 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/mod.rs b/crates/domain/src/ports/mod.rs index 07793697..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; @@ -14,12 +15,14 @@ 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; pub mod watchlist; pub mod wrapup; +pub use account_purge::*; pub use auth::*; pub use diary::*; pub use events::*; @@ -35,6 +38,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/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/noop.rs b/crates/domain/src/ports/noop.rs index 28007f00..9bde4778 100644 --- a/crates/domain/src/ports/noop.rs +++ b/crates/domain/src/ports/noop.rs @@ -34,6 +34,17 @@ impl super::RemoteWatchlistRepository for NoopRemoteWatchlistRepository { } } +// ── NoopFederationDeletionNotifier ─────────────────────────────────────────── + +pub struct NoopFederationDeletionNotifier; + +#[async_trait] +impl super::FederationDeletionNotifier for NoopFederationDeletionNotifier { + async fn notify_deleted(&self, _: &UserId) -> Result<(), DomainError> { + Ok(()) + } +} + // ── NoopSocialCommand ──────────────────────────────────────────────────────── pub struct NoopSocialCommand; @@ -44,25 +55,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/processed_events.rs b/crates/domain/src/ports/processed_events.rs new file mode 100644 index 00000000..352eae40 --- /dev/null +++ b/crates/domain/src/ports/processed_events.rs @@ -0,0 +1,46 @@ +use async_trait::async_trait; + +use crate::{errors::DomainError, events::EventId}; + +pub const HANDLER_LEASE_SECONDS: i64 = 1800; + +#[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 { + Run(ClaimToken), + AlreadyDone, + InProgressElsewhere { retry_after: std::time::Duration }, +} + +#[async_trait] +pub trait ProcessedEventLedger: Send + Sync { + async fn begin(&self, id: &EventId, handler: &str) -> Result; + + async fn complete( + &self, + id: &EventId, + handler: &str, + token: &ClaimToken, + ) -> Result<(), DomainError>; + + async fn release( + &self, + id: &EventId, + handler: &str, + token: &ClaimToken, + ) -> Result<(), DomainError>; + + async fn delete_older_than(&self, cutoff: chrono::NaiveDateTime) -> Result; +} diff --git a/crates/domain/src/ports/rss.rs b/crates/domain/src/ports/rss.rs index 3062a934..999163c8 100644 --- a/crates/domain/src/ports/rss.rs +++ b/crates/domain/src/ports/rss.rs @@ -1,12 +1,9 @@ +use crate::errors::DomainError; use crate::models::DiaryEntry; -/// Channel-level metadata for a rendered feed. Paths are site-relative -/// (e.g. `/users/{id}`); the renderer resolves them against its base URL. pub struct FeedChannel<'a> { pub title: &'a str, - /// Human-readable page the channel represents. pub link_path: &'a str, - /// Where the feed itself is served, advertised as `atom:link rel="self"`. pub self_path: &'a str, } @@ -15,5 +12,5 @@ pub trait RssFeedRenderer: Send + Sync { &self, entries: &[DiaryEntry], channel: &FeedChannel<'_>, - ) -> Result; + ) -> Result; } diff --git a/crates/domain/src/ports/social.rs b/crates/domain/src/ports/social.rs index 6680e9f6..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>; } @@ -150,164 +174,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/ports/watchlist.rs b/crates/domain/src/ports/watchlist.rs index ff87adc4..cfb53272 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,20 @@ 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>; 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/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/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/factories.rs b/crates/domain/src/testing/factories.rs new file mode 100644 index 00000000..81c2a3cc --- /dev/null +++ b/crates/domain/src/testing/factories.rs @@ -0,0 +1,130 @@ +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; + +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/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..95a5bb52 --- /dev/null +++ b/crates/domain/src/testing/fakes/mod.rs @@ -0,0 +1,27 @@ +mod insights; +mod noops; +mod panics; +mod refusing; +mod store; +mod store_goal; +mod store_import; +mod store_movie; +mod store_processed_events; +mod store_review; +mod store_session; +mod store_social; +mod store_user; +mod store_watch_event; +mod store_watchlist; +mod stubs; +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, +}; +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 73% rename from crates/domain/src/testing/noops.rs rename to crates/domain/src/testing/fakes/noops.rs index c3d9a851..341385d4 100644 --- a/crates/domain/src/testing/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, }; @@ -60,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 @@ -73,16 +78,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(()) } @@ -145,3 +151,41 @@ 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(()) + } + async fn defer(&self, _: std::time::Duration) -> 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/panics.rs b/crates/domain/src/testing/fakes/panics.rs similarity index 59% rename from crates/domain/src/testing/panics.rs rename to crates/domain/src/testing/fakes/panics.rs index a427a85c..5f7ee6c3 100644 --- a/crates/domain/src/testing/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; @@ -285,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( @@ -347,6 +362,7 @@ impl UserProfileFieldsRepository for PanicProfileFieldsRepo { &self, _: &UserId, _: Vec, + _: &[DomainEvent], ) -> Result<(), DomainError> { panic!("PanicProfileFieldsRepo called") } @@ -377,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( @@ -467,3 +487,302 @@ impl crate::ports::WebhookTokenRepository for PanicWebhookTokenRepository { panic!("PanicWebhookTokenRepository called") } } +pub struct PanicMovieCommand; + +#[async_trait] +impl MovieCommand for PanicMovieCommand { + async fn upsert_movie(&self, _: &Movie, _: &[DomainEvent]) -> Result<(), DomainError> { + panic!("PanicMovieCommand called") + } + async fn delete_movie(&self, _: &MovieId, _: &[DomainEvent]) -> 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, _: &[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, _: &[DomainEvent]) -> Result<(), DomainError> { + panic!("PanicReviewRepository called") + } + 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> { + 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") + } + async fn exists(&self, _: &str) -> Result { + 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, + _: &[DomainEvent], + ) -> 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; + +#[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, _: &[DomainEvent]) -> Result<(), DomainError> { + panic!("PanicGoalCommand called") + } + 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") + } +} + +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..3bf5a25a --- /dev/null +++ b/crates/domain/src/testing/fakes/refusing.rs @@ -0,0 +1,230 @@ +use async_trait::async_trait; +use chrono::NaiveDateTime; + +use crate::{ + errors::DomainError, + events::DomainEvent, + models::{ + Goal, Movie, ProfileField, Review, WatchEvent, WatchEventStatus, WatchlistEntry, + WatchlistWithMovie, + collections::{PageParams, Paginated}, + }, + ports::{ + EventPublisher, FollowCommand, FollowWrite, GoalCommand, MovieCommand, ReviewRepository, + SocialCommand, UserProfileFieldsRepository, WatchEventCommand, WatchlistRepository, + }, + value_objects::{ + FollowTarget, GoalId, MovieId, ReviewId, SocialIdentity, UserId, WatchEventId, + }, +}; + +fn refused() -> DomainError { + DomainError::InfrastructureError("refused".into()) +} + +pub struct RefusingReviewRepository; + +#[async_trait] +impl ReviewRepository for RefusingReviewRepository { + 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, _: &[DomainEvent]) -> Result<(), DomainError> { + Err(refused()) + } + 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> { + Err(refused()) + } +} + +pub struct RefusingMovieCommand; + +#[async_trait] +impl MovieCommand for RefusingMovieCommand { + async fn upsert_movie(&self, _: &Movie, _: &[DomainEvent]) -> Result<(), DomainError> { + Err(refused()) + } + async fn delete_movie(&self, _: &MovieId, _: &[DomainEvent]) -> Result<(), DomainError> { + Err(refused()) + } +} + +pub struct RefusingWatchlistRepository; + +#[async_trait] +impl WatchlistRepository for RefusingWatchlistRepository { + async fn add(&self, _: &WatchlistEntry, _: &[DomainEvent]) -> Result<(), DomainError> { + Err(refused()) + } + async fn remove(&self, _: &UserId, _: &MovieId, _: &[DomainEvent]) -> Result<(), DomainError> { + Err(refused()) + } + async fn remove_if_present( + &self, + _: &UserId, + _: &MovieId, + _: &[DomainEvent], + ) -> 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 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] +impl EventPublisher for RefusingEventPublisher { + async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { + 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()) + } +} + +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.rs b/crates/domain/src/testing/fakes/store.rs new file mode 100644 index 00000000..1516bbed --- /dev/null +++ b/crates/domain/src/testing/fakes/store.rs @@ -0,0 +1,205 @@ +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; + +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, + Accepted, +} + +pub const USER_CASCADE_TABLES: &[&str] = &[ + "watchlist_entries", + "goals", + "user_settings", + "user_profile_fields", + "webhook_tokens", + "watch_events", + "ap_followers", + "ap_following", + "blocked_actors", +]; + +pub const USER_CASCADE_TABLES_OUTSIDE_STORE: &[&str] = &["ap_local_actors"]; + +pub const MOVIE_CASCADE_TABLES: &[&str] = &[ + "watchlist_entries", + "movie_profiles", + "movie_genres", + "movie_keywords", + "movie_cast", + "movie_crew", +]; + +pub struct InMemoryStore { + pub(super) movies: RwLock>, + pub(super) movie_profiles: RwLock>, + pub(super) reviews: RwLock>, + pub(super) users: RwLock>, + pub(super) tombstoned: RwLock>, + pub(super) retired_usernames: 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>, + pub(super) processed_events: RwLock>, + pub(super) recorded_events: RwLock>, +} + +impl InMemoryStore { + pub fn new() -> Arc { + Arc::new(Self { + movies: RwLock::default(), + 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(), + 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(), + processed_events: RwLock::default(), + recorded_events: RwLock::default(), + }) + } + + pub fn with_remote_watchlist(entries: Vec) -> Arc { + let store = Self::new(); + *store.remote_watchlist.write().unwrap() = entries; + store + } + + 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() + } + + 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 processed_event_count(&self) -> usize { + self.processed_events.read().unwrap().len() + } + + pub fn seed_processed_event( + &self, + id: &crate::events::EventId, + handler: &str, + done: bool, + at: chrono::NaiveDateTime, + ) { + 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 { + self.refresh_sessions.read().unwrap().len() + } + + pub fn seed_user(&self, user: User) { + self.users.write().unwrap().insert(user.id().value(), user); + } + + 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..581c3678 --- /dev/null +++ b/crates/domain/src/testing/fakes/store_goal.rs @@ -0,0 +1,84 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + events::DomainEvent, + models::Goal, + ports::{GoalCommand, GoalQuery}, + value_objects::{GoalId, UserId}, +}; + +use super::InMemoryStore; + +#[async_trait] +impl GoalCommand for InMemoryStore { + 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()) + }); + if duplicate { + return Err(DomainError::InfrastructureError( + "Database operation failed".into(), + )); + } + goals.insert(goal.id().value(), goal.clone()); + drop(goals); + self.record_events(events); + Ok(()) + } + + 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(_) => { + Err(DomainError::NotFound("goal".into())) + } + } + } + + async fn delete( + &self, + id: &GoalId, + _user_id: &UserId, + events: &[DomainEvent], + ) -> Result<(), DomainError> { + self.goals + .write() + .unwrap() + .remove(&id.value()) + .ok_or_else(|| DomainError::NotFound("Goal not found".into()))?; + self.record_events(events); + 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..e5afba50 --- /dev/null +++ b/crates/domain/src/testing/fakes/store_movie.rs @@ -0,0 +1,246 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + events::DomainEvent, + 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 { + 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| { + 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()); + drop(movies); + self.record_events(events); + Ok(()) + } + + 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(()); + } + if self + .reviews + .read() + .unwrap() + .values() + .any(|r| r.movie_id().value() == id) + { + return Err(DomainError::InfrastructureError( + "Database operation failed".into(), + )); + } + self.remove_movie_with_cascades(id); + self.record_events(events); + Ok(()) + } +} + +impl InMemoryStore { + pub(super) fn remove_movie_with_cascades(&self, id: uuid::Uuid) { + self.movies.write().unwrap().remove(&id); + self.watchlist + .write() + .unwrap() + .retain(|(_, movie), _| *movie != id); + self.movie_profiles.write().unwrap().remove(&id); + 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); + } + } + } +} + +#[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> { + 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, + total_count, + limit: page.limit, + offset: page.offset, + }) + } + + 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_processed_events.rs b/crates/domain/src/testing/fakes/store_processed_events.rs new file mode 100644 index 00000000..ee706a7d --- /dev/null +++ b/crates/domain/src/testing/fakes/store_processed_events.rs @@ -0,0 +1,104 @@ +use async_trait::async_trait; +use chrono::{Duration, NaiveDateTime, Timelike, Utc}; + +use crate::{ + errors::DomainError, + events::EventId, + ports::{Begin, ClaimToken, HANDLER_LEASE_SECONDS, ProcessedEventLedger}, +}; + +use super::InMemoryStore; +use super::store::LedgerRow; + +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()) +} + +#[async_trait] +impl ProcessedEventLedger for InMemoryStore { + async fn begin(&self, id: &EventId, handler: &str) -> Result { + 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) { + None => { + rows.insert( + key, + LedgerRow { + done: false, + started_at: now, + processed_at: now, + }, + ); + Ok(Begin::Run(token(now))) + } + Some(row) if row.done => Ok(Begin::AlreadyDone), + Some(row) if row.started_at < now - lease => { + row.started_at = now; + 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, + }) + } + } + } + + 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(); + } + Ok(()) + } + + 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 && token(r.started_at) == *owner) + { + 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(|_, r| r.processed_at >= cutoff); + Ok((before - rows.len()) as u64) + } +} 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..db01103c --- /dev/null +++ b/crates/domain/src/testing/fakes/store_review.rs @@ -0,0 +1,79 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + events::DomainEvent, + models::Review, + ports::ReviewRepository, + value_objects::{MovieId, ReviewId, UserId}, +}; + +use super::InMemoryStore; + +#[async_trait] +impl ReviewRepository for InMemoryStore { + 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(()) + } + + 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, + events: &[DomainEvent], + ) -> Result<(), DomainError> { + self.reviews + .write() + .unwrap() + .insert(review.id().value(), review.clone()); + self.record_events(events); + Ok(()) + } + + async fn delete_review_and_orphaned_movie( + &self, + review_id: &ReviewId, + movie_id: &MovieId, + review_events: &[DomainEvent], + orphan_events: &[DomainEvent], + ) -> Result<(), DomainError> { + 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(()) + } + + 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..4627fb2e --- /dev/null +++ b/crates/domain/src/testing/fakes/store_session.rs @@ -0,0 +1,57 @@ +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 { + 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..5941cfae --- /dev/null +++ b/crates/domain/src/testing/fakes/store_social.rs @@ -0,0 +1,305 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + events::DomainEvent, + 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, + events: &[DomainEvent], + ) -> 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)); + drop(store); + self.record_events(events); + Ok(()) + } + + async fn unfollow( + &self, + follower: &UserId, + target: &SocialIdentity, + events: &[DomainEvent], + ) -> 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(), + )); + } + drop(store); + self.record_events(events); + Ok(()) + } + + async fn accept_follow( + &self, + owner: &UserId, + requester: &SocialIdentity, + events: &[DomainEvent], + ) -> 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; + drop(store); + self.record_events(events); + return Ok(()); + } + } + Err(DomainError::NotFound( + "Pending follow request not found".into(), + )) + } + + async fn reject_follow( + &self, + owner: &UserId, + requester: &SocialIdentity, + events: &[DomainEvent], + ) -> 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(), + )); + } + drop(store); + self.record_events(events); + Ok(()) + } + + async fn remove_follower( + &self, + owner: &UserId, + follower: &SocialIdentity, + events: &[DomainEvent], + ) -> 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())); + } + drop(store); + self.record_events(events); + Ok(()) + } + + 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, + 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(()) + } +} + +#[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..12fb02c4 --- /dev/null +++ b/crates/domain/src/testing/fakes/store_user.rs @@ -0,0 +1,260 @@ +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(); + 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()) + } + + 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> { + 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(); + 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())); + }; + 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() + .insert(user.username().value().to_lowercase()); + gone.insert(id); + drop(gone); + drop(users); + 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) + } + + async fn list_with_stats(&self) -> Result, DomainError> { + let store = self.users.read().unwrap(); + 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() + .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( + &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(()) + } +} + +#[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, + events: &[crate::events::DomainEvent], + ) -> Result<(), DomainError> { + self.profile_fields + .write() + .unwrap() + .insert(user_id.value(), fields); + self.record_events(events); + 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 { + 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() + .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..3673db76 --- /dev/null +++ b/crates/domain/src/testing/fakes/store_watch_event.rs @@ -0,0 +1,129 @@ +use async_trait::async_trait; +use chrono::NaiveDateTime; +use uuid::Uuid; + +use crate::{ + errors::DomainError, + events::DomainEvent, + 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, events: &[DomainEvent]) -> Result<(), DomainError> { + self.watch_events.write().unwrap().push(event.clone()); + self.record_events(events); + 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(); + let gone = self.tombstoned.read().unwrap(); + Ok(store + .iter() + .find(|t| t.token_hash() == hash && !gone.contains(&t.user_id().value())) + .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..ef85b55b --- /dev/null +++ b/crates/domain/src/testing/fakes/store_watchlist.rs @@ -0,0 +1,129 @@ +use async_trait::async_trait; +use uuid::Uuid; + +use crate::{ + errors::DomainError, + events::DomainEvent, + models::{ + RemoteWatchlistEntry, WatchlistEntry, WatchlistWithMovie, + collections::{PageParams, Paginated}, + }, + ports::{RemoteWatchlistRepository, WatchlistRepository}, + value_objects::{MovieId, UserId}, +}; + +use super::InMemoryStore; + +#[async_trait] +impl WatchlistRepository for InMemoryStore { + async fn add(&self, entry: &WatchlistEntry, events: &[DomainEvent]) -> Result<(), DomainError> { + let key = (entry.user_id.value(), entry.movie_id.value()); + 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(()) + } + + 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(()) + } + + async fn remove_if_present( + &self, + user_id: &UserId, + movie_id: &MovieId, + events: &[DomainEvent], + ) -> Result { + let key = (user_id.value(), movie_id.value()); + let removed = self.watchlist.write().unwrap().remove(&key).is_some(); + if removed { + self.record_events(events); + } + Ok(removed) + } + + 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/wrapup.rs b/crates/domain/src/testing/fakes/wrapup.rs similarity index 71% rename from crates/domain/src/testing/wrapup.rs rename to crates/domain/src/testing/fakes/wrapup.rs index 88085418..2da7e916 100644 --- a/crates/domain/src/testing/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,20 @@ 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()), }) } + + pub fn recorded_events(&self) -> Vec { + self.recorded.lock().unwrap().clone() + } } #[async_trait] @@ -83,8 +91,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 +118,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 +210,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 +229,7 @@ impl WrapUpRepository for PanicWrapUpRepository { &self, _: &WrapUpId, _: &crate::models::wrapup::WrapUpReport, + _: &[DomainEvent], ) -> Result<(), DomainError> { panic!("PanicWrapUpRepository called") } @@ -245,3 +263,74 @@ impl WrapUpRepository for PanicWrapUpRepository { panic!("PanicWrapUpRepository called") } } + +// ── RefusingWrapUpWrites ─────────────────────────────────────────────────── + +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/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 21299b3e..007b939e 100644 --- a/crates/domain/src/testing/mod.rs +++ b/crates/domain/src/testing/mod.rs @@ -1,13 +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::*; diff --git a/crates/domain/src/value_objects/ids.rs b/crates/domain/src/value_objects/ids.rs index 930a95d3..b4908b66 100644 --- a/crates/domain/src/value_objects/ids.rs +++ b/crates/domain/src/value_objects/ids.rs @@ -32,6 +32,18 @@ impl MovieId { uuid_id!(ReviewId); uuid_id!(UserId); + +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/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 74% rename from crates/domain/src/tests/events.rs rename to crates/domain/tests/events/events_test.rs index 57b618e3..482f2a8f 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() { @@ -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)); +} 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 79% rename from crates/domain/src/models/tests/mod.rs rename to crates/domain/tests/models/constructors_test.rs index 57e89c80..becb04bb 100644 --- a/crates/domain/src/models/tests/mod.rs +++ b/crates/domain/tests/models/constructors_test.rs @@ -1,20 +1,10 @@ -use super::*; -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(), - ) -} +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() { - 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( - 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(); + let m = Movie::new( + 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(domain::value_objects::PosterPath::new("/poster.jpg".into()).unwrap()), + ); assert_eq!(m.title().value(), "Blade Runner"); assert_eq!(m.release_year().value(), 1982); assert_eq!(m.director(), Some("Ridley Scott")); @@ -61,39 +47,26 @@ fn movie_new_sets_fields() { #[test] fn movie_update_poster() { - let mut m = make_movie(); - let new_poster = crate::value_objects::PosterPath::new("/new.jpg".into()).unwrap(); + let mut m = test_movie_with_year("Blade Runner", 1982); + 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::{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 domain::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,17 +198,17 @@ fn month_activity_construction() { #[test] fn movie_is_manual_match_same_title_year() { - let m = make_movie(); - let title = crate::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(); - let year = crate::value_objects::ReleaseYear::new(1982).unwrap(); + let m = test_movie_with_director("Blade Runner", 1982, "Ridley Scott"); + 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 = make_movie(); - let title = crate::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(); - let year = crate::value_objects::ReleaseYear::new(1982).unwrap(); + let m = test_movie_with_director("Blade Runner", 1982, "Ridley Scott"); + 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 69% rename from crates/domain/src/models/tests/goal.rs rename to crates/domain/tests/models/goal_test.rs index c3575b42..7b7afd86 100644 --- a/crates/domain/src/models/tests/goal.rs +++ b/crates/domain/tests/models/goal_test.rs @@ -1,39 +1,34 @@ -use super::*; -use crate::value_objects::UserId; - -fn make_goal(year: u16, target: u32) -> Result { - Goal::new(UserId::generate(), year, target, GoalType::Movies) -} +use domain::models::*; +use domain::testing::test_goal; +use domain::value_objects::{GoalId, UserId}; #[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/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 69% rename from crates/domain/src/models/tests/review.rs rename to crates/domain/tests/models/review_test.rs index c299d344..4d99db1d 100644 --- a/crates/domain/src/models/tests/review.rs +++ b/crates/domain/tests/models/review_test.rs @@ -1,16 +1,23 @@ -use chrono::NaiveDateTime; +use domain::testing::{test_review, test_watched_at}; +use domain::value_objects::{Comment, MovieId, Rating, UserId, WatchMedium}; -use crate::value_objects::{Comment, MovieId, Rating, UserId, WatchMedium}; +use domain::models::*; -use super::*; +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() + }) +} -fn make_review(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()), - NaiveDateTime::parse_from_str("2024-06-15 20:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), + test_watched_at(), watch_medium, false, ) @@ -19,19 +26,19 @@ fn make_review(watch_medium: Option) -> Review { #[test] fn new_review_stores_watch_medium() { - let review = make_review(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 = make_review(None); + let review = new_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 +50,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 +61,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 +71,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 +81,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 +98,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/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/tests/ports/federation_test.rs b/crates/domain/tests/ports/federation_test.rs new file mode 100644 index 00000000..62728bb1 --- /dev/null +++ b/crates/domain/tests/ports/federation_test.rs @@ -0,0 +1,42 @@ +use domain::ports::noop::{NoopApBackfill, NoopApDocument, NoopInstanceBlocklist}; +use domain::ports::{ApBackfillPort, ApDocumentPort, InstanceBlocklistPort}; + +#[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/domain/tests/ports/social_test.rs b/crates/domain/tests/ports/social_test.rs new file mode 100644 index 00000000..89fef2a9 --- /dev/null +++ b/crates/domain/tests/ports/social_test.rs @@ -0,0 +1,170 @@ +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, + _: &[domain::events::DomainEvent], + ) -> Result<(), DomainError> { + Ok(()) + } + async fn unfollow( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::SocialIdentity, + _: &[domain::events::DomainEvent], + ) -> Result<(), DomainError> { + Ok(()) + } + async fn accept_follow( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::SocialIdentity, + _: &[domain::events::DomainEvent], + ) -> Result<(), DomainError> { + Ok(()) + } + async fn reject_follow( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::SocialIdentity, + _: &[domain::events::DomainEvent], + ) -> Result<(), DomainError> { + Ok(()) + } + async fn remove_follower( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::SocialIdentity, + _: &[domain::events::DomainEvent], + ) -> Result<(), DomainError> { + Ok(()) + } + async fn block( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::SocialIdentity, + _: &[domain::events::DomainEvent], + ) -> Result<(), DomainError> { + Ok(()) + } + async fn unblock( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::SocialIdentity, + _: &[domain::events::DomainEvent], + ) -> 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, + _: &[domain::events::DomainEvent], + ) -> 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 80% rename from crates/domain/src/services/tests/review_history.rs rename to crates/domain/tests/services/review_history_test.rs index 9c99dbe0..312e1a86 100644 --- a/crates/domain/src/services/tests/review_history.rs +++ b/crates/domain/tests/services/review_history_test.rs @@ -1,17 +1,8 @@ -use super::*; -use crate::models::{Movie, Review, ReviewHistory}; -use crate::value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId}; use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; - -fn make_movie() -> Movie { - Movie::new( - None, - MovieTitle::new("Test".into()).unwrap(), - ReleaseYear::new(2024).unwrap(), - None, - None, - ) -} +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( @@ -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/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/tests/testing/in_memory_store_test.rs b/crates/domain/tests/testing/in_memory_store_test.rs new file mode 100644 index 00000000..dab8d1d5 --- /dev/null +++ b/crates/domain/tests/testing/in_memory_store_test.rs @@ -0,0 +1,658 @@ +use std::sync::Arc; + +use chrono::Utc; +use uuid::Uuid; + +use domain::errors::DomainError; +use domain::models::{ + CastMember, CrewMember, Genre, Keyword, Movie, MovieProfile, ProfileField, RefreshSession, + UserSettings, WatchEvent, WatchEventSource, WatchlistEntry, WebhookToken, +}; +use domain::ports::{ + GoalCommand, MovieCommand, MovieProfileRepository, RefreshSessionRepository, ReviewRepository, + SocialCommand, UserProfileFieldsRepository, UserRepository, UserSettingsRepository, + WatchEventCommand, WatchEventQuery, WatchlistRepository, WebhookTokenRepository, +}; +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 domain::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(), + } +} + +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, UserId) { + 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, 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; + 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, user_id) = seed_movie_with_dependents(&store, false).await; + + 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 = 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; + + 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 = 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; + + 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(_)))); +} + +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); +} + +#[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); +} + +#[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); +} + +#[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/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() { diff --git a/crates/event-payload/Cargo.toml b/crates/event-payload/Cargo.toml new file mode 100644 index 00000000..5b07d000 --- /dev/null +++ b/crates/event-payload/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "event-payload" +version = "0.1.0" +edition.workspace = true + +[dependencies] +domain = { workspace = true } +serde = { workspace = true, features = ["derive"] } +chrono = { workspace = true, features = ["serde"] } +uuid = { workspace = true, features = ["serde"] } +serde_json = { workspace = true } diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/event-payload/src/lib.rs similarity index 99% rename from crates/adapters/event-payload/src/lib.rs rename to crates/event-payload/src/lib.rs index 199c5290..7d6dd3af 100644 --- a/crates/adapters/event-payload/src/lib.rs +++ b/crates/event-payload/src/lib.rs @@ -154,6 +154,14 @@ pub enum EventPayload { }, } +#[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 { @@ -718,7 +726,3 @@ impl TryFrom for DomainEvent { } } } - -#[cfg(test)] -#[path = "tests/lib.rs"] -mod tests; diff --git a/crates/event-payload/tests/event_payload_test.rs b/crates/event-payload/tests/event_payload_test.rs new file mode 100644 index 00000000..34ee6356 --- /dev/null +++ b/crates/event-payload/tests/event_payload_test.rs @@ -0,0 +1,321 @@ +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) + .unwrap() + .naive_utc() +} + +fn review_logged() -> DomainEvent { + DomainEvent::ReviewLogged { + review_id: ReviewId::from_uuid(Uuid::new_v4()), + movie_id: MovieId::from_uuid(Uuid::new_v4()), + user_id: UserId::from_uuid(Uuid::new_v4()), + rating: Rating::new(4).unwrap(), + watched_at: fixed_dt(), + } +} + +fn review_updated() -> DomainEvent { + DomainEvent::ReviewUpdated { + review_id: ReviewId::from_uuid(Uuid::new_v4()), + movie_id: MovieId::from_uuid(Uuid::new_v4()), + user_id: UserId::from_uuid(Uuid::new_v4()), + rating: Rating::new(3).unwrap(), + watched_at: fixed_dt(), + } +} + +fn movie_discovered() -> DomainEvent { + DomainEvent::MovieDiscovered { + movie_id: MovieId::from_uuid(Uuid::new_v4()), + external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(), + } +} + +fn round_trip(event: DomainEvent) { + let payload = EventPayload::from(&event); + let json = serde_json::to_string(&payload).expect("serialize"); + let back: EventPayload = serde_json::from_str(&json).expect("deserialize"); + let recovered = DomainEvent::try_from(back).expect("try_from"); + assert_eq!(EventPayload::from(&event), EventPayload::from(&recovered)); +} + +#[test] +fn round_trip_review_logged() { + round_trip(review_logged()); +} + +#[test] +fn round_trip_review_updated() { + round_trip(review_updated()); +} + +#[test] +fn round_trip_movie_discovered() { + round_trip(movie_discovered()); +} + +#[test] +fn serialized_format_is_tagged() { + let payload = EventPayload::from(&movie_discovered()); + let json = serde_json::to_string(&payload).unwrap(); + assert!(json.contains(r#""type":"MovieDiscovered""#)); + assert!(json.contains(r#""data":"#)); +} + +#[test] +fn event_type_strings() { + assert_eq!( + EventPayload::from(&review_logged()).event_type(), + "ReviewLogged" + ); + assert_eq!( + EventPayload::from(&review_updated()).event_type(), + "ReviewUpdated" + ); + assert_eq!( + EventPayload::from(&movie_discovered()).event_type(), + "MovieDiscovered" + ); +} + +#[test] +fn round_trip_image_stored() { + let event = DomainEvent::ImageStored { + key: "avatars/abc123".into(), + }; + let payload = EventPayload::from(&event); + let json = serde_json::to_string(&payload).unwrap(); + let back: EventPayload = serde_json::from_str(&json).unwrap(); + let recovered = DomainEvent::try_from(back).unwrap(); + assert_eq!(EventPayload::from(&event), EventPayload::from(&recovered)); +} + +#[test] +fn image_stored_event_type() { + let payload = EventPayload::from(&DomainEvent::ImageStored { + key: "posters/x".into(), + }); + 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); +} + +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)); + } + } +} diff --git a/crates/infra-wiring/Cargo.toml b/crates/infra-wiring/Cargo.toml deleted file mode 100644 index fff101eb..00000000 --- a/crates/infra-wiring/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "infra-wiring" -version = "0.1.0" -edition = "2021" - -[dependencies] -sqlx = { workspace = true } -anyhow = { workspace = true } - -[features] -default = [] -sqlite = [] -postgres = ["sqlx/postgres"] -nats = [] diff --git a/crates/infra-wiring/src/config.rs b/crates/infra-wiring/src/config.rs deleted file mode 100644 index 75d1eb96..00000000 --- a/crates/infra-wiring/src/config.rs +++ /dev/null @@ -1,52 +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 wrapup: WrapUpConfig, -} - -#[derive(Clone)] -pub struct WrapUpConfig { - pub font_path: Option, - pub logo_path: Option, - 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); - Self { - allow_registration, - base_url, - rate_limit, - refresh_ttl_seconds, - wrapup: WrapUpConfig::from_env(), - } - } -} - -impl WrapUpConfig { - pub fn from_env() -> 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(), - } - } -} diff --git a/crates/infra-wiring/src/lib.rs b/crates/infra-wiring/src/lib.rs deleted file mode 100644 index dae26a9e..00000000 --- a/crates/infra-wiring/src/lib.rs +++ /dev/null @@ -1,35 +0,0 @@ -pub mod config; - -pub use config::{AppConfig, WrapUpConfig}; - -pub enum DbPool { - #[cfg(feature = "sqlite")] - Sqlite(sqlx::SqlitePool), - #[cfg(feature = "postgres")] - Postgres(sqlx::PgPool), -} - -#[derive(Clone, Copy)] -pub enum EventBusBackend { - Db, - #[cfg(feature = "nats")] - Nats, -} - -impl EventBusBackend { - pub fn from_env() -> anyhow::Result { - match std::env::var("EVENT_BUS_BACKEND") - .unwrap_or_else(|_| "db".to_string()) - .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 deleted file mode 100644 index 56d14455..00000000 --- a/crates/presentation/Cargo.toml +++ /dev/null @@ -1,46 +0,0 @@ -[package] -name = "presentation" -version = "0.1.0" -edition = "2024" -description = "Self-hosted movie diary with REST API and ActivityPub federation" -license = "MIT" - -[features] -default = ["federation"] -# Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working -federation = ["application/federation"] - -[dependencies] -tower-http = { version = "0.6.8", features = ["cors", "fs", "trace", "tracing"] } -infer = "0.19.0" -percent-encoding = "2" -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 } -domain = { workspace = true } -application = { workspace = true } -template-askama = { workspace = true } -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"] } - -[dev-dependencies] -bytes = { workspace = true } -futures = { workspace = true } -tower = { version = "0.5", features = ["util"] } -domain = { workspace = true, features = ["test-helpers"] } -composition = { workspace = true } diff --git a/crates/presentation/src/errors.rs b/crates/presentation/src/errors.rs deleted file mode 100644 index da5e6f5c..00000000 --- a/crates/presentation/src/errors.rs +++ /dev/null @@ -1,56 +0,0 @@ -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, -}; -use domain::errors::DomainError; - -pub fn domain_error_status(e: &DomainError) -> StatusCode { - match e { - DomainError::InvalidRating { .. } | DomainError::ValidationError(_) => { - StatusCode::BAD_REQUEST - } - DomainError::NotFound(_) => StatusCode::NOT_FOUND, - DomainError::Unauthorized(_) => StatusCode::UNAUTHORIZED, - DomainError::Forbidden(_) => StatusCode::FORBIDDEN, - DomainError::InfrastructureError(_) => StatusCode::INTERNAL_SERVER_ERROR, - } -} - -pub fn domain_error_response(e: DomainError) -> Response { - match &e { - DomainError::InfrastructureError(_) => { - tracing::error!("Internal error: {:?}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - _ => (domain_error_status(&e), e.to_string()).into_response(), - } -} - -pub struct ApiError(pub DomainError); - -impl From for ApiError { - fn from(err: DomainError) -> Self { - Self(err) - } -} - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - let status = domain_error_status(&self.0); - match &self.0 { - DomainError::InfrastructureError(_) => { - tracing::error!("Internal error: {:?}", self.0); - ( - status, - axum::Json(serde_json::json!({"error": "internal server error"})), - ) - .into_response() - } - _ => ( - status, - axum::Json(serde_json::json!({"error": self.0.to_string()})), - ) - .into_response(), - } - } -} diff --git a/crates/presentation/src/handlers/auth.rs b/crates/presentation/src/handlers/auth.rs deleted file mode 100644 index 89127aaa..00000000 --- a/crates/presentation/src/handlers/auth.rs +++ /dev/null @@ -1,256 +0,0 @@ -use axum::{ - Form, Json, - 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::{ - 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, -}; -use template_askama::{LoginTemplate, RegisterTemplate}; - -// ── HTML helpers ───────────────────────────────────────────────────────────── - -fn set_cookie_header(token: &str, max_age: i64) -> (axum::http::HeaderName, HeaderValue) { - let val = format!( - "token={}; HttpOnly; Path=/; SameSite=Strict; Max-Age={}{}", - token, - max_age, - secure_flag() - ); - ( - SET_COOKIE, - HeaderValue::from_str(&val).expect("valid cookie"), - ) -} - -// ── 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, - Extension(csrf): Extension, -) -> impl IntoResponse { - let ctx = HtmlPageContext { - user_email: None, - user_id: None, - is_admin: false, - register_enabled: state.app_ctx.config.allow_registration, - rss_url: "/feed.rss".to_string(), - page_title: "Login — Movies Diary".to_string(), - canonical_url: format!("{}/login", state.app_ctx.config.base_url), - csrf_token: csrf.0, - page_rss_url: None, - pending_follow_count: 0, - }; - render_page(LoginTemplate { - ctx: &ctx, - error: params.error.as_deref(), - }) -} - -pub async fn post_login( - 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 login_uc::execute( - &state.app_ctx.deps.auth.login, - LoginCommand { - email: form.email, - password: form.password, - }, - ) - .await - { - Ok(result) => { - let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); - let cookie = set_cookie_header(&result.token, max_age); - ([cookie], Redirect::to("/")).into_response() - } - Err(_) => Redirect::to("/login?error=Invalid+credentials").into_response(), - } -} - -pub async fn get_logout() -> impl IntoResponse { - let val = format!( - "token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0{}", - secure_flag() - ); - let cookie = ( - SET_COOKIE, - HeaderValue::from_str(&val).expect("valid cookie"), - ); - ([cookie], Redirect::to("/")).into_response() -} - -pub async fn get_register_page( - State(state): State, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - if !state.app_ctx.config.allow_registration { - return Redirect::to("/").into_response(); - } - let ctx = HtmlPageContext { - user_email: None, - user_id: None, - is_admin: false, - register_enabled: true, - rss_url: "/feed.rss".to_string(), - page_title: "Register — Movies Diary".to_string(), - canonical_url: format!("{}/register", state.app_ctx.config.base_url), - csrf_token: csrf.0, - page_rss_url: None, - pending_follow_count: 0, - }; - render_page(RegisterTemplate { - ctx: &ctx, - error: params.error.as_deref(), - }) - .into_response() -} - -pub async fn post_register( - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if !state.app_ctx.config.allow_registration { - return Redirect::to("/").into_response(); - } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - match application::auth::register_and_login::execute( - &state.app_ctx.deps.auth.register_and_login, - application::auth::commands::RegisterAndLoginCommand { - email: form.email, - username: form.username, - password: form.password, - }, - ) - .await - { - Ok(result) => { - let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); - let cookie = set_cookie_header(&result.token, max_age); - ([cookie], Redirect::to("/")).into_response() - } - Err(_) => { - Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response() - } - } -} diff --git a/crates/presentation/src/handlers/diary.rs b/crates/presentation/src/handlers/diary.rs deleted file mode 100644 index 1327b3cf..00000000 --- a/crates/presentation/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::{ - 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 template_askama::{ActivityFeedTemplate, NewReviewTemplate, build_page_items}; - -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/presentation/src/handlers/import.rs b/crates/presentation/src/handlers/import.rs deleted file mode 100644 index 33061e0f..00000000 --- a/crates/presentation/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::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 template_askama::{ - ImportMappingTemplate, ImportPreviewRow, ImportPreviewTemplate, ImportProfileView, - ImportUploadTemplate, -}; - -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/presentation/src/handlers/mod.rs b/crates/presentation/src/handlers/mod.rs deleted file mode 100644 index 0fc869cf..00000000 --- a/crates/presentation/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/presentation/src/handlers/social.rs b/crates/presentation/src/handlers/social.rs deleted file mode 100644 index cf978a89..00000000 --- a/crates/presentation/src/handlers/social.rs +++ /dev/null @@ -1,1195 +0,0 @@ -use axum::{ - Form, Json, - extract::{Extension, Path, Query, State}, - http::StatusCode, - response::{IntoResponse, Redirect}, -}; -use uuid::Uuid; - -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}; -#[cfg(feature = "federation")] -use template_askama::{BlockedActorsTemplate, BlockedDomainsTemplate}; -use template_askama::{FollowersTemplate, FollowingTemplate, RemoteActorData}; - -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| template_askama::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| template_askama::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)] -mod tests { - 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/handlers/users.rs b/crates/presentation/src/handlers/users.rs deleted file mode 100644 index ba5cc638..00000000 --- a/crates/presentation/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::{ - 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 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; - -// ── 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| template_askama::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/presentation/src/handlers/watchlist.rs b/crates/presentation/src/handlers/watchlist.rs deleted file mode 100644 index 0f360a41..00000000 --- a/crates/presentation/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::{ - csrf::CsrfToken, - errors::ApiError, - extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser}, - render::render_page, - state::AppState, -}; -use api_types::{ - AddToWatchlistRequest, PaginationQueryParams, WatchlistEntryDto, WatchlistResponse, - WatchlistStatusResponse, -}; -use template_askama::WatchlistTemplate; - -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/presentation/src/lib.rs b/crates/presentation/src/lib.rs deleted file mode 100644 index 79ba5c97..00000000 --- a/crates/presentation/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub mod context; -pub mod csrf; -pub mod errors; -pub mod extractors; -pub mod forms; -pub mod handlers; -pub mod mappers; -pub mod openapi; -pub mod render; -pub mod routes; -pub mod state; - -#[cfg(test)] -mod tests; diff --git a/crates/presentation/src/openapi/social.rs b/crates/presentation/src/openapi/social.rs deleted file mode 100644 index c5c8cee5..00000000 --- a/crates/presentation/src/openapi/social.rs +++ /dev/null @@ -1,44 +0,0 @@ -#[cfg(feature = "federation")] -use api_types::{ - ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse, - BlockedDomainResponse, FollowRelationResponse, FollowRequest, PendingCountResponse, - RemoteActorDto, -}; -#[cfg(feature = "federation")] -use utoipa::OpenApi; - -#[cfg(feature = "federation")] -#[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, - ), - components(schemas( - ActorListResponse, - RemoteActorDto, - FollowRequest, - ActorUrlRequest, - BlockedDomainResponse, - AddBlockedDomainRequest, - BlockedActorResponse, - FollowRelationResponse, - PendingCountResponse, - )) -)] -pub struct SocialDoc; diff --git a/crates/presentation/src/openapi/users.rs b/crates/presentation/src/openapi/users.rs deleted file mode 100644 index 67e4bed6..00000000 --- a/crates/presentation/src/openapi/users.rs +++ /dev/null @@ -1,28 +0,0 @@ -use api_types::{ - ProfileFieldDto, ProfileResponse, ProfileViewData, UpdateProfileFieldsRequest, UserProfileBase, - UserProfileResponse, UserStatsDto, UserSummaryDto, UsersResponse, -}; -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, - ), - components(schemas( - UsersResponse, - UserSummaryDto, - UserProfileBase, - UserProfileResponse, - ProfileViewData, - UserStatsDto, - ProfileResponse, - UpdateProfileFieldsRequest, - ProfileFieldDto, - )) -)] -pub struct UsersDoc; diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs deleted file mode 100644 index 53d7295f..00000000 --- a/crates/presentation/src/routes.rs +++ /dev/null @@ -1,589 +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 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)) - .merge(api_routes(rate_limit)) - .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) -> 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(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() -> 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, - 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) -> 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()) -} - -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/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs deleted file mode 100644 index 8a2cd575..00000000 --- a/crates/presentation/src/tests/extractors.rs +++ /dev/null @@ -1,1332 +0,0 @@ -use super::*; -use crate::context::AppContext; -use application::config::AppConfig; -use axum::{ - Router, - body::Body, - http::{Request, StatusCode}, - routing::get, -}; -use composition::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::InMemoryUserRepository, - value_objects::{ - Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, ReleaseYear, - ReviewId, UserId, - }, -}; -use std::sync::Arc; -use tower::ServiceExt; - -// --- Panic stubs (defined once) --- - -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 RejectingAuth; -#[async_trait::async_trait] -impl AuthService for RejectingAuth { - async fn generate_token(&self, _: &UserId) -> Result { - panic!() - } - async fn validate_token(&self, _: &str) -> Result { - Err(DomainError::Unauthorized("bad token".into())) - } -} - -#[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!() - } -} -#[cfg(feature = "federation")] -#[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 { - async fn log_review( - &self, - _: application::diary::commands::LogReviewCommand, - ) -> Result<(), DomainError> { - panic!() - } -} - -// --- 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 _, - 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 _, - federated_profile: None, - } -} - -// --- Single state factory — builds `deps` from whatever `repos` it is given --- - -/// Builds an `AppState` from caller-supplied `repos`, running `composition::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 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 _, - 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, - 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(composition::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(Panic), - } -} - -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(Panic)) -} - -/// 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(Panic)) -} - -/// 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(Panic)) -} - -/// 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!() - } -} - -/// 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. -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 { - user.0.value().to_string() -} -async fn optional_cookie_handler(user: OptionalCookieUser) -> String { - match user.0 { - Some(id) => id.value().to_string(), - None => "none".to_string(), - } -} -async fn required_cookie_handler(user: RequiredCookieUser) -> String { - user.0.value().to_string() -} -async fn admin_api_handler(user: AdminApiUser) -> String { - user.0.value().to_string() -} -async fn admin_cookie_handler(user: AdminUser) -> String { - user.0.value().to_string() -} - -fn router_protected(state: crate::state::AppState) -> Router { - Router::new() - .route("/protected", get(protected_handler)) - .with_state(state) -} -fn router_optional(state: crate::state::AppState) -> Router { - Router::new() - .route("/optional", get(optional_cookie_handler)) - .with_state(state) -} -fn router_admin_api(state: crate::state::AppState) -> Router { - Router::new() - .route("/admin-api", get(admin_api_handler)) - .with_state(state) -} -fn router_admin_cookie(state: crate::state::AppState) -> Router { - Router::new() - .route("/admin-cookie", get(admin_cookie_handler)) - .with_state(state) -} -fn router_required(state: crate::state::AppState) -> Router { - Router::new() - .route("/required", get(required_cookie_handler)) - .with_state(state) -} - -// --- Tests --- - -#[tokio::test] -async fn missing_auth_header_returns_401() { - let app = router_protected(test_app_state()); - let resp = app - .oneshot( - Request::builder() - .uri("/protected") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn optional_cookie_user_returns_none_without_cookie() { - let app = router_optional(test_app_state()); - let resp = app - .oneshot( - Request::builder() - .uri("/optional") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - assert_eq!(&body[..], b"none"); -} - -#[tokio::test] -async fn optional_cookie_user_returns_none_with_invalid_token() { - let app = router_optional(make_test_state(Arc::new(RejectingAuth))); - let resp = app - .oneshot( - Request::builder() - .uri("/optional") - .header("cookie", "token=bad.token.here") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - assert_eq!(&body[..], b"none"); -} - -#[tokio::test] -async fn required_cookie_user_redirects_without_cookie() { - let app = router_required(test_app_state()); - let resp = app - .oneshot( - Request::builder() - .uri("/required") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::SEE_OTHER); - assert_eq!(resp.headers().get("location").unwrap(), "/login"); -} - -#[tokio::test] -async fn required_cookie_user_redirects_with_invalid_token() { - let app = router_required(make_test_state(Arc::new(RejectingAuth))); - let resp = app - .oneshot( - Request::builder() - .uri("/required") - .header("cookie", "token=bad.token.here") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::SEE_OTHER); - assert_eq!(resp.headers().get("location").unwrap(), "/login"); -} - -// --- AdminApiUser / AdminUser: per-path rejection pinning --- -// -// These pin the exact rejection for every path `authorize_admin::execute` can -// produce, now that both extractors go through it instead of calling -// `repos.user.find_by_id` directly. Each test name states the path; the -// asserted status is the byte-identical-to-before-this-task value recorded in -// the task-4 report's before/after table. - -fn admin_id() -> UserId { - UserId::generate() -} - -#[tokio::test] -async fn admin_api_user_allows_an_admin_user() { - let id = admin_id(); - let user_repo = InMemoryUserRepository::new(); - user_repo.store.lock().unwrap().insert( - id.value(), - make_user_with_role(id.clone(), domain::models::UserRole::Admin), - ); - let app = router_admin_api(test_app_state_with_user( - user_repo as _, - Arc::new(AcceptingAuth(id)), - )); - let resp = app - .oneshot( - Request::builder() - .uri("/admin-api") - .header("authorization", "Bearer anything") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); -} - -#[tokio::test] -async fn admin_api_user_rejects_a_non_admin_user() { - let id = admin_id(); - let user_repo = InMemoryUserRepository::new(); - user_repo.store.lock().unwrap().insert( - id.value(), - make_user_with_role(id.clone(), domain::models::UserRole::Standard), - ); - let app = router_admin_api(test_app_state_with_user( - user_repo as _, - Arc::new(AcceptingAuth(id)), - )); - let resp = app - .oneshot( - Request::builder() - .uri("/admin-api") - .header("authorization", "Bearer anything") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::FORBIDDEN); -} - -#[tokio::test] -async fn admin_api_user_rejects_an_unknown_user() { - let id = admin_id(); - let user_repo = InMemoryUserRepository::new(); // empty — id is not in the store - let app = router_admin_api(test_app_state_with_user( - user_repo as _, - Arc::new(AcceptingAuth(id)), - )); - let resp = app - .oneshot( - Request::builder() - .uri("/admin-api") - .header("authorization", "Bearer anything") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn admin_api_user_surfaces_a_repository_error_as_500() { - let id = admin_id(); - let app = router_admin_api(test_app_state_with_user( - Arc::new(ErroringUserRepo), - Arc::new(AcceptingAuth(id)), - )); - let resp = app - .oneshot( - Request::builder() - .uri("/admin-api") - .header("authorization", "Bearer anything") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); -} - -#[tokio::test] -async fn admin_user_allows_an_admin_user() { - let id = admin_id(); - let user_repo = InMemoryUserRepository::new(); - user_repo.store.lock().unwrap().insert( - id.value(), - make_user_with_role(id.clone(), domain::models::UserRole::Admin), - ); - let app = router_admin_cookie(test_app_state_with_user( - user_repo as _, - Arc::new(AcceptingAuth(id)), - )); - let resp = app - .oneshot( - Request::builder() - .uri("/admin-cookie") - .header("cookie", "token=anything") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); -} - -#[tokio::test] -async fn admin_user_rejects_a_non_admin_user() { - let id = admin_id(); - let user_repo = InMemoryUserRepository::new(); - user_repo.store.lock().unwrap().insert( - id.value(), - make_user_with_role(id.clone(), domain::models::UserRole::Standard), - ); - let app = router_admin_cookie(test_app_state_with_user( - user_repo as _, - Arc::new(AcceptingAuth(id)), - )); - let resp = app - .oneshot( - Request::builder() - .uri("/admin-cookie") - .header("cookie", "token=anything") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::FORBIDDEN); -} - -#[tokio::test] -async fn admin_user_rejects_an_unknown_user() { - let id = admin_id(); - let user_repo = InMemoryUserRepository::new(); // empty — id is not in the store - let app = router_admin_cookie(test_app_state_with_user( - user_repo as _, - Arc::new(AcceptingAuth(id)), - )); - let resp = app - .oneshot( - Request::builder() - .uri("/admin-cookie") - .header("cookie", "token=anything") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn admin_user_surfaces_a_repository_error_as_500() { - let id = admin_id(); - let app = router_admin_cookie(test_app_state_with_user( - Arc::new(ErroringUserRepo), - Arc::new(AcceptingAuth(id)), - )); - let resp = app - .oneshot( - Request::builder() - .uri("/admin-cookie") - .header("cookie", "token=anything") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); -} diff --git a/crates/presentation/src/tests/mod.rs b/crates/presentation/src/tests/mod.rs deleted file mode 100644 index afb3afae..00000000 --- a/crates/presentation/src/tests/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod api_handlers; -mod context; diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 04be03c3..acca556c 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -1,78 +1,22 @@ [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" [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"] -sqlite-federation = [ - "sqlite", - "dep:sqlite-federation", - "dep:sqlite-social", - "dep:activitypub", - "federation", - "composition/sqlite-federation", -] -postgres-federation = [ - "postgres", - "dep:postgres-federation", - "dep:postgres-social", - "dep:activitypub", - "federation", - "composition/postgres-federation", -] +default = ["federation"] +federation = ["bootstrap/federation"] +nats = ["bootstrap/nats"] +sqlite = [] +sqlite-federation = ["federation"] [dependencies] -axum = { workspace = true } -tokio = { workspace = true } +bootstrap = { workspace = true } +config = { workspace = true } +axum = { workspace = true, features = ["http1", "tokio"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net"] } 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 } - -# 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 } -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"] } +tracing = { workspace = true } diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 5d73a0f7..b5593b59 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,335 +1,22 @@ -use std::sync::Arc; - 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; -use presentation::context::AppContext; -use presentation::{openapi, routes, state::AppState}; -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" -); #[tokio::main] async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); - init_tracing(); + bootstrap::init_tracing("server=debug,bootstrap=debug,config=info,tower_http=debug"); - let (state, ap_router) = wire_dependencies() - .await - .context("Failed to wire dependencies")?; + let config = config::load().context("Failed to load configuration")?; + let app = bootstrap::build_server(&config).await?; - 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 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() -> 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()); - - 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 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()?; - - #[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 { - #[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?; - - 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, - 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).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 { - #[cfg(feature = "sqlite-federation")] - 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"))] - 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( - std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()), - )), - }; - Ok((state, ap_router)) -} - -async fn create_event_publisher( - event_bus: EventBusBackend, - db_pool: &composition::DbPool, -) -> anyhow::Result> { - match event_bus { - 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? - } - }) - } - #[cfg(feature = "nats")] - EventBusBackend::Nats => { - let cfg = nats::NatsConfig::from_env() - .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 deleted file mode 100644 index a8698c22..00000000 --- a/crates/server/tests/api_test.rs +++ /dev/null @@ -1,846 +0,0 @@ -use std::sync::Arc; - -use application::config::AppConfig; -use async_trait::async_trait; -use axum::{ - Router, - body::Body, - http::{Request, StatusCode}, -}; -use composition::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_body_util::BodyExt; -use presentation::context::AppContext; -use presentation::{routes, state::AppState}; -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!() - } -} - -#[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> { - 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, - 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(composition::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 = routes::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); -} diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 1afb8dcb..fce7f28c 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 = [] @@ -13,23 +13,22 @@ linux-zbus = [ windows = ["dep:windows-native-keyring-store"] [dependencies] -ratatui = "0.30.0" -keyring-core = "1.0.0" -directories = "6" -csv = "1" +ratatui = { workspace = true } +keyring-core = { workspace = true } +directories = { workspace = true } +csv = { workspace = true } -apple-native-keyring-store = { version = "1.0.0", optional = true, features = [ - "keychain", -] } -zbus-secret-service-keyring-store = { version = "1.0.0", optional = true } -windows-native-keyring-store = { version = "1.0.0", optional = true } +apple-native-keyring-store = { workspace = true, optional = true } +zbus-secret-service-keyring-store = { workspace = true, optional = true } +windows-native-keyring-store = { workspace = true, optional = true } api-types = { workspace = true } reqwest = { workspace = true } -serde = { workspace = true } +rustls = { workspace = true, features = ["ring", "std", "tls12", "logging"] } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -tokio = { workspace = true } +tokio = { workspace = true, features = ["rt", "rt-multi-thread", "sync", "macros", "time"] } anyhow = { workspace = true } -uuid = { workspace = true } +uuid = { workspace = true, features = ["serde"] } thiserror = { workspace = true } 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/client.rs b/crates/tui/src/client.rs index 2c019248..e74aeba9 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -46,6 +46,7 @@ pub struct ApiClient { impl ApiClient { pub fn new(url: &str) -> Self { + let _ = rustls::crypto::ring::default_provider().install_default(); Self { base_url: std::sync::RwLock::new(url.to_string()), http: reqwest::Client::new(), 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(); } diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 54809513..822554bf 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -1,61 +1,19 @@ [package] name = "worker" version = "0.1.0" -edition = "2024" +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 = [ - "sqlite", - "dep:sqlite-federation", - "dep:activitypub", - "federation", - "composition/sqlite-federation", -] -postgres-federation = [ - "postgres", - "dep:postgres-federation", - "dep:activitypub", - "federation", - "composition/postgres-federation", -] +default = ["federation"] +federation = ["bootstrap/federation"] +nats = ["bootstrap/nats"] +sqlite = [] +sqlite-federation = ["federation"] [dependencies] -domain = { workspace = true } -application = { workspace = true } -composition = { workspace = true } -tokio = { workspace = true, features = ["signal"] } +bootstrap = { workspace = true } +config = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "sync"] } 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 } - -# 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 } +tracing = { workspace = true } diff --git a/crates/worker/src/event_bus.rs b/crates/worker/src/event_bus.rs deleted file mode 100644 index cc0c49ae..00000000 --- a/crates/worker/src/event_bus.rs +++ /dev/null @@ -1,34 +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, -) -> anyhow::Result<(Arc, Arc)> { - match EventBusBackend::from_env()? { - 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?) - } - } - } - #[cfg(feature = "nats")] - EventBusBackend::Nats => { - let cfg = nats::NatsConfig::from_env() - .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/follow_backfill_handler.rs b/crates/worker/src/follow_backfill_handler.rs deleted file mode 100644 index 454c12ba..00000000 --- a/crates/worker/src/follow_backfill_handler.rs +++ /dev/null @@ -1,49 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; -use domain::{ - errors::DomainError, - events::DomainEvent, - ports::{ApBackfillPort, EventHandler}, - value_objects::SocialIdentity, -}; - -pub struct FollowBackfillHandler { - pub backfill: Arc, -} - -#[async_trait] -impl EventHandler for FollowBackfillHandler { - async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { - match event { - DomainEvent::FollowAccepted { - owner, - requester: SocialIdentity::Remote { actor_url }, - } => { - tracing::info!(actor = %actor_url, "follow accepted — looking up outbox for import"); - let following = self.backfill.get_following(owner.value()).await?; - if let Some(actor) = following.iter().find(|a| a.url == *actor_url) { - if let Some(outbox_url) = &actor.outbox_url { - tracing::info!(outbox = %outbox_url, actor = %actor_url, "importing remote outbox"); - self.backfill - .import_remote_outbox(outbox_url, actor_url) - .await?; - } else { - tracing::warn!(actor = %actor_url, "no outbox URL for accepted follow — skipping import"); - } - } - Ok(()) - } - DomainEvent::BackfillFollower { - owner_user_id, - follower_inbox_url, - } => { - tracing::info!(owner = %owner_user_id.value(), inbox = %follower_inbox_url, "backfilling local content to new follower"); - self.backfill - .run_backfill_for_follower(owner_user_id.value(), follower_inbox_url.clone()) - .await - } - _ => Ok(()), - } - } -} diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 867b309f..6be9ed37 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -1,297 +1,12 @@ -mod event_bus; -mod follow_backfill_handler; - -use std::sync::Arc; - use anyhow::Context; -use application::{ - MovieDiscoveryIndexer, SearchCleanupHandler, SearchReindexHandler, config::AppConfig, - worker::WorkerService, -}; -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" -); #[tokio::main] async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); - init_tracing(); + bootstrap::init_tracing("worker=info,bootstrap=info,config=info,application=info,k_ap=info"); - 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()); - let app_config = AppConfig::from_env(); - 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()?; - - // 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() { - 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(&backend, &database_url, &instance).await?; - let (event_publisher_arc, consumer_arc) = event_bus::create(&db.db_pool).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 { - #[cfg(feature = "sqlite-federation")] - 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; - 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 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()?; - 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( - 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, - 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 config = config::load().context("Failed to load configuration")?; + let app = bootstrap::build_worker(&config).await?; let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); tokio::spawn(async move { @@ -299,32 +14,6 @@ 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; - - 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}")) - } + app.run(shutdown_rx).await } 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: diff --git a/docs/adr/0005-single-composition-root.md b/docs/adr/0005-single-composition-root.md index c7fb962c..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 @@ -75,3 +69,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/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/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 2c190b3e..946af264 100644 --- a/docs/adr/0008-presentation-names-renderers-not-reachers.md +++ b/docs/adr/0008-presentation-names-renderers-not-reachers.md @@ -259,3 +259,9 @@ 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. + +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 2eae79e9..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 @@ -365,3 +365,9 @@ 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. + +Now `make check-sqlite-is-ap-free` (was `check-social-crates-are-ap-free`); the sqlite social crates merged into `crates/adapters/sqlite`. 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. diff --git a/docs/adr/0013-config-and-bootstrap.md b/docs/adr/0013-config-and-bootstrap.md new file mode 100644 index 00000000..2ca5dc20 --- /dev/null +++ b/docs/adr/0013-config-and-bootstrap.md @@ -0,0 +1,45 @@ +# 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`. 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 +`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. 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..c921e66d --- /dev/null +++ b/docs/adr/0014-http-adapter-and-one-refusal-shape.md @@ -0,0 +1,56 @@ +# `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. The refusals were documented without a body schema. + +## 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`, `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 +existed before (`InvalidRating`/`ValidationError` 400, `NotFound` 404, `Unauthorized` 401, +`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 +`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. + +**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 +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. diff --git a/docs/adr/0015-transactional-outbox.md b/docs/adr/0015-transactional-outbox.md new file mode 100644 index 00000000..3846834b --- /dev/null +++ b/docs/adr/0015-transactional-outbox.md @@ -0,0 +1,102 @@ +# 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`, +`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` +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`, 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 + 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 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. + +## 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`), 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 + 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. 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 + is returned. +- Handler authors must keep new handlers idempotent; the ledger narrows duplicates but cannot + remove the crash-after-effect case. diff --git a/docs/adr/0016-account-deletion.md b/docs/adr/0016-account-deletion.md new file mode 100644 index 00000000..11088ee9 --- /dev/null +++ b/docs/adr/0016-account-deletion.md @@ -0,0 +1,103 @@ +# 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 user's +webhook tokens stop ingesting at once (`find_by_token_hash` ignores tokens of tombstoned users, +so the endpoint answers 401). The profile +and `?embed=true` pages 404. The ActivityPub actor document, inbox, outbox and featured answer +410 (`tombstone_gate` for every `/users/{id}/...` path, content objects included, and +`actor_is_gone` for the actor). WebFinger +answers 404 (owned by k-ap). + +**The service actor is not deletable.** The instance's service actor (`UserId::service_actor()`, +seeded by migration 0022, used for signed fetches) is refused by `request_account_deletion` +(403 `Forbidden`), and both UIs hide the action (`is_service_account` on the profile response; +the classic template checks the same). Deleting it would retire its name and, after the +retention period, its keypair, breaking signed fetches for good. + +**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. + Global wrap-up reports (`wrap_up_records` with no user) embed `most_active_user`; the purge + sets it to null in any report naming the user and keeps the rest (`wrap_up_records.report_json` + is in `PAYLOAD_KEYED_USER_COLUMNS`). + A failed purge is redelivered and queues the `Delete` again; a repeated `Delete` is + idempotent on remote servers. +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; `purge_expired_tombstones` deletes the user's `ap_followers` and + `ap_following` rows explicitly before the `users` row (a test covers it), not by relying on + the foreign-key cascade. +- 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. diff --git a/spa/src/components/delete-account-dialog.tsx b/spa/src/components/delete-account-dialog.tsx new file mode 100644 index 00000000..fa6b87b4 --- /dev/null +++ b/spa/src/components/delete-account-dialog.tsx @@ -0,0 +1,111 @@ +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..e5570194 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(), @@ -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(), @@ -228,3 +229,26 @@ 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: (_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 7ec90614..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" @@ -172,6 +186,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..27e2279b 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": "The last admin account 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..af03ea2e 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 [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() + // Full page load: the classic public home, not the SPA login. + window.location.assign("/") + }, + }, + ) + } + + 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..0557b394 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 && !data.is_service_account && ( + + )} + { + 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" }) + }, + }, + ) + } + />