From c9715baab8bf768be2593129d58eeffaa936bcf0 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 9 Aug 2026 14:58:14 +0200 Subject: [PATCH] structural refactor and codebase improvements --- .env.example | 4 +- .gitea/workflows/ci.yml | 5 + .github/workflows/ci.yml | 5 + .gitignore | 2 +- CONTRIBUTING.md | 4 +- Cargo.lock | 134 +- Cargo.toml | 7 + Dockerfile | 16 +- Makefile | 160 ++- README.md | 15 +- .../adapters/activitypub/src/event_handler.rs | 67 +- .../activitypub/src/federation_ports.rs | 137 +++ .../adapters/activitypub/src/goal_handler.rs | 10 +- crates/adapters/activitypub/src/lib.rs | 42 +- crates/adapters/activitypub/src/port.rs | 178 --- .../activitypub/src/review_handler.rs | 14 +- .../activitypub/src/social_adapter.rs | 197 +-- crates/adapters/activitypub/src/urls.rs | 49 +- .../adapters/activitypub/src/user_adapter.rs | 30 +- .../activitypub/src/watchlist_handler.rs | 12 +- .../adapters/postgres-federation/Cargo.toml | 1 + .../adapters/postgres-federation/src/lib.rs | 33 +- crates/adapters/postgres-social/Cargo.toml | 18 + .../src/ap_content.rs | 0 .../src/federated_profile.rs | 4 +- .../src/follow_repository.rs | 119 +- crates/adapters/postgres-social/src/lib.rs | 38 + .../src/remote_goals.rs | 0 .../src/social.rs | 4 +- .../src/watchlist.rs | 4 +- crates/adapters/postgres/Cargo.toml | 2 +- crates/adapters/postgres/src/lib.rs | 4 +- crates/adapters/postgres/src/profile.rs | 32 +- crates/adapters/sqlite-federation/Cargo.toml | 1 + crates/adapters/sqlite-federation/src/lib.rs | 32 +- .../sqlite-federation/src/tests/lib.rs | 63 - crates/adapters/sqlite-social/Cargo.toml | 15 + .../src/ap_content.rs | 0 .../src/federated_profile.rs | 4 +- .../src/follow_repository.rs | 122 +- crates/adapters/sqlite-social/src/lib.rs | 42 + .../src/remote_goals.rs | 0 .../src/social.rs | 4 +- .../src/tests/follow_relation_tests.rs | 253 ++++ .../src/watchlist.rs | 4 +- crates/adapters/sqlite/Cargo.toml | 2 +- crates/adapters/sqlite/src/lib.rs | 4 +- crates/adapters/sqlite/src/profile.rs | 36 +- crates/adapters/sqlite/src/tests/profile.rs | 95 ++ crates/adapters/template-askama/src/lib.rs | 1 + .../template-askama/templates/base.html | 2 +- .../template-askama/templates/following.html | 22 + crates/api-types/Cargo.toml | 3 + crates/api-types/src/rendering.rs | 1 + crates/api-types/src/social.rs | 63 + crates/application/src/auth/deps.rs | 4 + crates/application/src/auth/logout.rs | 11 +- crates/application/src/auth/tests/logout.rs | 12 +- crates/application/src/deps.rs | 194 +++ crates/application/src/diary/deps.rs | 24 +- crates/application/src/diary/export_diary.rs | 17 +- crates/application/src/diary/get_diary.rs | 8 +- .../src/diary/get_review_history.rs | 8 +- crates/application/src/diary/get_user_feed.rs | 61 + crates/application/src/diary/mod.rs | 1 + .../src/diary/tests/get_activity_feed.rs | 19 +- .../application/src/diary/tests/get_diary.rs | 5 +- .../src/diary/tests/get_review_history.rs | 8 +- .../src/diary/tests/get_user_feed.rs | 93 ++ .../src/import/apply_profile_and_map.rs | 64 + crates/application/src/import/commands.rs | 6 + .../application/src/import/delete_profile.rs | 10 +- crates/application/src/import/deps.rs | 32 + .../src/import/get_mapping_stage.rs | 47 + .../src/import/get_preview_stage.rs | 59 + .../src/import/get_session_state.rs | 43 + .../application/src/import/list_profiles.rs | 12 +- crates/application/src/import/mod.rs | 4 + .../src/import/tests/apply_profile_and_map.rs | 108 ++ .../src/import/tests/delete_profile.rs | 9 +- .../src/import/tests/get_mapping_stage.rs | 68 ++ .../src/import/tests/get_preview_stage.rs | 80 ++ .../src/import/tests/get_session_state.rs | 50 + .../src/import/tests/list_profiles.rs | 9 +- .../application/src/integrations/confirm.rs | 17 +- crates/application/src/integrations/deps.rs | 29 + .../application/src/integrations/dismiss.rs | 13 +- .../src/integrations/generate_token.rs | 12 +- .../application/src/integrations/get_queue.rs | 12 +- .../src/integrations/get_tokens.rs | 12 +- .../src/integrations/revoke_token.rs | 9 +- .../src/integrations/tests/confirm.rs | 48 +- .../src/integrations/tests/dismiss.rs | 17 +- .../src/integrations/tests/generate_token.rs | 5 +- .../src/integrations/tests/get_queue.rs | 11 +- .../src/integrations/tests/get_tokens.rs | 21 +- .../src/integrations/tests/ingest.rs | 6 +- .../src/integrations/tests/revoke_token.rs | 22 +- crates/application/src/jobs/wrapup.rs | 24 +- crates/application/src/lib.rs | 9 + crates/application/src/movies/deps.rs | 8 + .../src/movies/get_movie_profile.rs | 9 +- crates/application/src/movies/get_movies.rs | 8 +- .../src/movies/tests/get_movie_profile.rs | 9 +- .../src/movies/tests/get_movies.rs | 5 +- crates/application/src/search/deps.rs | 7 + crates/application/src/search/execute.rs | 11 +- crates/application/src/search/mod.rs | 1 + .../application/src/search/tests/execute.rs | 6 +- crates/application/src/services.rs | 25 + .../src/social/count_pending_followers.rs | 10 + crates/application/src/social/deps.rs | 6 +- crates/application/src/social/execute.rs | 30 +- crates/application/src/social/get_blocked.rs | 16 + .../application/src/social/get_followers.rs | 16 + .../application/src/social/get_following.rs | 16 + .../src/social/get_pending_followers.rs | 16 + .../src/social/get_pending_following.rs | 16 + crates/application/src/social/get_relation.rs | 17 + .../application/src/social/local_service.rs | 276 +++++ crates/application/src/social/mod.rs | 9 +- crates/application/src/social/queries.rs | 8 - .../application/src/social/tests/execute.rs | 323 ++++- .../src/social/tests/local_service.rs | 444 +++++++ crates/application/src/test_helpers.rs | 4 +- crates/application/src/tests/services.rs | 43 + .../application/src/users/authorize_admin.rs | 28 + crates/application/src/users/deps.rs | 70 +- crates/application/src/users/diary_filter.rs | 80 ++ .../src/users/get_current_profile.rs | 10 +- .../src/users/get_federated_profile.rs | 31 + .../src/users/get_federated_profile_stats.rs | 89 ++ .../src/users/get_local_profile.rs | 154 +++ .../application/src/users/get_page_viewer.rs | 44 + crates/application/src/users/get_profile.rs | 192 --- .../src/users/get_profile_settings.rs | 64 + crates/application/src/users/get_settings.rs | 10 +- crates/application/src/users/mod.rs | 9 +- .../src/users/resolve_username_to_id.rs | 23 + .../src/users/tests/authorize_admin.rs | 92 ++ .../src/users/tests/get_current_profile.rs | 11 +- .../src/users/tests/get_federated_profile.rs | 115 ++ .../tests/get_federated_profile_stats.rs | 45 + .../{get_profile.rs => get_local_profile.rs} | 200 ++- .../src/users/tests/get_page_viewer.rs | 66 + .../src/users/tests/get_profile_settings.rs | 64 + .../src/users/tests/get_settings.rs | 13 +- .../src/users/tests/resolve_username_to_id.rs | 23 + .../src/users/tests/update_profile_fields.rs | 22 +- .../src/users/tests/update_settings.rs | 30 +- .../src/users/update_profile_fields.rs | 16 +- .../application/src/users/update_settings.rs | 10 +- crates/application/src/watchlist/deps.rs | 22 +- crates/application/src/watchlist/get.rs | 8 +- .../src/watchlist/get_watchlist_for_owner.rs | 74 ++ crates/application/src/watchlist/is_on.rs | 8 +- crates/application/src/watchlist/mod.rs | 1 + crates/application/src/watchlist/remove.rs | 12 +- crates/application/src/watchlist/tests/get.rs | 7 +- .../tests/get_watchlist_for_owner.rs | 147 +++ .../application/src/watchlist/tests/is_on.rs | 12 +- .../application/src/watchlist/tests/remove.rs | 18 +- crates/application/src/wrapup/delete.rs | 14 +- crates/application/src/wrapup/deps.rs | 21 + crates/application/src/wrapup/generate.rs | 16 +- .../src/wrapup/get_ready_report.rs | 53 + crates/application/src/wrapup/get_wrapup.rs | 9 +- crates/application/src/wrapup/list_wrapups.rs | 11 +- crates/application/src/wrapup/mod.rs | 1 + crates/application/src/wrapup/tests/delete.rs | 11 +- .../application/src/wrapup/tests/generate.rs | 22 +- .../src/wrapup/tests/get_ready_report.rs | 152 +++ .../src/wrapup/tests/get_wrapup.rs | 11 +- .../src/wrapup/tests/list_wrapups.rs | 11 +- crates/composition/Cargo.toml | 46 + crates/composition/src/build.rs | 391 ++++++ .../src/factory.rs | 38 +- crates/composition/src/lib.rs | 11 + crates/composition/src/repositories.rs | 46 + crates/composition/src/tests/build.rs | 1087 +++++++++++++++++ crates/domain/Cargo.toml | 3 + crates/domain/src/models/federation.rs | 20 + crates/domain/src/ports/federation.rs | 103 ++ crates/domain/src/ports/follow.rs | 28 +- crates/domain/src/ports/mod.rs | 2 + crates/domain/src/ports/noop.rs | 91 +- crates/domain/src/ports/social.rs | 239 +++- crates/domain/src/testing/fakes.rs | 26 +- crates/domain/src/testing/in_memory.rs | 161 ++- crates/domain/src/testing/panics.rs | 30 +- crates/domain/src/tests/value_objects.rs | 93 ++ crates/domain/src/value_objects/instance.rs | 62 + crates/domain/src/value_objects/mod.rs | 2 + crates/domain/src/value_objects/social.rs | 33 +- crates/presentation/Cargo.toml | 45 +- crates/presentation/src/context.rs | 72 +- crates/presentation/src/extractors.rs | 44 +- crates/presentation/src/handlers/auth.rs | 58 +- crates/presentation/src/handlers/diary.rs | 49 +- crates/presentation/src/handlers/goals.rs | 42 +- crates/presentation/src/handlers/helpers.rs | 34 +- crates/presentation/src/handlers/import.rs | 187 +-- .../presentation/src/handlers/integrations.rs | 36 +- crates/presentation/src/handlers/mod.rs | 1 - crates/presentation/src/handlers/movies.rs | 34 +- crates/presentation/src/handlers/rss.rs | 34 +- crates/presentation/src/handlers/search.rs | 26 +- crates/presentation/src/handlers/social.rs | 582 +++++---- crates/presentation/src/handlers/users.rs | 251 ++-- crates/presentation/src/handlers/watchlist.rs | 80 +- crates/presentation/src/handlers/webhook.rs | 64 +- crates/presentation/src/handlers/wrapup.rs | 83 +- crates/presentation/src/lib.rs | 1 - crates/presentation/src/main.rs | 273 ----- crates/presentation/src/mappers/mod.rs | 2 - crates/presentation/src/mappers/social.rs | 9 - crates/presentation/src/mappers/users.rs | 2 +- crates/presentation/src/openapi/social.rs | 8 +- crates/presentation/src/routes.rs | 89 +- crates/presentation/src/tests/api_handlers.rs | 222 +++- crates/presentation/src/tests/context.rs | 35 + crates/presentation/src/tests/extractors.rs | 500 +++++++- crates/presentation/src/tests/mod.rs | 1 + crates/server/Cargo.toml | 78 ++ crates/server/src/main.rs | 320 +++++ .../tests/api_test.rs | 133 +- crates/worker/Cargo.toml | 25 +- crates/worker/src/db.rs | 125 -- crates/worker/src/event_bus.rs | 3 +- crates/worker/src/follow_backfill_handler.rs | 21 +- crates/worker/src/main.rs | 94 +- docs/adr/0001-general-review-editing.md | 8 + docs/adr/0004-instance-identity.md | 117 ++ docs/adr/0005-single-composition-root.md | 77 ++ docs/adr/0006-handlers-call-use-cases-only.md | 152 +++ .../0007-no-repositories-in-presentation.md | 148 +++ ...esentation-names-renderers-not-reachers.md | 261 ++++ ...ion-is-optional-at-the-dependency-level.md | 367 ++++++ spa/src/components/actor-list.tsx | 36 +- spa/src/components/bottom-tab-bar.tsx | 15 +- spa/src/components/profile-view.tsx | 3 +- spa/src/features/social.ts | 71 ++ spa/src/locales/en.json | 6 + spa/src/routes/_app.tsx | 5 +- spa/src/routes/_app/profile.tsx | 2 + spa/src/routes/_app/social.tsx | 69 +- spa/src/routes/_app/users.$id.tsx | 126 +- 247 files changed, 11515 insertions(+), 3063 deletions(-) create mode 100644 crates/adapters/activitypub/src/federation_ports.rs delete mode 100644 crates/adapters/activitypub/src/port.rs create mode 100644 crates/adapters/postgres-social/Cargo.toml rename crates/adapters/{postgres-federation => postgres-social}/src/ap_content.rs (100%) rename crates/adapters/{postgres-federation => postgres-social}/src/federated_profile.rs (94%) rename crates/adapters/{postgres-federation => postgres-social}/src/follow_repository.rs (71%) create mode 100644 crates/adapters/postgres-social/src/lib.rs rename crates/adapters/{postgres-federation => postgres-social}/src/remote_goals.rs (100%) rename crates/adapters/{postgres-federation => postgres-social}/src/social.rs (88%) rename crates/adapters/{postgres-federation => postgres-social}/src/watchlist.rs (97%) create mode 100644 crates/adapters/sqlite-social/Cargo.toml rename crates/adapters/{sqlite-federation => sqlite-social}/src/ap_content.rs (100%) rename crates/adapters/{sqlite-federation => sqlite-social}/src/federated_profile.rs (94%) rename crates/adapters/{sqlite-federation => sqlite-social}/src/follow_repository.rs (71%) create mode 100644 crates/adapters/sqlite-social/src/lib.rs rename crates/adapters/{sqlite-federation => sqlite-social}/src/remote_goals.rs (100%) rename crates/adapters/{sqlite-federation => sqlite-social}/src/social.rs (89%) create mode 100644 crates/adapters/sqlite-social/src/tests/follow_relation_tests.rs rename crates/adapters/{sqlite-federation => sqlite-social}/src/watchlist.rs (97%) create mode 100644 crates/adapters/sqlite/src/tests/profile.rs create mode 100644 crates/application/src/deps.rs create mode 100644 crates/application/src/diary/get_user_feed.rs create mode 100644 crates/application/src/diary/tests/get_user_feed.rs create mode 100644 crates/application/src/import/apply_profile_and_map.rs create mode 100644 crates/application/src/import/get_mapping_stage.rs create mode 100644 crates/application/src/import/get_preview_stage.rs create mode 100644 crates/application/src/import/get_session_state.rs create mode 100644 crates/application/src/import/tests/apply_profile_and_map.rs create mode 100644 crates/application/src/import/tests/get_mapping_stage.rs create mode 100644 crates/application/src/import/tests/get_preview_stage.rs create mode 100644 crates/application/src/import/tests/get_session_state.rs create mode 100644 crates/application/src/search/deps.rs create mode 100644 crates/application/src/services.rs create mode 100644 crates/application/src/social/count_pending_followers.rs create mode 100644 crates/application/src/social/get_blocked.rs create mode 100644 crates/application/src/social/get_followers.rs create mode 100644 crates/application/src/social/get_following.rs create mode 100644 crates/application/src/social/get_pending_followers.rs create mode 100644 crates/application/src/social/get_pending_following.rs create mode 100644 crates/application/src/social/get_relation.rs create mode 100644 crates/application/src/social/local_service.rs delete mode 100644 crates/application/src/social/queries.rs create mode 100644 crates/application/src/social/tests/local_service.rs create mode 100644 crates/application/src/tests/services.rs create mode 100644 crates/application/src/users/authorize_admin.rs create mode 100644 crates/application/src/users/diary_filter.rs create mode 100644 crates/application/src/users/get_federated_profile.rs create mode 100644 crates/application/src/users/get_federated_profile_stats.rs create mode 100644 crates/application/src/users/get_local_profile.rs create mode 100644 crates/application/src/users/get_page_viewer.rs delete mode 100644 crates/application/src/users/get_profile.rs create mode 100644 crates/application/src/users/get_profile_settings.rs create mode 100644 crates/application/src/users/resolve_username_to_id.rs create mode 100644 crates/application/src/users/tests/authorize_admin.rs create mode 100644 crates/application/src/users/tests/get_federated_profile.rs create mode 100644 crates/application/src/users/tests/get_federated_profile_stats.rs rename crates/application/src/users/tests/{get_profile.rs => get_local_profile.rs} (52%) create mode 100644 crates/application/src/users/tests/get_page_viewer.rs create mode 100644 crates/application/src/users/tests/get_profile_settings.rs create mode 100644 crates/application/src/users/tests/resolve_username_to_id.rs create mode 100644 crates/application/src/watchlist/get_watchlist_for_owner.rs create mode 100644 crates/application/src/watchlist/tests/get_watchlist_for_owner.rs create mode 100644 crates/application/src/wrapup/get_ready_report.rs create mode 100644 crates/application/src/wrapup/tests/get_ready_report.rs create mode 100644 crates/composition/Cargo.toml create mode 100644 crates/composition/src/build.rs rename crates/{presentation => composition}/src/factory.rs (80%) create mode 100644 crates/composition/src/lib.rs create mode 100644 crates/composition/src/repositories.rs create mode 100644 crates/composition/src/tests/build.rs create mode 100644 crates/domain/src/ports/federation.rs create mode 100644 crates/domain/src/value_objects/instance.rs delete mode 100644 crates/presentation/src/main.rs delete mode 100644 crates/presentation/src/mappers/social.rs create mode 100644 crates/presentation/src/tests/context.rs create mode 100644 crates/server/Cargo.toml create mode 100644 crates/server/src/main.rs rename crates/{presentation => server}/tests/api_test.rs (81%) delete mode 100644 crates/worker/src/db.rs create mode 100644 docs/adr/0001-general-review-editing.md create mode 100644 docs/adr/0004-instance-identity.md create mode 100644 docs/adr/0005-single-composition-root.md create mode 100644 docs/adr/0006-handlers-call-use-cases-only.md create mode 100644 docs/adr/0007-no-repositories-in-presentation.md create mode 100644 docs/adr/0008-presentation-names-renderers-not-reachers.md create mode 100644 docs/adr/0009-federation-is-optional-at-the-dependency-level.md diff --git a/.env.example b/.env.example index 105daa5..33f3052 100644 --- a/.env.example +++ b/.env.example @@ -45,7 +45,9 @@ ALLOW_REGISTRATION=true # PORT=3000 # RATE_LIMIT=60 # SECURE_COOKIES=true -# RUST_LOG=presentation=info,tower_http=info,worker=info +# Handler-level logs come from the `presentation` crate; startup/wiring logs come +# from `server`. Include both — `server=info` alone silently drops handler logs. +# RUST_LOG=server=info,presentation=info,tower_http=info,worker=info # CORS (for SPA development only) # CORS_ORIGINS=http://localhost:5173 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 976f967..d649966 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -40,3 +40,8 @@ jobs: - name: test run: cargo test + + # 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f806ec6..7291dc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,11 @@ jobs: - name: test run: cargo test + # 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 + docker: name: Build & Push Docker Image runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index e073875..6e4f522 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ .worktrees/ .superpowers/ -docs/ +docs/* !docs/adr/ imgs/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0881a87..75fb9d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ Thanks for your interest in Movies Diary! This is a personal project but contrib 4. Run the backend and worker: ```bash -cargo run -p presentation # HTTP server on :3000 +cargo run -p server # HTTP server on :3000 cargo run -p worker # event worker (separate terminal) ``` @@ -44,7 +44,7 @@ The project follows hexagonal (ports & adapters) architecture. See `architecture **Key rules:** - Presentation handlers never touch repositories directly — all domain logic goes through use cases in the `application` crate - Application use cases return raw domain data — URL formatting, date display, and view model assembly belong in presentation mappers (`presentation/src/mappers/`) -- Use cases called from presentation handlers take `&AppContext`. Functions called from adapter event handlers take individual `Arc` params to keep adapter dependencies explicit +- 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 ``` domain → pure types, traits (ports), zero deps diff --git a/Cargo.lock b/Cargo.lock index e630cc8..f738972 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,6 +306,7 @@ version = "0.1.0" dependencies = [ "domain", "serde", + "serde_json", "utoipa", "uuid", ] @@ -1118,6 +1119,39 @@ 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" @@ -1621,6 +1655,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", + "tokio", "uuid", ] @@ -3905,7 +3940,7 @@ dependencies = [ "chrono", "domain", "futures", - "postgres-federation", + "postgres-social", "serde", "serde_json", "sqlx", @@ -3940,6 +3975,7 @@ dependencies = [ "chrono", "domain", "k-ap", + "postgres-social", "serde_json", "sqlx", "tracing", @@ -3957,6 +3993,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "postgres-social" +version = "0.1.0" +dependencies = [ + "adapter-common", + "async-trait", + "chrono", + "domain", + "sqlx", + "uuid", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3991,43 +4039,22 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" name = "presentation" version = "0.1.0" dependencies = [ - "activitypub", "anyhow", "api-types", "application", "async-trait", - "auth", "axum", "axum-governor", "bytes", "chrono", + "composition", "domain", "dotenvy", - "export", "futures", - "http-body-util", - "importer", "infer", - "infra-wiring", - "jellyfin", - "metadata", - "nats", - "object-storage", "percent-encoding", - "plex", - "poster-fetcher", - "postgres", - "postgres-event-queue", - "postgres-federation", - "postgres-search", - "rss 0.1.0", "serde", "serde_json", - "sqlite", - "sqlite-event-queue", - "sqlite-federation", - "sqlite-search", - "sqlx", "template-askama", "tokio", "tower", @@ -4947,6 +4974,50 @@ dependencies = [ "serde", ] +[[package]] +name = "server" +version = "0.1.0" +dependencies = [ + "activitypub", + "anyhow", + "application", + "async-trait", + "auth", + "axum", + "bytes", + "composition", + "domain", + "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]] name = "sha1" version = "0.10.6" @@ -5190,7 +5261,7 @@ dependencies = [ "futures", "serde", "serde_json", - "sqlite-federation", + "sqlite-social", "sqlx", "tokio", "tracing", @@ -5224,6 +5295,7 @@ dependencies = [ "domain", "k-ap", "serde_json", + "sqlite-social", "sqlx", "tokio", "tracing", @@ -5242,6 +5314,19 @@ dependencies = [ "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" @@ -7151,6 +7236,7 @@ dependencies = [ "application", "async-trait", "auth", + "composition", "domain", "dotenvy", "export", diff --git a/Cargo.toml b/Cargo.toml index b1d0dfe..811985f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,9 @@ members = [ "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", @@ -23,7 +25,9 @@ members = [ "crates/adapters/tmdb-enrichment", "crates/adapters/image-converter", "crates/domain", + "crates/composition", "crates/presentation", + "crates/server", "crates/tui", "crates/worker", "crates/adapters/importer", @@ -68,6 +72,7 @@ api-types = { path = "crates/api-types" } domain = { path = "crates/domain" } tmdb-enrichment = { path = "crates/adapters/tmdb-enrichment" } application = { path = "crates/application" } +composition = { path = "crates/composition" } presentation = { path = "crates/presentation" } auth = { path = "crates/adapters/auth" } metadata = { path = "crates/adapters/metadata" } @@ -79,8 +84,10 @@ rss = { path = "crates/adapters/rss" } export = { path = "crates/adapters/export" } sqlite = { path = "crates/adapters/sqlite" } sqlite-federation = { path = "crates/adapters/sqlite-federation" } +sqlite-social = { path = "crates/adapters/sqlite-social" } 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" } diff --git a/Dockerfile b/Dockerfile index 4ba1adf..d136920 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,10 @@ 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 @@ -30,16 +34,20 @@ COPY crates/adapters/plex/Cargo.toml crates/adapters/plex/Cargo.tom COPY crates/adapters/rss/Cargo.toml crates/adapters/rss/Cargo.toml COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml COPY crates/adapters/sqlite-federation/Cargo.toml crates/adapters/sqlite-federation/Cargo.toml +COPY crates/adapters/sqlite-social/Cargo.toml crates/adapters/sqlite-social/Cargo.toml COPY crates/adapters/sqlite-event-queue/Cargo.toml crates/adapters/sqlite-event-queue/Cargo.toml COPY crates/adapters/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 @@ -71,7 +79,7 @@ COPY crates ./crates # 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 presentation -p worker --no-default-features --features "${FEATURES}" +RUN cargo build --release -p server -p worker --no-default-features --features "${FEATURES}" # ----- runtime ----- FROM debian:bookworm-slim @@ -85,13 +93,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app -COPY --from=builder /build/target/release/presentation ./presentation +COPY --from=builder /build/target/release/server ./server COPY --from=builder /build/target/release/worker ./worker COPY static ./static COPY --from=spa-builder /spa/dist ./spa/dist EXPOSE 3000 -ENV RUST_LOG=presentation=info,tower_http=info +ENV RUST_LOG=server=info,tower_http=info -CMD ["./presentation"] +CMD ["./server"] diff --git a/Makefile b/Makefile index 4b5fc2b..7069a4f 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .DEFAULT_GOAL := check # Run the full local check suite — same order as CI would. -check: fmt-check clippy test check-appcontext +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 @echo "✅ All checks passed" # Enforce that no application use case imports AppContext (god-object guard). @@ -13,6 +13,162 @@ check-appcontext: fi @echo "✅ No AppContext in application crate" +# Enforce that presentation never hand-builds a deps struct (composition-root guard). +# Catches struct literals (`FooDeps { ... }`) and conversions (`FooDeps::from(...)`) — +# both are the composition-root pattern that belongs in crates/composition. +# +# Scope is the whole crate, not just handlers/, so that factoring a +# `fn build_login_deps(state) -> LoginDeps { .. }` helper into a sibling module +# does not slip past the guard. One exclusion: +# src/tests/ — test fixtures legitimately assemble their own state +# `src/main.rs` used to be excluded too ("the binary IS the wiring root") before +# crates/server took over the binary (ADR-0006) — presentation is lib-only now, has no +# main.rs, and the exclusion was dropped rather than left pointing at a file that no +# longer exists. +# NOTE: `\s` is a GNU grep extension, not POSIX ERE. Fine on GNU/Ubuntu runners; +# would need `[[:space:]]` if this ever runs under BusyBox/Alpine grep. +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; \ + exit 1; \ + fi + @echo "✅ No hand-built deps structs in presentation" + +# Enforce that presentation calls use cases only, never repository methods directly +# (ADR-0006's guard; ADR-0007 records the widening below). +# +# Was: two shape-matching grep passes for `repos..(` and +# `repos..clone().(`, needed because `AppContext.repos` was still a +# legitimate way to reach a repository — ~40 sites cloned an Arc out of it to pass +# positionally into a use case, so the guard had to distinguish that legal shape +# from an illegal direct call. That shape-matching was demonstrably incomplete: it +# matches `repos..(` textually, so it cannot see a bare reference +# pass (`&repos.diary`, no call at all at the read site) or a `let`-bound access +# (`if let Some(x) = repos.federated_profile` then `x.method()` on the *binding*, +# not on `repos` itself). Both shapes existed in production code — Plan C2's Task 6 +# review caught it, Task 7 fixed both — while the two-pass guard printed a clean +# ✅ the whole time. ADR-0006's "handlers call use cases only" was false at that +# line for as long as the hole existed. +# +# Now: Plan C2 deleted `AppContext.repos` entirely (ADR-0007), so there is no +# longer any legitimate reason for the substring `app_ctx.repos` to appear in +# production code — not a call, not a clone, not a reference, not a let-binding. +# That makes the rule trivial and unevadeable: any mention at all is an offender. +# No shape to match means no shape to miss. +# +# One exclusion: src/tests/ — test helpers still legitimately build a +# `Repositories` and call `composition::build_deps` to produce test state. +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; \ + exit 1; \ + fi + @echo "✅ No app_ctx.repos references in presentation" + +# Enforce that presentation depends on at most one adapter crate. +# +# Presentation may name things that RENDER OUTPUT; it may not name things that +# REACH external systems or storage. `template-askama` is the HTML template +# engine, so it stays. See ADR-0008. +# +# This checks Cargo.toml rather than grepping src/, deliberately: `::` +# greps collide with same-named modules inside `application` and `presentation` +# (`auth::logout` is `application::auth`; `rss::get_user_feed` is +# `handlers::rss`), so the source-level grep cannot decide this. Every directory +# under crates/adapters/ is named exactly like its package, so directory names +# are a safe source of truth. +PRESENTATION_ALLOWED_ADAPTERS := template-askama + +check-presentation-adapters: + @deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/presentation/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; \ + 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."; \ + exit 1; \ + fi; \ + echo "✅ presentation depends on no adapter crate but $(PRESENTATION_ALLOWED_ADAPTERS)" + +# Enforce that the federation-off build wires real social behavior, not silent noops. +# +# With federation off, `NoopSocialCommand::follow` returns Ok(()) and writes nothing, +# and `NoopSocialQuery` always answers zero/empty. The write side is currently +# unreachable over HTTP (the social routes are federation-gated in presentation), but +# the query side is not: `get_local_profile`, `get_page_viewer` and `get_activity_feed` +# read it through fully ungated routes, so a noop there means a federation-off +# instance lies about its own follow graph on every profile view and activity feed +# request — contradicting ADR-0003, which established that local follows bypass +# ActivityPub entirely. `application::social::LocalSocialService` is what must be +# wired instead. See ADR-0009. +# +# Two checks, not one: absence of the noops is necessary but not sufficient — deleting +# the whole `#[cfg(not(feature = "federation"))]` wiring block would also make the +# first check pass, leaving federation-off with nothing wired at all, which is worse +# than the noops. So this also asserts `LocalSocialService` is present — and it must +# be present in the federation-OFF region specifically, not merely anywhere in the +# file: `LocalSocialService` is also constructed inside the federation-ON branch +# (feeding `CompositeSocialAdapter`), so a bare file-wide grep is satisfied by that +# occurrence alone and would not catch the federation-off block being deleted. The +# awk carves out the region from the first `#[cfg(not(feature = "federation"))]` to +# the next `#[cfg(feature = "federation")]` — the same kind of range +# `check-presentation-adapters` uses for `[dependencies]` — and greps only inside it. +# Not an occurrence count: that would work today but break the moment the two +# `LocalSocialService` constructions in `main.rs` are deduplicated (a recorded +# follow-up), which could legitimately drop the total to one. +# +# A grep rather than a test because the decision lives in `wire_dependencies()`, a +# private fn in a binary crate, unreachable from integration tests. +# +# Scope is the single file `crates/server/src/main.rs`, not the whole crate: it is the +# sole wiring site for these ports. `worker` and `tui` never name SocialCommand / +# FollowGraphQuery / BlockQuery at all, so there is nowhere else for the noops to +# reappear. Relocating the wiring out of main.rs would move it out of the guard's +# scope — widen this to `crates/server/src` if that ever happens. +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; \ + 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"; \ + exit 1; \ + fi + @echo "✅ server wires real social behavior in every configuration" + +# Enforce that `-social` crates never depend on ActivityPub. +# +# ADR-0009's whole federation-optional property rests on one invariant: a +# `-social` crate speaks only `domain::ports`; a `-federation` crate +# speaks ActivityPub; the former must never depend on the latter. It holds today, and +# nothing but this guard enforces it. One manifest line — +# `k-ap = { version = "0.5.0", registry = "gitea" }` in a `-social` crate's Cargo.toml, +# the obvious move when someone wants to reuse a helper like `status_to_str` from +# `sqlite-federation/src/lib.rs` — would silently restore all 76 crates and 19MB that +# federation-off exists to shed, while `make check`, `cargo test` and CI all stay green +# without this guard to catch it. Matches `activitypub` with any suffix, so +# `activitypub_federation = "0.6"` (the upstream crate carrying all 76 transitives) is +# caught too, not just the local `activitypub` crate. +# +# Known gap: a dependency renamed via a `package = "k-ap"` key (`foo = { package = +# "k-ap", ... }`), or declared with a `[dependencies.k-ap]` table header instead of the +# inline form, would not match this line-level regex. Not attempted — nothing in the +# workspace does either today. +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; \ + exit 1; \ + fi + @echo "✅ social crates depend on no k-ap, activitypub, or *-federation crate" + # Apply rustfmt to all files. fmt: cargo fmt @@ -34,4 +190,4 @@ fix: cargo fmt cargo clippy --fix --allow-dirty --allow-staged -.PHONY: check fmt fmt-check clippy test fix +.PHONY: check fmt fmt-check clippy test fix check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free diff --git a/README.md b/README.md index 9ac4c15..dd1c817 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,11 @@ 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 presentation and worker binaries +infra-wiring — shared infrastructure types (DbPool, EventBusBackend, AppConfig) used by both server and worker binaries 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, composition root for the HTTP process +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) adapters/ adapter-common — shared row-to-domain conversions, sqlx error mapping, date/uuid parsing utils @@ -159,12 +160,12 @@ Copy `.env.example` to `.env` and set the values below. Required fields must be | `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. `presentation=info,worker=info`) | +| `RUST_LOG` | — | No | Log verbosity (e.g. `server=info,worker=info`) | | `CORS_ORIGINS` | `*` | No | Comma-separated allowed origins for SPA dev | | `EVENT_BUS_BACKEND` | `db` | No | `db` (default) or `nats` | | `NATS_URL` | — | NATS only | NATS connection URL (e.g. `nats://localhost:4222`) | -The `worker` binary must run alongside `presentation` to process events: +The `worker` binary must run alongside `server` to process events: ```bash cargo run -p worker @@ -173,11 +174,11 @@ cargo run -p worker ## Run ```bash -cargo run -p presentation # HTTP server (0.0.0.0:3000) +cargo run -p server # HTTP server (0.0.0.0:3000) cargo run -p worker # event worker (poster sync, in a separate terminal) ``` -The worker polls the event queue and must run alongside the presentation to process background tasks like poster fetching. Both processes share the same database. +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. ## API @@ -249,7 +250,7 @@ This builds and starts the HTTP server (port 3000) and event worker. Data is per ### Manual docker run -The image contains both `presentation` and `worker` binaries. Run them as separate containers sharing the same data volume: +The image contains both `server` and `worker` binaries. Run them as separate containers sharing the same data volume: ```bash docker build -t movies-diary . diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index 6575e57..09f6b82 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -8,7 +8,7 @@ use domain::{ GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository, UserFederationSettingsQuery, }, - value_objects::{MovieId, ReviewId, UserId}, + value_objects::{InstanceIdentity, MovieId, ReviewId, UserId}, }; use std::sync::Arc; @@ -25,7 +25,7 @@ pub struct ActivityPubEventHandler { goal_repo: Arc, stats_repo: Arc, federation_settings: Arc, - base_url: String, + instance: InstanceIdentity, } impl ActivityPubEventHandler { @@ -38,7 +38,7 @@ impl ActivityPubEventHandler { goal_repo: Arc, stats_repo: Arc, federation_settings: Arc, - base_url: String, + instance: InstanceIdentity, ) -> Self { Self { ap_service, @@ -48,7 +48,7 @@ impl ActivityPubEventHandler { goal_repo, stats_repo, federation_settings, - base_url, + instance, } } } @@ -140,7 +140,7 @@ impl EventHandler for ActivityPubEventHandler { .await .map_err(|e| DomainError::InfrastructureError(e.to_string())), DomainEvent::UserDeleted { user_id } => { - let ap_id = actor_url(&self.base_url, user_id.value()); + let ap_id = actor_url(&self.instance, user_id.value()); self.ap_service .broadcast_delete_to_followers(user_id.value(), ap_id) .await @@ -179,8 +179,8 @@ impl ActivityPubEventHandler { None => return Ok(()), }; - let ap_id = review_url(&self.base_url, review_id); - let actor = actor_url(&self.base_url, user_id.value()); + let ap_id = review_url(&self.instance, review_id); + let actor = actor_url(&self.instance, user_id.value()); let movie = self .movie_repo @@ -210,8 +210,8 @@ impl ActivityPubEventHandler { poster_url: movie .as_ref() .and_then(|m| m.poster_path()) - .map(|p| format!("{}/images/{}", self.base_url, p.value())), - base_url: self.base_url.clone(), + .map(|p| self.instance.image_url_for(p.value())), + base_url: self.instance.base_url().to_string(), }, ); let json = serde_json::to_value(obj)?; @@ -245,8 +245,8 @@ impl ActivityPubEventHandler { None => return Ok(()), }; - let ap_id = review_url(&self.base_url, review_id); - let actor = actor_url(&self.base_url, user_id.value()); + let ap_id = review_url(&self.instance, review_id); + let actor = actor_url(&self.instance, user_id.value()); let movie = self .movie_repo @@ -276,8 +276,8 @@ impl ActivityPubEventHandler { poster_url: movie .as_ref() .and_then(|m| m.poster_path()) - .map(|p| format!("{}/images/{}", self.base_url, p.value())), - base_url: self.base_url.clone(), + .map(|p| self.instance.image_url_for(p.value())), + base_url: self.instance.base_url().to_string(), }, ); let json = serde_json::to_value(obj)?; @@ -294,7 +294,7 @@ impl ActivityPubEventHandler { user_id: &UserId, review_id: &ReviewId, ) -> anyhow::Result<()> { - let ap_id = review_url(&self.base_url, review_id); + let ap_id = review_url(&self.instance, review_id); self.ap_service .broadcast_delete_to_followers(user_id.value(), ap_id) .await?; @@ -320,8 +320,8 @@ impl ActivityPubEventHandler { } use crate::urls::watchlist_entry_url; - let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value()); - let actor = actor_url(&self.base_url, user_id.value()); + let ap_id = watchlist_entry_url(&self.instance, user_id.value(), movie_id.value()); + let actor = actor_url(&self.instance, user_id.value()); let poster_url = self .movie_repo @@ -331,7 +331,7 @@ impl ActivityPubEventHandler { .flatten() .and_then(|m| { m.poster_path() - .map(|p| format!("{}/images/{}", self.base_url, p.value())) + .map(|p| self.instance.image_url_for(p.value())) }); let added_at_utc = @@ -344,7 +344,7 @@ impl ActivityPubEventHandler { external_metadata_id: external_metadata_id.clone(), poster_url, added_at: added_at_utc, - base_url: self.base_url.clone(), + base_url: self.instance.base_url().to_string(), }); let json = serde_json::to_value(obj)?; @@ -360,7 +360,7 @@ impl ActivityPubEventHandler { movie_id: &domain::value_objects::MovieId, ) -> anyhow::Result<()> { use crate::urls::watchlist_entry_url; - let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value()); + let ap_id = watchlist_entry_url(&self.instance, user_id.value(), movie_id.value()); self.ap_service .broadcast_delete_to_followers(user_id.value(), ap_id) .await?; @@ -383,7 +383,7 @@ impl ActivityPubEventHandler { .map(|id| id.value().to_string()); let poster_url = movie .poster_path() - .map(|p| format!("{}/images/{}", self.base_url, p.value())); + .map(|p| self.instance.image_url_for(p.value())); for entry in entries { let review = entry.review(); @@ -398,8 +398,8 @@ impl ActivityPubEventHandler { continue; } - let ap_id = review_url(&self.base_url, review.id()); - let actor = actor_url(&self.base_url, user_id.value()); + let ap_id = review_url(&self.instance, review.id()); + let actor = actor_url(&self.instance, user_id.value()); let obj = review_to_ap_object( review, @@ -410,7 +410,7 @@ impl ActivityPubEventHandler { release_year: movie.release_year().value(), external_metadata_id: external_metadata_id.clone(), poster_url: poster_url.clone(), - base_url: self.base_url.clone(), + base_url: self.instance.base_url().to_string(), }, ); let json = serde_json::to_value(obj)?; @@ -450,15 +450,15 @@ impl ActivityPubEventHandler { .count_reviews_in_year(user_id, year) .await .unwrap_or(0); - let ap_id = goal_url(&self.base_url, user_id.value(), year); - let actor = actor_url(&self.base_url, user_id.value()); + let ap_id = goal_url(&self.instance, user_id.value(), year); + let actor = actor_url(&self.instance, user_id.value()); let obj = goal_to_ap_object( ap_id, actor, year, goal.target_count(), current, - &self.base_url, + self.instance.base_url(), ); let json = serde_json::to_value(obj)?; self.ap_service @@ -488,9 +488,16 @@ impl ActivityPubEventHandler { .await .unwrap_or(0); - let ap_id = goal_url(&self.base_url, user_id.value(), year); - let actor = actor_url(&self.base_url, user_id.value()); - let obj = goal_to_ap_object(ap_id, actor, year, target_count, current, &self.base_url); + let ap_id = goal_url(&self.instance, user_id.value(), year); + let actor = actor_url(&self.instance, user_id.value()); + let obj = goal_to_ap_object( + ap_id, + actor, + year, + target_count, + current, + self.instance.base_url(), + ); let json = serde_json::to_value(obj)?; if is_create { self.ap_service @@ -513,7 +520,7 @@ impl ActivityPubEventHandler { if !flags.goals { return Ok(()); } - let ap_id = goal_url(&self.base_url, user_id.value(), year); + let ap_id = goal_url(&self.instance, user_id.value(), year); self.ap_service .broadcast_delete_to_followers(user_id.value(), ap_id) .await?; diff --git a/crates/adapters/activitypub/src/federation_ports.rs b/crates/adapters/activitypub/src/federation_ports.rs new file mode 100644 index 0000000..4b4a9fb --- /dev/null +++ b/crates/adapters/activitypub/src/federation_ports.rs @@ -0,0 +1,137 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{BlockedDomainInfo, FollowedActorInfo}, + ports::{ApBackfillPort, ApDocumentPort, InstanceBlocklistPort}, +}; +use k_ap::ActivityPubService; +use uuid::Uuid; + +/// Adapts the federation library's service to the three domain-owned ports. +/// +/// A wrapper rather than a bare `impl ... for ActivityPubService` because the +/// orphan rule forbids implementing a foreign trait for a foreign type, and +/// from this crate both `ApDocumentPort` (owned by `domain`) and +/// `ActivityPubService` (owned by the external `k-ap` crate) are foreign. +/// +/// One type carrying all three impls mirrors `CompositeSocialAdapter`, which +/// serves `SocialCommand`, `FollowGraphQuery`, and `BlockQuery` the same way. +pub struct ApServiceAdapter { + service: Arc, +} + +impl ApServiceAdapter { + pub fn new(service: Arc) -> Self { + Self { service } + } +} + +/// Single conversion point from the federation library's `anyhow` errors to +/// `DomainError`. The log line and the resulting error string reproduce what +/// `presentation::handlers::social::ap_to_domain` produced before the port +/// inversion moved the boundary here. +fn ap_err(e: anyhow::Error) -> DomainError { + tracing::error!("ActivityPub error: {:?}", e); + DomainError::InfrastructureError(e.to_string()) +} + +#[async_trait] +impl ApDocumentPort for ApServiceAdapter { + async fn actor_json(&self, user_id: &str) -> Result { + self.service.actor_json(user_id).await.map_err(ap_err) + } + async fn followers_collection_json( + &self, + user_id: Uuid, + page: Option, + ) -> Result { + self.service + .followers_collection_json(user_id, page) + .await + .map_err(ap_err) + } + async fn following_collection_json( + &self, + user_id: Uuid, + page: Option, + ) -> Result { + self.service + .following_collection_json(user_id, page) + .await + .map_err(ap_err) + } +} + +#[async_trait] +impl InstanceBlocklistPort for ApServiceAdapter { + async fn get_blocked_domains(&self) -> Result, DomainError> { + let domains = self.service.get_blocked_domains().await.map_err(ap_err)?; + Ok(domains + .into_iter() + .map(|d| BlockedDomainInfo { + domain: d.domain, + reason: d.reason, + blocked_at: d.blocked_at, + }) + .collect()) + } + async fn add_blocked_domain( + &self, + domain: &str, + reason: Option<&str>, + ) -> Result<(), DomainError> { + self.service + .add_blocked_domain(domain, reason) + .await + .map_err(ap_err) + } + async fn remove_blocked_domain(&self, domain: &str) -> Result<(), DomainError> { + self.service + .remove_blocked_domain(domain) + .await + .map_err(ap_err) + } +} + +#[async_trait] +impl ApBackfillPort for ApServiceAdapter { + async fn get_following( + &self, + local_user_id: Uuid, + ) -> Result, DomainError> { + let actors = self + .service + .get_following(local_user_id) + .await + .map_err(ap_err)?; + Ok(actors + .into_iter() + .map(|a| FollowedActorInfo { + url: a.url, + outbox_url: a.outbox_url, + }) + .collect()) + } + async fn import_remote_outbox( + &self, + outbox_url: &str, + actor_url: &str, + ) -> Result<(), DomainError> { + self.service + .import_remote_outbox(outbox_url, actor_url) + .await + .map_err(ap_err) + } + async fn run_backfill_for_follower( + &self, + owner_user_id: Uuid, + follower_inbox_url: String, + ) -> Result<(), DomainError> { + self.service + .run_backfill_for_follower(owner_user_id, follower_inbox_url) + .await + .map_err(ap_err) + } +} diff --git a/crates/adapters/activitypub/src/goal_handler.rs b/crates/adapters/activitypub/src/goal_handler.rs index db826a3..adcdc30 100644 --- a/crates/adapters/activitypub/src/goal_handler.rs +++ b/crates/adapters/activitypub/src/goal_handler.rs @@ -5,7 +5,7 @@ use chrono::DateTime; use domain::{ models::RemoteGoalEntry, ports::{GoalQuery, RemoteGoalRepository}, - value_objects::UserId, + value_objects::{InstanceIdentity, UserId}, }; use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject}; use url::Url; @@ -16,7 +16,7 @@ use crate::urls::{actor_url, goal_url}; pub struct GoalObjectHandler { pub remote_goal_repo: Arc, pub goal_repo: Arc, - pub base_url: String, + pub instance: InstanceIdentity, } #[async_trait] @@ -34,11 +34,11 @@ impl ApContentReader for GoalObjectHandler { .await .map_err(|e| anyhow::anyhow!(e.to_string()))?; - let actor = actor_url(&self.base_url, user_id); + let actor = actor_url(&self.instance, user_id); let follower_cc = format!("{}/followers", actor); let mut results = Vec::new(); for goal in goals { - let ap_id = goal_url(&self.base_url, user_id, goal.year()); + let ap_id = goal_url(&self.instance, user_id, goal.year()); let published = DateTime::from_naive_utc_and_offset(*goal.created_at(), chrono::Utc); let obj = goal_to_ap_object( ap_id.clone(), @@ -46,7 +46,7 @@ impl ApContentReader for GoalObjectHandler { goal.year(), goal.target_count(), 0, - &self.base_url, + self.instance.base_url(), ); results.push(LocalObject { ap_id, diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index 7e14f8a..77bed93 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -1,9 +1,9 @@ pub mod composite_handler; pub mod event_handler; pub mod federation_event_bridge; +pub mod federation_ports; pub mod goal_handler; pub mod objects; -pub mod port; pub mod remote_review_repository; pub mod review_handler; pub mod social_adapter; @@ -22,7 +22,7 @@ pub use k_ap::{ }; pub use event_handler::ActivityPubEventHandler; -pub use port::{ActivityPubPort, NoopActivityPubService}; +pub use federation_ports::ApServiceAdapter; pub use remote_review_repository::{RemoteReviewRepository, RemoteReviewUpdate}; pub use review_handler::ReviewObjectHandler; pub use social_adapter::CompositeSocialAdapter; @@ -41,7 +41,16 @@ pub struct FederationRepos { } pub struct ActivityPubWire { - pub service: std::sync::Arc, + /// AP document serving. Prefer this over `service` from outside this crate. + pub document: std::sync::Arc, + /// Instance domain blocklist. Prefer this over `service` from outside this crate. + pub blocklist: std::sync::Arc, + /// Post-follow content backfill. Prefer this over `service` from outside this crate. + pub backfill: std::sync::Arc, + /// The concrete service, consumed by `crates/server` to construct + /// `CompositeSocialAdapter`. Everything else should use + /// `document`/`blocklist`/`backfill`. + pub service: std::sync::Arc, pub router: axum::Router, pub event_handler: std::sync::Arc, } @@ -64,7 +73,7 @@ pub struct ActivityPubDeps { pub federation_settings: std::sync::Arc, pub follow_command: std::sync::Arc, pub follow_query: std::sync::Arc, - pub base_url: String, + pub instance: domain::value_objects::InstanceIdentity, pub allow_registration: bool, pub event_publisher: std::sync::Arc, } @@ -88,7 +97,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { federation_settings, follow_command: _, follow_query: _, - base_url, + instance, allow_registration, event_publisher, } = deps; @@ -98,17 +107,17 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { diary_repo, review_store, event_publisher: std::sync::Arc::clone(&event_publisher), - base_url: base_url.clone(), + instance: instance.clone(), }); let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler { remote_watchlist_repo, content_query: std::sync::Arc::clone(&local_ap_content), - base_url: base_url.clone(), + instance: instance.clone(), }); let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler { remote_goal_repo, goal_repo: std::sync::Arc::clone(&goal_repo), - base_url: base_url.clone(), + instance: instance.clone(), }); let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler { review: review_handler, @@ -132,14 +141,14 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { ); let concrete = std::sync::Arc::new( - ActivityPubService::builder(base_url.clone()) + ActivityPubService::builder(instance.base_url().to_string()) .activity_repo(activity_repo) .follow_repo(follow_repo) .actor_repo(actor_repo) .blocklist_repo(blocklist_repo) .user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new( user_repo, - base_url.clone(), + instance.clone(), ))) .signed_fetch_actor_id(INSTANCE_ACTOR_ID) .content_reader(composite.clone() as std::sync::Arc) @@ -165,11 +174,20 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { goal_repo, stats_repo, federation_settings, - base_url, + instance, )) as std::sync::Arc; + let ports = std::sync::Arc::new(federation_ports::ApServiceAdapter::new( + std::sync::Arc::clone(&concrete), + )); + Ok(ActivityPubWire { - service: concrete as std::sync::Arc, + document: std::sync::Arc::clone(&ports) + as std::sync::Arc, + blocklist: std::sync::Arc::clone(&ports) + as std::sync::Arc, + backfill: ports as std::sync::Arc, + service: concrete, router, event_handler, }) diff --git a/crates/adapters/activitypub/src/port.rs b/crates/adapters/activitypub/src/port.rs deleted file mode 100644 index 688ebeb..0000000 --- a/crates/adapters/activitypub/src/port.rs +++ /dev/null @@ -1,178 +0,0 @@ -use async_trait::async_trait; -use uuid::Uuid; - -use k_ap::{ActivityPubService, BlockedDomain, RemoteActor}; - -#[async_trait] -pub trait ActivityPubPort: Send + Sync { - async fn actor_json(&self, user_id: &str) -> anyhow::Result; - async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()>; - async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>; - async fn accept_follower( - &self, - local_user_id: Uuid, - remote_actor_url: &str, - ) -> anyhow::Result<()>; - async fn reject_follower( - &self, - local_user_id: Uuid, - remote_actor_url: &str, - ) -> anyhow::Result<()>; - async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result>; - async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>; - async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>; - async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>; - async fn get_blocked_actors(&self, local_user_id: Uuid) -> anyhow::Result>; - async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()>; - async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()>; - async fn get_blocked_domains(&self) -> anyhow::Result>; - async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()>; - async fn followers_collection_json( - &self, - user_id: Uuid, - page: Option, - ) -> anyhow::Result; - async fn following_collection_json( - &self, - user_id: Uuid, - page: Option, - ) -> anyhow::Result; - async fn run_backfill_for_follower( - &self, - owner_user_id: Uuid, - follower_inbox_url: String, - ) -> anyhow::Result<()>; -} - -#[async_trait] -impl ActivityPubPort for ActivityPubService { - async fn actor_json(&self, user_id: &str) -> anyhow::Result { - self.actor_json(user_id).await - } - async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()> { - self.follow(local_user_id, handle).await - } - async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> { - self.unfollow(local_user_id, actor_url).await - } - async fn accept_follower( - &self, - local_user_id: Uuid, - remote_actor_url: &str, - ) -> anyhow::Result<()> { - self.accept_follower(local_user_id, remote_actor_url).await - } - async fn reject_follower( - &self, - local_user_id: Uuid, - remote_actor_url: &str, - ) -> anyhow::Result<()> { - self.reject_follower(local_user_id, remote_actor_url).await - } - async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result> { - self.get_following(local_user_id).await - } - async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> { - self.remove_follower(local_user_id, actor_url).await - } - async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> { - self.block_actor(local_user_id, actor_url).await - } - async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> { - self.unblock_actor(local_user_id, actor_url).await - } - async fn get_blocked_actors(&self, local_user_id: Uuid) -> anyhow::Result> { - self.get_blocked_actors(local_user_id).await - } - async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> { - self.add_blocked_domain(domain, reason).await - } - async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> { - self.remove_blocked_domain(domain).await - } - async fn get_blocked_domains(&self) -> anyhow::Result> { - self.get_blocked_domains().await - } - async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()> { - self.import_remote_outbox(outbox_url, actor_url).await - } - async fn followers_collection_json( - &self, - user_id: Uuid, - page: Option, - ) -> anyhow::Result { - self.followers_collection_json(user_id, page).await - } - async fn following_collection_json( - &self, - user_id: Uuid, - page: Option, - ) -> anyhow::Result { - self.following_collection_json(user_id, page).await - } - async fn run_backfill_for_follower( - &self, - owner_user_id: Uuid, - follower_inbox_url: String, - ) -> anyhow::Result<()> { - self.run_backfill_for_follower(owner_user_id, follower_inbox_url) - .await - } -} - -pub struct NoopActivityPubService; - -#[async_trait] -impl ActivityPubPort for NoopActivityPubService { - async fn actor_json(&self, _: &str) -> anyhow::Result { - Ok(String::new()) - } - async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> { - Ok(()) - } - async fn unfollow(&self, _: Uuid, _: &str) -> anyhow::Result<()> { - Ok(()) - } - async fn accept_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { - Ok(()) - } - async fn reject_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { - Ok(()) - } - async fn get_following(&self, _: Uuid) -> anyhow::Result> { - Ok(vec![]) - } - async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { - Ok(()) - } - async fn block_actor(&self, _: Uuid, _: &str) -> anyhow::Result<()> { - Ok(()) - } - async fn unblock_actor(&self, _: Uuid, _: &str) -> anyhow::Result<()> { - Ok(()) - } - async fn get_blocked_actors(&self, _: Uuid) -> anyhow::Result> { - Ok(vec![]) - } - async fn add_blocked_domain(&self, _: &str, _: Option<&str>) -> anyhow::Result<()> { - Ok(()) - } - async fn remove_blocked_domain(&self, _: &str) -> anyhow::Result<()> { - Ok(()) - } - async fn get_blocked_domains(&self) -> anyhow::Result> { - Ok(vec![]) - } - async fn import_remote_outbox(&self, _: &str, _: &str) -> anyhow::Result<()> { - Ok(()) - } - async fn followers_collection_json(&self, _: Uuid, _: Option) -> anyhow::Result { - Ok(String::new()) - } - async fn following_collection_json(&self, _: Uuid, _: Option) -> anyhow::Result { - Ok(String::new()) - } - async fn run_backfill_for_follower(&self, _: Uuid, _: String) -> anyhow::Result<()> { - Ok(()) - } -} diff --git a/crates/adapters/activitypub/src/review_handler.rs b/crates/adapters/activitypub/src/review_handler.rs index d507be4..5c3325d 100644 --- a/crates/adapters/activitypub/src/review_handler.rs +++ b/crates/adapters/activitypub/src/review_handler.rs @@ -5,7 +5,9 @@ use domain::{ events::DomainEvent, models::ReviewSource, ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery}, - value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId}, + value_objects::{ + Comment, ExternalMetadataId, InstanceIdentity, MovieId, Rating, ReviewId, UserId, + }, }; use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject}; use url::Url; @@ -20,7 +22,7 @@ pub struct ReviewObjectHandler { pub diary_repo: Arc, pub review_store: Arc, pub event_publisher: Arc, - pub base_url: String, + pub instance: InstanceIdentity, } #[async_trait] @@ -39,17 +41,17 @@ impl ApContentReader for ReviewObjectHandler { .await .map_err(|e| anyhow::anyhow!(e.to_string()))?; - let actor = actor_url(&self.base_url, user_id); + let actor = actor_url(&self.instance, user_id); let mut results = Vec::new(); for entry in entries { let review = entry.review(); let published = chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc); let movie = entry.movie(); - let ap_id = review_url(&self.base_url, review.id()); + let ap_id = review_url(&self.instance, review.id()); let poster_url = movie .poster_path() - .map(|p| format!("{}/images/{}", self.base_url, p.value())); + .map(|p| self.instance.image_url_for(p.value())); let obj = review_to_ap_object( review, @@ -62,7 +64,7 @@ impl ApContentReader for ReviewObjectHandler { .external_metadata_id() .map(|id| id.value().to_string()), poster_url, - base_url: self.base_url.clone(), + base_url: self.instance.base_url().to_string(), }, ); let follower_cc = format!("{}/followers", actor); diff --git a/crates/adapters/activitypub/src/social_adapter.rs b/crates/adapters/activitypub/src/social_adapter.rs index 4111443..c0a16c7 100644 --- a/crates/adapters/activitypub/src/social_adapter.rs +++ b/crates/adapters/activitypub/src/social_adapter.rs @@ -3,73 +3,33 @@ use std::sync::Arc; use async_trait::async_trait; use domain::{ errors::DomainError, - ports::{FollowCommand, FollowQuery, SocialCommand, SocialQuery, UserRepository}, - value_objects::{FollowStatus, FollowTarget, SocialActor, SocialIdentity, UserId, Username}, + ports::{BlockQuery, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository}, + value_objects::{ + FollowRelation, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, UserId, + }, }; -use super::ActivityPubPort; +use k_ap::ActivityPubService; pub struct CompositeSocialAdapter { - ap_service: Arc, + local: Arc, + ap_service: Arc, user_repo: Arc, - follow_command: Arc, - follow_query: Arc, - base_url: String, + instance: InstanceIdentity, } impl CompositeSocialAdapter { pub fn new( - ap_service: Arc, + local: Arc, + ap_service: Arc, user_repo: Arc, - follow_command: Arc, - follow_query: Arc, - base_url: String, + instance: InstanceIdentity, ) -> Self { Self { + local, ap_service, user_repo, - follow_command, - follow_query, - base_url, - } - } - - fn local_actor_url(&self, user_id: &UserId) -> String { - format!("{}/users/{}", self.base_url, user_id.value()) - } - - fn actor_url_from_identity(&self, identity: &SocialIdentity) -> String { - match identity { - SocialIdentity::Local(uid) => self.local_actor_url(uid), - SocialIdentity::Remote { actor_url } => actor_url.clone(), - } - } - - async fn resolve_target_identity( - &self, - target: &FollowTarget, - ) -> Result { - match target { - FollowTarget::Identity(id) => Ok(id.clone()), - FollowTarget::Handle(handle) => { - let host = handle.rsplit_once('@').map(|(_, h)| h).unwrap_or(""); - let local_host = SocialIdentity::host_from_base_url(&self.base_url); - if host == local_host { - let username_str = handle - .trim_start_matches('@') - .split('@') - .next() - .unwrap_or(""); - if let Ok(username) = Username::new(username_str.to_string()) - && let Some(user) = self.user_repo.find_by_username(&username).await? - { - return Ok(SocialIdentity::Local(user.id().clone())); - } - } - Ok(SocialIdentity::Remote { - actor_url: handle.clone(), - }) - } + instance, } } } @@ -81,23 +41,10 @@ fn ap_err(e: anyhow::Error) -> DomainError { #[async_trait] impl SocialCommand for CompositeSocialAdapter { async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> { - let identity = self.resolve_target_identity(target).await?; + let identity = self.local.resolve_target(target).await?; - if let SocialIdentity::Local(ref target_id) = identity { - if follower == target_id { - return Err(DomainError::ValidationError( - "Cannot follow yourself".into(), - )); - } - let follower_url = self.local_actor_url(follower); - let target_url = self.local_actor_url(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?; - return Ok(()); + if let SocialIdentity::Local(_) = identity { + return self.local.follow_resolved(follower, &identity).await; } let handle = match target { @@ -109,7 +56,7 @@ impl SocialCommand for CompositeSocialAdapter { .find_by_id(uid) .await? .ok_or_else(|| DomainError::NotFound("User not found".into()))?; - SocialIdentity::format_local_handle(user.username().value(), &self.base_url) + self.instance.handle_for(user.username().value()) } SocialIdentity::Remote { actor_url } => actor_url.clone(), }, @@ -125,21 +72,11 @@ impl SocialCommand for CompositeSocialAdapter { follower: &UserId, target: &SocialIdentity, ) -> Result<(), DomainError> { - let actor_url = self.actor_url_from_identity(target); match target { - SocialIdentity::Local(target_id) => { - let follower_url = self.local_actor_url(follower); - self.follow_command - .remove_follow(follower.value(), &actor_url) - .await?; - self.follow_command - .remove_follower_record(target_id.value(), &follower_url) - .await?; - Ok(()) - } + SocialIdentity::Local(_) => self.local.unfollow(follower, target).await, SocialIdentity::Remote { .. } => self .ap_service - .unfollow(follower.value(), &actor_url) + .unfollow(follower.value(), &self.instance.actor_url_of(target)) .await .map_err(ap_err), } @@ -150,21 +87,11 @@ impl SocialCommand for CompositeSocialAdapter { owner: &UserId, requester: &SocialIdentity, ) -> Result<(), DomainError> { - let actor_url = self.actor_url_from_identity(requester); match requester { - SocialIdentity::Local(requester_id) => { - let owner_url = self.local_actor_url(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(()) - } + SocialIdentity::Local(_) => self.local.accept_follow(owner, requester).await, SocialIdentity::Remote { .. } => self .ap_service - .accept_follower(owner.value(), &actor_url) + .accept_follower(owner.value(), &self.instance.actor_url_of(requester)) .await .map_err(ap_err), } @@ -175,21 +102,11 @@ impl SocialCommand for CompositeSocialAdapter { owner: &UserId, requester: &SocialIdentity, ) -> Result<(), DomainError> { - let actor_url = self.actor_url_from_identity(requester); match requester { - SocialIdentity::Local(requester_id) => { - let owner_url = self.local_actor_url(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(()) - } + SocialIdentity::Local(_) => self.local.reject_follow(owner, requester).await, SocialIdentity::Remote { .. } => self .ap_service - .reject_follower(owner.value(), &actor_url) + .reject_follower(owner.value(), &self.instance.actor_url_of(requester)) .await .map_err(ap_err), } @@ -200,28 +117,18 @@ impl SocialCommand for CompositeSocialAdapter { owner: &UserId, follower: &SocialIdentity, ) -> Result<(), DomainError> { - let actor_url = self.actor_url_from_identity(follower); match follower { - SocialIdentity::Local(follower_id) => { - let owner_url = self.local_actor_url(owner); - self.follow_command - .remove_follower_record(owner.value(), &actor_url) - .await?; - self.follow_command - .remove_follow(follower_id.value(), &owner_url) - .await?; - Ok(()) - } + SocialIdentity::Local(_) => self.local.remove_follower(owner, follower).await, SocialIdentity::Remote { .. } => self .ap_service - .remove_follower(owner.value(), &actor_url) + .remove_follower(owner.value(), &self.instance.actor_url_of(follower)) .await .map_err(ap_err), } } async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> { - let actor_url = self.actor_url_from_identity(target); + let actor_url = self.instance.actor_url_of(target); self.ap_service .block_actor(blocker.value(), &actor_url) .await @@ -229,7 +136,7 @@ impl SocialCommand for CompositeSocialAdapter { } async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> { - let actor_url = self.actor_url_from_identity(target); + let actor_url = self.instance.actor_url_of(target); self.ap_service .unblock_actor(blocker.value(), &actor_url) .await @@ -238,33 +145,46 @@ impl SocialCommand for CompositeSocialAdapter { } #[async_trait] -impl SocialQuery for CompositeSocialAdapter { +impl FollowGraphQuery for CompositeSocialAdapter { async fn get_following(&self, user: &UserId) -> Result, DomainError> { - self.follow_query - .get_following(user.value(), &self.base_url) - .await + self.local.get_following(user).await } async fn get_followers(&self, user: &UserId) -> Result, DomainError> { - self.follow_query - .get_followers(user.value(), &self.base_url) - .await + self.local.get_followers(user).await } async fn get_pending_followers(&self, user: &UserId) -> Result, DomainError> { - self.follow_query - .get_pending_followers(user.value(), &self.base_url) - .await + self.local.get_pending_followers(user).await + } + + async fn get_pending_following(&self, user: &UserId) -> Result, DomainError> { + self.local.get_pending_following(user).await } async fn count_following(&self, user: &UserId) -> Result { - self.follow_query.count_following(user.value()).await + self.local.count_following(user).await } async fn count_followers(&self, user: &UserId) -> Result { - self.follow_query.count_followers(user.value()).await + self.local.count_followers(user).await } + async fn count_pending_followers(&self, user: &UserId) -> Result { + self.local.count_pending_followers(user).await + } + + async fn get_relation( + &self, + viewer: &UserId, + target: &SocialIdentity, + ) -> Result { + self.local.get_relation(viewer, target).await + } +} + +#[async_trait] +impl BlockQuery for CompositeSocialAdapter { async fn get_blocked(&self, user: &UserId) -> Result, DomainError> { let actors = self .ap_service @@ -274,7 +194,7 @@ impl SocialQuery for CompositeSocialAdapter { Ok(actors .into_iter() .map(|a| { - let identity = SocialIdentity::from_actor_url(&a.url, &self.base_url); + let identity = self.instance.identify(&a.url); SocialActor { identity, handle: a.handle, @@ -284,15 +204,4 @@ impl SocialQuery for CompositeSocialAdapter { }) .collect()) } - - async fn is_following( - &self, - follower: &UserId, - target: &SocialIdentity, - ) -> Result { - let actor_url = self.actor_url_from_identity(target); - self.follow_query - .is_following(follower.value(), &actor_url) - .await - } } diff --git a/crates/adapters/activitypub/src/urls.rs b/crates/adapters/activitypub/src/urls.rs index 9acac72..576f096 100644 --- a/crates/adapters/activitypub/src/urls.rs +++ b/crates/adapters/activitypub/src/urls.rs @@ -1,28 +1,43 @@ -use domain::value_objects::ReviewId; +use domain::value_objects::{InstanceIdentity, ReviewId, UserId}; use url::Url; /// Builds the canonical actor URL: `{base_url}/users/{user_id}` -pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url { - Url::parse(&format!("{}/users/{}", base_url, user_id)) +pub fn actor_url(instance: &InstanceIdentity, user_id: uuid::Uuid) -> Url { + Url::parse(&instance.actor_url_for(&UserId::from_uuid(user_id))) .expect("base_url is always a valid URL prefix") } /// Builds the canonical review URL: `{base_url}/reviews/{review_id}` -pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url { - Url::parse(&format!("{}/reviews/{}", base_url, review_id.value())) - .expect("base_url is always a valid URL prefix") -} - -pub fn goal_url(base_url: &str, user_id: uuid::Uuid, year: u16) -> Url { - Url::parse(&format!("{}/users/{}/goals/{}", base_url, user_id, year)) - .expect("base_url is always a valid URL prefix") -} - -/// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}` -pub fn watchlist_entry_url(base_url: &str, user_id: uuid::Uuid, movie_id: uuid::Uuid) -> Url { +pub fn review_url(instance: &InstanceIdentity, review_id: &ReviewId) -> Url { Url::parse(&format!( - "{}/users/{}/watchlist/{}", - base_url, user_id, movie_id + "{}/reviews/{}", + instance.base_url(), + review_id.value() + )) + .expect("base_url is always a valid URL prefix") +} + +pub fn goal_url(instance: &InstanceIdentity, user_id: uuid::Uuid, year: u16) -> Url { + Url::parse(&format!( + "{}/users/{}/goals/{}", + instance.base_url(), + user_id, + year + )) + .expect("base_url is always a valid URL prefix") +} + +/// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}` +pub fn watchlist_entry_url( + instance: &InstanceIdentity, + user_id: uuid::Uuid, + movie_id: uuid::Uuid, +) -> Url { + Url::parse(&format!( + "{}/users/{}/watchlist/{}", + instance.base_url(), + user_id, + movie_id )) .expect("base_url is always a valid URL prefix") } diff --git a/crates/adapters/activitypub/src/user_adapter.rs b/crates/adapters/activitypub/src/user_adapter.rs index c335cd2..86ea330 100644 --- a/crates/adapters/activitypub/src/user_adapter.rs +++ b/crates/adapters/activitypub/src/user_adapter.rs @@ -1,28 +1,36 @@ use std::sync::Arc; use async_trait::async_trait; -use domain::{ports::UserRepository, value_objects::UserId}; +use domain::{ + ports::UserRepository, + value_objects::{InstanceIdentity, UserId}, +}; use k_ap::{ApProfileField, ApUser, ApUserRepository}; use url::Url; pub struct DomainUserRepoAdapter { pub repo: Arc, - pub base_url: String, + pub instance: InstanceIdentity, } impl DomainUserRepoAdapter { - pub fn new(repo: Arc, base_url: String) -> Self { - Self { repo, base_url } + pub fn new(repo: Arc, instance: InstanceIdentity) -> Self { + Self { repo, instance } } fn build_user(&self, u: &domain::models::User) -> ApUser { let avatar_url = u .avatar_path() - .and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()); + .and_then(|p| Url::parse(&self.instance.image_url_for(p)).ok()); let banner_url = u .banner_path() - .and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()); - let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok(); + .and_then(|p| Url::parse(&self.instance.image_url_for(p)).ok()); + let profile_url = Url::parse(&format!( + "{}/u/{}", + self.instance.base_url(), + u.username().value() + )) + .ok(); ApUser { id: u.id().value(), username: u.username().value().to_string(), @@ -46,12 +54,8 @@ impl DomainUserRepoAdapter { manually_approves_followers: true, discoverable: true, actor_type: Default::default(), - featured_url: Url::parse(&format!( - "{}/users/{}/featured", - self.base_url, - u.id().value() - )) - .ok(), + featured_url: Url::parse(&format!("{}/featured", self.instance.actor_url_for(u.id()))) + .ok(), } } } diff --git a/crates/adapters/activitypub/src/watchlist_handler.rs b/crates/adapters/activitypub/src/watchlist_handler.rs index 6e0f721..fea02c5 100644 --- a/crates/adapters/activitypub/src/watchlist_handler.rs +++ b/crates/adapters/activitypub/src/watchlist_handler.rs @@ -5,7 +5,7 @@ use chrono::DateTime; use domain::{ models::{RemoteWatchlistEntry, WatchlistWithMovie}, ports::{LocalApContentQuery, RemoteWatchlistRepository}, - value_objects::UserId, + value_objects::{InstanceIdentity, UserId}, }; use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject}; use url::Url; @@ -16,7 +16,7 @@ use crate::urls::{actor_url, watchlist_entry_url}; pub struct WatchlistObjectHandler { pub remote_watchlist_repo: Arc, pub content_query: Arc, - pub base_url: String, + pub instance: InstanceIdentity, } #[async_trait] @@ -34,15 +34,15 @@ impl ApContentReader for WatchlistObjectHandler { .await .map_err(|e| anyhow::anyhow!(e.to_string()))?; - let actor = actor_url(&self.base_url, user_id); + let actor = actor_url(&self.instance, user_id); let follower_cc = format!("{}/followers", actor); let mut results = Vec::new(); for WatchlistWithMovie { entry, movie } in entries { - let ap_id = watchlist_entry_url(&self.base_url, user_id, entry.movie_id.value()); + let ap_id = watchlist_entry_url(&self.instance, user_id, entry.movie_id.value()); let published = DateTime::from_naive_utc_and_offset(entry.added_at, chrono::Utc); let poster_url = movie .poster_path() - .map(|p| format!("{}/images/{}", self.base_url, p.value())); + .map(|p| self.instance.image_url_for(p.value())); let obj = watchlist_to_ap_object(WatchlistApInput { ap_id: ap_id.clone(), actor_url: actor.clone(), @@ -53,7 +53,7 @@ impl ApContentReader for WatchlistObjectHandler { .map(|id| id.value().to_string()), poster_url, added_at: published, - base_url: self.base_url.clone(), + base_url: self.instance.base_url().to_string(), }); results.push(LocalObject { ap_id, diff --git a/crates/adapters/postgres-federation/Cargo.toml b/crates/adapters/postgres-federation/Cargo.toml index 7c5758f..d130e25 100644 --- a/crates/adapters/postgres-federation/Cargo.toml +++ b/crates/adapters/postgres-federation/Cargo.toml @@ -14,6 +14,7 @@ sqlx = { version = "0.8.6", features = [ 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 } diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs index 961d2fa..e5c6eeb 100644 --- a/crates/adapters/postgres-federation/src/lib.rs +++ b/crates/adapters/postgres-federation/src/lib.rs @@ -1,17 +1,8 @@ mod activity; mod actor; -pub mod ap_content; mod blocklist; -mod federated_profile; mod follow; -mod follow_repository; -pub mod remote_goals; mod review; -mod social; -mod watchlist; - -pub use ap_content::PostgresApContentQuery; -pub use remote_goals::PostgresRemoteGoalRepository; use k_ap::{FollowerStatus, RemoteActor}; use sqlx::{PgPool, Row}; @@ -72,23 +63,23 @@ impl PostgresFederationRepository { } } -pub fn create_federated_profile_query( +pub fn wire( pool: PgPool, -) -> std::sync::Arc { - std::sync::Arc::new(PostgresFederationRepository::new(pool)) -} - -pub fn wire(pool: PgPool) -> activitypub::FederationRepos { - let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool)); + 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 _, - admin_query: std::sync::Arc::clone(&fed) as _, - review_store: std::sync::Arc::clone(&fed) as _, - remote_watchlist: std::sync::Arc::clone(&fed) as _, - follow_command: std::sync::Arc::clone(&fed) as _, - follow_query: 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-social/Cargo.toml b/crates/adapters/postgres-social/Cargo.toml new file mode 100644 index 0000000..d90e9bf --- /dev/null +++ b/crates/adapters/postgres-social/Cargo.toml @@ -0,0 +1,18 @@ +[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-federation/src/ap_content.rs b/crates/adapters/postgres-social/src/ap_content.rs similarity index 100% rename from crates/adapters/postgres-federation/src/ap_content.rs rename to crates/adapters/postgres-social/src/ap_content.rs diff --git a/crates/adapters/postgres-federation/src/federated_profile.rs b/crates/adapters/postgres-social/src/federated_profile.rs similarity index 94% rename from crates/adapters/postgres-federation/src/federated_profile.rs rename to crates/adapters/postgres-social/src/federated_profile.rs index 3e12da8..c32f360 100644 --- a/crates/adapters/postgres-federation/src/federated_profile.rs +++ b/crates/adapters/postgres-social/src/federated_profile.rs @@ -2,10 +2,10 @@ use async_trait::async_trait; use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery}; use sqlx::Row; -use super::PostgresFederationRepository; +use super::PostgresSocialRepository; #[async_trait] -impl FederatedProfileQuery for PostgresFederationRepository { +impl FederatedProfileQuery for PostgresSocialRepository { async fn get_federated_profile( &self, synthetic_user_id: uuid::Uuid, diff --git a/crates/adapters/postgres-federation/src/follow_repository.rs b/crates/adapters/postgres-social/src/follow_repository.rs similarity index 71% rename from crates/adapters/postgres-federation/src/follow_repository.rs rename to crates/adapters/postgres-social/src/follow_repository.rs index 07b034d..2c24115 100644 --- a/crates/adapters/postgres-federation/src/follow_repository.rs +++ b/crates/adapters/postgres-social/src/follow_repository.rs @@ -2,11 +2,11 @@ use async_trait::async_trait; use chrono::Utc; use domain::{ errors::DomainError, - value_objects::{FollowStatus, SocialActor, SocialIdentity}, + value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity}, }; use sqlx::Row; -use crate::PostgresFederationRepository; +use crate::PostgresSocialRepository; use adapter_common::datetime_to_str; fn follow_status_to_str(status: &FollowStatus) -> &'static str { @@ -21,8 +21,17 @@ 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 PostgresFederationRepository { +impl domain::ports::FollowCommand for PostgresSocialRepository { async fn add_follow( &self, follower_id: uuid::Uuid, @@ -142,9 +151,9 @@ impl domain::ports::FollowCommand for PostgresFederationRepository { } } -fn social_actor_from_row(row: &sqlx::postgres::PgRow, base_url: &str) -> SocialActor { +fn social_actor_from_row(row: &sqlx::postgres::PgRow, instance: &InstanceIdentity) -> SocialActor { let actor_url: String = row.get("remote_actor_url"); - let identity = SocialIdentity::from_actor_url(&actor_url, base_url); + let identity = instance.identify(&actor_url); let (handle, display_name, avatar_url) = match &identity { SocialIdentity::Local(_) => { @@ -154,17 +163,18 @@ fn social_actor_from_row(row: &sqlx::postgres::PgRow, base_url: &str) -> SocialA .try_get::, _>("local_avatar_path") .ok() .flatten() - .map(|p| format!("{}/images/{}", base_url, p)); + .map(|p| instance.image_url_for(&p)); let handle = username .as_deref() - .map(|u| SocialIdentity::format_local_handle(u, base_url)) + .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") + .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(); @@ -181,12 +191,8 @@ fn social_actor_from_row(row: &sqlx::postgres::PgRow, base_url: &str) -> SocialA } #[async_trait] -impl domain::ports::FollowQuery for PostgresFederationRepository { - async fn get_following( - &self, - user_id: uuid::Uuid, - base_url: &str, - ) -> Result, DomainError> { +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, @@ -197,22 +203,18 @@ impl domain::ports::FollowQuery for PostgresFederationRepository { LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $2 AND f.status = 'accepted'", ) - .bind(base_url) + .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, base_url)) + .map(|r| social_actor_from_row(r, &self.instance)) .collect()) } - async fn get_followers( - &self, - user_id: uuid::Uuid, - base_url: &str, - ) -> Result, DomainError> { + 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, @@ -223,21 +225,20 @@ impl domain::ports::FollowQuery for PostgresFederationRepository { LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $2 AND f.status = 'accepted'", ) - .bind(base_url) + .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, base_url)) + .map(|r| social_actor_from_row(r, &self.instance)) .collect()) } async fn get_pending_followers( &self, user_id: uuid::Uuid, - base_url: &str, ) -> Result, DomainError> { let uid = user_id.to_string(); let rows = sqlx::query( @@ -249,14 +250,39 @@ impl domain::ports::FollowQuery for PostgresFederationRepository { LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $2 AND f.status = 'pending'", ) - .bind(base_url) + .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, base_url)) + .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()) } @@ -284,20 +310,45 @@ impl domain::ports::FollowQuery for PostgresFederationRepository { Ok(count as usize) } - async fn is_following( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - ) -> Result { - let uid = follower_id.to_string(); + 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_following WHERE local_user_id = $1 AND remote_actor_url = $2 AND status = 'accepted'", + "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(count > 0) + + 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 new file mode 100644 index 0000000..af2d5e6 --- /dev/null +++ b/crates/adapters/postgres-social/src/lib.rs @@ -0,0 +1,38 @@ +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; + +/// Postgres-backed implementations of the *domain* social ports. +/// +/// Deliberately separate from `postgres-federation`: this crate knows nothing +/// about ActivityPub, which is what allows a build with the `federation` +/// feature off to exclude the federation stack entirely. See ADR-0009. +/// +/// Shares the `ap_followers` / `ap_following` tables with +/// `postgres-federation`; neither crate owns migrations. +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-federation/src/remote_goals.rs b/crates/adapters/postgres-social/src/remote_goals.rs similarity index 100% rename from crates/adapters/postgres-federation/src/remote_goals.rs rename to crates/adapters/postgres-social/src/remote_goals.rs diff --git a/crates/adapters/postgres-federation/src/social.rs b/crates/adapters/postgres-social/src/social.rs similarity index 88% rename from crates/adapters/postgres-federation/src/social.rs rename to crates/adapters/postgres-social/src/social.rs index d1e77e0..8f6dac4 100644 --- a/crates/adapters/postgres-federation/src/social.rs +++ b/crates/adapters/postgres-social/src/social.rs @@ -1,10 +1,10 @@ use async_trait::async_trait; use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery}; -use super::PostgresFederationRepository; +use super::PostgresSocialRepository; #[async_trait] -impl FederationAdminQuery for PostgresFederationRepository { +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'", diff --git a/crates/adapters/postgres-federation/src/watchlist.rs b/crates/adapters/postgres-social/src/watchlist.rs similarity index 97% rename from crates/adapters/postgres-federation/src/watchlist.rs rename to crates/adapters/postgres-social/src/watchlist.rs index 108a1da..903dde1 100644 --- a/crates/adapters/postgres-federation/src/watchlist.rs +++ b/crates/adapters/postgres-social/src/watchlist.rs @@ -2,10 +2,10 @@ use async_trait::async_trait; use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository}; use sqlx::Row; -use super::PostgresFederationRepository; +use super::PostgresSocialRepository; #[async_trait] -impl RemoteWatchlistRepository for PostgresFederationRepository { +impl RemoteWatchlistRepository for PostgresSocialRepository { async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> { sqlx::query( "INSERT INTO ap_remote_watchlist_entries \ diff --git a/crates/adapters/postgres/Cargo.toml b/crates/adapters/postgres/Cargo.toml index c031c05..38402ba 100644 --- a/crates/adapters/postgres/Cargo.toml +++ b/crates/adapters/postgres/Cargo.toml @@ -13,7 +13,7 @@ sqlx = { version = "0.8.6", features = [ ] } adapter-common = { workspace = true } domain = { workspace = true } -postgres-federation = { workspace = true } +postgres-social = { workspace = true } anyhow = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index 5498bb0..fd14134 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -28,7 +28,7 @@ pub use import_session::PostgresImportSessionRepository; pub use movie::PostgresMovieRepository; pub use movie_dedup::PostgresMovieDeduplicator; pub use persons::{PostgresPersonAdapter, create_person_adapter}; -pub use postgres_federation::PostgresApContentQuery; +pub use postgres_social::PostgresApContentQuery; pub use profile::PostgresMovieProfileRepository; pub use profile_fields::PostgresProfileFieldsRepository; pub use refresh_sessions::PostgresRefreshSessionAdapter; @@ -115,7 +115,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result { 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_federation::PostgresRemoteGoalRepository::new( + 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/profile.rs b/crates/adapters/postgres/src/profile.rs index 0f4c032..d713b2d 100644 --- a/crates/adapters/postgres/src/profile.rs +++ b/crates/adapters/postgres/src/profile.rs @@ -192,7 +192,10 @@ impl MovieProfileRepository for PostgresMovieProfileRepository { 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(), + profile_path: r + .try_get::, _>("profile_path") + .ok() + .flatten(), }) .collect(); @@ -210,31 +213,40 @@ impl MovieProfileRepository for PostgresMovieProfileRepository { 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(), + 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(), - overview: row.try_get("overview").ok(), - tagline: row.try_get("tagline").ok(), + 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(), - revenue_usd: row.try_get("revenue_usd").ok(), - vote_average: row.try_get("vote_average").ok(), + 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(), - collection_name: row.try_get("collection_name").ok(), + original_language: row + .try_get::, _>("original_language") + .ok() + .flatten(), + collection_name: row + .try_get::, _>("collection_name") + .ok() + .flatten(), genres, keywords, cast, diff --git a/crates/adapters/sqlite-federation/Cargo.toml b/crates/adapters/sqlite-federation/Cargo.toml index 13911ab..df18d5e 100644 --- a/crates/adapters/sqlite-federation/Cargo.toml +++ b/crates/adapters/sqlite-federation/Cargo.toml @@ -8,6 +8,7 @@ sqlx = { workspace = true } activitypub = { workspace = true } adapter-common = { workspace = true } k-ap = { version = "0.5.0", registry = "gitea" } +sqlite-social = { workspace = true } domain = { workspace = true } anyhow = { workspace = true } serde_json = { workspace = true } diff --git a/crates/adapters/sqlite-federation/src/lib.rs b/crates/adapters/sqlite-federation/src/lib.rs index 6430d10..84a2aab 100644 --- a/crates/adapters/sqlite-federation/src/lib.rs +++ b/crates/adapters/sqlite-federation/src/lib.rs @@ -1,18 +1,8 @@ mod activity; mod actor; mod blocklist; -mod federated_profile; mod follow; -mod follow_repository; mod review; -mod social; -mod watchlist; - -pub mod ap_content; -pub mod remote_goals; - -pub use ap_content::SqliteApContentQuery; -pub use remote_goals::SqliteRemoteGoalRepository; use k_ap::{FollowerStatus, RemoteActor}; use sqlx::SqlitePool; @@ -84,24 +74,22 @@ impl SqliteFederationRepository { } } -pub fn create_federated_profile_query( +pub fn wire( pool: SqlitePool, -) -> std::sync::Arc { - std::sync::Arc::new(SqliteFederationRepository::new(pool)) -} - -pub fn wire(pool: SqlitePool) -> activitypub::FederationRepos { - let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool)); + 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)); 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 _, - admin_query: std::sync::Arc::clone(&fed) as _, - review_store: std::sync::Arc::clone(&fed) as _, - remote_watchlist: std::sync::Arc::clone(&fed) as _, - follow_command: std::sync::Arc::clone(&fed) as _, - follow_query: 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/sqlite-federation/src/tests/lib.rs b/crates/adapters/sqlite-federation/src/tests/lib.rs index 4b9ad81..99958a2 100644 --- a/crates/adapters/sqlite-federation/src/tests/lib.rs +++ b/crates/adapters/sqlite-federation/src/tests/lib.rs @@ -1,6 +1,5 @@ use super::*; use chrono::Utc; -use domain::ports::FederationAdminQuery; use k_ap::AnnounceRepository; use sqlx::SqlitePool; @@ -48,65 +47,3 @@ async fn duplicate_announce_is_ignored() { .unwrap(); assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1); } - -async fn setup_db(pool: &SqlitePool) { - sqlx::query( - "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, - avatar_url TEXT, - fetched_at TEXT NOT NULL - )", - ) - .execute(pool) - .await - .unwrap(); - - sqlx::query( - "CREATE TABLE IF NOT EXISTS ap_following ( - local_user_id TEXT NOT NULL, - remote_actor_url TEXT NOT NULL, - follow_activity_id TEXT NOT NULL, - status TEXT NOT NULL, - PRIMARY KEY (local_user_id, remote_actor_url) - )", - ) - .execute(pool) - .await - .unwrap(); -} - -#[tokio::test] -async fn test_list_all_followed_remote_actors_deduplicates() { - let pool = SqlitePool::connect(":memory:").await.unwrap(); - setup_db(&pool).await; - let repo = SqliteFederationRepository::new(pool.clone()); - let user1 = uuid::Uuid::new_v4(); - let user2 = uuid::Uuid::new_v4(); - - sqlx::query( - "INSERT INTO ap_remote_actors (url, handle, inbox_url, fetched_at, display_name) - VALUES ('https://other.social/users/alice', 'alice@other.social', 'https://other.social/inbox', '2024-01-01', 'Alice')", - ) - .execute(&pool) - .await - .unwrap(); - - sqlx::query( - "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status) - VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'), - (?, 'https://other.social/users/alice', 'act2', 'accepted')", - ) - .bind(user1.to_string()) - .bind(user2.to_string()) - .execute(&pool) - .await - .unwrap(); - - let actors = repo.list_all_followed_remote_actors().await.unwrap(); - assert_eq!(actors.len(), 1); - assert_eq!(actors[0].handle, "alice@other.social"); -} diff --git a/crates/adapters/sqlite-social/Cargo.toml b/crates/adapters/sqlite-social/Cargo.toml new file mode 100644 index 0000000..d853a23 --- /dev/null +++ b/crates/adapters/sqlite-social/Cargo.toml @@ -0,0 +1,15 @@ +[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-federation/src/ap_content.rs b/crates/adapters/sqlite-social/src/ap_content.rs similarity index 100% rename from crates/adapters/sqlite-federation/src/ap_content.rs rename to crates/adapters/sqlite-social/src/ap_content.rs diff --git a/crates/adapters/sqlite-federation/src/federated_profile.rs b/crates/adapters/sqlite-social/src/federated_profile.rs similarity index 94% rename from crates/adapters/sqlite-federation/src/federated_profile.rs rename to crates/adapters/sqlite-social/src/federated_profile.rs index d53eb97..93fb370 100644 --- a/crates/adapters/sqlite-federation/src/federated_profile.rs +++ b/crates/adapters/sqlite-social/src/federated_profile.rs @@ -2,10 +2,10 @@ use async_trait::async_trait; use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery}; use sqlx::Row; -use super::SqliteFederationRepository; +use super::SqliteSocialRepository; #[async_trait] -impl FederatedProfileQuery for SqliteFederationRepository { +impl FederatedProfileQuery for SqliteSocialRepository { async fn get_federated_profile( &self, synthetic_user_id: uuid::Uuid, diff --git a/crates/adapters/sqlite-federation/src/follow_repository.rs b/crates/adapters/sqlite-social/src/follow_repository.rs similarity index 71% rename from crates/adapters/sqlite-federation/src/follow_repository.rs rename to crates/adapters/sqlite-social/src/follow_repository.rs index c8aba3b..51dde13 100644 --- a/crates/adapters/sqlite-federation/src/follow_repository.rs +++ b/crates/adapters/sqlite-social/src/follow_repository.rs @@ -2,11 +2,11 @@ use async_trait::async_trait; use chrono::Utc; use domain::{ errors::DomainError, - value_objects::{FollowStatus, SocialActor, SocialIdentity}, + value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity}, }; use sqlx::Row; -use crate::SqliteFederationRepository; +use crate::SqliteSocialRepository; use adapter_common::datetime_to_str; fn follow_status_to_str(status: &FollowStatus) -> &'static str { @@ -21,8 +21,17 @@ 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 SqliteFederationRepository { +impl domain::ports::FollowCommand for SqliteSocialRepository { async fn add_follow( &self, follower_id: uuid::Uuid, @@ -142,9 +151,12 @@ impl domain::ports::FollowCommand for SqliteFederationRepository { } } -fn social_actor_from_row(row: &sqlx::sqlite::SqliteRow, base_url: &str) -> SocialActor { +fn social_actor_from_row( + row: &sqlx::sqlite::SqliteRow, + instance: &InstanceIdentity, +) -> SocialActor { let actor_url: String = row.get("remote_actor_url"); - let identity = SocialIdentity::from_actor_url(&actor_url, base_url); + let identity = instance.identify(&actor_url); let (handle, display_name, avatar_url) = match &identity { SocialIdentity::Local(_) => { @@ -154,17 +166,18 @@ fn social_actor_from_row(row: &sqlx::sqlite::SqliteRow, base_url: &str) -> Socia .try_get::, _>("local_avatar_path") .ok() .flatten() - .map(|p| format!("{}/images/{}", base_url, p)); + .map(|p| instance.image_url_for(&p)); let handle = username .as_deref() - .map(|u| SocialIdentity::format_local_handle(u, base_url)) + .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") + .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(); @@ -181,12 +194,8 @@ fn social_actor_from_row(row: &sqlx::sqlite::SqliteRow, base_url: &str) -> Socia } #[async_trait] -impl domain::ports::FollowQuery for SqliteFederationRepository { - async fn get_following( - &self, - user_id: uuid::Uuid, - base_url: &str, - ) -> Result, DomainError> { +impl domain::ports::FollowQuery for SqliteSocialRepository { + 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, @@ -197,22 +206,18 @@ impl domain::ports::FollowQuery for SqliteFederationRepository { LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = ?2 AND f.status = 'accepted'", ) - .bind(base_url) + .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, base_url)) + .map(|r| social_actor_from_row(r, &self.instance)) .collect()) } - async fn get_followers( - &self, - user_id: uuid::Uuid, - base_url: &str, - ) -> Result, DomainError> { + 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, @@ -223,21 +228,20 @@ impl domain::ports::FollowQuery for SqliteFederationRepository { LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = ?2 AND f.status = 'accepted'", ) - .bind(base_url) + .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, base_url)) + .map(|r| social_actor_from_row(r, &self.instance)) .collect()) } async fn get_pending_followers( &self, user_id: uuid::Uuid, - base_url: &str, ) -> Result, DomainError> { let uid = user_id.to_string(); let rows = sqlx::query( @@ -249,14 +253,39 @@ impl domain::ports::FollowQuery for SqliteFederationRepository { LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = ?2 AND f.status = 'pending'", ) - .bind(base_url) + .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, base_url)) + .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()) } @@ -284,20 +313,45 @@ impl domain::ports::FollowQuery for SqliteFederationRepository { Ok(count as usize) } - async fn is_following( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - ) -> Result { - let uid = follower_id.to_string(); + 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_following WHERE local_user_id = ? AND remote_actor_url = ? AND status = 'accepted'", + "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? 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(count > 0) + + 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/sqlite-social/src/lib.rs b/crates/adapters/sqlite-social/src/lib.rs new file mode 100644 index 0000000..1ac48d9 --- /dev/null +++ b/crates/adapters/sqlite-social/src/lib.rs @@ -0,0 +1,42 @@ +mod federated_profile; +mod follow_repository; +mod social; +mod watchlist; + +pub mod ap_content; +pub mod remote_goals; + +pub use ap_content::SqliteApContentQuery; +pub use remote_goals::SqliteRemoteGoalRepository; + +use sqlx::SqlitePool; + +/// SQLite-backed implementations of the *domain* social ports. +/// +/// Deliberately separate from `sqlite-federation`: this crate knows nothing +/// about ActivityPub, which is what allows a build with the `federation` +/// feature off to exclude the federation stack entirely. See ADR-0009. +/// +/// Shares the `ap_followers` / `ap_following` tables with +/// `sqlite-federation`; neither crate owns migrations. +pub struct SqliteSocialRepository { + pub(crate) pool: SqlitePool, + pub(crate) instance: domain::value_objects::InstanceIdentity, +} + +impl SqliteSocialRepository { + pub fn new(pool: SqlitePool, instance: domain::value_objects::InstanceIdentity) -> Self { + Self { pool, instance } + } +} + +pub fn create_federated_profile_query( + pool: SqlitePool, + instance: domain::value_objects::InstanceIdentity, +) -> 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-federation/src/remote_goals.rs b/crates/adapters/sqlite-social/src/remote_goals.rs similarity index 100% rename from crates/adapters/sqlite-federation/src/remote_goals.rs rename to crates/adapters/sqlite-social/src/remote_goals.rs diff --git a/crates/adapters/sqlite-federation/src/social.rs b/crates/adapters/sqlite-social/src/social.rs similarity index 89% rename from crates/adapters/sqlite-federation/src/social.rs rename to crates/adapters/sqlite-social/src/social.rs index f94b7cf..1a2fc76 100644 --- a/crates/adapters/sqlite-federation/src/social.rs +++ b/crates/adapters/sqlite-social/src/social.rs @@ -1,10 +1,10 @@ use async_trait::async_trait; use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery}; -use super::SqliteFederationRepository; +use super::SqliteSocialRepository; #[async_trait] -impl FederationAdminQuery for SqliteFederationRepository { +impl FederationAdminQuery for SqliteSocialRepository { 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 diff --git a/crates/adapters/sqlite-social/src/tests/follow_relation_tests.rs b/crates/adapters/sqlite-social/src/tests/follow_relation_tests.rs new file mode 100644 index 0000000..dcb18b6 --- /dev/null +++ b/crates/adapters/sqlite-social/src/tests/follow_relation_tests.rs @@ -0,0 +1,253 @@ +use super::*; +use domain::ports::{FederationAdminQuery, FollowQuery}; +use domain::value_objects::{FollowStatus, InstanceIdentity, SocialIdentity}; +use sqlx::SqlitePool; + +async fn test_pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + for ddl in [ + "CREATE TABLE ap_following (local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL, + follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL, + PRIMARY KEY (local_user_id, remote_actor_url))", + "CREATE TABLE ap_followers (local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL, + 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)", + "CREATE TABLE ap_remote_actors (url TEXT PRIMARY KEY, handle TEXT NOT NULL, + display_name TEXT, avatar_url TEXT)", + ] { + sqlx::query(ddl).execute(&pool).await.unwrap(); + } + pool +} + +fn repo(pool: SqlitePool) -> SqliteSocialRepository { + SqliteSocialRepository::new(pool, InstanceIdentity::new("https://md.example")) +} + +#[tokio::test] +async fn get_relation_returns_no_edges_for_strangers() { + let r = repo(test_pool().await); + let rel = r + .get_relation(uuid::Uuid::new_v4(), "https://other.example/users/bob") + .await + .unwrap(); + assert_eq!(rel.following, None); + assert_eq!(rel.followed_by, None); +} + +#[tokio::test] +async fn get_relation_reads_following_direction_only_from_ap_following() { + let pool = test_pool().await; + let viewer = uuid::Uuid::new_v4(); + let target = "https://other.example/users/bob"; + sqlx::query( + "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status) + VALUES (?1, ?2, '', 'pending')", + ) + .bind(viewer.to_string()) + .bind(target) + .execute(&pool) + .await + .unwrap(); + + let rel = repo(pool).get_relation(viewer, target).await.unwrap(); + + assert_eq!(rel.following, Some(FollowStatus::Pending)); + assert_eq!( + rel.followed_by, None, + "an ap_following row must not populate followed_by" + ); +} + +#[tokio::test] +async fn get_relation_reads_followed_by_from_ap_followers_including_rejected() { + let pool = test_pool().await; + let owner = uuid::Uuid::new_v4(); + let requester = "https://other.example/users/carol"; + sqlx::query( + "INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status) + VALUES (?1, ?2, '', 'rejected')", + ) + .bind(owner.to_string()) + .bind(requester) + .execute(&pool) + .await + .unwrap(); + + let rel = repo(pool).get_relation(owner, requester).await.unwrap(); + + assert_eq!(rel.followed_by, Some(FollowStatus::Rejected)); + assert_eq!(rel.following, None); +} + +#[tokio::test] +async fn get_relation_treats_unknown_status_as_no_edge() { + let pool = test_pool().await; + let viewer = uuid::Uuid::new_v4(); + let target = "https://other.example/users/dave"; + sqlx::query( + "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status) + VALUES (?1, ?2, '', 'not-a-real-status')", + ) + .bind(viewer.to_string()) + .bind(target) + .execute(&pool) + .await + .unwrap(); + + let rel = repo(pool).get_relation(viewer, target).await.unwrap(); + + assert_eq!(rel.following, None); +} + +#[tokio::test] +async fn get_pending_following_returns_only_pending_rows() { + let pool = test_pool().await; + let viewer = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status) + VALUES (?1, 'https://other.example/users/pending', '', 'pending'), + (?1, 'https://other.example/users/accepted', '', 'accepted')", + ) + .bind(viewer.to_string()) + .execute(&pool) + .await + .unwrap(); + + let actors = FollowQuery::get_pending_following(&repo(pool), viewer) + .await + .unwrap(); + + assert_eq!( + actors.len(), + 1, + "accepted rows must not appear in pending_following" + ); + // The handle-fallback-on-join-miss behavior is covered by + // `remote_actor_with_no_cached_row_falls_back_to_its_actor_url`; this test + // only needs to check pending-row filtering, so it asserts on identity. + assert_eq!( + actors[0].identity, + SocialIdentity::Remote { + actor_url: "https://other.example/users/pending".to_string() + } + ); +} + +#[tokio::test] +async fn remote_actor_with_no_cached_row_falls_back_to_its_actor_url() { + let pool = test_pool().await; + let viewer = uuid::Uuid::new_v4(); + let orphan = "https://other.example/users/uncached"; + sqlx::query( + "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status) + VALUES (?1, ?2, '', 'pending')", + ) + .bind(viewer.to_string()) + .bind(orphan) + .execute(&pool) + .await + .unwrap(); + // deliberately NO ap_remote_actors row for `orphan` + + let actors = FollowQuery::get_pending_following(&repo(pool), viewer) + .await + .unwrap(); + + assert_eq!(actors.len(), 1); + assert_eq!( + actors[0].handle, orphan, + "with no cached actor, handle must fall back to the actor url, not render empty" + ); +} + +#[tokio::test] +async fn count_pending_followers_counts_only_pending() { + let pool = test_pool().await; + let owner = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status) + VALUES (?1, 'https://other.example/users/a', '', 'pending'), + (?1, 'https://other.example/users/b', '', 'pending'), + (?1, 'https://other.example/users/c', '', 'accepted'), + (?1, 'https://other.example/users/d', '', 'rejected')", + ) + .bind(owner.to_string()) + .execute(&pool) + .await + .unwrap(); + + let n = FollowQuery::count_pending_followers(&repo(pool), owner) + .await + .unwrap(); + + assert_eq!( + n, 2, + "only pending rows count; accepted and rejected must not" + ); +} + +async fn setup_admin_query_db(pool: &SqlitePool) { + sqlx::query( + "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, + avatar_url TEXT, + fetched_at TEXT NOT NULL + )", + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query( + "CREATE TABLE IF NOT EXISTS ap_following ( + local_user_id TEXT NOT NULL, + remote_actor_url TEXT NOT NULL, + follow_activity_id TEXT NOT NULL, + status TEXT NOT NULL, + PRIMARY KEY (local_user_id, remote_actor_url) + )", + ) + .execute(pool) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_list_all_followed_remote_actors_deduplicates() { + let pool = SqlitePool::connect(":memory:").await.unwrap(); + setup_admin_query_db(&pool).await; + let repo = + SqliteSocialRepository::new(pool.clone(), InstanceIdentity::new("https://localhost")); + let user1 = uuid::Uuid::new_v4(); + let user2 = uuid::Uuid::new_v4(); + + sqlx::query( + "INSERT INTO ap_remote_actors (url, handle, inbox_url, fetched_at, display_name) + VALUES ('https://other.social/users/alice', 'alice@other.social', 'https://other.social/inbox', '2024-01-01', 'Alice')", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status) + VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'), + (?, 'https://other.social/users/alice', 'act2', 'accepted')", + ) + .bind(user1.to_string()) + .bind(user2.to_string()) + .execute(&pool) + .await + .unwrap(); + + let actors = repo.list_all_followed_remote_actors().await.unwrap(); + assert_eq!(actors.len(), 1); + assert_eq!(actors[0].handle, "alice@other.social"); +} diff --git a/crates/adapters/sqlite-federation/src/watchlist.rs b/crates/adapters/sqlite-social/src/watchlist.rs similarity index 97% rename from crates/adapters/sqlite-federation/src/watchlist.rs rename to crates/adapters/sqlite-social/src/watchlist.rs index 880b320..f114344 100644 --- a/crates/adapters/sqlite-federation/src/watchlist.rs +++ b/crates/adapters/sqlite-social/src/watchlist.rs @@ -2,10 +2,10 @@ use async_trait::async_trait; use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository}; use sqlx::Row; -use super::SqliteFederationRepository; +use super::SqliteSocialRepository; #[async_trait] -impl RemoteWatchlistRepository for SqliteFederationRepository { +impl RemoteWatchlistRepository for SqliteSocialRepository { async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> { sqlx::query( "INSERT INTO ap_remote_watchlist_entries \ diff --git a/crates/adapters/sqlite/Cargo.toml b/crates/adapters/sqlite/Cargo.toml index a1fd990..7407917 100644 --- a/crates/adapters/sqlite/Cargo.toml +++ b/crates/adapters/sqlite/Cargo.toml @@ -13,7 +13,7 @@ sqlx = { version = "0.8.6", features = [ adapter-common = { workspace = true } domain = { workspace = true } -sqlite-federation = { workspace = true } +sqlite-social = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } anyhow = { workspace = true } diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index f9d2a9a..8d07876 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -32,7 +32,7 @@ pub use profile::SqliteMovieProfileRepository; pub use profile_fields::SqliteProfileFieldsRepository; pub use refresh_sessions::SqliteRefreshSessionAdapter; pub use review::SqliteReviewRepository; -pub use sqlite_federation::SqliteApContentQuery; +pub use sqlite_social::SqliteApContentQuery; pub use stats::SqliteStatsRepository; pub use users::SqliteUserRepository; pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository}; @@ -118,7 +118,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result { goal_query: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _, user_settings: std::sync::Arc::clone(&user_settings_repo) as _, federation_settings: user_settings_repo as _, - remote_goal: std::sync::Arc::new(sqlite_federation::SqliteRemoteGoalRepository::new( + remote_goal: std::sync::Arc::new(sqlite_social::SqliteRemoteGoalRepository::new( pool.clone(), )) as _, deduplicator: std::sync::Arc::new(SqliteMovieDeduplicator::new(pool)) as _, diff --git a/crates/adapters/sqlite/src/profile.rs b/crates/adapters/sqlite/src/profile.rs index 762cf28..f6437ef 100644 --- a/crates/adapters/sqlite/src/profile.rs +++ b/crates/adapters/sqlite/src/profile.rs @@ -208,7 +208,10 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { 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(), + profile_path: r + .try_get::, _>("profile_path") + .ok() + .flatten(), }) .collect(); @@ -226,31 +229,40 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { 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(), + 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(), - overview: row.try_get("overview").ok(), - tagline: row.try_get("tagline").ok(), + 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(), - revenue_usd: row.try_get("revenue_usd").ok(), - vote_average: row.try_get("vote_average").ok(), + 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(), - collection_name: row.try_get("collection_name").ok(), + original_language: row + .try_get::, _>("original_language") + .ok() + .flatten(), + collection_name: row + .try_get::, _>("collection_name") + .ok() + .flatten(), genres, keywords, cast, @@ -286,3 +298,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { .collect()) } } + +#[cfg(test)] +#[path = "tests/profile.rs"] +mod tests; diff --git a/crates/adapters/sqlite/src/tests/profile.rs b/crates/adapters/sqlite/src/tests/profile.rs new file mode 100644 index 0000000..bf19abc --- /dev/null +++ b/crates/adapters/sqlite/src/tests/profile.rs @@ -0,0 +1,95 @@ +use super::super::profile::SqliteMovieProfileRepository; +use domain::{ports::MovieProfileRepository, value_objects::MovieId}; +use sqlx::SqlitePool; + +async fn pool_with_schema() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::query( + "CREATE TABLE movie_profiles ( + movie_id TEXT PRIMARY KEY, tmdb_id INTEGER, imdb_id TEXT, + overview TEXT, tagline TEXT, runtime_minutes INTEGER, + budget_usd INTEGER, revenue_usd INTEGER, vote_average REAL, + vote_count INTEGER, original_language TEXT, collection_name TEXT, + enriched_at TEXT NOT NULL + )", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query("CREATE TABLE movie_genres (movie_id TEXT, tmdb_id INTEGER, name TEXT)") + .execute(&pool) + .await + .unwrap(); + sqlx::query("CREATE TABLE movie_keywords (movie_id TEXT, tmdb_id INTEGER, name TEXT)") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER, + name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT)", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "CREATE TABLE movie_crew (movie_id TEXT, tmdb_person_id INTEGER, + name TEXT, job TEXT, department TEXT, profile_path TEXT)", + ) + .execute(&pool) + .await + .unwrap(); + pool +} + +async fn insert_bare_profile(pool: &SqlitePool, movie_id: &str) { + sqlx::query("INSERT INTO movie_profiles (movie_id, tmdb_id, enriched_at) VALUES (?, 1, ?)") + .bind(movie_id) + .bind(chrono::Utc::now().to_rfc3339()) + .execute(pool) + .await + .unwrap(); +} + +#[tokio::test] +async fn null_cast_profile_path_becomes_none_not_empty_string() { + let pool = pool_with_schema().await; + let movie_id = MovieId::generate(); + let movie_id_str = movie_id.value().to_string(); + + insert_bare_profile(&pool, &movie_id_str).await; + + sqlx::query( + "INSERT INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path) + VALUES (?, 1, 'Alice', 'Hero', 0, NULL)", + ) + .bind(&movie_id_str) + .execute(&pool) + .await + .unwrap(); + + let adapter = SqliteMovieProfileRepository::new(pool); + let profile = adapter.get_by_movie_id(&movie_id).await.unwrap().unwrap(); + + assert_eq!(profile.cast.len(), 1); + assert_eq!( + profile.cast[0].profile_path, None, + "NULL profile_path must decode to None, not Some(\"\")" + ); +} + +#[tokio::test] +async fn null_budget_usd_becomes_none_not_some_zero() { + let pool = pool_with_schema().await; + let movie_id = MovieId::generate(); + let movie_id_str = movie_id.value().to_string(); + + insert_bare_profile(&pool, &movie_id_str).await; + + let adapter = SqliteMovieProfileRepository::new(pool); + let profile = adapter.get_by_movie_id(&movie_id).await.unwrap().unwrap(); + + assert_eq!( + profile.budget_usd, None, + "NULL budget_usd must decode to None, not Some(0)" + ); +} diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index 39c65c7..5a2b37b 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -296,6 +296,7 @@ pub struct FollowingTemplate { pub ctx: HtmlPageContext, pub user_id: uuid::Uuid, pub actors: Vec, + pub pending_actors: Vec, pub error: Option, } diff --git a/crates/adapters/template-askama/templates/base.html b/crates/adapters/template-askama/templates/base.html index a71f8eb..eddc863 100644 --- a/crates/adapters/template-askama/templates/base.html +++ b/crates/adapters/template-askama/templates/base.html @@ -34,7 +34,7 @@ Feed Users {% if let Some(uid) = ctx.user_id %} - Profile + Profile{% if ctx.pending_follow_count > 0 %} ({{ ctx.pending_follow_count }}){% endif %} Add Review Import Queue diff --git a/crates/adapters/template-askama/templates/following.html b/crates/adapters/template-askama/templates/following.html index 472a36e..b72d1b3 100644 --- a/crates/adapters/template-askama/templates/following.html +++ b/crates/adapters/template-askama/templates/following.html @@ -10,6 +10,28 @@ +{% if !pending_actors.is_empty() %} +

Requested ({{ pending_actors.len() }})

+
    +{% for actor in pending_actors %} +
  • + {% if let Some(avatar) = actor.avatar_url %} + + {% endif %} + {{ actor.handle }} + {% if let Some(name) = actor.display_name %} + ({{ name }}) + {% endif %} + View profile ↗ +
    + + + +
    +
  • +{% endfor %} +
+{% endif %} {% if actors.is_empty() %}

Not following anyone yet. Follow remote users from your profile page.

{% else %} diff --git a/crates/api-types/Cargo.toml b/crates/api-types/Cargo.toml index bd2618f..322565a 100644 --- a/crates/api-types/Cargo.toml +++ b/crates/api-types/Cargo.toml @@ -8,3 +8,6 @@ serde = { workspace = true } uuid = { workspace = true } utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] } domain = { path = "../domain" } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/api-types/src/rendering.rs b/crates/api-types/src/rendering.rs index 9f6feb2..c6cb34e 100644 --- a/crates/api-types/src/rendering.rs +++ b/crates/api-types/src/rendering.rs @@ -10,6 +10,7 @@ pub struct HtmlPageContext { pub canonical_url: String, pub csrf_token: String, pub page_rss_url: Option, + pub pending_follow_count: usize, } impl HtmlPageContext { diff --git a/crates/api-types/src/social.rs b/crates/api-types/src/social.rs index 1a90aaa..aa3db6a 100644 --- a/crates/api-types/src/social.rs +++ b/crates/api-types/src/social.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct FollowRequest { @@ -15,6 +16,9 @@ pub struct RemoteActorDto { pub handle: String, pub display_name: Option, pub url: String, + /// `Some` for local actors, so the SPA can link internally to `/users/{id}`. + pub user_id: Option, + pub avatar_url: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] @@ -42,3 +46,62 @@ pub struct BlockedActorResponse { pub display_name: Option, pub avatar_url: Option, } + +#[derive(Serialize, Deserialize, utoipa::ToSchema, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum FollowStateDto { + None, + Pending, + Accepted, + Rejected, +} + +impl From> for FollowStateDto { + fn from(s: Option) -> Self { + use domain::value_objects::FollowStatus as F; + match s { + None => Self::None, + Some(F::Pending) => Self::Pending, + Some(F::Accepted) => Self::Accepted, + Some(F::Rejected) => Self::Rejected, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct FollowRelationResponse { + pub following: FollowStateDto, + pub followed_by: FollowStateDto, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PendingCountResponse { + pub count: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Task 7's SPA zod schema parses these exact lowercase literals — + /// a casing or naming drift here breaks the SPA at runtime. + #[test] + fn follow_state_dto_serializes_to_lowercase_strings() { + assert_eq!( + serde_json::to_string(&FollowStateDto::None).unwrap(), + "\"none\"" + ); + assert_eq!( + serde_json::to_string(&FollowStateDto::Pending).unwrap(), + "\"pending\"" + ); + assert_eq!( + serde_json::to_string(&FollowStateDto::Accepted).unwrap(), + "\"accepted\"" + ); + assert_eq!( + serde_json::to_string(&FollowStateDto::Rejected).unwrap(), + "\"rejected\"" + ); + } +} diff --git a/crates/application/src/auth/deps.rs b/crates/application/src/auth/deps.rs index e628373..cd1c70c 100644 --- a/crates/application/src/auth/deps.rs +++ b/crates/application/src/auth/deps.rs @@ -31,3 +31,7 @@ pub struct RegisterAndLoginDeps { pub refresh_session: Arc, pub config: AppConfig, } + +pub struct LogoutDeps { + pub refresh_session: Arc, +} diff --git a/crates/application/src/auth/logout.rs b/crates/application/src/auth/logout.rs index 3b03605..44acbe3 100644 --- a/crates/application/src/auth/logout.rs +++ b/crates/application/src/auth/logout.rs @@ -1,12 +1,9 @@ -use std::sync::Arc; +use domain::errors::DomainError; -use domain::{errors::DomainError, ports::RefreshSessionRepository}; +use crate::auth::deps::LogoutDeps; -pub async fn execute( - refresh_session: Arc, - refresh_token: &str, -) -> Result<(), DomainError> { - refresh_session.revoke(refresh_token).await +pub async fn execute(deps: &LogoutDeps, refresh_token: &str) -> Result<(), DomainError> { + deps.refresh_session.revoke(refresh_token).await } #[cfg(test)] diff --git a/crates/application/src/auth/tests/logout.rs b/crates/application/src/auth/tests/logout.rs index 4e97242..ed4a6ff 100644 --- a/crates/application/src/auth/tests/logout.rs +++ b/crates/application/src/auth/tests/logout.rs @@ -6,7 +6,7 @@ use domain::testing::InMemoryUserRepository; use crate::{ auth::{ commands::RegisterCommand, - deps::{LoginDeps, RefreshDeps, RegisterDeps}, + deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterDeps}, login, logout, queries::LoginCommand, refresh, register, @@ -53,7 +53,10 @@ async fn logout_revokes_refresh_token() { .await .unwrap(); - logout::execute(b.refresh_session_repo.clone(), &login_result.refresh_token) + let logout_deps = LogoutDeps { + refresh_session: b.refresh_session_repo.clone(), + }; + logout::execute(&logout_deps, &login_result.refresh_token) .await .unwrap(); @@ -69,6 +72,9 @@ async fn logout_revokes_refresh_token() { #[tokio::test] async fn logout_with_unknown_token_succeeds() { let b = TestContextBuilder::new(); - let result = logout::execute(b.refresh_session_repo.clone(), "nonexistent-token").await; + let logout_deps = LogoutDeps { + refresh_session: b.refresh_session_repo.clone(), + }; + let result = logout::execute(&logout_deps, "nonexistent-token").await; assert!(result.is_ok()); } diff --git a/crates/application/src/deps.rs b/crates/application/src/deps.rs new file mode 100644 index 0000000..74b9c58 --- /dev/null +++ b/crates/application/src/deps.rs @@ -0,0 +1,194 @@ +use std::sync::Arc; + +use domain::ports::{EventPublisher, MediaServerParser, ObjectStorage, PersonEnrichmentClient}; + +use crate::auth::deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterAndLoginDeps, RegisterDeps}; +use crate::diary::deps::{ + DeleteReviewDeps, EditReviewDeps, ExportDiaryDeps, GetActivityFeedDeps, GetDiaryDeps, + GetMovieSocialPageDeps, GetReviewHistoryDeps, GetUserFeedDeps, +}; +use crate::goals::deps::{GoalCommandDeps, GoalQueryDeps}; +use crate::import::deps::{ + ApplyMappingDeps, ApplyProfileAndMapDeps, ApplyProfileDeps, CreateSessionDeps, + DeleteImportProfileDeps, ExecuteImportDeps, GetMappingStageDeps, GetPreviewStageDeps, + GetSessionStateDeps, ListImportProfilesDeps, SaveProfileDeps, +}; +use crate::integrations::deps::{ + ConfirmWatchEventsDeps, DismissWatchEventsDeps, GenerateWebhookTokenDeps, GetWatchQueueDeps, + GetWebhookTokensDeps, IngestWatchEventDeps, RevokeWebhookTokenDeps, +}; +use crate::movies::deps::{ + EnrichMovieDeps, GetMovieProfileDeps, GetMoviesDeps, ReindexSearchDeps, SyncPosterDeps, +}; +use crate::movies::merge_duplicates::MergeDuplicatesDeps; +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, + GetFederatedProfileStatsDeps, GetLocalProfileDeps, GetPageViewerDeps, GetProfileSettingsDeps, + GetSettingsDeps, GetUsersListDeps, ResolveUsernameDeps, UpdateProfileDeps, + UpdateProfileFieldsDeps, UpdateSettingsDeps, +}; +use crate::watchlist::deps::{ + GetWatchlistDeps, GetWatchlistForOwnerDeps, IsOnWatchlistDeps, RemoveFromWatchlistDeps, + WatchlistAddDeps, +}; +use crate::wrapup::deps::{ + DeleteWrapUpDeps, GenerateWrapUpDeps, GetReadyReportDeps, GetWrapUpDeps, + HandleWrapUpRequestedDeps, ListWrapUpsDeps, +}; + +pub struct AuthGroup { + pub login: LoginDeps, + pub register: RegisterDeps, + pub refresh: RefreshDeps, + pub register_and_login: RegisterAndLoginDeps, + pub logout: LogoutDeps, +} + +pub struct DiaryGroup { + pub delete_review: DeleteReviewDeps, + pub edit_review: EditReviewDeps, + pub get_movie_social_page: GetMovieSocialPageDeps, + pub get_activity_feed: GetActivityFeedDeps, + pub get_user_feed: GetUserFeedDeps, + pub get_diary: GetDiaryDeps, + pub get_review_history: GetReviewHistoryDeps, + pub export_diary: ExportDiaryDeps, +} + +pub struct GoalsGroup { + pub command: GoalCommandDeps, + pub query: GoalQueryDeps, +} + +pub struct ImportGroup { + pub create_session: CreateSessionDeps, + pub apply_mapping: ApplyMappingDeps, + pub apply_profile: ApplyProfileDeps, + pub execute_import: ExecuteImportDeps, + pub save_profile: SaveProfileDeps, + pub get_mapping_stage: GetMappingStageDeps, + pub get_preview_stage: GetPreviewStageDeps, + pub get_session_state: GetSessionStateDeps, + pub apply_profile_and_map: ApplyProfileAndMapDeps, + pub delete_profile: DeleteImportProfileDeps, + pub list_profiles: ListImportProfilesDeps, +} + +pub struct IntegrationsGroup { + pub ingest_watch_event: IngestWatchEventDeps, + pub confirm_watch_events: ConfirmWatchEventsDeps, + pub dismiss_watch_events: DismissWatchEventsDeps, + pub generate_webhook_token: GenerateWebhookTokenDeps, + pub get_watch_queue: GetWatchQueueDeps, + pub get_webhook_tokens: GetWebhookTokensDeps, + pub revoke_webhook_token: RevokeWebhookTokenDeps, + /// Webhook payload parsers. Held on the group rather than inside + /// `IngestWatchEventDeps` because `ingest::execute` takes the parser as an + /// argument — the caller picks which one per route. + pub jellyfin_parser: Arc, + pub plex_parser: Arc, +} + +pub struct MoviesGroup { + pub sync_poster: SyncPosterDeps, + pub get_movie_profile: GetMovieProfileDeps, + pub get_movies: GetMoviesDeps, +} + +pub struct PersonGroup { + pub get_person: GetPersonDeps, +} + +pub struct SearchGroup { + pub execute: SearchDeps, +} + +pub struct SocialGroup { + pub command: SocialCommandDeps, + pub query: SocialQueryDeps, +} + +pub struct UsersGroup { + pub get_local_profile: GetLocalProfileDeps, + pub get_federated_profile_stats: GetFederatedProfileStatsDeps, + pub get_page_viewer: GetPageViewerDeps, + pub resolve_username: ResolveUsernameDeps, + 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 get_current_profile: GetCurrentProfileDeps, + pub update_profile_fields: UpdateProfileFieldsDeps, + pub get_settings: GetSettingsDeps, + pub update_settings: UpdateSettingsDeps, + pub authorize_admin: AuthorizeAdminDeps, + pub get_federated_profile: GetFederatedProfileDeps, +} + +pub struct WatchlistGroup { + pub add: WatchlistAddDeps, + pub get_watchlist_for_owner: GetWatchlistForOwnerDeps, + pub get_watchlist: GetWatchlistDeps, + pub is_on_watchlist: IsOnWatchlistDeps, + pub remove_from_watchlist: RemoveFromWatchlistDeps, +} + +pub struct WrapupGroup { + pub get_ready_report: GetReadyReportDeps, + pub delete_wrapup: DeleteWrapUpDeps, + pub generate: GenerateWrapUpDeps, + pub get_wrapup: GetWrapUpDeps, + pub list_wrapups: ListWrapUpsDeps, +} + +/// Every deps struct a handler can need, built once by the composition root. +/// Use cases still receive only their own narrow struct — nothing takes `&Deps`. +/// +/// `composition::build_deps` is called only from `crates/presentation/src/main.rs`. +/// 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 +/// in `Deps` below is worker-only anymore, except one field with no consumer anywhere +/// in the workspace — see its comment. +pub struct Deps { + pub auth: AuthGroup, + pub diary: DiaryGroup, + pub goals: GoalsGroup, + pub import: ImportGroup, + pub integrations: IntegrationsGroup, + pub movies: MoviesGroup, + pub person: PersonGroup, + pub search: SearchGroup, + pub social: SocialGroup, + pub users: UsersGroup, + pub watchlist: WatchlistGroup, + pub wrapup: WrapupGroup, +} + +/// Ports the worker binary can actually construct — a strict subset of +/// `Services`. The worker has no `auth`, `password_hasher`, `diary_exporter`, +/// `document_parser`, or `review_logger`; those ports have no worker-side use case, +/// so `WorkerServices` simply does not carry them (see ADR / task-2 brief for why +/// this is a separate struct rather than an `Option`-riddled `Services`). +pub struct WorkerServices { + pub object_storage: Arc, + pub event_publisher: Arc, + /// `Option` here mirrors `Services::person_enrichment` — genuine optional + /// configuration, not a container-shape workaround. + pub person_enrichment: Option>, +} + +/// The deps structs the worker binary needs, built by `composition::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 { + pub enrich_movie: EnrichMovieDeps, + pub reindex_search: ReindexSearchDeps, + pub merge_duplicates: MergeDuplicatesDeps, + pub enrich_person: EnrichPersonDeps, + pub handle_requested: HandleWrapUpRequestedDeps, +} diff --git a/crates/application/src/diary/deps.rs b/crates/application/src/diary/deps.rs index 946b3a1..0309cd0 100644 --- a/crates/application/src/diary/deps.rs +++ b/crates/application/src/diary/deps.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use domain::ports::{ - DiaryQuery, EventPublisher, MovieCommand, MovieProfileRepository, MovieQuery, ReviewRepository, - SocialQuery, + DiaryExporter, DiaryQuery, EventPublisher, FollowGraphQuery, MovieCommand, + MovieProfileRepository, MovieQuery, ReviewRepository, UserRepository, }; use crate::config::AppConfig; @@ -27,6 +27,24 @@ pub struct GetMovieSocialPageDeps { pub struct GetActivityFeedDeps { pub diary: Arc, - pub social_query: Arc, + pub social_query: Arc, pub config: AppConfig, } + +pub struct GetUserFeedDeps { + pub user: Arc, + pub diary: Arc, +} + +pub struct GetDiaryDeps { + pub diary: Arc, +} + +pub struct GetReviewHistoryDeps { + pub diary: Arc, +} + +pub struct ExportDiaryDeps { + pub diary: Arc, + pub diary_exporter: Arc, +} diff --git a/crates/application/src/diary/export_diary.rs b/crates/application/src/diary/export_diary.rs index a04223e..34fc3d7 100644 --- a/crates/application/src/diary/export_diary.rs +++ b/crates/application/src/diary/export_diary.rs @@ -1,21 +1,16 @@ -use std::sync::Arc; - use bytes::Bytes; -use domain::{ - errors::DomainError, - ports::{DiaryExporter, DiaryQuery}, - value_objects::UserId, -}; +use domain::{errors::DomainError, value_objects::UserId}; use futures::stream::BoxStream; +use crate::diary::deps::ExportDiaryDeps; use crate::diary::queries::ExportQuery; pub fn execute( - diary: &Arc, - diary_exporter: &Arc, + deps: &ExportDiaryDeps, query: ExportQuery, ) -> BoxStream<'static, Result> { let user_id = UserId::from_uuid(query.user_id); - let entry_stream = diary.stream_user_history(user_id); - diary_exporter.stream_entries(entry_stream, query.format) + let entry_stream = deps.diary.stream_user_history(user_id); + deps.diary_exporter + .stream_entries(entry_stream, query.format) } diff --git a/crates/application/src/diary/get_diary.rs b/crates/application/src/diary/get_diary.rs index bc1cc05..dae085c 100644 --- a/crates/application/src/diary/get_diary.rs +++ b/crates/application/src/diary/get_diary.rs @@ -1,19 +1,17 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, models::{ DiaryEntry, DiaryFilter, ReviewSortBy, collections::{PageParams, Paginated}, }, - ports::DiaryQuery, value_objects::{MovieId, UserId}, }; +use crate::diary::deps::GetDiaryDeps; use crate::diary::queries::GetDiaryQuery; pub async fn execute( - diary: &Arc, + deps: &GetDiaryDeps, query: GetDiaryQuery, ) -> Result, DomainError> { let page = PageParams::new(query.limit, query.offset)?; @@ -29,7 +27,7 @@ pub async fn execute( include_remote: user_id.is_some(), }; - diary.query_diary(&filter).await + deps.diary.query_diary(&filter).await } #[cfg(test)] diff --git a/crates/application/src/diary/get_review_history.rs b/crates/application/src/diary/get_review_history.rs index 4782c55..a8f4d3e 100644 --- a/crates/application/src/diary/get_review_history.rs +++ b/crates/application/src/diary/get_review_history.rs @@ -1,22 +1,20 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, models::ReviewHistory, - ports::DiaryQuery, services::review_history::{ReviewHistoryAnalyzer, Trend}, value_objects::MovieId, }; +use crate::diary::deps::GetReviewHistoryDeps; use crate::diary::queries::GetReviewHistoryQuery; pub async fn execute( - diary: &Arc, + deps: &GetReviewHistoryDeps, query: GetReviewHistoryQuery, ) -> Result<(ReviewHistory, Trend), DomainError> { let movie_id = MovieId::from_uuid(query.movie_id); - let mut history = diary.get_review_history(&movie_id).await?; + let mut history = deps.diary.get_review_history(&movie_id).await?; let trend = ReviewHistoryAnalyzer::rating_trend(&history)?; diff --git a/crates/application/src/diary/get_user_feed.rs b/crates/application/src/diary/get_user_feed.rs new file mode 100644 index 0000000..3a81e81 --- /dev/null +++ b/crates/application/src/diary/get_user_feed.rs @@ -0,0 +1,61 @@ +use domain::{ + errors::DomainError, models::DiaryEntry, models::ReviewSortBy, value_objects::UserId, +}; +use uuid::Uuid; + +use crate::diary::deps::{GetDiaryDeps, GetUserFeedDeps}; +use crate::diary::get_diary; +use crate::diary::queries::GetDiaryQuery; + +/// The RSS feed's author line — derived the same way the deleted handler code +/// derived its page title: from the local part of the user's email, not their +/// username. +pub struct FeedAuthor { + pub display_name: String, +} + +pub struct UserFeed { + pub author: FeedAuthor, + pub entries: Vec, +} + +pub async fn execute( + deps: &GetUserFeedDeps, + user_id: Uuid, + limit: u32, +) -> Result { + let user = deps + .user + .find_by_id(&UserId::from_uuid(user_id)) + .await? + .ok_or_else(|| DomainError::NotFound(format!("User {user_id}")))?; + + let query = GetDiaryQuery { + limit: Some(limit), + offset: Some(0), + sort_by: Some(ReviewSortBy::Descending), + movie_id: None, + user_id: Some(user_id), + }; + let get_diary_deps = GetDiaryDeps { + diary: deps.diary.clone(), + }; + let page = get_diary::execute(&get_diary_deps, query).await?; + + let display_name = user + .email() + .value() + .split('@') + .next() + .unwrap_or("User") + .to_string(); + + Ok(UserFeed { + author: FeedAuthor { display_name }, + entries: page.items, + }) +} + +#[cfg(test)] +#[path = "tests/get_user_feed.rs"] +mod tests; diff --git a/crates/application/src/diary/mod.rs b/crates/application/src/diary/mod.rs index 68c7d09..fd9b8e6 100644 --- a/crates/application/src/diary/mod.rs +++ b/crates/application/src/diary/mod.rs @@ -7,6 +7,7 @@ pub mod get_activity_feed; pub mod get_diary; pub mod get_movie_social_page; pub mod get_review_history; +pub mod get_user_feed; pub mod log_review; pub mod movie_resolver; pub mod queries; diff --git a/crates/application/src/diary/tests/get_activity_feed.rs b/crates/application/src/diary/tests/get_activity_feed.rs index 2745360..89471ee 100644 --- a/crates/application/src/diary/tests/get_activity_feed.rs +++ b/crates/application/src/diary/tests/get_activity_feed.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use async_trait::async_trait; use domain::errors::DomainError; use domain::testing::InMemorySocialRepository; -use domain::value_objects::{SocialActor, SocialIdentity, UserId}; +use domain::value_objects::{FollowRelation, SocialActor, SocialIdentity, UserId}; use crate::{ config::AppConfig, diary::deps::GetActivityFeedDeps, diary::get_activity_feed, @@ -66,7 +66,7 @@ async fn returns_feed_with_following_filter() { struct FakeSocialWithFollowing(Vec); #[async_trait] -impl domain::ports::SocialQuery for FakeSocialWithFollowing { +impl domain::ports::FollowGraphQuery for FakeSocialWithFollowing { async fn get_following(&self, _: &UserId) -> Result, DomainError> { Ok(self.0.clone()) } @@ -76,17 +76,24 @@ impl domain::ports::SocialQuery for FakeSocialWithFollowing { async fn get_pending_followers(&self, _: &UserId) -> Result, DomainError> { Ok(vec![]) } + async fn get_pending_following(&self, _: &UserId) -> Result, DomainError> { + Ok(vec![]) + } async fn count_following(&self, _: &UserId) -> Result { Ok(0) } async fn count_followers(&self, _: &UserId) -> Result { Ok(0) } - async fn get_blocked(&self, _: &UserId) -> Result, DomainError> { - Ok(vec![]) + async fn count_pending_followers(&self, _: &UserId) -> Result { + Ok(0) } - async fn is_following(&self, _: &UserId, _: &SocialIdentity) -> Result { - Ok(false) + async fn get_relation( + &self, + _: &UserId, + _: &SocialIdentity, + ) -> Result { + Ok(FollowRelation::default()) } } diff --git a/crates/application/src/diary/tests/get_diary.rs b/crates/application/src/diary/tests/get_diary.rs index c6a0041..5a2fd04 100644 --- a/crates/application/src/diary/tests/get_diary.rs +++ b/crates/application/src/diary/tests/get_diary.rs @@ -1,14 +1,15 @@ use domain::testing::FakeDiaryQuery; use std::sync::Arc; -use crate::{diary::get_diary, diary::queries::GetDiaryQuery}; +use crate::{diary::deps::GetDiaryDeps, diary::get_diary, diary::queries::GetDiaryQuery}; #[tokio::test] async fn returns_empty_page() { let diary = FakeDiaryQuery::new() as Arc; + let deps = GetDiaryDeps { diary }; let result = get_diary::execute( - &diary, + &deps, GetDiaryQuery { limit: None, offset: None, diff --git a/crates/application/src/diary/tests/get_review_history.rs b/crates/application/src/diary/tests/get_review_history.rs index 2e09e68..b0131a7 100644 --- a/crates/application/src/diary/tests/get_review_history.rs +++ b/crates/application/src/diary/tests/get_review_history.rs @@ -7,7 +7,10 @@ use domain::{ value_objects::{MovieTitle, ReleaseYear}, }; -use crate::{diary::get_review_history, diary::queries::GetReviewHistoryQuery}; +use crate::{ + diary::deps::GetReviewHistoryDeps, diary::get_review_history, + diary::queries::GetReviewHistoryQuery, +}; #[tokio::test] async fn returns_empty_history() { @@ -23,8 +26,9 @@ async fn returns_empty_history() { let diary = domain::testing::FakeDiaryQuery::new(); diary.seed_history(movie, vec![]); let diary: Arc = diary; + let deps = GetReviewHistoryDeps { diary }; - let (history, trend) = get_review_history::execute(&diary, GetReviewHistoryQuery { movie_id }) + let (history, trend) = get_review_history::execute(&deps, GetReviewHistoryQuery { movie_id }) .await .unwrap(); diff --git a/crates/application/src/diary/tests/get_user_feed.rs b/crates/application/src/diary/tests/get_user_feed.rs new file mode 100644 index 0000000..b27792d --- /dev/null +++ b/crates/application/src/diary/tests/get_user_feed.rs @@ -0,0 +1,93 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use domain::errors::DomainError; +use domain::models::{DiaryEntry, Movie, Review, UserRole, collections::Paginated}; +use domain::testing::FakeDiaryQuery; +use domain::value_objects::{Email, MovieTitle, Rating, ReleaseYear, 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; + +async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) { + let deps = RegisterDeps { + user: b.user_repo.clone(), + 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(); +} + +#[tokio::test] +async fn user_feed_carries_author_and_entries() { + let b = TestContextBuilder::new(); + setup_user(&b, "feed@test.com", "feeduser").await; + + let email = Email::new("feed@test.com".into()).unwrap(); + let user = b.user_repo.find_by_email(&email).await.unwrap().unwrap(); + 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, + ) + .unwrap(); + diary.set_diary_page(Paginated { + items: vec![DiaryEntry::new(movie, review)], + total_count: 1, + limit: 50, + offset: 0, + }); + + let deps = GetUserFeedDeps { + user: b.user_repo.clone(), + diary: Arc::clone(&diary) as _, + }; + + let feed = get_user_feed::execute(&deps, uid, 50).await.unwrap(); + + assert_eq!(feed.author.display_name, "feed"); + assert_eq!(feed.entries.len(), 1); +} + +#[tokio::test] +async fn user_feed_is_not_found_for_unknown_user() { + let b = TestContextBuilder::new(); + let deps = GetUserFeedDeps { + user: b.user_repo.clone(), + diary: b.diary_repo.clone(), + }; + + let err = match get_user_feed::execute(&deps, Uuid::new_v4(), 50).await { + Err(e) => e, + Ok(_) => panic!("expected Err(NotFound) for an unknown user id, got Ok"), + }; + assert!(matches!(err, DomainError::NotFound(_))); +} diff --git a/crates/application/src/import/apply_profile_and_map.rs b/crates/application/src/import/apply_profile_and_map.rs new file mode 100644 index 0000000..ad214ef --- /dev/null +++ b/crates/application/src/import/apply_profile_and_map.rs @@ -0,0 +1,64 @@ +//! Absorbs `handlers/import.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 +//! to live in the handler; this use case is the only caller-visible change — +//! the two existing use cases it drives (`apply_profile::execute`, +//! `apply_mapping::execute`) are untouched, per this task's constraint against +//! reshaping already-existing use-case signatures. + +use domain::{errors::DomainError, value_objects::ImportSessionId}; + +use crate::import::{ + apply_mapping, apply_profile, + commands::{ApplyImportMappingCommand, ApplyImportProfileCommand, ApplyProfileAndMapCommand}, + deps::{ApplyMappingDeps, ApplyProfileAndMapDeps, ApplyProfileDeps}, +}; + +pub async fn execute( + deps: &ApplyProfileAndMapDeps, + cmd: ApplyProfileAndMapCommand, +) -> Result, DomainError> { + let profile_deps = ApplyProfileDeps { + import_profile: deps.import_profile.clone(), + import_session: deps.import_session.clone(), + }; + apply_profile::execute( + &profile_deps, + ApplyImportProfileCommand { + user_id: cmd.user_id, + session_id: cmd.session_id, + profile_id: cmd.profile_id, + }, + ) + .await?; + + let session_id = ImportSessionId::from_uuid(cmd.session_id); + let user_id = domain::value_objects::UserId::from_uuid(cmd.user_id); + let session = deps + .import_session + .get(&session_id, &user_id) + .await? + .ok_or_else(|| DomainError::NotFound("session not found after profile apply".into()))?; + + let mappings = session.field_mappings.unwrap_or_default(); + + let mapping_deps = ApplyMappingDeps { + import_session: deps.import_session.clone(), + document_parser: deps.document_parser.clone(), + movie_query: deps.movie_query.clone(), + }; + apply_mapping::execute( + &mapping_deps, + ApplyImportMappingCommand { + user_id: cmd.user_id, + session_id: cmd.session_id, + mappings, + }, + ) + .await +} + +#[cfg(test)] +#[path = "tests/apply_profile_and_map.rs"] +mod tests; diff --git a/crates/application/src/import/commands.rs b/crates/application/src/import/commands.rs index 9f49700..1377ca1 100644 --- a/crates/application/src/import/commands.rs +++ b/crates/application/src/import/commands.rs @@ -31,6 +31,12 @@ pub struct ApplyImportProfileCommand { pub profile_id: Uuid, } +pub struct ApplyProfileAndMapCommand { + pub user_id: Uuid, + pub session_id: Uuid, + pub profile_id: Uuid, +} + pub struct DeleteImportProfileCommand { pub user_id: Uuid, pub profile_id: Uuid, diff --git a/crates/application/src/import/delete_profile.rs b/crates/application/src/import/delete_profile.rs index 2ccd9e3..69e9fcb 100644 --- a/crates/application/src/import/delete_profile.rs +++ b/crates/application/src/import/delete_profile.rs @@ -1,24 +1,22 @@ -use std::sync::Arc; - use crate::import::commands::DeleteImportProfileCommand; +use crate::import::deps::DeleteImportProfileDeps; use domain::{ errors::DomainError, - ports::ImportProfileRepository, value_objects::{ImportProfileId, UserId}, }; pub async fn execute( - import_profile: Arc, + deps: &DeleteImportProfileDeps, cmd: DeleteImportProfileCommand, ) -> Result<(), DomainError> { let user_id = UserId::from_uuid(cmd.user_id); let profile_id = ImportProfileId::from_uuid(cmd.profile_id); - import_profile + deps.import_profile .get(&profile_id, &user_id) .await? .ok_or_else(|| DomainError::NotFound("import profile".into()))?; - import_profile.delete(&profile_id).await + deps.import_profile.delete(&profile_id).await } #[cfg(test)] diff --git a/crates/application/src/import/deps.rs b/crates/application/src/import/deps.rs index badb4f2..54d3ad4 100644 --- a/crates/application/src/import/deps.rs +++ b/crates/application/src/import/deps.rs @@ -29,3 +29,35 @@ pub struct SaveProfileDeps { pub import_session: Arc, pub import_profile: Arc, } + +pub struct GetMappingStageDeps { + pub import_session: Arc, +} + +pub struct GetPreviewStageDeps { + pub import_session: Arc, +} + +pub struct GetSessionStateDeps { + pub import_session: Arc, +} + +pub struct DeleteImportProfileDeps { + pub import_profile: Arc, +} + +pub struct ListImportProfilesDeps { + pub import_profile: Arc, +} + +/// Backs `apply_profile_and_map`, which internally drives `apply_profile::execute` +/// then `apply_mapping::execute` — these fields are exactly the union of +/// `ApplyProfileDeps` and `ApplyMappingDeps`'s fields, cloned once here and used to +/// build each nested deps struct inline at the call site (see that file's doc +/// comment for why: no use-case signature changes, per this task's constraints). +pub struct ApplyProfileAndMapDeps { + pub import_profile: Arc, + pub import_session: Arc, + pub document_parser: Arc, + pub movie_query: Arc, +} diff --git a/crates/application/src/import/get_mapping_stage.rs b/crates/application/src/import/get_mapping_stage.rs new file mode 100644 index 0000000..0a0ecb7 --- /dev/null +++ b/crates/application/src/import/get_mapping_stage.rs @@ -0,0 +1,47 @@ +//! The mapping-page stage gate: a session must exist and have a `parsed_file` +//! before its columns/sample rows can be shown for field mapping. Absorbs +//! `handlers/import.rs::get_mapping_page`'s two early-return checks (session +//! missing, `parsed_file` absent) — both collapse to `NotFound` here since the +//! handler redirected to the same place (`/import`) for either. + +use domain::{errors::DomainError, value_objects::ImportSessionId}; +use uuid::Uuid; + +use crate::import::deps::GetMappingStageDeps; + +/// Cap on sample rows shown on the mapping page — was a bare `.take(5)` in the +/// handler. +pub const SAMPLE_ROW_LIMIT: usize = 5; + +pub struct MappingStage { + pub columns: Vec, + pub sample_rows: Vec>, +} + +pub async fn execute( + deps: &GetMappingStageDeps, + session_id: ImportSessionId, + user_id: Uuid, +) -> Result { + let user_id = domain::value_objects::UserId::from_uuid(user_id); + let session = deps + .import_session + .get(&session_id, &user_id) + .await? + .ok_or_else(|| DomainError::NotFound("import session".into()))?; + + let parsed = session + .parsed_file + .ok_or_else(|| DomainError::NotFound("import session has no parsed file".into()))?; + + let sample_rows = parsed.rows.into_iter().take(SAMPLE_ROW_LIMIT).collect(); + + Ok(MappingStage { + columns: parsed.columns, + 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 new file mode 100644 index 0000000..a473589 --- /dev/null +++ b/crates/application/src/import/get_preview_stage.rs @@ -0,0 +1,59 @@ +//! The preview-page stage gate: a session must have `row_results` (i.e. a +//! mapping has already been applied) before its rows can be previewed. Serves +//! both the HTML preview handler and the API preview handler — +//! `handlers/import.rs::get_preview_page` and `::api_get_preview` — which +//! render/respond to `NotYetMapped` differently (redirect vs. status code); that +//! decision stays in the handlers, not here. + +use domain::{ + errors::DomainError, + models::AnnotatedRow, + value_objects::{ImportSessionId, UserId}, +}; +use uuid::Uuid; + +use crate::import::deps::GetPreviewStageDeps; + +/// The columns and mapped/annotated rows for a session whose mapping has +/// already been applied. `columns` comes from the session's `parsed_file` — +/// the HTML preview template renders it as the table header — while `rows` +/// comes from `row_results`. Not in the brief's `PreviewStage::Ready(Vec)` +/// sketch: the deleted `get_preview_page` handler code read both +/// `session.parsed_file.columns` and `session.row_results` to render the page, +/// so dropping `columns` here would either blank the preview table's header or +/// force the handler to re-fetch the session itself (forbidden — that's the +/// exact repo call this task removes). See task-2 report for detail. +pub struct PreviewRows { + pub columns: Vec, + pub rows: Vec, +} + +pub enum PreviewStage { + Ready(PreviewRows), + NotYetMapped, +} + +pub async fn execute( + deps: &GetPreviewStageDeps, + session_id: ImportSessionId, + user_id: Uuid, +) -> Result { + let user_id = UserId::from_uuid(user_id); + let session = deps + .import_session + .get(&session_id, &user_id) + .await? + .ok_or_else(|| DomainError::NotFound("session not found".into()))?; + + let Some(rows) = session.row_results else { + return Ok(PreviewStage::NotYetMapped); + }; + + let columns = session.parsed_file.map(|p| p.columns).unwrap_or_default(); + + 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 new file mode 100644 index 0000000..4898ad3 --- /dev/null +++ b/crates/application/src/import/get_session_state.rs @@ -0,0 +1,43 @@ +//! Backs `handlers/import.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). + +use domain::{ + errors::DomainError, + value_objects::{ImportSessionId, UserId}, +}; +use uuid::Uuid; + +use crate::import::deps::GetSessionStateDeps; + +pub struct SessionState { + pub columns: Vec, + pub has_mappings: bool, + pub row_count: usize, +} + +pub async fn execute( + deps: &GetSessionStateDeps, + session_id: ImportSessionId, + user_id: Uuid, +) -> Result { + let user_id = UserId::from_uuid(user_id); + let session = deps + .import_session + .get(&session_id, &user_id) + .await? + .ok_or_else(|| DomainError::NotFound("session not found".into()))?; + + let parsed = session.parsed_file.unwrap_or_default(); + let row_count = parsed.rows.len(); + + Ok(SessionState { + columns: parsed.columns, + has_mappings: session.field_mappings.is_some(), + 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 019cee8..ca5e214 100644 --- a/crates/application/src/import/list_profiles.rs +++ b/crates/application/src/import/list_profiles.rs @@ -1,15 +1,11 @@ -use std::sync::Arc; - -use domain::{ - errors::DomainError, models::ImportProfile, ports::ImportProfileRepository, - value_objects::UserId, -}; +use crate::import::deps::ListImportProfilesDeps; +use domain::{errors::DomainError, models::ImportProfile, value_objects::UserId}; pub async fn execute( - import_profile: Arc, + deps: &ListImportProfilesDeps, user_id: &UserId, ) -> Result, DomainError> { - import_profile.list_for_user(user_id).await + deps.import_profile.list_for_user(user_id).await } #[cfg(test)] diff --git a/crates/application/src/import/mod.rs b/crates/application/src/import/mod.rs index bd53d34..d87a76a 100644 --- a/crates/application/src/import/mod.rs +++ b/crates/application/src/import/mod.rs @@ -1,10 +1,14 @@ pub mod apply_mapping; pub mod apply_profile; +pub mod apply_profile_and_map; pub mod cleanup; pub mod commands; pub mod create_session; pub mod delete_profile; pub mod deps; pub mod execute; +pub mod get_mapping_stage; +pub mod get_preview_stage; +pub mod get_session_state; pub mod list_profiles; pub mod save_profile; diff --git a/crates/application/src/import/tests/apply_profile_and_map.rs b/crates/application/src/import/tests/apply_profile_and_map.rs new file mode 100644 index 0000000..a5ccb6b --- /dev/null +++ b/crates/application/src/import/tests/apply_profile_and_map.rs @@ -0,0 +1,108 @@ +use std::sync::Arc; + +use chrono::Utc; +use uuid::Uuid; + +use domain::models::import::{DomainField, Transform}; +use domain::models::{FieldMapping, FileFormat, ImportProfile}; +use domain::ports::{ImportProfileRepository, ImportSessionRepository}; +use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepository}; +use domain::value_objects::{ImportProfileId, UserId}; + +use crate::import::deps::{ApplyProfileAndMapDeps, CreateSessionDeps}; +use crate::import::{ + apply_profile_and_map, commands::ApplyProfileAndMapCommand, + commands::CreateImportSessionCommand, create_session, +}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn fails_when_profile_not_found() { + let profiles = InMemoryImportProfileRepository::new(); + let sessions = InMemoryImportSessionRepository::new(); + let b = TestContextBuilder::new(); + + let deps = ApplyProfileAndMapDeps { + import_profile: Arc::clone(&profiles) as _, + import_session: Arc::clone(&sessions) as _, + document_parser: b.document_parser.clone(), + movie_query: b.movie_query.clone(), + }; + + let result = apply_profile_and_map::execute( + &deps, + ApplyProfileAndMapCommand { + user_id: Uuid::new_v4(), + session_id: Uuid::new_v4(), + profile_id: Uuid::new_v4(), + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn applies_profile_then_regenerates_mapping() { + let profiles = InMemoryImportProfileRepository::new(); + let sessions = InMemoryImportSessionRepository::new(); + let b = TestContextBuilder::new(); + let user_id = Uuid::new_v4(); + + let profile = ImportProfile::new( + ImportProfileId::generate(), + UserId::from_uuid(user_id), + "letterboxd".into(), + vec![FieldMapping { + source_column: "title".into(), + domain_field: DomainField::Title, + transform: Transform::Identity, + }], + Utc::now().naive_utc(), + ); + let profile_id = profile.id.clone(); + profiles.save(&profile).await.unwrap(); + + let create_deps = CreateSessionDeps { + import_session: Arc::clone(&sessions) as _, + document_parser: b.document_parser.clone(), + }; + let created = create_session::execute( + &create_deps, + CreateImportSessionCommand { + user_id, + bytes: b"title\nTest".to_vec(), + format: FileFormat::Csv, + }, + ) + .await + .unwrap(); + + let deps = ApplyProfileAndMapDeps { + import_profile: Arc::clone(&profiles) as _, + import_session: Arc::clone(&sessions) as _, + document_parser: b.document_parser.clone(), + movie_query: b.movie_query.clone(), + }; + + let rows = apply_profile_and_map::execute( + &deps, + ApplyProfileAndMapCommand { + user_id, + session_id: created.session_id.value(), + profile_id: profile_id.value(), + }, + ) + .await + .unwrap(); + + assert!(!rows.is_empty()); + + let updated = sessions + .get(&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/delete_profile.rs b/crates/application/src/import/tests/delete_profile.rs index c1e2115..3256863 100644 --- a/crates/application/src/import/tests/delete_profile.rs +++ b/crates/application/src/import/tests/delete_profile.rs @@ -3,14 +3,19 @@ use std::sync::Arc; use domain::testing::InMemoryImportProfileRepository; use uuid::Uuid; -use crate::import::{commands::DeleteImportProfileCommand, delete_profile}; +use crate::import::{ + commands::DeleteImportProfileCommand, delete_profile, deps::DeleteImportProfileDeps, +}; #[tokio::test] async fn fails_when_profile_not_found() { let profiles = InMemoryImportProfileRepository::new(); + let deps = DeleteImportProfileDeps { + import_profile: Arc::clone(&profiles) as _, + }; let result = delete_profile::execute( - Arc::clone(&profiles) as _, + &deps, DeleteImportProfileCommand { user_id: Uuid::new_v4(), profile_id: Uuid::new_v4(), diff --git a/crates/application/src/import/tests/get_mapping_stage.rs b/crates/application/src/import/tests/get_mapping_stage.rs new file mode 100644 index 0000000..11743d6 --- /dev/null +++ b/crates/application/src/import/tests/get_mapping_stage.rs @@ -0,0 +1,68 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use domain::models::ImportSession; +use domain::models::import::ParsedFile; +use domain::ports::ImportSessionRepository; +use domain::testing::InMemoryImportSessionRepository; +use domain::value_objects::{ImportSessionId, UserId}; + +use crate::import::deps::GetMappingStageDeps; +use crate::import::get_mapping_stage::{self, SAMPLE_ROW_LIMIT}; + +#[tokio::test] +async fn get_mapping_stage_is_not_found_when_file_not_parsed() { + let sessions = InMemoryImportSessionRepository::new(); + let user_id = Uuid::new_v4(); + let session = ImportSession::new(UserId::from_uuid(user_id)); + let session_id = session.id.clone(); + sessions.create(&session).await.unwrap(); + + let deps = GetMappingStageDeps { + import_session: Arc::clone(&sessions) as _, + }; + + let result = get_mapping_stage::execute(&deps, session_id, user_id).await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn get_mapping_stage_is_not_found_when_session_missing() { + let sessions = InMemoryImportSessionRepository::new(); + let deps = GetMappingStageDeps { + import_session: Arc::clone(&sessions) as _, + }; + + let result = + get_mapping_stage::execute(&deps, ImportSessionId::generate(), Uuid::new_v4()).await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn get_mapping_stage_returns_columns_and_capped_sample_rows() { + let sessions = InMemoryImportSessionRepository::new(); + let user_id = Uuid::new_v4(); + let mut session = ImportSession::new(UserId::from_uuid(user_id)); + session.parsed_file = Some(ParsedFile { + columns: vec!["Name".into(), "Year".into()], + rows: (0..7) + .map(|i| vec![format!("row{i}"), "2020".into()]) + .collect(), + }); + let session_id = session.id.clone(); + sessions.create(&session).await.unwrap(); + + let deps = GetMappingStageDeps { + import_session: Arc::clone(&sessions) as _, + }; + + let stage = get_mapping_stage::execute(&deps, session_id, user_id) + .await + .unwrap(); + + assert_eq!(stage.columns, vec!["Name".to_string(), "Year".to_string()]); + assert_eq!(stage.sample_rows.len(), SAMPLE_ROW_LIMIT); +} diff --git a/crates/application/src/import/tests/get_preview_stage.rs b/crates/application/src/import/tests/get_preview_stage.rs new file mode 100644 index 0000000..7467c60 --- /dev/null +++ b/crates/application/src/import/tests/get_preview_stage.rs @@ -0,0 +1,80 @@ +use std::sync::Arc; + +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::value_objects::{ImportSessionId, UserId}; + +use crate::import::deps::GetPreviewStageDeps; +use crate::import::get_preview_stage::{self, PreviewStage}; + +#[tokio::test] +async fn get_preview_stage_reports_not_yet_mapped_when_row_results_absent() { + let sessions = InMemoryImportSessionRepository::new(); + let user_id = Uuid::new_v4(); + let session = ImportSession::new(UserId::from_uuid(user_id)); + let session_id = session.id.clone(); + sessions.create(&session).await.unwrap(); + + let deps = GetPreviewStageDeps { + import_session: Arc::clone(&sessions) as _, + }; + + let stage = get_preview_stage::execute(&deps, session_id, user_id) + .await + .unwrap(); + + assert!(matches!(stage, PreviewStage::NotYetMapped)); +} + +#[tokio::test] +async fn get_preview_stage_returns_rows_once_mapped() { + let sessions = InMemoryImportSessionRepository::new(); + let user_id = Uuid::new_v4(); + let mut session = ImportSession::new(UserId::from_uuid(user_id)); + session.parsed_file = Some(ParsedFile { + columns: vec!["Name".into()], + rows: vec![vec!["Test".into()]], + }); + session.row_results = Some(vec![AnnotatedRow { + result: RowResult::Valid(ImportRow { + title: Some("Test".into()), + ..ImportRow::default() + }), + is_duplicate: false, + }]); + let session_id = session.id.clone(); + sessions.create(&session).await.unwrap(); + + let deps = GetPreviewStageDeps { + import_session: Arc::clone(&sessions) as _, + }; + + let stage = get_preview_stage::execute(&deps, session_id, user_id) + .await + .unwrap(); + + match stage { + PreviewStage::Ready(preview) => { + assert_eq!(preview.columns, vec!["Name".to_string()]); + assert_eq!(preview.rows.len(), 1); + } + PreviewStage::NotYetMapped => panic!("expected Ready, got NotYetMapped"), + } +} + +#[tokio::test] +async fn get_preview_stage_is_not_found_when_session_missing() { + let sessions = InMemoryImportSessionRepository::new(); + let deps = GetPreviewStageDeps { + import_session: Arc::clone(&sessions) as _, + }; + + let result = + get_preview_stage::execute(&deps, ImportSessionId::generate(), Uuid::new_v4()).await; + + assert!(result.is_err()); +} diff --git a/crates/application/src/import/tests/get_session_state.rs b/crates/application/src/import/tests/get_session_state.rs new file mode 100644 index 0000000..ac02dbd --- /dev/null +++ b/crates/application/src/import/tests/get_session_state.rs @@ -0,0 +1,50 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use domain::models::ImportSession; +use domain::models::import::ParsedFile; +use domain::ports::ImportSessionRepository; +use domain::testing::InMemoryImportSessionRepository; +use domain::value_objects::{ImportSessionId, UserId}; + +use crate::import::deps::GetSessionStateDeps; +use crate::import::get_session_state; + +#[tokio::test] +async fn get_session_state_is_not_found_when_session_missing() { + let sessions = InMemoryImportSessionRepository::new(); + let deps = GetSessionStateDeps { + import_session: Arc::clone(&sessions) as _, + }; + + let result = + get_session_state::execute(&deps, ImportSessionId::generate(), Uuid::new_v4()).await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn get_session_state_reports_columns_row_count_and_mapping_status() { + let sessions = InMemoryImportSessionRepository::new(); + let user_id = Uuid::new_v4(); + let mut session = ImportSession::new(UserId::from_uuid(user_id)); + session.parsed_file = Some(ParsedFile { + columns: vec!["Name".into()], + rows: vec![vec!["a".into()], vec!["b".into()]], + }); + let session_id = session.id.clone(); + sessions.create(&session).await.unwrap(); + + let deps = GetSessionStateDeps { + import_session: Arc::clone(&sessions) as _, + }; + + let state = get_session_state::execute(&deps, session_id, user_id) + .await + .unwrap(); + + assert_eq!(state.columns, vec!["Name".to_string()]); + assert_eq!(state.row_count, 2); + assert!(!state.has_mappings); +} diff --git a/crates/application/src/import/tests/list_profiles.rs b/crates/application/src/import/tests/list_profiles.rs index b5cc7a2..79f20a7 100644 --- a/crates/application/src/import/tests/list_profiles.rs +++ b/crates/application/src/import/tests/list_profiles.rs @@ -4,16 +4,17 @@ use domain::testing::InMemoryImportProfileRepository; use domain::value_objects::UserId; use uuid::Uuid; -use crate::import::list_profiles; +use crate::import::{deps::ListImportProfilesDeps, list_profiles}; #[tokio::test] async fn returns_empty_when_no_profiles() { let profiles = InMemoryImportProfileRepository::new(); + let deps = ListImportProfilesDeps { + import_profile: Arc::clone(&profiles) as _, + }; let user_id = UserId::from_uuid(Uuid::new_v4()); - let result = list_profiles::execute(Arc::clone(&profiles) as _, &user_id) - .await - .unwrap(); + let result = list_profiles::execute(&deps, &user_id).await.unwrap(); assert!(result.is_empty()); } diff --git a/crates/application/src/integrations/confirm.rs b/crates/application/src/integrations/confirm.rs index 1385a5d..3e065a5 100644 --- a/crates/application/src/integrations/confirm.rs +++ b/crates/application/src/integrations/confirm.rs @@ -1,22 +1,16 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, models::WatchEventStatus, - ports::{WatchEventCommand, WatchEventQuery}, value_objects::{UserId, WatchEventId}, }; use crate::{ diary::commands::{LogReviewCommand, MovieInput}, - integrations::commands::ConfirmWatchEventsCommand, - ports::ReviewLogger, + integrations::{commands::ConfirmWatchEventsCommand, deps::ConfirmWatchEventsDeps}, }; pub async fn execute( - watch_event_command: Arc, - watch_event_query: Arc, - review_logger: Arc, + deps: &ConfirmWatchEventsDeps, cmd: ConfirmWatchEventsCommand, ) -> Result { let user_id = UserId::from_uuid(cmd.user_id); @@ -24,7 +18,8 @@ pub async fn execute( for c in cmd.confirmations { let event_id = WatchEventId::from_uuid(c.watch_event_id); - let event = watch_event_query + let event = deps + .watch_event_query .get_by_id(&event_id) .await? .ok_or_else(|| DomainError::NotFound(format!("WatchEvent {}", c.watch_event_id)))?; @@ -60,9 +55,9 @@ pub async fn execute( watch_medium: Some(domain::value_objects::WatchMedium::MediaServer), }; - review_logger.log_review(review_cmd).await?; + deps.review_logger.log_review(review_cmd).await?; - watch_event_command + deps.watch_event_command .update_status(&event_id, WatchEventStatus::Confirmed) .await?; diff --git a/crates/application/src/integrations/deps.rs b/crates/application/src/integrations/deps.rs index f1f3e17..d7cb1bb 100644 --- a/crates/application/src/integrations/deps.rs +++ b/crates/application/src/integrations/deps.rs @@ -2,9 +2,38 @@ use std::sync::Arc; use domain::ports::{EventPublisher, WatchEventCommand, WatchEventQuery, WebhookTokenRepository}; +use crate::ports::ReviewLogger; + pub struct IngestWatchEventDeps { pub webhook_token: Arc, pub watch_event_command: Arc, pub watch_event_query: Arc, pub event_publisher: Arc, } + +pub struct ConfirmWatchEventsDeps { + pub watch_event_command: Arc, + pub watch_event_query: Arc, + pub review_logger: Arc, +} + +pub struct DismissWatchEventsDeps { + pub watch_event_command: Arc, + pub watch_event_query: Arc, +} + +pub struct GenerateWebhookTokenDeps { + pub webhook_token: Arc, +} + +pub struct GetWatchQueueDeps { + pub watch_event_query: Arc, +} + +pub struct GetWebhookTokensDeps { + pub webhook_token: Arc, +} + +pub struct RevokeWebhookTokenDeps { + pub webhook_token: Arc, +} diff --git a/crates/application/src/integrations/dismiss.rs b/crates/application/src/integrations/dismiss.rs index e71a767..cc90721 100644 --- a/crates/application/src/integrations/dismiss.rs +++ b/crates/application/src/integrations/dismiss.rs @@ -1,17 +1,13 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, models::WatchEventStatus, - ports::{WatchEventCommand, WatchEventQuery}, value_objects::{UserId, WatchEventId}, }; -use crate::integrations::commands::DismissWatchEventsCommand; +use crate::integrations::{commands::DismissWatchEventsCommand, deps::DismissWatchEventsDeps}; pub async fn execute( - watch_event_command: Arc, - watch_event_query: Arc, + deps: &DismissWatchEventsDeps, cmd: DismissWatchEventsCommand, ) -> Result { let user_id = UserId::from_uuid(cmd.user_id); @@ -25,7 +21,7 @@ pub async fn execute( .map(|id| WatchEventId::from_uuid(*id)) .collect(); - let events = watch_event_query.get_by_ids(&ids).await?; + let events = deps.watch_event_query.get_by_ids(&ids).await?; if events.len() != ids.len() { return Err(DomainError::NotFound( @@ -38,7 +34,8 @@ pub async fn execute( } } - let count = watch_event_command + let count = deps + .watch_event_command .update_status_batch(&ids, WatchEventStatus::Dismissed) .await?; diff --git a/crates/application/src/integrations/generate_token.rs b/crates/application/src/integrations/generate_token.rs index 56209e7..aeee142 100644 --- a/crates/application/src/integrations/generate_token.rs +++ b/crates/application/src/integrations/generate_token.rs @@ -1,11 +1,7 @@ -use std::sync::Arc; - -use domain::{ - errors::DomainError, models::WebhookToken, ports::WebhookTokenRepository, value_objects::UserId, -}; +use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId}; use sha2::{Digest, Sha256}; -use crate::integrations::commands::GenerateWebhookTokenCommand; +use crate::integrations::{commands::GenerateWebhookTokenCommand, deps::GenerateWebhookTokenDeps}; pub struct GeneratedWebhookToken { pub token_plaintext: String, @@ -13,7 +9,7 @@ pub struct GeneratedWebhookToken { } pub async fn execute( - webhook_token: Arc, + deps: &GenerateWebhookTokenDeps, cmd: GenerateWebhookTokenCommand, ) -> Result { let plaintext = generate_random_token(); @@ -22,7 +18,7 @@ pub async fn execute( let user_id = UserId::from_uuid(cmd.user_id); let token = WebhookToken::new(user_id, hash, cmd.provider, cmd.label); - webhook_token.save(&token).await?; + deps.webhook_token.save(&token).await?; Ok(GeneratedWebhookToken { token_plaintext: plaintext, diff --git a/crates/application/src/integrations/get_queue.rs b/crates/application/src/integrations/get_queue.rs index 43d83ff..1516ed2 100644 --- a/crates/application/src/integrations/get_queue.rs +++ b/crates/application/src/integrations/get_queue.rs @@ -1,17 +1,13 @@ -use std::sync::Arc; +use domain::{errors::DomainError, models::WatchEvent, value_objects::UserId}; -use domain::{ - errors::DomainError, models::WatchEvent, ports::WatchEventQuery, value_objects::UserId, -}; - -use crate::integrations::queries::GetWatchQueueQuery; +use crate::integrations::{deps::GetWatchQueueDeps, queries::GetWatchQueueQuery}; pub async fn execute( - watch_event_query: Arc, + deps: &GetWatchQueueDeps, query: GetWatchQueueQuery, ) -> Result, DomainError> { let user_id = UserId::from_uuid(query.user_id); - watch_event_query.list_pending(&user_id).await + deps.watch_event_query.list_pending(&user_id).await } #[cfg(test)] diff --git a/crates/application/src/integrations/get_tokens.rs b/crates/application/src/integrations/get_tokens.rs index 192aef4..66faf73 100644 --- a/crates/application/src/integrations/get_tokens.rs +++ b/crates/application/src/integrations/get_tokens.rs @@ -1,17 +1,13 @@ -use std::sync::Arc; +use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId}; -use domain::{ - errors::DomainError, models::WebhookToken, ports::WebhookTokenRepository, value_objects::UserId, -}; - -use crate::integrations::queries::GetWebhookTokensQuery; +use crate::integrations::{deps::GetWebhookTokensDeps, queries::GetWebhookTokensQuery}; pub async fn execute( - webhook_token: Arc, + deps: &GetWebhookTokensDeps, query: GetWebhookTokensQuery, ) -> Result, DomainError> { let user_id = UserId::from_uuid(query.user_id); - webhook_token.list_by_user(&user_id).await + deps.webhook_token.list_by_user(&user_id).await } #[cfg(test)] diff --git a/crates/application/src/integrations/revoke_token.rs b/crates/application/src/integrations/revoke_token.rs index b245adf..8122cc2 100644 --- a/crates/application/src/integrations/revoke_token.rs +++ b/crates/application/src/integrations/revoke_token.rs @@ -1,20 +1,17 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, - ports::WebhookTokenRepository, value_objects::{UserId, WebhookTokenId}, }; -use crate::integrations::commands::RevokeWebhookTokenCommand; +use crate::integrations::{commands::RevokeWebhookTokenCommand, deps::RevokeWebhookTokenDeps}; pub async fn execute( - webhook_token: Arc, + deps: &RevokeWebhookTokenDeps, cmd: RevokeWebhookTokenCommand, ) -> Result<(), DomainError> { let user_id = UserId::from_uuid(cmd.user_id); let token_id = WebhookTokenId::from_uuid(cmd.token_id); - webhook_token.delete(&token_id, &user_id).await + deps.webhook_token.delete(&token_id, &user_id).await } #[cfg(test)] diff --git a/crates/application/src/integrations/tests/confirm.rs b/crates/application/src/integrations/tests/confirm.rs index 5328e06..ce18f3b 100644 --- a/crates/application/src/integrations/tests/confirm.rs +++ b/crates/application/src/integrations/tests/confirm.rs @@ -8,12 +8,24 @@ use uuid::Uuid; use crate::integrations::commands::{ConfirmWatchEventsCommand, WatchEventConfirmation}; use crate::integrations::confirm; +use crate::integrations::deps::ConfirmWatchEventsDeps; use crate::test_helpers::NoopReviewLogger; fn noop_logger() -> Arc { Arc::new(NoopReviewLogger) } +fn deps( + watch_events: &Arc, + review_logger: Arc, +) -> ConfirmWatchEventsDeps { + ConfirmWatchEventsDeps { + watch_event_command: Arc::clone(watch_events) as _, + watch_event_query: Arc::clone(watch_events) as _, + review_logger, + } +} + #[tokio::test] async fn confirms_watch_event_via_review_logger() { let watch_events = InMemoryWatchEventRepository::new(); @@ -32,9 +44,7 @@ async fn confirms_watch_event_via_review_logger() { watch_events.save(&event).await.unwrap(); let result = confirm::execute( - Arc::clone(&watch_events) as _, - Arc::clone(&watch_events) as _, - noop_logger(), + &deps(&watch_events, noop_logger()), ConfirmWatchEventsCommand { user_id: uid, confirmations: vec![WatchEventConfirmation { @@ -55,9 +65,7 @@ async fn empty_confirmations_returns_zero() { let watch_events = InMemoryWatchEventRepository::new(); let result = confirm::execute( - Arc::clone(&watch_events) as _, - Arc::clone(&watch_events) as _, - noop_logger(), + &deps(&watch_events, noop_logger()), ConfirmWatchEventsCommand { user_id: Uuid::new_v4(), confirmations: vec![], @@ -87,9 +95,7 @@ async fn confirms_event_with_external_metadata_id_and_no_movie_id() { watch_events.save(&event).await.unwrap(); let result = confirm::execute( - Arc::clone(&watch_events) as _, - Arc::clone(&watch_events) as _, - noop_logger(), + &deps(&watch_events, noop_logger()), ConfirmWatchEventsCommand { user_id: uid, confirmations: vec![WatchEventConfirmation { @@ -124,9 +130,7 @@ async fn rejects_other_users_event() { watch_events.save(&event).await.unwrap(); let result = confirm::execute( - Arc::clone(&watch_events) as _, - Arc::clone(&watch_events) as _, - noop_logger(), + &deps(&watch_events, noop_logger()), ConfirmWatchEventsCommand { user_id: intruder, confirmations: vec![WatchEventConfirmation { @@ -146,9 +150,7 @@ async fn fails_when_event_not_found() { let watch_events = InMemoryWatchEventRepository::new(); let result = confirm::execute( - Arc::clone(&watch_events) as _, - Arc::clone(&watch_events) as _, - noop_logger(), + &deps(&watch_events, noop_logger()), ConfirmWatchEventsCommand { user_id: Uuid::new_v4(), confirmations: vec![WatchEventConfirmation { @@ -208,9 +210,7 @@ async fn confirms_event_with_movie_id() { )); let result = confirm::execute( - Arc::clone(&watch_events) as _, - Arc::clone(&watch_events) as _, - review_logger, + &deps(&watch_events, review_logger), ConfirmWatchEventsCommand { user_id: uid, confirmations: vec![WatchEventConfirmation { @@ -244,9 +244,7 @@ async fn confirms_event_without_movie_id_and_without_external_metadata_id() { watch_events.save(&event).await.unwrap(); let result = confirm::execute( - Arc::clone(&watch_events) as _, - Arc::clone(&watch_events) as _, - noop_logger(), + &deps(&watch_events, noop_logger()), ConfirmWatchEventsCommand { user_id: uid, confirmations: vec![WatchEventConfirmation { @@ -293,9 +291,7 @@ async fn confirms_multiple_events() { watch_events.save(&event2).await.unwrap(); let result = confirm::execute( - Arc::clone(&watch_events) as _, - Arc::clone(&watch_events) as _, - noop_logger(), + &deps(&watch_events, noop_logger()), ConfirmWatchEventsCommand { user_id: uid, confirmations: vec![ @@ -336,9 +332,7 @@ async fn confirms_event_without_year() { watch_events.save(&event).await.unwrap(); let result = confirm::execute( - Arc::clone(&watch_events) as _, - Arc::clone(&watch_events) as _, - noop_logger(), + &deps(&watch_events, noop_logger()), ConfirmWatchEventsCommand { user_id: uid, confirmations: vec![WatchEventConfirmation { diff --git a/crates/application/src/integrations/tests/dismiss.rs b/crates/application/src/integrations/tests/dismiss.rs index fabc0b6..3db9f35 100644 --- a/crates/application/src/integrations/tests/dismiss.rs +++ b/crates/application/src/integrations/tests/dismiss.rs @@ -6,15 +6,22 @@ use domain::testing::InMemoryWatchEventRepository; use domain::value_objects::UserId; use uuid::Uuid; +use crate::integrations::deps::DismissWatchEventsDeps; use crate::integrations::{commands::DismissWatchEventsCommand, dismiss}; +fn deps(watch_events: &Arc) -> DismissWatchEventsDeps { + DismissWatchEventsDeps { + watch_event_command: Arc::clone(watch_events) as _, + watch_event_query: Arc::clone(watch_events) as _, + } +} + #[tokio::test] async fn dismisses_empty_list_returns_zero() { let events = InMemoryWatchEventRepository::new(); let result = dismiss::execute( - Arc::clone(&events) as _, - Arc::clone(&events) as _, + &deps(&events), DismissWatchEventsCommand { user_id: Uuid::new_v4(), event_ids: vec![], @@ -31,8 +38,7 @@ async fn fails_when_event_not_found() { let events = InMemoryWatchEventRepository::new(); let result = dismiss::execute( - Arc::clone(&events) as _, - Arc::clone(&events) as _, + &deps(&events), DismissWatchEventsCommand { user_id: Uuid::new_v4(), event_ids: vec![Uuid::new_v4()], @@ -73,8 +79,7 @@ async fn dismisses_existing_events() { watch_events.save(&e2).await.unwrap(); let result = dismiss::execute( - Arc::clone(&watch_events) as _, - Arc::clone(&watch_events) as _, + &deps(&watch_events), DismissWatchEventsCommand { user_id: uid, event_ids: vec![id1, id2], diff --git a/crates/application/src/integrations/tests/generate_token.rs b/crates/application/src/integrations/tests/generate_token.rs index 8127cb7..2b038ef 100644 --- a/crates/application/src/integrations/tests/generate_token.rs +++ b/crates/application/src/integrations/tests/generate_token.rs @@ -5,6 +5,7 @@ use domain::ports::WebhookTokenRepository; use domain::testing::InMemoryWebhookTokenRepository; use uuid::Uuid; +use crate::integrations::deps::GenerateWebhookTokenDeps; use crate::integrations::{commands::GenerateWebhookTokenCommand, generate_token}; #[tokio::test] @@ -13,7 +14,9 @@ async fn generates_token_and_saves() { let user_id = Uuid::new_v4(); let result = generate_token::execute( - Arc::clone(&tokens), + &GenerateWebhookTokenDeps { + webhook_token: Arc::clone(&tokens), + }, GenerateWebhookTokenCommand { user_id, provider: WatchEventSource::Jellyfin, diff --git a/crates/application/src/integrations/tests/get_queue.rs b/crates/application/src/integrations/tests/get_queue.rs index 41d3898..2169cbc 100644 --- a/crates/application/src/integrations/tests/get_queue.rs +++ b/crates/application/src/integrations/tests/get_queue.rs @@ -7,14 +7,21 @@ use domain::testing::InMemoryWatchEventRepository; use domain::value_objects::UserId; use uuid::Uuid; +use crate::integrations::deps::GetWatchQueueDeps; use crate::integrations::{get_queue, queries::GetWatchQueueQuery}; +fn deps(events: &Arc) -> GetWatchQueueDeps { + GetWatchQueueDeps { + watch_event_query: Arc::clone(events) as _, + } +} + #[tokio::test] async fn returns_empty_when_no_events() { let events = InMemoryWatchEventRepository::new(); let result = get_queue::execute( - Arc::clone(&events) as _, + &deps(&events), GetWatchQueueQuery { user_id: Uuid::new_v4(), }, @@ -41,7 +48,7 @@ async fn returns_pending_events() { ); events.save(&event).await.unwrap(); - let result = get_queue::execute(Arc::clone(&events) as _, GetWatchQueueQuery { user_id }) + let result = get_queue::execute(&deps(&events), GetWatchQueueQuery { user_id }) .await .unwrap(); diff --git a/crates/application/src/integrations/tests/get_tokens.rs b/crates/application/src/integrations/tests/get_tokens.rs index 05bb0c3..4347444 100644 --- a/crates/application/src/integrations/tests/get_tokens.rs +++ b/crates/application/src/integrations/tests/get_tokens.rs @@ -5,17 +5,30 @@ use domain::ports::WebhookTokenRepository; use domain::testing::InMemoryWebhookTokenRepository; use uuid::Uuid; +use crate::integrations::deps::{GenerateWebhookTokenDeps, GetWebhookTokensDeps}; use crate::integrations::{ commands::GenerateWebhookTokenCommand, generate_token, get_tokens, queries::GetWebhookTokensQuery, }; +fn generate_deps(tokens: &Arc) -> GenerateWebhookTokenDeps { + GenerateWebhookTokenDeps { + webhook_token: Arc::clone(tokens), + } +} + +fn get_deps(tokens: &Arc) -> GetWebhookTokensDeps { + GetWebhookTokensDeps { + webhook_token: Arc::clone(tokens), + } +} + #[tokio::test] async fn returns_empty_when_no_tokens() { let tokens: Arc = InMemoryWebhookTokenRepository::new(); let result = get_tokens::execute( - Arc::clone(&tokens), + &get_deps(&tokens), GetWebhookTokensQuery { user_id: Uuid::new_v4(), }, @@ -33,7 +46,7 @@ async fn returns_tokens_after_generate() { let user_id = Uuid::new_v4(); generate_token::execute( - Arc::clone(&tokens), + &generate_deps(&tokens), GenerateWebhookTokenCommand { user_id, provider: WatchEventSource::Jellyfin, @@ -44,7 +57,7 @@ async fn returns_tokens_after_generate() { .unwrap(); generate_token::execute( - Arc::clone(&tokens), + &generate_deps(&tokens), GenerateWebhookTokenCommand { user_id, provider: WatchEventSource::Plex, @@ -54,7 +67,7 @@ async fn returns_tokens_after_generate() { .await .unwrap(); - let result = get_tokens::execute(Arc::clone(&tokens), GetWebhookTokensQuery { user_id }) + let result = get_tokens::execute(&get_deps(&tokens), GetWebhookTokensQuery { user_id }) .await .unwrap(); diff --git a/crates/application/src/integrations/tests/ingest.rs b/crates/application/src/integrations/tests/ingest.rs index 49bef6c..04099b9 100644 --- a/crates/application/src/integrations/tests/ingest.rs +++ b/crates/application/src/integrations/tests/ingest.rs @@ -8,7 +8,7 @@ use domain::testing::{ use uuid::Uuid; use crate::integrations::commands::{GenerateWebhookTokenCommand, IngestWatchEventCommand}; -use crate::integrations::deps::IngestWatchEventDeps; +use crate::integrations::deps::{GenerateWebhookTokenDeps, IngestWatchEventDeps}; use crate::integrations::{generate_token, ingest}; struct FakeParser; @@ -35,7 +35,9 @@ async fn ingests_watch_event() { let user_id = Uuid::new_v4(); let generated = generate_token::execute( - Arc::clone(&tokens), + &GenerateWebhookTokenDeps { + webhook_token: Arc::clone(&tokens), + }, GenerateWebhookTokenCommand { user_id, provider: WatchEventSource::Jellyfin, diff --git a/crates/application/src/integrations/tests/revoke_token.rs b/crates/application/src/integrations/tests/revoke_token.rs index b8136c3..a89d701 100644 --- a/crates/application/src/integrations/tests/revoke_token.rs +++ b/crates/application/src/integrations/tests/revoke_token.rs @@ -5,6 +5,9 @@ use domain::ports::WebhookTokenRepository; use domain::testing::InMemoryWebhookTokenRepository; use uuid::Uuid; +use crate::integrations::deps::{ + GenerateWebhookTokenDeps, GetWebhookTokensDeps, RevokeWebhookTokenDeps, +}; use crate::integrations::{ commands::{GenerateWebhookTokenCommand, RevokeWebhookTokenCommand}, generate_token, get_tokens, @@ -19,7 +22,9 @@ async fn revokes_existing_token() { let user_id = Uuid::new_v4(); let generated = generate_token::execute( - Arc::clone(&tokens), + &GenerateWebhookTokenDeps { + webhook_token: Arc::clone(&tokens), + }, GenerateWebhookTokenCommand { user_id, provider: WatchEventSource::Jellyfin, @@ -32,15 +37,22 @@ async fn revokes_existing_token() { let token_id = generated.token.id().value(); revoke_token::execute( - Arc::clone(&tokens), + &RevokeWebhookTokenDeps { + webhook_token: Arc::clone(&tokens), + }, RevokeWebhookTokenCommand { user_id, token_id }, ) .await .unwrap(); - let remaining = get_tokens::execute(Arc::clone(&tokens), GetWebhookTokensQuery { user_id }) - .await - .unwrap(); + let remaining = get_tokens::execute( + &GetWebhookTokensDeps { + webhook_token: Arc::clone(&tokens), + }, + GetWebhookTokensQuery { user_id }, + ) + .await + .unwrap(); assert!(remaining.is_empty()); } diff --git a/crates/application/src/jobs/wrapup.rs b/crates/application/src/jobs/wrapup.rs index 4fddce5..2a2f404 100644 --- a/crates/application/src/jobs/wrapup.rs +++ b/crates/application/src/jobs/wrapup.rs @@ -58,13 +58,11 @@ impl PeriodicJob for WrapUpAutoGenerateJob { start_date: start, end_date: end, }; - if let Err(e) = crate::wrapup::generate::execute( - self.wrapup_repo.clone(), - self.event_publisher.clone(), - cmd, - ) - .await - { + 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 wrapup for user {} failed: {e}", user.user_id.value() @@ -81,13 +79,11 @@ impl PeriodicJob for WrapUpAutoGenerateJob { start_date: start, end_date: end, }; - if let Err(e) = crate::wrapup::generate::execute( - self.wrapup_repo.clone(), - self.event_publisher.clone(), - cmd, - ) - .await - { + 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 a58966b..79e4327 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,6 +1,8 @@ pub mod config; +pub mod deps; pub mod jobs; pub mod ports; +pub mod services; pub mod worker; pub mod auth; @@ -19,6 +21,13 @@ 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; pub use movies::SearchCleanupHandler; pub use movies::SearchReindexHandler; +pub use services::Services; diff --git a/crates/application/src/movies/deps.rs b/crates/application/src/movies/deps.rs index 15777cb..482693a 100644 --- a/crates/application/src/movies/deps.rs +++ b/crates/application/src/movies/deps.rs @@ -30,3 +30,11 @@ pub struct ReindexSearchDeps { pub person_command: Arc, pub person_query: Arc, } + +pub struct GetMovieProfileDeps { + pub movie_profile: Arc, +} + +pub struct GetMoviesDeps { + pub movie: Arc, +} diff --git a/crates/application/src/movies/get_movie_profile.rs b/crates/application/src/movies/get_movie_profile.rs index 705513b..03335e6 100644 --- a/crates/application/src/movies/get_movie_profile.rs +++ b/crates/application/src/movies/get_movie_profile.rs @@ -1,13 +1,12 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, models::{CastMember, CrewMember, ExternalPersonId, MovieProfile, PersonId}, - ports::MovieProfileRepository, value_objects::MovieId, }; use uuid::Uuid; +use crate::movies::deps::GetMovieProfileDeps; + pub struct GetMovieProfileQuery { pub movie_id: Uuid, } @@ -61,11 +60,11 @@ fn resolve_crew(member: &CrewMember) -> CrewMemberWithId { } pub async fn execute( - movie_profile: Arc, + deps: &GetMovieProfileDeps, query: GetMovieProfileQuery, ) -> Result, DomainError> { let movie_id = MovieId::from_uuid(query.movie_id); - let profile = movie_profile.get_by_movie_id(&movie_id).await?; + let profile = deps.movie_profile.get_by_movie_id(&movie_id).await?; Ok(profile.map(|p| { let cast = p.cast.iter().map(resolve_cast).collect(); diff --git a/crates/application/src/movies/get_movies.rs b/crates/application/src/movies/get_movies.rs index f3630e0..f4fd975 100644 --- a/crates/application/src/movies/get_movies.rs +++ b/crates/application/src/movies/get_movies.rs @@ -1,16 +1,14 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, models::collections::{PageParams, Paginated}, models::{MovieFilter, MovieSummary}, - ports::MovieQuery, }; +use crate::movies::deps::GetMoviesDeps; use crate::movies::queries::GetMoviesQuery; pub async fn execute( - movie: Arc, + deps: &GetMoviesDeps, query: GetMoviesQuery, ) -> Result, DomainError> { let page = PageParams::new(query.limit, query.offset)?; @@ -19,7 +17,7 @@ pub async fn execute( genre: query.genre, language: query.language, }; - movie.list_movies(&page, &filter).await + deps.movie.list_movies(&page, &filter).await } #[cfg(test)] diff --git a/crates/application/src/movies/tests/get_movie_profile.rs b/crates/application/src/movies/tests/get_movie_profile.rs index 23785ed..577ea3d 100644 --- a/crates/application/src/movies/tests/get_movie_profile.rs +++ b/crates/application/src/movies/tests/get_movie_profile.rs @@ -8,14 +8,16 @@ use domain::{ value_objects::MovieId, }; +use crate::movies::deps::GetMovieProfileDeps; use crate::movies::get_movie_profile::{self, GetMovieProfileQuery}; #[tokio::test] async fn returns_none_when_no_profile() { let movie_profile = InMemoryMovieProfileRepository::new(); + let deps = GetMovieProfileDeps { movie_profile }; let result = get_movie_profile::execute( - movie_profile, + &deps, GetMovieProfileQuery { movie_id: Uuid::new_v4(), }, @@ -64,8 +66,11 @@ async fn returns_profile_with_cast_and_crew() { }; profile_repo.upsert(&profile).await.unwrap(); + let deps = GetMovieProfileDeps { + movie_profile: profile_repo.clone(), + }; let result = get_movie_profile::execute( - profile_repo.clone(), + &deps, GetMovieProfileQuery { movie_id: movie_id.value(), }, diff --git a/crates/application/src/movies/tests/get_movies.rs b/crates/application/src/movies/tests/get_movies.rs index b61d573..9ac9c9e 100644 --- a/crates/application/src/movies/tests/get_movies.rs +++ b/crates/application/src/movies/tests/get_movies.rs @@ -1,13 +1,14 @@ use domain::testing::InMemoryMovieRepository; -use crate::movies::{get_movies, queries::GetMoviesQuery}; +use crate::movies::{deps::GetMoviesDeps, get_movies, queries::GetMoviesQuery}; #[tokio::test] async fn returns_empty_when_no_movies() { let movie = InMemoryMovieRepository::new(); + let deps = GetMoviesDeps { movie }; let result = get_movies::execute( - movie, + &deps, GetMoviesQuery { limit: None, offset: None, diff --git a/crates/application/src/search/deps.rs b/crates/application/src/search/deps.rs new file mode 100644 index 0000000..b150aec --- /dev/null +++ b/crates/application/src/search/deps.rs @@ -0,0 +1,7 @@ +use std::sync::Arc; + +use domain::ports::SearchPort; + +pub struct SearchDeps { + pub search_port: Arc, +} diff --git a/crates/application/src/search/execute.rs b/crates/application/src/search/execute.rs index d4b24e5..22d6fc6 100644 --- a/crates/application/src/search/execute.rs +++ b/crates/application/src/search/execute.rs @@ -1,15 +1,12 @@ use domain::{ errors::DomainError, models::{SearchQuery, SearchResults}, - ports::SearchPort, }; -use std::sync::Arc; -pub async fn execute( - search_port: Arc, - query: SearchQuery, -) -> Result { - search_port.search(&query).await +use crate::search::deps::SearchDeps; + +pub async fn execute(deps: &SearchDeps, query: SearchQuery) -> Result { + deps.search_port.search(&query).await } #[cfg(test)] diff --git a/crates/application/src/search/mod.rs b/crates/application/src/search/mod.rs index 2e8bddd..6017ec0 100644 --- a/crates/application/src/search/mod.rs +++ b/crates/application/src/search/mod.rs @@ -1 +1,2 @@ +pub mod deps; pub mod execute; diff --git a/crates/application/src/search/tests/execute.rs b/crates/application/src/search/tests/execute.rs index 913b90e..db219fa 100644 --- a/crates/application/src/search/tests/execute.rs +++ b/crates/application/src/search/tests/execute.rs @@ -1,13 +1,17 @@ use domain::models::SearchQuery; +use crate::search::deps::SearchDeps; use crate::search::execute; use crate::test_helpers::TestContextBuilder; #[tokio::test] async fn returns_empty_results() { let b = TestContextBuilder::new(); + let deps = SearchDeps { + search_port: b.search_port.clone(), + }; - let result = execute::execute(b.search_port.clone(), SearchQuery::default()) + let result = execute::execute(&deps, SearchQuery::default()) .await .unwrap(); diff --git a/crates/application/src/services.rs b/crates/application/src/services.rs new file mode 100644 index 0000000..b0ffd11 --- /dev/null +++ b/crates/application/src/services.rs @@ -0,0 +1,25 @@ +use std::sync::Arc; + +use domain::ports::{ + AuthService, DiaryExporter, DocumentParser, EventPublisher, MetadataClient, ObjectStorage, + PasswordHasher, PersonEnrichmentClient, PosterFetcherClient, +}; + +use crate::ports::ReviewLogger; + +/// Services the application layer needs, assembled by the composition root. +/// Adapter-typed ports do not belong here — the AP port inversion removed the +/// last of them; see ADR-0008. +#[derive(Clone)] +pub struct Services { + pub auth: Arc, + pub password_hasher: Arc, + pub metadata: Arc, + pub poster_fetcher: Arc, + pub object_storage: Arc, + pub event_publisher: Arc, + pub diary_exporter: Arc, + pub document_parser: Arc, + pub review_logger: Arc, + pub person_enrichment: Option>, +} diff --git a/crates/application/src/social/count_pending_followers.rs b/crates/application/src/social/count_pending_followers.rs new file mode 100644 index 0000000..c05cbde --- /dev/null +++ b/crates/application/src/social/count_pending_followers.rs @@ -0,0 +1,10 @@ +use domain::{errors::DomainError, value_objects::UserId}; +use uuid::Uuid; + +use super::deps::SocialQueryDeps; + +pub async fn execute(deps: &SocialQueryDeps, user_id: Uuid) -> Result { + deps.follow_graph + .count_pending_followers(&UserId::from_uuid(user_id)) + .await +} diff --git a/crates/application/src/social/deps.rs b/crates/application/src/social/deps.rs index fdda782..4e9dcf8 100644 --- a/crates/application/src/social/deps.rs +++ b/crates/application/src/social/deps.rs @@ -1,13 +1,13 @@ use std::sync::Arc; -use domain::ports::{EventPublisher, SocialCommand, SocialQuery}; +use domain::ports::{BlockQuery, EventPublisher, FollowGraphQuery, SocialCommand}; pub struct SocialCommandDeps { pub social_command: Arc, - pub social_query: Arc, pub event_publisher: Arc, } pub struct SocialQueryDeps { - pub social_query: Arc, + pub follow_graph: Arc, + pub block_query: Arc, } diff --git a/crates/application/src/social/execute.rs b/crates/application/src/social/execute.rs index d57005a..b5d97f3 100644 --- a/crates/application/src/social/execute.rs +++ b/crates/application/src/social/execute.rs @@ -1,14 +1,6 @@ -use domain::{ - errors::DomainError, - events::DomainEvent, - value_objects::{SocialActor, UserId}, -}; +use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; -use super::{ - commands::SocialCmd, - deps::{SocialCommandDeps, SocialQueryDeps}, - queries::SocialQry, -}; +use super::{commands::SocialCmd, deps::SocialCommandDeps}; pub async fn execute_command(deps: &SocialCommandDeps, cmd: SocialCmd) -> Result<(), DomainError> { let event = match cmd { @@ -69,24 +61,6 @@ pub async fn execute_command(deps: &SocialCommandDeps, cmd: SocialCmd) -> Result deps.event_publisher.publish(&event).await } -pub async fn execute_query( - deps: &SocialQueryDeps, - query: SocialQry, -) -> Result, DomainError> { - let user_id = match &query { - SocialQry::GetFollowing { user_id } - | SocialQry::GetFollowers { user_id } - | SocialQry::GetPending { user_id } - | SocialQry::GetBlocked { user_id } => UserId::from_uuid(*user_id), - }; - match query { - SocialQry::GetFollowing { .. } => deps.social_query.get_following(&user_id).await, - SocialQry::GetFollowers { .. } => deps.social_query.get_followers(&user_id).await, - SocialQry::GetPending { .. } => deps.social_query.get_pending_followers(&user_id).await, - SocialQry::GetBlocked { .. } => deps.social_query.get_blocked(&user_id).await, - } -} - #[cfg(test)] #[path = "tests/execute.rs"] mod tests; diff --git a/crates/application/src/social/get_blocked.rs b/crates/application/src/social/get_blocked.rs new file mode 100644 index 0000000..ebb3cff --- /dev/null +++ b/crates/application/src/social/get_blocked.rs @@ -0,0 +1,16 @@ +use domain::{ + errors::DomainError, + value_objects::{SocialActor, UserId}, +}; +use uuid::Uuid; + +use super::deps::SocialQueryDeps; + +pub async fn execute( + deps: &SocialQueryDeps, + user_id: Uuid, +) -> Result, DomainError> { + deps.block_query + .get_blocked(&UserId::from_uuid(user_id)) + .await +} diff --git a/crates/application/src/social/get_followers.rs b/crates/application/src/social/get_followers.rs new file mode 100644 index 0000000..94344b6 --- /dev/null +++ b/crates/application/src/social/get_followers.rs @@ -0,0 +1,16 @@ +use domain::{ + errors::DomainError, + value_objects::{SocialActor, UserId}, +}; +use uuid::Uuid; + +use super::deps::SocialQueryDeps; + +pub async fn execute( + deps: &SocialQueryDeps, + user_id: Uuid, +) -> Result, DomainError> { + deps.follow_graph + .get_followers(&UserId::from_uuid(user_id)) + .await +} diff --git a/crates/application/src/social/get_following.rs b/crates/application/src/social/get_following.rs new file mode 100644 index 0000000..1d5b8d6 --- /dev/null +++ b/crates/application/src/social/get_following.rs @@ -0,0 +1,16 @@ +use domain::{ + errors::DomainError, + value_objects::{SocialActor, UserId}, +}; +use uuid::Uuid; + +use super::deps::SocialQueryDeps; + +pub async fn execute( + deps: &SocialQueryDeps, + user_id: Uuid, +) -> Result, DomainError> { + deps.follow_graph + .get_following(&UserId::from_uuid(user_id)) + .await +} diff --git a/crates/application/src/social/get_pending_followers.rs b/crates/application/src/social/get_pending_followers.rs new file mode 100644 index 0000000..e594301 --- /dev/null +++ b/crates/application/src/social/get_pending_followers.rs @@ -0,0 +1,16 @@ +use domain::{ + errors::DomainError, + value_objects::{SocialActor, UserId}, +}; +use uuid::Uuid; + +use super::deps::SocialQueryDeps; + +pub async fn execute( + deps: &SocialQueryDeps, + user_id: Uuid, +) -> Result, DomainError> { + deps.follow_graph + .get_pending_followers(&UserId::from_uuid(user_id)) + .await +} diff --git a/crates/application/src/social/get_pending_following.rs b/crates/application/src/social/get_pending_following.rs new file mode 100644 index 0000000..e66139b --- /dev/null +++ b/crates/application/src/social/get_pending_following.rs @@ -0,0 +1,16 @@ +use domain::{ + errors::DomainError, + value_objects::{SocialActor, UserId}, +}; +use uuid::Uuid; + +use super::deps::SocialQueryDeps; + +pub async fn execute( + deps: &SocialQueryDeps, + user_id: Uuid, +) -> Result, DomainError> { + deps.follow_graph + .get_pending_following(&UserId::from_uuid(user_id)) + .await +} diff --git a/crates/application/src/social/get_relation.rs b/crates/application/src/social/get_relation.rs new file mode 100644 index 0000000..721030a --- /dev/null +++ b/crates/application/src/social/get_relation.rs @@ -0,0 +1,17 @@ +use domain::{ + errors::DomainError, + value_objects::{FollowRelation, SocialIdentity, UserId}, +}; +use uuid::Uuid; + +use super::deps::SocialQueryDeps; + +pub async fn execute( + deps: &SocialQueryDeps, + viewer_id: Uuid, + target: SocialIdentity, +) -> Result { + deps.follow_graph + .get_relation(&UserId::from_uuid(viewer_id), &target) + .await +} diff --git a/crates/application/src/social/local_service.rs b/crates/application/src/social/local_service.rs new file mode 100644 index 0000000..7205d0b --- /dev/null +++ b/crates/application/src/social/local_service.rs @@ -0,0 +1,276 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::{ + BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, FollowTargetResolver, + ResolvedFollow, SocialCommand, UserRepository, + }, + value_objects::{ + FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, + UserId, Username, + }, +}; + +/// The subset of `SocialCommand`/`FollowGraphQuery`/`BlockQuery` that a single +/// instance can serve with only its own database — no ActivityPub involved. +/// +/// This is the local half of `activitypub::CompositeSocialAdapter`, extracted +/// here so `application` needs no dependency on the `activitypub` crate to +/// offer it. It implements `domain::ports::LocalSocial` via that trait's +/// blanket impl over the five ports below. +pub struct LocalSocialService { + user_repo: Arc, + follow_command: Arc, + follow_query: Arc, + instance: InstanceIdentity, +} + +impl LocalSocialService { + pub fn new( + user_repo: Arc, + follow_command: Arc, + follow_query: Arc, + instance: InstanceIdentity, + ) -> Self { + Self { + user_repo, + follow_command, + follow_query, + instance, + } + } + + async fn resolve_target_identity( + &self, + target: &FollowTarget, + ) -> Result { + match target { + FollowTarget::Identity(id) => Ok(id.clone()), + FollowTarget::Handle(handle) => { + let host = handle.rsplit_once('@').map(|(_, h)| h).unwrap_or(""); + let local_host = self.instance.host(); + if host == local_host { + let username_str = handle + .trim_start_matches('@') + .split('@') + .next() + .unwrap_or(""); + if let Ok(username) = Username::new(username_str.to_string()) + && let Some(user) = self.user_repo.find_by_username(&username).await? + { + return Ok(SocialIdentity::Local(user.id().clone())); + } + } + Ok(SocialIdentity::Remote { + actor_url: handle.clone(), + }) + } + } + } +} + +fn remote_unsupported(actor: &str) -> DomainError { + DomainError::ValidationError(format!( + "cannot reach remote actor {actor}: this instance was built without the federation feature" + )) +} + +#[async_trait] +impl SocialCommand for LocalSocialService { + async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> { + let identity = self.resolve_target_identity(target).await?; + self.follow_resolved(follower, &identity).await + } + + async fn unfollow( + &self, + follower: &UserId, + target: &SocialIdentity, + ) -> 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(()) + } + SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)), + } + } + + async fn accept_follow( + &self, + owner: &UserId, + requester: &SocialIdentity, + ) -> 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(()) + } + SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)), + } + } + + async fn reject_follow( + &self, + owner: &UserId, + requester: &SocialIdentity, + ) -> 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(()) + } + SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)), + } + } + + async fn remove_follower( + &self, + owner: &UserId, + follower: &SocialIdentity, + ) -> 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(()) + } + SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)), + } + } + + async fn block(&self, _blocker: &UserId, _target: &SocialIdentity) -> Result<(), DomainError> { + Err(DomainError::ValidationError( + "blocking requires the federation feature".into(), + )) + } + + async fn unblock( + &self, + _blocker: &UserId, + _target: &SocialIdentity, + ) -> Result<(), DomainError> { + Err(DomainError::ValidationError( + "blocking requires the federation feature".into(), + )) + } +} + +#[async_trait] +impl FollowGraphQuery for LocalSocialService { + async fn get_following(&self, user: &UserId) -> Result, DomainError> { + self.follow_query.get_following(user.value()).await + } + + async fn get_followers(&self, user: &UserId) -> Result, DomainError> { + self.follow_query.get_followers(user.value()).await + } + + async fn get_pending_followers(&self, user: &UserId) -> Result, DomainError> { + self.follow_query.get_pending_followers(user.value()).await + } + + async fn get_pending_following(&self, user: &UserId) -> Result, DomainError> { + self.follow_query.get_pending_following(user.value()).await + } + + async fn count_following(&self, user: &UserId) -> Result { + self.follow_query.count_following(user.value()).await + } + + async fn count_followers(&self, user: &UserId) -> Result { + self.follow_query.count_followers(user.value()).await + } + + async fn count_pending_followers(&self, user: &UserId) -> Result { + self.follow_query + .count_pending_followers(user.value()) + .await + } + + async fn get_relation( + &self, + viewer: &UserId, + target: &SocialIdentity, + ) -> Result { + let actor_url = self.instance.actor_url_of(target); + self.follow_query + .get_relation(viewer.value(), &actor_url) + .await + } +} + +#[async_trait] +impl BlockQuery for LocalSocialService { + async fn get_blocked(&self, _user: &UserId) -> Result, DomainError> { + Ok(vec![]) + } +} + +#[async_trait] +impl FollowTargetResolver for LocalSocialService { + async fn resolve_target(&self, target: &FollowTarget) -> Result { + self.resolve_target_identity(target).await + } +} + +#[async_trait] +impl ResolvedFollow for LocalSocialService { + async fn follow_resolved( + &self, + follower: &UserId, + target: &SocialIdentity, + ) -> Result<(), DomainError> { + let SocialIdentity::Local(target_id) = target else { + let actor_url = self.instance.actor_url_of(target); + return Err(remote_unsupported(&actor_url)); + }; + if follower == target_id { + return Err(DomainError::ValidationError( + "Cannot follow yourself".into(), + )); + } + 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(()) + } +} + +#[cfg(test)] +#[path = "tests/local_service.rs"] +mod tests; diff --git a/crates/application/src/social/mod.rs b/crates/application/src/social/mod.rs index 7e812ba..47bdb5f 100644 --- a/crates/application/src/social/mod.rs +++ b/crates/application/src/social/mod.rs @@ -1,4 +1,11 @@ pub mod commands; +pub mod count_pending_followers; pub mod deps; pub mod execute; -pub mod queries; +pub mod get_blocked; +pub mod get_followers; +pub mod get_following; +pub mod get_pending_followers; +pub mod get_pending_following; +pub mod get_relation; +pub mod local_service; diff --git a/crates/application/src/social/queries.rs b/crates/application/src/social/queries.rs deleted file mode 100644 index df69dc1..0000000 --- a/crates/application/src/social/queries.rs +++ /dev/null @@ -1,8 +0,0 @@ -use uuid::Uuid; - -pub enum SocialQry { - GetFollowing { user_id: Uuid }, - GetFollowers { user_id: Uuid }, - GetPending { user_id: Uuid }, - GetBlocked { user_id: Uuid }, -} diff --git a/crates/application/src/social/tests/execute.rs b/crates/application/src/social/tests/execute.rs index e8862c8..724afa0 100644 --- a/crates/application/src/social/tests/execute.rs +++ b/crates/application/src/social/tests/execute.rs @@ -3,15 +3,14 @@ use std::sync::Arc; use domain::{ events::DomainEvent, testing::{InMemorySocialRepository, NoopEventPublisher}, - value_objects::{FollowTarget, SocialIdentity, UserId}, + value_objects::{FollowStatus, FollowTarget, SocialIdentity, UserId}, }; use uuid::Uuid; use crate::social::{ commands::SocialCmd, deps::{SocialCommandDeps, SocialQueryDeps}, - execute::{execute_command, execute_query}, - queries::SocialQry, + execute::execute_command, }; fn make_cmd_deps() -> ( @@ -23,7 +22,6 @@ fn make_cmd_deps() -> ( let events = NoopEventPublisher::new(); let deps = SocialCommandDeps { social_command: Arc::clone(&social) as _, - social_query: Arc::clone(&social) as _, event_publisher: Arc::clone(&events) as _, }; (social, events, deps) @@ -312,62 +310,49 @@ async fn unblock_emits_actor_unblocked_event() { // ── Get following ─────────────────────────────────────────────────────────── #[tokio::test] -async fn returns_accepted_follows() { - let social = InMemorySocialRepository::new(); - let events = NoopEventPublisher::new(); - let cmd_deps = SocialCommandDeps { - social_command: Arc::clone(&social) as _, - social_query: Arc::clone(&social) as _, - event_publisher: Arc::clone(&events) as _, - }; +async fn get_following_returns_accepted_targets() { + let (social, _events, cmd_deps) = make_cmd_deps(); let query_deps = SocialQueryDeps { - social_query: Arc::clone(&social) as _, + follow_graph: Arc::clone(&social) as _, + block_query: Arc::clone(&social) as _, }; - - let follower_id = Uuid::new_v4(); - let target_id = Uuid::new_v4(); + let follower = Uuid::new_v4(); + let target = UserId::from_uuid(Uuid::new_v4()); execute_command( &cmd_deps, SocialCmd::Follow { - follower_id, - target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(target_id))), + follower_id: follower, + target: FollowTarget::Identity(SocialIdentity::Local(target.clone())), }, ) .await .unwrap(); - // Pending follow should not appear - let following = execute_query( - &query_deps, - SocialQry::GetFollowing { - user_id: follower_id, - }, - ) - .await - .unwrap(); - assert!(following.is_empty()); + let before = crate::social::get_following::execute(&query_deps, follower) + .await + .unwrap(); + assert!( + before.is_empty(), + "a pending follow must not appear in get_following" + ); - // Accept, then it should appear execute_command( &cmd_deps, SocialCmd::AcceptFollow { - owner_id: target_id, - requester: SocialIdentity::Local(UserId::from_uuid(follower_id)), + owner_id: target.value(), + requester: SocialIdentity::Local(UserId::from_uuid(follower)), }, ) .await .unwrap(); - let following = execute_query( - &query_deps, - SocialQry::GetFollowing { - user_id: follower_id, - }, - ) - .await - .unwrap(); - assert_eq!(following.len(), 1); + let actors = crate::social::get_following::execute(&query_deps, follower) + .await + .unwrap(); + + assert_eq!(actors.len(), 1); + assert_eq!(actors[0].identity, SocialIdentity::Local(target)); } // ── Get followers ─────────────────────────────────────────────────────────── @@ -378,11 +363,11 @@ async fn returns_accepted_followers() { let events = NoopEventPublisher::new(); let cmd_deps = SocialCommandDeps { social_command: Arc::clone(&social) as _, - social_query: Arc::clone(&social) as _, event_publisher: Arc::clone(&events) as _, }; let query_deps = SocialQueryDeps { - social_query: Arc::clone(&social) as _, + follow_graph: Arc::clone(&social) as _, + block_query: Arc::clone(&social) as _, }; let follower_id = Uuid::new_v4(); @@ -408,7 +393,7 @@ async fn returns_accepted_followers() { .await .unwrap(); - let followers = execute_query(&query_deps, SocialQry::GetFollowers { user_id: owner_id }) + let followers = crate::social::get_followers::execute(&query_deps, owner_id) .await .unwrap(); assert_eq!(followers.len(), 1); @@ -422,11 +407,11 @@ async fn returns_only_pending_followers() { let events = NoopEventPublisher::new(); let cmd_deps = SocialCommandDeps { social_command: Arc::clone(&social) as _, - social_query: Arc::clone(&social) as _, event_publisher: Arc::clone(&events) as _, }; let query_deps = SocialQueryDeps { - social_query: Arc::clone(&social) as _, + follow_graph: Arc::clone(&social) as _, + block_query: Arc::clone(&social) as _, }; let follower_id = Uuid::new_v4(); @@ -442,8 +427,254 @@ async fn returns_only_pending_followers() { .await .unwrap(); - let pending = execute_query(&query_deps, SocialQry::GetPending { user_id: owner_id }) + let pending = crate::social::get_pending_followers::execute(&query_deps, owner_id) .await .unwrap(); assert_eq!(pending.len(), 1); } + +// ── get_relation ──────────────────────────────────────────────────────────── + +#[tokio::test] +async fn get_relation_reports_none_for_strangers() { + use domain::ports::FollowGraphQuery; + + let (social, _events, _deps) = make_cmd_deps(); + let rel = FollowGraphQuery::get_relation( + &*social, + &UserId::from_uuid(Uuid::new_v4()), + &SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())), + ) + .await + .unwrap(); + + assert_eq!(rel.following, None); + assert_eq!(rel.followed_by, None); +} + +#[tokio::test] +async fn get_relation_reports_pending_before_acceptance() { + use domain::ports::FollowGraphQuery; + + let (social, _events, deps) = make_cmd_deps(); + let a = Uuid::new_v4(); + let b_id = UserId::from_uuid(Uuid::new_v4()); + + execute_command( + &deps, + SocialCmd::Follow { + follower_id: a, + target: FollowTarget::Identity(SocialIdentity::Local(b_id.clone())), + }, + ) + .await + .unwrap(); + + let rel = FollowGraphQuery::get_relation( + &*social, + &UserId::from_uuid(a), + &SocialIdentity::Local(b_id), + ) + .await + .unwrap(); + + assert_eq!(rel.following, Some(FollowStatus::Pending)); + assert_eq!(rel.followed_by, None); +} + +#[tokio::test] +async fn get_relation_reports_accepted_in_both_directions_after_mutual_follow() { + use domain::ports::FollowGraphQuery; + + let (social, _events, deps) = make_cmd_deps(); + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + let a_id = UserId::from_uuid(a); + let b_id = UserId::from_uuid(b); + + for (from, to) in [(a, b_id.clone()), (b, a_id.clone())] { + execute_command( + &deps, + SocialCmd::Follow { + follower_id: from, + target: FollowTarget::Identity(SocialIdentity::Local(to.clone())), + }, + ) + .await + .unwrap(); + let owner = if from == a { b } else { a }; + execute_command( + &deps, + SocialCmd::AcceptFollow { + owner_id: owner, + requester: SocialIdentity::Local(UserId::from_uuid(from)), + }, + ) + .await + .unwrap(); + } + + let rel = FollowGraphQuery::get_relation(&*social, &a_id, &SocialIdentity::Local(b_id)) + .await + .unwrap(); + + assert_eq!(rel.following, Some(FollowStatus::Accepted)); + assert_eq!(rel.followed_by, Some(FollowStatus::Accepted)); +} + +// ── get_pending_following ─────────────────────────────────────────────────── + +#[tokio::test] +async fn unaccepted_follow_appears_in_pending_following_not_in_following() { + use domain::ports::FollowGraphQuery; + + let (social, _events, deps) = make_cmd_deps(); + let viewer = Uuid::new_v4(); + let target = UserId::from_uuid(Uuid::new_v4()); + + execute_command( + &deps, + SocialCmd::Follow { + follower_id: viewer, + target: FollowTarget::Identity(SocialIdentity::Local(target.clone())), + }, + ) + .await + .unwrap(); + + let pending = FollowGraphQuery::get_pending_following(&*social, &UserId::from_uuid(viewer)) + .await + .unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].identity, SocialIdentity::Local(target.clone())); + + let following = FollowGraphQuery::get_following(&*social, &UserId::from_uuid(viewer)) + .await + .unwrap(); + assert!( + following.is_empty(), + "an unaccepted follow must not appear in get_following" + ); +} + +// ── get_pending_following use case ────────────────────────────────────────── + +#[tokio::test] +async fn get_pending_following_returns_unaccepted_targets_and_get_following_does_not() { + let (social, _events, cmd_deps) = make_cmd_deps(); + let query_deps = SocialQueryDeps { + follow_graph: Arc::clone(&social) as _, + block_query: Arc::clone(&social) as _, + }; + let follower = Uuid::new_v4(); + let target = UserId::from_uuid(Uuid::new_v4()); + + execute_command( + &cmd_deps, + SocialCmd::Follow { + follower_id: follower, + target: FollowTarget::Identity(SocialIdentity::Local(target.clone())), + }, + ) + .await + .unwrap(); + // deliberately NOT accepted + + let pending = crate::social::get_pending_following::execute(&query_deps, follower) + .await + .unwrap(); + let accepted = crate::social::get_following::execute(&query_deps, follower) + .await + .unwrap(); + + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].identity, SocialIdentity::Local(target)); + assert!( + accepted.is_empty(), + "a pending follow must not leak into get_following — the privacy invariant" + ); +} + +// ── count_pending_followers use case ──────────────────────────────────────── + +#[tokio::test] +async fn count_pending_followers_drops_as_requests_are_accepted() { + let (social, _events, cmd_deps) = make_cmd_deps(); + let query_deps = SocialQueryDeps { + follow_graph: Arc::clone(&social) as _, + block_query: Arc::clone(&social) as _, + }; + let owner = UserId::from_uuid(Uuid::new_v4()); + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + + for follower in [a, b] { + execute_command( + &cmd_deps, + SocialCmd::Follow { + follower_id: follower, + target: FollowTarget::Identity(SocialIdentity::Local(owner.clone())), + }, + ) + .await + .unwrap(); + } + + let before = crate::social::count_pending_followers::execute(&query_deps, owner.value()) + .await + .unwrap(); + assert_eq!(before, 2); + + execute_command( + &cmd_deps, + SocialCmd::AcceptFollow { + owner_id: owner.value(), + requester: SocialIdentity::Local(UserId::from_uuid(a)), + }, + ) + .await + .unwrap(); + + let after = crate::social::count_pending_followers::execute(&query_deps, owner.value()) + .await + .unwrap(); + assert_eq!( + after, 1, + "accepting a request must remove it from the pending count" + ); +} + +// ── get_relation use case ─────────────────────────────────────────────────── + +#[tokio::test] +async fn get_relation_use_case_reports_direction_asymmetrically() { + let (social, _events, cmd_deps) = make_cmd_deps(); + let query_deps = SocialQueryDeps { + follow_graph: Arc::clone(&social) as _, + block_query: Arc::clone(&social) as _, + }; + let viewer = Uuid::new_v4(); + let target = UserId::from_uuid(Uuid::new_v4()); + + execute_command( + &cmd_deps, + SocialCmd::Follow { + follower_id: viewer, + target: FollowTarget::Identity(SocialIdentity::Local(target.clone())), + }, + ) + .await + .unwrap(); + + let rel = + crate::social::get_relation::execute(&query_deps, viewer, SocialIdentity::Local(target)) + .await + .unwrap(); + + assert_eq!( + rel.following, + Some(FollowStatus::Pending), + "viewer -> target" + ); + assert_eq!(rel.followed_by, None, "target has not followed back"); +} diff --git a/crates/application/src/social/tests/local_service.rs b/crates/application/src/social/tests/local_service.rs new file mode 100644 index 0000000..53615d8 --- /dev/null +++ b/crates/application/src/social/tests/local_service.rs @@ -0,0 +1,444 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{User, UserRole}, + ports::{ + BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, SocialCommand, UserRepository, + }, + testing::InMemoryUserRepository, + value_objects::{ + Email, FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, PasswordHash, + SocialActor, SocialIdentity, UserId, Username, + }, +}; +use uuid::Uuid; + +use crate::social::local_service::LocalSocialService; + +// ── Fakes ──────────────────────────────────────────────────────────────────── +// +// No fake exists anywhere for FollowCommand/FollowQuery. `InMemorySocialRepository` +// (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 +// sides of the follow edge in production. + +struct FollowFakeStore { + // (follower_id, target_actor_url, status) — written by add_follow/update_follow_status/remove_follow + follows: Mutex>, + // (local_user_id, follower_actor_url, status) — written by add_follower/update_follower_status/remove_follower_record + followers: Mutex>, +} + +impl FollowFakeStore { + fn new() -> Arc { + Arc::new(Self { + follows: Mutex::new(Vec::new()), + followers: Mutex::new(Vec::new()), + }) + } +} + +fn fake_actor(url: &str) -> SocialActor { + SocialActor { + identity: SocialIdentity::Remote { + actor_url: url.to_string(), + }, + handle: url.to_string(), + display_name: None, + avatar_url: None, + } +} + +#[async_trait] +impl FollowCommand for FollowFakeStore { + async fn add_follow( + &self, + follower_id: Uuid, + target_actor_url: &str, + status: FollowStatus, + ) -> 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; + } + 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; + } + 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)); + Ok(()) + } +} + +#[async_trait] +impl FollowQuery for FollowFakeStore { + async fn get_following(&self, user_id: Uuid) -> Result, DomainError> { + Ok(self + .follows + .lock() + .unwrap() + .iter() + .filter(|(f, _, _)| *f == user_id) + .map(|(_, t, _)| fake_actor(t)) + .collect()) + } + + async fn get_followers(&self, user_id: Uuid) -> Result, DomainError> { + Ok(self + .followers + .lock() + .unwrap() + .iter() + .filter(|(u, _, _)| *u == user_id) + .map(|(_, f, _)| fake_actor(f)) + .collect()) + } + + async fn get_pending_followers(&self, user_id: Uuid) -> Result, DomainError> { + Ok(self + .followers + .lock() + .unwrap() + .iter() + .filter(|(u, _, s)| *u == user_id && *s == FollowStatus::Pending) + .map(|(_, f, _)| fake_actor(f)) + .collect()) + } + + async fn get_pending_following(&self, user_id: Uuid) -> Result, DomainError> { + Ok(self + .follows + .lock() + .unwrap() + .iter() + .filter(|(f, _, s)| *f == user_id && *s == FollowStatus::Pending) + .map(|(_, t, _)| fake_actor(t)) + .collect()) + } + + async fn count_following(&self, user_id: Uuid) -> Result { + Ok(self + .follows + .lock() + .unwrap() + .iter() + .filter(|(f, _, _)| *f == user_id) + .count()) + } + + async fn count_followers(&self, user_id: Uuid) -> Result { + Ok(self + .followers + .lock() + .unwrap() + .iter() + .filter(|(u, _, _)| *u == user_id) + .count()) + } + + async fn count_pending_followers(&self, user_id: Uuid) -> Result { + Ok(self + .followers + .lock() + .unwrap() + .iter() + .filter(|(u, _, s)| *u == user_id && *s == FollowStatus::Pending) + .count()) + } + + async fn get_relation( + &self, + viewer_id: Uuid, + target_actor_url: &str, + ) -> Result { + let following = self + .follows + .lock() + .unwrap() + .iter() + .find(|(f, t, _)| *f == viewer_id && t == target_actor_url) + .map(|(_, _, s)| *s); + let followed_by = self + .followers + .lock() + .unwrap() + .iter() + .find(|(u, f, _)| *u == viewer_id && f == target_actor_url) + .map(|(_, _, s)| *s); + Ok(FollowRelation { + following, + followed_by, + }) + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +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, + ); + let id = user.id().clone(); + repo.save(&user).await.unwrap(); + id +} + +fn service( + user_repo: Arc, + follow_store: &Arc, + instance: InstanceIdentity, +) -> LocalSocialService { + LocalSocialService::new( + user_repo as Arc, + follow_store.clone() as Arc, + follow_store.clone() as Arc, + instance, + ) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn follow_local_user_writes_both_sides() { + let user_repo = InMemoryUserRepository::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 instance = instance(); + let svc = service(user_repo, &follow_store, instance.clone()); + + svc.follow( + &alice_id, + &FollowTarget::Handle("@bob@md.example".to_string()), + ) + .await + .unwrap(); + + let bob_url = instance.actor_url_for(&bob_id); + let alice_url = instance.actor_url_for(&alice_id); + + let followers = svc.get_followers(&bob_id).await.unwrap(); + assert!( + followers.iter().any(|a| a.handle == alice_url), + "add_follower should have recorded alice as bob's follower" + ); + + let following = svc.get_following(&alice_id).await.unwrap(); + assert!( + following.iter().any(|a| a.handle == bob_url), + "add_follow should have recorded bob in alice's following list" + ); +} + +#[tokio::test] +async fn follow_self_is_rejected() { + let user_repo = InMemoryUserRepository::new(); + let alice_id = register_user(&user_repo, "alice").await; + let follow_store = FollowFakeStore::new(); + let svc = service(user_repo, &follow_store, instance()); + + let result = svc + .follow( + &alice_id, + &FollowTarget::Handle("@alice@md.example".to_string()), + ) + .await; + + match result { + Err(DomainError::ValidationError(msg)) => assert_eq!( + msg, "Cannot follow yourself", + "the self-follow guard must not collapse into the generic remote-target error" + ), + other => panic!("expected ValidationError, got {other:?}"), + } + assert_eq!( + follow_store.follows.lock().unwrap().len(), + 0, + "self-follow must write nothing to the follows side" + ); + assert_eq!( + follow_store.followers.lock().unwrap().len(), + 0, + "self-follow must write nothing to the followers side" + ); +} + +#[tokio::test] +async fn follow_remote_target_errors_rather_than_silently_succeeding() { + let user_repo = InMemoryUserRepository::new(); + let alice_id = register_user(&user_repo, "alice").await; + let follow_store = FollowFakeStore::new(); + let svc = service(user_repo, &follow_store, instance()); + + let result = svc + .follow( + &alice_id, + &FollowTarget::Handle("@carol@other.example".to_string()), + ) + .await; + + match result { + Err(DomainError::ValidationError(msg)) => { + assert!( + msg.contains("@carol@other.example"), + "the remote-target error must name the specific handle that failed, \ + not a generic message: {msg}" + ); + assert!( + msg != "Cannot follow yourself", + "the remote-target error must not collapse into the self-follow message" + ); + } + other => panic!("expected ValidationError, got {other:?}"), + } + assert_eq!( + follow_store.follows.lock().unwrap().len(), + 0, + "a remote target must write nothing — this replaces NoopSocialCommand's silent Ok(())" + ); + assert_eq!(follow_store.followers.lock().unwrap().len(), 0); +} + +#[tokio::test] +async fn reject_follow_updates_follower_status_but_removes_the_follow_row() { + // Asymmetry with accept_follow, which updates the follow row's status instead + // of removing it: reject_follow marks the follower row Rejected but deletes + // 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 owner_id = register_user(&user_repo, "owner").await; + let requester_id = register_user(&user_repo, "requester").await; + let follow_store = FollowFakeStore::new(); + let instance = instance(); + let svc = service(user_repo, &follow_store, instance.clone()); + + svc.follow( + &requester_id, + &FollowTarget::Handle("@owner@md.example".to_string()), + ) + .await + .unwrap(); + + svc.reject_follow(&owner_id, &SocialIdentity::Local(requester_id.clone())) + .await + .unwrap(); + + let requester_url = instance.actor_url_for(&requester_id); + let owner_url = instance.actor_url_for(&owner_id); + + { + let followers = follow_store.followers.lock().unwrap(); + let follower_row = followers + .iter() + .find(|(u, f, _)| *u == owner_id.value() && f == &requester_url) + .expect("the follower row must still exist after rejection"); + assert_eq!( + follower_row.2, + FollowStatus::Rejected, + "reject_follow must mark the follower row Rejected" + ); + } + + let follows = follow_store.follows.lock().unwrap(); + assert!( + !follows + .iter() + .any(|(f, t, _)| *f == requester_id.value() && t == &owner_url), + "reject_follow must remove the follow row outright, not merely mark it Rejected — \ + this is the deliberate asymmetry with accept_follow" + ); +} + +#[tokio::test] +async fn block_errors_and_get_blocked_is_empty() { + let user_repo = InMemoryUserRepository::new(); + let alice_id = register_user(&user_repo, "alice").await; + let follow_store = FollowFakeStore::new(); + let svc = service(user_repo, &follow_store, instance()); + + let target = SocialIdentity::Remote { + actor_url: "https://other.example/users/carol".to_string(), + }; + + 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); +} diff --git a/crates/application/src/test_helpers.rs b/crates/application/src/test_helpers.rs index 324f30a..aa18b5e 100644 --- a/crates/application/src/test_helpers.rs +++ b/crates/application/src/test_helpers.rs @@ -74,7 +74,8 @@ pub struct TestContextBuilder { pub user_settings_repo: Arc, pub review_logger: Arc, pub social_command: Arc, - pub social_query_unified: Arc, + pub social_query_unified: Arc, + pub block_query: Arc, pub federation_admin: Arc, pub refresh_session_repo: Arc, pub config: AppConfig, @@ -127,6 +128,7 @@ impl TestContextBuilder { review_logger: Arc::new(NoopReviewLogger), social_command: Arc::clone(&social) as _, social_query_unified: Arc::clone(&social) as _, + block_query: Arc::clone(&social) as _, federation_admin: Arc::new(NoopFederationAdminQuery), refresh_session_repo: InMemoryRefreshSessionRepository::new(), config: AppConfig { diff --git a/crates/application/src/tests/services.rs b/crates/application/src/tests/services.rs new file mode 100644 index 0000000..b9b0934 --- /dev/null +++ b/crates/application/src/tests/services.rs @@ -0,0 +1,43 @@ +use crate::services::Services; +use crate::test_helpers::NoopReviewLogger; +use domain::testing::{ + FakeAuthService, FakeDocumentParser, FakeMetadataClient, FakePasswordHasher, FakePosterFetcher, + NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, +}; +use std::sync::Arc; + +/// Services must be constructible from domain ports alone — no adapter types. +/// If this file ever needs an adapter crate import, the boundary has regressed. +/// Asserts on Clone specifically: AppState is Clone, so a Services that clones +/// its Arcs by value instead of sharing them would silently duplicate state. +#[test] +fn services_clone_shares_the_same_port_instances() { + let logger: Arc = Arc::new(NoopReviewLogger); + let s = Services { + review_logger: Arc::clone(&logger), + person_enrichment: None, + 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), + }; + + let cloned = s.clone(); + + assert!( + Arc::ptr_eq(&s.review_logger, &cloned.review_logger), + "Clone must share port instances, not duplicate them" + ); + assert!( + Arc::ptr_eq(&s.review_logger, &logger), + "the field must hold the Arc it was given" + ); + assert!( + cloned.person_enrichment.is_none(), + "optional ports must survive Clone as None" + ); +} diff --git a/crates/application/src/users/authorize_admin.rs b/crates/application/src/users/authorize_admin.rs new file mode 100644 index 0000000..37a9b54 --- /dev/null +++ b/crates/application/src/users/authorize_admin.rs @@ -0,0 +1,28 @@ +use domain::errors::DomainError; +use domain::models::UserRole; +use domain::value_objects::UserId; +use uuid::Uuid; + +use crate::users::deps::AuthorizeAdminDeps; + +/// Whether `user_id` is an admin, for the two extractors (`AdminApiUser`, +/// `AdminUser`) that gate admin-only routes. +/// +/// Returns `Ok(None)` when the user row does not exist — a distinct success +/// value, not folded into `DomainError::NotFound`, specifically so callers can +/// tell "row missing" apart from "repository call failed" without depending on +/// which `DomainError` variant a lookup failure happens to produce. Each +/// extractor rejects those two cases differently (missing row is a 401/404, +/// a repository error is a 500) and that distinction must stay exact. +pub async fn execute( + deps: &AuthorizeAdminDeps, + user_id: Uuid, +) -> Result, DomainError> { + let id = UserId::from_uuid(user_id); + 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/deps.rs b/crates/application/src/users/deps.rs index 9a38090..b0dff52 100644 --- a/crates/application/src/users/deps.rs +++ b/crates/application/src/users/deps.rs @@ -1,14 +1,48 @@ use std::sync::Arc; use domain::ports::{ - DiaryQuery, EventPublisher, FederationAdminQuery, ObjectStorage, SocialQuery, StatsRepository, - UserRepository, + DiaryQuery, EventPublisher, FederatedProfileQuery, FederationAdminQuery, FollowGraphQuery, + ObjectStorage, StatsRepository, UserProfileFieldsRepository, UserRepository, + UserSettingsRepository, }; +use domain::value_objects::InstanceIdentity; -pub struct GetProfileDeps { +/// The local half of the former `GetProfileDeps` split (ADR-0004). `user` and +/// `instance` build the always-populated `ProfileIdentity`; `stats`/`diary`/ +/// `social_query` are unchanged from before the split. +pub struct GetLocalProfileDeps { pub stats: Arc, pub diary: Arc, - pub social_query: Arc, + pub social_query: Arc, + pub user: Arc, + pub instance: InstanceIdentity, +} + +/// The federated half of the former `GetProfileDeps` split. No `user`/`instance` — +/// the federated handler builds identity from the resolved remote actor and never +/// reads a local user row for this path. +pub struct GetFederatedProfileStatsDeps { + pub stats: Arc, + pub diary: Arc, + pub social_query: Arc, +} + +/// Backs page-chrome data (email/role badge, pending-follow badge). See the error +/// policy note on `get_page_viewer::execute`: this deliberately propagates errors +/// rather than degrading internally — the caller decides chrome degrades. +pub struct GetPageViewerDeps { + pub user: Arc, + pub follow_graph: Arc, +} + +pub struct ResolveUsernameDeps { + pub user: Arc, +} + +pub struct GetProfileSettingsDeps { + pub user: Arc, + pub profile_fields: Arc, + pub instance: InstanceIdentity, } pub struct GetUsersListDeps { @@ -26,3 +60,31 @@ pub struct DeleteAccountDeps { pub user: Arc, pub event_publisher: Arc, } + +pub struct GetCurrentProfileDeps { + pub user: Arc, +} + +pub struct UpdateProfileFieldsDeps { + pub profile_fields: Arc, + pub event_publisher: Arc, +} + +pub struct GetSettingsDeps { + pub user_settings: Arc, +} + +pub struct UpdateSettingsDeps { + pub user_settings: Arc, +} + +pub struct AuthorizeAdminDeps { + pub user: Arc, +} + +/// `Option` mirrors `Repositories::federated_profile` — genuine optional +/// configuration (federation on/off), not a container-shape workaround. Same +/// exemption as `Services::person_enrichment`. +pub struct GetFederatedProfileDeps { + pub federated_profile: Option>, +} diff --git a/crates/application/src/users/diary_filter.rs b/crates/application/src/users/diary_filter.rs new file mode 100644 index 0000000..326b9cf --- /dev/null +++ b/crates/application/src/users/diary_filter.rs @@ -0,0 +1,80 @@ +//! Shared, pure helpers for turning a `GetUserProfileQuery`'s sort/paging/search +//! fields into a `DiaryFilter`. Used by both halves of the former `get_profile` +//! split (`get_local_profile`, `get_federated_profile_stats`) so the query-shaping +//! logic has exactly one definition. + +use domain::{ + errors::DomainError, + models::{DiaryFilter, FeedSortBy, ReviewSortBy, collections::PageParams}, + value_objects::UserId, +}; + +pub(super) fn feed_sort_to_direction(sort_by: FeedSortBy) -> ReviewSortBy { + match sort_by { + FeedSortBy::Date => ReviewSortBy::Descending, + FeedSortBy::DateAsc => ReviewSortBy::Ascending, + FeedSortBy::Rating => ReviewSortBy::ByRatingDesc, + FeedSortBy::RatingAsc => ReviewSortBy::ByRatingAsc, + } +} + +pub(super) fn paged_user_filter( + user_id: UserId, + sort_by: ReviewSortBy, + limit: Option, + offset: Option, + search: Option, + include_remote: bool, +) -> Result { + let page = PageParams::new(limit, offset)?; + Ok(DiaryFilter { + sort_by, + page, + movie_id: None, + user_id: Some(user_id), + search, + include_remote, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn feed_sort_to_direction_all_variants() { + assert!(matches!( + feed_sort_to_direction(FeedSortBy::Date), + ReviewSortBy::Descending + )); + assert!(matches!( + feed_sort_to_direction(FeedSortBy::DateAsc), + ReviewSortBy::Ascending + )); + assert!(matches!( + feed_sort_to_direction(FeedSortBy::Rating), + ReviewSortBy::ByRatingDesc + )); + assert!(matches!( + feed_sort_to_direction(FeedSortBy::RatingAsc), + ReviewSortBy::ByRatingAsc + )); + } + + #[test] + fn paged_user_filter_builds_correctly() { + let uid = UserId::from_uuid(uuid::Uuid::new_v4()); + let filter = paged_user_filter( + uid.clone(), + ReviewSortBy::Descending, + Some(20), + Some(5), + Some("blade".into()), + false, + ) + .unwrap(); + + assert_eq!(filter.user_id.unwrap().value(), uid.value()); + assert_eq!(filter.search.as_deref(), Some("blade")); + } +} diff --git a/crates/application/src/users/get_current_profile.rs b/crates/application/src/users/get_current_profile.rs index 0015a83..3a0593e 100644 --- a/crates/application/src/users/get_current_profile.rs +++ b/crates/application/src/users/get_current_profile.rs @@ -1,7 +1,6 @@ -use std::sync::Arc; - -use domain::{errors::DomainError, ports::UserRepository}; +use domain::errors::DomainError; +use crate::users::deps::GetCurrentProfileDeps; use crate::users::queries::GetCurrentProfileQuery; pub struct ProfileFieldData { @@ -21,11 +20,12 @@ pub struct CurrentProfileData { } pub async fn execute( - user: Arc, + deps: &GetCurrentProfileDeps, query: GetCurrentProfileQuery, ) -> Result { let user_id = domain::value_objects::UserId::from_uuid(query.user_id); - let found = user + let found = deps + .user .find_by_id(&user_id) .await? .ok_or_else(|| DomainError::NotFound("User not found".into()))?; diff --git a/crates/application/src/users/get_federated_profile.rs b/crates/application/src/users/get_federated_profile.rs new file mode 100644 index 0000000..abb1d43 --- /dev/null +++ b/crates/application/src/users/get_federated_profile.rs @@ -0,0 +1,31 @@ +use domain::errors::DomainError; +use domain::models::FederatedProfile; +use uuid::Uuid; + +use crate::users::deps::GetFederatedProfileDeps; + +/// Looks up a federated (remote) profile by synthetic user id — the fallback +/// path `get_user_profile` (`handlers/users.rs`) takes when the local-profile +/// lookup comes back `NotFound`. +/// +/// Federation being disabled (`deps.federated_profile` is `None`) collapses to +/// `Ok(None)`, the same value a present-but-empty lookup would return. That's +/// deliberate: the handler's `if let Ok(Some(fed)) = ...` treats "port absent", +/// "call returned `Ok(None)`", and "call returned `Err`" identically — all three +/// fall through to a 404 — and this use case must not make any of those three +/// distinguishable to a caller that cannot act on the difference. An `Err` from +/// the port is propagated unchanged; the swallow stays at the handler, exactly +/// where it already was. +pub async fn execute( + deps: &GetFederatedProfileDeps, + user_id: Uuid, +) -> Result, DomainError> { + match &deps.federated_profile { + Some(fed_query) => fed_query.get_federated_profile(user_id).await, + 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 new file mode 100644 index 0000000..e37b7ff --- /dev/null +++ b/crates/application/src/users/get_federated_profile_stats.rs @@ -0,0 +1,89 @@ +//! The federated half of the former `get_profile` (ADR-0004's closed wart). +//! `build_federated_profile_response` (`handlers/users.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 +//! one for this path anyway, filled with empty-string sentinels for `username`/ +//! `handle`, and the handler never read it (see the closed wart paragraph in +//! `docs/adr/0004-instance-identity.md`). No type here can represent that +//! sentinel, because there is nothing to fill in. +//! +//! A federated actor also has no local pending-follow-request concept, so unlike +//! `get_local_profile` this never computes `pending_followers` — it would always +//! be empty here regardless of `is_own_profile`. + +use crate::users::{ + deps::GetFederatedProfileStatsDeps, + diary_filter::{feed_sort_to_direction, paged_user_filter}, + queries::{GetUserProfileQuery, ProfileView}, +}; +use domain::{ + errors::DomainError, + models::{DiaryEntry, UserStats, UserTrends, collections::Paginated}, + value_objects::UserId, +}; + +pub struct FederatedProfileStats { + pub stats: UserStats, + pub entries: Option>, + pub history: Option>, + pub trends: Option, + pub following_count: usize, + pub followers_count: usize, +} + +pub async fn execute( + deps: &GetFederatedProfileStatsDeps, + query: GetUserProfileQuery, +) -> Result { + let user_id = UserId::from_uuid(query.user_id); + let stats = deps.stats.get_user_stats(&user_id).await?; + + let following_count = deps + .social_query + .count_following(&user_id) + .await + .unwrap_or(0); + let followers_count = deps + .social_query + .count_followers(&user_id) + .await + .unwrap_or(0); + + let base = |entries, history, trends| FederatedProfileStats { + stats, + entries, + history, + trends, + following_count, + followers_count, + }; + + match query.view { + ProfileView::History => { + let all_entries = deps.diary.get_user_history(&user_id).await?; + Ok(base(None, Some(all_entries), None)) + } + ProfileView::Trends => { + let trends = deps.stats.get_user_trends(&user_id).await?; + Ok(base(None, None, Some(trends))) + } + ProfileView::Ratings | ProfileView::Recent => { + let sort_direction = feed_sort_to_direction(query.sort_by); + let filter = paged_user_filter( + user_id, + sort_direction, + query.limit, + query.offset, + query.search.clone(), + query.include_remote, + )?; + let entries = deps.diary.query_diary(&filter).await?; + Ok(base(Some(entries), None, None)) + } + } +} + +#[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 new file mode 100644 index 0000000..c8945fc --- /dev/null +++ b/crates/application/src/users/get_local_profile.rs @@ -0,0 +1,154 @@ +//! The local half of the former `get_profile` (ADR-0004's closed wart). Unlike +//! the old unified function, a user id with no local row is `Err(NotFound)` here +//! — there is no federated fallback inside this function, and no empty-string +//! sentinel for `identity`, because `LocalProfileData::identity` is always fully +//! populated by construction. + +use crate::users::{ + deps::GetLocalProfileDeps, + diary_filter::{feed_sort_to_direction, paged_user_filter}, + queries::{GetUserProfileQuery, ProfileView}, +}; +use domain::{ + errors::DomainError, + models::{DiaryEntry, UserStats, UserTrends, collections::Paginated}, + value_objects::{InstanceIdentity, UserId}, +}; + +pub struct PendingFollowerView { + pub url: String, + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, +} + +pub struct ProfileIdentity { + pub username: String, + pub display_name: Option, + pub bio: Option, + pub handle: String, + pub actor_url: String, + pub avatar_url: Option, + pub banner_url: Option, + /// Only the HTML profile handler reads this (it derives its "display name" + /// from the email's local part, not from `username` — see + /// `handlers/users.rs::get_user_profile_html`). Sourced from the same local + /// `User` row as every other identity field. + pub email: String, +} + +pub struct LocalProfileData { + pub stats: UserStats, + pub entries: Option>, + pub history: Option>, + pub trends: Option, + pub following_count: usize, + pub followers_count: usize, + pub pending_followers: Vec, + pub identity: ProfileIdentity, +} + +pub async fn execute( + deps: &GetLocalProfileDeps, + query: GetUserProfileQuery, +) -> Result { + let user_id = UserId::from_uuid(query.user_id); + let user = deps + .user + .find_by_id(&user_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("user {}", query.user_id)))?; + + let stats = deps.stats.get_user_stats(&user_id).await?; + + let (following_count, followers_count, pending_followers) = + load_social_counts(deps, &user_id, query.is_own_profile, &deps.instance).await; + + let identity = ProfileIdentity { + username: user.username().value().to_string(), + display_name: user.display_name().map(str::to_string), + bio: user.bio().map(str::to_string), + handle: deps.instance.handle_for(user.username().value()), + actor_url: deps.instance.actor_url_for(&user_id), + avatar_url: user.avatar_path().map(|p| deps.instance.image_url_for(p)), + banner_url: user.banner_path().map(|p| deps.instance.image_url_for(p)), + email: user.email().value().to_string(), + }; + + let base = |entries, history, trends| LocalProfileData { + stats, + entries, + history, + trends, + following_count, + followers_count, + pending_followers, + identity, + }; + + match query.view { + ProfileView::History => { + let all_entries = deps.diary.get_user_history(&user_id).await?; + Ok(base(None, Some(all_entries), None)) + } + ProfileView::Trends => { + let trends = deps.stats.get_user_trends(&user_id).await?; + Ok(base(None, None, Some(trends))) + } + ProfileView::Ratings | ProfileView::Recent => { + let sort_direction = feed_sort_to_direction(query.sort_by); + let filter = paged_user_filter( + user_id, + sort_direction, + query.limit, + query.offset, + query.search.clone(), + query.include_remote, + )?; + let entries = deps.diary.query_diary(&filter).await?; + Ok(base(Some(entries), None, None)) + } + } +} + +async fn load_social_counts( + deps: &GetLocalProfileDeps, + user_id: &UserId, + is_own_profile: bool, + instance: &InstanceIdentity, +) -> (usize, usize, Vec) { + let following = deps + .social_query + .count_following(user_id) + .await + .unwrap_or(0); + let followers = deps + .social_query + .count_followers(user_id) + .await + .unwrap_or(0); + if !is_own_profile { + return (following, followers, vec![]); + } + let pending = deps + .social_query + .get_pending_followers(user_id) + .await + .unwrap_or_default() + .into_iter() + .map(|p| { + let url = instance.actor_url_of(&p.identity); + PendingFollowerView { + url, + handle: p.handle, + display_name: p.display_name, + avatar_url: p.avatar_url, + } + }) + .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 new file mode 100644 index 0000000..f2e7d24 --- /dev/null +++ b/crates/application/src/users/get_page_viewer.rs @@ -0,0 +1,44 @@ +//! 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 +//! 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 +//! (`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. + +use domain::{errors::DomainError, models::UserRole, value_objects::UserId}; +use uuid::Uuid; + +use crate::users::deps::GetPageViewerDeps; + +pub struct PageViewer { + pub email: Option, + pub is_admin: bool, + pub pending_follow_count: usize, +} + +pub async fn execute(deps: &GetPageViewerDeps, user_id: Uuid) -> Result { + let uid = UserId::from_uuid(user_id); + let user = deps + .user + .find_by_id(&uid) + .await? + .ok_or_else(|| DomainError::NotFound(format!("user {}", user_id)))?; + + // Reuse `FollowGraphQuery::count_pending_followers` directly — the same port + // method `social::count_pending_followers::execute` wraps — rather than + // re-deriving the count from raw follow rows a second time. + let pending_follow_count = deps.follow_graph.count_pending_followers(&uid).await?; + + Ok(PageViewer { + email: Some(user.email().value().to_string()), + is_admin: matches!(user.role(), UserRole::Admin), + pending_follow_count, + }) +} + +#[cfg(test)] +#[path = "tests/get_page_viewer.rs"] +mod tests; diff --git a/crates/application/src/users/get_profile.rs b/crates/application/src/users/get_profile.rs deleted file mode 100644 index 5c6a078..0000000 --- a/crates/application/src/users/get_profile.rs +++ /dev/null @@ -1,192 +0,0 @@ -use crate::users::{ - deps::GetProfileDeps, - queries::{GetUserProfileQuery, ProfileView}, -}; -use domain::{ - errors::DomainError, - models::FeedSortBy, - models::{ - DiaryEntry, DiaryFilter, ReviewSortBy, UserStats, UserTrends, - collections::{PageParams, Paginated}, - }, - value_objects::UserId, -}; - -pub struct PendingFollowerView { - pub url: String, - pub handle: String, - pub display_name: Option, - pub avatar_url: Option, -} - -pub struct UserProfileData { - pub stats: UserStats, - pub entries: Option>, - pub history: Option>, - pub trends: Option, - pub following_count: usize, - pub followers_count: usize, - pub pending_followers: Vec, -} - -pub async fn execute( - deps: &GetProfileDeps, - query: GetUserProfileQuery, -) -> Result { - let user_id = UserId::from_uuid(query.user_id); - let stats = deps.stats.get_user_stats(&user_id).await?; - - let (following_count, followers_count, pending_followers) = - load_social_counts(deps, &user_id, query.is_own_profile).await; - - let base = |entries, history, trends| UserProfileData { - stats, - entries, - history, - trends, - following_count, - followers_count, - pending_followers, - }; - - match query.view { - ProfileView::History => { - let all_entries = deps.diary.get_user_history(&user_id).await?; - Ok(base(None, Some(all_entries), None)) - } - ProfileView::Trends => { - let trends = deps.stats.get_user_trends(&user_id).await?; - Ok(base(None, None, Some(trends))) - } - ProfileView::Ratings | ProfileView::Recent => { - let sort_direction = feed_sort_to_direction(query.sort_by); - let filter = paged_user_filter( - user_id, - sort_direction, - query.limit, - query.offset, - query.search.clone(), - query.include_remote, - )?; - let entries = deps.diary.query_diary(&filter).await?; - Ok(base(Some(entries), None, None)) - } - } -} - -async fn load_social_counts( - deps: &GetProfileDeps, - user_id: &UserId, - is_own_profile: bool, -) -> (usize, usize, Vec) { - let following = deps - .social_query - .count_following(user_id) - .await - .unwrap_or(0); - let followers = deps - .social_query - .count_followers(user_id) - .await - .unwrap_or(0); - if !is_own_profile { - return (following, followers, vec![]); - } - let pending = deps - .social_query - .get_pending_followers(user_id) - .await - .unwrap_or_default() - .into_iter() - .map(|p| { - let url = match &p.identity { - domain::value_objects::SocialIdentity::Remote { actor_url } => actor_url.clone(), - domain::value_objects::SocialIdentity::Local(uid) => { - format!("local:{}", uid.value()) - } - }; - PendingFollowerView { - url, - handle: p.handle, - display_name: p.display_name, - avatar_url: p.avatar_url, - } - }) - .collect(); - (following, followers, pending) -} - -fn feed_sort_to_direction(sort_by: FeedSortBy) -> ReviewSortBy { - match sort_by { - FeedSortBy::Date => ReviewSortBy::Descending, - FeedSortBy::DateAsc => ReviewSortBy::Ascending, - FeedSortBy::Rating => ReviewSortBy::ByRatingDesc, - FeedSortBy::RatingAsc => ReviewSortBy::ByRatingAsc, - } -} - -fn paged_user_filter( - user_id: UserId, - sort_by: ReviewSortBy, - limit: Option, - offset: Option, - search: Option, - include_remote: bool, -) -> Result { - let page = PageParams::new(limit, offset)?; - Ok(DiaryFilter { - sort_by, - page, - movie_id: None, - user_id: Some(user_id), - search, - include_remote, - }) -} - -#[cfg(test)] -#[path = "tests/get_profile.rs"] -mod tests; - -#[cfg(test)] -mod helper_tests { - use super::*; - - #[test] - fn feed_sort_to_direction_all_variants() { - use domain::models::FeedSortBy; - assert!(matches!( - feed_sort_to_direction(FeedSortBy::Date), - ReviewSortBy::Descending - )); - assert!(matches!( - feed_sort_to_direction(FeedSortBy::DateAsc), - ReviewSortBy::Ascending - )); - assert!(matches!( - feed_sort_to_direction(FeedSortBy::Rating), - ReviewSortBy::ByRatingDesc - )); - assert!(matches!( - feed_sort_to_direction(FeedSortBy::RatingAsc), - ReviewSortBy::ByRatingAsc - )); - } - - #[test] - fn paged_user_filter_builds_correctly() { - let uid = UserId::from_uuid(uuid::Uuid::new_v4()); - let filter = paged_user_filter( - uid.clone(), - ReviewSortBy::Descending, - Some(20), - Some(5), - Some("blade".into()), - false, - ) - .unwrap(); - - assert_eq!(filter.user_id.unwrap().value(), uid.value()); - assert_eq!(filter.search.as_deref(), Some("blade")); - } -} diff --git a/crates/application/src/users/get_profile_settings.rs b/crates/application/src/users/get_profile_settings.rs new file mode 100644 index 0000000..4be896b --- /dev/null +++ b/crates/application/src/users/get_profile_settings.rs @@ -0,0 +1,64 @@ +//! Absorbs BOTH of `handlers/users.rs::get_profile_settings`'s repository calls: +//! the user lookup (`repos.user.find_by_id`) and the profile_fields join +//! (`repos.profile_fields.get_fields`) — plus the avatar/banner URL derivation via +//! `InstanceIdentity`, following the same pattern `get_local_profile` uses. +//! +//! Error policy: profile fields are primary content for this page (it's the page +//! whose entire job is editing them), not page chrome, so a failed join +//! propagates as `Err` rather than silently degrading to an empty list. + +use domain::{errors::DomainError, value_objects::UserId}; +use uuid::Uuid; + +use crate::users::deps::GetProfileSettingsDeps; + +pub struct ProfileSettings { + pub username: String, + pub display_name: Option, + pub bio: Option, + pub avatar_url: Option, + pub banner_url: Option, + /// Not in the brief's interface sketch, but the deleted handler code read + /// `user.also_known_as()` and rendered it in `ProfileSettingsTemplate` — this + /// use case absorbs that read too, or the settings page would silently stop + /// showing it. + pub also_known_as: Option, + pub fields: Vec<(String, String)>, +} + +pub async fn execute( + deps: &GetProfileSettingsDeps, + user_id: Uuid, +) -> Result { + let uid = UserId::from_uuid(user_id); + let user = deps + .user + .find_by_id(&uid) + .await? + .ok_or_else(|| DomainError::NotFound(format!("user {}", user_id)))?; + + let avatar_url = user.avatar_path().map(|p| deps.instance.image_url_for(p)); + let banner_url = user.banner_path().map(|p| deps.instance.image_url_for(p)); + + let fields = deps + .profile_fields + .get_fields(&uid) + .await? + .into_iter() + .map(|f| (f.name, f.value)) + .collect(); + + Ok(ProfileSettings { + username: user.username().value().to_string(), + display_name: user.display_name().map(str::to_string), + bio: user.bio().map(str::to_string), + avatar_url, + banner_url, + also_known_as: user.also_known_as().map(str::to_string), + fields, + }) +} + +#[cfg(test)] +#[path = "tests/get_profile_settings.rs"] +mod tests; diff --git a/crates/application/src/users/get_settings.rs b/crates/application/src/users/get_settings.rs index 2320065..b699e48 100644 --- a/crates/application/src/users/get_settings.rs +++ b/crates/application/src/users/get_settings.rs @@ -1,15 +1,13 @@ -use std::sync::Arc; +use domain::{errors::DomainError, models::UserSettings, value_objects::UserId}; -use domain::{ - errors::DomainError, models::UserSettings, ports::UserSettingsRepository, value_objects::UserId, -}; +use crate::users::deps::GetSettingsDeps; pub async fn execute( - user_settings: Arc, + deps: &GetSettingsDeps, user_id: uuid::Uuid, ) -> Result { let uid = UserId::from_uuid(user_id); - user_settings.get(&uid).await + deps.user_settings.get(&uid).await } #[cfg(test)] diff --git a/crates/application/src/users/mod.rs b/crates/application/src/users/mod.rs index e420628..b60d9f7 100644 --- a/crates/application/src/users/mod.rs +++ b/crates/application/src/users/mod.rs @@ -1,11 +1,18 @@ +pub mod authorize_admin; pub mod commands; pub mod delete_account; pub mod deps; +mod diary_filter; pub mod get_current_profile; -pub mod get_profile; +pub mod get_federated_profile; +pub mod get_federated_profile_stats; +pub mod get_local_profile; +pub mod get_page_viewer; +pub mod get_profile_settings; pub mod get_settings; pub mod get_users; pub mod queries; +pub mod resolve_username_to_id; pub mod update_profile; pub mod update_profile_fields; pub mod update_settings; diff --git a/crates/application/src/users/resolve_username_to_id.rs b/crates/application/src/users/resolve_username_to_id.rs new file mode 100644 index 0000000..223c21d --- /dev/null +++ b/crates/application/src/users/resolve_username_to_id.rs @@ -0,0 +1,23 @@ +//! Absorbs `handlers/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. + +use domain::{ + errors::DomainError, + value_objects::{UserId, Username}, +}; + +use crate::users::deps::ResolveUsernameDeps; + +pub async fn execute( + deps: &ResolveUsernameDeps, + username: &Username, +) -> Result, DomainError> { + 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/authorize_admin.rs b/crates/application/src/users/tests/authorize_admin.rs new file mode 100644 index 0000000..7e1810b --- /dev/null +++ b/crates/application/src/users/tests/authorize_admin.rs @@ -0,0 +1,92 @@ +use std::sync::Arc; + +use domain::models::UserRole; +use domain::testing::InMemoryUserRepository; + +use crate::{ + auth::{commands::RegisterCommand, deps::RegisterDeps, register}, + test_helpers::TestContextBuilder, + users::{authorize_admin, deps::AuthorizeAdminDeps}, +}; + +async fn register_user( + b: &TestContextBuilder, + email: &str, + username: &str, + role: UserRole, +) -> domain::models::User { + let reg_deps = RegisterDeps { + user: b.user_repo.clone(), + password_hasher: b.password_hasher.clone(), + config: b.config.clone(), + }; + register::execute( + ®_deps, + RegisterCommand { + email: email.into(), + username: username.into(), + password: "password123".into(), + role, + }, + ) + .await + .unwrap(); + + b.user_repo + .find_by_email(&domain::value_objects::Email::new(email.into()).unwrap()) + .await + .unwrap() + .unwrap() +} + +#[tokio::test] +async fn authorize_admin_is_true_for_an_admin_user() { + let users = InMemoryUserRepository::new(); + let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); + let user = register_user(&b, "admin@example.com", "admin_user", UserRole::Admin).await; + + let deps = AuthorizeAdminDeps { + user: b.user_repo.clone(), + }; + let result = authorize_admin::execute(&deps, user.id().value()) + .await + .unwrap(); + + assert_eq!(result, Some(true)); +} + +#[tokio::test] +async fn authorize_admin_is_false_for_a_regular_user() { + let users = InMemoryUserRepository::new(); + let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); + let user = register_user( + &b, + "standard@example.com", + "standard_user", + UserRole::Standard, + ) + .await; + + let deps = AuthorizeAdminDeps { + user: b.user_repo.clone(), + }; + let result = authorize_admin::execute(&deps, user.id().value()) + .await + .unwrap(); + + assert_eq!(result, Some(false)); +} + +#[tokio::test] +async fn authorize_admin_is_not_found_for_unknown_user() { + let b = TestContextBuilder::new(); + + let deps = AuthorizeAdminDeps { + user: b.user_repo.clone(), + }; + let result = authorize_admin::execute(&deps, uuid::Uuid::new_v4()) + .await + .unwrap(); + + assert_eq!(result, None); +} diff --git a/crates/application/src/users/tests/get_current_profile.rs b/crates/application/src/users/tests/get_current_profile.rs index d216bf5..7aa383c 100644 --- a/crates/application/src/users/tests/get_current_profile.rs +++ b/crates/application/src/users/tests/get_current_profile.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use crate::{ auth::{commands::RegisterCommand, deps::RegisterDeps, register}, test_helpers::TestContextBuilder, - users::{get_current_profile, queries::GetCurrentProfileQuery}, + users::{deps::GetCurrentProfileDeps, get_current_profile, queries::GetCurrentProfileQuery}, }; #[tokio::test] @@ -41,8 +41,9 @@ async fn returns_profile_for_existing_user() { .unwrap() .unwrap(); + let deps = GetCurrentProfileDeps { user: user_repo }; let profile = get_current_profile::execute( - user_repo, + &deps, GetCurrentProfileQuery { user_id: user.id().value(), }, @@ -58,8 +59,9 @@ async fn fails_for_nonexistent_user() { let b = TestContextBuilder::new(); let user_repo = b.user_repo.clone(); + let deps = GetCurrentProfileDeps { user: user_repo }; let result = get_current_profile::execute( - user_repo, + &deps, GetCurrentProfileQuery { user_id: Uuid::new_v4(), }, @@ -97,8 +99,9 @@ async fn returns_profile_with_avatar_banner_and_fields() { let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _); let user_repo = b.user_repo.clone(); + let deps = GetCurrentProfileDeps { user: user_repo }; let profile = get_current_profile::execute( - user_repo, + &deps, GetCurrentProfileQuery { user_id: uid.value(), }, diff --git a/crates/application/src/users/tests/get_federated_profile.rs b/crates/application/src/users/tests/get_federated_profile.rs new file mode 100644 index 0000000..d754cf8 --- /dev/null +++ b/crates/application/src/users/tests/get_federated_profile.rs @@ -0,0 +1,115 @@ +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; + +fn a_profile() -> FederatedProfile { + FederatedProfile { + actor_url: "https://remote.example/users/alice".into(), + handle: "alice@remote.example".into(), + display_name: Some("Alice".into()), + bio: None, + avatar_url: None, + banner_url: None, + } +} + +struct FoundFederatedProfileQuery; + +#[async_trait] +impl FederatedProfileQuery for FoundFederatedProfileQuery { + async fn get_federated_profile( + &self, + _synthetic_user_id: uuid::Uuid, + ) -> Result, DomainError> { + Ok(Some(a_profile())) + } +} + +struct EmptyFederatedProfileQuery; + +#[async_trait] +impl FederatedProfileQuery for EmptyFederatedProfileQuery { + async fn get_federated_profile( + &self, + _synthetic_user_id: uuid::Uuid, + ) -> Result, DomainError> { + Ok(None) + } +} + +struct FailingFederatedProfileQuery; + +#[async_trait] +impl FederatedProfileQuery for FailingFederatedProfileQuery { + async fn get_federated_profile( + &self, + _synthetic_user_id: uuid::Uuid, + ) -> Result, DomainError> { + Err(DomainError::InfrastructureError( + "remote lookup failed".into(), + )) + } +} + +#[tokio::test] +async fn returns_the_profile_when_the_port_finds_one() { + let deps = GetFederatedProfileDeps { + federated_profile: Some(Arc::new(FoundFederatedProfileQuery)), + }; + + let result = get_federated_profile::execute(&deps, uuid::Uuid::new_v4()) + .await + .unwrap(); + + assert_eq!( + result.map(|p| p.handle), + Some("alice@remote.example".to_string()) + ); +} + +#[tokio::test] +async fn returns_ok_none_when_the_port_finds_nothing() { + let deps = GetFederatedProfileDeps { + federated_profile: Some(Arc::new(EmptyFederatedProfileQuery)), + }; + + let result = get_federated_profile::execute(&deps, uuid::Uuid::new_v4()) + .await + .unwrap(); + + assert!(result.is_none()); +} + +#[tokio::test] +async fn returns_ok_none_when_federation_is_disabled() { + let deps = GetFederatedProfileDeps { + federated_profile: None, + }; + + let result = get_federated_profile::execute(&deps, uuid::Uuid::new_v4()) + .await + .unwrap(); + + assert!( + result.is_none(), + "an absent port must collapse to Ok(None), the same as an empty lookup — \ + the handler cannot and must not tell the two apart" + ); +} + +#[tokio::test] +async fn propagates_an_error_from_the_port_unchanged() { + let deps = GetFederatedProfileDeps { + federated_profile: Some(Arc::new(FailingFederatedProfileQuery)), + }; + + let err = get_federated_profile::execute(&deps, uuid::Uuid::new_v4()) + .await + .expect_err("a repository error must not be swallowed inside the use case"); + + assert!(matches!(err, DomainError::InfrastructureError(_))); +} diff --git a/crates/application/src/users/tests/get_federated_profile_stats.rs b/crates/application/src/users/tests/get_federated_profile_stats.rs new file mode 100644 index 0000000..54d4db9 --- /dev/null +++ b/crates/application/src/users/tests/get_federated_profile_stats.rs @@ -0,0 +1,45 @@ +use crate::test_helpers::TestContextBuilder; +use crate::users::deps::GetFederatedProfileStatsDeps; +use crate::users::get_federated_profile_stats; +use crate::users::queries::{GetUserProfileQuery, ProfileView}; + +/// Mirrors exactly how `build_federated_profile_response` +/// (`crates/presentation/src/handlers/users.rs`) calls `execute`: a `user_id` that +/// has no row in the local `users` table (a federated/remote profile uses a +/// synthetic id — see `FederatedProfileQuery`), `is_own_profile: false`, +/// `include_remote: true`. Before ADR-0004's split this was the tolerant branch of +/// the unified `get_profile`; now it's simply this function's only behavior — it +/// never looks at the local `users` table at all, so there is nothing to be +/// tolerant about. +#[tokio::test] +async fn succeeds_for_a_user_id_with_no_local_row() { + let b = TestContextBuilder::new(); + let deps = GetFederatedProfileStatsDeps { + stats: b.stats_repo.clone(), + diary: b.diary_repo.clone(), + social_query: b.social_query_unified.clone(), + }; + + let synthetic_user_id = uuid::Uuid::new_v4(); + + let result = get_federated_profile_stats::execute( + &deps, + GetUserProfileQuery { + user_id: synthetic_user_id, + view: ProfileView::Recent, + limit: None, + offset: None, + sort_by: domain::models::FeedSortBy::Date, + search: None, + is_own_profile: false, + include_remote: true, + }, + ) + .await; + + assert!( + result.is_ok(), + "execute must not hard-fail for a user_id absent from the local `users` table \ + (the federated-profile call shape) — got Err" + ); +} diff --git a/crates/application/src/users/tests/get_profile.rs b/crates/application/src/users/tests/get_local_profile.rs similarity index 52% rename from crates/application/src/users/tests/get_profile.rs rename to crates/application/src/users/tests/get_local_profile.rs index 52be317..fd1a41d 100644 --- a/crates/application/src/users/tests/get_profile.rs +++ b/crates/application/src/users/tests/get_local_profile.rs @@ -1,3 +1,4 @@ +use domain::errors::DomainError; use domain::models::UserRole; use domain::value_objects::Email; @@ -5,9 +6,11 @@ use crate::auth::commands::RegisterCommand; use crate::auth::deps::RegisterDeps; use crate::auth::register; use crate::test_helpers::TestContextBuilder; -use crate::users::deps::GetProfileDeps; -use crate::users::get_profile; +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; async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) { let deps = RegisterDeps { @@ -28,15 +31,51 @@ async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) { .unwrap(); } +fn deps(b: &TestContextBuilder) -> GetLocalProfileDeps { + GetLocalProfileDeps { + stats: b.stats_repo.clone(), + diary: b.diary_repo.clone(), + social_query: b.social_query_unified.clone(), + user: b.user_repo.clone(), + instance: domain::value_objects::InstanceIdentity::new(b.config.base_url.clone()), + } +} + +fn unknown_query(user_id: uuid::Uuid) -> GetUserProfileQuery { + GetUserProfileQuery { + user_id, + view: ProfileView::Recent, + limit: None, + offset: None, + sort_by: domain::models::FeedSortBy::Date, + search: None, + is_own_profile: false, + include_remote: false, + } +} + +/// `get_local_profile` is the local-only half of the former `get_profile` split +/// (ADR-0004's closed wart). Unlike the old tolerant behavior, a user id with no +/// local row must be `NotFound` here — the empty-string identity sentinel this +/// replaces cannot be represented in `LocalProfileData` at all. +#[tokio::test] +async fn get_local_profile_is_not_found_for_unknown_user() { + let b = TestContextBuilder::new(); + let d = deps(&b); + + let unknown_id = uuid::Uuid::new_v4(); + let err = match get_local_profile::execute(&d, unknown_query(unknown_id)).await { + Err(e) => e, + Ok(_) => panic!("expected Err(NotFound) for an unknown user id, got Ok"), + }; + assert!(matches!(err, DomainError::NotFound(_)), "got {err:?}"); +} + #[tokio::test] async fn returns_profile_with_empty_stats() { let b = TestContextBuilder::new(); let user_repo = b.user_repo.clone(); - let deps = GetProfileDeps { - stats: b.stats_repo.clone(), - diary: b.diary_repo.clone(), - social_query: b.social_query_unified.clone(), - }; + let d = deps(&b); setup_user(&b, "profile@test.com", "profuser").await; @@ -44,8 +83,8 @@ async fn returns_profile_with_empty_stats() { let user = user_repo.find_by_email(&email).await.unwrap().unwrap(); let uid = user.id().value(); - let result = get_profile::execute( - &deps, + let result = get_local_profile::execute( + &d, GetUserProfileQuery { user_id: uid, view: ProfileView::Recent, @@ -67,11 +106,7 @@ async fn returns_profile_with_empty_stats() { async fn returns_history_view() { let b = TestContextBuilder::new(); let user_repo = b.user_repo.clone(); - let deps = GetProfileDeps { - stats: b.stats_repo.clone(), - diary: b.diary_repo.clone(), - social_query: b.social_query_unified.clone(), - }; + let d = deps(&b); setup_user(&b, "hist@test.com", "histuser").await; @@ -79,8 +114,8 @@ async fn returns_history_view() { let user = user_repo.find_by_email(&email).await.unwrap().unwrap(); let uid = user.id().value(); - let result = get_profile::execute( - &deps, + let result = get_local_profile::execute( + &d, GetUserProfileQuery { user_id: uid, view: ProfileView::History, @@ -104,11 +139,7 @@ async fn returns_history_view() { async fn returns_trends_view() { let b = TestContextBuilder::new(); let user_repo = b.user_repo.clone(); - let deps = GetProfileDeps { - stats: b.stats_repo.clone(), - diary: b.diary_repo.clone(), - social_query: b.social_query_unified.clone(), - }; + let d = deps(&b); setup_user(&b, "trends@test.com", "trendsuser").await; @@ -116,8 +147,8 @@ async fn returns_trends_view() { let user = user_repo.find_by_email(&email).await.unwrap().unwrap(); let uid = user.id().value(); - let result = get_profile::execute( - &deps, + let result = get_local_profile::execute( + &d, GetUserProfileQuery { user_id: uid, view: ProfileView::Trends, @@ -141,11 +172,7 @@ async fn returns_trends_view() { async fn returns_ratings_view() { let b = TestContextBuilder::new(); let user_repo = b.user_repo.clone(); - let deps = GetProfileDeps { - stats: b.stats_repo.clone(), - diary: b.diary_repo.clone(), - social_query: b.social_query_unified.clone(), - }; + let d = deps(&b); setup_user(&b, "ratings@test.com", "ratingsuser").await; @@ -153,8 +180,8 @@ async fn returns_ratings_view() { let user = user_repo.find_by_email(&email).await.unwrap().unwrap(); let uid = user.id().value(); - let result = get_profile::execute( - &deps, + let result = get_local_profile::execute( + &d, GetUserProfileQuery { user_id: uid, view: ProfileView::Ratings, @@ -176,11 +203,7 @@ async fn returns_ratings_view() { async fn returns_recent_with_search() { let b = TestContextBuilder::new(); let user_repo = b.user_repo.clone(); - let deps = GetProfileDeps { - stats: b.stats_repo.clone(), - diary: b.diary_repo.clone(), - social_query: b.social_query_unified.clone(), - }; + let d = deps(&b); setup_user(&b, "search@test.com", "searchuser").await; @@ -188,8 +211,8 @@ async fn returns_recent_with_search() { let user = user_repo.find_by_email(&email).await.unwrap().unwrap(); let uid = user.id().value(); - let result = get_profile::execute( - &deps, + let result = get_local_profile::execute( + &d, GetUserProfileQuery { user_id: uid, view: ProfileView::Recent, @@ -211,11 +234,7 @@ async fn returns_recent_with_search() { async fn non_own_profile_skips_pending_followers() { let b = TestContextBuilder::new(); let user_repo = b.user_repo.clone(); - let deps = GetProfileDeps { - stats: b.stats_repo.clone(), - diary: b.diary_repo.clone(), - social_query: b.social_query_unified.clone(), - }; + let d = deps(&b); setup_user(&b, "other@test.com", "otheruser").await; @@ -223,8 +242,8 @@ async fn non_own_profile_skips_pending_followers() { let user = user_repo.find_by_email(&email).await.unwrap().unwrap(); let uid = user.id().value(); - let result = get_profile::execute( - &deps, + let result = get_local_profile::execute( + &d, GetUserProfileQuery { user_id: uid, view: ProfileView::Recent, @@ -241,3 +260,96 @@ async fn non_own_profile_skips_pending_followers() { assert!(result.pending_followers.is_empty()); } + +#[tokio::test] +async fn populates_handle_and_actor_url_for_a_local_user() { + let b = TestContextBuilder::new(); + let user_repo = b.user_repo.clone(); + let d = deps(&b); + + setup_user(&b, "handle@test.com", "gabriel").await; + let email = Email::new("handle@test.com".into()).unwrap(); + let user = user_repo.find_by_email(&email).await.unwrap().unwrap(); + let uid = user.id().value(); + + // `InMemoryUserRepository::update_profile` now actually persists (previously a + // no-op stub) — use it to give this test a real display_name/bio to assert, + // rather than asserting `None` for a mapping that could just as easily be + // wrong, inverted, or hardcoded. + let update_deps = UpdateProfileDeps { + user: user_repo.clone(), + object_storage: b.object_storage.clone(), + event_publisher: b.event_publisher.clone(), + }; + 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, + }, + ) + .await + .unwrap(); + + let result = get_local_profile::execute( + &d, + GetUserProfileQuery { + user_id: uid, + view: ProfileView::Recent, + limit: None, + offset: None, + sort_by: domain::models::FeedSortBy::Date, + search: None, + is_own_profile: true, + include_remote: false, + }, + ) + .await + .unwrap(); + + assert_eq!(result.identity.username, "gabriel"); + assert_eq!(result.identity.handle, "@gabriel@localhost:3000"); + assert_eq!( + result.identity.actor_url, + format!("http://localhost:3000/users/{}", uid) + ); + assert_eq!(result.identity.display_name, Some("Gabriel K".to_string())); + assert_eq!(result.identity.bio, Some("Movies and diaries.".to_string())); +} + +#[tokio::test] +async fn identity_display_name_and_bio_are_none_when_unset() { + let b = TestContextBuilder::new(); + let user_repo = b.user_repo.clone(); + let d = deps(&b); + + setup_user(&b, "unset@test.com", "unsetuser").await; + let email = Email::new("unset@test.com".into()).unwrap(); + let user = user_repo.find_by_email(&email).await.unwrap().unwrap(); + let uid = user.id().value(); + + let result = get_local_profile::execute( + &d, + GetUserProfileQuery { + user_id: uid, + view: ProfileView::Recent, + limit: None, + offset: None, + sort_by: domain::models::FeedSortBy::Date, + search: None, + is_own_profile: true, + include_remote: false, + }, + ) + .await + .unwrap(); + + assert_eq!(result.identity.display_name, None); + assert_eq!(result.identity.bio, None); +} diff --git a/crates/application/src/users/tests/get_page_viewer.rs b/crates/application/src/users/tests/get_page_viewer.rs new file mode 100644 index 0000000..e774188 --- /dev/null +++ b/crates/application/src/users/tests/get_page_viewer.rs @@ -0,0 +1,66 @@ +use domain::models::UserRole; +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; + +async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) -> uuid::Uuid { + let deps = RegisterDeps { + user: b.user_repo.clone(), + 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(); + let user = b + .user_repo + .find_by_email(&Email::new(email.into()).unwrap()) + .await + .unwrap() + .unwrap(); + user.id().value() +} + +/// `get_page_viewer` absorbs `helpers.rs::build_page_context`'s inline pending-count +/// computation. It must reuse `FollowGraphQuery::count_pending_followers` rather than +/// re-deriving the count from raw follow rows. +#[tokio::test] +async fn get_page_viewer_reports_pending_follower_count() { + let b = TestContextBuilder::new(); + let owner_uuid = setup_user(&b, "owner@test.com", "pageowner").await; + let follower1 = setup_user(&b, "follower1@test.com", "pagefollower1").await; + let follower2 = setup_user(&b, "follower2@test.com", "pagefollower2").await; + + 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) + .await + .unwrap(); + b.social_command + .follow(&UserId::from_uuid(follower2), &target) + .await + .unwrap(); + + let deps = GetPageViewerDeps { + user: b.user_repo.clone(), + follow_graph: b.social_query_unified.clone(), + }; + + let v = get_page_viewer::execute(&deps, owner_uuid).await.unwrap(); + assert_eq!(v.pending_follow_count, 2); + assert!(!v.is_admin); +} diff --git a/crates/application/src/users/tests/get_profile_settings.rs b/crates/application/src/users/tests/get_profile_settings.rs new file mode 100644 index 0000000..2258558 --- /dev/null +++ b/crates/application/src/users/tests/get_profile_settings.rs @@ -0,0 +1,64 @@ +use domain::models::{ProfileField, UserRole}; +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; + +/// `get_profile_settings` absorbs BOTH of the handler's calls: the user lookup +/// (`repos.user.find_by_id`) and the profile_fields join +/// (`repos.profile_fields.get_fields`). This proves the join actually moved in, +/// not just the user lookup. +#[tokio::test] +async fn get_profile_settings_includes_profile_fields() { + let b = TestContextBuilder::new(); + let register_deps = RegisterDeps { + user: b.user_repo.clone(), + password_hasher: b.password_hasher.clone(), + config: b.config.clone(), + }; + register::execute( + ®ister_deps, + RegisterCommand { + email: "settings@test.com".into(), + username: "settingsuser".into(), + password: "password123".into(), + role: UserRole::Standard, + }, + ) + .await + .unwrap(); + let user = b + .user_repo + .find_by_email(&Email::new("settings@test.com".into()).unwrap()) + .await + .unwrap() + .unwrap(); + let uid = user.id().value(); + + b.profile_fields_repo + .set_fields( + &UserId::from_uuid(uid), + vec![ProfileField { + name: "pronouns".into(), + value: "they/them".into(), + }], + ) + .await + .unwrap(); + + let deps = GetProfileSettingsDeps { + user: b.user_repo.clone(), + profile_fields: b.profile_fields_repo.clone(), + instance: domain::value_objects::InstanceIdentity::new(b.config.base_url.clone()), + }; + + let s = get_profile_settings::execute(&deps, uid).await.unwrap(); + assert_eq!( + s.fields, + vec![("pronouns".to_string(), "they/them".to_string())] + ); +} diff --git a/crates/application/src/users/tests/get_settings.rs b/crates/application/src/users/tests/get_settings.rs index 47cd583..fd02f4f 100644 --- a/crates/application/src/users/tests/get_settings.rs +++ b/crates/application/src/users/tests/get_settings.rs @@ -1,15 +1,18 @@ use uuid::Uuid; -use crate::{test_helpers::TestContextBuilder, users::get_settings}; +use crate::{ + test_helpers::TestContextBuilder, + users::{deps::GetSettingsDeps, get_settings}, +}; #[tokio::test] async fn returns_default_settings() { let b = TestContextBuilder::new(); - let user_settings = b.user_settings_repo.clone(); + let deps = GetSettingsDeps { + user_settings: b.user_settings_repo.clone(), + }; - let settings = get_settings::execute(user_settings, Uuid::nil()) - .await - .unwrap(); + let settings = get_settings::execute(&deps, Uuid::nil()).await.unwrap(); assert!(settings.federate_goals()); assert!(settings.federate_reviews()); diff --git a/crates/application/src/users/tests/resolve_username_to_id.rs b/crates/application/src/users/tests/resolve_username_to_id.rs new file mode 100644 index 0000000..4fa4109 --- /dev/null +++ b/crates/application/src/users/tests/resolve_username_to_id.rs @@ -0,0 +1,23 @@ +use domain::value_objects::Username; + +use crate::test_helpers::TestContextBuilder; +use crate::users::deps::ResolveUsernameDeps; +use crate::users::resolve_username_to_id; + +/// `resolve_username_to_id` absorbs `handlers/users.rs::get_user_by_username`'s +/// `repos.user.find_by_username()` call. Unknown usernames must resolve to `None`, +/// not an error — the handler turns `None` into a 404, same as today. +#[tokio::test] +async fn resolve_username_to_id_returns_none_for_unknown() { + let b = TestContextBuilder::new(); + let deps = ResolveUsernameDeps { + user: b.user_repo.clone(), + }; + let unknown = Username::new("nosuchuser".into()).unwrap(); + assert!( + resolve_username_to_id::execute(&deps, &unknown) + .await + .unwrap() + .is_none() + ); +} diff --git a/crates/application/src/users/tests/update_profile_fields.rs b/crates/application/src/users/tests/update_profile_fields.rs index 7c79822..b33a89f 100644 --- a/crates/application/src/users/tests/update_profile_fields.rs +++ b/crates/application/src/users/tests/update_profile_fields.rs @@ -7,7 +7,9 @@ use uuid::Uuid; use crate::{ test_helpers::TestContextBuilder, - users::{commands::UpdateProfileFieldsCommand, update_profile_fields}, + users::{ + commands::UpdateProfileFieldsCommand, deps::UpdateProfileFieldsDeps, update_profile_fields, + }, }; #[tokio::test] @@ -17,12 +19,13 @@ async fn saves_profile_fields() { let b = TestContextBuilder::new() .with_profile_fields(Arc::clone(&fields_repo) as _) .with_event_publisher(Arc::clone(&events) as _); - let profile_fields = b.profile_fields_repo.clone(); - let event_publisher = b.event_publisher.clone(); + let deps = UpdateProfileFieldsDeps { + profile_fields: b.profile_fields_repo.clone(), + event_publisher: b.event_publisher.clone(), + }; update_profile_fields::execute( - profile_fields, - event_publisher, + &deps, UpdateProfileFieldsCommand { user_id: Uuid::nil(), fields: vec![ @@ -51,8 +54,10 @@ async fn saves_profile_fields() { #[tokio::test] async fn rejects_more_than_four_fields() { let b = TestContextBuilder::new(); - let profile_fields = b.profile_fields_repo.clone(); - let event_publisher = b.event_publisher.clone(); + let deps = UpdateProfileFieldsDeps { + profile_fields: b.profile_fields_repo.clone(), + event_publisher: b.event_publisher.clone(), + }; let fields: Vec = (0..5) .map(|i| ProfileField { @@ -62,8 +67,7 @@ async fn rejects_more_than_four_fields() { .collect(); let result = update_profile_fields::execute( - profile_fields, - event_publisher, + &deps, UpdateProfileFieldsCommand { user_id: Uuid::nil(), fields, diff --git a/crates/application/src/users/tests/update_settings.rs b/crates/application/src/users/tests/update_settings.rs index bb17b56..58caac9 100644 --- a/crates/application/src/users/tests/update_settings.rs +++ b/crates/application/src/users/tests/update_settings.rs @@ -5,7 +5,11 @@ use uuid::Uuid; use crate::{ test_helpers::TestContextBuilder, - users::{get_settings, update_settings::UpdateUserSettingsCommand}, + users::{ + deps::{GetSettingsDeps, UpdateSettingsDeps}, + get_settings, + update_settings::UpdateUserSettingsCommand, + }, }; #[tokio::test] @@ -16,7 +20,9 @@ async fn updates_federate_goals() { let uid = Uuid::nil(); crate::users::update_settings::execute( - user_settings.clone(), + &UpdateSettingsDeps { + user_settings: user_settings.clone(), + }, UpdateUserSettingsCommand { user_id: uid, federate_goals: false, @@ -27,7 +33,9 @@ async fn updates_federate_goals() { .await .unwrap(); - let settings = get_settings::execute(user_settings, uid).await.unwrap(); + let settings = get_settings::execute(&GetSettingsDeps { user_settings }, uid) + .await + .unwrap(); assert!(!settings.federate_goals()); assert!(settings.federate_reviews()); assert!(settings.federate_watchlist()); @@ -41,7 +49,9 @@ async fn updates_federate_reviews() { let uid = Uuid::nil(); crate::users::update_settings::execute( - user_settings.clone(), + &UpdateSettingsDeps { + user_settings: user_settings.clone(), + }, UpdateUserSettingsCommand { user_id: uid, federate_goals: true, @@ -52,7 +62,9 @@ async fn updates_federate_reviews() { .await .unwrap(); - let settings = get_settings::execute(user_settings, uid).await.unwrap(); + let settings = get_settings::execute(&GetSettingsDeps { user_settings }, uid) + .await + .unwrap(); assert!(settings.federate_goals()); assert!(!settings.federate_reviews()); assert!(settings.federate_watchlist()); @@ -66,7 +78,9 @@ async fn updates_federate_watchlist() { let uid = Uuid::nil(); crate::users::update_settings::execute( - user_settings.clone(), + &UpdateSettingsDeps { + user_settings: user_settings.clone(), + }, UpdateUserSettingsCommand { user_id: uid, federate_goals: true, @@ -77,7 +91,9 @@ async fn updates_federate_watchlist() { .await .unwrap(); - let settings = get_settings::execute(user_settings, uid).await.unwrap(); + let settings = get_settings::execute(&GetSettingsDeps { user_settings }, uid) + .await + .unwrap(); assert!(settings.federate_goals()); assert!(settings.federate_reviews()); assert!(!settings.federate_watchlist()); diff --git a/crates/application/src/users/update_profile_fields.rs b/crates/application/src/users/update_profile_fields.rs index e923c86..7929669 100644 --- a/crates/application/src/users/update_profile_fields.rs +++ b/crates/application/src/users/update_profile_fields.rs @@ -1,24 +1,18 @@ -use std::sync::Arc; - use domain::{ - errors::DomainError, - events::DomainEvent, - models::UserProfile, - ports::{EventPublisher, UserProfileFieldsRepository}, - value_objects::UserId, + errors::DomainError, events::DomainEvent, models::UserProfile, value_objects::UserId, }; use crate::users::commands::UpdateProfileFieldsCommand; +use crate::users::deps::UpdateProfileFieldsDeps; pub async fn execute( - profile_fields: Arc, - event_publisher: Arc, + deps: &UpdateProfileFieldsDeps, cmd: UpdateProfileFieldsCommand, ) -> Result<(), DomainError> { UserProfile::validate_custom_fields(&cmd.fields)?; let user_id = UserId::from_uuid(cmd.user_id); - profile_fields.set_fields(&user_id, cmd.fields).await?; - event_publisher + deps.profile_fields.set_fields(&user_id, cmd.fields).await?; + deps.event_publisher .publish(&DomainEvent::UserUpdated { user_id }) .await?; Ok(()) diff --git a/crates/application/src/users/update_settings.rs b/crates/application/src/users/update_settings.rs index ed2db18..1bb7371 100644 --- a/crates/application/src/users/update_settings.rs +++ b/crates/application/src/users/update_settings.rs @@ -1,6 +1,6 @@ -use std::sync::Arc; +use domain::{errors::DomainError, value_objects::UserId}; -use domain::{errors::DomainError, ports::UserSettingsRepository, value_objects::UserId}; +use crate::users::deps::UpdateSettingsDeps; pub struct UpdateUserSettingsCommand { pub user_id: uuid::Uuid, @@ -10,15 +10,15 @@ pub struct UpdateUserSettingsCommand { } pub async fn execute( - user_settings: Arc, + deps: &UpdateSettingsDeps, cmd: UpdateUserSettingsCommand, ) -> Result<(), DomainError> { let uid = UserId::from_uuid(cmd.user_id); - let mut settings = user_settings.get(&uid).await?; + let mut settings = deps.user_settings.get(&uid).await?; settings.set_federate_goals(cmd.federate_goals); settings.set_federate_reviews(cmd.federate_reviews); settings.set_federate_watchlist(cmd.federate_watchlist); - user_settings.save(&settings).await + deps.user_settings.save(&settings).await } #[cfg(test)] diff --git a/crates/application/src/watchlist/deps.rs b/crates/application/src/watchlist/deps.rs index 75da4a3..acb4ef3 100644 --- a/crates/application/src/watchlist/deps.rs +++ b/crates/application/src/watchlist/deps.rs @@ -1,7 +1,8 @@ use std::sync::Arc; use domain::ports::{ - EventPublisher, MetadataClient, MovieCommand, MovieQuery, WatchlistRepository, + EventPublisher, MetadataClient, MovieCommand, MovieQuery, RemoteWatchlistRepository, + UserRepository, WatchlistRepository, }; pub struct WatchlistAddDeps { @@ -11,3 +12,22 @@ pub struct WatchlistAddDeps { pub watchlist: Arc, pub event_publisher: Arc, } + +pub struct GetWatchlistForOwnerDeps { + pub user: Arc, + pub watchlist: Arc, + pub remote_watchlist: Arc, +} + +pub struct GetWatchlistDeps { + pub watchlist: Arc, +} + +pub struct IsOnWatchlistDeps { + pub watchlist: Arc, +} + +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 cfb3691..5b94bd8 100644 --- a/crates/application/src/watchlist/get.rs +++ b/crates/application/src/watchlist/get.rs @@ -1,24 +1,22 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, models::{ WatchlistWithMovie, collections::{PageParams, Paginated}, }, - ports::WatchlistRepository, value_objects::UserId, }; +use crate::watchlist::deps::GetWatchlistDeps; use crate::watchlist::queries::GetWatchlistQuery; pub async fn execute( - watchlist: Arc, + deps: &GetWatchlistDeps, query: GetWatchlistQuery, ) -> Result, DomainError> { let user_id = UserId::from_uuid(query.user_id); let page = PageParams::new(query.limit, query.offset)?; - watchlist.get_for_user(&user_id, &page).await + deps.watchlist.get_for_user(&user_id, &page).await } #[cfg(test)] diff --git a/crates/application/src/watchlist/get_watchlist_for_owner.rs b/crates/application/src/watchlist/get_watchlist_for_owner.rs new file mode 100644 index 0000000..31fd32f --- /dev/null +++ b/crates/application/src/watchlist/get_watchlist_for_owner.rs @@ -0,0 +1,74 @@ +use domain::{ + errors::DomainError, + models::{RemoteWatchlistEntry, WatchlistWithMovie, collections::Paginated}, + value_objects::UserId, +}; +use uuid::Uuid; + +use crate::watchlist::deps::{GetWatchlistDeps, GetWatchlistForOwnerDeps}; +use crate::watchlist::get; +use crate::watchlist::queries::GetWatchlistQuery; + +/// Which data source answered a watchlist page request. The former handler +/// decided this by probing for a local user row — that decision now lives here, +/// not in `handlers/watchlist.rs`. +pub enum WatchlistView { + Local(Paginated), + Remote(Vec), +} + +/// `limit`/`offset` are taken raw, not as a pre-built `PageParams`, on purpose: +/// they're only validated on the local branch, by delegating to +/// `watchlist::get::execute` (whose `PageParams::new` call is where the validation +/// actually happens). The remote branch ignores them entirely, unconditionally +/// fetching the full federated watchlist. The delegation call below must stay +/// inside the `if is_local` branch — calling it before the local/remote decision +/// would turn an out-of-range `limit` into a 400 on a remote owner's page, which +/// never happened before and must not start happening now. +pub async fn execute( + deps: &GetWatchlistForOwnerDeps, + owner_id: Uuid, + limit: Option, + offset: Option, +) -> Result { + let user_id = UserId::from_uuid(owner_id); + + // Matches the deleted handler's `.map(|u| u.is_some()).unwrap_or(false)` + // exactly: a lookup error is treated the same as "no local row", falling + // through to the remote arm rather than propagating. + let is_local = deps + .user + .find_by_id(&user_id) + .await + .map(|u| u.is_some()) + .unwrap_or(false); + + if is_local { + let get_deps = GetWatchlistDeps { + watchlist: deps.watchlist.clone(), + }; + let paginated = get::execute( + &get_deps, + GetWatchlistQuery { + user_id: owner_id, + limit, + offset, + }, + ) + .await?; + Ok(WatchlistView::Local(paginated)) + } else { + // Matches the deleted handler's `.unwrap_or_default()`: a federation + // lookup error yields an empty list, not a propagated error. + let entries = deps + .remote_watchlist + .get_by_derived_uuid(owner_id) + .await + .unwrap_or_default(); + 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 a197446..4924af9 100644 --- a/crates/application/src/watchlist/is_on.rs +++ b/crates/application/src/watchlist/is_on.rs @@ -1,20 +1,18 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, - ports::WatchlistRepository, value_objects::{MovieId, UserId}, }; +use crate::watchlist::deps::IsOnWatchlistDeps; use crate::watchlist::queries::IsOnWatchlistQuery; pub async fn execute( - watchlist: Arc, + deps: &IsOnWatchlistDeps, query: IsOnWatchlistQuery, ) -> Result { let user_id = UserId::from_uuid(query.user_id); let movie_id = MovieId::from_uuid(query.movie_id); - watchlist.contains(&user_id, &movie_id).await + deps.watchlist.contains(&user_id, &movie_id).await } #[cfg(test)] diff --git a/crates/application/src/watchlist/mod.rs b/crates/application/src/watchlist/mod.rs index d0bc91e..dff1814 100644 --- a/crates/application/src/watchlist/mod.rs +++ b/crates/application/src/watchlist/mod.rs @@ -2,6 +2,7 @@ pub mod add; pub mod commands; pub mod deps; pub mod get; +pub mod get_watchlist_for_owner; pub mod is_on; pub mod queries; pub mod remove; diff --git a/crates/application/src/watchlist/remove.rs b/crates/application/src/watchlist/remove.rs index d4297e0..d871487 100644 --- a/crates/application/src/watchlist/remove.rs +++ b/crates/application/src/watchlist/remove.rs @@ -1,24 +1,22 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, events::DomainEvent, - ports::{EventPublisher, WatchlistRepository}, value_objects::{MovieId, UserId}, }; use crate::watchlist::commands::RemoveFromWatchlistCommand; +use crate::watchlist::deps::RemoveFromWatchlistDeps; pub async fn execute( - watchlist: Arc, - event_publisher: Arc, + deps: &RemoveFromWatchlistDeps, cmd: RemoveFromWatchlistCommand, ) -> Result<(), DomainError> { let user_id = UserId::from_uuid(cmd.user_id); let movie_id = MovieId::from_uuid(cmd.movie_id); - watchlist.remove(&user_id, &movie_id).await?; + deps.watchlist.remove(&user_id, &movie_id).await?; - let _ = event_publisher + let _ = deps + .event_publisher .publish(&DomainEvent::WatchlistEntryRemoved { user_id, movie_id }) .await; diff --git a/crates/application/src/watchlist/tests/get.rs b/crates/application/src/watchlist/tests/get.rs index e4b78c5..a77ba97 100644 --- a/crates/application/src/watchlist/tests/get.rs +++ b/crates/application/src/watchlist/tests/get.rs @@ -1,13 +1,16 @@ use uuid::Uuid; use crate::test_helpers::TestContextBuilder; -use crate::watchlist::{get, queries::GetWatchlistQuery}; +use crate::watchlist::{deps::GetWatchlistDeps, get, queries::GetWatchlistQuery}; #[tokio::test] async fn returns_empty_page_for_new_user() { let b = TestContextBuilder::new(); + let deps = GetWatchlistDeps { + watchlist: b.watchlist_repo.clone(), + }; let result = get::execute( - b.watchlist_repo.clone(), + &deps, GetWatchlistQuery { user_id: Uuid::new_v4(), limit: None, diff --git a/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs b/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs new file mode 100644 index 0000000..bba6cdd --- /dev/null +++ b/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs @@ -0,0 +1,147 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use domain::models::UserRole; +use domain::testing::{ + InMemoryRemoteWatchlistRepository, InMemoryUserRepository, InMemoryWatchlistRepository, + PanicRemoteWatchlistRepository, PanicWatchlistRepository, +}; +use domain::value_objects::Email; + +use crate::auth::commands::RegisterCommand; +use crate::auth::deps::RegisterDeps; +use crate::auth::register; +use crate::test_helpers::TestContextBuilder; +use crate::watchlist::deps::GetWatchlistForOwnerDeps; +use crate::watchlist::get_watchlist_for_owner::{self, WatchlistView}; + +async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) { + let deps = RegisterDeps { + user: b.user_repo.clone(), + 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(); +} + +#[tokio::test] +async fn watchlist_for_owner_returns_local_when_user_row_exists() { + let b = TestContextBuilder::new(); + setup_user(&b, "owner@test.com", "owneruser").await; + + let email = Email::new("owner@test.com".into()).unwrap(); + let user = b.user_repo.find_by_email(&email).await.unwrap().unwrap(); + let uid = user.id().value(); + + // If the local-row decision were wrong and this reached the remote arm, the + // panic port below would fail the test instead of silently passing. + let deps = GetWatchlistForOwnerDeps { + user: b.user_repo.clone(), + watchlist: InMemoryWatchlistRepository::new(), + remote_watchlist: Arc::new(PanicRemoteWatchlistRepository), + }; + + let view = get_watchlist_for_owner::execute(&deps, uid, Some(20), Some(0)) + .await + .unwrap(); + + assert!(matches!(view, WatchlistView::Local(_)), "expected Local"); +} + +#[tokio::test] +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(), + }, + ]); + + // No user row exists for `owner_id` in this fresh, empty user repo — the + // local-vs-remote decision must fall through to the remote arm. The panic + // port below proves the local arm is never touched. + let deps = GetWatchlistForOwnerDeps { + user: InMemoryUserRepository::new(), + watchlist: Arc::new(PanicWatchlistRepository), + remote_watchlist: remote, + }; + + let view = get_watchlist_for_owner::execute(&deps, owner_id, Some(20), Some(0)) + .await + .unwrap(); + + match view { + WatchlistView::Remote(entries) => { + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].movie_title, "Remote Movie"); + } + WatchlistView::Local(_) => panic!("expected Remote"), + } +} + +/// Pins a pre-existing quirk: the old handler validated `limit`/`offset` only on +/// the local branch (inside `watchlist::get::execute`'s `PageParams::new` call) — +/// the remote branch never looked at them at all. An out-of-range limit must +/// still surface as a validation error on the local arm. +#[tokio::test] +async fn watchlist_for_owner_local_arm_validates_limit() { + let b = TestContextBuilder::new(); + setup_user(&b, "badlimit@test.com", "badlimituser").await; + + let email = Email::new("badlimit@test.com".into()).unwrap(); + let user = b.user_repo.find_by_email(&email).await.unwrap().unwrap(); + let uid = user.id().value(); + + let deps = GetWatchlistForOwnerDeps { + user: b.user_repo.clone(), + watchlist: InMemoryWatchlistRepository::new(), + remote_watchlist: Arc::new(PanicRemoteWatchlistRepository), + }; + + let err = match get_watchlist_for_owner::execute(&deps, uid, Some(0), Some(0)).await { + Err(e) => e, + Ok(_) => panic!("expected Err(ValidationError) for limit=0 on the local arm, got Ok"), + }; + assert!(matches!( + err, + domain::errors::DomainError::ValidationError(_) + )); +} + +/// Same quirk, other direction: an out-of-range limit is silently ignored on the +/// remote arm, exactly as before — it must not turn into a validation error just +/// because the use case now owns the branch decision. +#[tokio::test] +async fn watchlist_for_owner_remote_arm_ignores_invalid_limit() { + let owner_id = Uuid::new_v4(); + let remote = InMemoryRemoteWatchlistRepository::new(); + + let deps = GetWatchlistForOwnerDeps { + user: InMemoryUserRepository::new(), + watchlist: Arc::new(PanicWatchlistRepository), + remote_watchlist: remote, + }; + + let view = get_watchlist_for_owner::execute(&deps, owner_id, Some(0), Some(0)) + .await + .unwrap(); + assert!(matches!(view, WatchlistView::Remote(_)), "expected Remote"); +} diff --git a/crates/application/src/watchlist/tests/is_on.rs b/crates/application/src/watchlist/tests/is_on.rs index c05d31b..7ec7e09 100644 --- a/crates/application/src/watchlist/tests/is_on.rs +++ b/crates/application/src/watchlist/tests/is_on.rs @@ -7,7 +7,7 @@ use domain::value_objects::{MovieId, UserId}; use uuid::Uuid; use crate::test_helpers::TestContextBuilder; -use crate::watchlist::{is_on, queries::IsOnWatchlistQuery}; +use crate::watchlist::{deps::IsOnWatchlistDeps, is_on, queries::IsOnWatchlistQuery}; #[tokio::test] async fn returns_true_when_present() { @@ -22,8 +22,11 @@ async fn returns_true_when_present() { .await .unwrap(); + let deps = IsOnWatchlistDeps { + watchlist: Arc::clone(&watchlist) as _, + }; let result = is_on::execute( - Arc::clone(&watchlist) as _, + &deps, IsOnWatchlistQuery { user_id: uid, movie_id: mid, @@ -38,8 +41,11 @@ async fn returns_true_when_present() { #[tokio::test] async fn returns_false_when_absent() { let b = TestContextBuilder::new(); + let deps = IsOnWatchlistDeps { + watchlist: b.watchlist_repo.clone(), + }; let result = is_on::execute( - b.watchlist_repo.clone(), + &deps, IsOnWatchlistQuery { user_id: Uuid::new_v4(), movie_id: Uuid::new_v4(), diff --git a/crates/application/src/watchlist/tests/remove.rs b/crates/application/src/watchlist/tests/remove.rs index e5fd4e4..0a58d27 100644 --- a/crates/application/src/watchlist/tests/remove.rs +++ b/crates/application/src/watchlist/tests/remove.rs @@ -8,7 +8,9 @@ use domain::value_objects::{MovieId, UserId}; use uuid::Uuid; use crate::test_helpers::TestContextBuilder; -use crate::watchlist::{commands::RemoveFromWatchlistCommand, remove}; +use crate::watchlist::{ + commands::RemoveFromWatchlistCommand, deps::RemoveFromWatchlistDeps, remove, +}; #[tokio::test] async fn removes_entry_and_emits_event() { @@ -24,9 +26,12 @@ async fn removes_entry_and_emits_event() { .await .unwrap(); + let deps = RemoveFromWatchlistDeps { + watchlist: Arc::clone(&watchlist) as _, + event_publisher: Arc::clone(&events) as _, + }; remove::execute( - Arc::clone(&watchlist) as _, - Arc::clone(&events) as _, + &deps, RemoveFromWatchlistCommand { user_id: uid, movie_id: mid, @@ -47,9 +52,12 @@ async fn removes_entry_and_emits_event() { #[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( - b.watchlist_repo.clone(), - b.event_publisher.clone(), + &deps, RemoveFromWatchlistCommand { user_id: Uuid::new_v4(), movie_id: Uuid::new_v4(), diff --git a/crates/application/src/wrapup/delete.rs b/crates/application/src/wrapup/delete.rs index 1729ea7..84ac0ef 100644 --- a/crates/application/src/wrapup/delete.rs +++ b/crates/application/src/wrapup/delete.rs @@ -1,19 +1,15 @@ -use std::sync::Arc; - use domain::errors::DomainError; -use domain::ports::WrapUpRepository; use domain::value_objects::WrapUpId; -pub async fn execute( - wrapup_repo: Arc, - id: WrapUpId, -) -> Result<(), DomainError> { - wrapup_repo +use crate::wrapup::deps::DeleteWrapUpDeps; + +pub async fn execute(deps: &DeleteWrapUpDeps, id: WrapUpId) -> Result<(), DomainError> { + deps.wrapup_repo .get_by_id(&id) .await? .ok_or_else(|| DomainError::NotFound("wrap-up not found".into()))?; - wrapup_repo.delete(&id).await + deps.wrapup_repo.delete(&id).await } #[cfg(test)] diff --git a/crates/application/src/wrapup/deps.rs b/crates/application/src/wrapup/deps.rs index b57f806..74f9bb9 100644 --- a/crates/application/src/wrapup/deps.rs +++ b/crates/application/src/wrapup/deps.rs @@ -7,3 +7,24 @@ pub struct HandleWrapUpRequestedDeps { pub event_publisher: Arc, pub wrapup_stats: Arc, } + +pub struct GetReadyReportDeps { + pub wrapup_repo: Arc, +} + +pub struct DeleteWrapUpDeps { + pub wrapup_repo: Arc, +} + +pub struct GenerateWrapUpDeps { + pub wrapup_repo: Arc, + pub event_publisher: Arc, +} + +pub struct GetWrapUpDeps { + pub wrapup_repo: Arc, +} + +pub struct ListWrapUpsDeps { + pub wrapup_repo: Arc, +} diff --git a/crates/application/src/wrapup/generate.rs b/crates/application/src/wrapup/generate.rs index fd3e05e..01dd5d1 100644 --- a/crates/application/src/wrapup/generate.rs +++ b/crates/application/src/wrapup/generate.rs @@ -1,17 +1,14 @@ -use std::sync::Arc; - use chrono::Utc; use domain::errors::DomainError; use domain::events::DomainEvent; use domain::models::wrapup::{DateRange, WrapUpStatus}; -use domain::ports::{EventPublisher, WrapUpRepository}; use domain::value_objects::{UserId, WrapUpId}; use crate::wrapup::commands::RequestWrapUpCommand; +use crate::wrapup::deps::GenerateWrapUpDeps; pub async fn execute( - wrapup_repo: Arc, - event_publisher: Arc, + deps: &GenerateWrapUpDeps, cmd: RequestWrapUpCommand, ) -> Result { let date_range = DateRange::new(cmd.start_date, cmd.end_date)?; @@ -22,7 +19,8 @@ pub async fn execute( )); } - let existing = wrapup_repo + let existing = deps + .wrapup_repo .find_existing(cmd.user_id, date_range.start(), date_range.end()) .await?; @@ -30,7 +28,7 @@ pub async fn execute( match rec.status { WrapUpStatus::Ready | WrapUpStatus::Generating => return Ok(rec.id.clone()), WrapUpStatus::Failed => { - wrapup_repo.delete(&rec.id).await?; + deps.wrapup_repo.delete(&rec.id).await?; } WrapUpStatus::Pending => return Ok(rec.id.clone()), } @@ -48,9 +46,9 @@ pub async fn execute( created_at: Utc::now().naive_utc(), completed_at: None, }; - wrapup_repo.create(&record).await?; + deps.wrapup_repo.create(&record).await?; - event_publisher + deps.event_publisher .publish(&DomainEvent::WrapUpRequested { wrapup_id: id.clone(), user_id: cmd.user_id.map(UserId::from_uuid), diff --git a/crates/application/src/wrapup/get_ready_report.rs b/crates/application/src/wrapup/get_ready_report.rs new file mode 100644 index 0000000..a12668f --- /dev/null +++ b/crates/application/src/wrapup/get_ready_report.rs @@ -0,0 +1,53 @@ +use chrono::NaiveDate; +use domain::{ + errors::DomainError, + models::wrapup::{WrapUpReport, WrapUpScope, WrapUpStatus}, +}; + +use crate::wrapup::deps::GetReadyReportDeps; + +/// Absorbs the year -> date-range conversion and the `status == Ready` + +/// `report.is_some()` gating both HTML wrap-up handlers used to do by hand. +/// +/// Takes `domain::models::wrapup::WrapUpScope` directly (the same type +/// `wrapup::handle_requested` already matches on) rather than a second, +/// use-case-local `WrapUpScope` — there is exactly one concept here, one type. +/// `None` can no longer be silently passed for "global": callers must pick +/// `WrapUpScope::User(uid)` or `WrapUpScope::Global` explicitly. +/// +/// Error shape mirrors the deleted handlers exactly: +/// - An unrepresentable `year` (can't form a valid `NaiveDate`) is +/// `ValidationError` — the handlers used this to return a bare 400. +/// - Everything else — no record, a record whose `status` isn't `Ready`, a +/// `Ready` record with `report: None`, or even a repository error — collapses +/// to `NotFound`, matching the old handlers' `_ => return StatusCode::NOT_FOUND` +/// catch-all (which really did swallow repo errors into a 404, not just the +/// "not ready yet" cases; preserved here rather than tidied). +pub async fn execute( + deps: &GetReadyReportDeps, + scope: WrapUpScope, + year: i32, +) -> Result { + let start = NaiveDate::from_ymd_opt(year, 1, 1) + .ok_or_else(|| DomainError::ValidationError("invalid year".into()))?; + let end = NaiveDate::from_ymd_opt(year + 1, 1, 1) + .ok_or_else(|| DomainError::ValidationError("invalid year".into()))?; + + let user_id = match scope { + WrapUpScope::User(uid) => Some(uid), + WrapUpScope::Global => None, + }; + + let record = match deps.wrapup_repo.find_existing(user_id, start, end).await { + Ok(Some(r)) if r.status == WrapUpStatus::Ready => r, + _ => return Err(DomainError::NotFound("wrap-up report".into())), + }; + + record + .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 b57e4f7..0613849 100644 --- a/crates/application/src/wrapup/get_wrapup.rs +++ b/crates/application/src/wrapup/get_wrapup.rs @@ -1,15 +1,14 @@ -use std::sync::Arc; - use domain::errors::DomainError; use domain::models::wrapup::WrapUpRecord; -use domain::ports::WrapUpRepository; use domain::value_objects::WrapUpId; +use crate::wrapup::deps::GetWrapUpDeps; + pub async fn execute( - wrapup_repo: Arc, + deps: &GetWrapUpDeps, id: WrapUpId, ) -> Result, DomainError> { - wrapup_repo.get_by_id(&id).await + deps.wrapup_repo.get_by_id(&id).await } #[cfg(test)] diff --git a/crates/application/src/wrapup/list_wrapups.rs b/crates/application/src/wrapup/list_wrapups.rs index ad4d9e3..c270158 100644 --- a/crates/application/src/wrapup/list_wrapups.rs +++ b/crates/application/src/wrapup/list_wrapups.rs @@ -1,22 +1,21 @@ -use std::sync::Arc; - use uuid::Uuid; use domain::errors::DomainError; use domain::models::wrapup::WrapUpRecord; -use domain::ports::WrapUpRepository; + +use crate::wrapup::deps::ListWrapUpsDeps; pub struct ListWrapUpsQuery { pub user_id: Option, } pub async fn execute( - wrapup_repo: Arc, + deps: &ListWrapUpsDeps, query: ListWrapUpsQuery, ) -> Result, DomainError> { match query.user_id { - Some(uid) => wrapup_repo.list_for_user(uid).await, - None => wrapup_repo.list_global().await, + Some(uid) => deps.wrapup_repo.list_for_user(uid).await, + None => deps.wrapup_repo.list_global().await, } } diff --git a/crates/application/src/wrapup/mod.rs b/crates/application/src/wrapup/mod.rs index cf9cae8..ae52741 100644 --- a/crates/application/src/wrapup/mod.rs +++ b/crates/application/src/wrapup/mod.rs @@ -4,6 +4,7 @@ pub mod delete; pub mod deps; pub mod event_handler; pub mod generate; +pub mod get_ready_report; pub mod get_wrapup; pub mod handle_requested; pub mod list_wrapups; diff --git a/crates/application/src/wrapup/tests/delete.rs b/crates/application/src/wrapup/tests/delete.rs index 6a0ffe9..2cbeb1d 100644 --- a/crates/application/src/wrapup/tests/delete.rs +++ b/crates/application/src/wrapup/tests/delete.rs @@ -4,6 +4,7 @@ use domain::testing::InMemoryWrapUpRepository; use domain::value_objects::WrapUpId; use crate::wrapup::delete; +use crate::wrapup::deps::DeleteWrapUpDeps; #[tokio::test] async fn deletes_existing_wrapup() { @@ -21,13 +22,19 @@ async fn deletes_existing_wrapup() { completed_at: None, }); - delete::execute(repo.clone(), id).await.unwrap(); + let deps = DeleteWrapUpDeps { + wrapup_repo: repo.clone(), + }; + delete::execute(&deps, id).await.unwrap(); assert_eq!(repo.store.lock().unwrap().len(), 0); } #[tokio::test] async fn fails_when_not_found() { let repo = InMemoryWrapUpRepository::new(); - let result = delete::execute(repo.clone(), WrapUpId::generate()).await; + let deps = DeleteWrapUpDeps { + wrapup_repo: repo.clone(), + }; + let result = delete::execute(&deps, WrapUpId::generate()).await; assert!(result.is_err()); } diff --git a/crates/application/src/wrapup/tests/generate.rs b/crates/application/src/wrapup/tests/generate.rs index 4168c19..1212d49 100644 --- a/crates/application/src/wrapup/tests/generate.rs +++ b/crates/application/src/wrapup/tests/generate.rs @@ -5,8 +5,21 @@ use domain::testing::{InMemoryWrapUpRepository, NoopEventPublisher}; use domain::value_objects::WrapUpId; use uuid::Uuid; +use std::sync::Arc; + +use crate::wrapup::deps::GenerateWrapUpDeps; use crate::wrapup::{commands::RequestWrapUpCommand, generate}; +fn deps( + repo: &Arc, + events: &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()), @@ -20,7 +33,7 @@ async fn creates_pending_record_and_emits_event() { let repo = InMemoryWrapUpRepository::new(); let events = NoopEventPublisher::new(); - let id = generate::execute(repo.clone(), events.clone(), past_cmd()) + let id = generate::execute(&deps(&repo, &events), past_cmd()) .await .unwrap(); @@ -54,7 +67,7 @@ async fn reuses_existing_ready_wrapup() { }); let events = NoopEventPublisher::new(); - let id = generate::execute(repo.clone(), events.clone(), past_cmd()) + let id = generate::execute(&deps(&repo, &events), past_cmd()) .await .unwrap(); assert_eq!(id, existing_id); @@ -78,7 +91,7 @@ async fn replaces_failed_wrapup() { let events = NoopEventPublisher::new(); - let id = generate::execute(repo.clone(), events.clone(), past_cmd()) + let id = generate::execute(&deps(&repo, &events), past_cmd()) .await .unwrap(); @@ -93,8 +106,7 @@ async fn rejects_future_end_date() { let repo = InMemoryWrapUpRepository::new(); let events = NoopEventPublisher::new(); let err = generate::execute( - repo.clone(), - events.clone(), + &deps(&repo, &events), RequestWrapUpCommand { user_id: None, start_date: NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(), diff --git a/crates/application/src/wrapup/tests/get_ready_report.rs b/crates/application/src/wrapup/tests/get_ready_report.rs new file mode 100644 index 0000000..0d4a607 --- /dev/null +++ b/crates/application/src/wrapup/tests/get_ready_report.rs @@ -0,0 +1,152 @@ +use chrono::NaiveDate; +use uuid::Uuid; + +use domain::errors::DomainError; +use domain::models::wrapup::{DateRange, WrapUpRecord, WrapUpScope, WrapUpStatus}; +use domain::ports::WrapUpRepository; +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; + +fn year_range(year: i32) -> DateRange { + DateRange::new( + NaiveDate::from_ymd_opt(year, 1, 1).unwrap(), + NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap(), + ) + .unwrap() +} + +fn record( + user_id: Option, + year: i32, + status: WrapUpStatus, + with_report: bool, +) -> WrapUpRecord { + let range = year_range(year); + let domain_scope = match user_id { + Some(u) => WrapUpScope::User(u), + None => WrapUpScope::Global, + }; + let report = if with_report { + Some(wrapup_analyzer::build_report( + domain_scope, + range.clone(), + &[], + )) + } else { + None + }; + WrapUpRecord { + id: WrapUpId::generate(), + user_id, + start_date: range.start(), + end_date: range.end(), + status, + report, + error_message: None, + created_at: chrono::Utc::now().naive_utc(), + completed_at: None, + } +} + +#[tokio::test] +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)) + .await + .unwrap(); + + let deps = GetReadyReportDeps { wrapup_repo: repo }; + + let report = get_ready_report::execute(&deps, WrapUpScope::User(user_id), 2024) + .await + .unwrap(); + assert_eq!(report.total_movies, 0); +} + +#[tokio::test] +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, + )) + .await + .unwrap(); + + let deps = GetReadyReportDeps { wrapup_repo: repo }; + + let err = get_ready_report::execute(&deps, WrapUpScope::User(user_id), 2024) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::NotFound(_)), "got {err:?}"); +} + +#[tokio::test] +async fn ready_report_is_not_found_when_report_absent() { + let repo = InMemoryWrapUpRepository::new(); + let user_id = Uuid::new_v4(); + // 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(); + + let deps = GetReadyReportDeps { wrapup_repo: repo }; + + let err = get_ready_report::execute(&deps, WrapUpScope::User(user_id), 2024) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::NotFound(_)), "got {err:?}"); +} + +#[tokio::test] +async fn ready_report_is_not_found_when_no_record_exists() { + let repo = InMemoryWrapUpRepository::new(); + let deps = GetReadyReportDeps { wrapup_repo: repo }; + + let err = get_ready_report::execute(&deps, WrapUpScope::User(Uuid::new_v4()), 2024) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::NotFound(_)), "got {err:?}"); +} + +#[tokio::test] +async fn ready_report_global_scope_ignores_user_id() { + let repo = InMemoryWrapUpRepository::new(); + repo.create(&record(None, 2024, WrapUpStatus::Ready, true)) + .await + .unwrap(); + + let deps = GetReadyReportDeps { wrapup_repo: repo }; + + get_ready_report::execute(&deps, WrapUpScope::Global, 2024) + .await + .unwrap(); +} + +/// The old handlers guarded `NaiveDate::from_ymd_opt` up front and returned a bare +/// 400 for a year that can't form a valid date — that guard moved into the use +/// case along with the rest of the year->date-range conversion. A distinguishable +/// error (ValidationError, not NotFound) lets the handler still map it to 400. +#[tokio::test] +async fn ready_report_is_validation_error_for_unrepresentable_year() { + let repo = InMemoryWrapUpRepository::new(); + let deps = GetReadyReportDeps { wrapup_repo: repo }; + + let err = get_ready_report::execute(&deps, WrapUpScope::Global, 999_999) + .await + .unwrap_err(); + assert!( + matches!(err, DomainError::ValidationError(_)), + "got {err:?}" + ); +} diff --git a/crates/application/src/wrapup/tests/get_wrapup.rs b/crates/application/src/wrapup/tests/get_wrapup.rs index 791bcde..9792d7f 100644 --- a/crates/application/src/wrapup/tests/get_wrapup.rs +++ b/crates/application/src/wrapup/tests/get_wrapup.rs @@ -3,6 +3,7 @@ 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; #[tokio::test] @@ -21,7 +22,10 @@ async fn returns_record_when_exists() { completed_at: None, }); - let result = get_wrapup::execute(repo.clone(), id).await.unwrap(); + let deps = GetWrapUpDeps { + wrapup_repo: repo.clone(), + }; + let result = get_wrapup::execute(&deps, id).await.unwrap(); assert!(result.is_some()); assert_eq!(result.unwrap().status, WrapUpStatus::Pending); } @@ -29,7 +33,10 @@ async fn returns_record_when_exists() { #[tokio::test] async fn returns_none_when_missing() { let repo = InMemoryWrapUpRepository::new(); - let result = get_wrapup::execute(repo.clone(), WrapUpId::generate()) + let deps = GetWrapUpDeps { + wrapup_repo: repo.clone(), + }; + let result = get_wrapup::execute(&deps, WrapUpId::generate()) .await .unwrap(); assert!(result.is_none()); diff --git a/crates/application/src/wrapup/tests/list_wrapups.rs b/crates/application/src/wrapup/tests/list_wrapups.rs index 27ee1d0..da71dd3 100644 --- a/crates/application/src/wrapup/tests/list_wrapups.rs +++ b/crates/application/src/wrapup/tests/list_wrapups.rs @@ -4,6 +4,7 @@ use domain::testing::InMemoryWrapUpRepository; use domain::value_objects::WrapUpId; use uuid::Uuid; +use crate::wrapup::deps::ListWrapUpsDeps; use crate::wrapup::list_wrapups::{self, ListWrapUpsQuery}; fn make_record(user_id: Option) -> WrapUpRecord { @@ -31,7 +32,10 @@ async fn filters_by_user() { store.push(make_record(None)); } - let result = list_wrapups::execute(repo.clone(), ListWrapUpsQuery { user_id: Some(uid) }) + let deps = ListWrapUpsDeps { + wrapup_repo: repo.clone(), + }; + let result = list_wrapups::execute(&deps, ListWrapUpsQuery { user_id: Some(uid) }) .await .unwrap(); assert_eq!(result.len(), 1); @@ -48,7 +52,10 @@ async fn returns_global_when_no_user() { store.push(make_record(Some(Uuid::new_v4()))); } - let result = list_wrapups::execute(repo.clone(), ListWrapUpsQuery { user_id: None }) + let deps = ListWrapUpsDeps { + wrapup_repo: repo.clone(), + }; + let result = list_wrapups::execute(&deps, ListWrapUpsQuery { user_id: None }) .await .unwrap(); assert_eq!(result.len(), 2); diff --git a/crates/composition/Cargo.toml b/crates/composition/Cargo.toml new file mode 100644 index 0000000..e4dab62 --- /dev/null +++ b/crates/composition/Cargo.toml @@ -0,0 +1,46 @@ +[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/build.rs b/crates/composition/src/build.rs new file mode 100644 index 0000000..d9229fc --- /dev/null +++ b/crates/composition/src/build.rs @@ -0,0 +1,391 @@ +use application::auth::deps::{ + LoginDeps, LogoutDeps, RefreshDeps, RegisterAndLoginDeps, RegisterDeps, +}; +use application::deps::{ + AuthGroup, Deps, DiaryGroup, GoalsGroup, ImportGroup, IntegrationsGroup, MoviesGroup, + PersonGroup, SearchGroup, SocialGroup, UsersGroup, WatchlistGroup, WorkerDeps, WrapupGroup, +}; +use application::diary::deps::{ + DeleteReviewDeps, EditReviewDeps, ExportDiaryDeps, GetActivityFeedDeps, GetDiaryDeps, + GetMovieSocialPageDeps, GetReviewHistoryDeps, GetUserFeedDeps, +}; +use application::goals::deps::{GoalCommandDeps, GoalQueryDeps}; +use application::import::deps::{ + ApplyMappingDeps, ApplyProfileAndMapDeps, ApplyProfileDeps, CreateSessionDeps, + DeleteImportProfileDeps, ExecuteImportDeps, GetMappingStageDeps, GetPreviewStageDeps, + GetSessionStateDeps, ListImportProfilesDeps, SaveProfileDeps, +}; +use application::integrations::deps::{ + ConfirmWatchEventsDeps, DismissWatchEventsDeps, GenerateWebhookTokenDeps, GetWatchQueueDeps, + GetWebhookTokensDeps, IngestWatchEventDeps, RevokeWebhookTokenDeps, +}; +use application::movies::deps::{ + EnrichMovieDeps, GetMovieProfileDeps, GetMoviesDeps, ReindexSearchDeps, SyncPosterDeps, +}; +use application::movies::merge_duplicates::MergeDuplicatesDeps; +use application::person::deps::{EnrichPersonDeps, GetPersonDeps}; +use application::search::deps::SearchDeps; +use application::social::deps::{SocialCommandDeps, SocialQueryDeps}; +use application::users::deps::{ + AuthorizeAdminDeps, DeleteAccountDeps, GetCurrentProfileDeps, GetFederatedProfileDeps, + GetFederatedProfileStatsDeps, GetLocalProfileDeps, GetPageViewerDeps, GetProfileSettingsDeps, + GetSettingsDeps, GetUsersListDeps, ResolveUsernameDeps, UpdateProfileDeps, + UpdateProfileFieldsDeps, UpdateSettingsDeps, +}; +use application::watchlist::deps::{ + GetWatchlistDeps, GetWatchlistForOwnerDeps, IsOnWatchlistDeps, RemoveFromWatchlistDeps, + WatchlistAddDeps, +}; +use application::wrapup::deps::{ + DeleteWrapUpDeps, GenerateWrapUpDeps, GetReadyReportDeps, GetWrapUpDeps, + HandleWrapUpRequestedDeps, ListWrapUpsDeps, +}; +use domain::value_objects::InstanceIdentity; + +use crate::{DatabaseOutput, Repositories}; + +/// The composition root's single assembly point: every one of the server-facing +/// deps structs, built once from the already-constructed `Repositories` and +/// `application::Services`. Only `Arc` clones happen here — no adapters are built. +pub fn build_deps( + repos: &Repositories, + services: &application::Services, + config: &application::config::AppConfig, + instance: &InstanceIdentity, +) -> Deps { + Deps { + auth: AuthGroup { + login: LoginDeps { + user: repos.user.clone(), + password_hasher: services.password_hasher.clone(), + auth: services.auth.clone(), + refresh_session: repos.refresh_session.clone(), + config: config.clone(), + }, + register: RegisterDeps { + user: repos.user.clone(), + password_hasher: services.password_hasher.clone(), + config: config.clone(), + }, + refresh: RefreshDeps { + refresh_session: repos.refresh_session.clone(), + auth: services.auth.clone(), + config: config.clone(), + }, + register_and_login: RegisterAndLoginDeps { + user: repos.user.clone(), + password_hasher: services.password_hasher.clone(), + auth: services.auth.clone(), + refresh_session: repos.refresh_session.clone(), + config: config.clone(), + }, + logout: LogoutDeps { + refresh_session: repos.refresh_session.clone(), + }, + }, + diary: DiaryGroup { + delete_review: DeleteReviewDeps { + review: repos.review.clone(), + diary: repos.diary.clone(), + 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(), + diary: repos.diary.clone(), + movie_profile: repos.movie_profile.clone(), + }, + get_activity_feed: GetActivityFeedDeps { + diary: repos.diary.clone(), + social_query: repos.follow_graph.clone(), + config: config.clone(), + }, + get_user_feed: GetUserFeedDeps { + user: repos.user.clone(), + diary: repos.diary.clone(), + }, + get_diary: GetDiaryDeps { + diary: repos.diary.clone(), + }, + get_review_history: GetReviewHistoryDeps { + diary: repos.diary.clone(), + }, + export_diary: ExportDiaryDeps { + diary: repos.diary.clone(), + diary_exporter: services.diary_exporter.clone(), + }, + }, + goals: GoalsGroup { + command: GoalCommandDeps { + goal_command: repos.goal_command.clone(), + goal_query: repos.goal_query.clone(), + stats: repos.stats.clone(), + event_publisher: services.event_publisher.clone(), + }, + query: GoalQueryDeps { + goal_query: repos.goal_query.clone(), + stats: repos.stats.clone(), + }, + }, + import: ImportGroup { + create_session: CreateSessionDeps { + import_session: repos.import_session.clone(), + document_parser: services.document_parser.clone(), + }, + apply_mapping: ApplyMappingDeps { + import_session: repos.import_session.clone(), + document_parser: services.document_parser.clone(), + movie_query: repos.movie_query.clone(), + }, + apply_profile: ApplyProfileDeps { + import_profile: repos.import_profile.clone(), + import_session: repos.import_session.clone(), + }, + execute_import: ExecuteImportDeps { + import_session: repos.import_session.clone(), + review_logger: services.review_logger.clone(), + }, + save_profile: SaveProfileDeps { + import_session: repos.import_session.clone(), + import_profile: repos.import_profile.clone(), + }, + get_mapping_stage: GetMappingStageDeps { + import_session: repos.import_session.clone(), + }, + get_preview_stage: GetPreviewStageDeps { + import_session: repos.import_session.clone(), + }, + get_session_state: GetSessionStateDeps { + import_session: repos.import_session.clone(), + }, + apply_profile_and_map: ApplyProfileAndMapDeps { + import_profile: repos.import_profile.clone(), + import_session: repos.import_session.clone(), + document_parser: services.document_parser.clone(), + movie_query: repos.movie_query.clone(), + }, + delete_profile: DeleteImportProfileDeps { + import_profile: repos.import_profile.clone(), + }, + list_profiles: ListImportProfilesDeps { + import_profile: repos.import_profile.clone(), + }, + }, + integrations: IntegrationsGroup { + ingest_watch_event: IngestWatchEventDeps { + webhook_token: repos.webhook_token.clone(), + watch_event_command: repos.watch_event_command.clone(), + watch_event_query: repos.watch_event_query.clone(), + event_publisher: services.event_publisher.clone(), + }, + confirm_watch_events: ConfirmWatchEventsDeps { + watch_event_command: repos.watch_event_command.clone(), + watch_event_query: repos.watch_event_query.clone(), + review_logger: services.review_logger.clone(), + }, + dismiss_watch_events: DismissWatchEventsDeps { + watch_event_command: repos.watch_event_command.clone(), + watch_event_query: repos.watch_event_query.clone(), + }, + generate_webhook_token: GenerateWebhookTokenDeps { + webhook_token: repos.webhook_token.clone(), + }, + get_watch_queue: GetWatchQueueDeps { + watch_event_query: repos.watch_event_query.clone(), + }, + get_webhook_tokens: GetWebhookTokensDeps { + webhook_token: repos.webhook_token.clone(), + }, + revoke_webhook_token: RevokeWebhookTokenDeps { + webhook_token: repos.webhook_token.clone(), + }, + jellyfin_parser: std::sync::Arc::new(jellyfin::JellyfinParser), + plex_parser: std::sync::Arc::new(plex::PlexParser), + }, + movies: MoviesGroup { + sync_poster: SyncPosterDeps { + movie_command: repos.movie_command.clone(), + movie_query: repos.movie_query.clone(), + movie_profile: repos.movie_profile.clone(), + metadata: services.metadata.clone(), + poster_fetcher: services.poster_fetcher.clone(), + object_storage: services.object_storage.clone(), + event_publisher: services.event_publisher.clone(), + search_command: repos.search_command.clone(), + }, + get_movie_profile: GetMovieProfileDeps { + movie_profile: repos.movie_profile.clone(), + }, + get_movies: GetMoviesDeps { + movie: repos.movie_query.clone(), + }, + }, + person: PersonGroup { + get_person: GetPersonDeps { + person_query: repos.person_query.clone(), + event_publisher: services.event_publisher.clone(), + }, + }, + search: SearchGroup { + execute: SearchDeps { + search_port: repos.search_port.clone(), + }, + }, + social: SocialGroup { + command: SocialCommandDeps { + social_command: repos.social_command.clone(), + event_publisher: services.event_publisher.clone(), + }, + query: SocialQueryDeps { + follow_graph: repos.follow_graph.clone(), + block_query: repos.block_query.clone(), + }, + }, + users: UsersGroup { + get_local_profile: GetLocalProfileDeps { + stats: repos.stats.clone(), + diary: repos.diary.clone(), + social_query: repos.follow_graph.clone(), + user: repos.user.clone(), + instance: instance.clone(), + }, + get_federated_profile_stats: GetFederatedProfileStatsDeps { + stats: repos.stats.clone(), + diary: repos.diary.clone(), + social_query: repos.follow_graph.clone(), + }, + get_page_viewer: GetPageViewerDeps { + user: repos.user.clone(), + follow_graph: repos.follow_graph.clone(), + }, + resolve_username: ResolveUsernameDeps { + user: repos.user.clone(), + }, + get_profile_settings: GetProfileSettingsDeps { + user: repos.user.clone(), + profile_fields: repos.profile_fields.clone(), + instance: instance.clone(), + }, + get_users_list: GetUsersListDeps { + user: repos.user.clone(), + federation_admin: repos.federation_admin.clone(), + }, + update_profile: UpdateProfileDeps { + user: repos.user.clone(), + object_storage: services.object_storage.clone(), + event_publisher: services.event_publisher.clone(), + }, + delete_account: DeleteAccountDeps { + 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(), + }, + update_settings: UpdateSettingsDeps { + user_settings: repos.user_settings.clone(), + }, + authorize_admin: AuthorizeAdminDeps { + user: repos.user.clone(), + }, + get_federated_profile: GetFederatedProfileDeps { + federated_profile: repos.federated_profile.clone(), + }, + }, + watchlist: WatchlistGroup { + add: WatchlistAddDeps { + movie_command: repos.movie_command.clone(), + movie_query: repos.movie_query.clone(), + metadata: services.metadata.clone(), + watchlist: repos.watchlist.clone(), + event_publisher: services.event_publisher.clone(), + }, + get_watchlist_for_owner: GetWatchlistForOwnerDeps { + user: repos.user.clone(), + watchlist: repos.watchlist.clone(), + remote_watchlist: repos.remote_watchlist.clone(), + }, + get_watchlist: GetWatchlistDeps { + watchlist: repos.watchlist.clone(), + }, + is_on_watchlist: IsOnWatchlistDeps { + watchlist: repos.watchlist.clone(), + }, + remove_from_watchlist: RemoveFromWatchlistDeps { + watchlist: repos.watchlist.clone(), + event_publisher: services.event_publisher.clone(), + }, + }, + wrapup: WrapupGroup { + get_ready_report: GetReadyReportDeps { + wrapup_repo: repos.wrapup_repo.clone(), + }, + delete_wrapup: DeleteWrapUpDeps { + wrapup_repo: repos.wrapup_repo.clone(), + }, + generate: GenerateWrapUpDeps { + wrapup_repo: repos.wrapup_repo.clone(), + event_publisher: services.event_publisher.clone(), + }, + get_wrapup: GetWrapUpDeps { + wrapup_repo: repos.wrapup_repo.clone(), + }, + list_wrapups: ListWrapUpsDeps { + wrapup_repo: repos.wrapup_repo.clone(), + }, + }, + } +} + +/// The worker binary's assembly point: the five deps structs that have no consumer +/// the server binary can ever reach, built straight from `DatabaseOutput` — the +/// worker cannot honestly construct a `Repositories` (it lacks the six +/// federation-sourced ports) but every field this function reads is on +/// `DatabaseOutput` — plus `application::WorkerServices`, the strict subset of +/// ports the worker actually constructs. +pub fn build_worker_deps( + db: &DatabaseOutput, + services: &application::WorkerServices, +) -> WorkerDeps { + WorkerDeps { + enrich_movie: EnrichMovieDeps { + movie_query: db.movie_query.clone(), + movie_profile: db.movie_profile.clone(), + person_command: db.person_command.clone(), + search_command: db.search_command.clone(), + }, + reindex_search: ReindexSearchDeps { + movie_query: db.movie_query.clone(), + movie_profile: db.movie_profile.clone(), + search_command: db.search_command.clone(), + person_command: db.person_command.clone(), + person_query: db.person_query.clone(), + }, + merge_duplicates: MergeDuplicatesDeps { + movie_query: db.movie_query.clone(), + deduplicator: db.deduplicator.clone(), + object_storage: services.object_storage.clone(), + }, + enrich_person: EnrichPersonDeps { + person_query: db.person_query.clone(), + person_enrichment: services.person_enrichment.clone(), + person_command: db.person_command.clone(), + }, + handle_requested: HandleWrapUpRequestedDeps { + wrapup_repo: db.wrapup_repo.clone(), + event_publisher: services.event_publisher.clone(), + wrapup_stats: db.wrapup_stats.clone(), + }, + } +} diff --git a/crates/presentation/src/factory.rs b/crates/composition/src/factory.rs similarity index 80% rename from crates/presentation/src/factory.rs rename to crates/composition/src/factory.rs index f201e5a..3c225b5 100644 --- a/crates/presentation/src/factory.rs +++ b/crates/composition/src/factory.rs @@ -2,9 +2,10 @@ use std::sync::Arc; use anyhow::Context; use domain::ports::{ - AuthService, LocalApContentQuery, MetadataClient, ObjectStorage, PasswordHasher, - PosterFetcherClient, RefreshSessionRepository, UserProfileFieldsRepository, WatchEventCommand, - WatchEventQuery, WebhookTokenRepository, + AuthService, ImageRefCommand, ImageRefQuery, LocalApContentQuery, MetadataClient, + MovieDeduplicator, ObjectStorage, PasswordHasher, PosterFetcherClient, + RefreshSessionRepository, UserProfileFieldsRepository, WatchEventCommand, WatchEventQuery, + WebhookTokenRepository, }; pub use infra_wiring::DbPool; @@ -37,16 +38,26 @@ pub struct DatabaseOutput { 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) -> anyhow::Result { +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()); @@ -54,6 +65,10 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result 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, @@ -84,6 +99,11 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result 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), }) } @@ -92,12 +112,17 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result 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, @@ -127,6 +152,11 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result 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), }) } diff --git a/crates/composition/src/lib.rs b/crates/composition/src/lib.rs new file mode 100644 index 0000000..88e56cb --- /dev/null +++ b/crates/composition/src/lib.rs @@ -0,0 +1,11 @@ +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/repositories.rs b/crates/composition/src/repositories.rs new file mode 100644 index 0000000..0333c5c --- /dev/null +++ b/crates/composition/src/repositories.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; + +use domain::ports::{ + BlockQuery, DiaryQuery, FederatedProfileQuery, FederationAdminQuery, FollowGraphQuery, + GoalCommand, GoalQuery, ImportProfileRepository, ImportSessionRepository, MovieCommand, + MovieProfileRepository, MovieQuery, PersonCommand, PersonQuery, RefreshSessionRepository, + RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository, SearchCommand, SearchPort, + SocialCommand, StatsRepository, UserProfileFieldsRepository, UserRepository, + UserSettingsRepository, WatchEventCommand, WatchEventQuery, WatchlistRepository, + WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery, +}; + +#[derive(Clone)] +pub struct Repositories { + pub movie_command: Arc, + 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 remote_watchlist: Arc, + pub social_command: Arc, + pub follow_graph: Arc, + pub block_query: Arc, + pub federation_admin: Arc, + pub wrapup_stats: Arc, + pub wrapup_repo: Arc, + pub goal_command: Arc, + pub goal_query: Arc, + pub user_settings: Arc, + pub remote_goal: Arc, + pub refresh_session: Arc, + pub federated_profile: Option>, +} diff --git a/crates/composition/src/tests/build.rs b/crates/composition/src/tests/build.rs new file mode 100644 index 0000000..7171cb7 --- /dev/null +++ b/crates/composition/src/tests/build.rs @@ -0,0 +1,1087 @@ +use std::sync::Arc; + +use application::config::AppConfig; +use domain::testing::{ + FakeAuthService, FakeDocumentParser, FakeMetadataClient, FakePasswordHasher, FakePersonQuery, + FakePosterFetcher, FakeSearchCommand, FakeSearchPort, FakeStatsRepository, + InMemoryGoalRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository, + 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(), + 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(), + 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/domain/Cargo.toml b/crates/domain/Cargo.toml index 8c86696..1e2cc03 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -15,5 +15,8 @@ serde_json = { workspace = true } email_address = "0.2.9" +[dev-dependencies] +tokio = { workspace = true } + [features] test-helpers = [] diff --git a/crates/domain/src/models/federation.rs b/crates/domain/src/models/federation.rs index e67697a..f89a2bc 100644 --- a/crates/domain/src/models/federation.rs +++ b/crates/domain/src/models/federation.rs @@ -38,3 +38,23 @@ pub struct FederatedProfile { pub avatar_url: Option, pub banner_url: Option, } + +/// A domain-blocklist entry as presented to admin surfaces. Mirrors what the +/// federation adapter can supply; `blocked_at` is a pre-formatted string +/// because that is what the underlying store returns and every consumer +/// renders it verbatim. +#[derive(Debug, Clone)] +pub struct BlockedDomainInfo { + pub domain: String, + pub reason: Option, + pub blocked_at: String, +} + +/// A remote actor the local instance follows, reduced to the two fields any +/// consumer actually reads. Deliberately narrower than the federation +/// library's actor type — widening it is a decision, not an oversight. +#[derive(Debug, Clone)] +pub struct FollowedActorInfo { + pub url: String, + pub outbox_url: Option, +} diff --git a/crates/domain/src/ports/federation.rs b/crates/domain/src/ports/federation.rs new file mode 100644 index 0000000..d2074b1 --- /dev/null +++ b/crates/domain/src/ports/federation.rs @@ -0,0 +1,103 @@ +use async_trait::async_trait; +use uuid::Uuid; + +use crate::{ + errors::DomainError, + models::{BlockedDomainInfo, FollowedActorInfo}, +}; + +/// Serves ActivityPub documents for content negotiation. Presentation calls +/// this when a peer sends `Accept: application/activity+json`. +#[async_trait] +pub trait ApDocumentPort: Send + Sync { + async fn actor_json(&self, user_id: &str) -> Result; + async fn followers_collection_json( + &self, + user_id: Uuid, + page: Option, + ) -> Result; + async fn following_collection_json( + &self, + user_id: Uuid, + page: Option, + ) -> Result; +} + +/// Instance-wide domain blocklist administration. Presentation calls this from +/// the admin API and the admin HTML pages; nothing outside presentation does. +#[async_trait] +pub trait InstanceBlocklistPort: Send + Sync { + async fn get_blocked_domains(&self) -> Result, DomainError>; + async fn add_blocked_domain( + &self, + domain: &str, + reason: Option<&str>, + ) -> Result<(), DomainError>; + async fn remove_blocked_domain(&self, domain: &str) -> Result<(), DomainError>; +} + +/// Pulling remote content in, and pushing local content out, after a follow +/// is established. Worker-side only — no HTTP handler calls this. +#[async_trait] +pub trait ApBackfillPort: Send + Sync { + async fn get_following( + &self, + local_user_id: Uuid, + ) -> Result, DomainError>; + async fn import_remote_outbox( + &self, + outbox_url: &str, + actor_url: &str, + ) -> Result<(), DomainError>; + async fn run_backfill_for_follower( + &self, + owner_user_id: Uuid, + 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 0fa9bcf..427ed16 100644 --- a/crates/domain/src/ports/follow.rs +++ b/crates/domain/src/ports/follow.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use crate::{ errors::DomainError, - value_objects::{FollowStatus, SocialActor}, + value_objects::{FollowRelation, FollowStatus, SocialActor}, }; #[async_trait] @@ -50,31 +50,29 @@ pub trait FollowCommand: Send + Sync { #[async_trait] pub trait FollowQuery: Send + Sync { - async fn get_following( - &self, - user_id: uuid::Uuid, - base_url: &str, - ) -> Result, DomainError>; + async fn get_following(&self, user_id: uuid::Uuid) -> Result, DomainError>; - async fn get_followers( - &self, - user_id: uuid::Uuid, - base_url: &str, - ) -> Result, DomainError>; + async fn get_followers(&self, user_id: uuid::Uuid) -> Result, DomainError>; async fn get_pending_followers( &self, user_id: uuid::Uuid, - base_url: &str, + ) -> Result, DomainError>; + + async fn get_pending_following( + &self, + user_id: uuid::Uuid, ) -> Result, DomainError>; async fn count_following(&self, user_id: uuid::Uuid) -> Result; async fn count_followers(&self, user_id: uuid::Uuid) -> Result; - async fn is_following( + async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result; + + async fn get_relation( &self, - follower_id: uuid::Uuid, + viewer_id: uuid::Uuid, target_actor_url: &str, - ) -> Result; + ) -> Result; } diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs index 7b4f6f9..9c90deb 100644 --- a/crates/domain/src/ports/mod.rs +++ b/crates/domain/src/ports/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod diary; pub mod events; pub mod federated_profile; +pub mod federation; pub mod follow; pub mod goals; pub mod image_fetcher; @@ -22,6 +23,7 @@ pub use auth::*; pub use diary::*; pub use events::*; pub use federated_profile::*; +pub use federation::*; pub use follow::*; pub use goals::*; pub use image_fetcher::*; diff --git a/crates/domain/src/ports/noop.rs b/crates/domain/src/ports/noop.rs index 592bb17..714147f 100644 --- a/crates/domain/src/ports/noop.rs +++ b/crates/domain/src/ports/noop.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use crate::{ errors::DomainError, - value_objects::{SocialActor, SocialIdentity, UserId}, + value_objects::{FollowRelation, SocialActor, SocialIdentity, UserId}, }; // ── NoopRemoteWatchlistRepository ───────────────────────────────────────────── @@ -73,7 +73,7 @@ impl super::SocialCommand for NoopSocialCommand { pub struct NoopSocialQuery; #[async_trait] -impl super::SocialQuery for NoopSocialQuery { +impl super::FollowGraphQuery for NoopSocialQuery { async fn get_following(&self, _: &UserId) -> Result, DomainError> { Ok(vec![]) } @@ -83,18 +83,32 @@ impl super::SocialQuery for NoopSocialQuery { async fn get_pending_followers(&self, _: &UserId) -> Result, DomainError> { Ok(vec![]) } + async fn get_pending_following(&self, _: &UserId) -> Result, DomainError> { + Ok(vec![]) + } async fn count_following(&self, _: &UserId) -> Result { Ok(0) } async fn count_followers(&self, _: &UserId) -> Result { Ok(0) } + async fn count_pending_followers(&self, _: &UserId) -> Result { + Ok(0) + } + async fn get_relation( + &self, + _: &UserId, + _: &SocialIdentity, + ) -> Result { + Ok(FollowRelation::default()) + } +} + +#[async_trait] +impl super::BlockQuery for NoopSocialQuery { async fn get_blocked(&self, _: &UserId) -> Result, DomainError> { Ok(vec![]) } - async fn is_following(&self, _: &UserId, _: &SocialIdentity) -> Result { - Ok(false) - } } // ── NoopFederationAdminQuery ───────────────────────────────────────────────── @@ -110,3 +124,70 @@ impl super::FederationAdminQuery for NoopFederationAdminQuery { Ok(vec![]) } } + +// ── NoopApDocument ─────────────────────────────────────────────────────────── + +/// Stub used when federation is disabled — every operation is a no-op. +pub struct NoopApDocument; + +#[async_trait] +impl super::ApDocumentPort for NoopApDocument { + async fn actor_json(&self, _: &str) -> Result { + Ok(String::new()) + } + async fn followers_collection_json( + &self, + _: uuid::Uuid, + _: Option, + ) -> Result { + Ok(String::new()) + } + async fn following_collection_json( + &self, + _: uuid::Uuid, + _: Option, + ) -> Result { + Ok(String::new()) + } +} + +// ── NoopInstanceBlocklist ──────────────────────────────────────────────────── + +/// Stub used when federation is disabled — every operation is a no-op. +pub struct NoopInstanceBlocklist; + +#[async_trait] +impl super::InstanceBlocklistPort for NoopInstanceBlocklist { + async fn get_blocked_domains( + &self, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn add_blocked_domain(&self, _: &str, _: Option<&str>) -> Result<(), DomainError> { + Ok(()) + } + async fn remove_blocked_domain(&self, _: &str) -> Result<(), DomainError> { + Ok(()) + } +} + +// ── NoopApBackfill ─────────────────────────────────────────────────────────── + +/// Stub used when federation is disabled — every operation is a no-op. +pub struct NoopApBackfill; + +#[async_trait] +impl super::ApBackfillPort for NoopApBackfill { + async fn get_following( + &self, + _: uuid::Uuid, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn import_remote_outbox(&self, _: &str, _: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn run_backfill_for_follower(&self, _: uuid::Uuid, _: String) -> Result<(), DomainError> { + Ok(()) + } +} diff --git a/crates/domain/src/ports/social.rs b/crates/domain/src/ports/social.rs index 2530083..f268123 100644 --- a/crates/domain/src/ports/social.rs +++ b/crates/domain/src/ports/social.rs @@ -7,7 +7,7 @@ use crate::{ DiaryEntry, FederationFlags, RemoteActorInfo, RemoteGoalEntry, RemoteWatchlistEntry, WatchlistWithMovie, }, - value_objects::{FollowTarget, MovieId, SocialActor, SocialIdentity, UserId}, + value_objects::{FollowRelation, FollowTarget, MovieId, SocialActor, SocialIdentity, UserId}, }; // ── Unified social ports (ADR-0002) ───────────────────────────────────────── @@ -43,24 +43,31 @@ pub trait SocialCommand: Send + Sync { } #[async_trait] -pub trait SocialQuery: Send + Sync { +pub trait FollowGraphQuery: Send + Sync { async fn get_following(&self, user: &UserId) -> Result, DomainError>; async fn get_followers(&self, user: &UserId) -> Result, DomainError>; async fn get_pending_followers(&self, user: &UserId) -> Result, DomainError>; + async fn get_pending_following(&self, user: &UserId) -> Result, DomainError>; + async fn count_following(&self, user: &UserId) -> Result; async fn count_followers(&self, user: &UserId) -> Result; - async fn get_blocked(&self, user: &UserId) -> Result, DomainError>; + async fn count_pending_followers(&self, user: &UserId) -> Result; - async fn is_following( + async fn get_relation( &self, - follower: &UserId, + viewer: &UserId, target: &SocialIdentity, - ) -> Result; + ) -> Result; +} + +#[async_trait] +pub trait BlockQuery: Send + Sync { + async fn get_blocked(&self, user: &UserId) -> Result, DomainError>; } #[async_trait] @@ -119,3 +126,223 @@ pub trait LocalApContentQuery: Send + Sync { limit: usize, ) -> Result, DomainError>; } + +/// Resolves a `FollowTarget` (a handle or an already-known identity) to the +/// `SocialIdentity` that should be dispatched on — local vs. remote. +/// +/// Split out from `SocialCommand::follow` because the decision must happen +/// once, before dispatch, and both `LocalSocialService` and +/// `CompositeSocialAdapter` need to observe its result rather than each +/// re-deriving it (which would need `UserRepository` on the composite and +/// duplicate the local/remote fallthrough logic). +#[async_trait] +pub trait FollowTargetResolver: Send + Sync { + async fn resolve_target(&self, target: &FollowTarget) -> Result; +} + +/// Performs the local follow write for a target whose `SocialIdentity` a caller +/// has *already* resolved via `FollowTargetResolver::resolve_target`. +/// +/// Exists so `CompositeSocialAdapter` — which must call `resolve_target` first +/// to decide local vs. remote dispatch — doesn't then hand the raw +/// `FollowTarget` to `SocialCommand::follow` and pay for a second resolution of +/// the same handle. That second resolution wasn't just wasteful: on a +/// federation-ON deployment it could also change the answer (the target user +/// deleted between the two calls, e.g.), turning a local follow into a bogus +/// "federation is not enabled" error. +/// +/// `LocalSocialService::follow` is `resolve_target_identity` then +/// `follow_resolved` — the self-follow guard and the two-sided write live only +/// in the latter, so a caller that already has the identity (the composite) +/// and one that doesn't (federation-off `SocialCommand::follow`, wired +/// directly) both end up running the exact same write path. +#[async_trait] +pub trait ResolvedFollow: Send + Sync { + async fn follow_resolved( + &self, + follower: &UserId, + target: &SocialIdentity, + ) -> Result<(), DomainError>; +} + +/// The subset of social behavior that needs no ActivityPub — everything a +/// single-instance deployment can do with only its own database. +/// +/// A marker supertrait with a blanket impl, so any type implementing all five +/// parts is usable as `Arc` without a separate registration. +/// Same shape as `k_ap::FollowRepository` over its five follow traits. +/// +/// `CompositeSocialAdapter` takes one of these for its local branches, which is +/// what lets the `activitypub` crate stay unaware of `application`. +pub trait LocalSocial: + SocialCommand + FollowGraphQuery + BlockQuery + FollowTargetResolver + ResolvedFollow +{ +} + +impl + LocalSocial for T +{ +} + +#[cfg(test)] +mod local_social_tests { + use super::*; + use std::sync::Arc; + + struct Stub; + + #[async_trait] + impl SocialCommand for Stub { + async fn follow( + &self, + _: &crate::value_objects::UserId, + _: &crate::value_objects::FollowTarget, + ) -> 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(()) + } + } + + /// The blanket impl must make any type implementing all five usable as + /// `Arc`, and `LocalSocial` must stay object-safe. + #[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/testing/fakes.rs b/crates/domain/src/testing/fakes.rs index b39c8af..7f883ac 100644 --- a/crates/domain/src/testing/fakes.rs +++ b/crates/domain/src/testing/fakes.rs @@ -85,12 +85,14 @@ impl MetadataClient for FakeMetadataClient { pub struct FakeDiaryQuery { histories: Mutex)>>, + diary_page: Mutex>>, } impl FakeDiaryQuery { pub fn new() -> Arc { Arc::new(Self { histories: Mutex::new(HashMap::new()), + diary_page: Mutex::new(None), }) } @@ -100,6 +102,13 @@ impl FakeDiaryQuery { .unwrap() .insert(movie.id().value(), (movie, reviews)); } + + /// Configures what `query_diary` returns. Without this, `query_diary` keeps + /// its original always-empty behavior — this is purely opt-in for tests that + /// need to prove entries actually flow through a use case. + pub fn set_diary_page(&self, page: Paginated) { + *self.diary_page.lock().unwrap() = Some(page); + } } #[async_trait] @@ -108,12 +117,17 @@ impl DiaryQuery for FakeDiaryQuery { &self, _filter: &DiaryFilter, ) -> Result, DomainError> { - Ok(Paginated { - items: vec![], - total_count: 0, - limit: 10, - offset: 0, - }) + Ok(self + .diary_page + .lock() + .unwrap() + .clone() + .unwrap_or(Paginated { + items: vec![], + total_count: 0, + limit: 10, + offset: 0, + })) } async fn query_activity_feed( diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs index 78b64b0..0503889 100644 --- a/crates/domain/src/testing/in_memory.rs +++ b/crates/domain/src/testing/in_memory.rs @@ -12,21 +12,23 @@ use crate::{ errors::DomainError, models::{ FederationFlags, Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, - MovieSummary, ProfileField, RefreshSession, Review, User, UserSettings, UserSummary, - WatchEvent, WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken, + MovieSummary, ProfileField, RefreshSession, RemoteWatchlistEntry, Review, User, + UserSettings, UserSummary, WatchEvent, WatchEventStatus, WatchlistEntry, + WatchlistWithMovie, WebhookToken, collections::{PageParams, Paginated}, }, ports::{ - GoalCommand, GoalQuery, ImportProfileRepository, ImportSessionRepository, MovieCommand, - MovieProfileRepository, MovieQuery, RefreshSessionRepository, ReviewRepository, - SocialCommand, SocialQuery, UserFederationSettingsQuery, UserProfileFieldsRepository, - UserRepository, UserSettingsRepository, WatchEventCommand, WatchEventQuery, - WatchlistRepository, WebhookTokenRepository, + 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, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, - ReleaseYear, ReviewId, SocialActor, SocialIdentity, UserId, Username, WatchEventId, - WebhookTokenId, + Email, ExternalMetadataId, FollowRelation, FollowStatus, GoalId, ImportProfileId, + ImportSessionId, MovieId, MovieTitle, ReleaseYear, ReviewId, SocialActor, SocialIdentity, + UserId, Username, WatchEventId, WebhookTokenId, }, }; @@ -264,9 +266,13 @@ impl UserRepository for InMemoryUserRepository { async fn update_profile( &self, - _user_id: &UserId, - _profile: &crate::models::UserProfile, + 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(()) } } @@ -339,6 +345,78 @@ impl WatchlistRepository for InMemoryWatchlistRepository { } } +// ── InMemoryRemoteWatchlistRepository ───────────────────────────────────────── + +/// Unlike `NoopRemoteWatchlistRepository`, this actually stores what it's given — +/// needed by tests that must prove a federated-watchlist read returns real seeded +/// data, not just an empty default that happens to match by coincidence. +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(()) + } + + /// Real adapters derive `uuid` from the actor URL via a SQL-side hash — not + /// domain logic, so this fake doesn't replicate it. It simply returns + /// everything seeded, which is all callers in this workspace need: tests seed + /// exactly the entries for the owner under test. + async fn get_by_derived_uuid( + &self, + _uuid: Uuid, + ) -> Result, DomainError> { + Ok(self.store.lock().unwrap().clone()) + } +} + // ── InMemoryGoalRepository ────────────────────────────────────────────────── pub struct InMemoryGoalRepository { @@ -1033,7 +1111,7 @@ impl SocialCommand for InMemorySocialRepository { } #[async_trait] -impl SocialQuery for InMemorySocialRepository { +impl FollowGraphQuery for InMemorySocialRepository { async fn get_following(&self, user: &UserId) -> Result, DomainError> { let store = self.follows.lock().unwrap(); Ok(store @@ -1069,6 +1147,15 @@ impl SocialQuery for InMemorySocialRepository { .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 @@ -1086,6 +1173,43 @@ impl SocialQuery for InMemorySocialRepository { .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 @@ -1094,15 +1218,4 @@ impl SocialQuery for InMemorySocialRepository { .map(|(_, t)| Self::identity_to_actor(t)) .collect()) } - - async fn is_following( - &self, - follower: &UserId, - target: &SocialIdentity, - ) -> Result { - let store = self.follows.lock().unwrap(); - Ok(store.iter().any(|(f, t, state)| { - *f == follower.value() && t == target && *state == FollowState::Accepted - })) - } } diff --git a/crates/domain/src/testing/panics.rs b/crates/domain/src/testing/panics.rs index d5aa964..a427a85 100644 --- a/crates/domain/src/testing/panics.rs +++ b/crates/domain/src/testing/panics.rs @@ -7,14 +7,15 @@ use crate::{ FeedEntry, FeedSortBy, FieldMapping, FileFormat, FollowingFilter, ImportError, ImportProfile, ImportSession, IndexableDocument, MovieProfile, MovieStats, ParsedFile, Person, PersonCredits, PersonEnrichmentData, PersonId, RefreshSession, RemoteActorInfo, - ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends, + ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends, WatchlistEntry, + WatchlistWithMovie, collections::{PageParams, Paginated}, }, ports::{ DiaryExporter, DiaryQuery, DocumentParser, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, PersonCommand, PersonQuery, PosterFetcherClient, RefreshSessionRepository, SearchCommand, SearchPort, StatsRepository, - UserProfileFieldsRepository, + UserProfileFieldsRepository, WatchlistRepository, }, value_objects::{ImportProfileId, ImportSessionId, MovieId, PosterUrl, UserId}, }; @@ -280,6 +281,31 @@ impl DocumentParser for PanicDocumentParser { } } +pub struct PanicWatchlistRepository; + +#[async_trait] +impl WatchlistRepository for PanicWatchlistRepository { + async fn add(&self, _: &WatchlistEntry) -> Result<(), DomainError> { + panic!("PanicWatchlistRepository called") + } + async fn remove(&self, _: &UserId, _: &MovieId) -> Result<(), DomainError> { + panic!("PanicWatchlistRepository called") + } + async fn remove_if_present(&self, _: &UserId, _: &MovieId) -> Result { + panic!("PanicWatchlistRepository called") + } + async fn get_for_user( + &self, + _: &UserId, + _: &PageParams, + ) -> Result, DomainError> { + panic!("PanicWatchlistRepository called") + } + async fn contains(&self, _: &UserId, _: &MovieId) -> Result { + panic!("PanicWatchlistRepository called") + } +} + pub struct PanicRemoteWatchlistRepository; #[async_trait] diff --git a/crates/domain/src/tests/value_objects.rs b/crates/domain/src/tests/value_objects.rs index 11dabb7..beed6f0 100644 --- a/crates/domain/src/tests/value_objects.rs +++ b/crates/domain/src/tests/value_objects.rs @@ -200,3 +200,96 @@ fn watch_medium_display_round_trips() { assert_eq!(parsed, v); } } + +// ── InstanceIdentity ──────────────────────────────────────────────────────── + +fn instance() -> InstanceIdentity { + InstanceIdentity::new("https://md.example") +} + +#[test] +fn instance_normalizes_trailing_slash() { + assert_eq!( + InstanceIdentity::new("https://md.example/").base_url(), + "https://md.example" + ); + assert_eq!( + InstanceIdentity::new("https://md.example").base_url(), + "https://md.example" + ); +} + +#[test] +fn instance_host_strips_scheme_and_path() { + assert_eq!(instance().host(), "md.example"); + assert_eq!( + InstanceIdentity::new("http://localhost:3000").host(), + "localhost:3000" + ); +} + +#[test] +fn instance_builds_actor_url_for_local_user() { + let uid = UserId::from_uuid(uuid::Uuid::nil()); + assert_eq!( + instance().actor_url_for(&uid), + format!("https://md.example/users/{}", uuid::Uuid::nil()) + ); +} + +#[test] +fn instance_builds_handle_and_image_url() { + assert_eq!(instance().handle_for("gabriel"), "@gabriel@md.example"); + assert_eq!( + instance().image_url_for("avatars/a.webp"), + "https://md.example/images/avatars/a.webp" + ); +} + +#[test] +fn instance_identifies_own_actor_url_as_local() { + let uid = UserId::from_uuid(uuid::Uuid::new_v4()); + let url = instance().actor_url_for(&uid); + assert_eq!(instance().identify(&url), SocialIdentity::Local(uid)); +} + +#[test] +fn instance_identifies_foreign_actor_url_as_remote() { + let url = "https://other.example/users/bob"; + assert_eq!( + instance().identify(url), + SocialIdentity::Remote { + actor_url: url.to_string() + } + ); +} + +#[test] +fn instance_identifies_own_host_with_bad_uuid_as_remote() { + let url = "https://md.example/users/not-a-uuid"; + assert_eq!( + instance().identify(url), + SocialIdentity::Remote { + actor_url: url.to_string() + } + ); +} + +#[test] +fn instance_actor_url_round_trips_both_variants() { + let i = instance(); + let local = SocialIdentity::Local(UserId::from_uuid(uuid::Uuid::new_v4())); + let remote = SocialIdentity::Remote { + actor_url: "https://other.example/users/bob".into(), + }; + assert_eq!(i.identify(&i.actor_url_of(&local)), local); + assert_eq!(i.identify(&i.actor_url_of(&remote)), remote); +} + +#[test] +fn instance_rejects_prefix_collision() { + // A different instance whose base_url merely starts the same must not read as local. + let i = InstanceIdentity::new("https://md.example"); + let url = "https://md.example.evil.test/users/00000000-0000-0000-0000-000000000000"; + assert!(matches!(i.identify(url), SocialIdentity::Remote { .. })); +} diff --git a/crates/domain/src/value_objects/instance.rs b/crates/domain/src/value_objects/instance.rs new file mode 100644 index 0000000..7f3e13d --- /dev/null +++ b/crates/domain/src/value_objects/instance.rs @@ -0,0 +1,62 @@ +use super::{SocialIdentity, UserId}; + +/// This instance's own identity on the network. Owns every URL and handle +/// derivation that used to be spelled with a bare `base_url: &str`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InstanceIdentity { + base_url: String, +} + +impl InstanceIdentity { + /// Normalizes away a trailing slash so every derived URL has exactly one. + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_string(), + } + } + + pub fn base_url(&self) -> &str { + &self.base_url + } + + /// Host and port, as it appears in a fediverse handle. + pub fn host(&self) -> &str { + self.base_url + .split("://") + .nth(1) + .and_then(|s| s.split('/').next()) + .unwrap_or("localhost") + } + + pub fn actor_url_for(&self, user: &UserId) -> String { + format!("{}/users/{}", self.base_url, user.value()) + } + + pub fn handle_for(&self, username: &str) -> String { + format!("@{}@{}", username, self.host()) + } + + pub fn image_url_for(&self, path: &str) -> String { + format!("{}/images/{}", self.base_url, path) + } + + pub fn actor_url_of(&self, identity: &SocialIdentity) -> String { + match identity { + SocialIdentity::Local(uid) => self.actor_url_for(uid), + SocialIdentity::Remote { actor_url } => actor_url.clone(), + } + } + + /// Inverse of `actor_url_of`: decides whether an actor URL is ours. + pub fn identify(&self, actor_url: &str) -> SocialIdentity { + let prefix = format!("{}/users/", self.base_url); + if let Some(uuid_str) = actor_url.strip_prefix(&prefix) + && let Ok(uuid) = uuid::Uuid::parse_str(uuid_str) + { + return SocialIdentity::Local(UserId::from_uuid(uuid)); + } + SocialIdentity::Remote { + actor_url: actor_url.to_string(), + } + } +} diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs index 813563e..1d41b86 100644 --- a/crates/domain/src/value_objects/mod.rs +++ b/crates/domain/src/value_objects/mod.rs @@ -1,10 +1,12 @@ mod ids; +mod instance; mod movie; mod review; mod social; mod user; pub use ids::*; +pub use instance::*; pub use movie::*; pub use review::*; pub use social::*; diff --git a/crates/domain/src/value_objects/social.rs b/crates/domain/src/value_objects/social.rs index 41f9eb5..9b6364c 100644 --- a/crates/domain/src/value_objects/social.rs +++ b/crates/domain/src/value_objects/social.rs @@ -7,18 +7,6 @@ pub enum SocialIdentity { } impl SocialIdentity { - pub fn from_actor_url(actor_url: &str, base_url: &str) -> Self { - let prefix = format!("{}/users/", base_url); - if let Some(uuid_str) = actor_url.strip_prefix(&prefix) - && let Ok(uuid) = uuid::Uuid::parse_str(uuid_str) - { - return Self::Local(UserId::from_uuid(uuid)); - } - Self::Remote { - actor_url: actor_url.to_string(), - } - } - pub fn is_local(&self) -> bool { matches!(self, Self::Local(_)) } @@ -26,19 +14,6 @@ impl SocialIdentity { pub fn is_remote(&self) -> bool { matches!(self, Self::Remote { .. }) } - - pub fn format_local_handle(username: &str, base_url: &str) -> String { - let host = Self::host_from_base_url(base_url); - format!("@{}@{}", username, host) - } - - pub fn host_from_base_url(base_url: &str) -> &str { - base_url - .split("://") - .nth(1) - .and_then(|s| s.split('/').next()) - .unwrap_or("localhost") - } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -61,3 +36,11 @@ pub struct SocialActor { pub display_name: Option, pub avatar_url: Option, } + +/// Both directions of the follow edge between a viewer and a target. +/// `None` means no edge at all; `Some(Pending)` means requested but not accepted. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct FollowRelation { + pub following: Option, + pub followed_by: Option, +} diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index 2184bb2..f145fda 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -6,24 +6,9 @@ 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"] -postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search", "infra-wiring/postgres"] -nats = ["dep:nats", "infra-wiring/nats"] +default = ["federation"] # Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working federation = ["application/federation"] -sqlite-federation = [ - "sqlite", - "dep:sqlite-federation", - "dep:activitypub", - "federation", -] -postgres-federation = [ - "postgres", - "dep:postgres-federation", - "dep:activitypub", - "federation", -] [dependencies] tower-http = { version = "0.6.8", features = ["cors", "fs", "trace", "tracing"] } @@ -47,38 +32,14 @@ futures = { workspace = true } api-types = { workspace = true } domain = { workspace = true } application = { workspace = true } -auth = { workspace = true } -metadata = { workspace = true } -poster-fetcher = { workspace = true } -object-storage = { workspace = true } template-askama = { workspace = true } -nats = { workspace = true, optional = true } -rss = { workspace = true } -export = { workspace = true } -importer = { workspace = true } -jellyfin = { workspace = true } -plex = { workspace = true } -sqlx = { workspace = true } -infra-wiring = { 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"] } -# 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 } - [dev-dependencies] bytes = { workspace = true } futures = { workspace = true } tower = { version = "0.5", features = ["util"] } -http-body-util = "0.1" +domain = { workspace = true, features = ["test-helpers"] } +composition = { workspace = true } diff --git a/crates/presentation/src/context.rs b/crates/presentation/src/context.rs index c2bec16..11f7c6b 100644 --- a/crates/presentation/src/context.rs +++ b/crates/presentation/src/context.rs @@ -1,73 +1,15 @@ use std::sync::Arc; -use domain::ports::{ - AuthService, DiaryExporter, DiaryQuery, DocumentParser, EventPublisher, FederatedProfileQuery, - FederationAdminQuery, GoalCommand, GoalQuery, ImportProfileRepository, ImportSessionRepository, - MetadataClient, MovieCommand, MovieProfileRepository, MovieQuery, ObjectStorage, - PasswordHasher, PersonCommand, PersonEnrichmentClient, PersonQuery, PosterFetcherClient, - RefreshSessionRepository, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository, - SearchCommand, SearchPort, SocialCommand, SocialQuery, StatsRepository, - UserProfileFieldsRepository, UserRepository, UserSettingsRepository, WatchEventCommand, - WatchEventQuery, WatchlistRepository, WebhookTokenRepository, WrapUpRepository, - WrapUpStatsQuery, -}; - use application::config::AppConfig; -use application::ports::ReviewLogger; - -#[derive(Clone)] -pub struct Repositories { - 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 remote_watchlist: Arc, - pub social_command: Arc, - pub social_query_unified: Arc, - pub federation_admin: Arc, - pub wrapup_stats: Arc, - pub wrapup_repo: Arc, - pub goal_command: Arc, - pub goal_query: Arc, - pub user_settings: Arc, - pub remote_goal: Arc, - pub refresh_session: Arc, - pub federated_profile: Option>, -} - -#[derive(Clone)] -pub struct Services { - pub auth: Arc, - pub password_hasher: Arc, - pub metadata: Arc, - pub poster_fetcher: Arc, - pub object_storage: Arc, - pub event_publisher: Arc, - pub diary_exporter: Arc, - pub document_parser: Arc, - pub review_logger: Arc, - pub person_enrichment: Option>, - #[cfg(feature = "federation")] - pub ap_service: Arc, -} #[derive(Clone)] pub struct AppContext { - pub repos: Repositories, - pub services: Services, + pub deps: Arc, + pub services: application::Services, pub config: AppConfig, + pub instance: domain::value_objects::InstanceIdentity, + #[cfg(feature = "federation")] + pub ap_document: Arc, + #[cfg(feature = "federation")] + pub ap_blocklist: Arc, } diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index d28c04e..b10d0a2 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -111,17 +111,17 @@ where let AuthenticatedUser(user_id) = AuthenticatedUser::from_request_parts(parts, state).await?; let app_state = AppState::from_ref(state); - let user = app_state - .app_ctx - .repos - .user - .find_by_id(&user_id) - .await - .map_err(ApiError)? - .ok_or_else(|| ApiError(DomainError::NotFound("user not found".into())))?; - match user.role() { - domain::models::UserRole::Admin => Ok(AdminApiUser(user_id)), - _ => Err(ApiError(DomainError::Forbidden("admin only".into()))), + let is_admin = application::users::authorize_admin::execute( + &app_state.app_ctx.deps.users.authorize_admin, + user_id.value(), + ) + .await + .map_err(ApiError)? + .ok_or_else(|| ApiError(DomainError::NotFound("user not found".into())))?; + if is_admin { + Ok(AdminApiUser(user_id)) + } else { + Err(ApiError(DomainError::Forbidden("admin only".into()))) } } } @@ -139,17 +139,17 @@ where let app_state = AppState::from_ref(state); let RequiredCookieUser(user_id) = RequiredCookieUser::from_request_parts(parts, state).await?; - let user = app_state - .app_ctx - .repos - .user - .find_by_id(&user_id) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())? - .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; - match user.role() { - domain::models::UserRole::Admin => Ok(AdminUser(user_id)), - _ => Err(StatusCode::FORBIDDEN.into_response()), + let is_admin = application::users::authorize_admin::execute( + &app_state.app_ctx.deps.users.authorize_admin, + user_id.value(), + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())? + .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; + if is_admin { + Ok(AdminUser(user_id)) + } else { + Err(StatusCode::FORBIDDEN.into_response()) } } } diff --git a/crates/presentation/src/handlers/auth.rs b/crates/presentation/src/handlers/auth.rs index 6cb1ee0..89127aa 100644 --- a/crates/presentation/src/handlers/auth.rs +++ b/crates/presentation/src/handlers/auth.rs @@ -7,11 +7,7 @@ use axum::{ use chrono::Utc; use application::auth::{ - commands::RegisterCommand, - deps::{LoginDeps, RefreshDeps, RegisterAndLoginDeps, RegisterDeps}, - login as login_uc, - queries::LoginCommand, - register as register_uc, + commands::RegisterCommand, login as login_uc, queries::LoginCommand, register as register_uc, }; use crate::{ @@ -56,15 +52,8 @@ pub async fn login( State(state): State, Json(req): Json, ) -> Result, ApiError> { - let deps = LoginDeps { - user: state.app_ctx.repos.user.clone(), - password_hasher: state.app_ctx.services.password_hasher.clone(), - auth: state.app_ctx.services.auth.clone(), - refresh_session: state.app_ctx.repos.refresh_session.clone(), - config: state.app_ctx.config.clone(), - }; let result = login_uc::execute( - &deps, + &state.app_ctx.deps.auth.login, LoginCommand { email: req.email, password: req.password, @@ -93,13 +82,8 @@ pub async fn register( State(state): State, Json(req): Json, ) -> Result { - let deps = RegisterDeps { - user: state.app_ctx.repos.user.clone(), - password_hasher: state.app_ctx.services.password_hasher.clone(), - config: state.app_ctx.config.clone(), - }; register_uc::execute( - &deps, + &state.app_ctx.deps.auth.register, RegisterCommand { email: req.email, username: req.username, @@ -123,12 +107,9 @@ pub async fn refresh( State(state): State, Json(req): Json, ) -> Result, ApiError> { - let deps = RefreshDeps { - refresh_session: state.app_ctx.repos.refresh_session.clone(), - auth: state.app_ctx.services.auth.clone(), - config: state.app_ctx.config.clone(), - }; - let result = application::auth::refresh::execute(&deps, &req.refresh_token).await?; + 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, @@ -147,11 +128,8 @@ pub async fn api_logout( State(state): State, Json(req): Json, ) -> StatusCode { - let _ = application::auth::logout::execute( - state.app_ctx.repos.refresh_session.clone(), - &req.refresh_token, - ) - .await; + let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token) + .await; StatusCode::NO_CONTENT } @@ -172,6 +150,7 @@ pub async fn get_login_page( 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, @@ -187,15 +166,8 @@ pub async fn post_login( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - let deps = LoginDeps { - user: state.app_ctx.repos.user.clone(), - password_hasher: state.app_ctx.services.password_hasher.clone(), - auth: state.app_ctx.services.auth.clone(), - refresh_session: state.app_ctx.repos.refresh_session.clone(), - config: state.app_ctx.config.clone(), - }; match login_uc::execute( - &deps, + &state.app_ctx.deps.auth.login, LoginCommand { email: form.email, password: form.password, @@ -242,6 +214,7 @@ pub async fn get_register_page( 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, @@ -261,15 +234,8 @@ pub async fn post_register( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - let deps = RegisterAndLoginDeps { - user: state.app_ctx.repos.user.clone(), - password_hasher: state.app_ctx.services.password_hasher.clone(), - auth: state.app_ctx.services.auth.clone(), - refresh_session: state.app_ctx.repos.refresh_session.clone(), - config: state.app_ctx.config.clone(), - }; match application::auth::register_and_login::execute( - &deps, + &state.app_ctx.deps.auth.register_and_login, application::auth::commands::RegisterAndLoginCommand { email: form.email, username: form.username, diff --git a/crates/presentation/src/handlers/diary.rs b/crates/presentation/src/handlers/diary.rs index 6dd47c2..1ac9bbe 100644 --- a/crates/presentation/src/handlers/diary.rs +++ b/crates/presentation/src/handlers/diary.rs @@ -8,9 +8,7 @@ use uuid::Uuid; use application::diary::{ commands::{DeleteReviewCommand, EditReviewCommand}, - delete_review, - deps::{DeleteReviewDeps, EditReviewDeps, GetActivityFeedDeps}, - edit_review, get_activity_feed as get_feed_uc, get_diary, log_review, + delete_review, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review, queries::GetActivityFeedQuery, }; @@ -45,7 +43,8 @@ pub async fn get_diary( State(state): State, Query(params): Query, ) -> Result, ApiError> { - let page = get_diary::execute(&state.app_ctx.repos.diary, to_diary_query(params)).await?; + let page = + get_diary::execute(&state.app_ctx.deps.diary.get_diary, to_diary_query(params)).await?; Ok(Json(DiaryResponse { items: page @@ -103,13 +102,7 @@ pub async fn delete_review( review_id, requesting_user_id: user_id.value(), }; - let deps = DeleteReviewDeps { - review: state.app_ctx.repos.review.clone(), - diary: state.app_ctx.repos.diary.clone(), - movie_command: state.app_ctx.repos.movie_command.clone(), - event_publisher: state.app_ctx.services.event_publisher.clone(), - }; - delete_review::execute(&deps, cmd).await?; + delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await?; Ok(StatusCode::NO_CONTENT) } @@ -145,11 +138,7 @@ pub async fn patch_review( watched_at, watch_medium: req.watch_medium, }; - let deps = EditReviewDeps { - review: state.app_ctx.repos.review.clone(), - event_publisher: state.app_ctx.services.event_publisher.clone(), - }; - edit_review::execute(&deps, cmd).await?; + edit_review::execute(&state.app_ctx.deps.diary.edit_review, cmd).await?; Ok(StatusCode::OK) } @@ -180,13 +169,8 @@ pub async fn get_activity_feed( State(state): State, Query(params): Query, ) -> Result, ApiError> { - let deps = GetActivityFeedDeps { - diary: state.app_ctx.repos.diary.clone(), - social_query: state.app_ctx.repos.social_query_unified.clone(), - config: state.app_ctx.config.clone(), - }; let page = get_feed_uc::execute( - &deps, + &state.app_ctx.deps.diary.get_activity_feed, GetActivityFeedQuery { limit: params.limit.unwrap_or(20), offset: params.offset.unwrap_or(0), @@ -274,13 +258,7 @@ pub async fn post_delete_review_html( review_id, requesting_user_id: user_id.value(), }; - let deps = DeleteReviewDeps { - review: state.app_ctx.repos.review.clone(), - diary: state.app_ctx.repos.diary.clone(), - movie_command: state.app_ctx.repos.movie_command.clone(), - event_publisher: state.app_ctx.services.event_publisher.clone(), - }; - match delete_review::execute(&deps, cmd).await { + match delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await { Ok(()) => { let redirect_url = form .redirect_after @@ -335,13 +313,12 @@ pub async fn get_activity_feed_html( filter_following, }; - let deps = GetActivityFeedDeps { - diary: state.app_ctx.repos.diary.clone(), - social_query: state.app_ctx.repos.social_query_unified.clone(), - config: state.app_ctx.config.clone(), - }; - - match application::diary::get_activity_feed::execute(&deps, query).await { + 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; diff --git a/crates/presentation/src/handlers/goals.rs b/crates/presentation/src/handlers/goals.rs index 9a02c7b..2e1778c 100644 --- a/crates/presentation/src/handlers/goals.rs +++ b/crates/presentation/src/handlers/goals.rs @@ -10,8 +10,6 @@ use api_types::{ CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest, UserSettingsDto, }; -use application::goals::deps::{GoalCommandDeps, GoalQueryDeps}; - // ── Shared mapper ──────────────────────────────────────────────────────────── pub fn goal_with_progress_to_dto(g: &domain::models::GoalWithProgress) -> GoalDto { @@ -39,12 +37,8 @@ pub async fn list_goals( State(state): State, user: AuthenticatedUser, ) -> Result, ApiError> { - let deps = GoalQueryDeps { - goal_query: state.app_ctx.repos.goal_query.clone(), - stats: state.app_ctx.repos.stats.clone(), - }; let goals = application::goals::list::execute( - &deps, + &state.app_ctx.deps.goals.query, application::goals::queries::ListGoalsQuery { user_id: user.0.value(), }, @@ -69,14 +63,8 @@ pub async fn create_goal( user: AuthenticatedUser, Json(req): Json, ) -> Result, ApiError> { - let deps = GoalCommandDeps { - goal_command: state.app_ctx.repos.goal_command.clone(), - goal_query: state.app_ctx.repos.goal_query.clone(), - stats: state.app_ctx.repos.stats.clone(), - event_publisher: state.app_ctx.services.event_publisher.clone(), - }; let g = application::goals::create::execute( - &deps, + &state.app_ctx.deps.goals.command, application::goals::commands::CreateGoalCommand { user_id: user.0.value(), year: req.year, @@ -103,14 +91,8 @@ pub async fn update_goal( Path(year): Path, Json(req): Json, ) -> Result, ApiError> { - let deps = GoalCommandDeps { - goal_command: state.app_ctx.repos.goal_command.clone(), - goal_query: state.app_ctx.repos.goal_query.clone(), - stats: state.app_ctx.repos.stats.clone(), - event_publisher: state.app_ctx.services.event_publisher.clone(), - }; let g = application::goals::update::execute( - &deps, + &state.app_ctx.deps.goals.command, application::goals::commands::UpdateGoalCommand { user_id: user.0.value(), year, @@ -135,14 +117,8 @@ pub async fn delete_goal( user: AuthenticatedUser, Path(year): Path, ) -> Result { - let deps = GoalCommandDeps { - goal_command: state.app_ctx.repos.goal_command.clone(), - goal_query: state.app_ctx.repos.goal_query.clone(), - stats: state.app_ctx.repos.stats.clone(), - event_publisher: state.app_ctx.services.event_publisher.clone(), - }; application::goals::delete::execute( - &deps, + &state.app_ctx.deps.goals.command, application::goals::commands::DeleteGoalCommand { user_id: user.0.value(), year, @@ -165,12 +141,8 @@ pub async fn get_user_goals( AuthenticatedUser(_viewer): AuthenticatedUser, Path(user_id): Path, ) -> Result, ApiError> { - let deps = GoalQueryDeps { - goal_query: state.app_ctx.repos.goal_query.clone(), - stats: state.app_ctx.repos.stats.clone(), - }; let goals = application::goals::list::execute( - &deps, + &state.app_ctx.deps.goals.query, application::goals::queries::ListGoalsQuery { user_id }, ) .await?; @@ -194,7 +166,7 @@ pub async fn get_settings( user: AuthenticatedUser, ) -> Result, ApiError> { let settings = application::users::get_settings::execute( - state.app_ctx.repos.user_settings.clone(), + &state.app_ctx.deps.users.get_settings, user.0.value(), ) .await?; @@ -220,7 +192,7 @@ pub async fn update_settings( Json(req): Json, ) -> Result { application::users::update_settings::execute( - state.app_ctx.repos.user_settings.clone(), + &state.app_ctx.deps.users.update_settings, application::users::update_settings::UpdateUserSettingsCommand { user_id: user.0.value(), federate_goals: req.federate_goals, diff --git a/crates/presentation/src/handlers/helpers.rs b/crates/presentation/src/handlers/helpers.rs index 99969d3..5c73a8d 100644 --- a/crates/presentation/src/handlers/helpers.rs +++ b/crates/presentation/src/handlers/helpers.rs @@ -29,11 +29,7 @@ pub(crate) fn build_export_response( ExportFormat::Json => ("application/json", "diary.json"), }; let query = ExportQuery { user_id, format }; - let stream = export_diary_uc::execute( - &state.app_ctx.repos.diary, - &state.app_ctx.services.diary_exporter, - query, - ); + let stream = export_diary_uc::execute(&state.app_ctx.deps.diary.export_diary, query); let stream = stream.map(|r| { if let Err(ref e) = r { tracing::error!("diary export stream error: {e}"); @@ -146,16 +142,25 @@ pub(crate) async fn build_page_context( csrf_token: String, ) -> HtmlPageContext { let uuid = user_id.as_ref().map(|u| u.value()); - let (user_email, is_admin) = if let Some(ref id) = user_id { - let user = state.app_ctx.repos.user.find_by_id(id).await.ok().flatten(); - let email = user.as_ref().map(|u| u.email().value().to_string()); - let admin = user - .as_ref() - .map(|u| matches!(u.role(), domain::models::UserRole::Admin)) - .unwrap_or(false); - (email, admin) + // 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) + (None, false, 0) }; HtmlPageContext { user_email, @@ -167,5 +172,6 @@ pub(crate) async fn build_page_context( 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/import.rs b/crates/presentation/src/handlers/import.rs index 1491354..452a6d9 100644 --- a/crates/presentation/src/handlers/import.rs +++ b/crates/presentation/src/handlers/import.rs @@ -13,17 +13,14 @@ use std::collections::HashMap; use crate::render::render_page; use application::import::{ - apply_mapping as apply_import_mapping, apply_profile as apply_import_profile, + apply_mapping as apply_import_mapping, apply_profile_and_map, commands::{ - ApplyImportMappingCommand, ApplyImportProfileCommand, CreateImportSessionCommand, + ApplyImportMappingCommand, ApplyProfileAndMapCommand, CreateImportSessionCommand, DeleteImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand, }, create_session as create_import_session, delete_profile as delete_import_profile, - deps::{ - ApplyMappingDeps, ApplyProfileDeps, CreateSessionDeps, ExecuteImportDeps, SaveProfileDeps, - }, - execute as execute_import, list_profiles as list_import_profiles, - save_profile as save_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::{ @@ -111,7 +108,7 @@ pub async fn get_import_page( ) -> 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.repos.import_profile.clone(), &user_id) + list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id) .await .unwrap_or_default() .into_iter() @@ -163,10 +160,7 @@ pub async fn post_upload( }; match create_import_session::execute( - &CreateSessionDeps { - import_session: state.app_ctx.repos.import_session.clone(), - document_parser: state.app_ctx.services.document_parser.clone(), - }, + &state.app_ctx.deps.import.create_session, CreateImportSessionCommand { user_id: user_id.value(), bytes, @@ -194,21 +188,17 @@ pub async fn get_mapping_page( else { return Redirect::to("/import").into_response(); }; - let Ok(Some(session)) = state - .app_ctx - .repos - .import_session - .get(&session_id, &user_id) - .await + 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 Some(parsed) = session.parsed_file else { - return Redirect::to("/import").into_response(); - }; let ctx = super::helpers::build_page_context(&state, Some(user_id), csrf.0).await; - let sample_rows: Vec> = parsed.rows.into_iter().take(5).collect(); let domain_fields: Vec<(&str, &str)> = vec![ ("title", "Title"), ("release_year", "Release Year"), @@ -221,8 +211,8 @@ pub async fn get_mapping_page( render_page(ImportMappingTemplate { ctx: &ctx, session_id: &session_id_str, - columns: &parsed.columns, - sample_rows: &sample_rows, + columns: &stage.columns, + sample_rows: &stage.sample_rows, domain_fields: &domain_fields, error: None, }) @@ -255,11 +245,7 @@ pub async fn post_mapping( .into_response(); } match apply_import_mapping::execute( - &ApplyMappingDeps { - import_session: state.app_ctx.repos.import_session.clone(), - document_parser: state.app_ctx.services.document_parser.clone(), - movie_query: state.app_ctx.repos.movie_query.clone(), - }, + &state.app_ctx.deps.import.apply_mapping, ApplyImportMappingCommand { user_id: user_id.value(), session_id: session_id.value(), @@ -290,24 +276,25 @@ pub async fn get_preview_page( else { return Redirect::to("/import").into_response(); }; - let Ok(Some(session)) = state - .app_ctx - .repos - .import_session - .get(&session_id, &user_id) - .await + 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(); }; - if session.row_results.is_none() { - return Redirect::to(&format!("/import/{}/mapping", session_id_str)).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 parsed = session.parsed_file.unwrap_or_default(); - let annotated: Vec = session.row_results.unwrap_or_default(); - - let rows: Vec = annotated + let rows: Vec = preview + .rows .iter() .enumerate() .map(|(i, a)| annotated_to_preview_row(i, a)) @@ -317,7 +304,7 @@ pub async fn get_preview_page( render_page(ImportPreviewTemplate { ctx: &ctx, session_id: &session_id_str, - columns: &parsed.columns, + columns: &preview.columns, rows: &rows, }) .into_response() @@ -353,10 +340,7 @@ pub async fn post_confirm( .filter(|n| !n.trim().is_empty()); if let Some(name) = profile_name { let _ = save_import_profile::execute( - &SaveProfileDeps { - import_session: state.app_ctx.repos.import_session.clone(), - import_profile: state.app_ctx.repos.import_profile.clone(), - }, + &state.app_ctx.deps.import.save_profile, SaveImportProfileCommand { user_id: user_id.value(), session_id: session_id.value(), @@ -374,10 +358,7 @@ pub async fn post_confirm( .collect(); match execute_import::execute( - &ExecuteImportDeps { - import_session: state.app_ctx.repos.import_session.clone(), - review_logger: state.app_ctx.services.review_logger.clone(), - }, + &state.app_ctx.deps.import.execute_import, ExecuteImportCommand { user_id: user_id.value(), session_id: session_id.value(), @@ -412,7 +393,7 @@ pub async fn post_delete_profile( } if let Ok(profile_id) = profile_id_str.parse::() { let _ = delete_import_profile::execute( - state.app_ctx.repos.import_profile.clone(), + &state.app_ctx.deps.import.delete_profile, DeleteImportProfileCommand { user_id: user_id.value(), profile_id, @@ -498,10 +479,7 @@ pub async fn api_post_session( _ => FileFormat::Csv, }; let r = create_import_session::execute( - &CreateSessionDeps { - import_session: state.app_ctx.repos.import_session.clone(), - document_parser: state.app_ctx.services.document_parser.clone(), - }, + &state.app_ctx.deps.import.create_session, CreateImportSessionCommand { user_id: user_id.value(), bytes, @@ -535,20 +513,17 @@ pub async fn api_get_session( .parse::() .map(ImportSessionId::from_uuid) .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; - let session = state - .app_ctx - .repos - .import_session - .get(&session_id, &user_id) - .await? - .ok_or(DomainError::NotFound("session not found".into()))?; - let parsed = session.parsed_file.unwrap_or_default(); - let row_count = parsed.rows.len(); + 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: parsed.columns, - has_mappings: session.field_mappings.is_some(), - row_count, + columns: state_data.columns, + has_mappings: state_data.has_mappings, + row_count: state_data.row_count, })) } @@ -596,11 +571,7 @@ pub async fn api_put_mapping( .collect(); let rows = apply_import_mapping::execute( - &ApplyMappingDeps { - import_session: state.app_ctx.repos.import_session.clone(), - document_parser: state.app_ctx.services.document_parser.clone(), - movie_query: state.app_ctx.repos.movie_query.clone(), - }, + &state.app_ctx.deps.import.apply_mapping, ApplyImportMappingCommand { user_id: user_id.value(), session_id: session_id.value(), @@ -621,15 +592,21 @@ pub async fn api_get_preview( .map(ImportSessionId::from_uuid) .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; - let session = state - .app_ctx - .repos - .import_session - .get(&session_id, &user_id) - .await? - .ok_or(DomainError::NotFound("session not found".into()))?; + let stage = get_preview_stage::execute( + &state.app_ctx.deps.import.get_preview_stage, + session_id, + user_id.value(), + ) + .await?; - let annotated: Vec = session.row_results.unwrap_or_default(); + // 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() @@ -689,10 +666,7 @@ pub async fn api_post_confirm( .map(ImportSessionId::from_uuid) .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; let s = execute_import::execute( - &ExecuteImportDeps { - import_session: state.app_ctx.repos.import_session.clone(), - review_logger: state.app_ctx.services.review_logger.clone(), - }, + &state.app_ctx.deps.import.execute_import, ExecuteImportCommand { user_id: user_id.value(), session_id: session_id.value(), @@ -720,7 +694,7 @@ pub async fn api_get_profiles( AuthenticatedUser(user_id): AuthenticatedUser, ) -> Result { let profiles = - list_import_profiles::execute(state.app_ctx.repos.import_profile.clone(), &user_id).await?; + list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id).await?; Ok(axum::Json( profiles .into_iter() @@ -756,10 +730,7 @@ pub async fn api_post_profile( .map(ImportSessionId::from_uuid) .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; let id = save_import_profile::execute( - &SaveProfileDeps { - import_session: state.app_ctx.repos.import_session.clone(), - import_profile: state.app_ctx.repos.import_profile.clone(), - }, + &state.app_ctx.deps.import.save_profile, SaveImportProfileCommand { user_id: user_id.value(), session_id: session_id.value(), @@ -791,7 +762,7 @@ pub async fn api_delete_profile( .parse::() .map_err(|_| DomainError::ValidationError("invalid profile id".into()))?; delete_import_profile::execute( - state.app_ctx.repos.import_profile.clone(), + &state.app_ctx.deps.import.delete_profile, DeleteImportProfileCommand { user_id: user_id.value(), profile_id, @@ -828,42 +799,14 @@ pub async fn api_apply_profile( .parse::() .map_err(|_| DomainError::ValidationError("invalid profile id".into()))?; - apply_import_profile::execute( - &ApplyProfileDeps { - import_profile: state.app_ctx.repos.import_profile.clone(), - import_session: state.app_ctx.repos.import_session.clone(), - }, - ApplyImportProfileCommand { + 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?; - - let session = state - .app_ctx - .repos - .import_session - .get(&ImportSessionId::from_uuid(session_id), &user_id) - .await? - .ok_or(DomainError::NotFound( - "session not found after profile apply".into(), - ))?; - - let mappings = session.field_mappings.unwrap_or_default(); - let rows = apply_import_mapping::execute( - &ApplyMappingDeps { - import_session: state.app_ctx.repos.import_session.clone(), - document_parser: state.app_ctx.services.document_parser.clone(), - movie_query: state.app_ctx.repos.movie_query.clone(), - }, - ApplyImportMappingCommand { - user_id: user_id.value(), - session_id, - mappings, - }, - ) - .await?; Ok(axum::Json(serde_json::json!({"row_count": rows.len()}))) } diff --git a/crates/presentation/src/handlers/integrations.rs b/crates/presentation/src/handlers/integrations.rs index 11803f2..4ffdcce 100644 --- a/crates/presentation/src/handlers/integrations.rs +++ b/crates/presentation/src/handlers/integrations.rs @@ -41,9 +41,10 @@ pub async fn get_integrations_page( let query = GetWebhookTokensQuery { user_id: user_id.value(), }; - let tokens = get_webhook_tokens::execute(state.app_ctx.repos.webhook_token.clone(), query) - .await - .unwrap_or_default(); + let tokens = + get_webhook_tokens::execute(&state.app_ctx.deps.integrations.get_webhook_tokens, query) + .await + .unwrap_or_default(); let token_views: Vec = tokens .iter() @@ -81,7 +82,12 @@ pub async fn post_generate_token( label: form.label.filter(|l| !l.trim().is_empty()), }; - match generate_webhook_token::execute(state.app_ctx.repos.webhook_token.clone(), cmd).await { + match generate_webhook_token::execute( + &state.app_ctx.deps.integrations.generate_webhook_token, + cmd, + ) + .await + { Ok(result) => { let encoded = percent_encoding::utf8_percent_encode( &result.token_plaintext, @@ -112,7 +118,8 @@ pub async fn post_revoke_token( token_id, }; if let Err(e) = - revoke_webhook_token::execute(state.app_ctx.repos.webhook_token.clone(), cmd).await + revoke_webhook_token::execute(&state.app_ctx.deps.integrations.revoke_webhook_token, cmd) + .await { tracing::error!("revoke token failed: {:?}", e); } @@ -135,7 +142,7 @@ pub async fn get_watch_queue_page( let query = GetWatchQueueQuery { user_id: user_id.value(), }; - let events = get_watch_queue::execute(state.app_ctx.repos.watch_event_query.clone(), query) + let events = get_watch_queue::execute(&state.app_ctx.deps.integrations.get_watch_queue, query) .await .unwrap_or_default(); @@ -172,13 +179,8 @@ pub async fn post_confirm_single( }], }; - match confirm_watch_events::execute( - state.app_ctx.repos.watch_event_command.clone(), - state.app_ctx.repos.watch_event_query.clone(), - state.app_ctx.services.review_logger.clone(), - cmd, - ) - .await + match confirm_watch_events::execute(&state.app_ctx.deps.integrations.confirm_watch_events, cmd) + .await { Ok(_) => Redirect::to("/watch-queue").into_response(), Err(e) => { @@ -204,12 +206,8 @@ pub async fn post_dismiss_single( event_ids: vec![event_id], }; - match dismiss_watch_events::execute( - state.app_ctx.repos.watch_event_command.clone(), - state.app_ctx.repos.watch_event_query.clone(), - cmd, - ) - .await + match dismiss_watch_events::execute(&state.app_ctx.deps.integrations.dismiss_watch_events, cmd) + .await { Ok(_) => Redirect::to("/watch-queue").into_response(), Err(e) => { diff --git a/crates/presentation/src/handlers/mod.rs b/crates/presentation/src/handlers/mod.rs index 3f69412..3681128 100644 --- a/crates/presentation/src/handlers/mod.rs +++ b/crates/presentation/src/handlers/mod.rs @@ -8,7 +8,6 @@ pub mod integrations; pub mod movies; pub mod rss; pub mod search; -#[cfg(feature = "federation")] pub mod social; pub mod users; pub mod watchlist; diff --git a/crates/presentation/src/handlers/movies.rs b/crates/presentation/src/handlers/movies.rs index 9424ff2..5caa1d4 100644 --- a/crates/presentation/src/handlers/movies.rs +++ b/crates/presentation/src/handlers/movies.rs @@ -9,11 +9,10 @@ use uuid::Uuid; use application::{ diary::{ commands::SyncPosterCommand, - deps::GetMovieSocialPageDeps, get_movie_social_page, get_review_history, queries::{GetMovieSocialPageQuery, GetReviewHistoryQuery}, }, - movies::{deps::SyncPosterDeps, get_movies, queries::GetMoviesQuery, sync_poster}, + movies::{get_movies, queries::GetMoviesQuery, sync_poster}, watchlist::{is_on as is_on_watchlist, queries::IsOnWatchlistQuery}, }; use domain::services::review_history::Trend; @@ -48,7 +47,7 @@ pub async fn list_movies( Query(params): Query, ) -> Result, ApiError> { let page = get_movies::execute( - state.app_ctx.repos.movie_query.clone(), + &state.app_ctx.deps.movies.get_movies, GetMoviesQuery { limit: params.limit, offset: params.offset, @@ -84,7 +83,7 @@ pub async fn get_review_history( Path(movie_id): Path, ) -> Result, ApiError> { let (history, trend) = get_review_history::execute( - &state.app_ctx.repos.diary, + &state.app_ctx.deps.diary.get_review_history, GetReviewHistoryQuery { movie_id }, ) .await?; @@ -121,16 +120,7 @@ pub async fn sync_poster( Path(movie_id): Path, ) -> Result { sync_poster::execute( - &SyncPosterDeps { - movie_command: state.app_ctx.repos.movie_command.clone(), - movie_query: state.app_ctx.repos.movie_query.clone(), - movie_profile: state.app_ctx.repos.movie_profile.clone(), - metadata: state.app_ctx.services.metadata.clone(), - poster_fetcher: state.app_ctx.services.poster_fetcher.clone(), - object_storage: state.app_ctx.services.object_storage.clone(), - event_publisher: state.app_ctx.services.event_publisher.clone(), - search_command: state.app_ctx.repos.search_command.clone(), - }, + &state.app_ctx.deps.movies.sync_poster, SyncPosterCommand { movie_id }, ) .await?; @@ -154,11 +144,7 @@ pub async fn get_movie_detail( let offset = params.offset.unwrap_or(0); let result = get_movie_social_page::execute( - &GetMovieSocialPageDeps { - movie_query: state.app_ctx.repos.movie_query.clone(), - diary: state.app_ctx.repos.diary.clone(), - movie_profile: state.app_ctx.repos.movie_profile.clone(), - }, + &state.app_ctx.deps.diary.get_movie_social_page, GetMovieSocialPageQuery { movie_id, limit, @@ -210,7 +196,7 @@ pub async fn get_movie_profile( ) -> impl IntoResponse { use application::movies::get_movie_profile; let query = get_movie_profile::GetMovieProfileQuery { movie_id }; - match get_movie_profile::execute(state.app_ctx.repos.movie_profile.clone(), query).await { + match get_movie_profile::execute(&state.app_ctx.deps.movies.get_movie_profile, query).await { Ok(Some(result)) => { let p = result.profile; Json(MovieProfileResponse { @@ -288,11 +274,7 @@ pub async fn get_movie_detail_html( let offset = params.offset.unwrap_or(0); match get_movie_social_page::execute( - &GetMovieSocialPageDeps { - movie_query: state.app_ctx.repos.movie_query.clone(), - diary: state.app_ctx.repos.diary.clone(), - movie_profile: state.app_ctx.repos.movie_profile.clone(), - }, + &state.app_ctx.deps.diary.get_movie_social_page, GetMovieSocialPageQuery { movie_id, limit, @@ -314,7 +296,7 @@ pub async fn get_movie_detail_html( 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.repos.watchlist.clone(), + &state.app_ctx.deps.watchlist.is_on_watchlist, IsOnWatchlistQuery { user_id: uid.value(), movie_id, diff --git a/crates/presentation/src/handlers/rss.rs b/crates/presentation/src/handlers/rss.rs index 17e6f3b..d83d765 100644 --- a/crates/presentation/src/handlers/rss.rs +++ b/crates/presentation/src/handlers/rss.rs @@ -5,8 +5,8 @@ use axum::{ }; use uuid::Uuid; -use application::{diary::get_diary, diary::queries::GetDiaryQuery}; -use domain::{errors::DomainError, models::ReviewSortBy, value_objects::UserId}; +use application::{diary::get_diary, diary::get_user_feed, diary::queries::GetDiaryQuery}; +use domain::{errors::DomainError, models::ReviewSortBy}; use crate::{errors::ApiError, state::AppState}; @@ -18,7 +18,7 @@ pub async fn get_feed(State(state): State) -> Result, Path(user_id): Path, ) -> Result { - let user = state - .app_ctx - .repos - .user - .find_by_id(&UserId::from_uuid(user_id)) - .await - .map_err(ApiError)? - .ok_or_else(|| ApiError(DomainError::NotFound(format!("User {user_id}"))))?; + let feed = get_user_feed::execute( + &state.app_ctx.deps.diary.get_user_feed, + user_id, + super::RSS_FEED_LIMIT, + ) + .await?; - let query = GetDiaryQuery { - limit: Some(super::RSS_FEED_LIMIT), - offset: Some(0), - sort_by: Some(ReviewSortBy::Descending), - movie_id: None, - user_id: Some(user_id), - }; - let page = get_diary::execute(&state.app_ctx.repos.diary, query).await?; - - let display_name = user.email().value().split('@').next().unwrap_or("User"); - let title = format!("{}'s Movie Diary", display_name); + let title = format!("{}'s Movie Diary", feed.author.display_name); let xml = state .rss_renderer - .render_feed(&page.items, &title) + .render_feed(&feed.entries, &title) .map_err(|e| ApiError(DomainError::InfrastructureError(e)))?; Ok(( diff --git a/crates/presentation/src/handlers/search.rs b/crates/presentation/src/handlers/search.rs index 4fafd55..4f39f9b 100644 --- a/crates/presentation/src/handlers/search.rs +++ b/crates/presentation/src/handlers/search.rs @@ -5,7 +5,7 @@ use axum::{ }; use application::{ - person::{deps::GetPersonDeps, get as get_person, get_credits as get_person_credits}, + person::{get as get_person, get_credits as get_person_credits}, search::execute as search_uc, }; use domain::models::{PersonId, collections::PageParams}; @@ -45,7 +45,7 @@ pub async fn get_search( }, }; - match search_uc::execute(state.app_ctx.repos.search_port.clone(), query).await { + match search_uc::execute(&state.app_ctx.deps.search.execute, query).await { Ok(results) => axum::Json(SearchResponse { movies: PaginatedMovieHits { items: results @@ -101,11 +101,12 @@ pub async fn get_person_handler( State(state): State, Path(id): Path, ) -> impl IntoResponse { - let deps = GetPersonDeps { - person_query: state.app_ctx.repos.person_query.clone(), - event_publisher: state.app_ctx.services.event_publisher.clone(), - }; - match get_person::execute(&deps, PersonId::from_uuid(id)).await { + match get_person::execute( + &state.app_ctx.deps.person.get_person, + PersonId::from_uuid(id), + ) + .await + { Ok(Some(person)) => { axum::Json(crate::mappers::search::person_to_dto(&person)).into_response() } @@ -127,11 +128,12 @@ pub async fn get_person_credits_handler( State(state): State, Path(id): Path, ) -> impl IntoResponse { - let deps = GetPersonDeps { - person_query: state.app_ctx.repos.person_query.clone(), - event_publisher: state.app_ctx.services.event_publisher.clone(), - }; - match get_person_credits::execute(&deps, PersonId::from_uuid(id)).await { + match get_person_credits::execute( + &state.app_ctx.deps.person.get_person, + PersonId::from_uuid(id), + ) + .await + { Ok(credits) => axum::Json(PersonCreditsDto { person: crate::mappers::search::person_to_dto(&credits.person), cast: credits diff --git a/crates/presentation/src/handlers/social.rs b/crates/presentation/src/handlers/social.rs index f61c576..459bdcc 100644 --- a/crates/presentation/src/handlers/social.rs +++ b/crates/presentation/src/handlers/social.rs @@ -9,77 +9,58 @@ use uuid::Uuid; use crate::{ csrf::CsrfToken, errors::ApiError, - extractors::{AdminApiUser, AuthenticatedUser, RequiredCookieUser}, - forms::{ - ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, - UnfollowForm, - }, + 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, AddBlockedDomainRequest, BlockedActorResponse, - BlockedDomainResponse, FollowRequest, RemoteActorDto, -}; -use application::social::deps::{SocialCommandDeps, SocialQueryDeps}; -use domain::value_objects::{FollowTarget, SocialActor, SocialIdentity}; -use template_askama::{ - BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate, - RemoteActorData, + 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_error}; -impl From<&AppState> for SocialCommandDeps { - fn from(state: &AppState) -> Self { - Self { - social_command: state.app_ctx.repos.social_command.clone(), - social_query: state.app_ctx.repos.social_query_unified.clone(), - event_publisher: state.app_ctx.services.event_publisher.clone(), - } - } -} - -impl From<&AppState> for SocialQueryDeps { - fn from(state: &AppState) -> Self { - Self { - social_query: state.app_ctx.repos.social_query_unified.clone(), - } - } -} - -fn ap_to_domain(e: anyhow::Error) -> domain::errors::DomainError { - tracing::error!("ActivityPub error: {:?}", e); - domain::errors::DomainError::InfrastructureError(e.to_string()) -} - -fn actor_url(identity: &SocialIdentity) -> String { - match identity { - SocialIdentity::Remote { actor_url } => actor_url.clone(), - SocialIdentity::Local(uid) => format!("local:{}", uid.value()), - } -} - -fn social_actor_to_dto(actor: SocialActor) -> RemoteActorDto { +fn social_actor_to_dto(actor: SocialActor, instance: &InstanceIdentity) -> RemoteActorDto { RemoteActorDto { - url: actor_url(&actor.identity), - handle: actor.handle, - display_name: actor.display_name, - } -} - -fn social_actor_to_blocked_dto(actor: SocialActor) -> BlockedActorResponse { - BlockedActorResponse { - url: actor_url(&actor.identity), + 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, } } -fn social_actor_to_template(actor: SocialActor) -> RemoteActorData { +#[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: actor_url(&actor.identity), + url: instance.actor_url_of(&actor.identity), handle: actor.handle, display_name: actor.display_name, avatar_url: actor.avatar_url, @@ -88,6 +69,7 @@ fn social_actor_to_template(actor: SocialActor) -> RemoteActorData { // ── API ────────────────────────────────────────────────────────────────────── +#[cfg(feature = "federation")] #[utoipa::path( get, path = "/api/v1/admin/blocked-domains", responses( @@ -101,13 +83,7 @@ pub async fn get_blocked_domains_admin( State(state): State, _admin: AdminApiUser, ) -> Result>, ApiError> { - let domains = state - .app_ctx - .services - .ap_service - .get_blocked_domains() - .await - .map_err(ap_to_domain)?; + let domains = state.app_ctx.ap_blocklist.get_blocked_domains().await?; Ok(Json( domains .into_iter() @@ -120,6 +96,7 @@ pub async fn get_blocked_domains_admin( )) } +#[cfg(feature = "federation")] #[utoipa::path( post, path = "/api/v1/admin/blocked-domains", request_body = AddBlockedDomainRequest, @@ -137,14 +114,13 @@ pub async fn add_blocked_domain_admin( ) -> Result { state .app_ctx - .services - .ap_service + .ap_blocklist .add_blocked_domain(&body.domain, body.reason.as_deref()) - .await - .map_err(ap_to_domain)?; + .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")), @@ -162,14 +138,13 @@ pub async fn remove_blocked_domain_admin( ) -> Result { state .app_ctx - .services - .ap_service + .ap_blocklist .remove_blocked_domain(&domain) - .await - .map_err(ap_to_domain)?; + .await?; Ok(StatusCode::NO_CONTENT) } +#[cfg(feature = "federation")] #[utoipa::path( post, path = "/api/v1/social/block", request_body = ActorUrlRequest, @@ -184,18 +159,19 @@ pub async fn block_actor_api( user: AuthenticatedUser, axum::Json(body): axum::Json, ) -> Result { - let deps = SocialCommandDeps::from(&state); + let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( - &deps, + &state.app_ctx.deps.social.command, application::social::commands::SocialCmd::Block { blocker_id: user.0.value(), - target: SocialIdentity::from_actor_url(&body.actor_url, &state.app_ctx.config.base_url), + 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, @@ -210,18 +186,19 @@ pub async fn unblock_actor_api( user: AuthenticatedUser, axum::Json(body): axum::Json, ) -> Result { - let deps = SocialCommandDeps::from(&state); + let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( - &deps, + &state.app_ctx.deps.social.command, application::social::commands::SocialCmd::Unblock { blocker_id: user.0.value(), - target: SocialIdentity::from_actor_url(&body.actor_url, &state.app_ctx.config.base_url), + target: instance.identify(&body.actor_url), }, ) .await?; Ok(StatusCode::NO_CONTENT) } +#[cfg(feature = "federation")] #[utoipa::path( get, path = "/api/v1/social/blocked", responses( @@ -234,18 +211,14 @@ pub async fn get_blocked_actors_api( State(state): State, user: AuthenticatedUser, ) -> Result>, ApiError> { - let deps = SocialQueryDeps::from(&state); - let identities = application::social::execute::execute_query( - &deps, - application::social::queries::SocialQry::GetBlocked { - user_id: user.0.value(), - }, - ) - .await?; + 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(social_actor_to_blocked_dto) + .map(|a| social_actor_to_blocked_dto(a, &instance)) .collect(), )) } @@ -262,16 +235,17 @@ pub async fn get_following( State(state): State, user: AuthenticatedUser, ) -> Result, ApiError> { - let deps = SocialQueryDeps::from(&state); - let identities = application::social::execute::execute_query( - &deps, - application::social::queries::SocialQry::GetFollowing { - user_id: user.0.value(), - }, + 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(social_actor_to_dto).collect(), + actors: identities + .into_iter() + .map(|a| social_actor_to_dto(a, &instance)) + .collect(), })) } @@ -287,16 +261,17 @@ pub async fn get_followers( State(state): State, user: AuthenticatedUser, ) -> Result, ApiError> { - let deps = SocialQueryDeps::from(&state); - let identities = application::social::execute::execute_query( - &deps, - application::social::queries::SocialQry::GetFollowers { - user_id: user.0.value(), - }, + 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(social_actor_to_dto).collect(), + actors: identities + .into_iter() + .map(|a| social_actor_to_dto(a, &instance)) + .collect(), })) } @@ -305,14 +280,15 @@ pub async fn get_user_following( _user: AuthenticatedUser, Path(user_id): Path, ) -> Result, ApiError> { - let deps = SocialQueryDeps::from(&state); - let identities = application::social::execute::execute_query( - &deps, - application::social::queries::SocialQry::GetFollowing { user_id }, - ) - .await?; + 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(social_actor_to_dto).collect(), + actors: identities + .into_iter() + .map(|a| social_actor_to_dto(a, &instance)) + .collect(), })) } @@ -321,14 +297,15 @@ pub async fn get_user_followers( _user: AuthenticatedUser, Path(user_id): Path, ) -> Result, ApiError> { - let deps = SocialQueryDeps::from(&state); - let identities = application::social::execute::execute_query( - &deps, - application::social::queries::SocialQry::GetFollowers { user_id }, - ) - .await?; + 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(social_actor_to_dto).collect(), + actors: identities + .into_iter() + .map(|a| social_actor_to_dto(a, &instance)) + .collect(), })) } @@ -346,9 +323,8 @@ pub async fn follow( user: AuthenticatedUser, Json(body): Json, ) -> Result { - let deps = SocialCommandDeps::from(&state); application::social::execute::execute_command( - &deps, + &state.app_ctx.deps.social.command, application::social::commands::SocialCmd::Follow { follower_id: user.0.value(), target: FollowTarget::Handle(body.handle), @@ -372,12 +348,12 @@ pub async fn unfollow( user: AuthenticatedUser, Json(body): Json, ) -> Result { - let deps = SocialCommandDeps::from(&state); + let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( - &deps, + &state.app_ctx.deps.social.command, application::social::commands::SocialCmd::Unfollow { follower_id: user.0.value(), - target: SocialIdentity::from_actor_url(&body.actor_url, &state.app_ctx.config.base_url), + target: instance.identify(&body.actor_url), }, ) .await?; @@ -398,15 +374,12 @@ pub async fn accept_follower( user: AuthenticatedUser, Json(body): Json, ) -> Result { - let deps = SocialCommandDeps::from(&state); + let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( - &deps, + &state.app_ctx.deps.social.command, application::social::commands::SocialCmd::AcceptFollow { owner_id: user.0.value(), - requester: SocialIdentity::from_actor_url( - &body.actor_url, - &state.app_ctx.config.base_url, - ), + requester: instance.identify(&body.actor_url), }, ) .await?; @@ -427,15 +400,12 @@ pub async fn reject_follower( user: AuthenticatedUser, Json(body): Json, ) -> Result { - let deps = SocialCommandDeps::from(&state); + let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( - &deps, + &state.app_ctx.deps.social.command, application::social::commands::SocialCmd::RejectFollow { owner_id: user.0.value(), - requester: SocialIdentity::from_actor_url( - &body.actor_url, - &state.app_ctx.config.base_url, - ), + requester: instance.identify(&body.actor_url), }, ) .await?; @@ -456,15 +426,12 @@ pub async fn remove_follower( user: AuthenticatedUser, Json(body): Json, ) -> Result { - let deps = SocialCommandDeps::from(&state); + let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( - &deps, + &state.app_ctx.deps.social.command, application::social::commands::SocialCmd::RemoveFollower { owner_id: user.0.value(), - follower: SocialIdentity::from_actor_url( - &body.actor_url, - &state.app_ctx.config.base_url, - ), + follower: instance.identify(&body.actor_url), }, ) .await?; @@ -483,22 +450,101 @@ pub async fn get_pending_followers( State(state): State, user: AuthenticatedUser, ) -> Result, ApiError> { - let deps = SocialQueryDeps::from(&state); - let identities = application::social::execute::execute_query( - &deps, - application::social::queries::SocialQry::GetPending { - user_id: user.0.value(), - }, + 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(social_actor_to_dto).collect(), + 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_remote_user( +pub async fn follow_user( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, Path(profile_user_uuid): Path, @@ -518,9 +564,8 @@ pub async fn follow_remote_user( .unwrap_or(&format!("/users/{}", profile_user_uuid)) .to_string(); - let deps = SocialCommandDeps::from(&state); match application::social::execute::execute_command( - &deps, + &state.app_ctx.deps.social.command, application::social::commands::SocialCmd::Follow { follower_id: user_id.value(), target: FollowTarget::Handle(form.handle), @@ -542,7 +587,7 @@ pub async fn follow_remote_user( } } -pub async fn unfollow_remote_user( +pub async fn unfollow_user( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, Path(profile_user_uuid): Path, @@ -555,12 +600,12 @@ pub async fn unfollow_remote_user( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - let deps = SocialCommandDeps::from(&state); + let instance = state.app_ctx.instance.clone(); match application::social::execute::execute_command( - &deps, + &state.app_ctx.deps.social.command, application::social::commands::SocialCmd::Unfollow { follower_id: user_id.value(), - target: SocialIdentity::from_actor_url(&form.actor_url, &state.app_ctx.config.base_url), + target: instance.identify(&form.actor_url), }, ) .await @@ -592,15 +637,12 @@ pub async fn accept_follower_html( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - let deps = SocialCommandDeps::from(&state); + let instance = state.app_ctx.instance.clone(); match application::social::execute::execute_command( - &deps, + &state.app_ctx.deps.social.command, application::social::commands::SocialCmd::AcceptFollow { owner_id: user_id.value(), - requester: SocialIdentity::from_actor_url( - &form.actor_url, - &state.app_ctx.config.base_url, - ), + requester: instance.identify(&form.actor_url), }, ) .await @@ -626,15 +668,12 @@ pub async fn reject_follower_html( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - let deps = SocialCommandDeps::from(&state); + let instance = state.app_ctx.instance.clone(); match application::social::execute::execute_command( - &deps, + &state.app_ctx.deps.social.command, application::social::commands::SocialCmd::RejectFollow { owner_id: user_id.value(), - requester: SocialIdentity::from_actor_url( - &form.actor_url, - &state.app_ctx.config.base_url, - ), + requester: instance.identify(&form.actor_url), }, ) .await @@ -647,6 +686,7 @@ pub async fn reject_follower_html( } } +#[cfg(feature = "federation")] pub async fn get_followers_collection( State(state): State, Path(user_id): Path, @@ -661,8 +701,7 @@ pub async fn get_followers_collection( let page = params.get("page").and_then(|p| p.parse::().ok()); return match state .app_ctx - .services - .ap_service + .ap_document .followers_collection_json(user_id, page) .await { @@ -674,12 +713,16 @@ pub async fn get_followers_collection( json, ) .into_response(), - Err(_) => StatusCode::NOT_FOUND.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, @@ -694,8 +737,7 @@ pub async fn get_following_collection( let page = params.get("page").and_then(|p| p.parse::().ok()); return match state .app_ctx - .services - .ap_service + .ap_document .following_collection_json(user_id, page) .await { @@ -707,7 +749,10 @@ pub async fn get_following_collection( json, ) .into_response(), - Err(_) => StatusCode::NOT_FOUND.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() @@ -729,24 +774,39 @@ pub async fn get_following_page( "{}/users/{}/following-list", state.app_ctx.config.base_url, profile_user_uuid ); - let deps = SocialQueryDeps::from(&state); - match application::social::execute::execute_query( - &deps, - application::social::queries::SocialQry::GetFollowing { - user_id: user_id.value(), - }, + 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(social_actor_to_template) + .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() @@ -778,19 +838,17 @@ pub async fn get_followers_page( "{}/users/{}/followers-list", state.app_ctx.config.base_url, profile_user_uuid ); - let deps = SocialQueryDeps::from(&state); - match application::social::execute::execute_query( - &deps, - application::social::queries::SocialQry::GetFollowers { - user_id: user_id.value(), - }, + 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(social_actor_to_template) + .map(|a| social_actor_to_template(a, &instance)) .collect(); render_page(FollowersTemplate { ctx, @@ -824,15 +882,12 @@ pub async fn remove_follower_html( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - let deps = SocialCommandDeps::from(&state); + let instance = state.app_ctx.instance.clone(); match application::social::execute::execute_command( - &deps, + &state.app_ctx.deps.social.command, application::social::commands::SocialCmd::RemoveFollower { owner_id: user_id.value(), - follower: SocialIdentity::from_actor_url( - &form.actor_url, - &state.app_ctx.config.base_url, - ), + follower: instance.identify(&form.actor_url), }, ) .await @@ -851,6 +906,7 @@ pub async fn remove_follower_html( } } +#[cfg(feature = "federation")] pub async fn get_blocked_domains_page( crate::extractors::AdminUser(user_id): crate::extractors::AdminUser, State(state): State, @@ -859,13 +915,7 @@ pub async fn get_blocked_domains_page( 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 - .services - .ap_service - .get_blocked_domains() - .await - { + match state.app_ctx.ap_blocklist.get_blocked_domains().await { Ok(domains) => { let entries: Vec = domains .into_iter() @@ -892,6 +942,7 @@ pub async fn get_blocked_domains_page( } } +#[cfg(feature = "federation")] pub async fn post_blocked_domain( crate::extractors::AdminUser(_): crate::extractors::AdminUser, State(state): State, @@ -904,8 +955,7 @@ pub async fn post_blocked_domain( let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty()); match state .app_ctx - .services - .ap_service + .ap_blocklist .add_blocked_domain(&form.domain, reason) .await { @@ -917,6 +967,7 @@ pub async fn post_blocked_domain( } } +#[cfg(feature = "federation")] pub async fn post_remove_blocked_domain( crate::extractors::AdminUser(_): crate::extractors::AdminUser, State(state): State, @@ -928,8 +979,7 @@ pub async fn post_remove_blocked_domain( } match state .app_ctx - .services - .ap_service + .ap_blocklist .remove_blocked_domain(&form.domain) .await { @@ -941,6 +991,7 @@ pub async fn post_remove_blocked_domain( } } +#[cfg(feature = "federation")] pub async fn get_blocked_actors_page( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, @@ -949,12 +1000,10 @@ pub async fn get_blocked_actors_page( 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 deps = SocialQueryDeps::from(&state); - match application::social::execute::execute_query( - &deps, - application::social::queries::SocialQry::GetBlocked { - user_id: user_id.value(), - }, + let instance = state.app_ctx.instance.clone(); + match application::social::get_blocked::execute( + &state.app_ctx.deps.social.query, + user_id.value(), ) .await { @@ -962,7 +1011,7 @@ pub async fn get_blocked_actors_page( let entries: Vec = blocked .into_iter() .map(|a| template_askama::BlockedActorEntry { - url: actor_url(&a.identity), + url: instance.actor_url_of(&a.identity), handle: a.handle, display_name: a.display_name, avatar_url: a.avatar_url, @@ -985,6 +1034,7 @@ pub async fn get_blocked_actors_page( } } +#[cfg(feature = "federation")] pub async fn post_block_actor_html( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, @@ -994,12 +1044,12 @@ pub async fn post_block_actor_html( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - let deps = SocialCommandDeps::from(&state); + let instance = state.app_ctx.instance.clone(); match application::social::execute::execute_command( - &deps, + &state.app_ctx.deps.social.command, application::social::commands::SocialCmd::Block { blocker_id: user_id.value(), - target: SocialIdentity::from_actor_url(&form.actor_url, &state.app_ctx.config.base_url), + target: instance.identify(&form.actor_url), }, ) .await @@ -1012,6 +1062,7 @@ pub async fn post_block_actor_html( } } +#[cfg(feature = "federation")] pub async fn post_unblock_actor( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, @@ -1021,12 +1072,12 @@ pub async fn post_unblock_actor( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - let deps = SocialCommandDeps::from(&state); + let instance = state.app_ctx.instance.clone(); match application::social::execute::execute_command( - &deps, + &state.app_ctx.deps.social.command, application::social::commands::SocialCmd::Unblock { blocker_id: user_id.value(), - target: SocialIdentity::from_actor_url(&form.actor_url, &state.app_ctx.config.base_url), + target: instance.identify(&form.actor_url), }, ) .await @@ -1038,3 +1089,110 @@ pub async fn post_unblock_actor( } } } + +#[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, + } + } + + /// The bug: a local actor used to serialize as `local:{uuid}`, which + /// `identify` cannot parse, so every POST echoing this url back resolved + /// to Remote and was routed to ActivityPub. + #[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 index 0315c85..d0a4c13 100644 --- a/crates/presentation/src/handlers/users.rs +++ b/crates/presentation/src/handlers/users.rs @@ -9,12 +9,12 @@ use axum::{ use uuid::Uuid; use application::users::{ - deps::{GetProfileDeps, UpdateProfileDeps}, - get_profile as get_user_profile_uc, get_users, + get_federated_profile, get_federated_profile_stats, get_local_profile, + get_profile_settings as get_profile_settings_uc, get_users, queries::{GetUserProfileQuery, GetUsersQuery}, - update_profile, update_profile_fields, + resolve_username_to_id, update_profile, update_profile_fields, }; -use domain::value_objects::UserId; +use domain::{errors::DomainError, value_objects::Username}; use crate::{ csrf::CsrfToken, @@ -53,24 +53,20 @@ pub async fn get_profile( AuthenticatedUser(user_id): AuthenticatedUser, ) -> Result, ApiError> { let profile = application::users::get_current_profile::execute( - state.app_ctx.repos.user.clone(), + &state.app_ctx.deps.users.get_current_profile, application::users::queries::GetCurrentProfileQuery { user_id: user_id.value(), }, ) .await?; - let base_url = &state.app_ctx.config.base_url; + 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| format!("{}/images/{}", base_url, p)), - banner_url: profile - .banner_path - .map(|p| format!("{}/images/{}", base_url, p)), + 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 @@ -113,12 +109,7 @@ pub async fn update_profile_handler( also_known_as: data.also_known_as, }; - let deps = UpdateProfileDeps { - user: state.app_ctx.repos.user.clone(), - object_storage: state.app_ctx.services.object_storage.clone(), - event_publisher: state.app_ctx.services.event_publisher.clone(), - }; - match update_profile::execute(&deps, cmd).await { + 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), } @@ -159,12 +150,7 @@ pub async fn update_profile_fields_handler( fields, }; - match update_profile_fields::execute( - state.app_ctx.repos.profile_fields.clone(), - state.app_ctx.services.event_publisher.clone(), - cmd, - ) - .await + 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), @@ -176,11 +162,8 @@ pub async fn update_profile_fields_handler( responses((status = 200, body = UsersResponse)), )] pub async fn list_users(State(state): State) -> Result, ApiError> { - let deps = application::users::deps::GetUsersListDeps { - user: state.app_ctx.repos.user.clone(), - federation_admin: state.app_ctx.repos.federation_admin.clone(), - }; - let result = get_users::execute(&deps, GetUsersQuery).await?; + let result = + get_users::execute(&state.app_ctx.deps.users.get_users_list, GetUsersQuery).await?; Ok(Json(UsersResponse { users: result .users @@ -220,44 +203,15 @@ pub async fn get_user_profile( Err(_) => return StatusCode::BAD_REQUEST.into_response(), }; - let local_user = match state - .app_ctx - .repos - .user - .find_by_id(&UserId::from_uuid(user_id)) - .await - { - Ok(u) => u, - Err(e) => { - return crate::errors::domain_error_response(e); - } - }; - - if local_user.is_none() { - if let Some(ref fed_query) = state.app_ctx.repos.federated_profile - && let Ok(Some(fed)) = fed_query.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(); - } - let user = local_user.unwrap(); - - let get_profile_deps = GetProfileDeps { - stats: state.app_ctx.repos.stats.clone(), - diary: state.app_ctx.repos.diary.clone(), - social_query: state.app_ctx.repos.social_query_unified.clone(), - }; - let profile = match get_user_profile_uc::execute( - &get_profile_deps, + 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, + search: params.search.clone(), is_own_profile: viewer_id.value() == user_id, include_remote: false, }, @@ -265,6 +219,24 @@ pub async fn get_user_profile( .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), }; @@ -306,15 +278,11 @@ pub async fn get_user_profile( Json(UserProfileResponse { user_id, profile: api_types::UserProfileBase { - username: user.username().value().to_string(), - avatar_url: user - .avatar_path() - .map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)), - banner_url: user - .banner_path() - .map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)), - display_name: None, - bio: None, + 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, @@ -327,10 +295,7 @@ pub async fn get_user_profile( view_data, goals: { let goals_list = application::goals::list::execute( - &application::goals::deps::GoalQueryDeps { - goal_query: state.app_ctx.repos.goal_query.clone(), - stats: state.app_ctx.repos.stats.clone(), - }, + &state.app_ctx.deps.goals.query, application::goals::queries::ListGoalsQuery { user_id }, ) .await @@ -342,8 +307,8 @@ pub async fn get_user_profile( } }, is_federated: false, - handle: None, - actor_url: None, + handle: Some(profile.identity.handle.clone()), + actor_url: Some(profile.identity.actor_url.clone()), }) .into_response() } @@ -355,13 +320,8 @@ async fn build_federated_profile_response( profile_view: application::users::queries::ProfileView, params: &UserProfileQueryParams, ) -> axum::response::Response { - let get_profile_deps = GetProfileDeps { - stats: state.app_ctx.repos.stats.clone(), - diary: state.app_ctx.repos.diary.clone(), - social_query: state.app_ctx.repos.social_query_unified.clone(), - }; - let profile = match get_user_profile_uc::execute( - &get_profile_deps, + let profile = match get_federated_profile_stats::execute( + &state.app_ctx.deps.users.get_federated_profile_stats, GetUserProfileQuery { user_id, view: profile_view, @@ -481,12 +441,8 @@ pub async fn get_users_list( ctx.page_title = "Members — Movies Diary".to_string(); ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url); - let users_deps = application::users::deps::GetUsersListDeps { - user: state.app_ctx.repos.user.clone(), - federation_admin: state.app_ctx.repos.federation_admin.clone(), - }; match application::users::get_users::execute( - &users_deps, + &state.app_ctx.deps.users.get_users_list, application::users::queries::GetUsersQuery, ) .await @@ -517,14 +473,14 @@ pub async fn get_user_by_username( State(state): State, Path(username): Path, ) -> impl IntoResponse { - let uname = match domain::value_objects::Username::new(username) { + let uname = match Username::new(username) { Ok(u) => u, Err(_) => return StatusCode::NOT_FOUND.into_response(), }; - match state.app_ctx.repos.user.find_by_username(&uname).await { - Ok(Some(user)) => { - axum::response::Redirect::permanent(&format!("/users/{}", user.id().value())) - .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(), } @@ -607,10 +563,7 @@ async fn fetch_profile_goals( user_id: Uuid, ) -> Vec { let goals_list = application::goals::list::execute( - &application::goals::deps::GoalQueryDeps { - goal_query: state.app_ctx.repos.goal_query.clone(), - stats: state.app_ctx.repos.stats.clone(), - }, + &state.app_ctx.deps.goals.query, application::goals::queries::ListGoalsQuery { user_id }, ) .await @@ -647,8 +600,7 @@ pub async fn get_user_profile_html( if accept.contains("application/activity+json") || accept.contains("application/ld+json") { return match state .app_ctx - .services - .ap_service + .ap_document .actor_json(&profile_user_uuid.to_string()) .await { @@ -681,25 +633,6 @@ pub async fn get_user_profile_html( } }; - let profile_user = match state - .app_ctx - .repos - .user - .find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid)) - .await - { - Ok(Some(u)) => u, - Ok(None) => return StatusCode::NOT_FOUND.into_response(), - Err(e) => return crate::errors::domain_error_response(e), - }; - - let display_name = profile_user.username().value(); - ctx.page_title = format!("{}'s Diary — Movies Diary", display_name); - ctx.canonical_url = format!( - "{}/users/{}", - state.app_ctx.config.base_url, profile_user_uuid - ); - let sort_by_str = match params.sort_by.as_str() { "date_asc" => "date_asc", "rating" => "rating", @@ -727,19 +660,25 @@ pub async fn get_user_profile_html( include_remote: false, }; - let html_profile_deps = GetProfileDeps { - stats: state.app_ctx.repos.stats.clone(), - diary: state.app_ctx.repos.diary.clone(), - social_query: state.app_ctx.repos.social_query_unified.clone(), - }; - match application::users::get_profile::execute(&html_profile_deps, query).await { + 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 email = profile_user.email().value().to_string(); - let display_name = email.split('@').next().unwrap_or("?").to_string(); + 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(); @@ -809,6 +748,7 @@ pub async fn get_user_profile_html( .into_response() } } + Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(), Err(e) => crate::errors::domain_error_response(e), } } @@ -828,43 +768,26 @@ pub async fn get_profile_settings( ctx.page_title = "Profile Settings — Movies Diary".to_string(); ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url); - let user = match state.app_ctx.repos.user.find_by_id(&user_id).await { - Ok(Some(u)) => u, - Ok(None) => return StatusCode::NOT_FOUND.into_response(), + 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 base_url = &state.app_ctx.config.base_url; - let avatar_url = user - .avatar_path() - .map(|path| format!("{}/images/{}", base_url, path)); - let banner_url = user - .banner_path() - .map(|path| format!("{}/images/{}", base_url, path)); - - let profile_fields: Vec<(String, String)> = state - .app_ctx - .repos - .profile_fields - .get_fields(&user_id) - .await - .unwrap_or_default() - .into_iter() - .map(|f| (f.name, f.value)) - .collect(); - let saved = params.saved.as_deref() == Some("1"); - let bio = user.bio().map(|s| s.to_string()); - let also_known_as = user.also_known_as().map(|s| s.to_string()); - render_page(ProfileSettingsTemplate { ctx: &ctx, - bio: bio.as_deref(), - avatar_url: avatar_url.as_deref(), - banner_url: banner_url.as_deref(), - also_known_as: also_known_as.as_deref(), - profile_fields: &profile_fields, + 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", @@ -892,12 +815,7 @@ pub async fn post_profile_settings( banner_content_type: data.banner_content_type, also_known_as: data.also_known_as, }; - let update_deps = UpdateProfileDeps { - user: state.app_ctx.repos.user.clone(), - object_storage: state.app_ctx.services.object_storage.clone(), - event_publisher: state.app_ctx.services.event_publisher.clone(), - }; - if let Err(e) = update_profile::execute(&update_deps, cmd).await { + 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={}", @@ -925,12 +843,9 @@ pub async fn post_profile_settings( user_id: user_id.value(), fields, }; - if let Err(e) = update_profile_fields::execute( - state.app_ctx.repos.profile_fields.clone(), - state.app_ctx.services.event_publisher.clone(), - fields_cmd, - ) - .await + 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!( diff --git a/crates/presentation/src/handlers/watchlist.rs b/crates/presentation/src/handlers/watchlist.rs index b34f0ac..0f360a4 100644 --- a/crates/presentation/src/handlers/watchlist.rs +++ b/crates/presentation/src/handlers/watchlist.rs @@ -11,8 +11,7 @@ use application::{ watchlist::{ add as add_to_watchlist, commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand}, - deps::WatchlistAddDeps, - get as get_watchlist, is_on as is_on_watchlist, + get as get_watchlist, get_watchlist_for_owner, is_on as is_on_watchlist, queries::{GetWatchlistQuery, IsOnWatchlistQuery}, remove as remove_from_watchlist, }, @@ -54,7 +53,7 @@ pub async fn get_watchlist_handler( Query(params): Query, ) -> Result, ApiError> { let page = get_watchlist::execute( - state.app_ctx.repos.watchlist.clone(), + &state.app_ctx.deps.watchlist.get_watchlist, GetWatchlistQuery { user_id: user.0.value(), limit: params.limit, @@ -94,15 +93,8 @@ pub async fn post_watchlist_add( user: AuthenticatedUser, Json(req): Json, ) -> Result { - let deps = WatchlistAddDeps { - movie_command: state.app_ctx.repos.movie_command.clone(), - movie_query: state.app_ctx.repos.movie_query.clone(), - metadata: state.app_ctx.services.metadata.clone(), - watchlist: state.app_ctx.repos.watchlist.clone(), - event_publisher: state.app_ctx.services.event_publisher.clone(), - }; add_to_watchlist::execute( - &deps, + &state.app_ctx.deps.watchlist.add, AddToWatchlistCommand { user_id: user.0.value(), input: MovieInput { @@ -134,8 +126,7 @@ pub async fn delete_watchlist_entry( Path(movie_id): Path, ) -> Result { remove_from_watchlist::execute( - state.app_ctx.repos.watchlist.clone(), - state.app_ctx.services.event_publisher.clone(), + &state.app_ctx.deps.watchlist.remove_from_watchlist, RemoveFromWatchlistCommand { user_id: user.0.value(), movie_id, @@ -160,7 +151,7 @@ pub async fn get_watchlist_status( Path(movie_id): Path, ) -> Result, ApiError> { let on_watchlist = is_on_watchlist::execute( - state.app_ctx.repos.watchlist.clone(), + &state.app_ctx.deps.watchlist.is_on_watchlist, IsOnWatchlistQuery { user_id: user.0.value(), movie_id, @@ -182,39 +173,25 @@ pub async fn get_watchlist_page( 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 user_id = domain::value_objects::UserId::from_uuid(owner_id); - let is_local = state - .app_ctx - .repos - .user - .find_by_id(&user_id) - .await - .map(|u| u.is_some()) - .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 = if is_local { - match get_watchlist::execute( - state.app_ctx.repos.watchlist.clone(), - application::watchlist::queries::GetWatchlistQuery { - user_id: owner_id, - limit: params.limit.or(Some(20)), - offset: params.offset.or(Some(0)), - }, - ) - .await - { - Ok(page) => crate::mappers::watchlist::build_watchlist_page(page, is_owner), - 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) } - } else { - let remote_entries = state - .app_ctx - .repos - .remote_watchlist - .get_by_derived_uuid(owner_id) - .await - .unwrap_or_default(); - crate::mappers::watchlist::build_remote_watchlist_page(remote_entries) }; render_page(WatchlistTemplate { @@ -280,16 +257,8 @@ pub async fn post_watchlist_add_html( } }; - let deps = WatchlistAddDeps { - movie_command: state.app_ctx.repos.movie_command.clone(), - movie_query: state.app_ctx.repos.movie_query.clone(), - metadata: state.app_ctx.services.metadata.clone(), - watchlist: state.app_ctx.repos.watchlist.clone(), - event_publisher: state.app_ctx.services.event_publisher.clone(), - }; - match add_to_watchlist::execute( - &deps, + &state.app_ctx.deps.watchlist.add, AddToWatchlistCommand { user_id: user_id.value(), input, @@ -323,8 +292,7 @@ pub async fn post_watchlist_remove_html( return StatusCode::FORBIDDEN.into_response(); } match remove_from_watchlist::execute( - state.app_ctx.repos.watchlist.clone(), - state.app_ctx.services.event_publisher.clone(), + &state.app_ctx.deps.watchlist.remove_from_watchlist, RemoveFromWatchlistCommand { user_id: user_id.value(), movie_id, diff --git a/crates/presentation/src/handlers/webhook.rs b/crates/presentation/src/handlers/webhook.rs index b877f10..de53ea4 100644 --- a/crates/presentation/src/handlers/webhook.rs +++ b/crates/presentation/src/handlers/webhook.rs @@ -15,10 +15,9 @@ use application::integrations::{ ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand, IngestWatchEventCommand, RevokeWebhookTokenCommand, WatchEventConfirmation, }, - confirm as confirm_watch_events, - deps::IngestWatchEventDeps, - dismiss as dismiss_watch_events, generate_token as generate_webhook_token, - get_queue as get_watch_queue, get_tokens as get_webhook_tokens, ingest as ingest_watch_event, + confirm as confirm_watch_events, dismiss as dismiss_watch_events, + generate_token as generate_webhook_token, get_queue as get_watch_queue, + get_tokens as get_webhook_tokens, ingest as ingest_watch_event, queries::{GetWatchQueueQuery, GetWebhookTokensQuery}, revoke_token as revoke_webhook_token, }; @@ -72,7 +71,12 @@ pub async fn post_jellyfin_webhook( source: WatchEventSource::Jellyfin, }; - run_ingest(&state, cmd, &jellyfin::JellyfinParser).await + run_ingest( + &state, + cmd, + &*state.app_ctx.deps.integrations.jellyfin_parser, + ) + .await } // ── Plex webhook (multipart form data with `payload` JSON field) ────────────── @@ -119,7 +123,7 @@ pub async fn post_plex_webhook( source: WatchEventSource::Plex, }; - run_ingest(&state, cmd, &plex::PlexParser).await + run_ingest(&state, cmd, &*state.app_ctx.deps.integrations.plex_parser).await } async fn run_ingest( @@ -127,13 +131,13 @@ async fn run_ingest( cmd: IngestWatchEventCommand, parser: &dyn domain::ports::MediaServerParser, ) -> StatusCode { - let deps = IngestWatchEventDeps { - webhook_token: state.app_ctx.repos.webhook_token.clone(), - watch_event_command: state.app_ctx.repos.watch_event_command.clone(), - watch_event_query: state.app_ctx.repos.watch_event_query.clone(), - event_publisher: state.app_ctx.services.event_publisher.clone(), - }; - match ingest_watch_event::execute(&deps, cmd, parser).await { + match ingest_watch_event::execute( + &state.app_ctx.deps.integrations.ingest_watch_event, + cmd, + parser, + ) + .await + { Ok(()) => StatusCode::OK, Err(e) => crate::errors::domain_error_status(&e), } @@ -166,8 +170,11 @@ pub async fn post_generate_webhook_token( label: req.label, }; - let result = - generate_webhook_token::execute(state.app_ctx.repos.webhook_token.clone(), cmd).await?; + let result = generate_webhook_token::execute( + &state.app_ctx.deps.integrations.generate_webhook_token, + cmd, + ) + .await?; let base_url = &state.app_ctx.config.base_url; let webhook_url = format!("{base_url}/api/v1/webhooks/{provider}"); @@ -195,7 +202,8 @@ pub async fn get_webhook_tokens( user_id: user.0.value(), }; let tokens = - get_webhook_tokens::execute(state.app_ctx.repos.webhook_token.clone(), query).await?; + get_webhook_tokens::execute(&state.app_ctx.deps.integrations.get_webhook_tokens, query) + .await?; let dtos = tokens .into_iter() @@ -230,7 +238,8 @@ pub async fn delete_webhook_token( user_id: user.0.value(), token_id: id, }; - revoke_webhook_token::execute(state.app_ctx.repos.webhook_token.clone(), cmd).await?; + revoke_webhook_token::execute(&state.app_ctx.deps.integrations.revoke_webhook_token, cmd) + .await?; Ok(StatusCode::NO_CONTENT) } @@ -252,7 +261,7 @@ pub async fn get_watch_queue( user_id: user.0.value(), }; let events = - get_watch_queue::execute(state.app_ctx.repos.watch_event_query.clone(), query).await?; + get_watch_queue::execute(&state.app_ctx.deps.integrations.get_watch_queue, query).await?; let dtos = events .into_iter() @@ -297,13 +306,9 @@ pub async fn post_confirm_watch_events( .collect(), }; - let confirmed = confirm_watch_events::execute( - state.app_ctx.repos.watch_event_command.clone(), - state.app_ctx.repos.watch_event_query.clone(), - state.app_ctx.services.review_logger.clone(), - cmd, - ) - .await?; + let confirmed = + confirm_watch_events::execute(&state.app_ctx.deps.integrations.confirm_watch_events, cmd) + .await?; Ok(Json(ConfirmWatchResponse { confirmed })) } @@ -327,11 +332,8 @@ pub async fn post_dismiss_watch_events( event_ids: req.event_ids, }; - let dismissed = dismiss_watch_events::execute( - state.app_ctx.repos.watch_event_command.clone(), - state.app_ctx.repos.watch_event_query.clone(), - cmd, - ) - .await?; + let dismissed = + dismiss_watch_events::execute(&state.app_ctx.deps.integrations.dismiss_watch_events, cmd) + .await?; Ok(Json(DismissWatchResponse { dismissed })) } diff --git a/crates/presentation/src/handlers/wrapup.rs b/crates/presentation/src/handlers/wrapup.rs index 75a74dd..0bb6545 100644 --- a/crates/presentation/src/handlers/wrapup.rs +++ b/crates/presentation/src/handlers/wrapup.rs @@ -9,11 +9,11 @@ use uuid::Uuid; use application::wrapup::{ commands::RequestWrapUpCommand, - delete as delete_wrapup, generate, get_wrapup, + delete as delete_wrapup, generate, get_ready_report, get_wrapup, list_wrapups::{self, ListWrapUpsQuery}, }; use domain::errors::DomainError; -use domain::models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpStatus}; +use domain::models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus}; use domain::value_objects::WrapUpId; use crate::{ @@ -66,12 +66,7 @@ pub async fn post_generate( start_date: start, end_date: end, }; - let id = generate::execute( - state.app_ctx.repos.wrapup_repo.clone(), - state.app_ctx.services.event_publisher.clone(), - cmd, - ) - .await?; + let id = generate::execute(&state.app_ctx.deps.wrapup.generate, cmd).await?; Ok(Json(WrapUpGeneratedResponse { id: id.value().to_string(), })) @@ -90,7 +85,7 @@ pub async fn get_list( user: AuthenticatedUser, ) -> Result, ApiError> { let records = list_wrapups::execute( - state.app_ctx.repos.wrapup_repo.clone(), + &state.app_ctx.deps.wrapup.list_wrapups, ListWrapUpsQuery { user_id: Some(user.0.value()), }, @@ -117,7 +112,7 @@ pub async fn get_status( Path(id): Path, ) -> Result, ApiError> { let record = get_wrapup::execute( - state.app_ctx.repos.wrapup_repo.clone(), + &state.app_ctx.deps.wrapup.get_wrapup, WrapUpId::from_uuid(id), ) .await? @@ -142,7 +137,7 @@ pub async fn get_report( Path(id): Path, ) -> impl IntoResponse { match get_wrapup::execute( - state.app_ctx.repos.wrapup_repo.clone(), + &state.app_ctx.deps.wrapup.get_wrapup, WrapUpId::from_uuid(id), ) .await @@ -177,7 +172,7 @@ pub async fn delete_wrapup_handler( Path(id): Path, ) -> Result { delete_wrapup::execute( - state.app_ctx.repos.wrapup_repo.clone(), + &state.app_ctx.deps.wrapup.delete_wrapup, WrapUpId::from_uuid(id), ) .await?; @@ -243,30 +238,15 @@ pub async fn get_user_wrapup_html( Path((user_id, year)): Path<(Uuid, i32)>, Extension(csrf): Extension, ) -> impl IntoResponse { - let start = match NaiveDate::from_ymd_opt(year, 1, 1) { - Some(d) => d, - None => return StatusCode::BAD_REQUEST.into_response(), - }; - let end = match NaiveDate::from_ymd_opt(year + 1, 1, 1) { - Some(d) => d, - None => return StatusCode::BAD_REQUEST.into_response(), - }; - - let record = match state - .app_ctx - .repos - .wrapup_repo - .find_existing(Some(user_id), start, end) - .await - { - Ok(Some(r)) if r.status == WrapUpStatus::Ready => r, - _ => return StatusCode::NOT_FOUND.into_response(), - }; - - let report = match record.report { - Some(r) => r, - None => return StatusCode::NOT_FOUND.into_response(), - }; + 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) @@ -278,29 +258,16 @@ pub async fn get_global_wrapup_html( Path(year): Path, Extension(csrf): Extension, ) -> impl IntoResponse { - let start = match NaiveDate::from_ymd_opt(year, 1, 1) { - Some(d) => d, - None => return StatusCode::BAD_REQUEST.into_response(), - }; - let end = match NaiveDate::from_ymd_opt(year + 1, 1, 1) { - Some(d) => d, - None => return StatusCode::BAD_REQUEST.into_response(), - }; - - let record = match state - .app_ctx - .repos - .wrapup_repo - .find_existing(None, start, end) - .await + let report = match get_ready_report::execute( + &state.app_ctx.deps.wrapup.get_ready_report, + WrapUpScope::Global, + year, + ) + .await { - Ok(Some(r)) if r.status == WrapUpStatus::Ready => r, - _ => return StatusCode::NOT_FOUND.into_response(), - }; - - let report = match record.report { - Some(r) => r, - None => return StatusCode::NOT_FOUND.into_response(), + 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; diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs index 06b44b2..79ba5c9 100644 --- a/crates/presentation/src/lib.rs +++ b/crates/presentation/src/lib.rs @@ -2,7 +2,6 @@ pub mod context; pub mod csrf; pub mod errors; pub mod extractors; -pub mod factory; pub mod forms; pub mod handlers; pub mod mappers; diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs deleted file mode 100644 index 8728282..0000000 --- a/crates/presentation/src/main.rs +++ /dev/null @@ -1,273 +0,0 @@ -use std::sync::Arc; - -use anyhow::Context; - -use tokio::net::TcpListener; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -use application::config::AppConfig; -use export::ExportAdapter; -use importer::ImporterDocumentParser; -use presentation::context::{AppContext, Repositories, Services}; -use presentation::{factory, 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(); - - let (state, ap_router) = wire_dependencies() - .await - .context("Failed to wire dependencies")?; - - let app = openapi::serve(routes::build_router(state, ap_router)); - - let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); - let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string()); - let addr = format!("{}:{}", host, port); - let listener = TcpListener::bind(&addr).await?; - tracing::info!("Listening on {}", addr); - axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ) - .await?; - - Ok(()) -} - -async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { - let app_config = AppConfig::from_env(); - 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) = factory::build_auth_adapters()?; - let metadata_client = factory::build_metadata_client()?; - let poster_fetcher = factory::build_poster_fetcher()?; - let object_storage = factory::build_object_storage()?; - - let db = factory::build_database_adapters(&backend, &database_url).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_service, - social_query, - remote_watchlist_repo, - social_command_arc, - social_query_unified_arc, - ) = { - let fed_repos = match &db_pool { - #[cfg(feature = "postgres-federation")] - factory::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), - #[cfg(feature = "sqlite-federation")] - factory::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.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), - base_url: app_config.base_url.clone(), - allow_registration: app_config.allow_registration, - event_publisher: Arc::clone(&ep), - }) - .await?; - let ap_router = ap.router; - let ap_service_arc = ap.service; - - let composite_social = Arc::new(activitypub::CompositeSocialAdapter::new( - Arc::clone(&ap_service_arc), - Arc::clone(&db.user), - fed_repos.follow_command, - fed_repos.follow_query, - app_config.base_url.clone(), - )); - - ( - ep, - ap_router, - ap_service_arc, - fed_repos.admin_query, - fed_repos.remote_watchlist, - 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: Arc = - Arc::new(domain::ports::noop::NoopSocialCommand); - #[cfg(not(feature = "federation"))] - let social_query_unified_arc: Arc = - Arc::new(domain::ports::noop::NoopSocialQuery); - - 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 app_ctx = AppContext { - 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, - social_query_unified: social_query_unified_arc, - #[cfg(feature = "federation")] - federation_admin: social_query.clone(), - #[cfg(not(feature = "federation"))] - federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery), - wrapup_stats: db.wrapup_stats, - wrapup_repo: db.wrapup_repo, - 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")] - factory::DbPool::Sqlite(pool) => { - sqlite_federation::create_federated_profile_query(pool.clone()) - } - #[cfg(feature = "postgres-federation")] - factory::DbPool::Postgres(pool) => { - postgres_federation::create_federated_profile_query(pool.clone()) - } - #[cfg(not(feature = "sqlite-federation"))] - _ => unreachable!(), - } - }), - #[cfg(not(feature = "federation"))] - federated_profile: None, - }, - services: 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, - #[cfg(feature = "federation")] - ap_service, - }, - config: app_config, - }; - - 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: &factory::DbPool, -) -> anyhow::Result> { - match event_bus { - EventBusBackend::Db => { - tracing::info!("event bus: DB queue"); - Ok(match db_pool { - #[cfg(feature = "postgres")] - factory::DbPool::Postgres(pool) => { - postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await? - } - #[cfg(feature = "sqlite")] - factory::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(tracing_subscriber::EnvFilter::new( - std::env::var("RUST_LOG") - .unwrap_or_else(|_| "presentation=debug,tower_http=debug".into()), - )) - .with(tracing_subscriber::fmt::layer()) - .init(); -} diff --git a/crates/presentation/src/mappers/mod.rs b/crates/presentation/src/mappers/mod.rs index f1574c5..12a30a1 100644 --- a/crates/presentation/src/mappers/mod.rs +++ b/crates/presentation/src/mappers/mod.rs @@ -3,7 +3,5 @@ pub mod import; pub mod integrations; pub mod movies; pub mod search; -#[cfg(feature = "federation")] -pub mod social; pub mod users; pub mod watchlist; diff --git a/crates/presentation/src/mappers/social.rs b/crates/presentation/src/mappers/social.rs deleted file mode 100644 index bc92fd3..0000000 --- a/crates/presentation/src/mappers/social.rs +++ /dev/null @@ -1,9 +0,0 @@ -use api_types::RemoteActorDto; - -pub fn remote_actor_to_dto(a: activitypub::RemoteActor) -> RemoteActorDto { - RemoteActorDto { - handle: a.handle, - display_name: a.display_name, - url: a.url, - } -} diff --git a/crates/presentation/src/mappers/users.rs b/crates/presentation/src/mappers/users.rs index 00a1932..ee3c0fd 100644 --- a/crates/presentation/src/mappers/users.rs +++ b/crates/presentation/src/mappers/users.rs @@ -1,4 +1,4 @@ -use application::users::get_profile::PendingFollowerView; +use application::users::get_local_profile::PendingFollowerView; use domain::models::RemoteActorInfo; use domain::models::UserSummary; use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView}; diff --git a/crates/presentation/src/openapi/social.rs b/crates/presentation/src/openapi/social.rs index d72b65f..c5c8cee 100644 --- a/crates/presentation/src/openapi/social.rs +++ b/crates/presentation/src/openapi/social.rs @@ -1,7 +1,8 @@ #[cfg(feature = "federation")] use api_types::{ ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse, - BlockedDomainResponse, FollowRequest, RemoteActorDto, + BlockedDomainResponse, FollowRelationResponse, FollowRequest, PendingCountResponse, + RemoteActorDto, }; #[cfg(feature = "federation")] use utoipa::OpenApi; @@ -13,6 +14,9 @@ use utoipa::OpenApi; 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, @@ -33,6 +37,8 @@ use utoipa::OpenApi; BlockedDomainResponse, AddBlockedDomainRequest, BlockedActorResponse, + FollowRelationResponse, + PendingCountResponse, )) )] pub struct SocialDoc; diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 4eea57f..efc883c 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -184,22 +184,22 @@ fn html_routes(rate_limit: u64) -> Router { 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)) } -#[cfg(feature = "federation")] -fn federation_html_routes() -> Router { - Router::new() +fn social_html_routes() -> Router { + let base = Router::new() .route( "/users/{id}/follow", - routing::post(handlers::social::follow_remote_user), + routing::post(handlers::social::follow_user), ) .route( "/users/{id}/unfollow", - routing::post(handlers::social::unfollow_remote_user), + routing::post(handlers::social::unfollow_user), ) .route( "/users/{id}/followers/accept", @@ -210,12 +210,8 @@ fn federation_html_routes() -> Router { routing::post(handlers::social::reject_follower_html), ) .route( - "/users/{id}/followers", - routing::get(handlers::social::get_followers_collection), - ) - .route( - "/users/{id}/following", - routing::get(handlers::social::get_following_collection), + "/users/{id}/followers/remove", + routing::post(handlers::social::remove_follower_html), ) .route( "/users/{id}/following-list", @@ -224,10 +220,40 @@ fn federation_html_routes() -> Router { .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}/followers/remove", - routing::post(handlers::social::remove_follower_html), + "/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", @@ -455,6 +481,7 @@ fn api_routes(rate_limit: u64) -> Router { 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()); @@ -481,8 +508,7 @@ fn api_routes(rate_limit: u64) -> Router { .layer(cors_layer()) } -#[cfg(feature = "federation")] -fn federation_api_routes() -> Router { +fn social_api_routes() -> Router { Router::new() .route( "/social/following", @@ -496,6 +522,18 @@ fn federation_api_routes() -> Router { "/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", @@ -513,6 +551,19 @@ fn federation_api_routes() -> Router { "/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) @@ -534,12 +585,4 @@ fn federation_api_routes() -> Router { "/social/blocked", routing::get(handlers::social::get_blocked_actors_api), ) - .route( - "/users/{id}/following", - routing::get(handlers::social::get_user_following), - ) - .route( - "/users/{id}/followers", - routing::get(handlers::social::get_user_followers), - ) } diff --git a/crates/presentation/src/tests/api_handlers.rs b/crates/presentation/src/tests/api_handlers.rs index 2fc95a1..031d37b 100644 --- a/crates/presentation/src/tests/api_handlers.rs +++ b/crates/presentation/src/tests/api_handlers.rs @@ -1,4 +1,4 @@ -use crate::extractors::tests::{Panic, make_test_state}; +use crate::extractors::tests::{AcceptingAuth, test_app_state, test_app_state_with_follow_graph}; use axum::{ Router, body::Body, @@ -73,9 +73,7 @@ impl domain::ports::PersonQuery for PersonQueryStub { #[tokio::test] async fn search_endpoint_returns_200_with_empty_results() { - let mut state = make_test_state(Arc::new(Panic)); - // Override the search_port with our stub - state.app_ctx.repos.search_port = Arc::new(SearchPortStub); + let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub)); let app = Router::new() .route("/api/v1/search", get(crate::handlers::search::get_search)) .with_state(state); @@ -95,9 +93,7 @@ async fn search_endpoint_returns_200_with_empty_results() { #[tokio::test] async fn search_endpoint_with_no_query_returns_200() { - let mut state = make_test_state(Arc::new(Panic)); - // Override the search_port with our stub - state.app_ctx.repos.search_port = Arc::new(SearchPortStub); + let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub)); let app = Router::new() .route("/api/v1/search", get(crate::handlers::search::get_search)) .with_state(state); @@ -119,9 +115,8 @@ async fn search_endpoint_with_no_query_returns_200() { #[tokio::test] async fn person_endpoint_returns_404_for_unknown_id() { - let mut state = make_test_state(Arc::new(Panic)); - // Override the person_query with our stub - state.app_ctx.repos.person_query = Arc::new(PersonQueryStub); + let state = + crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub)); let app = Router::new() .route( "/api/v1/people/{id}", @@ -145,9 +140,8 @@ async fn person_endpoint_returns_404_for_unknown_id() { #[tokio::test] async fn person_credits_endpoint_returns_404_for_unknown_id() { - let mut state = make_test_state(Arc::new(Panic)); - // Override the person_query with our stub - state.app_ctx.repos.person_query = Arc::new(PersonQueryStub); + let state = + crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub)); let app = Router::new() .route( "/api/v1/people/{id}/credits", @@ -169,11 +163,30 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } +/// Proves stub injection reaches the prebuilt deps, not just the `Repositories` +/// passed into `build_test_state_from`. Before the fix that added construct-time +/// stub injection, this failed on the assertion below — exactly the false-green +/// that would have silently broken the person-handler tests in Task 6. `AppContext` +/// no longer carries a `repos` field (Plan C2), so the original companion +/// assertion on `state.app_ctx.repos.person_query` was dropped along with the +/// field; the assertion that matters — that `deps` itself holds the stub — stays. +#[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)); + + assert!( + Arc::ptr_eq(&state.app_ctx.deps.person.get_person.person_query, &stub), + "prebuilt deps must hold the injected stub too, or handlers reading deps \ + will silently use the real dependency" + ); +} + // --- Watchlist endpoint tests --- #[tokio::test] async fn get_watchlist_requires_auth() { - let state = make_test_state(Arc::new(Panic)); + let state = test_app_state(); let app = Router::new() .route( "/api/v1/watchlist", @@ -194,9 +207,103 @@ async fn get_watchlist_requires_auth() { assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } +// --- Pending-follower-count endpoint tests --- + +/// Stub `FollowGraphQuery` returning a fixed pending-follower count. +struct FixedPendingCount(usize); +#[async_trait::async_trait] +impl domain::ports::FollowGraphQuery for FixedPendingCount { + async fn get_following( + &self, + _: &domain::value_objects::UserId, + ) -> Result, DomainError> { + unreachable!() + } + async fn get_followers( + &self, + _: &domain::value_objects::UserId, + ) -> Result, DomainError> { + unreachable!() + } + async fn get_pending_followers( + &self, + _: &domain::value_objects::UserId, + ) -> Result, DomainError> { + unreachable!() + } + async fn get_pending_following( + &self, + _: &domain::value_objects::UserId, + ) -> Result, DomainError> { + unreachable!() + } + async fn count_following( + &self, + _: &domain::value_objects::UserId, + ) -> Result { + unreachable!() + } + async fn count_followers( + &self, + _: &domain::value_objects::UserId, + ) -> Result { + unreachable!() + } + async fn count_pending_followers( + &self, + _: &domain::value_objects::UserId, + ) -> Result { + Ok(self.0) + } + async fn get_relation( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::SocialIdentity, + ) -> Result { + unreachable!() + } +} + +/// The point of Task 4: one use case (`social::count_pending_followers`) now +/// serves both the classic UI (via `get_page_viewer`) and this new HTTP +/// endpoint. Seed the query port with a fixed count and assert the endpoint +/// reflects it verbatim. +#[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)), + Arc::new(AcceptingAuth(uid)), + ); + let app = Router::new() + .route( + "/api/v1/social/followers/pending/count", + get(crate::handlers::social::get_pending_follower_count), + ) + .with_state(state); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/v1/social/followers/pending/count") + .header("authorization", "Bearer test-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["count"], 3); +} + #[tokio::test] async fn get_watchlist_status_requires_auth() { - let state = make_test_state(Arc::new(Panic)); + let state = test_app_state(); let app = Router::new() .route( "/api/v1/watchlist/{movie_id}", @@ -216,3 +323,88 @@ async fn get_watchlist_status_requires_auth() { assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } + +// --- Federation collection error-handling tests --- + +#[cfg(feature = "federation")] +struct FailingApDocument; + +#[cfg(feature = "federation")] +#[async_trait::async_trait] +impl domain::ports::ApDocumentPort for FailingApDocument { + async fn actor_json(&self, _: &str) -> Result { + Err(DomainError::InfrastructureError("boom".into())) + } + async fn followers_collection_json( + &self, + _: Uuid, + _: Option, + ) -> Result { + Err(DomainError::InfrastructureError("boom".into())) + } + async fn following_collection_json( + &self, + _: Uuid, + _: Option, + ) -> Result { + Err(DomainError::InfrastructureError("boom".into())) + } +} + +/// Swapping `ap_document` on the returned state is safe — unlike the `repos` +/// fields, handlers read `app_ctx.ap_document` directly and no prebuilt deps +/// struct carries a copy of it. +#[cfg(feature = "federation")] +fn state_with_failing_ap_document() -> crate::state::AppState { + let mut state = test_app_state(); + state.app_ctx.ap_document = Arc::new(FailingApDocument); + state +} + +#[cfg(feature = "federation")] +#[tokio::test] +async fn followers_collection_returns_500_on_federation_error() { + let app = Router::new() + .route( + "/users/{id}/followers", + get(crate::handlers::social::get_followers_collection), + ) + .with_state(state_with_failing_ap_document()); + + let resp = app + .oneshot( + Request::builder() + .uri(format!("/users/{}/followers", Uuid::nil())) + .header(axum::http::header::ACCEPT, "application/activity+json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[cfg(feature = "federation")] +#[tokio::test] +async fn following_collection_returns_500_on_federation_error() { + let app = Router::new() + .route( + "/users/{id}/following", + get(crate::handlers::social::get_following_collection), + ) + .with_state(state_with_failing_ap_document()); + + let resp = app + .oneshot( + Request::builder() + .uri(format!("/users/{}/following", Uuid::nil())) + .header(axum::http::header::ACCEPT, "application/activity+json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); +} diff --git a/crates/presentation/src/tests/context.rs b/crates/presentation/src/tests/context.rs new file mode 100644 index 0000000..c23a19b --- /dev/null +++ b/crates/presentation/src/tests/context.rs @@ -0,0 +1,35 @@ +/// The prebuilt deps must point at the SAME port instances as the `Repositories` +/// they were built from. If build_deps is wired to a different Arc — or is called +/// before a repo is swapped — handlers in Plan B silently talk to the wrong +/// dependency, and no other test in Plan A would catch it. +/// +/// `AppContext` no longer carries a `repos` field (Plan C2 deleted it), so this +/// test builds its own `Repositories` locally, keeps a clone of the Arcs it +/// cares about, and hands the original to `build_test_state_from` — the same +/// construction `test_app_state()` uses internally. +#[test] +fn prebuilt_deps_share_port_instances_with_repos() { + use std::sync::Arc; + + let repos = crate::extractors::tests::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), + ); + + assert!( + Arc::ptr_eq(&state.app_ctx.deps.users.get_local_profile.user, &user), + "deps.users.get_local_profile.user must be the same Arc as the Repositories it was built from" + ); + assert!( + Arc::ptr_eq(&state.app_ctx.deps.social.query.follow_graph, &follow_graph), + "deps.social.query.follow_graph must be the same Arc as the Repositories it was built from" + ); + assert_eq!( + state.app_ctx.deps.users.get_local_profile.instance, state.app_ctx.instance, + "prebuilt deps must carry the context's InstanceIdentity" + ); +} diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index 0f1cd20..a965244 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -1,5 +1,5 @@ use super::*; -use crate::context::{AppContext, Repositories, Services}; +use crate::context::AppContext; use application::config::AppConfig; use axum::{ Router, @@ -7,6 +7,7 @@ use axum::{ http::{Request, StatusCode}, routing::get, }; +use composition::Repositories; use domain::{ errors::DomainError, events::DomainEvent, @@ -17,11 +18,12 @@ use domain::{ collections::{PageParams, Paginated}, }, ports::{ - AuthService, DiaryQuery, EventPublisher, MetadataClient, MovieCommand, MovieQuery, - ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, + AuthService, DiaryQuery, EventPublisher, FollowGraphQuery, 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, @@ -761,74 +763,232 @@ impl application::ports::ReviewLogger for Panic { } } -// --- Single state factory — only auth_service varies --- +// --- Repositories factory — `Panic` stubs everywhere --- -pub fn make_test_state(auth_service: Arc) -> crate::state::AppState { +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 _, + 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 { - repos: 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 _, - social_query_unified: 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 _, - 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, - }, - services: 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, - #[cfg(feature = "federation")] - ap_service: Arc::new(activitypub::NoopActivityPubService), - }, - 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, - }, - }, + 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 { @@ -843,6 +1003,12 @@ async fn optional_cookie_handler(user: OptionalCookieUser) -> 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() @@ -854,6 +1020,16 @@ fn router_optional(state: crate::state::AppState) -> Router { .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)) @@ -864,7 +1040,7 @@ fn router_required(state: crate::state::AppState) -> Router { #[tokio::test] async fn missing_auth_header_returns_401() { - let app = router_protected(make_test_state(Arc::new(Panic))); + let app = router_protected(test_app_state()); let resp = app .oneshot( Request::builder() @@ -879,7 +1055,7 @@ async fn missing_auth_header_returns_401() { #[tokio::test] async fn optional_cookie_user_returns_none_without_cookie() { - let app = router_optional(make_test_state(Arc::new(Panic))); + let app = router_optional(test_app_state()); let resp = app .oneshot( Request::builder() @@ -918,7 +1094,7 @@ async fn optional_cookie_user_returns_none_with_invalid_token() { #[tokio::test] async fn required_cookie_user_redirects_without_cookie() { - let app = router_required(make_test_state(Arc::new(Panic))); + let app = router_required(test_app_state()); let resp = app .oneshot( Request::builder() @@ -948,3 +1124,197 @@ async fn required_cookie_user_redirects_with_invalid_token() { 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 index 1df7f56..afb3afa 100644 --- a/crates/presentation/src/tests/mod.rs +++ b/crates/presentation/src/tests/mod.rs @@ -1 +1,2 @@ mod api_handlers; +mod context; diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 0000000..04be03c --- /dev/null +++ b/crates/server/Cargo.toml @@ -0,0 +1,78 @@ +[package] +name = "server" +version = "0.1.0" +edition = "2024" +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", +] + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +dotenvy = { workspace = true } + +domain = { workspace = true } +application = { workspace = true } +composition = { workspace = true } +presentation = { path = "../presentation", default-features = false } +auth = { workspace = true } +metadata = { workspace = true } +poster-fetcher = { workspace = true } +object-storage = { workspace = true } +rss = { workspace = true } +export = { workspace = true } +importer = { workspace = true } +nats = { workspace = true, optional = true } +sqlx = { workspace = true } +infra-wiring = { workspace = true } +async-trait = { workspace = true } + +# 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"] } diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs new file mode 100644 index 0000000..63cd894 --- /dev/null +++ b/crates/server/src/main.rs @@ -0,0 +1,320 @@ +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(); + + let (state, ap_router) = wire_dependencies() + .await + .context("Failed to wire dependencies")?; + + let app = openapi::serve(routes::build_router(state, ap_router)); + + let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); + let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string()); + let addr = format!("{}:{}", host, port); + let listener = TcpListener::bind(&addr).await?; + tracing::info!("Listening on {}", addr); + axum::serve( + listener, + app.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, + 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(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "server=debug,tower_http=debug".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); +} diff --git a/crates/presentation/tests/api_test.rs b/crates/server/tests/api_test.rs similarity index 81% rename from crates/presentation/tests/api_test.rs rename to crates/server/tests/api_test.rs index b70a333..c8ae166 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/server/tests/api_test.rs @@ -7,6 +7,7 @@ use axum::{ body::Body, http::{Request, StatusCode}, }; +use composition::Repositories; use domain::{ errors::DomainError, events::DomainEvent, @@ -22,7 +23,7 @@ use domain::{ value_objects::{Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId}, }; use http_body_util::BodyExt; -use presentation::context::{AppContext, Repositories, Services}; +use presentation::context::AppContext; use presentation::{routes, state::AppState}; use rss::RssAdapter; use sqlite::{ @@ -405,65 +406,81 @@ async fn test_app() -> Router { .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 _, + 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(PanicAuth), + 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 { - 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), - social_query_unified: 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 _, - 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, - }, - services: Services { - auth: Arc::new(PanicAuth), - 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, - #[cfg(feature = "federation")] - ap_service: Arc::new(activitypub::NoopActivityPubService), - }, - 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, - }, - }, + 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())), }; diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 30bf4da..5480951 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -5,17 +5,30 @@ edition = "2024" [features] default = ["sqlite", "sqlite-federation"] -sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "infra-wiring/sqlite"] -postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search", "infra-wiring/postgres"] -nats = ["dep:nats", "infra-wiring/nats"] +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"] -postgres-federation = ["postgres", "dep:postgres-federation", "dep:activitypub", "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", +] [dependencies] domain = { workspace = true } application = { workspace = true } -tokio = { workspace = true } +composition = { workspace = true } +tokio = { workspace = true, features = ["signal"] } anyhow = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/crates/worker/src/db.rs b/crates/worker/src/db.rs deleted file mode 100644 index 2885aa6..0000000 --- a/crates/worker/src/db.rs +++ /dev/null @@ -1,125 +0,0 @@ -use std::sync::Arc; - -use anyhow::Context; -use domain::ports::{ - DiaryQuery, GoalCommand, GoalQuery, ImageRefCommand, ImageRefQuery, ImportSessionRepository, - LocalApContentQuery, MovieCommand, MovieDeduplicator, MovieProfileRepository, MovieQuery, - PersonCommand, PersonQuery, ReviewRepository, SearchCommand, StatsRepository, UserRepository, - WatchEventCommand, WatchEventQuery, -}; - -pub use infra_wiring::DbPool; - -pub struct WorkerDbOutput { - pub movie_command: Arc, - pub movie_query: Arc, - pub review: Arc, - pub diary: Arc, - pub stats: Arc, - pub _goal_command: Arc, - pub goal_query: Arc, - pub user: Arc, - pub import_session: Arc, - pub movie_profile: Arc, - pub watch_event_command: Arc, - pub _watch_event_query: Arc, - pub person_command: Arc, - pub person_query: Arc, - pub search_command: Arc, - pub ap_content: Arc, - pub image_ref_command: Arc, - pub image_ref_query: Arc, - pub wrapup_stats: Arc, - pub wrapup_repo: Arc, - pub remote_goal: Arc, - pub refresh_session: Arc, - pub federation_settings: Arc, - pub deduplicator: Arc, - pub db_pool: DbPool, -} - -pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result { - match backend { - #[cfg(feature = "postgres")] - "postgres" => { - let w = postgres::wire(database_url) - .await - .context("PostgreSQL connection failed")?; - let (image_ref_command, image_ref_query) = postgres::create_image_ref(w.pool.clone()); - let (person_command, person_query) = postgres::create_person_adapter(w.pool.clone()); - let (search_command, _search_port) = - postgres_search::create_search_adapter(w.pool.clone()); - let we = Arc::new(postgres::PostgresWatchEventRepository::new(w.pool.clone())); - Ok(WorkerDbOutput { - movie_command: w.movie_command, - movie_query: w.movie_query, - review: w.review, - diary: w.diary, - stats: w.stats, - _goal_command: w.goal_command, - goal_query: w.goal_query, - user: w.user, - import_session: w.import_session, - movie_profile: w.movie_profile, - watch_event_command: we.clone() as _, - _watch_event_query: we as _, - person_command, - person_query, - search_command, - ap_content: w.ap_content, - image_ref_command, - image_ref_query, - wrapup_stats: w.wrapup_stats, - wrapup_repo: w.wrapup_repo, - remote_goal: w.remote_goal, - refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new( - w.pool.clone(), - )) as _, - federation_settings: w.federation_settings, - deduplicator: w.deduplicator, - db_pool: DbPool::Postgres(w.pool), - }) - } - #[cfg(feature = "sqlite")] - _ => { - let w = sqlite::wire(database_url) - .await - .context("SQLite connection failed")?; - let (image_ref_command, image_ref_query) = sqlite::create_image_ref(w.pool.clone()); - let (person_command, person_query) = sqlite::create_person_adapter(w.pool.clone()); - let (search_command, _search_port) = - sqlite_search::create_search_adapter(w.pool.clone()); - let we = Arc::new(sqlite::SqliteWatchEventRepository::new(w.pool.clone())); - Ok(WorkerDbOutput { - movie_command: w.movie_command, - movie_query: w.movie_query, - review: w.review, - diary: w.diary, - stats: w.stats, - _goal_command: w.goal_command, - goal_query: w.goal_query, - user: w.user, - import_session: w.import_session, - movie_profile: w.movie_profile, - watch_event_command: we.clone() as _, - _watch_event_query: we as _, - person_command, - person_query, - search_command, - ap_content: w.ap_content, - image_ref_command, - image_ref_query, - wrapup_stats: w.wrapup_stats, - wrapup_repo: w.wrapup_repo, - remote_goal: w.remote_goal, - refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone())) - as _, - federation_settings: w.federation_settings, - deduplicator: w.deduplicator, - db_pool: DbPool::Sqlite(w.pool), - }) - } - #[cfg(not(feature = "sqlite"))] - _ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build"), - } -} diff --git a/crates/worker/src/event_bus.rs b/crates/worker/src/event_bus.rs index 20982a6..cc0c49a 100644 --- a/crates/worker/src/event_bus.rs +++ b/crates/worker/src/event_bus.rs @@ -4,8 +4,7 @@ use std::sync::Arc; use anyhow::Context; use domain::ports::{EventConsumer, EventPublisher}; -use crate::db::DbPool; -use infra_wiring::EventBusBackend; +use infra_wiring::{DbPool, EventBusBackend}; pub async fn create( db_pool: &DbPool, diff --git a/crates/worker/src/follow_backfill_handler.rs b/crates/worker/src/follow_backfill_handler.rs index bf64658..454c12b 100644 --- a/crates/worker/src/follow_backfill_handler.rs +++ b/crates/worker/src/follow_backfill_handler.rs @@ -2,11 +2,14 @@ use std::sync::Arc; use async_trait::async_trait; use domain::{ - errors::DomainError, events::DomainEvent, ports::EventHandler, value_objects::SocialIdentity, + errors::DomainError, + events::DomainEvent, + ports::{ApBackfillPort, EventHandler}, + value_objects::SocialIdentity, }; pub struct FollowBackfillHandler { - pub ap_service: Arc, + pub backfill: Arc, } #[async_trait] @@ -18,18 +21,13 @@ impl EventHandler for FollowBackfillHandler { requester: SocialIdentity::Remote { actor_url }, } => { tracing::info!(actor = %actor_url, "follow accepted — looking up outbox for import"); - let following = self - .ap_service - .get_following(owner.value()) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + 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.ap_service + self.backfill .import_remote_outbox(outbox_url, actor_url) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + .await?; } else { tracing::warn!(actor = %actor_url, "no outbox URL for accepted follow — skipping import"); } @@ -41,10 +39,9 @@ impl EventHandler for FollowBackfillHandler { follower_inbox_url, } => { tracing::info!(owner = %owner_user_id.value(), inbox = %follower_inbox_url, "backfilling local content to new follower"); - self.ap_service + self.backfill .run_backfill_for_follower(owner_user_id.value(), follower_inbox_url.clone()) .await - .map_err(|e| DomainError::InfrastructureError(e.to_string())) } _ => Ok(()), } diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 0015671..7e70ade 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -1,4 +1,3 @@ -mod db; mod event_bus; mod follow_backfill_handler; @@ -7,7 +6,7 @@ use std::sync::Arc; use anyhow::Context; use application::{ MovieDiscoveryIndexer, SearchCleanupHandler, SearchReindexHandler, config::AppConfig, - movies::deps::ReindexSearchDeps, worker::WorkerService, + worker::WorkerService, }; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -26,14 +25,39 @@ async fn main() -> anyhow::Result<()> { 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()?; - let db = db::connect(&database_url, &backend).await?; + // 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); @@ -46,7 +70,6 @@ async fn main() -> anyhow::Result<()> { fed_goal_repo, fed_stats_repo, fed_user_repo, - base_url, allow_registration, ) = ( Arc::clone(&db.ap_content), @@ -56,29 +79,29 @@ async fn main() -> anyhow::Result<()> { Arc::clone(&db.goal_query), Arc::clone(&db.stats), Arc::clone(&db.user), - app_config.base_url.clone(), 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")] - db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()), + composition::DbPool::Sqlite(pool) => { + sqlite_federation::wire(pool.clone(), instance.clone()) + } #[cfg(feature = "postgres-federation")] - db::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), + composition::DbPool::Postgres(pool) => { + postgres_federation::wire(pool.clone(), instance.clone()) + } }; let movie_command = db.movie_command; let movie_query = db.movie_query; - let deduplicator = db.deduplicator; 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_command = db.person_command; let person_query = db.person_query; let search_command = db.search_command; - let wrapup_stats = db.wrapup_stats; let wrapup_repo = db.wrapup_repo; let remote_goal = db.remote_goal; let refresh_session = db.refresh_session; @@ -97,25 +120,22 @@ async fn main() -> anyhow::Result<()> { Option>, ); let (enrichment_handler, person_enrichment_handler, enrichment_job): EnrichmentParts = - match tmdb_enrichment::TmdbEnrichmentClient::from_env() { - Ok(client) => { - tracing::info!("TMDb enrichment enabled"); - let client = Arc::new(client); + 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, - Arc::clone(&movie_query), - Arc::clone(&movie_profile), - Arc::clone(&person_command), - Arc::clone(&search_command), + 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_enrichment_arc = Arc::clone(&client) as Arc; let person_handler = Arc::new(application::person::PersonEnrichmentHandler::new( - Arc::clone(&person_query), - Some(person_enrichment_arc), - Arc::clone(&person_command), + 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), @@ -123,10 +143,7 @@ async fn main() -> anyhow::Result<()> { )) as Arc; (Some(handler), Some(person_handler), Some(job)) } - Err(e) => { - tracing::warn!("TMDb enrichment disabled: {e}"); - (None, None, None) - } + None => (None, None, None), }; // ── Image conversion ────────────────────────────────────────────────────── @@ -142,9 +159,9 @@ async fn main() -> anyhow::Result<()> { let mut periodic_jobs: Vec> = vec![ Arc::new(application::jobs::MovieDeduplicationJob::new( - Arc::clone(&movie_query), - Arc::clone(&deduplicator), - Arc::clone(&object_storage), + 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(), @@ -211,18 +228,13 @@ async fn main() -> anyhow::Result<()> { )) as Arc; let wrapup_handler = Arc::new(application::wrapup::event_handler::WrapUpEventHandler::new( - Arc::clone(&wrapup_repo), - Arc::clone(&event_publisher), - Arc::clone(&wrapup_stats), + 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(ReindexSearchDeps { - movie_query: Arc::clone(&movie_query), - movie_profile: Arc::clone(&movie_profile), - search_command: Arc::clone(&search_command), - person_command: Arc::clone(&person_command), - person_query: Arc::clone(&person_query), - })) as Arc; + let reindex_handler = Arc::new(SearchReindexHandler::new(worker_deps.reindex_search)) + as Arc; let mut h = vec![ poster, @@ -252,7 +264,7 @@ async fn main() -> anyhow::Result<()> { user_repo: fed_user_repo, follow_command: fed_repos.follow_command, follow_query: fed_repos.follow_query, - base_url, + instance: instance.clone(), allow_registration, event_publisher: Arc::clone(&event_publisher), federation_settings: std::sync::Arc::clone(&db.federation_settings), @@ -262,7 +274,7 @@ async fn main() -> anyhow::Result<()> { tracing::info!("federation event handler registered"); h.push(ap_wire.event_handler); h.push(Arc::new(follow_backfill_handler::FollowBackfillHandler { - ap_service: ap_wire.service, + backfill: ap_wire.backfill, }) as Arc); } diff --git a/docs/adr/0001-general-review-editing.md b/docs/adr/0001-general-review-editing.md new file mode 100644 index 0000000..a1991e3 --- /dev/null +++ b/docs/adr/0001-general-review-editing.md @@ -0,0 +1,8 @@ +# General review editing with partial updates + +Reviews were insert-only by design — one watch, one immutable record. Adding the WatchMedium field (optional metadata for how a movie was watched) required an update path for backfilling existing entries. Rather than adding a narrow "set medium" operation, we chose general partial editing of all mutable fields (rating, comment, watched_at, watch_medium). This mirrors the update capability that already exists for inbound federated reviews, and avoids accumulating single-field setters as new optional fields are added over time. Local edits broadcast an AP `Update` activity to stay consistent with federation. + +## Considered Options + +- **Narrow setter per field** — rejected because it would need to be repeated for every future optional field, and the infrastructure cost (use case, repo method, API endpoint, UI) is identical to general edit. +- **Delete and re-create** — rejected because it changes the review ID, breaks AP references, and is a worse UX for correcting a typo. diff --git a/docs/adr/0004-instance-identity.md b/docs/adr/0004-instance-identity.md new file mode 100644 index 0000000..0813d6c --- /dev/null +++ b/docs/adr/0004-instance-identity.md @@ -0,0 +1,117 @@ +# InstanceIdentity owns URL derivation; social queries decompose + +ADR-0002 and ADR-0003 unified local and federated social operations, but left `base_url` +travelling as a bare `&str`: a parameter on `FollowQuery` methods, a field on +`CompositeSocialAdapter`, and an argument to `SocialIdentity::from_actor_url`, +`format_local_handle` and `host_from_base_url`, scattered across many call sites. With no +owner, each layer re-derived URLs its own way — and `handlers/social.rs` derived +`local:{uuid}`, which `from_actor_url` could not parse, so every POST echoing a local actor's +url back resolved to `Remote` and was routed to ActivityPub. Accepting a local follow request +through the SPA was broken. + +## Decision + +- `InstanceIdentity` (`crates/domain/src/value_objects/instance.rs`) is a domain value object + owning `actor_url_for`, `handle_for`, `image_url_for`, `actor_url_of`, `identify` and `host`. + `SocialIdentity`'s static URL helpers (`from_actor_url`, `format_local_handle`, + `host_from_base_url`) are deleted so the duplication cannot return. +- AP object URLs — review, goal and watchlist-entry URLs — deliberately stay out of + `InstanceIdentity` and live in the ActivityPub adapter's `urls.rs` + (`crates/adapters/activitypub/src/urls.rs`), which takes `&InstanceIdentity` and builds on + `instance.base_url()`/`instance.actor_url_for()` rather than re-deriving. A review, goal or + watchlist entry isn't an identity of this instance the way an actor URL or handle is — it's a + content object's location — so folding them into the value object would turn it into a general + URL grab-bag instead of an identity abstraction. `actor_url` is the one exception that + delegates straight to `instance.actor_url_for(...)`, so there is exactly one construction of + an actor URL in the codebase. The five ActivityPub adapter structs (`ActivityPubEventHandler`, + `GoalObjectHandler`, `ReviewObjectHandler`, `DomainUserRepoAdapter`, `WatchlistObjectHandler`) + and `handlers/users.rs`'s image-serving endpoints now hold/use `InstanceIdentity` instead of a + bare `base_url: String`, closing the gap the whole-branch review found: this ADR's scope claim + now holds for the entire codebase, not just the follow/social/profile-identity paths that + landed first. +- `handle_for` takes `&str`, not `&Username`. Database rows carry unvalidated + `Option` usernames; requiring a `Username` would force revalidation of data that is + already stored and already trusted. +- Local actors are identified on the wire by their canonical AP actor URL, + `{base_url}/users/{uuid}` — what the database already stores. `identify(actor_url_of(id)) == + id` is a tested domain property (`instance_actor_url_round_trips_both_variants` in + `crates/domain/src/tests/value_objects.rs`). +- Adapters hold an `InstanceIdentity` rather than receiving `base_url` per call; it is the same + value on every call. `CompositeSocialAdapter` and `GetProfileDeps` both carry one. +- `SocialQuery` splits into `FollowGraphQuery` and `BlockQuery`, along the seam + `CompositeSocialAdapter` already showed: `get_blocked` is the only method that reaches k_ap + (`ap_service.get_blocked_actors`); every `FollowGraphQuery` method — including the new + `get_relation` — goes through the local `follow_query` SQL port instead. `is_following -> + bool` becomes `get_relation -> FollowRelation`, carrying both directions + (`following`/`followed_by`) and distinguishing pending from accepted. `get_relation` + propagates sqlx decode errors as `DomainError::InfrastructureError` rather than folding them + into the same `None` that means "no follow edge" — the original snippet used + `.ok().flatten()`, which would have reported a broken query as a stranger. +- Social **queries** become one file per use case (`get_followers.rs`, `get_following.rs`, + `get_blocked.rs`, `get_pending_followers.rs` under `crates/application/src/social/`), + matching `diary/` and `users/` and making ADR-0002's one-file-per-use-case claim actually + true — it described a pattern the query side never had until now. `SocialQry` and + `execute_query` are deleted outright, along with the `queries.rs` file that held nothing + else. +- The `SocialCmd` **command** dispatcher (`crates/application/src/social/execute.rs`) stays as + a single `execute_command` matching on an enum. Every branch is genuinely "call port, return + event" and `execute_command` publishes the event — a real, uniform abstraction, not an + accident of history the way the query enum was. +- `get_relation`'s application-layer wrapper is deferred, not built. The port method exists and + is tested (against `InMemorySocialRepository`; neither SQL adapter's query is exercised yet), + but nothing calls it until a follow-up plan adds a `/social/relationship` endpoint — building + an unused public function now would be overbuilding. +- The dead `SocialCommandDeps.social_query` field was removed. `execute_command` never read it; + every construction site was cloning an `Arc` nothing consumed. +- `get_profile` (`crates/application/src/users/get_profile.rs`) now assembles the whole + `ProfileIdentity` — username, display name, bio, handle, actor URL, avatar/banner URLs — from + the `User` entity and `InstanceIdentity`, rather than leaving the handler to fill in `None` + for fields it didn't have. `display_name` and `bio` are populated for the first time; the API + had always returned `None` for both despite the entity exposing them. +- `get_profile`'s missing-local-row case was deliberately tolerant, not `NotFound`. + `build_federated_profile_response` built its own `GetProfileDeps` and called `execute()` a + second time with a federated `user_id` that has no row in the local `users` table — there are + three `execute` call sites in `handlers/users.rs`, not one, and the file aliases the import as + `get_user_profile_uc::execute`, which hides two of them from a naive grep. Returning + `NotFound` here would have broken federated profile viewing outright. The tolerant branch was + covered by a permanent regression test, + `tolerates_a_user_id_with_no_local_row_for_federated_style_calls`. + - **Known wart, closed:** the tolerant branch filled `username` and `handle` with empty-string + sentinels, but `actor_url` was still a well-formed-looking `{base_url}/users/{uuid}` pointing + at a user that does not exist locally — one field obviously wrong, one plausibly wrong. It + was inert only because the federated handler never read `profile.identity` (it built its own + handle/actor_url from the resolved `fed` actor). A follow-up refactor (see the C1 + bypasses-and-server-split plan, task 1) made that "moment any caller starts consuming + `profile.identity` on the federated path" arrive: `get_profile` split into + `users::get_local_profile` — `Err(DomainError::NotFound(_))` for a missing local row, + `identity` always fully populated — and `users::get_federated_profile_stats`, whose return + type (`FederatedProfileStats`) carries no `identity` field at all, because the federated + handler never read one. The old regression test now lives as + `get_federated_profile_stats::tests::succeeds_for_a_user_id_with_no_local_row`, and a new + one, `get_local_profile::tests::get_local_profile_is_not_found_for_unknown_user`, pins the + behavior change directly. No type in the codebase can represent the old sentinel anymore. + ADR-0006, covering the rest of the bypasses-and-server-split plan, cross-references this + closure. + +## Considered Options + +- **Teach `from_actor_url` to parse `local:{uuid}`** — rejected because it keeps two encodings + for one concept and lets the API's on-the-wire form diverge from the stored and federated + form. +- **Pass `base_url: String` in deps structs and keep the static helpers** — rejected because it + fixes the symptom while leaving the primitive threaded through every layer and the duplicated + `format!` calls in place. +- **A port for instance identity** — rejected as needless indirection over a static config + value; a value object is testable without mocking. +- **Decompose commands as well as queries** — rejected because the command enum's uniform + "call port, return event" shape is a real abstraction, and splitting it would produce seven + near-identical files plus a dissolved event-publishing seam. The query enum was the one worth + breaking up because it forced every query into a single `Vec` return type, which + a count or a relationship could not fit. + +Footnote, unrelated to the design decisions above: writing this ADR discovered that `.gitignore`'s +`docs/` + `!docs/adr/` pattern never actually re-included anything under `docs/adr/` for new +files — excluding a directory outright stops git from descending into it, so the negation was +dead on arrival. It silently swallowed `docs/adr/0001-general-review-editing.md`, which existed +on disk with zero git history until this ADR's landing fixed the pattern to `docs/*` + +`!docs/adr/` and recovered it in the same commit. diff --git a/docs/adr/0005-single-composition-root.md b/docs/adr/0005-single-composition-root.md new file mode 100644 index 0000000..c7fb962 --- /dev/null +++ b/docs/adr/0005-single-composition-root.md @@ -0,0 +1,77 @@ +# crates/composition is the sole composition root; worker stops hand-wiring adapters + +The workspace had two composition roots. `crates/presentation` built its adapters and deps +through `crates/composition`; `crates/worker` built its own, separately, in +`crates/worker/src/db.rs` (125 lines) — a 25-field `WorkerDbOutput` of which 22 duplicated fields +`composition`'s then-28-field `DatabaseOutput` already had, two of those under different names +(`_goal_command` / `_watch_event_query`, underscore-prefixed because the worker's copy never read +them). Every adapter change had to be +made twice, in two files that had already drifted (three fields — `deduplicator`, +`image_ref_command`, `image_ref_query` — existed only in the worker's copy, unreachable from the +server). Nothing enforced that the two stayed in sync; only a diff would catch it. + +## Decision + +**`crates/composition` is now the only composition root. `crates/worker/src/db.rs` is deleted.** +Both binaries call `composition::factory::build_database_adapters` to get one `DatabaseOutput`, +and each then builds only the deps it can honestly use from it. + +- `DatabaseOutput` (`crates/composition/src/factory.rs`) is a deliberate 31-field superset of + every adapter either binary needs. The server constructs three adapters it never reads — + `deduplicator`, `image_ref_command`, `image_ref_query` (worker-only) — in exchange for one + construction path that cannot drift. That trade is the point of this ADR: a handful of + wasted `Arc` constructions on the server side is cheaper than a second hand-maintained factory. +- The deps container is split, not shared: `application::Deps` (25 fields across 10 groups) is + built by `composition::build_deps` and consumed only by `crates/presentation`; the five groups + 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 + `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 + `application::Services` the worker can actually construct (`object_storage`, `event_publisher`, + `person_enrichment`). The worker has no `auth`, `password_hasher`, `diary_exporter`, + `document_parser`, or `review_logger` — those use cases don't run in the worker process, so + `WorkerServices` simply doesn't carry them. +- `build_worker_deps` takes `&DatabaseOutput`, not `&Repositories`, even though `Repositories` is + what `build_deps` takes and superficially looks like the more natural shared type. The worker + cannot honestly construct a `Repositories`: five of its six federation-sourced fields + (`remote_watchlist`, `social_command`, `follow_graph`, `block_query`, `federation_admin` — the + 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 + wrong adapter is exactly the kind of defect unit tests can't see — only a running process can. + +## Considered Options + +- **A separate `WorkerExtras` struct, kept alongside `Deps`** — rejected because it recreates the + exact problem this ADR fixes: two structs that have to be kept in sync by hand, just smaller + ones than `WorkerDbOutput` was. +- **Noop ports for the five `Services` the worker lacks** (`auth`, `password_hasher`, + `diary_exporter`, `document_parser`, `review_logger`) — rejected because it reintroduces the + sentinel-port pattern this codebase has been actively removing elsewhere; a `Services` field the + worker can't fill should not exist in a struct the worker holds, dressed up as a port that + panics or no-ops if called. +- **`Option` fields on a single shared `Deps`/`Services`, meaning "not for this binary"** — + rejected for the same reason as ADR-0002/0003 avoid `Option` for "not applicable here": absence + is a fact about which binary you're in, and that fact is better expressed by which container + (`Deps` vs `WorkerDeps`, `Services` vs `WorkerServices`) a field lives in than by an `Option` + every reader then has to unwrap or justify. + +## Known follow-up, out of scope here + +`crates/worker/src/follow_backfill_handler.rs` references `activitypub::ActivityPubPort` +unconditionally, with no `#[cfg(feature = "federation")]` gate. `cargo build -p worker +--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. diff --git a/docs/adr/0006-handlers-call-use-cases-only.md b/docs/adr/0006-handlers-call-use-cases-only.md new file mode 100644 index 0000000..87bc76d --- /dev/null +++ b/docs/adr/0006-handlers-call-use-cases-only.md @@ -0,0 +1,152 @@ +# Handlers call use cases only; check-handler-repos guard, and presentation goes lib-only + +CONTRIBUTING.md had said "presentation handlers never touch repositories directly" for a while, +but nothing enforced it. 16 call sites across `crates/presentation/src/handlers/` called a +repository method directly — `repos.user.find_by_id(...)`, `repos.wrapup_repo.find_existing(...)`, +`repos.import_session.get(...)`, and thirteen more — instead of going through an +`application`-layer use case. Each bypass carried real logic that had nowhere principled to live: +`get_user_profile`'s local-vs-federated dispatch, `get_watchlist_page`'s decision to read a local +or a remote repository, `get_wrapup_html`'s "is this report actually ready" gate, `get_mapping_page`'s +"has this session even been parsed" gate, `build_page_context`'s viewer-chrome assembly (email, +admin flag, pending-follower badge count) — all sitting in a handler function, untested except by +whatever HTTP-level test happened to exercise that route, and invisible to `check-appcontext` +because bypassing `application` entirely means there is no `application` code to scan. + +A companion problem, orthogonal but resolved in the same plan: `crates/presentation` carried +`main.rs` and every backend-selection feature (`sqlite`, `postgres`, `sqlite-federation`, +`postgres-federation`, `nats`) even though `crates/composition` (ADR-0005) was already the actual +composition root. The binary and the library were the same crate for no reason connected to +either one's job. + +## Decision + +**Handlers call use cases only, never repository methods, enforced by `make check-handler-repos`.** +The Makefile target greps `crates/presentation/src/handlers/` for `repos..(` and +fails, naming file and line, on any match whose method name isn't `clone`. `.clone()` stays +legal: ~40 sites in handlers still read `state.app_ctx.repos..clone()` to pass an `Arc` +positionally into a use case that takes individual params rather than a deps struct — Plan C2's +job, not this plan's. The guard is two grep passes rather than one, specifically so a single +line carrying both a legal clone and an illegal call doesn't hide the illegal one: `grep -o` +extracts each `repos..(` occurrence onto its own output line (file:line still +attached, since `-n`/`-r` key off the match, not the containing line), then a second `grep -v` +drops the occurrences whose method name is exactly `clone`. A single `grep -v` filtering whole +lines would have wrongly cleared a line pairing `repos.a.clone()` with an illegal +`repos.b.find_by_id(...)`. Verified against the current tree (zero offenders) and against a +deliberately reintroduced `repos.user.find_by_id(` call in `handlers/users.rs::get_user_profile`, +which the guard reported by exact file and line before the call was reverted. + +**Sixteen bypass call sites became eleven use cases** (twelve modules, because one use case +split in two — see below), each one absorbing the logic that used to live in the handler: + +- `users::get_local_profile` / `users::get_federated_profile_stats` — the local and federated + halves of what was one `get_profile` use case (ADR-0004). Absorbs the local-vs-federated + identity decision described below. +- `users::get_page_viewer` — **page-chrome assembly**: viewer email, admin flag, and pending- + follower badge count, previously three separate lookups scattered through + `handlers/helpers.rs::build_page_context` with independent, inconsistent error tolerance. + `get_page_viewer::execute` now propagates every error with `?`; `build_page_context` is the + one place that decides to degrade chrome on failure (logs, falls back to `(None, false, 0)`). +- `users::resolve_username_to_id` — username → id lookup, previously a bare + `repos.user.find_by_username(...)` call. +- `users::get_profile_settings` — settings-page field assembly (username, display name, bio, + avatar/banner, `also_known_as`, custom profile fields), previously assembled inline from two + separate repository calls in the handler. +- `import::get_mapping_stage` — **import stage gating**: `NotFound` for both "session missing" + and "session has no parsed file yet", collapsing two conditions the old handler redirected to + the same place for. Owns the `SAMPLE_ROW_LIMIT = 5` constant that used to be a bare `.take(5)`. +- `import::get_preview_stage` — the other **import stage gate**: `Ok(NotYetMapped)` when row + results are absent (a business state, not an error) vs `Ok(Ready(PreviewRows))`. Called from + two handler sites (`get_preview_page` HTML and `api_get_preview` JSON) — the one deliberate + exception to "one use case, one call site" in this plan. +- `import::get_session_state` — session summary (columns, mapping status, row count) for the + JSON session-state endpoint. +- `import::apply_profile_and_map` — orchestrates `apply_profile::execute` then + `apply_mapping::execute` inside the use case layer instead of the handler, matching the + existing `MovieEnrichmentHandler`-calls-`enrich_movie` precedent rather than inventing a new + shape for cross-use-case calls. +- `diary::get_user_feed` — **feed author lookup**: resolves the RSS feed's author display name + (from the user's email local-part) alongside the diary entries, previously two separate reads + (`repos.user.find_by_id` then `get_diary::execute`) glued together in `handlers/rss.rs`. +- `watchlist::get_watchlist_for_owner` — **the local-vs-federated watchlist decision**: probes + `repos.user.find_by_id` to decide whether the requested owner is a local user (paginated, + validated `PageParams`) or a federated one (unpaginated, `limit`/`offset` ignored — a + pre-existing asymmetry, preserved deliberately rather than "fixed" into new behavior). Returns + `WatchlistView::Local(Paginated<..>) | Remote(Vec<..>)`; the handler still chooses which + template to render. +- `wrapup::get_ready_report` — **wrapup report gating**: collapses "no record", "wrong status", + "repository error", and "`Ready` but `report: None`" into one `NotFound`/`ValidationError` + surface, reproducing a pre-existing quirk (repo errors becoming a 404) rather than introducing + new-looking behavior for it. Takes `domain::models::wrapup::WrapUpScope` (`User(Uuid) | + Global`) directly — an application-layer duplicate of that type was drafted and then dropped in + favor of the existing domain enum once it was clear the duplicate added no impedance, just a + second name for the same idea. + +**The pending-follower count is one use case behind two transports.** Both the SPA's +`GET /api/v1/social/followers/pending/count` endpoint and the classic UI's page chrome +(`get_page_viewer`) read the same `FollowGraphQuery::count_pending_followers` call, which is also +the same method `application::social::count_pending_followers` wraps. No second counting path +exists; the two frontends cannot show different numbers because there is exactly one place the +number comes from. + +**The ADR-0004 sentinel wart is closed.** ADR-0004 recorded a "known wart": `get_profile`'s +tolerant, non-`NotFound` handling of a missing local row filled `username`/`handle` with +empty-string sentinels while `actor_url` stayed a well-formed-looking (but meaningless) URL — one +field obviously wrong, one plausibly wrong — and noted it was inert only because no caller read +`profile.identity` on the federated path. This plan is the "moment any caller starts consuming +`profile.identity` on the federated path" ADR-0004 said to watch for, and it resolved the wart by +removing the shared type rather than patching it: `get_local_profile` returns +`Err(DomainError::NotFound(_))` for a missing local row with `identity` always fully populated on +success, and `get_federated_profile_stats`'s return type (`FederatedProfileStats`) carries no +`identity` field at all, because the federated handler never read one. No type in the codebase +can represent the old sentinel state anymore — there is no `ProfileIdentity` for the federated +path to half-fill. +`get_federated_profile_stats::tests::succeeds_for_a_user_id_with_no_local_row` carries forward +the old regression test's intent; +`get_local_profile::tests::get_local_profile_is_not_found_for_unknown_user` pins the new +`NotFound` behavior directly. + +**`crates/presentation` is lib-only; `crates/server` owns the binary.** `main.rs` and the +integration test that exercised it moved to a new `crates/server` crate, which now holds every +backend-selection feature (`sqlite`, `postgres`, `sqlite-federation`, `postgres-federation`, +`nats`) that used to live on `presentation`. `presentation`'s `Cargo.toml` now has exactly one +feature axis — `federation` (on by default) — and depends on no adapter crate except +`activitypub`, gated behind that same feature. `cargo tree -p presentation` shows no +`sqlite`/`postgres`/`sqlx`/`infra-wiring` anywhere in the tree. The one remaining adapter +dependency, `activitypub`, is out of scope here — inverting `ActivityPubPort` so `presentation` +depends on nothing but `domain`/`application`/`composition` ports is Plan D's job, not this +plan's. + +**`app_ctx.repos` deliberately remains, and stays `pub`.** This plan removed sixteen repository +*method calls* from handlers; it did not touch the ~40 `state.app_ctx.repos..clone()` +sites that pass an `Arc` positionally into use cases taking individual params, and it did not +delete the `repos` field itself. `check-handler-repos` is written narrowly on purpose — it +forbids the bypass this plan closes, not the clone-and-pass pattern Plan C2 is scoped to migrate. +Removing `repos` now would break every one of those 40 sites for no gain this plan can claim. + +## Considered Options + +- **Thin entity-fetch use cases** — e.g. a bare `get_user(id) -> User` wrapping + `repos.user.find_by_id` — were rejected as a repository with extra indirection. They would + satisfy `check-handler-repos` literally while leaving every real decision (local-vs-federated, + stage gating, chrome assembly, report gating) sitting in the handler exactly where it was, + which is the actual problem this plan exists to fix, not a naming exercise. +- **Deleting `app_ctx.repos` in this plan** — rejected because ~40 clone-passed sites across the + converted handler files still construct use case calls positionally from it; deleting the field + would require migrating all 40 to deps-struct calls in the same change, which is Plan C2's + scope, not this one's. Landing that migration and the use-case extraction together would make + either piece harder to review and revert independently. + +## Known follow-ups, out of scope here + +- Plan C2 migrates the ~40 `repos..clone()` sites in handlers to deps-struct calls and + then removes `app_ctx.repos`. `check-handler-repos` does not (and should not) block on these — + it only forbids the method-call bypass this plan closed. +- Plan D inverts `ActivityPubPort` so `presentation` drops its one remaining adapter dependency, + `activitypub`. +- The pre-existing `follow_backfill_handler.rs` federation-gate bug (recorded in ADR-0005's + follow-ups) is untouched by this plan. +- `cargo test -p composition` in isolation fails one test, `worker_deps_wiring`, with + `unreachable code: worker_deps_wiring needs the sqlite feature; cargo test --workspace enables + 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. diff --git a/docs/adr/0007-no-repositories-in-presentation.md b/docs/adr/0007-no-repositories-in-presentation.md new file mode 100644 index 0000000..c7a251b --- /dev/null +++ b/docs/adr/0007-no-repositories-in-presentation.md @@ -0,0 +1,148 @@ +# `AppContext` carries no `Repositories`; `composition` is a dev-only dependency of `presentation` + +ADR-0006 closed sixteen call sites where a presentation handler called a repository method +directly, but explicitly left `AppContext.repos` in place: ~40 sites still read +`state.app_ctx.repos..clone()` to pass an `Arc` positionally into a use case that took +individual params rather than a deps struct, and migrating all of them at once would have made +ADR-0006's handler-extraction change harder to review and revert independently. `check-handler-repos` +was written narrowly on purpose, forbidding only the direct-call bypass ADR-0006 closed, not the +clone-and-pass pattern this plan (Plan C2) exists to remove. + +Plan C2 did that migration in five tasks. Tasks 1-3 converted the 23 use cases the plan's initial +audit found reading an `Arc` cloned from `Repositories` — across `integrations`/`webhook`, `wrapup`, +`watchlist`, `import`, `users`, `movies`, `goals`, `auth`, and `search` — from individual `Arc` +parameters to a single `&FooDeps` struct each, registered on `application::Deps` and constructed by +`composition::build_deps`, following the convention ~30 other use cases already used. Task 4 replaced +the last two direct repository calls in the codebase — `extractors.rs`'s `AdminApiUser` and +`AdminUser` both called `repos.user.find_by_id(...)` inline to check admin status — with a new +`users::authorize_admin` use case, closing the gap ADR-0006 honestly scoped as handlers-only (these +were two axum extractors, not handler functions). + +Task 5 widened `check-handler-repos` from `handlers/` to all of `crates/presentation/src`, and reported +zero offenders. That measurement was wrong. Its pattern was `repos\.[a-z_]+\.clone\(\)` — built to +catch the clone-and-pass shape the plan had inventoried — and it could not see two other shapes present +in the same tree: a bare reference passed by argument (`&state.app_ctx.repos.diary`, no `.clone()` +anywhere) and a `let`-bound access (`if let Some(ref fed_query) = ...repos.federated_profile`, +followed by a method call on the *binding* rather than on `repos` itself). `diary::get_diary`, +`diary::get_review_history`, and `diary::export_diary` all took their repository argument **by +reference** rather than owned, so their three call sites (`handlers/diary.rs`, `handlers/rss.rs`, +`handlers/movies.rs`, `handlers/helpers.rs`) were structurally invisible to a regex anchored on +`.clone()`. `handlers/users.rs`'s federated-profile lookup bound `repos.federated_profile` with `if +let` and called `.get_federated_profile(...)` on the bound variable, which no `repos..(` +pattern can match across a `let`. The first attempt at this task found the five live sites by +inspection when it went to delete the field the guard was meant to make safe to delete, and correctly +returned blocked rather than deleting `AppContext.repos` out from under them; a new Task 7 then +converted all five (plus a sixth, non-`repos` internal caller broken purely by `get_diary`'s signature +change) before this attempt at Task 6 ran. + +## Decision + +**`AppContext` no longer has a `repos` field, and `presentation` no longer depends on `composition` +in production.** `pub repos: Repositories` and the `pub use composition::Repositories;` re-export are +both deleted from `crates/presentation/src/context.rs`. There is no route by which any code in +`crates/presentation/src` (outside `src/tests/`, which builds its own `Repositories` purely to drive +`composition::build_deps` for test fixtures) can reach a repository directly — not a stored field, not +an import, not a re-export. Every dependency a handler or extractor needs now comes from +`state.app_ctx.deps`, a prebuilt `Arc` assembled once at the composition root. + +`crates/presentation/Cargo.toml` moves `composition` from `[dependencies]` to `[dev-dependencies]`. +After the field deletion, `composition` is referenced only from `src/tests/extractors.rs`, which calls +`composition::build_deps` to produce a real `Deps` for test fixtures — a legitimate use of the +composition root's public API, just not one production code needs. Confirmed both ways: +`cargo tree -p presentation -e normal --depth 1` shows no `composition` anywhere in the production +dependency tree; `cargo tree -p presentation -e dev --depth 1` shows it as a direct dev-dependency. +`crates/server` (ADR-0006's home for the binary and every backend-selection feature) already depended +on `composition` directly in `[dependencies]` — that entry is untouched by this task — because it is +the actual composition root, not `presentation`. It still builds a `Repositories` and still needs +`AppContext { deps, .. }` without a `repos` field, so this task's field deletion required two small +production-adjacent fixes outside `crates/presentation` that no earlier task in this plan touched: +`crates/server/src/main.rs::wire_dependencies` (production) and `crates/server/tests/api_test.rs` +(its integration-test harness) both used to write `presentation::context::{AppContext, Repositories}` +and construct `AppContext { repos, deps, .. }`; both now import `Repositories` from `composition` +directly and drop `repos` from the struct literal, keeping the local `repos` binding alive only long +enough to pass `&repos` into `composition::build_deps`, the same pattern `presentation`'s own test +helper uses. + +**All use cases that used to read an `Arc` out of `AppContext.repos` — 23 converted in Tasks 1-3, three +more (`get_diary`, `get_review_history`, `export_diary`) converted in Task 7 once the guard's blind +spot surfaced them, plus two newly-created use cases (`users::authorize_admin` in Task 4, +`users::get_federated_profile` in Task 7) — now take a single deps struct.** The two `extractors.rs` +admin checks (`AdminApiUser`, `AdminUser`) both now call `users::authorize_admin::execute`, returning +`Result, DomainError>` so "user not found" and "repository call failed" stay +distinguishable by construction rather than by pattern-matching a `DomainError` variant an adapter +happens not to raise today. `handlers/users.rs`'s federated-profile branch now calls +`users::get_federated_profile::execute`, which mirrors `Repositories::federated_profile`'s own +`Option`-typed optionality (federation can be configured off) rather than inventing a new way to say +"this binary doesn't need it." Both migrations preserve their call sites' exact rejection behavior on +every path (missing port, repository error, empty result, found result) — verified path-by-path +against the pre-migration code in Task 4's and Task 7's own reports, not asserted here. + +**Two use cases remain on individual params, correctly, because they were never reachable through +`AppContext.repos` in the first place:** `movies::request_enrichment::fetch_if_stale` and +`movies::enrich_movie::execute`, both called only from `MovieEnrichmentHandler` +(`crates/application/src/movies/event_handler.rs`), a `domain::ports::EventHandler` implementor that +the composition root constructs directly with individual `Arc` fields, the same way any adapter is +wired — never through `state.app_ctx.deps`. `fetch_if_stale` takes fully individual parameters +(`&dyn MovieEnrichmentClient`, `&Arc`, plus two value params). +`enrich_movie::execute` actually does take a struct, `EnrichMovieDeps` — but one built locally inside +`MovieEnrichmentHandler::handle` from its own already-held `Arc` fields, not a struct registered on +`application::Deps` or constructed by `composition::build_deps`. Neither shape is what this ADR means +by "use cases now take deps structs"; both are correctly out of scope for the same reason `Services`- +sourced use cases are (see below), and the plan's original note that they're "adapter-called, don't +migrate them" holds up. + +**This ADR's "use cases take deps structs" claim is scoped to what Plan C2 touched: use cases that +used to read `AppContext.repos`.** It is not a claim that every `execute` function in the codebase +takes an `application::Deps`-registered struct. Two examples outside that scope, found while +fact-checking this ADR, are worth naming so nobody mistakes their shape for a regression: +`diary::log_review::execute` is called directly by two presentation handlers +(`handlers/diary.rs::post_review` and `::create_review`) with an individual +`&state.app_ctx.services.review_logger` argument — legitimate, because `review_logger` comes from +`AppContext.services`, a field this plan never touched, not from `repos`. `wrapup::compute::execute`, +`import::cleanup::execute`, and `integrations::cleanup::execute` also take individual params, but none +of the three is ever called from a presentation handler — `compute` is called internally by +`wrapup::handle_requested`, and the two `cleanup` use cases are called only from +`crates/application/src/jobs/`, the background-job runner. All four were outside Plan C2's stated +scope before this task started and remain untouched by it. + +**`check-handler-repos` is now a single whole-crate rule: any mention of `app_ctx.repos` outside +`src/tests/` is an offender.** The two-pass shape-matching regex (`repos..(`, +`repos..clone().(`) is gone. It is gone because it is no longer needed, not because it +was simplified for its own sake: with `AppContext.repos` deleted, there is no shape of legitimate +production access left to distinguish from an illegal one — no clone-as-argument to permit, no direct +call to forbid separately. A single substring match is sufficient and, unlike the old guard, +unevadeable: it cannot be blind to a reference pass or a `let`-binding the way the shape-matching guard +demonstrably was (see above), because it does not attempt to parse the shape of the access at all — it +only asks whether the now-nonexistent field is mentioned. Verified by injecting `app_ctx.repos.user` +into `extractors.rs` (a non-test production file) and confirming `make check-handler-repos` fails, +naming the exact file and line, before reverting. + +## Considered Options + +- **Leaving the shape-matching two-pass guard in place, just widened to the whole crate** — this is + what Task 5 actually did, and it is what let three reference-pass sites and one `let`-binding site + through with a clean ✅. Once `repos` no longer exists, keeping the old guard's shape-matching would + mean maintaining two regex passes to catch a category of mistake (any mention at all) that a single + `grep` already catches completely. Rejected as strictly worse than the simple rule, not merely + equivalent to it. +- **Deleting `check-handler-repos` entirely, since the compiler now enforces the invariant** — a + production file referencing `app_ctx.repos` simply fails to build once the field is gone, so the + guard is redundant with `cargo build` in the narrow sense of "will this compile." Rejected because + the guard is cheaper to run (`grep` vs. a full workspace build) for `make check`'s fast-path checks, + and because it fails with a direct "here's the offending line" message rather than a compiler error + several frames removed from the actual mistake (e.g. a struct-literal error at the `AppContext` + construction site rather than at the stray reference itself). + +## Known follow-ups, out of scope here + +- Plan D inverts `ActivityPubPort` so `presentation` drops its one remaining adapter dependency, + `activitypub` — untouched by this plan, exactly as ADR-0006 recorded. +- The pre-existing `follow_backfill_handler.rs` federation-gate bug and the isolated + `cargo test -p composition` `worker_deps_wiring` failure (both recorded in ADR-0006) are unaffected. +- `diary::log_review`, `wrapup::compute`, `import::cleanup`, and `integrations::cleanup` remain on + individual params, correctly — none of them ever read from `AppContext.repos`, so none was ever in + this plan's scope. Whether they *should* eventually move to deps structs for consistency with the + rest of the codebase is a separate, unstarted question this ADR takes no position on. +- `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. diff --git a/docs/adr/0008-presentation-names-renderers-not-reachers.md b/docs/adr/0008-presentation-names-renderers-not-reachers.md new file mode 100644 index 0000000..2c190b3 --- /dev/null +++ b/docs/adr/0008-presentation-names-renderers-not-reachers.md @@ -0,0 +1,261 @@ +# `presentation` may name renderers, not reachers; `ActivityPubPort` inverts into three `domain` ports + +ADR-0006 and ADR-0007 both closed with the same known follow-up, in the same words: "Plan D inverts +`ActivityPubPort` so `presentation` drops its one remaining adapter dependency, `activitypub`." That +framing understated the problem in one direction and overstated it in another. Understated, because +`activitypub` was not the only adapter crate `presentation` depended on — `crates/presentation/Cargo.toml` +carried **eleven** adapter-crate entries at `051d9ce`: ten unconditional (`auth`, `metadata`, +`poster-fetcher`, `object-storage`, `template-askama`, `rss`, `export`, `importer`, `jellyfin`, `plex`) +plus `activitypub` as an optional dependency behind the `federation` feature. Overstated, because the +reason the inversion kept getting deferred — that moving the trait into `domain` would drag +`k_ap::RemoteActor` and `k_ap::BlockedDomain` in with it, giving the domain layer a dependency on a +third-party federation library — turned out to be an artifact of treating a 17-method trait as one +indivisible unit. + +Seven of the eleven were simply dead. They were found by commenting each dependency line out and +running `cargo check -p presentation --all-features`, then repeating with `--all-targets`: `auth`, +`metadata`, `poster-fetcher`, `object-storage`, `rss`, `export`, `importer`. **Both grep-based methods +that would have been the obvious way to audit this gave wrong answers**, and the reason is worth +recording because it also determines the shape of this ADR's guard: `use ::` misses the +fully-qualified call sites, and plain `::` collides with same-named *modules* inside +`application` and `presentation`. `auth::` matches `application::auth::logout`. `rss::` matches +`handlers::rss::get_user_feed`. Both crates therefore looked live to a source grep while being +entirely unreferenced as crates. The one function in `presentation` that genuinely named an adapter +type — `mappers/social.rs::remote_actor_to_dto`, taking `activitypub::RemoteActor` — had zero callers +anywhere in the workspace and was deleted with the file. + +`ActivityPubPort`'s 17 methods split across three **disjoint** consumer sets, which is what dissolves +the `k_ap`-in-`domain` blocker: + +| Methods | Only consumer | +| --- | --- | +| `follow`, `unfollow`, `accept_follower`, `reject_follower`, `remove_follower`, `block_actor`, `unblock_actor`, `get_blocked_actors` | `CompositeSocialAdapter`, **inside the `activitypub` crate itself** | +| `actor_json`, `followers_collection_json`, `following_collection_json`, `get_blocked_domains`, `add_blocked_domain`, `remove_blocked_domain` | `presentation` | +| `get_following`, `import_remote_outbox`, `run_backfill_for_follower` | `worker::FollowBackfillHandler` | + +Eight of the seventeen have exactly one call site today, and it lives in the same crate as the trait, +so they did not need a trait. That is a claim about call sites, not reachability: `ActivityPubWire` +also hands `crates/server` the same `Arc` that `CompositeSocialAdapter` wraps (the +`service` field, below), so all eight are `pub` and callable from outside the crate today — nothing +does, but nothing stops it either. See the deferred item below on closing that gap. + +Exactly two of the seventeen return `k_ap::RemoteActor` — `get_blocked_actors`, which is one of those +eight and therefore lost its trait, and `get_following`, which is worker-only. Presentation reached +neither. The only `k_ap` type in +presentation's six signatures is `BlockedDomain` in `get_blocked_domains`'s return, and both call +sites consumed it structurally — reading `.domain`, `.reason`, `.blocked_at` into an +`api_types::BlockedDomainResponse` or a `template_askama::BlockedDomainEntry` — without ever naming +the type. `BlockedDomain` is `{ domain: String, reason: Option, blocked_at: String }`; +`blocked_at` is `String` at every layer including both destinations, so a three-field domain-owned +record reproduces it exactly. The worker's `get_following` does return `Vec`, but the +worker reads exactly `.url` and `.outbox_url`. Two minimal domain models therefore replace both `k_ap` +types at the boundary, and `crates/domain/Cargo.toml` gained no dependency other than a new +`[dev-dependencies]` entry for `tokio`, to run the noop tests. + +## Decision + +**`presentation` may name things that render output; it may not name things that reach external +systems or storage.** `template-askama` is the sole permitted adapter crate, because an HTML template +engine is a renderer: it turns data the handler already holds into bytes. Everything else in +`crates/adapters/` reaches — over HTTP, to a database, to object storage, to a message queue — and a +handler that names one of those has, by construction, a path around the application layer that +ADR-0006 and ADR-0007 spent two plans closing. `crates/presentation/Cargo.toml`'s `[dependencies]` +now lists exactly one entry from `crates/adapters/`: `template-askama`. Verified by iterating all 26 +directory names under `crates/adapters/` against the section and finding exactly one hit. + +**`ActivityPubPort` is deleted and replaced by three consumer-shaped traits in +`domain::ports::federation`.** `ApDocumentPort` (3 methods) serves ActivityPub documents for content +negotiation and is called only from presentation. `InstanceBlocklistPort` (3 methods) is instance-wide +domain blocklist administration, also presentation-only. `ApBackfillPort` (3 methods) pulls remote +content in and pushes local content out after a follow is established, and is worker-side only — no +HTTP handler calls it. The eight adapter-internal methods lost their trait entirely; +`CompositeSocialAdapter` now takes a concrete `Arc` and calls them as +inherent methods. All nine surviving methods return `Result<_, DomainError>` rather than +`anyhow::Result<_>`, so the error type crosses the boundary as a domain type. `grep -rn +"ActivityPubPort\|NoopActivityPubService" crates` returns nothing. + +**The adapter carries the three impls on a local wrapper type, `ApServiceAdapter`, not on +`k_ap::ActivityPubService` directly — this is forced, not stylistic.** The plan originally specified +`impl domain::ports::ApDocumentPort for k_ap::ActivityPubService`. That does not compile: E0117, the +orphan rule. From inside `crates/adapters/activitypub`, both the trait (owned by `domain`) and the +type (owned by the external `k-ap` crate) are foreign. The deleted `port.rs` compiled only because +`ActivityPubPort` was *local* to that crate — the very property being given up by moving the trait to +`domain`. `ApServiceAdapter { service: Arc }` in +`crates/adapters/activitypub/src/federation_ports.rs` holds all three impls, matching two existing +precedents in the same crate: `DomainUserRepoAdapter` (a local wrapper across a foreign boundary) and +`CompositeSocialAdapter` (one type serving three `domain` traits, handed out as three `Arc`s). A +side effect worth naming, since the plan feared the opposite: because `ApServiceAdapter` has no +inherent `actor_json`, `self.service.actor_json(..)` is unambiguous and no fully-qualified call or +recursion guard is needed. `ActivityPubWire` gained `document`, `blocklist`, and `backfill` fields, +three `Arc` casts of one `ApServiceAdapter`. + +**`actor_json` keeps its `Err(_) => 404`; the two collection handlers now return a logged 500.** This +is the plan's one intentional behavior change and lands in its own commit (`8bfeae5`). The asymmetry +is a measured property of `k_ap`, not a preference: `followers_collection_json` and +`following_collection_json` never look the user up. They derive a URL from the UUID and count rows, so +a nonexistent user yields `total = 0` and a valid empty `OrderedCollection` — `Ok`, not `Err`. Every +`Err` those two can produce is therefore a genuine infrastructure or serialization failure, and the +404 they used to emit misreported it as "no such user", hiding real faults from operators. +`actor_json` calls `get_local_actor`, which genuinely fails for a missing user, and a federation peer +probing an actor URL should get 404 — so it is untouched, deliberately. Two tests in +`crates/presentation/src/tests/api_handlers.rs` pin the new behavior; both were observed failing with +`left: 404, right: 500` before the change, and both send `Accept: application/activity+json`, without +which the handlers redirect and the tests would have passed against the old code too. + +**Jellyfin and Plex parser construction moved to the composition root.** `presentation`'s two webhook +handlers used to name `jellyfin::JellyfinParser` / `plex::PlexParser` inline, passing each unit struct +by reference straight into `run_ingest`. The parsers are now built once in `composition::build_deps` +and held on `application::Deps`'s `IntegrationsGroup`; the +handlers read `state.app_ctx.deps.integrations.{jellyfin,plex}_parser`. `jellyfin` and `plex` moved +from `presentation`'s `[dependencies]` to `composition`'s, unconditionally. `ingest::execute`'s +signature and its test-suite `FakeParser` injection point are untouched. + +**A fourth Makefile guard, `check-presentation-adapters`, encodes the rule.** `make check` now runs +`fmt-check clippy test check-appcontext check-handler-deps check-handler-repos +check-presentation-adapters`, and both CI workflows (`.github/workflows/ci.yml:47` and +`.gitea/workflows/ci.yml:47`, which must stay in sync) invoke it. **It reads `Cargo.toml`, not `src/`, +deliberately** — for exactly the reason the `auth`/`rss` audit above failed: a source-level +`::` grep cannot distinguish an adapter crate from a same-named module inside `application` or +`presentation`, so it is structurally incapable of deciding this question. The guard `awk`s the +`[dependencies]` section, `sed`s out the key of each `key = ...` line, and rejects any that matches a +directory name under `crates/adapters/` other than `template-askama`. All 26 adapter directory names +equal their package `name` today (verified by reading each `Cargo.toml`), which is what makes +directory names a safe source of truth. Both behaviors were observed: `jellyfin` added under +`[dependencies]` produced `❌ presentation depends on adapter crate(s): jellyfin` and exit 1; the same +line under `[dev-dependencies]` passed, which is what proves the `awk` range actually stops at the +next section header rather than scanning the whole file. + +**`cargo build -p worker --no-default-features --features sqlite` compiles again, closing a follow-up +ADR-0005 opened and ADR-0006/0007 carried forward.** `crates/worker/src/follow_backfill_handler.rs` +named `activitypub::ActivityPubPort` unconditionally, with no `#[cfg(feature = "federation")]` gate, +so the federation-off worker build failed with E0433. `FollowBackfillHandler` now holds +`Arc`, a trait from a crate the worker depends on unconditionally, +so the gate is unnecessary rather than merely added. Three `map_err` closures that converted `anyhow` +to `DomainError` at the call site collapsed to bare `?`, because the port already returns +`DomainError` — verified to produce the same `DomainError` and the same log output as the closures did. + +### Four things this ADR does not claim + +These were measured or observed during the work and are recorded rather than fixed. This project +documents its guards as useful-not-airtight (see ADR-0007 on `check-handler-repos`); the same honesty +applies here. + +**`check-presentation-adapters` has four known blind spots.** Each needs an atypical or deliberately +obfuscated manifest form, and none is present today: +1. `[dependencies.jellyfin]` dotted-table form — `awk`'s `/^\[/` closes the range, and the crate name + lives in the section header rather than on a `name = ` line. Invisible to the guard. +2. `[target.'cfg(...)'.dependencies]` — never scanned at all; the range opens only on an exact + `^\[dependencies\]`. This is the most plausible *accidental* blind spot, though unlikely in a web + backend crate. +3. `alias = { package = "jellyfin", workspace = true }` — the `sed` extracts `alias`, which matches no + adapter directory. Requires deliberate evasion. +4. Directory name equal to package name is *assumed*, not enforced. True for all 26 adapters today; a + future adapter whose directory and package names diverge would be unguarded. + +**Two HTML handlers now log one extra line on the error path.** The plan's contract for Tasks 1-7 was +"identical status codes, identical response bodies, identical log lines", and the third of those is +not literally true. `ap_err` in the adapter logs `ActivityPub error: {:?}` before returning — the same +message, at the same level, that presentation's deleted `ap_to_domain` used to emit — so the two +blocklist HTML handlers (`handlers/social.rs` `post_blocked_domain`, `post_remove_blocked_domain`), +which log their own line on error, now emit two lines where they emitted one. The two collection +handlers (`get_followers_collection`, `get_following_collection` in `handlers/social.rs:707` and +`:742`) previously logged nothing on the error path and now log two lines, not one: the adapter's +`ap_err` line and their own (`followers_collection_json error: {:?}` / `following_collection_json +error: {:?}`), so their error path went from 0 lines to 2. The two lines are complementary, not +duplicative — the adapter's carries the cause, the full `anyhow` chain via `{:?}`; the handler's +identifies which operation failed. Strictly more detail, same level, no client-visible change; +unavoidable once the conversion point moved into the adapter, short of reintroducing a shim in +presentation purely to suppress it. + +**"The worker sqlite-only build compiles" is not "it is clippy-clean."** +`cargo clippy -p worker --no-default-features --features sqlite -- -D warnings` reports three errors: +unused `app_config`, unused `remote_goal`, and `FollowBackfillHandler is never constructed`. The first +two predate this work and are unrelated to it. No gate runs this combination — `make clippy` is +`cargo clippy -- -D warnings` and CI adds `--all-targets`, both at default features, where the +worker's defaults are `["sqlite", "sqlite-federation"]` and the struct *is* constructed. Gating +`mod follow_backfill_handler;` behind a `federation` cfg would silence one of the three and leave the +combination dirty regardless, so it was measured and deliberately not done. + +**`cargo check -p presentation --no-default-features --all-targets` fails, and failed identically +before this work.** Two errors, both in the test target: E0433 `cannot find social in handlers` +(`handlers::social` is federation-gated; something in the test tree names it unconditionally) and +E0277 `Panic: RemoteWatchlistRepository` not satisfied. Measured on the worktree and on master at +`051d9ce` with the same command and the same two errors, so it is pre-existing, not a regression from +the port swap. The library itself compiles federation-off both before and after — `cargo check -p +presentation --no-default-features` finishes clean on both — so the `#[cfg(feature = "federation")]` +gates around the new `ap_document` / `ap_blocklist` fields do hold. It remains a known-broken feature +combination in the repo — not the only one; see the `server`/`worker` `federation` meta-feature gap +recorded under Known follow-ups below, which is also pre-existing. + +## Considered Options + +- **Moving `ActivityPubPort` into `domain` whole, accepting a `k_ap` dependency in the domain layer** — + the option the previous two ADRs implicitly assumed and rejected, which is why the work kept being + deferred. Rejected here too, but the point is that it was never the only option: splitting by + consumer means the two `k_ap` types that forced the dependency (`RemoteActor`, `BlockedDomain`) land + respectively in the eight methods that need no trait at all and in one method whose three scalar + fields a domain-owned record reproduces exactly. +- **One `domain` trait with all nine cross-boundary methods, instead of three** — simpler to wire: one + `Arc` on `AppContext` instead of two, and one trait to name instead of three. Rejected because the + three consumer sets are disjoint: presentation would hold a handle exposing `run_backfill_for_follower`, + the worker one exposing `actor_json`, and the ability to call a method is what the previous two ADRs + were about removing. Three traits make "no HTTP handler runs a backfill" a compile-time fact rather + than a convention. +- **Grepping `src/` for adapter crate names instead of reading `Cargo.toml`** — the intuitive form of + this guard. Rejected because it was tried as the audit method and demonstrably produced wrong + answers in both directions: `auth` and `rss` looked live because `application::auth` and + `handlers::rss` exist, while fully-qualified call sites are invisible to a `use ::` pattern. A + guard cannot be more reliable than the measurement technique it encodes. +- **Deleting `port.rs` in the same task that added the new ports** — the plan's original shape, which + would have left `presentation`, `server`, and `worker` uncompilable for three tasks and required + skipping `make check` in between, contradicting the plan's own green-at-every-commit constraint. Split + into additive-first (new ports alongside the old trait) and delete-last (after every consumer moved), + which is why the branch has an extra commit and no known-red window. + +## Known follow-ups, out of scope here + +- **Collapsing the worker's three backfill calls into one adapter-side operation.** `FollowBackfillHandler` + calls `get_following`, filters for the actor, then `import_remote_outbox`. That filtering could live + behind one port method, but moving it would move logic across the boundary during a refactor whose + contract was behavior preservation. Not started; a real question, deliberately left open. The + duplication is also a naming collision: `ApBackfillPort::get_following` is now the third + `get_following` in the `domain::ports` glob namespace, alongside `FollowGraphQuery::get_following` + (`crates/domain/src/ports/social.rs:47`) and `FollowQuery::get_following` + (`crates/domain/src/ports/follow.rs:53`). All three compile and answer different questions, but a + reader seeing `deps.something.get_following()` now has three candidates. +- **Splitting `actor_json`'s error so a missing actor and an infrastructure failure become + distinguishable.** Today both produce `DomainError::InfrastructureError` and the handler returns 404 + for either. Doing it properly needs `k_ap` to expose a typed error; doing it by inspecting the + `anyhow` message for a substring would be fragile against a third-party crate's wording. Rejected as + currently formulated, not deferred for lack of time. +- **`service`'s reachability gap, noted above.** `CompositeSocialAdapter::new` is only called from + `crates/server/src/main.rs:127`, outside this crate, using `ActivityPubWire::service`. Moving that + construction into `wire()` itself and exposing `Arc` / `FollowGraphQuery` / + `BlockQuery` on `ActivityPubWire` instead would let `service` become crate-private, and would make + "the eight adapter-internal methods are unreachable from outside the crate" true of reachability, not + just of call sites, closing the gap recorded above. Not started here — `wire()` doesn't have the + `UserRepository`, `FollowCommand`, and `FollowQuery` handles `CompositeSocialAdapter::new` takes, + and threading them through is real wiring work, not a rename. +- **The `PRESENTATION_ALLOWED_ADAPTERS` guard trusts `template-askama` by name, not by property.** + `PRESENTATION_ALLOWED_ADAPTERS := template-askama` permits that crate because it is a renderer today, + but nothing stops it from later acquiring a reaching dependency and smuggling it through the guard + transitively — the Makefile checks the manifest's direct `[dependencies]` entries, not what + `template-askama` itself depends on. It is clean today: its own `[dependencies]` are `askama`, + `chrono`, `uuid`, `domain`, `api-types`, none of which reach. The "renderer, not reacher" premise + genuinely holds, but it is trusted rather than checked. +- **A fourth known-broken feature combination, pre-existing and untouched by this branch:** + `cargo check -p server --no-default-features --features sqlite,federation` fails with E0433 on + `activitypub`. `server`'s `federation` feature (`crates/server/Cargo.toml:14`) is a bare meta-feature + that enables `application/federation` and `presentation/federation` but does not imply + `dep:activitypub` — only `sqlite-federation` and `postgres-federation` do that. The same gap exists in + `crates/worker/Cargo.toml`. Neither manifest was modified by this branch (both last touched at + `4d8f836`, before this branch's merge-base `051d9ce`), so this is recorded here rather than + attributed to this plan. +- The other deferred items recorded in ADR-0005/0006/0007 are unaffected by this plan: the isolated + `cargo test -p composition` `worker_deps_wiring` failure, the admin-extractor body assertions, the + SPA zod schemas, and `check-handler-repos`'s substring fragility. The one item this plan does close + is the worker sqlite-only build, as recorded above. +- `crates/presentation/Cargo.toml`'s `[dev-dependencies]` still lists `composition`, which ADR-0007 + 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. 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 new file mode 100644 index 0000000..2eae79e --- /dev/null +++ b/docs/adr/0009-federation-is-optional-at-the-dependency-level.md @@ -0,0 +1,367 @@ +# Federation is optional at the dependency level, not just in code paths + +The `federation` feature has existed since ADR-0002 and has been treated by every ADR since as the +switch that decides whether this instance speaks ActivityPub. It did not do that. It gated code +paths, HTTP routes and struct fields; it removed nothing from the build. Measured at this branch's +merge-base `f6ff7dd` with `cargo tree -p server -e normal --prefix none`, deduplicated by +`sed 's/ (\*)$//' | awk '{print $1" "$2}' | sort -u | wc -l`: + +| Feature set at `f6ff7dd` | unique crates | `activitypub_federation` | +| --- | --- | --- | +| `sqlite` | 357 | present | +| `sqlite,sqlite-federation` | 357 | present | +| `postgres` | 368 | present | +| `postgres,postgres-federation` | 368 | present | + +Identical, to the crate. The cause was one unconditional manifest edge per backend: +`crates/adapters/sqlite/Cargo.toml:16` read `sqlite-federation = { workspace = true }`, and +`crates/adapters/postgres/Cargo.toml:16` the same for `postgres-federation`. Those crates depend on +`k-ap`, which depends on `activitypub_federation`. Anything that wanted a database therefore got the +whole ActivityPub stack, and `--no-default-features --features sqlite` compiled, linked and shipped +it. The feature was a runtime posture, not a build-time decision. + +Two consequences followed from that, and only the first was known. The first is size: an operator who +does not want federation still pays for it in compile time, binary size and dependency surface. The +second needs more care than an earlier draft of this document gave it: it is a read-path bug, not the +write-path bug it first looked like. + +Because the whole social stack lived on one struct inside the federation crate, a federation-off +`crates/server` had nothing to wire `SocialCommand`/`FollowGraphQuery`/`BlockQuery` to, so it wired +`NoopSocialCommand`/`NoopSocialQuery` — `follow` returned `Ok(())` and wrote nothing, every query +returned `0` or `vec![]`. It is tempting to read that as "following a user on your own instance +silently did nothing," directly contradicting ADR-0003. **That reading is wrong: nothing federation-off +could ever reach `NoopSocialCommand::follow` in the first place.** Every social write route — +`/social/follow`, `/social/unfollow`, and everything else `SocialCommand` exposes — lives in +`federation_api_routes()` (`crates/presentation/src/routes.rs:485`) and `federation_html_routes()` +(`routes.rs:194`), both `#[cfg(feature = "federation")]`, merged into the router only under that gate +(`routes.rs:459`, `:188`). `crates/server/Cargo.toml` takes `presentation` with `default-features = +false`, so on a federation-off build `POST /api/v1/social/follow` is a 404 — the handler was never +compiled in. Nobody could follow anybody, successfully or silently, because there was no route to call. +`NoopSocialCommand::follow`'s silent `Ok(())` was a latent trap, genuinely worth removing, but it was +never a shipped bug: correct-looking code sitting behind a door that no federation-off request could +open. + +`NoopSocialQuery` sat behind no such door. Three consumers call `FollowGraphQuery` through routes that +carry **no federation gate at all**: `get_local_profile` (follower/following counts on `/users/{id}`, +which is an ungated route at `routes.rs:76`), `get_page_viewer` (the pending-follower badge), and +`get_activity_feed` (the followed-user set behind the activity feed's `filter_following`). All three +read `deps.social_query`, which `crates/composition` wires from `repos.follow_graph` — before this +branch, `NoopSocialQuery`, federation-off. **A federation-off instance did not fail to follow; it lied +about its own follow graph on every profile view and every activity-feed request** — reporting zero +followers, zero following, no pending requests, no followed users, regardless of what +`ap_followers`/`ap_following` actually held. That is the live bug this branch fixes, and it is the read +half, not the write half: the write half was unreachable over HTTP, so its noop cost nothing; the read +half was reachable everywhere, so its noop cost the truth. + +## Decision + +**A `-social` crate speaks only `domain::ports`; a `-federation` crate speaks +ActivityPub; the former must never depend on the latter.** `crates/adapters/sqlite-social` and +`crates/adapters/postgres-social` are new and hold the implementations of the **seven** *domain* ports +that the federation crates used to carry, across three types. `SqliteSocialRepository` / +`PostgresSocialRepository` carry five — `FollowCommand`, `FollowQuery`, `FederatedProfileQuery`, +`RemoteWatchlistRepository`, `FederationAdminQuery` — and two more sit on their own single-purpose +types: `SqliteApContentQuery` implements `LocalApContentQuery` (`sqlite-social/src/ap_content.rs:9`), +and `SqliteRemoteGoalRepository` implements `RemoteGoalRepository` +(`sqlite-social/src/remote_goals.rs:18`). This is not cosmetic: those last two are exactly what the base +`sqlite` crate re-exported from the federation crate before this split +(`crates/adapters/sqlite/src/lib.rs:35`, `:121`) — they are the answer to "why did `sqlite` depend on +`sqlite-federation` at all." `sqlite-federation` and `postgres-federation` keep the twelve `k_ap` trait +impls — `ActivityRepository`, `KeypairRepository`, `RemoteActorCache`, +`AnnounceRepository`, `RemoteReviewRepository`, `DomainBlocklist`, `ActorBlocklist`, and the five +`follow/` traits. The base backend crates now depend on `-social` unconditionally and on `-federation` +not at all; the federation crates enter the graph only through the `sqlite-federation` / +`postgres-federation` features. That inversion is the whole mechanism: the feature is real because the +edge it gates is the only edge. + +Measured on this branch with the same command and the same dedup pipeline: + +| Feature set | unique crates | `activitypub_federation` | +| --- | --- | --- | +| `sqlite` | **282** | **absent** | +| `sqlite,sqlite-federation` | 358 | present | +| `postgres` | **293** | **absent** | +| `postgres,postgres-federation` | 369 | present | + +Both backends drop exactly **76** crates when federation is off, and `grep -c activitypub_federation` +over the federation-off tree returns `0` for each. The on-counts are 358 and 369 rather than the old +357 and 368 because `sqlite-social` and `postgres-social` are themselves new crates in the graph; +against the old build, a federation-off deployment resolves 75 fewer crates. + +**The 76 crates are visible in the shipped image, not only in `cargo tree`.** Both container images +were built and compared: + +``` +docker build --build-arg FEATURES=sqlite . -> 171MB +docker build --build-arg FEATURES=sqlite,sqlite-federation . -> 190MB +``` + +**19MB smaller.** This is the measurement that matters to an operator: a crate count is an argument +about the build graph, whereas the image size is the consequence someone actually deploys. It is also +the evidence that the decoupling reaches all the way through — a change that only moved `impl` blocks +around would leave both images the same size. + +Note the second invocation is what plain `docker build .` does: **the Dockerfile's default is +`ARG FEATURES=sqlite,sqlite-federation`, i.e. federation-on.** The federation-off image — the entire +point of this ADR — is only built when asked for explicitly, so `docker build .` on its own does not +exercise it. Both commands above are the reproduction, and both should be run before a release that +claims the federation-off deployment works. + +**The orphan rule forced the struct split, and this is the third consecutive plan in which it has +dictated a design.** `SqliteFederationRepository` was a two-field struct — `pool` and `instance` — +carrying **seventeen** trait impls: five owned by `domain`, twelve owned by `k_ap`. Moving five impls +to a new crate is not a matter of moving five `impl` blocks, because from inside `sqlite-social` both +`domain`'s traits and a type defined in `sqlite-federation` are foreign, which is E0117. The five +impls needed a type defined where they live, so `SqliteSocialRepository { pool, instance }` exists, +and `PostgresSocialRepository` likewise. ADR-0008 records the first two occasions +(`ApServiceAdapter`, `DomainUserRepoAdapter`); the pattern is now settled enough to state as a rule +of this codebase: **"move these impls to another crate" always implies "and define a type there to +carry them."** Plan for the type, not just the impls. + +The two structs are deliberately *not* symmetric. `SqliteFederationRepository` lost its `instance` +field entirely — after the five domain impls left, nothing read it, and the alternative on the table +was an `#[allow(dead_code)]`, which was reversed during review in favour of deleting the field and +updating its seven crate-internal call sites. `SqliteSocialRepository` keeps `instance`, because +`follow_repository.rs` reads it in eight places to build the actor URLs ADR-0003 specifies. The +asymmetry was measured before the postgres half was written, so `postgres-social` was built to the +corrected shape rather than copied from its sibling and then fixed. + +**A non-recursive glob misclassified an entire subdirectory, and the correction is worth recording +because it nearly moved the wrong code.** The plan's first pass classified which files were +ActivityPub-free using a `src/*.rs` glob. That glob never descends into `src/follow/`, so `follow/` +was scored as clean and slated to move into the social crate. It is the opposite of clean: all five +of its traits — `FollowerWriter`, `FollowerReader`, `FollowingWriter`, `FollowingReader`, +`FollowMigration` — are `k_ap` traits, and its files import `k_ap::{Follower, FollowerStatus, +RemoteActor, ...}` directly. A recursive rescan before any code moved corrected the classification and +moved the split from a claimed 1333/359 line division to an actual 923/769. `follow/` stayed put. + +The near-miss is easy to make because the *local* follow path really is ActivityPub-free — it is just +a different file. `crates/adapters/sqlite-social/src/follow_repository.rs` imports only `domain`, +`sqlx`, `chrono`, `async_trait` and `adapter_common`, names no `k_ap` type, and carries the +`FollowCommand`/`FollowQuery` impls. Two similarly-named things sit side by side: the AP follower +protocol in `follow/`, and ADR-0003's direct-SQL local follow in `follow_repository.rs`. That the file +layout already separated them cleanly is ADR-0003's design showing through — the split was legible +because that decision had been made years earlier. The lesson stands regardless: **scan recursively**. +This was the second wrong classification produced by a single grep pattern in the same session. + +**The wiring is correct now; the read path is what a federation-off deployment actually gets today.** +`application::social::LocalSocialService` implements `SocialCommand`, `FollowGraphQuery` and +`BlockQuery` against the domain ports only, and `crates/server/src/main.rs` wires one instance of it +across all three when `federation` is off, replacing three separate noops. The bodies were ported from +`CompositeSocialAdapter`'s local branches rather than rewritten, and were compared token-by-token +against the originals during review — the two copies coexisted and agreed for three commits, which is +what made that comparison possible. `CompositeSocialAdapter` then delegates its local branches to +`Arc` and shrank from 299 to 207 lines; every surviving `SocialCommand` line in it is +either a delegation or genuinely ActivityPub-specific. + +What this buys a federation-off build, concretely, is the read half described above: +`get_local_profile`, `get_page_viewer` and `get_activity_feed` now read real +`ap_followers`/`ap_following` rows through `LocalSocialService` instead of `NoopSocialQuery`'s +always-empty answer. **An instance rebuilt federation-off stops lying about its own follow graph** — +that is the user-visible improvement this branch ships, and the ADR previously did not name it. + +The write half — `SocialCommand::follow`/`unfollow`/`accept_follow`/`reject_follow` — is now wired +correctly and errors honestly for a remote target (below), but it has **no HTTP surface** federation-off: +the social routes that would call it are federation-gated in `presentation`, as established above, and +this branch does not touch that gating. The wiring exists so it is correct on the day those routes are +ungated, not because anything reaches it today. Ungating the local-only social routes — splitting +`federation_api_routes`/`federation_html_routes` into a local-only half (follow, unfollow, accept/reject, +the counts and lists) and an AP-only half (blocks, remote admin queries, the AP follower/following +collections) — was raised and considered during review. **Decision: do not ungate them here.** It is a +deliberate non-goal of this branch and a candidate follow-up, not a gap in it: the split is a +presentation design task nobody has done or reviewed, since the two halves are currently interleaved +one function each, and getting it right means deciding case-by-case which of +`get_followers`/`get_following`/`get_relationship`/etc. are actually AP-agnostic versus AP-flavored in +their response shape. Recorded in the follow-ups below. + +**A remote target on a federation-off build now errors, and the error names the actor:** `cannot +reach remote actor {actor}: this instance was built without the federation feature` +(`crates/application/src/social/local_service.rs:74`). This is the branch's one intentional behaviour +change and lands in its own commit (`b8f74eb`). The verb is "reach", not "follow", because the same +constructor serves five call sites including unfollow and unblock, and "cannot follow" would +misdescribe four of them. At the four non-follow sites `{actor}` is whatever `actor_url_of` returns, +which for a persisted row is an AP URL rather than an `@user@host` handle; the message says "remote +actor", not "handle", so both read correctly. + +**The wiring fix is guarded by a Makefile grep rather than a test, and that is a limitation, not a +preference.** The decision lives in `wire_dependencies()` — a private `async fn` in +`crates/server/src/main.rs`, a *binary* crate. Binary crates expose no library API, so nothing under +`crates/server/tests/` can call it; `server/tests/api_test.rs` builds `AppState` and `AppContext` by +hand with its own fakes and never exercises the real wiring either. Making it testable means splitting +`server` into lib+bin, a structural change well outside this work. `LocalSocialService`'s *behaviour* +has five unit tests from earlier in the branch; what has no test is the *wiring*, and this repo +already expresses that class of rule as a Makefile grep guard — four existed, and ADR-0006/0007 +record the convention. `check-federation-off-social` is therefore the idiomatic instrument here rather +than a consolation prize. It was observed red (three hits at `main.rs:161,164,167`) before the fix and +green after, and is wired into `make check` (`Makefile:4`), `.PHONY`, and line 47 of both +`.github/workflows/ci.yml` and `.gitea/workflows/ci.yml`. `make check` now runs **six** guards: the +five above, plus `check-social-crates-are-ap-free`, added during this review to enforce the invariant +the whole federation-optional property rests on — a `-social` crate must never depend on +`k-ap`, `activitypub`, or anything matching `*-federation`. A single manifest line +(`k-ap = { version = "0.5.0", registry = "gitea" }` in `sqlite-social/Cargo.toml`) would silently +restore all 76 crates and 19MB while `make check`, `cargo test` and CI stayed green without it. +`check-federation-off-social` was also strengthened in the same review: it previously only asserted the +noops were *absent* from `main.rs`, which passes even if `LocalSocialService` were deleted from the same +file and nothing were wired at all. It now also asserts `LocalSocialService` is *present*. + +Its scope is a single file, which is narrower than its siblings and is now documented in the recipe: +`crates/server/src/main.rs` is the sole wiring site, and `worker` and `tui` do not name +`SocialCommand`, `FollowGraphQuery` or `BlockQuery` at all — verified by recursive grep over both +crates' `src/`, zero hits. Relocating the wiring out of `main.rs` would move it out of the guard's +scope; the comment says to widen the guard to `crates/server/src` if that ever happens. + +**Both halves share the `ap_followers` and `ap_following` tables, and neither owns the migrations.** +Those tables are created by `crates/adapters/sqlite/migrations/0003_activitypub.sql` and +`crates/adapters/postgres/migrations/0001_initial.sql` — the *base* backend crates, which both halves +already depend on. This is ADR-0003's arrangement unchanged: a local follow writes the same rows in +the same format as a remote one, which is what makes local relationships visible to the fediverse +through the AP collection endpoints without any duplication. The consequence for maintenance is that +**a schema change to either table touches both crates**, and neither crate's directory is where you +would look for the migration. This is a real coupling, accepted deliberately, because the alternative +— a separate `local_follows` table — was rejected in ADR-0003 as two sources of truth for one concept. + +**A latent bug in `worker` was exposed, not caused, by cutting the dependency edge.** +`crates/worker/src/main.rs` calls `tokio::signal::ctrl_c()` with no feature gate, but the workspace +`tokio` (`Cargo.toml:44`) declares only `macros, net, rt, rt-multi-thread, sync, time` — no `signal`. +It had been arriving transitively, through `sqlite → sqlite-federation → activitypub_federation`. +Removing that edge broke the worker build, which looked like collateral damage and was not: the worker +had a hard requirement satisfied by a dependency four hops away that it did not declare and had no +reason to expect. It would have broken the moment anything else in the graph shifted. Diagnosed by +stashing back to the pristine tree to confirm the transitive source, and fixed at the place the +requirement actually lives — `crates/worker/Cargo.toml:31` now reads +`tokio = { workspace = true, features = ["signal"] }`. This is the kind of thing a decoupling finds +that no amount of reading finds. + +### What this ADR does not claim + +**Blocks remain federation-only, and `LocalSocialService::block`/`unblock` always error.** There is no +domain port for block storage — blocking is `k_ap::ActorBlocklist` and `k_ap::DomainBlocklist`, both +`k_ap` traits on the federation struct — so making blocks work federation-off is a new port and new +tables, not a wiring change. It was not attempted. + +**`LocalSocialService::get_blocked` returns `Ok(vec![])` federation-off — the same stale-row shape this +section already discusses at length for `FederationAdminQuery`, and worth naming rather than leaving +implicit.** `block`/`unblock` always error, so no block can be *recorded* through this path either way, +but an instance that accumulated blocks while federation-ON and was then rebuilt federation-OFF would +report none. Operationally harmless: block enforcement itself lives in `k_ap`, absent federation-off, +and the route that exposes this list (`/social/blocked`) is federation-gated, so nothing acts on the +stale answer. But it is the same class of claim as the paragraph below makes for +`FederationAdminQuery`, and this section should say so plainly rather than naming `block`/`unblock` and +skipping the query. + +**`FederationAdminQuery` is still noop'd federation-off, returning an empty list, and the empty list +is an invariant rather than an assumption.** The weaker argument — "you cannot follow a remote actor +federation-off, so empty is honest" — is only plausible. The stronger one is provable from the wiring +this change installs: `LocalSocialService::follow_resolved` hard-errors on any non-`Local` target +(`local_service.rs:253-256`) and `block`/`unblock` always error, so a federation-off build **cannot +persist a remote follow through this path at all**. The empty list is enforced, not estimated. A +comment at the wiring site now records this so a future reader does not re-derive it. + +There is one exception, and it is a display artifact rather than a functional regression: **an +instance that ran federation-ON, accumulated remote follow rows, and was then rebuilt federation-OFF +would report zero remote follows while the rows still exist.** Those follows cannot be acted on +without federation anyway, so nothing is lost operationally, but the admin view understates reality. +Wiring `FederationAdminQuery` for real is viable whenever that is judged worth surfacing — both +`-social` crates implement it, and both are present federation-off. It was left noop'd because doing +otherwise would surface rows the build cannot act on. + +**"The federation-off builds compile" is not "they are clippy-clean."** +`cargo clippy -p worker --no-default-features --features sqlite -- -D warnings` still fails, with two +errors: unused `remote_goal` and `FollowBackfillHandler is never constructed`. ADR-0008 recorded +*three* here; the third, unused `app_config`, is incidentally gone because this branch hoists +`let instance` above `build_database_adapters` in both binaries and derives it from `app_config` +unconditionally. The remaining two predate this work and no gate runs this combination. Likewise +`cargo build -p server --no-default-features --features sqlite` emits one pre-existing warning, +unused `ap_content_repo` at `main.rs:65` (introduced at `ac7edd6`, long before this branch), and the +single-backend postgres builds emit unreachable-pattern warnings on `DbPool` matches that are +inherent to compiling a two-variant enum with one variant enabled. All warnings, none errors, none +new. + +**The known-broken feature combinations ADR-0008 recorded are unchanged.** +`--features sqlite,federation` on `server` still fails — `federation` is a bare meta-feature that does +not imply `dep:activitypub` — and `cargo check -p presentation --no-default-features --all-targets` +still fails with the same two pre-existing errors. This branch neither fixed nor worsened either. + +## Considered Options + +- **Gate the existing `sqlite-federation` dependency behind the feature instead of splitting the + crate** — the obvious one-line fix, and the reason it does not work is the whole point. The five + domain-port impls the rest of the system needs unconditionally (`FollowCommand`, `FollowQuery`, + `FederatedProfileQuery`, `RemoteWatchlistRepository`, `FederationAdminQuery`) lived on the same + struct in the same crate as the twelve `k_ap` impls. Gating the edge would have removed those five + too, and the `federation`-off build would have had no follow storage at all — which is precisely the + state that produced the silent-noop bug. The dependency could not be made conditional until the code + behind it was separated by what it speaks. +- **`#[cfg(feature = "federation")]` inside the existing crates rather than two new crates** — + cheaper in file moves, and rejected because a cfg inside a crate does not remove the crate's + manifest dependency on `k-ap`. `cargo tree` would have been unchanged, `activitypub_federation` + would still resolve, and the headline measurement would still read 357/357. Conditional compilation + cannot subtract a dependency edge; only the manifest can. +- **Keep the noops and document that federation-off means no social features** — internally + consistent, and rejected because it contradicts ADR-0003 on the merits, not merely on the docs. That + ADR's entire argument is that a local follow is two SQL writes and does not need ActivityPub. A build + that omitted ActivityPub and therefore omitted local-follow *storage and querying* would prove + ADR-0003 wrong; the split proves it right at the storage and service level — the HTTP surface for the + write path remains gated regardless of which option was chosen here, see the follow-up below. +- **Put `follow_resolved` on `SocialCommand` instead of on a new sibling trait** — the smaller-looking + option, rejected by enumerating implementors rather than by taste. Only `LocalSocialService` and one + domain test stub satisfy all four original `LocalSocial` bounds; `CompositeSocialAdapter` and + `InMemorySocialRepository` are real `SocialCommand` implementors that are *not* `LocalSocial` and + could not meaningfully implement `follow_resolved`. Extending `SocialCommand` would have stranded + two types. `LocalSocial` is now a marker supertrait over five traits with an unconstrained blanket + impl, matching `k_ap::FollowRepository`'s precedent over its own five. +- **Resolve the follow target twice on the local path** — what the plan originally prescribed, and + what the first implementation did: the composite called `resolve_target` to choose a branch, then + handed the raw `FollowTarget` to `local.follow`, which resolved it again. Cost was a redundant + `find_by_username` per handle-based local follow plus a race in which a user deleted between the two + resolutions makes a *federation-ON* deployment emit "this instance was built without the federation + feature", which is nonsense in that configuration. Replaced by `ResolvedFollow::follow_resolved`, so + the composite passes the identity it already computed. The five `LocalSocialService` tests passed + byte-unmodified across that change, which is the evidence that plumbing moved and behaviour did not. + +## Known follow-ups, out of scope here + +- **Ungating the local-only social routes** (`/social/follow`, `/social/unfollow`, accept/reject, the + counts and lists in `federation_api_routes`/`federation_html_routes`) so a federation-off build gets an + HTTP surface for the write path described above. Considered and deliberately deferred during review, + not because it is undesirable but because it is unreviewed design work: it needs splitting those two + functions into a local-only half and an AP-only half, deciding case-by-case which handlers are + AP-agnostic versus AP-flavored, and nobody has done or reviewed that split. Until it happens, + `LocalSocialService`'s write path is correctly wired and unreachable over HTTP federation-off. +- **The Dockerfile hand-enumerates every workspace member's `Cargo.toml`, and this branch broke it.** + `docker build .` failed at `RUN cargo fetch` with `failed to read + /build/crates/adapters/sqlite-social/Cargo.toml`, because the dependency-cache layer copies manifests + one `COPY` line at a time and the two new crates had no lines. Fixed here by adding them and by + verifying the list now matches `find crates -name Cargo.toml` exactly (37 against 37), with a + comment saying that adding a crate means adding a line. + + **This is a standing trap, not a one-off, and it is still unguarded.** Adding any workspace crate + silently breaks the container image, and the breakage is invisible to `make check`, to `cargo build` + in every feature combination, and to CI as currently configured — only a full `docker build` finds + it. This plan tripped it, and the project's history already records a Docker build broken across + three merges for the same reason: nobody ran it. **Recommendation, not implemented here** (it is a + separate decision): a `make` target that diffs the Dockerfile's `COPY` list against + `find crates -name Cargo.toml` and fails on any difference would close it in a few lines, and is + exactly the check that was run by hand to confirm this fix. A seventh guard is cheap; a fourth broken + release is not. +- **The error message names the actor and the build-time cause, but not which mutation was attempted.** + A user who unfollows and sees "cannot reach remote actor X: built without the federation feature" + learns why but not what. Closing this needs either per-site verbs — which reintroduces five message + strings to keep in sync — or route-level logging. Left open deliberately. +- **`FederationAdminQuery`'s stale-row case**, described above. Both `-social` crates already implement + the port federation-off, so this is wiring, not new code, if the staleness is judged worth surfacing. +- **`crates/infra-wiring` (87 lines across two files) still exists to break a real cycle** — `DbPool`, + `EventBusBackend` and `AppConfig` need to be visible to both `composition` and the binaries without + `composition` depending on `application::config`. It looks like a candidate for dissolution and is + not one; the cycle is genuine. Recorded so the next reader does not re-investigate. +- **`build_database_adapters`' two backend arms are near-duplicates** (`crates/composition/src/factory.rs:49`). + Each now constructs a `*SocialRepository` alongside everything else, so the duplication grew slightly + in this branch. Collapsing them needs a trait over the two backends' `wire()` outputs — real design + work, not a tidy-up, and not attempted during a refactor whose contract was behaviour preservation. +- **`tests/local_service.rs`'s `service()` helper takes `user_repo` by value and `follow_store` by + reference.** Pure polish, explicitly left alone. +- **`crates/adapters/` now holds 28 directories, up from the 26 ADR-0008 counted.** + `check-presentation-adapters` iterates those directory names and still passes, so neither new crate + 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. diff --git a/spa/src/components/actor-list.tsx b/spa/src/components/actor-list.tsx index 1b1cc7c..3918838 100644 --- a/spa/src/components/actor-list.tsx +++ b/spa/src/components/actor-list.tsx @@ -1,5 +1,6 @@ import type { LucideIcon } from "lucide-react" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { Link } from "@tanstack/react-router" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Card, CardContent } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" import { EmptyState } from "@/components/empty-state" @@ -28,9 +29,9 @@ export function ActorList({ data, isPending, emptyIcon, emptyTitle, emptyDescrip } function actorHandle(actor: RemoteActorDto): string { + if (actor.handle.startsWith("@")) return actor.handle try { - const host = new URL(actor.url).host - return `@${actor.handle}@${host}` + return `@${actor.handle}@${new URL(actor.url).host}` } catch { return `@${actor.handle}` } @@ -39,16 +40,31 @@ function actorHandle(actor: RemoteActorDto): string { function ActorCard({ actor, action }: { actor: RemoteActorDto; action?: React.ReactNode }) { const initial = (actor.display_name || actor.handle)[0]?.toUpperCase() ?? "?" + const identity = ( +
+ + {actor.avatar_url && } + {initial} + +
+

{actor.display_name || actor.handle}

+

{actorHandle(actor)}

+
+
+ ) + return ( - - {initial} - -
-

{actor.display_name || actor.handle}

-

{actorHandle(actor)}

-
+ {actor.user_id ? ( + + {identity} + + ) : ( + + {identity} + + )} {action}
diff --git a/spa/src/components/bottom-tab-bar.tsx b/spa/src/components/bottom-tab-bar.tsx index 827cf9e..94dce90 100644 --- a/spa/src/components/bottom-tab-bar.tsx +++ b/spa/src/components/bottom-tab-bar.tsx @@ -3,7 +3,13 @@ import { useTranslation } from "react-i18next" import { Home, Search, BookOpen, User } from "lucide-react" import { cn } from "@/lib/utils" -export function BottomTabBar({ onLogTap }: { onLogTap: () => void }) { +export function BottomTabBar({ + onLogTap, + pendingCount = 0, +}: { + onLogTap: () => void + pendingCount?: number +}) { const { t } = useTranslation() const matchRoute = useMatchRoute() @@ -55,7 +61,12 @@ export function BottomTabBar({ onLogTap }: { onLogTap: () => void }) { active ? "text-foreground" : "text-muted-foreground", )} > - +
+ + {tab.to === "/profile" && pendingCount > 0 && ( + + )} +
{tab.label} ) diff --git a/spa/src/components/profile-view.tsx b/spa/src/components/profile-view.tsx index c283bd4..a76afe5 100644 --- a/spa/src/components/profile-view.tsx +++ b/spa/src/components/profile-view.tsx @@ -41,7 +41,6 @@ export function ProfileView({ userId, search, onSearchChange, - isFederated, bio, handle, }: ProfileViewProps) { @@ -65,7 +64,7 @@ export function ProfileView({

{data.username}

- {isFederated && handle && ( + {handle && (
{handle} diff --git a/spa/src/features/social.ts b/spa/src/features/social.ts index c0dc8e9..757a7fd 100644 --- a/spa/src/features/social.ts +++ b/spa/src/features/social.ts @@ -6,9 +6,20 @@ export const remoteActorDtoSchema = z.object({ handle: z.string(), display_name: z.string().optional(), url: z.string(), + user_id: z.string().nullish(), + avatar_url: z.string().nullish(), }) export type RemoteActorDto = z.infer +export const followStateSchema = z.enum(["none", "pending", "accepted", "rejected"]) +export type FollowState = z.infer + +export const followRelationSchema = z.object({ + following: followStateSchema, + followed_by: followStateSchema, +}) +export type FollowRelation = z.infer + export const actorListResponseSchema = z.object({ actors: z.array(remoteActorDtoSchema), }) @@ -45,6 +56,11 @@ export const blockedActorResponseSchema = z.object({ }) export type BlockedActorResponse = z.infer +export const pendingCountResponseSchema = z.object({ + count: z.number(), +}) +export type PendingCountResponse = z.infer + function getFollowing() { return get("/social/following") } @@ -65,6 +81,18 @@ function getPendingFollowers() { return get("/social/followers/pending") } +function getPendingFollowerCount() { + return get("/social/followers/pending/count") +} + +function getPendingFollowing() { + return get("/social/following/pending") +} + +function getRelationship(actorUrl: string) { + return get(`/social/relationship?actor_url=${encodeURIComponent(actorUrl)}`) +} + function follow(data: FollowRequest) { return post("/social/follow", data) } @@ -113,6 +141,9 @@ export const socialKeys = { following: ["following"] as const, followers: ["followers"] as const, pending: ["followers-pending"] as const, + pendingCount: ["followers-pending-count"] as const, + pendingFollowing: ["following-pending"] as const, + relationship: (url: string) => ["relationship", url] as const, userFollowing: (id: string) => ["following", id] as const, userFollowers: (id: string) => ["followers", id] as const, blockedDomains: ["blocked-domains"] as const, @@ -156,12 +187,35 @@ export function usePendingFollowers() { }) } +export function usePendingFollowerCount() { + return useQuery({ + queryKey: socialKeys.pendingCount, + queryFn: getPendingFollowerCount, + }) +} + +export function usePendingFollowing() { + return useQuery({ + queryKey: socialKeys.pendingFollowing, + queryFn: getPendingFollowing, + }) +} + +export function useRelationship(actorUrl?: string) { + return useQuery({ + queryKey: socialKeys.relationship(actorUrl ?? ""), + queryFn: () => getRelationship(actorUrl!), + enabled: !!actorUrl, + }) +} + export function useFollow() { const qc = useQueryClient() return useMutation({ mutationFn: (data: FollowRequest) => follow(data), onSuccess: () => { qc.invalidateQueries({ queryKey: socialKeys.following }) + qc.invalidateQueries({ queryKey: ["relationship"] }) }, }) } @@ -172,6 +226,18 @@ export function useUnfollow() { mutationFn: (data: ActorUrlRequest) => unfollow(data), onSuccess: () => { qc.invalidateQueries({ queryKey: socialKeys.following }) + qc.invalidateQueries({ queryKey: ["relationship"] }) + }, + }) +} + +export function useCancelFollow() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (data: ActorUrlRequest) => unfollow(data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: socialKeys.pendingFollowing }) + qc.invalidateQueries({ queryKey: ["relationship"] }) }, }) } @@ -182,7 +248,9 @@ export function useAcceptFollower() { mutationFn: (data: ActorUrlRequest) => acceptFollower(data), onSuccess: () => { qc.invalidateQueries({ queryKey: socialKeys.pending }) + qc.invalidateQueries({ queryKey: socialKeys.pendingCount }) qc.invalidateQueries({ queryKey: socialKeys.followers }) + qc.invalidateQueries({ queryKey: ["relationship"] }) }, }) } @@ -193,6 +261,8 @@ export function useRejectFollower() { mutationFn: (data: ActorUrlRequest) => rejectFollower(data), onSuccess: () => { qc.invalidateQueries({ queryKey: socialKeys.pending }) + qc.invalidateQueries({ queryKey: socialKeys.pendingCount }) + qc.invalidateQueries({ queryKey: ["relationship"] }) }, }) } @@ -203,6 +273,7 @@ export function useRemoveFollower() { mutationFn: (data: ActorUrlRequest) => removeFollower(data), onSuccess: () => { qc.invalidateQueries({ queryKey: socialKeys.followers }) + qc.invalidateQueries({ queryKey: ["relationship"] }) }, }) } diff --git a/spa/src/locales/en.json b/spa/src/locales/en.json index 032011d..7b84332 100644 --- a/spa/src/locales/en.json +++ b/spa/src/locales/en.json @@ -2,6 +2,7 @@ "common": { "cancel": "Cancel", "confirm": "Confirm", + "requested": "Requested", "delete": "Delete", "edit": "Edit", "save": "Save", @@ -143,11 +144,16 @@ "following": "Following", "followers": "Followers", "pending": "Pending", + "requested": "Requested", "notFollowing": "Not following anyone", "notFollowingDesc": "Follow users to see their reviews in your feed", "noFollowers": "No followers yet", "noFollowersOther": "No followers", "noPending": "No pending requests", + "noRequested": "No requests sent", + "noRequestedDesc": "Follow requests you send will appear here until accepted", + "cancelRequest": "Cancel request", + "cancelRequestConfirm": "Cancel this follow request?", "followSent": "Follow request sent to {{handle}}", "followError": "Could not follow that user", "handlePlaceholder": "@user@instance.example" diff --git a/spa/src/routes/_app.tsx b/spa/src/routes/_app.tsx index 1a3fd15..140332f 100644 --- a/spa/src/routes/_app.tsx +++ b/spa/src/routes/_app.tsx @@ -11,6 +11,7 @@ import { Toaster } from "@/components/ui/sonner" import { BottomTabBar } from "@/components/bottom-tab-bar" import { ReviewSheet } from "@/components/review-sheet" import { getAuth } from "@/lib/auth" +import { usePendingFollowerCount } from "@/features/social" export const Route = createFileRoute("/_app")({ beforeLoad: () => { @@ -39,6 +40,8 @@ function AppLayout() { const [logOpen, setLogOpen] = useState(false) const matches = useMatches() const routeKey = matches.at(-1)?.id ?? "" + const { data: pending } = usePendingFollowerCount() + const pendingCount = pending?.count ?? 0 return (
@@ -47,7 +50,7 @@ function AppLayout() {
- setLogOpen(true)} /> + setLogOpen(true)} pendingCount={pendingCount} />
diff --git a/spa/src/routes/_app/profile.tsx b/spa/src/routes/_app/profile.tsx index 96416fe..6947d98 100644 --- a/spa/src/routes/_app/profile.tsx +++ b/spa/src/routes/_app/profile.tsx @@ -46,6 +46,8 @@ function ProfilePage() { userId={auth.user_id} search={search} onSearchChange={setSearch} + bio={data.bio} + handle={data.handle} actions={ <> diff --git a/spa/src/routes/_app/social.tsx b/spa/src/routes/_app/social.tsx index e5c923f..1694e9d 100644 --- a/spa/src/routes/_app/social.tsx +++ b/spa/src/routes/_app/social.tsx @@ -1,26 +1,31 @@ import { createFileRoute, Link } from "@tanstack/react-router" import { useState } from "react" import { useTranslation } from "react-i18next" -import { ArrowLeft, UserCheck, UserMinus, UserPlus, UserX, Users } from "lucide-react" +import { ArrowLeft, Clock, UserCheck, UserMinus, UserPlus, UserX, Users, X } from "lucide-react" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { toast } from "sonner" import { useAuth } from "@/components/auth-provider" import { ActorList } from "@/components/actor-list" +import { ConfirmDialog } from "@/components/confirm-dialog" import { useFollow, useFollowing, useFollowers, usePendingFollowers, + usePendingFollowing, useUnfollow, + useCancelFollow, useAcceptFollower, useRejectFollower, useRemoveFollower, useUserFollowing, useUserFollowers, } from "@/features/social" +import type { RemoteActorDto } from "@/features/social" import { useDocumentTitle } from "@/hooks/use-document-title" type SearchParams = { user?: string } @@ -57,14 +62,31 @@ function SocialPage() { function OwnSocialTabs() { const { t } = useTranslation() + const { data: pendingFollowers } = usePendingFollowers() + const { data: pendingFollowing } = usePendingFollowing() + const pendingCount = pendingFollowers?.actors.length ?? 0 + const requestedCount = pendingFollowing?.actors.length ?? 0 + return ( {t("social.following")} + + {t("social.requested")} + {requestedCount > 0 && ( + {requestedCount} + )} + {t("social.followers")} - {t("social.pending")} + + {t("social.pending")} + {pendingCount > 0 && ( + {pendingCount} + )} + + @@ -165,6 +187,49 @@ function PendingTab() { ) } +function RequestedTab() { + const { t } = useTranslation() + const { data, isPending } = usePendingFollowing() + const cancelMutation = useCancelFollow() + const [target, setTarget] = useState(null) + + return ( + <> + ( +
+ {t("common.requested")} + +
+ )} + /> + !open && setTarget(null)} + title={t("social.cancelRequestConfirm")} + confirmLabel={t("social.cancelRequest")} + onConfirm={() => { + if (target) cancelMutation.mutate({ actor_url: target.url }) + setTarget(null) + }} + /> + + ) +} + function UserFollowingTab({ userId }: { userId: string }) { const { t } = useTranslation() const { data, isPending } = useUserFollowing(userId) diff --git a/spa/src/routes/_app/users.$id.tsx b/spa/src/routes/_app/users.$id.tsx index 1cf1919..9fb99f1 100644 --- a/spa/src/routes/_app/users.$id.tsx +++ b/spa/src/routes/_app/users.$id.tsx @@ -1,15 +1,23 @@ import { createFileRoute } from "@tanstack/react-router" import { useState } from "react" import { useTranslation } from "react-i18next" -import { ExternalLink, UserCheck, UserPlus } from "lucide-react" +import { Clock, ExternalLink, 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 { useFollow, useUnfollow, useFollowing } from "@/features/social" +import { useFollow, useUnfollow, useCancelFollow, useRelationship } from "@/features/social" import { useDocumentTitle } from "@/hooks/use-document-title" +import type { FollowState } from "@/features/social" + +type FollowButtonContent = { + icon: React.ReactNode + label: string + variant: "default" | "outline" +} export const Route = createFileRoute("/_app/users/$id")({ component: UserProfilePage, @@ -20,17 +28,77 @@ function UserProfilePage() { const { id } = Route.useParams() const { auth } = useAuth() const { data, isPending } = useUserProfile(id, { view: "trends" }) - const { data: followingData } = useFollowing() + const { data: relation } = useRelationship(data?.actor_url ?? undefined) const followMutation = useFollow() const unfollowMutation = useUnfollow() + const cancelFollowMutation = useCancelFollow() const [search, setSearch] = useState("") + const [confirmOpen, setConfirmOpen] = useState(false) useDocumentTitle(data?.username) if (isPending) return if (!data) return null const isSelf = auth?.user_id === id - const isFollowing = followingData?.actors.some((a) => a.handle === data.username) ?? false + const followState: FollowState = relation?.following ?? "none" + + // The follow endpoint requires the full "user@domain" handle — a bare + // username 500s server-side (CompositeSocialAdapter.resolve_target_identity + // only recognizes the local instance when the handle carries "@"). + // Arrow consts, not function declarations: hoisted declarations would not see + // the `if (!data) return null` narrowing above. + const sendFollowRequest = () => { + followMutation.mutate({ handle: data.handle ?? data.username }) + } + + const handleFollowButtonClick = () => { + switch (followState) { + case "pending": + setConfirmOpen(true) + return + case "accepted": + unfollowMutation.mutate({ actor_url: data.actor_url ?? "" }) + return + case "rejected": + // A rejected request leaves nothing pending to cancel — let the user ask again. + sendFollowRequest() + return + case "none": + sendFollowRequest() + return + } + } + + function followButtonContent(): FollowButtonContent { + switch (followState) { + case "pending": + return { + icon: , + label: t("social.requested", { defaultValue: "Requested" }), + variant: "outline", + } + case "accepted": + return { + icon: , + label: t("common.following"), + variant: "outline", + } + case "rejected": + return { + icon: , + label: t("common.follow"), + variant: "default", + } + case "none": + return { + icon: , + label: t("common.follow"), + variant: "default", + } + } + } + + const { icon: followIcon, label: followLabel, variant: followVariant } = followButtonContent() return (
@@ -55,34 +123,36 @@ function UserProfilePage() { ) : undefined } headerRight={ - !isSelf && !data.is_federated ? ( - isFollowing ? ( + !isSelf ? ( +
- ) : ( - - ) - ) : data.is_federated && data.actor_url ? ( - - - + {data.is_federated && data.actor_url && ( + + + + )} + { + cancelFollowMutation.mutate({ actor_url: data.actor_url ?? "" }) + setConfirmOpen(false) + }} + /> +
) : undefined } />