From 189e866fb18e1d692dfb565251b1e6b7691ebe7b Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Thu, 19 Feb 2026 19:54:42 +0100 Subject: [PATCH] chore(api): P2.9 prettier, lint-staged, husky, and Copilot setup (#57) * feat(sybil): add graph-based sybil resistance and reputation hardening Implement P2.10 addressing VUL-010 (HIGH severity). Adds all 5 defense layers from architecture.md: PDS trust signals, EigenTrust graph-based trust propagation, logarithmic cluster ceiling, behavioral heuristics, and AT Protocol labeler integration points. Core additions: - 7 new database tables (interaction_graph, trust_seeds, trust_scores, sybil_clusters, sybil_cluster_members, behavioral_flags, pds_trust_factors) - EigenTrust algorithm with double-buffered iteration and convergence detection - Sybil cluster detection via BFS connected components - Interaction graph service wired into replies and reactions (fire-and-forget) - Behavioral heuristics: burst voting, trigram Jaccard content similarity, interaction diversity scoring - Background job orchestrating EigenTrust -> heuristics -> detection - Admin API: trust seed CRUD, sybil cluster management, PDS trust factors, behavioral flags, trust graph recompute - Reputation formula: base * voterTrustScore * pdsTrustFactor * clusterDiversityFactor - Sybil simulation test: 20 real + 10 sybil accounts confirms trust separation 127 new tests across 13 test files. All 1545 tests pass. * fix(db): add sybil resistance tables to drizzle config Include all 7 new P2.10 schema files in drizzle.config.ts so that fresh installations via drizzle-kit push create the tables. * fix(ci): add retry logic to security audit for registry outages pnpm audit fails the entire job when npm's registry returns 500 or is unreachable. Retry up to 3 times with 15s delay for transient errors (ERR_PNPM_AUDIT_BAD_RESPONSE, ECONNREFUSED, ETIMEDOUT, EAI_AGAIN). Real vulnerabilities still fail immediately on first attempt. * chore(api): add prettier, lint-staged, husky hooks, and commitlint Add prettier, lint-staged, husky, and commitlint as devDependencies using pnpm catalog references. Add format/format:check scripts and husky prepare script. Create prettier config (matching workspace standard), lint-staged config, commitlint config, prettierignore, and husky pre-commit/commit-msg hooks for conventional commit enforcement. * style(api): apply prettier formatting Apply prettier (semi: false, singleQuote, tabWidth: 2, trailingComma: es5, printWidth: 100) to all existing source files, tests, config files, and drizzle migration snapshots for consistent code style. * chore(api): add copilot-setup-steps.yml for AI coding agents Add GitHub Copilot coding agent setup workflow with PostgreSQL (pgvector) and Valkey services, matching the CI integration test configuration. Enables AI agents to install deps, run migrations, typecheck, and run both unit and integration tests. * fix(api): add prettier and lint-staged to standalone CI catalog Add prettier and lint-staged version entries to the per-repo pnpm-workspace.yaml catalog so pnpm install works in standalone CI environments (without the workspace root). --- .github/SECURITY.md | 6 + .github/dependabot.yml | 26 +- .github/workflows/ci.yml | 26 +- .github/workflows/cla.yml | 4 +- .github/workflows/copilot-setup-steps.yml | 61 + .husky/commit-msg | 1 + .husky/pre-commit | 1 + .prettierignore | 5 + AGENTS.md | 22 +- README.md | 112 +- commitlint.config.mjs | 31 + drizzle.config.ts | 57 +- drizzle/meta/0000_snapshot.json | 2 +- drizzle/meta/0001_snapshot.json | 8 +- drizzle/meta/0002_snapshot.json | 8 +- drizzle/meta/0003_snapshot.json | 8 +- drizzle/meta/0004_snapshot.json | 8 +- drizzle/meta/0005_snapshot.json | 8 +- drizzle/meta/0006_snapshot.json | 16 +- drizzle/meta/0007_snapshot.json | 16 +- drizzle/meta/0008_snapshot.json | 16 +- drizzle/meta/0009_snapshot.json | 16 +- drizzle/meta/0010_snapshot.json | 16 +- drizzle/meta/0011_snapshot.json | 16 +- drizzle/meta/0012_snapshot.json | 21 +- drizzle/meta/0013_snapshot.json | 21 +- drizzle/meta/0014_snapshot.json | 21 +- drizzle/meta/0015_snapshot.json | 21 +- drizzle/meta/0016_snapshot.json | 21 +- drizzle/meta/0017_snapshot.json | 27 +- drizzle/meta/0018_snapshot.json | 27 +- drizzle/meta/0020_snapshot.json | 27 +- drizzle/meta/0021_snapshot.json | 27 +- drizzle/meta/0022_snapshot.json | 27 +- drizzle/meta/0023_snapshot.json | 32 +- drizzle/meta/0024_snapshot.json | 32 +- drizzle/meta/_journal.json | 2 +- eslint.config.js | 23 +- lint-staged.config.mjs | 5 + package.json | 10 +- pnpm-workspace.yaml | 24 +- prettier.config.mjs | 10 + src/app.ts | 374 +-- src/auth/middleware.ts | 74 +- src/auth/oauth-client.ts | 102 +- src/auth/oauth-stores.ts | 100 +- src/auth/require-admin.ts | 49 +- src/auth/require-moderator.ts | 49 +- src/auth/require-operator.ts | 42 +- src/auth/scopes.ts | 18 +- src/auth/session.ts | 226 +- src/cache/index.ts | 26 +- src/config/env.ts | 72 +- src/db/index.ts | 14 +- src/db/schema/account-filters.ts | 59 +- src/db/schema/account-trust.ts | 27 +- src/db/schema/behavioral-flags.ts | 25 + src/db/schema/categories.ts | 51 +- src/db/schema/community-filters.ts | 46 +- src/db/schema/community-profiles.ts | 34 +- src/db/schema/community-settings.ts | 86 +- src/db/schema/cross-posts.ts | 28 +- src/db/schema/firehose.ts | 14 +- src/db/schema/index.ts | 47 +- src/db/schema/interaction-graph.ts | 34 + src/db/schema/moderation-actions.ts | 44 +- src/db/schema/moderation-queue.ts | 57 +- src/db/schema/notifications.ts | 52 +- src/db/schema/onboarding-fields.ts | 71 +- src/db/schema/ozone-labels.ts | 46 +- src/db/schema/pds-trust-factors.ts | 13 + src/db/schema/reactions.ts | 48 +- src/db/schema/replies.ts | 73 +- src/db/schema/reports.ts | 75 +- src/db/schema/sybil-cluster-members.ts | 17 + src/db/schema/sybil-clusters.ts | 22 + src/db/schema/topics.ts | 84 +- src/db/schema/tracked-repos.ts | 12 +- src/db/schema/trust-scores.ts | 19 + src/db/schema/trust-seeds.ts | 18 + src/db/schema/user-preferences.ts | 70 +- src/db/schema/users.ts | 45 +- src/firehose/cursor.ts | 53 +- src/firehose/handlers/identity.ts | 65 +- src/firehose/handlers/record.ts | 212 +- src/firehose/indexers/reaction.ts | 92 +- src/firehose/indexers/reply.ts | 96 +- src/firehose/indexers/topic.ts | 92 +- src/firehose/repo-manager.ts | 55 +- src/firehose/service.ts | 134 +- src/firehose/types.ts | 75 +- src/firehose/validation.ts | 49 +- src/jobs/compute-trust-graph.ts | 123 + src/lib/anti-spam.ts | 295 +-- src/lib/api-errors.ts | 18 +- src/lib/block-mute.ts | 20 +- src/lib/content-filter.ts | 33 +- src/lib/handle-resolver.ts | 112 +- src/lib/jurisdiction.ts | 10 +- src/lib/logger.ts | 2 +- src/lib/maturity.ts | 14 +- src/lib/muted-words.ts | 50 +- src/lib/onboarding-gate.ts | 35 +- src/lib/pds-client.ts | 123 +- src/lib/resolve-authors.ts | 54 +- src/lib/resolve-profile.ts | 38 +- src/lib/storage.ts | 60 +- src/routes/admin-settings.ts | 650 ++--- src/routes/admin-sybil.ts | 1201 +++++++++ src/routes/auth.ts | 367 +-- src/routes/block-mute.ts | 200 +- src/routes/categories.ts | 1007 ++++---- src/routes/community-profiles.ts | 195 +- src/routes/global-filters.ts | 726 +++--- src/routes/health.ts | 72 +- src/routes/moderation-queue.ts | 749 +++--- src/routes/moderation.ts | 2173 +++++++++-------- src/routes/notifications.ts | 232 +- src/routes/oauth-metadata.ts | 30 +- src/routes/onboarding.ts | 865 +++---- src/routes/profiles.ts | 707 +++--- src/routes/reactions.ts | 732 +++--- src/routes/replies.ts | 1287 +++++----- src/routes/search.ts | 811 +++--- src/routes/setup.ts | 56 +- src/routes/topics.ts | 1677 ++++++------- src/routes/uploads.ts | 135 +- src/server.ts | 16 +- src/services/account-age.ts | 71 +- src/services/ban-propagation.ts | 45 +- src/services/behavioral-heuristics.ts | 323 +++ src/services/cluster-diversity.ts | 26 + src/services/cross-post.ts | 254 +- src/services/embedding.ts | 46 +- src/services/interaction-graph.ts | 122 + src/services/notification.ts | 201 +- src/services/og-image.ts | 96 +- src/services/ozone.ts | 168 +- src/services/plc-did.ts | 216 +- src/services/profile-sync.ts | 81 +- src/services/sybil-detector.ts | 329 +++ src/services/trust-graph.ts | 303 +++ src/setup/service.ts | 122 +- src/validation/admin-settings.ts | 44 +- src/validation/anti-spam.ts | 18 +- src/validation/block-mute.ts | 10 +- src/validation/categories.ts | 116 +- src/validation/community-profiles.ts | 6 +- src/validation/global-filters.ts | 30 +- src/validation/moderation.ts | 51 +- src/validation/notifications.ts | 12 +- src/validation/onboarding.ts | 122 +- src/validation/profiles.ts | 80 +- src/validation/reactions.ts | 53 +- src/validation/replies.ts | 39 +- src/validation/search.ts | 24 +- src/validation/sybil.ts | 67 + src/validation/topics.ts | 65 +- tests/helpers/mock-db.ts | 97 +- .../firehose/account-deletion.test.ts | 379 ++- .../firehose/record-processing.test.ts | 590 ++--- tests/integration/health.test.ts | 104 +- tests/integration/plc-did-live.test.ts | 133 +- tests/unit/auth/middleware.test.ts | 276 +-- tests/unit/auth/oauth-client.test.ts | 492 ++-- tests/unit/auth/oauth-metadata.test.ts | 198 +- tests/unit/auth/oauth-stores.test.ts | 450 ++-- tests/unit/auth/require-admin.test.ts | 237 +- tests/unit/auth/require-moderator.test.ts | 268 +- tests/unit/auth/require-operator.test.ts | 340 ++- tests/unit/auth/scopes.test.ts | 172 +- tests/unit/auth/session.test.ts | 732 +++--- tests/unit/config/env.test.ts | 311 ++- tests/unit/db/schema/account-filters.test.ts | 144 +- tests/unit/db/schema/categories.test.ts | 100 +- .../unit/db/schema/community-filters.test.ts | 114 +- .../unit/db/schema/community-settings.test.ts | 178 +- tests/unit/db/schema/cross-posts.test.ts | 104 +- .../unit/db/schema/interaction-graph.test.ts | 65 + .../unit/db/schema/moderation-actions.test.ts | 74 +- tests/unit/db/schema/notifications.test.ts | 108 +- tests/unit/db/schema/ozone-labels.test.ts | 124 +- tests/unit/db/schema/reactions.test.ts | 84 +- tests/unit/db/schema/replies.test.ts | 108 +- tests/unit/db/schema/reports.test.ts | 98 +- .../db/schema/sybil-cluster-members.test.ts | 46 + tests/unit/db/schema/sybil-clusters.test.ts | 65 + tests/unit/db/schema/topics.test.ts | 134 +- tests/unit/db/schema/tracked-repos.test.ts | 42 +- tests/unit/db/schema/trust-scores.test.ts | 46 + tests/unit/db/schema/trust-seeds.test.ts | 53 + tests/unit/db/schema/user-preferences.test.ts | 214 +- tests/unit/firehose/cursor.test.ts | 114 +- tests/unit/firehose/handlers/identity.test.ts | 124 +- tests/unit/firehose/handlers/record.test.ts | 602 +++-- tests/unit/firehose/indexers/reaction.test.ts | 82 +- tests/unit/firehose/indexers/reply.test.ts | 110 +- tests/unit/firehose/indexers/topic.test.ts | 128 +- tests/unit/firehose/repo-manager.test.ts | 162 +- tests/unit/firehose/service.test.ts | 162 +- tests/unit/firehose/types.test.ts | 79 +- tests/unit/firehose/validation.test.ts | 170 +- tests/unit/jobs/compute-trust-graph.test.ts | 122 + tests/unit/lib/anti-spam.test.ts | 770 +++--- tests/unit/lib/block-mute.test.ts | 116 +- tests/unit/lib/content-filter.test.ts | 144 +- tests/unit/lib/handle-resolver.test.ts | 260 +- tests/unit/lib/jurisdiction.test.ts | 118 +- tests/unit/lib/maturity.test.ts | 98 +- tests/unit/lib/muted-words.test.ts | 239 +- tests/unit/lib/onboarding-gate.test.ts | 153 +- tests/unit/lib/resolve-authors.test.ts | 152 +- tests/unit/lib/resolve-profile.test.ts | 176 +- tests/unit/lib/storage.test.ts | 188 +- tests/unit/routes/admin-settings.test.ts | 1152 ++++----- tests/unit/routes/admin-sybil.test.ts | 1069 ++++++++ tests/unit/routes/auth.test.ts | 922 ++++--- tests/unit/routes/block-mute.test.ts | 499 ++-- tests/unit/routes/categories.test.ts | 1296 +++++----- tests/unit/routes/community-profiles.test.ts | 495 ++-- tests/unit/routes/global-filters.test.ts | 1431 +++++------ tests/unit/routes/health.test.ts | 138 +- tests/unit/routes/maturity-filtering.test.ts | 405 ++- tests/unit/routes/moderation-appeals.test.ts | 581 ++--- tests/unit/routes/moderation-queue.test.ts | 440 ++-- tests/unit/routes/moderation.test.ts | 1503 ++++++------ tests/unit/routes/notifications.test.ts | 564 ++--- tests/unit/routes/onboarding.test.ts | 883 +++---- tests/unit/routes/openapi.test.ts | 331 ++- tests/unit/routes/profiles.test.ts | 1187 +++++---- tests/unit/routes/reactions.test.ts | 922 +++---- tests/unit/routes/replies.test.ts | 1495 ++++++------ tests/unit/routes/search.test.ts | 592 +++-- tests/unit/routes/setup.test.ts | 414 ++-- .../routes/topics-replies-integration.test.ts | 740 +++--- tests/unit/routes/topics.test.ts | 1848 +++++++------- tests/unit/routes/uploads.test.ts | 447 ++-- tests/unit/services/account-age.test.ts | 172 +- tests/unit/services/ban-propagation.test.ts | 166 +- .../services/behavioral-heuristics.test.ts | 291 +++ tests/unit/services/cluster-diversity.test.ts | 30 + tests/unit/services/cross-post.test.ts | 676 ++--- tests/unit/services/embedding.test.ts | 296 +-- tests/unit/services/interaction-graph.test.ts | 163 ++ tests/unit/services/notification.test.ts | 428 ++-- tests/unit/services/og-image.test.ts | 278 +-- tests/unit/services/ozone.test.ts | 551 ++--- tests/unit/services/plc-did.test.ts | 567 +++-- tests/unit/services/profile-sync.test.ts | 220 +- tests/unit/services/sybil-detector.test.ts | 149 ++ tests/unit/services/sybil-simulation.test.ts | 143 ++ tests/unit/services/trust-graph.test.ts | 187 ++ tests/unit/setup/service.test.ts | 330 +-- tests/unit/validation/categories.test.ts | 506 ++-- tests/unit/validation/global-filters.test.ts | 616 ++--- tests/unit/validation/notifications.test.ts | 237 +- tests/unit/validation/onboarding.test.ts | 356 ++- tests/unit/validation/profiles.test.ts | 324 +-- tests/unit/validation/search.test.ts | 314 +-- tests/unit/validation/sybil.test.ts | 259 ++ vitest.config.integration.ts | 8 +- vitest.config.ts | 16 +- 262 files changed, 32190 insertions(+), 27565 deletions(-) create mode 100644 .github/workflows/copilot-setup-steps.yml create mode 100755 .husky/commit-msg create mode 100755 .husky/pre-commit create mode 100644 .prettierignore create mode 100644 commitlint.config.mjs create mode 100644 lint-staged.config.mjs create mode 100644 prettier.config.mjs create mode 100644 src/db/schema/behavioral-flags.ts create mode 100644 src/db/schema/interaction-graph.ts create mode 100644 src/db/schema/pds-trust-factors.ts create mode 100644 src/db/schema/sybil-cluster-members.ts create mode 100644 src/db/schema/sybil-clusters.ts create mode 100644 src/db/schema/trust-scores.ts create mode 100644 src/db/schema/trust-seeds.ts create mode 100644 src/jobs/compute-trust-graph.ts create mode 100644 src/routes/admin-sybil.ts create mode 100644 src/services/behavioral-heuristics.ts create mode 100644 src/services/cluster-diversity.ts create mode 100644 src/services/interaction-graph.ts create mode 100644 src/services/sybil-detector.ts create mode 100644 src/services/trust-graph.ts create mode 100644 src/validation/sybil.ts create mode 100644 tests/unit/db/schema/interaction-graph.test.ts create mode 100644 tests/unit/db/schema/sybil-cluster-members.test.ts create mode 100644 tests/unit/db/schema/sybil-clusters.test.ts create mode 100644 tests/unit/db/schema/trust-scores.test.ts create mode 100644 tests/unit/db/schema/trust-seeds.test.ts create mode 100644 tests/unit/jobs/compute-trust-graph.test.ts create mode 100644 tests/unit/routes/admin-sybil.test.ts create mode 100644 tests/unit/services/behavioral-heuristics.test.ts create mode 100644 tests/unit/services/cluster-diversity.test.ts create mode 100644 tests/unit/services/interaction-graph.test.ts create mode 100644 tests/unit/services/sybil-detector.test.ts create mode 100644 tests/unit/services/sybil-simulation.test.ts create mode 100644 tests/unit/services/trust-graph.test.ts create mode 100644 tests/unit/validation/sybil.test.ts diff --git a/.github/SECURITY.md b/.github/SECURITY.md index d685a9f..d31ac02 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -27,29 +27,34 @@ We will respond within 72 hours with next steps. barazo-api is the AppView backend -- it handles authentication, user input, database access, and firehose ingestion. The following areas are in scope for security reports: ### Authentication & Authorization + - **OAuth bypass** -- circumventing AT Protocol OAuth flows, session hijacking, token leakage - **Authorization escalation** -- accessing admin/moderator endpoints without the required role - **Session management** -- JWT/session token weaknesses, missing expiration, replay attacks ### Input Validation & Injection + - **SQL injection** -- any path that bypasses Drizzle ORM parameterized queries - **NoSQL/command injection** -- Valkey command injection via unsanitized input - **Content injection** -- storing malicious content that bypasses DOMPurify sanitization - **Zod schema bypass** -- requests that circumvent Zod validation on API endpoints ### AT Protocol & Firehose + - **Firehose record manipulation** -- crafted AT Protocol records that exploit indexing logic - **DID spoofing** -- forging identity claims through manipulated DIDs or handles - **Cross-community data leaks** -- accessing data from communities the user is not authorized to view - **Deletion event bypass** -- circumventing GDPR deletion propagation via firehose replay ### Rate Limiting & Abuse + - **Rate limit bypass** -- circumventing per-endpoint or per-user rate limits - **Burst detection evasion** -- evading anti-spam burst detection thresholds - **First-post queue bypass** -- new accounts posting without moderation review - **Resource exhaustion** -- requests that cause excessive CPU, memory, or database load ### Data Security + - **BYOK key exposure** -- leaking user-provided AI API keys (encrypted with AES-256-GCM at rest) - **Backup data exposure** -- unencrypted PII in backup outputs - **Logging PII** -- personal data appearing in Pino structured logs @@ -72,6 +77,7 @@ barazo-api is the AppView backend -- it handles authentication, user input, data ## Disclosure Policy We follow responsible disclosure: + - 90 days before public disclosure - Credit given to reporter (if desired) - CVE assigned when applicable diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 834bfbb..0406824 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,31 +1,31 @@ version: 2 updates: # Keep GitHub Actions pinned SHAs up-to-date - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: 'github-actions' + directory: '/' schedule: - interval: "weekly" + interval: 'weekly' open-pull-requests-limit: 5 labels: - - "dependencies" - - "ci" + - 'dependencies' + - 'ci' # Enable security updates for npm dependencies - - package-ecosystem: "npm" - directory: "/" + - package-ecosystem: 'npm' + directory: '/' schedule: - interval: "weekly" + interval: 'weekly' # Automatically create PRs for security updates open-pull-requests-limit: 10 # Group minor and patch updates groups: dependencies: patterns: - - "*" + - '*' update-types: - - "minor" - - "patch" + - 'minor' + - 'patch' # Auto-label PRs labels: - - "dependencies" - - "security" + - 'dependencies' + - 'security' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14d2100..82fa752 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 24 - registry-url: "https://npm.pkg.github.com" + registry-url: 'https://npm.pkg.github.com' - run: pnpm install env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -35,7 +35,7 @@ jobs: - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 24 - registry-url: "https://npm.pkg.github.com" + registry-url: 'https://npm.pkg.github.com' - run: pnpm install env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -50,7 +50,7 @@ jobs: - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 24 - registry-url: "https://npm.pkg.github.com" + registry-url: 'https://npm.pkg.github.com' - run: pnpm install env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -88,7 +88,7 @@ jobs: - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 24 - registry-url: "https://npm.pkg.github.com" + registry-url: 'https://npm.pkg.github.com' - run: pnpm install env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -111,7 +111,7 @@ jobs: - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 24 - registry-url: "https://npm.pkg.github.com" + registry-url: 'https://npm.pkg.github.com' - run: pnpm install env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -126,8 +126,20 @@ jobs: - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 24 - registry-url: "https://npm.pkg.github.com" + registry-url: 'https://npm.pkg.github.com' - run: pnpm install env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: pnpm audit --audit-level=high + - name: Security audit with retry + run: | + for attempt in 1 2 3; do + output=$(pnpm audit --audit-level=high 2>&1) && { echo "$output"; exit 0; } + if echo "$output" | grep -q "ERR_PNPM_AUDIT_BAD_RESPONSE\|ECONNREFUSED\|ETIMEDOUT\|EAI_AGAIN"; then + echo "::warning::Audit registry unavailable (attempt $attempt/3), retrying in 15s..." + sleep 15 + else + echo "$output" + exit 1 + fi + done + echo "::warning::Audit registry unavailable after 3 attempts, skipping" diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 6bbed85..7fcc343 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -1,4 +1,4 @@ -name: "CLA Assistant" +name: 'CLA Assistant' on: issue_comment: types: [created] @@ -15,7 +15,7 @@ jobs: cla: runs-on: ubuntu-latest steps: - - name: "CLA Assistant" + - name: 'CLA Assistant' if: (github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA' || github.event_name == 'pull_request_target') uses: contributor-assistant/github-action@fdca7a016082d9130c3cd91a236ddf956ec35f1d # v2.5.2 env: diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 0000000..1556a3f --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,61 @@ +name: 'Copilot Setup Steps' + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: barazo + POSTGRES_PASSWORD: barazo_dev + POSTGRES_DB: barazo + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U barazo" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + valkey: + image: valkey/valkey:8-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "valkey-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 3 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24 + registry-url: 'https://npm.pkg.github.com' + - run: pnpm install + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: pnpm db:migrate + env: + DATABASE_URL: postgresql://barazo:barazo_dev@localhost:5432/barazo + - run: pnpm typecheck + - run: pnpm test + - run: pnpm test:integration + env: + DATABASE_URL: postgresql://barazo:barazo_dev@localhost:5432/barazo + VALKEY_URL: redis://localhost:6379 + TAP_URL: http://localhost:2480 + TAP_ADMIN_PASSWORD: tap_dev_secret diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..2e6b87e --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +pnpm exec commitlint --edit "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..5ee7abd --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm exec lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..8e8e2fa --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist/ +node_modules/ +coverage/ +*.min.js +src/generated/ diff --git a/AGENTS.md b/AGENTS.md index d084443..3b96334 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,17 +9,17 @@ The AppView backend for Barazo. Subscribes to AT Protocol firehose, indexes `for ## Tech Stack -| Component | Technology | -|-----------|-----------| -| Runtime | Node.js 24 LTS / TypeScript (strict) | -| Framework | Fastify | -| Protocol | @atproto/api, @atproto/oauth-client-node, @atproto/tap | -| Database | PostgreSQL 16 (Drizzle ORM, Drizzle Kit migrations) | -| Cache | Valkey | -| Testing | Vitest + Supertest | -| Logging | Pino (structured) | -| Monitoring | GlitchTip (Sentry SDK-compatible) | -| Security | Helmet + Zod + DOMPurify + rate limiting | +| Component | Technology | +| ---------- | ------------------------------------------------------ | +| Runtime | Node.js 24 LTS / TypeScript (strict) | +| Framework | Fastify | +| Protocol | @atproto/api, @atproto/oauth-client-node, @atproto/tap | +| Database | PostgreSQL 16 (Drizzle ORM, Drizzle Kit migrations) | +| Cache | Valkey | +| Testing | Vitest + Supertest | +| Logging | Pino (structured) | +| Monitoring | GlitchTip (Sentry SDK-compatible) | +| Security | Helmet + Zod + DOMPurify + rate limiting | ## What This Repo Does diff --git a/README.md b/README.md index f8fafdf..b41d55d 100644 --- a/README.md +++ b/README.md @@ -28,19 +28,19 @@ The AppView backend for Barazo forums. Handles authentication, forum CRUD, fireh ## Tech Stack -| Component | Technology | -|-----------|-----------| -| Runtime | Node.js 24 LTS / TypeScript (strict mode) | -| Framework | Fastify 5 | -| Protocol | @atproto/api, @atproto/oauth-client-node, @atproto/tap | -| Database | PostgreSQL 16 + pgvector (Drizzle ORM, Drizzle Kit migrations) | -| Cache | Valkey (via ioredis) | -| Validation | Zod 4 | -| Testing | Vitest 4 + Supertest + Testcontainers | -| Logging | Pino (structured) | -| Monitoring | GlitchTip (self-hosted, Sentry SDK-compatible) | -| Security | Helmet + DOMPurify + rate limiting + CSP/HSTS | -| API docs | @fastify/swagger + Scalar | +| Component | Technology | +| ---------- | -------------------------------------------------------------- | +| Runtime | Node.js 24 LTS / TypeScript (strict mode) | +| Framework | Fastify 5 | +| Protocol | @atproto/api, @atproto/oauth-client-node, @atproto/tap | +| Database | PostgreSQL 16 + pgvector (Drizzle ORM, Drizzle Kit migrations) | +| Cache | Valkey (via ioredis) | +| Validation | Zod 4 | +| Testing | Vitest 4 + Supertest + Testcontainers | +| Logging | Pino (structured) | +| Monitoring | GlitchTip (self-hosted, Sentry SDK-compatible) | +| Security | Helmet + DOMPurify + rate limiting + CSP/HSTS | +| API docs | @fastify/swagger + Scalar | --- @@ -48,23 +48,23 @@ The AppView backend for Barazo forums. Handles authentication, forum CRUD, fireh 15 route modules across 74 source files: -| Module | File | Functionality | -|--------|------|---------------| -| Auth | `auth.ts` | AT Protocol OAuth sign-in with any PDS | -| OAuth Metadata | `oauth-metadata.ts` | OAuth discovery metadata endpoint | -| Health | `health.ts` | Health check | -| Topics | `topics.ts` | CRUD, sorting (chronological / reactions / trending), cross-posting to Bluesky + Frontpage, self-labels | -| Replies | `replies.ts` | CRUD threaded replies, self-labels | -| Categories | `categories.ts` | CRUD with maturity ratings (SFW / Mature / Adult), parent-child hierarchy | -| Reactions | `reactions.ts` | Configurable reaction types per community | -| Search | `search.ts` | Full-text search (PostgreSQL tsvector + GIN index), optional semantic search via `EMBEDDING_URL` | -| Profiles | `profiles.ts` | User profiles with PDS sync, cross-community reputation, age declaration | -| Notifications | `notifications.ts` | In-app and email notification system | -| Moderation | `moderation.ts` | Lock, pin, delete, ban, content reporting, first-post queue, word/phrase blocklists, link spam detection, mod action log | -| Admin Settings | `admin-settings.ts` | Community settings, maturity rating, branding, jurisdiction + age threshold configuration | -| Block / Mute | `block-mute.ts` | Block and mute users (portable via PDS records) | -| Onboarding | `onboarding.ts` | Admin-configurable community onboarding fields, user response submission and status tracking | -| Setup | `setup.ts` | Initial community setup wizard | +| Module | File | Functionality | +| -------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Auth | `auth.ts` | AT Protocol OAuth sign-in with any PDS | +| OAuth Metadata | `oauth-metadata.ts` | OAuth discovery metadata endpoint | +| Health | `health.ts` | Health check | +| Topics | `topics.ts` | CRUD, sorting (chronological / reactions / trending), cross-posting to Bluesky + Frontpage, self-labels | +| Replies | `replies.ts` | CRUD threaded replies, self-labels | +| Categories | `categories.ts` | CRUD with maturity ratings (SFW / Mature / Adult), parent-child hierarchy | +| Reactions | `reactions.ts` | Configurable reaction types per community | +| Search | `search.ts` | Full-text search (PostgreSQL tsvector + GIN index), optional semantic search via `EMBEDDING_URL` | +| Profiles | `profiles.ts` | User profiles with PDS sync, cross-community reputation, age declaration | +| Notifications | `notifications.ts` | In-app and email notification system | +| Moderation | `moderation.ts` | Lock, pin, delete, ban, content reporting, first-post queue, word/phrase blocklists, link spam detection, mod action log | +| Admin Settings | `admin-settings.ts` | Community settings, maturity rating, branding, jurisdiction + age threshold configuration | +| Block / Mute | `block-mute.ts` | Block and mute users (portable via PDS records) | +| Onboarding | `onboarding.ts` | Admin-configurable community onboarding fields, user response submission and status tracking | +| Setup | `setup.ts` | Initial community setup wizard | --- @@ -72,29 +72,30 @@ The AppView backend for Barazo forums. Handles authentication, forum CRUD, fireh 15 schema modules (Drizzle ORM): -| Schema | Purpose | -|--------|---------| -| `users.ts` | User accounts synced from PDS | -| `topics.ts` | Forum topics with maturity, self-labels | -| `replies.ts` | Threaded replies | -| `categories.ts` | Category hierarchy with maturity ratings | -| `reactions.ts` | Reaction records | -| `reports.ts` | Content reports | -| `notifications.ts` | Notification records | -| `moderation-actions.ts` | Moderation action log | -| `cross-posts.ts` | Bluesky + Frontpage cross-post tracking | +| Schema | Purpose | +| ----------------------- | -------------------------------------------------------- | +| `users.ts` | User accounts synced from PDS | +| `topics.ts` | Forum topics with maturity, self-labels | +| `replies.ts` | Threaded replies | +| `categories.ts` | Category hierarchy with maturity ratings | +| `reactions.ts` | Reaction records | +| `reports.ts` | Content reports | +| `notifications.ts` | Notification records | +| `moderation-actions.ts` | Moderation action log | +| `cross-posts.ts` | Bluesky + Frontpage cross-post tracking | | `community-settings.ts` | Per-community configuration, jurisdiction, age threshold | -| `user-preferences.ts` | Global and per-community user preferences | -| `onboarding-fields.ts` | Admin-defined onboarding fields and user responses | -| `tracked-repos.ts` | AT Protocol repo tracking state | -| `firehose.ts` | Firehose cursor and subscription state | -| `index.ts` | Schema barrel export | +| `user-preferences.ts` | Global and per-community user preferences | +| `onboarding-fields.ts` | Admin-defined onboarding fields and user responses | +| `tracked-repos.ts` | AT Protocol repo tracking state | +| `firehose.ts` | Firehose cursor and subscription state | +| `index.ts` | Schema barrel export | --- ## Features **AT Protocol integration:** + - OAuth authentication with any AT Protocol PDS - Firehose subscription via Tap, filtered for `forum.barazo.*` collections - Record validation (Zod) before indexing @@ -106,6 +107,7 @@ The AppView backend for Barazo forums. Handles authentication, forum CRUD, fireh - Two operating modes: single-forum or global aggregator (`COMMUNITY_MODE=global`) **Forum core:** + - Topics CRUD with sorting (chronological, reactions, trending) - Threaded replies CRUD - Categories with parent-child hierarchy and per-category maturity ratings @@ -118,6 +120,7 @@ The AppView backend for Barazo forums. Handles authentication, forum CRUD, fireh - User preferences (global and per-community) **Content maturity and age gating:** + - Three-tier content maturity system: SFW, Mature, Adult - Maturity ratings at both forum and category level - Content maturity filtering based on user age declaration @@ -125,6 +128,7 @@ The AppView backend for Barazo forums. Handles authentication, forum CRUD, fireh - Admin-configurable jurisdiction country and age threshold **Moderation:** + - Content reporting system - First-post moderation queue - Word and phrase blocklists @@ -135,6 +139,7 @@ The AppView backend for Barazo forums. Handles authentication, forum CRUD, fireh - GDPR-compliant account deletion (identity event handling) **Community administration:** + - Admin settings panel (name, description, branding, colors) - Community setup wizard - Admin-configurable onboarding fields (text, select, checkbox, etc.) @@ -142,9 +147,11 @@ The AppView backend for Barazo forums. Handles authentication, forum CRUD, fireh - Jurisdiction and age threshold configuration **Plugin system:** + - Plugin-aware route architecture across all modules **Security:** + - Zod validation on all API endpoints - DOMPurify output sanitization on all user-generated content - Helmet security headers (CSP, HSTS) @@ -213,6 +220,7 @@ pnpm typecheck # TypeScript strict mode check See [CONTRIBUTING.md](https://github.com/barazo-forum/.github/blob/main/CONTRIBUTING.md) for branching strategy, commit format, and code review process. **Key standards:** + - TypeScript strict mode (no `any`, no `@ts-ignore`) - All endpoints validate input with Zod schemas - All user content sanitized with DOMPurify @@ -233,12 +241,12 @@ See [barazo-deploy](https://github.com/barazo-forum/barazo-deploy) for full depl ## Related Repositories -| Repository | Description | License | -|------------|-------------|---------| -| [barazo-web](https://github.com/barazo-forum/barazo-web) | Forum frontend (Next.js, Tailwind) | MIT | -| [barazo-lexicons](https://github.com/barazo-forum/barazo-lexicons) | AT Protocol lexicon schemas + generated types | MIT | -| [barazo-deploy](https://github.com/barazo-forum/barazo-deploy) | Docker Compose deployment templates | MIT | -| [barazo-website](https://github.com/barazo-forum/barazo-website) | Marketing + documentation site | MIT | +| Repository | Description | License | +| ------------------------------------------------------------------ | --------------------------------------------- | ------- | +| [barazo-web](https://github.com/barazo-forum/barazo-web) | Forum frontend (Next.js, Tailwind) | MIT | +| [barazo-lexicons](https://github.com/barazo-forum/barazo-lexicons) | AT Protocol lexicon schemas + generated types | MIT | +| [barazo-deploy](https://github.com/barazo-forum/barazo-deploy) | Docker Compose deployment templates | MIT | +| [barazo-website](https://github.com/barazo-forum/barazo-website) | Marketing + documentation site | MIT | --- diff --git a/commitlint.config.mjs b/commitlint.config.mjs new file mode 100644 index 0000000..555e8c3 --- /dev/null +++ b/commitlint.config.mjs @@ -0,0 +1,31 @@ +/** + * Commitlint configuration + * Conventional Commits enforced per CLAUDE.md + * @see https://commitlint.js.org/#/reference-configuration + */ +export default { + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [ + 2, + 'always', + [ + 'build', + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'style', + 'test', + 'a11y', + 'security', + ], + ], + 'scope-empty': [0], + 'subject-case': [0], + }, +} diff --git a/drizzle.config.ts b/drizzle.config.ts index 7f1b94f..df12a27 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,31 +1,38 @@ -import { defineConfig } from "drizzle-kit"; +import { defineConfig } from 'drizzle-kit' export default defineConfig({ schema: [ - "./src/db/schema/users.ts", - "./src/db/schema/firehose.ts", - "./src/db/schema/topics.ts", - "./src/db/schema/replies.ts", - "./src/db/schema/reactions.ts", - "./src/db/schema/tracked-repos.ts", - "./src/db/schema/community-settings.ts", - "./src/db/schema/categories.ts", - "./src/db/schema/moderation-actions.ts", - "./src/db/schema/reports.ts", - "./src/db/schema/notifications.ts", - "./src/db/schema/user-preferences.ts", - "./src/db/schema/cross-posts.ts", - "./src/db/schema/onboarding-fields.ts", - "./src/db/schema/moderation-queue.ts", - "./src/db/schema/account-trust.ts", - "./src/db/schema/community-filters.ts", - "./src/db/schema/account-filters.ts", - "./src/db/schema/ozone-labels.ts", - "./src/db/schema/community-profiles.ts", + './src/db/schema/users.ts', + './src/db/schema/firehose.ts', + './src/db/schema/topics.ts', + './src/db/schema/replies.ts', + './src/db/schema/reactions.ts', + './src/db/schema/tracked-repos.ts', + './src/db/schema/community-settings.ts', + './src/db/schema/categories.ts', + './src/db/schema/moderation-actions.ts', + './src/db/schema/reports.ts', + './src/db/schema/notifications.ts', + './src/db/schema/user-preferences.ts', + './src/db/schema/cross-posts.ts', + './src/db/schema/onboarding-fields.ts', + './src/db/schema/moderation-queue.ts', + './src/db/schema/account-trust.ts', + './src/db/schema/community-filters.ts', + './src/db/schema/account-filters.ts', + './src/db/schema/ozone-labels.ts', + './src/db/schema/community-profiles.ts', + './src/db/schema/interaction-graph.ts', + './src/db/schema/trust-seeds.ts', + './src/db/schema/trust-scores.ts', + './src/db/schema/sybil-clusters.ts', + './src/db/schema/sybil-cluster-members.ts', + './src/db/schema/behavioral-flags.ts', + './src/db/schema/pds-trust-factors.ts', ], - out: "./drizzle", - dialect: "postgresql", + out: './drizzle', + dialect: 'postgresql', dbCredentials: { - url: process.env["DATABASE_URL"] ?? "postgresql://barazo:barazo_dev@localhost:5432/barazo", + url: process.env['DATABASE_URL'] ?? 'postgresql://barazo:barazo_dev@localhost:5432/barazo', }, -}); +}) diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 0a23734..f147d68 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -121,4 +121,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json index 5bce6fa..e281e6f 100644 --- a/drizzle/meta/0001_snapshot.json +++ b/drizzle/meta/0001_snapshot.json @@ -595,11 +595,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -644,4 +640,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json index 8354742..effbdd3 100644 --- a/drizzle/meta/0002_snapshot.json +++ b/drizzle/meta/0002_snapshot.json @@ -595,11 +595,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -698,4 +694,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json index f475c48..c0fa22b 100644 --- a/drizzle/meta/0003_snapshot.json +++ b/drizzle/meta/0003_snapshot.json @@ -595,11 +595,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -704,4 +700,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json index 8d73d50..cc27139 100644 --- a/drizzle/meta/0004_snapshot.json +++ b/drizzle/meta/0004_snapshot.json @@ -610,11 +610,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -719,4 +715,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json index daa216c..d53d384 100644 --- a/drizzle/meta/0005_snapshot.json +++ b/drizzle/meta/0005_snapshot.json @@ -616,11 +616,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -876,4 +872,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json index 6f13d00..e15620c 100644 --- a/drizzle/meta/0006_snapshot.json +++ b/drizzle/meta/0006_snapshot.json @@ -616,11 +616,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -862,12 +858,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -890,4 +882,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json index 39ac14a..8c342a3 100644 --- a/drizzle/meta/0007_snapshot.json +++ b/drizzle/meta/0007_snapshot.json @@ -623,11 +623,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -869,12 +865,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -897,4 +889,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json index 835b42f..4aaad0f 100644 --- a/drizzle/meta/0008_snapshot.json +++ b/drizzle/meta/0008_snapshot.json @@ -623,11 +623,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -876,12 +872,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -904,4 +896,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json index 7d19c36..49d0550 100644 --- a/drizzle/meta/0009_snapshot.json +++ b/drizzle/meta/0009_snapshot.json @@ -644,11 +644,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -911,12 +907,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1282,4 +1274,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json index 917ffad..296cc7a 100644 --- a/drizzle/meta/0010_snapshot.json +++ b/drizzle/meta/0010_snapshot.json @@ -632,11 +632,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -899,12 +895,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1270,4 +1262,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json index 911ff01..62d9e1d 100644 --- a/drizzle/meta/0011_snapshot.json +++ b/drizzle/meta/0011_snapshot.json @@ -632,11 +632,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -899,12 +895,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1385,4 +1377,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json index 247be4d..18cf763 100644 --- a/drizzle/meta/0012_snapshot.json +++ b/drizzle/meta/0012_snapshot.json @@ -632,11 +632,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -899,12 +895,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1463,10 +1455,7 @@ "compositePrimaryKeys": { "user_community_preferences_did_community_did_pk": { "name": "user_community_preferences_did_community_did_pk", - "columns": [ - "did", - "community_did" - ] + "columns": ["did", "community_did"] } }, "uniqueConstraints": {}, @@ -1560,4 +1549,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0013_snapshot.json b/drizzle/meta/0013_snapshot.json index 965e4ba..abc3441 100644 --- a/drizzle/meta/0013_snapshot.json +++ b/drizzle/meta/0013_snapshot.json @@ -632,11 +632,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -923,12 +919,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1487,10 +1479,7 @@ "compositePrimaryKeys": { "user_community_preferences_did_community_did_pk": { "name": "user_community_preferences_did_community_did_pk", - "columns": [ - "did", - "community_did" - ] + "columns": ["did", "community_did"] } }, "uniqueConstraints": {}, @@ -1584,4 +1573,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0014_snapshot.json b/drizzle/meta/0014_snapshot.json index f3fa627..ad3a6f3 100644 --- a/drizzle/meta/0014_snapshot.json +++ b/drizzle/meta/0014_snapshot.json @@ -632,11 +632,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -923,12 +919,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1487,10 +1479,7 @@ "compositePrimaryKeys": { "user_community_preferences_did_community_did_pk": { "name": "user_community_preferences_did_community_did_pk", - "columns": [ - "did", - "community_did" - ] + "columns": ["did", "community_did"] } }, "uniqueConstraints": {}, @@ -1671,4 +1660,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0015_snapshot.json b/drizzle/meta/0015_snapshot.json index 9097d27..2db331c 100644 --- a/drizzle/meta/0015_snapshot.json +++ b/drizzle/meta/0015_snapshot.json @@ -632,11 +632,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -947,12 +943,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1511,10 +1503,7 @@ "compositePrimaryKeys": { "user_community_preferences_did_community_did_pk": { "name": "user_community_preferences_did_community_did_pk", - "columns": [ - "did", - "community_did" - ] + "columns": ["did", "community_did"] } }, "uniqueConstraints": {}, @@ -1695,4 +1684,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0016_snapshot.json b/drizzle/meta/0016_snapshot.json index d605031..bb15ad6 100644 --- a/drizzle/meta/0016_snapshot.json +++ b/drizzle/meta/0016_snapshot.json @@ -632,11 +632,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -967,12 +963,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1531,10 +1523,7 @@ "compositePrimaryKeys": { "user_community_preferences_did_community_did_pk": { "name": "user_community_preferences_did_community_did_pk", - "columns": [ - "did", - "community_did" - ] + "columns": ["did", "community_did"] } }, "uniqueConstraints": {}, @@ -1715,4 +1704,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0017_snapshot.json b/drizzle/meta/0017_snapshot.json index c1d1b2d..42ef03e 100644 --- a/drizzle/meta/0017_snapshot.json +++ b/drizzle/meta/0017_snapshot.json @@ -632,11 +632,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -967,12 +963,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1531,10 +1523,7 @@ "compositePrimaryKeys": { "user_community_preferences_did_community_did_pk": { "name": "user_community_preferences_did_community_did_pk", - "columns": [ - "did", - "community_did" - ] + "columns": ["did", "community_did"] } }, "uniqueConstraints": {}, @@ -1859,11 +1848,7 @@ "compositePrimaryKeys": { "user_onboarding_responses_did_community_did_field_id_pk": { "name": "user_onboarding_responses_did_community_did_field_id_pk", - "columns": [ - "did", - "community_did", - "field_id" - ] + "columns": ["did", "community_did", "field_id"] } }, "uniqueConstraints": {}, @@ -1883,4 +1868,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0018_snapshot.json b/drizzle/meta/0018_snapshot.json index 18e8e20..021410e 100644 --- a/drizzle/meta/0018_snapshot.json +++ b/drizzle/meta/0018_snapshot.json @@ -676,11 +676,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -1011,12 +1007,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1575,10 +1567,7 @@ "compositePrimaryKeys": { "user_community_preferences_did_community_did_pk": { "name": "user_community_preferences_did_community_did_pk", - "columns": [ - "did", - "community_did" - ] + "columns": ["did", "community_did"] } }, "uniqueConstraints": {}, @@ -1903,11 +1892,7 @@ "compositePrimaryKeys": { "user_onboarding_responses_did_community_did_field_id_pk": { "name": "user_onboarding_responses_did_community_did_field_id_pk", - "columns": [ - "did", - "community_did", - "field_id" - ] + "columns": ["did", "community_did", "field_id"] } }, "uniqueConstraints": {}, @@ -2172,4 +2157,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0020_snapshot.json b/drizzle/meta/0020_snapshot.json index cd77da2..420ad31 100644 --- a/drizzle/meta/0020_snapshot.json +++ b/drizzle/meta/0020_snapshot.json @@ -726,11 +726,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -1061,12 +1057,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1625,10 +1617,7 @@ "compositePrimaryKeys": { "user_community_preferences_did_community_did_pk": { "name": "user_community_preferences_did_community_did_pk", - "columns": [ - "did", - "community_did" - ] + "columns": ["did", "community_did"] } }, "uniqueConstraints": {}, @@ -1953,11 +1942,7 @@ "compositePrimaryKeys": { "user_onboarding_responses_did_community_did_field_id_pk": { "name": "user_onboarding_responses_did_community_did_field_id_pk", - "columns": [ - "did", - "community_did", - "field_id" - ] + "columns": ["did", "community_did", "field_id"] } }, "uniqueConstraints": {}, @@ -2641,4 +2626,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0021_snapshot.json b/drizzle/meta/0021_snapshot.json index 3d4f7d8..eef65e9 100644 --- a/drizzle/meta/0021_snapshot.json +++ b/drizzle/meta/0021_snapshot.json @@ -726,11 +726,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -1061,12 +1057,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1644,10 +1636,7 @@ "compositePrimaryKeys": { "user_community_preferences_did_community_did_pk": { "name": "user_community_preferences_did_community_did_pk", - "columns": [ - "did", - "community_did" - ] + "columns": ["did", "community_did"] } }, "uniqueConstraints": {}, @@ -1972,11 +1961,7 @@ "compositePrimaryKeys": { "user_onboarding_responses_did_community_did_field_id_pk": { "name": "user_onboarding_responses_did_community_did_field_id_pk", - "columns": [ - "did", - "community_did", - "field_id" - ] + "columns": ["did", "community_did", "field_id"] } }, "uniqueConstraints": {}, @@ -2660,4 +2645,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0022_snapshot.json b/drizzle/meta/0022_snapshot.json index cef1b7b..f4f4f6e 100644 --- a/drizzle/meta/0022_snapshot.json +++ b/drizzle/meta/0022_snapshot.json @@ -738,11 +738,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -1073,12 +1069,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1656,10 +1648,7 @@ "compositePrimaryKeys": { "user_community_preferences_did_community_did_pk": { "name": "user_community_preferences_did_community_did_pk", - "columns": [ - "did", - "community_did" - ] + "columns": ["did", "community_did"] } }, "uniqueConstraints": {}, @@ -1984,11 +1973,7 @@ "compositePrimaryKeys": { "user_onboarding_responses_did_community_did_field_id_pk": { "name": "user_onboarding_responses_did_community_did_field_id_pk", - "columns": [ - "did", - "community_did", - "field_id" - ] + "columns": ["did", "community_did", "field_id"] } }, "uniqueConstraints": {}, @@ -2672,4 +2657,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0023_snapshot.json b/drizzle/meta/0023_snapshot.json index 83842b8..8d0f60d 100644 --- a/drizzle/meta/0023_snapshot.json +++ b/drizzle/meta/0023_snapshot.json @@ -738,11 +738,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -1073,12 +1069,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1656,10 +1648,7 @@ "compositePrimaryKeys": { "user_community_preferences_did_community_did_pk": { "name": "user_community_preferences_did_community_did_pk", - "columns": [ - "did", - "community_did" - ] + "columns": ["did", "community_did"] } }, "uniqueConstraints": {}, @@ -1984,11 +1973,7 @@ "compositePrimaryKeys": { "user_onboarding_responses_did_community_did_field_id_pk": { "name": "user_onboarding_responses_did_community_did_field_id_pk", - "columns": [ - "did", - "community_did", - "field_id" - ] + "columns": ["did", "community_did", "field_id"] } }, "uniqueConstraints": {}, @@ -2744,10 +2729,7 @@ "compositePrimaryKeys": { "community_profiles_did_community_did_pk": { "name": "community_profiles_did_community_did_pk", - "columns": [ - "did", - "community_did" - ] + "columns": ["did", "community_did"] } }, "uniqueConstraints": {}, @@ -2767,4 +2749,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0024_snapshot.json b/drizzle/meta/0024_snapshot.json index d0774d4..9f43a5c 100644 --- a/drizzle/meta/0024_snapshot.json +++ b/drizzle/meta/0024_snapshot.json @@ -738,11 +738,7 @@ "reactions_author_subject_type_uniq": { "name": "reactions_author_subject_type_uniq", "nullsNotDistinct": false, - "columns": [ - "author_did", - "subject_uri", - "type" - ] + "columns": ["author_did", "subject_uri", "type"] } }, "policies": {}, @@ -1073,12 +1069,8 @@ "name": "categories_parent_id_fk", "tableFrom": "categories", "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1656,10 +1648,7 @@ "compositePrimaryKeys": { "user_community_preferences_did_community_did_pk": { "name": "user_community_preferences_did_community_did_pk", - "columns": [ - "did", - "community_did" - ] + "columns": ["did", "community_did"] } }, "uniqueConstraints": {}, @@ -1991,11 +1980,7 @@ "compositePrimaryKeys": { "user_onboarding_responses_did_community_did_field_id_pk": { "name": "user_onboarding_responses_did_community_did_field_id_pk", - "columns": [ - "did", - "community_did", - "field_id" - ] + "columns": ["did", "community_did", "field_id"] } }, "uniqueConstraints": {}, @@ -2751,10 +2736,7 @@ "compositePrimaryKeys": { "community_profiles_did_community_did_pk": { "name": "community_profiles_did_community_did_pk", - "columns": [ - "did", - "community_did" - ] + "columns": ["did", "community_did"] } }, "uniqueConstraints": {}, @@ -2774,4 +2756,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4f3cd78..109b91f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -178,4 +178,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/eslint.config.js b/eslint.config.js index d30dc21..a74d300 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,28 +1,25 @@ -import tseslint from "typescript-eslint"; +import tseslint from 'typescript-eslint' export default tseslint.config( ...tseslint.configs.strictTypeChecked, { languageOptions: { parserOptions: { - project: "./tsconfig.eslint.json", + project: './tsconfig.eslint.json', tsconfigRootDir: import.meta.dirname, }, }, }, { rules: { - "no-console": "error", - "@typescript-eslint/no-explicit-any": "error", - "@typescript-eslint/no-unused-vars": [ - "error", - { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, - ], - "@typescript-eslint/consistent-type-imports": [ - "error", - { prefer: "type-imports" }, + 'no-console': 'error', + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, ], + '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], }, }, - { ignores: ["dist/", "node_modules/", "drizzle/", "*.config.*"] }, -); + { ignores: ['dist/', 'node_modules/', 'drizzle/', '*.config.*'] } +) diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs new file mode 100644 index 0000000..2e64100 --- /dev/null +++ b/lint-staged.config.mjs @@ -0,0 +1,5 @@ +export default { + '*.ts': ['prettier --write', 'eslint --fix'], + '*.{js,mjs,cjs}': ['prettier --write', 'eslint --fix'], + '*.{json,md,yml,yaml}': ['prettier --write'], +} diff --git a/package.json b/package.json index a6301e8..a39938a 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,10 @@ "test:integration": "vitest run --config vitest.config.integration.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "format": "prettier --write .", + "format:check": "prettier --check .", + "prepare": "husky" }, "dependencies": { "@atproto/api": "^0.18.21", @@ -51,11 +54,16 @@ "zod": "^4.3.6" }, "devDependencies": { + "@commitlint/cli": "catalog:", + "@commitlint/config-conventional": "catalog:", "@testcontainers/postgresql": "^11.11.0", "@types/node": "^25.2.3", "@vitest/coverage-v8": "^4.0.18", "drizzle-kit": "^0.31.9", "eslint": "^9.39.2", + "husky": "catalog:", + "lint-staged": "catalog:", + "prettier": "catalog:", "supertest": "^7.1.0", "testcontainers": "^11.11.0", "tsx": "^4.20.3", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b4d9ff0..72dc27b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,14 +2,16 @@ # When running in the workspace root, the root pnpm-workspace.yaml takes precedence. # Keep these versions in sync with the workspace root catalog. catalog: - zod: "^4.3.6" - vitest: "^4.0.18" - typescript: "^5.9.3" - typescript-eslint: "^8.55.0" - eslint: "^9.39.2" - "@types/node": "^25.2.3" - "@commitlint/cli": "^20.4.1" - "@commitlint/config-conventional": "^20.4.1" - "@vitest/coverage-v8": "^4.0.18" - husky: "^9.1.7" - multiformats: "^13.4.2" + zod: '^4.3.6' + vitest: '^4.0.18' + typescript: '^5.9.3' + typescript-eslint: '^8.55.0' + eslint: '^9.39.2' + '@types/node': '^25.2.3' + '@commitlint/cli': '^20.4.1' + '@commitlint/config-conventional': '^20.4.1' + '@vitest/coverage-v8': '^4.0.18' + husky: '^9.1.7' + lint-staged: '^16.2.7' + multiformats: '^13.4.2' + prettier: '^3.8.1' diff --git a/prettier.config.mjs b/prettier.config.mjs new file mode 100644 index 0000000..3232e00 --- /dev/null +++ b/prettier.config.mjs @@ -0,0 +1,10 @@ +const config = { + semi: false, + singleQuote: true, + tabWidth: 2, + trailingComma: 'es5', + printWidth: 100, + plugins: [], +} + +export default config diff --git a/src/app.ts b/src/app.ts index 899d286..a5d1ed5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,74 +1,81 @@ -import Fastify from "fastify"; -import helmet from "@fastify/helmet"; -import cors from "@fastify/cors"; -import cookie from "@fastify/cookie"; -import multipart from "@fastify/multipart"; -import rateLimit from "@fastify/rate-limit"; -import swagger from "@fastify/swagger"; -import scalarApiReference from "@scalar/fastify-api-reference"; -import * as Sentry from "@sentry/node"; -import type { FastifyError } from "fastify"; -import type { NodeOAuthClient } from "@atproto/oauth-client-node"; -import type { Env } from "./config/env.js"; -import { createDb } from "./db/index.js"; -import { createCache } from "./cache/index.js"; -import { FirehoseService } from "./firehose/service.js"; -import { createOAuthClient } from "./auth/oauth-client.js"; -import { createSessionService } from "./auth/session.js"; -import type { SessionService } from "./auth/session.js"; -import { createAuthMiddleware } from "./auth/middleware.js"; -import type { AuthMiddleware, RequestUser } from "./auth/middleware.js"; -import healthRoutes from "./routes/health.js"; -import { oauthMetadataRoutes } from "./routes/oauth-metadata.js"; -import { authRoutes } from "./routes/auth.js"; -import { setupRoutes } from "./routes/setup.js"; -import { topicRoutes } from "./routes/topics.js"; -import { replyRoutes } from "./routes/replies.js"; -import { categoryRoutes } from "./routes/categories.js"; -import { adminSettingsRoutes } from "./routes/admin-settings.js"; -import { reactionRoutes } from "./routes/reactions.js"; -import { moderationRoutes } from "./routes/moderation.js"; -import { moderationQueueRoutes } from "./routes/moderation-queue.js"; -import { searchRoutes } from "./routes/search.js"; -import { notificationRoutes } from "./routes/notifications.js"; -import { profileRoutes } from "./routes/profiles.js"; -import { blockMuteRoutes } from "./routes/block-mute.js"; -import { onboardingRoutes } from "./routes/onboarding.js"; -import { globalFilterRoutes } from "./routes/global-filters.js"; -import { communityProfileRoutes } from "./routes/community-profiles.js"; -import { uploadRoutes } from "./routes/uploads.js"; -import { createRequireAdmin } from "./auth/require-admin.js"; -import { createRequireOperator } from "./auth/require-operator.js"; -import { OzoneService } from "./services/ozone.js"; -import { createSetupService } from "./setup/service.js"; -import type { SetupService } from "./setup/service.js"; -import { createPlcDidService } from "./services/plc-did.js"; -import { createHandleResolver } from "./lib/handle-resolver.js"; -import type { HandleResolver } from "./lib/handle-resolver.js"; -import { createProfileSyncService } from "./services/profile-sync.js"; -import type { ProfileSyncService } from "./services/profile-sync.js"; -import { createLocalStorage } from "./lib/storage.js"; -import type { StorageService } from "./lib/storage.js"; -import type { Database } from "./db/index.js"; -import type { Cache } from "./cache/index.js"; +import Fastify from 'fastify' +import helmet from '@fastify/helmet' +import cors from '@fastify/cors' +import cookie from '@fastify/cookie' +import multipart from '@fastify/multipart' +import rateLimit from '@fastify/rate-limit' +import swagger from '@fastify/swagger' +import scalarApiReference from '@scalar/fastify-api-reference' +import * as Sentry from '@sentry/node' +import type { FastifyError } from 'fastify' +import type { NodeOAuthClient } from '@atproto/oauth-client-node' +import type { Env } from './config/env.js' +import { createDb } from './db/index.js' +import { createCache } from './cache/index.js' +import { FirehoseService } from './firehose/service.js' +import { createOAuthClient } from './auth/oauth-client.js' +import { createSessionService } from './auth/session.js' +import type { SessionService } from './auth/session.js' +import { createAuthMiddleware } from './auth/middleware.js' +import type { AuthMiddleware, RequestUser } from './auth/middleware.js' +import healthRoutes from './routes/health.js' +import { oauthMetadataRoutes } from './routes/oauth-metadata.js' +import { authRoutes } from './routes/auth.js' +import { setupRoutes } from './routes/setup.js' +import { topicRoutes } from './routes/topics.js' +import { replyRoutes } from './routes/replies.js' +import { categoryRoutes } from './routes/categories.js' +import { adminSettingsRoutes } from './routes/admin-settings.js' +import { reactionRoutes } from './routes/reactions.js' +import { moderationRoutes } from './routes/moderation.js' +import { moderationQueueRoutes } from './routes/moderation-queue.js' +import { searchRoutes } from './routes/search.js' +import { notificationRoutes } from './routes/notifications.js' +import { profileRoutes } from './routes/profiles.js' +import { blockMuteRoutes } from './routes/block-mute.js' +import { onboardingRoutes } from './routes/onboarding.js' +import { globalFilterRoutes } from './routes/global-filters.js' +import { communityProfileRoutes } from './routes/community-profiles.js' +import { uploadRoutes } from './routes/uploads.js' +import { adminSybilRoutes } from './routes/admin-sybil.js' +import { createRequireAdmin } from './auth/require-admin.js' +import { createRequireOperator } from './auth/require-operator.js' +import { OzoneService } from './services/ozone.js' +import { createSetupService } from './setup/service.js' +import type { SetupService } from './setup/service.js' +import { createPlcDidService } from './services/plc-did.js' +import { createHandleResolver } from './lib/handle-resolver.js' +import type { HandleResolver } from './lib/handle-resolver.js' +import { createProfileSyncService } from './services/profile-sync.js' +import type { ProfileSyncService } from './services/profile-sync.js' +import { createLocalStorage } from './lib/storage.js' +import type { StorageService } from './lib/storage.js' +import type { Database } from './db/index.js' +import type { Cache } from './cache/index.js' +import { createInteractionGraphService } from './services/interaction-graph.js' +import type { InteractionGraphService } from './services/interaction-graph.js' +import { createTrustGraphService } from './services/trust-graph.js' +import type { TrustGraphService } from './services/trust-graph.js' // Extend Fastify types with decorated properties -declare module "fastify" { +declare module 'fastify' { interface FastifyInstance { - db: Database; - cache: Cache; - env: Env; - firehose: FirehoseService; - oauthClient: NodeOAuthClient; - sessionService: SessionService; - authMiddleware: AuthMiddleware; - setupService: SetupService; - handleResolver: HandleResolver; - requireAdmin: ReturnType; - requireOperator: ReturnType; - ozoneService: OzoneService | null; - profileSync: ProfileSyncService; - storage: StorageService; + db: Database + cache: Cache + env: Env + firehose: FirehoseService + oauthClient: NodeOAuthClient + sessionService: SessionService + authMiddleware: AuthMiddleware + setupService: SetupService + handleResolver: HandleResolver + requireAdmin: ReturnType + requireOperator: ReturnType + ozoneService: OzoneService | null + profileSync: ProfileSyncService + storage: StorageService + interactionGraphService: InteractionGraphService + trustGraphService: TrustGraphService } } @@ -78,46 +85,44 @@ export async function buildApp(env: Env) { Sentry.init({ dsn: env.GLITCHTIP_DSN, environment: - env.LOG_LEVEL === "debug" || env.LOG_LEVEL === "trace" - ? "development" - : "production", - }); + env.LOG_LEVEL === 'debug' || env.LOG_LEVEL === 'trace' ? 'development' : 'production', + }) } const app = Fastify({ logger: { level: env.LOG_LEVEL, - ...(process.env.NODE_ENV === "development" && - (env.LOG_LEVEL === "debug" || env.LOG_LEVEL === "trace") - ? { transport: { target: "pino-pretty" } } + ...(process.env.NODE_ENV === 'development' && + (env.LOG_LEVEL === 'debug' || env.LOG_LEVEL === 'trace') + ? { transport: { target: 'pino-pretty' } } : {}), }, trustProxy: true, - }); + }) // Database - const { db, client: dbClient } = createDb(env.DATABASE_URL); - app.decorate("db", db); - app.decorate("env", env); + const { db, client: dbClient } = createDb(env.DATABASE_URL) + app.decorate('db', db) + app.decorate('env', env) // Cache - const cache = createCache(env.VALKEY_URL, app.log); - app.decorate("cache", cache); + const cache = createCache(env.VALKEY_URL, app.log) + app.decorate('cache', cache) // Firehose - const firehose = new FirehoseService(db, app.log, env); - app.decorate("firehose", firehose); + const firehose = new FirehoseService(db, app.log, env) + app.decorate('firehose', firehose) // Security headers await app.register(helmet, { contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"], - styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"], - imgSrc: ["'self'", "data:", "https:"], + scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'], + styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'], + imgSrc: ["'self'", 'data:', 'https:'], connectSrc: ["'self'"], - fontSrc: ["'self'", "https://cdn.jsdelivr.net"], + fontSrc: ["'self'", 'https://cdn.jsdelivr.net'], objectSrc: ["'none'"], frameSrc: ["'none'"], }, @@ -127,184 +132,185 @@ export async function buildApp(env: Env) { includeSubDomains: true, preload: true, }, - }); + }) // CORS await app.register(cors, { - origin: env.CORS_ORIGINS.split(",").map((o) => o.trim()), + origin: env.CORS_ORIGINS.split(',').map((o) => o.trim()), credentials: true, - methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - allowedHeaders: ["Content-Type", "Authorization"], - }); + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + }) // Rate limiting await app.register(rateLimit, { max: env.RATE_LIMIT_READ_ANON, - timeWindow: "1 minute", - }); + timeWindow: '1 minute', + }) // Cookies (must be registered before auth routes) - await app.register(cookie, { secret: env.SESSION_SECRET }); + await app.register(cookie, { secret: env.SESSION_SECRET }) // Multipart file uploads await app.register(multipart, { limits: { fileSize: env.UPLOAD_MAX_SIZE_BYTES }, - }); + }) // OAuth client - const oauthClient = createOAuthClient(env, cache, app.log); - app.decorate("oauthClient", oauthClient); + const oauthClient = createOAuthClient(env, cache, app.log) + app.decorate('oauthClient', oauthClient) // Session service const sessionService = createSessionService(cache, app.log, { sessionTtl: env.OAUTH_SESSION_TTL, accessTokenTtl: env.OAUTH_ACCESS_TOKEN_TTL, - }); - app.decorate("sessionService", sessionService); + }) + app.decorate('sessionService', sessionService) // Auth middleware (request decoration must happen before hooks can set the property) - app.decorateRequest("user", undefined as RequestUser | undefined); - const authMiddleware = createAuthMiddleware(sessionService, app.log); - app.decorate("authMiddleware", authMiddleware); + app.decorateRequest('user', undefined as RequestUser | undefined) + const authMiddleware = createAuthMiddleware(sessionService, app.log) + app.decorate('authMiddleware', authMiddleware) // Handle resolver (DID -> handle, with cache) - const handleResolver = createHandleResolver(cache, db, app.log); - app.decorate("handleResolver", handleResolver); + const handleResolver = createHandleResolver(cache, db, app.log) + app.decorate('handleResolver', handleResolver) // Profile sync (fetches AT Protocol profile from PDS at login) - const profileSync = createProfileSyncService(oauthClient, db, app.log); - app.decorate("profileSync", profileSync); + const profileSync = createProfileSyncService(oauthClient, db, app.log) + app.decorate('profileSync', profileSync) // PLC DID service + Setup service - const plcDidService = createPlcDidService(app.log); - const setupService = createSetupService(db, app.log, plcDidService); - app.decorate("setupService", setupService); + const plcDidService = createPlcDidService(app.log) + const setupService = createSetupService(db, app.log, plcDidService) + app.decorate('setupService', setupService) // Admin middleware - const requireAdmin = createRequireAdmin(db, authMiddleware, app.log); - app.decorate("requireAdmin", requireAdmin); + const requireAdmin = createRequireAdmin(db, authMiddleware, app.log) + app.decorate('requireAdmin', requireAdmin) // Operator middleware (global mode only) - const requireOperator = createRequireOperator(env, authMiddleware, app.log); - app.decorate("requireOperator", requireOperator); + const requireOperator = createRequireOperator(env, authMiddleware, app.log) + app.decorate('requireOperator', requireOperator) // Local file storage for uploads const uploadBaseUrl = - env.UPLOAD_BASE_URL ?? - env.CORS_ORIGINS.split(",")[0]?.trim() ?? - "http://localhost:3000"; - const storage = createLocalStorage(env.UPLOAD_DIR, uploadBaseUrl, app.log); - app.decorate("storage", storage); + env.UPLOAD_BASE_URL ?? env.CORS_ORIGINS.split(',')[0]?.trim() ?? 'http://localhost:3000' + const storage = createLocalStorage(env.UPLOAD_DIR, uploadBaseUrl, app.log) + app.decorate('storage', storage) + + // Interaction graph service (records reply/reaction/co-participation edges) + const interactionGraphService = createInteractionGraphService(db, app.log) + app.decorate('interactionGraphService', interactionGraphService) + + // Trust graph service (EigenTrust computation + score lookup) + const trustGraphService = createTrustGraphService(db, app.log) + app.decorate('trustGraphService', trustGraphService) // Ozone labeler service (opt-in, only if URL is configured) - let ozoneService: OzoneService | null = null; + let ozoneService: OzoneService | null = null if (env.OZONE_LABELER_URL) { - ozoneService = new OzoneService(db, cache, app.log, env.OZONE_LABELER_URL); + ozoneService = new OzoneService(db, cache, app.log, env.OZONE_LABELER_URL) } - app.decorate("ozoneService", ozoneService); + app.decorate('ozoneService', ozoneService) // OpenAPI documentation (register before routes so schemas are collected) await app.register(swagger, { openapi: { - openapi: "3.1.0", + openapi: '3.1.0', info: { - title: "Barazo Forum API", - description: - "AT Protocol forum AppView -- portable identity, federated communities.", - version: "0.1.0", + title: 'Barazo Forum API', + description: 'AT Protocol forum AppView -- portable identity, federated communities.', + version: '0.1.0', }, servers: [ { - url: env.CORS_ORIGINS.split(",")[0]?.trim() ?? "http://localhost:3000", - description: "Primary server", + url: env.CORS_ORIGINS.split(',')[0]?.trim() ?? 'http://localhost:3000', + description: 'Primary server', }, ], components: { securitySchemes: { bearerAuth: { - type: "http", - scheme: "bearer", - description: "Access token from /api/auth/callback or /api/auth/refresh", + type: 'http', + scheme: 'bearer', + description: 'Access token from /api/auth/callback or /api/auth/refresh', }, }, }, }, - }); + }) await app.register(scalarApiReference, { - routePrefix: "/docs", + routePrefix: '/docs', configuration: { - theme: "kepler", + theme: 'kepler', }, - }); + }) // Routes - await app.register(healthRoutes); - await app.register(oauthMetadataRoutes(oauthClient)); - await app.register(authRoutes(oauthClient)); - await app.register(setupRoutes()); - await app.register(topicRoutes()); - await app.register(replyRoutes()); - await app.register(categoryRoutes()); - await app.register(adminSettingsRoutes()); - await app.register(reactionRoutes()); - await app.register(moderationRoutes()); - await app.register(moderationQueueRoutes()); - await app.register(searchRoutes()); - await app.register(notificationRoutes()); - await app.register(profileRoutes()); - await app.register(blockMuteRoutes()); - await app.register(onboardingRoutes()); - await app.register(globalFilterRoutes()); - await app.register(communityProfileRoutes()); - await app.register(uploadRoutes()); + await app.register(healthRoutes) + await app.register(oauthMetadataRoutes(oauthClient)) + await app.register(authRoutes(oauthClient)) + await app.register(setupRoutes()) + await app.register(topicRoutes()) + await app.register(replyRoutes()) + await app.register(categoryRoutes()) + await app.register(adminSettingsRoutes()) + await app.register(reactionRoutes()) + await app.register(moderationRoutes()) + await app.register(moderationQueueRoutes()) + await app.register(searchRoutes()) + await app.register(notificationRoutes()) + await app.register(profileRoutes()) + await app.register(blockMuteRoutes()) + await app.register(onboardingRoutes()) + await app.register(globalFilterRoutes()) + await app.register(communityProfileRoutes()) + await app.register(uploadRoutes()) + await app.register(adminSybilRoutes()) // OpenAPI spec endpoint (after routes so all schemas are registered) - app.get("/api/openapi.json", { schema: { hide: true } }, async (_request, reply) => { - return reply - .header("Content-Type", "application/json") - .send(app.swagger()); - }); + app.get('/api/openapi.json', { schema: { hide: true } }, async (_request, reply) => { + return reply.header('Content-Type', 'application/json').send(app.swagger()) + }) // Start firehose and optional services when app is ready - app.addHook("onReady", async () => { - await firehose.start(); + app.addHook('onReady', async () => { + await firehose.start() if (ozoneService) { - ozoneService.start(); + ozoneService.start() } - }); + }) // Graceful shutdown: stop services before closing DB - app.addHook("onClose", async () => { - app.log.info("Shutting down..."); + app.addHook('onClose', async () => { + app.log.info('Shutting down...') if (ozoneService) { - ozoneService.stop(); + ozoneService.stop() } - await firehose.stop(); - await cache.quit(); - await dbClient.end(); - app.log.info("Connections closed"); - }); + await firehose.stop() + await cache.quit() + await dbClient.end() + app.log.info('Connections closed') + }) // GlitchTip error handler app.setErrorHandler((error: FastifyError, request, reply) => { if (env.GLITCHTIP_DSN) { - Sentry.captureException(error); + Sentry.captureException(error) } - app.log.error( - { err: error, requestId: request.id }, - "Unhandled error", - ); - const statusCode = error.statusCode ?? 500; + app.log.error({ err: error, requestId: request.id }, 'Unhandled error') + const statusCode = error.statusCode ?? 500 return reply.status(statusCode).send({ - error: "Internal Server Error", + error: 'Internal Server Error', message: - env.LOG_LEVEL === "debug" || env.LOG_LEVEL === "trace" + env.LOG_LEVEL === 'debug' || env.LOG_LEVEL === 'trace' ? error.message - : "An unexpected error occurred", + : 'An unexpected error occurred', statusCode, - }); - }); + }) + }) - return app; + return app } diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts index 1417420..bfe34c4 100644 --- a/src/auth/middleware.ts +++ b/src/auth/middleware.ts @@ -1,6 +1,6 @@ -import type { FastifyReply, FastifyRequest } from "fastify"; -import type { SessionService } from "./session.js"; -import type { Logger } from "../lib/logger.js"; +import type { FastifyReply, FastifyRequest } from 'fastify' +import type { SessionService } from './session.js' +import type { Logger } from '../lib/logger.js' // --------------------------------------------------------------------------- // Types @@ -8,25 +8,25 @@ import type { Logger } from "../lib/logger.js"; /** User info attached to authenticated requests. */ export interface RequestUser { - did: string; - handle: string; - sid: string; + did: string + handle: string + sid: string } /** Auth middleware hooks returned by createAuthMiddleware. */ export interface AuthMiddleware { - requireAuth: (request: FastifyRequest, reply: FastifyReply) => Promise; - optionalAuth: (request: FastifyRequest, reply: FastifyReply) => Promise; + requireAuth: (request: FastifyRequest, reply: FastifyReply) => Promise + optionalAuth: (request: FastifyRequest, reply: FastifyReply) => Promise } // --------------------------------------------------------------------------- // Extend Fastify's request type // --------------------------------------------------------------------------- -declare module "fastify" { +declare module 'fastify' { interface FastifyRequest { /** Authenticated user info (set by requireAuth or optionalAuth middleware). */ - user?: RequestUser; + user?: RequestUser } } @@ -39,17 +39,17 @@ declare module "fastify" { * Returns the token string if valid, or undefined if missing/malformed. */ function extractBearerToken(request: FastifyRequest): string | undefined { - const authHeader = request.headers.authorization; - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return undefined; + const authHeader = request.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return undefined } - const token = authHeader.slice("Bearer ".length); + const token = authHeader.slice('Bearer '.length) if (token.length === 0) { - return undefined; + return undefined } - return token; + return token } // --------------------------------------------------------------------------- @@ -70,37 +70,34 @@ function extractBearerToken(request: FastifyRequest): string | undefined { */ export function createAuthMiddleware( sessionService: SessionService, - logger: Logger, + logger: Logger ): AuthMiddleware { /** * Require authentication. Returns 401 if no valid token, 502 if service error. * On success, sets `request.user` with the authenticated user info. */ - async function requireAuth( - request: FastifyRequest, - reply: FastifyReply, - ): Promise { - const token = extractBearerToken(request); + async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise { + const token = extractBearerToken(request) if (token === undefined) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } try { - const session = await sessionService.validateAccessToken(token); + const session = await sessionService.validateAccessToken(token) if (!session) { - await reply.status(401).send({ error: "Invalid or expired token" }); - return; + await reply.status(401).send({ error: 'Invalid or expired token' }) + return } request.user = { did: session.did, handle: session.handle, sid: session.sid, - }; + } } catch (err: unknown) { - logger.error({ err }, "Token validation failed in requireAuth"); - await reply.status(502).send({ error: "Service temporarily unavailable" }); + logger.error({ err }, 'Token validation failed in requireAuth') + await reply.status(502).send({ error: 'Service temporarily unavailable' }) } } @@ -108,28 +105,25 @@ export function createAuthMiddleware( * Optional authentication. If a valid token is present, sets `request.user`. * If no token, invalid token, or service error: continues with `request.user` undefined. */ - async function optionalAuth( - request: FastifyRequest, - _reply: FastifyReply, - ): Promise { - const token = extractBearerToken(request); + async function optionalAuth(request: FastifyRequest, _reply: FastifyReply): Promise { + const token = extractBearerToken(request) if (token === undefined) { - return; + return } try { - const session = await sessionService.validateAccessToken(token); + const session = await sessionService.validateAccessToken(token) if (session) { request.user = { did: session.did, handle: session.handle, sid: session.sid, - }; + } } } catch (err: unknown) { - logger.warn({ err }, "Token validation failed in optionalAuth, continuing unauthenticated"); + logger.warn({ err }, 'Token validation failed in optionalAuth, continuing unauthenticated') } } - return { requireAuth, optionalAuth }; + return { requireAuth, optionalAuth } } diff --git a/src/auth/oauth-client.ts b/src/auth/oauth-client.ts index f085284..ffd2b41 100644 --- a/src/auth/oauth-client.ts +++ b/src/auth/oauth-client.ts @@ -1,21 +1,21 @@ -import { NodeOAuthClient } from "@atproto/oauth-client-node"; -import type { RuntimeLock } from "@atproto/oauth-client-node"; -import type { Env } from "../config/env.js"; -import type { Cache } from "../cache/index.js"; -import type { Logger } from "../lib/logger.js"; -import { ValkeyStateStore, ValkeySessionStore } from "./oauth-stores.js"; -import { BARAZO_BASE_SCOPES } from "./scopes.js"; +import { NodeOAuthClient } from '@atproto/oauth-client-node' +import type { RuntimeLock } from '@atproto/oauth-client-node' +import type { Env } from '../config/env.js' +import type { Cache } from '../cache/index.js' +import type { Logger } from '../lib/logger.js' +import { ValkeyStateStore, ValkeySessionStore } from './oauth-stores.js' +import { BARAZO_BASE_SCOPES } from './scopes.js' -const LOCK_KEY_PREFIX = "barazo:oauth:lock:"; -const LOCK_TTL_SECONDS = 10; -const LOCK_RETRY_DELAY_MS = 1000; +const LOCK_KEY_PREFIX = 'barazo:oauth:lock:' +const LOCK_TTL_SECONDS = 10 +const LOCK_RETRY_DELAY_MS = 1000 /** * Determine whether the OAuth client should operate in loopback (development) mode. * Loopback mode is detected when OAUTH_CLIENT_ID starts with "http://localhost". */ function isLoopbackMode(clientId: string): boolean { - return clientId.startsWith("http://localhost"); + return clientId.startsWith('http://localhost') } /** @@ -24,7 +24,7 @@ function isLoopbackMode(clientId: string): boolean { * and scope directly in the client_id URL as query parameters. */ function buildLoopbackClientId(redirectUri: string): string { - return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(BARAZO_BASE_SCOPES)}`; + return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(BARAZO_BASE_SCOPES)}` } /** @@ -36,37 +36,37 @@ function buildLoopbackClientId(redirectUri: string): string { */ function createRequestLock(cache: Cache, logger: Logger): RuntimeLock { return async (name: string, fn: () => T | PromiseLike): Promise => { - const lockKey = `${LOCK_KEY_PREFIX}${name}`; + const lockKey = `${LOCK_KEY_PREFIX}${name}` // Attempt to acquire lock: SET key value EX ttl NX (only if not exists) - const acquired = await cache.set(lockKey, "1", "EX", LOCK_TTL_SECONDS, "NX"); + const acquired = await cache.set(lockKey, '1', 'EX', LOCK_TTL_SECONDS, 'NX') if (acquired === null) { // Lock not acquired, wait and retry once - logger.debug({ lockKey }, "Lock not acquired, retrying"); + logger.debug({ lockKey }, 'Lock not acquired, retrying') await new Promise((resolve) => { - setTimeout(resolve, LOCK_RETRY_DELAY_MS); - }); + setTimeout(resolve, LOCK_RETRY_DELAY_MS) + }) - const retryAcquired = await cache.set(lockKey, "1", "EX", LOCK_TTL_SECONDS, "NX"); + const retryAcquired = await cache.set(lockKey, '1', 'EX', LOCK_TTL_SECONDS, 'NX') if (retryAcquired === null) { - logger.warn({ lockKey }, "Could not acquire OAuth lock after retry"); - throw new Error(`Could not acquire OAuth lock: ${name}`); + logger.warn({ lockKey }, 'Could not acquire OAuth lock after retry') + throw new Error(`Could not acquire OAuth lock: ${name}`) } } try { - return await fn(); + return await fn() } finally { // TODO(multi-instance): Use Redlock or check-and-delete Lua script for multi-instance safety. // Current simple DEL does not verify lock ownership; safe for single-instance MVP. // Only needed when SaaS tier runs multiple API instances against shared Valkey. try { - await cache.del(lockKey); + await cache.del(lockKey) } catch (err: unknown) { - logger.error({ err, lockKey }, "Failed to release OAuth lock"); + logger.error({ err, lockKey }, 'Failed to release OAuth lock') } } - }; + } } /** @@ -78,52 +78,42 @@ function createRequestLock(cache: Cache, logger: Logger): RuntimeLock { * - **Production:** client_id points to the publicly served metadata endpoint. * The PDS fetches metadata from that URL. */ -export function createOAuthClient( - env: Env, - cache: Cache, - logger: Logger, -): NodeOAuthClient { - const loopback = isLoopbackMode(env.OAUTH_CLIENT_ID); - const clientId = loopback - ? buildLoopbackClientId(env.OAUTH_REDIRECT_URI) - : env.OAUTH_CLIENT_ID; +export function createOAuthClient(env: Env, cache: Cache, logger: Logger): NodeOAuthClient { + const loopback = isLoopbackMode(env.OAUTH_CLIENT_ID) + const clientId = loopback ? buildLoopbackClientId(env.OAUTH_REDIRECT_URI) : env.OAUTH_CLIENT_ID - logger.info( - { loopback, clientId: loopback ? "(loopback)" : clientId }, - "Creating OAuth client", - ); + logger.info({ loopback, clientId: loopback ? '(loopback)' : clientId }, 'Creating OAuth client') const client = new NodeOAuthClient({ clientMetadata: { - client_name: "Barazo Forum", + client_name: 'Barazo Forum', client_id: clientId, - client_uri: loopback ? "http://localhost" : env.OAUTH_CLIENT_ID.replace(/\/oauth-client-metadata\.json$/, ""), + client_uri: loopback + ? 'http://localhost' + : env.OAUTH_CLIENT_ID.replace(/\/oauth-client-metadata\.json$/, ''), redirect_uris: [env.OAUTH_REDIRECT_URI], scope: BARAZO_BASE_SCOPES, - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - application_type: "web", - token_endpoint_auth_method: "none", + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + application_type: 'web', + token_endpoint_auth_method: 'none', dpop_bound_access_tokens: true, }, stateStore: new ValkeyStateStore(cache, logger), sessionStore: new ValkeySessionStore(cache, logger, env.OAUTH_SESSION_TTL), requestLock: createRequestLock(cache, logger), - }); + }) // Log session lifecycle events for observability - client.addEventListener("updated", (event: CustomEvent) => { - const detail = event.detail as { sub: string }; - logger.info({ sub: detail.sub }, "OAuth session updated"); - }); + client.addEventListener('updated', (event: CustomEvent) => { + const detail = event.detail as { sub: string } + logger.info({ sub: detail.sub }, 'OAuth session updated') + }) - client.addEventListener("deleted", (event: CustomEvent) => { - const detail = event.detail as { sub: string; cause: unknown }; - logger.info( - { sub: detail.sub, cause: String(detail.cause) }, - "OAuth session deleted", - ); - }); + client.addEventListener('deleted', (event: CustomEvent) => { + const detail = event.detail as { sub: string; cause: unknown } + logger.info({ sub: detail.sub, cause: String(detail.cause) }, 'OAuth session deleted') + }) - return client; + return client } diff --git a/src/auth/oauth-stores.ts b/src/auth/oauth-stores.ts index 2f42737..eaf549b 100644 --- a/src/auth/oauth-stores.ts +++ b/src/auth/oauth-stores.ts @@ -1,12 +1,12 @@ -import type { NodeSavedSession, NodeSavedState } from "@atproto/oauth-client-node"; -import type { Cache } from "../cache/index.js"; -import type { Logger } from "../lib/logger.js"; +import type { NodeSavedSession, NodeSavedState } from '@atproto/oauth-client-node' +import type { Cache } from '../cache/index.js' +import type { Logger } from '../lib/logger.js' -const STATE_KEY_PREFIX = "barazo:oauth:state:"; -const SESSION_KEY_PREFIX = "barazo:oauth:session:"; +const STATE_KEY_PREFIX = 'barazo:oauth:state:' +const SESSION_KEY_PREFIX = 'barazo:oauth:session:' /** Default state TTL: 5 minutes (OAuth state is short-lived) */ -const DEFAULT_STATE_TTL = 300; +const DEFAULT_STATE_TTL = 300 /** * Valkey-backed store for OAuth authorization state. @@ -14,48 +14,48 @@ const DEFAULT_STATE_TTL = 300; * authorization code flow between redirect and callback. */ export class ValkeyStateStore { - private readonly cache: Cache; - private readonly logger: Logger; + private readonly cache: Cache + private readonly logger: Logger constructor(cache: Cache, logger: Logger) { - this.cache = cache; - this.logger = logger; + this.cache = cache + this.logger = logger } async set(key: string, state: NodeSavedState): Promise { - const cacheKey = `${STATE_KEY_PREFIX}${key}`; + const cacheKey = `${STATE_KEY_PREFIX}${key}` try { - await this.cache.set(cacheKey, JSON.stringify(state), "EX", DEFAULT_STATE_TTL); - this.logger.debug({ key: cacheKey }, "OAuth state stored"); + await this.cache.set(cacheKey, JSON.stringify(state), 'EX', DEFAULT_STATE_TTL) + this.logger.debug({ key: cacheKey }, 'OAuth state stored') } catch (err: unknown) { - this.logger.error({ err, key: cacheKey }, "Failed to store OAuth state"); - throw err; + this.logger.error({ err, key: cacheKey }, 'Failed to store OAuth state') + throw err } } async get(key: string): Promise { - const cacheKey = `${STATE_KEY_PREFIX}${key}`; + const cacheKey = `${STATE_KEY_PREFIX}${key}` try { - const data = await this.cache.get(cacheKey); + const data = await this.cache.get(cacheKey) if (data === null) { - this.logger.debug({ key: cacheKey }, "OAuth state not found"); - return undefined; + this.logger.debug({ key: cacheKey }, 'OAuth state not found') + return undefined } - return JSON.parse(data) as NodeSavedState; + return JSON.parse(data) as NodeSavedState } catch (err: unknown) { - this.logger.error({ err, key: cacheKey }, "Failed to retrieve OAuth state"); - throw err; + this.logger.error({ err, key: cacheKey }, 'Failed to retrieve OAuth state') + throw err } } async del(key: string): Promise { - const cacheKey = `${STATE_KEY_PREFIX}${key}`; + const cacheKey = `${STATE_KEY_PREFIX}${key}` try { - await this.cache.del(cacheKey); - this.logger.debug({ key: cacheKey }, "OAuth state deleted"); + await this.cache.del(cacheKey) + this.logger.debug({ key: cacheKey }, 'OAuth state deleted') } catch (err: unknown) { - this.logger.error({ err, key: cacheKey }, "Failed to delete OAuth state"); - throw err; + this.logger.error({ err, key: cacheKey }, 'Failed to delete OAuth state') + throw err } } } @@ -66,50 +66,50 @@ export class ValkeyStateStore { * Default TTL is 7 days (604800 seconds), configurable via OAUTH_SESSION_TTL. */ export class ValkeySessionStore { - private readonly cache: Cache; - private readonly logger: Logger; - private readonly ttl: number; + private readonly cache: Cache + private readonly logger: Logger + private readonly ttl: number constructor(cache: Cache, logger: Logger, ttl: number) { - this.cache = cache; - this.logger = logger; - this.ttl = ttl; + this.cache = cache + this.logger = logger + this.ttl = ttl } async set(sub: string, session: NodeSavedSession): Promise { - const cacheKey = `${SESSION_KEY_PREFIX}${sub}`; + const cacheKey = `${SESSION_KEY_PREFIX}${sub}` try { - await this.cache.set(cacheKey, JSON.stringify(session), "EX", this.ttl); - this.logger.debug({ key: cacheKey }, "OAuth session stored"); + await this.cache.set(cacheKey, JSON.stringify(session), 'EX', this.ttl) + this.logger.debug({ key: cacheKey }, 'OAuth session stored') } catch (err: unknown) { - this.logger.error({ err, key: cacheKey }, "Failed to store OAuth session"); - throw err; + this.logger.error({ err, key: cacheKey }, 'Failed to store OAuth session') + throw err } } async get(sub: string): Promise { - const cacheKey = `${SESSION_KEY_PREFIX}${sub}`; + const cacheKey = `${SESSION_KEY_PREFIX}${sub}` try { - const data = await this.cache.get(cacheKey); + const data = await this.cache.get(cacheKey) if (data === null) { - this.logger.debug({ key: cacheKey }, "OAuth session not found"); - return undefined; + this.logger.debug({ key: cacheKey }, 'OAuth session not found') + return undefined } - return JSON.parse(data) as NodeSavedSession; + return JSON.parse(data) as NodeSavedSession } catch (err: unknown) { - this.logger.error({ err, key: cacheKey }, "Failed to retrieve OAuth session"); - throw err; + this.logger.error({ err, key: cacheKey }, 'Failed to retrieve OAuth session') + throw err } } async del(sub: string): Promise { - const cacheKey = `${SESSION_KEY_PREFIX}${sub}`; + const cacheKey = `${SESSION_KEY_PREFIX}${sub}` try { - await this.cache.del(cacheKey); - this.logger.debug({ key: cacheKey }, "OAuth session deleted"); + await this.cache.del(cacheKey) + this.logger.debug({ key: cacheKey }, 'OAuth session deleted') } catch (err: unknown) { - this.logger.error({ err, key: cacheKey }, "Failed to delete OAuth session"); - throw err; + this.logger.error({ err, key: cacheKey }, 'Failed to delete OAuth session') + throw err } } } diff --git a/src/auth/require-admin.ts b/src/auth/require-admin.ts index fbb10d6..b897ac4 100644 --- a/src/auth/require-admin.ts +++ b/src/auth/require-admin.ts @@ -1,9 +1,9 @@ -import type { FastifyReply, FastifyRequest } from "fastify"; -import { eq } from "drizzle-orm"; -import type { AuthMiddleware } from "./middleware.js"; -import type { Database } from "../db/index.js"; -import type { Logger } from "../lib/logger.js"; -import { users } from "../db/schema/users.js"; +import type { FastifyReply, FastifyRequest } from 'fastify' +import { eq } from 'drizzle-orm' +import type { AuthMiddleware } from './middleware.js' +import type { Database } from '../db/index.js' +import type { Logger } from '../lib/logger.js' +import { users } from '../db/schema/users.js' /** * Create a requireAdmin preHandler hook for Fastify routes. @@ -23,46 +23,43 @@ import { users } from "../db/schema/users.js"; export function createRequireAdmin( db: Database, authMiddleware: AuthMiddleware, - logger?: Logger, + logger?: Logger ): (request: FastifyRequest, reply: FastifyReply) => Promise { return async (request: FastifyRequest, reply: FastifyReply): Promise => { // First, run requireAuth to verify authentication - await authMiddleware.requireAuth(request, reply); + await authMiddleware.requireAuth(request, reply) // If requireAuth sent a response (e.g. 401), stop here if (reply.sent) { - return; + return } // At this point request.user should be set by requireAuth if (!request.user) { logger?.warn( { url: request.url, method: request.method }, - "Admin access denied: no user after auth", - ); - await reply.status(403).send({ error: "Admin access required" }); - return; + 'Admin access denied: no user after auth' + ) + await reply.status(403).send({ error: 'Admin access required' }) + return } // Look up user role in database - const rows = await db - .select() - .from(users) - .where(eq(users.did, request.user.did)); + const rows = await db.select().from(users).where(eq(users.did, request.user.did)) - const userRow = rows[0]; - if (!userRow || userRow.role !== "admin") { + const userRow = rows[0] + if (!userRow || userRow.role !== 'admin') { logger?.warn( { did: request.user.did, role: userRow?.role, url: request.url, method: request.method }, - "Admin access denied: insufficient role", - ); - await reply.status(403).send({ error: "Admin access required" }); - return; + 'Admin access denied: insufficient role' + ) + await reply.status(403).send({ error: 'Admin access required' }) + return } logger?.info( { did: request.user.did, url: request.url, method: request.method }, - "Admin access granted", - ); - }; + 'Admin access granted' + ) + } } diff --git a/src/auth/require-moderator.ts b/src/auth/require-moderator.ts index c30f669..7778417 100644 --- a/src/auth/require-moderator.ts +++ b/src/auth/require-moderator.ts @@ -1,9 +1,9 @@ -import type { FastifyReply, FastifyRequest } from "fastify"; -import { eq } from "drizzle-orm"; -import type { AuthMiddleware } from "./middleware.js"; -import type { Database } from "../db/index.js"; -import type { Logger } from "../lib/logger.js"; -import { users } from "../db/schema/users.js"; +import type { FastifyReply, FastifyRequest } from 'fastify' +import { eq } from 'drizzle-orm' +import type { AuthMiddleware } from './middleware.js' +import type { Database } from '../db/index.js' +import type { Logger } from '../lib/logger.js' +import { users } from '../db/schema/users.js' /** * Create a requireModerator preHandler hook for Fastify routes. @@ -23,46 +23,43 @@ import { users } from "../db/schema/users.js"; export function createRequireModerator( db: Database, authMiddleware: AuthMiddleware, - logger?: Logger, + logger?: Logger ): (request: FastifyRequest, reply: FastifyReply) => Promise { return async (request: FastifyRequest, reply: FastifyReply): Promise => { // First, run requireAuth to verify authentication - await authMiddleware.requireAuth(request, reply); + await authMiddleware.requireAuth(request, reply) // If requireAuth sent a response (e.g. 401), stop here if (reply.sent) { - return; + return } // At this point request.user should be set by requireAuth if (!request.user) { logger?.warn( { url: request.url, method: request.method }, - "Moderator access denied: no user after auth", - ); - await reply.status(403).send({ error: "Moderator access required" }); - return; + 'Moderator access denied: no user after auth' + ) + await reply.status(403).send({ error: 'Moderator access required' }) + return } // Look up user role in database - const rows = await db - .select() - .from(users) - .where(eq(users.did, request.user.did)); + const rows = await db.select().from(users).where(eq(users.did, request.user.did)) - const userRow = rows[0]; - if (!userRow || (userRow.role !== "moderator" && userRow.role !== "admin")) { + const userRow = rows[0] + if (!userRow || (userRow.role !== 'moderator' && userRow.role !== 'admin')) { logger?.warn( { did: request.user.did, role: userRow?.role, url: request.url, method: request.method }, - "Moderator access denied: insufficient role", - ); - await reply.status(403).send({ error: "Moderator access required" }); - return; + 'Moderator access denied: insufficient role' + ) + await reply.status(403).send({ error: 'Moderator access required' }) + return } logger?.info( { did: request.user.did, role: userRow.role, url: request.url, method: request.method }, - "Moderator access granted", - ); - }; + 'Moderator access granted' + ) + } } diff --git a/src/auth/require-operator.ts b/src/auth/require-operator.ts index bb51102..f0756bf 100644 --- a/src/auth/require-operator.ts +++ b/src/auth/require-operator.ts @@ -1,7 +1,7 @@ -import type { FastifyReply, FastifyRequest } from "fastify"; -import type { AuthMiddleware } from "./middleware.js"; -import type { Env } from "../config/env.js"; -import type { Logger } from "../lib/logger.js"; +import type { FastifyReply, FastifyRequest } from 'fastify' +import type { AuthMiddleware } from './middleware.js' +import type { Env } from '../config/env.js' +import type { Logger } from '../lib/logger.js' /** * Create a requireOperator preHandler hook for Fastify routes. @@ -18,44 +18,44 @@ import type { Logger } from "../lib/logger.js"; export function createRequireOperator( env: Env, authMiddleware: AuthMiddleware, - logger?: Logger, + logger?: Logger ): (request: FastifyRequest, reply: FastifyReply) => Promise { return async (request: FastifyRequest, reply: FastifyReply): Promise => { // Global-mode-only routes return 404 in single-community mode - if (env.COMMUNITY_MODE !== "global") { - await reply.status(404).send({ error: "Not found" }); - return; + if (env.COMMUNITY_MODE !== 'global') { + await reply.status(404).send({ error: 'Not found' }) + return } // Verify authentication - await authMiddleware.requireAuth(request, reply); + await authMiddleware.requireAuth(request, reply) if (reply.sent) { - return; + return } if (!request.user) { logger?.warn( { url: request.url, method: request.method }, - "Operator access denied: no user after auth", - ); - await reply.status(403).send({ error: "Operator access required" }); - return; + 'Operator access denied: no user after auth' + ) + await reply.status(403).send({ error: 'Operator access required' }) + return } // Check if DID is in the operator list if (!env.OPERATOR_DIDS.includes(request.user.did)) { logger?.warn( { did: request.user.did, url: request.url, method: request.method }, - "Operator access denied: DID not in OPERATOR_DIDS", - ); - await reply.status(403).send({ error: "Operator access required" }); - return; + 'Operator access denied: DID not in OPERATOR_DIDS' + ) + await reply.status(403).send({ error: 'Operator access required' }) + return } logger?.info( { did: request.user.did, url: request.url, method: request.method }, - "Operator access granted", - ); - }; + 'Operator access granted' + ) + } } diff --git a/src/auth/scopes.ts b/src/auth/scopes.ts index 015a663..b5133eb 100644 --- a/src/auth/scopes.ts +++ b/src/auth/scopes.ts @@ -11,18 +11,17 @@ /** Base scopes for core Barazo forum operations (read/write own forum records). */ export const BARAZO_BASE_SCOPES = - "atproto repo:forum.barazo.topic.post repo:forum.barazo.topic.reply repo:forum.barazo.interaction.reaction"; + 'atproto repo:forum.barazo.topic.post repo:forum.barazo.topic.reply repo:forum.barazo.interaction.reaction' /** Additional scopes needed for cross-posting to Bluesky and Frontpage. */ export const CROSSPOST_ADDITIONAL_SCOPES = - "repo:app.bsky.feed.post?action=create repo:fyi.frontpage.post?action=create blob:image/*"; + 'repo:app.bsky.feed.post?action=create repo:fyi.frontpage.post?action=create blob:image/*' /** Combined scopes for base + cross-posting. */ -export const BARAZO_CROSSPOST_SCOPES = - `${BARAZO_BASE_SCOPES} ${CROSSPOST_ADDITIONAL_SCOPES}`; +export const BARAZO_CROSSPOST_SCOPES = `${BARAZO_BASE_SCOPES} ${CROSSPOST_ADDITIONAL_SCOPES}` /** Legacy fallback for PDS implementations that don't support granular scopes. */ -export const FALLBACK_SCOPE = "atproto transition:generic"; +export const FALLBACK_SCOPE = 'atproto transition:generic' /** * Check whether a granted scope string includes cross-post permissions. @@ -31,17 +30,14 @@ export const FALLBACK_SCOPE = "atproto transition:generic"; */ export function hasCrossPostScopes(scope: string): boolean { if (isFallbackScope(scope)) { - return true; + return true } - return ( - scope.includes("repo:app.bsky.feed.post") && - scope.includes("repo:fyi.frontpage.post") - ); + return scope.includes('repo:app.bsky.feed.post') && scope.includes('repo:fyi.frontpage.post') } /** * Check whether a scope string is the legacy `transition:generic` fallback. */ export function isFallbackScope(scope: string): boolean { - return scope.includes("transition:generic"); + return scope.includes('transition:generic') } diff --git a/src/auth/session.ts b/src/auth/session.ts index 9ca5f27..1dc4228 100644 --- a/src/auth/session.ts +++ b/src/auth/session.ts @@ -1,14 +1,14 @@ -import crypto from "node:crypto"; -import type { Cache } from "../cache/index.js"; -import type { Logger } from "../lib/logger.js"; +import crypto from 'node:crypto' +import type { Cache } from '../cache/index.js' +import type { Logger } from '../lib/logger.js' // --------------------------------------------------------------------------- // Key prefixes // --------------------------------------------------------------------------- -const SESSION_DATA_PREFIX = "barazo:session:data:"; -const ACCESS_TOKEN_PREFIX = "barazo:session:access:"; -const DID_INDEX_PREFIX = "barazo:session:did:"; +const SESSION_DATA_PREFIX = 'barazo:session:data:' +const ACCESS_TOKEN_PREFIX = 'barazo:session:access:' +const DID_INDEX_PREFIX = 'barazo:session:did:' // --------------------------------------------------------------------------- // Types @@ -16,9 +16,9 @@ const DID_INDEX_PREFIX = "barazo:session:did:"; export interface SessionConfig { /** Session TTL in seconds (default: 604800 = 7 days) */ - sessionTtl: number; + sessionTtl: number /** Access token TTL in seconds (default: 900 = 15 min) */ - accessTokenTtl: number; + accessTokenTtl: number } /** @@ -27,17 +27,17 @@ export interface SessionConfig { */ export interface Session { /** Unique session identifier (used as refresh token) */ - sid: string; + sid: string /** User's AT Protocol DID */ - did: string; + did: string /** User's AT Protocol handle */ - handle: string; + handle: string /** SHA-256 hash of the access token (raw token is never persisted) */ - accessTokenHash: string; + accessTokenHash: string /** When the access token expires (epoch ms) */ - accessTokenExpiresAt: number; + accessTokenExpiresAt: number /** When the session was created (epoch ms) */ - createdAt: number; + createdAt: number } /** @@ -46,7 +46,7 @@ export interface Session { */ export interface SessionWithToken extends Session { /** Raw access token (returned to caller for HTTP response, never persisted) */ - accessToken: string; + accessToken: string } export interface SessionService { @@ -55,31 +55,31 @@ export interface SessionService { * Generates session ID and access token, stores both in Valkey. * Returns SessionWithToken (includes raw access token for HTTP response). */ - createSession(did: string, handle: string): Promise; + createSession(did: string, handle: string): Promise /** * Validate an access token. Returns the session if valid, undefined if invalid/expired. * Looks up by access token hash, then fetches full session data. */ - validateAccessToken(accessToken: string): Promise; + validateAccessToken(accessToken: string): Promise /** * Refresh a session: generate new access token, keep same session ID. * The refresh token (session ID) comes from the HTTP-only cookie. * Returns SessionWithToken with new access token, or undefined if session expired. */ - refreshSession(sid: string): Promise; + refreshSession(sid: string): Promise /** * Delete a session (logout). Removes both the session data and the access token lookup. */ - deleteSession(sid: string): Promise; + deleteSession(sid: string): Promise /** * Delete ALL sessions for a given DID (used on account deletion). * Uses the DID-to-sessions index to find all sessions. */ - deleteAllSessionsForDid(did: string): Promise; + deleteAllSessionsForDid(did: string): Promise } // --------------------------------------------------------------------------- @@ -88,17 +88,17 @@ export interface SessionService { /** Generate a cryptographically random 32-byte hex string (64 chars). */ function generateToken(): string { - return crypto.randomBytes(32).toString("hex"); + return crypto.randomBytes(32).toString('hex') } /** SHA-256 hash a value and return the hex digest. */ function sha256(value: string): string { - return crypto.createHash("sha256").update(value).digest("hex"); + return crypto.createHash('sha256').update(value).digest('hex') } /** Truncate a hash to 8 characters for safe logging. */ function truncateForLog(value: string): string { - return value.slice(0, 8); + return value.slice(0, 8) } // --------------------------------------------------------------------------- @@ -108,15 +108,15 @@ function truncateForLog(value: string): string { export function createSessionService( cache: Cache, logger: Logger, - config: SessionConfig, + config: SessionConfig ): SessionService { - const { sessionTtl, accessTokenTtl } = config; + const { sessionTtl, accessTokenTtl } = config async function createSession(did: string, handle: string): Promise { - const sid = generateToken(); - const accessToken = generateToken(); - const tokenHash = sha256(accessToken); - const now = Date.now(); + const sid = generateToken() + const accessToken = generateToken() + const tokenHash = sha256(accessToken) + const now = Date.now() // Persisted session stores only the hash (never the raw token) const session: Session = { @@ -126,207 +126,151 @@ export function createSessionService( accessTokenHash: tokenHash, accessTokenExpiresAt: now + accessTokenTtl * 1000, createdAt: now, - }; + } try { // Store session data (TTL = session lifetime) - await cache.set( - `${SESSION_DATA_PREFIX}${sid}`, - JSON.stringify(session), - "EX", - sessionTtl, - ); + await cache.set(`${SESSION_DATA_PREFIX}${sid}`, JSON.stringify(session), 'EX', sessionTtl) // Store access token hash → session ID mapping (TTL = access token lifetime) - await cache.set( - `${ACCESS_TOKEN_PREFIX}${tokenHash}`, - sid, - "EX", - accessTokenTtl, - ); + await cache.set(`${ACCESS_TOKEN_PREFIX}${tokenHash}`, sid, 'EX', accessTokenTtl) // Add session ID to DID index set and refresh its TTL - await cache.sadd(`${DID_INDEX_PREFIX}${did}`, sid); - await cache.expire(`${DID_INDEX_PREFIX}${did}`, sessionTtl); + await cache.sadd(`${DID_INDEX_PREFIX}${did}`, sid) + await cache.expire(`${DID_INDEX_PREFIX}${did}`, sessionTtl) - logger.debug( - { did, sid: truncateForLog(sid) }, - "Session created", - ); + logger.debug({ did, sid: truncateForLog(sid) }, 'Session created') // Return with raw token for the HTTP response (never persisted) - return { ...session, accessToken }; + return { ...session, accessToken } } catch (err: unknown) { - logger.error( - { err, did, sid: truncateForLog(sid) }, - "Failed to create session", - ); - throw err; + logger.error({ err, did, sid: truncateForLog(sid) }, 'Failed to create session') + throw err } } async function validateAccessToken(accessToken: string): Promise { - const tokenHash = sha256(accessToken); + const tokenHash = sha256(accessToken) try { // Look up session ID by access token hash - const sid = await cache.get(`${ACCESS_TOKEN_PREFIX}${tokenHash}`); + const sid = await cache.get(`${ACCESS_TOKEN_PREFIX}${tokenHash}`) if (sid === null) { - logger.debug( - { tokenHash: truncateForLog(tokenHash) }, - "Access token not found", - ); - return undefined; + logger.debug({ tokenHash: truncateForLog(tokenHash) }, 'Access token not found') + return undefined } // Fetch full session data - const data = await cache.get(`${SESSION_DATA_PREFIX}${sid}`); + const data = await cache.get(`${SESSION_DATA_PREFIX}${sid}`) if (data === null) { logger.debug( { sid: truncateForLog(sid), tokenHash: truncateForLog(tokenHash) }, - "Session data not found (orphaned token)", - ); - return undefined; + 'Session data not found (orphaned token)' + ) + return undefined } // Safe cast: we control all writes to this key via createSession/refreshSession - return JSON.parse(data) as Session; + return JSON.parse(data) as Session } catch (err: unknown) { - logger.error( - { err, tokenHash: truncateForLog(tokenHash) }, - "Failed to validate access token", - ); - throw err; + logger.error({ err, tokenHash: truncateForLog(tokenHash) }, 'Failed to validate access token') + throw err } } async function refreshSession(sid: string): Promise { try { // Fetch existing session - const data = await cache.get(`${SESSION_DATA_PREFIX}${sid}`); + const data = await cache.get(`${SESSION_DATA_PREFIX}${sid}`) if (data === null) { - logger.debug( - { sid: truncateForLog(sid) }, - "Session not found for refresh", - ); - return undefined; + logger.debug({ sid: truncateForLog(sid) }, 'Session not found for refresh') + return undefined } // Safe cast: we control all writes to this key via createSession/refreshSession - const existing = JSON.parse(data) as Session; + const existing = JSON.parse(data) as Session // Delete old access token lookup (session stores only the hash) - await cache.del(`${ACCESS_TOKEN_PREFIX}${existing.accessTokenHash}`); + await cache.del(`${ACCESS_TOKEN_PREFIX}${existing.accessTokenHash}`) // Generate new access token - const newAccessToken = generateToken(); - const newTokenHash = sha256(newAccessToken); - const now = Date.now(); + const newAccessToken = generateToken() + const newTokenHash = sha256(newAccessToken) + const now = Date.now() const updated: Session = { ...existing, accessTokenHash: newTokenHash, accessTokenExpiresAt: now + accessTokenTtl * 1000, - }; + } // Store new access token hash → session ID mapping - await cache.set( - `${ACCESS_TOKEN_PREFIX}${newTokenHash}`, - sid, - "EX", - accessTokenTtl, - ); + await cache.set(`${ACCESS_TOKEN_PREFIX}${newTokenHash}`, sid, 'EX', accessTokenTtl) // Update session data (sliding window: resets TTL on refresh) - await cache.set( - `${SESSION_DATA_PREFIX}${sid}`, - JSON.stringify(updated), - "EX", - sessionTtl, - ); - - logger.debug( - { sid: truncateForLog(sid) }, - "Session refreshed", - ); + await cache.set(`${SESSION_DATA_PREFIX}${sid}`, JSON.stringify(updated), 'EX', sessionTtl) + + logger.debug({ sid: truncateForLog(sid) }, 'Session refreshed') // Return with raw token for the HTTP response (never persisted) - return { ...updated, accessToken: newAccessToken }; + return { ...updated, accessToken: newAccessToken } } catch (err: unknown) { - logger.error( - { err, sid: truncateForLog(sid) }, - "Failed to refresh session", - ); - throw err; + logger.error({ err, sid: truncateForLog(sid) }, 'Failed to refresh session') + throw err } } async function deleteSession(sid: string): Promise { try { // Fetch session to get access token hash and DID for cleanup - const data = await cache.get(`${SESSION_DATA_PREFIX}${sid}`); + const data = await cache.get(`${SESSION_DATA_PREFIX}${sid}`) if (data === null) { - logger.debug( - { sid: truncateForLog(sid) }, - "Session not found for deletion", - ); - return; + logger.debug({ sid: truncateForLog(sid) }, 'Session not found for deletion') + return } // Safe cast: we control all writes to this key via createSession/refreshSession - const session = JSON.parse(data) as Session; + const session = JSON.parse(data) as Session // Delete access token lookup (session stores only the hash, no re-hashing needed) - await cache.del(`${ACCESS_TOKEN_PREFIX}${session.accessTokenHash}`); + await cache.del(`${ACCESS_TOKEN_PREFIX}${session.accessTokenHash}`) // Delete session data - await cache.del(`${SESSION_DATA_PREFIX}${sid}`); + await cache.del(`${SESSION_DATA_PREFIX}${sid}`) // Remove session ID from DID index - await cache.srem(`${DID_INDEX_PREFIX}${session.did}`, sid); + await cache.srem(`${DID_INDEX_PREFIX}${session.did}`, sid) - logger.debug( - { sid: truncateForLog(sid) }, - "Session deleted", - ); + logger.debug({ sid: truncateForLog(sid) }, 'Session deleted') } catch (err: unknown) { - logger.error( - { err, sid: truncateForLog(sid) }, - "Failed to delete session", - ); - throw err; + logger.error({ err, sid: truncateForLog(sid) }, 'Failed to delete session') + throw err } } async function deleteAllSessionsForDid(did: string): Promise { try { // Get all session IDs for this DID - const sids = await cache.smembers(`${DID_INDEX_PREFIX}${did}`); + const sids = await cache.smembers(`${DID_INDEX_PREFIX}${did}`) if (sids.length === 0) { - logger.debug({ did, count: 0 }, "All sessions deleted for DID"); - return 0; + logger.debug({ did, count: 0 }, 'All sessions deleted for DID') + return 0 } // Delete each session individually (cleans up access token lookups too) // TODO(phase-3): Pipeline deletes for performance when moving to multi-instance for (const sid of sids) { - await deleteSession(sid); + await deleteSession(sid) } // Delete the DID index set itself - await cache.del(`${DID_INDEX_PREFIX}${did}`); + await cache.del(`${DID_INDEX_PREFIX}${did}`) - logger.debug( - { did, count: sids.length }, - "All sessions deleted for DID", - ); + logger.debug({ did, count: sids.length }, 'All sessions deleted for DID') - return sids.length; + return sids.length } catch (err: unknown) { - logger.error( - { err, did }, - "Failed to delete all sessions for DID", - ); - throw err; + logger.error({ err, did }, 'Failed to delete all sessions for DID') + throw err } } @@ -336,5 +280,5 @@ export function createSessionService( refreshSession, deleteSession, deleteAllSessionsForDid, - }; + } } diff --git a/src/cache/index.ts b/src/cache/index.ts index 7bc46b6..4076b1d 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -1,25 +1,25 @@ -import { Redis } from "ioredis"; -import type { FastifyBaseLogger } from "fastify"; +import { Redis } from 'ioredis' +import type { FastifyBaseLogger } from 'fastify' export function createCache(valkeyUrl: string, logger: FastifyBaseLogger) { const cache = new Redis(valkeyUrl, { maxRetriesPerRequest: 3, retryStrategy(times: number) { - const delay = Math.min(times * 200, 2000); - return delay; + const delay = Math.min(times * 200, 2000) + return delay }, lazyConnect: true, - }); + }) - cache.on("error", (err: Error) => { - logger.error({ err }, "Valkey connection error"); - }); + cache.on('error', (err: Error) => { + logger.error({ err }, 'Valkey connection error') + }) - cache.on("connect", () => { - logger.info("Connected to Valkey"); - }); + cache.on('connect', () => { + logger.info('Connected to Valkey') + }) - return cache; + return cache } -export type Cache = ReturnType; +export type Cache = ReturnType diff --git a/src/config/env.ts b/src/config/env.ts index b0543b8..e507892 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,24 +1,24 @@ -import { z } from "zod/v4"; +import { z } from 'zod/v4' const portSchema = z .string() - .default("3000") + .default('3000') .transform((val) => Number(val)) - .pipe(z.number().int().min(1).max(65535)); + .pipe(z.number().int().min(1).max(65535)) const intFromString = (defaultVal: string) => z .string() .default(defaultVal) .transform((val) => Number(val)) - .pipe(z.number().int().min(0)); + .pipe(z.number().int().min(0)) const positiveIntFromString = (defaultVal: string) => z .string() .default(defaultVal) .transform((val) => Number(val)) - .pipe(z.number().int().positive()); + .pipe(z.number().int().positive()) export const envSchema = z.object({ // Required @@ -28,32 +28,30 @@ export const envSchema = z.object({ TAP_ADMIN_PASSWORD: z.string().min(1), // Server - HOST: z.string().default("0.0.0.0"), + HOST: z.string().default('0.0.0.0'), PORT: portSchema, - LOG_LEVEL: z - .enum(["fatal", "error", "warn", "info", "debug", "trace"]) - .default("info"), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), // CORS - CORS_ORIGINS: z.string().default("http://localhost:3001"), + CORS_ORIGINS: z.string().default('http://localhost:3001'), // Community - COMMUNITY_MODE: z.enum(["single", "global"]).default("single"), + COMMUNITY_MODE: z.enum(['single', 'global']).default('single'), COMMUNITY_DID: z.string().optional(), - COMMUNITY_NAME: z.string().default("Barazo Community"), + COMMUNITY_NAME: z.string().default('Barazo Community'), // Rate Limiting (requests per minute) - RATE_LIMIT_AUTH: intFromString("10"), - RATE_LIMIT_WRITE: intFromString("10"), - RATE_LIMIT_READ_ANON: intFromString("100"), - RATE_LIMIT_READ_AUTH: intFromString("300"), + RATE_LIMIT_AUTH: intFromString('10'), + RATE_LIMIT_WRITE: intFromString('10'), + RATE_LIMIT_READ_ANON: intFromString('100'), + RATE_LIMIT_READ_AUTH: intFromString('300'), // OAuth OAUTH_CLIENT_ID: z.string().min(1), OAUTH_REDIRECT_URI: z.string().min(1), SESSION_SECRET: z.string().min(32), - OAUTH_SESSION_TTL: positiveIntFromString("604800"), - OAUTH_ACCESS_TOKEN_TTL: positiveIntFromString("900"), + OAUTH_SESSION_TTL: positiveIntFromString('604800'), + OAUTH_ACCESS_TOKEN_TTL: positiveIntFromString('900'), // Monitoring (GlitchTip - Sentry SDK compatible) GLITCHTIP_DSN: z.string().optional(), @@ -62,48 +60,48 @@ export const envSchema = z.object({ EMBEDDING_URL: z.string().optional(), AI_EMBEDDING_DIMENSIONS: z .string() - .default("768") + .default('768') .transform((val) => Number(val)) .pipe(z.number().int().min(384).max(1536)), // Cross-posting FEATURE_CROSSPOST_BLUESKY: z - .enum(["true", "false"]) - .default("true") - .transform((v) => v === "true"), + .enum(['true', 'false']) + .default('true') + .transform((v) => v === 'true'), FEATURE_CROSSPOST_FRONTPAGE: z - .enum(["true", "false"]) - .default("false") - .transform((v) => v === "true"), - PUBLIC_URL: z.string().default("http://localhost:3001"), + .enum(['true', 'false']) + .default('false') + .transform((v) => v === 'true'), + PUBLIC_URL: z.string().default('http://localhost:3001'), // Global mode: operator DIDs (comma-separated) OPERATOR_DIDS: z .string() - .default("") + .default('') .transform((v) => v - .split(",") + .split(',') .map((s) => s.trim()) - .filter((s) => s.length > 0), + .filter((s) => s.length > 0) ), // Uploads - UPLOAD_DIR: z.string().default("./uploads"), + UPLOAD_DIR: z.string().default('./uploads'), UPLOAD_MAX_SIZE_BYTES: z.coerce.number().default(5_242_880), // 5MB UPLOAD_BASE_URL: z.string().optional(), // Ozone labeler (opt-in) - OZONE_LABELER_URL: z.string().default("https://mod.bsky.app"), -}); + OZONE_LABELER_URL: z.string().default('https://mod.bsky.app'), +}) -export type Env = z.infer; +export type Env = z.infer export function parseEnv(env: Record): Env { - const result = envSchema.safeParse(env); + const result = envSchema.safeParse(env) if (!result.success) { - const formatted = z.prettifyError(result.error); - throw new Error(`Invalid environment configuration:\n${formatted}`); + const formatted = z.prettifyError(result.error) + throw new Error(`Invalid environment configuration:\n${formatted}`) } - return result.data; + return result.data } diff --git a/src/db/index.ts b/src/db/index.ts index e9cad50..243ad00 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,17 +1,17 @@ -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; -import * as schema from "./schema/index.js"; +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import * as schema from './schema/index.js' export function createDb(databaseUrl: string) { const client = postgres(databaseUrl, { max: 20, idle_timeout: 30, connect_timeout: 5, - }); + }) - const db = drizzle(client, { schema }); + const db = drizzle(client, { schema }) - return { db, client }; + return { db, client } } -export type Database = ReturnType["db"]; +export type Database = ReturnType['db'] diff --git a/src/db/schema/account-filters.ts b/src/db/schema/account-filters.ts index 9e58f60..45c828e 100644 --- a/src/db/schema/account-filters.ts +++ b/src/db/schema/account-filters.ts @@ -1,44 +1,29 @@ -import { - pgTable, - text, - timestamp, - index, - serial, - integer, - uniqueIndex, -} from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, index, serial, integer, uniqueIndex } from 'drizzle-orm/pg-core' export const accountFilters = pgTable( - "account_filters", + 'account_filters', { - id: serial("id").primaryKey(), - did: text("did").notNull(), - communityDid: text("community_did").notNull(), - status: text("status", { - enum: ["active", "warned", "filtered"], + id: serial('id').primaryKey(), + did: text('did').notNull(), + communityDid: text('community_did').notNull(), + status: text('status', { + enum: ['active', 'warned', 'filtered'], }) .notNull() - .default("active"), - reason: text("reason"), - reportCount: integer("report_count").notNull().default(0), - banCount: integer("ban_count").notNull().default(0), - lastReviewedAt: timestamp("last_reviewed_at", { withTimezone: true }), - filteredBy: text("filtered_by"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), + .default('active'), + reason: text('reason'), + reportCount: integer('report_count').notNull().default(0), + banCount: integer('ban_count').notNull().default(0), + lastReviewedAt: timestamp('last_reviewed_at', { withTimezone: true }), + filteredBy: text('filtered_by'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ - uniqueIndex("account_filters_did_community_idx").on( - table.did, - table.communityDid, - ), - index("account_filters_did_idx").on(table.did), - index("account_filters_community_did_idx").on(table.communityDid), - index("account_filters_status_idx").on(table.status), - index("account_filters_updated_at_idx").on(table.updatedAt), - ], -); + uniqueIndex('account_filters_did_community_idx').on(table.did, table.communityDid), + index('account_filters_did_idx').on(table.did), + index('account_filters_community_did_idx').on(table.communityDid), + index('account_filters_status_idx').on(table.status), + index('account_filters_updated_at_idx').on(table.updatedAt), + ] +) diff --git a/src/db/schema/account-trust.ts b/src/db/schema/account-trust.ts index ffba380..011ffb3 100644 --- a/src/db/schema/account-trust.ts +++ b/src/db/schema/account-trust.ts @@ -7,23 +7,20 @@ import { timestamp, index, uniqueIndex, -} from "drizzle-orm/pg-core"; +} from 'drizzle-orm/pg-core' export const accountTrust = pgTable( - "account_trust", + 'account_trust', { - id: serial("id").primaryKey(), - did: text("did").notNull(), - communityDid: text("community_did").notNull(), - approvedPostCount: integer("approved_post_count").notNull().default(0), - isTrusted: boolean("is_trusted").notNull().default(false), - trustedAt: timestamp("trusted_at", { withTimezone: true }), + id: serial('id').primaryKey(), + did: text('did').notNull(), + communityDid: text('community_did').notNull(), + approvedPostCount: integer('approved_post_count').notNull().default(0), + isTrusted: boolean('is_trusted').notNull().default(false), + trustedAt: timestamp('trusted_at', { withTimezone: true }), }, (table) => [ - uniqueIndex("account_trust_did_community_idx").on( - table.did, - table.communityDid, - ), - index("account_trust_did_idx").on(table.did), - ], -); + uniqueIndex('account_trust_did_community_idx').on(table.did, table.communityDid), + index('account_trust_did_idx').on(table.did), + ] +) diff --git a/src/db/schema/behavioral-flags.ts b/src/db/schema/behavioral-flags.ts new file mode 100644 index 0000000..f5110b4 --- /dev/null +++ b/src/db/schema/behavioral-flags.ts @@ -0,0 +1,25 @@ +import { pgTable, serial, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core' + +export const behavioralFlags = pgTable( + 'behavioral_flags', + { + id: serial('id').primaryKey(), + flagType: text('flag_type', { + enum: ['burst_voting', 'content_similarity', 'low_diversity'], + }).notNull(), + affectedDids: jsonb('affected_dids').$type().notNull(), + details: text('details').notNull(), + communityDid: text('community_did'), + status: text('status', { + enum: ['pending', 'dismissed', 'action_taken'], + }) + .notNull() + .default('pending'), + detectedAt: timestamp('detected_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index('behavioral_flags_flag_type_idx').on(table.flagType), + index('behavioral_flags_status_idx').on(table.status), + index('behavioral_flags_detected_at_idx').on(table.detectedAt), + ] +) diff --git a/src/db/schema/categories.ts b/src/db/schema/categories.ts index 0359aa8..ad1e231 100644 --- a/src/db/schema/categories.ts +++ b/src/db/schema/categories.ts @@ -6,42 +6,35 @@ import { index, uniqueIndex, foreignKey, -} from "drizzle-orm/pg-core"; +} from 'drizzle-orm/pg-core' export const categories = pgTable( - "categories", + 'categories', { - id: text("id").primaryKey(), - slug: text("slug").notNull(), - name: text("name").notNull(), - description: text("description"), - parentId: text("parent_id"), - sortOrder: integer("sort_order").notNull().default(0), - communityDid: text("community_did").notNull(), - maturityRating: text("maturity_rating", { - enum: ["safe", "mature", "adult"], + id: text('id').primaryKey(), + slug: text('slug').notNull(), + name: text('name').notNull(), + description: text('description'), + parentId: text('parent_id'), + sortOrder: integer('sort_order').notNull().default(0), + communityDid: text('community_did').notNull(), + maturityRating: text('maturity_rating', { + enum: ['safe', 'mature', 'adult'], }) .notNull() - .default("safe"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), + .default('safe'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ - uniqueIndex("categories_slug_community_did_idx").on( - table.slug, - table.communityDid, - ), - index("categories_parent_id_idx").on(table.parentId), - index("categories_community_did_idx").on(table.communityDid), - index("categories_maturity_rating_idx").on(table.maturityRating), + uniqueIndex('categories_slug_community_did_idx').on(table.slug, table.communityDid), + index('categories_parent_id_idx').on(table.parentId), + index('categories_community_did_idx').on(table.communityDid), + index('categories_maturity_rating_idx').on(table.maturityRating), foreignKey({ columns: [table.parentId], foreignColumns: [table.id], - name: "categories_parent_id_fk", - }).onDelete("set null"), - ], -); + name: 'categories_parent_id_fk', + }).onDelete('set null'), + ] +) diff --git a/src/db/schema/community-filters.ts b/src/db/schema/community-filters.ts index 7b2571a..67af9f4 100644 --- a/src/db/schema/community-filters.ts +++ b/src/db/schema/community-filters.ts @@ -1,35 +1,25 @@ -import { - pgTable, - text, - timestamp, - index, - integer, -} from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, index, integer } from 'drizzle-orm/pg-core' export const communityFilters = pgTable( - "community_filters", + 'community_filters', { - communityDid: text("community_did").primaryKey(), - status: text("status", { - enum: ["active", "warned", "filtered"], + communityDid: text('community_did').primaryKey(), + status: text('status', { + enum: ['active', 'warned', 'filtered'], }) .notNull() - .default("active"), - adminDid: text("admin_did"), - reason: text("reason"), - reportCount: integer("report_count").notNull().default(0), - lastReviewedAt: timestamp("last_reviewed_at", { withTimezone: true }), - filteredBy: text("filtered_by"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), + .default('active'), + adminDid: text('admin_did'), + reason: text('reason'), + reportCount: integer('report_count').notNull().default(0), + lastReviewedAt: timestamp('last_reviewed_at', { withTimezone: true }), + filteredBy: text('filtered_by'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ - index("community_filters_status_idx").on(table.status), - index("community_filters_admin_did_idx").on(table.adminDid), - index("community_filters_updated_at_idx").on(table.updatedAt), - ], -); + index('community_filters_status_idx').on(table.status), + index('community_filters_admin_did_idx').on(table.adminDid), + index('community_filters_updated_at_idx').on(table.updatedAt), + ] +) diff --git a/src/db/schema/community-profiles.ts b/src/db/schema/community-profiles.ts index cd601ea..b31787a 100644 --- a/src/db/schema/community-profiles.ts +++ b/src/db/schema/community-profiles.ts @@ -1,10 +1,4 @@ -import { - pgTable, - text, - timestamp, - index, - primaryKey, -} from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, index, primaryKey } from 'drizzle-orm/pg-core' /** * Per-community profile overrides. @@ -12,21 +6,19 @@ import { * Keyed by (did, community_did). Deleted when user leaves or is purged. */ export const communityProfiles = pgTable( - "community_profiles", + 'community_profiles', { - did: text("did").notNull(), - communityDid: text("community_did").notNull(), - displayName: text("display_name"), - avatarUrl: text("avatar_url"), - bannerUrl: text("banner_url"), - bio: text("bio"), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), + did: text('did').notNull(), + communityDid: text('community_did').notNull(), + displayName: text('display_name'), + avatarUrl: text('avatar_url'), + bannerUrl: text('banner_url'), + bio: text('bio'), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ primaryKey({ columns: [table.did, table.communityDid] }), - index("community_profiles_did_idx").on(table.did), - index("community_profiles_community_idx").on(table.communityDid), - ], -); + index('community_profiles_did_idx').on(table.did), + index('community_profiles_community_idx').on(table.communityDid), + ] +) diff --git a/src/db/schema/community-settings.ts b/src/db/schema/community-settings.ts index efa105b..d5289e3 100644 --- a/src/db/schema/community-settings.ts +++ b/src/db/schema/community-settings.ts @@ -1,33 +1,30 @@ -import { pgTable, text, boolean, timestamp, jsonb, integer } from "drizzle-orm/pg-core"; +import { pgTable, text, boolean, timestamp, jsonb, integer } from 'drizzle-orm/pg-core' -export const communitySettings = pgTable("community_settings", { - id: text("id").primaryKey().default("default"), - initialized: boolean("initialized").notNull().default(false), - communityDid: text("community_did"), - adminDid: text("admin_did"), - communityName: text("community_name").notNull().default("Barazo Community"), - maturityRating: text("maturity_rating", { - enum: ["safe", "mature", "adult"], +export const communitySettings = pgTable('community_settings', { + id: text('id').primaryKey().default('default'), + initialized: boolean('initialized').notNull().default(false), + communityDid: text('community_did'), + adminDid: text('admin_did'), + communityName: text('community_name').notNull().default('Barazo Community'), + maturityRating: text('maturity_rating', { + enum: ['safe', 'mature', 'adult'], }) .notNull() - .default("safe"), - reactionSet: jsonb("reaction_set") - .$type() - .notNull() - .default(["like"]), - moderationThresholds: jsonb("moderation_thresholds") + .default('safe'), + reactionSet: jsonb('reaction_set').$type().notNull().default(['like']), + moderationThresholds: jsonb('moderation_thresholds') .$type<{ - autoBlockReportCount: number; - warnThreshold: number; - firstPostQueueCount: number; - newAccountDays: number; - newAccountWriteRatePerMin: number; - establishedWriteRatePerMin: number; - linkHoldEnabled: boolean; - topicCreationDelayEnabled: boolean; - burstPostCount: number; - burstWindowMinutes: number; - trustedPostThreshold: number; + autoBlockReportCount: number + warnThreshold: number + firstPostQueueCount: number + newAccountDays: number + newAccountWriteRatePerMin: number + establishedWriteRatePerMin: number + linkHoldEnabled: boolean + topicCreationDelayEnabled: boolean + burstPostCount: number + burstWindowMinutes: number + trustedPostThreshold: number }>() .notNull() .default({ @@ -43,25 +40,18 @@ export const communitySettings = pgTable("community_settings", { burstWindowMinutes: 10, trustedPostThreshold: 10, }), - wordFilter: jsonb("word_filter") - .$type() - .notNull() - .default([]), - jurisdictionCountry: text("jurisdiction_country"), - ageThreshold: integer("age_threshold").notNull().default(16), - requireLoginForMature: boolean("require_login_for_mature").notNull().default(true), - communityDescription: text("community_description"), - handle: text("handle"), - serviceEndpoint: text("service_endpoint"), - signingKey: text("signing_key"), - rotationKey: text("rotation_key"), - communityLogoUrl: text("community_logo_url"), - primaryColor: text("primary_color"), - accentColor: text("accent_color"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); + wordFilter: jsonb('word_filter').$type().notNull().default([]), + jurisdictionCountry: text('jurisdiction_country'), + ageThreshold: integer('age_threshold').notNull().default(16), + requireLoginForMature: boolean('require_login_for_mature').notNull().default(true), + communityDescription: text('community_description'), + handle: text('handle'), + serviceEndpoint: text('service_endpoint'), + signingKey: text('signing_key'), + rotationKey: text('rotation_key'), + communityLogoUrl: text('community_logo_url'), + primaryColor: text('primary_color'), + accentColor: text('accent_color'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) diff --git a/src/db/schema/cross-posts.ts b/src/db/schema/cross-posts.ts index 8980f13..5f00b23 100644 --- a/src/db/schema/cross-posts.ts +++ b/src/db/schema/cross-posts.ts @@ -1,22 +1,20 @@ -import { pgTable, text, timestamp, index } from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, index } from 'drizzle-orm/pg-core' export const crossPosts = pgTable( - "cross_posts", + 'cross_posts', { - id: text("id") + id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), - topicUri: text("topic_uri").notNull(), - service: text("service", { enum: ["bluesky", "frontpage"] }).notNull(), - crossPostUri: text("cross_post_uri").notNull(), - crossPostCid: text("cross_post_cid").notNull(), - authorDid: text("author_did").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), + topicUri: text('topic_uri').notNull(), + service: text('service', { enum: ['bluesky', 'frontpage'] }).notNull(), + crossPostUri: text('cross_post_uri').notNull(), + crossPostCid: text('cross_post_cid').notNull(), + authorDid: text('author_did').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ - index("cross_posts_topic_uri_idx").on(table.topicUri), - index("cross_posts_author_did_idx").on(table.authorDid), - ], -); + index('cross_posts_topic_uri_idx').on(table.topicUri), + index('cross_posts_author_did_idx').on(table.authorDid), + ] +) diff --git a/src/db/schema/firehose.ts b/src/db/schema/firehose.ts index 762de4c..b5eb457 100644 --- a/src/db/schema/firehose.ts +++ b/src/db/schema/firehose.ts @@ -1,9 +1,7 @@ -import { pgTable, text, bigint, timestamp } from "drizzle-orm/pg-core"; +import { pgTable, text, bigint, timestamp } from 'drizzle-orm/pg-core' -export const firehoseCursor = pgTable("firehose_cursor", { - id: text("id").primaryKey().default("default"), - cursor: bigint("cursor", { mode: "bigint" }), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); +export const firehoseCursor = pgTable('firehose_cursor', { + id: text('id').primaryKey().default('default'), + cursor: bigint('cursor', { mode: 'bigint' }), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 93adf47..ae02a66 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1,20 +1,27 @@ -export { users } from "./users.js"; -export { firehoseCursor } from "./firehose.js"; -export { topics } from "./topics.js"; -export { replies } from "./replies.js"; -export { reactions } from "./reactions.js"; -export { trackedRepos } from "./tracked-repos.js"; -export { communitySettings } from "./community-settings.js"; -export { categories } from "./categories.js"; -export { moderationActions } from "./moderation-actions.js"; -export { reports } from "./reports.js"; -export { notifications } from "./notifications.js"; -export { userPreferences, userCommunityPreferences } from "./user-preferences.js"; -export { crossPosts } from "./cross-posts.js"; -export { communityOnboardingFields, userOnboardingResponses } from "./onboarding-fields.js"; -export { moderationQueue } from "./moderation-queue.js"; -export { accountTrust } from "./account-trust.js"; -export { communityFilters } from "./community-filters.js"; -export { accountFilters } from "./account-filters.js"; -export { ozoneLabels } from "./ozone-labels.js"; -export { communityProfiles } from "./community-profiles.js"; +export { users } from './users.js' +export { firehoseCursor } from './firehose.js' +export { topics } from './topics.js' +export { replies } from './replies.js' +export { reactions } from './reactions.js' +export { trackedRepos } from './tracked-repos.js' +export { communitySettings } from './community-settings.js' +export { categories } from './categories.js' +export { moderationActions } from './moderation-actions.js' +export { reports } from './reports.js' +export { notifications } from './notifications.js' +export { userPreferences, userCommunityPreferences } from './user-preferences.js' +export { crossPosts } from './cross-posts.js' +export { communityOnboardingFields, userOnboardingResponses } from './onboarding-fields.js' +export { moderationQueue } from './moderation-queue.js' +export { accountTrust } from './account-trust.js' +export { communityFilters } from './community-filters.js' +export { accountFilters } from './account-filters.js' +export { ozoneLabels } from './ozone-labels.js' +export { communityProfiles } from './community-profiles.js' +export { interactionGraph } from './interaction-graph.js' +export { trustSeeds } from './trust-seeds.js' +export { trustScores } from './trust-scores.js' +export { sybilClusters } from './sybil-clusters.js' +export { sybilClusterMembers } from './sybil-cluster-members.js' +export { behavioralFlags } from './behavioral-flags.js' +export { pdsTrustFactors } from './pds-trust-factors.js' diff --git a/src/db/schema/interaction-graph.ts b/src/db/schema/interaction-graph.ts new file mode 100644 index 0000000..ae9a99b --- /dev/null +++ b/src/db/schema/interaction-graph.ts @@ -0,0 +1,34 @@ +import { pgTable, text, integer, timestamp, index, primaryKey } from 'drizzle-orm/pg-core' + +export const interactionGraph = pgTable( + 'interaction_graph', + { + sourceDid: text('source_did').notNull(), + targetDid: text('target_did').notNull(), + communityId: text('community_id').notNull(), + interactionType: text('interaction_type', { + enum: ['reply', 'reaction', 'topic_coparticipation'], + }).notNull(), + weight: integer('weight').notNull().default(1), + firstInteractionAt: timestamp('first_interaction_at', { + withTimezone: true, + }) + .notNull() + .defaultNow(), + lastInteractionAt: timestamp('last_interaction_at', { + withTimezone: true, + }) + .notNull() + .defaultNow(), + }, + (table) => [ + primaryKey({ + columns: [table.sourceDid, table.targetDid, table.communityId, table.interactionType], + }), + index('interaction_graph_source_target_community_idx').on( + table.sourceDid, + table.targetDid, + table.communityId + ), + ] +) diff --git a/src/db/schema/moderation-actions.ts b/src/db/schema/moderation-actions.ts index cdc1b52..c626214 100644 --- a/src/db/schema/moderation-actions.ts +++ b/src/db/schema/moderation-actions.ts @@ -1,32 +1,24 @@ -import { - pgTable, - text, - timestamp, - index, - serial, -} from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, index, serial } from 'drizzle-orm/pg-core' export const moderationActions = pgTable( - "moderation_actions", + 'moderation_actions', { - id: serial("id").primaryKey(), - action: text("action", { - enum: ["lock", "unlock", "pin", "unpin", "delete", "ban", "unban"], + id: serial('id').primaryKey(), + action: text('action', { + enum: ['lock', 'unlock', 'pin', 'unpin', 'delete', 'ban', 'unban'], }).notNull(), - targetUri: text("target_uri"), - targetDid: text("target_did"), - moderatorDid: text("moderator_did").notNull(), - communityDid: text("community_did").notNull(), - reason: text("reason"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), + targetUri: text('target_uri'), + targetDid: text('target_did'), + moderatorDid: text('moderator_did').notNull(), + communityDid: text('community_did').notNull(), + reason: text('reason'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ - index("mod_actions_moderator_did_idx").on(table.moderatorDid), - index("mod_actions_community_did_idx").on(table.communityDid), - index("mod_actions_created_at_idx").on(table.createdAt), - index("mod_actions_target_uri_idx").on(table.targetUri), - index("mod_actions_target_did_idx").on(table.targetDid), - ], -); + index('mod_actions_moderator_did_idx').on(table.moderatorDid), + index('mod_actions_community_did_idx').on(table.communityDid), + index('mod_actions_created_at_idx').on(table.createdAt), + index('mod_actions_target_uri_idx').on(table.targetUri), + index('mod_actions_target_did_idx').on(table.targetDid), + ] +) diff --git a/src/db/schema/moderation-queue.ts b/src/db/schema/moderation-queue.ts index cc66ea2..9d2f925 100644 --- a/src/db/schema/moderation-queue.ts +++ b/src/db/schema/moderation-queue.ts @@ -1,42 +1,33 @@ -import { - pgTable, - serial, - text, - jsonb, - timestamp, - index, -} from "drizzle-orm/pg-core"; +import { pgTable, serial, text, jsonb, timestamp, index } from 'drizzle-orm/pg-core' export const moderationQueue = pgTable( - "moderation_queue", + 'moderation_queue', { - id: serial("id").primaryKey(), - contentUri: text("content_uri").notNull(), - contentType: text("content_type", { - enum: ["topic", "reply"], + id: serial('id').primaryKey(), + contentUri: text('content_uri').notNull(), + contentType: text('content_type', { + enum: ['topic', 'reply'], }).notNull(), - authorDid: text("author_did").notNull(), - communityDid: text("community_did").notNull(), - queueReason: text("queue_reason", { - enum: ["word_filter", "first_post", "link_hold", "burst", "topic_delay"], + authorDid: text('author_did').notNull(), + communityDid: text('community_did').notNull(), + queueReason: text('queue_reason', { + enum: ['word_filter', 'first_post', 'link_hold', 'burst', 'topic_delay'], }).notNull(), - matchedWords: jsonb("matched_words").$type(), - status: text("status", { - enum: ["pending", "approved", "rejected"], + matchedWords: jsonb('matched_words').$type(), + status: text('status', { + enum: ['pending', 'approved', 'rejected'], }) .notNull() - .default("pending"), - reviewedBy: text("reviewed_by"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - reviewedAt: timestamp("reviewed_at", { withTimezone: true }), + .default('pending'), + reviewedBy: text('reviewed_by'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + reviewedAt: timestamp('reviewed_at', { withTimezone: true }), }, (table) => [ - index("mod_queue_author_did_idx").on(table.authorDid), - index("mod_queue_community_did_idx").on(table.communityDid), - index("mod_queue_status_idx").on(table.status), - index("mod_queue_created_at_idx").on(table.createdAt), - index("mod_queue_content_uri_idx").on(table.contentUri), - ], -); + index('mod_queue_author_did_idx').on(table.authorDid), + index('mod_queue_community_did_idx').on(table.communityDid), + index('mod_queue_status_idx').on(table.status), + index('mod_queue_created_at_idx').on(table.createdAt), + index('mod_queue_content_uri_idx').on(table.contentUri), + ] +) diff --git a/src/db/schema/notifications.ts b/src/db/schema/notifications.ts index 494ccb5..f3ce3f9 100644 --- a/src/db/schema/notifications.ts +++ b/src/db/schema/notifications.ts @@ -1,34 +1,30 @@ -import { - pgTable, - text, - boolean, - timestamp, - index, - serial, -} from "drizzle-orm/pg-core"; +import { pgTable, text, boolean, timestamp, index, serial } from 'drizzle-orm/pg-core' export const notifications = pgTable( - "notifications", + 'notifications', { - id: serial("id").primaryKey(), - recipientDid: text("recipient_did").notNull(), - type: text("type", { - enum: ["reply", "reaction", "mention", "mod_action", "global_report", "cross_post_failed", "cross_post_revoked"], + id: serial('id').primaryKey(), + recipientDid: text('recipient_did').notNull(), + type: text('type', { + enum: [ + 'reply', + 'reaction', + 'mention', + 'mod_action', + 'global_report', + 'cross_post_failed', + 'cross_post_revoked', + ], }).notNull(), - subjectUri: text("subject_uri").notNull(), - actorDid: text("actor_did").notNull(), - communityDid: text("community_did").notNull(), - read: boolean("read").notNull().default(false), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), + subjectUri: text('subject_uri').notNull(), + actorDid: text('actor_did').notNull(), + communityDid: text('community_did').notNull(), + read: boolean('read').notNull().default(false), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ - index("notifications_recipient_did_idx").on(table.recipientDid), - index("notifications_recipient_read_idx").on( - table.recipientDid, - table.read, - ), - index("notifications_created_at_idx").on(table.createdAt), - ], -); + index('notifications_recipient_did_idx').on(table.recipientDid), + index('notifications_recipient_read_idx').on(table.recipientDid, table.read), + index('notifications_created_at_idx').on(table.createdAt), + ] +) diff --git a/src/db/schema/onboarding-fields.ts b/src/db/schema/onboarding-fields.ts index 2816b9d..c863ab9 100644 --- a/src/db/schema/onboarding-fields.ts +++ b/src/db/schema/onboarding-fields.ts @@ -7,56 +7,47 @@ import { jsonb, primaryKey, index, -} from "drizzle-orm/pg-core"; +} from 'drizzle-orm/pg-core' export const communityOnboardingFields = pgTable( - "community_onboarding_fields", + 'community_onboarding_fields', { - id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), - communityDid: text("community_did").notNull(), - fieldType: text("field_type", { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + communityDid: text('community_did').notNull(), + fieldType: text('field_type', { enum: [ - "age_confirmation", - "tos_acceptance", - "newsletter_email", - "custom_text", - "custom_select", - "custom_checkbox", + 'age_confirmation', + 'tos_acceptance', + 'newsletter_email', + 'custom_text', + 'custom_select', + 'custom_checkbox', ], }).notNull(), - label: text("label").notNull(), - description: text("description"), - isMandatory: boolean("is_mandatory").notNull().default(true), - sortOrder: integer("sort_order").notNull().default(0), - config: jsonb("config").$type>(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), + label: text('label').notNull(), + description: text('description'), + isMandatory: boolean('is_mandatory').notNull().default(true), + sortOrder: integer('sort_order').notNull().default(0), + config: jsonb('config').$type>(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, - (table) => [ - index("onboarding_fields_community_idx").on(table.communityDid), - ], -); + (table) => [index('onboarding_fields_community_idx').on(table.communityDid)] +) export const userOnboardingResponses = pgTable( - "user_onboarding_responses", + 'user_onboarding_responses', { - did: text("did").notNull(), - communityDid: text("community_did").notNull(), - fieldId: text("field_id").notNull(), - response: jsonb("response").$type().notNull(), - completedAt: timestamp("completed_at", { withTimezone: true }) - .notNull() - .defaultNow(), + did: text('did').notNull(), + communityDid: text('community_did').notNull(), + fieldId: text('field_id').notNull(), + response: jsonb('response').$type().notNull(), + completedAt: timestamp('completed_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ primaryKey({ columns: [table.did, table.communityDid, table.fieldId] }), - index("onboarding_responses_did_community_idx").on( - table.did, - table.communityDid, - ), - ], -); + index('onboarding_responses_did_community_idx').on(table.did, table.communityDid), + ] +) diff --git a/src/db/schema/ozone-labels.ts b/src/db/schema/ozone-labels.ts index d9a04a2..3aa2946 100644 --- a/src/db/schema/ozone-labels.ts +++ b/src/db/schema/ozone-labels.ts @@ -1,35 +1,21 @@ -import { - pgTable, - text, - boolean, - timestamp, - index, - serial, - uniqueIndex, -} from "drizzle-orm/pg-core"; +import { pgTable, text, boolean, timestamp, index, serial, uniqueIndex } from 'drizzle-orm/pg-core' export const ozoneLabels = pgTable( - "ozone_labels", + 'ozone_labels', { - id: serial("id").primaryKey(), - src: text("src").notNull(), - uri: text("uri").notNull(), - val: text("val").notNull(), - neg: boolean("neg").notNull().default(false), - cts: timestamp("cts", { withTimezone: true }).notNull(), - exp: timestamp("exp", { withTimezone: true }), - indexedAt: timestamp("indexed_at", { withTimezone: true }) - .notNull() - .defaultNow(), + id: serial('id').primaryKey(), + src: text('src').notNull(), + uri: text('uri').notNull(), + val: text('val').notNull(), + neg: boolean('neg').notNull().default(false), + cts: timestamp('cts', { withTimezone: true }).notNull(), + exp: timestamp('exp', { withTimezone: true }), + indexedAt: timestamp('indexed_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ - uniqueIndex("ozone_labels_src_uri_val_idx").on( - table.src, - table.uri, - table.val, - ), - index("ozone_labels_uri_idx").on(table.uri), - index("ozone_labels_val_idx").on(table.val), - index("ozone_labels_indexed_at_idx").on(table.indexedAt), - ], -); + uniqueIndex('ozone_labels_src_uri_val_idx').on(table.src, table.uri, table.val), + index('ozone_labels_uri_idx').on(table.uri), + index('ozone_labels_val_idx').on(table.val), + index('ozone_labels_indexed_at_idx').on(table.indexedAt), + ] +) diff --git a/src/db/schema/pds-trust-factors.ts b/src/db/schema/pds-trust-factors.ts new file mode 100644 index 0000000..34a46e0 --- /dev/null +++ b/src/db/schema/pds-trust-factors.ts @@ -0,0 +1,13 @@ +import { pgTable, serial, text, real, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core' + +export const pdsTrustFactors = pgTable( + 'pds_trust_factors', + { + id: serial('id').primaryKey(), + pdsHost: text('pds_host').notNull(), + trustFactor: real('trust_factor').notNull(), + isDefault: boolean('is_default').notNull().default(false), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [uniqueIndex('pds_trust_factors_pds_host_idx').on(table.pdsHost)] +) diff --git a/src/db/schema/reactions.ts b/src/db/schema/reactions.ts index 1210fc9..afcc287 100644 --- a/src/db/schema/reactions.ts +++ b/src/db/schema/reactions.ts @@ -1,37 +1,25 @@ -import { - pgTable, - text, - timestamp, - index, - unique, -} from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, index, unique } from 'drizzle-orm/pg-core' export const reactions = pgTable( - "reactions", + 'reactions', { - uri: text("uri").primaryKey(), - rkey: text("rkey").notNull(), - authorDid: text("author_did").notNull(), - subjectUri: text("subject_uri").notNull(), - subjectCid: text("subject_cid").notNull(), - type: text("type").notNull(), - communityDid: text("community_did").notNull(), - cid: text("cid").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - indexedAt: timestamp("indexed_at", { withTimezone: true }) - .notNull() - .defaultNow(), + uri: text('uri').primaryKey(), + rkey: text('rkey').notNull(), + authorDid: text('author_did').notNull(), + subjectUri: text('subject_uri').notNull(), + subjectCid: text('subject_cid').notNull(), + type: text('type').notNull(), + communityDid: text('community_did').notNull(), + cid: text('cid').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), + indexedAt: timestamp('indexed_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ - index("reactions_author_did_idx").on(table.authorDid), - index("reactions_subject_uri_idx").on(table.subjectUri), - index("reactions_community_did_idx").on(table.communityDid), + index('reactions_author_did_idx').on(table.authorDid), + index('reactions_subject_uri_idx').on(table.subjectUri), + index('reactions_community_did_idx').on(table.communityDid), // communityDid intentionally excluded: AT URIs are globally unique, so a // reaction to a given subject is inherently community-scoped via the subject URI. - unique("reactions_author_subject_type_uniq").on( - table.authorDid, - table.subjectUri, - table.type, - ), - ], -); + unique('reactions_author_subject_type_uniq').on(table.authorDid, table.subjectUri, table.type), + ] +) diff --git a/src/db/schema/replies.ts b/src/db/schema/replies.ts index f8096ad..ccb27c4 100644 --- a/src/db/schema/replies.ts +++ b/src/db/schema/replies.ts @@ -1,55 +1,46 @@ -import { - pgTable, - text, - integer, - timestamp, - jsonb, - index, -} from "drizzle-orm/pg-core"; +import { pgTable, text, integer, timestamp, jsonb, index } from 'drizzle-orm/pg-core' export const replies = pgTable( - "replies", + 'replies', { - uri: text("uri").primaryKey(), - rkey: text("rkey").notNull(), - authorDid: text("author_did").notNull(), - content: text("content").notNull(), - contentFormat: text("content_format"), - rootUri: text("root_uri").notNull(), - rootCid: text("root_cid").notNull(), - parentUri: text("parent_uri").notNull(), - parentCid: text("parent_cid").notNull(), - communityDid: text("community_did").notNull(), - cid: text("cid").notNull(), - labels: jsonb("labels").$type<{ values: { val: string }[] }>(), - reactionCount: integer("reaction_count").notNull().default(0), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - indexedAt: timestamp("indexed_at", { withTimezone: true }) - .notNull() - .defaultNow(), - moderationStatus: text("moderation_status", { - enum: ["approved", "held", "rejected"], + uri: text('uri').primaryKey(), + rkey: text('rkey').notNull(), + authorDid: text('author_did').notNull(), + content: text('content').notNull(), + contentFormat: text('content_format'), + rootUri: text('root_uri').notNull(), + rootCid: text('root_cid').notNull(), + parentUri: text('parent_uri').notNull(), + parentCid: text('parent_cid').notNull(), + communityDid: text('community_did').notNull(), + cid: text('cid').notNull(), + labels: jsonb('labels').$type<{ values: { val: string }[] }>(), + reactionCount: integer('reaction_count').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), + indexedAt: timestamp('indexed_at', { withTimezone: true }).notNull().defaultNow(), + moderationStatus: text('moderation_status', { + enum: ['approved', 'held', 'rejected'], }) .notNull() - .default("approved"), + .default('approved'), /** Trust status based on account age at indexing time. 'new' for accounts < 24h old. */ - trustStatus: text("trust_status", { - enum: ["trusted", "new"], + trustStatus: text('trust_status', { + enum: ['trusted', 'new'], }) .notNull() - .default("trusted"), + .default('trusted'), // Note: search_vector (tsvector) and embedding (vector) columns exist in the // database but are managed outside Drizzle schema (see migration 0010). // search_vector is maintained by a database trigger. // embedding is nullable vector(768) for optional semantic search. }, (table) => [ - index("replies_author_did_idx").on(table.authorDid), - index("replies_root_uri_idx").on(table.rootUri), - index("replies_parent_uri_idx").on(table.parentUri), - index("replies_created_at_idx").on(table.createdAt), - index("replies_community_did_idx").on(table.communityDid), - index("replies_moderation_status_idx").on(table.moderationStatus), - index("replies_trust_status_idx").on(table.trustStatus), - ], -); + index('replies_author_did_idx').on(table.authorDid), + index('replies_root_uri_idx').on(table.rootUri), + index('replies_parent_uri_idx').on(table.parentUri), + index('replies_created_at_idx').on(table.createdAt), + index('replies_community_did_idx').on(table.communityDid), + index('replies_moderation_status_idx').on(table.moderationStatus), + index('replies_trust_status_idx').on(table.trustStatus), + ] +) diff --git a/src/db/schema/reports.ts b/src/db/schema/reports.ts index 7949634..ba40b4e 100644 --- a/src/db/schema/reports.ts +++ b/src/db/schema/reports.ts @@ -1,56 +1,47 @@ -import { - pgTable, - text, - timestamp, - index, - serial, - uniqueIndex, -} from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, index, serial, uniqueIndex } from 'drizzle-orm/pg-core' export const reports = pgTable( - "reports", + 'reports', { - id: serial("id").primaryKey(), - reporterDid: text("reporter_did").notNull(), - targetUri: text("target_uri").notNull(), - targetDid: text("target_did").notNull(), - reasonType: text("reason_type", { - enum: ["spam", "sexual", "harassment", "violation", "misleading", "other"], + id: serial('id').primaryKey(), + reporterDid: text('reporter_did').notNull(), + targetUri: text('target_uri').notNull(), + targetDid: text('target_did').notNull(), + reasonType: text('reason_type', { + enum: ['spam', 'sexual', 'harassment', 'violation', 'misleading', 'other'], }).notNull(), - description: text("description"), - communityDid: text("community_did").notNull(), - status: text("status", { - enum: ["pending", "resolved"], + description: text('description'), + communityDid: text('community_did').notNull(), + status: text('status', { + enum: ['pending', 'resolved'], }) .notNull() - .default("pending"), - resolutionType: text("resolution_type", { - enum: ["dismissed", "warned", "labeled", "removed", "banned"], + .default('pending'), + resolutionType: text('resolution_type', { + enum: ['dismissed', 'warned', 'labeled', 'removed', 'banned'], }), - resolvedBy: text("resolved_by"), - resolvedAt: timestamp("resolved_at", { withTimezone: true }), - appealReason: text("appeal_reason"), - appealedAt: timestamp("appealed_at", { withTimezone: true }), - appealStatus: text("appeal_status", { - enum: ["none", "pending", "rejected"], + resolvedBy: text('resolved_by'), + resolvedAt: timestamp('resolved_at', { withTimezone: true }), + appealReason: text('appeal_reason'), + appealedAt: timestamp('appealed_at', { withTimezone: true }), + appealStatus: text('appeal_status', { + enum: ['none', 'pending', 'rejected'], }) .notNull() - .default("none"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), + .default('none'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ - index("reports_reporter_did_idx").on(table.reporterDid), - index("reports_target_uri_idx").on(table.targetUri), - index("reports_target_did_idx").on(table.targetDid), - index("reports_community_did_idx").on(table.communityDid), - index("reports_status_idx").on(table.status), - index("reports_created_at_idx").on(table.createdAt), - uniqueIndex("reports_unique_reporter_target_idx").on( + index('reports_reporter_did_idx').on(table.reporterDid), + index('reports_target_uri_idx').on(table.targetUri), + index('reports_target_did_idx').on(table.targetDid), + index('reports_community_did_idx').on(table.communityDid), + index('reports_status_idx').on(table.status), + index('reports_created_at_idx').on(table.createdAt), + uniqueIndex('reports_unique_reporter_target_idx').on( table.reporterDid, table.targetUri, - table.communityDid, + table.communityDid ), - ], -); + ] +) diff --git a/src/db/schema/sybil-cluster-members.ts b/src/db/schema/sybil-cluster-members.ts new file mode 100644 index 0000000..38e8fac --- /dev/null +++ b/src/db/schema/sybil-cluster-members.ts @@ -0,0 +1,17 @@ +import { pgTable, text, integer, timestamp, primaryKey } from 'drizzle-orm/pg-core' +import { sybilClusters } from './sybil-clusters.js' + +export const sybilClusterMembers = pgTable( + 'sybil_cluster_members', + { + clusterId: integer('cluster_id') + .notNull() + .references(() => sybilClusters.id), + did: text('did').notNull(), + roleInCluster: text('role_in_cluster', { + enum: ['core', 'peripheral'], + }).notNull(), + joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [primaryKey({ columns: [table.clusterId, table.did] })] +) diff --git a/src/db/schema/sybil-clusters.ts b/src/db/schema/sybil-clusters.ts new file mode 100644 index 0000000..d75652c --- /dev/null +++ b/src/db/schema/sybil-clusters.ts @@ -0,0 +1,22 @@ +import { pgTable, serial, text, integer, timestamp, uniqueIndex } from 'drizzle-orm/pg-core' + +export const sybilClusters = pgTable( + 'sybil_clusters', + { + id: serial('id').primaryKey(), + clusterHash: text('cluster_hash').notNull(), + internalEdgeCount: integer('internal_edge_count').notNull(), + externalEdgeCount: integer('external_edge_count').notNull(), + memberCount: integer('member_count').notNull(), + status: text('status', { + enum: ['flagged', 'dismissed', 'monitoring', 'banned'], + }) + .notNull() + .default('flagged'), + reviewedBy: text('reviewed_by'), + reviewedAt: timestamp('reviewed_at', { withTimezone: true }), + detectedAt: timestamp('detected_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [uniqueIndex('sybil_clusters_hash_idx').on(table.clusterHash)] +) diff --git a/src/db/schema/topics.ts b/src/db/schema/topics.ts index 62a4018..2a425fb 100644 --- a/src/db/schema/topics.ts +++ b/src/db/schema/topics.ts @@ -1,62 +1,50 @@ -import { - pgTable, - text, - integer, - timestamp, - jsonb, - boolean, - index, -} from "drizzle-orm/pg-core"; +import { pgTable, text, integer, timestamp, jsonb, boolean, index } from 'drizzle-orm/pg-core' export const topics = pgTable( - "topics", + 'topics', { - uri: text("uri").primaryKey(), - rkey: text("rkey").notNull(), - authorDid: text("author_did").notNull(), - title: text("title").notNull(), - content: text("content").notNull(), - contentFormat: text("content_format"), - category: text("category").notNull(), - tags: jsonb("tags").$type(), - communityDid: text("community_did").notNull(), - cid: text("cid").notNull(), - labels: jsonb("labels").$type<{ values: { val: string }[] }>(), - replyCount: integer("reply_count").notNull().default(0), - reactionCount: integer("reaction_count").notNull().default(0), - lastActivityAt: timestamp("last_activity_at", { withTimezone: true }) - .notNull() - .defaultNow(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - indexedAt: timestamp("indexed_at", { withTimezone: true }) - .notNull() - .defaultNow(), - isLocked: boolean("is_locked").notNull().default(false), - isPinned: boolean("is_pinned").notNull().default(false), - isModDeleted: boolean("is_mod_deleted").notNull().default(false), - moderationStatus: text("moderation_status", { - enum: ["approved", "held", "rejected"], + uri: text('uri').primaryKey(), + rkey: text('rkey').notNull(), + authorDid: text('author_did').notNull(), + title: text('title').notNull(), + content: text('content').notNull(), + contentFormat: text('content_format'), + category: text('category').notNull(), + tags: jsonb('tags').$type(), + communityDid: text('community_did').notNull(), + cid: text('cid').notNull(), + labels: jsonb('labels').$type<{ values: { val: string }[] }>(), + replyCount: integer('reply_count').notNull().default(0), + reactionCount: integer('reaction_count').notNull().default(0), + lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).notNull().defaultNow(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), + indexedAt: timestamp('indexed_at', { withTimezone: true }).notNull().defaultNow(), + isLocked: boolean('is_locked').notNull().default(false), + isPinned: boolean('is_pinned').notNull().default(false), + isModDeleted: boolean('is_mod_deleted').notNull().default(false), + moderationStatus: text('moderation_status', { + enum: ['approved', 'held', 'rejected'], }) .notNull() - .default("approved"), + .default('approved'), /** Trust status based on account age at indexing time. 'new' for accounts < 24h old. */ - trustStatus: text("trust_status", { - enum: ["trusted", "new"], + trustStatus: text('trust_status', { + enum: ['trusted', 'new'], }) .notNull() - .default("trusted"), + .default('trusted'), // Note: search_vector (tsvector) and embedding (vector) columns exist in the // database but are managed outside Drizzle schema (see migration 0010). // search_vector is maintained by a database trigger. // embedding is nullable vector(768) for optional semantic search. }, (table) => [ - index("topics_author_did_idx").on(table.authorDid), - index("topics_category_idx").on(table.category), - index("topics_created_at_idx").on(table.createdAt), - index("topics_last_activity_at_idx").on(table.lastActivityAt), - index("topics_community_did_idx").on(table.communityDid), - index("topics_moderation_status_idx").on(table.moderationStatus), - index("topics_trust_status_idx").on(table.trustStatus), - ], -); + index('topics_author_did_idx').on(table.authorDid), + index('topics_category_idx').on(table.category), + index('topics_created_at_idx').on(table.createdAt), + index('topics_last_activity_at_idx').on(table.lastActivityAt), + index('topics_community_did_idx').on(table.communityDid), + index('topics_moderation_status_idx').on(table.moderationStatus), + index('topics_trust_status_idx').on(table.trustStatus), + ] +) diff --git a/src/db/schema/tracked-repos.ts b/src/db/schema/tracked-repos.ts index 6a5f2c3..c14f6ca 100644 --- a/src/db/schema/tracked-repos.ts +++ b/src/db/schema/tracked-repos.ts @@ -1,8 +1,6 @@ -import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp } from 'drizzle-orm/pg-core' -export const trackedRepos = pgTable("tracked_repos", { - did: text("did").primaryKey(), - trackedAt: timestamp("tracked_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); +export const trackedRepos = pgTable('tracked_repos', { + did: text('did').primaryKey(), + trackedAt: timestamp('tracked_at', { withTimezone: true }).notNull().defaultNow(), +}) diff --git a/src/db/schema/trust-scores.ts b/src/db/schema/trust-scores.ts new file mode 100644 index 0000000..b08e422 --- /dev/null +++ b/src/db/schema/trust-scores.ts @@ -0,0 +1,19 @@ +import { pgTable, text, real, timestamp, primaryKey, index } from 'drizzle-orm/pg-core' + +/** + * Trust scores table. `communityId` uses empty string "" as sentinel for + * "global" scope instead of NULL, so the composite PK works correctly. + */ +export const trustScores = pgTable( + 'trust_scores', + { + did: text('did').notNull(), + communityId: text('community_id').notNull().default(''), + score: real('score').notNull(), + computedAt: timestamp('computed_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.did, table.communityId] }), + index('trust_scores_did_community_idx').on(table.did, table.communityId), + ] +) diff --git a/src/db/schema/trust-seeds.ts b/src/db/schema/trust-seeds.ts new file mode 100644 index 0000000..149425b --- /dev/null +++ b/src/db/schema/trust-seeds.ts @@ -0,0 +1,18 @@ +import { pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core' + +/** + * Trust seeds table. `communityId` uses empty string "" as sentinel for + * "global" scope instead of NULL, so the unique index works correctly. + */ +export const trustSeeds = pgTable( + 'trust_seeds', + { + id: serial('id').primaryKey(), + did: text('did').notNull(), + communityId: text('community_id').notNull().default(''), + addedBy: text('added_by').notNull(), + reason: text('reason'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [uniqueIndex('trust_seeds_did_community_idx').on(table.did, table.communityId)] +) diff --git a/src/db/schema/user-preferences.ts b/src/db/schema/user-preferences.ts index 81dd530..993cf44 100644 --- a/src/db/schema/user-preferences.ts +++ b/src/db/schema/user-preferences.ts @@ -7,59 +7,55 @@ import { boolean, index, primaryKey, -} from "drizzle-orm/pg-core"; +} from 'drizzle-orm/pg-core' // --------------------------------------------------------------------------- // Global user preferences (stored in PostgreSQL for MVP, will sync to PDS later) // --------------------------------------------------------------------------- -export const userPreferences = pgTable("user_preferences", { - did: text("did").primaryKey(), - maturityLevel: text("maturity_level", { - enum: ["sfw", "mature"], +export const userPreferences = pgTable('user_preferences', { + did: text('did').primaryKey(), + maturityLevel: text('maturity_level', { + enum: ['sfw', 'mature'], }) .notNull() - .default("sfw"), - declaredAge: integer("declared_age"), - mutedWords: jsonb("muted_words").$type().notNull().default([]), - blockedDids: jsonb("blocked_dids").$type().notNull().default([]), - mutedDids: jsonb("muted_dids").$type().notNull().default([]), - crossPostBluesky: boolean("cross_post_bluesky").notNull().default(false), - crossPostFrontpage: boolean("cross_post_frontpage").notNull().default(false), - crossPostScopesGranted: boolean("cross_post_scopes_granted").notNull().default(false), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); + .default('sfw'), + declaredAge: integer('declared_age'), + mutedWords: jsonb('muted_words').$type().notNull().default([]), + blockedDids: jsonb('blocked_dids').$type().notNull().default([]), + mutedDids: jsonb('muted_dids').$type().notNull().default([]), + crossPostBluesky: boolean('cross_post_bluesky').notNull().default(false), + crossPostFrontpage: boolean('cross_post_frontpage').notNull().default(false), + crossPostScopesGranted: boolean('cross_post_scopes_granted').notNull().default(false), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) // --------------------------------------------------------------------------- // Per-community preference overrides // --------------------------------------------------------------------------- export const userCommunityPreferences = pgTable( - "user_community_preferences", + 'user_community_preferences', { - did: text("did").notNull(), - communityDid: text("community_did").notNull(), - maturityOverride: text("maturity_override", { - enum: ["sfw", "mature"], + did: text('did').notNull(), + communityDid: text('community_did').notNull(), + maturityOverride: text('maturity_override', { + enum: ['sfw', 'mature'], }), - mutedWords: jsonb("muted_words").$type(), - blockedDids: jsonb("blocked_dids").$type(), - mutedDids: jsonb("muted_dids").$type(), - notificationPrefs: jsonb("notification_prefs").$type<{ - replies: boolean; - reactions: boolean; - mentions: boolean; - modActions: boolean; + mutedWords: jsonb('muted_words').$type(), + blockedDids: jsonb('blocked_dids').$type(), + mutedDids: jsonb('muted_dids').$type(), + notificationPrefs: jsonb('notification_prefs').$type<{ + replies: boolean + reactions: boolean + mentions: boolean + modActions: boolean }>(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ primaryKey({ columns: [table.did, table.communityDid] }), - index("user_community_prefs_did_idx").on(table.did), - index("user_community_prefs_community_idx").on(table.communityDid), - ], -); + index('user_community_prefs_did_idx').on(table.did), + index('user_community_prefs_community_idx').on(table.communityDid), + ] +) diff --git a/src/db/schema/users.ts b/src/db/schema/users.ts index 8e0d9f5..451e50f 100644 --- a/src/db/schema/users.ts +++ b/src/db/schema/users.ts @@ -1,30 +1,25 @@ -import { pgTable, text, timestamp, boolean, integer } from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core' - -export const users = pgTable("users", { - did: text("did").primaryKey(), - handle: text("handle").notNull(), - displayName: text("display_name"), - avatarUrl: text("avatar_url"), - bannerUrl: text("banner_url"), - bio: text("bio"), - role: text("role", { enum: ["user", "moderator", "admin"] }) - .notNull() - .default("user"), - isBanned: boolean("is_banned").notNull().default(false), - reputationScore: integer("reputation_score").notNull().default(0), - firstSeenAt: timestamp("first_seen_at", { withTimezone: true }) - .notNull() - .defaultNow(), - lastActiveAt: timestamp("last_active_at", { withTimezone: true }) +export const users = pgTable('users', { + did: text('did').primaryKey(), + handle: text('handle').notNull(), + displayName: text('display_name'), + avatarUrl: text('avatar_url'), + bannerUrl: text('banner_url'), + bio: text('bio'), + role: text('role', { enum: ['user', 'moderator', 'admin'] }) .notNull() - .defaultNow(), - declaredAge: integer("declared_age"), - maturityPref: text("maturity_pref", { - enum: ["safe", "mature", "adult"], + .default('user'), + isBanned: boolean('is_banned').notNull().default(false), + reputationScore: integer('reputation_score').notNull().default(0), + firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull().defaultNow(), + lastActiveAt: timestamp('last_active_at', { withTimezone: true }).notNull().defaultNow(), + declaredAge: integer('declared_age'), + maturityPref: text('maturity_pref', { + enum: ['safe', 'mature', 'adult'], }) .notNull() - .default("safe"), + .default('safe'), /** Account creation date resolved from PLC directory on first encounter. */ - accountCreatedAt: timestamp("account_created_at", { withTimezone: true }), -}); + accountCreatedAt: timestamp('account_created_at', { withTimezone: true }), +}) diff --git a/src/firehose/cursor.ts b/src/firehose/cursor.ts index c40bf65..cc6ef3d 100644 --- a/src/firehose/cursor.ts +++ b/src/firehose/cursor.ts @@ -1,61 +1,58 @@ -import { eq } from "drizzle-orm"; -import { firehoseCursor } from "../db/schema/firehose.js"; -import type { Database } from "../db/index.js"; +import { eq } from 'drizzle-orm' +import { firehoseCursor } from '../db/schema/firehose.js' +import type { Database } from '../db/index.js' -const DEFAULT_DEBOUNCE_MS = 5000; +const DEFAULT_DEBOUNCE_MS = 5000 export class CursorStore { - private db: Database; - private debounceMs: number; - private pendingCursor: bigint | null = null; - private timer: ReturnType | null = null; + private db: Database + private debounceMs: number + private pendingCursor: bigint | null = null + private timer: ReturnType | null = null constructor(db: Database, debounceMs = DEFAULT_DEBOUNCE_MS) { - this.db = db; - this.debounceMs = debounceMs; + this.db = db + this.debounceMs = debounceMs } async getCursor(): Promise { - const rows = await this.db - .select() - .from(firehoseCursor) - .where(eq(firehoseCursor.id, "default")); + const rows = await this.db.select().from(firehoseCursor).where(eq(firehoseCursor.id, 'default')) - const row = rows[0]; - return row?.cursor ?? null; + const row = rows[0] + return row?.cursor ?? null } saveCursor(cursor: bigint): void { - this.pendingCursor = cursor; + this.pendingCursor = cursor if (this.timer !== null) { - return; + return } this.timer = setTimeout(() => { - void this.writeCursor(); - }, this.debounceMs); + void this.writeCursor() + }, this.debounceMs) } async flush(): Promise { if (this.timer !== null) { - clearTimeout(this.timer); - this.timer = null; + clearTimeout(this.timer) + this.timer = null } - await this.writeCursor(); + await this.writeCursor() } private async writeCursor(): Promise { - this.timer = null; - const cursor = this.pendingCursor; + this.timer = null + const cursor = this.pendingCursor if (cursor === null) { - return; + return } - this.pendingCursor = null; + this.pendingCursor = null await this.db .update(firehoseCursor) .set({ cursor, updatedAt: new Date() }) - .where(eq(firehoseCursor.id, "default")); + .where(eq(firehoseCursor.id, 'default')) } } diff --git a/src/firehose/handlers/identity.ts b/src/firehose/handlers/identity.ts index bcb0dce..0af234a 100644 --- a/src/firehose/handlers/identity.ts +++ b/src/firehose/handlers/identity.ts @@ -1,52 +1,49 @@ -import { eq } from "drizzle-orm"; -import { users } from "../../db/schema/users.js"; -import { topics } from "../../db/schema/topics.js"; -import { replies } from "../../db/schema/replies.js"; -import { reactions } from "../../db/schema/reactions.js"; -import { trackedRepos } from "../../db/schema/tracked-repos.js"; -import type { Database } from "../../db/index.js"; -import type { Logger } from "../../lib/logger.js"; -import type { IdentityEvent } from "../types.js"; +import { eq } from 'drizzle-orm' +import { users } from '../../db/schema/users.js' +import { topics } from '../../db/schema/topics.js' +import { replies } from '../../db/schema/replies.js' +import { reactions } from '../../db/schema/reactions.js' +import { trackedRepos } from '../../db/schema/tracked-repos.js' +import type { Database } from '../../db/index.js' +import type { Logger } from '../../lib/logger.js' +import type { IdentityEvent } from '../types.js' export class IdentityHandler { constructor( private db: Database, - private logger: Logger, + private logger: Logger ) {} async handle(event: IdentityEvent): Promise { - const { did, handle, status } = event; + const { did, handle, status } = event switch (status) { - case "deleted": - await this.purgeAccount(did); - this.logger.info({ did }, "Purged all data for deleted account"); - break; + case 'deleted': + await this.purgeAccount(did) + this.logger.info({ did }, 'Purged all data for deleted account') + break - case "active": - await this.upsertUser(did, handle); - this.logger.debug({ did, handle }, "Identity active"); - break; + case 'active': + await this.upsertUser(did, handle) + this.logger.debug({ did, handle }, 'Identity active') + break - case "takendown": - case "suspended": - case "deactivated": - this.logger.info( - { did, handle, status }, - "Identity status change", - ); - break; + case 'takendown': + case 'suspended': + case 'deactivated': + this.logger.info({ did, handle, status }, 'Identity status change') + break } } private async purgeAccount(did: string): Promise { await this.db.transaction(async (tx) => { - await tx.delete(reactions).where(eq(reactions.authorDid, did)); - await tx.delete(replies).where(eq(replies.authorDid, did)); - await tx.delete(topics).where(eq(topics.authorDid, did)); - await tx.delete(users).where(eq(users.did, did)); - await tx.delete(trackedRepos).where(eq(trackedRepos.did, did)); - }); + await tx.delete(reactions).where(eq(reactions.authorDid, did)) + await tx.delete(replies).where(eq(replies.authorDid, did)) + await tx.delete(topics).where(eq(topics.authorDid, did)) + await tx.delete(users).where(eq(users.did, did)) + await tx.delete(trackedRepos).where(eq(trackedRepos.did, did)) + }) } private async upsertUser(did: string, handle: string): Promise { @@ -62,6 +59,6 @@ export class IdentityHandler { handle, lastActiveAt: new Date(), }, - }); + }) } } diff --git a/src/firehose/handlers/record.ts b/src/firehose/handlers/record.ts index 73a110e..a9a93b0 100644 --- a/src/firehose/handlers/record.ts +++ b/src/firehose/handlers/record.ts @@ -1,26 +1,24 @@ -import { eq } from "drizzle-orm"; -import { users } from "../../db/schema/users.js"; -import type { Database } from "../../db/index.js"; -import type { Logger } from "../../lib/logger.js"; -import type { RecordEvent } from "../types.js"; -import { COLLECTION_MAP, SUPPORTED_COLLECTIONS } from "../types.js"; -import type { SupportedCollection } from "../types.js"; -import { validateRecord } from "../validation.js"; -import type { TopicIndexer } from "../indexers/topic.js"; -import type { ReplyIndexer } from "../indexers/reply.js"; -import type { ReactionIndexer } from "../indexers/reaction.js"; -import type { AccountAgeService, TrustStatus } from "../../services/account-age.js"; +import { eq } from 'drizzle-orm' +import { users } from '../../db/schema/users.js' +import type { Database } from '../../db/index.js' +import type { Logger } from '../../lib/logger.js' +import type { RecordEvent } from '../types.js' +import { COLLECTION_MAP, SUPPORTED_COLLECTIONS } from '../types.js' +import type { SupportedCollection } from '../types.js' +import { validateRecord } from '../validation.js' +import type { TopicIndexer } from '../indexers/topic.js' +import type { ReplyIndexer } from '../indexers/reply.js' +import type { ReactionIndexer } from '../indexers/reaction.js' +import type { AccountAgeService, TrustStatus } from '../../services/account-age.js' interface Indexers { - topic: TopicIndexer; - reply: ReplyIndexer; - reaction: ReactionIndexer; + topic: TopicIndexer + reply: ReplyIndexer + reaction: ReactionIndexer } -function isSupportedCollection( - collection: string, -): collection is SupportedCollection { - return (SUPPORTED_COLLECTIONS as readonly string[]).includes(collection); +function isSupportedCollection(collection: string): collection is SupportedCollection { + return (SUPPORTED_COLLECTIONS as readonly string[]).includes(collection) } export class RecordHandler { @@ -28,117 +26,114 @@ export class RecordHandler { private indexers: Indexers, private db: Database, private logger: Logger, - private accountAgeService: AccountAgeService, + private accountAgeService: AccountAgeService ) {} async handle(event: RecordEvent): Promise { try { - const { collection, action, did, rkey, record, cid, live } = event; + const { collection, action, did, rkey, record, cid, live } = event if (!isSupportedCollection(collection)) { - return; + return } - const uri = `at://${did}/${collection}/${rkey}`; - const indexerName = COLLECTION_MAP[collection]; + const uri = `at://${did}/${collection}/${rkey}` + const indexerName = COLLECTION_MAP[collection] // For delete events, no record validation needed - if (action === "delete") { - await this.dispatchDelete(indexerName, { uri, rkey, did }); - return; + if (action === 'delete') { + await this.dispatchDelete(indexerName, { uri, rkey, did }) + return } // Create and update require a valid record if (record === undefined) { - this.logger.warn( - { collection, action, did, rkey }, - "Record event missing record data", - ); - return; + this.logger.warn({ collection, action, did, rkey }, 'Record event missing record data') + return } - const validation = validateRecord(collection, record); + const validation = validateRecord(collection, record) if (!validation.success) { this.logger.debug( { collection, did, rkey, error: validation.error }, - "Record validation failed", - ); - return; + 'Record validation failed' + ) + return } // Resolve trust status on create (upsert user + check account age) - let trustStatus: TrustStatus = "trusted"; - if (action === "create") { - trustStatus = await this.upsertUserWithTrustCheck(did); + let trustStatus: TrustStatus = 'trusted' + if (action === 'create') { + trustStatus = await this.upsertUserWithTrustCheck(did) } const params = { uri, rkey, did, - cid: cid ?? "", + cid: cid ?? '', record, live, trustStatus, - }; + } - if (action === "create") { - await this.dispatchCreate(indexerName, params); + if (action === 'create') { + await this.dispatchCreate(indexerName, params) } else { - await this.dispatchUpdate(indexerName, params); + await this.dispatchUpdate(indexerName, params) } } catch (err) { this.logger.error( { err, eventId: event.id, collection: event.collection }, - "Error handling record event", - ); + 'Error handling record event' + ) } } private async dispatchCreate( indexerName: string, params: { - uri: string; - rkey: string; - did: string; - cid: string; - record: Record; - live: boolean; - trustStatus: TrustStatus; - }, + uri: string + rkey: string + did: string + cid: string + record: Record + live: boolean + trustStatus: TrustStatus + } ): Promise { switch (indexerName) { - case "topic": - await this.indexers.topic.handleCreate(params); - break; - case "reply": - await this.indexers.reply.handleCreate(params); - break; - case "reaction": - await this.indexers.reaction.handleCreate(params); - break; + case 'topic': + await this.indexers.topic.handleCreate(params) + break + case 'reply': + await this.indexers.reply.handleCreate(params) + break + case 'reaction': + await this.indexers.reaction.handleCreate(params) + break } } private async dispatchUpdate( indexerName: string, params: { - uri: string; - rkey: string; - did: string; - cid: string; - record: Record; - live: boolean; - trustStatus: TrustStatus; - }, + uri: string + rkey: string + did: string + cid: string + record: Record + live: boolean + trustStatus: TrustStatus + } ): Promise { switch (indexerName) { - case "topic": - await this.indexers.topic.handleUpdate(params); - break; - case "reply": - await this.indexers.reply.handleUpdate(params); - break; + case 'topic': + await this.indexers.topic.handleUpdate(params) + break + case 'reply': + await this.indexers.reply.handleUpdate(params) + break // Reactions don't have update } } @@ -146,20 +141,20 @@ export class RecordHandler { private async dispatchDelete( indexerName: string, params: { - uri: string; - rkey: string; - did: string; - }, + uri: string + rkey: string + did: string + } ): Promise { switch (indexerName) { - case "topic": + case 'topic': await this.indexers.topic.handleDelete({ uri: params.uri, rkey: params.rkey, did: params.did, - }); - break; - case "reply": + }) + break + case 'reply': // For reply delete, we need the root URI to decrement the count. // If the record is available (backfill), use it. Otherwise, the // integration will handle the count via the stored rootUri. @@ -167,17 +162,17 @@ export class RecordHandler { uri: params.uri, rkey: params.rkey, did: params.did, - rootUri: "", - }); - break; - case "reaction": + rootUri: '', + }) + break + case 'reaction': await this.indexers.reaction.handleDelete({ uri: params.uri, rkey: params.rkey, did: params.did, - subjectUri: "", - }); - break; + subjectUri: '', + }) + break } } @@ -203,27 +198,24 @@ export class RecordHandler { accountCreatedAt: users.accountCreatedAt, }) .from(users) - .where(eq(users.did, did)); + .where(eq(users.did, did)) if (existing.length > 0) { - const user = existing[0]; + const user = existing[0] if (user?.accountCreatedAt) { - return this.accountAgeService.determineTrustStatus(user.accountCreatedAt); + return this.accountAgeService.determineTrustStatus(user.accountCreatedAt) } // Legacy row without accountCreatedAt -- resolve now - const createdAt = await this.accountAgeService.resolveCreationDate(did); + const createdAt = await this.accountAgeService.resolveCreationDate(did) if (createdAt) { - await this.db - .update(users) - .set({ accountCreatedAt: createdAt }) - .where(eq(users.did, did)); + await this.db.update(users).set({ accountCreatedAt: createdAt }).where(eq(users.did, did)) } - return this.accountAgeService.determineTrustStatus(createdAt); + return this.accountAgeService.determineTrustStatus(createdAt) } // New user -- resolve account creation date before inserting - const createdAt = await this.accountAgeService.resolveCreationDate(did); + const createdAt = await this.accountAgeService.resolveCreationDate(did) await this.db .insert(users) @@ -232,21 +224,21 @@ export class RecordHandler { handle: did, // Stub -- will be updated by identity event accountCreatedAt: createdAt, }) - .onConflictDoNothing(); + .onConflictDoNothing() - const trustStatus = this.accountAgeService.determineTrustStatus(createdAt); + const trustStatus = this.accountAgeService.determineTrustStatus(createdAt) - if (trustStatus === "new") { + if (trustStatus === 'new') { this.logger.info( { did, accountCreatedAt: createdAt?.toISOString() }, - "New account detected (< 24h old), indexing with trust_status: new", - ); + 'New account detected (< 24h old), indexing with trust_status: new' + ) } - return trustStatus; + return trustStatus } catch (err) { - this.logger.error({ err, did }, "Failed to upsert user with trust check"); - return "trusted"; // Fail open -- don't block indexing + this.logger.error({ err, did }, 'Failed to upsert user with trust check') + return 'trusted' // Fail open -- don't block indexing } } } diff --git a/src/firehose/indexers/reaction.ts b/src/firehose/indexers/reaction.ts index 210a352..093d3d7 100644 --- a/src/firehose/indexers/reaction.ts +++ b/src/firehose/indexers/reaction.ts @@ -1,46 +1,46 @@ -import { eq, sql } from "drizzle-orm"; -import { reactions } from "../../db/schema/reactions.js"; -import { topics } from "../../db/schema/topics.js"; -import { replies } from "../../db/schema/replies.js"; -import type { Database } from "../../db/index.js"; -import type { Logger } from "../../lib/logger.js"; +import { eq, sql } from 'drizzle-orm' +import { reactions } from '../../db/schema/reactions.js' +import { topics } from '../../db/schema/topics.js' +import { replies } from '../../db/schema/replies.js' +import type { Database } from '../../db/index.js' +import type { Logger } from '../../lib/logger.js' -const TOPIC_COLLECTION = "forum.barazo.topic.post"; -const REPLY_COLLECTION = "forum.barazo.topic.reply"; +const TOPIC_COLLECTION = 'forum.barazo.topic.post' +const REPLY_COLLECTION = 'forum.barazo.topic.reply' interface CreateParams { - uri: string; - rkey: string; - did: string; - cid: string; - record: Record; - live: boolean; + uri: string + rkey: string + did: string + cid: string + record: Record + live: boolean } interface DeleteParams { - uri: string; - rkey: string; - did: string; - subjectUri: string; + uri: string + rkey: string + did: string + subjectUri: string } function getCollectionFromUri(uri: string): string | undefined { // AT URI format: at://did/collection/rkey - const parts = uri.split("/"); + const parts = uri.split('/') // parts: ["at:", "", "did", "collection", "rkey"] for at://did/collection/rkey // But NSID collections have dots, so we need index 3 - return parts[3]; + return parts[3] } export class ReactionIndexer { constructor( private db: Database, - private logger: Logger, + private logger: Logger ) {} async handleCreate(params: CreateParams): Promise { - const { uri, rkey, did, cid, record } = params; - const subject = record["subject"] as { uri: string; cid: string }; + const { uri, rkey, did, cid, record } = params + const subject = record['subject'] as { uri: string; cid: string } await this.db.transaction(async (tx) => { await tx @@ -51,54 +51,48 @@ export class ReactionIndexer { authorDid: did, subjectUri: subject.uri, subjectCid: subject.cid, - type: record["type"] as string, - communityDid: record["community"] as string, + type: record['type'] as string, + communityDid: record['community'] as string, cid, - createdAt: new Date(record["createdAt"] as string), + createdAt: new Date(record['createdAt'] as string), }) - .onConflictDoNothing(); + .onConflictDoNothing() - await this.incrementReactionCount(tx as never, subject.uri); - }); + await this.incrementReactionCount(tx as never, subject.uri) + }) - this.logger.debug({ uri, did }, "Indexed reaction"); + this.logger.debug({ uri, did }, 'Indexed reaction') } async handleDelete(params: DeleteParams): Promise { - const { uri, subjectUri } = params; + const { uri, subjectUri } = params await this.db.transaction(async (tx) => { - await tx.delete(reactions).where(eq(reactions.uri, uri)); - await this.decrementReactionCount(tx as never, subjectUri); - }); + await tx.delete(reactions).where(eq(reactions.uri, uri)) + await this.decrementReactionCount(tx as never, subjectUri) + }) - this.logger.debug({ uri }, "Deleted reaction"); + this.logger.debug({ uri }, 'Deleted reaction') } - private async incrementReactionCount( - tx: Database, - subjectUri: string, - ): Promise { - const collection = getCollectionFromUri(subjectUri); + private async incrementReactionCount(tx: Database, subjectUri: string): Promise { + const collection = getCollectionFromUri(subjectUri) if (collection === TOPIC_COLLECTION) { await tx .update(topics) .set({ reactionCount: sql`${topics.reactionCount} + 1` }) - .where(eq(topics.uri, subjectUri)); + .where(eq(topics.uri, subjectUri)) } else if (collection === REPLY_COLLECTION) { await tx .update(replies) .set({ reactionCount: sql`${replies.reactionCount} + 1` }) - .where(eq(replies.uri, subjectUri)); + .where(eq(replies.uri, subjectUri)) } } - private async decrementReactionCount( - tx: Database, - subjectUri: string, - ): Promise { - const collection = getCollectionFromUri(subjectUri); + private async decrementReactionCount(tx: Database, subjectUri: string): Promise { + const collection = getCollectionFromUri(subjectUri) if (collection === TOPIC_COLLECTION) { await tx @@ -106,14 +100,14 @@ export class ReactionIndexer { .set({ reactionCount: sql`GREATEST(${topics.reactionCount} - 1, 0)`, }) - .where(eq(topics.uri, subjectUri)); + .where(eq(topics.uri, subjectUri)) } else if (collection === REPLY_COLLECTION) { await tx .update(replies) .set({ reactionCount: sql`GREATEST(${replies.reactionCount} - 1, 0)`, }) - .where(eq(replies.uri, subjectUri)); + .where(eq(replies.uri, subjectUri)) } } } diff --git a/src/firehose/indexers/reply.ts b/src/firehose/indexers/reply.ts index ff9758d..9451059 100644 --- a/src/firehose/indexers/reply.ts +++ b/src/firehose/indexers/reply.ts @@ -1,48 +1,48 @@ -import { eq, sql } from "drizzle-orm"; -import { replies } from "../../db/schema/replies.js"; -import { topics } from "../../db/schema/topics.js"; -import type { Database } from "../../db/index.js"; -import type { Logger } from "../../lib/logger.js"; -import type { TrustStatus } from "../../services/account-age.js"; +import { eq, sql } from 'drizzle-orm' +import { replies } from '../../db/schema/replies.js' +import { topics } from '../../db/schema/topics.js' +import type { Database } from '../../db/index.js' +import type { Logger } from '../../lib/logger.js' +import type { TrustStatus } from '../../services/account-age.js' interface CreateParams { - uri: string; - rkey: string; - did: string; - cid: string; - record: Record; - live: boolean; - trustStatus: TrustStatus; + uri: string + rkey: string + did: string + cid: string + record: Record + live: boolean + trustStatus: TrustStatus } interface UpdateParams { - uri: string; - rkey: string; - did: string; - cid: string; - record: Record; - live: boolean; - trustStatus: TrustStatus; + uri: string + rkey: string + did: string + cid: string + record: Record + live: boolean + trustStatus: TrustStatus } interface DeleteParams { - uri: string; - rkey: string; - did: string; - rootUri: string; + uri: string + rkey: string + did: string + rootUri: string } export class ReplyIndexer { constructor( private db: Database, - private logger: Logger, + private logger: Logger ) {} async handleCreate(params: CreateParams): Promise { - const { uri, rkey, did, cid, record, trustStatus } = params; + const { uri, rkey, did, cid, record, trustStatus } = params - const root = record["root"] as { uri: string; cid: string }; - const parent = record["parent"] as { uri: string; cid: string }; + const root = record['root'] as { uri: string; cid: string } + const parent = record['parent'] as { uri: string; cid: string } await this.db.transaction(async (tx) => { await tx @@ -51,19 +51,19 @@ export class ReplyIndexer { uri, rkey, authorDid: did, - content: record["content"] as string, - contentFormat: (record["contentFormat"] as string | undefined) ?? null, + content: record['content'] as string, + contentFormat: (record['contentFormat'] as string | undefined) ?? null, rootUri: root.uri, rootCid: root.cid, parentUri: parent.uri, parentCid: parent.cid, - communityDid: record["community"] as string, + communityDid: record['community'] as string, cid, - labels: (record["labels"] as { values: { val: string }[] } | undefined) ?? null, - createdAt: new Date(record["createdAt"] as string), + labels: (record['labels'] as { values: { val: string }[] } | undefined) ?? null, + createdAt: new Date(record['createdAt'] as string), trustStatus, }) - .onConflictDoNothing(); + .onConflictDoNothing() // Increment reply count and update last activity await tx @@ -72,34 +72,34 @@ export class ReplyIndexer { replyCount: sql`${topics.replyCount} + 1`, lastActivityAt: new Date(), }) - .where(eq(topics.uri, root.uri)); - }); + .where(eq(topics.uri, root.uri)) + }) - this.logger.debug({ uri, did, trustStatus }, "Indexed reply"); + this.logger.debug({ uri, did, trustStatus }, 'Indexed reply') } async handleUpdate(params: UpdateParams): Promise { - const { uri, cid, record } = params; + const { uri, cid, record } = params await this.db .update(replies) .set({ - content: record["content"] as string, - contentFormat: (record["contentFormat"] as string | undefined) ?? null, + content: record['content'] as string, + contentFormat: (record['contentFormat'] as string | undefined) ?? null, cid, - labels: (record["labels"] as { values: { val: string }[] } | undefined) ?? null, + labels: (record['labels'] as { values: { val: string }[] } | undefined) ?? null, indexedAt: new Date(), }) - .where(eq(replies.uri, uri)); + .where(eq(replies.uri, uri)) - this.logger.debug({ uri }, "Updated reply"); + this.logger.debug({ uri }, 'Updated reply') } async handleDelete(params: DeleteParams): Promise { - const { uri, rootUri } = params; + const { uri, rootUri } = params await this.db.transaction(async (tx) => { - await tx.delete(replies).where(eq(replies.uri, uri)); + await tx.delete(replies).where(eq(replies.uri, uri)) // Decrement reply count (floor at 0 via GREATEST) await tx @@ -107,9 +107,9 @@ export class ReplyIndexer { .set({ replyCount: sql`GREATEST(${topics.replyCount} - 1, 0)`, }) - .where(eq(topics.uri, rootUri)); - }); + .where(eq(topics.uri, rootUri)) + }) - this.logger.debug({ uri }, "Deleted reply"); + this.logger.debug({ uri }, 'Deleted reply') } } diff --git a/src/firehose/indexers/topic.ts b/src/firehose/indexers/topic.ts index fe81bb8..f1f1828 100644 --- a/src/firehose/indexers/topic.ts +++ b/src/firehose/indexers/topic.ts @@ -1,33 +1,33 @@ -import { eq } from "drizzle-orm"; -import { topics } from "../../db/schema/topics.js"; -import type { Database } from "../../db/index.js"; -import type { Logger } from "../../lib/logger.js"; -import type { TrustStatus } from "../../services/account-age.js"; +import { eq } from 'drizzle-orm' +import { topics } from '../../db/schema/topics.js' +import type { Database } from '../../db/index.js' +import type { Logger } from '../../lib/logger.js' +import type { TrustStatus } from '../../services/account-age.js' interface CreateParams { - uri: string; - rkey: string; - did: string; - cid: string; - record: Record; - live: boolean; - trustStatus: TrustStatus; + uri: string + rkey: string + did: string + cid: string + record: Record + live: boolean + trustStatus: TrustStatus } interface DeleteParams { - uri: string; - rkey: string; - did: string; + uri: string + rkey: string + did: string } export class TopicIndexer { constructor( private db: Database, - private logger: Logger, + private logger: Logger ) {} async handleCreate(params: CreateParams): Promise { - const { uri, rkey, did, cid, record, trustStatus } = params; + const { uri, rkey, did, cid, record, trustStatus } = params await this.db .insert(topics) @@ -35,60 +35,60 @@ export class TopicIndexer { uri, rkey, authorDid: did, - title: record["title"] as string, - content: record["content"] as string, - contentFormat: (record["contentFormat"] as string | undefined) ?? null, - category: record["category"] as string, - tags: (record["tags"] as string[] | undefined) ?? null, - communityDid: record["community"] as string, + title: record['title'] as string, + content: record['content'] as string, + contentFormat: (record['contentFormat'] as string | undefined) ?? null, + category: record['category'] as string, + tags: (record['tags'] as string[] | undefined) ?? null, + communityDid: record['community'] as string, cid, - labels: (record["labels"] as { values: { val: string }[] } | undefined) ?? null, - createdAt: new Date(record["createdAt"] as string), - lastActivityAt: new Date(record["createdAt"] as string), + labels: (record['labels'] as { values: { val: string }[] } | undefined) ?? null, + createdAt: new Date(record['createdAt'] as string), + lastActivityAt: new Date(record['createdAt'] as string), trustStatus, }) .onConflictDoUpdate({ target: topics.uri, set: { - title: record["title"] as string, - content: record["content"] as string, - contentFormat: (record["contentFormat"] as string | undefined) ?? null, - category: record["category"] as string, - tags: (record["tags"] as string[] | undefined) ?? null, + title: record['title'] as string, + content: record['content'] as string, + contentFormat: (record['contentFormat'] as string | undefined) ?? null, + category: record['category'] as string, + tags: (record['tags'] as string[] | undefined) ?? null, cid, - labels: (record["labels"] as { values: { val: string }[] } | undefined) ?? null, + labels: (record['labels'] as { values: { val: string }[] } | undefined) ?? null, indexedAt: new Date(), }, - }); + }) - this.logger.debug({ uri, did, trustStatus }, "Indexed topic"); + this.logger.debug({ uri, did, trustStatus }, 'Indexed topic') } async handleUpdate(params: CreateParams): Promise { - const { uri, cid, record } = params; + const { uri, cid, record } = params await this.db .update(topics) .set({ - title: record["title"] as string, - content: record["content"] as string, - contentFormat: (record["contentFormat"] as string | undefined) ?? null, - category: record["category"] as string, - tags: (record["tags"] as string[] | undefined) ?? null, + title: record['title'] as string, + content: record['content'] as string, + contentFormat: (record['contentFormat'] as string | undefined) ?? null, + category: record['category'] as string, + tags: (record['tags'] as string[] | undefined) ?? null, cid, - labels: (record["labels"] as { values: { val: string }[] } | undefined) ?? null, + labels: (record['labels'] as { values: { val: string }[] } | undefined) ?? null, indexedAt: new Date(), }) - .where(eq(topics.uri, uri)); + .where(eq(topics.uri, uri)) - this.logger.debug({ uri }, "Updated topic"); + this.logger.debug({ uri }, 'Updated topic') } async handleDelete(params: DeleteParams): Promise { - const { uri } = params; + const { uri } = params - await this.db.delete(topics).where(eq(topics.uri, uri)); + await this.db.delete(topics).where(eq(topics.uri, uri)) - this.logger.debug({ uri }, "Deleted topic"); + this.logger.debug({ uri }, 'Deleted topic') } } diff --git a/src/firehose/repo-manager.ts b/src/firehose/repo-manager.ts index 0acce6d..144c941 100644 --- a/src/firehose/repo-manager.ts +++ b/src/firehose/repo-manager.ts @@ -1,67 +1,56 @@ -import { eq } from "drizzle-orm"; -import { trackedRepos } from "../db/schema/tracked-repos.js"; -import type { Database } from "../db/index.js"; -import type { Logger } from "../lib/logger.js"; -import type { TapClient } from "./types.js"; +import { eq } from 'drizzle-orm' +import { trackedRepos } from '../db/schema/tracked-repos.js' +import type { Database } from '../db/index.js' +import type { Logger } from '../lib/logger.js' +import type { TapClient } from './types.js' -const BATCH_SIZE = 100; +const BATCH_SIZE = 100 export class RepoManager { constructor( private db: Database, private tap: TapClient, - private logger: Logger, + private logger: Logger ) {} async trackRepo(did: string): Promise { - await this.db - .insert(trackedRepos) - .values({ did }) - .onConflictDoNothing(); + await this.db.insert(trackedRepos).values({ did }).onConflictDoNothing() - await this.tap.addRepos([did]); + await this.tap.addRepos([did]) - this.logger.debug({ did }, "Tracked repo"); + this.logger.debug({ did }, 'Tracked repo') } async untrackRepo(did: string): Promise { - await this.db - .delete(trackedRepos) - .where(eq(trackedRepos.did, did)); + await this.db.delete(trackedRepos).where(eq(trackedRepos.did, did)) - await this.tap.removeRepos([did]); + await this.tap.removeRepos([did]) - this.logger.debug({ did }, "Untracked repo"); + this.logger.debug({ did }, 'Untracked repo') } async restoreTrackedRepos(): Promise { - const rows = await this.db.select().from(trackedRepos); + const rows = await this.db.select().from(trackedRepos) if (rows.length === 0) { - this.logger.info("No tracked repos to restore"); - return; + this.logger.info('No tracked repos to restore') + return } - const dids = rows.map((r) => r.did); + const dids = rows.map((r) => r.did) // Batch into chunks of BATCH_SIZE for (let i = 0; i < dids.length; i += BATCH_SIZE) { - const batch = dids.slice(i, i + BATCH_SIZE); - await this.tap.addRepos(batch); + const batch = dids.slice(i, i + BATCH_SIZE) + await this.tap.addRepos(batch) } - this.logger.info( - { count: dids.length }, - "Restored tracked repos", - ); + this.logger.info({ count: dids.length }, 'Restored tracked repos') } async isTracked(did: string): Promise { - const rows = await this.db - .select() - .from(trackedRepos) - .where(eq(trackedRepos.did, did)); + const rows = await this.db.select().from(trackedRepos).where(eq(trackedRepos.did, did)) - return rows.length > 0; + return rows.length > 0 } } diff --git a/src/firehose/service.ts b/src/firehose/service.ts index 5087636..1dfc63a 100644 --- a/src/firehose/service.ts +++ b/src/firehose/service.ts @@ -1,66 +1,66 @@ -import { Tap, SimpleIndexer } from "@atproto/tap"; -import type { TapChannel } from "@atproto/tap"; -import type { RecordEvent as TapRecordEvent, IdentityEvent as TapIdentityEvent } from "@atproto/tap"; -import type { Database } from "../db/index.js"; -import type { Logger } from "../lib/logger.js"; -import type { Env } from "../config/env.js"; -import { CursorStore } from "./cursor.js"; -import { RepoManager } from "./repo-manager.js"; -import { TopicIndexer } from "./indexers/topic.js"; -import { ReplyIndexer } from "./indexers/reply.js"; -import { ReactionIndexer } from "./indexers/reaction.js"; -import { RecordHandler } from "./handlers/record.js"; -import { IdentityHandler } from "./handlers/identity.js"; -import { createAccountAgeService } from "../services/account-age.js"; -import type { RecordEvent, IdentityEvent } from "./types.js"; +import { Tap, SimpleIndexer } from '@atproto/tap' +import type { TapChannel } from '@atproto/tap' +import type { RecordEvent as TapRecordEvent, IdentityEvent as TapIdentityEvent } from '@atproto/tap' +import type { Database } from '../db/index.js' +import type { Logger } from '../lib/logger.js' +import type { Env } from '../config/env.js' +import { CursorStore } from './cursor.js' +import { RepoManager } from './repo-manager.js' +import { TopicIndexer } from './indexers/topic.js' +import { ReplyIndexer } from './indexers/reply.js' +import { ReactionIndexer } from './indexers/reaction.js' +import { RecordHandler } from './handlers/record.js' +import { IdentityHandler } from './handlers/identity.js' +import { createAccountAgeService } from '../services/account-age.js' +import type { RecordEvent, IdentityEvent } from './types.js' interface FirehoseStatus { - connected: boolean; - lastEventId: number | null; + connected: boolean + lastEventId: number | null } export class FirehoseService { - private tap: Tap; - private channel: TapChannel | null = null; - private cursorStore: CursorStore; - private repoManager: RepoManager; - private recordHandler: RecordHandler; - private identityHandler: IdentityHandler; - private connected = false; - private lastEventId: number | null = null; + private tap: Tap + private channel: TapChannel | null = null + private cursorStore: CursorStore + private repoManager: RepoManager + private recordHandler: RecordHandler + private identityHandler: IdentityHandler + private connected = false + private lastEventId: number | null = null constructor( db: Database, private logger: Logger, - env: Env, + env: Env ) { this.tap = new Tap(env.TAP_URL, { adminPassword: env.TAP_ADMIN_PASSWORD, - }); + }) - this.cursorStore = new CursorStore(db); - this.repoManager = new RepoManager(db, this.tap, logger); + this.cursorStore = new CursorStore(db) + this.repoManager = new RepoManager(db, this.tap, logger) - const topicIndexer = new TopicIndexer(db, logger); - const replyIndexer = new ReplyIndexer(db, logger); - const reactionIndexer = new ReactionIndexer(db, logger); - const accountAgeService = createAccountAgeService(logger); + const topicIndexer = new TopicIndexer(db, logger) + const replyIndexer = new ReplyIndexer(db, logger) + const reactionIndexer = new ReactionIndexer(db, logger) + const accountAgeService = createAccountAgeService(logger) this.recordHandler = new RecordHandler( { topic: topicIndexer, reply: replyIndexer, reaction: reactionIndexer }, db, logger, - accountAgeService, - ); + accountAgeService + ) - this.identityHandler = new IdentityHandler(db, logger); + this.identityHandler = new IdentityHandler(db, logger) } async start(): Promise { try { - await this.repoManager.restoreTrackedRepos(); + await this.repoManager.restoreTrackedRepos() - const indexer = new SimpleIndexer(); + const indexer = new SimpleIndexer() indexer.record(async (evt: TapRecordEvent) => { const event: RecordEvent = { @@ -70,17 +70,15 @@ export class FirehoseService { rev: evt.rev, collection: evt.collection, rkey: evt.rkey, - ...(evt.record !== undefined - ? { record: evt.record as Record } - : {}), + ...(evt.record !== undefined ? { record: evt.record as Record } : {}), ...(evt.cid !== undefined ? { cid: evt.cid } : {}), live: evt.live, - }; + } - await this.recordHandler.handle(event); - this.lastEventId = evt.id; - this.cursorStore.saveCursor(BigInt(evt.id)); - }); + await this.recordHandler.handle(event) + this.lastEventId = evt.id + this.cursorStore.saveCursor(BigInt(evt.id)) + }) indexer.identity(async (evt: TapIdentityEvent) => { const event: IdentityEvent = { @@ -89,50 +87,50 @@ export class FirehoseService { handle: evt.handle, isActive: evt.isActive, status: evt.status, - }; + } - await this.identityHandler.handle(event); - this.lastEventId = evt.id; - this.cursorStore.saveCursor(BigInt(evt.id)); - }); + await this.identityHandler.handle(event) + this.lastEventId = evt.id + this.cursorStore.saveCursor(BigInt(evt.id)) + }) indexer.error((err: Error) => { - this.logger.error({ err }, "Firehose indexer error"); - }); + this.logger.error({ err }, 'Firehose indexer error') + }) - this.channel = this.tap.channel(indexer); + this.channel = this.tap.channel(indexer) // Start in background (non-blocking) void this.channel.start().catch((err: unknown) => { - this.logger.error({ err }, "Firehose channel error"); - this.connected = false; - }); + this.logger.error({ err }, 'Firehose channel error') + this.connected = false + }) - this.connected = true; - this.logger.info("Firehose subscription started"); + this.connected = true + this.logger.info('Firehose subscription started') } catch (err) { - this.logger.error({ err }, "Failed to start firehose service"); - this.connected = false; + this.logger.error({ err }, 'Failed to start firehose service') + this.connected = false } } async stop(): Promise { if (this.channel) { - await this.channel.destroy(); - this.channel = null; + await this.channel.destroy() + this.channel = null } - await this.cursorStore.flush(); - this.connected = false; - this.logger.info("Firehose subscription stopped"); + await this.cursorStore.flush() + this.connected = false + this.logger.info('Firehose subscription stopped') } getStatus(): FirehoseStatus { return { connected: this.connected, lastEventId: this.lastEventId, - }; + } } getRepoManager(): RepoManager { - return this.repoManager; + return this.repoManager } } diff --git a/src/firehose/types.ts b/src/firehose/types.ts index 3e97355..e5c7c88 100644 --- a/src/firehose/types.ts +++ b/src/firehose/types.ts @@ -1,68 +1,61 @@ -import type { LEXICON_IDS } from "@barazo-forum/lexicons"; +import type { LEXICON_IDS } from '@barazo-forum/lexicons' /** Record actions from the firehose. */ -export type RecordAction = "create" | "update" | "delete"; +export type RecordAction = 'create' | 'update' | 'delete' /** Account status from identity events. */ -export type RepoStatus = - | "active" - | "takendown" - | "suspended" - | "deactivated" - | "deleted"; +export type RepoStatus = 'active' | 'takendown' | 'suspended' | 'deactivated' | 'deleted' /** A firehose record event (decoupled from @atproto/tap for testability). */ export interface RecordEvent { - id: number; - action: RecordAction; - did: string; - rev: string; - collection: string; - rkey: string; - record?: Record; - cid?: string; - live: boolean; + id: number + action: RecordAction + did: string + rev: string + collection: string + rkey: string + record?: Record + cid?: string + live: boolean } /** A firehose identity event (decoupled from @atproto/tap for testability). */ export interface IdentityEvent { - id: number; - did: string; - handle: string; - isActive: boolean; - status: RepoStatus; + id: number + did: string + handle: string + isActive: boolean + status: RepoStatus } /** Parameters passed to indexer handlers. */ export interface IndexerParams { - uri: string; - rkey: string; - did: string; - cid: string; - record: Record; - live: boolean; + uri: string + rkey: string + did: string + cid: string + record: Record + live: boolean } /** Interface for Tap client operations (for testability). */ export interface TapClient { - addRepos(dids: string[]): Promise; - removeRepos(dids: string[]): Promise; + addRepos(dids: string[]): Promise + removeRepos(dids: string[]): Promise } /** Collections supported by Barazo. */ export const SUPPORTED_COLLECTIONS = [ - "forum.barazo.topic.post", - "forum.barazo.topic.reply", - "forum.barazo.interaction.reaction", -] as const satisfies ReadonlyArray< - (typeof LEXICON_IDS)[keyof typeof LEXICON_IDS] ->; + 'forum.barazo.topic.post', + 'forum.barazo.topic.reply', + 'forum.barazo.interaction.reaction', +] as const satisfies ReadonlyArray<(typeof LEXICON_IDS)[keyof typeof LEXICON_IDS]> -export type SupportedCollection = (typeof SUPPORTED_COLLECTIONS)[number]; +export type SupportedCollection = (typeof SUPPORTED_COLLECTIONS)[number] /** Maps collection NSIDs to short indexer names. */ export const COLLECTION_MAP: Record = { - "forum.barazo.topic.post": "topic", - "forum.barazo.topic.reply": "reply", - "forum.barazo.interaction.reaction": "reaction", -} as const; + 'forum.barazo.topic.post': 'topic', + 'forum.barazo.topic.reply': 'reply', + 'forum.barazo.interaction.reaction': 'reaction', +} as const diff --git a/src/firehose/validation.ts b/src/firehose/validation.ts index 30d277e..f4993cd 100644 --- a/src/firehose/validation.ts +++ b/src/firehose/validation.ts @@ -1,54 +1,45 @@ -import { - topicPostSchema, - topicReplySchema, - reactionSchema, -} from "@barazo-forum/lexicons"; -import type { SupportedCollection } from "./types.js"; -import { SUPPORTED_COLLECTIONS } from "./types.js"; +import { topicPostSchema, topicReplySchema, reactionSchema } from '@barazo-forum/lexicons' +import type { SupportedCollection } from './types.js' +import { SUPPORTED_COLLECTIONS } from './types.js' -const MAX_RECORD_SIZE = 64 * 1024; // 64KB +const MAX_RECORD_SIZE = 64 * 1024 // 64KB type ValidationResult = | { success: true; data: Record } - | { success: false; error: string }; + | { success: false; error: string } const schemaMap: Record< SupportedCollection, { safeParse: (data: unknown) => { success: boolean; error?: unknown } } > = { - "forum.barazo.topic.post": topicPostSchema, - "forum.barazo.topic.reply": topicReplySchema, - "forum.barazo.interaction.reaction": reactionSchema, -}; - -function isSupportedCollection( - collection: string, -): collection is SupportedCollection { - return (SUPPORTED_COLLECTIONS as readonly string[]).includes(collection); + 'forum.barazo.topic.post': topicPostSchema, + 'forum.barazo.topic.reply': topicReplySchema, + 'forum.barazo.interaction.reaction': reactionSchema, } -export function validateRecord( - collection: string, - record: unknown, -): ValidationResult { +function isSupportedCollection(collection: string): collection is SupportedCollection { + return (SUPPORTED_COLLECTIONS as readonly string[]).includes(collection) +} + +export function validateRecord(collection: string, record: unknown): ValidationResult { if (!isSupportedCollection(collection)) { - return { success: false, error: `Unsupported collection: ${collection}` }; + return { success: false, error: `Unsupported collection: ${collection}` } } // Size check: rough estimate using JSON serialization - const serialized = JSON.stringify(record); + const serialized = JSON.stringify(record) if (serialized.length > MAX_RECORD_SIZE) { return { success: false, error: `Record exceeds maximum size of ${String(MAX_RECORD_SIZE)} bytes`, - }; + } } - const schema = schemaMap[collection]; - const result = schema.safeParse(record); + const schema = schemaMap[collection] + const result = schema.safeParse(record) if (!result.success) { - return { success: false, error: `Validation failed for ${collection}` }; + return { success: false, error: `Validation failed for ${collection}` } } - return { success: true, data: record as Record }; + return { success: true, data: record as Record } } diff --git a/src/jobs/compute-trust-graph.ts b/src/jobs/compute-trust-graph.ts new file mode 100644 index 0000000..d7b958c --- /dev/null +++ b/src/jobs/compute-trust-graph.ts @@ -0,0 +1,123 @@ +import type { Logger } from '../lib/logger.js' +import type { TrustGraphService, TrustComputationResult } from '../services/trust-graph.js' +import type { SybilDetectorService, DetectionResult } from '../services/sybil-detector.js' +import type { + BehavioralHeuristicsService, + BehavioralFlag, +} from '../services/behavioral-heuristics.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface JobResult { + trustComputation: TrustComputationResult + behavioralFlags: BehavioralFlag[] + sybilDetection: DetectionResult + durationMs: number +} + +export type JobState = 'idle' | 'running' | 'completed' | 'failed' + +export interface JobStatus { + state: JobState + lastComputedAt: Date | null + lastDurationMs: number | null + lastError: string | null +} + +export interface TrustGraphJob { + run(communityId: string | null): Promise + getStatus(): JobStatus +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createTrustGraphJob( + trustGraphService: TrustGraphService, + sybilDetectorService: SybilDetectorService, + behavioralHeuristicsService: BehavioralHeuristicsService, + logger: Logger +): TrustGraphJob { + let state: JobState = 'idle' + let lastComputedAt: Date | null = null + let lastDurationMs: number | null = null + let lastError: string | null = null + + async function run(communityId: string | null): Promise { + const start = Date.now() + state = 'running' + + logger.info({ communityId }, 'Starting trust graph computation job') + + try { + // Step 1: Compute trust scores (EigenTrust) + const trustComputation = await trustGraphService.computeTrustScores(communityId) + + logger.info( + { + communityId, + nodes: trustComputation.totalNodes, + edges: trustComputation.totalEdges, + converged: trustComputation.converged, + iterations: trustComputation.iterations, + }, + 'Trust computation phase completed' + ) + + // Step 2: Run behavioral heuristics + const behavioralFlags = await behavioralHeuristicsService.runAll(communityId) + + logger.info( + { + communityId, + flagsDetected: behavioralFlags.length, + }, + 'Behavioral heuristics phase completed' + ) + + // Step 3: Detect sybil clusters + const sybilDetection = await sybilDetectorService.detectClusters(communityId) + + logger.info( + { + communityId, + clustersDetected: sybilDetection.clustersDetected, + lowTrustDids: sybilDetection.totalLowTrustDids, + }, + 'Sybil detection phase completed' + ) + + const durationMs = Date.now() - start + state = 'completed' + lastComputedAt = new Date() + lastDurationMs = durationMs + lastError = null + + logger.info({ communityId, durationMs }, 'Trust graph computation job completed') + + return { trustComputation, behavioralFlags, sybilDetection, durationMs } + } catch (err) { + state = 'failed' + const errorMessage = err instanceof Error ? err.message : 'Unknown error' + lastError = errorMessage + + logger.error({ communityId, err }, 'Trust graph computation job failed') + + throw err + } + } + + function getStatus(): JobStatus { + return { + state, + lastComputedAt, + lastDurationMs, + lastError, + } + } + + return { run, getStatus } +} diff --git a/src/lib/anti-spam.ts b/src/lib/anti-spam.ts index af9f518..b965027 100644 --- a/src/lib/anti-spam.ts +++ b/src/lib/anti-spam.ts @@ -1,40 +1,35 @@ -import { eq, and } from "drizzle-orm"; -import type { Database } from "../db/index.js"; -import type { Cache } from "../cache/index.js"; -import { communitySettings } from "../db/schema/community-settings.js"; -import { accountTrust } from "../db/schema/account-trust.js"; -import { users } from "../db/schema/users.js"; +import { eq, and } from 'drizzle-orm' +import type { Database } from '../db/index.js' +import type { Cache } from '../cache/index.js' +import { communitySettings } from '../db/schema/community-settings.js' +import { accountTrust } from '../db/schema/account-trust.js' +import { users } from '../db/schema/users.js' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface AntiSpamSettings { - wordFilter: string[]; - firstPostQueueCount: number; - newAccountDays: number; - newAccountWriteRatePerMin: number; - establishedWriteRatePerMin: number; - linkHoldEnabled: boolean; - topicCreationDelayEnabled: boolean; - burstPostCount: number; - burstWindowMinutes: number; - trustedPostThreshold: number; + wordFilter: string[] + firstPostQueueCount: number + newAccountDays: number + newAccountWriteRatePerMin: number + establishedWriteRatePerMin: number + linkHoldEnabled: boolean + topicCreationDelayEnabled: boolean + burstPostCount: number + burstWindowMinutes: number + trustedPostThreshold: number } -export type QueueReason = - | "word_filter" - | "first_post" - | "link_hold" - | "burst" - | "topic_delay"; +export type QueueReason = 'word_filter' | 'first_post' | 'link_hold' | 'burst' | 'topic_delay' export interface AntiSpamCheckResult { - held: boolean; + held: boolean reasons: Array<{ - reason: QueueReason; - matchedWords?: string[]; - }>; + reason: QueueReason + matchedWords?: string[] + }> } // --------------------------------------------------------------------------- @@ -52,9 +47,9 @@ const DEFAULTS: AntiSpamSettings = { burstPostCount: 5, burstWindowMinutes: 10, trustedPostThreshold: 10, -}; +} -const SETTINGS_CACHE_TTL = 60; // seconds +const SETTINGS_CACHE_TTL = 60 // seconds // --------------------------------------------------------------------------- // Settings loader @@ -63,14 +58,14 @@ const SETTINGS_CACHE_TTL = 60; // seconds export async function loadAntiSpamSettings( db: Database, cache: Cache, - communityDid: string, + communityDid: string ): Promise { - const cacheKey = `antispam:settings:${communityDid}`; + const cacheKey = `antispam:settings:${communityDid}` try { - const cached = await cache.get(cacheKey); + const cached = await cache.get(cacheKey) if (cached) { - return JSON.parse(cached) as AntiSpamSettings; + return JSON.parse(cached) as AntiSpamSettings } } catch { // Cache miss or error -- fall through to DB @@ -82,41 +77,34 @@ export async function loadAntiSpamSettings( wordFilter: communitySettings.wordFilter, }) .from(communitySettings) - .where(eq(communitySettings.id, "default")); + .where(eq(communitySettings.id, 'default')) - const row = rows[0]; - const thresholds = row?.moderationThresholds; + const row = rows[0] + const thresholds = row?.moderationThresholds const settings: AntiSpamSettings = { wordFilter: row?.wordFilter ?? DEFAULTS.wordFilter, - firstPostQueueCount: - thresholds?.firstPostQueueCount ?? DEFAULTS.firstPostQueueCount, + firstPostQueueCount: thresholds?.firstPostQueueCount ?? DEFAULTS.firstPostQueueCount, newAccountDays: thresholds?.newAccountDays ?? DEFAULTS.newAccountDays, newAccountWriteRatePerMin: - thresholds?.newAccountWriteRatePerMin ?? - DEFAULTS.newAccountWriteRatePerMin, + thresholds?.newAccountWriteRatePerMin ?? DEFAULTS.newAccountWriteRatePerMin, establishedWriteRatePerMin: - thresholds?.establishedWriteRatePerMin ?? - DEFAULTS.establishedWriteRatePerMin, - linkHoldEnabled: - thresholds?.linkHoldEnabled ?? DEFAULTS.linkHoldEnabled, + thresholds?.establishedWriteRatePerMin ?? DEFAULTS.establishedWriteRatePerMin, + linkHoldEnabled: thresholds?.linkHoldEnabled ?? DEFAULTS.linkHoldEnabled, topicCreationDelayEnabled: - thresholds?.topicCreationDelayEnabled ?? - DEFAULTS.topicCreationDelayEnabled, + thresholds?.topicCreationDelayEnabled ?? DEFAULTS.topicCreationDelayEnabled, burstPostCount: thresholds?.burstPostCount ?? DEFAULTS.burstPostCount, - burstWindowMinutes: - thresholds?.burstWindowMinutes ?? DEFAULTS.burstWindowMinutes, - trustedPostThreshold: - thresholds?.trustedPostThreshold ?? DEFAULTS.trustedPostThreshold, - }; + burstWindowMinutes: thresholds?.burstWindowMinutes ?? DEFAULTS.burstWindowMinutes, + trustedPostThreshold: thresholds?.trustedPostThreshold ?? DEFAULTS.trustedPostThreshold, + } try { - await cache.set(cacheKey, JSON.stringify(settings), "EX", SETTINGS_CACHE_TTL); + await cache.set(cacheKey, JSON.stringify(settings), 'EX', SETTINGS_CACHE_TTL) } catch { // Non-critical -- settings just won't be cached } - return settings; + return settings } // --------------------------------------------------------------------------- @@ -127,59 +115,48 @@ export async function isNewAccount( db: Database, authorDid: string, communityDid: string, - newAccountDays: number, + newAccountDays: number ): Promise { - if (newAccountDays <= 0) return false; + if (newAccountDays <= 0) return false // Check account_trust for community-specific history const trustRows = await db .select({ approvedPostCount: accountTrust.approvedPostCount }) .from(accountTrust) - .where( - and( - eq(accountTrust.did, authorDid), - eq(accountTrust.communityDid, communityDid), - ), - ); - - const trust = trustRows[0]; + .where(and(eq(accountTrust.did, authorDid), eq(accountTrust.communityDid, communityDid))) + + const trust = trustRows[0] // If they have any approved posts, check when they first appeared if (trust && trust.approvedPostCount > 0) { // Check firstSeenAt from users table as proxy for community activity start const userRows = await db .select({ firstSeenAt: users.firstSeenAt }) .from(users) - .where(eq(users.did, authorDid)); + .where(eq(users.did, authorDid)) - const user = userRows[0]; + const user = userRows[0] if (user) { - const daysSinceFirstSeen = - (Date.now() - user.firstSeenAt.getTime()) / (1000 * 60 * 60 * 24); - return daysSinceFirstSeen < newAccountDays; + const daysSinceFirstSeen = (Date.now() - user.firstSeenAt.getTime()) / (1000 * 60 * 60 * 24) + return daysSinceFirstSeen < newAccountDays } } // No trust record or no approved posts = new account - return true; + return true } export async function isAccountTrusted( db: Database, authorDid: string, communityDid: string, - _trustThreshold: number, + _trustThreshold: number ): Promise { const rows = await db .select({ isTrusted: accountTrust.isTrusted }) .from(accountTrust) - .where( - and( - eq(accountTrust.did, authorDid), - eq(accountTrust.communityDid, communityDid), - ), - ); - - return rows[0]?.isTrusted ?? false; + .where(and(eq(accountTrust.did, authorDid), eq(accountTrust.communityDid, communityDid))) + + return rows[0]?.isTrusted ?? false } // --------------------------------------------------------------------------- @@ -187,35 +164,35 @@ export async function isAccountTrusted( // --------------------------------------------------------------------------- function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } export function checkWordFilter( content: string, title: string | undefined, - wordFilter: string[], + wordFilter: string[] ): { matches: boolean; matchedWords: string[] } { if (wordFilter.length === 0) { - return { matches: false, matchedWords: [] }; + return { matches: false, matchedWords: [] } } - const text = title ? `${title} ${content}` : content; - const matchedWords: string[] = []; + const text = title ? `${title} ${content}` : content + const matchedWords: string[] = [] for (const word of wordFilter) { - const pattern = new RegExp(`\\b${escapeRegex(word)}\\b`, "i"); + const pattern = new RegExp(`\\b${escapeRegex(word)}\\b`, 'i') if (pattern.test(text)) { - matchedWords.push(word); + matchedWords.push(word) } } - return { matches: matchedWords.length > 0, matchedWords }; + return { matches: matchedWords.length > 0, matchedWords } } -const URL_PATTERN = /https?:\/\/[^\s]+|www\.[^\s]+/i; +const URL_PATTERN = /https?:\/\/[^\s]+|www\.[^\s]+/i export function checkForUrls(content: string): boolean { - return URL_PATTERN.test(content); + return URL_PATTERN.test(content) } // --------------------------------------------------------------------------- @@ -227,62 +204,60 @@ export async function checkWriteRateLimit( authorDid: string, communityDid: string, isNew: boolean, - settings: AntiSpamSettings, + settings: AntiSpamSettings ): Promise { - const limit = isNew - ? settings.newAccountWriteRatePerMin - : settings.establishedWriteRatePerMin; + const limit = isNew ? settings.newAccountWriteRatePerMin : settings.establishedWriteRatePerMin - const key = `antispam:rate:${communityDid}:${authorDid}`; - const now = Date.now(); - const windowStart = now - 60_000; // 1 minute window + const key = `antispam:rate:${communityDid}:${authorDid}` + const now = Date.now() + const windowStart = now - 60_000 // 1 minute window try { // Remove expired entries and count current - await cache.zremrangebyscore(key, "-inf", String(windowStart)); - const count = await cache.zcard(key); + await cache.zremrangebyscore(key, '-inf', String(windowStart)) + const count = await cache.zcard(key) if (count >= limit) { - return true; // rate-limited + return true // rate-limited } // Add current write - await cache.zadd(key, String(now), `${String(now)}:${crypto.randomUUID()}`); - await cache.expire(key, 120); // TTL = 2 minutes + await cache.zadd(key, String(now), `${String(now)}:${crypto.randomUUID()}`) + await cache.expire(key, 120) // TTL = 2 minutes } catch { // If Valkey is down, allow the write (fail open for rate limiting) - return false; + return false } - return false; + return false } export async function checkBurstDetection( cache: Cache, authorDid: string, communityDid: string, - settings: AntiSpamSettings, + settings: AntiSpamSettings ): Promise { - const key = `antispam:burst:${communityDid}:${authorDid}`; - const now = Date.now(); - const windowMs = settings.burstWindowMinutes * 60_000; - const windowStart = now - windowMs; + const key = `antispam:burst:${communityDid}:${authorDid}` + const now = Date.now() + const windowMs = settings.burstWindowMinutes * 60_000 + const windowStart = now - windowMs try { - await cache.zremrangebyscore(key, "-inf", String(windowStart)); - const count = await cache.zcard(key); + await cache.zremrangebyscore(key, '-inf', String(windowStart)) + const count = await cache.zcard(key) if (count >= settings.burstPostCount) { - return true; // burst detected + return true // burst detected } - await cache.zadd(key, String(now), `${String(now)}:${crypto.randomUUID()}`); - await cache.expire(key, settings.burstWindowMinutes * 60 + 60); + await cache.zadd(key, String(now), `${String(now)}:${crypto.randomUUID()}`) + await cache.expire(key, settings.burstWindowMinutes * 60 + 60) } catch { - return false; + return false } - return false; + return false } // --------------------------------------------------------------------------- @@ -293,23 +268,18 @@ export async function needsFirstPostModeration( db: Database, authorDid: string, communityDid: string, - firstPostQueueCount: number, + firstPostQueueCount: number ): Promise { - if (firstPostQueueCount <= 0) return false; + if (firstPostQueueCount <= 0) return false const rows = await db .select({ approvedPostCount: accountTrust.approvedPostCount }) .from(accountTrust) - .where( - and( - eq(accountTrust.did, authorDid), - eq(accountTrust.communityDid, communityDid), - ), - ); - - const trust = rows[0]; - const approvedCount = trust?.approvedPostCount ?? 0; - return approvedCount < firstPostQueueCount; + .where(and(eq(accountTrust.did, authorDid), eq(accountTrust.communityDid, communityDid))) + + const trust = rows[0] + const approvedCount = trust?.approvedPostCount ?? 0 + return approvedCount < firstPostQueueCount } // --------------------------------------------------------------------------- @@ -320,22 +290,17 @@ export async function canCreateTopic( db: Database, authorDid: string, communityDid: string, - topicDelayEnabled: boolean, + topicDelayEnabled: boolean ): Promise { - if (!topicDelayEnabled) return true; + if (!topicDelayEnabled) return true const rows = await db .select({ approvedPostCount: accountTrust.approvedPostCount }) .from(accountTrust) - .where( - and( - eq(accountTrust.did, authorDid), - eq(accountTrust.communityDid, communityDid), - ), - ); - - const trust = rows[0]; - return (trust?.approvedPostCount ?? 0) > 0; + .where(and(eq(accountTrust.did, authorDid), eq(accountTrust.communityDid, communityDid))) + + const trust = rows[0] + return (trust?.approvedPostCount ?? 0) > 0 } // --------------------------------------------------------------------------- @@ -346,54 +311,50 @@ export async function runAntiSpamChecks( db: Database, cache: Cache, params: { - authorDid: string; - communityDid: string; - contentType: "topic" | "reply"; - title?: string; - content: string; - }, + authorDid: string + communityDid: string + contentType: 'topic' | 'reply' + title?: string + content: string + } ): Promise { - const settings = await loadAntiSpamSettings(db, cache, params.communityDid); + const settings = await loadAntiSpamSettings(db, cache, params.communityDid) // Check if user is trusted (bypasses all content checks) const trusted = await isAccountTrusted( db, params.authorDid, params.communityDid, - settings.trustedPostThreshold, - ); + settings.trustedPostThreshold + ) if (trusted) { - return { held: false, reasons: [] }; + return { held: false, reasons: [] } } // Check if user is a moderator or admin (they bypass anti-spam) const userRows = await db .select({ role: users.role }) .from(users) - .where(eq(users.did, params.authorDid)); - const userRole = userRows[0]?.role; - if (userRole === "moderator" || userRole === "admin") { - return { held: false, reasons: [] }; + .where(eq(users.did, params.authorDid)) + const userRole = userRows[0]?.role + if (userRole === 'moderator' || userRole === 'admin') { + return { held: false, reasons: [] } } - const reasons: AntiSpamCheckResult["reasons"] = []; + const reasons: AntiSpamCheckResult['reasons'] = [] const isNew = await isNewAccount( db, params.authorDid, params.communityDid, - settings.newAccountDays, - ); + settings.newAccountDays + ) // Word filter (applies to all users, not just new ones) - const wordResult = checkWordFilter( - params.content, - params.title, - settings.wordFilter, - ); + const wordResult = checkWordFilter(params.content, params.title, settings.wordFilter) if (wordResult.matches) { - reasons.push({ reason: "word_filter", matchedWords: wordResult.matchedWords }); + reasons.push({ reason: 'word_filter', matchedWords: wordResult.matchedWords }) } if (isNew) { @@ -402,15 +363,15 @@ export async function runAntiSpamChecks( db, params.authorDid, params.communityDid, - settings.firstPostQueueCount, - ); + settings.firstPostQueueCount + ) if (needsQueue) { - reasons.push({ reason: "first_post" }); + reasons.push({ reason: 'first_post' }) } // Link hold if (settings.linkHoldEnabled && checkForUrls(params.content)) { - reasons.push({ reason: "link_hold" }); + reasons.push({ reason: 'link_hold' }) } } @@ -419,14 +380,14 @@ export async function runAntiSpamChecks( cache, params.authorDid, params.communityDid, - settings, - ); + settings + ) if (burstDetected) { - reasons.push({ reason: "burst" }); + reasons.push({ reason: 'burst' }) } return { held: reasons.length > 0, reasons, - }; + } } diff --git a/src/lib/api-errors.ts b/src/lib/api-errors.ts index 433f48c..68690cd 100644 --- a/src/lib/api-errors.ts +++ b/src/lib/api-errors.ts @@ -11,12 +11,12 @@ * Fastify uses `statusCode` on thrown errors to set the response status. */ export class ApiError extends Error { - readonly statusCode: number; + readonly statusCode: number constructor(statusCode: number, message: string) { - super(message); - this.statusCode = statusCode; - this.name = "ApiError"; + super(message) + this.statusCode = statusCode + this.name = 'ApiError' } } @@ -26,7 +26,7 @@ export class ApiError extends Error { * @param message - Human-readable description of what was not found. */ export function notFound(message: string): ApiError { - return new ApiError(404, message); + return new ApiError(404, message) } /** @@ -35,7 +35,7 @@ export function notFound(message: string): ApiError { * @param message - Human-readable reason for the denial. */ export function forbidden(message: string): ApiError { - return new ApiError(403, message); + return new ApiError(403, message) } /** @@ -44,7 +44,7 @@ export function forbidden(message: string): ApiError { * @param message - Human-readable description of the validation failure. */ export function badRequest(message: string): ApiError { - return new ApiError(400, message); + return new ApiError(400, message) } /** @@ -53,7 +53,7 @@ export function badRequest(message: string): ApiError { * @param message - Human-readable description of the conflict. */ export function conflict(message: string): ApiError { - return new ApiError(409, message); + return new ApiError(409, message) } /** @@ -62,5 +62,5 @@ export function conflict(message: string): ApiError { * @param message - Human-readable description of the rate limit violation. */ export function tooManyRequests(message: string): ApiError { - return new ApiError(429, message); + return new ApiError(429, message) } diff --git a/src/lib/block-mute.ts b/src/lib/block-mute.ts index 7eca7a2..e3784b3 100644 --- a/src/lib/block-mute.ts +++ b/src/lib/block-mute.ts @@ -1,14 +1,14 @@ -import { eq } from "drizzle-orm"; -import type { Database } from "../db/index.js"; -import { userPreferences } from "../db/schema/user-preferences.js"; +import { eq } from 'drizzle-orm' +import type { Database } from '../db/index.js' +import { userPreferences } from '../db/schema/user-preferences.js' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface BlockMuteLists { - blockedDids: string[]; - mutedDids: string[]; + blockedDids: string[] + mutedDids: string[] } // --------------------------------------------------------------------------- @@ -27,10 +27,10 @@ export interface BlockMuteLists { */ export async function loadBlockMuteLists( userDid: string | undefined, - db: Database, + db: Database ): Promise { if (!userDid) { - return { blockedDids: [], mutedDids: [] }; + return { blockedDids: [], mutedDids: [] } } const rows = await db @@ -39,11 +39,11 @@ export async function loadBlockMuteLists( mutedDids: userPreferences.mutedDids, }) .from(userPreferences) - .where(eq(userPreferences.did, userDid)); + .where(eq(userPreferences.did, userDid)) - const prefs = rows[0]; + const prefs = rows[0] return { blockedDids: prefs?.blockedDids ?? [], mutedDids: prefs?.mutedDids ?? [], - }; + } } diff --git a/src/lib/content-filter.ts b/src/lib/content-filter.ts index 5b8127e..d3af755 100644 --- a/src/lib/content-filter.ts +++ b/src/lib/content-filter.ts @@ -5,17 +5,17 @@ // authentication status, declared age, age threshold, and maturity preference. // --------------------------------------------------------------------------- -import { isMaturityAtMost, ratingsAtMost } from "./maturity.js"; -import type { MaturityRating } from "./maturity.js"; +import { isMaturityAtMost, ratingsAtMost } from './maturity.js' +import type { MaturityRating } from './maturity.js' /** Minimal user shape needed for maturity resolution. */ export interface MaturityUser { - declaredAge: number | null | undefined; - maturityPref: string; + declaredAge: number | null | undefined + maturityPref: string } /** Default age threshold (GDPR Art. 8 strictest: 16). */ -const DEFAULT_AGE_THRESHOLD = 16; +const DEFAULT_AGE_THRESHOLD = 16 /** * Resolve the maximum maturity rating a user is allowed to view. @@ -29,14 +29,14 @@ const DEFAULT_AGE_THRESHOLD = 16; */ export function resolveMaxMaturity( user: MaturityUser | undefined, - ageThreshold: number = DEFAULT_AGE_THRESHOLD, + ageThreshold: number = DEFAULT_AGE_THRESHOLD ): MaturityRating { - if (!user) return "safe"; - if (user.declaredAge === null || user.declaredAge === undefined) return "safe"; - if (user.declaredAge === 0) return "safe"; - if (user.declaredAge < ageThreshold) return "safe"; - const pref = user.maturityPref as MaturityRating; - return pref; + if (!user) return 'safe' + if (user.declaredAge === null || user.declaredAge === undefined) return 'safe' + if (user.declaredAge === 0) return 'safe' + if (user.declaredAge < ageThreshold) return 'safe' + const pref = user.maturityPref as MaturityRating + return pref } /** @@ -46,11 +46,8 @@ export function resolveMaxMaturity( * A content rating is allowed if it is <= maxAllowed in the hierarchy: * safe (0) <= mature (1) <= adult (2) */ -export function maturityAllows( - maxAllowed: MaturityRating, - contentRating: MaturityRating, -): boolean { - return isMaturityAtMost(contentRating, maxAllowed); +export function maturityAllows(maxAllowed: MaturityRating, contentRating: MaturityRating): boolean { + return isMaturityAtMost(contentRating, maxAllowed) } /** @@ -58,5 +55,5 @@ export function maturityAllows( * Useful for building SQL IN clauses. */ export function allowedRatings(maxAllowed: MaturityRating): MaturityRating[] { - return ratingsAtMost(maxAllowed); + return ratingsAtMost(maxAllowed) } diff --git a/src/lib/handle-resolver.ts b/src/lib/handle-resolver.ts index c5eafd9..496c130 100644 --- a/src/lib/handle-resolver.ts +++ b/src/lib/handle-resolver.ts @@ -1,16 +1,16 @@ -import type { Cache } from "../cache/index.js"; -import type { Database } from "../db/index.js"; -import type { Logger } from "./logger.js"; -import { users } from "../db/schema/users.js"; -import { eq } from "drizzle-orm"; +import type { Cache } from '../cache/index.js' +import type { Database } from '../db/index.js' +import type { Logger } from './logger.js' +import { users } from '../db/schema/users.js' +import { eq } from 'drizzle-orm' // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const HANDLE_CACHE_PREFIX = "barazo:handle:"; -const HANDLE_CACHE_TTL = 3600; // 1 hour -const PLC_DIRECTORY_URL = "https://plc.directory"; +const HANDLE_CACHE_PREFIX = 'barazo:handle:' +const HANDLE_CACHE_TTL = 3600 // 1 hour +const PLC_DIRECTORY_URL = 'https://plc.directory' // --------------------------------------------------------------------------- // Types @@ -18,8 +18,8 @@ const PLC_DIRECTORY_URL = "https://plc.directory"; /** DID document from PLC directory. */ interface PlcDidDocument { - id: string; - alsoKnownAs?: string[]; + id: string + alsoKnownAs?: string[] } export interface HandleResolver { @@ -33,7 +33,7 @@ export interface HandleResolver { * * Returns the DID itself as fallback if resolution fails (never blocks auth). */ - resolve(did: string): Promise; + resolve(did: string): Promise } // --------------------------------------------------------------------------- @@ -46,33 +46,29 @@ export interface HandleResolver { */ function extractHandleFromDidDocument(doc: PlcDidDocument): string | undefined { if (!doc.alsoKnownAs || !Array.isArray(doc.alsoKnownAs)) { - return undefined; + return undefined } for (const aka of doc.alsoKnownAs) { - if (typeof aka === "string" && aka.startsWith("at://")) { - return aka.slice("at://".length); + if (typeof aka === 'string' && aka.startsWith('at://')) { + return aka.slice('at://'.length) } } - return undefined; + return undefined } // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- -export function createHandleResolver( - cache: Cache, - db: Database, - logger: Logger, -): HandleResolver { +export function createHandleResolver(cache: Cache, db: Database, logger: Logger): HandleResolver { async function resolveFromCache(did: string): Promise { - const cached = await cache.get(`${HANDLE_CACHE_PREFIX}${did}`); + const cached = await cache.get(`${HANDLE_CACHE_PREFIX}${did}`) if (cached !== null) { - return cached; + return cached } - return undefined; + return undefined } async function resolveFromDb(did: string): Promise { @@ -80,93 +76,85 @@ export function createHandleResolver( .select({ handle: users.handle }) .from(users) .where(eq(users.did, did)) - .limit(1); + .limit(1) - const row = rows[0]; + const row = rows[0] if (row !== undefined && row.handle !== did) { - return row.handle; + return row.handle } - return undefined; + return undefined } async function resolveFromPlcDirectory(did: string): Promise { - if (!did.startsWith("did:plc:")) { + if (!did.startsWith('did:plc:')) { // did:web resolution is not yet needed for MVP - logger.debug({ did }, "Non-PLC DID, skipping PLC directory lookup"); - return undefined; + logger.debug({ did }, 'Non-PLC DID, skipping PLC directory lookup') + return undefined } - const url = `${PLC_DIRECTORY_URL}/${encodeURIComponent(did)}`; + const url = `${PLC_DIRECTORY_URL}/${encodeURIComponent(did)}` const response = await fetch(url, { - headers: { Accept: "application/json" }, + headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(5000), - }); + }) if (!response.ok) { - logger.warn( - { did, status: response.status }, - "PLC directory lookup failed", - ); - return undefined; + logger.warn({ did, status: response.status }, 'PLC directory lookup failed') + return undefined } - const doc = (await response.json()) as PlcDidDocument; - return extractHandleFromDidDocument(doc); + const doc = (await response.json()) as PlcDidDocument + return extractHandleFromDidDocument(doc) } async function cacheHandle(did: string, handle: string): Promise { - await cache.set( - `${HANDLE_CACHE_PREFIX}${did}`, - handle, - "EX", - HANDLE_CACHE_TTL, - ); + await cache.set(`${HANDLE_CACHE_PREFIX}${did}`, handle, 'EX', HANDLE_CACHE_TTL) } async function resolve(did: string): Promise { // 1. Check Valkey cache try { - const cached = await resolveFromCache(did); + const cached = await resolveFromCache(did) if (cached) { - return cached; + return cached } } catch (err: unknown) { - logger.warn({ err, did }, "Handle cache lookup failed, continuing"); + logger.warn({ err, did }, 'Handle cache lookup failed, continuing') } // 2. Check users table (firehose may have indexed the handle) try { - const dbHandle = await resolveFromDb(did); + const dbHandle = await resolveFromDb(did) if (dbHandle) { // Populate cache for next time await cacheHandle(did, dbHandle).catch((err: unknown) => { - logger.warn({ err, did }, "Failed to cache handle from DB"); - }); - return dbHandle; + logger.warn({ err, did }, 'Failed to cache handle from DB') + }) + return dbHandle } } catch (err: unknown) { - logger.warn({ err, did }, "Handle DB lookup failed, continuing"); + logger.warn({ err, did }, 'Handle DB lookup failed, continuing') } // 3. Resolve from PLC directory try { - const plcHandle = await resolveFromPlcDirectory(did); + const plcHandle = await resolveFromPlcDirectory(did) if (plcHandle) { // Populate cache for next time await cacheHandle(did, plcHandle).catch((err: unknown) => { - logger.warn({ err, did }, "Failed to cache handle from PLC"); - }); - return plcHandle; + logger.warn({ err, did }, 'Failed to cache handle from PLC') + }) + return plcHandle } } catch (err: unknown) { - logger.warn({ err, did }, "PLC directory lookup failed, continuing"); + logger.warn({ err, did }, 'PLC directory lookup failed, continuing') } // 4. Fallback: return DID itself (auth should never fail due to handle resolution) - logger.info({ did }, "Handle resolution failed, using DID as fallback"); - return did; + logger.info({ did }, 'Handle resolution failed, using DID as fallback') + return did } - return { resolve }; + return { resolve } } diff --git a/src/lib/jurisdiction.ts b/src/lib/jurisdiction.ts index 9f4a747..78a4b8c 100644 --- a/src/lib/jurisdiction.ts +++ b/src/lib/jurisdiction.ts @@ -57,23 +57,23 @@ export const JURISDICTION_AGE_THRESHOLDS: Readonly> = { NZ: 13, JP: 13, KR: 14, -} as const; +} as const /** Default age threshold when country is not listed or not set. */ -export const DEFAULT_AGE_THRESHOLD = 16; +export const DEFAULT_AGE_THRESHOLD = 16 /** * Get the age threshold for a given country code. * Returns the country-specific threshold if known, otherwise the default (16). */ export function getAgeThreshold(countryCode: string | null | undefined): number { - if (!countryCode) return DEFAULT_AGE_THRESHOLD; - return JURISDICTION_AGE_THRESHOLDS[countryCode.toUpperCase()] ?? DEFAULT_AGE_THRESHOLD; + if (!countryCode) return DEFAULT_AGE_THRESHOLD + return JURISDICTION_AGE_THRESHOLDS[countryCode.toUpperCase()] ?? DEFAULT_AGE_THRESHOLD } /** * Get a sorted list of all supported country codes. */ export function getSupportedCountries(): string[] { - return Object.keys(JURISDICTION_AGE_THRESHOLDS).sort(); + return Object.keys(JURISDICTION_AGE_THRESHOLDS).sort() } diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 677d44a..e4054b3 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -1,3 +1,3 @@ // Fastify creates its own Pino logger instance. // This module re-exports the logger type for use outside request context. -export type { FastifyBaseLogger as Logger } from "fastify"; +export type { FastifyBaseLogger as Logger } from 'fastify' diff --git a/src/lib/maturity.ts b/src/lib/maturity.ts index cc973ee..3fa64be 100644 --- a/src/lib/maturity.ts +++ b/src/lib/maturity.ts @@ -2,24 +2,24 @@ // Maturity rating helpers (shared between categories and admin-settings) // --------------------------------------------------------------------------- -import type { MaturityRating as ZodMaturityRating } from "../validation/categories.js"; +import type { MaturityRating as ZodMaturityRating } from '../validation/categories.js' /** Valid maturity rating values. Derived from Zod schema as single source of truth. */ -export type MaturityRating = ZodMaturityRating; +export type MaturityRating = ZodMaturityRating /** Numeric order of maturity ratings for comparison. */ export const MATURITY_ORDER: Record = { safe: 0, mature: 1, adult: 2, -} as const; +} as const /** * Check if maturity rating `a` is lower than `b` in the hierarchy. * Hierarchy: safe < mature < adult */ export function isMaturityLowerThan(a: MaturityRating, b: MaturityRating): boolean { - return MATURITY_ORDER[a] < MATURITY_ORDER[b]; + return MATURITY_ORDER[a] < MATURITY_ORDER[b] } /** @@ -27,7 +27,7 @@ export function isMaturityLowerThan(a: MaturityRating, b: MaturityRating): boole * Used for content visibility: content rating must be at most user's max allowed. */ export function isMaturityAtMost(a: MaturityRating, b: MaturityRating): boolean { - return MATURITY_ORDER[a] <= MATURITY_ORDER[b]; + return MATURITY_ORDER[a] <= MATURITY_ORDER[b] } /** @@ -35,8 +35,8 @@ export function isMaturityAtMost(a: MaturityRating, b: MaturityRating): boolean * Useful for building SQL IN clauses. */ export function ratingsAtMost(maxLevel: MaturityRating): MaturityRating[] { - const max = MATURITY_ORDER[maxLevel]; + const max = MATURITY_ORDER[maxLevel] return (Object.entries(MATURITY_ORDER) as Array<[MaturityRating, number]>) .filter(([, order]) => order <= max) - .map(([rating]) => rating); + .map(([rating]) => rating) } diff --git a/src/lib/muted-words.ts b/src/lib/muted-words.ts index 95df506..b26c4fd 100644 --- a/src/lib/muted-words.ts +++ b/src/lib/muted-words.ts @@ -1,9 +1,6 @@ -import { eq, and } from "drizzle-orm"; -import type { Database } from "../db/index.js"; -import { - userPreferences, - userCommunityPreferences, -} from "../db/schema/user-preferences.js"; +import { eq, and } from 'drizzle-orm' +import type { Database } from '../db/index.js' +import { userPreferences, userCommunityPreferences } from '../db/schema/user-preferences.js' // --------------------------------------------------------------------------- // Loader @@ -21,23 +18,23 @@ import { export async function loadMutedWords( userDid: string | undefined, communityDid: string | undefined, - db: Database, + db: Database ): Promise { if (!userDid) { - return []; + return [] } // Fetch global muted words const globalRows = await db .select({ mutedWords: userPreferences.mutedWords }) .from(userPreferences) - .where(eq(userPreferences.did, userDid)); + .where(eq(userPreferences.did, userDid)) - const globalWords: string[] = globalRows[0]?.mutedWords ?? []; + const globalWords: string[] = globalRows[0]?.mutedWords ?? [] // If no community context, return global only if (!communityDid) { - return globalWords; + return globalWords } // Fetch per-community override @@ -47,20 +44,19 @@ export async function loadMutedWords( .where( and( eq(userCommunityPreferences.did, userDid), - eq(userCommunityPreferences.communityDid, communityDid), - ), - ); + eq(userCommunityPreferences.communityDid, communityDid) + ) + ) - const communityWords: string[] | null = - communityRows[0]?.mutedWords ?? null; + const communityWords: string[] | null = communityRows[0]?.mutedWords ?? null // null = no override, use global only if (communityWords === null) { - return globalWords; + return globalWords } // Merge and deduplicate (union of global + community) - return [...new Set([...globalWords, ...communityWords])]; + return [...new Set([...globalWords, ...communityWords])] } // --------------------------------------------------------------------------- @@ -72,7 +68,7 @@ export async function loadMutedWords( * match inside a RegExp. */ function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } /** @@ -91,20 +87,20 @@ function escapeRegex(str: string): string { export function contentMatchesMutedWords( content: string, mutedWords: string[], - title?: string, + title?: string ): boolean { - if (mutedWords.length === 0) return false; + if (mutedWords.length === 0) return false - const text = title ? `${title} ${content}` : content; - if (text.length === 0) return false; + const text = title ? `${title} ${content}` : content + if (text.length === 0) return false for (const word of mutedWords) { - const escaped = escapeRegex(word); - const pattern = new RegExp(`(?:^|\\b|(?<=\\W))${escaped}(?:$|\\b|(?=\\W))`, "i"); + const escaped = escapeRegex(word) + const pattern = new RegExp(`(?:^|\\b|(?<=\\W))${escaped}(?:$|\\b|(?=\\W))`, 'i') if (pattern.test(text)) { - return true; + return true } } - return false; + return false } diff --git a/src/lib/onboarding-gate.ts b/src/lib/onboarding-gate.ts index 7454f5f..b172fb9 100644 --- a/src/lib/onboarding-gate.ts +++ b/src/lib/onboarding-gate.ts @@ -1,10 +1,13 @@ -import { eq, and } from "drizzle-orm"; -import { communityOnboardingFields, userOnboardingResponses } from "../db/schema/onboarding-fields.js"; -import type { Database } from "../db/index.js"; +import { eq, and } from 'drizzle-orm' +import { + communityOnboardingFields, + userOnboardingResponses, +} from '../db/schema/onboarding-fields.js' +import type { Database } from '../db/index.js' export interface OnboardingCheckResult { - complete: boolean; - missingFields: { id: string; label: string; fieldType: string }[]; + complete: boolean + missingFields: { id: string; label: string; fieldType: string }[] } /** @@ -15,7 +18,7 @@ export interface OnboardingCheckResult { export async function checkOnboardingComplete( db: Database, did: string, - communityDid: string, + communityDid: string ): Promise { // Get mandatory fields for this community const fields = await db @@ -24,12 +27,12 @@ export async function checkOnboardingComplete( .where( and( eq(communityOnboardingFields.communityDid, communityDid), - eq(communityOnboardingFields.isMandatory, true), - ), - ); + eq(communityOnboardingFields.isMandatory, true) + ) + ) if (fields.length === 0) { - return { complete: true, missingFields: [] }; + return { complete: true, missingFields: [] } } // Get user's responses for this community @@ -39,18 +42,18 @@ export async function checkOnboardingComplete( .where( and( eq(userOnboardingResponses.did, did), - eq(userOnboardingResponses.communityDid, communityDid), - ), - ); + eq(userOnboardingResponses.communityDid, communityDid) + ) + ) - const answeredFieldIds = new Set(responses.map((r) => r.fieldId)); + const answeredFieldIds = new Set(responses.map((r) => r.fieldId)) const missingFields = fields .filter((f) => !answeredFieldIds.has(f.id)) - .map((f) => ({ id: f.id, label: f.label, fieldType: f.fieldType })); + .map((f) => ({ id: f.id, label: f.label, fieldType: f.fieldType })) return { complete: missingFields.length === 0, missingFields, - }; + } } diff --git a/src/lib/pds-client.ts b/src/lib/pds-client.ts index c871cf1..46cd4c5 100644 --- a/src/lib/pds-client.ts +++ b/src/lib/pds-client.ts @@ -1,6 +1,6 @@ -import { Agent } from "@atproto/api"; -import type { NodeOAuthClient } from "@atproto/oauth-client-node"; -import type { Logger } from "./logger.js"; +import { Agent } from '@atproto/api' +import type { NodeOAuthClient } from '@atproto/oauth-client-node' +import type { Logger } from './logger.js' // --------------------------------------------------------------------------- // Types @@ -8,8 +8,8 @@ import type { Logger } from "./logger.js"; /** Result of a successful record creation or update on the user's PDS. */ export interface PdsWriteResult { - uri: string; - cid: string; + uri: string + cid: string } /** PDS client interface for creating, updating, and deleting AT Protocol records. */ @@ -17,31 +17,23 @@ export interface PdsClient { createRecord( did: string, collection: string, - record: Record, - ): Promise; + record: Record + ): Promise updateRecord( did: string, collection: string, rkey: string, - record: Record, - ): Promise; + record: Record + ): Promise - deleteRecord( - did: string, - collection: string, - rkey: string, - ): Promise; + deleteRecord(did: string, collection: string, rkey: string): Promise /** * Upload a binary blob (e.g. an image) to the user's PDS. * Returns the blob reference object suitable for embedding in records. */ - uploadBlob( - did: string, - data: Uint8Array, - mimeType: string, - ): Promise; + uploadBlob(did: string, data: Uint8Array, mimeType: string): Promise } // --------------------------------------------------------------------------- @@ -53,9 +45,9 @@ export interface PdsClient { */ function pdsErrorMessage(err: unknown): string { if (err instanceof Error) { - return err.message; + return err.message } - return String(err); + return String(err) } // --------------------------------------------------------------------------- @@ -69,43 +61,36 @@ function pdsErrorMessage(err: unknown): string { * @param oauthClient - The AT Protocol OAuth client (provides session restore) * @param logger - Pino logger for structured logging */ -export function createPdsClient( - oauthClient: NodeOAuthClient, - logger: Logger, -): PdsClient { +export function createPdsClient(oauthClient: NodeOAuthClient, logger: Logger): PdsClient { /** * Restore an authenticated Agent for the given DID. * The OAuth client manages token refresh transparently. */ async function getAgent(did: string): Promise { - const session = await oauthClient.restore(did); - return new Agent(session); + const session = await oauthClient.restore(did) + return new Agent(session) } return { async createRecord( did: string, collection: string, - record: Record, + record: Record ): Promise { - logger.debug({ did, collection }, "PDS createRecord"); + logger.debug({ did, collection }, 'PDS createRecord') try { - const agent = await getAgent(did); + const agent = await getAgent(did) const response = await agent.com.atproto.repo.createRecord({ repo: did, collection, record, - }); + }) - return { uri: response.data.uri, cid: response.data.cid }; + return { uri: response.data.uri, cid: response.data.cid } } catch (err: unknown) { - logger.error( - { err, did, collection }, - "PDS createRecord failed: %s", - pdsErrorMessage(err), - ); - throw err; + logger.error({ err, did, collection }, 'PDS createRecord failed: %s', pdsErrorMessage(err)) + throw err } }, @@ -113,76 +98,64 @@ export function createPdsClient( did: string, collection: string, rkey: string, - record: Record, + record: Record ): Promise { - logger.debug({ did, collection, rkey }, "PDS updateRecord"); + logger.debug({ did, collection, rkey }, 'PDS updateRecord') try { - const agent = await getAgent(did); + const agent = await getAgent(did) const response = await agent.com.atproto.repo.putRecord({ repo: did, collection, rkey, record, - }); + }) - return { uri: response.data.uri, cid: response.data.cid }; + return { uri: response.data.uri, cid: response.data.cid } } catch (err: unknown) { logger.error( { err, did, collection, rkey }, - "PDS updateRecord failed: %s", - pdsErrorMessage(err), - ); - throw err; + 'PDS updateRecord failed: %s', + pdsErrorMessage(err) + ) + throw err } }, - async deleteRecord( - did: string, - collection: string, - rkey: string, - ): Promise { - logger.debug({ did, collection, rkey }, "PDS deleteRecord"); + async deleteRecord(did: string, collection: string, rkey: string): Promise { + logger.debug({ did, collection, rkey }, 'PDS deleteRecord') try { - const agent = await getAgent(did); + const agent = await getAgent(did) await agent.com.atproto.repo.deleteRecord({ repo: did, collection, rkey, - }); + }) } catch (err: unknown) { logger.error( { err, did, collection, rkey }, - "PDS deleteRecord failed: %s", - pdsErrorMessage(err), - ); - throw err; + 'PDS deleteRecord failed: %s', + pdsErrorMessage(err) + ) + throw err } }, - async uploadBlob( - did: string, - data: Uint8Array, - mimeType: string, - ): Promise { - logger.debug({ did, mimeType, size: data.length }, "PDS uploadBlob"); + async uploadBlob(did: string, data: Uint8Array, mimeType: string): Promise { + logger.debug({ did, mimeType, size: data.length }, 'PDS uploadBlob') try { - const agent = await getAgent(did); + const agent = await getAgent(did) const response = await agent.uploadBlob(data, { encoding: mimeType, - }); + }) - return response.data.blob; + return response.data.blob } catch (err: unknown) { - logger.error( - { err, did, mimeType }, - "PDS uploadBlob failed: %s", - pdsErrorMessage(err), - ); - throw err; + logger.error({ err, did, mimeType }, 'PDS uploadBlob failed: %s', pdsErrorMessage(err)) + throw err } }, - }; + } } diff --git a/src/lib/resolve-authors.ts b/src/lib/resolve-authors.ts index f6d398a..7d771eb 100644 --- a/src/lib/resolve-authors.ts +++ b/src/lib/resolve-authors.ts @@ -1,22 +1,18 @@ -import { and, eq, inArray } from "drizzle-orm"; -import type { Database } from "../db/index.js"; -import { users } from "../db/schema/users.js"; -import { communityProfiles } from "../db/schema/community-profiles.js"; -import { - resolveProfile, - type SourceProfile, - type CommunityOverride, -} from "./resolve-profile.js"; +import { and, eq, inArray } from 'drizzle-orm' +import type { Database } from '../db/index.js' +import { users } from '../db/schema/users.js' +import { communityProfiles } from '../db/schema/community-profiles.js' +import { resolveProfile, type SourceProfile, type CommunityOverride } from './resolve-profile.js' /** * Compact author profile for embedding in topic/reply responses. * Intentionally excludes bannerUrl and bio to keep payloads small. */ export interface AuthorProfile { - did: string; - handle: string; - displayName: string | null; - avatarUrl: string | null; + did: string + handle: string + displayName: string | null + avatarUrl: string | null } /** @@ -30,11 +26,11 @@ export interface AuthorProfile { export async function resolveAuthors( dids: string[], communityDid: string | null, - db: Database, + db: Database ): Promise> { - const uniqueDids = [...new Set(dids)]; + const uniqueDids = [...new Set(dids)] if (uniqueDids.length === 0) { - return new Map(); + return new Map() } // Batch query 1: source profiles from users table @@ -48,15 +44,15 @@ export async function resolveAuthors( bio: users.bio, }) .from(users) - .where(inArray(users.did, uniqueDids)); + .where(inArray(users.did, uniqueDids)) - const sourceMap = new Map(); + const sourceMap = new Map() for (const row of userRows) { - sourceMap.set(row.did, row); + sourceMap.set(row.did, row) } // Batch query 2: community profile overrides (only when community context exists) - const overrideMap = new Map(); + const overrideMap = new Map() if (communityDid) { const overrideRows = await db .select({ @@ -70,9 +66,9 @@ export async function resolveAuthors( .where( and( inArray(communityProfiles.did, uniqueDids), - eq(communityProfiles.communityDid, communityDid), - ), - ); + eq(communityProfiles.communityDid, communityDid) + ) + ) for (const row of overrideRows) { overrideMap.set(row.did, { @@ -80,12 +76,12 @@ export async function resolveAuthors( avatarUrl: row.avatarUrl, bannerUrl: row.bannerUrl, bio: row.bio, - }); + }) } } // Merge: resolve each DID using resolveProfile, then project to AuthorProfile - const result = new Map(); + const result = new Map() for (const did of uniqueDids) { const source = sourceMap.get(did) ?? { did, @@ -94,17 +90,17 @@ export async function resolveAuthors( avatarUrl: null, bannerUrl: null, bio: null, - }; + } - const resolved = resolveProfile(source, overrideMap.get(did) ?? null); + const resolved = resolveProfile(source, overrideMap.get(did) ?? null) result.set(did, { did: resolved.did, handle: resolved.handle, displayName: resolved.displayName, avatarUrl: resolved.avatarUrl, - }); + }) } - return result; + return result } diff --git a/src/lib/resolve-profile.ts b/src/lib/resolve-profile.ts index 3a2551e..1315ce5 100644 --- a/src/lib/resolve-profile.ts +++ b/src/lib/resolve-profile.ts @@ -1,26 +1,26 @@ export interface SourceProfile { - did: string; - handle: string; - displayName: string | null; - avatarUrl: string | null; - bannerUrl: string | null; - bio: string | null; + did: string + handle: string + displayName: string | null + avatarUrl: string | null + bannerUrl: string | null + bio: string | null } export interface CommunityOverride { - displayName: string | null; - avatarUrl: string | null; - bannerUrl: string | null; - bio: string | null; + displayName: string | null + avatarUrl: string | null + bannerUrl: string | null + bio: string | null } export interface ResolvedProfile { - did: string; - handle: string; - displayName: string | null; - avatarUrl: string | null; - bannerUrl: string | null; - bio: string | null; + did: string + handle: string + displayName: string | null + avatarUrl: string | null + bannerUrl: string | null + bio: string | null } /** @@ -29,7 +29,7 @@ export interface ResolvedProfile { */ export function resolveProfile( source: SourceProfile, - override: CommunityOverride | null, + override: CommunityOverride | null ): ResolvedProfile { if (!override) { return { @@ -39,7 +39,7 @@ export function resolveProfile( avatarUrl: source.avatarUrl, bannerUrl: source.bannerUrl, bio: source.bio, - }; + } } return { @@ -49,5 +49,5 @@ export function resolveProfile( avatarUrl: override.avatarUrl ?? source.avatarUrl, bannerUrl: override.bannerUrl ?? source.bannerUrl, bio: override.bio ?? source.bio, - }; + } } diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 8c14530..559ecea 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -1,52 +1,48 @@ -import { randomUUID } from "node:crypto"; -import { mkdir, writeFile, unlink } from "node:fs/promises"; -import { join } from "node:path"; -import type { Logger } from "./logger.js"; +import { randomUUID } from 'node:crypto' +import { mkdir, writeFile, unlink } from 'node:fs/promises' +import { join } from 'node:path' +import type { Logger } from './logger.js' export interface StorageService { - store(data: Buffer, mimeType: string, prefix: string): Promise; - delete(url: string): Promise; + store(data: Buffer, mimeType: string, prefix: string): Promise + delete(url: string): Promise } const MIME_TO_EXT: Record = { - "image/jpeg": ".jpg", - "image/png": ".png", - "image/webp": ".webp", - "image/gif": ".gif", -}; + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif', +} export function createLocalStorage( uploadDir: string, baseUrl: string, - logger: Logger, + logger: Logger ): StorageService { return { - async store( - data: Buffer, - mimeType: string, - prefix: string, - ): Promise { - const ext = MIME_TO_EXT[mimeType] ?? ".bin"; - const filename = `${prefix}-${randomUUID()}${ext}`; - const dir = join(uploadDir, prefix); - await mkdir(dir, { recursive: true }); - const filepath = join(dir, filename); - await writeFile(filepath, data); - logger.debug({ filepath, size: data.length }, "File stored"); - return `${baseUrl}/uploads/${prefix}/${filename}`; + async store(data: Buffer, mimeType: string, prefix: string): Promise { + const ext = MIME_TO_EXT[mimeType] ?? '.bin' + const filename = `${prefix}-${randomUUID()}${ext}` + const dir = join(uploadDir, prefix) + await mkdir(dir, { recursive: true }) + const filepath = join(dir, filename) + await writeFile(filepath, data) + logger.debug({ filepath, size: data.length }, 'File stored') + return `${baseUrl}/uploads/${prefix}/${filename}` }, async delete(url: string): Promise { try { - const uploadsIdx = url.indexOf("/uploads/"); - if (uploadsIdx === -1) return; - const relativePath = url.slice(uploadsIdx + "/uploads/".length); - const filepath = join(uploadDir, relativePath); - await unlink(filepath); - logger.debug({ filepath }, "File deleted"); + const uploadsIdx = url.indexOf('/uploads/') + if (uploadsIdx === -1) return + const relativePath = url.slice(uploadsIdx + '/uploads/'.length) + const filepath = join(uploadDir, relativePath) + await unlink(filepath) + logger.debug({ filepath }, 'File deleted') } catch { // Best-effort deletion } }, - }; + } } diff --git a/src/routes/admin-settings.ts b/src/routes/admin-settings.ts index e79b69d..f5d6e19 100644 --- a/src/routes/admin-settings.ts +++ b/src/routes/admin-settings.ts @@ -1,85 +1,85 @@ -import { eq, sql } from "drizzle-orm"; -import type { FastifyPluginCallback } from "fastify"; -import { notFound, badRequest } from "../lib/api-errors.js"; -import { isMaturityLowerThan } from "../lib/maturity.js"; -import { updateSettingsSchema } from "../validation/admin-settings.js"; -import { communitySettings } from "../db/schema/community-settings.js"; -import { categories } from "../db/schema/categories.js"; +import { eq, sql } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { notFound, badRequest } from '../lib/api-errors.js' +import { isMaturityLowerThan } from '../lib/maturity.js' +import { updateSettingsSchema } from '../validation/admin-settings.js' +import { communitySettings } from '../db/schema/community-settings.js' +import { categories } from '../db/schema/categories.js' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- const settingsJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - id: { type: "string" as const }, - initialized: { type: "boolean" as const }, - communityDid: { type: ["string", "null"] as const }, - adminDid: { type: ["string", "null"] as const }, - communityName: { type: "string" as const }, - maturityRating: { type: "string" as const, enum: ["safe", "mature", "adult"] }, - reactionSet: { type: "array" as const, items: { type: "string" as const } }, - communityDescription: { type: ["string", "null"] as const }, - communityLogoUrl: { type: ["string", "null"] as const }, - primaryColor: { type: ["string", "null"] as const }, - accentColor: { type: ["string", "null"] as const }, - jurisdictionCountry: { type: ["string", "null"] as const }, - ageThreshold: { type: "integer" as const }, - requireLoginForMature: { type: "boolean" as const }, - createdAt: { type: "string" as const, format: "date-time" as const }, - updatedAt: { type: "string" as const, format: "date-time" as const }, + id: { type: 'string' as const }, + initialized: { type: 'boolean' as const }, + communityDid: { type: ['string', 'null'] as const }, + adminDid: { type: ['string', 'null'] as const }, + communityName: { type: 'string' as const }, + maturityRating: { type: 'string' as const, enum: ['safe', 'mature', 'adult'] }, + reactionSet: { type: 'array' as const, items: { type: 'string' as const } }, + communityDescription: { type: ['string', 'null'] as const }, + communityLogoUrl: { type: ['string', 'null'] as const }, + primaryColor: { type: ['string', 'null'] as const }, + accentColor: { type: ['string', 'null'] as const }, + jurisdictionCountry: { type: ['string', 'null'] as const }, + ageThreshold: { type: 'integer' as const }, + requireLoginForMature: { type: 'boolean' as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + updatedAt: { type: 'string' as const, format: 'date-time' as const }, }, -}; +} const errorJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, - message: { type: "string" as const }, - statusCode: { type: "integer" as const }, + error: { type: 'string' as const }, + message: { type: 'string' as const }, + statusCode: { type: 'integer' as const }, }, -}; +} const conflictJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, - message: { type: "string" as const }, - statusCode: { type: "integer" as const }, + error: { type: 'string' as const }, + message: { type: 'string' as const }, + statusCode: { type: 'integer' as const }, details: { - type: "object" as const, + type: 'object' as const, properties: { categories: { - type: "array" as const, + type: 'array' as const, items: { - type: "object" as const, + type: 'object' as const, properties: { - id: { type: "string" as const }, - slug: { type: "string" as const }, - name: { type: "string" as const }, - maturityRating: { type: "string" as const }, + id: { type: 'string' as const }, + slug: { type: 'string' as const }, + name: { type: 'string' as const }, + maturityRating: { type: 'string' as const }, }, }, }, }, }, }, -}; +} const statsJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - topicCount: { type: "integer" as const }, - replyCount: { type: "integer" as const }, - userCount: { type: "integer" as const }, - categoryCount: { type: "integer" as const }, - reportCount: { type: "integer" as const }, - recentTopics: { type: "integer" as const }, - recentReplies: { type: "integer" as const }, - recentUsers: { type: "integer" as const }, + topicCount: { type: 'integer' as const }, + replyCount: { type: 'integer' as const }, + userCount: { type: 'integer' as const }, + categoryCount: { type: 'integer' as const }, + reportCount: { type: 'integer' as const }, + recentTopics: { type: 'integer' as const }, + recentReplies: { type: 'integer' as const }, + recentUsers: { type: 'integer' as const }, }, -}; +} // --------------------------------------------------------------------------- // Helpers @@ -103,7 +103,7 @@ function serializeSettings(row: typeof communitySettings.$inferSelect) { requireLoginForMature: row.requireLoginForMature, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), - }; + } } // --------------------------------------------------------------------------- @@ -119,273 +119,294 @@ function serializeSettings(row: typeof communitySettings.$inferSelect) { */ export function adminSettingsRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db } = app; - const requireAdmin = app.requireAdmin; + const { db } = app + const requireAdmin = app.requireAdmin // ------------------------------------------------------------------- // GET /api/settings/public (no auth, public community info) // ------------------------------------------------------------------- - app.get("/api/settings/public", { - schema: { - tags: ["Settings"], - summary: "Get public community settings (no auth required)", - response: { - 200: { - type: "object" as const, - properties: { - communityDid: { type: ["string", "null"] as const }, - communityName: { type: "string" as const }, - maturityRating: { type: "string" as const, enum: ["safe", "mature", "adult"] }, - communityDescription: { type: ["string", "null"] as const }, - communityLogoUrl: { type: ["string", "null"] as const }, + app.get( + '/api/settings/public', + { + schema: { + tags: ['Settings'], + summary: 'Get public community settings (no auth required)', + response: { + 200: { + type: 'object' as const, + properties: { + communityDid: { type: ['string', 'null'] as const }, + communityName: { type: 'string' as const }, + maturityRating: { type: 'string' as const, enum: ['safe', 'mature', 'adult'] }, + communityDescription: { type: ['string', 'null'] as const }, + communityLogoUrl: { type: ['string', 'null'] as const }, + }, }, + 404: errorJsonSchema, }, - 404: errorJsonSchema, }, }, - }, async (_request, reply) => { - const rows = await db - .select() - .from(communitySettings) - .where(eq(communitySettings.id, "default")); - - const row = rows[0]; - if (!row) { - throw notFound("Community settings not found"); - } + async (_request, reply) => { + const rows = await db + .select() + .from(communitySettings) + .where(eq(communitySettings.id, 'default')) + + const row = rows[0] + if (!row) { + throw notFound('Community settings not found') + } - return reply.status(200).send({ - communityDid: row.communityDid ?? null, - communityName: row.communityName, - maturityRating: row.maturityRating, - communityDescription: row.communityDescription ?? null, - communityLogoUrl: row.communityLogoUrl ?? null, - }); - }); + return reply.status(200).send({ + communityDid: row.communityDid ?? null, + communityName: row.communityName, + maturityRating: row.maturityRating, + communityDescription: row.communityDescription ?? null, + communityLogoUrl: row.communityLogoUrl ?? null, + }) + } + ) // ------------------------------------------------------------------- // GET /api/admin/settings (admin only) // ------------------------------------------------------------------- - app.get("/api/admin/settings", { - preHandler: [requireAdmin], - schema: { - tags: ["Admin"], - summary: "Get community settings", - security: [{ bearerAuth: [] }], - response: { - 200: settingsJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + app.get( + '/api/admin/settings', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin'], + summary: 'Get community settings', + security: [{ bearerAuth: [] }], + response: { + 200: settingsJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + }, }, }, - }, async (_request, reply) => { - const rows = await db - .select() - .from(communitySettings) - .where(eq(communitySettings.id, "default")); - - const row = rows[0]; - if (!row) { - throw notFound("Community settings not found"); - } + async (_request, reply) => { + const rows = await db + .select() + .from(communitySettings) + .where(eq(communitySettings.id, 'default')) + + const row = rows[0] + if (!row) { + throw notFound('Community settings not found') + } - return reply.status(200).send(serializeSettings(row)); - }); + return reply.status(200).send(serializeSettings(row)) + } + ) // ------------------------------------------------------------------- // PUT /api/admin/settings (admin only) // ------------------------------------------------------------------- - app.put("/api/admin/settings", { - preHandler: [requireAdmin], - schema: { - tags: ["Admin"], - summary: "Update community settings", - security: [{ bearerAuth: [] }], - body: { - type: "object", - properties: { - communityName: { type: "string", minLength: 1, maxLength: 100 }, - maturityRating: { type: "string", enum: ["safe", "mature", "adult"] }, - reactionSet: { - type: "array", - items: { type: "string", minLength: 1, maxLength: 30 }, - minItems: 1, + app.put( + '/api/admin/settings', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin'], + summary: 'Update community settings', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + properties: { + communityName: { type: 'string', minLength: 1, maxLength: 100 }, + maturityRating: { type: 'string', enum: ['safe', 'mature', 'adult'] }, + reactionSet: { + type: 'array', + items: { type: 'string', minLength: 1, maxLength: 30 }, + minItems: 1, + }, + communityDescription: { type: 'string', maxLength: 500 }, + communityLogoUrl: { type: 'string', format: 'uri' }, + primaryColor: { + type: 'string', + pattern: '^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$', + }, + accentColor: { + type: 'string', + pattern: '^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$', + }, + jurisdictionCountry: { type: ['string', 'null'] }, + ageThreshold: { type: 'integer', minimum: 13, maximum: 18 }, + requireLoginForMature: { type: 'boolean' }, }, - communityDescription: { type: "string", maxLength: 500 }, - communityLogoUrl: { type: "string", format: "uri" }, - primaryColor: { type: "string", pattern: "^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" }, - accentColor: { type: "string", pattern: "^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" }, - jurisdictionCountry: { type: ["string", "null"] }, - ageThreshold: { type: "integer", minimum: 13, maximum: 18 }, - requireLoginForMature: { type: "boolean" }, }, - }, - response: { - 200: settingsJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 409: conflictJsonSchema, + response: { + 200: settingsJsonSchema, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + 409: conflictJsonSchema, + }, }, }, - }, async (request, reply) => { - const parsed = updateSettingsSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid settings data"); - } + async (request, reply) => { + const parsed = updateSettingsSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid settings data') + } - const updates = parsed.data; - - // Require at least one field to update - if ( - updates.communityName === undefined && - updates.maturityRating === undefined && - updates.reactionSet === undefined && - updates.communityDescription === undefined && - updates.communityLogoUrl === undefined && - updates.primaryColor === undefined && - updates.accentColor === undefined && - updates.jurisdictionCountry === undefined && - updates.ageThreshold === undefined && - updates.requireLoginForMature === undefined - ) { - throw badRequest("At least one field must be provided"); - } + const updates = parsed.data + + // Require at least one field to update + if ( + updates.communityName === undefined && + updates.maturityRating === undefined && + updates.reactionSet === undefined && + updates.communityDescription === undefined && + updates.communityLogoUrl === undefined && + updates.primaryColor === undefined && + updates.accentColor === undefined && + updates.jurisdictionCountry === undefined && + updates.ageThreshold === undefined && + updates.requireLoginForMature === undefined + ) { + throw badRequest('At least one field must be provided') + } - // Fetch current settings - const rows = await db - .select() - .from(communitySettings) - .where(eq(communitySettings.id, "default")); + // Fetch current settings + const rows = await db + .select() + .from(communitySettings) + .where(eq(communitySettings.id, 'default')) - const current = rows[0]; - if (!current) { - throw notFound("Community settings not found"); - } + const current = rows[0] + if (!current) { + throw notFound('Community settings not found') + } - // If the community maturity floor is being raised, check for incompatible - // categories. Lowering the floor (relaxing constraints) is always allowed - // because existing categories remain above the new, lower threshold. - if ( - updates.maturityRating !== undefined && - updates.maturityRating !== current.maturityRating - ) { - const newRating = updates.maturityRating; - const currentRating = current.maturityRating; - - if (isMaturityLowerThan(currentRating, newRating)) { - // Raising maturity: find categories below the new threshold - const communityDid = current.communityDid ?? ""; - const allCategories = await db - .select() - .from(categories) - .where(eq(categories.communityDid, communityDid)); - - // Filter in application code since maturity comparison is enum-based - const belowThreshold = allCategories.filter((cat) => - isMaturityLowerThan(cat.maturityRating, newRating), - ); - - if (belowThreshold.length > 0) { - return reply.status(409).send({ - error: "Conflict", - message: `Cannot raise community maturity to "${newRating}": ${String(belowThreshold.length)} categories have a lower maturity rating. Update these categories first.`, - statusCode: 409, - details: { - categories: belowThreshold.map((cat) => ({ - id: cat.id, - slug: cat.slug, - name: cat.name, - maturityRating: cat.maturityRating, - })), - }, - }); + // If the community maturity floor is being raised, check for incompatible + // categories. Lowering the floor (relaxing constraints) is always allowed + // because existing categories remain above the new, lower threshold. + if ( + updates.maturityRating !== undefined && + updates.maturityRating !== current.maturityRating + ) { + const newRating = updates.maturityRating + const currentRating = current.maturityRating + + if (isMaturityLowerThan(currentRating, newRating)) { + // Raising maturity: find categories below the new threshold + const communityDid = current.communityDid ?? '' + const allCategories = await db + .select() + .from(categories) + .where(eq(categories.communityDid, communityDid)) + + // Filter in application code since maturity comparison is enum-based + const belowThreshold = allCategories.filter((cat) => + isMaturityLowerThan(cat.maturityRating, newRating) + ) + + if (belowThreshold.length > 0) { + return reply.status(409).send({ + error: 'Conflict', + message: `Cannot raise community maturity to "${newRating}": ${String(belowThreshold.length)} categories have a lower maturity rating. Update these categories first.`, + statusCode: 409, + details: { + categories: belowThreshold.map((cat) => ({ + id: cat.id, + slug: cat.slug, + name: cat.name, + maturityRating: cat.maturityRating, + })), + }, + }) + } } } - } - // Build update set - const dbUpdates: Record = { - updatedAt: new Date(), - }; - if (updates.communityName !== undefined) { - dbUpdates.communityName = updates.communityName; - } - if (updates.maturityRating !== undefined) { - dbUpdates.maturityRating = updates.maturityRating; - } - if (updates.reactionSet !== undefined) { - dbUpdates.reactionSet = updates.reactionSet; - } - if (updates.communityDescription !== undefined) { - dbUpdates.communityDescription = updates.communityDescription; - } - if (updates.communityLogoUrl !== undefined) { - dbUpdates.communityLogoUrl = updates.communityLogoUrl; - } - if (updates.primaryColor !== undefined) { - dbUpdates.primaryColor = updates.primaryColor; - } - if (updates.accentColor !== undefined) { - dbUpdates.accentColor = updates.accentColor; - } - if (updates.jurisdictionCountry !== undefined) { - dbUpdates.jurisdictionCountry = updates.jurisdictionCountry; - } - if (updates.ageThreshold !== undefined) { - dbUpdates.ageThreshold = updates.ageThreshold; - } - if (updates.requireLoginForMature !== undefined) { - dbUpdates.requireLoginForMature = updates.requireLoginForMature; - } + // Build update set + const dbUpdates: Record = { + updatedAt: new Date(), + } + if (updates.communityName !== undefined) { + dbUpdates.communityName = updates.communityName + } + if (updates.maturityRating !== undefined) { + dbUpdates.maturityRating = updates.maturityRating + } + if (updates.reactionSet !== undefined) { + dbUpdates.reactionSet = updates.reactionSet + } + if (updates.communityDescription !== undefined) { + dbUpdates.communityDescription = updates.communityDescription + } + if (updates.communityLogoUrl !== undefined) { + dbUpdates.communityLogoUrl = updates.communityLogoUrl + } + if (updates.primaryColor !== undefined) { + dbUpdates.primaryColor = updates.primaryColor + } + if (updates.accentColor !== undefined) { + dbUpdates.accentColor = updates.accentColor + } + if (updates.jurisdictionCountry !== undefined) { + dbUpdates.jurisdictionCountry = updates.jurisdictionCountry + } + if (updates.ageThreshold !== undefined) { + dbUpdates.ageThreshold = updates.ageThreshold + } + if (updates.requireLoginForMature !== undefined) { + dbUpdates.requireLoginForMature = updates.requireLoginForMature + } - const updated = await db - .update(communitySettings) - .set(dbUpdates) - .where(eq(communitySettings.id, "default")) - .returning(); + const updated = await db + .update(communitySettings) + .set(dbUpdates) + .where(eq(communitySettings.id, 'default')) + .returning() - const updatedRow = updated[0]; - if (!updatedRow) { - throw notFound("Community settings not found after update"); - } + const updatedRow = updated[0] + if (!updatedRow) { + throw notFound('Community settings not found after update') + } - // TODO: Write to admin_audit_log table when implemented (standards/backend.md audit logging) - app.log.info( - { - event: "settings_updated", - did: request.user?.did, - changes: Object.keys(parsed.data), - }, - "Community settings updated", - ); + // TODO: Write to admin_audit_log table when implemented (standards/backend.md audit logging) + app.log.info( + { + event: 'settings_updated', + did: request.user?.did, + changes: Object.keys(parsed.data), + }, + 'Community settings updated' + ) - return reply.status(200).send(serializeSettings(updatedRow)); - }); + return reply.status(200).send(serializeSettings(updatedRow)) + } + ) // ------------------------------------------------------------------- // GET /api/admin/stats (admin only) // ------------------------------------------------------------------- - app.get("/api/admin/stats", { - preHandler: [requireAdmin], - schema: { - tags: ["Admin"], - summary: "Get community statistics", - security: [{ bearerAuth: [] }], - response: { - 200: statsJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, + app.get( + '/api/admin/stats', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin'], + summary: 'Get community statistics', + security: [{ bearerAuth: [] }], + response: { + 200: statsJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + }, }, }, - }, async (_request, reply) => { - const result = await db.execute(sql` + async (_request, reply) => { + const result = await db.execute(sql` SELECT (SELECT COUNT(*) FROM topics WHERE is_mod_deleted = false) AS topic_count, (SELECT COUNT(*) FROM replies) AS reply_count, @@ -395,47 +416,48 @@ export function adminSettingsRoutes(): FastifyPluginCallback { (SELECT COUNT(*) FROM topics WHERE is_mod_deleted = false AND created_at > NOW() - INTERVAL '7 days') AS recent_topics, (SELECT COUNT(*) FROM replies WHERE created_at > NOW() - INTERVAL '7 days') AS recent_replies, (SELECT COUNT(*) FROM users WHERE first_seen_at > NOW() - INTERVAL '7 days') AS recent_users - `); - - interface StatsRow { - topic_count: string; - reply_count: string; - user_count: string; - category_count: string; - report_count: string; - recent_topics: string; - recent_replies: string; - recent_users: string; - } + `) + + interface StatsRow { + topic_count: string + reply_count: string + user_count: string + category_count: string + report_count: string + recent_topics: string + recent_replies: string + recent_users: string + } + + const rows = result as unknown as StatsRow[] + const row = rows[0] + if (!row) { + // Should never happen -- subquery always returns one row + return reply.status(200).send({ + topicCount: 0, + replyCount: 0, + userCount: 0, + categoryCount: 0, + reportCount: 0, + recentTopics: 0, + recentReplies: 0, + recentUsers: 0, + }) + } - const rows = result as unknown as StatsRow[]; - const row = rows[0]; - if (!row) { - // Should never happen -- subquery always returns one row return reply.status(200).send({ - topicCount: 0, - replyCount: 0, - userCount: 0, - categoryCount: 0, - reportCount: 0, - recentTopics: 0, - recentReplies: 0, - recentUsers: 0, - }); + topicCount: Number(row.topic_count), + replyCount: Number(row.reply_count), + userCount: Number(row.user_count), + categoryCount: Number(row.category_count), + reportCount: Number(row.report_count), + recentTopics: Number(row.recent_topics), + recentReplies: Number(row.recent_replies), + recentUsers: Number(row.recent_users), + }) } + ) - return reply.status(200).send({ - topicCount: Number(row.topic_count), - replyCount: Number(row.reply_count), - userCount: Number(row.user_count), - categoryCount: Number(row.category_count), - reportCount: Number(row.report_count), - recentTopics: Number(row.recent_topics), - recentReplies: Number(row.recent_replies), - recentUsers: Number(row.recent_users), - }); - }); - - done(); - }; + done() + } } diff --git a/src/routes/admin-sybil.ts b/src/routes/admin-sybil.ts new file mode 100644 index 0000000..d875ba4 --- /dev/null +++ b/src/routes/admin-sybil.ts @@ -0,0 +1,1201 @@ +import { eq, and, desc, sql, count } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { notFound, badRequest, tooManyRequests } from '../lib/api-errors.js' +import { + trustSeedCreateSchema, + trustSeedQuerySchema, + clusterQuerySchema, + clusterStatusUpdateSchema, + pdsTrustUpdateSchema, + pdsTrustQuerySchema, + behavioralFlagUpdateSchema, + behavioralFlagQuerySchema, +} from '../validation/sybil.js' +import { trustSeeds } from '../db/schema/trust-seeds.js' +import { sybilClusters } from '../db/schema/sybil-clusters.js' +import { sybilClusterMembers } from '../db/schema/sybil-cluster-members.js' +import { users } from '../db/schema/users.js' +import { trustScores } from '../db/schema/trust-scores.js' +import { interactionGraph } from '../db/schema/interaction-graph.js' +import { behavioralFlags } from '../db/schema/behavioral-flags.js' +import { pdsTrustFactors } from '../db/schema/pds-trust-factors.js' + +// --------------------------------------------------------------------------- +// OpenAPI JSON Schema definitions +// --------------------------------------------------------------------------- + +const errorJsonSchema = { + type: 'object' as const, + properties: { + error: { type: 'string' as const }, + }, +} + +const trustSeedJsonSchema = { + type: 'object' as const, + properties: { + id: { type: 'number' as const }, + did: { type: 'string' as const }, + handle: { type: ['string', 'null'] as const }, + displayName: { type: ['string', 'null'] as const }, + communityId: { type: ['string', 'null'] as const }, + addedBy: { type: 'string' as const }, + reason: { type: ['string', 'null'] as const }, + implicit: { type: 'boolean' as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + }, +} + +const sybilClusterJsonSchema = { + type: 'object' as const, + properties: { + id: { type: 'number' as const }, + clusterHash: { type: 'string' as const }, + internalEdgeCount: { type: 'number' as const }, + externalEdgeCount: { type: 'number' as const }, + memberCount: { type: 'number' as const }, + suspicionRatio: { type: 'number' as const }, + status: { type: 'string' as const }, + reviewedBy: { type: ['string', 'null'] as const }, + reviewedAt: { type: ['string', 'null'] as const }, + detectedAt: { type: 'string' as const, format: 'date-time' as const }, + updatedAt: { type: 'string' as const, format: 'date-time' as const }, + }, +} + +const pdsTrustJsonSchema = { + type: 'object' as const, + properties: { + id: { type: 'number' as const }, + pdsHost: { type: 'string' as const }, + trustFactor: { type: 'number' as const }, + isDefault: { type: 'boolean' as const }, + updatedAt: { type: 'string' as const, format: 'date-time' as const }, + }, +} + +const behavioralFlagJsonSchema = { + type: 'object' as const, + properties: { + id: { type: 'number' as const }, + flagType: { type: 'string' as const }, + affectedDids: { type: 'array' as const, items: { type: 'string' as const } }, + details: { type: 'string' as const }, + status: { type: 'string' as const }, + detectedAt: { type: 'string' as const, format: 'date-time' as const }, + }, +} + +const clusterMemberJsonSchema = { + type: 'object' as const, + properties: { + did: { type: 'string' as const }, + handle: { type: ['string', 'null'] as const }, + displayName: { type: ['string', 'null'] as const }, + trustScore: { type: ['number', 'null'] as const }, + reputationScore: { type: 'number' as const }, + accountAge: { type: ['string', 'null'] as const }, + roleInCluster: { type: 'string' as const }, + joinedAt: { type: 'string' as const, format: 'date-time' as const }, + }, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function encodeCursor(createdAt: string, id: number): string { + return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64') +} + +function decodeCursor(cursor: string): { createdAt: string; id: number } | null { + try { + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as Record< + string, + unknown + > + if (typeof decoded.createdAt === 'string' && typeof decoded.id === 'number') { + return { createdAt: decoded.createdAt, id: decoded.id } + } + return null + } catch { + return null + } +} + +interface TrustSeedWithUser { + seed: typeof trustSeeds.$inferSelect + handle: string | null + displayName: string | null +} + +function serializeTrustSeed(row: TrustSeedWithUser, implicit: boolean) { + return { + id: row.seed.id, + did: row.seed.did, + handle: row.handle, + displayName: row.displayName, + communityId: row.seed.communityId || null, // Convert "" sentinel back to null for API + addedBy: row.seed.addedBy, + reason: row.seed.reason, + implicit, + createdAt: row.seed.createdAt.toISOString(), + } +} + +function computeSuspicionRatio(internalEdgeCount: number, externalEdgeCount: number): number { + const total = internalEdgeCount + externalEdgeCount + return total > 0 ? internalEdgeCount / total : 0 +} + +function serializeCluster(row: typeof sybilClusters.$inferSelect) { + return { + id: row.id, + clusterHash: row.clusterHash, + internalEdgeCount: row.internalEdgeCount, + externalEdgeCount: row.externalEdgeCount, + memberCount: row.memberCount, + suspicionRatio: computeSuspicionRatio(row.internalEdgeCount, row.externalEdgeCount), + status: row.status, + reviewedBy: row.reviewedBy, + reviewedAt: row.reviewedAt?.toISOString() ?? null, + detectedAt: row.detectedAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +function serializePdsTrust(row: typeof pdsTrustFactors.$inferSelect) { + return { + id: row.id, + pdsHost: row.pdsHost, + trustFactor: row.trustFactor, + isDefault: row.isDefault, + updatedAt: row.updatedAt.toISOString(), + } +} + +function serializeBehavioralFlag(row: typeof behavioralFlags.$inferSelect) { + return { + id: row.id, + flagType: row.flagType, + affectedDids: row.affectedDids, + details: row.details, + status: row.status, + detectedAt: row.detectedAt.toISOString(), + } +} + +// Rate limit key for trust graph recompute +const RECOMPUTE_CACHE_KEY = 'trust-graph:last-recompute' +const RECOMPUTE_COOLDOWN_MS = 60 * 60 * 1000 // 1 hour + +/** Fire-and-forget trust graph recomputation. */ +function triggerRecompute(app: { + trustGraphService: { computeTrustScores(communityId: string | null): Promise } + log: { warn(obj: unknown, msg: string): void; info(msg: string): void } +}): void { + app.log.info('Triggering fire-and-forget trust graph recompute') + app.trustGraphService.computeTrustScores(null).catch((err: unknown) => { + app.log.warn({ err }, 'Trust graph recompute failed') + }) +} + +// --------------------------------------------------------------------------- +// Admin sybil routes plugin +// --------------------------------------------------------------------------- + +export function adminSybilRoutes(): FastifyPluginCallback { + return (app, _opts, done) => { + const { db, cache } = app + const requireAdmin = app.requireAdmin + + // ======================================================================= + // TRUST SEED ROUTES + // ======================================================================= + + // ------------------------------------------------------------------- + // GET /api/admin/trust-seeds + // ------------------------------------------------------------------- + + app.get( + '/api/admin/trust-seeds', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin - Sybil'], + summary: 'List trust seeds (including implicit seeds from mods/admins)', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', + properties: { + cursor: { type: 'string' }, + limit: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + seeds: { type: 'array', items: trustSeedJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, + }, + 400: errorJsonSchema, + }, + }, + }, + async (request, reply) => { + const parsed = trustSeedQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } + + const { cursor, limit } = parsed.data + + // Fetch explicit trust seeds joined with users for handle/displayName + const conditions = [] + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${trustSeeds.createdAt}, ${trustSeeds.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` + ) + } + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + const fetchLimit = limit + 1 + + const explicitRows = await db + .select({ + seed: trustSeeds, + handle: users.handle, + displayName: users.displayName, + }) + .from(trustSeeds) + .leftJoin(users, eq(trustSeeds.did, users.did)) + .where(whereClause) + .orderBy(desc(trustSeeds.createdAt)) + .limit(fetchLimit) + + const hasMore = explicitRows.length > limit + const resultRows = hasMore ? explicitRows.slice(0, limit) : explicitRows + + // Fetch implicit seeds (admins and moderators) + const implicitUsers = await db + .select({ + did: users.did, + handle: users.handle, + displayName: users.displayName, + role: users.role, + firstSeenAt: users.firstSeenAt, + }) + .from(users) + .where(sql`${users.role} IN ('admin', 'moderator')`) + + // Merge explicit seeds with implicit ones + const explicitDids = new Set(resultRows.map((r) => r.seed.did)) + const implicitSeeds = implicitUsers + .filter((u) => !explicitDids.has(u.did)) + .map((u) => ({ + id: 0, + did: u.did, + handle: u.handle, + displayName: u.displayName, + communityId: null, + addedBy: 'system', + reason: `Implicit trust seed (${u.role})`, + implicit: true, + createdAt: u.firstSeenAt.toISOString(), + })) + + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.seed.createdAt.toISOString(), lastRow.seed.id) + } + } + + return reply.status(200).send({ + seeds: [...resultRows.map((r) => serializeTrustSeed(r, false)), ...implicitSeeds], + cursor: nextCursor, + }) + } + ) + + // ------------------------------------------------------------------- + // POST /api/admin/trust-seeds + // ------------------------------------------------------------------- + + app.post( + '/api/admin/trust-seeds', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin - Sybil'], + summary: 'Add a trust seed (triggers trust graph recompute)', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + required: ['did'], + properties: { + did: { type: 'string', minLength: 1 }, + communityId: { type: 'string' }, + reason: { type: 'string', maxLength: 500 }, + }, + }, + response: { + 201: trustSeedJsonSchema, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + }, + }, + }, + async (request, reply) => { + const admin = request.user + if (!admin) { + return reply.status(401).send({ error: 'Authentication required' }) + } + + const parsed = trustSeedCreateSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid trust seed data') + } + + const { did, communityId, reason } = parsed.data + + // Validate DID exists in users table and fetch handle/displayName + const userRows = await db + .select({ did: users.did, handle: users.handle, displayName: users.displayName }) + .from(users) + .where(eq(users.did, did)) + + if (userRows.length === 0) { + throw notFound('User not found') + } + + const inserted = await db + .insert(trustSeeds) + .values({ + did, + communityId: communityId ?? '', + addedBy: admin.did, + reason: reason ?? null, + }) + .returning() + + const seed = inserted[0] + if (!seed) { + throw badRequest('Failed to create trust seed') + } + + app.log.info({ seedId: seed.id, did, addedBy: admin.did }, 'Trust seed added') + + // Fire-and-forget trust graph recomputation + triggerRecompute(app) + + const user = userRows[0] + return reply + .status(201) + .send( + serializeTrustSeed( + { seed, handle: user?.handle ?? null, displayName: user?.displayName ?? null }, + false + ) + ) + } + ) + + // ------------------------------------------------------------------- + // DELETE /api/admin/trust-seeds/:id + // ------------------------------------------------------------------- + + app.delete( + '/api/admin/trust-seeds/:id', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin - Sybil'], + summary: 'Remove a trust seed (triggers recompute)', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + response: { + 204: { type: 'null' as const }, + 400: errorJsonSchema, + 404: errorJsonSchema, + }, + }, + }, + async (request, reply) => { + const { id } = request.params as { id: string } + const seedId = Number(id) + if (Number.isNaN(seedId)) { + throw badRequest('Invalid seed ID') + } + + const existing = await db + .select({ id: trustSeeds.id }) + .from(trustSeeds) + .where(eq(trustSeeds.id, seedId)) + + if (existing.length === 0) { + throw notFound('Trust seed not found') + } + + await db.delete(trustSeeds).where(eq(trustSeeds.id, seedId)) + + app.log.info({ seedId }, 'Trust seed removed') + + // Fire-and-forget trust graph recomputation + triggerRecompute(app) + + return reply.status(204).send() + } + ) + + // ======================================================================= + // SYBIL CLUSTER ROUTES + // ======================================================================= + + // ------------------------------------------------------------------- + // GET /api/admin/sybil-clusters + // ------------------------------------------------------------------- + + app.get( + '/api/admin/sybil-clusters', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin - Sybil'], + summary: 'List sybil clusters (paginated, filterable)', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', + properties: { + status: { type: 'string', enum: ['flagged', 'dismissed', 'monitoring', 'banned'] }, + cursor: { type: 'string' }, + limit: { type: 'string' }, + sort: { type: 'string', enum: ['detected_at', 'member_count', 'confidence'] }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + clusters: { type: 'array', items: sybilClusterJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, + }, + 400: errorJsonSchema, + }, + }, + }, + async (request, reply) => { + const parsed = clusterQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } + + const { status, cursor, limit, sort } = parsed.data + const conditions = [] + + if (status) { + conditions.push(eq(sybilClusters.status, status)) + } + + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${sybilClusters.detectedAt}, ${sybilClusters.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` + ) + } + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + const fetchLimit = limit + 1 + + // Determine sort order + let orderByCol + switch (sort) { + case 'member_count': + orderByCol = desc(sybilClusters.memberCount) + break + case 'confidence': + // L5: Sort by suspicion ratio (internal / (internal + external)) + orderByCol = desc( + sql`CASE WHEN (${sybilClusters.internalEdgeCount} + ${sybilClusters.externalEdgeCount}) > 0 + THEN ${sybilClusters.internalEdgeCount}::real / (${sybilClusters.internalEdgeCount} + ${sybilClusters.externalEdgeCount})::real + ELSE 0 END` + ) + break + default: + orderByCol = desc(sybilClusters.detectedAt) + } + + const rows = await db + .select() + .from(sybilClusters) + .where(whereClause) + .orderBy(orderByCol) + .limit(fetchLimit) + + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.detectedAt.toISOString(), lastRow.id) + } + } + + return reply.status(200).send({ + clusters: resultRows.map(serializeCluster), + cursor: nextCursor, + }) + } + ) + + // ------------------------------------------------------------------- + // GET /api/admin/sybil-clusters/:id + // ------------------------------------------------------------------- + + app.get( + '/api/admin/sybil-clusters/:id', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin - Sybil'], + summary: 'Get sybil cluster detail with enriched member list', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + response: { + 200: { + type: 'object', + properties: { + ...sybilClusterJsonSchema.properties, + members: { + type: 'array', + items: clusterMemberJsonSchema, + }, + }, + }, + 400: errorJsonSchema, + 404: errorJsonSchema, + }, + }, + }, + async (request, reply) => { + const { id } = request.params as { id: string } + const clusterId = Number(id) + if (Number.isNaN(clusterId)) { + throw badRequest('Invalid cluster ID') + } + + const clusterRows = await db + .select() + .from(sybilClusters) + .where(eq(sybilClusters.id, clusterId)) + + const cluster = clusterRows[0] + if (!cluster) { + throw notFound('Sybil cluster not found') + } + + // M5: Enriched member list with user data and trust scores + const members = await db + .select({ + did: sybilClusterMembers.did, + roleInCluster: sybilClusterMembers.roleInCluster, + joinedAt: sybilClusterMembers.joinedAt, + handle: users.handle, + displayName: users.displayName, + reputationScore: users.reputationScore, + accountCreatedAt: users.accountCreatedAt, + trustScore: trustScores.score, + }) + .from(sybilClusterMembers) + .leftJoin(users, eq(sybilClusterMembers.did, users.did)) + .leftJoin(trustScores, eq(sybilClusterMembers.did, trustScores.did)) + .where(eq(sybilClusterMembers.clusterId, clusterId)) + + return reply.status(200).send({ + ...serializeCluster(cluster), + members: members.map((m) => ({ + did: m.did, + handle: m.handle ?? null, + displayName: m.displayName ?? null, + trustScore: m.trustScore ?? null, + reputationScore: m.reputationScore ?? 0, + accountAge: m.accountCreatedAt?.toISOString() ?? null, + roleInCluster: m.roleInCluster, + joinedAt: m.joinedAt.toISOString(), + })), + }) + } + ) + + // ------------------------------------------------------------------- + // PUT /api/admin/sybil-clusters/:id + // ------------------------------------------------------------------- + + app.put( + '/api/admin/sybil-clusters/:id', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin - Sybil'], + summary: 'Update sybil cluster status (handles ban propagation)', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + body: { + type: 'object', + required: ['status'], + properties: { + status: { type: 'string', enum: ['dismissed', 'monitoring', 'banned'] }, + }, + }, + response: { + 200: sybilClusterJsonSchema, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + }, + }, + }, + async (request, reply) => { + const admin = request.user + if (!admin) { + return reply.status(401).send({ error: 'Authentication required' }) + } + + const { id } = request.params as { id: string } + const clusterId = Number(id) + if (Number.isNaN(clusterId)) { + throw badRequest('Invalid cluster ID') + } + + const parsed = clusterStatusUpdateSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid status update') + } + + const clusterRows = await db + .select() + .from(sybilClusters) + .where(eq(sybilClusters.id, clusterId)) + + const cluster = clusterRows[0] + if (!cluster) { + throw notFound('Sybil cluster not found') + } + + const now = new Date() + const updated = await db + .update(sybilClusters) + .set({ + status: parsed.data.status, + reviewedBy: admin.did, + reviewedAt: now, + updatedAt: now, + }) + .where(eq(sybilClusters.id, clusterId)) + .returning() + + const updatedCluster = updated[0] + if (!updatedCluster) { + throw notFound('Cluster not found after update') + } + + // If status is 'banned', propagate ban to all cluster members + if (parsed.data.status === 'banned') { + const members = await db + .select({ did: sybilClusterMembers.did }) + .from(sybilClusterMembers) + .where(eq(sybilClusterMembers.clusterId, clusterId)) + + for (const member of members) { + await db.update(users).set({ isBanned: true }).where(eq(users.did, member.did)) + } + + app.log.warn( + { + clusterId, + bannedDids: members.map((m) => m.did), + adminDid: admin.did, + }, + 'Sybil cluster banned, propagated to all members' + ) + } else { + app.log.info( + { clusterId, status: parsed.data.status, adminDid: admin.did }, + 'Sybil cluster status updated' + ) + } + + return reply.status(200).send(serializeCluster(updatedCluster)) + } + ) + + // ======================================================================= + // PDS TRUST FACTOR ROUTES + // ======================================================================= + + // ------------------------------------------------------------------- + // GET /api/admin/pds-trust + // ------------------------------------------------------------------- + + app.get( + '/api/admin/pds-trust', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin - Sybil'], + summary: 'List PDS trust factors (with defaults)', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', + properties: { + cursor: { type: 'string' }, + limit: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + factors: { type: 'array', items: pdsTrustJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, + }, + 400: errorJsonSchema, + }, + }, + }, + async (request, reply) => { + const parsed = pdsTrustQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } + + const { cursor, limit } = parsed.data + const conditions = [] + + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${pdsTrustFactors.updatedAt}, ${pdsTrustFactors.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` + ) + } + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + const fetchLimit = limit + 1 + + const rows = await db + .select() + .from(pdsTrustFactors) + .where(whereClause) + .orderBy(desc(pdsTrustFactors.updatedAt)) + .limit(fetchLimit) + + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.updatedAt.toISOString(), lastRow.id) + } + } + + return reply.status(200).send({ + factors: resultRows.map(serializePdsTrust), + cursor: nextCursor, + }) + } + ) + + // ------------------------------------------------------------------- + // PUT /api/admin/pds-trust + // ------------------------------------------------------------------- + + app.put( + '/api/admin/pds-trust', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin - Sybil'], + summary: 'Create or update PDS trust factor override', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + required: ['pdsHost', 'trustFactor'], + properties: { + pdsHost: { type: 'string', minLength: 1 }, + trustFactor: { type: 'number', minimum: 0, maximum: 1 }, + }, + }, + response: { + 200: pdsTrustJsonSchema, + 400: errorJsonSchema, + }, + }, + }, + async (request, reply) => { + const parsed = pdsTrustUpdateSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid PDS trust data') + } + + const { pdsHost, trustFactor } = parsed.data + const now = new Date() + + const upserted = await db + .insert(pdsTrustFactors) + .values({ + pdsHost, + trustFactor, + isDefault: false, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [pdsTrustFactors.pdsHost], + set: { + trustFactor, + isDefault: false, + updatedAt: now, + }, + }) + .returning() + + const row = upserted[0] + if (!row) { + throw badRequest('Failed to upsert PDS trust factor') + } + + app.log.info({ pdsHost, trustFactor }, 'PDS trust factor updated') + + return reply.status(200).send(serializePdsTrust(row)) + } + ) + + // ======================================================================= + // TRUST GRAPH ADMIN ROUTES + // ======================================================================= + + // ------------------------------------------------------------------- + // POST /api/admin/trust-graph/recompute + // ------------------------------------------------------------------- + + app.post( + '/api/admin/trust-graph/recompute', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin - Sybil'], + summary: 'Trigger trust graph recomputation (rate limited: 1/hour)', + security: [{ bearerAuth: [] }], + response: { + 202: { + type: 'object', + properties: { + message: { type: 'string' }, + startedAt: { type: 'string', format: 'date-time' }, + }, + }, + 429: errorJsonSchema, + }, + }, + }, + async (_request, reply) => { + // Rate limit: 1 recompute per hour + try { + const lastRecompute = await cache.get(RECOMPUTE_CACHE_KEY) + if (lastRecompute) { + const lastTime = Number(lastRecompute) + if (Date.now() - lastTime < RECOMPUTE_COOLDOWN_MS) { + throw tooManyRequests('Trust graph recompute is rate limited to once per hour') + } + } + } catch (err) { + if (err instanceof Error && err.message.includes('rate limited')) { + throw err + } + // Cache errors are non-critical, proceed + } + + const now = new Date() + + // Mark recompute as started in cache + try { + await cache.set(RECOMPUTE_CACHE_KEY, String(now.getTime()), 'EX', 3600) + } catch { + // Non-critical + } + + // H5: Trigger actual trust graph recomputation (fire-and-forget) + triggerRecompute(app) + + return reply.status(202).send({ + message: 'Trust graph recomputation started', + startedAt: now.toISOString(), + }) + } + ) + + // ------------------------------------------------------------------- + // GET /api/admin/trust-graph/status + // ------------------------------------------------------------------- + + app.get( + '/api/admin/trust-graph/status', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin - Sybil'], + summary: 'Get trust graph computation stats', + security: [{ bearerAuth: [] }], + response: { + 200: { + type: 'object', + properties: { + lastComputedAt: { type: ['string', 'null'] }, + totalNodes: { type: 'number' }, + totalEdges: { type: 'number' }, + computationDurationMs: { type: ['number', 'null'] }, + clustersFlagged: { type: 'number' }, + nextScheduledAt: { type: ['string', 'null'] }, + }, + }, + }, + }, + }, + async (_request, reply) => { + // Get last recompute time from cache + let lastComputedAt: string | null = null + let computationDurationMs: number | null = null + let nextScheduledAt: string | null = null + try { + const cached = await cache.get(RECOMPUTE_CACHE_KEY) + if (cached) { + const lastTime = Number(cached) + lastComputedAt = new Date(lastTime).toISOString() + // Next scheduled: 1 hour after last computation + nextScheduledAt = new Date(lastTime + RECOMPUTE_COOLDOWN_MS).toISOString() + } + + // Check for stored duration + const durationCached = await cache.get('trust-graph:last-duration-ms') + if (durationCached) { + computationDurationMs = Number(durationCached) + } + } catch { + // Non-critical + } + + // C2: Get counts from database using Drizzle ORM (no raw SQL) + const [nodeRows, edgeRows, flaggedRows] = await Promise.all([ + db.select({ nodeCount: count() }).from(trustScores), + db.select({ edgeCount: count() }).from(interactionGraph), + db + .select({ flaggedCount: count() }) + .from(sybilClusters) + .where(eq(sybilClusters.status, 'flagged')), + ]) + + return reply.status(200).send({ + lastComputedAt, + totalNodes: nodeRows[0]?.nodeCount ?? 0, + totalEdges: edgeRows[0]?.edgeCount ?? 0, + computationDurationMs, + clustersFlagged: flaggedRows[0]?.flaggedCount ?? 0, + nextScheduledAt, + }) + } + ) + + // ======================================================================= + // BEHAVIORAL FLAGS ROUTES + // ======================================================================= + + // ------------------------------------------------------------------- + // GET /api/admin/behavioral-flags + // ------------------------------------------------------------------- + + app.get( + '/api/admin/behavioral-flags', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin - Sybil'], + summary: 'List behavioral flags (paginated)', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', + properties: { + flagType: { + type: 'string', + enum: ['burst_voting', 'content_similarity', 'low_diversity'], + }, + status: { type: 'string', enum: ['pending', 'dismissed', 'action_taken'] }, + cursor: { type: 'string' }, + limit: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + flags: { type: 'array', items: behavioralFlagJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, + }, + 400: errorJsonSchema, + }, + }, + }, + async (request, reply) => { + const parsed = behavioralFlagQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } + + const { flagType, status, cursor, limit } = parsed.data + const conditions = [] + + if (flagType) { + conditions.push(eq(behavioralFlags.flagType, flagType)) + } + if (status) { + conditions.push(eq(behavioralFlags.status, status)) + } + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${behavioralFlags.detectedAt}, ${behavioralFlags.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` + ) + } + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + const fetchLimit = limit + 1 + + const rows = await db + .select() + .from(behavioralFlags) + .where(whereClause) + .orderBy(desc(behavioralFlags.detectedAt)) + .limit(fetchLimit) + + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.detectedAt.toISOString(), lastRow.id) + } + } + + return reply.status(200).send({ + flags: resultRows.map(serializeBehavioralFlag), + cursor: nextCursor, + }) + } + ) + + // ------------------------------------------------------------------- + // PUT /api/admin/behavioral-flags/:id + // ------------------------------------------------------------------- + + app.put( + '/api/admin/behavioral-flags/:id', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin - Sybil'], + summary: 'Update behavioral flag status', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + body: { + type: 'object', + required: ['status'], + properties: { + status: { type: 'string', enum: ['dismissed', 'action_taken'] }, + }, + }, + response: { + 200: behavioralFlagJsonSchema, + 400: errorJsonSchema, + 404: errorJsonSchema, + }, + }, + }, + async (request, reply) => { + const { id } = request.params as { id: string } + const flagId = Number(id) + if (Number.isNaN(flagId)) { + throw badRequest('Invalid flag ID') + } + + const parsed = behavioralFlagUpdateSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid status update') + } + + const existing = await db + .select() + .from(behavioralFlags) + .where(eq(behavioralFlags.id, flagId)) + + if (existing.length === 0) { + throw notFound('Behavioral flag not found') + } + + const updated = await db + .update(behavioralFlags) + .set({ status: parsed.data.status }) + .where(eq(behavioralFlags.id, flagId)) + .returning() + + const updatedFlag = updated[0] + if (!updatedFlag) { + throw notFound('Flag not found after update') + } + + app.log.info({ flagId, status: parsed.data.status }, 'Behavioral flag status updated') + + return reply.status(200).send(serializeBehavioralFlag(updatedFlag)) + } + ) + + done() + } +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts index ba97bbe..d46774a 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,14 +1,14 @@ -import { z } from "zod/v4"; -import { eq } from "drizzle-orm"; -import type { FastifyPluginCallback } from "fastify"; -import type { NodeOAuthClient } from "@atproto/oauth-client-node"; +import { z } from 'zod/v4' +import { eq } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import type { NodeOAuthClient } from '@atproto/oauth-client-node' import { BARAZO_BASE_SCOPES, BARAZO_CROSSPOST_SCOPES, FALLBACK_SCOPE, hasCrossPostScopes, -} from "../auth/scopes.js"; -import { userPreferences } from "../db/schema/user-preferences.js"; +} from '../auth/scopes.js' +import { userPreferences } from '../db/schema/user-preferences.js' // --------------------------------------------------------------------------- // Zod schemas for request validation @@ -16,24 +16,24 @@ import { userPreferences } from "../db/schema/user-preferences.js"; const loginQuerySchema = z.object({ handle: z.string().trim().min(1), - crosspost: z.enum(["true", "false"]).optional(), -}); + crosspost: z.enum(['true', 'false']).optional(), +}) const callbackQuerySchema = z.object({ iss: z.string().min(1), code: z.string().min(1), state: z.string().min(1), -}); +}) // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const COOKIE_NAME = "barazo_refresh"; -const COOKIE_PATH = "/api/auth"; +const COOKIE_NAME = 'barazo_refresh' +const COOKIE_PATH = '/api/auth' function isDevMode(clientId: string): boolean { - return clientId.startsWith("http://localhost"); + return clientId.startsWith('http://localhost') } // --------------------------------------------------------------------------- @@ -49,201 +49,212 @@ function isDevMode(clientId: string): boolean { * - DELETE /api/auth/session -- Logout * - GET /api/auth/me -- Current user info */ -export function authRoutes( - oauthClient: NodeOAuthClient, -): FastifyPluginCallback { +export function authRoutes(oauthClient: NodeOAuthClient): FastifyPluginCallback { return (app, _opts, done) => { - const { sessionService, handleResolver, env } = app; - const dev = isDevMode(env.OAUTH_CLIENT_ID); - const sessionTtl = env.OAUTH_SESSION_TTL; + const { sessionService, handleResolver, env } = app + const dev = isDevMode(env.OAUTH_CLIENT_ID) + const sessionTtl = env.OAUTH_SESSION_TTL // ------------------------------------------------------------------- // GET /api/auth/login?handle={handle} // ------------------------------------------------------------------- - app.get("/api/auth/login", { - config: { rateLimit: { max: env.RATE_LIMIT_AUTH, timeWindow: "1 minute" } }, - }, async (request, reply) => { - const parsed = loginQuerySchema.safeParse(request.query); - if (!parsed.success) { - return reply.status(400).send({ error: "Invalid handle" }); - } + app.get( + '/api/auth/login', + { + config: { rateLimit: { max: env.RATE_LIMIT_AUTH, timeWindow: '1 minute' } }, + }, + async (request, reply) => { + const parsed = loginQuerySchema.safeParse(request.query) + if (!parsed.success) { + return reply.status(400).send({ error: 'Invalid handle' }) + } - const { handle, crosspost } = parsed.data; + const { handle, crosspost } = parsed.data - const requestedScope = crosspost === "true" - ? BARAZO_CROSSPOST_SCOPES - : BARAZO_BASE_SCOPES; + const requestedScope = crosspost === 'true' ? BARAZO_CROSSPOST_SCOPES : BARAZO_BASE_SCOPES - try { - let redirectUrl: URL; try { - redirectUrl = await oauthClient.authorize(handle, { - scope: requestedScope, - }); - } catch { - // PDS may not support granular scopes -- fall back to transition:generic - app.log.warn( - { handle, requestedScope }, - "Granular scopes rejected by PDS, falling back to transition:generic", - ); - redirectUrl = await oauthClient.authorize(handle, { - scope: FALLBACK_SCOPE, - }); + let redirectUrl: URL + try { + redirectUrl = await oauthClient.authorize(handle, { + scope: requestedScope, + }) + } catch { + // PDS may not support granular scopes -- fall back to transition:generic + app.log.warn( + { handle, requestedScope }, + 'Granular scopes rejected by PDS, falling back to transition:generic' + ) + redirectUrl = await oauthClient.authorize(handle, { + scope: FALLBACK_SCOPE, + }) + } + return await reply.status(200).send({ url: redirectUrl.toString() }) + } catch (err: unknown) { + app.log.error({ err, handle }, 'OAuth authorize failed') + return await reply.status(502).send({ error: 'Failed to initiate login' }) } - return await reply.status(200).send({ url: redirectUrl.toString() }); - } catch (err: unknown) { - app.log.error({ err, handle }, "OAuth authorize failed"); - return await reply.status(502).send({ error: "Failed to initiate login" }); } - }); + ) // ------------------------------------------------------------------- // GET /api/auth/callback?iss={iss}&code={code}&state={state} // ------------------------------------------------------------------- - app.get("/api/auth/callback", { - config: { rateLimit: { max: Math.ceil(env.RATE_LIMIT_AUTH / 2), timeWindow: "1 minute" } }, - }, async (request, reply) => { - const parsed = callbackQuerySchema.safeParse(request.query); - if (!parsed.success) { - return reply.status(400).send({ error: "Invalid callback parameters" }); - } - - const { iss, code, state } = parsed.data; - - // Determine the frontend origin for redirect - const frontendOrigin = env.CORS_ORIGINS.split(",")[0]?.trim() || "http://localhost:3000"; - - try { - // Build URLSearchParams for the OAuth client callback - const callbackParams = new URLSearchParams({ iss, code, state }); - const result = await oauthClient.callback(callbackParams); - - // Extract DID from the OAuth session - const did = result.session.did; - - // Resolve handle from DID via AT Protocol identity layer - // (PLC directory lookup with Valkey cache + DB fallback) - const handle = await handleResolver.resolve(did); - - const session = await sessionService.createSession(did, handle); - - // Fire-and-forget profile sync from PDS (never blocks auth flow) - void app.profileSync.syncProfile(did); - - // Detect cross-post scope grant and persist to user preferences. - // The tokenSet scope field reflects what the PDS actually granted. - const grantedScope = (result.session as { tokenSet?: { scope?: string } }).tokenSet?.scope ?? ""; - if (hasCrossPostScopes(grantedScope)) { - void app.db - .insert(userPreferences) - .values({ did, crossPostScopesGranted: true }) - .onConflictDoUpdate({ - target: userPreferences.did, - set: { crossPostScopesGranted: true, updatedAt: new Date() }, - }) - .catch((dbErr: unknown) => { - app.log.error({ err: dbErr, did }, "Failed to persist cross-post scope grant"); - }); + app.get( + '/api/auth/callback', + { + config: { rateLimit: { max: Math.ceil(env.RATE_LIMIT_AUTH / 2), timeWindow: '1 minute' } }, + }, + async (request, reply) => { + const parsed = callbackQuerySchema.safeParse(request.query) + if (!parsed.success) { + return reply.status(400).send({ error: 'Invalid callback parameters' }) } - // Set refresh cookie (sameSite lax to survive cross-site redirect from PDS) - void reply.setCookie(COOKIE_NAME, session.sid, { - httpOnly: true, - secure: !dev, - sameSite: "lax", - path: COOKIE_PATH, - maxAge: sessionTtl, - }); - - // Redirect to frontend -- no tokens in URL, frontend uses cookie to refresh - const redirectUrl = new URL("/auth/callback", frontendOrigin); - redirectUrl.searchParams.set("success", "true"); + const { iss, code, state } = parsed.data - return await reply.redirect(redirectUrl.toString(), 302); - } catch (err: unknown) { - app.log.error({ err }, "OAuth callback failed"); + // Determine the frontend origin for redirect + const frontendOrigin = env.CORS_ORIGINS.split(',')[0]?.trim() || 'http://localhost:3000' - // Redirect to frontend with error - const errorUrl = new URL("/auth/callback", frontendOrigin); - errorUrl.searchParams.set("error", "OAuth callback failed"); - return await reply.redirect(errorUrl.toString(), 302); + try { + // Build URLSearchParams for the OAuth client callback + const callbackParams = new URLSearchParams({ iss, code, state }) + const result = await oauthClient.callback(callbackParams) + + // Extract DID from the OAuth session + const did = result.session.did + + // Resolve handle from DID via AT Protocol identity layer + // (PLC directory lookup with Valkey cache + DB fallback) + const handle = await handleResolver.resolve(did) + + const session = await sessionService.createSession(did, handle) + + // Fire-and-forget profile sync from PDS (never blocks auth flow) + void app.profileSync.syncProfile(did) + + // Detect cross-post scope grant and persist to user preferences. + // The tokenSet scope field reflects what the PDS actually granted. + const grantedScope = + (result.session as { tokenSet?: { scope?: string } }).tokenSet?.scope ?? '' + if (hasCrossPostScopes(grantedScope)) { + void app.db + .insert(userPreferences) + .values({ did, crossPostScopesGranted: true }) + .onConflictDoUpdate({ + target: userPreferences.did, + set: { crossPostScopesGranted: true, updatedAt: new Date() }, + }) + .catch((dbErr: unknown) => { + app.log.error({ err: dbErr, did }, 'Failed to persist cross-post scope grant') + }) + } + + // Set refresh cookie (sameSite lax to survive cross-site redirect from PDS) + void reply.setCookie(COOKIE_NAME, session.sid, { + httpOnly: true, + secure: !dev, + sameSite: 'lax', + path: COOKIE_PATH, + maxAge: sessionTtl, + }) + + // Redirect to frontend -- no tokens in URL, frontend uses cookie to refresh + const redirectUrl = new URL('/auth/callback', frontendOrigin) + redirectUrl.searchParams.set('success', 'true') + + return await reply.redirect(redirectUrl.toString(), 302) + } catch (err: unknown) { + app.log.error({ err }, 'OAuth callback failed') + + // Redirect to frontend with error + const errorUrl = new URL('/auth/callback', frontendOrigin) + errorUrl.searchParams.set('error', 'OAuth callback failed') + return await reply.redirect(errorUrl.toString(), 302) + } } - }); + ) // ------------------------------------------------------------------- // GET /api/auth/crosspost-authorize // ------------------------------------------------------------------- - app.get("/api/auth/crosspost-authorize", { - config: { rateLimit: { max: env.RATE_LIMIT_AUTH, timeWindow: "1 minute" } }, - }, async (request, reply) => { - const authHeader = request.headers.authorization; - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return reply.status(401).send({ error: "Authentication required" }); - } + app.get( + '/api/auth/crosspost-authorize', + { + config: { rateLimit: { max: env.RATE_LIMIT_AUTH, timeWindow: '1 minute' } }, + }, + async (request, reply) => { + const authHeader = request.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const token = authHeader.slice("Bearer ".length); - const session = await sessionService.validateAccessToken(token); - if (!session) { - return await reply.status(401).send({ error: "Invalid or expired token" }); - } + const token = authHeader.slice('Bearer '.length) + const session = await sessionService.validateAccessToken(token) + if (!session) { + return await reply.status(401).send({ error: 'Invalid or expired token' }) + } - try { - let redirectUrl: URL; try { - redirectUrl = await oauthClient.authorize(session.handle, { - scope: BARAZO_CROSSPOST_SCOPES, - }); - } catch { - app.log.warn( - { handle: session.handle }, - "Granular cross-post scopes rejected by PDS, falling back to transition:generic", - ); - redirectUrl = await oauthClient.authorize(session.handle, { - scope: FALLBACK_SCOPE, - }); + let redirectUrl: URL + try { + redirectUrl = await oauthClient.authorize(session.handle, { + scope: BARAZO_CROSSPOST_SCOPES, + }) + } catch { + app.log.warn( + { handle: session.handle }, + 'Granular cross-post scopes rejected by PDS, falling back to transition:generic' + ) + redirectUrl = await oauthClient.authorize(session.handle, { + scope: FALLBACK_SCOPE, + }) + } + return await reply.status(200).send({ url: redirectUrl.toString() }) + } catch (err: unknown) { + app.log.error({ err, handle: session.handle }, 'Cross-post authorize failed') + return await reply + .status(502) + .send({ error: 'Failed to initiate cross-post authorization' }) } - return await reply.status(200).send({ url: redirectUrl.toString() }); - } catch (err: unknown) { - app.log.error({ err, handle: session.handle }, "Cross-post authorize failed"); - return await reply.status(502).send({ error: "Failed to initiate cross-post authorization" }); } - }); + ) // ------------------------------------------------------------------- // POST /api/auth/refresh // ------------------------------------------------------------------- - app.post("/api/auth/refresh", async (request, reply) => { - const sid = request.cookies[COOKIE_NAME]; + app.post('/api/auth/refresh', async (request, reply) => { + const sid = request.cookies[COOKIE_NAME] if (!sid) { - return reply.status(401).send({ error: "No refresh token" }); + return reply.status(401).send({ error: 'No refresh token' }) } try { - const session = await sessionService.refreshSession(sid); + const session = await sessionService.refreshSession(sid) if (!session) { // Clear the stale cookie - void reply.clearCookie(COOKIE_NAME, { path: COOKIE_PATH }); - return await reply.status(401).send({ error: "Session expired" }); + void reply.clearCookie(COOKIE_NAME, { path: COOKIE_PATH }) + return await reply.status(401).send({ error: 'Session expired' }) } // Re-set refresh cookie with refreshed maxAge void reply.setCookie(COOKIE_NAME, session.sid, { httpOnly: true, secure: !dev, - sameSite: "lax", + sameSite: 'lax', path: COOKIE_PATH, maxAge: sessionTtl, - }); + }) // Query cross-post scope status from user preferences const prefRows = await app.db .select({ crossPostScopesGranted: userPreferences.crossPostScopesGranted }) .from(userPreferences) - .where(eq(userPreferences.did, session.did)); + .where(eq(userPreferences.did, session.did)) return await reply.status(200).send({ accessToken: session.accessToken, @@ -251,71 +262,71 @@ export function authRoutes( did: session.did, handle: session.handle, crossPostScopesGranted: prefRows[0]?.crossPostScopesGranted ?? false, - }); + }) } catch (err: unknown) { - app.log.error({ err }, "Session refresh failed"); - return reply.status(502).send({ error: "Service temporarily unavailable" }); + app.log.error({ err }, 'Session refresh failed') + return reply.status(502).send({ error: 'Service temporarily unavailable' }) } - }); + }) // ------------------------------------------------------------------- // DELETE /api/auth/session // ------------------------------------------------------------------- - app.delete("/api/auth/session", async (request, reply) => { - const sid = request.cookies[COOKIE_NAME]; + app.delete('/api/auth/session', async (request, reply) => { + const sid = request.cookies[COOKIE_NAME] if (!sid) { - return reply.status(204).send(); + return reply.status(204).send() } try { - await sessionService.deleteSession(sid); + await sessionService.deleteSession(sid) } catch (err: unknown) { - app.log.error({ err }, "Session deletion failed"); - return reply.status(502).send({ error: "Service temporarily unavailable" }); + app.log.error({ err }, 'Session deletion failed') + return reply.status(502).send({ error: 'Service temporarily unavailable' }) } // Clear the cookie - void reply.clearCookie(COOKIE_NAME, { path: COOKIE_PATH }); + void reply.clearCookie(COOKIE_NAME, { path: COOKIE_PATH }) - return reply.status(204).send(); - }); + return reply.status(204).send() + }) // ------------------------------------------------------------------- // GET /api/auth/me // ------------------------------------------------------------------- - app.get("/api/auth/me", async (request, reply) => { - const authHeader = request.headers.authorization; - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return reply.status(401).send({ error: "Authentication required" }); + app.get('/api/auth/me', async (request, reply) => { + const authHeader = request.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return reply.status(401).send({ error: 'Authentication required' }) } - const token = authHeader.slice("Bearer ".length); + const token = authHeader.slice('Bearer '.length) try { - const session = await sessionService.validateAccessToken(token); + const session = await sessionService.validateAccessToken(token) if (!session) { - return await reply.status(401).send({ error: "Invalid or expired token" }); + return await reply.status(401).send({ error: 'Invalid or expired token' }) } // Query cross-post scope status from user preferences const mePrefRows = await app.db .select({ crossPostScopesGranted: userPreferences.crossPostScopesGranted }) .from(userPreferences) - .where(eq(userPreferences.did, session.did)); + .where(eq(userPreferences.did, session.did)) return await reply.status(200).send({ did: session.did, handle: session.handle, crossPostScopesGranted: mePrefRows[0]?.crossPostScopesGranted ?? false, - }); + }) } catch (err: unknown) { - app.log.error({ err }, "Token validation failed"); - return reply.status(502).send({ error: "Service temporarily unavailable" }); + app.log.error({ err }, 'Token validation failed') + return reply.status(502).send({ error: 'Service temporarily unavailable' }) } - }); + }) - done(); - }; + done() + } } diff --git a/src/routes/block-mute.ts b/src/routes/block-mute.ts index aa5b5f4..e6d9acc 100644 --- a/src/routes/block-mute.ts +++ b/src/routes/block-mute.ts @@ -1,34 +1,34 @@ -import { eq } from "drizzle-orm"; -import type { FastifyPluginCallback } from "fastify"; -import { badRequest } from "../lib/api-errors.js"; -import { didParamSchema } from "../validation/block-mute.js"; -import { userPreferences } from "../db/schema/user-preferences.js"; +import { eq } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { badRequest } from '../lib/api-errors.js' +import { didParamSchema } from '../validation/block-mute.js' +import { userPreferences } from '../db/schema/user-preferences.js' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- const errorJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, + error: { type: 'string' as const }, }, -}; +} const successJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - success: { type: "boolean" as const }, + success: { type: 'boolean' as const }, }, -}; +} const didParamJsonSchema = { - type: "object" as const, - required: ["did"], + type: 'object' as const, + required: ['did'], properties: { - did: { type: "string" as const }, + did: { type: 'string' as const }, }, -}; +} // --------------------------------------------------------------------------- // Block/mute action routes plugin @@ -44,19 +44,19 @@ const didParamJsonSchema = { */ export function blockMuteRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, authMiddleware } = app; + const { db, authMiddleware } = app // ------------------------------------------------------------------- // POST /api/users/me/block/:did (auth required) // ------------------------------------------------------------------- app.post( - "/api/users/me/block/:did", + '/api/users/me/block/:did', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Block & Mute"], - summary: "Block a user by DID", + tags: ['Block & Mute'], + summary: 'Block a user by DID', security: [{ bearerAuth: [] }], params: didParamJsonSchema, response: { @@ -67,39 +67,35 @@ export function blockMuteRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const requestUser = request.user; + const requestUser = request.user if (!requestUser) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } const paramResult = didParamSchema.safeParse({ - did: decodeURIComponent( - (request.params as { did: string }).did, - ), - }); + did: decodeURIComponent((request.params as { did: string }).did), + }) if (!paramResult.success) { - throw badRequest("Invalid DID format"); + throw badRequest('Invalid DID format') } - const targetDid = paramResult.data.did; + const targetDid = paramResult.data.did // Read current preferences const rows = await db .select() .from(userPreferences) - .where(eq(userPreferences.did, requestUser.did)); + .where(eq(userPreferences.did, requestUser.did)) - const prefs = rows[0]; - const currentBlocked: string[] = prefs?.blockedDids ?? []; + const prefs = rows[0] + const currentBlocked: string[] = prefs?.blockedDids ?? [] // Idempotent: if already blocked, return success if (currentBlocked.includes(targetDid)) { - return reply.status(200).send({ success: true }); + return reply.status(200).send({ success: true }) } - const newBlocked = [...currentBlocked, targetDid]; - const now = new Date(); + const newBlocked = [...currentBlocked, targetDid] + const now = new Date() // Upsert preferences with updated blockedDids await db @@ -115,23 +111,23 @@ export function blockMuteRoutes(): FastifyPluginCallback { blockedDids: newBlocked, updatedAt: now, }, - }); + }) - return reply.status(200).send({ success: true }); - }, - ); + return reply.status(200).send({ success: true }) + } + ) // ------------------------------------------------------------------- // DELETE /api/users/me/block/:did (auth required) // ------------------------------------------------------------------- app.delete( - "/api/users/me/block/:did", + '/api/users/me/block/:did', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Block & Mute"], - summary: "Unblock a user by DID", + tags: ['Block & Mute'], + summary: 'Unblock a user by DID', security: [{ bearerAuth: [] }], params: didParamJsonSchema, response: { @@ -142,33 +138,29 @@ export function blockMuteRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const requestUser = request.user; + const requestUser = request.user if (!requestUser) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } const paramResult = didParamSchema.safeParse({ - did: decodeURIComponent( - (request.params as { did: string }).did, - ), - }); + did: decodeURIComponent((request.params as { did: string }).did), + }) if (!paramResult.success) { - throw badRequest("Invalid DID format"); + throw badRequest('Invalid DID format') } - const targetDid = paramResult.data.did; + const targetDid = paramResult.data.did // Read current preferences const rows = await db .select() .from(userPreferences) - .where(eq(userPreferences.did, requestUser.did)); + .where(eq(userPreferences.did, requestUser.did)) - const prefs = rows[0]; - const currentBlocked: string[] = prefs?.blockedDids ?? []; - const newBlocked = currentBlocked.filter((d) => d !== targetDid); - const now = new Date(); + const prefs = rows[0] + const currentBlocked: string[] = prefs?.blockedDids ?? [] + const newBlocked = currentBlocked.filter((d) => d !== targetDid) + const now = new Date() // Upsert preferences with updated blockedDids await db @@ -184,23 +176,23 @@ export function blockMuteRoutes(): FastifyPluginCallback { blockedDids: newBlocked, updatedAt: now, }, - }); + }) - return reply.status(200).send({ success: true }); - }, - ); + return reply.status(200).send({ success: true }) + } + ) // ------------------------------------------------------------------- // POST /api/users/me/mute/:did (auth required) // ------------------------------------------------------------------- app.post( - "/api/users/me/mute/:did", + '/api/users/me/mute/:did', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Block & Mute"], - summary: "Mute a user by DID", + tags: ['Block & Mute'], + summary: 'Mute a user by DID', security: [{ bearerAuth: [] }], params: didParamJsonSchema, response: { @@ -211,39 +203,35 @@ export function blockMuteRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const requestUser = request.user; + const requestUser = request.user if (!requestUser) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } const paramResult = didParamSchema.safeParse({ - did: decodeURIComponent( - (request.params as { did: string }).did, - ), - }); + did: decodeURIComponent((request.params as { did: string }).did), + }) if (!paramResult.success) { - throw badRequest("Invalid DID format"); + throw badRequest('Invalid DID format') } - const targetDid = paramResult.data.did; + const targetDid = paramResult.data.did // Read current preferences const rows = await db .select() .from(userPreferences) - .where(eq(userPreferences.did, requestUser.did)); + .where(eq(userPreferences.did, requestUser.did)) - const prefs = rows[0]; - const currentMuted: string[] = prefs?.mutedDids ?? []; + const prefs = rows[0] + const currentMuted: string[] = prefs?.mutedDids ?? [] // Idempotent: if already muted, return success if (currentMuted.includes(targetDid)) { - return reply.status(200).send({ success: true }); + return reply.status(200).send({ success: true }) } - const newMuted = [...currentMuted, targetDid]; - const now = new Date(); + const newMuted = [...currentMuted, targetDid] + const now = new Date() // Upsert preferences with updated mutedDids await db @@ -259,23 +247,23 @@ export function blockMuteRoutes(): FastifyPluginCallback { mutedDids: newMuted, updatedAt: now, }, - }); + }) - return reply.status(200).send({ success: true }); - }, - ); + return reply.status(200).send({ success: true }) + } + ) // ------------------------------------------------------------------- // DELETE /api/users/me/mute/:did (auth required) // ------------------------------------------------------------------- app.delete( - "/api/users/me/mute/:did", + '/api/users/me/mute/:did', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Block & Mute"], - summary: "Unmute a user by DID", + tags: ['Block & Mute'], + summary: 'Unmute a user by DID', security: [{ bearerAuth: [] }], params: didParamJsonSchema, response: { @@ -286,33 +274,29 @@ export function blockMuteRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const requestUser = request.user; + const requestUser = request.user if (!requestUser) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } const paramResult = didParamSchema.safeParse({ - did: decodeURIComponent( - (request.params as { did: string }).did, - ), - }); + did: decodeURIComponent((request.params as { did: string }).did), + }) if (!paramResult.success) { - throw badRequest("Invalid DID format"); + throw badRequest('Invalid DID format') } - const targetDid = paramResult.data.did; + const targetDid = paramResult.data.did // Read current preferences const rows = await db .select() .from(userPreferences) - .where(eq(userPreferences.did, requestUser.did)); + .where(eq(userPreferences.did, requestUser.did)) - const prefs = rows[0]; - const currentMuted: string[] = prefs?.mutedDids ?? []; - const newMuted = currentMuted.filter((d) => d !== targetDid); - const now = new Date(); + const prefs = rows[0] + const currentMuted: string[] = prefs?.mutedDids ?? [] + const newMuted = currentMuted.filter((d) => d !== targetDid) + const now = new Date() // Upsert preferences with updated mutedDids await db @@ -328,12 +312,12 @@ export function blockMuteRoutes(): FastifyPluginCallback { mutedDids: newMuted, updatedAt: now, }, - }); + }) - return reply.status(200).send({ success: true }); - }, - ); + return reply.status(200).send({ success: true }) + } + ) - done(); - }; + done() + } } diff --git a/src/routes/categories.ts b/src/routes/categories.ts index d1413e2..9682dfe 100644 --- a/src/routes/categories.ts +++ b/src/routes/categories.ts @@ -1,17 +1,17 @@ -import { randomUUID } from "node:crypto"; -import { eq, and, count } from "drizzle-orm"; -import type { FastifyPluginCallback } from "fastify"; -import { notFound, badRequest, conflict } from "../lib/api-errors.js"; -import { isMaturityLowerThan } from "../lib/maturity.js"; +import { randomUUID } from 'node:crypto' +import { eq, and, count } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { notFound, badRequest, conflict } from '../lib/api-errors.js' +import { isMaturityLowerThan } from '../lib/maturity.js' import { createCategorySchema, updateCategorySchema, updateMaturitySchema, categoryQuerySchema, -} from "../validation/categories.js"; -import { categories } from "../db/schema/categories.js"; -import { communitySettings } from "../db/schema/community-settings.js"; -import { topics } from "../db/schema/topics.js"; +} from '../validation/categories.js' +import { categories } from '../db/schema/categories.js' +import { communitySettings } from '../db/schema/community-settings.js' +import { topics } from '../db/schema/topics.js' /** * Serialize a category row from the DB into a JSON-safe response object. @@ -29,21 +29,21 @@ function serializeCategory(row: typeof categories.$inferSelect) { maturityRating: row.maturityRating, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), - }; + } } interface CategoryTreeNode { - id: string; - slug: string; - name: string; - description: string | null; - parentId: string | null; - sortOrder: number; - communityDid: string; - maturityRating: string; - createdAt: string; - updatedAt: string; - children: CategoryTreeNode[]; + id: string + slug: string + name: string + description: string | null + parentId: string | null + sortOrder: number + communityDid: string + maturityRating: string + createdAt: string + updatedAt: string + children: CategoryTreeNode[] } /** @@ -54,29 +54,29 @@ function buildCategoryTree(rows: Array): Categor const serialized = rows.map((row) => ({ ...serializeCategory(row), children: [] as CategoryTreeNode[], - })); + })) - const byId = new Map(); + const byId = new Map() for (const node of serialized) { - byId.set(node.id, node); + byId.set(node.id, node) } - const roots: CategoryTreeNode[] = []; + const roots: CategoryTreeNode[] = [] for (const node of serialized) { if (node.parentId !== null) { - const parent = byId.get(node.parentId); + const parent = byId.get(node.parentId) if (parent) { - parent.children.push(node); + parent.children.push(node) } else { // Orphan -- treat as root - roots.push(node); + roots.push(node) } } else { - roots.push(node); + roots.push(node) } } - return roots; + return roots } /** @@ -87,45 +87,45 @@ function buildCategoryTree(rows: Array): Categor function wouldCreateCycle( categoryId: string, newParentId: string, - allCategories: Array<{ id: string; parentId: string | null }>, + allCategories: Array<{ id: string; parentId: string | null }> ): boolean { // Self-reference if (categoryId === newParentId) { - return true; + return true } - const byId = new Map(); + const byId = new Map() for (const cat of allCategories) { - byId.set(cat.id, cat); + byId.set(cat.id, cat) } // Walk up from newParentId - let current = newParentId; - const visited = new Set(); + let current = newParentId + const visited = new Set() while (current) { if (current === categoryId) { - return true; + return true } if (visited.has(current)) { // Already in a cycle (should not happen but protect against it) - return true; + return true } - visited.add(current); - const node = byId.get(current); + visited.add(current) + const node = byId.get(current) if (!node?.parentId) { - break; + break } - current = node.parentId; + current = node.parentId } - return false; + return false } /** * Generate a random ID for a new category. */ function generateId(): string { - return `cat-${randomUUID()}`; + return `cat-${randomUUID()}` } // --------------------------------------------------------------------------- @@ -133,37 +133,37 @@ function generateId(): string { // --------------------------------------------------------------------------- const categoryJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - id: { type: "string" as const }, - slug: { type: "string" as const }, - name: { type: "string" as const }, - description: { type: ["string", "null"] as const }, - parentId: { type: ["string", "null"] as const }, - sortOrder: { type: "integer" as const }, - communityDid: { type: "string" as const }, - maturityRating: { type: "string" as const, enum: ["safe", "mature", "adult"] }, - createdAt: { type: "string" as const, format: "date-time" as const }, - updatedAt: { type: "string" as const, format: "date-time" as const }, + id: { type: 'string' as const }, + slug: { type: 'string' as const }, + name: { type: 'string' as const }, + description: { type: ['string', 'null'] as const }, + parentId: { type: ['string', 'null'] as const }, + sortOrder: { type: 'integer' as const }, + communityDid: { type: 'string' as const }, + maturityRating: { type: 'string' as const, enum: ['safe', 'mature', 'adult'] }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + updatedAt: { type: 'string' as const, format: 'date-time' as const }, }, -}; +} const categoryWithTopicCountJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { ...categoryJsonSchema.properties, - topicCount: { type: "integer" as const }, + topicCount: { type: 'integer' as const }, }, -}; +} const errorJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, - message: { type: "string" as const }, - statusCode: { type: "integer" as const }, + error: { type: 'string' as const }, + message: { type: 'string' as const }, + statusCode: { type: 'integer' as const }, }, -}; +} // --------------------------------------------------------------------------- // Category routes plugin @@ -184,554 +184,535 @@ const errorJsonSchema = { */ export function categoryRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, env, authMiddleware, requireAdmin } = app; + const { db, env, authMiddleware, requireAdmin } = app // ------------------------------------------------------------------- // GET /api/categories (public, optionalAuth) // ------------------------------------------------------------------- - app.get("/api/categories", { - preHandler: [authMiddleware.optionalAuth], - schema: { - tags: ["Categories"], - summary: "List categories as a tree structure", - querystring: { - type: "object", - properties: { - parentId: { type: "string" }, - }, - }, - response: { - 200: { - type: "object", - additionalProperties: true, + app.get( + '/api/categories', + { + preHandler: [authMiddleware.optionalAuth], + schema: { + tags: ['Categories'], + summary: 'List categories as a tree structure', + querystring: { + type: 'object', properties: { - categories: { type: "array" }, + parentId: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + additionalProperties: true, + properties: { + categories: { type: 'array' }, + }, }, }, }, }, - }, async (request, reply) => { - const parsed = categoryQuerySchema.safeParse(request.query); - const parentId = parsed.success ? parsed.data.parentId : undefined; - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - - const conditions = [eq(categories.communityDid, communityDid)]; - if (parentId !== undefined) { - conditions.push(eq(categories.parentId, parentId)); - } + async (request, reply) => { + const parsed = categoryQuerySchema.safeParse(request.query) + const parentId = parsed.success ? parsed.data.parentId : undefined + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' + + const conditions = [eq(categories.communityDid, communityDid)] + if (parentId !== undefined) { + conditions.push(eq(categories.parentId, parentId)) + } - const rows = await db - .select() - .from(categories) - .where(and(...conditions)); + const rows = await db + .select() + .from(categories) + .where(and(...conditions)) - const tree = buildCategoryTree(rows); + const tree = buildCategoryTree(rows) - return reply.status(200).send({ categories: tree }); - }); + return reply.status(200).send({ categories: tree }) + } + ) // ------------------------------------------------------------------- // GET /api/categories/:slug (public, optionalAuth) // ------------------------------------------------------------------- - app.get("/api/categories/:slug", { - preHandler: [authMiddleware.optionalAuth], - schema: { - tags: ["Categories"], - summary: "Get a single category by slug", - params: { - type: "object", - required: ["slug"], - properties: { - slug: { type: "string" }, + app.get( + '/api/categories/:slug', + { + preHandler: [authMiddleware.optionalAuth], + schema: { + tags: ['Categories'], + summary: 'Get a single category by slug', + params: { + type: 'object', + required: ['slug'], + properties: { + slug: { type: 'string' }, + }, + }, + response: { + 200: categoryWithTopicCountJsonSchema, + 404: errorJsonSchema, }, - }, - response: { - 200: categoryWithTopicCountJsonSchema, - 404: errorJsonSchema, }, }, - }, async (request, reply) => { - const { slug } = request.params as { slug: string }; - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - - const rows = await db - .select() - .from(categories) - .where( - and( - eq(categories.slug, slug), - eq(categories.communityDid, communityDid), - ), - ); - - const row = rows[0]; - if (!row) { - throw notFound("Category not found"); - } + async (request, reply) => { + const { slug } = request.params as { slug: string } + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' + + const rows = await db + .select() + .from(categories) + .where(and(eq(categories.slug, slug), eq(categories.communityDid, communityDid))) + + const row = rows[0] + if (!row) { + throw notFound('Category not found') + } + + // Count topics in this category within this community + const topicCountResult = await db + .select({ count: count() }) + .from(topics) + .where(and(eq(topics.category, slug), eq(topics.communityDid, communityDid))) - // Count topics in this category within this community - const topicCountResult = await db - .select({ count: count() }) - .from(topics) - .where( - and( - eq(topics.category, slug), - eq(topics.communityDid, communityDid), - ), - ); - - const topicCount = topicCountResult[0]?.count ?? 0; - - return reply.status(200).send({ - ...serializeCategory(row), - topicCount, - }); - }); + const topicCount = topicCountResult[0]?.count ?? 0 + + return reply.status(200).send({ + ...serializeCategory(row), + topicCount, + }) + } + ) // ------------------------------------------------------------------- // POST /api/admin/categories (admin required) // ------------------------------------------------------------------- - app.post("/api/admin/categories", { - preHandler: [requireAdmin], - schema: { - tags: ["Categories (Admin)"], - summary: "Create a new category", - security: [{ bearerAuth: [] }], - body: { - type: "object", - required: ["name", "slug"], - properties: { - name: { type: "string", minLength: 1, maxLength: 100 }, - slug: { type: "string", minLength: 1, maxLength: 50 }, - description: { type: "string", maxLength: 500 }, - parentId: { type: "string" }, - sortOrder: { type: "integer", minimum: 0 }, - maturityRating: { type: "string", enum: ["safe", "mature", "adult"] }, + app.post( + '/api/admin/categories', + { + preHandler: [requireAdmin], + schema: { + tags: ['Categories (Admin)'], + summary: 'Create a new category', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + required: ['name', 'slug'], + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + slug: { type: 'string', minLength: 1, maxLength: 50 }, + description: { type: 'string', maxLength: 500 }, + parentId: { type: 'string' }, + sortOrder: { type: 'integer', minimum: 0 }, + maturityRating: { type: 'string', enum: ['safe', 'mature', 'adult'] }, + }, + }, + response: { + 201: categoryJsonSchema, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 409: errorJsonSchema, }, - }, - response: { - 201: categoryJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 409: errorJsonSchema, }, }, - }, async (request, reply) => { - const parsed = createCategorySchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid category data"); - } - - const { name, slug, description, parentId, sortOrder, maturityRating } = parsed.data; - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; + async (request, reply) => { + const parsed = createCategorySchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid category data') + } - // Fetch community settings for maturity default - const settingsRows = await db - .select() - .from(communitySettings) - .where(eq(communitySettings.id, "default")); + const { name, slug, description, parentId, sortOrder, maturityRating } = parsed.data + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' - const settings = settingsRows[0]; - const communityDefault = settings?.maturityRating ?? "safe"; - const effectiveMaturity = maturityRating ?? communityDefault; + // Fetch community settings for maturity default + const settingsRows = await db + .select() + .from(communitySettings) + .where(eq(communitySettings.id, 'default')) - // Validate: maturity cannot be lower than community default - if (isMaturityLowerThan(effectiveMaturity, communityDefault)) { - throw badRequest( - `Category maturity rating "${effectiveMaturity}" cannot be lower than community default "${communityDefault}"`, - ); - } + const settings = settingsRows[0] + const communityDefault = settings?.maturityRating ?? 'safe' + const effectiveMaturity = maturityRating ?? communityDefault - // Check slug uniqueness within community - const existingSlug = await db - .select() - .from(categories) - .where( - and( - eq(categories.slug, slug), - eq(categories.communityDid, communityDid), - ), - ); - - if (existingSlug.length > 0) { - throw conflict(`Category with slug "${slug}" already exists in this community`); - } + // Validate: maturity cannot be lower than community default + if (isMaturityLowerThan(effectiveMaturity, communityDefault)) { + throw badRequest( + `Category maturity rating "${effectiveMaturity}" cannot be lower than community default "${communityDefault}"` + ) + } - // Validate parentId if provided - if (parentId !== undefined) { - const parentRows = await db + // Check slug uniqueness within community + const existingSlug = await db .select() .from(categories) - .where(eq(categories.id, parentId)); + .where(and(eq(categories.slug, slug), eq(categories.communityDid, communityDid))) - if (parentRows.length === 0) { - throw badRequest(`Parent category "${parentId}" does not exist`); + if (existingSlug.length > 0) { + throw conflict(`Category with slug "${slug}" already exists in this community`) } - } - const now = new Date(); - const id = generateId(); - - const inserted = await db - .insert(categories) - .values({ - id, - slug, - name, - description: description ?? null, - parentId: parentId ?? null, - sortOrder: sortOrder ?? 0, - communityDid, - maturityRating: effectiveMaturity, - createdAt: now, - updatedAt: now, - }) - .returning(); + // Validate parentId if provided + if (parentId !== undefined) { + const parentRows = await db.select().from(categories).where(eq(categories.id, parentId)) - const created = inserted[0]; - if (!created) { - throw badRequest("Failed to create category"); - } + if (parentRows.length === 0) { + throw badRequest(`Parent category "${parentId}" does not exist`) + } + } + + const now = new Date() + const id = generateId() + + const inserted = await db + .insert(categories) + .values({ + id, + slug, + name, + description: description ?? null, + parentId: parentId ?? null, + sortOrder: sortOrder ?? 0, + communityDid, + maturityRating: effectiveMaturity, + createdAt: now, + updatedAt: now, + }) + .returning() + + const created = inserted[0] + if (!created) { + throw badRequest('Failed to create category') + } - app.log.info( - { categoryId: id, slug, adminDid: request.user?.did }, - "Category created", - ); + app.log.info({ categoryId: id, slug, adminDid: request.user?.did }, 'Category created') - return reply.status(201).send(serializeCategory(created)); - }); + return reply.status(201).send(serializeCategory(created)) + } + ) // ------------------------------------------------------------------- // PUT /api/admin/categories/:id (admin required) // ------------------------------------------------------------------- - app.put("/api/admin/categories/:id", { - preHandler: [requireAdmin], - schema: { - tags: ["Categories (Admin)"], - summary: "Update a category", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["id"], - properties: { - id: { type: "string" }, + app.put( + '/api/admin/categories/:id', + { + preHandler: [requireAdmin], + schema: { + tags: ['Categories (Admin)'], + summary: 'Update a category', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + }, }, - }, - body: { - type: "object", - properties: { - name: { type: "string", minLength: 1, maxLength: 100 }, - slug: { type: "string", minLength: 1, maxLength: 50 }, - description: { type: ["string", "null"], maxLength: 500 }, - parentId: { type: ["string", "null"] }, - sortOrder: { type: "integer", minimum: 0 }, - maturityRating: { type: "string", enum: ["safe", "mature", "adult"] }, + body: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + slug: { type: 'string', minLength: 1, maxLength: 50 }, + description: { type: ['string', 'null'], maxLength: 500 }, + parentId: { type: ['string', 'null'] }, + sortOrder: { type: 'integer', minimum: 0 }, + maturityRating: { type: 'string', enum: ['safe', 'mature', 'adult'] }, + }, + }, + response: { + 200: categoryJsonSchema, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + 409: errorJsonSchema, }, - }, - response: { - 200: categoryJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, }, }, - }, async (request, reply) => { - const { id } = request.params as { id: string }; - - // Find existing category - const existingRows = await db - .select() - .from(categories) - .where(eq(categories.id, id)); - - const existing = existingRows[0]; - if (!existing) { - throw notFound("Category not found"); - } - - const parsed = updateCategorySchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid update data"); - } + async (request, reply) => { + const { id } = request.params as { id: string } - const updates = parsed.data; - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; + // Find existing category + const existingRows = await db.select().from(categories).where(eq(categories.id, id)) - // Fetch community settings for maturity validation - const settingsRows = await db - .select() - .from(communitySettings) - .where(eq(communitySettings.id, "default")); - - const settings = settingsRows[0]; - const communityDefault = settings?.maturityRating ?? "safe"; - - // Validate maturity rating if provided - if (updates.maturityRating !== undefined) { - if (isMaturityLowerThan(updates.maturityRating, communityDefault)) { - throw badRequest( - `Category maturity rating "${updates.maturityRating}" cannot be lower than community default "${communityDefault}"`, - ); + const existing = existingRows[0] + if (!existing) { + throw notFound('Category not found') } - } - - // Validate slug uniqueness if slug is being changed - if (updates.slug !== undefined && updates.slug !== existing.slug) { - const existingSlug = await db - .select() - .from(categories) - .where( - and( - eq(categories.slug, updates.slug), - eq(categories.communityDid, communityDid), - ), - ); - if (existingSlug.length > 0) { - throw conflict(`Category with slug "${updates.slug}" already exists in this community`); + const parsed = updateCategorySchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid update data') } - } - // Validate parentId if provided (null = move to root, string = set parent) - if (updates.parentId !== undefined && updates.parentId !== null) { - // Check parent exists - const parentRows = await db - .select() - .from(categories) - .where(eq(categories.id, updates.parentId)); + const updates = parsed.data + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' - if (parentRows.length === 0) { - throw badRequest(`Parent category "${updates.parentId}" does not exist`); + // Fetch community settings for maturity validation + const settingsRows = await db + .select() + .from(communitySettings) + .where(eq(communitySettings.id, 'default')) + + const settings = settingsRows[0] + const communityDefault = settings?.maturityRating ?? 'safe' + + // Validate maturity rating if provided + if (updates.maturityRating !== undefined) { + if (isMaturityLowerThan(updates.maturityRating, communityDefault)) { + throw badRequest( + `Category maturity rating "${updates.maturityRating}" cannot be lower than community default "${communityDefault}"` + ) + } } - // Check for circular references - // Self-reference is the simplest case - if (updates.parentId === id) { - throw badRequest("Category cannot be its own parent"); + // Validate slug uniqueness if slug is being changed + if (updates.slug !== undefined && updates.slug !== existing.slug) { + const existingSlug = await db + .select() + .from(categories) + .where( + and(eq(categories.slug, updates.slug), eq(categories.communityDid, communityDid)) + ) + + if (existingSlug.length > 0) { + throw conflict(`Category with slug "${updates.slug}" already exists in this community`) + } } - // Fetch all categories to check for cycles - const allCats = await db - .select() - .from(categories) - .where(eq(categories.communityDid, communityDid)); - - if (wouldCreateCycle(id, updates.parentId, allCats)) { - throw badRequest("Setting this parent would create a circular reference"); + // Validate parentId if provided (null = move to root, string = set parent) + if (updates.parentId !== undefined && updates.parentId !== null) { + // Check parent exists + const parentRows = await db + .select() + .from(categories) + .where(eq(categories.id, updates.parentId)) + + if (parentRows.length === 0) { + throw badRequest(`Parent category "${updates.parentId}" does not exist`) + } + + // Check for circular references + // Self-reference is the simplest case + if (updates.parentId === id) { + throw badRequest('Category cannot be its own parent') + } + + // Fetch all categories to check for cycles + const allCats = await db + .select() + .from(categories) + .where(eq(categories.communityDid, communityDid)) + + if (wouldCreateCycle(id, updates.parentId, allCats)) { + throw badRequest('Setting this parent would create a circular reference') + } } - } - // Build update set - const dbUpdates: Record = { - updatedAt: new Date(), - }; - if (updates.name !== undefined) dbUpdates.name = updates.name; - if (updates.slug !== undefined) dbUpdates.slug = updates.slug; - if (updates.description !== undefined) dbUpdates.description = updates.description ?? null; - if (updates.parentId !== undefined) dbUpdates.parentId = updates.parentId ?? null; - if (updates.sortOrder !== undefined) dbUpdates.sortOrder = updates.sortOrder; - if (updates.maturityRating !== undefined) dbUpdates.maturityRating = updates.maturityRating; - - const updated = await db - .update(categories) - .set(dbUpdates) - .where(eq(categories.id, id)) - .returning(); - - const updatedRow = updated[0]; - if (!updatedRow) { - throw notFound("Category not found after update"); - } + // Build update set + const dbUpdates: Record = { + updatedAt: new Date(), + } + if (updates.name !== undefined) dbUpdates.name = updates.name + if (updates.slug !== undefined) dbUpdates.slug = updates.slug + if (updates.description !== undefined) dbUpdates.description = updates.description ?? null + if (updates.parentId !== undefined) dbUpdates.parentId = updates.parentId ?? null + if (updates.sortOrder !== undefined) dbUpdates.sortOrder = updates.sortOrder + if (updates.maturityRating !== undefined) dbUpdates.maturityRating = updates.maturityRating + + const updated = await db + .update(categories) + .set(dbUpdates) + .where(eq(categories.id, id)) + .returning() + + const updatedRow = updated[0] + if (!updatedRow) { + throw notFound('Category not found after update') + } - app.log.info( - { categoryId: id, updates: Object.keys(updates), adminDid: request.user?.did }, - "Category updated", - ); + app.log.info( + { categoryId: id, updates: Object.keys(updates), adminDid: request.user?.did }, + 'Category updated' + ) - return reply.status(200).send(serializeCategory(updatedRow)); - }); + return reply.status(200).send(serializeCategory(updatedRow)) + } + ) // ------------------------------------------------------------------- // DELETE /api/admin/categories/:id (admin required) // ------------------------------------------------------------------- - app.delete("/api/admin/categories/:id", { - preHandler: [requireAdmin], - schema: { - tags: ["Categories (Admin)"], - summary: "Delete a category", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["id"], - properties: { - id: { type: "string" }, + app.delete( + '/api/admin/categories/:id', + { + preHandler: [requireAdmin], + schema: { + tags: ['Categories (Admin)'], + summary: 'Delete a category', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + }, + }, + response: { + 204: { type: 'null' }, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + 409: errorJsonSchema, }, - }, - response: { - 204: { type: "null" }, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, }, }, - }, async (request, reply) => { - const { id } = request.params as { id: string }; - - // Find existing category - const existingRows = await db - .select() - .from(categories) - .where(eq(categories.id, id)); - - const existing = existingRows[0]; - if (!existing) { - throw notFound("Category not found"); - } + async (request, reply) => { + const { id } = request.params as { id: string } - // Check if category has topics within this community - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - const topicCountResult = await db - .select({ count: count() }) - .from(topics) - .where( - and( - eq(topics.category, existing.slug), - eq(topics.communityDid, communityDid), - ), - ); - - const topicCount = topicCountResult[0]?.count ?? 0; - if (topicCount > 0) { - throw conflict( - `Cannot delete category: it has ${String(topicCount)} topic(s). Move or delete them first.`, - ); - } + // Find existing category + const existingRows = await db.select().from(categories).where(eq(categories.id, id)) - // Check if category has children - const childRows = await db - .select() - .from(categories) - .where(eq(categories.parentId, id)); + const existing = existingRows[0] + if (!existing) { + throw notFound('Category not found') + } - if (childRows.length > 0) { - throw conflict( - `Cannot delete category: it has ${String(childRows.length)} child category/categories. Move or delete them first.`, - ); - } + // Check if category has topics within this community + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' + const topicCountResult = await db + .select({ count: count() }) + .from(topics) + .where(and(eq(topics.category, existing.slug), eq(topics.communityDid, communityDid))) + + const topicCount = topicCountResult[0]?.count ?? 0 + if (topicCount > 0) { + throw conflict( + `Cannot delete category: it has ${String(topicCount)} topic(s). Move or delete them first.` + ) + } - // Delete the category - await db - .delete(categories) - .where(eq(categories.id, id)); + // Check if category has children + const childRows = await db.select().from(categories).where(eq(categories.parentId, id)) + + if (childRows.length > 0) { + throw conflict( + `Cannot delete category: it has ${String(childRows.length)} child category/categories. Move or delete them first.` + ) + } - app.log.info( - { categoryId: id, slug: existing.slug, adminDid: request.user?.did }, - "Category deleted", - ); + // Delete the category + await db.delete(categories).where(eq(categories.id, id)) - return reply.status(204).send(); - }); + app.log.info( + { categoryId: id, slug: existing.slug, adminDid: request.user?.did }, + 'Category deleted' + ) + + return reply.status(204).send() + } + ) // ------------------------------------------------------------------- // PUT /api/admin/categories/:id/maturity (admin required) // ------------------------------------------------------------------- - app.put("/api/admin/categories/:id/maturity", { - preHandler: [requireAdmin], - schema: { - tags: ["Categories (Admin)"], - summary: "Update category maturity rating", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["id"], - properties: { - id: { type: "string" }, + app.put( + '/api/admin/categories/:id/maturity', + { + preHandler: [requireAdmin], + schema: { + tags: ['Categories (Admin)'], + summary: 'Update category maturity rating', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + }, }, - }, - body: { - type: "object", - required: ["maturityRating"], - properties: { - maturityRating: { type: "string", enum: ["safe", "mature", "adult"] }, + body: { + type: 'object', + required: ['maturityRating'], + properties: { + maturityRating: { type: 'string', enum: ['safe', 'mature', 'adult'] }, + }, + }, + response: { + 200: categoryJsonSchema, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, }, - }, - response: { - 200: categoryJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, }, }, - }, async (request, reply) => { - const { id } = request.params as { id: string }; - - const parsed = updateMaturitySchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid maturity rating"); - } + async (request, reply) => { + const { id } = request.params as { id: string } - const { maturityRating } = parsed.data; + const parsed = updateMaturitySchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid maturity rating') + } - // Find existing category - const existingRows = await db - .select() - .from(categories) - .where(eq(categories.id, id)); + const { maturityRating } = parsed.data - const existing = existingRows[0]; - if (!existing) { - throw notFound("Category not found"); - } + // Find existing category + const existingRows = await db.select().from(categories).where(eq(categories.id, id)) - // Fetch community settings for maturity validation - const settingsRows = await db - .select() - .from(communitySettings) - .where(eq(communitySettings.id, "default")); + const existing = existingRows[0] + if (!existing) { + throw notFound('Category not found') + } - const settings = settingsRows[0]; - const communityDefault = settings?.maturityRating ?? "safe"; + // Fetch community settings for maturity validation + const settingsRows = await db + .select() + .from(communitySettings) + .where(eq(communitySettings.id, 'default')) - // Validate: cannot be lower than community default - if (isMaturityLowerThan(maturityRating, communityDefault)) { - throw badRequest( - `Category maturity rating "${maturityRating}" cannot be lower than community default "${communityDefault}"`, - ); - } + const settings = settingsRows[0] + const communityDefault = settings?.maturityRating ?? 'safe' - const updated = await db - .update(categories) - .set({ - maturityRating, - updatedAt: new Date(), - }) - .where(eq(categories.id, id)) - .returning(); + // Validate: cannot be lower than community default + if (isMaturityLowerThan(maturityRating, communityDefault)) { + throw badRequest( + `Category maturity rating "${maturityRating}" cannot be lower than community default "${communityDefault}"` + ) + } - const updatedRow = updated[0]; - if (!updatedRow) { - throw notFound("Category not found after update"); - } + const updated = await db + .update(categories) + .set({ + maturityRating, + updatedAt: new Date(), + }) + .where(eq(categories.id, id)) + .returning() + + const updatedRow = updated[0] + if (!updatedRow) { + throw notFound('Category not found after update') + } - app.log.info( - { categoryId: id, maturityRating, adminDid: request.user?.did }, - "Category maturity rating updated", - ); + app.log.info( + { categoryId: id, maturityRating, adminDid: request.user?.did }, + 'Category maturity rating updated' + ) - return reply.status(200).send(serializeCategory(updatedRow)); - }); + return reply.status(200).send(serializeCategory(updatedRow)) + } + ) - done(); - }; + done() + } } diff --git a/src/routes/community-profiles.ts b/src/routes/community-profiles.ts index 15eda40..198c9e6 100644 --- a/src/routes/community-profiles.ts +++ b/src/routes/community-profiles.ts @@ -1,52 +1,52 @@ -import { eq, and } from "drizzle-orm"; -import type { FastifyPluginCallback } from "fastify"; -import { notFound, badRequest } from "../lib/api-errors.js"; -import { resolveProfile } from "../lib/resolve-profile.js"; -import type { SourceProfile, CommunityOverride } from "../lib/resolve-profile.js"; -import { updateCommunityProfileSchema } from "../validation/community-profiles.js"; -import { users } from "../db/schema/users.js"; -import { communityProfiles } from "../db/schema/community-profiles.js"; +import { eq, and } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { notFound, badRequest } from '../lib/api-errors.js' +import { resolveProfile } from '../lib/resolve-profile.js' +import type { SourceProfile, CommunityOverride } from '../lib/resolve-profile.js' +import { updateCommunityProfileSchema } from '../validation/community-profiles.js' +import { users } from '../db/schema/users.js' +import { communityProfiles } from '../db/schema/community-profiles.js' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- const errorJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, + error: { type: 'string' as const }, }, -}; +} const communityProfileJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - did: { type: "string" as const }, - handle: { type: "string" as const }, - displayName: { type: ["string", "null"] as const }, - avatarUrl: { type: ["string", "null"] as const }, - bannerUrl: { type: ["string", "null"] as const }, - bio: { type: ["string", "null"] as const }, - communityDid: { type: "string" as const }, - hasOverride: { type: "boolean" as const }, + did: { type: 'string' as const }, + handle: { type: 'string' as const }, + displayName: { type: ['string', 'null'] as const }, + avatarUrl: { type: ['string', 'null'] as const }, + bannerUrl: { type: ['string', 'null'] as const }, + bio: { type: ['string', 'null'] as const }, + communityDid: { type: 'string' as const }, + hasOverride: { type: 'boolean' as const }, source: { - type: "object" as const, + type: 'object' as const, properties: { - displayName: { type: ["string", "null"] as const }, - avatarUrl: { type: ["string", "null"] as const }, - bannerUrl: { type: ["string", "null"] as const }, - bio: { type: ["string", "null"] as const }, + displayName: { type: ['string', 'null'] as const }, + avatarUrl: { type: ['string', 'null'] as const }, + bannerUrl: { type: ['string', 'null'] as const }, + bio: { type: ['string', 'null'] as const }, }, }, }, -}; +} const successJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - success: { type: "boolean" as const }, + success: { type: 'boolean' as const }, }, -}; +} // --------------------------------------------------------------------------- // Community profile routes plugin @@ -61,25 +61,25 @@ const successJsonSchema = { */ export function communityProfileRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, authMiddleware } = app; + const { db, authMiddleware } = app // ------------------------------------------------------------------- // GET /api/communities/:communityDid/profile (auth required) // ------------------------------------------------------------------- app.get( - "/api/communities/:communityDid/profile", + '/api/communities/:communityDid/profile', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Community Profiles"], - summary: "Get own resolved profile in a community", + tags: ['Community Profiles'], + summary: 'Get own resolved profile in a community', security: [{ bearerAuth: [] }], params: { - type: "object", - required: ["communityDid"], + type: 'object', + required: ['communityDid'], properties: { - communityDid: { type: "string" }, + communityDid: { type: 'string' }, }, }, response: { @@ -90,25 +90,20 @@ export function communityProfileRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const requestUser = request.user; + const requestUser = request.user if (!requestUser) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } - const { communityDid } = request.params as { communityDid: string }; - const userDid = requestUser.did; + const { communityDid } = request.params as { communityDid: string } + const userDid = requestUser.did // Fetch source profile from users table - const userRows = await db - .select() - .from(users) - .where(eq(users.did, userDid)); + const userRows = await db.select().from(users).where(eq(users.did, userDid)) - const user = userRows[0]; + const user = userRows[0] if (!user) { - throw notFound("User not found"); + throw notFound('User not found') } // Fetch community override @@ -118,11 +113,11 @@ export function communityProfileRoutes(): FastifyPluginCallback { .where( and( eq(communityProfiles.did, userDid), - eq(communityProfiles.communityDid, communityDid), - ), - ); + eq(communityProfiles.communityDid, communityDid) + ) + ) - const overrideRow = overrideRows[0]; + const overrideRow = overrideRows[0] const source: SourceProfile = { did: user.did, @@ -131,7 +126,7 @@ export function communityProfileRoutes(): FastifyPluginCallback { avatarUrl: user.avatarUrl ?? null, bannerUrl: user.bannerUrl ?? null, bio: user.bio ?? null, - }; + } const override: CommunityOverride | null = overrideRow ? { @@ -140,9 +135,9 @@ export function communityProfileRoutes(): FastifyPluginCallback { bannerUrl: overrideRow.bannerUrl ?? null, bio: overrideRow.bio ?? null, } - : null; + : null - const resolved = resolveProfile(source, override); + const resolved = resolveProfile(source, override) return reply.status(200).send({ did: resolved.did, @@ -159,34 +154,34 @@ export function communityProfileRoutes(): FastifyPluginCallback { bannerUrl: source.bannerUrl, bio: source.bio, }, - }); - }, - ); + }) + } + ) // ------------------------------------------------------------------- // PUT /api/communities/:communityDid/profile (auth required) // ------------------------------------------------------------------- app.put( - "/api/communities/:communityDid/profile", + '/api/communities/:communityDid/profile', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Community Profiles"], - summary: "Update per-community profile overrides", + tags: ['Community Profiles'], + summary: 'Update per-community profile overrides', security: [{ bearerAuth: [] }], params: { - type: "object", - required: ["communityDid"], + type: 'object', + required: ['communityDid'], properties: { - communityDid: { type: "string" }, + communityDid: { type: 'string' }, }, }, body: { - type: "object", + type: 'object', properties: { - displayName: { type: ["string", "null"] }, - bio: { type: ["string", "null"] }, + displayName: { type: ['string', 'null'] }, + bio: { type: ['string', 'null'] }, }, }, response: { @@ -197,28 +192,26 @@ export function communityProfileRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const requestUser = request.user; + const requestUser = request.user if (!requestUser) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } - const { communityDid } = request.params as { communityDid: string }; + const { communityDid } = request.params as { communityDid: string } - const parsed = updateCommunityProfileSchema.safeParse(request.body); + const parsed = updateCommunityProfileSchema.safeParse(request.body) if (!parsed.success) { - throw badRequest("Invalid community profile data"); + throw badRequest('Invalid community profile data') } - const now = new Date(); - const updateData: Record = { updatedAt: now }; + const now = new Date() + const updateData: Record = { updatedAt: now } if (parsed.data.displayName !== undefined) { - updateData["displayName"] = parsed.data.displayName; + updateData['displayName'] = parsed.data.displayName } if (parsed.data.bio !== undefined) { - updateData["bio"] = parsed.data.bio; + updateData['bio'] = parsed.data.bio } // Upsert: use composite key (did, communityDid) @@ -233,60 +226,58 @@ export function communityProfileRoutes(): FastifyPluginCallback { .onConflictDoUpdate({ target: [communityProfiles.did, communityProfiles.communityDid], set: updateData, - }); + }) - return reply.status(200).send({ success: true }); - }, - ); + return reply.status(200).send({ success: true }) + } + ) // ------------------------------------------------------------------- // DELETE /api/communities/:communityDid/profile (auth required) // ------------------------------------------------------------------- app.delete( - "/api/communities/:communityDid/profile", + '/api/communities/:communityDid/profile', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Community Profiles"], - summary: "Reset community profile to source (delete override)", + tags: ['Community Profiles'], + summary: 'Reset community profile to source (delete override)', security: [{ bearerAuth: [] }], params: { - type: "object", - required: ["communityDid"], + type: 'object', + required: ['communityDid'], properties: { - communityDid: { type: "string" }, + communityDid: { type: 'string' }, }, }, response: { - 204: { type: "null" }, + 204: { type: 'null' }, 401: errorJsonSchema, }, }, }, async (request, reply) => { - const requestUser = request.user; + const requestUser = request.user if (!requestUser) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } - const { communityDid } = request.params as { communityDid: string }; + const { communityDid } = request.params as { communityDid: string } await db .delete(communityProfiles) .where( and( eq(communityProfiles.did, requestUser.did), - eq(communityProfiles.communityDid, communityDid), - ), - ); + eq(communityProfiles.communityDid, communityDid) + ) + ) - return reply.status(204).send(); - }, - ); + return reply.status(204).send() + } + ) - done(); - }; + done() + } } diff --git a/src/routes/global-filters.ts b/src/routes/global-filters.ts index 91b7084..9779e6f 100644 --- a/src/routes/global-filters.ts +++ b/src/routes/global-filters.ts @@ -1,58 +1,58 @@ -import { eq, and, desc, sql } from "drizzle-orm"; -import type { FastifyPluginCallback } from "fastify"; -import { badRequest } from "../lib/api-errors.js"; +import { eq, and, desc, sql } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { badRequest } from '../lib/api-errors.js' import { communityFilterQuerySchema, updateCommunityFilterSchema, accountFilterQuerySchema, updateAccountFilterSchema, globalReportQuerySchema, -} from "../validation/global-filters.js"; -import { communityFilters } from "../db/schema/community-filters.js"; -import { accountFilters } from "../db/schema/account-filters.js"; +} from '../validation/global-filters.js' +import { communityFilters } from '../db/schema/community-filters.js' +import { accountFilters } from '../db/schema/account-filters.js' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- const errorJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, + error: { type: 'string' as const }, }, -}; +} const communityFilterJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - communityDid: { type: "string" as const }, - status: { type: "string" as const }, - adminDid: { type: ["string", "null"] as const }, - reason: { type: ["string", "null"] as const }, - reportCount: { type: "number" as const }, - lastReviewedAt: { type: ["string", "null"] as const }, - filteredBy: { type: ["string", "null"] as const }, - createdAt: { type: "string" as const, format: "date-time" as const }, - updatedAt: { type: "string" as const, format: "date-time" as const }, + communityDid: { type: 'string' as const }, + status: { type: 'string' as const }, + adminDid: { type: ['string', 'null'] as const }, + reason: { type: ['string', 'null'] as const }, + reportCount: { type: 'number' as const }, + lastReviewedAt: { type: ['string', 'null'] as const }, + filteredBy: { type: ['string', 'null'] as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + updatedAt: { type: 'string' as const, format: 'date-time' as const }, }, -}; +} const accountFilterJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - id: { type: "number" as const }, - did: { type: "string" as const }, - communityDid: { type: "string" as const }, - status: { type: "string" as const }, - reason: { type: ["string", "null"] as const }, - reportCount: { type: "number" as const }, - banCount: { type: "number" as const }, - lastReviewedAt: { type: ["string", "null"] as const }, - filteredBy: { type: ["string", "null"] as const }, - createdAt: { type: "string" as const, format: "date-time" as const }, - updatedAt: { type: "string" as const, format: "date-time" as const }, + id: { type: 'number' as const }, + did: { type: 'string' as const }, + communityDid: { type: 'string' as const }, + status: { type: 'string' as const }, + reason: { type: ['string', 'null'] as const }, + reportCount: { type: 'number' as const }, + banCount: { type: 'number' as const }, + lastReviewedAt: { type: ['string', 'null'] as const }, + filteredBy: { type: ['string', 'null'] as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + updatedAt: { type: 'string' as const, format: 'date-time' as const }, }, -}; +} // --------------------------------------------------------------------------- // Helpers @@ -69,7 +69,7 @@ function serializeCommunityFilter(row: typeof communityFilters.$inferSelect) { filteredBy: row.filteredBy, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), - }; + } } function serializeAccountFilter(row: typeof accountFilters.$inferSelect) { @@ -85,27 +85,28 @@ function serializeAccountFilter(row: typeof accountFilters.$inferSelect) { filteredBy: row.filteredBy, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), - }; + } } function encodeCursor(updatedAt: string, id: string | number): string { - return Buffer.from(JSON.stringify({ updatedAt, id })).toString("base64"); + return Buffer.from(JSON.stringify({ updatedAt, id })).toString('base64') } function decodeCursor(cursor: string): { updatedAt: string; id: string | number } | null { try { - const decoded = JSON.parse( - Buffer.from(cursor, "base64").toString("utf-8"), - ) as Record; + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as Record< + string, + unknown + > if ( - typeof decoded.updatedAt === "string" && - (typeof decoded.id === "string" || typeof decoded.id === "number") + typeof decoded.updatedAt === 'string' && + (typeof decoded.id === 'string' || typeof decoded.id === 'number') ) { - return { updatedAt: decoded.updatedAt, id: decoded.id }; + return { updatedAt: decoded.updatedAt, id: decoded.id } } - return null; + return null } catch { - return null; + return null } } @@ -115,389 +116,399 @@ function decodeCursor(cursor: string): { updatedAt: string; id: string | number export function globalFilterRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db } = app; - const requireOperator = app.requireOperator; + const { db } = app + const requireOperator = app.requireOperator // ------------------------------------------------------------------- // GET /api/global/filters/communities // ------------------------------------------------------------------- - app.get("/api/global/filters/communities", { - preHandler: [requireOperator], - schema: { - tags: ["Global Filters"], - summary: "List community filter statuses", - security: [{ bearerAuth: [] }], - querystring: { - type: "object", - properties: { - status: { type: "string", enum: ["active", "warned", "filtered"] }, - cursor: { type: "string" }, - limit: { type: "string" }, - }, - }, - response: { - 200: { - type: "object", + app.get( + '/api/global/filters/communities', + { + preHandler: [requireOperator], + schema: { + tags: ['Global Filters'], + summary: 'List community filter statuses', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', properties: { - filters: { type: "array", items: communityFilterJsonSchema }, - cursor: { type: ["string", "null"] }, + status: { type: 'string', enum: ['active', 'warned', 'filtered'] }, + cursor: { type: 'string' }, + limit: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + filters: { type: 'array', items: communityFilterJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, }, + 400: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, }, - 400: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, }, }, - }, async (request, reply) => { - const parsed = communityFilterQuerySchema.safeParse(request.query); - if (!parsed.success) { - throw badRequest("Invalid query parameters"); - } + async (request, reply) => { + const parsed = communityFilterQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } - const { status, cursor, limit } = parsed.data; - const conditions = []; + const { status, cursor, limit } = parsed.data + const conditions = [] - if (status) { - conditions.push(eq(communityFilters.status, status)); - } + if (status) { + conditions.push(eq(communityFilters.status, status)) + } - if (cursor) { - const decoded = decodeCursor(cursor); - if (decoded) { - conditions.push( - sql`(${communityFilters.updatedAt}, ${communityFilters.communityDid}) < (${decoded.updatedAt}::timestamptz, ${decoded.id})`, - ); + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${communityFilters.updatedAt}, ${communityFilters.communityDid}) < (${decoded.updatedAt}::timestamptz, ${decoded.id})` + ) + } } - } - const whereClause = conditions.length > 0 ? and(...conditions) : undefined; - const fetchLimit = limit + 1; - - const rows = await db - .select() - .from(communityFilters) - .where(whereClause) - .orderBy(desc(communityFilters.updatedAt)) - .limit(fetchLimit); - - const hasMore = rows.length > limit; - const resultRows = hasMore ? rows.slice(0, limit) : rows; - - let nextCursor: string | null = null; - if (hasMore) { - const lastRow = resultRows[resultRows.length - 1]; - if (lastRow) { - nextCursor = encodeCursor( - lastRow.updatedAt.toISOString(), - lastRow.communityDid, - ); + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + const fetchLimit = limit + 1 + + const rows = await db + .select() + .from(communityFilters) + .where(whereClause) + .orderBy(desc(communityFilters.updatedAt)) + .limit(fetchLimit) + + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.updatedAt.toISOString(), lastRow.communityDid) + } } - } - return reply.status(200).send({ - filters: resultRows.map(serializeCommunityFilter), - cursor: nextCursor, - }); - }); + return reply.status(200).send({ + filters: resultRows.map(serializeCommunityFilter), + cursor: nextCursor, + }) + } + ) // ------------------------------------------------------------------- // PUT /api/global/filters/communities/:did // ------------------------------------------------------------------- - app.put("/api/global/filters/communities/:did", { - preHandler: [requireOperator], - schema: { - tags: ["Global Filters"], - summary: "Update community filter (upsert)", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["did"], - properties: { did: { type: "string" } }, - }, - body: { - type: "object", - required: ["status"], - properties: { - status: { type: "string", enum: ["active", "warned", "filtered"] }, - reason: { type: "string", maxLength: 1000 }, - adminDid: { type: "string" }, + app.put( + '/api/global/filters/communities/:did', + { + preHandler: [requireOperator], + schema: { + tags: ['Global Filters'], + summary: 'Update community filter (upsert)', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['did'], + properties: { did: { type: 'string' } }, + }, + body: { + type: 'object', + required: ['status'], + properties: { + status: { type: 'string', enum: ['active', 'warned', 'filtered'] }, + reason: { type: 'string', maxLength: 1000 }, + adminDid: { type: 'string' }, + }, + }, + response: { + 200: communityFilterJsonSchema, + 400: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, }, - }, - response: { - 200: communityFilterJsonSchema, - 400: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, }, }, - }, async (request, reply) => { - // requireOperator guarantees request.user is set - const user = request.user as NonNullable; - - const { did } = request.params as { did: string }; - const parsed = updateCommunityFilterSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid filter data"); - } + async (request, reply) => { + // requireOperator guarantees request.user is set + const user = request.user as NonNullable + + const { did } = request.params as { did: string } + const parsed = updateCommunityFilterSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid filter data') + } - const { status, reason, adminDid } = parsed.data; - - const upserted = await db - .insert(communityFilters) - .values({ - communityDid: did, - status, - reason, - adminDid, - filteredBy: user.did, - lastReviewedAt: new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: communityFilters.communityDid, - set: { + const { status, reason, adminDid } = parsed.data + + const upserted = await db + .insert(communityFilters) + .values({ + communityDid: did, status, reason, - ...(adminDid !== undefined ? { adminDid } : {}), + adminDid, filteredBy: user.did, lastReviewedAt: new Date(), updatedAt: new Date(), - }, - }) - .returning(); + }) + .onConflictDoUpdate({ + target: communityFilters.communityDid, + set: { + status, + reason, + ...(adminDid !== undefined ? { adminDid } : {}), + filteredBy: user.did, + lastReviewedAt: new Date(), + updatedAt: new Date(), + }, + }) + .returning() - const row = upserted[0]; - if (!row) { - throw badRequest("Failed to upsert community filter"); - } + const row = upserted[0] + if (!row) { + throw badRequest('Failed to upsert community filter') + } - app.log.info( - { communityDid: did, status, operatorDid: user.did }, - "Community filter updated", - ); + app.log.info( + { communityDid: did, status, operatorDid: user.did }, + 'Community filter updated' + ) - return reply.status(200).send(serializeCommunityFilter(row)); - }); + return reply.status(200).send(serializeCommunityFilter(row)) + } + ) // ------------------------------------------------------------------- // GET /api/global/filters/accounts // ------------------------------------------------------------------- - app.get("/api/global/filters/accounts", { - preHandler: [requireOperator], - schema: { - tags: ["Global Filters"], - summary: "List account filter statuses", - security: [{ bearerAuth: [] }], - querystring: { - type: "object", - properties: { - status: { type: "string", enum: ["active", "warned", "filtered"] }, - communityDid: { type: "string" }, - cursor: { type: "string" }, - limit: { type: "string" }, - }, - }, - response: { - 200: { - type: "object", + app.get( + '/api/global/filters/accounts', + { + preHandler: [requireOperator], + schema: { + tags: ['Global Filters'], + summary: 'List account filter statuses', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', properties: { - filters: { type: "array", items: accountFilterJsonSchema }, - cursor: { type: ["string", "null"] }, + status: { type: 'string', enum: ['active', 'warned', 'filtered'] }, + communityDid: { type: 'string' }, + cursor: { type: 'string' }, + limit: { type: 'string' }, }, }, - 400: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + response: { + 200: { + type: 'object', + properties: { + filters: { type: 'array', items: accountFilterJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, + }, + 400: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + }, }, }, - }, async (request, reply) => { - const parsed = accountFilterQuerySchema.safeParse(request.query); - if (!parsed.success) { - throw badRequest("Invalid query parameters"); - } + async (request, reply) => { + const parsed = accountFilterQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } - const { status, communityDid, cursor, limit } = parsed.data; - const conditions = []; + const { status, communityDid, cursor, limit } = parsed.data + const conditions = [] - if (status) { - conditions.push(eq(accountFilters.status, status)); - } + if (status) { + conditions.push(eq(accountFilters.status, status)) + } - if (communityDid) { - conditions.push(eq(accountFilters.communityDid, communityDid)); - } + if (communityDid) { + conditions.push(eq(accountFilters.communityDid, communityDid)) + } - if (cursor) { - const decoded = decodeCursor(cursor); - if (decoded && typeof decoded.id === "number") { - conditions.push( - sql`(${accountFilters.updatedAt}, ${accountFilters.id}) < (${decoded.updatedAt}::timestamptz, ${decoded.id})`, - ); + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded && typeof decoded.id === 'number') { + conditions.push( + sql`(${accountFilters.updatedAt}, ${accountFilters.id}) < (${decoded.updatedAt}::timestamptz, ${decoded.id})` + ) + } } - } - const whereClause = conditions.length > 0 ? and(...conditions) : undefined; - const fetchLimit = limit + 1; - - const rows = await db - .select() - .from(accountFilters) - .where(whereClause) - .orderBy(desc(accountFilters.updatedAt)) - .limit(fetchLimit); - - const hasMore = rows.length > limit; - const resultRows = hasMore ? rows.slice(0, limit) : rows; - - let nextCursor: string | null = null; - if (hasMore) { - const lastRow = resultRows[resultRows.length - 1]; - if (lastRow) { - nextCursor = encodeCursor( - lastRow.updatedAt.toISOString(), - lastRow.id, - ); + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + const fetchLimit = limit + 1 + + const rows = await db + .select() + .from(accountFilters) + .where(whereClause) + .orderBy(desc(accountFilters.updatedAt)) + .limit(fetchLimit) + + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.updatedAt.toISOString(), lastRow.id) + } } - } - return reply.status(200).send({ - filters: resultRows.map(serializeAccountFilter), - cursor: nextCursor, - }); - }); + return reply.status(200).send({ + filters: resultRows.map(serializeAccountFilter), + cursor: nextCursor, + }) + } + ) // ------------------------------------------------------------------- // PUT /api/global/filters/accounts/:did // ------------------------------------------------------------------- - app.put("/api/global/filters/accounts/:did", { - preHandler: [requireOperator], - schema: { - tags: ["Global Filters"], - summary: "Update account filter (upsert, global level)", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["did"], - properties: { did: { type: "string" } }, - }, - body: { - type: "object", - required: ["status"], - properties: { - status: { type: "string", enum: ["active", "warned", "filtered"] }, - reason: { type: "string", maxLength: 1000 }, + app.put( + '/api/global/filters/accounts/:did', + { + preHandler: [requireOperator], + schema: { + tags: ['Global Filters'], + summary: 'Update account filter (upsert, global level)', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['did'], + properties: { did: { type: 'string' } }, + }, + body: { + type: 'object', + required: ['status'], + properties: { + status: { type: 'string', enum: ['active', 'warned', 'filtered'] }, + reason: { type: 'string', maxLength: 1000 }, + }, + }, + response: { + 200: accountFilterJsonSchema, + 400: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, }, - }, - response: { - 200: accountFilterJsonSchema, - 400: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, }, }, - }, async (request, reply) => { - // requireOperator guarantees request.user is set - const user = request.user as NonNullable; - - const { did } = request.params as { did: string }; - const parsed = updateAccountFilterSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid filter data"); - } + async (request, reply) => { + // requireOperator guarantees request.user is set + const user = request.user as NonNullable + + const { did } = request.params as { did: string } + const parsed = updateAccountFilterSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid filter data') + } - const { status, reason } = parsed.data; - const globalSentinel = "__global__"; - - const upserted = await db - .insert(accountFilters) - .values({ - did, - communityDid: globalSentinel, - status, - reason, - filteredBy: user.did, - lastReviewedAt: new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [accountFilters.did, accountFilters.communityDid], - set: { + const { status, reason } = parsed.data + const globalSentinel = '__global__' + + const upserted = await db + .insert(accountFilters) + .values({ + did, + communityDid: globalSentinel, status, reason, filteredBy: user.did, lastReviewedAt: new Date(), updatedAt: new Date(), - }, - }) - .returning(); + }) + .onConflictDoUpdate({ + target: [accountFilters.did, accountFilters.communityDid], + set: { + status, + reason, + filteredBy: user.did, + lastReviewedAt: new Date(), + updatedAt: new Date(), + }, + }) + .returning() - const row = upserted[0]; - if (!row) { - throw badRequest("Failed to upsert account filter"); - } + const row = upserted[0] + if (!row) { + throw badRequest('Failed to upsert account filter') + } - app.log.info( - { accountDid: did, status, operatorDid: user.did }, - "Account filter updated", - ); + app.log.info({ accountDid: did, status, operatorDid: user.did }, 'Account filter updated') - return reply.status(200).send(serializeAccountFilter(row)); - }); + return reply.status(200).send(serializeAccountFilter(row)) + } + ) // ------------------------------------------------------------------- // GET /api/global/reports/communities // ------------------------------------------------------------------- - app.get("/api/global/reports/communities", { - preHandler: [requireOperator], - schema: { - tags: ["Global Filters"], - summary: "Most-reported communities (aggregate reports)", - security: [{ bearerAuth: [] }], - querystring: { - type: "object", - properties: { - limit: { type: "string" }, - }, - }, - response: { - 200: { - type: "object", + app.get( + '/api/global/reports/communities', + { + preHandler: [requireOperator], + schema: { + tags: ['Global Filters'], + summary: 'Most-reported communities (aggregate reports)', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', properties: { - communities: { - type: "array", - items: { - type: "object", - properties: { - communityDid: { type: "string" }, - reportCount: { type: "number" }, - topicCount: { type: "number" }, + limit: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + communities: { + type: 'array', + items: { + type: 'object', + properties: { + communityDid: { type: 'string' }, + reportCount: { type: 'number' }, + topicCount: { type: 'number' }, + }, }, }, }, }, + 400: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, }, - 400: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, }, }, - }, async (request, reply) => { - const parsed = globalReportQuerySchema.safeParse(request.query); - const limit = parsed.success ? parsed.data.limit : 25; - - // Aggregate reports by communityDid, join topics for post count - interface CommunityReportRow { - community_did: string; - report_count: number; - topic_count: number; - } + async (request, reply) => { + const parsed = globalReportQuerySchema.safeParse(request.query) + const limit = parsed.success ? parsed.data.limit : 25 + + // Aggregate reports by communityDid, join topics for post count + interface CommunityReportRow { + community_did: string + report_count: number + topic_count: number + } - const rows = await db.execute(sql` + const rows = (await db.execute(sql` SELECT r.community_did, count(DISTINCT r.id)::int AS report_count, @@ -511,17 +522,18 @@ export function globalFilterRoutes(): FastifyPluginCallback { GROUP BY r.community_did, t.topic_count ORDER BY report_count DESC LIMIT ${limit} - `) as unknown as CommunityReportRow[]; - - return reply.status(200).send({ - communities: rows.map((r) => ({ - communityDid: r.community_did, - reportCount: r.report_count, - topicCount: r.topic_count, - })), - }); - }); - - done(); - }; + `)) as unknown as CommunityReportRow[] + + return reply.status(200).send({ + communities: rows.map((r) => ({ + communityDid: r.community_did, + reportCount: r.report_count, + topicCount: r.topic_count, + })), + }) + } + ) + + done() + } } diff --git a/src/routes/health.ts b/src/routes/health.ts index 9fe68ed..7f1ad3b 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -1,62 +1,58 @@ -import type { FastifyPluginCallback } from "fastify"; -import { sql } from "drizzle-orm"; +import type { FastifyPluginCallback } from 'fastify' +import { sql } from 'drizzle-orm' const healthRoutes: FastifyPluginCallback = (fastify, _opts, done) => { - fastify.get("/api/health", async (_request, reply) => { + fastify.get('/api/health', async (_request, reply) => { return reply.send({ - status: "healthy", - version: "0.1.0", + status: 'healthy', + version: '0.1.0', uptime: process.uptime(), - }); - }); + }) + }) - fastify.get("/api/health/ready", async (_request, reply) => { - const checks: Record = {}; + fastify.get('/api/health/ready', async (_request, reply) => { + const checks: Record = {} // Check database - const dbStart = performance.now(); + const dbStart = performance.now() try { - await fastify.db.execute(sql`SELECT 1`); - checks["database"] = { - status: "healthy", + await fastify.db.execute(sql`SELECT 1`) + checks['database'] = { + status: 'healthy', latency: Math.round(performance.now() - dbStart), - }; + } } catch { - checks["database"] = { status: "unhealthy" }; + checks['database'] = { status: 'unhealthy' } } // Check cache - const cacheStart = performance.now(); + const cacheStart = performance.now() try { - await fastify.cache.ping(); - checks["cache"] = { - status: "healthy", + await fastify.cache.ping() + checks['cache'] = { + status: 'healthy', latency: Math.round(performance.now() - cacheStart), - }; + } } catch { - checks["cache"] = { status: "unhealthy" }; + checks['cache'] = { status: 'unhealthy' } } // Check firehose - const firehoseStatus = fastify.firehose.getStatus(); - checks["firehose"] = { - status: firehoseStatus.connected ? "healthy" : "unhealthy", - ...(firehoseStatus.lastEventId !== null - ? { latency: firehoseStatus.lastEventId } - : {}), - }; - - const allHealthy = Object.values(checks).every( - (c) => c.status === "healthy", - ); + const firehoseStatus = fastify.firehose.getStatus() + checks['firehose'] = { + status: firehoseStatus.connected ? 'healthy' : 'unhealthy', + ...(firehoseStatus.lastEventId !== null ? { latency: firehoseStatus.lastEventId } : {}), + } + + const allHealthy = Object.values(checks).every((c) => c.status === 'healthy') return reply.status(allHealthy ? 200 : 503).send({ - status: allHealthy ? "ready" : "degraded", + status: allHealthy ? 'ready' : 'degraded', checks, - }); - }); + }) + }) - done(); -}; + done() +} -export default healthRoutes; +export default healthRoutes diff --git a/src/routes/moderation-queue.ts b/src/routes/moderation-queue.ts index 07a6534..933624c 100644 --- a/src/routes/moderation-queue.ts +++ b/src/routes/moderation-queue.ts @@ -1,51 +1,43 @@ -import { eq, and, desc, sql } from "drizzle-orm"; -import type { FastifyPluginCallback } from "fastify"; -import { - notFound, - badRequest, - conflict, -} from "../lib/api-errors.js"; -import { - wordFilterSchema, - queueActionSchema, - queueQuerySchema, -} from "../validation/anti-spam.js"; -import { moderationQueue } from "../db/schema/moderation-queue.js"; -import { accountTrust } from "../db/schema/account-trust.js"; -import { topics } from "../db/schema/topics.js"; -import { replies } from "../db/schema/replies.js"; -import { communitySettings } from "../db/schema/community-settings.js"; -import { createRequireModerator } from "../auth/require-moderator.js"; +import { eq, and, desc, sql } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { notFound, badRequest, conflict } from '../lib/api-errors.js' +import { wordFilterSchema, queueActionSchema, queueQuerySchema } from '../validation/anti-spam.js' +import { moderationQueue } from '../db/schema/moderation-queue.js' +import { accountTrust } from '../db/schema/account-trust.js' +import { topics } from '../db/schema/topics.js' +import { replies } from '../db/schema/replies.js' +import { communitySettings } from '../db/schema/community-settings.js' +import { createRequireModerator } from '../auth/require-moderator.js' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- const errorJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, + error: { type: 'string' as const }, }, -}; +} const queueItemJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - id: { type: "number" as const }, - contentUri: { type: "string" as const }, - contentType: { type: "string" as const }, - authorDid: { type: "string" as const }, - queueReason: { type: "string" as const }, + id: { type: 'number' as const }, + contentUri: { type: 'string' as const }, + contentType: { type: 'string' as const }, + authorDid: { type: 'string' as const }, + queueReason: { type: 'string' as const }, matchedWords: { - type: ["array", "null"] as const, - items: { type: "string" as const }, + type: ['array', 'null'] as const, + items: { type: 'string' as const }, }, - status: { type: "string" as const }, - reviewedBy: { type: ["string", "null"] as const }, - createdAt: { type: "string" as const, format: "date-time" as const }, - reviewedAt: { type: ["string", "null"] as const }, + status: { type: 'string' as const }, + reviewedBy: { type: ['string', 'null'] as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + reviewedAt: { type: ['string', 'null'] as const }, }, -}; +} // --------------------------------------------------------------------------- // Helpers @@ -63,29 +55,25 @@ function serializeQueueItem(row: typeof moderationQueue.$inferSelect) { reviewedBy: row.reviewedBy ?? null, createdAt: row.createdAt.toISOString(), reviewedAt: row.reviewedAt?.toISOString() ?? null, - }; + } } function encodeCursor(createdAt: string, id: number): string { - return Buffer.from(JSON.stringify({ createdAt, id })).toString("base64"); + return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64') } -function decodeCursor( - cursor: string, -): { createdAt: string; id: number } | null { +function decodeCursor(cursor: string): { createdAt: string; id: number } | null { try { - const decoded = JSON.parse( - Buffer.from(cursor, "base64").toString("utf-8"), - ) as Record; - if ( - typeof decoded.createdAt === "string" && - typeof decoded.id === "number" - ) { - return { createdAt: decoded.createdAt, id: decoded.id }; + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as Record< + string, + unknown + > + if (typeof decoded.createdAt === 'string' && typeof decoded.id === 'number') { + return { createdAt: decoded.createdAt, id: decoded.id } } - return null; + return null } catch { - return null; + return null } } @@ -95,415 +83,408 @@ function decodeCursor( export function moderationQueueRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, env, authMiddleware } = app; - const requireModerator = createRequireModerator( - db, - authMiddleware, - app.log, - ); - const requireAdmin = app.requireAdmin; - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; + const { db, env, authMiddleware } = app + const requireModerator = createRequireModerator(db, authMiddleware, app.log) + const requireAdmin = app.requireAdmin + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' // ------------------------------------------------------------------- // GET /api/moderation/queue (moderator+) // ------------------------------------------------------------------- - app.get("/api/moderation/queue", { - preHandler: [requireModerator], - schema: { - tags: ["Moderation"], - summary: "List moderation queue items (paginated)", - security: [{ bearerAuth: [] }], - querystring: { - type: "object", - properties: { - status: { - type: "string", - enum: ["pending", "approved", "rejected"], - }, - queueReason: { - type: "string", - enum: [ - "word_filter", - "first_post", - "link_hold", - "burst", - "topic_delay", - ], + app.get( + '/api/moderation/queue', + { + preHandler: [requireModerator], + schema: { + tags: ['Moderation'], + summary: 'List moderation queue items (paginated)', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['pending', 'approved', 'rejected'], + }, + queueReason: { + type: 'string', + enum: ['word_filter', 'first_post', 'link_hold', 'burst', 'topic_delay'], + }, + cursor: { type: 'string' }, + limit: { type: 'string' }, }, - cursor: { type: "string" }, - limit: { type: "string" }, }, - }, - response: { - 200: { - type: "object", - properties: { - items: { type: "array", items: queueItemJsonSchema }, - cursor: { type: ["string", "null"] }, + response: { + 200: { + type: 'object', + properties: { + items: { type: 'array', items: queueItemJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, }, + 400: errorJsonSchema, }, - 400: errorJsonSchema, }, }, - }, async (request, reply) => { - const parsed = queueQuerySchema.safeParse(request.query); - if (!parsed.success) { - throw badRequest("Invalid query parameters"); - } + async (request, reply) => { + const parsed = queueQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } - const { status, queueReason, cursor, limit } = parsed.data; - const conditions = [eq(moderationQueue.communityDid, communityDid)]; + const { status, queueReason, cursor, limit } = parsed.data + const conditions = [eq(moderationQueue.communityDid, communityDid)] - conditions.push(eq(moderationQueue.status, status)); + conditions.push(eq(moderationQueue.status, status)) - if (queueReason) { - conditions.push(eq(moderationQueue.queueReason, queueReason)); - } + if (queueReason) { + conditions.push(eq(moderationQueue.queueReason, queueReason)) + } - if (cursor) { - const decoded = decodeCursor(cursor); - if (decoded) { - conditions.push( - sql`(${moderationQueue.createdAt}, ${moderationQueue.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})`, - ); + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${moderationQueue.createdAt}, ${moderationQueue.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` + ) + } } - } - const whereClause = and(...conditions); - const fetchLimit = limit + 1; - - const rows = await db - .select() - .from(moderationQueue) - .where(whereClause) - .orderBy(desc(moderationQueue.createdAt)) - .limit(fetchLimit); - - const hasMore = rows.length > limit; - const resultRows = hasMore ? rows.slice(0, limit) : rows; - - let nextCursor: string | null = null; - if (hasMore) { - const lastRow = resultRows[resultRows.length - 1]; - if (lastRow) { - nextCursor = encodeCursor( - lastRow.createdAt.toISOString(), - lastRow.id, - ); + const whereClause = and(...conditions) + const fetchLimit = limit + 1 + + const rows = await db + .select() + .from(moderationQueue) + .where(whereClause) + .orderBy(desc(moderationQueue.createdAt)) + .limit(fetchLimit) + + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id) + } } - } - return reply.status(200).send({ - items: resultRows.map(serializeQueueItem), - cursor: nextCursor, - }); - }); + return reply.status(200).send({ + items: resultRows.map(serializeQueueItem), + cursor: nextCursor, + }) + } + ) // ------------------------------------------------------------------- // PUT /api/moderation/queue/:id (moderator+) // ------------------------------------------------------------------- - app.put("/api/moderation/queue/:id", { - preHandler: [requireModerator], - schema: { - tags: ["Moderation"], - summary: "Approve or reject a queued item", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["id"], - properties: { id: { type: "string" } }, - }, - body: { - type: "object", - required: ["action"], - properties: { - action: { type: "string", enum: ["approve", "reject"] }, + app.put( + '/api/moderation/queue/:id', + { + preHandler: [requireModerator], + schema: { + tags: ['Moderation'], + summary: 'Approve or reject a queued item', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + body: { + type: 'object', + required: ['action'], + properties: { + action: { type: 'string', enum: ['approve', 'reject'] }, + }, + }, + response: { + 200: queueItemJsonSchema, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + 409: errorJsonSchema, }, - }, - response: { - 200: queueItemJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const { id } = request.params as { id: string }; - const queueId = Number(id); - if (Number.isNaN(queueId)) { - throw badRequest("Invalid queue item ID"); - } + const { id } = request.params as { id: string } + const queueId = Number(id) + if (Number.isNaN(queueId)) { + throw badRequest('Invalid queue item ID') + } - const parsed = queueActionSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid action"); - } + const parsed = queueActionSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid action') + } - const { action } = parsed.data; - - // Fetch the queue item - const existing = await db - .select() - .from(moderationQueue) - .where( - and( - eq(moderationQueue.id, queueId), - eq(moderationQueue.communityDid, communityDid), - ), - ); - - const item = existing[0]; - if (!item) { - throw notFound("Queue item not found"); - } + const { action } = parsed.data - if (item.status !== "pending") { - throw conflict("Queue item already reviewed"); - } + // Fetch the queue item + const existing = await db + .select() + .from(moderationQueue) + .where( + and(eq(moderationQueue.id, queueId), eq(moderationQueue.communityDid, communityDid)) + ) - const newStatus = action === "approve" ? "approved" : "rejected"; - const contentStatus = action === "approve" ? "approved" : "rejected"; + const item = existing[0] + if (!item) { + throw notFound('Queue item not found') + } - await db.transaction(async (tx) => { - // Update queue item - await tx - .update(moderationQueue) - .set({ - status: newStatus, - reviewedBy: user.did, - reviewedAt: new Date(), - }) - .where(eq(moderationQueue.id, queueId)); + if (item.status !== 'pending') { + throw conflict('Queue item already reviewed') + } - // Update content moderation status - if (item.contentType === "topic") { - await tx - .update(topics) - .set({ moderationStatus: contentStatus }) - .where(eq(topics.uri, item.contentUri)); - } else { + const newStatus = action === 'approve' ? 'approved' : 'rejected' + const contentStatus = action === 'approve' ? 'approved' : 'rejected' + + await db.transaction(async (tx) => { + // Update queue item await tx - .update(replies) - .set({ moderationStatus: contentStatus }) - .where(eq(replies.uri, item.contentUri)); - } + .update(moderationQueue) + .set({ + status: newStatus, + reviewedBy: user.did, + reviewedAt: new Date(), + }) + .where(eq(moderationQueue.id, queueId)) - // On approve: increment account trust - if (action === "approve") { - // Check if there are other pending queue items for the same content URI - // Only increment trust once per content item (not per queue reason) - const otherPending = await tx - .select({ id: moderationQueue.id }) - .from(moderationQueue) - .where( - and( - eq(moderationQueue.contentUri, item.contentUri), - eq(moderationQueue.status, "pending"), - sql`${moderationQueue.id} != ${queueId}`, - ), - ); - - // Also approve any other pending queue items for the same content - if (otherPending.length > 0) { + // Update content moderation status + if (item.contentType === 'topic') { await tx - .update(moderationQueue) - .set({ - status: "approved", - reviewedBy: user.did, - reviewedAt: new Date(), - }) + .update(topics) + .set({ moderationStatus: contentStatus }) + .where(eq(topics.uri, item.contentUri)) + } else { + await tx + .update(replies) + .set({ moderationStatus: contentStatus }) + .where(eq(replies.uri, item.contentUri)) + } + + // On approve: increment account trust + if (action === 'approve') { + // Check if there are other pending queue items for the same content URI + // Only increment trust once per content item (not per queue reason) + const otherPending = await tx + .select({ id: moderationQueue.id }) + .from(moderationQueue) .where( and( eq(moderationQueue.contentUri, item.contentUri), - eq(moderationQueue.status, "pending"), - ), - ); - } - - // Upsert account trust - const existingTrust = await tx - .select() - .from(accountTrust) - .where( - and( - eq(accountTrust.did, item.authorDid), - eq(accountTrust.communityDid, communityDid), - ), - ); - - // Load thresholds for trust check - const settingsRows = await tx - .select({ - moderationThresholds: - communitySettings.moderationThresholds, - }) - .from(communitySettings) - .where(eq(communitySettings.id, "default")); - const trustedPostThreshold = - settingsRows[0]?.moderationThresholds.trustedPostThreshold ?? 10; - - if (existingTrust.length > 0) { - const newCount = - (existingTrust[0]?.approvedPostCount ?? 0) + 1; - const nowTrusted = newCount >= trustedPostThreshold; - - await tx - .update(accountTrust) - .set({ - approvedPostCount: newCount, - isTrusted: nowTrusted, - ...(nowTrusted && !existingTrust[0]?.isTrusted - ? { trustedAt: new Date() } - : {}), - }) + eq(moderationQueue.status, 'pending'), + sql`${moderationQueue.id} != ${queueId}` + ) + ) + + // Also approve any other pending queue items for the same content + if (otherPending.length > 0) { + await tx + .update(moderationQueue) + .set({ + status: 'approved', + reviewedBy: user.did, + reviewedAt: new Date(), + }) + .where( + and( + eq(moderationQueue.contentUri, item.contentUri), + eq(moderationQueue.status, 'pending') + ) + ) + } + + // Upsert account trust + const existingTrust = await tx + .select() + .from(accountTrust) .where( and( eq(accountTrust.did, item.authorDid), - eq(accountTrust.communityDid, communityDid), - ), - ); - } else { - const nowTrusted = 1 >= trustedPostThreshold; - await tx.insert(accountTrust).values({ - did: item.authorDid, - communityDid, - approvedPostCount: 1, - isTrusted: nowTrusted, - ...(nowTrusted ? { trustedAt: new Date() } : {}), - }); + eq(accountTrust.communityDid, communityDid) + ) + ) + + // Load thresholds for trust check + const settingsRows = await tx + .select({ + moderationThresholds: communitySettings.moderationThresholds, + }) + .from(communitySettings) + .where(eq(communitySettings.id, 'default')) + const trustedPostThreshold = + settingsRows[0]?.moderationThresholds.trustedPostThreshold ?? 10 + + if (existingTrust.length > 0) { + const newCount = (existingTrust[0]?.approvedPostCount ?? 0) + 1 + const nowTrusted = newCount >= trustedPostThreshold + + await tx + .update(accountTrust) + .set({ + approvedPostCount: newCount, + isTrusted: nowTrusted, + ...(nowTrusted && !existingTrust[0]?.isTrusted ? { trustedAt: new Date() } : {}), + }) + .where( + and( + eq(accountTrust.did, item.authorDid), + eq(accountTrust.communityDid, communityDid) + ) + ) + } else { + const nowTrusted = 1 >= trustedPostThreshold + await tx.insert(accountTrust).values({ + did: item.authorDid, + communityDid, + approvedPostCount: 1, + isTrusted: nowTrusted, + ...(nowTrusted ? { trustedAt: new Date() } : {}), + }) + } } + }) + + app.log.info( + { + queueId, + action, + contentUri: item.contentUri, + reviewedBy: user.did, + }, + `Queue item ${action}d` + ) + + // Fetch updated item + const updated = await db + .select() + .from(moderationQueue) + .where(eq(moderationQueue.id, queueId)) + + const updatedItem = updated[0] + if (!updatedItem) { + throw notFound('Queue item not found after update') } - }); - - app.log.info( - { - queueId, - action, - contentUri: item.contentUri, - reviewedBy: user.did, - }, - `Queue item ${action}d`, - ); - - // Fetch updated item - const updated = await db - .select() - .from(moderationQueue) - .where(eq(moderationQueue.id, queueId)); - - const updatedItem = updated[0]; - if (!updatedItem) { - throw notFound("Queue item not found after update"); - } - return reply.status(200).send(serializeQueueItem(updatedItem)); - }); + return reply.status(200).send(serializeQueueItem(updatedItem)) + } + ) // ------------------------------------------------------------------- // GET /api/admin/moderation/word-filter (admin only) // ------------------------------------------------------------------- - app.get("/api/admin/moderation/word-filter", { - preHandler: [requireAdmin], - schema: { - tags: ["Admin"], - summary: "Get word filter list", - security: [{ bearerAuth: [] }], - response: { - 200: { - type: "object", - properties: { - words: { - type: "array", - items: { type: "string" }, + app.get( + '/api/admin/moderation/word-filter', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin'], + summary: 'Get word filter list', + security: [{ bearerAuth: [] }], + response: { + 200: { + type: 'object', + properties: { + words: { + type: 'array', + items: { type: 'string' }, + }, }, }, }, }, }, - }, async (_request, reply) => { - const rows = await db - .select({ wordFilter: communitySettings.wordFilter }) - .from(communitySettings) - .where(eq(communitySettings.id, "default")); + async (_request, reply) => { + const rows = await db + .select({ wordFilter: communitySettings.wordFilter }) + .from(communitySettings) + .where(eq(communitySettings.id, 'default')) - const words = rows[0]?.wordFilter ?? []; + const words = rows[0]?.wordFilter ?? [] - return reply.status(200).send({ words }); - }); + return reply.status(200).send({ words }) + } + ) // ------------------------------------------------------------------- // PUT /api/admin/moderation/word-filter (admin only) // ------------------------------------------------------------------- - app.put("/api/admin/moderation/word-filter", { - preHandler: [requireAdmin], - schema: { - tags: ["Admin"], - summary: "Update word filter list", - security: [{ bearerAuth: [] }], - body: { - type: "object", - required: ["words"], - properties: { - words: { - type: "array", - items: { type: "string", minLength: 1, maxLength: 100 }, - maxItems: 500, - }, - }, - }, - response: { - 200: { - type: "object", + app.put( + '/api/admin/moderation/word-filter', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin'], + summary: 'Update word filter list', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + required: ['words'], properties: { words: { - type: "array", - items: { type: "string" }, + type: 'array', + items: { type: 'string', minLength: 1, maxLength: 100 }, + maxItems: 500, }, }, }, - 400: errorJsonSchema, + response: { + 200: { + type: 'object', + properties: { + words: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + 400: errorJsonSchema, + }, }, }, - }, async (request, reply) => { - const parsed = wordFilterSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid word filter data"); - } + async (request, reply) => { + const parsed = wordFilterSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid word filter data') + } - // Deduplicate and normalize to lowercase - const words = [...new Set(parsed.data.words.map((w) => w.toLowerCase()))]; + // Deduplicate and normalize to lowercase + const words = [...new Set(parsed.data.words.map((w) => w.toLowerCase()))] - await db - .update(communitySettings) - .set({ wordFilter: words }) - .where(eq(communitySettings.id, "default")); + await db + .update(communitySettings) + .set({ wordFilter: words }) + .where(eq(communitySettings.id, 'default')) - // Invalidate cached anti-spam settings - try { - await app.cache.del(`antispam:settings:${communityDid}`); - } catch { - // Non-critical - } + // Invalidate cached anti-spam settings + try { + await app.cache.del(`antispam:settings:${communityDid}`) + } catch { + // Non-critical + } - app.log.info( - { wordCount: words.length }, - "Word filter updated", - ); + app.log.info({ wordCount: words.length }, 'Word filter updated') - return reply.status(200).send({ words }); - }); + return reply.status(200).send({ words }) + } + ) - done(); - }; + done() + } } diff --git a/src/routes/moderation.ts b/src/routes/moderation.ts index cf30cee..7605684 100644 --- a/src/routes/moderation.ts +++ b/src/routes/moderation.ts @@ -1,11 +1,6 @@ -import { eq, and, desc, sql } from "drizzle-orm"; -import type { FastifyPluginCallback } from "fastify"; -import { - notFound, - forbidden, - badRequest, - conflict, -} from "../lib/api-errors.js"; +import { eq, and, desc, sql } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { notFound, forbidden, badRequest, conflict } from '../lib/api-errors.js' import { lockTopicSchema, pinTopicSchema, @@ -19,62 +14,62 @@ import { moderationThresholdsSchema, appealReportSchema, myReportsQuerySchema, -} from "../validation/moderation.js"; -import { topics } from "../db/schema/topics.js"; -import { replies } from "../db/schema/replies.js"; -import { users } from "../db/schema/users.js"; -import { moderationActions } from "../db/schema/moderation-actions.js"; -import { reports } from "../db/schema/reports.js"; -import { communitySettings } from "../db/schema/community-settings.js"; -import { notifications } from "../db/schema/notifications.js"; -import { communityFilters } from "../db/schema/community-filters.js"; -import { createRequireModerator } from "../auth/require-moderator.js"; -import { checkBanPropagation } from "../services/ban-propagation.js"; -import { createNotificationService } from "../services/notification.js"; +} from '../validation/moderation.js' +import { topics } from '../db/schema/topics.js' +import { replies } from '../db/schema/replies.js' +import { users } from '../db/schema/users.js' +import { moderationActions } from '../db/schema/moderation-actions.js' +import { reports } from '../db/schema/reports.js' +import { communitySettings } from '../db/schema/community-settings.js' +import { notifications } from '../db/schema/notifications.js' +import { communityFilters } from '../db/schema/community-filters.js' +import { createRequireModerator } from '../auth/require-moderator.js' +import { checkBanPropagation } from '../services/ban-propagation.js' +import { createNotificationService } from '../services/notification.js' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- const errorJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, + error: { type: 'string' as const }, }, -}; +} const moderationActionJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - id: { type: "number" as const }, - action: { type: "string" as const }, - targetUri: { type: ["string", "null"] as const }, - targetDid: { type: ["string", "null"] as const }, - moderatorDid: { type: "string" as const }, - reason: { type: ["string", "null"] as const }, - createdAt: { type: "string" as const, format: "date-time" as const }, + id: { type: 'number' as const }, + action: { type: 'string' as const }, + targetUri: { type: ['string', 'null'] as const }, + targetDid: { type: ['string', 'null'] as const }, + moderatorDid: { type: 'string' as const }, + reason: { type: ['string', 'null'] as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, }, -}; +} const reportJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - id: { type: "number" as const }, - reporterDid: { type: "string" as const }, - targetUri: { type: "string" as const }, - targetDid: { type: "string" as const }, - reasonType: { type: "string" as const }, - description: { type: ["string", "null"] as const }, - status: { type: "string" as const }, - resolutionType: { type: ["string", "null"] as const }, - resolvedBy: { type: ["string", "null"] as const }, - resolvedAt: { type: ["string", "null"] as const }, - appealReason: { type: ["string", "null"] as const }, - appealedAt: { type: ["string", "null"] as const }, - appealStatus: { type: "string" as const, enum: ["none", "pending", "rejected"] }, - createdAt: { type: "string" as const, format: "date-time" as const }, + id: { type: 'number' as const }, + reporterDid: { type: 'string' as const }, + targetUri: { type: 'string' as const }, + targetDid: { type: 'string' as const }, + reasonType: { type: 'string' as const }, + description: { type: ['string', 'null'] as const }, + status: { type: 'string' as const }, + resolutionType: { type: ['string', 'null'] as const }, + resolvedBy: { type: ['string', 'null'] as const }, + resolvedAt: { type: ['string', 'null'] as const }, + appealReason: { type: ['string', 'null'] as const }, + appealedAt: { type: ['string', 'null'] as const }, + appealStatus: { type: 'string' as const, enum: ['none', 'pending', 'rejected'] }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, }, -}; +} // --------------------------------------------------------------------------- // Helpers @@ -89,7 +84,7 @@ function serializeAction(row: typeof moderationActions.$inferSelect) { moderatorDid: row.moderatorDid, reason: row.reason, createdAt: row.createdAt.toISOString(), - }; + } } function serializeReport(row: typeof reports.$inferSelect) { @@ -108,24 +103,25 @@ function serializeReport(row: typeof reports.$inferSelect) { appealedAt: row.appealedAt?.toISOString() ?? null, appealStatus: row.appealStatus, createdAt: row.createdAt.toISOString(), - }; + } } function encodeCursor(createdAt: string, id: number): string { - return Buffer.from(JSON.stringify({ createdAt, id })).toString("base64"); + return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64') } function decodeCursor(cursor: string): { createdAt: string; id: number } | null { try { - const decoded = JSON.parse( - Buffer.from(cursor, "base64").toString("utf-8"), - ) as Record; - if (typeof decoded.createdAt === "string" && typeof decoded.id === "number") { - return { createdAt: decoded.createdAt, id: decoded.id }; + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as Record< + string, + unknown + > + if (typeof decoded.createdAt === 'string' && typeof decoded.id === 'number') { + return { createdAt: decoded.createdAt, id: decoded.id } } - return null; + return null } catch { - return null; + return null } } @@ -134,8 +130,8 @@ function decodeCursor(cursor: string): { createdAt: string; id: number } | null * Format: at://did:plc:xxx/collection/rkey -> did:plc:xxx */ function extractDidFromUri(uri: string): string | undefined { - const match = /^at:\/\/(did:[^/]+)\//.exec(uri); - return match?.[1]; + const match = /^at:\/\/(did:[^/]+)\//.exec(uri) + return match?.[1] } // --------------------------------------------------------------------------- @@ -144,905 +140,911 @@ function extractDidFromUri(uri: string): string | undefined { export function moderationRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, env, authMiddleware } = app; - const requireModerator = createRequireModerator(db, authMiddleware, app.log); - const requireAdmin = app.requireAdmin; - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - const notificationService = createNotificationService(db, app.log); + const { db, env, authMiddleware } = app + const requireModerator = createRequireModerator(db, authMiddleware, app.log) + const requireAdmin = app.requireAdmin + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' + const notificationService = createNotificationService(db, app.log) // ------------------------------------------------------------------- // POST /api/moderation/lock/:id (moderator+) // ------------------------------------------------------------------- - app.post("/api/moderation/lock/:id", { - preHandler: [requireModerator], - schema: { - tags: ["Moderation"], - summary: "Lock or unlock a topic", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["id"], - properties: { id: { type: "string" } }, - }, - body: { - type: "object", - properties: { - reason: { type: "string", maxLength: 500 }, + app.post( + '/api/moderation/lock/:id', + { + preHandler: [requireModerator], + schema: { + tags: ['Moderation'], + summary: 'Lock or unlock a topic', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, }, - }, - response: { - 200: { - type: "object", + body: { + type: 'object', properties: { - uri: { type: "string" }, - isLocked: { type: "boolean" }, + reason: { type: 'string', maxLength: 500 }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + uri: { type: 'string' }, + isLocked: { type: 'boolean' }, + }, }, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, }, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const { id } = request.params as { id: string }; - const decodedUri = decodeURIComponent(id); - const parsed = lockTopicSchema.safeParse(request.body); + const { id } = request.params as { id: string } + const decodedUri = decodeURIComponent(id) + const parsed = lockTopicSchema.safeParse(request.body) - const topicRows = await db - .select() - .from(topics) - .where(and(eq(topics.uri, decodedUri), eq(topics.communityDid, communityDid))); + const topicRows = await db + .select() + .from(topics) + .where(and(eq(topics.uri, decodedUri), eq(topics.communityDid, communityDid))) - const topic = topicRows[0]; - if (!topic) { - throw notFound("Topic not found"); - } + const topic = topicRows[0] + if (!topic) { + throw notFound('Topic not found') + } + + const newLocked = !topic.isLocked + const action = newLocked ? 'lock' : 'unlock' + + await db.transaction(async (tx) => { + await tx.update(topics).set({ isLocked: newLocked }).where(eq(topics.uri, decodedUri)) - const newLocked = !topic.isLocked; - const action = newLocked ? "lock" : "unlock"; - - await db.transaction(async (tx) => { - await tx - .update(topics) - .set({ isLocked: newLocked }) - .where(eq(topics.uri, decodedUri)); - - await tx.insert(moderationActions).values({ - action, - targetUri: decodedUri, - moderatorDid: user.did, - communityDid, - reason: parsed.success ? parsed.data.reason : undefined, - }); - }); - - app.log.info( - { action, topicUri: decodedUri, moderatorDid: user.did }, - `Topic ${action}ed`, - ); - - // Fire-and-forget: notify topic author of lock/unlock - notificationService.notifyOnModAction({ - targetUri: decodedUri, - moderatorDid: user.did, - targetDid: topic.authorDid, - communityDid, - }).catch((err: unknown) => { - app.log.error({ err, topicUri: decodedUri }, "Mod action notification failed"); - }); - - return reply.status(200).send({ - uri: decodedUri, - isLocked: newLocked, - }); - }); + await tx.insert(moderationActions).values({ + action, + targetUri: decodedUri, + moderatorDid: user.did, + communityDid, + reason: parsed.success ? parsed.data.reason : undefined, + }) + }) + + app.log.info({ action, topicUri: decodedUri, moderatorDid: user.did }, `Topic ${action}ed`) + + // Fire-and-forget: notify topic author of lock/unlock + notificationService + .notifyOnModAction({ + targetUri: decodedUri, + moderatorDid: user.did, + targetDid: topic.authorDid, + communityDid, + }) + .catch((err: unknown) => { + app.log.error({ err, topicUri: decodedUri }, 'Mod action notification failed') + }) + + return reply.status(200).send({ + uri: decodedUri, + isLocked: newLocked, + }) + } + ) // ------------------------------------------------------------------- // POST /api/moderation/pin/:id (moderator+) // ------------------------------------------------------------------- - app.post("/api/moderation/pin/:id", { - preHandler: [requireModerator], - schema: { - tags: ["Moderation"], - summary: "Pin or unpin a topic", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["id"], - properties: { id: { type: "string" } }, - }, - body: { - type: "object", - properties: { - reason: { type: "string", maxLength: 500 }, + app.post( + '/api/moderation/pin/:id', + { + preHandler: [requireModerator], + schema: { + tags: ['Moderation'], + summary: 'Pin or unpin a topic', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, }, - }, - response: { - 200: { - type: "object", + body: { + type: 'object', properties: { - uri: { type: "string" }, - isPinned: { type: "boolean" }, + reason: { type: 'string', maxLength: 500 }, }, }, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + response: { + 200: { + type: 'object', + properties: { + uri: { type: 'string' }, + isPinned: { type: 'boolean' }, + }, + }, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + }, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const { id } = request.params as { id: string }; - const decodedUri = decodeURIComponent(id); - const parsed = pinTopicSchema.safeParse(request.body); + const { id } = request.params as { id: string } + const decodedUri = decodeURIComponent(id) + const parsed = pinTopicSchema.safeParse(request.body) - const topicRows = await db - .select() - .from(topics) - .where(and(eq(topics.uri, decodedUri), eq(topics.communityDid, communityDid))); + const topicRows = await db + .select() + .from(topics) + .where(and(eq(topics.uri, decodedUri), eq(topics.communityDid, communityDid))) - const topic = topicRows[0]; - if (!topic) { - throw notFound("Topic not found"); - } + const topic = topicRows[0] + if (!topic) { + throw notFound('Topic not found') + } + + const newPinned = !topic.isPinned + const action = newPinned ? 'pin' : 'unpin' + + await db.transaction(async (tx) => { + await tx.update(topics).set({ isPinned: newPinned }).where(eq(topics.uri, decodedUri)) - const newPinned = !topic.isPinned; - const action = newPinned ? "pin" : "unpin"; - - await db.transaction(async (tx) => { - await tx - .update(topics) - .set({ isPinned: newPinned }) - .where(eq(topics.uri, decodedUri)); - - await tx.insert(moderationActions).values({ - action, - targetUri: decodedUri, - moderatorDid: user.did, - communityDid, - reason: parsed.success ? parsed.data.reason : undefined, - }); - }); - - app.log.info( - { action, topicUri: decodedUri, moderatorDid: user.did }, - `Topic ${action}ned`, - ); - - // Fire-and-forget: notify topic author of pin/unpin - notificationService.notifyOnModAction({ - targetUri: decodedUri, - moderatorDid: user.did, - targetDid: topic.authorDid, - communityDid, - }).catch((err: unknown) => { - app.log.error({ err, topicUri: decodedUri }, "Mod action notification failed"); - }); - - return reply.status(200).send({ - uri: decodedUri, - isPinned: newPinned, - }); - }); + await tx.insert(moderationActions).values({ + action, + targetUri: decodedUri, + moderatorDid: user.did, + communityDid, + reason: parsed.success ? parsed.data.reason : undefined, + }) + }) + + app.log.info({ action, topicUri: decodedUri, moderatorDid: user.did }, `Topic ${action}ned`) + + // Fire-and-forget: notify topic author of pin/unpin + notificationService + .notifyOnModAction({ + targetUri: decodedUri, + moderatorDid: user.did, + targetDid: topic.authorDid, + communityDid, + }) + .catch((err: unknown) => { + app.log.error({ err, topicUri: decodedUri }, 'Mod action notification failed') + }) + + return reply.status(200).send({ + uri: decodedUri, + isPinned: newPinned, + }) + } + ) // ------------------------------------------------------------------- // POST /api/moderation/delete/:id (moderator+) // ------------------------------------------------------------------- - app.post("/api/moderation/delete/:id", { - preHandler: [requireModerator], - schema: { - tags: ["Moderation"], - summary: "Mod-delete content (marks as deleted in index, does NOT delete from PDS)", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["id"], - properties: { id: { type: "string" } }, - }, - body: { - type: "object", - required: ["reason"], - properties: { - reason: { type: "string", minLength: 1, maxLength: 500 }, + app.post( + '/api/moderation/delete/:id', + { + preHandler: [requireModerator], + schema: { + tags: ['Moderation'], + summary: 'Mod-delete content (marks as deleted in index, does NOT delete from PDS)', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, }, - }, - response: { - 200: { - type: "object", + body: { + type: 'object', + required: ['reason'], properties: { - uri: { type: "string" }, - isModDeleted: { type: "boolean" }, + reason: { type: 'string', minLength: 1, maxLength: 500 }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + uri: { type: 'string' }, + isModDeleted: { type: 'boolean' }, + }, }, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + 409: errorJsonSchema, }, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const { id } = request.params as { id: string }; - const decodedUri = decodeURIComponent(id); - const parsed = modDeleteSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Reason is required for mod-delete"); - } + const { id } = request.params as { id: string } + const decodedUri = decodeURIComponent(id) + const parsed = modDeleteSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Reason is required for mod-delete') + } - // Check if this is a topic or reply - const topicRows = await db - .select() - .from(topics) - .where(and(eq(topics.uri, decodedUri), eq(topics.communityDid, communityDid))); + // Check if this is a topic or reply + const topicRows = await db + .select() + .from(topics) + .where(and(eq(topics.uri, decodedUri), eq(topics.communityDid, communityDid))) - const topic = topicRows[0]; + const topic = topicRows[0] - if (topic) { - if (topic.isModDeleted) { - throw conflict("Content already mod-deleted"); + if (topic) { + if (topic.isModDeleted) { + throw conflict('Content already mod-deleted') + } + + await db.transaction(async (tx) => { + await tx.update(topics).set({ isModDeleted: true }).where(eq(topics.uri, decodedUri)) + + await tx.insert(moderationActions).values({ + action: 'delete', + targetUri: decodedUri, + targetDid: topic.authorDid, + moderatorDid: user.did, + communityDid, + reason: parsed.data.reason, + }) + }) + + app.log.info( + { action: 'delete', topicUri: decodedUri, moderatorDid: user.did }, + 'Topic mod-deleted' + ) + + // Fire-and-forget: notify topic author of deletion + notificationService + .notifyOnModAction({ + targetUri: decodedUri, + moderatorDid: user.did, + targetDid: topic.authorDid, + communityDid, + }) + .catch((err: unknown) => { + app.log.error({ err, topicUri: decodedUri }, 'Mod action notification failed') + }) + + return reply.status(200).send({ + uri: decodedUri, + isModDeleted: true, + }) + } + + // Not a topic -- check replies + const replyRows = await db + .select() + .from(replies) + .where(and(eq(replies.uri, decodedUri), eq(replies.communityDid, communityDid))) + + const replyRow = replyRows[0] + if (!replyRow) { + throw notFound('Content not found') } + // For replies, we delete from the index entirely (no isModDeleted column on replies) await db.transaction(async (tx) => { + await tx.delete(replies).where(eq(replies.uri, decodedUri)) + + // Decrement reply count on parent topic await tx .update(topics) - .set({ isModDeleted: true }) - .where(eq(topics.uri, decodedUri)); + .set({ replyCount: sql`GREATEST(${topics.replyCount} - 1, 0)` }) + .where(eq(topics.uri, replyRow.rootUri)) await tx.insert(moderationActions).values({ - action: "delete", + action: 'delete', targetUri: decodedUri, - targetDid: topic.authorDid, + targetDid: replyRow.authorDid, moderatorDid: user.did, communityDid, reason: parsed.data.reason, - }); - }); + }) + }) app.log.info( - { action: "delete", topicUri: decodedUri, moderatorDid: user.did }, - "Topic mod-deleted", - ); - - // Fire-and-forget: notify topic author of deletion - notificationService.notifyOnModAction({ - targetUri: decodedUri, - moderatorDid: user.did, - targetDid: topic.authorDid, - communityDid, - }).catch((err: unknown) => { - app.log.error({ err, topicUri: decodedUri }, "Mod action notification failed"); - }); + { action: 'delete', replyUri: decodedUri, moderatorDid: user.did }, + 'Reply mod-deleted' + ) + + // Fire-and-forget: notify reply author of deletion + notificationService + .notifyOnModAction({ + targetUri: decodedUri, + moderatorDid: user.did, + targetDid: replyRow.authorDid, + communityDid, + }) + .catch((err: unknown) => { + app.log.error({ err, replyUri: decodedUri }, 'Mod action notification failed') + }) return reply.status(200).send({ uri: decodedUri, isModDeleted: true, - }); - } - - // Not a topic -- check replies - const replyRows = await db - .select() - .from(replies) - .where(and(eq(replies.uri, decodedUri), eq(replies.communityDid, communityDid))); - - const replyRow = replyRows[0]; - if (!replyRow) { - throw notFound("Content not found"); + }) } - - // For replies, we delete from the index entirely (no isModDeleted column on replies) - await db.transaction(async (tx) => { - await tx - .delete(replies) - .where(eq(replies.uri, decodedUri)); - - // Decrement reply count on parent topic - await tx - .update(topics) - .set({ replyCount: sql`GREATEST(${topics.replyCount} - 1, 0)` }) - .where(eq(topics.uri, replyRow.rootUri)); - - await tx.insert(moderationActions).values({ - action: "delete", - targetUri: decodedUri, - targetDid: replyRow.authorDid, - moderatorDid: user.did, - communityDid, - reason: parsed.data.reason, - }); - }); - - app.log.info( - { action: "delete", replyUri: decodedUri, moderatorDid: user.did }, - "Reply mod-deleted", - ); - - // Fire-and-forget: notify reply author of deletion - notificationService.notifyOnModAction({ - targetUri: decodedUri, - moderatorDid: user.did, - targetDid: replyRow.authorDid, - communityDid, - }).catch((err: unknown) => { - app.log.error({ err, replyUri: decodedUri }, "Mod action notification failed"); - }); - - return reply.status(200).send({ - uri: decodedUri, - isModDeleted: true, - }); - }); + ) // ------------------------------------------------------------------- // POST /api/moderation/ban (admin only) // ------------------------------------------------------------------- - app.post("/api/moderation/ban", { - preHandler: [requireAdmin], - schema: { - tags: ["Moderation"], - summary: "Ban or unban a user by DID", - security: [{ bearerAuth: [] }], - body: { - type: "object", - required: ["did", "reason"], - properties: { - did: { type: "string", minLength: 1 }, - reason: { type: "string", minLength: 1, maxLength: 500 }, - }, - }, - response: { - 200: { - type: "object", + app.post( + '/api/moderation/ban', + { + preHandler: [requireAdmin], + schema: { + tags: ['Moderation'], + summary: 'Ban or unban a user by DID', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + required: ['did', 'reason'], properties: { - did: { type: "string" }, - isBanned: { type: "boolean" }, + did: { type: 'string', minLength: 1 }, + reason: { type: 'string', minLength: 1, maxLength: 500 }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + did: { type: 'string' }, + isBanned: { type: 'boolean' }, + }, }, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, }, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, }, }, - }, async (request, reply) => { - const admin = request.user; - if (!admin) { - return reply.status(401).send({ error: "Authentication required" }); - } + async (request, reply) => { + const admin = request.user + if (!admin) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const parsed = banUserSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("DID and reason are required"); - } + const parsed = banUserSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('DID and reason are required') + } - const { did: targetDid, reason } = parsed.data; + const { did: targetDid, reason } = parsed.data - // Prevent self-ban - if (targetDid === admin.did) { - throw badRequest("Cannot ban yourself"); - } + // Prevent self-ban + if (targetDid === admin.did) { + throw badRequest('Cannot ban yourself') + } - // Check user exists - const userRows = await db - .select() - .from(users) - .where(eq(users.did, targetDid)); + // Check user exists + const userRows = await db.select().from(users).where(eq(users.did, targetDid)) - const targetUser = userRows[0]; - if (!targetUser) { - throw notFound("User not found"); - } + const targetUser = userRows[0] + if (!targetUser) { + throw notFound('User not found') + } - // Prevent banning other admins - if (targetUser.role === "admin") { - throw forbidden("Cannot ban an admin"); - } + // Prevent banning other admins + if (targetUser.role === 'admin') { + throw forbidden('Cannot ban an admin') + } - const newBanned = !targetUser.isBanned; - const action = newBanned ? "ban" : "unban"; - - await db.transaction(async (tx) => { - await tx - .update(users) - .set({ isBanned: newBanned }) - .where(eq(users.did, targetDid)); - - await tx.insert(moderationActions).values({ - action, - targetDid, - moderatorDid: admin.did, - communityDid, - reason, - }); - }); - - app.log.info( - { action, targetDid, adminDid: admin.did }, - `User ${action}ned`, - ); - - // In global mode, check ban propagation across communities - if (env.COMMUNITY_MODE === "global" && action === "ban") { - try { - const result = await checkBanPropagation( - db, - app.cache, - app.log, + const newBanned = !targetUser.isBanned + const action = newBanned ? 'ban' : 'unban' + + await db.transaction(async (tx) => { + await tx.update(users).set({ isBanned: newBanned }).where(eq(users.did, targetDid)) + + await tx.insert(moderationActions).values({ + action, targetDid, - ); - if (result.propagated) { - app.log.info( - { targetDid, banCount: result.banCount }, - "Ban propagation triggered global account filter", - ); + moderatorDid: admin.did, + communityDid, + reason, + }) + }) + + app.log.info({ action, targetDid, adminDid: admin.did }, `User ${action}ned`) + + // In global mode, check ban propagation across communities + if (env.COMMUNITY_MODE === 'global' && action === 'ban') { + try { + const result = await checkBanPropagation(db, app.cache, app.log, targetDid) + if (result.propagated) { + app.log.info( + { targetDid, banCount: result.banCount }, + 'Ban propagation triggered global account filter' + ) + } + } catch (err) { + app.log.warn({ err, targetDid }, 'Ban propagation check failed (non-critical)') } - } catch (err) { - app.log.warn( - { err, targetDid }, - "Ban propagation check failed (non-critical)", - ); } - } - // Fire-and-forget: notify banned/unbanned user - // Use targetDid as the targetUri since bans are user-level, not content-level - notificationService.notifyOnModAction({ - targetUri: `at://${targetDid}`, - moderatorDid: admin.did, - targetDid, - communityDid, - }).catch((err: unknown) => { - app.log.error({ err, targetDid }, "Mod action notification failed"); - }); - - return reply.status(200).send({ - did: targetDid, - isBanned: newBanned, - }); - }); + // Fire-and-forget: notify banned/unbanned user + // Use targetDid as the targetUri since bans are user-level, not content-level + notificationService + .notifyOnModAction({ + targetUri: `at://${targetDid}`, + moderatorDid: admin.did, + targetDid, + communityDid, + }) + .catch((err: unknown) => { + app.log.error({ err, targetDid }, 'Mod action notification failed') + }) + + return reply.status(200).send({ + did: targetDid, + isBanned: newBanned, + }) + } + ) // ------------------------------------------------------------------- // GET /api/moderation/log (moderator+) // ------------------------------------------------------------------- - app.get("/api/moderation/log", { - preHandler: [requireModerator], - schema: { - tags: ["Moderation"], - summary: "Get moderation action log (paginated)", - security: [{ bearerAuth: [] }], - querystring: { - type: "object", - properties: { - cursor: { type: "string" }, - limit: { type: "string" }, - action: { - type: "string", - enum: ["lock", "unlock", "pin", "unpin", "delete", "ban", "unban"], + app.get( + '/api/moderation/log', + { + preHandler: [requireModerator], + schema: { + tags: ['Moderation'], + summary: 'Get moderation action log (paginated)', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', + properties: { + cursor: { type: 'string' }, + limit: { type: 'string' }, + action: { + type: 'string', + enum: ['lock', 'unlock', 'pin', 'unpin', 'delete', 'ban', 'unban'], + }, }, }, - }, - response: { - 200: { - type: "object", - properties: { - actions: { type: "array", items: moderationActionJsonSchema }, - cursor: { type: ["string", "null"] }, + response: { + 200: { + type: 'object', + properties: { + actions: { type: 'array', items: moderationActionJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, }, + 400: errorJsonSchema, }, - 400: errorJsonSchema, }, }, - }, async (request, reply) => { - const parsed = moderationLogQuerySchema.safeParse(request.query); - if (!parsed.success) { - throw badRequest("Invalid query parameters"); - } + async (request, reply) => { + const parsed = moderationLogQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } - const { cursor, limit, action } = parsed.data; - const conditions = [eq(moderationActions.communityDid, communityDid)]; + const { cursor, limit, action } = parsed.data + const conditions = [eq(moderationActions.communityDid, communityDid)] - if (action) { - conditions.push(eq(moderationActions.action, action)); - } + if (action) { + conditions.push(eq(moderationActions.action, action)) + } - if (cursor) { - const decoded = decodeCursor(cursor); - if (decoded) { - conditions.push( - sql`(${moderationActions.createdAt}, ${moderationActions.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})`, - ); + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${moderationActions.createdAt}, ${moderationActions.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` + ) + } } - } - const whereClause = and(...conditions); - const fetchLimit = limit + 1; + const whereClause = and(...conditions) + const fetchLimit = limit + 1 - const rows = await db - .select() - .from(moderationActions) - .where(whereClause) - .orderBy(desc(moderationActions.createdAt)) - .limit(fetchLimit); + const rows = await db + .select() + .from(moderationActions) + .where(whereClause) + .orderBy(desc(moderationActions.createdAt)) + .limit(fetchLimit) - const hasMore = rows.length > limit; - const resultRows = hasMore ? rows.slice(0, limit) : rows; + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows - let nextCursor: string | null = null; - if (hasMore) { - const lastRow = resultRows[resultRows.length - 1]; - if (lastRow) { - nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id); + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id) + } } - } - return reply.status(200).send({ - actions: resultRows.map(serializeAction), - cursor: nextCursor, - }); - }); + return reply.status(200).send({ + actions: resultRows.map(serializeAction), + cursor: nextCursor, + }) + } + ) // ------------------------------------------------------------------- // POST /api/moderation/report (authenticated user) // ------------------------------------------------------------------- - app.post("/api/moderation/report", { - preHandler: [authMiddleware.requireAuth], - schema: { - tags: ["Moderation"], - summary: "Report content for moderator review", - security: [{ bearerAuth: [] }], - body: { - type: "object", - required: ["targetUri", "reasonType"], - properties: { - targetUri: { type: "string", minLength: 1 }, - reasonType: { - type: "string", - enum: ["spam", "sexual", "harassment", "violation", "misleading", "other"], + app.post( + '/api/moderation/report', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Moderation'], + summary: 'Report content for moderator review', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + required: ['targetUri', 'reasonType'], + properties: { + targetUri: { type: 'string', minLength: 1 }, + reasonType: { + type: 'string', + enum: ['spam', 'sexual', 'harassment', 'violation', 'misleading', 'other'], + }, + description: { type: 'string', maxLength: 1000 }, }, - description: { type: "string", maxLength: 1000 }, }, - }, - response: { - 201: reportJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, + response: { + 201: reportJsonSchema, + 400: errorJsonSchema, + 401: errorJsonSchema, + 404: errorJsonSchema, + 409: errorJsonSchema, + }, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } - - const parsed = createReportSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid report data"); - } + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const { targetUri, reasonType, description } = parsed.data; + const parsed = createReportSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid report data') + } - // Extract target DID from URI - const targetDid = extractDidFromUri(targetUri); - if (!targetDid) { - throw badRequest("Invalid target URI format"); - } + const { targetUri, reasonType, description } = parsed.data - // Cannot report own content - if (targetDid === user.did) { - throw badRequest("Cannot report your own content"); - } + // Extract target DID from URI + const targetDid = extractDidFromUri(targetUri) + if (!targetDid) { + throw badRequest('Invalid target URI format') + } - // Verify target content exists (topic or reply) - const topicRows = await db - .select({ uri: topics.uri }) - .from(topics) - .where(and(eq(topics.uri, targetUri), eq(topics.communityDid, communityDid))); + // Cannot report own content + if (targetDid === user.did) { + throw badRequest('Cannot report your own content') + } - let contentExists = topicRows.length > 0; + // Verify target content exists (topic or reply) + const topicRows = await db + .select({ uri: topics.uri }) + .from(topics) + .where(and(eq(topics.uri, targetUri), eq(topics.communityDid, communityDid))) - if (!contentExists) { - const replyRows = await db - .select({ uri: replies.uri }) - .from(replies) - .where(and(eq(replies.uri, targetUri), eq(replies.communityDid, communityDid))); - contentExists = replyRows.length > 0; - } + let contentExists = topicRows.length > 0 - if (!contentExists) { - throw notFound("Content not found"); - } + if (!contentExists) { + const replyRows = await db + .select({ uri: replies.uri }) + .from(replies) + .where(and(eq(replies.uri, targetUri), eq(replies.communityDid, communityDid))) + contentExists = replyRows.length > 0 + } - // Check for duplicate report - const existingReports = await db - .select({ id: reports.id }) - .from(reports) - .where( - and( - eq(reports.reporterDid, user.did), - eq(reports.targetUri, targetUri), - eq(reports.communityDid, communityDid), - ), - ); - - if (existingReports.length > 0) { - throw conflict("You have already reported this content"); - } + if (!contentExists) { + throw notFound('Content not found') + } - const inserted = await db - .insert(reports) - .values({ - reporterDid: user.did, - targetUri, - targetDid, - reasonType, - description, - communityDid, - }) - .returning(); + // Check for duplicate report + const existingReports = await db + .select({ id: reports.id }) + .from(reports) + .where( + and( + eq(reports.reporterDid, user.did), + eq(reports.targetUri, targetUri), + eq(reports.communityDid, communityDid) + ) + ) + + if (existingReports.length > 0) { + throw conflict('You have already reported this content') + } - const report = inserted[0]; - if (!report) { - throw badRequest("Failed to create report"); - } + const inserted = await db + .insert(reports) + .values({ + reporterDid: user.did, + targetUri, + targetDid, + reasonType, + description, + communityDid, + }) + .returning() - app.log.info( - { reportId: report.id, reporterDid: user.did, targetUri, reasonType }, - "Content reported", - ); + const report = inserted[0] + if (!report) { + throw badRequest('Failed to create report') + } - // In global mode, notify the community admin about the report - if (env.COMMUNITY_MODE === "global") { - try { - const filterRows = await db - .select({ adminDid: communityFilters.adminDid }) - .from(communityFilters) - .where(eq(communityFilters.communityDid, communityDid)); - - const adminDid = filterRows[0]?.adminDid; - if (adminDid) { - await db.insert(notifications).values({ - recipientDid: adminDid, - type: "global_report", - subjectUri: targetUri, - actorDid: user.did, - communityDid, - }); + app.log.info( + { reportId: report.id, reporterDid: user.did, targetUri, reasonType }, + 'Content reported' + ) + + // In global mode, notify the community admin about the report + if (env.COMMUNITY_MODE === 'global') { + try { + const filterRows = await db + .select({ adminDid: communityFilters.adminDid }) + .from(communityFilters) + .where(eq(communityFilters.communityDid, communityDid)) + + const adminDid = filterRows[0]?.adminDid + if (adminDid) { + await db.insert(notifications).values({ + recipientDid: adminDid, + type: 'global_report', + subjectUri: targetUri, + actorDid: user.did, + communityDid, + }) + } + } catch (err) { + app.log.warn( + { err, communityDid }, + 'Failed to send global report notification (non-critical)' + ) } - } catch (err) { - app.log.warn( - { err, communityDid }, - "Failed to send global report notification (non-critical)", - ); } - } - return reply.status(201).send(serializeReport(report)); - }); + return reply.status(201).send(serializeReport(report)) + } + ) // ------------------------------------------------------------------- // GET /api/moderation/reports (moderator+) // ------------------------------------------------------------------- - app.get("/api/moderation/reports", { - preHandler: [requireModerator], - schema: { - tags: ["Moderation"], - summary: "List content reports (paginated)", - security: [{ bearerAuth: [] }], - querystring: { - type: "object", - properties: { - status: { type: "string", enum: ["pending", "resolved"] }, - cursor: { type: "string" }, - limit: { type: "string" }, - }, - }, - response: { - 200: { - type: "object", + app.get( + '/api/moderation/reports', + { + preHandler: [requireModerator], + schema: { + tags: ['Moderation'], + summary: 'List content reports (paginated)', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', properties: { - reports: { type: "array", items: reportJsonSchema }, - cursor: { type: ["string", "null"] }, + status: { type: 'string', enum: ['pending', 'resolved'] }, + cursor: { type: 'string' }, + limit: { type: 'string' }, }, }, - 400: errorJsonSchema, + response: { + 200: { + type: 'object', + properties: { + reports: { type: 'array', items: reportJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, + }, + 400: errorJsonSchema, + }, }, }, - }, async (request, reply) => { - const parsed = reportQuerySchema.safeParse(request.query); - if (!parsed.success) { - throw badRequest("Invalid query parameters"); - } + async (request, reply) => { + const parsed = reportQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } - const { status, cursor, limit } = parsed.data; - const conditions = [eq(reports.communityDid, communityDid)]; + const { status, cursor, limit } = parsed.data + const conditions = [eq(reports.communityDid, communityDid)] - if (status) { - conditions.push(eq(reports.status, status)); - } + if (status) { + conditions.push(eq(reports.status, status)) + } - if (cursor) { - const decoded = decodeCursor(cursor); - if (decoded) { - conditions.push( - sql`(${reports.createdAt}, ${reports.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})`, - ); + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${reports.createdAt}, ${reports.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` + ) + } } - } - const whereClause = and(...conditions); - const fetchLimit = limit + 1; + const whereClause = and(...conditions) + const fetchLimit = limit + 1 - const rows = await db - .select() - .from(reports) - .where(whereClause) - .orderBy(desc(reports.createdAt)) - .limit(fetchLimit); + const rows = await db + .select() + .from(reports) + .where(whereClause) + .orderBy(desc(reports.createdAt)) + .limit(fetchLimit) - const hasMore = rows.length > limit; - const resultRows = hasMore ? rows.slice(0, limit) : rows; + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows - let nextCursor: string | null = null; - if (hasMore) { - const lastRow = resultRows[resultRows.length - 1]; - if (lastRow) { - nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id); + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id) + } } - } - return reply.status(200).send({ - reports: resultRows.map(serializeReport), - cursor: nextCursor, - }); - }); + return reply.status(200).send({ + reports: resultRows.map(serializeReport), + cursor: nextCursor, + }) + } + ) // ------------------------------------------------------------------- // PUT /api/moderation/reports/:id (moderator+) // ------------------------------------------------------------------- - app.put("/api/moderation/reports/:id", { - preHandler: [requireModerator], - schema: { - tags: ["Moderation"], - summary: "Resolve a content report", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["id"], - properties: { id: { type: "string" } }, - }, - body: { - type: "object", - required: ["resolutionType"], - properties: { - resolutionType: { - type: "string", - enum: ["dismissed", "warned", "labeled", "removed", "banned"], + app.put( + '/api/moderation/reports/:id', + { + preHandler: [requireModerator], + schema: { + tags: ['Moderation'], + summary: 'Resolve a content report', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + body: { + type: 'object', + required: ['resolutionType'], + properties: { + resolutionType: { + type: 'string', + enum: ['dismissed', 'warned', 'labeled', 'removed', 'banned'], + }, }, }, - }, - response: { - 200: reportJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, + response: { + 200: reportJsonSchema, + 400: errorJsonSchema, + 401: errorJsonSchema, + 404: errorJsonSchema, + 409: errorJsonSchema, + }, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const { id } = request.params as { id: string }; - const reportId = Number(id); - if (Number.isNaN(reportId)) { - throw badRequest("Invalid report ID"); - } + const { id } = request.params as { id: string } + const reportId = Number(id) + if (Number.isNaN(reportId)) { + throw badRequest('Invalid report ID') + } - const parsed = resolveReportSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid resolution data"); - } + const parsed = resolveReportSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid resolution data') + } - const existing = await db - .select() - .from(reports) - .where( - and( - eq(reports.id, reportId), - eq(reports.communityDid, communityDid), - ), - ); - - const report = existing[0]; - if (!report) { - throw notFound("Report not found"); - } + const existing = await db + .select() + .from(reports) + .where(and(eq(reports.id, reportId), eq(reports.communityDid, communityDid))) - if (report.status === "resolved") { - throw conflict("Report already resolved"); - } + const report = existing[0] + if (!report) { + throw notFound('Report not found') + } - const updated = await db - .update(reports) - .set({ - status: "resolved", - resolutionType: parsed.data.resolutionType, - resolvedBy: user.did, - resolvedAt: new Date(), - }) - .where(eq(reports.id, reportId)) - .returning(); + if (report.status === 'resolved') { + throw conflict('Report already resolved') + } - const resolvedReport = updated[0]; - if (!resolvedReport) { - throw notFound("Report not found after update"); - } + const updated = await db + .update(reports) + .set({ + status: 'resolved', + resolutionType: parsed.data.resolutionType, + resolvedBy: user.did, + resolvedAt: new Date(), + }) + .where(eq(reports.id, reportId)) + .returning() + + const resolvedReport = updated[0] + if (!resolvedReport) { + throw notFound('Report not found after update') + } - app.log.info( - { - reportId, - resolutionType: parsed.data.resolutionType, - resolvedBy: user.did, - }, - "Report resolved", - ); + app.log.info( + { + reportId, + resolutionType: parsed.data.resolutionType, + resolvedBy: user.did, + }, + 'Report resolved' + ) - return reply.status(200).send(serializeReport(resolvedReport)); - }); + return reply.status(200).send(serializeReport(resolvedReport)) + } + ) // ------------------------------------------------------------------- // GET /api/admin/reports/users (admin only) // ------------------------------------------------------------------- - app.get("/api/admin/reports/users", { - preHandler: [requireAdmin], - schema: { - tags: ["Admin"], - summary: "Most-reported users in this community", - security: [{ bearerAuth: [] }], - querystring: { - type: "object", - properties: { - limit: { type: "string" }, - }, - }, - response: { - 200: { - type: "object", + app.get( + '/api/admin/reports/users', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin'], + summary: 'Most-reported users in this community', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', properties: { - users: { - type: "array", - items: { - type: "object", - properties: { - did: { type: "string" }, - reportCount: { type: "number" }, + limit: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + users: { + type: 'array', + items: { + type: 'object', + properties: { + did: { type: 'string' }, + reportCount: { type: 'number' }, + }, }, }, }, @@ -1050,360 +1052,369 @@ export function moderationRoutes(): FastifyPluginCallback { }, }, }, - }, async (request, reply) => { - const parsed = reportedUsersQuerySchema.safeParse(request.query); - const limit = parsed.success ? parsed.data.limit : 25; - - const rows = await db - .select({ - did: reports.targetDid, - reportCount: sql`count(*)::int`, - }) - .from(reports) - .where(eq(reports.communityDid, communityDid)) - .groupBy(reports.targetDid) - .orderBy(sql`count(*) DESC`) - .limit(limit); + async (request, reply) => { + const parsed = reportedUsersQuerySchema.safeParse(request.query) + const limit = parsed.success ? parsed.data.limit : 25 + + const rows = await db + .select({ + did: reports.targetDid, + reportCount: sql`count(*)::int`, + }) + .from(reports) + .where(eq(reports.communityDid, communityDid)) + .groupBy(reports.targetDid) + .orderBy(sql`count(*) DESC`) + .limit(limit) - return reply.status(200).send({ - users: rows.map((r) => ({ did: r.did, reportCount: r.reportCount })), - }); - }); + return reply.status(200).send({ + users: rows.map((r) => ({ did: r.did, reportCount: r.reportCount })), + }) + } + ) // ------------------------------------------------------------------- // GET /api/admin/moderation/thresholds (admin only) // ------------------------------------------------------------------- - app.get("/api/admin/moderation/thresholds", { - preHandler: [requireAdmin], - schema: { - tags: ["Admin"], - summary: "Get moderation thresholds for this community", - security: [{ bearerAuth: [] }], - response: { - 200: { - type: "object", - properties: { - autoBlockReportCount: { type: "number" }, - warnThreshold: { type: "number" }, - firstPostQueueCount: { type: "number" }, - newAccountDays: { type: "number" }, - newAccountWriteRatePerMin: { type: "number" }, - establishedWriteRatePerMin: { type: "number" }, - linkHoldEnabled: { type: "boolean" }, - topicCreationDelayEnabled: { type: "boolean" }, - burstPostCount: { type: "number" }, - burstWindowMinutes: { type: "number" }, - trustedPostThreshold: { type: "number" }, + app.get( + '/api/admin/moderation/thresholds', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin'], + summary: 'Get moderation thresholds for this community', + security: [{ bearerAuth: [] }], + response: { + 200: { + type: 'object', + properties: { + autoBlockReportCount: { type: 'number' }, + warnThreshold: { type: 'number' }, + firstPostQueueCount: { type: 'number' }, + newAccountDays: { type: 'number' }, + newAccountWriteRatePerMin: { type: 'number' }, + establishedWriteRatePerMin: { type: 'number' }, + linkHoldEnabled: { type: 'boolean' }, + topicCreationDelayEnabled: { type: 'boolean' }, + burstPostCount: { type: 'number' }, + burstWindowMinutes: { type: 'number' }, + trustedPostThreshold: { type: 'number' }, + }, }, }, }, }, - }, async (_request, reply) => { - const settingsRows = await db - .select({ moderationThresholds: communitySettings.moderationThresholds }) - .from(communitySettings) - .where(eq(communitySettings.id, "default")); - - const settings = settingsRows[0]; - const t = settings?.moderationThresholds; - - return reply.status(200).send({ - autoBlockReportCount: t?.autoBlockReportCount ?? 5, - warnThreshold: t?.warnThreshold ?? 3, - firstPostQueueCount: t?.firstPostQueueCount ?? 3, - newAccountDays: t?.newAccountDays ?? 7, - newAccountWriteRatePerMin: t?.newAccountWriteRatePerMin ?? 3, - establishedWriteRatePerMin: t?.establishedWriteRatePerMin ?? 10, - linkHoldEnabled: t?.linkHoldEnabled ?? true, - topicCreationDelayEnabled: t?.topicCreationDelayEnabled ?? true, - burstPostCount: t?.burstPostCount ?? 5, - burstWindowMinutes: t?.burstWindowMinutes ?? 10, - trustedPostThreshold: t?.trustedPostThreshold ?? 10, - }); - }); + async (_request, reply) => { + const settingsRows = await db + .select({ moderationThresholds: communitySettings.moderationThresholds }) + .from(communitySettings) + .where(eq(communitySettings.id, 'default')) + + const settings = settingsRows[0] + const t = settings?.moderationThresholds + + return reply.status(200).send({ + autoBlockReportCount: t?.autoBlockReportCount ?? 5, + warnThreshold: t?.warnThreshold ?? 3, + firstPostQueueCount: t?.firstPostQueueCount ?? 3, + newAccountDays: t?.newAccountDays ?? 7, + newAccountWriteRatePerMin: t?.newAccountWriteRatePerMin ?? 3, + establishedWriteRatePerMin: t?.establishedWriteRatePerMin ?? 10, + linkHoldEnabled: t?.linkHoldEnabled ?? true, + topicCreationDelayEnabled: t?.topicCreationDelayEnabled ?? true, + burstPostCount: t?.burstPostCount ?? 5, + burstWindowMinutes: t?.burstWindowMinutes ?? 10, + trustedPostThreshold: t?.trustedPostThreshold ?? 10, + }) + } + ) // ------------------------------------------------------------------- // PUT /api/admin/moderation/thresholds (admin only) // ------------------------------------------------------------------- - app.put("/api/admin/moderation/thresholds", { - preHandler: [requireAdmin], - schema: { - tags: ["Admin"], - summary: "Update moderation thresholds", - security: [{ bearerAuth: [] }], - body: { - type: "object", - properties: { - autoBlockReportCount: { type: "number", minimum: 1, maximum: 100 }, - warnThreshold: { type: "number", minimum: 1, maximum: 50 }, - firstPostQueueCount: { type: "number", minimum: 0, maximum: 50 }, - newAccountDays: { type: "number", minimum: 0, maximum: 90 }, - newAccountWriteRatePerMin: { type: "number", minimum: 1, maximum: 30 }, - establishedWriteRatePerMin: { type: "number", minimum: 1, maximum: 100 }, - linkHoldEnabled: { type: "boolean" }, - topicCreationDelayEnabled: { type: "boolean" }, - burstPostCount: { type: "number", minimum: 2, maximum: 50 }, - burstWindowMinutes: { type: "number", minimum: 1, maximum: 60 }, - trustedPostThreshold: { type: "number", minimum: 1, maximum: 100 }, - }, - }, - response: { - 200: { - type: "object", + app.put( + '/api/admin/moderation/thresholds', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin'], + summary: 'Update moderation thresholds', + security: [{ bearerAuth: [] }], + body: { + type: 'object', properties: { - autoBlockReportCount: { type: "number" }, - warnThreshold: { type: "number" }, - firstPostQueueCount: { type: "number" }, - newAccountDays: { type: "number" }, - newAccountWriteRatePerMin: { type: "number" }, - establishedWriteRatePerMin: { type: "number" }, - linkHoldEnabled: { type: "boolean" }, - topicCreationDelayEnabled: { type: "boolean" }, - burstPostCount: { type: "number" }, - burstWindowMinutes: { type: "number" }, - trustedPostThreshold: { type: "number" }, + autoBlockReportCount: { type: 'number', minimum: 1, maximum: 100 }, + warnThreshold: { type: 'number', minimum: 1, maximum: 50 }, + firstPostQueueCount: { type: 'number', minimum: 0, maximum: 50 }, + newAccountDays: { type: 'number', minimum: 0, maximum: 90 }, + newAccountWriteRatePerMin: { type: 'number', minimum: 1, maximum: 30 }, + establishedWriteRatePerMin: { type: 'number', minimum: 1, maximum: 100 }, + linkHoldEnabled: { type: 'boolean' }, + topicCreationDelayEnabled: { type: 'boolean' }, + burstPostCount: { type: 'number', minimum: 2, maximum: 50 }, + burstWindowMinutes: { type: 'number', minimum: 1, maximum: 60 }, + trustedPostThreshold: { type: 'number', minimum: 1, maximum: 100 }, }, }, - 400: errorJsonSchema, + response: { + 200: { + type: 'object', + properties: { + autoBlockReportCount: { type: 'number' }, + warnThreshold: { type: 'number' }, + firstPostQueueCount: { type: 'number' }, + newAccountDays: { type: 'number' }, + newAccountWriteRatePerMin: { type: 'number' }, + establishedWriteRatePerMin: { type: 'number' }, + linkHoldEnabled: { type: 'boolean' }, + topicCreationDelayEnabled: { type: 'boolean' }, + burstPostCount: { type: 'number' }, + burstWindowMinutes: { type: 'number' }, + trustedPostThreshold: { type: 'number' }, + }, + }, + 400: errorJsonSchema, + }, }, }, - }, async (request, reply) => { - const parsed = moderationThresholdsSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid threshold values"); - } + async (request, reply) => { + const parsed = moderationThresholdsSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid threshold values') + } - // Load existing thresholds, merge with partial update - const existingRows = await db - .select({ moderationThresholds: communitySettings.moderationThresholds }) - .from(communitySettings) - .where(eq(communitySettings.id, "default")); - - const existing = existingRows[0]?.moderationThresholds ?? { - autoBlockReportCount: 5, - warnThreshold: 3, - firstPostQueueCount: 3, - newAccountDays: 7, - newAccountWriteRatePerMin: 3, - establishedWriteRatePerMin: 10, - linkHoldEnabled: true, - topicCreationDelayEnabled: true, - burstPostCount: 5, - burstWindowMinutes: 10, - trustedPostThreshold: 10, - }; - - // Filter out undefined values from the partial update - const definedUpdates: Record = {}; - for (const [key, value] of Object.entries(parsed.data)) { - if (value !== undefined) { - definedUpdates[key] = value; + // Load existing thresholds, merge with partial update + const existingRows = await db + .select({ moderationThresholds: communitySettings.moderationThresholds }) + .from(communitySettings) + .where(eq(communitySettings.id, 'default')) + + const existing = existingRows[0]?.moderationThresholds ?? { + autoBlockReportCount: 5, + warnThreshold: 3, + firstPostQueueCount: 3, + newAccountDays: 7, + newAccountWriteRatePerMin: 3, + establishedWriteRatePerMin: 10, + linkHoldEnabled: true, + topicCreationDelayEnabled: true, + burstPostCount: 5, + burstWindowMinutes: 10, + trustedPostThreshold: 10, + } + + // Filter out undefined values from the partial update + const definedUpdates: Record = {} + for (const [key, value] of Object.entries(parsed.data)) { + if (value !== undefined) { + definedUpdates[key] = value + } + } + const merged = { ...existing, ...definedUpdates } as typeof existing + + await db + .update(communitySettings) + .set({ moderationThresholds: merged }) + .where(eq(communitySettings.id, 'default')) + + // Invalidate cached anti-spam settings + try { + await app.cache.del(`antispam:settings:${communityDid}`) + } catch { + // Non-critical } - } - const merged = { ...existing, ...definedUpdates } as typeof existing; - - await db - .update(communitySettings) - .set({ moderationThresholds: merged }) - .where(eq(communitySettings.id, "default")); - - // Invalidate cached anti-spam settings - try { - await app.cache.del(`antispam:settings:${communityDid}`); - } catch { - // Non-critical - } - return reply.status(200).send(merged); - }); + return reply.status(200).send(merged) + } + ) // ------------------------------------------------------------------- // GET /api/moderation/my-reports (authenticated user) // ------------------------------------------------------------------- - app.get("/api/moderation/my-reports", { - preHandler: [authMiddleware.requireAuth], - schema: { - tags: ["Moderation"], - summary: "List reports submitted by the authenticated user (paginated)", - security: [{ bearerAuth: [] }], - querystring: { - type: "object", - properties: { - cursor: { type: "string" }, - limit: { type: "string" }, - }, - }, - response: { - 200: { - type: "object", + app.get( + '/api/moderation/my-reports', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Moderation'], + summary: 'List reports submitted by the authenticated user (paginated)', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', properties: { - reports: { type: "array", items: reportJsonSchema }, - cursor: { type: ["string", "null"] }, + cursor: { type: 'string' }, + limit: { type: 'string' }, }, }, - 400: errorJsonSchema, - 401: errorJsonSchema, + response: { + 200: { + type: 'object', + properties: { + reports: { type: 'array', items: reportJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, + }, + 400: errorJsonSchema, + 401: errorJsonSchema, + }, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const parsed = myReportsQuerySchema.safeParse(request.query); - if (!parsed.success) { - throw badRequest("Invalid query parameters"); - } + const parsed = myReportsQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } - const { cursor, limit } = parsed.data; - const conditions = [ - eq(reports.reporterDid, user.did), - eq(reports.communityDid, communityDid), - ]; - - if (cursor) { - const decoded = decodeCursor(cursor); - if (decoded) { - conditions.push( - sql`(${reports.createdAt}, ${reports.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})`, - ); + const { cursor, limit } = parsed.data + const conditions = [ + eq(reports.reporterDid, user.did), + eq(reports.communityDid, communityDid), + ] + + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${reports.createdAt}, ${reports.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` + ) + } } - } - const whereClause = and(...conditions); - const fetchLimit = limit + 1; + const whereClause = and(...conditions) + const fetchLimit = limit + 1 - const rows = await db - .select() - .from(reports) - .where(whereClause) - .orderBy(desc(reports.createdAt)) - .limit(fetchLimit); + const rows = await db + .select() + .from(reports) + .where(whereClause) + .orderBy(desc(reports.createdAt)) + .limit(fetchLimit) - const hasMore = rows.length > limit; - const resultRows = hasMore ? rows.slice(0, limit) : rows; + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows - let nextCursor: string | null = null; - if (hasMore) { - const lastRow = resultRows[resultRows.length - 1]; - if (lastRow) { - nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id); + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id) + } } - } - return reply.status(200).send({ - reports: resultRows.map(serializeReport), - cursor: nextCursor, - }); - }); + return reply.status(200).send({ + reports: resultRows.map(serializeReport), + cursor: nextCursor, + }) + } + ) // ------------------------------------------------------------------- // POST /api/moderation/reports/:id/appeal (authenticated user) // ------------------------------------------------------------------- - app.post("/api/moderation/reports/:id/appeal", { - preHandler: [authMiddleware.requireAuth], - schema: { - tags: ["Moderation"], - summary: "Appeal a dismissed report", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["id"], - properties: { id: { type: "string" } }, - }, - body: { - type: "object", - required: ["reason"], - properties: { - reason: { type: "string", minLength: 1, maxLength: 1000 }, + app.post( + '/api/moderation/reports/:id/appeal', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Moderation'], + summary: 'Appeal a dismissed report', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + body: { + type: 'object', + required: ['reason'], + properties: { + reason: { type: 'string', minLength: 1, maxLength: 1000 }, + }, + }, + response: { + 200: reportJsonSchema, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + 409: errorJsonSchema, }, - }, - response: { - 200: reportJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const { id } = request.params as { id: string }; - const reportId = Number(id); - if (Number.isNaN(reportId)) { - throw badRequest("Invalid report ID"); - } + const { id } = request.params as { id: string } + const reportId = Number(id) + if (Number.isNaN(reportId)) { + throw badRequest('Invalid report ID') + } - const parsed = appealReportSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid appeal data"); - } + const parsed = appealReportSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid appeal data') + } - const existing = await db - .select() - .from(reports) - .where( - and( - eq(reports.id, reportId), - eq(reports.communityDid, communityDid), - ), - ); - - const report = existing[0]; - if (!report) { - throw notFound("Report not found"); - } + const existing = await db + .select() + .from(reports) + .where(and(eq(reports.id, reportId), eq(reports.communityDid, communityDid))) - if (report.reporterDid !== user.did) { - throw forbidden("You can only appeal your own reports"); - } + const report = existing[0] + if (!report) { + throw notFound('Report not found') + } - if (report.status !== "resolved") { - throw badRequest("Can only appeal resolved reports"); - } + if (report.reporterDid !== user.did) { + throw forbidden('You can only appeal your own reports') + } - if (report.resolutionType !== "dismissed") { - throw badRequest("Can only appeal dismissed reports"); - } + if (report.status !== 'resolved') { + throw badRequest('Can only appeal resolved reports') + } - if (report.appealStatus !== "none") { - throw conflict("Report has already been appealed"); - } + if (report.resolutionType !== 'dismissed') { + throw badRequest('Can only appeal dismissed reports') + } - const updated = await db - .update(reports) - .set({ - appealReason: parsed.data.reason, - appealedAt: new Date(), - appealStatus: "pending", - status: "pending", - }) - .where(eq(reports.id, reportId)) - .returning(); + if (report.appealStatus !== 'none') { + throw conflict('Report has already been appealed') + } - const appealedReport = updated[0]; - if (!appealedReport) { - throw notFound("Report not found after update"); - } + const updated = await db + .update(reports) + .set({ + appealReason: parsed.data.reason, + appealedAt: new Date(), + appealStatus: 'pending', + status: 'pending', + }) + .where(eq(reports.id, reportId)) + .returning() + + const appealedReport = updated[0] + if (!appealedReport) { + throw notFound('Report not found after update') + } - app.log.info( - { reportId, reporterDid: user.did }, - "Report appealed", - ); + app.log.info({ reportId, reporterDid: user.did }, 'Report appealed') - return reply.status(200).send(serializeReport(appealedReport)); - }); + return reply.status(200).send(serializeReport(appealedReport)) + } + ) - done(); - }; + done() + } } diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts index 1294df0..2e213fe 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -1,35 +1,32 @@ -import { eq, and, sql, desc } from "drizzle-orm"; -import type { FastifyPluginCallback } from "fastify"; -import { badRequest } from "../lib/api-errors.js"; -import { - notificationQuerySchema, - markReadSchema, -} from "../validation/notifications.js"; -import { notifications } from "../db/schema/notifications.js"; +import { eq, and, sql, desc } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { badRequest } from '../lib/api-errors.js' +import { notificationQuerySchema, markReadSchema } from '../validation/notifications.js' +import { notifications } from '../db/schema/notifications.js' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- const notificationJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - id: { type: "number" as const }, - type: { type: "string" as const }, - subjectUri: { type: "string" as const }, - actorDid: { type: "string" as const }, - communityDid: { type: "string" as const }, - read: { type: "boolean" as const }, - createdAt: { type: "string" as const, format: "date-time" as const }, + id: { type: 'number' as const }, + type: { type: 'string' as const }, + subjectUri: { type: 'string' as const }, + actorDid: { type: 'string' as const }, + communityDid: { type: 'string' as const }, + read: { type: 'boolean' as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, }, -}; +} const errorJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, + error: { type: 'string' as const }, }, -}; +} // --------------------------------------------------------------------------- // Helpers @@ -48,35 +45,31 @@ function serializeNotification(row: typeof notifications.$inferSelect) { communityDid: row.communityDid, read: row.read, createdAt: row.createdAt.toISOString(), - }; + } } /** * Encode a pagination cursor from createdAt + id. */ function encodeCursor(createdAt: string, id: number): string { - return Buffer.from(JSON.stringify({ createdAt, id })).toString("base64"); + return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64') } /** * Decode a pagination cursor. Returns null if invalid. */ -function decodeCursor( - cursor: string, -): { createdAt: string; id: number } | null { +function decodeCursor(cursor: string): { createdAt: string; id: number } | null { try { - const decoded = JSON.parse( - Buffer.from(cursor, "base64").toString("utf-8"), - ) as Record; - if ( - typeof decoded.createdAt === "string" && - typeof decoded.id === "number" - ) { - return { createdAt: decoded.createdAt, id: decoded.id }; + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as Record< + string, + unknown + > + if (typeof decoded.createdAt === 'string' && typeof decoded.id === 'number') { + return { createdAt: decoded.createdAt, id: decoded.id } } - return null; + return null } catch { - return null; + return null } } @@ -93,38 +86,38 @@ function decodeCursor( */ export function notificationRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, authMiddleware } = app; + const { db, authMiddleware } = app // ------------------------------------------------------------------- // GET /api/notifications (auth required) // ------------------------------------------------------------------- app.get( - "/api/notifications", + '/api/notifications', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Notifications"], - summary: "List notifications for the authenticated user", + tags: ['Notifications'], + summary: 'List notifications for the authenticated user', security: [{ bearerAuth: [] }], querystring: { - type: "object", + type: 'object', properties: { - limit: { type: "string" }, - cursor: { type: "string" }, - unreadOnly: { type: "string" }, + limit: { type: 'string' }, + cursor: { type: 'string' }, + unreadOnly: { type: 'string' }, }, }, response: { 200: { - type: "object", + type: 'object', properties: { notifications: { - type: "array", + type: 'array', items: notificationJsonSchema, }, - cursor: { type: ["string", "null"] }, - total: { type: "number" }, + cursor: { type: ['string', 'null'] }, + total: { type: 'number' }, }, }, 400: errorJsonSchema, @@ -133,73 +126,65 @@ export function notificationRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const user = request.user; + const user = request.user if (!user) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } - const parsed = notificationQuerySchema.safeParse(request.query); + const parsed = notificationQuerySchema.safeParse(request.query) if (!parsed.success) { - throw badRequest("Invalid query parameters"); + throw badRequest('Invalid query parameters') } - const { limit, cursor, unreadOnly } = parsed.data; + const { limit, cursor, unreadOnly } = parsed.data // Build conditions - const conditions = [eq(notifications.recipientDid, user.did)]; + const conditions = [eq(notifications.recipientDid, user.did)] if (unreadOnly) { - conditions.push(eq(notifications.read, false)); + conditions.push(eq(notifications.read, false)) } // Cursor-based pagination if (cursor) { - const decoded = decodeCursor(cursor); + const decoded = decodeCursor(cursor) if (decoded) { conditions.push( - sql`(${notifications.read}, ${notifications.createdAt}, ${notifications.id}) > (${decoded.createdAt === "unread" ? false : true}, ${decoded.createdAt === "unread" ? decoded.createdAt : decoded.createdAt}::timestamptz, ${decoded.id})`, - ); + sql`(${notifications.read}, ${notifications.createdAt}, ${notifications.id}) > (${decoded.createdAt === 'unread' ? false : true}, ${decoded.createdAt === 'unread' ? decoded.createdAt : decoded.createdAt}::timestamptz, ${decoded.id})` + ) } } - const whereClause = and(...conditions); + const whereClause = and(...conditions) // Fetch limit + 1 to detect if there are more pages - const fetchLimit = limit + 1; + const fetchLimit = limit + 1 // Order: unread first (read=false < read=true), then newest first const rows = await db .select() .from(notifications) .where(whereClause) - .orderBy( - sql`${notifications.read} ASC`, - desc(notifications.createdAt), - ) - .limit(fetchLimit); + .orderBy(sql`${notifications.read} ASC`, desc(notifications.createdAt)) + .limit(fetchLimit) - const hasMore = rows.length > limit; - const resultRows = hasMore ? rows.slice(0, limit) : rows; - const serialized = resultRows.map(serializeNotification); + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + const serialized = resultRows.map(serializeNotification) // Get total count for the user const countResult = await db .select({ count: sql`count(*)::int` }) .from(notifications) - .where(eq(notifications.recipientDid, user.did)); + .where(eq(notifications.recipientDid, user.did)) - const total = countResult[0]?.count ?? 0; + const total = countResult[0]?.count ?? 0 - let nextCursor: string | null = null; + let nextCursor: string | null = null if (hasMore) { - const lastRow = resultRows[resultRows.length - 1]; + const lastRow = resultRows[resultRows.length - 1] if (lastRow) { - nextCursor = encodeCursor( - lastRow.createdAt.toISOString(), - lastRow.id, - ); + nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id) } } @@ -207,34 +192,34 @@ export function notificationRoutes(): FastifyPluginCallback { notifications: serialized, cursor: nextCursor, total, - }); - }, - ); + }) + } + ) // ------------------------------------------------------------------- // PUT /api/notifications/read (auth required) // ------------------------------------------------------------------- app.put( - "/api/notifications/read", + '/api/notifications/read', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Notifications"], - summary: "Mark notification(s) as read", + tags: ['Notifications'], + summary: 'Mark notification(s) as read', security: [{ bearerAuth: [] }], body: { - type: "object", + type: 'object', properties: { - notificationId: { type: "number" }, - all: { type: "boolean" }, + notificationId: { type: 'number' }, + all: { type: 'boolean' }, }, }, response: { 200: { - type: "object", + type: 'object', properties: { - success: { type: "boolean" }, + success: { type: 'boolean' }, }, }, 400: errorJsonSchema, @@ -243,24 +228,20 @@ export function notificationRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const user = request.user; + const user = request.user if (!user) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } - const parsed = markReadSchema.safeParse(request.body); + const parsed = markReadSchema.safeParse(request.body) if (!parsed.success) { - throw badRequest("Invalid request body"); + throw badRequest('Invalid request body') } - const { notificationId, all } = parsed.data; + const { notificationId, all } = parsed.data if (!notificationId && !all) { - throw badRequest( - "Either notificationId or all must be provided", - ); + throw badRequest('Either notificationId or all must be provided') } if (all) { @@ -268,46 +249,38 @@ export function notificationRoutes(): FastifyPluginCallback { await db .update(notifications) .set({ read: true }) - .where( - and( - eq(notifications.recipientDid, user.did), - eq(notifications.read, false), - ), - ); + .where(and(eq(notifications.recipientDid, user.did), eq(notifications.read, false))) } else if (notificationId) { // Mark a single notification as read (scoped to user) await db .update(notifications) .set({ read: true }) .where( - and( - eq(notifications.id, notificationId), - eq(notifications.recipientDid, user.did), - ), - ); + and(eq(notifications.id, notificationId), eq(notifications.recipientDid, user.did)) + ) } - return reply.status(200).send({ success: true }); - }, - ); + return reply.status(200).send({ success: true }) + } + ) // ------------------------------------------------------------------- // GET /api/notifications/count (auth required) // ------------------------------------------------------------------- app.get( - "/api/notifications/count", + '/api/notifications/count', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Notifications"], - summary: "Get unread notification count", + tags: ['Notifications'], + summary: 'Get unread notification count', security: [{ bearerAuth: [] }], response: { 200: { - type: "object", + type: 'object', properties: { - unread: { type: "number" }, + unread: { type: 'number' }, }, }, 401: errorJsonSchema, @@ -315,29 +288,22 @@ export function notificationRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const user = request.user; + const user = request.user if (!user) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } const countResult = await db .select({ count: sql`count(*)::int` }) .from(notifications) - .where( - and( - eq(notifications.recipientDid, user.did), - eq(notifications.read, false), - ), - ); + .where(and(eq(notifications.recipientDid, user.did), eq(notifications.read, false))) - const unread = countResult[0]?.count ?? 0; + const unread = countResult[0]?.count ?? 0 - return reply.status(200).send({ unread }); - }, - ); + return reply.status(200).send({ unread }) + } + ) - done(); - }; + done() + } } diff --git a/src/routes/oauth-metadata.ts b/src/routes/oauth-metadata.ts index 0a23c66..7841917 100644 --- a/src/routes/oauth-metadata.ts +++ b/src/routes/oauth-metadata.ts @@ -1,8 +1,8 @@ -import type { FastifyPluginCallback } from "fastify"; -import type { NodeOAuthClient } from "@atproto/oauth-client-node"; +import type { FastifyPluginCallback } from 'fastify' +import type { NodeOAuthClient } from '@atproto/oauth-client-node' /** Cache-Control header value: public, max-age 1 hour, stale-while-revalidate 1 day */ -const CACHE_CONTROL = "public, max-age=3600, stale-while-revalidate=86400"; +const CACHE_CONTROL = 'public, max-age=3600, stale-while-revalidate=86400' /** * OAuth metadata endpoints required by the AT Protocol OAuth spec. @@ -11,20 +11,20 @@ const CACHE_CONTROL = "public, max-age=3600, stale-while-revalidate=86400"; */ export function oauthMetadataRoutes(oauthClient: NodeOAuthClient): FastifyPluginCallback { return (fastify, _opts, done) => { - fastify.get("/oauth-client-metadata.json", async (_request, reply) => { + fastify.get('/oauth-client-metadata.json', async (_request, reply) => { return reply - .header("Content-Type", "application/json") - .header("Cache-Control", CACHE_CONTROL) - .send(oauthClient.clientMetadata); - }); + .header('Content-Type', 'application/json') + .header('Cache-Control', CACHE_CONTROL) + .send(oauthClient.clientMetadata) + }) - fastify.get("/jwks.json", async (_request, reply) => { + fastify.get('/jwks.json', async (_request, reply) => { return reply - .header("Content-Type", "application/json") - .header("Cache-Control", CACHE_CONTROL) - .send(oauthClient.jwks); - }); + .header('Content-Type', 'application/json') + .header('Cache-Control', CACHE_CONTROL) + .send(oauthClient.jwks) + }) - done(); - }; + done() + } } diff --git a/src/routes/onboarding.ts b/src/routes/onboarding.ts index 3fdeed2..eaf0f57 100644 --- a/src/routes/onboarding.ts +++ b/src/routes/onboarding.ts @@ -1,62 +1,64 @@ -import { eq, and, asc } from "drizzle-orm"; -import type { FastifyPluginCallback } from "fastify"; -import { notFound, badRequest, forbidden } from "../lib/api-errors.js"; +import { eq, and, asc } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { notFound, badRequest, forbidden } from '../lib/api-errors.js' import { createOnboardingFieldSchema, updateOnboardingFieldSchema, reorderFieldsSchema, submitOnboardingSchema, validateFieldResponse, -} from "../validation/onboarding.js"; - -import { communityOnboardingFields, userOnboardingResponses } from "../db/schema/onboarding-fields.js"; +} from '../validation/onboarding.js' +import { + communityOnboardingFields, + userOnboardingResponses, +} from '../db/schema/onboarding-fields.js' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- const onboardingFieldJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - id: { type: "string" as const }, - communityDid: { type: "string" as const }, - fieldType: { type: "string" as const }, - label: { type: "string" as const }, - description: { type: ["string", "null"] as const }, - isMandatory: { type: "boolean" as const }, - sortOrder: { type: "integer" as const }, - config: { type: ["object", "null"] as const }, - createdAt: { type: "string" as const, format: "date-time" as const }, - updatedAt: { type: "string" as const, format: "date-time" as const }, + id: { type: 'string' as const }, + communityDid: { type: 'string' as const }, + fieldType: { type: 'string' as const }, + label: { type: 'string' as const }, + description: { type: ['string', 'null'] as const }, + isMandatory: { type: 'boolean' as const }, + sortOrder: { type: 'integer' as const }, + config: { type: ['object', 'null'] as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + updatedAt: { type: 'string' as const, format: 'date-time' as const }, }, -}; +} const onboardingStatusJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - complete: { type: "boolean" as const }, + complete: { type: 'boolean' as const }, fields: { - type: "array" as const, + type: 'array' as const, items: { - type: "object" as const, + type: 'object' as const, properties: { ...onboardingFieldJsonSchema.properties, - completed: { type: "boolean" as const }, + completed: { type: 'boolean' as const }, response: {}, }, }, }, }, -}; +} const errorJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, - message: { type: "string" as const }, + error: { type: 'string' as const }, + message: { type: 'string' as const }, }, -}; +} // --------------------------------------------------------------------------- // Helpers @@ -74,7 +76,7 @@ function serializeField(row: typeof communityOnboardingFields.$inferSelect) { config: row.config ?? null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), - }; + } } // --------------------------------------------------------------------------- @@ -83,8 +85,8 @@ function serializeField(row: typeof communityOnboardingFields.$inferSelect) { export function onboardingRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, authMiddleware, env } = app; - const requireAdmin = app.requireAdmin; + const { db, authMiddleware, env } = app + const requireAdmin = app.requireAdmin // ===================================================================== // ADMIN ENDPOINTS @@ -94,290 +96,310 @@ export function onboardingRoutes(): FastifyPluginCallback { // GET /api/admin/onboarding-fields // ------------------------------------------------------------------- - app.get("/api/admin/onboarding-fields", { - preHandler: [requireAdmin], - schema: { - tags: ["Admin"], - summary: "List onboarding fields for this community", - security: [{ bearerAuth: [] }], - response: { - 200: { - type: "array" as const, - items: onboardingFieldJsonSchema, + app.get( + '/api/admin/onboarding-fields', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin'], + summary: 'List onboarding fields for this community', + security: [{ bearerAuth: [] }], + response: { + 200: { + type: 'array' as const, + items: onboardingFieldJsonSchema, + }, + 401: errorJsonSchema, + 403: errorJsonSchema, }, - 401: errorJsonSchema, - 403: errorJsonSchema, }, }, - }, async (_request, reply) => { - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; + async (_request, reply) => { + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' - const fields = await db - .select() - .from(communityOnboardingFields) - .where(eq(communityOnboardingFields.communityDid, communityDid)) - .orderBy(asc(communityOnboardingFields.sortOrder)); + const fields = await db + .select() + .from(communityOnboardingFields) + .where(eq(communityOnboardingFields.communityDid, communityDid)) + .orderBy(asc(communityOnboardingFields.sortOrder)) - return reply.status(200).send(fields.map(serializeField)); - }); + return reply.status(200).send(fields.map(serializeField)) + } + ) // ------------------------------------------------------------------- // POST /api/admin/onboarding-fields // ------------------------------------------------------------------- - app.post("/api/admin/onboarding-fields", { - preHandler: [requireAdmin], - schema: { - tags: ["Admin"], - summary: "Create a new onboarding field", - security: [{ bearerAuth: [] }], - body: { - type: "object" as const, - properties: { - fieldType: { type: "string" as const }, - label: { type: "string" as const }, - description: { type: ["string", "null"] as const }, - isMandatory: { type: "boolean" as const }, - sortOrder: { type: "integer" as const }, - config: { type: ["object", "null"] as const }, + app.post( + '/api/admin/onboarding-fields', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin'], + summary: 'Create a new onboarding field', + security: [{ bearerAuth: [] }], + body: { + type: 'object' as const, + properties: { + fieldType: { type: 'string' as const }, + label: { type: 'string' as const }, + description: { type: ['string', 'null'] as const }, + isMandatory: { type: 'boolean' as const }, + sortOrder: { type: 'integer' as const }, + config: { type: ['object', 'null'] as const }, + }, + required: ['fieldType', 'label'], + }, + response: { + 201: onboardingFieldJsonSchema, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, }, - required: ["fieldType", "label"], - }, - response: { - 201: onboardingFieldJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, }, }, - }, async (request, reply) => { - const parsed = createOnboardingFieldSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid onboarding field data"); - } + async (request, reply) => { + const parsed = createOnboardingFieldSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid onboarding field data') + } - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - - const inserted = await db - .insert(communityOnboardingFields) - .values({ - communityDid, - fieldType: parsed.data.fieldType, - label: parsed.data.label, - description: parsed.data.description ?? null, - isMandatory: parsed.data.isMandatory, - sortOrder: parsed.data.sortOrder, - config: parsed.data.config ?? null, - }) - .returning(); + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' - const row = inserted[0]; - if (!row) { - throw badRequest("Failed to create onboarding field"); - } + const inserted = await db + .insert(communityOnboardingFields) + .values({ + communityDid, + fieldType: parsed.data.fieldType, + label: parsed.data.label, + description: parsed.data.description ?? null, + isMandatory: parsed.data.isMandatory, + sortOrder: parsed.data.sortOrder, + config: parsed.data.config ?? null, + }) + .returning() - app.log.info( - { event: "onboarding_field_created", fieldId: row.id, fieldType: row.fieldType }, - "Onboarding field created", - ); + const row = inserted[0] + if (!row) { + throw badRequest('Failed to create onboarding field') + } - return reply.status(201).send(serializeField(row)); - }); + app.log.info( + { event: 'onboarding_field_created', fieldId: row.id, fieldType: row.fieldType }, + 'Onboarding field created' + ) + + return reply.status(201).send(serializeField(row)) + } + ) // ------------------------------------------------------------------- // PUT /api/admin/onboarding-fields/:id // ------------------------------------------------------------------- - app.put<{ Params: { id: string } }>("/api/admin/onboarding-fields/:id", { - preHandler: [requireAdmin], - schema: { - tags: ["Admin"], - summary: "Update an onboarding field", - security: [{ bearerAuth: [] }], - params: { - type: "object" as const, - properties: { id: { type: "string" as const } }, - required: ["id"], - }, - body: { - type: "object" as const, - properties: { - label: { type: "string" as const }, - description: { type: ["string", "null"] as const }, - isMandatory: { type: "boolean" as const }, - sortOrder: { type: "integer" as const }, - config: { type: ["object", "null"] as const }, + app.put<{ Params: { id: string } }>( + '/api/admin/onboarding-fields/:id', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin'], + summary: 'Update an onboarding field', + security: [{ bearerAuth: [] }], + params: { + type: 'object' as const, + properties: { id: { type: 'string' as const } }, + required: ['id'], + }, + body: { + type: 'object' as const, + properties: { + label: { type: 'string' as const }, + description: { type: ['string', 'null'] as const }, + isMandatory: { type: 'boolean' as const }, + sortOrder: { type: 'integer' as const }, + config: { type: ['object', 'null'] as const }, + }, + }, + response: { + 200: onboardingFieldJsonSchema, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, }, - }, - response: { - 200: onboardingFieldJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, }, }, - }, async (request, reply) => { - const parsed = updateOnboardingFieldSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid update data"); - } + async (request, reply) => { + const parsed = updateOnboardingFieldSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid update data') + } - const updates = parsed.data; - if ( - updates.label === undefined && - updates.description === undefined && - updates.isMandatory === undefined && - updates.sortOrder === undefined && - updates.config === undefined - ) { - throw badRequest("At least one field must be provided"); - } + const updates = parsed.data + if ( + updates.label === undefined && + updates.description === undefined && + updates.isMandatory === undefined && + updates.sortOrder === undefined && + updates.config === undefined + ) { + throw badRequest('At least one field must be provided') + } - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - - const dbUpdates: Record = { updatedAt: new Date() }; - if (updates.label !== undefined) dbUpdates.label = updates.label; - if (updates.description !== undefined) dbUpdates.description = updates.description; - if (updates.isMandatory !== undefined) dbUpdates.isMandatory = updates.isMandatory; - if (updates.sortOrder !== undefined) dbUpdates.sortOrder = updates.sortOrder; - if (updates.config !== undefined) dbUpdates.config = updates.config; - - const updated = await db - .update(communityOnboardingFields) - .set(dbUpdates) - .where( - and( - eq(communityOnboardingFields.id, request.params.id), - eq(communityOnboardingFields.communityDid, communityDid), - ), - ) - .returning(); + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' - const row = updated[0]; - if (!row) { - throw notFound("Onboarding field not found"); - } + const dbUpdates: Record = { updatedAt: new Date() } + if (updates.label !== undefined) dbUpdates.label = updates.label + if (updates.description !== undefined) dbUpdates.description = updates.description + if (updates.isMandatory !== undefined) dbUpdates.isMandatory = updates.isMandatory + if (updates.sortOrder !== undefined) dbUpdates.sortOrder = updates.sortOrder + if (updates.config !== undefined) dbUpdates.config = updates.config - return reply.status(200).send(serializeField(row)); - }); + const updated = await db + .update(communityOnboardingFields) + .set(dbUpdates) + .where( + and( + eq(communityOnboardingFields.id, request.params.id), + eq(communityOnboardingFields.communityDid, communityDid) + ) + ) + .returning() + + const row = updated[0] + if (!row) { + throw notFound('Onboarding field not found') + } + + return reply.status(200).send(serializeField(row)) + } + ) // ------------------------------------------------------------------- // DELETE /api/admin/onboarding-fields/:id // ------------------------------------------------------------------- - app.delete<{ Params: { id: string } }>("/api/admin/onboarding-fields/:id", { - preHandler: [requireAdmin], - schema: { - tags: ["Admin"], - summary: "Delete an onboarding field", - security: [{ bearerAuth: [] }], - params: { - type: "object" as const, - properties: { id: { type: "string" as const } }, - required: ["id"], - }, - response: { - 200: { - type: "object" as const, - properties: { success: { type: "boolean" as const } }, + app.delete<{ Params: { id: string } }>( + '/api/admin/onboarding-fields/:id', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin'], + summary: 'Delete an onboarding field', + security: [{ bearerAuth: [] }], + params: { + type: 'object' as const, + properties: { id: { type: 'string' as const } }, + required: ['id'], + }, + response: { + 200: { + type: 'object' as const, + properties: { success: { type: 'boolean' as const } }, + }, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, }, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, }, }, - }, async (request, reply) => { - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - - const deleted = await db - .delete(communityOnboardingFields) - .where( - and( - eq(communityOnboardingFields.id, request.params.id), - eq(communityOnboardingFields.communityDid, communityDid), - ), - ) - .returning(); + async (request, reply) => { + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' - if (deleted.length === 0) { - throw notFound("Onboarding field not found"); - } + const deleted = await db + .delete(communityOnboardingFields) + .where( + and( + eq(communityOnboardingFields.id, request.params.id), + eq(communityOnboardingFields.communityDid, communityDid) + ) + ) + .returning() + + if (deleted.length === 0) { + throw notFound('Onboarding field not found') + } - // Also clean up user responses for this field - await db - .delete(userOnboardingResponses) - .where(eq(userOnboardingResponses.fieldId, request.params.id)); + // Also clean up user responses for this field + await db + .delete(userOnboardingResponses) + .where(eq(userOnboardingResponses.fieldId, request.params.id)) - app.log.info( - { event: "onboarding_field_deleted", fieldId: request.params.id }, - "Onboarding field deleted", - ); + app.log.info( + { event: 'onboarding_field_deleted', fieldId: request.params.id }, + 'Onboarding field deleted' + ) - return reply.status(200).send({ success: true }); - }); + return reply.status(200).send({ success: true }) + } + ) // ------------------------------------------------------------------- // PUT /api/admin/onboarding-fields/reorder // ------------------------------------------------------------------- - app.put("/api/admin/onboarding-fields/reorder", { - preHandler: [requireAdmin], - schema: { - tags: ["Admin"], - summary: "Reorder onboarding fields", - security: [{ bearerAuth: [] }], - body: { - type: "array" as const, - items: { - type: "object" as const, - properties: { - id: { type: "string" as const }, - sortOrder: { type: "integer" as const }, + app.put( + '/api/admin/onboarding-fields/reorder', + { + preHandler: [requireAdmin], + schema: { + tags: ['Admin'], + summary: 'Reorder onboarding fields', + security: [{ bearerAuth: [] }], + body: { + type: 'array' as const, + items: { + type: 'object' as const, + properties: { + id: { type: 'string' as const }, + sortOrder: { type: 'integer' as const }, + }, + required: ['id', 'sortOrder'], }, - required: ["id", "sortOrder"], }, - }, - response: { - 200: { - type: "array" as const, - items: onboardingFieldJsonSchema, + response: { + 200: { + type: 'array' as const, + items: onboardingFieldJsonSchema, + }, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, }, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, }, }, - }, async (request, reply) => { - const parsed = reorderFieldsSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid reorder data"); - } - - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; + async (request, reply) => { + const parsed = reorderFieldsSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid reorder data') + } - // Update each field's sort order - for (const item of parsed.data) { - await db - .update(communityOnboardingFields) - .set({ sortOrder: item.sortOrder, updatedAt: new Date() }) - .where( - and( - eq(communityOnboardingFields.id, item.id), - eq(communityOnboardingFields.communityDid, communityDid), - ), - ); - } + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' + + // Update each field's sort order + for (const item of parsed.data) { + await db + .update(communityOnboardingFields) + .set({ sortOrder: item.sortOrder, updatedAt: new Date() }) + .where( + and( + eq(communityOnboardingFields.id, item.id), + eq(communityOnboardingFields.communityDid, communityDid) + ) + ) + } - // Return updated list - const fields = await db - .select() - .from(communityOnboardingFields) - .where(eq(communityOnboardingFields.communityDid, communityDid)) - .orderBy(asc(communityOnboardingFields.sortOrder)); + // Return updated list + const fields = await db + .select() + .from(communityOnboardingFields) + .where(eq(communityOnboardingFields.communityDid, communityDid)) + .orderBy(asc(communityOnboardingFields.sortOrder)) - return reply.status(200).send(fields.map(serializeField)); - }); + return reply.status(200).send(fields.map(serializeField)) + } + ) // ===================================================================== // USER ENDPOINTS @@ -387,185 +409,192 @@ export function onboardingRoutes(): FastifyPluginCallback { // GET /api/onboarding/status // ------------------------------------------------------------------- - app.get("/api/onboarding/status", { - preHandler: [authMiddleware.requireAuth], - schema: { - tags: ["Onboarding"], - summary: "Get onboarding status for the current community", - security: [{ bearerAuth: [] }], - response: { - 200: onboardingStatusJsonSchema, - 401: errorJsonSchema, + app.get( + '/api/onboarding/status', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Onboarding'], + summary: 'Get onboarding status for the current community', + security: [{ bearerAuth: [] }], + response: { + 200: onboardingStatusJsonSchema, + 401: errorJsonSchema, + }, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - throw forbidden("Authentication required"); - } + async (request, reply) => { + const user = request.user + if (!user) { + throw forbidden('Authentication required') + } + + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - - // Get all fields for this community - const fields = await db - .select() - .from(communityOnboardingFields) - .where(eq(communityOnboardingFields.communityDid, communityDid)) - .orderBy(asc(communityOnboardingFields.sortOrder)); - - // Get user's responses - const responses = await db - .select() - .from(userOnboardingResponses) - .where( - and( - eq(userOnboardingResponses.did, user.did), - eq(userOnboardingResponses.communityDid, communityDid), - ), - ); - - const responseMap = new Map(responses.map((r) => [r.fieldId, r.response])); - - const fieldsWithStatus = fields.map((field) => ({ - ...serializeField(field), - completed: responseMap.has(field.id), - response: responseMap.get(field.id) ?? null, - })); - - const complete = fields - .filter((f) => f.isMandatory) - .every((f) => responseMap.has(f.id)); - - return reply.status(200).send({ - complete, - fields: fieldsWithStatus, - }); - }); + // Get all fields for this community + const fields = await db + .select() + .from(communityOnboardingFields) + .where(eq(communityOnboardingFields.communityDid, communityDid)) + .orderBy(asc(communityOnboardingFields.sortOrder)) + + // Get user's responses + const responses = await db + .select() + .from(userOnboardingResponses) + .where( + and( + eq(userOnboardingResponses.did, user.did), + eq(userOnboardingResponses.communityDid, communityDid) + ) + ) + + const responseMap = new Map(responses.map((r) => [r.fieldId, r.response])) + + const fieldsWithStatus = fields.map((field) => ({ + ...serializeField(field), + completed: responseMap.has(field.id), + response: responseMap.get(field.id) ?? null, + })) + + const complete = fields.filter((f) => f.isMandatory).every((f) => responseMap.has(f.id)) + + return reply.status(200).send({ + complete, + fields: fieldsWithStatus, + }) + } + ) // ------------------------------------------------------------------- // POST /api/onboarding/submit // ------------------------------------------------------------------- - app.post("/api/onboarding/submit", { - preHandler: [authMiddleware.requireAuth], - schema: { - tags: ["Onboarding"], - summary: "Submit onboarding responses", - security: [{ bearerAuth: [] }], - body: { - type: "array" as const, - items: { - type: "object" as const, - properties: { - fieldId: { type: "string" as const }, - response: {}, + app.post( + '/api/onboarding/submit', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Onboarding'], + summary: 'Submit onboarding responses', + security: [{ bearerAuth: [] }], + body: { + type: 'array' as const, + items: { + type: 'object' as const, + properties: { + fieldId: { type: 'string' as const }, + response: {}, + }, + required: ['fieldId', 'response'], }, - required: ["fieldId", "response"], }, - }, - response: { - 200: { - type: "object" as const, - properties: { - success: { type: "boolean" as const }, - complete: { type: "boolean" as const }, + response: { + 200: { + type: 'object' as const, + properties: { + success: { type: 'boolean' as const }, + complete: { type: 'boolean' as const }, + }, }, + 400: errorJsonSchema, + 401: errorJsonSchema, }, - 400: errorJsonSchema, - 401: errorJsonSchema, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - throw forbidden("Authentication required"); - } - - const parsed = submitOnboardingSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid submission data"); - } - - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - - // Fetch all community fields to validate against - const fields = await db - .select() - .from(communityOnboardingFields) - .where(eq(communityOnboardingFields.communityDid, communityDid)); + async (request, reply) => { + const user = request.user + if (!user) { + throw forbidden('Authentication required') + } - const fieldMap = new Map(fields.map((f) => [f.id, f])); + const parsed = submitOnboardingSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid submission data') + } - // Validate each response - const errors: string[] = []; - for (const submission of parsed.data) { - const field = fieldMap.get(submission.fieldId); - if (!field) { - errors.push(`Unknown field: ${submission.fieldId}`); - continue; + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' + + // Fetch all community fields to validate against + const fields = await db + .select() + .from(communityOnboardingFields) + .where(eq(communityOnboardingFields.communityDid, communityDid)) + + const fieldMap = new Map(fields.map((f) => [f.id, f])) + + // Validate each response + const errors: string[] = [] + for (const submission of parsed.data) { + const field = fieldMap.get(submission.fieldId) + if (!field) { + errors.push(`Unknown field: ${submission.fieldId}`) + continue + } + + const error = validateFieldResponse(field.fieldType, submission.response, field.config) + if (error) { + errors.push(`${field.label}: ${error}`) + } } - const error = validateFieldResponse( - field.fieldType, - submission.response, - field.config, - ); - if (error) { - errors.push(`${field.label}: ${error}`); + if (errors.length > 0) { + throw badRequest(errors.join('; ')) } - } - if (errors.length > 0) { - throw badRequest(errors.join("; ")); - } + // Upsert responses (idempotent) + for (const submission of parsed.data) { + await db + .insert(userOnboardingResponses) + .values({ + did: user.did, + communityDid, + fieldId: submission.fieldId, + response: submission.response, + }) + .onConflictDoUpdate({ + target: [ + userOnboardingResponses.did, + userOnboardingResponses.communityDid, + userOnboardingResponses.fieldId, + ], + set: { + response: submission.response, + completedAt: new Date(), + }, + }) + } - // Upsert responses (idempotent) - for (const submission of parsed.data) { - await db - .insert(userOnboardingResponses) - .values({ + // Check completeness (all mandatory fields answered?) + const existingResponses = await db + .select() + .from(userOnboardingResponses) + .where( + and( + eq(userOnboardingResponses.did, user.did), + eq(userOnboardingResponses.communityDid, communityDid) + ) + ) + + const answeredFieldIds = new Set(existingResponses.map((r) => r.fieldId)) + const complete = fields + .filter((f) => f.isMandatory) + .every((f) => answeredFieldIds.has(f.id)) + + app.log.info( + { + event: 'onboarding_submitted', did: user.did, - communityDid, - fieldId: submission.fieldId, - response: submission.response, - }) - .onConflictDoUpdate({ - target: [ - userOnboardingResponses.did, - userOnboardingResponses.communityDid, - userOnboardingResponses.fieldId, - ], - set: { - response: submission.response, - completedAt: new Date(), - }, - }); + fieldCount: parsed.data.length, + complete, + }, + 'Onboarding responses submitted' + ) + + return reply.status(200).send({ success: true, complete }) } + ) - // Check completeness (all mandatory fields answered?) - const existingResponses = await db - .select() - .from(userOnboardingResponses) - .where( - and( - eq(userOnboardingResponses.did, user.did), - eq(userOnboardingResponses.communityDid, communityDid), - ), - ); - - const answeredFieldIds = new Set(existingResponses.map((r) => r.fieldId)); - const complete = fields - .filter((f) => f.isMandatory) - .every((f) => answeredFieldIds.has(f.id)); - - app.log.info( - { event: "onboarding_submitted", did: user.did, fieldCount: parsed.data.length, complete }, - "Onboarding responses submitted", - ); - - return reply.status(200).send({ success: true, complete }); - }); - - done(); - }; + done() + } } diff --git a/src/routes/profiles.ts b/src/routes/profiles.ts index 38a24e2..fad2d62 100644 --- a/src/routes/profiles.ts +++ b/src/routes/profiles.ts @@ -1,124 +1,126 @@ -import { eq, and, sql } from "drizzle-orm"; -import type { FastifyPluginCallback } from "fastify"; -import { notFound, badRequest } from "../lib/api-errors.js"; +import { eq, and, sql } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { notFound, badRequest } from '../lib/api-errors.js' import { userPreferencesSchema, communityPreferencesSchema, ageDeclarationSchema, -} from "../validation/profiles.js"; -import { users } from "../db/schema/users.js"; -import { communityProfiles } from "../db/schema/community-profiles.js"; -import { resolveProfile } from "../lib/resolve-profile.js"; -import { topics } from "../db/schema/topics.js"; -import { replies } from "../db/schema/replies.js"; -import { reactions } from "../db/schema/reactions.js"; -import { notifications } from "../db/schema/notifications.js"; -import { reports } from "../db/schema/reports.js"; -import { - userPreferences, - userCommunityPreferences, -} from "../db/schema/user-preferences.js"; +} from '../validation/profiles.js' +import { users } from '../db/schema/users.js' +import { communityProfiles } from '../db/schema/community-profiles.js' +import { resolveProfile } from '../lib/resolve-profile.js' +import { topics } from '../db/schema/topics.js' +import { replies } from '../db/schema/replies.js' +import { reactions } from '../db/schema/reactions.js' +import { notifications } from '../db/schema/notifications.js' +import { reports } from '../db/schema/reports.js' +import { userPreferences, userCommunityPreferences } from '../db/schema/user-preferences.js' +import { computeClusterDiversityFactor } from '../services/cluster-diversity.js' +import { sybilClusterMembers } from '../db/schema/sybil-cluster-members.js' +import { sybilClusters } from '../db/schema/sybil-clusters.js' +import { interactionGraph } from '../db/schema/interaction-graph.js' +import { pdsTrustFactors } from '../db/schema/pds-trust-factors.js' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- const errorJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, + error: { type: 'string' as const }, }, -}; +} const profileJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - did: { type: "string" as const }, - handle: { type: "string" as const }, - displayName: { type: ["string", "null"] as const }, - avatarUrl: { type: ["string", "null"] as const }, - bannerUrl: { type: ["string", "null"] as const }, - bio: { type: ["string", "null"] as const }, - role: { type: "string" as const }, - firstSeenAt: { type: "string" as const, format: "date-time" as const }, - lastActiveAt: { type: "string" as const, format: "date-time" as const }, + did: { type: 'string' as const }, + handle: { type: 'string' as const }, + displayName: { type: ['string', 'null'] as const }, + avatarUrl: { type: ['string', 'null'] as const }, + bannerUrl: { type: ['string', 'null'] as const }, + bio: { type: ['string', 'null'] as const }, + role: { type: 'string' as const }, + firstSeenAt: { type: 'string' as const, format: 'date-time' as const }, + lastActiveAt: { type: 'string' as const, format: 'date-time' as const }, activity: { - type: "object" as const, + type: 'object' as const, properties: { - topicCount: { type: "number" as const }, - replyCount: { type: "number" as const }, - reactionsReceived: { type: "number" as const }, + topicCount: { type: 'number' as const }, + replyCount: { type: 'number' as const }, + reactionsReceived: { type: 'number' as const }, }, }, }, -}; +} const reputationJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - did: { type: "string" as const }, - handle: { type: "string" as const }, - reputation: { type: "number" as const }, + did: { type: 'string' as const }, + handle: { type: 'string' as const }, + reputation: { type: 'number' as const }, breakdown: { - type: "object" as const, + type: 'object' as const, properties: { - topicCount: { type: "number" as const }, - replyCount: { type: "number" as const }, - reactionsReceived: { type: "number" as const }, + topicCount: { type: 'number' as const }, + replyCount: { type: 'number' as const }, + reactionsReceived: { type: 'number' as const }, }, }, - communityCount: { type: "number" as const }, + communityCount: { type: 'number' as const }, }, -}; +} const preferencesJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - maturityLevel: { type: "string" as const }, + maturityLevel: { type: 'string' as const }, declaredAge: { - type: ["integer", "null"] as const, + type: ['integer', 'null'] as const, }, - mutedWords: { type: "array" as const, items: { type: "string" as const } }, + mutedWords: { type: 'array' as const, items: { type: 'string' as const } }, blockedDids: { - type: "array" as const, - items: { type: "string" as const }, + type: 'array' as const, + items: { type: 'string' as const }, }, - mutedDids: { type: "array" as const, items: { type: "string" as const } }, - crossPostBluesky: { type: "boolean" as const }, - crossPostFrontpage: { type: "boolean" as const }, - updatedAt: { type: "string" as const, format: "date-time" as const }, + mutedDids: { type: 'array' as const, items: { type: 'string' as const } }, + crossPostBluesky: { type: 'boolean' as const }, + crossPostFrontpage: { type: 'boolean' as const }, + updatedAt: { type: 'string' as const, format: 'date-time' as const }, }, -}; +} const communityPrefsJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - communityDid: { type: "string" as const }, - maturityOverride: { type: ["string", "null"] as const }, + communityDid: { type: 'string' as const }, + maturityOverride: { type: ['string', 'null'] as const }, mutedWords: { - type: ["array", "null"] as const, - items: { type: "string" as const }, + type: ['array', 'null'] as const, + items: { type: 'string' as const }, }, blockedDids: { - type: ["array", "null"] as const, - items: { type: "string" as const }, + type: ['array', 'null'] as const, + items: { type: 'string' as const }, }, mutedDids: { - type: ["array", "null"] as const, - items: { type: "string" as const }, + type: ['array', 'null'] as const, + items: { type: 'string' as const }, }, notificationPrefs: { - type: ["object", "null"] as const, + type: ['object', 'null'] as const, properties: { - replies: { type: "boolean" as const }, - reactions: { type: "boolean" as const }, - mentions: { type: "boolean" as const }, - modActions: { type: "boolean" as const }, + replies: { type: 'boolean' as const }, + reactions: { type: 'boolean' as const }, + mentions: { type: 'boolean' as const }, + modActions: { type: 'boolean' as const }, }, }, - updatedAt: { type: "string" as const, format: "date-time" as const }, + updatedAt: { type: 'string' as const, format: 'date-time' as const }, }, -}; +} // --------------------------------------------------------------------------- // Helpers @@ -127,7 +129,7 @@ const communityPrefsJsonSchema = { /** Default global preferences returned when no row exists yet. */ function defaultPreferences() { return { - maturityLevel: "sfw" as const, + maturityLevel: 'sfw' as const, declaredAge: null as number | null, mutedWords: [] as string[], blockedDids: [] as string[], @@ -135,7 +137,7 @@ function defaultPreferences() { crossPostBluesky: false, crossPostFrontpage: false, updatedAt: new Date().toISOString(), - }; + } } /** Default per-community preferences returned when no row exists yet. */ @@ -148,7 +150,7 @@ function defaultCommunityPreferences(communityDid: string) { mutedDids: null, notificationPrefs: null, updatedAt: new Date().toISOString(), - }; + } } // --------------------------------------------------------------------------- @@ -169,30 +171,30 @@ function defaultCommunityPreferences(communityDid: string) { */ export function profileRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, authMiddleware } = app; + const { db, authMiddleware } = app // ------------------------------------------------------------------- // GET /api/users/:handle (public, optionalAuth) // ------------------------------------------------------------------- app.get( - "/api/users/:handle", + '/api/users/:handle', { preHandler: [authMiddleware.optionalAuth], schema: { - tags: ["Profiles"], - summary: "Get user profile and activity summary", + tags: ['Profiles'], + summary: 'Get user profile and activity summary', params: { - type: "object", - required: ["handle"], + type: 'object', + required: ['handle'], properties: { - handle: { type: "string" }, + handle: { type: 'string' }, }, }, querystring: { - type: "object", + type: 'object', properties: { - communityDid: { type: "string" }, + communityDid: { type: 'string' }, }, }, response: { @@ -202,51 +204,47 @@ export function profileRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const { handle } = request.params as { handle: string }; - const { communityDid } = request.query as { communityDid?: string }; + const { handle } = request.params as { handle: string } + const { communityDid } = request.query as { communityDid?: string } // Look up user by handle - const userRows = await db - .select() - .from(users) - .where(eq(users.handle, handle)); + const userRows = await db.select().from(users).where(eq(users.handle, handle)) - const user = userRows[0]; + const user = userRows[0] if (!user) { - throw notFound("User not found"); + throw notFound('User not found') } // Aggregate activity counts const topicCountResult = await db .select({ count: sql`count(*)::int` }) .from(topics) - .where(eq(topics.authorDid, user.did)); + .where(eq(topics.authorDid, user.did)) const replyCountResult = await db .select({ count: sql`count(*)::int` }) .from(replies) - .where(eq(replies.authorDid, user.did)); + .where(eq(replies.authorDid, user.did)) // Count reactions received on user's topics and replies const reactionsOnTopicsResult = await db .select({ count: sql`count(*)::int` }) .from(reactions) .where( - sql`${reactions.subjectUri} IN (SELECT ${topics.uri} FROM ${topics} WHERE ${topics.authorDid} = ${user.did})`, - ); + sql`${reactions.subjectUri} IN (SELECT ${topics.uri} FROM ${topics} WHERE ${topics.authorDid} = ${user.did})` + ) const reactionsOnRepliesResult = await db .select({ count: sql`count(*)::int` }) .from(reactions) .where( - sql`${reactions.subjectUri} IN (SELECT ${replies.uri} FROM ${replies} WHERE ${replies.authorDid} = ${user.did})`, - ); + sql`${reactions.subjectUri} IN (SELECT ${replies.uri} FROM ${replies} WHERE ${replies.authorDid} = ${user.did})` + ) - const topicCount = topicCountResult[0]?.count ?? 0; - const replyCount = replyCountResult[0]?.count ?? 0; + const topicCount = topicCountResult[0]?.count ?? 0 + const replyCount = replyCountResult[0]?.count ?? 0 const reactionsReceived = - (reactionsOnTopicsResult[0]?.count ?? 0) + - (reactionsOnRepliesResult[0]?.count ?? 0); + (reactionsOnTopicsResult[0]?.count ?? 0) + (reactionsOnRepliesResult[0]?.count ?? 0) // Build source profile for resolution const sourceProfile = { @@ -256,10 +254,10 @@ export function profileRoutes(): FastifyPluginCallback { avatarUrl: user.avatarUrl ?? null, bannerUrl: user.bannerUrl ?? null, bio: user.bio ?? null, - }; + } // Optionally resolve through community override layer - let resolved = sourceProfile; + let resolved = sourceProfile if (communityDid) { const overrideRows = await db .select() @@ -267,12 +265,12 @@ export function profileRoutes(): FastifyPluginCallback { .where( and( eq(communityProfiles.did, user.did), - eq(communityProfiles.communityDid, communityDid), - ), - ); + eq(communityProfiles.communityDid, communityDid) + ) + ) - const override = overrideRows[0] ?? null; - resolved = resolveProfile(sourceProfile, override); + const override = overrideRows[0] ?? null + resolved = resolveProfile(sourceProfile, override) } return reply.status(200).send({ @@ -290,26 +288,26 @@ export function profileRoutes(): FastifyPluginCallback { replyCount, reactionsReceived, }, - }); - }, - ); + }) + } + ) // ------------------------------------------------------------------- // GET /api/users/:handle/reputation (public) // ------------------------------------------------------------------- app.get( - "/api/users/:handle/reputation", + '/api/users/:handle/reputation', { preHandler: [authMiddleware.optionalAuth], schema: { - tags: ["Profiles"], - summary: "Get user reputation score", + tags: ['Profiles'], + summary: 'Get user reputation score', params: { - type: "object", - required: ["handle"], + type: 'object', + required: ['handle'], properties: { - handle: { type: "string" }, + handle: { type: 'string' }, }, }, response: { @@ -319,77 +317,148 @@ export function profileRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const { handle } = request.params as { handle: string }; + const { handle } = request.params as { handle: string } // Look up user by handle - const userRows = await db - .select() - .from(users) - .where(eq(users.handle, handle)); + const userRows = await db.select().from(users).where(eq(users.handle, handle)) - const user = userRows[0]; + const user = userRows[0] if (!user) { - throw notFound("User not found"); + throw notFound('User not found') } // Count topics const topicCountResult = await db .select({ count: sql`count(*)::int` }) .from(topics) - .where(eq(topics.authorDid, user.did)); + .where(eq(topics.authorDid, user.did)) // Count replies const replyCountResult = await db .select({ count: sql`count(*)::int` }) .from(replies) - .where(eq(replies.authorDid, user.did)); + .where(eq(replies.authorDid, user.did)) // Count reactions received const reactionsOnTopicsResult = await db .select({ count: sql`count(*)::int` }) .from(reactions) .where( - sql`${reactions.subjectUri} IN (SELECT ${topics.uri} FROM ${topics} WHERE ${topics.authorDid} = ${user.did})`, - ); + sql`${reactions.subjectUri} IN (SELECT ${topics.uri} FROM ${topics} WHERE ${topics.authorDid} = ${user.did})` + ) const reactionsOnRepliesResult = await db .select({ count: sql`count(*)::int` }) .from(reactions) .where( - sql`${reactions.subjectUri} IN (SELECT ${replies.uri} FROM ${replies} WHERE ${replies.authorDid} = ${user.did})`, - ); + sql`${reactions.subjectUri} IN (SELECT ${replies.uri} FROM ${replies} WHERE ${replies.authorDid} = ${user.did})` + ) - const topicCount = topicCountResult[0]?.count ?? 0; - const replyCount = replyCountResult[0]?.count ?? 0; + const topicCount = topicCountResult[0]?.count ?? 0 + const replyCount = replyCountResult[0]?.count ?? 0 const reactionsReceived = - (reactionsOnTopicsResult[0]?.count ?? 0) + - (reactionsOnRepliesResult[0]?.count ?? 0); + (reactionsOnTopicsResult[0]?.count ?? 0) + (reactionsOnRepliesResult[0]?.count ?? 0) + + // Base reputation formula: (topics * 5) + (replies * 2) + (reactions_received * 1) + const baseReputation = topicCount * 5 + replyCount * 2 + reactionsReceived * 1 + + // Trust-weighted reputation: multiply by voter trust score, PDS trust factor, and cluster diversity factor + const voterTrustScore = await app.trustGraphService + .getTrustScore(user.did, null) + .catch(() => 0.1) + + // Look up PDS trust factor from user's handle domain + let pdsTrustFactor = 0.3 // Default for unknown PDS + try { + const handleParts = user.handle.split('.') + // Extract host: "alice.bsky.social" -> "bsky.social", "alice.example.com" -> "example.com" + const pdsHost = handleParts.length > 1 ? handleParts.slice(1).join('.') : user.handle + + const pdsRows = await db + .select({ trustFactor: pdsTrustFactors.trustFactor }) + .from(pdsTrustFactors) + .where(eq(pdsTrustFactors.pdsHost, pdsHost)) + + const pdsRow = pdsRows[0] + if (pdsRow) { + pdsTrustFactor = pdsRow.trustFactor + } + } catch { + // Non-critical: default to 0.3 for unknown PDS + } + + // Check if user is in a flagged sybil cluster + let inFlaggedCluster = false + let externalInteractionCount = 0 + try { + const memberRows = await db + .select({ clusterId: sybilClusterMembers.clusterId }) + .from(sybilClusterMembers) + .where(eq(sybilClusterMembers.did, user.did)) + + if (memberRows.length > 0) { + const clusterIds = memberRows.map((r) => r.clusterId) + const flaggedRows = await db + .select({ id: sybilClusters.id }) + .from(sybilClusters) + .where( + and( + sql`${sybilClusters.id} = ANY(${clusterIds})`, + eq(sybilClusters.status, 'flagged') + ) + ) + inFlaggedCluster = flaggedRows.length > 0 + + if (inFlaggedCluster) { + // Count distinct external DIDs this user interacts with + const externalRows = await db + .select({ + count: sql`count(DISTINCT ${interactionGraph.targetDid})::int`, + }) + .from(interactionGraph) + .where( + and( + eq(interactionGraph.sourceDid, user.did), + sql`${interactionGraph.targetDid} NOT IN ( + SELECT ${sybilClusterMembers.did} + FROM ${sybilClusterMembers} + WHERE ${sybilClusterMembers.clusterId} = ANY(${clusterIds}) + )` + ) + ) + externalInteractionCount = externalRows[0]?.count ?? 0 + } + } + } catch { + // Non-critical: default to no cluster adjustment + } - // Reputation formula: (topics * 5) + (replies * 2) + (reactions_received * 1) - const reputation = - topicCount * 5 + replyCount * 2 + reactionsReceived * 1; + const clusterDiversityFactor = computeClusterDiversityFactor( + inFlaggedCluster, + externalInteractionCount + ) + + const reputation = Math.round( + baseReputation * voterTrustScore * pdsTrustFactor * clusterDiversityFactor + ) // Count distinct communities the user has contributed to const topicCommResult = await db .selectDistinct({ communityDid: topics.communityDid }) .from(topics) - .where(eq(topics.authorDid, user.did)); + .where(eq(topics.authorDid, user.did)) const replyCommResult = await db .selectDistinct({ communityDid: replies.communityDid }) .from(replies) - .where(eq(replies.authorDid, user.did)); + .where(eq(replies.authorDid, user.did)) const allCommunities = new Set([ - ...topicCommResult.map( - (r: { communityDid: string }) => r.communityDid, - ), - ...replyCommResult.map( - (r: { communityDid: string }) => r.communityDid, - ), - ]); + ...topicCommResult.map((r: { communityDid: string }) => r.communityDid), + ...replyCommResult.map((r: { communityDid: string }) => r.communityDid), + ]) - const communityCount = allCommunities.size; + const communityCount = allCommunities.size return reply.status(200).send({ did: user.did, @@ -401,36 +470,36 @@ export function profileRoutes(): FastifyPluginCallback { reactionsReceived, }, communityCount, - }); - }, - ); + }) + } + ) // ------------------------------------------------------------------- // POST /api/users/me/age-declaration (auth required) // ------------------------------------------------------------------- app.post( - "/api/users/me/age-declaration", + '/api/users/me/age-declaration', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Profiles"], - summary: "Declare age to unlock mature content access", + tags: ['Profiles'], + summary: 'Declare age to unlock mature content access', security: [{ bearerAuth: [] }], body: { - type: "object", - required: ["declaredAge"], + type: 'object', + required: ['declaredAge'], properties: { - declaredAge: { type: "integer" }, + declaredAge: { type: 'integer' }, }, }, response: { 200: { - type: "object", + type: 'object', properties: { - success: { type: "boolean" }, + success: { type: 'boolean' }, declaredAge: { - type: "integer", + type: 'integer', }, }, }, @@ -440,20 +509,18 @@ export function profileRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const requestUser = request.user; + const requestUser = request.user if (!requestUser) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } - const parsed = ageDeclarationSchema.safeParse(request.body); + const parsed = ageDeclarationSchema.safeParse(request.body) if (!parsed.success) { - throw badRequest("declaredAge must be one of: 0, 13, 14, 15, 16, 18"); + throw badRequest('declaredAge must be one of: 0, 13, 14, 15, 16, 18') } - const { declaredAge } = parsed.data; - const now = new Date(); + const { declaredAge } = parsed.data + const now = new Date() // Upsert into user_preferences await db @@ -469,32 +536,29 @@ export function profileRoutes(): FastifyPluginCallback { declaredAge, updatedAt: now, }, - }); + }) // Also update users table - await db - .update(users) - .set({ declaredAge }) - .where(eq(users.did, requestUser.did)); + await db.update(users).set({ declaredAge }).where(eq(users.did, requestUser.did)) return reply.status(200).send({ success: true, declaredAge, - }); - }, - ); + }) + } + ) // ------------------------------------------------------------------- // GET /api/users/me/preferences (auth required) // ------------------------------------------------------------------- app.get( - "/api/users/me/preferences", + '/api/users/me/preferences', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Profiles"], - summary: "Get global user preferences", + tags: ['Profiles'], + summary: 'Get global user preferences', security: [{ bearerAuth: [] }], response: { 200: preferencesJsonSchema, @@ -503,21 +567,19 @@ export function profileRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const requestUser = request.user; + const requestUser = request.user if (!requestUser) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } const rows = await db .select() .from(userPreferences) - .where(eq(userPreferences.did, requestUser.did)); + .where(eq(userPreferences.did, requestUser.did)) - const prefs = rows[0]; + const prefs = rows[0] if (!prefs) { - return reply.status(200).send(defaultPreferences()); + return reply.status(200).send(defaultPreferences()) } return reply.status(200).send({ @@ -529,40 +591,40 @@ export function profileRoutes(): FastifyPluginCallback { crossPostBluesky: prefs.crossPostBluesky, crossPostFrontpage: prefs.crossPostFrontpage, updatedAt: prefs.updatedAt.toISOString(), - }); - }, - ); + }) + } + ) // ------------------------------------------------------------------- // PUT /api/users/me/preferences (auth required) // ------------------------------------------------------------------- app.put( - "/api/users/me/preferences", + '/api/users/me/preferences', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Profiles"], - summary: "Update global user preferences", + tags: ['Profiles'], + summary: 'Update global user preferences', security: [{ bearerAuth: [] }], body: { - type: "object", + type: 'object', properties: { - maturityLevel: { type: "string", enum: ["sfw", "mature"] }, + maturityLevel: { type: 'string', enum: ['sfw', 'mature'] }, mutedWords: { - type: "array", - items: { type: "string" }, + type: 'array', + items: { type: 'string' }, }, blockedDids: { - type: "array", - items: { type: "string" }, + type: 'array', + items: { type: 'string' }, }, mutedDids: { - type: "array", - items: { type: "string" }, + type: 'array', + items: { type: 'string' }, }, - crossPostBluesky: { type: "boolean" }, - crossPostFrontpage: { type: "boolean" }, + crossPostBluesky: { type: 'boolean' }, + crossPostFrontpage: { type: 'boolean' }, }, }, response: { @@ -573,38 +635,36 @@ export function profileRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const requestUser = request.user; + const requestUser = request.user if (!requestUser) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } - const parsed = userPreferencesSchema.safeParse(request.body); + const parsed = userPreferencesSchema.safeParse(request.body) if (!parsed.success) { - throw badRequest("Invalid preferences data"); + throw badRequest('Invalid preferences data') } - const now = new Date(); - const updateData: Record = { updatedAt: now }; + const now = new Date() + const updateData: Record = { updatedAt: now } if (parsed.data.maturityLevel !== undefined) { - updateData["maturityLevel"] = parsed.data.maturityLevel; + updateData['maturityLevel'] = parsed.data.maturityLevel } if (parsed.data.mutedWords !== undefined) { - updateData["mutedWords"] = parsed.data.mutedWords; + updateData['mutedWords'] = parsed.data.mutedWords } if (parsed.data.blockedDids !== undefined) { - updateData["blockedDids"] = parsed.data.blockedDids; + updateData['blockedDids'] = parsed.data.blockedDids } if (parsed.data.mutedDids !== undefined) { - updateData["mutedDids"] = parsed.data.mutedDids; + updateData['mutedDids'] = parsed.data.mutedDids } if (parsed.data.crossPostBluesky !== undefined) { - updateData["crossPostBluesky"] = parsed.data.crossPostBluesky; + updateData['crossPostBluesky'] = parsed.data.crossPostBluesky } if (parsed.data.crossPostFrontpage !== undefined) { - updateData["crossPostFrontpage"] = parsed.data.crossPostFrontpage; + updateData['crossPostFrontpage'] = parsed.data.crossPostFrontpage } // Upsert @@ -618,17 +678,17 @@ export function profileRoutes(): FastifyPluginCallback { .onConflictDoUpdate({ target: userPreferences.did, set: updateData, - }); + }) // Fetch the updated row const rows = await db .select() .from(userPreferences) - .where(eq(userPreferences.did, requestUser.did)); + .where(eq(userPreferences.did, requestUser.did)) - const prefs = rows[0]; + const prefs = rows[0] if (!prefs) { - return reply.status(200).send(defaultPreferences()); + return reply.status(200).send(defaultPreferences()) } return reply.status(200).send({ @@ -640,27 +700,27 @@ export function profileRoutes(): FastifyPluginCallback { crossPostBluesky: prefs.crossPostBluesky, crossPostFrontpage: prefs.crossPostFrontpage, updatedAt: prefs.updatedAt.toISOString(), - }); - }, - ); + }) + } + ) // ------------------------------------------------------------------- // GET /api/users/me/communities/:communityId/preferences (auth required) // ------------------------------------------------------------------- app.get( - "/api/users/me/communities/:communityId/preferences", + '/api/users/me/communities/:communityId/preferences', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Profiles"], - summary: "Get per-community user preferences", + tags: ['Profiles'], + summary: 'Get per-community user preferences', security: [{ bearerAuth: [] }], params: { - type: "object", - required: ["communityId"], + type: 'object', + required: ['communityId'], properties: { - communityId: { type: "string" }, + communityId: { type: 'string' }, }, }, response: { @@ -670,14 +730,12 @@ export function profileRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const requestUser = request.user; + const requestUser = request.user if (!requestUser) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } - const { communityId } = request.params as { communityId: string }; + const { communityId } = request.params as { communityId: string } const rows = await db .select() @@ -685,15 +743,13 @@ export function profileRoutes(): FastifyPluginCallback { .where( and( eq(userCommunityPreferences.did, requestUser.did), - eq(userCommunityPreferences.communityDid, communityId), - ), - ); + eq(userCommunityPreferences.communityDid, communityId) + ) + ) - const prefs = rows[0]; + const prefs = rows[0] if (!prefs) { - return reply - .status(200) - .send(defaultCommunityPreferences(communityId)); + return reply.status(200).send(defaultCommunityPreferences(communityId)) } return reply.status(200).send({ @@ -704,55 +760,55 @@ export function profileRoutes(): FastifyPluginCallback { mutedDids: prefs.mutedDids ?? null, notificationPrefs: prefs.notificationPrefs ?? null, updatedAt: prefs.updatedAt.toISOString(), - }); - }, - ); + }) + } + ) // ------------------------------------------------------------------- // PUT /api/users/me/communities/:communityId/preferences (auth required) // ------------------------------------------------------------------- app.put( - "/api/users/me/communities/:communityId/preferences", + '/api/users/me/communities/:communityId/preferences', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Profiles"], - summary: "Update per-community user preferences", + tags: ['Profiles'], + summary: 'Update per-community user preferences', security: [{ bearerAuth: [] }], params: { - type: "object", - required: ["communityId"], + type: 'object', + required: ['communityId'], properties: { - communityId: { type: "string" }, + communityId: { type: 'string' }, }, }, body: { - type: "object", + type: 'object', properties: { maturityOverride: { - type: ["string", "null"], - enum: ["sfw", "mature", null], + type: ['string', 'null'], + enum: ['sfw', 'mature', null], }, mutedWords: { - type: ["array", "null"], - items: { type: "string" }, + type: ['array', 'null'], + items: { type: 'string' }, }, blockedDids: { - type: ["array", "null"], - items: { type: "string" }, + type: ['array', 'null'], + items: { type: 'string' }, }, mutedDids: { - type: ["array", "null"], - items: { type: "string" }, + type: ['array', 'null'], + items: { type: 'string' }, }, notificationPrefs: { - type: ["object", "null"], + type: ['object', 'null'], properties: { - replies: { type: "boolean" }, - reactions: { type: "boolean" }, - mentions: { type: "boolean" }, - modActions: { type: "boolean" }, + replies: { type: 'boolean' }, + reactions: { type: 'boolean' }, + mentions: { type: 'boolean' }, + modActions: { type: 'boolean' }, }, }, }, @@ -765,37 +821,35 @@ export function profileRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const requestUser = request.user; + const requestUser = request.user if (!requestUser) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } - const { communityId } = request.params as { communityId: string }; + const { communityId } = request.params as { communityId: string } - const parsed = communityPreferencesSchema.safeParse(request.body); + const parsed = communityPreferencesSchema.safeParse(request.body) if (!parsed.success) { - throw badRequest("Invalid community preferences data"); + throw badRequest('Invalid community preferences data') } - const now = new Date(); - const updateData: Record = { updatedAt: now }; + const now = new Date() + const updateData: Record = { updatedAt: now } if (parsed.data.maturityOverride !== undefined) { - updateData["maturityOverride"] = parsed.data.maturityOverride; + updateData['maturityOverride'] = parsed.data.maturityOverride } if (parsed.data.mutedWords !== undefined) { - updateData["mutedWords"] = parsed.data.mutedWords; + updateData['mutedWords'] = parsed.data.mutedWords } if (parsed.data.blockedDids !== undefined) { - updateData["blockedDids"] = parsed.data.blockedDids; + updateData['blockedDids'] = parsed.data.blockedDids } if (parsed.data.mutedDids !== undefined) { - updateData["mutedDids"] = parsed.data.mutedDids; + updateData['mutedDids'] = parsed.data.mutedDids } if (parsed.data.notificationPrefs !== undefined) { - updateData["notificationPrefs"] = parsed.data.notificationPrefs; + updateData['notificationPrefs'] = parsed.data.notificationPrefs } // Upsert: use composite key (did, communityDid) @@ -808,12 +862,9 @@ export function profileRoutes(): FastifyPluginCallback { updatedAt: now, }) .onConflictDoUpdate({ - target: [ - userCommunityPreferences.did, - userCommunityPreferences.communityDid, - ], + target: [userCommunityPreferences.did, userCommunityPreferences.communityDid], set: updateData, - }); + }) // Fetch updated row const rows = await db @@ -822,15 +873,13 @@ export function profileRoutes(): FastifyPluginCallback { .where( and( eq(userCommunityPreferences.did, requestUser.did), - eq(userCommunityPreferences.communityDid, communityId), - ), - ); + eq(userCommunityPreferences.communityDid, communityId) + ) + ) - const prefs = rows[0]; + const prefs = rows[0] if (!prefs) { - return reply - .status(200) - .send(defaultCommunityPreferences(communityId)); + return reply.status(200).send(defaultCommunityPreferences(communityId)) } return reply.status(200).send({ @@ -841,98 +890,72 @@ export function profileRoutes(): FastifyPluginCallback { mutedDids: prefs.mutedDids ?? null, notificationPrefs: prefs.notificationPrefs ?? null, updatedAt: prefs.updatedAt.toISOString(), - }); - }, - ); + }) + } + ) // ------------------------------------------------------------------- // DELETE /api/users/me (auth required) -- GDPR Art. 17 purge // ------------------------------------------------------------------- app.delete( - "/api/users/me", + '/api/users/me', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Profiles"], - summary: - "Delete all indexed data for the authenticated user (GDPR Art. 17)", + tags: ['Profiles'], + summary: 'Delete all indexed data for the authenticated user (GDPR Art. 17)', security: [{ bearerAuth: [] }], response: { - 204: { type: "null" }, + 204: { type: 'null' }, 401: errorJsonSchema, }, }, }, async (request, reply) => { - const requestUser = request.user; + const requestUser = request.user if (!requestUser) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } - const userDid = requestUser.did; + const userDid = requestUser.did await db.transaction(async (tx) => { // Delete reactions by this user - await tx - .delete(reactions) - .where(eq(reactions.authorDid, userDid)); + await tx.delete(reactions).where(eq(reactions.authorDid, userDid)) // Delete notifications for/by this user - await tx - .delete(notifications) - .where(eq(notifications.recipientDid, userDid)); - await tx - .delete(notifications) - .where(eq(notifications.actorDid, userDid)); + await tx.delete(notifications).where(eq(notifications.recipientDid, userDid)) + await tx.delete(notifications).where(eq(notifications.actorDid, userDid)) // Delete reports filed by this user - await tx - .delete(reports) - .where(eq(reports.reporterDid, userDid)); + await tx.delete(reports).where(eq(reports.reporterDid, userDid)) // Delete replies by this user - await tx - .delete(replies) - .where(eq(replies.authorDid, userDid)); + await tx.delete(replies).where(eq(replies.authorDid, userDid)) // Delete topics by this user - await tx - .delete(topics) - .where(eq(topics.authorDid, userDid)); + await tx.delete(topics).where(eq(topics.authorDid, userDid)) // Delete community profile overrides - await tx - .delete(communityProfiles) - .where(eq(communityProfiles.did, userDid)); + await tx.delete(communityProfiles).where(eq(communityProfiles.did, userDid)) // Delete community preferences - await tx - .delete(userCommunityPreferences) - .where(eq(userCommunityPreferences.did, userDid)); + await tx.delete(userCommunityPreferences).where(eq(userCommunityPreferences.did, userDid)) // Delete global preferences - await tx - .delete(userPreferences) - .where(eq(userPreferences.did, userDid)); + await tx.delete(userPreferences).where(eq(userPreferences.did, userDid)) // Delete user record - await tx - .delete(users) - .where(eq(users.did, userDid)); - }); + await tx.delete(users).where(eq(users.did, userDid)) + }) - app.log.info( - { did: userDid }, - "GDPR Art. 17: all indexed data purged for user", - ); + app.log.info({ did: userDid }, 'GDPR Art. 17: all indexed data purged for user') - return reply.status(204).send(); - }, - ); + return reply.status(204).send() + } + ) - done(); - }; + done() + } } diff --git a/src/routes/reactions.ts b/src/routes/reactions.ts index 64f109c..0e40800 100644 --- a/src/routes/reactions.ts +++ b/src/routes/reactions.ts @@ -1,46 +1,46 @@ -import { eq, and, sql, asc } from "drizzle-orm"; -import type { FastifyPluginCallback } from "fastify"; -import { createPdsClient } from "../lib/pds-client.js"; -import { notFound, forbidden, badRequest, conflict } from "../lib/api-errors.js"; -import { createReactionSchema, reactionQuerySchema } from "../validation/reactions.js"; -import { reactions } from "../db/schema/reactions.js"; -import { topics } from "../db/schema/topics.js"; -import { replies } from "../db/schema/replies.js"; -import { communitySettings } from "../db/schema/community-settings.js"; -import { checkOnboardingComplete } from "../lib/onboarding-gate.js"; -import { createNotificationService } from "../services/notification.js"; +import { eq, and, sql, asc } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { createPdsClient } from '../lib/pds-client.js' +import { notFound, forbidden, badRequest, conflict } from '../lib/api-errors.js' +import { createReactionSchema, reactionQuerySchema } from '../validation/reactions.js' +import { reactions } from '../db/schema/reactions.js' +import { topics } from '../db/schema/topics.js' +import { replies } from '../db/schema/replies.js' +import { communitySettings } from '../db/schema/community-settings.js' +import { checkOnboardingComplete } from '../lib/onboarding-gate.js' +import { createNotificationService } from '../services/notification.js' // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const COLLECTION = "forum.barazo.interaction.reaction"; -const TOPIC_COLLECTION = "forum.barazo.topic.post"; -const REPLY_COLLECTION = "forum.barazo.topic.reply"; +const COLLECTION = 'forum.barazo.interaction.reaction' +const TOPIC_COLLECTION = 'forum.barazo.topic.post' +const REPLY_COLLECTION = 'forum.barazo.topic.reply' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- const reactionJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - uri: { type: "string" as const }, - rkey: { type: "string" as const }, - authorDid: { type: "string" as const }, - subjectUri: { type: "string" as const }, - type: { type: "string" as const }, - cid: { type: "string" as const }, - createdAt: { type: "string" as const, format: "date-time" as const }, + uri: { type: 'string' as const }, + rkey: { type: 'string' as const }, + authorDid: { type: 'string' as const }, + subjectUri: { type: 'string' as const }, + type: { type: 'string' as const }, + cid: { type: 'string' as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, }, -}; +} const errorJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, + error: { type: 'string' as const }, }, -}; +} // --------------------------------------------------------------------------- // Helpers @@ -59,14 +59,14 @@ function serializeReaction(row: typeof reactions.$inferSelect) { type: row.type, cid: row.cid, createdAt: row.createdAt.toISOString(), - }; + } } /** * Encode a pagination cursor from createdAt + uri. */ function encodeCursor(createdAt: string, uri: string): string { - return Buffer.from(JSON.stringify({ createdAt, uri })).toString("base64"); + return Buffer.from(JSON.stringify({ createdAt, uri })).toString('base64') } /** @@ -74,13 +74,16 @@ function encodeCursor(createdAt: string, uri: string): string { */ function decodeCursor(cursor: string): { createdAt: string; uri: string } | null { try { - const decoded = JSON.parse(Buffer.from(cursor, "base64").toString("utf-8")) as Record; - if (typeof decoded.createdAt === "string" && typeof decoded.uri === "string") { - return { createdAt: decoded.createdAt, uri: decoded.uri }; + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as Record< + string, + unknown + > + if (typeof decoded.createdAt === 'string' && typeof decoded.uri === 'string') { + return { createdAt: decoded.createdAt, uri: decoded.uri } } - return null; + return null } catch { - return null; + return null } } @@ -89,12 +92,12 @@ function decodeCursor(cursor: string): { createdAt: string; uri: string } | null * Format: at://did:plc:xxx/collection/rkey */ function extractRkey(uri: string): string { - const parts = uri.split("/"); - const rkey = parts[parts.length - 1]; + const parts = uri.split('/') + const rkey = parts[parts.length - 1] if (!rkey) { - throw badRequest("Invalid AT URI: missing rkey"); + throw badRequest('Invalid AT URI: missing rkey') } - return rkey; + return rkey } /** @@ -102,8 +105,8 @@ function extractRkey(uri: string): string { * Format: at://did/collection/rkey -> returns "collection" */ function getCollectionFromUri(uri: string): string | undefined { - const parts = uri.split("/"); - return parts[3]; + const parts = uri.split('/') + return parts[3] } // --------------------------------------------------------------------------- @@ -119,386 +122,407 @@ function getCollectionFromUri(uri: string): string | undefined { */ export function reactionRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, env, authMiddleware, firehose } = app; - const pdsClient = createPdsClient(app.oauthClient, app.log); - const notificationService = createNotificationService(db, app.log); + const { db, env, authMiddleware, firehose } = app + const pdsClient = createPdsClient(app.oauthClient, app.log) + const notificationService = createNotificationService(db, app.log) // ------------------------------------------------------------------- // POST /api/reactions (auth required) // ------------------------------------------------------------------- - app.post("/api/reactions", { - preHandler: [authMiddleware.requireAuth], - schema: { - tags: ["Reactions"], - summary: "Create a reaction on a topic or reply", - security: [{ bearerAuth: [] }], - body: { - type: "object", - required: ["subjectUri", "subjectCid", "type"], - properties: { - subjectUri: { type: "string", minLength: 1 }, - subjectCid: { type: "string", minLength: 1 }, - type: { type: "string", minLength: 1, maxLength: 300 }, - }, - }, - response: { - 201: { - type: "object", + app.post( + '/api/reactions', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Reactions'], + summary: 'Create a reaction on a topic or reply', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + required: ['subjectUri', 'subjectCid', 'type'], properties: { - uri: { type: "string" }, - cid: { type: "string" }, - rkey: { type: "string" }, - type: { type: "string" }, - subjectUri: { type: "string" }, - createdAt: { type: "string", format: "date-time" }, + subjectUri: { type: 'string', minLength: 1 }, + subjectCid: { type: 'string', minLength: 1 }, + type: { type: 'string', minLength: 1, maxLength: 300 }, }, }, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, - 502: errorJsonSchema, + response: { + 201: { + type: 'object', + properties: { + uri: { type: 'string' }, + cid: { type: 'string' }, + rkey: { type: 'string' }, + type: { type: 'string' }, + subjectUri: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + 409: errorJsonSchema, + 502: errorJsonSchema, + }, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const parsed = createReactionSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid reaction data"); - } + const parsed = createReactionSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid reaction data') + } - const { subjectUri, subjectCid, type: reactionType } = parsed.data; - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; + const { subjectUri, subjectCid, type: reactionType } = parsed.data + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' - // Onboarding gate: block if user hasn't completed mandatory onboarding - const onboarding = await checkOnboardingComplete(db, user.did, communityDid); - if (!onboarding.complete) { - return reply.status(403).send({ - error: "Onboarding required", - fields: onboarding.missingFields, - }); - } + // Onboarding gate: block if user hasn't completed mandatory onboarding + const onboarding = await checkOnboardingComplete(db, user.did, communityDid) + if (!onboarding.complete) { + return reply.status(403).send({ + error: 'Onboarding required', + fields: onboarding.missingFields, + }) + } - // Fetch community settings to get the allowed reaction set - const settingsRows = await db - .select({ reactionSet: communitySettings.reactionSet }) - .from(communitySettings) - .where(eq(communitySettings.id, "default")); + // Fetch community settings to get the allowed reaction set + const settingsRows = await db + .select({ reactionSet: communitySettings.reactionSet }) + .from(communitySettings) + .where(eq(communitySettings.id, 'default')) - const settings = settingsRows[0]; - const reactionSet: string[] = settings?.reactionSet ?? ["like"]; + const settings = settingsRows[0] + const reactionSet: string[] = settings?.reactionSet ?? ['like'] - // Validate that the reaction type is in the community's allowed set - if (!reactionSet.includes(reactionType)) { - throw badRequest( - `Reaction type "${reactionType}" is not allowed. Allowed types: ${reactionSet.join(", ")}`, - ); - } + // Validate that the reaction type is in the community's allowed set + if (!reactionSet.includes(reactionType)) { + throw badRequest( + `Reaction type "${reactionType}" is not allowed. Allowed types: ${reactionSet.join(', ')}` + ) + } - // Verify subject exists and belongs to the same community - const collection = getCollectionFromUri(subjectUri); - let subjectExists = false; - - if (collection === TOPIC_COLLECTION) { - const topicRows = await db - .select({ uri: topics.uri }) - .from(topics) - .where( - and( - eq(topics.uri, subjectUri), - eq(topics.communityDid, communityDid), - ), - ); - subjectExists = topicRows.length > 0; - } else if (collection === REPLY_COLLECTION) { - const replyRows = await db - .select({ uri: replies.uri }) - .from(replies) - .where( - and( - eq(replies.uri, subjectUri), - eq(replies.communityDid, communityDid), - ), - ); - subjectExists = replyRows.length > 0; - } + // Verify subject exists and belongs to the same community + const collection = getCollectionFromUri(subjectUri) + let subjectExists = false + let subjectAuthorDid: string | null = null + + if (collection === TOPIC_COLLECTION) { + const topicRows = await db + .select({ uri: topics.uri, authorDid: topics.authorDid }) + .from(topics) + .where(and(eq(topics.uri, subjectUri), eq(topics.communityDid, communityDid))) + subjectExists = topicRows.length > 0 + subjectAuthorDid = topicRows[0]?.authorDid ?? null + } else if (collection === REPLY_COLLECTION) { + const replyRows = await db + .select({ uri: replies.uri, authorDid: replies.authorDid }) + .from(replies) + .where(and(eq(replies.uri, subjectUri), eq(replies.communityDid, communityDid))) + subjectExists = replyRows.length > 0 + subjectAuthorDid = replyRows[0]?.authorDid ?? null + } - if (!subjectExists) { - throw notFound("Subject not found"); - } + if (!subjectExists) { + throw notFound('Subject not found') + } + + const now = new Date().toISOString() - const now = new Date().toISOString(); - - // Build AT Protocol record - const record: Record = { - subject: { uri: subjectUri, cid: subjectCid }, - type: reactionType, - community: communityDid, - createdAt: now, - }; - - try { - // Write record to user's PDS - const result = await pdsClient.createRecord(user.did, COLLECTION, record); - const rkey = extractRkey(result.uri); - - // Track repo if this is user's first interaction - const repoManager = firehose.getRepoManager(); - const alreadyTracked = await repoManager.isTracked(user.did); - if (!alreadyTracked) { - await repoManager.trackRepo(user.did); + // Build AT Protocol record + const record: Record = { + subject: { uri: subjectUri, cid: subjectCid }, + type: reactionType, + community: communityDid, + createdAt: now, } - // Optimistically insert into local DB + increment count in a transaction - const insertResult = await db.transaction(async (tx) => { - const inserted = await tx - .insert(reactions) - .values({ - uri: result.uri, - rkey, - authorDid: user.did, - subjectUri, - subjectCid, - type: reactionType, - communityDid, - cid: result.cid, - createdAt: new Date(now), - indexedAt: new Date(), - }) - .onConflictDoNothing() - .returning(); + try { + // Write record to user's PDS + const result = await pdsClient.createRecord(user.did, COLLECTION, record) + const rkey = extractRkey(result.uri) - // If no rows were inserted, the unique constraint was hit (duplicate reaction) - if (inserted.length === 0) { - return inserted; + // Track repo if this is user's first interaction + const repoManager = firehose.getRepoManager() + const alreadyTracked = await repoManager.isTracked(user.did) + if (!alreadyTracked) { + await repoManager.trackRepo(user.did) } - // Increment reaction count on the subject - if (collection === TOPIC_COLLECTION) { - await tx - .update(topics) - .set({ reactionCount: sql`${topics.reactionCount} + 1` }) - .where(eq(topics.uri, subjectUri)); - } else if (collection === REPLY_COLLECTION) { - await tx - .update(replies) - .set({ reactionCount: sql`${replies.reactionCount} + 1` }) - .where(eq(replies.uri, subjectUri)); + // Optimistically insert into local DB + increment count in a transaction + const insertResult = await db.transaction(async (tx) => { + const inserted = await tx + .insert(reactions) + .values({ + uri: result.uri, + rkey, + authorDid: user.did, + subjectUri, + subjectCid, + type: reactionType, + communityDid, + cid: result.cid, + createdAt: new Date(now), + indexedAt: new Date(), + }) + .onConflictDoNothing() + .returning() + + // If no rows were inserted, the unique constraint was hit (duplicate reaction) + if (inserted.length === 0) { + return inserted + } + + // Increment reaction count on the subject + if (collection === TOPIC_COLLECTION) { + await tx + .update(topics) + .set({ reactionCount: sql`${topics.reactionCount} + 1` }) + .where(eq(topics.uri, subjectUri)) + } else if (collection === REPLY_COLLECTION) { + await tx + .update(replies) + .set({ reactionCount: sql`${replies.reactionCount} + 1` }) + .where(eq(replies.uri, subjectUri)) + } + + return inserted + }) + + if (insertResult.length === 0) { + throw conflict('Reaction already exists') } - return inserted; - }); + // Fire-and-forget: generate notification for the content author + notificationService + .notifyOnReaction({ + subjectUri, + actorDid: user.did, + communityDid, + }) + .catch((err: unknown) => { + app.log.error({ err, subjectUri }, 'Reaction notification failed') + }) - if (insertResult.length === 0) { - throw conflict("Reaction already exists"); - } + // Fire-and-forget: record interaction graph edge + if (subjectAuthorDid) { + app.interactionGraphService + .recordReaction(user.did, subjectAuthorDid, communityDid) + .catch((err: unknown) => { + app.log.warn({ err, subjectUri }, 'Interaction graph recordReaction failed') + }) + } - // Fire-and-forget: generate notification for the content author - notificationService.notifyOnReaction({ - subjectUri, - actorDid: user.did, - communityDid, - }).catch((err: unknown) => { - app.log.error({ err, subjectUri }, "Reaction notification failed"); - }); - - return await reply.status(201).send({ - uri: result.uri, - cid: result.cid, - rkey, - type: reactionType, - subjectUri, - createdAt: now, - }); - } catch (err: unknown) { - if (err instanceof Error && "statusCode" in err) { - throw err; // Re-throw ApiError instances + return await reply.status(201).send({ + uri: result.uri, + cid: result.cid, + rkey, + type: reactionType, + subjectUri, + createdAt: now, + }) + } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) { + throw err // Re-throw ApiError instances + } + app.log.error({ err, did: user.did }, 'Failed to create reaction') + return reply.status(502).send({ error: 'Failed to create reaction' }) } - app.log.error({ err, did: user.did }, "Failed to create reaction"); - return reply.status(502).send({ error: "Failed to create reaction" }); } - }); + ) // ------------------------------------------------------------------- // DELETE /api/reactions/:uri (auth required, author only) // ------------------------------------------------------------------- - app.delete("/api/reactions/:uri", { - preHandler: [authMiddleware.requireAuth], - schema: { - tags: ["Reactions"], - summary: "Delete a reaction (author only)", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["uri"], - properties: { - uri: { type: "string" }, + app.delete( + '/api/reactions/:uri', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Reactions'], + summary: 'Delete a reaction (author only)', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['uri'], + properties: { + uri: { type: 'string' }, + }, + }, + response: { + 204: { type: 'null' }, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + 502: errorJsonSchema, }, - }, - response: { - 204: { type: "null" }, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 502: errorJsonSchema, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } - - const { uri } = request.params as { uri: string }; - const decodedUri = decodeURIComponent(uri); - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - - // Fetch existing reaction (scoped to this community) - const existing = await db - .select() - .from(reactions) - .where(and(eq(reactions.uri, decodedUri), eq(reactions.communityDid, communityDid))); + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const reaction = existing[0]; - if (!reaction) { - throw notFound("Reaction not found"); - } + const { uri } = request.params as { uri: string } + const decodedUri = decodeURIComponent(uri) + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' - // Author check - if (reaction.authorDid !== user.did) { - throw forbidden("Not authorized to delete this reaction"); - } + // Fetch existing reaction (scoped to this community) + const existing = await db + .select() + .from(reactions) + .where(and(eq(reactions.uri, decodedUri), eq(reactions.communityDid, communityDid))) - const rkey = extractRkey(decodedUri); + const reaction = existing[0] + if (!reaction) { + throw notFound('Reaction not found') + } - try { - // Delete from PDS - await pdsClient.deleteRecord(user.did, COLLECTION, rkey); + // Author check + if (reaction.authorDid !== user.did) { + throw forbidden('Not authorized to delete this reaction') + } - // In transaction: delete from DB + decrement count on subject - await db.transaction(async (tx) => { - await tx.delete(reactions).where(and(eq(reactions.uri, decodedUri), eq(reactions.communityDid, communityDid))); + const rkey = extractRkey(decodedUri) - const subjectCollection = getCollectionFromUri(reaction.subjectUri); + try { + // Delete from PDS + await pdsClient.deleteRecord(user.did, COLLECTION, rkey) - if (subjectCollection === TOPIC_COLLECTION) { - await tx - .update(topics) - .set({ - reactionCount: sql`GREATEST(${topics.reactionCount} - 1, 0)`, - }) - .where(eq(topics.uri, reaction.subjectUri)); - } else if (subjectCollection === REPLY_COLLECTION) { + // In transaction: delete from DB + decrement count on subject + await db.transaction(async (tx) => { await tx - .update(replies) - .set({ - reactionCount: sql`GREATEST(${replies.reactionCount} - 1, 0)`, - }) - .where(eq(replies.uri, reaction.subjectUri)); + .delete(reactions) + .where(and(eq(reactions.uri, decodedUri), eq(reactions.communityDid, communityDid))) + + const subjectCollection = getCollectionFromUri(reaction.subjectUri) + + if (subjectCollection === TOPIC_COLLECTION) { + await tx + .update(topics) + .set({ + reactionCount: sql`GREATEST(${topics.reactionCount} - 1, 0)`, + }) + .where(eq(topics.uri, reaction.subjectUri)) + } else if (subjectCollection === REPLY_COLLECTION) { + await tx + .update(replies) + .set({ + reactionCount: sql`GREATEST(${replies.reactionCount} - 1, 0)`, + }) + .where(eq(replies.uri, reaction.subjectUri)) + } + }) + + return await reply.status(204).send() + } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) { + throw err } - }); - - return await reply.status(204).send(); - } catch (err: unknown) { - if (err instanceof Error && "statusCode" in err) { - throw err; + app.log.error({ err, uri: decodedUri }, 'Failed to delete reaction') + return await reply.status(502).send({ error: 'Failed to delete reaction' }) } - app.log.error({ err, uri: decodedUri }, "Failed to delete reaction"); - return await reply.status(502).send({ error: "Failed to delete reaction" }); } - }); + ) // ------------------------------------------------------------------- // GET /api/reactions (public, optionalAuth) // ------------------------------------------------------------------- - app.get("/api/reactions", { - preHandler: [authMiddleware.optionalAuth], - schema: { - tags: ["Reactions"], - summary: "List reactions for a subject URI", - querystring: { - type: "object", - required: ["subjectUri"], - properties: { - subjectUri: { type: "string" }, - type: { type: "string" }, - cursor: { type: "string" }, - limit: { type: "string" }, - }, - }, - response: { - 200: { - type: "object", + app.get( + '/api/reactions', + { + preHandler: [authMiddleware.optionalAuth], + schema: { + tags: ['Reactions'], + summary: 'List reactions for a subject URI', + querystring: { + type: 'object', + required: ['subjectUri'], properties: { - reactions: { type: "array", items: reactionJsonSchema }, - cursor: { type: ["string", "null"] }, + subjectUri: { type: 'string' }, + type: { type: 'string' }, + cursor: { type: 'string' }, + limit: { type: 'string' }, }, }, - 400: errorJsonSchema, + response: { + 200: { + type: 'object', + properties: { + reactions: { type: 'array', items: reactionJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, + }, + 400: errorJsonSchema, + }, }, }, - }, async (request, reply) => { - const parsed = reactionQuerySchema.safeParse(request.query); - if (!parsed.success) { - throw badRequest("Invalid query parameters"); - } + async (request, reply) => { + const parsed = reactionQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } - const { subjectUri, type: reactionType, cursor, limit } = parsed.data; - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - const conditions = [eq(reactions.subjectUri, subjectUri), eq(reactions.communityDid, communityDid)]; + const { subjectUri, type: reactionType, cursor, limit } = parsed.data + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' + const conditions = [ + eq(reactions.subjectUri, subjectUri), + eq(reactions.communityDid, communityDid), + ] - // Optional type filter - if (reactionType) { - conditions.push(eq(reactions.type, reactionType)); - } + // Optional type filter + if (reactionType) { + conditions.push(eq(reactions.type, reactionType)) + } - // Cursor-based pagination (ASC order) - if (cursor) { - const decoded = decodeCursor(cursor); - if (decoded) { - conditions.push( - sql`(${reactions.createdAt}, ${reactions.uri}) > (${decoded.createdAt}::timestamptz, ${decoded.uri})`, - ); + // Cursor-based pagination (ASC order) + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${reactions.createdAt}, ${reactions.uri}) > (${decoded.createdAt}::timestamptz, ${decoded.uri})` + ) + } } - } - const whereClause = and(...conditions); - - // Fetch limit + 1 to detect if there are more pages - const fetchLimit = limit + 1; - const rows = await db - .select() - .from(reactions) - .where(whereClause) - .orderBy(asc(reactions.createdAt)) - .limit(fetchLimit); - - const hasMore = rows.length > limit; - const resultRows = hasMore ? rows.slice(0, limit) : rows; - const serialized = resultRows.map(serializeReaction); - - let nextCursor: string | null = null; - if (hasMore) { - const lastRow = resultRows[resultRows.length - 1]; - if (lastRow) { - nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.uri); + const whereClause = and(...conditions) + + // Fetch limit + 1 to detect if there are more pages + const fetchLimit = limit + 1 + const rows = await db + .select() + .from(reactions) + .where(whereClause) + .orderBy(asc(reactions.createdAt)) + .limit(fetchLimit) + + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + const serialized = resultRows.map(serializeReaction) + + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.uri) + } } - } - return reply.status(200).send({ - reactions: serialized, - cursor: nextCursor, - }); - }); + return reply.status(200).send({ + reactions: serialized, + cursor: nextCursor, + }) + } + ) - done(); - }; + done() + } } diff --git a/src/routes/replies.ts b/src/routes/replies.ts index 7ab76f4..424e93a 100644 --- a/src/routes/replies.ts +++ b/src/routes/replies.ts @@ -1,91 +1,91 @@ -import { eq, and, sql, asc, notInArray } from "drizzle-orm"; -import type { FastifyPluginCallback } from "fastify"; -import { createPdsClient } from "../lib/pds-client.js"; -import { notFound, forbidden, badRequest } from "../lib/api-errors.js"; -import { resolveMaxMaturity, maturityAllows } from "../lib/content-filter.js"; -import type { MaturityUser } from "../lib/content-filter.js"; -import { loadBlockMuteLists } from "../lib/block-mute.js"; -import { loadMutedWords, contentMatchesMutedWords } from "../lib/muted-words.js"; -import { resolveAuthors } from "../lib/resolve-authors.js"; -import { createReplySchema, updateReplySchema, replyQuerySchema } from "../validation/replies.js"; +import { eq, and, sql, asc, notInArray } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { createPdsClient } from '../lib/pds-client.js' +import { notFound, forbidden, badRequest } from '../lib/api-errors.js' +import { resolveMaxMaturity, maturityAllows } from '../lib/content-filter.js' +import type { MaturityUser } from '../lib/content-filter.js' +import { loadBlockMuteLists } from '../lib/block-mute.js' +import { loadMutedWords, contentMatchesMutedWords } from '../lib/muted-words.js' +import { resolveAuthors } from '../lib/resolve-authors.js' +import { createReplySchema, updateReplySchema, replyQuerySchema } from '../validation/replies.js' import { runAntiSpamChecks, loadAntiSpamSettings, isNewAccount, isAccountTrusted, checkWriteRateLimit, -} from "../lib/anti-spam.js"; -import { tooManyRequests } from "../lib/api-errors.js"; -import { moderationQueue } from "../db/schema/moderation-queue.js"; -import { replies } from "../db/schema/replies.js"; -import { topics } from "../db/schema/topics.js"; -import { users } from "../db/schema/users.js"; -import { categories } from "../db/schema/categories.js"; -import { communitySettings } from "../db/schema/community-settings.js"; -import { checkOnboardingComplete } from "../lib/onboarding-gate.js"; -import { createNotificationService } from "../services/notification.js"; +} from '../lib/anti-spam.js' +import { tooManyRequests } from '../lib/api-errors.js' +import { moderationQueue } from '../db/schema/moderation-queue.js' +import { replies } from '../db/schema/replies.js' +import { topics } from '../db/schema/topics.js' +import { users } from '../db/schema/users.js' +import { categories } from '../db/schema/categories.js' +import { communitySettings } from '../db/schema/community-settings.js' +import { checkOnboardingComplete } from '../lib/onboarding-gate.js' +import { createNotificationService } from '../services/notification.js' // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const COLLECTION = "forum.barazo.topic.reply"; +const COLLECTION = 'forum.barazo.topic.reply' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- const replyJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - uri: { type: "string" as const }, - rkey: { type: "string" as const }, - authorDid: { type: "string" as const }, + uri: { type: 'string' as const }, + rkey: { type: 'string' as const }, + authorDid: { type: 'string' as const }, author: { - type: "object" as const, + type: 'object' as const, properties: { - did: { type: "string" as const }, - handle: { type: "string" as const }, - displayName: { type: ["string", "null"] as const }, - avatarUrl: { type: ["string", "null"] as const }, + did: { type: 'string' as const }, + handle: { type: 'string' as const }, + displayName: { type: ['string', 'null'] as const }, + avatarUrl: { type: ['string', 'null'] as const }, }, }, - content: { type: "string" as const }, - contentFormat: { type: ["string", "null"] as const }, - rootUri: { type: "string" as const }, - rootCid: { type: "string" as const }, - parentUri: { type: "string" as const }, - parentCid: { type: "string" as const }, + content: { type: 'string' as const }, + contentFormat: { type: ['string', 'null'] as const }, + rootUri: { type: 'string' as const }, + rootCid: { type: 'string' as const }, + parentUri: { type: 'string' as const }, + parentCid: { type: 'string' as const }, labels: { - type: ["object", "null"] as const, + type: ['object', 'null'] as const, properties: { values: { - type: "array" as const, + type: 'array' as const, items: { - type: "object" as const, - properties: { val: { type: "string" as const } }, + type: 'object' as const, + properties: { val: { type: 'string' as const } }, }, }, }, }, - communityDid: { type: "string" as const }, - cid: { type: "string" as const }, - depth: { type: "integer" as const }, - reactionCount: { type: "integer" as const }, - isMuted: { type: "boolean" as const }, - isMutedWord: { type: "boolean" as const }, - ozoneLabel: { type: ["string", "null"] as const }, - createdAt: { type: "string" as const, format: "date-time" as const }, - indexedAt: { type: "string" as const, format: "date-time" as const }, + communityDid: { type: 'string' as const }, + cid: { type: 'string' as const }, + depth: { type: 'integer' as const }, + reactionCount: { type: 'integer' as const }, + isMuted: { type: 'boolean' as const }, + isMutedWord: { type: 'boolean' as const }, + ozoneLabel: { type: ['string', 'null'] as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + indexedAt: { type: 'string' as const, format: 'date-time' as const }, }, -}; +} const errorJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, + error: { type: 'string' as const }, }, -}; +} // --------------------------------------------------------------------------- // Helpers @@ -99,7 +99,7 @@ function serializeReply(row: typeof replies.$inferSelect) { // Simple depth calculation for MVP: // depth 0 = direct reply to topic (parentUri === rootUri) // depth 1 = reply to a reply (parentUri !== rootUri) - const depth = row.parentUri === row.rootUri ? 0 : 1; + const depth = row.parentUri === row.rootUri ? 0 : 1 return { uri: row.uri, @@ -118,14 +118,14 @@ function serializeReply(row: typeof replies.$inferSelect) { reactionCount: row.reactionCount, createdAt: row.createdAt.toISOString(), indexedAt: row.indexedAt.toISOString(), - }; + } } /** * Encode a pagination cursor from createdAt + uri. */ function encodeCursor(createdAt: string, uri: string): string { - return Buffer.from(JSON.stringify({ createdAt, uri })).toString("base64"); + return Buffer.from(JSON.stringify({ createdAt, uri })).toString('base64') } /** @@ -133,13 +133,16 @@ function encodeCursor(createdAt: string, uri: string): string { */ function decodeCursor(cursor: string): { createdAt: string; uri: string } | null { try { - const decoded = JSON.parse(Buffer.from(cursor, "base64").toString("utf-8")) as Record; - if (typeof decoded.createdAt === "string" && typeof decoded.uri === "string") { - return { createdAt: decoded.createdAt, uri: decoded.uri }; + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as Record< + string, + unknown + > + if (typeof decoded.createdAt === 'string' && typeof decoded.uri === 'string') { + return { createdAt: decoded.createdAt, uri: decoded.uri } } - return null; + return null } catch { - return null; + return null } } @@ -148,12 +151,12 @@ function decodeCursor(cursor: string): { createdAt: string; uri: string } | null * Format: at://did:plc:xxx/collection/rkey */ function extractRkey(uri: string): string { - const parts = uri.split("/"); - const rkey = parts[parts.length - 1]; + const parts = uri.split('/') + const rkey = parts[parts.length - 1] if (!rkey) { - throw badRequest("Invalid AT URI: missing rkey"); + throw badRequest('Invalid AT URI: missing rkey') } - return rkey; + return rkey } // --------------------------------------------------------------------------- @@ -170,675 +173,713 @@ function extractRkey(uri: string): string { */ export function replyRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, env, authMiddleware, firehose } = app; - const pdsClient = createPdsClient(app.oauthClient, app.log); - const notificationService = createNotificationService(db, app.log); + const { db, env, authMiddleware, firehose } = app + const pdsClient = createPdsClient(app.oauthClient, app.log) + const notificationService = createNotificationService(db, app.log) // ------------------------------------------------------------------- // POST /api/topics/:topicUri/replies (auth required) // ------------------------------------------------------------------- - app.post("/api/topics/:topicUri/replies", { - preHandler: [authMiddleware.requireAuth], - schema: { - tags: ["Replies"], - summary: "Create a reply to a topic", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["topicUri"], - properties: { - topicUri: { type: "string" }, + app.post( + '/api/topics/:topicUri/replies', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Replies'], + summary: 'Create a reply to a topic', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['topicUri'], + properties: { + topicUri: { type: 'string' }, + }, }, - }, - body: { - type: "object", - required: ["content"], - properties: { - content: { type: "string", minLength: 1, maxLength: 50000 }, - parentUri: { type: "string", minLength: 1 }, - labels: { - type: "object", - properties: { - values: { - type: "array", - items: { - type: "object", - required: ["val"], - properties: { val: { type: "string" } }, + body: { + type: 'object', + required: ['content'], + properties: { + content: { type: 'string', minLength: 1, maxLength: 50000 }, + parentUri: { type: 'string', minLength: 1 }, + labels: { + type: 'object', + properties: { + values: { + type: 'array', + items: { + type: 'object', + required: ['val'], + properties: { val: { type: 'string' } }, + }, }, }, }, }, }, - }, - response: { - 201: { - type: "object", - properties: { - uri: { type: "string" }, - cid: { type: "string" }, - rkey: { type: "string" }, - content: { type: "string" }, - moderationStatus: { type: "string", enum: ["approved", "held", "rejected"] }, - createdAt: { type: "string", format: "date-time" }, + response: { + 201: { + type: 'object', + properties: { + uri: { type: 'string' }, + cid: { type: 'string' }, + rkey: { type: 'string' }, + content: { type: 'string' }, + moderationStatus: { type: 'string', enum: ['approved', 'held', 'rejected'] }, + createdAt: { type: 'string', format: 'date-time' }, + }, }, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + 502: errorJsonSchema, }, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 502: errorJsonSchema, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const parsed = createReplySchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid reply data"); - } + const parsed = createReplySchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid reply data') + } - const { topicUri } = request.params as { topicUri: string }; - const decodedTopicUri = decodeURIComponent(topicUri); - const { content, parentUri, labels } = parsed.data; + const { topicUri } = request.params as { topicUri: string } + const decodedTopicUri = decodeURIComponent(topicUri) + const { content, parentUri, labels } = parsed.data - // Look up the parent topic - const topicRows = await db - .select() - .from(topics) - .where(eq(topics.uri, decodedTopicUri)); + // Look up the parent topic + const topicRows = await db.select().from(topics).where(eq(topics.uri, decodedTopicUri)) - const topic = topicRows[0]; - if (!topic) { - throw notFound("Topic not found"); - } - - // Onboarding gate: block if user hasn't completed mandatory onboarding - const onboarding = await checkOnboardingComplete(db, user.did, topic.communityDid); - if (!onboarding.complete) { - return reply.status(403).send({ - error: "Onboarding required", - fields: onboarding.missingFields, - }); - } + const topic = topicRows[0] + if (!topic) { + throw notFound('Topic not found') + } - // Ozone label check: spam-labeled accounts get stricter rate limits - let ozoneSpamLabeled = false; - if (app.ozoneService) { - ozoneSpamLabeled = await app.ozoneService.isSpamLabeled(user.did); - } + // Onboarding gate: block if user hasn't completed mandatory onboarding + const onboarding = await checkOnboardingComplete(db, user.did, topic.communityDid) + if (!onboarding.complete) { + return reply.status(403).send({ + error: 'Onboarding required', + fields: onboarding.missingFields, + }) + } - // Anti-spam checks - const antiSpamSettings = await loadAntiSpamSettings(db, app.cache, topic.communityDid); - const trusted = !ozoneSpamLabeled && await isAccountTrusted(db, user.did, topic.communityDid, antiSpamSettings.trustedPostThreshold); + // Ozone label check: spam-labeled accounts get stricter rate limits + let ozoneSpamLabeled = false + if (app.ozoneService) { + ozoneSpamLabeled = await app.ozoneService.isSpamLabeled(user.did) + } - if (!trusted) { - // Ozone spam-labeled accounts are always treated as new (stricter rate limits) - const isNew = ozoneSpamLabeled || await isNewAccount(db, user.did, topic.communityDid, antiSpamSettings.newAccountDays); + // Anti-spam checks + const antiSpamSettings = await loadAntiSpamSettings(db, app.cache, topic.communityDid) + const trusted = + !ozoneSpamLabeled && + (await isAccountTrusted( + db, + user.did, + topic.communityDid, + antiSpamSettings.trustedPostThreshold + )) + + if (!trusted) { + // Ozone spam-labeled accounts are always treated as new (stricter rate limits) + const isNew = + ozoneSpamLabeled || + (await isNewAccount(db, user.did, topic.communityDid, antiSpamSettings.newAccountDays)) + + // Write rate limit + const rateLimited = await checkWriteRateLimit( + app.cache, + user.did, + topic.communityDid, + isNew, + antiSpamSettings + ) + if (rateLimited) { + throw tooManyRequests('Write rate limit exceeded. Please try again later.') + } + } - // Write rate limit - const rateLimited = await checkWriteRateLimit(app.cache, user.did, topic.communityDid, isNew, antiSpamSettings); - if (rateLimited) { - throw tooManyRequests("Write rate limit exceeded. Please try again later."); + // Content-level anti-spam checks (word filter, first-post queue, link hold, burst) + const spamResult = await runAntiSpamChecks(db, app.cache, { + authorDid: user.did, + communityDid: topic.communityDid, + contentType: 'reply', + content, + }) + + // Resolve parent reference + let parentRefUri = topic.uri + let parentRefCid = topic.cid + + if (parentUri) { + // Look up the parent reply + const parentReplyRows = await db.select().from(replies).where(eq(replies.uri, parentUri)) + + const parentReply = parentReplyRows[0] + if (!parentReply) { + throw badRequest('Parent reply not found') + } + parentRefUri = parentReply.uri + parentRefCid = parentReply.cid } - } - // Content-level anti-spam checks (word filter, first-post queue, link hold, burst) - const spamResult = await runAntiSpamChecks(db, app.cache, { - authorDid: user.did, - communityDid: topic.communityDid, - contentType: "reply", - content, - }); - - // Resolve parent reference - let parentRefUri = topic.uri; - let parentRefCid = topic.cid; - - if (parentUri) { - // Look up the parent reply - const parentReplyRows = await db - .select() - .from(replies) - .where(eq(replies.uri, parentUri)); + const now = new Date().toISOString() - const parentReply = parentReplyRows[0]; - if (!parentReply) { - throw badRequest("Parent reply not found"); + // Build AT Protocol record + const record: Record = { + content, + community: topic.communityDid, + root: { uri: topic.uri, cid: topic.cid }, + parent: { uri: parentRefUri, cid: parentRefCid }, + createdAt: now, + ...(labels ? { labels } : {}), } - parentRefUri = parentReply.uri; - parentRefCid = parentReply.cid; - } - const now = new Date().toISOString(); - - // Build AT Protocol record - const record: Record = { - content, - community: topic.communityDid, - root: { uri: topic.uri, cid: topic.cid }, - parent: { uri: parentRefUri, cid: parentRefCid }, - createdAt: now, - ...(labels ? { labels } : {}), - }; - - try { - // Write record to user's PDS - const result = await pdsClient.createRecord(user.did, COLLECTION, record); - const rkey = extractRkey(result.uri); - - // Track repo if this is user's first post - const repoManager = firehose.getRepoManager(); - const alreadyTracked = await repoManager.isTracked(user.did); - if (!alreadyTracked) { - await repoManager.trackRepo(user.did); - } + try { + // Write record to user's PDS + const result = await pdsClient.createRecord(user.did, COLLECTION, record) + const rkey = extractRkey(result.uri) - // Insert into local DB optimistically - const contentModerationStatus = spamResult.held ? "held" : "approved"; - await db - .insert(replies) - .values({ - uri: result.uri, - rkey, - authorDid: user.did, - content, - rootUri: topic.uri, - rootCid: topic.cid, - parentUri: parentRefUri, - parentCid: parentRefCid, - communityDid: topic.communityDid, - cid: result.cid, - labels: labels ?? null, - reactionCount: 0, - moderationStatus: contentModerationStatus, - createdAt: new Date(now), - indexedAt: new Date(), - }) - .onConflictDoUpdate({ - target: replies.uri, - set: { + // Track repo if this is user's first post + const repoManager = firehose.getRepoManager() + const alreadyTracked = await repoManager.isTracked(user.did) + if (!alreadyTracked) { + await repoManager.trackRepo(user.did) + } + + // Insert into local DB optimistically + const contentModerationStatus = spamResult.held ? 'held' : 'approved' + await db + .insert(replies) + .values({ + uri: result.uri, + rkey, + authorDid: user.did, content, - labels: labels ?? null, + rootUri: topic.uri, + rootCid: topic.cid, + parentUri: parentRefUri, + parentCid: parentRefCid, + communityDid: topic.communityDid, cid: result.cid, + labels: labels ?? null, + reactionCount: 0, moderationStatus: contentModerationStatus, + createdAt: new Date(now), indexedAt: new Date(), - }, - }); - - // Insert moderation queue entries if held - if (spamResult.held) { - const queueEntries = spamResult.reasons.map((r) => ({ - contentUri: result.uri, - contentType: "reply" as const, - authorDid: user.did, - communityDid: topic.communityDid, - queueReason: r.reason, - matchedWords: r.matchedWords ?? null, - })); - await db.insert(moderationQueue).values(queueEntries); - - app.log.info( - { - replyUri: result.uri, - reasons: spamResult.reasons.map((r) => r.reason), - authorDid: user.did, - }, - "Reply held for moderation", - ); - } - - // Update parent topic: increment replyCount, set lastActivityAt - // Only count approved replies in the visible reply count - if (!spamResult.held) { - await db - .update(topics) - .set({ - replyCount: sql`${topics.replyCount} + 1`, - lastActivityAt: new Date(), }) - .where(eq(topics.uri, decodedTopicUri)); - } + .onConflictDoUpdate({ + target: replies.uri, + set: { + content, + labels: labels ?? null, + cid: result.cid, + moderationStatus: contentModerationStatus, + indexedAt: new Date(), + }, + }) - // Fire-and-forget: generate notifications for reply + mentions - if (!spamResult.held) { - notificationService.notifyOnReply({ - replyUri: result.uri, - actorDid: user.did, - topicUri: decodedTopicUri, - parentUri: parentRefUri, - communityDid: topic.communityDid, - }).catch((err: unknown) => { - app.log.error({ err, replyUri: result.uri }, "Reply notification failed"); - }); - - notificationService.notifyOnMentions({ + // Insert moderation queue entries if held + if (spamResult.held) { + const queueEntries = spamResult.reasons.map((r) => ({ + contentUri: result.uri, + contentType: 'reply' as const, + authorDid: user.did, + communityDid: topic.communityDid, + queueReason: r.reason, + matchedWords: r.matchedWords ?? null, + })) + await db.insert(moderationQueue).values(queueEntries) + + app.log.info( + { + replyUri: result.uri, + reasons: spamResult.reasons.map((r) => r.reason), + authorDid: user.did, + }, + 'Reply held for moderation' + ) + } + + // Update parent topic: increment replyCount, set lastActivityAt + // Only count approved replies in the visible reply count + if (!spamResult.held) { + await db + .update(topics) + .set({ + replyCount: sql`${topics.replyCount} + 1`, + lastActivityAt: new Date(), + }) + .where(eq(topics.uri, decodedTopicUri)) + } + + // Fire-and-forget: generate notifications for reply + mentions + if (!spamResult.held) { + notificationService + .notifyOnReply({ + replyUri: result.uri, + actorDid: user.did, + topicUri: decodedTopicUri, + parentUri: parentRefUri, + communityDid: topic.communityDid, + }) + .catch((err: unknown) => { + app.log.error({ err, replyUri: result.uri }, 'Reply notification failed') + }) + + notificationService + .notifyOnMentions({ + content, + subjectUri: result.uri, + actorDid: user.did, + communityDid: topic.communityDid, + }) + .catch((err: unknown) => { + app.log.error({ err, replyUri: result.uri }, 'Mention notification failed') + }) + + // Fire-and-forget: record interaction graph edges + app.interactionGraphService + .recordReply(user.did, topic.authorDid, topic.communityDid) + .catch((err: unknown) => { + app.log.warn({ err, replyUri: result.uri }, 'Interaction graph recordReply failed') + }) + + app.interactionGraphService + .recordCoParticipation(decodedTopicUri, topic.communityDid) + .catch((err: unknown) => { + app.log.warn( + { err, topicUri: decodedTopicUri }, + 'Interaction graph recordCoParticipation failed' + ) + }) + } + + return await reply.status(201).send({ + uri: result.uri, + cid: result.cid, + rkey, content, - subjectUri: result.uri, - actorDid: user.did, - communityDid: topic.communityDid, - }).catch((err: unknown) => { - app.log.error({ err, replyUri: result.uri }, "Mention notification failed"); - }); - } - - return await reply.status(201).send({ - uri: result.uri, - cid: result.cid, - rkey, - content, - moderationStatus: contentModerationStatus, - createdAt: now, - }); - } catch (err: unknown) { - if (err instanceof Error && "statusCode" in err) { - throw err; // Re-throw ApiError instances + moderationStatus: contentModerationStatus, + createdAt: now, + }) + } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) { + throw err // Re-throw ApiError instances + } + app.log.error({ err, did: user.did }, 'Failed to create reply') + return reply.status(502).send({ error: 'Failed to create reply' }) } - app.log.error({ err, did: user.did }, "Failed to create reply"); - return reply.status(502).send({ error: "Failed to create reply" }); } - }); + ) // ------------------------------------------------------------------- // GET /api/topics/:topicUri/replies (public, optionalAuth) // ------------------------------------------------------------------- - app.get("/api/topics/:topicUri/replies", { - config: { rateLimit: { max: env.RATE_LIMIT_READ_ANON, timeWindow: "1 minute" } }, - preHandler: [authMiddleware.optionalAuth], - schema: { - tags: ["Replies"], - summary: "List replies for a topic with pagination", - params: { - type: "object", - required: ["topicUri"], - properties: { - topicUri: { type: "string" }, - }, - }, - querystring: { - type: "object", - properties: { - cursor: { type: "string" }, - limit: { type: "string" }, + app.get( + '/api/topics/:topicUri/replies', + { + config: { rateLimit: { max: env.RATE_LIMIT_READ_ANON, timeWindow: '1 minute' } }, + preHandler: [authMiddleware.optionalAuth], + schema: { + tags: ['Replies'], + summary: 'List replies for a topic with pagination', + params: { + type: 'object', + required: ['topicUri'], + properties: { + topicUri: { type: 'string' }, + }, }, - }, - response: { - 200: { - type: "object", + querystring: { + type: 'object', properties: { - replies: { type: "array", items: replyJsonSchema }, - cursor: { type: ["string", "null"] }, + cursor: { type: 'string' }, + limit: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + replies: { type: 'array', items: replyJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, }, + 400: errorJsonSchema, + 404: errorJsonSchema, }, - 400: errorJsonSchema, - 404: errorJsonSchema, }, }, - }, async (request, reply) => { - const { topicUri } = request.params as { topicUri: string }; - const decodedTopicUri = decodeURIComponent(topicUri); + async (request, reply) => { + const { topicUri } = request.params as { topicUri: string } + const decodedTopicUri = decodeURIComponent(topicUri) - const parsedQuery = replyQuerySchema.safeParse(request.query); - if (!parsedQuery.success) { - throw badRequest("Invalid query parameters"); - } + const parsedQuery = replyQuerySchema.safeParse(request.query) + if (!parsedQuery.success) { + throw badRequest('Invalid query parameters') + } - // Check that the topic exists - const topicRows = await db - .select() - .from(topics) - .where(eq(topics.uri, decodedTopicUri)); + // Check that the topic exists + const topicRows = await db.select().from(topics).where(eq(topics.uri, decodedTopicUri)) - const topic = topicRows[0]; - if (!topic) { - throw notFound("Topic not found"); - } + const topic = topicRows[0] + if (!topic) { + throw notFound('Topic not found') + } - // Maturity check: verify the topic's category is within the user's allowed level - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - const catRows = await db - .select({ maturityRating: categories.maturityRating }) - .from(categories) - .where( - and( - eq(categories.slug, topic.category), - eq(categories.communityDid, communityDid), - ), - ); - - if (catRows.length === 0) { - app.log.warn({ category: topic.category, communityDid }, "Category not found for maturity check, defaulting to safe"); - } - const categoryRating = catRows[0]?.maturityRating ?? "safe"; - - let userProfile: MaturityUser | undefined; - if (request.user) { - const userRows = await db - .select({ declaredAge: users.declaredAge, maturityPref: users.maturityPref }) - .from(users) - .where(eq(users.did, request.user.did)); - const row = userRows[0]; - if (row) { - userProfile = row; + // Maturity check: verify the topic's category is within the user's allowed level + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' + const catRows = await db + .select({ maturityRating: categories.maturityRating }) + .from(categories) + .where( + and(eq(categories.slug, topic.category), eq(categories.communityDid, communityDid)) + ) + + if (catRows.length === 0) { + app.log.warn( + { category: topic.category, communityDid }, + 'Category not found for maturity check, defaulting to safe' + ) + } + const categoryRating = catRows[0]?.maturityRating ?? 'safe' + + let userProfile: MaturityUser | undefined + if (request.user) { + const userRows = await db + .select({ declaredAge: users.declaredAge, maturityPref: users.maturityPref }) + .from(users) + .where(eq(users.did, request.user.did)) + const row = userRows[0] + if (row) { + userProfile = row + } } - } - // Fetch community age threshold - const replySettingsRows = await db - .select({ ageThreshold: communitySettings.ageThreshold }) - .from(communitySettings) - .where(eq(communitySettings.id, "default")); - const replyAgeThreshold = replySettingsRows[0]?.ageThreshold ?? 16; + // Fetch community age threshold + const replySettingsRows = await db + .select({ ageThreshold: communitySettings.ageThreshold }) + .from(communitySettings) + .where(eq(communitySettings.id, 'default')) + const replyAgeThreshold = replySettingsRows[0]?.ageThreshold ?? 16 - const maxMaturity = resolveMaxMaturity(userProfile, replyAgeThreshold); - if (!maturityAllows(maxMaturity, categoryRating)) { - throw forbidden("Content restricted by maturity settings"); - } + const maxMaturity = resolveMaxMaturity(userProfile, replyAgeThreshold) + if (!maturityAllows(maxMaturity, categoryRating)) { + throw forbidden('Content restricted by maturity settings') + } - // Block/mute filtering: load the authenticated user's preferences - const { blockedDids, mutedDids } = await loadBlockMuteLists(request.user?.did, db); + // Block/mute filtering: load the authenticated user's preferences + const { blockedDids, mutedDids } = await loadBlockMuteLists(request.user?.did, db) - const { cursor, limit } = parsedQuery.data; - const conditions = [ - eq(replies.rootUri, decodedTopicUri), - eq(replies.moderationStatus, "approved"), - ]; + const { cursor, limit } = parsedQuery.data + const conditions = [ + eq(replies.rootUri, decodedTopicUri), + eq(replies.moderationStatus, 'approved'), + ] - // Exclude replies by blocked authors - if (blockedDids.length > 0) { - conditions.push(notInArray(replies.authorDid, blockedDids)); - } + // Exclude replies by blocked authors + if (blockedDids.length > 0) { + conditions.push(notInArray(replies.authorDid, blockedDids)) + } - // Cursor-based pagination (ASC order for conversation flow) - if (cursor) { - const decoded = decodeCursor(cursor); - if (decoded) { - conditions.push( - sql`(${replies.createdAt}, ${replies.uri}) > (${decoded.createdAt}::timestamptz, ${decoded.uri})`, - ); + // Cursor-based pagination (ASC order for conversation flow) + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${replies.createdAt}, ${replies.uri}) > (${decoded.createdAt}::timestamptz, ${decoded.uri})` + ) + } } - } - const whereClause = and(...conditions); - - // Fetch limit + 1 to detect if there are more pages - const fetchLimit = limit + 1; - const rows = await db - .select() - .from(replies) - .where(whereClause) - .orderBy(asc(replies.createdAt)) - .limit(fetchLimit); - - const hasMore = rows.length > limit; - const resultRows = hasMore ? rows.slice(0, limit) : rows; - const serialized = resultRows.map(serializeReply); - - // Ozone label annotation: flag content from spam-labeled accounts - const ozoneMap = new Map(); - if (app.ozoneService) { - const uniqueDids = [...new Set(serialized.map((r) => r.authorDid))]; - for (const did of uniqueDids) { - const isSpam = await app.ozoneService.isSpamLabeled(did); - ozoneMap.set(did, isSpam ? "spam" : null); + const whereClause = and(...conditions) + + // Fetch limit + 1 to detect if there are more pages + const fetchLimit = limit + 1 + const rows = await db + .select() + .from(replies) + .where(whereClause) + .orderBy(asc(replies.createdAt)) + .limit(fetchLimit) + + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + const serialized = resultRows.map(serializeReply) + + // Ozone label annotation: flag content from spam-labeled accounts + const ozoneMap = new Map() + if (app.ozoneService) { + const uniqueDids = [...new Set(serialized.map((r) => r.authorDid))] + for (const did of uniqueDids) { + const isSpam = await app.ozoneService.isSpamLabeled(did) + ozoneMap.set(did, isSpam ? 'spam' : null) + } } - } - // Batch-resolve author profiles - const authorMap = await resolveAuthors( - serialized.map((r) => r.authorDid), - topic.communityDid, - db, - ); - - // Load muted words for content filtering - const mutedWords = await loadMutedWords(request.user?.did, topic.communityDid, db); - - // Annotate muted authors and muted word matches (content still returned, just flagged) - const mutedSet = new Set(mutedDids); - const annotatedReplies = serialized.map((r) => ({ - ...r, - author: authorMap.get(r.authorDid) ?? { did: r.authorDid, handle: r.authorDid, displayName: null, avatarUrl: null }, - isMuted: mutedSet.has(r.authorDid), - isMutedWord: contentMatchesMutedWords(r.content, mutedWords), - ozoneLabel: ozoneMap.get(r.authorDid) ?? null, - })); - - let nextCursor: string | null = null; - if (hasMore) { - const lastRow = resultRows[resultRows.length - 1]; - if (lastRow) { - nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.uri); + // Batch-resolve author profiles + const authorMap = await resolveAuthors( + serialized.map((r) => r.authorDid), + topic.communityDid, + db + ) + + // Load muted words for content filtering + const mutedWords = await loadMutedWords(request.user?.did, topic.communityDid, db) + + // Annotate muted authors and muted word matches (content still returned, just flagged) + const mutedSet = new Set(mutedDids) + const annotatedReplies = serialized.map((r) => ({ + ...r, + author: authorMap.get(r.authorDid) ?? { + did: r.authorDid, + handle: r.authorDid, + displayName: null, + avatarUrl: null, + }, + isMuted: mutedSet.has(r.authorDid), + isMutedWord: contentMatchesMutedWords(r.content, mutedWords), + ozoneLabel: ozoneMap.get(r.authorDid) ?? null, + })) + + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.uri) + } } - } - return reply.status(200).send({ - replies: annotatedReplies, - cursor: nextCursor, - }); - }); + return reply.status(200).send({ + replies: annotatedReplies, + cursor: nextCursor, + }) + } + ) // ------------------------------------------------------------------- // PUT /api/replies/:uri (auth required, author only) // ------------------------------------------------------------------- - app.put("/api/replies/:uri", { - preHandler: [authMiddleware.requireAuth], - schema: { - tags: ["Replies"], - summary: "Update a reply (author only)", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["uri"], - properties: { - uri: { type: "string" }, + app.put( + '/api/replies/:uri', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Replies'], + summary: 'Update a reply (author only)', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['uri'], + properties: { + uri: { type: 'string' }, + }, }, - }, - body: { - type: "object", - required: ["content"], - properties: { - content: { type: "string", minLength: 1, maxLength: 50000 }, - labels: { - type: "object", - properties: { - values: { - type: "array", - items: { - type: "object", - required: ["val"], - properties: { val: { type: "string" } }, + body: { + type: 'object', + required: ['content'], + properties: { + content: { type: 'string', minLength: 1, maxLength: 50000 }, + labels: { + type: 'object', + properties: { + values: { + type: 'array', + items: { + type: 'object', + required: ['val'], + properties: { val: { type: 'string' } }, + }, }, }, }, }, }, - }, - response: { - 200: replyJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 502: errorJsonSchema, + response: { + 200: replyJsonSchema, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + 502: errorJsonSchema, + }, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } - - const parsed = updateReplySchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid update data"); - } - - const { uri } = request.params as { uri: string }; - const decodedUri = decodeURIComponent(uri); + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - // Fetch existing reply - const existing = await db - .select() - .from(replies) - .where(eq(replies.uri, decodedUri)); + const parsed = updateReplySchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid update data') + } - const replyRow = existing[0]; - if (!replyRow) { - throw notFound("Reply not found"); - } + const { uri } = request.params as { uri: string } + const decodedUri = decodeURIComponent(uri) - // Author check - if (replyRow.authorDid !== user.did) { - throw forbidden("Not authorized to edit this reply"); - } + // Fetch existing reply + const existing = await db.select().from(replies).where(eq(replies.uri, decodedUri)) - const { content, labels } = parsed.data; - const rkey = extractRkey(decodedUri); + const replyRow = existing[0] + if (!replyRow) { + throw notFound('Reply not found') + } - // Resolve labels for PDS record: use provided value, or fall back to existing - const resolvedLabels = labels !== undefined ? labels : (replyRow.labels ?? null); + // Author check + if (replyRow.authorDid !== user.did) { + throw forbidden('Not authorized to edit this reply') + } - // Build updated record for PDS - const updatedRecord: Record = { - content, - community: replyRow.communityDid, - root: { uri: replyRow.rootUri, cid: replyRow.rootCid }, - parent: { uri: replyRow.parentUri, cid: replyRow.parentCid }, - createdAt: replyRow.createdAt.toISOString(), - ...(resolvedLabels ? { labels: resolvedLabels } : {}), - }; + const { content, labels } = parsed.data + const rkey = extractRkey(decodedUri) - try { - const result = await pdsClient.updateRecord(user.did, COLLECTION, rkey, updatedRecord); + // Resolve labels for PDS record: use provided value, or fall back to existing + const resolvedLabels = labels !== undefined ? labels : (replyRow.labels ?? null) - // Build DB update set - const dbUpdates: Record = { + // Build updated record for PDS + const updatedRecord: Record = { content, - cid: result.cid, - indexedAt: new Date(), - }; - if (labels !== undefined) dbUpdates.labels = labels; - - const updated = await db - .update(replies) - .set(dbUpdates) - .where(eq(replies.uri, decodedUri)) - .returning(); - - const updatedRow = updated[0]; - if (!updatedRow) { - throw notFound("Reply not found after update"); + community: replyRow.communityDid, + root: { uri: replyRow.rootUri, cid: replyRow.rootCid }, + parent: { uri: replyRow.parentUri, cid: replyRow.parentCid }, + createdAt: replyRow.createdAt.toISOString(), + ...(resolvedLabels ? { labels: resolvedLabels } : {}), } - return await reply.status(200).send(serializeReply(updatedRow)); - } catch (err: unknown) { - if (err instanceof Error && "statusCode" in err) { - throw err; // Re-throw ApiError instances + try { + const result = await pdsClient.updateRecord(user.did, COLLECTION, rkey, updatedRecord) + + // Build DB update set + const dbUpdates: Record = { + content, + cid: result.cid, + indexedAt: new Date(), + } + if (labels !== undefined) dbUpdates.labels = labels + + const updated = await db + .update(replies) + .set(dbUpdates) + .where(eq(replies.uri, decodedUri)) + .returning() + + const updatedRow = updated[0] + if (!updatedRow) { + throw notFound('Reply not found after update') + } + + return await reply.status(200).send(serializeReply(updatedRow)) + } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) { + throw err // Re-throw ApiError instances + } + app.log.error({ err, uri: decodedUri }, 'Failed to update reply') + return await reply.status(502).send({ error: 'Failed to update reply' }) } - app.log.error({ err, uri: decodedUri }, "Failed to update reply"); - return await reply.status(502).send({ error: "Failed to update reply" }); } - }); + ) // ------------------------------------------------------------------- // DELETE /api/replies/:uri (auth required, author or moderator) // ------------------------------------------------------------------- - app.delete("/api/replies/:uri", { - preHandler: [authMiddleware.requireAuth], - schema: { - tags: ["Replies"], - summary: "Delete a reply (author or moderator)", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["uri"], - properties: { - uri: { type: "string" }, + app.delete( + '/api/replies/:uri', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Replies'], + summary: 'Delete a reply (author or moderator)', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['uri'], + properties: { + uri: { type: 'string' }, + }, + }, + response: { + 204: { type: 'null' }, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + 502: errorJsonSchema, }, - }, - response: { - 204: { type: "null" }, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 502: errorJsonSchema, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } - - const { uri } = request.params as { uri: string }; - const decodedUri = decodeURIComponent(uri); + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - // Fetch existing reply - const existing = await db - .select() - .from(replies) - .where(eq(replies.uri, decodedUri)); + const { uri } = request.params as { uri: string } + const decodedUri = decodeURIComponent(uri) - const replyRow = existing[0]; - if (!replyRow) { - throw notFound("Reply not found"); - } + // Fetch existing reply + const existing = await db.select().from(replies).where(eq(replies.uri, decodedUri)) - const isAuthor = replyRow.authorDid === user.did; + const replyRow = existing[0] + if (!replyRow) { + throw notFound('Reply not found') + } - // Check if user is a moderator or admin - let isMod = false; - if (!isAuthor) { - const userRows = await db - .select() - .from(users) - .where(eq(users.did, user.did)); + const isAuthor = replyRow.authorDid === user.did - const userRow = userRows[0]; - isMod = userRow?.role === "moderator" || userRow?.role === "admin"; - } + // Check if user is a moderator or admin + let isMod = false + if (!isAuthor) { + const userRows = await db.select().from(users).where(eq(users.did, user.did)) - if (!isAuthor && !isMod) { - throw forbidden("Not authorized to delete this reply"); - } + const userRow = userRows[0] + isMod = userRow?.role === 'moderator' || userRow?.role === 'admin' + } - try { - // Author: delete from PDS AND DB - // Moderator: delete from DB only (leave record on PDS) - if (isAuthor) { - const rkey = extractRkey(decodedUri); - await pdsClient.deleteRecord(user.did, COLLECTION, rkey); + if (!isAuthor && !isMod) { + throw forbidden('Not authorized to delete this reply') } - // Delete reply and update topic replyCount in a transaction - await db.transaction(async (tx) => { - await tx.delete(replies).where(eq(replies.uri, decodedUri)); - await tx - .update(topics) - .set({ - replyCount: sql`GREATEST(${topics.replyCount} - 1, 0)`, - }) - .where(eq(topics.uri, replyRow.rootUri)); - }); + try { + // Author: delete from PDS AND DB + // Moderator: delete from DB only (leave record on PDS) + if (isAuthor) { + const rkey = extractRkey(decodedUri) + await pdsClient.deleteRecord(user.did, COLLECTION, rkey) + } + + // Delete reply and update topic replyCount in a transaction + await db.transaction(async (tx) => { + await tx.delete(replies).where(eq(replies.uri, decodedUri)) + await tx + .update(topics) + .set({ + replyCount: sql`GREATEST(${topics.replyCount} - 1, 0)`, + }) + .where(eq(topics.uri, replyRow.rootUri)) + }) - return await reply.status(204).send(); - } catch (err: unknown) { - if (err instanceof Error && "statusCode" in err) { - throw err; + return await reply.status(204).send() + } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) { + throw err + } + app.log.error({ err, uri: decodedUri }, 'Failed to delete reply') + return await reply.status(502).send({ error: 'Failed to delete reply' }) } - app.log.error({ err, uri: decodedUri }, "Failed to delete reply"); - return await reply.status(502).send({ error: "Failed to delete reply" }); } - }); + ) - done(); - }; + done() + } } diff --git a/src/routes/search.ts b/src/routes/search.ts index 98f841e..a9ad7fc 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -1,97 +1,97 @@ -import { sql } from "drizzle-orm"; -import type { FastifyPluginCallback } from "fastify"; -import { badRequest } from "../lib/api-errors.js"; -import { loadMutedWords, contentMatchesMutedWords } from "../lib/muted-words.js"; -import { createEmbeddingService } from "../services/embedding.js"; -import { searchQuerySchema } from "../validation/search.js"; -import type { Database } from "../db/index.js"; +import { sql } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { badRequest } from '../lib/api-errors.js' +import { loadMutedWords, contentMatchesMutedWords } from '../lib/muted-words.js' +import { createEmbeddingService } from '../services/embedding.js' +import { searchQuerySchema } from '../validation/search.js' +import type { Database } from '../db/index.js' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- const searchResultJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - type: { type: "string" as const, enum: ["topic", "reply"] }, - uri: { type: "string" as const }, - rkey: { type: "string" as const }, - authorDid: { type: "string" as const }, - title: { type: ["string", "null"] as const }, - content: { type: "string" as const }, - category: { type: ["string", "null"] as const }, - communityDid: { type: "string" as const }, - replyCount: { type: ["integer", "null"] as const }, - reactionCount: { type: "integer" as const }, - createdAt: { type: "string" as const, format: "date-time" as const }, - rank: { type: "number" as const }, - rootUri: { type: ["string", "null"] as const }, - rootTitle: { type: ["string", "null"] as const }, - isMutedWord: { type: "boolean" as const }, + type: { type: 'string' as const, enum: ['topic', 'reply'] }, + uri: { type: 'string' as const }, + rkey: { type: 'string' as const }, + authorDid: { type: 'string' as const }, + title: { type: ['string', 'null'] as const }, + content: { type: 'string' as const }, + category: { type: ['string', 'null'] as const }, + communityDid: { type: 'string' as const }, + replyCount: { type: ['integer', 'null'] as const }, + reactionCount: { type: 'integer' as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + rank: { type: 'number' as const }, + rootUri: { type: ['string', 'null'] as const }, + rootTitle: { type: ['string', 'null'] as const }, + isMutedWord: { type: 'boolean' as const }, }, -}; +} const errorJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, + error: { type: 'string' as const }, }, -}; +} // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface TopicSearchRow { - [key: string]: unknown; - uri: string; - rkey: string; - author_did: string; - title: string; - content: string; - category: string; - community_did: string; - reply_count: number; - reaction_count: number; - created_at: Date; - rank: number; + [key: string]: unknown + uri: string + rkey: string + author_did: string + title: string + content: string + category: string + community_did: string + reply_count: number + reaction_count: number + created_at: Date + rank: number } interface ReplySearchRow { - [key: string]: unknown; - uri: string; - rkey: string; - author_did: string; - content: string; - community_did: string; - reaction_count: number; - created_at: Date; - root_uri: string; - root_title: string | null; - rank: number; + [key: string]: unknown + uri: string + rkey: string + author_did: string + content: string + community_did: string + reaction_count: number + created_at: Date + root_uri: string + root_title: string | null + rank: number } interface CountRow { - [key: string]: unknown; - count: string; + [key: string]: unknown + count: string } interface SearchResultItem { - type: "topic" | "reply"; - uri: string; - rkey: string; - authorDid: string; - title: string | null; - content: string; - category: string | null; - communityDid: string; - replyCount: number | null; - reactionCount: number; - createdAt: string; - rank: number; - rootUri: string | null; - rootTitle: string | null; - isMutedWord?: boolean; + type: 'topic' | 'reply' + uri: string + rkey: string + authorDid: string + title: string | null + content: string + category: string | null + communityDid: string + replyCount: number | null + reactionCount: number + createdAt: string + rank: number + rootUri: string | null + rootTitle: string | null + isMutedWord?: boolean } // --------------------------------------------------------------------------- @@ -104,34 +104,33 @@ interface SearchResultItem { */ function createSnippet(content: string, maxLength = 300): string { if (content.length <= maxLength) { - return content; + return content } - return content.slice(0, maxLength) + "..."; + return content.slice(0, maxLength) + '...' } /** * Encode a search cursor from rank + uri. */ function encodeCursor(rank: number, uri: string): string { - return Buffer.from(JSON.stringify({ rank, uri })).toString("base64"); + return Buffer.from(JSON.stringify({ rank, uri })).toString('base64') } /** * Decode a search cursor. Returns null if invalid. */ -function decodeCursor( - cursor: string, -): { rank: number; uri: string } | null { +function decodeCursor(cursor: string): { rank: number; uri: string } | null { try { - const decoded = JSON.parse( - Buffer.from(cursor, "base64").toString("utf-8"), - ) as Record; - if (typeof decoded.rank === "number" && typeof decoded.uri === "string") { - return { rank: decoded.rank, uri: decoded.uri }; + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as Record< + string, + unknown + > + if (typeof decoded.rank === 'number' && typeof decoded.uri === 'string') { + return { rank: decoded.rank, uri: decoded.uri } } - return null; + return null } catch { - return null; + return null } } @@ -143,35 +142,35 @@ function decodeCursor( function reciprocalRankFusion( fulltextResults: SearchResultItem[], vectorResults: SearchResultItem[], - k = 60, + k = 60 ): SearchResultItem[] { - const scores = new Map(); + const scores = new Map() // Score full-text results by their position (rank = position, 1-indexed) for (let i = 0; i < fulltextResults.length; i++) { - const item = fulltextResults[i]; - if (!item) continue; - const rrfScore = 1.0 / (k + i + 1); - scores.set(item.uri, { score: rrfScore, item }); + const item = fulltextResults[i] + if (!item) continue + const rrfScore = 1.0 / (k + i + 1) + scores.set(item.uri, { score: rrfScore, item }) } // Score vector results by their position for (let i = 0; i < vectorResults.length; i++) { - const item = vectorResults[i]; - if (!item) continue; - const rrfScore = 1.0 / (k + i + 1); - const existing = scores.get(item.uri); + const item = vectorResults[i] + if (!item) continue + const rrfScore = 1.0 / (k + i + 1) + const existing = scores.get(item.uri) if (existing) { - existing.score += rrfScore; + existing.score += rrfScore } else { - scores.set(item.uri, { score: rrfScore, item }); + scores.set(item.uri, { score: rrfScore, item }) } } // Sort by RRF score descending return Array.from(scores.values()) .sort((a, b) => b.score - a.score) - .map((entry) => ({ ...entry.item, rank: entry.score })); + .map((entry) => ({ ...entry.item, rank: entry.score })) } // --------------------------------------------------------------------------- @@ -185,189 +184,125 @@ function reciprocalRankFusion( */ export function searchRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, env, authMiddleware } = app; + const { db, env, authMiddleware } = app const embeddingService = createEmbeddingService( env.EMBEDDING_URL, env.AI_EMBEDDING_DIMENSIONS, - app.log, - ); + app.log + ) // ------------------------------------------------------------------- // GET /api/search (public, optionalAuth) // ------------------------------------------------------------------- - app.get("/api/search", { - preHandler: [authMiddleware.optionalAuth], - schema: { - tags: ["Search"], - summary: "Search topics and replies", - querystring: { - type: "object", - required: ["q"], - properties: { - q: { type: "string", minLength: 1, maxLength: 500 }, - category: { type: "string" }, - author: { type: "string" }, - dateFrom: { type: "string", format: "date-time" }, - dateTo: { type: "string", format: "date-time" }, - type: { - type: "string", - enum: ["topics", "replies", "all"], - default: "all", - }, - limit: { type: "string" }, - cursor: { type: "string" }, - }, - }, - response: { - 200: { - type: "object", + app.get( + '/api/search', + { + preHandler: [authMiddleware.optionalAuth], + schema: { + tags: ['Search'], + summary: 'Search topics and replies', + querystring: { + type: 'object', + required: ['q'], properties: { - results: { - type: "array", - items: searchResultJsonSchema, + q: { type: 'string', minLength: 1, maxLength: 500 }, + category: { type: 'string' }, + author: { type: 'string' }, + dateFrom: { type: 'string', format: 'date-time' }, + dateTo: { type: 'string', format: 'date-time' }, + type: { + type: 'string', + enum: ['topics', 'replies', 'all'], + default: 'all', }, - cursor: { type: ["string", "null"] }, - total: { type: "integer" }, - searchMode: { - type: "string", - enum: ["fulltext", "hybrid"], + limit: { type: 'string' }, + cursor: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + results: { + type: 'array', + items: searchResultJsonSchema, + }, + cursor: { type: ['string', 'null'] }, + total: { type: 'integer' }, + searchMode: { + type: 'string', + enum: ['fulltext', 'hybrid'], + }, }, }, + 400: errorJsonSchema, }, - 400: errorJsonSchema, }, }, - }, async (request, reply) => { - const parsed = searchQuerySchema.safeParse(request.query); - if (!parsed.success) { - throw badRequest("Invalid search parameters"); - } - - const { - q: query, - category, - author, - dateFrom, - dateTo, - type: searchType, - limit, - cursor, - } = parsed.data; - - // Parse cursor for pagination - let cursorRank: number | undefined; - let cursorUri: string | undefined; - if (cursor) { - const decoded = decodeCursor(cursor); - if (decoded) { - cursorRank = decoded.rank; - cursorUri = decoded.uri; + async (request, reply) => { + const parsed = searchQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid search parameters') } - } - - // Determine search mode - let searchMode: "fulltext" | "hybrid" = "fulltext"; - let queryEmbedding: number[] | null = null; - - if (embeddingService.isEnabled()) { - queryEmbedding = await embeddingService.generateEmbedding(query); - if (queryEmbedding) { - searchMode = "hybrid"; - } - } - - const allResults: SearchResultItem[] = []; - // ----------------------------------------------------------------- - // Full-text search: topics - // ----------------------------------------------------------------- - if (searchType === "topics" || searchType === "all") { - const topicResults = await searchTopicsFulltext(db, query, { + const { + q: query, category, author, dateFrom, dateTo, - cursorRank, - cursorUri, - }, limit + 1); - - for (const row of topicResults) { - allResults.push({ - type: "topic", - uri: row.uri, - rkey: row.rkey, - authorDid: row.author_did, - title: row.title, - content: createSnippet(row.content), - category: row.category, - communityDid: row.community_did, - replyCount: row.reply_count, - reactionCount: row.reaction_count, - createdAt: row.created_at instanceof Date - ? row.created_at.toISOString() - : String(row.created_at), - rank: row.rank, - rootUri: null, - rootTitle: null, - }); - } - } - - // ----------------------------------------------------------------- - // Full-text search: replies - // ----------------------------------------------------------------- - if (searchType === "replies" || searchType === "all") { - const replyResults = await searchRepliesFulltext(db, query, { - author, - dateFrom, - dateTo, - cursorRank, - cursorUri, - }, limit + 1); - - for (const row of replyResults) { - allResults.push({ - type: "reply", - uri: row.uri, - rkey: row.rkey, - authorDid: row.author_did, - title: null, - content: createSnippet(row.content), - category: null, - communityDid: row.community_did, - replyCount: null, - reactionCount: row.reaction_count, - createdAt: row.created_at instanceof Date - ? row.created_at.toISOString() - : String(row.created_at), - rank: row.rank, - rootUri: row.root_uri, - rootTitle: row.root_title, - }); + type: searchType, + limit, + cursor, + } = parsed.data + + // Parse cursor for pagination + let cursorRank: number | undefined + let cursorUri: string | undefined + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + cursorRank = decoded.rank + cursorUri = decoded.uri + } } - } - // ----------------------------------------------------------------- - // Hybrid search: merge with vector results via RRF - // ----------------------------------------------------------------- - let mergedResults: SearchResultItem[]; + // Determine search mode + let searchMode: 'fulltext' | 'hybrid' = 'fulltext' + let queryEmbedding: number[] | null = null - if (searchMode === "hybrid" && queryEmbedding) { - const vectorResults: SearchResultItem[] = []; + if (embeddingService.isEnabled()) { + queryEmbedding = await embeddingService.generateEmbedding(query) + if (queryEmbedding) { + searchMode = 'hybrid' + } + } - if (searchType === "topics" || searchType === "all") { - const vecTopics = await searchTopicsVector(db, queryEmbedding, { - category, - author, - dateFrom, - dateTo, - }, limit); + const allResults: SearchResultItem[] = [] + + // ----------------------------------------------------------------- + // Full-text search: topics + // ----------------------------------------------------------------- + if (searchType === 'topics' || searchType === 'all') { + const topicResults = await searchTopicsFulltext( + db, + query, + { + category, + author, + dateFrom, + dateTo, + cursorRank, + cursorUri, + }, + limit + 1 + ) - for (const row of vecTopics) { - vectorResults.push({ - type: "topic", + for (const row of topicResults) { + allResults.push({ + type: 'topic', uri: row.uri, rkey: row.rkey, authorDid: row.author_did, @@ -377,26 +312,37 @@ export function searchRoutes(): FastifyPluginCallback { communityDid: row.community_did, replyCount: row.reply_count, reactionCount: row.reaction_count, - createdAt: row.created_at instanceof Date - ? row.created_at.toISOString() - : String(row.created_at), + createdAt: + row.created_at instanceof Date + ? row.created_at.toISOString() + : String(row.created_at), rank: row.rank, rootUri: null, rootTitle: null, - }); + }) } } - if (searchType === "replies" || searchType === "all") { - const vecReplies = await searchRepliesVector(db, queryEmbedding, { - author, - dateFrom, - dateTo, - }, limit); + // ----------------------------------------------------------------- + // Full-text search: replies + // ----------------------------------------------------------------- + if (searchType === 'replies' || searchType === 'all') { + const replyResults = await searchRepliesFulltext( + db, + query, + { + author, + dateFrom, + dateTo, + cursorRank, + cursorUri, + }, + limit + 1 + ) - for (const row of vecReplies) { - vectorResults.push({ - type: "reply", + for (const row of replyResults) { + allResults.push({ + type: 'reply', uri: row.uri, rkey: row.rkey, authorDid: row.author_did, @@ -406,75 +352,148 @@ export function searchRoutes(): FastifyPluginCallback { communityDid: row.community_did, replyCount: null, reactionCount: row.reaction_count, - createdAt: row.created_at instanceof Date - ? row.created_at.toISOString() - : String(row.created_at), + createdAt: + row.created_at instanceof Date + ? row.created_at.toISOString() + : String(row.created_at), rank: row.rank, rootUri: row.root_uri, rootTitle: row.root_title, - }); + }) } } - mergedResults = reciprocalRankFusion(allResults, vectorResults); - } else { - // Full-text only: sort all results by rank descending - mergedResults = allResults.sort((a, b) => b.rank - a.rank); - } + // ----------------------------------------------------------------- + // Hybrid search: merge with vector results via RRF + // ----------------------------------------------------------------- + let mergedResults: SearchResultItem[] + + if (searchMode === 'hybrid' && queryEmbedding) { + const vectorResults: SearchResultItem[] = [] + + if (searchType === 'topics' || searchType === 'all') { + const vecTopics = await searchTopicsVector( + db, + queryEmbedding, + { + category, + author, + dateFrom, + dateTo, + }, + limit + ) + + for (const row of vecTopics) { + vectorResults.push({ + type: 'topic', + uri: row.uri, + rkey: row.rkey, + authorDid: row.author_did, + title: row.title, + content: createSnippet(row.content), + category: row.category, + communityDid: row.community_did, + replyCount: row.reply_count, + reactionCount: row.reaction_count, + createdAt: + row.created_at instanceof Date + ? row.created_at.toISOString() + : String(row.created_at), + rank: row.rank, + rootUri: null, + rootTitle: null, + }) + } + } + + if (searchType === 'replies' || searchType === 'all') { + const vecReplies = await searchRepliesVector( + db, + queryEmbedding, + { + author, + dateFrom, + dateTo, + }, + limit + ) + + for (const row of vecReplies) { + vectorResults.push({ + type: 'reply', + uri: row.uri, + rkey: row.rkey, + authorDid: row.author_did, + title: null, + content: createSnippet(row.content), + category: null, + communityDid: row.community_did, + replyCount: null, + reactionCount: row.reaction_count, + createdAt: + row.created_at instanceof Date + ? row.created_at.toISOString() + : String(row.created_at), + rank: row.rank, + rootUri: row.root_uri, + rootTitle: row.root_title, + }) + } + } - // ----------------------------------------------------------------- - // Pagination - // ----------------------------------------------------------------- - const hasMore = mergedResults.length > limit; - const pageResults = hasMore - ? mergedResults.slice(0, limit) - : mergedResults; - - let nextCursor: string | null = null; - if (hasMore) { - const lastResult = pageResults[pageResults.length - 1]; - if (lastResult) { - nextCursor = encodeCursor(lastResult.rank, lastResult.uri); + mergedResults = reciprocalRankFusion(allResults, vectorResults) + } else { + // Full-text only: sort all results by rank descending + mergedResults = allResults.sort((a, b) => b.rank - a.rank) } - } - // Count total results (separate query for accurate count) - let total = pageResults.length; - if (hasMore) { - total = await countSearchResults(db, query, { - category, - author, - dateFrom, - dateTo, - searchType, - }); + // ----------------------------------------------------------------- + // Pagination + // ----------------------------------------------------------------- + const hasMore = mergedResults.length > limit + const pageResults = hasMore ? mergedResults.slice(0, limit) : mergedResults + + let nextCursor: string | null = null + if (hasMore) { + const lastResult = pageResults[pageResults.length - 1] + if (lastResult) { + nextCursor = encodeCursor(lastResult.rank, lastResult.uri) + } + } + + // Count total results (separate query for accurate count) + let total = pageResults.length + if (hasMore) { + total = await countSearchResults(db, query, { + category, + author, + dateFrom, + dateTo, + searchType, + }) + } + + // Muted word annotation: flag matching content for client-side collapsing + const communityDid = env.COMMUNITY_MODE === 'single' ? env.COMMUNITY_DID : undefined + const mutedWords = await loadMutedWords(request.user?.did, communityDid, db) + + const annotatedResults = pageResults.map((r) => ({ + ...r, + isMutedWord: contentMatchesMutedWords(r.content, mutedWords, r.title ?? undefined), + })) + + return reply.status(200).send({ + results: annotatedResults, + cursor: nextCursor, + total, + searchMode, + }) } + ) - // Muted word annotation: flag matching content for client-side collapsing - const communityDid = env.COMMUNITY_MODE === "single" - ? env.COMMUNITY_DID - : undefined; - const mutedWords = await loadMutedWords(request.user?.did, communityDid, db); - - const annotatedResults = pageResults.map((r) => ({ - ...r, - isMutedWord: contentMatchesMutedWords( - r.content, - mutedWords, - r.title ?? undefined, - ), - })); - - return reply.status(200).send({ - results: annotatedResults, - cursor: nextCursor, - total, - searchMode, - }); - }); - - done(); - }; + done() + } } // --------------------------------------------------------------------------- @@ -482,12 +501,12 @@ export function searchRoutes(): FastifyPluginCallback { // --------------------------------------------------------------------------- interface SearchFilters { - category?: string | undefined; - author?: string | undefined; - dateFrom?: string | undefined; - dateTo?: string | undefined; - cursorRank?: number | undefined; - cursorUri?: string | undefined; + category?: string | undefined + author?: string | undefined + dateFrom?: string | undefined + dateTo?: string | undefined + cursorRank?: number | undefined + cursorUri?: string | undefined } /** @@ -497,32 +516,32 @@ async function searchTopicsFulltext( db: Database, query: string, filters: SearchFilters, - fetchLimit: number, + fetchLimit: number ): Promise { const conditions: ReturnType[] = [ sql`search_vector @@ websearch_to_tsquery('english', ${query})`, sql`is_mod_deleted = false`, - ]; + ] if (filters.category) { - conditions.push(sql`category = ${filters.category}`); + conditions.push(sql`category = ${filters.category}`) } if (filters.author) { - conditions.push(sql`author_did = ${filters.author}`); + conditions.push(sql`author_did = ${filters.author}`) } if (filters.dateFrom) { - conditions.push(sql`created_at >= ${filters.dateFrom}::timestamptz`); + conditions.push(sql`created_at >= ${filters.dateFrom}::timestamptz`) } if (filters.dateTo) { - conditions.push(sql`created_at <= ${filters.dateTo}::timestamptz`); + conditions.push(sql`created_at <= ${filters.dateTo}::timestamptz`) } if (filters.cursorRank !== undefined && filters.cursorUri) { conditions.push( - sql`(ts_rank_cd(search_vector, websearch_to_tsquery('english', ${query})), uri) < (${filters.cursorRank}, ${filters.cursorUri})`, - ); + sql`(ts_rank_cd(search_vector, websearch_to_tsquery('english', ${query})), uri) < (${filters.cursorRank}, ${filters.cursorUri})` + ) } - const whereClause = sql.join(conditions, sql` AND `); + const whereClause = sql.join(conditions, sql` AND `) const result = await db.execute(sql` SELECT @@ -533,9 +552,9 @@ async function searchTopicsFulltext( WHERE ${whereClause} ORDER BY rank DESC, created_at DESC LIMIT ${fetchLimit} - `); + `) - return result as unknown as TopicSearchRow[]; + return result as unknown as TopicSearchRow[] } /** @@ -545,29 +564,29 @@ async function searchTopicsFulltext( async function searchRepliesFulltext( db: Database, query: string, - filters: Omit, - fetchLimit: number, + filters: Omit, + fetchLimit: number ): Promise { const conditions: ReturnType[] = [ sql`r.search_vector @@ websearch_to_tsquery('english', ${query})`, - ]; + ] if (filters.author) { - conditions.push(sql`r.author_did = ${filters.author}`); + conditions.push(sql`r.author_did = ${filters.author}`) } if (filters.dateFrom) { - conditions.push(sql`r.created_at >= ${filters.dateFrom}::timestamptz`); + conditions.push(sql`r.created_at >= ${filters.dateFrom}::timestamptz`) } if (filters.dateTo) { - conditions.push(sql`r.created_at <= ${filters.dateTo}::timestamptz`); + conditions.push(sql`r.created_at <= ${filters.dateTo}::timestamptz`) } if (filters.cursorRank !== undefined && filters.cursorUri) { conditions.push( - sql`(ts_rank_cd(r.search_vector, websearch_to_tsquery('english', ${query})), r.uri) < (${filters.cursorRank}, ${filters.cursorUri})`, - ); + sql`(ts_rank_cd(r.search_vector, websearch_to_tsquery('english', ${query})), r.uri) < (${filters.cursorRank}, ${filters.cursorUri})` + ) } - const whereClause = sql.join(conditions, sql` AND `); + const whereClause = sql.join(conditions, sql` AND `) const result = await db.execute(sql` SELECT @@ -580,9 +599,9 @@ async function searchRepliesFulltext( WHERE ${whereClause} ORDER BY rank DESC, r.created_at DESC LIMIT ${fetchLimit} - `); + `) - return result as unknown as ReplySearchRow[]; + return result as unknown as ReplySearchRow[] } /** @@ -592,31 +611,31 @@ async function searchRepliesFulltext( async function searchTopicsVector( db: Database, queryEmbedding: number[], - filters: Omit, - fetchLimit: number, + filters: Omit, + fetchLimit: number ): Promise { - const embeddingStr = `[${queryEmbedding.join(",")}]`; + const embeddingStr = `[${queryEmbedding.join(',')}]` const conditions: ReturnType[] = [ sql`embedding IS NOT NULL`, sql`is_mod_deleted = false`, sql`embedding <=> ${embeddingStr}::vector < 0.5`, - ]; + ] if (filters.category) { - conditions.push(sql`category = ${filters.category}`); + conditions.push(sql`category = ${filters.category}`) } if (filters.author) { - conditions.push(sql`author_did = ${filters.author}`); + conditions.push(sql`author_did = ${filters.author}`) } if (filters.dateFrom) { - conditions.push(sql`created_at >= ${filters.dateFrom}::timestamptz`); + conditions.push(sql`created_at >= ${filters.dateFrom}::timestamptz`) } if (filters.dateTo) { - conditions.push(sql`created_at <= ${filters.dateTo}::timestamptz`); + conditions.push(sql`created_at <= ${filters.dateTo}::timestamptz`) } - const whereClause = sql.join(conditions, sql` AND `); + const whereClause = sql.join(conditions, sql` AND `) const result = await db.execute(sql` SELECT @@ -627,9 +646,9 @@ async function searchTopicsVector( WHERE ${whereClause} ORDER BY embedding <=> ${embeddingStr}::vector ASC LIMIT ${fetchLimit} - `); + `) - return result as unknown as TopicSearchRow[]; + return result as unknown as TopicSearchRow[] } /** @@ -639,27 +658,27 @@ async function searchTopicsVector( async function searchRepliesVector( db: Database, queryEmbedding: number[], - filters: Omit, - fetchLimit: number, + filters: Omit, + fetchLimit: number ): Promise { - const embeddingStr = `[${queryEmbedding.join(",")}]`; + const embeddingStr = `[${queryEmbedding.join(',')}]` const conditions: ReturnType[] = [ sql`r.embedding IS NOT NULL`, sql`r.embedding <=> ${embeddingStr}::vector < 0.5`, - ]; + ] if (filters.author) { - conditions.push(sql`r.author_did = ${filters.author}`); + conditions.push(sql`r.author_did = ${filters.author}`) } if (filters.dateFrom) { - conditions.push(sql`r.created_at >= ${filters.dateFrom}::timestamptz`); + conditions.push(sql`r.created_at >= ${filters.dateFrom}::timestamptz`) } if (filters.dateTo) { - conditions.push(sql`r.created_at <= ${filters.dateTo}::timestamptz`); + conditions.push(sql`r.created_at <= ${filters.dateTo}::timestamptz`) } - const whereClause = sql.join(conditions, sql` AND `); + const whereClause = sql.join(conditions, sql` AND `) const result = await db.execute(sql` SELECT @@ -672,9 +691,9 @@ async function searchRepliesVector( WHERE ${whereClause} ORDER BY r.embedding <=> ${embeddingStr}::vector ASC LIMIT ${fetchLimit} - `); + `) - return result as unknown as ReplySearchRow[]; + return result as unknown as ReplySearchRow[] } /** @@ -684,72 +703,72 @@ async function countSearchResults( db: Database, query: string, filters: { - category?: string | undefined; - author?: string | undefined; - dateFrom?: string | undefined; - dateTo?: string | undefined; - searchType: "topics" | "replies" | "all"; - }, + category?: string | undefined + author?: string | undefined + dateFrom?: string | undefined + dateTo?: string | undefined + searchType: 'topics' | 'replies' | 'all' + } ): Promise { - let total = 0; + let total = 0 - if (filters.searchType === "topics" || filters.searchType === "all") { + if (filters.searchType === 'topics' || filters.searchType === 'all') { const conditions: ReturnType[] = [ sql`search_vector @@ websearch_to_tsquery('english', ${query})`, sql`is_mod_deleted = false`, - ]; + ] if (filters.category) { - conditions.push(sql`category = ${filters.category}`); + conditions.push(sql`category = ${filters.category}`) } if (filters.author) { - conditions.push(sql`author_did = ${filters.author}`); + conditions.push(sql`author_did = ${filters.author}`) } if (filters.dateFrom) { - conditions.push(sql`created_at >= ${filters.dateFrom}::timestamptz`); + conditions.push(sql`created_at >= ${filters.dateFrom}::timestamptz`) } if (filters.dateTo) { - conditions.push(sql`created_at <= ${filters.dateTo}::timestamptz`); + conditions.push(sql`created_at <= ${filters.dateTo}::timestamptz`) } - const whereClause = sql.join(conditions, sql` AND `); + const whereClause = sql.join(conditions, sql` AND `) const result = await db.execute(sql` SELECT COUNT(*) AS count FROM topics WHERE ${whereClause} - `); + `) - const rows = result as unknown as CountRow[]; - total += Number(rows[0]?.count ?? 0); + const rows = result as unknown as CountRow[] + total += Number(rows[0]?.count ?? 0) } - if (filters.searchType === "replies" || filters.searchType === "all") { + if (filters.searchType === 'replies' || filters.searchType === 'all') { const conditions: ReturnType[] = [ sql`search_vector @@ websearch_to_tsquery('english', ${query})`, - ]; + ] if (filters.author) { - conditions.push(sql`author_did = ${filters.author}`); + conditions.push(sql`author_did = ${filters.author}`) } if (filters.dateFrom) { - conditions.push(sql`created_at >= ${filters.dateFrom}::timestamptz`); + conditions.push(sql`created_at >= ${filters.dateFrom}::timestamptz`) } if (filters.dateTo) { - conditions.push(sql`created_at <= ${filters.dateTo}::timestamptz`); + conditions.push(sql`created_at <= ${filters.dateTo}::timestamptz`) } - const whereClause = sql.join(conditions, sql` AND `); + const whereClause = sql.join(conditions, sql` AND `) const result = await db.execute(sql` SELECT COUNT(*) AS count FROM replies WHERE ${whereClause} - `); + `) - const rows = result as unknown as CountRow[]; - total += Number(rows[0]?.count ?? 0); + const rows = result as unknown as CountRow[] + total += Number(rows[0]?.count ?? 0) } - return total; + return total } diff --git a/src/routes/setup.ts b/src/routes/setup.ts index ac6243e..8e9c293 100644 --- a/src/routes/setup.ts +++ b/src/routes/setup.ts @@ -1,5 +1,5 @@ -import { z } from "zod/v4"; -import type { FastifyPluginCallback } from "fastify"; +import { z } from 'zod/v4' +import type { FastifyPluginCallback } from 'fastify' // --------------------------------------------------------------------------- // Zod schemas for request validation @@ -9,7 +9,7 @@ const initializeBodySchema = z.object({ communityName: z.string().trim().min(1).max(255).optional(), handle: z.string().trim().min(1).max(253).optional(), serviceEndpoint: z.url().optional(), -}); +}) // --------------------------------------------------------------------------- // Setup routes plugin @@ -26,42 +26,42 @@ const initializeBodySchema = z.object({ */ export function setupRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { setupService, authMiddleware } = app; + const { setupService, authMiddleware } = app // ------------------------------------------------------------------- // GET /api/setup/status (public, no auth required) // ------------------------------------------------------------------- - app.get("/api/setup/status", async (_request, reply) => { + app.get('/api/setup/status', async (_request, reply) => { try { - const status = await setupService.getStatus(); - return await reply.status(200).send(status); + const status = await setupService.getStatus() + return await reply.status(200).send(status) } catch (err: unknown) { - app.log.error({ err }, "Failed to get setup status"); + app.log.error({ err }, 'Failed to get setup status') return await reply.status(502).send({ - error: "Service temporarily unavailable", - }); + error: 'Service temporarily unavailable', + }) } - }); + }) // ------------------------------------------------------------------- // POST /api/setup/initialize (requires auth) // ------------------------------------------------------------------- app.post( - "/api/setup/initialize", + '/api/setup/initialize', { preHandler: [authMiddleware.requireAuth] }, async (request, reply) => { // Validate request body - const parsed = initializeBodySchema.safeParse(request.body); + const parsed = initializeBodySchema.safeParse(request.body) if (!parsed.success) { - return await reply.status(400).send({ error: "Invalid request body" }); + return await reply.status(400).send({ error: 'Invalid request body' }) } // request.user is guaranteed by requireAuth - const user = request.user; + const user = request.user if (!user) { - return await reply.status(401).send({ error: "Authentication required" }); + return await reply.status(401).send({ error: 'Authentication required' }) } try { @@ -70,24 +70,24 @@ export function setupRoutes(): FastifyPluginCallback { communityName: parsed.data.communityName, handle: parsed.data.handle, serviceEndpoint: parsed.data.serviceEndpoint, - }); + }) - if ("alreadyInitialized" in result) { + if ('alreadyInitialized' in result) { return await reply.status(409).send({ - error: "Community already initialized", - }); + error: 'Community already initialized', + }) } - return await reply.status(200).send(result); + return await reply.status(200).send(result) } catch (err: unknown) { - app.log.error({ err }, "Failed to initialize community"); + app.log.error({ err }, 'Failed to initialize community') return await reply.status(502).send({ - error: "Service temporarily unavailable", - }); + error: 'Service temporarily unavailable', + }) } - }, - ); + } + ) - done(); - }; + done() + } } diff --git a/src/routes/topics.ts b/src/routes/topics.ts index 12a8674..96f53ed 100644 --- a/src/routes/topics.ts +++ b/src/routes/topics.ts @@ -1,14 +1,14 @@ -import { eq, and, desc, sql, inArray, notInArray, isNotNull, ne, or } from "drizzle-orm"; -import type { FastifyPluginCallback } from "fastify"; -import { createPdsClient } from "../lib/pds-client.js"; -import { notFound, forbidden, badRequest } from "../lib/api-errors.js"; -import { resolveMaxMaturity, allowedRatings, maturityAllows } from "../lib/content-filter.js"; -import type { MaturityUser } from "../lib/content-filter.js"; -import { createTopicSchema, updateTopicSchema, topicQuerySchema } from "../validation/topics.js"; -import { createCrossPostService } from "../services/cross-post.js"; -import { loadBlockMuteLists } from "../lib/block-mute.js"; -import { loadMutedWords, contentMatchesMutedWords } from "../lib/muted-words.js"; -import { resolveAuthors } from "../lib/resolve-authors.js"; +import { eq, and, desc, sql, inArray, notInArray, isNotNull, ne, or } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { createPdsClient } from '../lib/pds-client.js' +import { notFound, forbidden, badRequest } from '../lib/api-errors.js' +import { resolveMaxMaturity, allowedRatings, maturityAllows } from '../lib/content-filter.js' +import type { MaturityUser } from '../lib/content-filter.js' +import { createTopicSchema, updateTopicSchema, topicQuerySchema } from '../validation/topics.js' +import { createCrossPostService } from '../services/cross-post.js' +import { loadBlockMuteLists } from '../lib/block-mute.js' +import { loadMutedWords, contentMatchesMutedWords } from '../lib/muted-words.js' +import { resolveAuthors } from '../lib/resolve-authors.js' import { runAntiSpamChecks, loadAntiSpamSettings, @@ -16,79 +16,79 @@ import { isAccountTrusted, checkWriteRateLimit, canCreateTopic, -} from "../lib/anti-spam.js"; -import { tooManyRequests } from "../lib/api-errors.js"; -import { moderationQueue } from "../db/schema/moderation-queue.js"; -import { topics } from "../db/schema/topics.js"; -import { replies } from "../db/schema/replies.js"; -import { users } from "../db/schema/users.js"; -import { categories } from "../db/schema/categories.js"; -import { communitySettings } from "../db/schema/community-settings.js"; -import { checkOnboardingComplete } from "../lib/onboarding-gate.js"; -import { createNotificationService } from "../services/notification.js"; +} from '../lib/anti-spam.js' +import { tooManyRequests } from '../lib/api-errors.js' +import { moderationQueue } from '../db/schema/moderation-queue.js' +import { topics } from '../db/schema/topics.js' +import { replies } from '../db/schema/replies.js' +import { users } from '../db/schema/users.js' +import { categories } from '../db/schema/categories.js' +import { communitySettings } from '../db/schema/community-settings.js' +import { checkOnboardingComplete } from '../lib/onboarding-gate.js' +import { createNotificationService } from '../services/notification.js' // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const COLLECTION = "forum.barazo.topic.post"; +const COLLECTION = 'forum.barazo.topic.post' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- const topicJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - uri: { type: "string" as const }, - rkey: { type: "string" as const }, - authorDid: { type: "string" as const }, + uri: { type: 'string' as const }, + rkey: { type: 'string' as const }, + authorDid: { type: 'string' as const }, author: { - type: "object" as const, + type: 'object' as const, properties: { - did: { type: "string" as const }, - handle: { type: "string" as const }, - displayName: { type: ["string", "null"] as const }, - avatarUrl: { type: ["string", "null"] as const }, + did: { type: 'string' as const }, + handle: { type: 'string' as const }, + displayName: { type: ['string', 'null'] as const }, + avatarUrl: { type: ['string', 'null'] as const }, }, }, - title: { type: "string" as const }, - content: { type: "string" as const }, - contentFormat: { type: ["string", "null"] as const }, - category: { type: "string" as const }, - tags: { type: ["array", "null"] as const, items: { type: "string" as const } }, + title: { type: 'string' as const }, + content: { type: 'string' as const }, + contentFormat: { type: ['string', 'null'] as const }, + category: { type: 'string' as const }, + tags: { type: ['array', 'null'] as const, items: { type: 'string' as const } }, labels: { - type: ["object", "null"] as const, + type: ['object', 'null'] as const, properties: { values: { - type: "array" as const, + type: 'array' as const, items: { - type: "object" as const, - properties: { val: { type: "string" as const } }, + type: 'object' as const, + properties: { val: { type: 'string' as const } }, }, }, }, }, - communityDid: { type: "string" as const }, - cid: { type: "string" as const }, - replyCount: { type: "integer" as const }, - reactionCount: { type: "integer" as const }, - isMuted: { type: "boolean" as const }, - isMutedWord: { type: "boolean" as const }, - ozoneLabel: { type: ["string", "null"] as const }, - categoryMaturityRating: { type: "string" as const, enum: ["safe", "mature", "adult"] }, - lastActivityAt: { type: "string" as const, format: "date-time" as const }, - createdAt: { type: "string" as const, format: "date-time" as const }, - indexedAt: { type: "string" as const, format: "date-time" as const }, + communityDid: { type: 'string' as const }, + cid: { type: 'string' as const }, + replyCount: { type: 'integer' as const }, + reactionCount: { type: 'integer' as const }, + isMuted: { type: 'boolean' as const }, + isMutedWord: { type: 'boolean' as const }, + ozoneLabel: { type: ['string', 'null'] as const }, + categoryMaturityRating: { type: 'string' as const, enum: ['safe', 'mature', 'adult'] }, + lastActivityAt: { type: 'string' as const, format: 'date-time' as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + indexedAt: { type: 'string' as const, format: 'date-time' as const }, }, -}; +} const errorJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, + error: { type: 'string' as const }, }, -}; +} // --------------------------------------------------------------------------- // Helpers @@ -99,10 +99,7 @@ const errorJsonSchema = { * Converts Date fields to ISO strings. * @param categoryMaturityRating - The maturity rating inherited from the topic's category. */ -function serializeTopic( - row: typeof topics.$inferSelect, - categoryMaturityRating: string = "safe", -) { +function serializeTopic(row: typeof topics.$inferSelect, categoryMaturityRating: string = 'safe') { return { uri: row.uri, rkey: row.rkey, @@ -121,14 +118,14 @@ function serializeTopic( lastActivityAt: row.lastActivityAt.toISOString(), createdAt: row.createdAt.toISOString(), indexedAt: row.indexedAt.toISOString(), - }; + } } /** * Encode a pagination cursor from lastActivityAt + uri. */ function encodeCursor(lastActivityAt: string, uri: string): string { - return Buffer.from(JSON.stringify({ lastActivityAt, uri })).toString("base64"); + return Buffer.from(JSON.stringify({ lastActivityAt, uri })).toString('base64') } /** @@ -136,13 +133,16 @@ function encodeCursor(lastActivityAt: string, uri: string): string { */ function decodeCursor(cursor: string): { lastActivityAt: string; uri: string } | null { try { - const decoded = JSON.parse(Buffer.from(cursor, "base64").toString("utf-8")) as Record; - if (typeof decoded.lastActivityAt === "string" && typeof decoded.uri === "string") { - return { lastActivityAt: decoded.lastActivityAt, uri: decoded.uri }; + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as Record< + string, + unknown + > + if (typeof decoded.lastActivityAt === 'string' && typeof decoded.uri === 'string') { + return { lastActivityAt: decoded.lastActivityAt, uri: decoded.uri } } - return null; + return null } catch { - return null; + return null } } @@ -151,12 +151,12 @@ function decodeCursor(cursor: string): { lastActivityAt: string; uri: string } | * Format: at://did:plc:xxx/collection/rkey */ function extractRkey(uri: string): string { - const parts = uri.split("/"); - const rkey = parts[parts.length - 1]; + const parts = uri.split('/') + const rkey = parts[parts.length - 1] if (!rkey) { - throw badRequest("Invalid AT URI: missing rkey"); + throw badRequest('Invalid AT URI: missing rkey') } - return rkey; + return rkey } // --------------------------------------------------------------------------- @@ -174,899 +174,930 @@ function extractRkey(uri: string): string { */ export function topicRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, env, authMiddleware, firehose } = app; - const pdsClient = createPdsClient(app.oauthClient, app.log); - const notificationService = createNotificationService(db, app.log); - const crossPostService = createCrossPostService(pdsClient, db, app.log, { - blueskyEnabled: env.FEATURE_CROSSPOST_BLUESKY, - frontpageEnabled: env.FEATURE_CROSSPOST_FRONTPAGE, - publicUrl: env.PUBLIC_URL, - communityName: env.COMMUNITY_NAME, - }, notificationService); + const { db, env, authMiddleware, firehose } = app + const pdsClient = createPdsClient(app.oauthClient, app.log) + const notificationService = createNotificationService(db, app.log) + const crossPostService = createCrossPostService( + pdsClient, + db, + app.log, + { + blueskyEnabled: env.FEATURE_CROSSPOST_BLUESKY, + frontpageEnabled: env.FEATURE_CROSSPOST_FRONTPAGE, + publicUrl: env.PUBLIC_URL, + communityName: env.COMMUNITY_NAME, + }, + notificationService + ) // ------------------------------------------------------------------- // POST /api/topics (auth required) // ------------------------------------------------------------------- - app.post("/api/topics", { - preHandler: [authMiddleware.requireAuth], - schema: { - tags: ["Topics"], - summary: "Create a new topic", - security: [{ bearerAuth: [] }], - body: { - type: "object", - required: ["title", "content", "category"], - properties: { - title: { type: "string", minLength: 1, maxLength: 200 }, - content: { type: "string", minLength: 1, maxLength: 100000 }, - category: { type: "string", minLength: 1 }, - tags: { - type: "array", - items: { type: "string", minLength: 1, maxLength: 30 }, - maxItems: 5, - }, - labels: { - type: "object", - properties: { - values: { - type: "array", - items: { - type: "object", - required: ["val"], - properties: { val: { type: "string" } }, + app.post( + '/api/topics', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Topics'], + summary: 'Create a new topic', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + required: ['title', 'content', 'category'], + properties: { + title: { type: 'string', minLength: 1, maxLength: 200 }, + content: { type: 'string', minLength: 1, maxLength: 100000 }, + category: { type: 'string', minLength: 1 }, + tags: { + type: 'array', + items: { type: 'string', minLength: 1, maxLength: 30 }, + maxItems: 5, + }, + labels: { + type: 'object', + properties: { + values: { + type: 'array', + items: { + type: 'object', + required: ['val'], + properties: { val: { type: 'string' } }, + }, }, }, }, }, }, - }, - response: { - 201: { - type: "object", - properties: { - uri: { type: "string" }, - cid: { type: "string" }, - rkey: { type: "string" }, - title: { type: "string" }, - category: { type: "string" }, - moderationStatus: { type: "string", enum: ["approved", "held", "rejected"] }, - createdAt: { type: "string", format: "date-time" }, + response: { + 201: { + type: 'object', + properties: { + uri: { type: 'string' }, + cid: { type: 'string' }, + rkey: { type: 'string' }, + title: { type: 'string' }, + category: { type: 'string' }, + moderationStatus: { type: 'string', enum: ['approved', 'held', 'rejected'] }, + createdAt: { type: 'string', format: 'date-time' }, + }, }, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 502: errorJsonSchema, }, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 502: errorJsonSchema, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const parsed = createTopicSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid topic data"); - } + const parsed = createTopicSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid topic data') + } - const { title, content, category, tags, labels } = parsed.data; - const now = new Date().toISOString(); - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - - // Onboarding gate: block if user hasn't completed mandatory onboarding - const onboarding = await checkOnboardingComplete(db, user.did, communityDid); - if (!onboarding.complete) { - return reply.status(403).send({ - error: "Onboarding required", - fields: onboarding.missingFields, - }); - } + const { title, content, category, tags, labels } = parsed.data + const now = new Date().toISOString() + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' - // Maturity check: verify user can post in this category - const catRows = await db - .select({ maturityRating: categories.maturityRating }) - .from(categories) - .where( - and( - eq(categories.slug, category), - eq(categories.communityDid, communityDid), - ), - ); - - const categoryRating = catRows[0]?.maturityRating ?? "safe"; - - const userRows = await db - .select({ declaredAge: users.declaredAge, maturityPref: users.maturityPref }) - .from(users) - .where(eq(users.did, user.did)); - const userProfile: MaturityUser | undefined = userRows[0] ?? undefined; - - // Fetch community age threshold - const settingsRows = await db - .select({ ageThreshold: communitySettings.ageThreshold }) - .from(communitySettings) - .where(eq(communitySettings.id, "default")); - const ageThreshold = settingsRows[0]?.ageThreshold ?? 16; - - const maxMaturity = resolveMaxMaturity(userProfile, ageThreshold); - if (!maturityAllows(maxMaturity, categoryRating)) { - throw forbidden("Content restricted by maturity settings"); - } + // Onboarding gate: block if user hasn't completed mandatory onboarding + const onboarding = await checkOnboardingComplete(db, user.did, communityDid) + if (!onboarding.complete) { + return reply.status(403).send({ + error: 'Onboarding required', + fields: onboarding.missingFields, + }) + } - // Ozone label check: spam-labeled accounts get stricter rate limits - let ozoneSpamLabeled = false; - if (app.ozoneService) { - ozoneSpamLabeled = await app.ozoneService.isSpamLabeled(user.did); - } + // Maturity check: verify user can post in this category + const catRows = await db + .select({ maturityRating: categories.maturityRating }) + .from(categories) + .where(and(eq(categories.slug, category), eq(categories.communityDid, communityDid))) - // Anti-spam checks - const antiSpamSettings = await loadAntiSpamSettings(db, app.cache, communityDid); - const trusted = !ozoneSpamLabeled && await isAccountTrusted(db, user.did, communityDid, antiSpamSettings.trustedPostThreshold); + const categoryRating = catRows[0]?.maturityRating ?? 'safe' - if (!trusted) { - // Ozone spam-labeled accounts are always treated as new (stricter rate limits) - const isNew = ozoneSpamLabeled || await isNewAccount(db, user.did, communityDid, antiSpamSettings.newAccountDays); + const userRows = await db + .select({ declaredAge: users.declaredAge, maturityPref: users.maturityPref }) + .from(users) + .where(eq(users.did, user.did)) + const userProfile: MaturityUser | undefined = userRows[0] ?? undefined - // Write rate limit - const rateLimited = await checkWriteRateLimit(app.cache, user.did, communityDid, isNew, antiSpamSettings); - if (rateLimited) { - throw tooManyRequests("Write rate limit exceeded. Please try again later."); + // Fetch community age threshold + const settingsRows = await db + .select({ ageThreshold: communitySettings.ageThreshold }) + .from(communitySettings) + .where(eq(communitySettings.id, 'default')) + const ageThreshold = settingsRows[0]?.ageThreshold ?? 16 + + const maxMaturity = resolveMaxMaturity(userProfile, ageThreshold) + if (!maturityAllows(maxMaturity, categoryRating)) { + throw forbidden('Content restricted by maturity settings') + } + + // Ozone label check: spam-labeled accounts get stricter rate limits + let ozoneSpamLabeled = false + if (app.ozoneService) { + ozoneSpamLabeled = await app.ozoneService.isSpamLabeled(user.did) } - // Topic creation delay: new accounts need at least one approved reply - if (antiSpamSettings.topicCreationDelayEnabled) { - const canPost = await canCreateTopic(db, user.did, communityDid, true); - if (!canPost) { - throw forbidden("New accounts must have at least one approved reply before creating topics"); + // Anti-spam checks + const antiSpamSettings = await loadAntiSpamSettings(db, app.cache, communityDid) + const trusted = + !ozoneSpamLabeled && + (await isAccountTrusted( + db, + user.did, + communityDid, + antiSpamSettings.trustedPostThreshold + )) + + if (!trusted) { + // Ozone spam-labeled accounts are always treated as new (stricter rate limits) + const isNew = + ozoneSpamLabeled || + (await isNewAccount(db, user.did, communityDid, antiSpamSettings.newAccountDays)) + + // Write rate limit + const rateLimited = await checkWriteRateLimit( + app.cache, + user.did, + communityDid, + isNew, + antiSpamSettings + ) + if (rateLimited) { + throw tooManyRequests('Write rate limit exceeded. Please try again later.') + } + + // Topic creation delay: new accounts need at least one approved reply + if (antiSpamSettings.topicCreationDelayEnabled) { + const canPost = await canCreateTopic(db, user.did, communityDid, true) + if (!canPost) { + throw forbidden( + 'New accounts must have at least one approved reply before creating topics' + ) + } } } - } - // Content-level anti-spam checks (word filter, first-post queue, link hold, burst) - const spamResult = await runAntiSpamChecks(db, app.cache, { - authorDid: user.did, - communityDid, - contentType: "topic", - title, - content, - }); - - // Build AT Protocol record - const record: Record = { - title, - content, - category, - tags: tags ?? [], - community: communityDid, - createdAt: now, - ...(labels ? { labels } : {}), - }; - - try { - // Write record to user's PDS - const result = await pdsClient.createRecord(user.did, COLLECTION, record); - const rkey = extractRkey(result.uri); - - // Track repo if this is user's first post - const repoManager = firehose.getRepoManager(); - const alreadyTracked = await repoManager.isTracked(user.did); - if (!alreadyTracked) { - await repoManager.trackRepo(user.did); + // Content-level anti-spam checks (word filter, first-post queue, link hold, burst) + const spamResult = await runAntiSpamChecks(db, app.cache, { + authorDid: user.did, + communityDid, + contentType: 'topic', + title, + content, + }) + + // Build AT Protocol record + const record: Record = { + title, + content, + category, + tags: tags ?? [], + community: communityDid, + createdAt: now, + ...(labels ? { labels } : {}), } - // Insert into local DB optimistically (don't wait for firehose) - const contentModerationStatus = spamResult.held ? "held" : "approved"; - await db - .insert(topics) - .values({ - uri: result.uri, - rkey, - authorDid: user.did, - title, - content, - category, - tags: tags ?? [], - labels: labels ?? null, - communityDid, - cid: result.cid, - replyCount: 0, - reactionCount: 0, - moderationStatus: contentModerationStatus, - lastActivityAt: new Date(now), - createdAt: new Date(now), - indexedAt: new Date(), - }) - .onConflictDoUpdate({ - target: topics.uri, - set: { + try { + // Write record to user's PDS + const result = await pdsClient.createRecord(user.did, COLLECTION, record) + const rkey = extractRkey(result.uri) + + // Track repo if this is user's first post + const repoManager = firehose.getRepoManager() + const alreadyTracked = await repoManager.isTracked(user.did) + if (!alreadyTracked) { + await repoManager.trackRepo(user.did) + } + + // Insert into local DB optimistically (don't wait for firehose) + const contentModerationStatus = spamResult.held ? 'held' : 'approved' + await db + .insert(topics) + .values({ + uri: result.uri, + rkey, + authorDid: user.did, title, content, category, tags: tags ?? [], labels: labels ?? null, + communityDid, cid: result.cid, + replyCount: 0, + reactionCount: 0, moderationStatus: contentModerationStatus, + lastActivityAt: new Date(now), + createdAt: new Date(now), indexedAt: new Date(), - }, - }); - - // Insert moderation queue entries if held - if (spamResult.held) { - const queueEntries = spamResult.reasons.map((r) => ({ - contentUri: result.uri, - contentType: "topic" as const, - authorDid: user.did, - communityDid, - queueReason: r.reason, - matchedWords: r.matchedWords ?? null, - })); - await db.insert(moderationQueue).values(queueEntries); - - app.log.info( - { - topicUri: result.uri, - reasons: spamResult.reasons.map((r) => r.reason), + }) + .onConflictDoUpdate({ + target: topics.uri, + set: { + title, + content, + category, + tags: tags ?? [], + labels: labels ?? null, + cid: result.cid, + moderationStatus: contentModerationStatus, + indexedAt: new Date(), + }, + }) + + // Insert moderation queue entries if held + if (spamResult.held) { + const queueEntries = spamResult.reasons.map((r) => ({ + contentUri: result.uri, + contentType: 'topic' as const, authorDid: user.did, - }, - "Topic held for moderation", - ); - } + communityDid, + queueReason: r.reason, + matchedWords: r.matchedWords ?? null, + })) + await db.insert(moderationQueue).values(queueEntries) + + app.log.info( + { + topicUri: result.uri, + reasons: spamResult.reasons.map((r) => r.reason), + authorDid: user.did, + }, + 'Topic held for moderation' + ) + } + + // Fire cross-posting in background (fire-and-forget, does not block response) + // Only cross-post if content is approved (not held) + if ( + !spamResult.held && + (env.FEATURE_CROSSPOST_BLUESKY || env.FEATURE_CROSSPOST_FRONTPAGE) + ) { + crossPostService + .crossPostTopic({ + did: user.did, + topicUri: result.uri, + title, + content, + category, + communityDid, + }) + .catch((err: unknown) => { + app.log.error({ err, topicUri: result.uri }, 'Cross-posting failed') + }) + } + + // Fire-and-forget: generate mention notifications from topic content + if (!spamResult.held) { + notificationService + .notifyOnMentions({ + content, + subjectUri: result.uri, + actorDid: user.did, + communityDid, + }) + .catch((err: unknown) => { + app.log.error({ err, topicUri: result.uri }, 'Mention notification failed') + }) + } - // Fire cross-posting in background (fire-and-forget, does not block response) - // Only cross-post if content is approved (not held) - if (!spamResult.held && (env.FEATURE_CROSSPOST_BLUESKY || env.FEATURE_CROSSPOST_FRONTPAGE)) { - crossPostService.crossPostTopic({ - did: user.did, - topicUri: result.uri, + return await reply.status(201).send({ + uri: result.uri, + cid: result.cid, + rkey, title, - content, category, - communityDid, - }).catch((err: unknown) => { - app.log.error({ err, topicUri: result.uri }, "Cross-posting failed"); - }); - } - - // Fire-and-forget: generate mention notifications from topic content - if (!spamResult.held) { - notificationService.notifyOnMentions({ - content, - subjectUri: result.uri, - actorDid: user.did, - communityDid, - }).catch((err: unknown) => { - app.log.error({ err, topicUri: result.uri }, "Mention notification failed"); - }); + moderationStatus: contentModerationStatus, + createdAt: now, + }) + } catch (err: unknown) { + app.log.error({ err, did: user.did }, 'Failed to create topic') + return reply.status(502).send({ error: 'Failed to create topic' }) } - - return await reply.status(201).send({ - uri: result.uri, - cid: result.cid, - rkey, - title, - category, - moderationStatus: contentModerationStatus, - createdAt: now, - }); - } catch (err: unknown) { - app.log.error({ err, did: user.did }, "Failed to create topic"); - return reply.status(502).send({ error: "Failed to create topic" }); } - }); + ) // ------------------------------------------------------------------- // GET /api/topics (public, optionalAuth) // ------------------------------------------------------------------- - app.get("/api/topics", { - config: { rateLimit: { max: env.RATE_LIMIT_READ_ANON, timeWindow: "1 minute" } }, - preHandler: [authMiddleware.optionalAuth], - schema: { - tags: ["Topics"], - summary: "List topics with pagination", - querystring: { - type: "object", - properties: { - cursor: { type: "string" }, - limit: { type: "string" }, - category: { type: "string" }, - tag: { type: "string" }, - }, - }, - response: { - 200: { - type: "object", + app.get( + '/api/topics', + { + config: { rateLimit: { max: env.RATE_LIMIT_READ_ANON, timeWindow: '1 minute' } }, + preHandler: [authMiddleware.optionalAuth], + schema: { + tags: ['Topics'], + summary: 'List topics with pagination', + querystring: { + type: 'object', properties: { - topics: { type: "array", items: topicJsonSchema }, - cursor: { type: ["string", "null"] }, + cursor: { type: 'string' }, + limit: { type: 'string' }, + category: { type: 'string' }, + tag: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + topics: { type: 'array', items: topicJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, }, + 400: errorJsonSchema, }, - 400: errorJsonSchema, }, }, - }, async (request, reply) => { - const parsed = topicQuerySchema.safeParse(request.query); - if (!parsed.success) { - throw badRequest("Invalid query parameters"); - } - - const { cursor, limit, category, tag } = parsed.data; - const conditions = []; + async (request, reply) => { + const parsed = topicQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } - // Maturity filtering: resolve user's max allowed maturity level - let userProfile: MaturityUser | undefined; - if (request.user) { - const userRows = await db - .select({ declaredAge: users.declaredAge, maturityPref: users.maturityPref }) - .from(users) - .where(eq(users.did, request.user.did)); - const row = userRows[0]; - if (row) { - userProfile = row; + const { cursor, limit, category, tag } = parsed.data + const conditions = [] + + // Maturity filtering: resolve user's max allowed maturity level + let userProfile: MaturityUser | undefined + if (request.user) { + const userRows = await db + .select({ declaredAge: users.declaredAge, maturityPref: users.maturityPref }) + .from(users) + .where(eq(users.did, request.user.did)) + const row = userRows[0] + if (row) { + userProfile = row + } } - } - // Fetch community age threshold - const settingsRowsList = await db - .select({ ageThreshold: communitySettings.ageThreshold }) - .from(communitySettings) - .where(eq(communitySettings.id, "default")); - const listAgeThreshold = settingsRowsList[0]?.ageThreshold ?? 16; - - const maxMaturity = resolveMaxMaturity(userProfile, listAgeThreshold); - const allowed = allowedRatings(maxMaturity); - - // Slug→maturityRating lookup, populated by the category queries below - const categoryMaturityMap = new Map(); - - if (env.COMMUNITY_MODE === "global") { - // --------------------------------------------------------------- - // Global mode: multi-community filtering - // --------------------------------------------------------------- - - // Get all community settings with a valid communityDid - const communityRows = await db - .select({ - communityDid: communitySettings.communityDid, - maturityRating: communitySettings.maturityRating, - }) + // Fetch community age threshold + const settingsRowsList = await db + .select({ ageThreshold: communitySettings.ageThreshold }) .from(communitySettings) - .where(isNotNull(communitySettings.communityDid)); - - // Filter: NEVER show adult communities in global mode, - // check mature communities against user's max maturity preference - const allowedCommunityDids = communityRows - .filter((c) => { - if (!c.communityDid) return false; - if (c.maturityRating === "adult") return false; - return maturityAllows(maxMaturity, c.maturityRating); - }) - .map((c) => c.communityDid as string); - - if (allowedCommunityDids.length === 0) { - return reply.status(200).send({ topics: [], cursor: null }); - } + .where(eq(communitySettings.id, 'default')) + const listAgeThreshold = settingsRowsList[0]?.ageThreshold ?? 16 + + const maxMaturity = resolveMaxMaturity(userProfile, listAgeThreshold) + const allowed = allowedRatings(maxMaturity) + + // Slug→maturityRating lookup, populated by the category queries below + const categoryMaturityMap = new Map() + + if (env.COMMUNITY_MODE === 'global') { + // --------------------------------------------------------------- + // Global mode: multi-community filtering + // --------------------------------------------------------------- + + // Get all community settings with a valid communityDid + const communityRows = await db + .select({ + communityDid: communitySettings.communityDid, + maturityRating: communitySettings.maturityRating, + }) + .from(communitySettings) + .where(isNotNull(communitySettings.communityDid)) + + // Filter: NEVER show adult communities in global mode, + // check mature communities against user's max maturity preference + const allowedCommunityDids = communityRows + .filter((c) => { + if (!c.communityDid) return false + if (c.maturityRating === 'adult') return false + return maturityAllows(maxMaturity, c.maturityRating) + }) + .map((c) => c.communityDid as string) + + if (allowedCommunityDids.length === 0) { + return reply.status(200).send({ topics: [], cursor: null }) + } - // Restrict topics to allowed communities - conditions.push(inArray(topics.communityDid, allowedCommunityDids)); + // Restrict topics to allowed communities + conditions.push(inArray(topics.communityDid, allowedCommunityDids)) + + // Also filter by category maturity across all allowed communities + const allowedCats = await db + .select({ slug: categories.slug, maturityRating: categories.maturityRating }) + .from(categories) + .where( + and( + inArray(categories.communityDid, allowedCommunityDids), + inArray(categories.maturityRating, allowed) + ) + ) + + const allowedSlugs = [...new Set(allowedCats.map((c) => c.slug))] + // Build slug→maturityRating lookup for serialization + for (const cat of allowedCats) { + categoryMaturityMap.set(cat.slug, cat.maturityRating) + } + if (allowedSlugs.length === 0) { + return reply.status(200).send({ topics: [], cursor: null }) + } + conditions.push(inArray(topics.category, allowedSlugs)) - // Also filter by category maturity across all allowed communities - const allowedCats = await db - .select({ slug: categories.slug, maturityRating: categories.maturityRating }) - .from(categories) - .where( - and( - inArray(categories.communityDid, allowedCommunityDids), - inArray(categories.maturityRating, allowed), - ), - ); - - const allowedSlugs = [...new Set(allowedCats.map((c) => c.slug))]; - // Build slug→maturityRating lookup for serialization - for (const cat of allowedCats) { - categoryMaturityMap.set(cat.slug, cat.maturityRating); - } - if (allowedSlugs.length === 0) { - return reply.status(200).send({ topics: [], cursor: null }); - } - conditions.push(inArray(topics.category, allowedSlugs)); - - // Exclude content from accounts < 24h old in global aggregator feeds. - // Uses a query-time check so trust auto-upgrades after 24h without a cron job: - // exclude WHERE trust_status = 'new' AND author's account_created_at > now() - 24h. - // Content from new accounts remains visible in specific community feeds (single mode). - conditions.push( - or( - ne(topics.trustStatus, "new"), - sql`NOT EXISTS ( + // Exclude content from accounts < 24h old in global aggregator feeds. + // Uses a query-time check so trust auto-upgrades after 24h without a cron job: + // exclude WHERE trust_status = 'new' AND author's account_created_at > now() - 24h. + // Content from new accounts remains visible in specific community feeds (single mode). + conditions.push( + or( + ne(topics.trustStatus, 'new'), + sql`NOT EXISTS ( SELECT 1 FROM users u WHERE u.did = ${topics.authorDid} AND u.account_created_at > NOW() - INTERVAL '24 hours' - )`, - ), - ); - } else { - // --------------------------------------------------------------- - // Single mode: filter by the one configured community - // --------------------------------------------------------------- - - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - - // Get category slugs matching allowed maturity levels - const allowedCategories = await db - .select({ slug: categories.slug, maturityRating: categories.maturityRating }) - .from(categories) - .where( - and( - eq(categories.communityDid, communityDid), - inArray(categories.maturityRating, allowed), - ), - ); - - const allowedSlugs = allowedCategories.map((c) => c.slug); - // Build slug→maturityRating lookup for serialization - for (const cat of allowedCategories) { - categoryMaturityMap.set(cat.slug, cat.maturityRating); - } + )` + ) + ) + } else { + // --------------------------------------------------------------- + // Single mode: filter by the one configured community + // --------------------------------------------------------------- + + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' + + // Get category slugs matching allowed maturity levels + const allowedCategories = await db + .select({ slug: categories.slug, maturityRating: categories.maturityRating }) + .from(categories) + .where( + and( + eq(categories.communityDid, communityDid), + inArray(categories.maturityRating, allowed) + ) + ) + + const allowedSlugs = allowedCategories.map((c) => c.slug) + // Build slug→maturityRating lookup for serialization + for (const cat of allowedCategories) { + categoryMaturityMap.set(cat.slug, cat.maturityRating) + } - // If no categories are allowed, return empty result - if (allowedSlugs.length === 0) { - return reply.status(200).send({ topics: [], cursor: null }); - } + // If no categories are allowed, return empty result + if (allowedSlugs.length === 0) { + return reply.status(200).send({ topics: [], cursor: null }) + } - // Filter topics to only those in allowed categories - conditions.push(inArray(topics.category, allowedSlugs)); - } + // Filter topics to only those in allowed categories + conditions.push(inArray(topics.category, allowedSlugs)) + } - // Only show approved content in public listings - conditions.push(eq(topics.moderationStatus, "approved")); + // Only show approved content in public listings + conditions.push(eq(topics.moderationStatus, 'approved')) - // Block/mute filtering: load the authenticated user's preferences - const { blockedDids, mutedDids } = await loadBlockMuteLists(request.user?.did, db); + // Block/mute filtering: load the authenticated user's preferences + const { blockedDids, mutedDids } = await loadBlockMuteLists(request.user?.did, db) - // Exclude topics by blocked authors - if (blockedDids.length > 0) { - conditions.push(notInArray(topics.authorDid, blockedDids)); - } + // Exclude topics by blocked authors + if (blockedDids.length > 0) { + conditions.push(notInArray(topics.authorDid, blockedDids)) + } - // Category filter (explicit user filter, further narrows results) - if (category) { - conditions.push(eq(topics.category, category)); - } + // Category filter (explicit user filter, further narrows results) + if (category) { + conditions.push(eq(topics.category, category)) + } - // Tag filter (jsonb contains) - if (tag) { - conditions.push(sql`${topics.tags} @> ${JSON.stringify([tag])}::jsonb`); - } + // Tag filter (jsonb contains) + if (tag) { + conditions.push(sql`${topics.tags} @> ${JSON.stringify([tag])}::jsonb`) + } - // Cursor-based pagination - if (cursor) { - const decoded = decodeCursor(cursor); - if (decoded) { - conditions.push( - sql`(${topics.lastActivityAt}, ${topics.uri}) < (${decoded.lastActivityAt}::timestamptz, ${decoded.uri})`, - ); + // Cursor-based pagination + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${topics.lastActivityAt}, ${topics.uri}) < (${decoded.lastActivityAt}::timestamptz, ${decoded.uri})` + ) + } } - } - const whereClause = conditions.length > 0 ? and(...conditions) : undefined; - - // Fetch limit + 1 to detect if there are more pages - const fetchLimit = limit + 1; - const rows = await db - .select() - .from(topics) - .where(whereClause) - .orderBy(desc(topics.lastActivityAt)) - .limit(fetchLimit); - - const hasMore = rows.length > limit; - const resultRows = hasMore ? rows.slice(0, limit) : rows; - const serialized = resultRows.map((row) => - serializeTopic(row, categoryMaturityMap.get(row.category) ?? "safe"), - ); - - // Ozone label annotation: flag content from spam-labeled accounts - const ozoneMap = new Map(); - if (app.ozoneService) { - const uniqueDids = [...new Set(serialized.map((t) => t.authorDid))]; - for (const did of uniqueDids) { - const isSpam = await app.ozoneService.isSpamLabeled(did); - ozoneMap.set(did, isSpam ? "spam" : null); + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + + // Fetch limit + 1 to detect if there are more pages + const fetchLimit = limit + 1 + const rows = await db + .select() + .from(topics) + .where(whereClause) + .orderBy(desc(topics.lastActivityAt)) + .limit(fetchLimit) + + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + const serialized = resultRows.map((row) => + serializeTopic(row, categoryMaturityMap.get(row.category) ?? 'safe') + ) + + // Ozone label annotation: flag content from spam-labeled accounts + const ozoneMap = new Map() + if (app.ozoneService) { + const uniqueDids = [...new Set(serialized.map((t) => t.authorDid))] + for (const did of uniqueDids) { + const isSpam = await app.ozoneService.isSpamLabeled(did) + ozoneMap.set(did, isSpam ? 'spam' : null) + } } - } - // Load muted words for content filtering - const communityDid = env.COMMUNITY_MODE === "single" - ? env.COMMUNITY_DID - : undefined; - const mutedWords = await loadMutedWords(request.user?.did, communityDid, db); - - // Batch-resolve author profiles - const authorMap = await resolveAuthors( - serialized.map((t) => t.authorDid), - communityDid ?? null, - db, - ); - - // Annotate muted authors and muted word matches (content still returned, just flagged) - const mutedSet = new Set(mutedDids); - const annotatedTopics = serialized.map((t) => ({ - ...t, - author: authorMap.get(t.authorDid) ?? { did: t.authorDid, handle: t.authorDid, displayName: null, avatarUrl: null }, - isMuted: mutedSet.has(t.authorDid), - isMutedWord: contentMatchesMutedWords(t.content, mutedWords, t.title), - ozoneLabel: ozoneMap.get(t.authorDid) ?? null, - })); - - let nextCursor: string | null = null; - if (hasMore) { - const lastRow = resultRows[resultRows.length - 1]; - if (lastRow) { - nextCursor = encodeCursor(lastRow.lastActivityAt.toISOString(), lastRow.uri); + // Load muted words for content filtering + const communityDid = env.COMMUNITY_MODE === 'single' ? env.COMMUNITY_DID : undefined + const mutedWords = await loadMutedWords(request.user?.did, communityDid, db) + + // Batch-resolve author profiles + const authorMap = await resolveAuthors( + serialized.map((t) => t.authorDid), + communityDid ?? null, + db + ) + + // Annotate muted authors and muted word matches (content still returned, just flagged) + const mutedSet = new Set(mutedDids) + const annotatedTopics = serialized.map((t) => ({ + ...t, + author: authorMap.get(t.authorDid) ?? { + did: t.authorDid, + handle: t.authorDid, + displayName: null, + avatarUrl: null, + }, + isMuted: mutedSet.has(t.authorDid), + isMutedWord: contentMatchesMutedWords(t.content, mutedWords, t.title), + ozoneLabel: ozoneMap.get(t.authorDid) ?? null, + })) + + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.lastActivityAt.toISOString(), lastRow.uri) + } } - } - return reply.status(200).send({ - topics: annotatedTopics, - cursor: nextCursor, - }); - }); + return reply.status(200).send({ + topics: annotatedTopics, + cursor: nextCursor, + }) + } + ) // ------------------------------------------------------------------- // GET /api/topics/by-rkey/:rkey (public, no auth) // ------------------------------------------------------------------- - app.get("/api/topics/by-rkey/:rkey", { - schema: { - tags: ["Topics"], - summary: "Get a single topic by rkey (for SEO/metadata)", - params: { - type: "object", - required: ["rkey"], - properties: { - rkey: { type: "string" }, + app.get( + '/api/topics/by-rkey/:rkey', + { + schema: { + tags: ['Topics'], + summary: 'Get a single topic by rkey (for SEO/metadata)', + params: { + type: 'object', + required: ['rkey'], + properties: { + rkey: { type: 'string' }, + }, + }, + response: { + 200: topicJsonSchema, + 404: errorJsonSchema, }, - }, - response: { - 200: topicJsonSchema, - 404: errorJsonSchema, }, }, - }, async (request, reply) => { - const { rkey } = request.params as { rkey: string }; + async (request, reply) => { + const { rkey } = request.params as { rkey: string } - const rows = await db - .select() - .from(topics) - .where(eq(topics.rkey, rkey)); + const rows = await db.select().from(topics).where(eq(topics.rkey, rkey)) - const row = rows[0]; - if (!row) { - throw notFound("Topic not found"); - } + const row = rows[0] + if (!row) { + throw notFound('Topic not found') + } - // Look up the category maturity rating - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - const catRows = await db - .select({ maturityRating: categories.maturityRating }) - .from(categories) - .where( - and( - eq(categories.slug, row.category), - eq(categories.communityDid, communityDid), - ), - ); - const categoryRating = catRows[0]?.maturityRating ?? "safe"; - - return reply.status(200).send(serializeTopic(row, categoryRating)); - }); + // Look up the category maturity rating + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' + const catRows = await db + .select({ maturityRating: categories.maturityRating }) + .from(categories) + .where(and(eq(categories.slug, row.category), eq(categories.communityDid, communityDid))) + const categoryRating = catRows[0]?.maturityRating ?? 'safe' + + return reply.status(200).send(serializeTopic(row, categoryRating)) + } + ) // ------------------------------------------------------------------- // GET /api/topics/:uri (public, optionalAuth) // ------------------------------------------------------------------- - app.get("/api/topics/:uri", { - preHandler: [authMiddleware.optionalAuth], - schema: { - tags: ["Topics"], - summary: "Get a single topic by AT URI", - params: { - type: "object", - required: ["uri"], - properties: { - uri: { type: "string" }, + app.get( + '/api/topics/:uri', + { + preHandler: [authMiddleware.optionalAuth], + schema: { + tags: ['Topics'], + summary: 'Get a single topic by AT URI', + params: { + type: 'object', + required: ['uri'], + properties: { + uri: { type: 'string' }, + }, + }, + response: { + 200: topicJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, }, - }, - response: { - 200: topicJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, }, }, - }, async (request, reply) => { - const { uri } = request.params as { uri: string }; - const decodedUri = decodeURIComponent(uri); - - const rows = await db - .select() - .from(topics) - .where(eq(topics.uri, decodedUri)); - - const row = rows[0]; - if (!row) { - throw notFound("Topic not found"); - } + async (request, reply) => { + const { uri } = request.params as { uri: string } + const decodedUri = decodeURIComponent(uri) - // Maturity check: verify the topic's category is within the user's allowed level - const communityDid = env.COMMUNITY_DID ?? "did:plc:placeholder"; - const catRows = await db - .select({ maturityRating: categories.maturityRating }) - .from(categories) - .where( - and( - eq(categories.slug, row.category), - eq(categories.communityDid, communityDid), - ), - ); - - if (catRows.length === 0) { - app.log.warn({ category: row.category, communityDid }, "Category not found for maturity check, defaulting to safe"); - } - const categoryRating = catRows[0]?.maturityRating ?? "safe"; + const rows = await db.select().from(topics).where(eq(topics.uri, decodedUri)) - let userProfile: MaturityUser | undefined; - if (request.user) { - const userRows = await db - .select({ declaredAge: users.declaredAge, maturityPref: users.maturityPref }) - .from(users) - .where(eq(users.did, request.user.did)); - userProfile = userRows[0] ?? undefined; - } + const row = rows[0] + if (!row) { + throw notFound('Topic not found') + } - // Fetch community age threshold - const singleSettingsRows = await db - .select({ ageThreshold: communitySettings.ageThreshold }) - .from(communitySettings) - .where(eq(communitySettings.id, "default")); - const singleAgeThreshold = singleSettingsRows[0]?.ageThreshold ?? 16; + // Maturity check: verify the topic's category is within the user's allowed level + const communityDid = env.COMMUNITY_DID ?? 'did:plc:placeholder' + const catRows = await db + .select({ maturityRating: categories.maturityRating }) + .from(categories) + .where(and(eq(categories.slug, row.category), eq(categories.communityDid, communityDid))) - const maxMaturity = resolveMaxMaturity(userProfile, singleAgeThreshold); - if (!maturityAllows(maxMaturity, categoryRating)) { - throw forbidden("Content restricted by maturity settings"); - } + if (catRows.length === 0) { + app.log.warn( + { category: row.category, communityDid }, + 'Category not found for maturity check, defaulting to safe' + ) + } + const categoryRating = catRows[0]?.maturityRating ?? 'safe' + + let userProfile: MaturityUser | undefined + if (request.user) { + const userRows = await db + .select({ declaredAge: users.declaredAge, maturityPref: users.maturityPref }) + .from(users) + .where(eq(users.did, request.user.did)) + userProfile = userRows[0] ?? undefined + } - return reply.status(200).send(serializeTopic(row, categoryRating)); - }); + // Fetch community age threshold + const singleSettingsRows = await db + .select({ ageThreshold: communitySettings.ageThreshold }) + .from(communitySettings) + .where(eq(communitySettings.id, 'default')) + const singleAgeThreshold = singleSettingsRows[0]?.ageThreshold ?? 16 + + const maxMaturity = resolveMaxMaturity(userProfile, singleAgeThreshold) + if (!maturityAllows(maxMaturity, categoryRating)) { + throw forbidden('Content restricted by maturity settings') + } + + return reply.status(200).send(serializeTopic(row, categoryRating)) + } + ) // ------------------------------------------------------------------- // PUT /api/topics/:uri (auth required, author only) // ------------------------------------------------------------------- - app.put("/api/topics/:uri", { - preHandler: [authMiddleware.requireAuth], - schema: { - tags: ["Topics"], - summary: "Update a topic (author only)", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["uri"], - properties: { - uri: { type: "string" }, - }, - }, - body: { - type: "object", - properties: { - title: { type: "string", minLength: 1, maxLength: 200 }, - content: { type: "string", minLength: 1, maxLength: 100000 }, - category: { type: "string", minLength: 1 }, - tags: { - type: "array", - items: { type: "string", minLength: 1, maxLength: 30 }, - maxItems: 5, + app.put( + '/api/topics/:uri', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Topics'], + summary: 'Update a topic (author only)', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['uri'], + properties: { + uri: { type: 'string' }, }, - labels: { - type: "object", - properties: { - values: { - type: "array", - items: { - type: "object", - required: ["val"], - properties: { val: { type: "string" } }, + }, + body: { + type: 'object', + properties: { + title: { type: 'string', minLength: 1, maxLength: 200 }, + content: { type: 'string', minLength: 1, maxLength: 100000 }, + category: { type: 'string', minLength: 1 }, + tags: { + type: 'array', + items: { type: 'string', minLength: 1, maxLength: 30 }, + maxItems: 5, + }, + labels: { + type: 'object', + properties: { + values: { + type: 'array', + items: { + type: 'object', + required: ['val'], + properties: { val: { type: 'string' } }, + }, }, }, }, }, }, - }, - response: { - 200: topicJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 502: errorJsonSchema, + response: { + 200: topicJsonSchema, + 400: errorJsonSchema, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + 502: errorJsonSchema, + }, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const parsed = updateTopicSchema.safeParse(request.body); - if (!parsed.success) { - throw badRequest("Invalid update data"); - } + const parsed = updateTopicSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid update data') + } - const { uri } = request.params as { uri: string }; - const decodedUri = decodeURIComponent(uri); + const { uri } = request.params as { uri: string } + const decodedUri = decodeURIComponent(uri) - // Fetch existing topic - const existing = await db - .select() - .from(topics) - .where(eq(topics.uri, decodedUri)); + // Fetch existing topic + const existing = await db.select().from(topics).where(eq(topics.uri, decodedUri)) - const topic = existing[0]; - if (!topic) { - throw notFound("Topic not found"); - } + const topic = existing[0] + if (!topic) { + throw notFound('Topic not found') + } - // Author check - if (topic.authorDid !== user.did) { - throw forbidden("Not authorized to edit this topic"); - } + // Author check + if (topic.authorDid !== user.did) { + throw forbidden('Not authorized to edit this topic') + } - const updates = parsed.data; - const rkey = extractRkey(decodedUri); - - // Resolve labels for PDS record: use provided value, or fall back to existing - const resolvedLabels = updates.labels !== undefined ? (updates.labels ?? null) : (topic.labels ?? null); - - // Build updated record for PDS - const updatedRecord: Record = { - title: updates.title ?? topic.title, - content: updates.content ?? topic.content, - category: updates.category ?? topic.category, - tags: updates.tags ?? topic.tags ?? [], - community: topic.communityDid, - createdAt: topic.createdAt.toISOString(), - ...(resolvedLabels ? { labels: resolvedLabels } : {}), - }; - - try { - const result = await pdsClient.updateRecord(user.did, COLLECTION, rkey, updatedRecord); - - // Build DB update set - const dbUpdates: Record = { - cid: result.cid, - indexedAt: new Date(), - }; - if (updates.title !== undefined) dbUpdates.title = updates.title; - if (updates.content !== undefined) dbUpdates.content = updates.content; - if (updates.category !== undefined) dbUpdates.category = updates.category; - if (updates.tags !== undefined) dbUpdates.tags = updates.tags; - if (updates.labels !== undefined) dbUpdates.labels = updates.labels ?? null; - - const updated = await db - .update(topics) - .set(dbUpdates) - .where(eq(topics.uri, decodedUri)) - .returning(); - - const updatedRow = updated[0]; - if (!updatedRow) { - throw notFound("Topic not found after update"); + const updates = parsed.data + const rkey = extractRkey(decodedUri) + + // Resolve labels for PDS record: use provided value, or fall back to existing + const resolvedLabels = + updates.labels !== undefined ? (updates.labels ?? null) : (topic.labels ?? null) + + // Build updated record for PDS + const updatedRecord: Record = { + title: updates.title ?? topic.title, + content: updates.content ?? topic.content, + category: updates.category ?? topic.category, + tags: updates.tags ?? topic.tags ?? [], + community: topic.communityDid, + createdAt: topic.createdAt.toISOString(), + ...(resolvedLabels ? { labels: resolvedLabels } : {}), } - return await reply.status(200).send(serializeTopic(updatedRow)); - } catch (err: unknown) { - if (err instanceof Error && "statusCode" in err) { - throw err; // Re-throw ApiError instances + try { + const result = await pdsClient.updateRecord(user.did, COLLECTION, rkey, updatedRecord) + + // Build DB update set + const dbUpdates: Record = { + cid: result.cid, + indexedAt: new Date(), + } + if (updates.title !== undefined) dbUpdates.title = updates.title + if (updates.content !== undefined) dbUpdates.content = updates.content + if (updates.category !== undefined) dbUpdates.category = updates.category + if (updates.tags !== undefined) dbUpdates.tags = updates.tags + if (updates.labels !== undefined) dbUpdates.labels = updates.labels ?? null + + const updated = await db + .update(topics) + .set(dbUpdates) + .where(eq(topics.uri, decodedUri)) + .returning() + + const updatedRow = updated[0] + if (!updatedRow) { + throw notFound('Topic not found after update') + } + + return await reply.status(200).send(serializeTopic(updatedRow)) + } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) { + throw err // Re-throw ApiError instances + } + app.log.error({ err, uri: decodedUri }, 'Failed to update topic') + return await reply.status(502).send({ error: 'Failed to update topic' }) } - app.log.error({ err, uri: decodedUri }, "Failed to update topic"); - return await reply.status(502).send({ error: "Failed to update topic" }); } - }); + ) // ------------------------------------------------------------------- // DELETE /api/topics/:uri (auth required, author or moderator) // ------------------------------------------------------------------- - app.delete("/api/topics/:uri", { - preHandler: [authMiddleware.requireAuth], - schema: { - tags: ["Topics"], - summary: "Delete a topic (author or moderator)", - security: [{ bearerAuth: [] }], - params: { - type: "object", - required: ["uri"], - properties: { - uri: { type: "string" }, + app.delete( + '/api/topics/:uri', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Topics'], + summary: 'Delete a topic (author or moderator)', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['uri'], + properties: { + uri: { type: 'string' }, + }, + }, + response: { + 204: { type: 'null' }, + 401: errorJsonSchema, + 403: errorJsonSchema, + 404: errorJsonSchema, + 502: errorJsonSchema, }, - }, - response: { - 204: { type: "null" }, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 502: errorJsonSchema, }, }, - }, async (request, reply) => { - const user = request.user; - if (!user) { - return reply.status(401).send({ error: "Authentication required" }); - } + async (request, reply) => { + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } - const { uri } = request.params as { uri: string }; - const decodedUri = decodeURIComponent(uri); + const { uri } = request.params as { uri: string } + const decodedUri = decodeURIComponent(uri) - // Fetch existing topic - const existing = await db - .select() - .from(topics) - .where(eq(topics.uri, decodedUri)); + // Fetch existing topic + const existing = await db.select().from(topics).where(eq(topics.uri, decodedUri)) - const topic = existing[0]; - if (!topic) { - throw notFound("Topic not found"); - } + const topic = existing[0] + if (!topic) { + throw notFound('Topic not found') + } - const isAuthor = topic.authorDid === user.did; + const isAuthor = topic.authorDid === user.did - // Check if user is a moderator or admin - let isMod = false; - if (!isAuthor) { - const userRows = await db - .select() - .from(users) - .where(eq(users.did, user.did)); + // Check if user is a moderator or admin + let isMod = false + if (!isAuthor) { + const userRows = await db.select().from(users).where(eq(users.did, user.did)) - const userRow = userRows[0]; - isMod = userRow?.role === "moderator" || userRow?.role === "admin"; - } - - if (!isAuthor && !isMod) { - throw forbidden("Not authorized to delete this topic"); - } + const userRow = userRows[0] + isMod = userRow?.role === 'moderator' || userRow?.role === 'admin' + } - try { - // Author: delete from PDS AND DB - // Moderator: delete from DB only (leave record on PDS) - if (isAuthor) { - const rkey = extractRkey(decodedUri); - await pdsClient.deleteRecord(user.did, COLLECTION, rkey); + if (!isAuthor && !isMod) { + throw forbidden('Not authorized to delete this topic') } - // Best-effort cross-post deletion (fire-and-forget) - crossPostService.deleteCrossPosts(decodedUri, user.did).catch((err: unknown) => { - app.log.warn({ err, topicUri: decodedUri }, "Failed to delete cross-posts"); - }); - - // Cascade delete in a transaction for consistency - await db.transaction(async (tx) => { - await tx.delete(replies).where(eq(replies.rootUri, decodedUri)); - await tx.delete(topics).where(eq(topics.uri, decodedUri)); - }); - - return await reply.status(204).send(); - } catch (err: unknown) { - if (err instanceof Error && "statusCode" in err) { - throw err; + try { + // Author: delete from PDS AND DB + // Moderator: delete from DB only (leave record on PDS) + if (isAuthor) { + const rkey = extractRkey(decodedUri) + await pdsClient.deleteRecord(user.did, COLLECTION, rkey) + } + + // Best-effort cross-post deletion (fire-and-forget) + crossPostService.deleteCrossPosts(decodedUri, user.did).catch((err: unknown) => { + app.log.warn({ err, topicUri: decodedUri }, 'Failed to delete cross-posts') + }) + + // Cascade delete in a transaction for consistency + await db.transaction(async (tx) => { + await tx.delete(replies).where(eq(replies.rootUri, decodedUri)) + await tx.delete(topics).where(eq(topics.uri, decodedUri)) + }) + + return await reply.status(204).send() + } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) { + throw err + } + app.log.error({ err, uri: decodedUri }, 'Failed to delete topic') + return await reply.status(502).send({ error: 'Failed to delete topic' }) } - app.log.error({ err, uri: decodedUri }, "Failed to delete topic"); - return await reply.status(502).send({ error: "Failed to delete topic" }); } - }); + ) - done(); - }; + done() + } } diff --git a/src/routes/uploads.ts b/src/routes/uploads.ts index 449aa45..5d49531 100644 --- a/src/routes/uploads.ts +++ b/src/routes/uploads.ts @@ -1,43 +1,38 @@ -import type { FastifyPluginCallback } from "fastify"; -import sharp from "sharp"; -import { badRequest } from "../lib/api-errors.js"; -import { communityProfiles } from "../db/schema/community-profiles.js"; +import type { FastifyPluginCallback } from 'fastify' +import sharp from 'sharp' +import { badRequest } from '../lib/api-errors.js' +import { communityProfiles } from '../db/schema/community-profiles.js' -const ALLOWED_MIMES = new Set([ - "image/jpeg", - "image/png", - "image/webp", - "image/gif", -]); +const ALLOWED_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']) -const AVATAR_SIZE = { width: 400, height: 400 }; -const BANNER_SIZE = { width: 1500, height: 500 }; +const AVATAR_SIZE = { width: 400, height: 400 } +const BANNER_SIZE = { width: 1500, height: 500 } // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- const errorJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - error: { type: "string" as const }, + error: { type: 'string' as const }, }, -}; +} const uploadResponseJsonSchema = { - type: "object" as const, + type: 'object' as const, properties: { - url: { type: "string" as const }, + url: { type: 'string' as const }, }, -}; +} const paramsJsonSchema = { - type: "object" as const, - required: ["communityDid"], + type: 'object' as const, + required: ['communityDid'], properties: { - communityDid: { type: "string" as const }, + communityDid: { type: 'string' as const }, }, -}; +} // --------------------------------------------------------------------------- // Upload routes plugin @@ -51,22 +46,22 @@ const paramsJsonSchema = { */ export function uploadRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, authMiddleware, storage, env } = app; - const maxSize = env.UPLOAD_MAX_SIZE_BYTES; + const { db, authMiddleware, storage, env } = app + const maxSize = env.UPLOAD_MAX_SIZE_BYTES // ----------------------------------------------------------------- // POST /api/communities/:communityDid/profile/avatar // ----------------------------------------------------------------- app.post( - "/api/communities/:communityDid/profile/avatar", + '/api/communities/:communityDid/profile/avatar', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Uploads"], - summary: "Upload community profile avatar", + tags: ['Uploads'], + summary: 'Upload community profile avatar', security: [{ bearerAuth: [] }], - consumes: ["multipart/form-data"], + consumes: ['multipart/form-data'], params: paramsJsonSchema, response: { 200: uploadResponseJsonSchema, @@ -76,36 +71,32 @@ export function uploadRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const requestUser = request.user; + const requestUser = request.user if (!requestUser) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } - const { communityDid } = request.params as { communityDid: string }; + const { communityDid } = request.params as { communityDid: string } - const file = await request.file(); - if (!file) throw badRequest("No file uploaded"); + const file = await request.file() + if (!file) throw badRequest('No file uploaded') if (!ALLOWED_MIMES.has(file.mimetype)) { - throw badRequest("File must be JPEG, PNG, WebP, or GIF"); + throw badRequest('File must be JPEG, PNG, WebP, or GIF') } - const buffer = await file.toBuffer(); + const buffer = await file.toBuffer() if (buffer.length > maxSize) { - throw badRequest( - `File too large (max ${String(Math.round(maxSize / 1024 / 1024))}MB)`, - ); + throw badRequest(`File too large (max ${String(Math.round(maxSize / 1024 / 1024))}MB)`) } const processed = await sharp(buffer) - .resize(AVATAR_SIZE.width, AVATAR_SIZE.height, { fit: "cover" }) + .resize(AVATAR_SIZE.width, AVATAR_SIZE.height, { fit: 'cover' }) .webp({ quality: 85 }) - .toBuffer(); + .toBuffer() - const url = await storage.store(processed, "image/webp", "avatars"); + const url = await storage.store(processed, 'image/webp', 'avatars') - const now = new Date(); + const now = new Date() await db .insert(communityProfiles) .values({ @@ -117,25 +108,25 @@ export function uploadRoutes(): FastifyPluginCallback { .onConflictDoUpdate({ target: [communityProfiles.did, communityProfiles.communityDid], set: { avatarUrl: url, updatedAt: now }, - }); + }) - return reply.status(200).send({ url }); - }, - ); + return reply.status(200).send({ url }) + } + ) // ----------------------------------------------------------------- // POST /api/communities/:communityDid/profile/banner // ----------------------------------------------------------------- app.post( - "/api/communities/:communityDid/profile/banner", + '/api/communities/:communityDid/profile/banner', { preHandler: [authMiddleware.requireAuth], schema: { - tags: ["Uploads"], - summary: "Upload community profile banner", + tags: ['Uploads'], + summary: 'Upload community profile banner', security: [{ bearerAuth: [] }], - consumes: ["multipart/form-data"], + consumes: ['multipart/form-data'], params: paramsJsonSchema, response: { 200: uploadResponseJsonSchema, @@ -145,36 +136,32 @@ export function uploadRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const requestUser = request.user; + const requestUser = request.user if (!requestUser) { - return reply - .status(401) - .send({ error: "Authentication required" }); + return reply.status(401).send({ error: 'Authentication required' }) } - const { communityDid } = request.params as { communityDid: string }; + const { communityDid } = request.params as { communityDid: string } - const file = await request.file(); - if (!file) throw badRequest("No file uploaded"); + const file = await request.file() + if (!file) throw badRequest('No file uploaded') if (!ALLOWED_MIMES.has(file.mimetype)) { - throw badRequest("File must be JPEG, PNG, WebP, or GIF"); + throw badRequest('File must be JPEG, PNG, WebP, or GIF') } - const buffer = await file.toBuffer(); + const buffer = await file.toBuffer() if (buffer.length > maxSize) { - throw badRequest( - `File too large (max ${String(Math.round(maxSize / 1024 / 1024))}MB)`, - ); + throw badRequest(`File too large (max ${String(Math.round(maxSize / 1024 / 1024))}MB)`) } const processed = await sharp(buffer) - .resize(BANNER_SIZE.width, BANNER_SIZE.height, { fit: "cover" }) + .resize(BANNER_SIZE.width, BANNER_SIZE.height, { fit: 'cover' }) .webp({ quality: 85 }) - .toBuffer(); + .toBuffer() - const url = await storage.store(processed, "image/webp", "banners"); + const url = await storage.store(processed, 'image/webp', 'banners') - const now = new Date(); + const now = new Date() await db .insert(communityProfiles) .values({ @@ -186,12 +173,12 @@ export function uploadRoutes(): FastifyPluginCallback { .onConflictDoUpdate({ target: [communityProfiles.did, communityProfiles.communityDid], set: { bannerUrl: url, updatedAt: now }, - }); + }) - return reply.status(200).send({ url }); - }, - ); + return reply.status(200).send({ url }) + } + ) - done(); - }; + done() + } } diff --git a/src/server.ts b/src/server.ts index 4614857..674bc2b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,16 +1,16 @@ -import { parseEnv } from "./config/env.js"; -import { buildApp } from "./app.js"; +import { parseEnv } from './config/env.js' +import { buildApp } from './app.js' async function main() { - const env = parseEnv(process.env); - const app = await buildApp(env); + const env = parseEnv(process.env) + const app = await buildApp(env) try { - await app.listen({ host: env.HOST, port: env.PORT }); + await app.listen({ host: env.HOST, port: env.PORT }) } catch (err) { - app.log.fatal(err, "Failed to start server"); - process.exit(1); + app.log.fatal(err, 'Failed to start server') + process.exit(1) } } -void main(); +void main() diff --git a/src/services/account-age.ts b/src/services/account-age.ts index 0843f55..3c680f1 100644 --- a/src/services/account-age.ts +++ b/src/services/account-age.ts @@ -1,12 +1,12 @@ -import type { Logger } from "../lib/logger.js"; +import type { Logger } from '../lib/logger.js' // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const PLC_DIRECTORY_URL = "https://plc.directory"; -const PLC_TIMEOUT_MS = 5000; -const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000; +const PLC_DIRECTORY_URL = 'https://plc.directory' +const PLC_TIMEOUT_MS = 5000 +const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000 // --------------------------------------------------------------------------- // Types @@ -14,24 +14,24 @@ const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000; /** A single entry from the PLC directory audit log. */ interface PlcAuditEntry { - createdAt: string; - [key: string]: unknown; + createdAt: string + [key: string]: unknown } -export type TrustStatus = "trusted" | "new"; +export type TrustStatus = 'trusted' | 'new' export interface AccountAgeService { /** * Resolve the account creation date for a DID from the PLC directory. * Returns null if resolution fails (non-PLC DID, network error, etc.). */ - resolveCreationDate(did: string): Promise; + resolveCreationDate(did: string): Promise /** * Determine trust status based on account creation date. * Accounts < 24 hours old are 'new', all others are 'trusted'. */ - determineTrustStatus(accountCreatedAt: Date | null): TrustStatus; + determineTrustStatus(accountCreatedAt: Date | null): TrustStatus } // --------------------------------------------------------------------------- @@ -40,63 +40,60 @@ export interface AccountAgeService { export function createAccountAgeService(logger: Logger): AccountAgeService { async function resolveCreationDate(did: string): Promise { - if (!did.startsWith("did:plc:")) { - logger.debug({ did }, "Non-PLC DID, cannot resolve account creation date"); - return null; + if (!did.startsWith('did:plc:')) { + logger.debug({ did }, 'Non-PLC DID, cannot resolve account creation date') + return null } try { - const url = `${PLC_DIRECTORY_URL}/${encodeURIComponent(did)}/log/audit`; + const url = `${PLC_DIRECTORY_URL}/${encodeURIComponent(did)}/log/audit` const response = await fetch(url, { - headers: { Accept: "application/json" }, + headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(PLC_TIMEOUT_MS), - }); + }) if (!response.ok) { - logger.warn( - { did, status: response.status }, - "PLC directory audit log lookup failed", - ); - return null; + logger.warn({ did, status: response.status }, 'PLC directory audit log lookup failed') + return null } - const entries = (await response.json()) as PlcAuditEntry[]; + const entries = (await response.json()) as PlcAuditEntry[] if (!Array.isArray(entries) || entries.length === 0) { - logger.warn({ did }, "PLC directory returned empty audit log"); - return null; + logger.warn({ did }, 'PLC directory returned empty audit log') + return null } - const firstEntry = entries[0]; + const firstEntry = entries[0] if (!firstEntry?.createdAt) { - logger.warn({ did }, "PLC audit log entry missing createdAt"); - return null; + logger.warn({ did }, 'PLC audit log entry missing createdAt') + return null } - const createdAt = new Date(firstEntry.createdAt); + const createdAt = new Date(firstEntry.createdAt) if (isNaN(createdAt.getTime())) { logger.warn( { did, createdAt: firstEntry.createdAt }, - "Invalid createdAt timestamp in PLC audit log", - ); - return null; + 'Invalid createdAt timestamp in PLC audit log' + ) + return null } - return createdAt; + return createdAt } catch (err) { - logger.warn({ err, did }, "Failed to resolve account creation date from PLC"); - return null; + logger.warn({ err, did }, 'Failed to resolve account creation date from PLC') + return null } } function determineTrustStatus(accountCreatedAt: Date | null): TrustStatus { if (!accountCreatedAt) { - return "trusted"; // Can't determine age → default to trusted + return 'trusted' // Can't determine age → default to trusted } - const ageMs = Date.now() - accountCreatedAt.getTime(); - return ageMs < TWENTY_FOUR_HOURS_MS ? "new" : "trusted"; + const ageMs = Date.now() - accountCreatedAt.getTime() + return ageMs < TWENTY_FOUR_HOURS_MS ? 'new' : 'trusted' } - return { resolveCreationDate, determineTrustStatus }; + return { resolveCreationDate, determineTrustStatus } } diff --git a/src/services/ban-propagation.ts b/src/services/ban-propagation.ts index 41c084c..4f42e30 100644 --- a/src/services/ban-propagation.ts +++ b/src/services/ban-propagation.ts @@ -1,14 +1,14 @@ -import { sql } from "drizzle-orm"; -import type { Database } from "../db/index.js"; -import type { Cache } from "../cache/index.js"; -import type { Logger } from "../lib/logger.js"; -import { accountFilters } from "../db/schema/account-filters.js"; +import { sql } from 'drizzle-orm' +import type { Database } from '../db/index.js' +import type { Cache } from '../cache/index.js' +import type { Logger } from '../lib/logger.js' +import { accountFilters } from '../db/schema/account-filters.js' -const GLOBAL_SENTINEL = "__global__"; -const BAN_THRESHOLD = 2; +const GLOBAL_SENTINEL = '__global__' +const BAN_THRESHOLD = 2 interface BanCountRow { - ban_count: number; + ban_count: number } /** @@ -23,11 +23,11 @@ export async function checkBanPropagation( db: Database, cache: Cache, logger: Logger, - targetDid: string, + targetDid: string ): Promise<{ propagated: boolean; banCount: number }> { // Count distinct communities where the user's latest action is "ban" // (i.e., not followed by an "unban" in the same community) - const result = await db.execute(sql` + const result = (await db.execute(sql` WITH latest_actions AS ( SELECT DISTINCT ON (community_did) community_did, @@ -40,9 +40,9 @@ export async function checkBanPropagation( SELECT count(*)::int AS ban_count FROM latest_actions WHERE action = 'ban' - `) as unknown as BanCountRow[]; + `)) as unknown as BanCountRow[] - const banCount = result[0]?.ban_count ?? 0; + const banCount = result[0]?.ban_count ?? 0 if (banCount >= BAN_THRESHOLD) { // Upsert global account filter @@ -51,37 +51,34 @@ export async function checkBanPropagation( .values({ did: targetDid, communityDid: GLOBAL_SENTINEL, - status: "filtered", + status: 'filtered', reason: `Auto-filtered: banned in ${String(banCount)} communities`, banCount, - filteredBy: "system", + filteredBy: 'system', updatedAt: new Date(), }) .onConflictDoUpdate({ target: [accountFilters.did, accountFilters.communityDid], set: { - status: "filtered", + status: 'filtered', reason: `Auto-filtered: banned in ${String(banCount)} communities`, banCount, - filteredBy: "system", + filteredBy: 'system', updatedAt: new Date(), }, - }); + }) // Invalidate any cached account filter status try { - await cache.del(`account-filter:${targetDid}`); + await cache.del(`account-filter:${targetDid}`) } catch { // Non-critical } - logger.info( - { targetDid, banCount }, - "Account auto-filtered due to cross-community bans", - ); + logger.info({ targetDid, banCount }, 'Account auto-filtered due to cross-community bans') - return { propagated: true, banCount }; + return { propagated: true, banCount } } - return { propagated: false, banCount }; + return { propagated: false, banCount } } diff --git a/src/services/behavioral-heuristics.ts b/src/services/behavioral-heuristics.ts new file mode 100644 index 0000000..457d9be --- /dev/null +++ b/src/services/behavioral-heuristics.ts @@ -0,0 +1,323 @@ +import { and, gte, eq, count, countDistinct, gt, lt } from 'drizzle-orm' +import type { Database } from '../db/index.js' +import type { Logger } from '../lib/logger.js' +import { reactions } from '../db/schema/reactions.js' +import { topics } from '../db/schema/topics.js' +import { replies } from '../db/schema/replies.js' +import { behavioralFlags } from '../db/schema/behavioral-flags.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface BehavioralFlag { + flagType: 'burst_voting' | 'content_similarity' | 'low_diversity' + affectedDids: string[] + details: string + detectedAt: Date +} + +export interface BehavioralHeuristicsService { + detectBurstVoting(communityId: string | null): Promise + detectContentSimilarity(communityId: string | null): Promise + detectLowDiversity(communityId: string | null): Promise + runAll(communityId: string | null): Promise +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Burst voting threshold: more than this many reactions in the window. */ +const BURST_REACTION_THRESHOLD = 20 + +/** Burst voting window in minutes. */ +const BURST_WINDOW_MINUTES = 10 + +/** Jaccard similarity threshold for content fingerprinting. */ +const SIMILARITY_THRESHOLD = 0.8 + +/** Minimum number of posts from different DIDs with high similarity to flag. */ +const SIMILARITY_MIN_POSTS = 3 + +/** Minimum interactions for low diversity check. */ +const LOW_DIVERSITY_MIN_INTERACTIONS = 10 + +/** Minimum unique targets for low diversity check. */ +const LOW_DIVERSITY_MIN_TARGETS = 3 + +/** + * Compute normalized trigrams from text content. + * Lowercases, strips non-alphanumeric chars, splits into 3-char sequences. + */ +export function computeTrigrams(text: string): Set { + const normalized = text + .toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, ' ') + .trim() + const trigrams = new Set() + for (let i = 0; i <= normalized.length - 3; i++) { + trigrams.add(normalized.slice(i, i + 3)) + } + return trigrams +} + +/** + * Compute Jaccard similarity between two sets. + */ +export function jaccardSimilarity(a: Set, b: Set): number { + if (a.size === 0 && b.size === 0) return 1 + let intersection = 0 + for (const item of a) { + if (b.has(item)) intersection++ + } + const union = a.size + b.size - intersection + return union === 0 ? 0 : intersection / union +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createBehavioralHeuristicsService( + db: Database, + logger: Logger +): BehavioralHeuristicsService { + async function detectBurstVoting(communityId: string | null): Promise { + const flags: BehavioralFlag[] = [] + const now = new Date() + const windowStart = new Date(now.getTime() - BURST_WINDOW_MINUTES * 60 * 1000) + + try { + // Build conditions for query + const conditions = [gte(reactions.createdAt, windowStart)] + if (communityId) { + conditions.push(eq(reactions.communityDid, communityId)) + } + + // Query reactions grouped by author in the burst window using Drizzle ORM + const rows = await db + .select({ + authorDid: reactions.authorDid, + reactionCount: count(), + }) + .from(reactions) + .where(and(...conditions)) + .groupBy(reactions.authorDid) + .having(gt(count(), BURST_REACTION_THRESHOLD)) + + if (rows.length > 0) { + const affectedDids = rows.map((r) => r.authorDid) + const detailParts = rows.map( + (r) => + `${r.authorDid}: ${String(r.reactionCount)} reactions in ${String(BURST_WINDOW_MINUTES)}min` + ) + + const flag: BehavioralFlag = { + flagType: 'burst_voting', + affectedDids, + details: `Burst voting detected: ${detailParts.join('; ')}`, + detectedAt: now, + } + flags.push(flag) + + // Persist to database + await db.insert(behavioralFlags).values({ + flagType: 'burst_voting', + affectedDids, + details: flag.details, + communityDid: communityId, + detectedAt: now, + }) + + logger.warn({ affectedDids, communityId }, 'Burst voting detected') + } + } catch (err: unknown) { + logger.error({ err, communityId }, 'Failed to detect burst voting') + } + + return flags + } + + async function detectContentSimilarity(communityId: string | null): Promise { + const flags: BehavioralFlag[] = [] + const now = new Date() + const windowStart = new Date(now.getTime() - 24 * 60 * 60 * 1000) // 24h + + try { + // Fetch recent topics + const topicConditions = [gte(topics.createdAt, windowStart)] + if (communityId) { + topicConditions.push(eq(topics.communityDid, communityId)) + } + + const recentTopics = await db + .select({ + authorDid: topics.authorDid, + content: topics.content, + uri: topics.uri, + }) + .from(topics) + .where(and(...topicConditions)) + + // Fetch recent replies + const replyConditions = [gte(replies.createdAt, windowStart)] + if (communityId) { + replyConditions.push(eq(replies.communityDid, communityId)) + } + + const recentReplies = await db + .select({ + authorDid: replies.authorDid, + content: replies.content, + uri: replies.uri, + }) + .from(replies) + .where(and(...replyConditions)) + + // Combine all posts with their fingerprints + const posts = [ + ...recentTopics.map((t) => ({ + authorDid: t.authorDid, + content: t.content, + uri: t.uri, + trigrams: computeTrigrams(t.content), + })), + ...recentReplies.map((r) => ({ + authorDid: r.authorDid, + content: r.content, + uri: r.uri, + trigrams: computeTrigrams(r.content), + })), + ] + + // Compare posts from different DIDs + // Group similar posts into clusters + const similarClusters: Map> = new Map() + + for (let i = 0; i < posts.length; i++) { + for (let j = i + 1; j < posts.length; j++) { + const a = posts[i] + const b = posts[j] + if (!a || !b) continue + if (a.authorDid === b.authorDid) continue + if (a.trigrams.size < 3 || b.trigrams.size < 3) continue + + const similarity = jaccardSimilarity(a.trigrams, b.trigrams) + if (similarity >= SIMILARITY_THRESHOLD) { + // Find or create a cluster key + const clusterKey = a.uri + const cluster = similarClusters.get(clusterKey) ?? new Set() + cluster.add(a.authorDid) + cluster.add(b.authorDid) + similarClusters.set(clusterKey, cluster) + } + } + } + + // Flag clusters with enough different DIDs + for (const [, dids] of similarClusters) { + if (dids.size >= SIMILARITY_MIN_POSTS) { + const affectedDids = [...dids] + const flag: BehavioralFlag = { + flagType: 'content_similarity', + affectedDids, + details: `High content similarity (Jaccard >= ${String(SIMILARITY_THRESHOLD)}) detected across ${String(affectedDids.length)} different accounts`, + detectedAt: now, + } + flags.push(flag) + + await db.insert(behavioralFlags).values({ + flagType: 'content_similarity', + affectedDids, + details: flag.details, + communityDid: communityId, + detectedAt: now, + }) + + logger.warn({ affectedDids, communityId }, 'Content similarity detected') + } + } + } catch (err: unknown) { + logger.error({ err, communityId }, 'Failed to detect content similarity') + } + + return flags + } + + async function detectLowDiversity(communityId: string | null): Promise { + const flags: BehavioralFlag[] = [] + const now = new Date() + + try { + // Build conditions + const conditions = communityId ? [eq(reactions.communityDid, communityId)] : [] + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + + // Query reactions: for each author, count total and distinct targets using Drizzle ORM + const rows = await db + .select({ + authorDid: reactions.authorDid, + totalInteractions: count(), + uniqueTargets: countDistinct(reactions.subjectUri), + }) + .from(reactions) + .where(whereClause) + .groupBy(reactions.authorDid) + .having( + and( + gt(count(), LOW_DIVERSITY_MIN_INTERACTIONS), + lt(countDistinct(reactions.subjectUri), LOW_DIVERSITY_MIN_TARGETS) + ) + ) + + if (rows.length > 0) { + const affectedDids = rows.map((r) => r.authorDid) + const detailParts = rows.map( + (r) => + `${r.authorDid}: ${String(r.totalInteractions)} interactions, ${String(r.uniqueTargets)} unique targets` + ) + + const flag: BehavioralFlag = { + flagType: 'low_diversity', + affectedDids, + details: `Low interaction diversity: ${detailParts.join('; ')}`, + detectedAt: now, + } + flags.push(flag) + + await db.insert(behavioralFlags).values({ + flagType: 'low_diversity', + affectedDids, + details: flag.details, + communityDid: communityId, + detectedAt: now, + }) + + logger.warn({ affectedDids, communityId }, 'Low interaction diversity detected') + } + } catch (err: unknown) { + logger.error({ err, communityId }, 'Failed to detect low diversity') + } + + return flags + } + + async function runAll(communityId: string | null): Promise { + const results = await Promise.all([ + detectBurstVoting(communityId), + detectContentSimilarity(communityId), + detectLowDiversity(communityId), + ]) + return results.flat() + } + + return { + detectBurstVoting, + detectContentSimilarity, + detectLowDiversity, + runAll, + } +} diff --git a/src/services/cluster-diversity.ts b/src/services/cluster-diversity.ts new file mode 100644 index 0000000..5a16dfe --- /dev/null +++ b/src/services/cluster-diversity.ts @@ -0,0 +1,26 @@ +// --------------------------------------------------------------------------- +// Cluster diversity factor for reputation weighting +// --------------------------------------------------------------------------- + +/** + * Compute the cluster diversity factor for a voter. + * + * - If the voter is NOT in any flagged sybil cluster, returns 1.0. + * - If the voter IS in a flagged cluster, returns log2(1 + externalInteractionCount). + * This means voters in clusters with zero external interactions contribute + * a factor of 0, effectively zeroing their reputation impact. + * + * @param inFlaggedCluster - Whether the voter belongs to a flagged sybil cluster + * @param externalInteractionCount - Number of distinct external DIDs the voter + * interacts with outside any flagged cluster they belong to + */ +export function computeClusterDiversityFactor( + inFlaggedCluster: boolean, + externalInteractionCount: number +): number { + if (!inFlaggedCluster) { + return 1.0 + } + + return Math.log2(1 + externalInteractionCount) +} diff --git a/src/services/cross-post.ts b/src/services/cross-post.ts index 9114d21..ea2d7c2 100644 --- a/src/services/cross-post.ts +++ b/src/services/cross-post.ts @@ -1,51 +1,51 @@ -import { eq } from "drizzle-orm"; -import type { PdsClient } from "../lib/pds-client.js"; -import type { Logger } from "../lib/logger.js"; -import type { Database } from "../db/index.js"; -import type { NotificationService } from "./notification.js"; -import { generateOgImage } from "./og-image.js"; -import { crossPosts } from "../db/schema/cross-posts.js"; -import { userPreferences } from "../db/schema/user-preferences.js"; +import { eq } from 'drizzle-orm' +import type { PdsClient } from '../lib/pds-client.js' +import type { Logger } from '../lib/logger.js' +import type { Database } from '../db/index.js' +import type { NotificationService } from './notification.js' +import { generateOgImage } from './og-image.js' +import { crossPosts } from '../db/schema/cross-posts.js' +import { userPreferences } from '../db/schema/user-preferences.js' // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Maximum grapheme length for Bluesky post text. */ -const BLUESKY_TEXT_LIMIT = 300; +const BLUESKY_TEXT_LIMIT = 300 /** Maximum length for the Bluesky embed description. */ -const EMBED_DESCRIPTION_LIMIT = 300; +const EMBED_DESCRIPTION_LIMIT = 300 /** AT Protocol collection for Bluesky posts. */ -const BLUESKY_COLLECTION = "app.bsky.feed.post"; +const BLUESKY_COLLECTION = 'app.bsky.feed.post' /** AT Protocol collection for Frontpage link submissions. */ -const FRONTPAGE_COLLECTION = "fyi.frontpage.post"; +const FRONTPAGE_COLLECTION = 'fyi.frontpage.post' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface CrossPostParams { - did: string; - topicUri: string; - title: string; - content: string; - category: string; - communityDid: string; + did: string + topicUri: string + title: string + content: string + category: string + communityDid: string } export interface CrossPostService { - crossPostTopic(params: CrossPostParams): Promise; - deleteCrossPosts(topicUri: string, did: string): Promise; + crossPostTopic(params: CrossPostParams): Promise + deleteCrossPosts(topicUri: string, did: string): Promise } export interface CrossPostConfig { - blueskyEnabled: boolean; - frontpageEnabled: boolean; - publicUrl: string; - communityName: string; + blueskyEnabled: boolean + frontpageEnabled: boolean + publicUrl: string + communityName: string } // --------------------------------------------------------------------------- @@ -57,8 +57,8 @@ export interface CrossPostConfig { * Format: at://did:plc:xxx/collection/rkey */ function extractRkey(uri: string): string { - const parts = uri.split("/"); - return parts[parts.length - 1] ?? ""; + const parts = uri.split('/') + return parts[parts.length - 1] ?? '' } /** @@ -66,9 +66,9 @@ function extractRkey(uri: string): string { */ function truncate(text: string, maxLength: number): string { if (text.length <= maxLength) { - return text; + return text } - return text.slice(0, maxLength - 1) + "\u2026"; + return text.slice(0, maxLength - 1) + '\u2026' } /** @@ -76,22 +76,22 @@ function truncate(text: string, maxLength: number): string { * Format: "{title}\n\n{truncated content}" (fitting within BLUESKY_TEXT_LIMIT). */ function buildBlueskyPostText(title: string, content: string): string { - const prefix = title + "\n\n"; - const remainingChars = BLUESKY_TEXT_LIMIT - prefix.length; + const prefix = title + '\n\n' + const remainingChars = BLUESKY_TEXT_LIMIT - prefix.length if (remainingChars <= 0) { - return truncate(title, BLUESKY_TEXT_LIMIT); + return truncate(title, BLUESKY_TEXT_LIMIT) } - return prefix + truncate(content, remainingChars); + return prefix + truncate(content, remainingChars) } /** * Build the public URL for a topic from its AT URI. */ function buildTopicUrl(publicUrl: string, topicUri: string): string { - const rkey = extractRkey(topicUri); - return `${publicUrl}/topics/${rkey}`; + const rkey = extractRkey(topicUri) + return `${publicUrl}/topics/${rkey}` } // --------------------------------------------------------------------------- @@ -114,29 +114,27 @@ export function createCrossPostService( db: Database, logger: Logger, config: CrossPostConfig, - notificationService: NotificationService, + notificationService: NotificationService ): CrossPostService { /** * Generate and upload an OG image for use as a Bluesky embed thumbnail. * Returns the blob reference on success, or undefined on failure (best-effort). */ - async function generateAndUploadThumb( - params: CrossPostParams, - ): Promise { + async function generateAndUploadThumb(params: CrossPostParams): Promise { try { const pngBuffer = await generateOgImage({ title: params.title, category: params.category, communityName: config.communityName, - }); + }) - return await pdsClient.uploadBlob(params.did, pngBuffer, "image/png"); + return await pdsClient.uploadBlob(params.did, pngBuffer, 'image/png') } catch (err: unknown) { logger.warn( { err, topicUri: params.topicUri }, - "Failed to generate or upload OG image for cross-post thumbnail", - ); - return undefined; + 'Failed to generate or upload OG image for cross-post thumbnail' + ) + return undefined } } @@ -145,21 +143,18 @@ export function createCrossPostService( * with an `app.bsky.embed.external` embed containing a link back * to the forum topic and a branded OG image thumbnail. */ - async function crossPostToBluesky( - params: CrossPostParams, - thumb: unknown, - ): Promise { - const topicUrl = buildTopicUrl(config.publicUrl, params.topicUri); - const postText = buildBlueskyPostText(params.title, params.content); + async function crossPostToBluesky(params: CrossPostParams, thumb: unknown): Promise { + const topicUrl = buildTopicUrl(config.publicUrl, params.topicUri) + const postText = buildBlueskyPostText(params.title, params.content) const external: Record = { uri: topicUrl, title: params.title, description: truncate(params.content, EMBED_DESCRIPTION_LIMIT), - }; + } if (thumb !== undefined) { - external.thumb = thumb; + external.thumb = thumb } const record: Record = { @@ -167,38 +162,34 @@ export function createCrossPostService( text: postText, createdAt: new Date().toISOString(), embed: { - $type: "app.bsky.embed.external", + $type: 'app.bsky.embed.external', external, }, - langs: ["en"], - }; + langs: ['en'], + } - let result: { uri: string; cid: string }; + let result: { uri: string; cid: string } try { - result = await pdsClient.createRecord( - params.did, - BLUESKY_COLLECTION, - record, - ); + result = await pdsClient.createRecord(params.did, BLUESKY_COLLECTION, record) } catch (err: unknown) { if (isScopeError(err)) { - await handleScopeRevocation(params.did, params.communityDid); + await handleScopeRevocation(params.did, params.communityDid) } - throw err; + throw err } await db.insert(crossPosts).values({ topicUri: params.topicUri, - service: "bluesky", + service: 'bluesky', crossPostUri: result.uri, crossPostCid: result.cid, authorDid: params.did, - }); + }) logger.info( { topicUri: params.topicUri, crossPostUri: result.uri }, - "Cross-posted topic to Bluesky", - ); + 'Cross-posted topic to Bluesky' + ) } /** @@ -206,50 +197,46 @@ export function createCrossPostService( * (link submission pointing back to the forum topic). */ async function crossPostToFrontpage(params: CrossPostParams): Promise { - const topicUrl = buildTopicUrl(config.publicUrl, params.topicUri); + const topicUrl = buildTopicUrl(config.publicUrl, params.topicUri) const record: Record = { title: params.title, url: topicUrl, createdAt: new Date().toISOString(), - }; + } - let result: { uri: string; cid: string }; + let result: { uri: string; cid: string } try { - result = await pdsClient.createRecord( - params.did, - FRONTPAGE_COLLECTION, - record, - ); + result = await pdsClient.createRecord(params.did, FRONTPAGE_COLLECTION, record) } catch (err: unknown) { if (isScopeError(err)) { - await handleScopeRevocation(params.did, params.communityDid); + await handleScopeRevocation(params.did, params.communityDid) } - throw err; + throw err } await db.insert(crossPosts).values({ topicUri: params.topicUri, - service: "frontpage", + service: 'frontpage', crossPostUri: result.uri, crossPostCid: result.cid, authorDid: params.did, - }); + }) logger.info( { topicUri: params.topicUri, crossPostUri: result.uri }, - "Cross-posted topic to Frontpage", - ); + 'Cross-posted topic to Frontpage' + ) } /** * Detect whether an error from the PDS indicates insufficient scope (403). */ function isScopeError(err: unknown): boolean { - if (err !== null && typeof err === "object" && "status" in err) { - return (err as { status: number }).status === 403; + if (err !== null && typeof err === 'object' && 'status' in err) { + return (err as { status: number }).status === 403 } - return false; + return false } /** @@ -261,17 +248,14 @@ export function createCrossPostService( await db .update(userPreferences) .set({ crossPostScopesGranted: false, updatedAt: new Date() }) - .where(eq(userPreferences.did, did)); + .where(eq(userPreferences.did, did)) await notificationService.notifyOnCrossPostScopeRevoked({ authorDid: did, communityDid, - }); + }) } catch (revokeErr: unknown) { - logger.error( - { err: revokeErr, did }, - "Failed to handle cross-post scope revocation", - ); + logger.error({ err: revokeErr, did }, 'Failed to handle cross-post scope revocation') } } @@ -281,124 +265,116 @@ export function createCrossPostService( const prefRows = await db .select({ crossPostScopesGranted: userPreferences.crossPostScopesGranted }) .from(userPreferences) - .where(eq(userPreferences.did, params.did)); + .where(eq(userPreferences.did, params.did)) if (!(prefRows[0]?.crossPostScopesGranted ?? false)) { logger.info( { did: params.did, topicUri: params.topicUri }, - "Skipping cross-post: user has not authorized cross-post scopes", - ); - return; + 'Skipping cross-post: user has not authorized cross-post scopes' + ) + return } // Generate and upload OG image for Bluesky (only if Bluesky is enabled) - let thumb: unknown; + let thumb: unknown if (config.blueskyEnabled) { - thumb = await generateAndUploadThumb(params); + thumb = await generateAndUploadThumb(params) } - const tasks: Promise>[] = []; + const tasks: Promise>[] = [] if (config.blueskyEnabled) { tasks.push( crossPostToBluesky(params, thumb) .then>(() => ({ - status: "fulfilled" as const, + status: 'fulfilled' as const, value: undefined, })) .catch>((err: unknown) => { logger.error( - { err, topicUri: params.topicUri, service: "bluesky" }, - "Failed to cross-post to Bluesky", - ); + { err, topicUri: params.topicUri, service: 'bluesky' }, + 'Failed to cross-post to Bluesky' + ) notificationService .notifyOnCrossPostFailure({ topicUri: params.topicUri, authorDid: params.did, - service: "bluesky", + service: 'bluesky', communityDid: params.communityDid, }) .catch((notifErr: unknown) => { logger.error( { err: notifErr, topicUri: params.topicUri }, - "Failed to send cross-post failure notification", - ); - }); + 'Failed to send cross-post failure notification' + ) + }) return { - status: "rejected" as const, + status: 'rejected' as const, reason: err, - }; - }), - ); + } + }) + ) } if (config.frontpageEnabled) { tasks.push( crossPostToFrontpage(params) .then>(() => ({ - status: "fulfilled" as const, + status: 'fulfilled' as const, value: undefined, })) .catch>((err: unknown) => { logger.error( - { err, topicUri: params.topicUri, service: "frontpage" }, - "Failed to cross-post to Frontpage", - ); + { err, topicUri: params.topicUri, service: 'frontpage' }, + 'Failed to cross-post to Frontpage' + ) notificationService .notifyOnCrossPostFailure({ topicUri: params.topicUri, authorDid: params.did, - service: "frontpage", + service: 'frontpage', communityDid: params.communityDid, }) .catch((notifErr: unknown) => { logger.error( { err: notifErr, topicUri: params.topicUri }, - "Failed to send cross-post failure notification", - ); - }); + 'Failed to send cross-post failure notification' + ) + }) return { - status: "rejected" as const, + status: 'rejected' as const, reason: err, - }; - }), - ); + } + }) + ) } - await Promise.all(tasks); + await Promise.all(tasks) }, async deleteCrossPosts(topicUri: string, did: string): Promise { - const rows = await db - .select() - .from(crossPosts) - .where(eq(crossPosts.topicUri, topicUri)); + const rows = await db.select().from(crossPosts).where(eq(crossPosts.topicUri, topicUri)) for (const row of rows) { - const rkey = extractRkey(row.crossPostUri); - const collection = - row.service === "bluesky" - ? BLUESKY_COLLECTION - : FRONTPAGE_COLLECTION; + const rkey = extractRkey(row.crossPostUri) + const collection = row.service === 'bluesky' ? BLUESKY_COLLECTION : FRONTPAGE_COLLECTION try { - await pdsClient.deleteRecord(did, collection, rkey); + await pdsClient.deleteRecord(did, collection, rkey) logger.info( { crossPostUri: row.crossPostUri, service: row.service }, - "Deleted cross-post", - ); + 'Deleted cross-post' + ) } catch (err: unknown) { logger.warn( { err, crossPostUri: row.crossPostUri, service: row.service }, - "Failed to delete cross-post from PDS (best-effort)", - ); + 'Failed to delete cross-post from PDS (best-effort)' + ) } } // Always clean up DB rows regardless of PDS delete success - await db - .delete(crossPosts) - .where(eq(crossPosts.topicUri, topicUri)); + await db.delete(crossPosts).where(eq(crossPosts.topicUri, topicUri)) }, - }; + } } diff --git a/src/services/embedding.ts b/src/services/embedding.ts index 31c8367..8b12f2e 100644 --- a/src/services/embedding.ts +++ b/src/services/embedding.ts @@ -1,4 +1,4 @@ -import type { Logger } from "../lib/logger.js"; +import type { Logger } from '../lib/logger.js' // --------------------------------------------------------------------------- // Types @@ -6,14 +6,14 @@ import type { Logger } from "../lib/logger.js"; export interface EmbeddingService { /** Generate an embedding vector for the given text. Returns null on failure or when disabled. */ - generateEmbedding(text: string): Promise; + generateEmbedding(text: string): Promise /** Whether the embedding service is configured and available. */ - isEnabled(): boolean; + isEnabled(): boolean } /** OpenAI-compatible embedding response. */ interface EmbeddingResponse { - data: ReadonlyArray<{ embedding: number[] }>; + data: ReadonlyArray<{ embedding: number[] }> } // --------------------------------------------------------------------------- @@ -33,53 +33,53 @@ interface EmbeddingResponse { export function createEmbeddingService( embeddingUrl: string | undefined, dimensions: number, - logger: Logger, + logger: Logger ): EmbeddingService { - const enabled = typeof embeddingUrl === "string" && embeddingUrl.length > 0; + const enabled = typeof embeddingUrl === 'string' && embeddingUrl.length > 0 return { isEnabled(): boolean { - return enabled; + return enabled }, async generateEmbedding(text: string): Promise { if (!enabled || !embeddingUrl) { - return null; + return null } try { const response = await fetch(embeddingUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: text, - model: "default", + model: 'default', dimensions, }), signal: AbortSignal.timeout(10_000), - }); + }) if (!response.ok) { logger.warn( { status: response.status, url: embeddingUrl }, - "Embedding API returned non-OK status", - ); - return null; + 'Embedding API returned non-OK status' + ) + return null } - const body = (await response.json()) as EmbeddingResponse; - const embedding = body.data[0]?.embedding; + const body = (await response.json()) as EmbeddingResponse + const embedding = body.data[0]?.embedding if (!Array.isArray(embedding) || embedding.length === 0) { - logger.warn("Embedding API returned empty or invalid embedding"); - return null; + logger.warn('Embedding API returned empty or invalid embedding') + return null } - return embedding; + return embedding } catch (err: unknown) { - logger.warn({ err }, "Failed to generate embedding"); - return null; + logger.warn({ err }, 'Failed to generate embedding') + return null } }, - }; + } } diff --git a/src/services/interaction-graph.ts b/src/services/interaction-graph.ts new file mode 100644 index 0000000..13286e6 --- /dev/null +++ b/src/services/interaction-graph.ts @@ -0,0 +1,122 @@ +import { sql } from 'drizzle-orm' +import type { Database } from '../db/index.js' +import type { Logger } from '../lib/logger.js' +import { interactionGraph } from '../db/schema/interaction-graph.js' +import { replies } from '../db/schema/replies.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface InteractionGraphService { + recordReply(replierDid: string, topicAuthorDid: string, communityId: string): Promise + recordReaction(reactorDid: string, contentAuthorDid: string, communityId: string): Promise + recordCoParticipation(topicUri: string, communityId: string): Promise +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MAX_COPARTICIPATION_AUTHORS = 50 + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createInteractionGraphService( + db: Database, + logger: Logger +): InteractionGraphService { + async function upsertInteraction( + sourceDid: string, + targetDid: string, + communityId: string, + interactionType: 'reply' | 'reaction' | 'topic_coparticipation' + ): Promise { + // Skip self-interaction + if (sourceDid === targetDid) return + + await db + .insert(interactionGraph) + .values({ + sourceDid, + targetDid, + communityId, + interactionType, + weight: 1, + firstInteractionAt: new Date(), + lastInteractionAt: new Date(), + }) + .onConflictDoUpdate({ + target: [ + interactionGraph.sourceDid, + interactionGraph.targetDid, + interactionGraph.communityId, + interactionGraph.interactionType, + ], + set: { + weight: sql`${interactionGraph.weight} + 1`, + lastInteractionAt: new Date(), + }, + }) + } + + async function recordReply( + replierDid: string, + topicAuthorDid: string, + communityId: string + ): Promise { + await upsertInteraction(replierDid, topicAuthorDid, communityId, 'reply') + logger.debug({ replierDid, topicAuthorDid, communityId }, 'Recorded reply interaction') + } + + async function recordReaction( + reactorDid: string, + contentAuthorDid: string, + communityId: string + ): Promise { + await upsertInteraction(reactorDid, contentAuthorDid, communityId, 'reaction') + logger.debug({ reactorDid, contentAuthorDid, communityId }, 'Recorded reaction interaction') + } + + async function recordCoParticipation(topicUri: string, communityId: string): Promise { + // Get unique reply authors for the topic + const authorRows = await db + .select({ authorDid: replies.authorDid }) + .from(replies) + .where(sql`${replies.rootUri} = ${topicUri}`) + + // Deduplicate + const uniqueAuthors = [...new Set(authorRows.map((r) => r.authorDid))] + + // Skip if too many authors or not enough for pairs + if (uniqueAuthors.length > MAX_COPARTICIPATION_AUTHORS || uniqueAuthors.length < 2) { + if (uniqueAuthors.length > MAX_COPARTICIPATION_AUTHORS) { + logger.debug( + { topicUri, authorCount: uniqueAuthors.length }, + 'Skipping co-participation: too many authors' + ) + } + return + } + + // Create pairwise interactions + for (let i = 0; i < uniqueAuthors.length; i++) { + const authorA = uniqueAuthors[i] + if (!authorA) continue + for (let j = i + 1; j < uniqueAuthors.length; j++) { + const authorB = uniqueAuthors[j] + if (!authorB) continue + await upsertInteraction(authorA, authorB, communityId, 'topic_coparticipation') + } + } + + logger.debug( + { topicUri, authorCount: uniqueAuthors.length, communityId }, + 'Recorded co-participation interactions' + ) + } + + return { recordReply, recordReaction, recordCoParticipation } +} diff --git a/src/services/notification.ts b/src/services/notification.ts index 7b75914..4fb8462 100644 --- a/src/services/notification.ts +++ b/src/services/notification.ts @@ -1,100 +1,107 @@ -import { eq, inArray } from "drizzle-orm"; -import type { Database } from "../db/index.js"; -import type { Logger } from "../lib/logger.js"; -import { notifications } from "../db/schema/notifications.js"; -import { topics } from "../db/schema/topics.js"; -import { replies } from "../db/schema/replies.js"; -import { users } from "../db/schema/users.js"; +import { eq, inArray } from 'drizzle-orm' +import type { Database } from '../db/index.js' +import type { Logger } from '../lib/logger.js' +import { notifications } from '../db/schema/notifications.js' +import { topics } from '../db/schema/topics.js' +import { replies } from '../db/schema/replies.js' +import { users } from '../db/schema/users.js' // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Maximum unique @mentions that generate notifications per post. */ -const MAX_MENTION_NOTIFICATIONS = 10; +const MAX_MENTION_NOTIFICATIONS = 10 /** * Regex to extract @mentions from content. * Matches `@handle.domain.tld` patterns (AT Protocol handles). * Does NOT match bare `@word` without a dot -- that avoids false positives. */ -const MENTION_REGEX = /@([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)/g; +const MENTION_REGEX = + /@([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)/g // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -export type NotificationType = "reply" | "reaction" | "mention" | "mod_action" | "cross_post_failed" | "cross_post_revoked"; +export type NotificationType = + | 'reply' + | 'reaction' + | 'mention' + | 'mod_action' + | 'cross_post_failed' + | 'cross_post_revoked' export interface CrossPostScopeRevokedNotificationParams { /** DID of the user whose cross-post scopes were revoked (notification recipient). */ - authorDid: string; + authorDid: string /** Community DID. */ - communityDid: string; + communityDid: string } export interface NotificationService { - notifyOnReply(params: ReplyNotificationParams): Promise; - notifyOnReaction(params: ReactionNotificationParams): Promise; - notifyOnModAction(params: ModActionNotificationParams): Promise; - notifyOnMentions(params: MentionNotificationParams): Promise; - notifyOnCrossPostFailure(params: CrossPostFailureNotificationParams): Promise; - notifyOnCrossPostScopeRevoked(params: CrossPostScopeRevokedNotificationParams): Promise; + notifyOnReply(params: ReplyNotificationParams): Promise + notifyOnReaction(params: ReactionNotificationParams): Promise + notifyOnModAction(params: ModActionNotificationParams): Promise + notifyOnMentions(params: MentionNotificationParams): Promise + notifyOnCrossPostFailure(params: CrossPostFailureNotificationParams): Promise + notifyOnCrossPostScopeRevoked(params: CrossPostScopeRevokedNotificationParams): Promise } export interface ReplyNotificationParams { /** The reply URI (used as subjectUri in the notification). */ - replyUri: string; + replyUri: string /** DID of the user who created the reply. */ - actorDid: string; + actorDid: string /** URI of the root topic. */ - topicUri: string; + topicUri: string /** URI of the parent (topic URI if direct reply, reply URI if nested). */ - parentUri: string; + parentUri: string /** Community DID. */ - communityDid: string; + communityDid: string } export interface ReactionNotificationParams { /** The subject URI that was reacted to. */ - subjectUri: string; + subjectUri: string /** DID of the user who reacted. */ - actorDid: string; + actorDid: string /** Community DID. */ - communityDid: string; + communityDid: string } export interface ModActionNotificationParams { /** URI of the content affected by the mod action. */ - targetUri: string; + targetUri: string /** DID of the moderator. */ - moderatorDid: string; + moderatorDid: string /** DID of the content author (the notification recipient). */ - targetDid: string; + targetDid: string /** Community DID. */ - communityDid: string; + communityDid: string } export interface MentionNotificationParams { /** The content containing @mentions. */ - content: string; + content: string /** URI of the post/reply containing the mentions. */ - subjectUri: string; + subjectUri: string /** DID of the user who wrote the content. */ - actorDid: string; + actorDid: string /** Community DID. */ - communityDid: string; + communityDid: string } export interface CrossPostFailureNotificationParams { /** URI of the topic that failed to cross-post. */ - topicUri: string; + topicUri: string /** DID of the topic author (notification recipient). */ - authorDid: string; + authorDid: string /** Which cross-post service failed ("bluesky" or "frontpage"). */ - service: string; + service: string /** Community DID. */ - communityDid: string; + communityDid: string } // --------------------------------------------------------------------------- @@ -106,23 +113,23 @@ export interface CrossPostFailureNotificationParams { * Returns at most MAX_MENTION_NOTIFICATIONS handles. */ export function extractMentions(content: string): string[] { - const matches = new Set(); - let match: RegExpExecArray | null; + const matches = new Set() + let match: RegExpExecArray | null // Reset regex lastIndex for safety - MENTION_REGEX.lastIndex = 0; + MENTION_REGEX.lastIndex = 0 while ((match = MENTION_REGEX.exec(content)) !== null) { - const handle = match[1]; + const handle = match[1] if (handle) { - matches.add(handle.toLowerCase()); + matches.add(handle.toLowerCase()) } if (matches.size >= MAX_MENTION_NOTIFICATIONS) { - break; + break } } - return [...matches]; + return [...matches] } // --------------------------------------------------------------------------- @@ -136,10 +143,7 @@ export function extractMentions(content: string): string[] { * the calling flow. Self-notifications are suppressed (you don't get * notified about your own actions). */ -export function createNotificationService( - db: Database, - logger: Logger, -): NotificationService { +export function createNotificationService(db: Database, logger: Logger): NotificationService { /** * Insert a single notification row. * Skips silently if recipientDid === actorDid (no self-notifications). @@ -149,10 +153,10 @@ export function createNotificationService( type: NotificationType, subjectUri: string, actorDid: string, - communityDid: string, + communityDid: string ): Promise { if (recipientDid === actorDid) { - return; + return } await db.insert(notifications).values({ @@ -161,7 +165,7 @@ export function createNotificationService( subjectUri, actorDid, communityDid, - }); + }) } return { @@ -171,18 +175,18 @@ export function createNotificationService( const topicRows = await db .select({ authorDid: topics.authorDid }) .from(topics) - .where(eq(topics.uri, params.topicUri)); + .where(eq(topics.uri, params.topicUri)) - const topicAuthor = topicRows[0]?.authorDid; + const topicAuthor = topicRows[0]?.authorDid if (topicAuthor) { await insertNotification( topicAuthor, - "reply", + 'reply', params.replyUri, params.actorDid, - params.communityDid, - ); + params.communityDid + ) } // If this is a nested reply (parentUri !== topicUri), also notify @@ -191,24 +195,21 @@ export function createNotificationService( const parentReplyRows = await db .select({ authorDid: replies.authorDid }) .from(replies) - .where(eq(replies.uri, params.parentUri)); + .where(eq(replies.uri, params.parentUri)) - const parentAuthor = parentReplyRows[0]?.authorDid; + const parentAuthor = parentReplyRows[0]?.authorDid if (parentAuthor && parentAuthor !== topicAuthor) { await insertNotification( parentAuthor, - "reply", + 'reply', params.replyUri, params.actorDid, - params.communityDid, - ); + params.communityDid + ) } } } catch (err: unknown) { - logger.error( - { err, replyUri: params.replyUri }, - "Failed to generate reply notifications", - ); + logger.error({ err, replyUri: params.replyUri }, 'Failed to generate reply notifications') } }, @@ -218,33 +219,33 @@ export function createNotificationService( const topicRows = await db .select({ authorDid: topics.authorDid }) .from(topics) - .where(eq(topics.uri, params.subjectUri)); + .where(eq(topics.uri, params.subjectUri)) - let contentAuthor = topicRows[0]?.authorDid; + let contentAuthor = topicRows[0]?.authorDid if (!contentAuthor) { const replyRows = await db .select({ authorDid: replies.authorDid }) .from(replies) - .where(eq(replies.uri, params.subjectUri)); + .where(eq(replies.uri, params.subjectUri)) - contentAuthor = replyRows[0]?.authorDid; + contentAuthor = replyRows[0]?.authorDid } if (contentAuthor) { await insertNotification( contentAuthor, - "reaction", + 'reaction', params.subjectUri, params.actorDid, - params.communityDid, - ); + params.communityDid + ) } } catch (err: unknown) { logger.error( { err, subjectUri: params.subjectUri }, - "Failed to generate reaction notification", - ); + 'Failed to generate reaction notification' + ) } }, @@ -252,90 +253,88 @@ export function createNotificationService( try { await insertNotification( params.targetDid, - "mod_action", + 'mod_action', params.targetUri, params.moderatorDid, - params.communityDid, - ); + params.communityDid + ) } catch (err: unknown) { logger.error( { err, targetUri: params.targetUri }, - "Failed to generate mod action notification", - ); + 'Failed to generate mod action notification' + ) } }, async notifyOnMentions(params: MentionNotificationParams): Promise { try { - const handles = extractMentions(params.content); + const handles = extractMentions(params.content) if (handles.length === 0) { - return; + return } // Resolve handles to DIDs via the users table const resolvedUsers = await db .select({ did: users.did, handle: users.handle }) .from(users) - .where(inArray(users.handle, handles)); + .where(inArray(users.handle, handles)) // Generate a notification for each resolved user for (const resolved of resolvedUsers) { await insertNotification( resolved.did, - "mention", + 'mention', params.subjectUri, params.actorDid, - params.communityDid, - ); + params.communityDid + ) } } catch (err: unknown) { logger.error( { err, subjectUri: params.subjectUri }, - "Failed to generate mention notifications", - ); + 'Failed to generate mention notifications' + ) } }, - async notifyOnCrossPostFailure( - params: CrossPostFailureNotificationParams, - ): Promise { + async notifyOnCrossPostFailure(params: CrossPostFailureNotificationParams): Promise { try { // Use communityDid as actorDid since this is a system-generated // notification (avoids self-notification suppression) await db.insert(notifications).values({ recipientDid: params.authorDid, - type: "cross_post_failed", + type: 'cross_post_failed', subjectUri: params.topicUri, actorDid: params.communityDid, communityDid: params.communityDid, - }); + }) } catch (err: unknown) { logger.error( { err, topicUri: params.topicUri, service: params.service }, - "Failed to generate cross-post failure notification", - ); + 'Failed to generate cross-post failure notification' + ) } }, async notifyOnCrossPostScopeRevoked( - params: CrossPostScopeRevokedNotificationParams, + params: CrossPostScopeRevokedNotificationParams ): Promise { try { // Use communityDid as actorDid since this is a system-generated // notification (avoids self-notification suppression) await db.insert(notifications).values({ recipientDid: params.authorDid, - type: "cross_post_revoked", + type: 'cross_post_revoked', subjectUri: params.communityDid, actorDid: params.communityDid, communityDid: params.communityDid, - }); + }) } catch (err: unknown) { logger.error( { err, authorDid: params.authorDid }, - "Failed to generate cross-post scope revoked notification", - ); + 'Failed to generate cross-post scope revoked notification' + ) } }, - }; + } } diff --git a/src/services/og-image.ts b/src/services/og-image.ts index 3b158bc..9e1e86b 100644 --- a/src/services/og-image.ts +++ b/src/services/og-image.ts @@ -1,13 +1,13 @@ -import sharp from "sharp"; +import sharp from 'sharp' // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const OG_WIDTH = 1200; -const OG_HEIGHT = 630; -const MAX_TITLE_LINES = 3; -const MAX_CHARS_PER_LINE = 38; +const OG_WIDTH = 1200 +const OG_HEIGHT = 630 +const MAX_TITLE_LINES = 3 +const MAX_CHARS_PER_LINE = 38 // --------------------------------------------------------------------------- // Helpers (exported for testing) @@ -18,11 +18,11 @@ const MAX_CHARS_PER_LINE = 38; */ export function escapeXml(text: string): string { return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') } /** @@ -30,51 +30,47 @@ export function escapeXml(text: string): string { * limited to `maxLines` total. If truncated, the last line ends with * an ellipsis character. */ -export function wrapText( - text: string, - maxCharsPerLine: number, - maxLines: number, -): string[] { - const words = text.split(/\s+/).filter((w) => w.length > 0); +export function wrapText(text: string, maxCharsPerLine: number, maxLines: number): string[] { + const words = text.split(/\s+/).filter((w) => w.length > 0) if (words.length === 0) { - return []; + return [] } - const lines: string[] = []; - let currentLine = ""; + const lines: string[] = [] + let currentLine = '' for (const word of words) { if (lines.length >= maxLines) { - break; + break } - const testLine = currentLine ? `${currentLine} ${word}` : word; + const testLine = currentLine ? `${currentLine} ${word}` : word if (testLine.length > maxCharsPerLine && currentLine) { - lines.push(currentLine); - currentLine = word; + lines.push(currentLine) + currentLine = word } else { - currentLine = testLine; + currentLine = testLine } } if (currentLine && lines.length < maxLines) { - lines.push(currentLine); + lines.push(currentLine) } // Check if text was truncated - const joinedLength = lines.join(" ").length; - const fullLength = words.join(" ").length; + const joinedLength = lines.join(' ').length + const fullLength = words.join(' ').length if (fullLength > joinedLength && lines.length > 0) { - const lastIdx = lines.length - 1; - const lastLine = lines[lastIdx] ?? ""; + const lastIdx = lines.length - 1 + const lastLine = lines[lastIdx] ?? '' if (lastLine.length > maxCharsPerLine - 1) { - lines[lastIdx] = lastLine.slice(0, maxCharsPerLine - 1) + "\u2026"; + lines[lastIdx] = lastLine.slice(0, maxCharsPerLine - 1) + '\u2026' } else { - lines[lastIdx] = lastLine + "\u2026"; + lines[lastIdx] = lastLine + '\u2026' } } - return lines; + return lines } // --------------------------------------------------------------------------- @@ -82,9 +78,9 @@ export function wrapText( // --------------------------------------------------------------------------- export interface OgImageParams { - title: string; - category: string; - communityName: string; + title: string + category: string + communityName: string } /** @@ -98,25 +94,25 @@ export interface OgImageParams { * - Barazo branding footer */ export function generateOgSvg(params: OgImageParams): string { - const titleLines = wrapText(params.title, MAX_CHARS_PER_LINE, MAX_TITLE_LINES); - const categoryText = escapeXml(params.category.toUpperCase()); - const communityText = escapeXml(params.communityName); + const titleLines = wrapText(params.title, MAX_CHARS_PER_LINE, MAX_TITLE_LINES) + const categoryText = escapeXml(params.category.toUpperCase()) + const communityText = escapeXml(params.communityName) // Estimate category badge width (~11px per char + 32px padding) - const categoryWidth = String(Math.max(categoryText.length * 11 + 32, 60)); - const categoryTextX = String(60 + 16); - const categoryTextY = String(60 + 24); - const footerY = String(OG_HEIGHT - 40); - const brandingX = String(OG_WIDTH - 60); - const width = String(OG_WIDTH); - const height = String(OG_HEIGHT); + const categoryWidth = String(Math.max(categoryText.length * 11 + 32, 60)) + const categoryTextX = String(60 + 16) + const categoryTextY = String(60 + 24) + const footerY = String(OG_HEIGHT - 40) + const brandingX = String(OG_WIDTH - 60) + const width = String(OG_WIDTH) + const height = String(OG_HEIGHT) const titleSvg = titleLines .map( (line, i) => - `${escapeXml(line)}`, + `${escapeXml(line)}` ) - .join("\n "); + .join('\n ') return ` @@ -134,7 +130,7 @@ export function generateOgSvg(params: OgImageParams): string { Powered by Barazo barazo.forum -`; +` } // --------------------------------------------------------------------------- @@ -148,6 +144,6 @@ export function generateOgSvg(params: OgImageParams): string { * Bluesky's `app.bsky.embed.external` records. */ export async function generateOgImage(params: OgImageParams): Promise { - const svg = generateOgSvg(params); - return sharp(Buffer.from(svg)).png().toBuffer(); + const svg = generateOgSvg(params) + return sharp(Buffer.from(svg)).png().toBuffer() } diff --git a/src/services/ozone.ts b/src/services/ozone.ts index b141400..9165783 100644 --- a/src/services/ozone.ts +++ b/src/services/ozone.ts @@ -1,124 +1,119 @@ -import { eq, and } from "drizzle-orm"; -import type { Database } from "../db/index.js"; -import type { Cache } from "../cache/index.js"; -import type { Logger } from "../lib/logger.js"; -import { ozoneLabels } from "../db/schema/ozone-labels.js"; - -const CACHE_TTL = 3600; // 1 hour -const CACHE_PREFIX = "ozone:labels:"; -const INITIAL_RECONNECT_MS = 1000; -const MAX_RECONNECT_MS = 60000; -const SPAM_LABELS = new Set(["spam", "!hide"]); +import { eq, and } from 'drizzle-orm' +import type { Database } from '../db/index.js' +import type { Cache } from '../cache/index.js' +import type { Logger } from '../lib/logger.js' +import { ozoneLabels } from '../db/schema/ozone-labels.js' + +const CACHE_TTL = 3600 // 1 hour +const CACHE_PREFIX = 'ozone:labels:' +const INITIAL_RECONNECT_MS = 1000 +const MAX_RECONNECT_MS = 60000 +const SPAM_LABELS = new Set(['spam', '!hide']) interface LabelEvent { - seq: number; - labels: Label[]; + seq: number + labels: Label[] } interface Label { - src: string; - uri: string; - val: string; - neg?: boolean; - cts: string; - exp?: string; + src: string + uri: string + val: string + neg?: boolean + cts: string + exp?: string } interface CachedLabel { - val: string; - src: string; - neg: boolean; + val: string + src: string + neg: boolean } export class OzoneService { - private ws: WebSocket | null = null; - private reconnectMs = INITIAL_RECONNECT_MS; - private stopping = false; + private ws: WebSocket | null = null + private reconnectMs = INITIAL_RECONNECT_MS + private stopping = false constructor( private db: Database, private cache: Cache, private logger: Logger, - private labelerUrl: string, + private labelerUrl: string ) {} start(): void { - this.stopping = false; - this.connect(); + this.stopping = false + this.connect() } stop(): void { - this.stopping = true; + this.stopping = true if (this.ws) { - this.ws.close(); - this.ws = null; + this.ws.close() + this.ws = null } } private connect(): void { - if (this.stopping) return; + if (this.stopping) return - const wsUrl = this.labelerUrl - .replace(/^https?:/, "wss:") - .replace(/\/$/, ""); - const url = `${wsUrl}/xrpc/com.atproto.label.subscribeLabels`; + const wsUrl = this.labelerUrl.replace(/^https?:/, 'wss:').replace(/\/$/, '') + const url = `${wsUrl}/xrpc/com.atproto.label.subscribeLabels` - this.logger.info({ url }, "Connecting to Ozone labeler"); + this.logger.info({ url }, 'Connecting to Ozone labeler') try { - this.ws = new WebSocket(url); + this.ws = new WebSocket(url) } catch (err) { - this.logger.warn({ err }, "Failed to create Ozone WebSocket"); - this.scheduleReconnect(); - return; + this.logger.warn({ err }, 'Failed to create Ozone WebSocket') + this.scheduleReconnect() + return } - this.ws.addEventListener("open", () => { - this.logger.info("Connected to Ozone labeler"); - this.reconnectMs = INITIAL_RECONNECT_MS; - }); + this.ws.addEventListener('open', () => { + this.logger.info('Connected to Ozone labeler') + this.reconnectMs = INITIAL_RECONNECT_MS + }) - this.ws.addEventListener("message", (event) => { - void this.handleMessage(event.data); - }); + this.ws.addEventListener('message', (event) => { + void this.handleMessage(event.data) + }) - this.ws.addEventListener("close", () => { - this.logger.info("Ozone labeler connection closed"); - this.scheduleReconnect(); - }); + this.ws.addEventListener('close', () => { + this.logger.info('Ozone labeler connection closed') + this.scheduleReconnect() + }) - this.ws.addEventListener("error", (event) => { - this.logger.warn({ event }, "Ozone labeler WebSocket error"); - }); + this.ws.addEventListener('error', (event) => { + this.logger.warn({ event }, 'Ozone labeler WebSocket error') + }) } private scheduleReconnect(): void { - if (this.stopping) return; + if (this.stopping) return - this.logger.info( - { reconnectMs: this.reconnectMs }, - "Scheduling Ozone labeler reconnect", - ); + this.logger.info({ reconnectMs: this.reconnectMs }, 'Scheduling Ozone labeler reconnect') setTimeout(() => { - this.connect(); - }, this.reconnectMs); + this.connect() + }, this.reconnectMs) - this.reconnectMs = Math.min(this.reconnectMs * 2, MAX_RECONNECT_MS); + this.reconnectMs = Math.min(this.reconnectMs * 2, MAX_RECONNECT_MS) } private async handleMessage(data: unknown): Promise { try { - const text = typeof data === "string" ? data : String(data); - const event = JSON.parse(text) as LabelEvent; + const text = typeof data === 'string' ? data : String(data) + const event = JSON.parse(text) as LabelEvent - if (!Array.isArray(event.labels)) return; + if (!Array.isArray(event.labels)) return for (const label of event.labels) { - await this.processLabel(label); + await this.processLabel(label) } } catch (err) { - this.logger.warn({ err }, "Failed to process Ozone label event"); + this.logger.warn({ err }, 'Failed to process Ozone label event') } } @@ -131,9 +126,9 @@ export class OzoneService { and( eq(ozoneLabels.src, label.src), eq(ozoneLabels.uri, label.uri), - eq(ozoneLabels.val, label.val), - ), - ); + eq(ozoneLabels.val, label.val) + ) + ) } else { // Upsert the label await this.db @@ -154,12 +149,12 @@ export class OzoneService { exp: label.exp ? new Date(label.exp) : undefined, indexedAt: new Date(), }, - }); + }) } // Invalidate cache for this URI try { - await this.cache.del(`${CACHE_PREFIX}${label.uri}`); + await this.cache.del(`${CACHE_PREFIX}${label.uri}`) } catch { // Non-critical } @@ -170,13 +165,13 @@ export class OzoneService { * Results are cached in Valkey for 1 hour. */ async getLabels(uri: string): Promise { - const cacheKey = `${CACHE_PREFIX}${uri}`; + const cacheKey = `${CACHE_PREFIX}${uri}` // Try cache first try { - const cached = await this.cache.get(cacheKey); + const cached = await this.cache.get(cacheKey) if (cached) { - return JSON.parse(cached) as CachedLabel[]; + return JSON.parse(cached) as CachedLabel[] } } catch { // Fall through to DB @@ -189,42 +184,37 @@ export class OzoneService { neg: ozoneLabels.neg, }) .from(ozoneLabels) - .where( - and( - eq(ozoneLabels.uri, uri), - eq(ozoneLabels.neg, false), - ), - ); + .where(and(eq(ozoneLabels.uri, uri), eq(ozoneLabels.neg, false))) const labels: CachedLabel[] = rows.map((r) => ({ val: r.val, src: r.src, neg: r.neg, - })); + })) // Cache result try { - await this.cache.set(cacheKey, JSON.stringify(labels), "EX", CACHE_TTL); + await this.cache.set(cacheKey, JSON.stringify(labels), 'EX', CACHE_TTL) } catch { // Non-critical } - return labels; + return labels } /** * Check if a URI has a specific label value. */ async hasLabel(uri: string, val: string): Promise { - const labels = await this.getLabels(uri); - return labels.some((l) => l.val === val); + const labels = await this.getLabels(uri) + return labels.some((l) => l.val === val) } /** * Check if a DID or URI has any spam-related labels (spam, !hide). */ async isSpamLabeled(didOrUri: string): Promise { - const labels = await this.getLabels(didOrUri); - return labels.some((l) => SPAM_LABELS.has(l.val)); + const labels = await this.getLabels(didOrUri) + return labels.some((l) => SPAM_LABELS.has(l.val)) } } diff --git a/src/services/plc-did.ts b/src/services/plc-did.ts index 2590e8d..60c164d 100644 --- a/src/services/plc-did.ts +++ b/src/services/plc-did.ts @@ -1,7 +1,7 @@ -import { createHash, createHmac } from "node:crypto"; -import * as secp256k1 from "@noble/secp256k1"; -import * as dagCbor from "@ipld/dag-cbor"; -import type { Logger } from "../lib/logger.js"; +import { createHash, createHmac } from 'node:crypto' +import * as secp256k1 from '@noble/secp256k1' +import * as dagCbor from '@ipld/dag-cbor' +import type { Logger } from '../lib/logger.js' // --------------------------------------------------------------------------- // Configure @noble/secp256k1 v3 sync hashes (required for sync sign/verify) @@ -9,24 +9,24 @@ import type { Logger } from "../lib/logger.js"; // --------------------------------------------------------------------------- secp256k1.hashes.hmacSha256 = (key: Uint8Array, message: Uint8Array) => { - return new Uint8Array(createHmac("sha256", key).update(message).digest()); -}; + return new Uint8Array(createHmac('sha256', key).update(message).digest()) +} secp256k1.hashes.sha256 = (message: Uint8Array) => { - return new Uint8Array(createHash("sha256").update(message).digest()); -}; + return new Uint8Array(createHash('sha256').update(message).digest()) +} // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const DEFAULT_PLC_DIRECTORY_URL = "https://plc.directory"; +const DEFAULT_PLC_DIRECTORY_URL = 'https://plc.directory' /** * Multicodec prefix for secp256k1 public keys. * Varint-encoded 0xe7 = [0xe7, 0x01]. */ -const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01]); +const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01]) // --------------------------------------------------------------------------- // Types @@ -35,48 +35,48 @@ const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01]); /** Parameters for generating a PLC DID. */ export interface GenerateDidParams { /** Community handle, e.g. "community.barazo.forum" */ - handle: string; + handle: string /** Community service endpoint, e.g. "https://community.barazo.forum" */ - serviceEndpoint: string; + serviceEndpoint: string /** PLC directory URL. Defaults to https://plc.directory */ - plcDirectoryUrl?: string; + plcDirectoryUrl?: string } /** Result of PLC DID generation. */ export interface GenerateDidResult { /** The generated DID, e.g. "did:plc:abc123..." */ - did: string; + did: string /** Hex-encoded signing private key */ - signingKey: string; + signingKey: string /** Hex-encoded rotation private key */ - rotationKey: string; + rotationKey: string } /** PLC genesis operation (unsigned). */ export interface PlcGenesisOperation { - type: "plc_operation"; - rotationKeys: string[]; + type: 'plc_operation' + rotationKeys: string[] verificationMethods: { - atproto: string; - }; - alsoKnownAs: string[]; + atproto: string + } + alsoKnownAs: string[] services: { atproto_pds: { - type: "AtprotoPersonalDataServer"; - endpoint: string; - }; - }; - prev: null; + type: 'AtprotoPersonalDataServer' + endpoint: string + } + } + prev: null } /** PLC genesis operation with signature. */ export interface SignedPlcOperation extends PlcGenesisOperation { - sig: string; + sig: string } /** PLC DID service interface for dependency injection and testing. */ export interface PlcDidService { - generateDid(params: GenerateDidParams): Promise; + generateDid(params: GenerateDidParams): Promise } // --------------------------------------------------------------------------- @@ -88,27 +88,27 @@ export interface PlcDidService { * Used for PLC DID computation. */ export function base32Encode(bytes: Uint8Array): string { - const alphabet = "abcdefghijklmnopqrstuvwxyz234567"; - let bits = 0; - let value = 0; - let output = ""; + const alphabet = 'abcdefghijklmnopqrstuvwxyz234567' + let bits = 0 + let value = 0 + let output = '' for (const byte of bytes) { - value = (value << 8) | byte; - bits += 8; + value = (value << 8) | byte + bits += 8 while (bits >= 5) { - bits -= 5; - const char = alphabet[(value >>> bits) & 31]; - if (char !== undefined) output += char; + bits -= 5 + const char = alphabet[(value >>> bits) & 31] + if (char !== undefined) output += char } } if (bits > 0) { - const char = alphabet[(value << (5 - bits)) & 31]; - if (char !== undefined) output += char; + const char = alphabet[(value << (5 - bits)) & 31] + if (char !== undefined) output += char } - return output; + return output } /** @@ -121,47 +121,45 @@ export function base32Encode(bytes: Uint8Array): string { */ export function compressedPubKeyToDidKey(pubKey: Uint8Array): string { // Concatenate multicodec prefix + compressed public key - const prefixed = new Uint8Array( - SECP256K1_MULTICODEC_PREFIX.length + pubKey.length, - ); - prefixed.set(SECP256K1_MULTICODEC_PREFIX, 0); - prefixed.set(pubKey, SECP256K1_MULTICODEC_PREFIX.length); + const prefixed = new Uint8Array(SECP256K1_MULTICODEC_PREFIX.length + pubKey.length) + prefixed.set(SECP256K1_MULTICODEC_PREFIX, 0) + prefixed.set(pubKey, SECP256K1_MULTICODEC_PREFIX.length) // Base58btc encode (with 'z' multibase prefix) - const encoded = base58btcEncode(prefixed); - return `did:key:z${encoded}`; + const encoded = base58btcEncode(prefixed) + return `did:key:z${encoded}` } /** * Base58btc encoding using the Bitcoin alphabet. */ export function base58btcEncode(bytes: Uint8Array): string { - const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' // Count leading zeros - let leadingZeros = 0; + let leadingZeros = 0 for (const b of bytes) { - if (b !== 0) break; - leadingZeros++; + if (b !== 0) break + leadingZeros++ } // Convert bytes to a BigInt - let num = 0n; + let num = 0n for (const b of bytes) { - num = num * 256n + BigInt(b); + num = num * 256n + BigInt(b) } // Encode to base58 - let encoded = ""; + let encoded = '' while (num > 0n) { - const remainder = Number(num % 58n); - num = num / 58n; - const char = ALPHABET[remainder] ?? ""; - encoded = char + encoded; + const remainder = Number(num % 58n) + num = num / 58n + const char = ALPHABET[remainder] ?? '' + encoded = char + encoded } // Add leading '1's for each leading zero byte - return "1".repeat(leadingZeros) + encoded; + return '1'.repeat(leadingZeros) + encoded } /** @@ -171,10 +169,10 @@ export function buildGenesisOperation( signingPubKeyDidKey: string, rotationPubKeyDidKey: string, handle: string, - serviceEndpoint: string, + serviceEndpoint: string ): PlcGenesisOperation { return { - type: "plc_operation", + type: 'plc_operation', rotationKeys: [rotationPubKeyDidKey], verificationMethods: { atproto: signingPubKeyDidKey, @@ -182,12 +180,12 @@ export function buildGenesisOperation( alsoKnownAs: [`at://${handle}`], services: { atproto_pds: { - type: "AtprotoPersonalDataServer", + type: 'AtprotoPersonalDataServer', endpoint: serviceEndpoint, }, }, prev: null, - }; + } } /** @@ -201,19 +199,19 @@ export function buildGenesisOperation( */ export function signGenesisOperation( operation: PlcGenesisOperation, - rotationPrivKey: Uint8Array, + rotationPrivKey: Uint8Array ): SignedPlcOperation { - const cborBytes = dagCbor.encode(operation); - const hash = createHash("sha256").update(cborBytes).digest(); + const cborBytes = dagCbor.encode(operation) + const hash = createHash('sha256').update(cborBytes).digest() // secp256k1 v3 sign() returns compact Bytes directly. // prehash: false because we already SHA-256 hashed the CBOR bytes. const sigBytes = secp256k1.sign(new Uint8Array(hash), rotationPrivKey, { prehash: false, - }); - const sig = Buffer.from(sigBytes).toString("base64url"); + }) + const sig = Buffer.from(sigBytes).toString('base64url') - return { ...operation, sig }; + return { ...operation, sig } } /** @@ -224,14 +222,12 @@ export function signGenesisOperation( * The first 15 bytes (120 bits) of the SHA-256 hash are base32-encoded * to produce a 24-character identifier. */ -export function computeDidFromSignedOperation( - signedOp: SignedPlcOperation, -): string { - const cborBytes = dagCbor.encode(signedOp); - const hash = createHash("sha256").update(cborBytes).digest(); - const truncated = hash.subarray(0, 15); - const encoded = base32Encode(new Uint8Array(truncated)); - return `did:plc:${encoded}`; +export function computeDidFromSignedOperation(signedOp: SignedPlcOperation): string { + const cborBytes = dagCbor.encode(signedOp) + const hash = createHash('sha256').update(cborBytes).digest() + const truncated = hash.subarray(0, 15) + const encoded = base32Encode(new Uint8Array(truncated)) + return `did:plc:${encoded}` } /** @@ -241,30 +237,25 @@ async function submitToPlcDirectory( did: string, signedOp: SignedPlcOperation, plcDirectoryUrl: string, - logger: Logger, + logger: Logger ): Promise { - const url = `${plcDirectoryUrl}/${did}`; + const url = `${plcDirectoryUrl}/${did}` - logger.info({ did, plcDirectoryUrl }, "Submitting PLC genesis operation"); + logger.info({ did, plcDirectoryUrl }, 'Submitting PLC genesis operation') const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(signedOp), - }); + }) if (!response.ok) { - const body = await response.text(); - logger.error( - { did, status: response.status, body }, - "PLC directory rejected genesis operation", - ); - throw new Error( - `PLC directory returned ${String(response.status)}: ${body}`, - ); + const body = await response.text() + logger.error({ did, status: response.status, body }, 'PLC directory rejected genesis operation') + throw new Error(`PLC directory returned ${String(response.status)}: ${body}`) } - logger.info({ did }, "PLC DID registered successfully"); + logger.info({ did }, 'PLC DID registered successfully') } // --------------------------------------------------------------------------- @@ -282,52 +273,49 @@ async function submitToPlcDirectory( * @returns PlcDidService with generateDid method */ export function createPlcDidService(logger: Logger): PlcDidService { - async function generateDid( - params: GenerateDidParams, - ): Promise { - const plcDirectoryUrl = - params.plcDirectoryUrl ?? DEFAULT_PLC_DIRECTORY_URL; + async function generateDid(params: GenerateDidParams): Promise { + const plcDirectoryUrl = params.plcDirectoryUrl ?? DEFAULT_PLC_DIRECTORY_URL logger.info( { handle: params.handle, serviceEndpoint: params.serviceEndpoint }, - "Generating PLC DID for community", - ); + 'Generating PLC DID for community' + ) // 1. Generate key pairs (v3: utils.randomSecretKey) - const signingPrivKey = secp256k1.utils.randomSecretKey(); - const signingPubKey = secp256k1.getPublicKey(signingPrivKey, true); + const signingPrivKey = secp256k1.utils.randomSecretKey() + const signingPubKey = secp256k1.getPublicKey(signingPrivKey, true) - const rotationPrivKey = secp256k1.utils.randomSecretKey(); - const rotationPubKey = secp256k1.getPublicKey(rotationPrivKey, true); + const rotationPrivKey = secp256k1.utils.randomSecretKey() + const rotationPubKey = secp256k1.getPublicKey(rotationPrivKey, true) // 2. Encode public keys as did:key - const signingDidKey = compressedPubKeyToDidKey(signingPubKey); - const rotationDidKey = compressedPubKeyToDidKey(rotationPubKey); + const signingDidKey = compressedPubKeyToDidKey(signingPubKey) + const rotationDidKey = compressedPubKeyToDidKey(rotationPubKey) // 3. Build genesis operation const genesisOp = buildGenesisOperation( signingDidKey, rotationDidKey, params.handle, - params.serviceEndpoint, - ); + params.serviceEndpoint + ) // 4. Sign with rotation key - const signedOp = signGenesisOperation(genesisOp, rotationPrivKey); + const signedOp = signGenesisOperation(genesisOp, rotationPrivKey) // 5. Compute DID - const did = computeDidFromSignedOperation(signedOp); + const did = computeDidFromSignedOperation(signedOp) // 6. Submit to plc.directory - await submitToPlcDirectory(did, signedOp, plcDirectoryUrl, logger); + await submitToPlcDirectory(did, signedOp, plcDirectoryUrl, logger) // 7. Return DID and hex-encoded private keys return { did, - signingKey: Buffer.from(signingPrivKey).toString("hex"), - rotationKey: Buffer.from(rotationPrivKey).toString("hex"), - }; + signingKey: Buffer.from(signingPrivKey).toString('hex'), + rotationKey: Buffer.from(rotationPrivKey).toString('hex'), + } } - return { generateDid }; + return { generateDid } } diff --git a/src/services/profile-sync.ts b/src/services/profile-sync.ts index 2fc8605..3a363f3 100644 --- a/src/services/profile-sync.ts +++ b/src/services/profile-sync.ts @@ -1,9 +1,9 @@ -import { Agent } from "@atproto/api"; -import { eq } from "drizzle-orm"; -import type { NodeOAuthClient } from "@atproto/oauth-client-node"; -import type { Logger } from "../lib/logger.js"; -import type { Database } from "../db/index.js"; -import { users } from "../db/schema/users.js"; +import { Agent } from '@atproto/api' +import { eq } from 'drizzle-orm' +import type { NodeOAuthClient } from '@atproto/oauth-client-node' +import type { Logger } from '../lib/logger.js' +import type { Database } from '../db/index.js' +import { users } from '../db/schema/users.js' // --------------------------------------------------------------------------- // Types @@ -11,14 +11,14 @@ import { users } from "../db/schema/users.js"; /** Profile data extracted from the user's PDS. */ export interface ProfileData { - displayName: string | null; - avatarUrl: string | null; - bannerUrl: string | null; - bio: string | null; + displayName: string | null + avatarUrl: string | null + bannerUrl: string | null + bio: string | null } export interface ProfileSyncService { - syncProfile(did: string): Promise; + syncProfile(did: string): Promise } /** Null profile returned on any failure. */ @@ -27,7 +27,7 @@ const NULL_PROFILE: ProfileData = { avatarUrl: null, bannerUrl: null, bio: null, -}; +} // --------------------------------------------------------------------------- // Agent factory (injectable for testing) @@ -36,23 +36,23 @@ const NULL_PROFILE: ProfileData = { interface AgentLike { getProfile(params: { actor: string }): Promise<{ data: { - displayName?: string; - avatar?: string; - banner?: string; - description?: string; - }; - }>; + displayName?: string + avatar?: string + banner?: string + description?: string + } + }> } interface AgentFactory { - createAgent(session: unknown): AgentLike; + createAgent(session: unknown): AgentLike } const defaultAgentFactory: AgentFactory = { createAgent(session: unknown): AgentLike { - return new Agent(session as ConstructorParameters[0]); + return new Agent(session as ConstructorParameters[0]) }, -}; +} // --------------------------------------------------------------------------- // Factory @@ -71,39 +71,33 @@ export function createProfileSyncService( oauthClient: NodeOAuthClient, db: Database, logger: Logger, - agentFactory: AgentFactory = defaultAgentFactory, + agentFactory: AgentFactory = defaultAgentFactory ): ProfileSyncService { return { async syncProfile(did: string): Promise { // 1. Restore OAuth session and create agent - let agent: AgentLike; + let agent: AgentLike try { - const session = await oauthClient.restore(did); - agent = agentFactory.createAgent(session); + const session = await oauthClient.restore(did) + agent = agentFactory.createAgent(session) } catch (err: unknown) { - logger.debug( - { did, err }, - "profile sync failed: could not restore OAuth session", - ); - return NULL_PROFILE; + logger.debug({ did, err }, 'profile sync failed: could not restore OAuth session') + return NULL_PROFILE } // 2. Fetch profile from PDS - let profileData: ProfileData; + let profileData: ProfileData try { - const response = await agent.getProfile({ actor: did }); + const response = await agent.getProfile({ actor: did }) profileData = { displayName: response.data.displayName ?? null, avatarUrl: response.data.avatar ?? null, bannerUrl: response.data.banner ?? null, bio: response.data.description ?? null, - }; + } } catch (err: unknown) { - logger.debug( - { did, err }, - "profile sync failed: could not fetch profile from PDS", - ); - return NULL_PROFILE; + logger.debug({ did, err }, 'profile sync failed: could not fetch profile from PDS') + return NULL_PROFILE } // 3. Best-effort DB update @@ -117,15 +111,12 @@ export function createProfileSyncService( bio: profileData.bio, lastActiveAt: new Date(), }) - .where(eq(users.did, did)); + .where(eq(users.did, did)) } catch (err: unknown) { - logger.warn( - { did, err }, - "profile DB update failed: could not persist profile data", - ); + logger.warn({ did, err }, 'profile DB update failed: could not persist profile data') } - return profileData; + return profileData }, - }; + } } diff --git a/src/services/sybil-detector.ts b/src/services/sybil-detector.ts new file mode 100644 index 0000000..51f3060 --- /dev/null +++ b/src/services/sybil-detector.ts @@ -0,0 +1,329 @@ +import { eq, and, sql, lt, or, inArray } from 'drizzle-orm' +import { createHash } from 'node:crypto' +import type { Database } from '../db/index.js' +import type { Logger } from '../lib/logger.js' +import { interactionGraph } from '../db/schema/interaction-graph.js' +import { trustScores } from '../db/schema/trust-scores.js' +import { sybilClusters } from '../db/schema/sybil-clusters.js' +import { sybilClusterMembers } from '../db/schema/sybil-cluster-members.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface DetectionResult { + clustersDetected: number + totalLowTrustDids: number + durationMs: number +} + +export interface SybilDetectorService { + detectClusters(communityId: string | null): Promise +} + +export interface ClusterInfo { + members: string[] + internalEdges: number + externalEdges: number + ratio: number +} + +// --------------------------------------------------------------------------- +// Pure cluster detection (exported for simulation tests) +// --------------------------------------------------------------------------- + +type Edge = { target: string; weight: number } + +/** + * Find connected components of low-trust DIDs and identify sybil clusters. + * + * @param lowTrustDids - Set of DIDs with trust below threshold + * @param subgraphEdges - Adjacency list of edges between low-trust DIDs (undirected) + * @param allEdges - Full adjacency list (directed) for counting external edges + * @param minSize - Minimum component size to consider (default 3) + * @param ratioThreshold - Internal/(internal+external) ratio to flag (default 0.8) + */ +export function findSybilClusters( + lowTrustDids: Set, + subgraphEdges: Map>, + allEdges: Map, + minSize: number = 3, + ratioThreshold: number = 0.8 +): ClusterInfo[] { + // Find connected components using BFS + const visited = new Set() + const components: string[][] = [] + + for (const did of lowTrustDids) { + if (visited.has(did)) continue + // Only start BFS from nodes that appear in the subgraph + if (!subgraphEdges.has(did)) { + visited.add(did) + continue + } + + const component: string[] = [] + const queue = [did] + visited.add(did) + + while (queue.length > 0) { + const current = queue.shift() + if (current === undefined) break + component.push(current) + + const neighbors = subgraphEdges.get(current) + if (neighbors) { + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + visited.add(neighbor) + queue.push(neighbor) + } + } + } + } + + if (component.length >= minSize) { + components.push(component) + } + } + + // For each component, count internal vs external edges + const clusters: ClusterInfo[] = [] + + for (const component of components) { + const memberSet = new Set(component) + let internalEdges = 0 + let externalEdges = 0 + + for (const member of component) { + const targets = allEdges.get(member) + if (!targets) continue + for (const { target } of targets) { + if (memberSet.has(target)) { + internalEdges++ + } else { + externalEdges++ + } + } + } + + const total = internalEdges + externalEdges + const ratio = total > 0 ? internalEdges / total : 0 + + if (ratio > ratioThreshold) { + clusters.push({ + members: component, + internalEdges, + externalEdges, + ratio, + }) + } + } + + return clusters +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +const LOW_TRUST_THRESHOLD = 0.05 +const MIN_CLUSTER_SIZE = 3 +const INTERNAL_RATIO_THRESHOLD = 0.8 + +export function createSybilDetectorService(db: Database, logger: Logger): SybilDetectorService { + async function detectClusters(communityId: string | null): Promise { + const start = Date.now() + + // 1. Find low-trust DIDs (empty string = global scope) + const trustFilter = communityId + ? and( + lt(trustScores.score, LOW_TRUST_THRESHOLD), + or(eq(trustScores.communityId, communityId), eq(trustScores.communityId, '')) + ) + : and(lt(trustScores.score, LOW_TRUST_THRESHOLD), eq(trustScores.communityId, '')) + + const lowTrustRows = await db + .select({ did: trustScores.did }) + .from(trustScores) + .where(trustFilter) + + const lowTrustDids = new Set(lowTrustRows.map((r) => r.did)) + + if (lowTrustDids.size === 0) { + logger.info({ communityId }, 'No low-trust DIDs found, skipping sybil detection') + return { + clustersDetected: 0, + totalLowTrustDids: 0, + durationMs: Date.now() - start, + } + } + + // 2. Build subgraph of edges between low-trust DIDs + const lowTrustArray = Array.from(lowTrustDids) + const communityFilter = communityId ? eq(interactionGraph.communityId, communityId) : sql`true` + + const subgraphRows = await db + .select({ + source_did: interactionGraph.sourceDid, + target_did: interactionGraph.targetDid, + weight: interactionGraph.weight, + }) + .from(interactionGraph) + .where( + and( + communityFilter, + inArray(interactionGraph.sourceDid, lowTrustArray), + inArray(interactionGraph.targetDid, lowTrustArray) + ) + ) + + // Build undirected subgraph adjacency + const subgraphEdges = new Map>() + for (const row of subgraphRows) { + const sourceSet = subgraphEdges.get(row.source_did) ?? new Set() + sourceSet.add(row.target_did) + subgraphEdges.set(row.source_did, sourceSet) + + const targetSet = subgraphEdges.get(row.target_did) ?? new Set() + targetSet.add(row.source_did) + subgraphEdges.set(row.target_did, targetSet) + } + + // 3. Load ALL edges involving low-trust DIDs (for internal/external ratio) + const allEdgesRows = await db + .select({ + source_did: interactionGraph.sourceDid, + target_did: interactionGraph.targetDid, + weight: interactionGraph.weight, + }) + .from(interactionGraph) + .where( + and( + communityFilter, + or( + inArray(interactionGraph.sourceDid, lowTrustArray), + inArray(interactionGraph.targetDid, lowTrustArray) + ) + ) + ) + + // Build directed adjacency from low-trust sources + const allEdges = new Map() + for (const row of allEdgesRows) { + if (lowTrustDids.has(row.source_did)) { + const existing = allEdges.get(row.source_did) + if (existing) { + existing.push({ target: row.target_did, weight: row.weight }) + } else { + allEdges.set(row.source_did, [{ target: row.target_did, weight: row.weight }]) + } + } + } + + // 4. Run cluster detection + const clusterInfos = findSybilClusters( + lowTrustDids, + subgraphEdges, + allEdges, + MIN_CLUSTER_SIZE, + INTERNAL_RATIO_THRESHOLD + ) + + // 5. Upsert clusters to database + let clustersDetected = 0 + + for (const cluster of clusterInfos) { + const sortedMembers = [...cluster.members].sort() + const clusterHash = createHash('sha256').update(sortedMembers.join(',')).digest('hex') + + // Check if dismissed cluster exists with same hash + const existingRows = await db + .select({ + id: sybilClusters.id, + status: sybilClusters.status, + }) + .from(sybilClusters) + .where(eq(sybilClusters.clusterHash, clusterHash)) + + const existing = existingRows[0] + if (existing?.status === 'dismissed') { + // Skip dismissed clusters unless members changed (hash handles this) + continue + } + + // Upsert cluster + const clusterRows = await db + .insert(sybilClusters) + .values({ + clusterHash, + internalEdgeCount: cluster.internalEdges, + externalEdgeCount: cluster.externalEdges, + memberCount: cluster.members.length, + status: 'flagged', + detectedAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [sybilClusters.clusterHash], + set: { + internalEdgeCount: cluster.internalEdges, + externalEdgeCount: cluster.externalEdges, + memberCount: cluster.members.length, + updatedAt: new Date(), + }, + }) + .returning({ id: sybilClusters.id }) + + const clusterId = clusterRows[0]?.id + if (clusterId == null) continue + + // Compute median internal connections for core/peripheral classification + const connectionCounts: number[] = [] + for (const member of sortedMembers) { + const neighbors = subgraphEdges.get(member) + connectionCounts.push(neighbors?.size ?? 0) + } + connectionCounts.sort((a, b) => a - b) + const median = connectionCounts[Math.floor(connectionCounts.length / 2)] ?? 0 + + // Delete existing members and re-insert + await db.delete(sybilClusterMembers).where(eq(sybilClusterMembers.clusterId, clusterId)) + + for (const member of sortedMembers) { + const neighbors = subgraphEdges.get(member) + const count = neighbors?.size ?? 0 + const role = count > median ? 'core' : 'peripheral' + + await db.insert(sybilClusterMembers).values({ + clusterId, + did: member, + roleInCluster: role, + joinedAt: new Date(), + }) + } + + clustersDetected++ + } + + const durationMs = Date.now() - start + + logger.info( + { + communityId, + totalLowTrustDids: lowTrustDids.size, + clustersDetected, + durationMs, + }, + 'Sybil detection completed' + ) + + return { + clustersDetected, + totalLowTrustDids: lowTrustDids.size, + durationMs, + } + } + + return { detectClusters } +} diff --git a/src/services/trust-graph.ts b/src/services/trust-graph.ts new file mode 100644 index 0000000..526a9fc --- /dev/null +++ b/src/services/trust-graph.ts @@ -0,0 +1,303 @@ +import { eq, and, sql, or, inArray } from 'drizzle-orm' +import type { Database } from '../db/index.js' +import type { Logger } from '../lib/logger.js' +import { interactionGraph } from '../db/schema/interaction-graph.js' +import { trustSeeds } from '../db/schema/trust-seeds.js' +import { trustScores } from '../db/schema/trust-scores.js' +import { users } from '../db/schema/users.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface TrustComputationResult { + totalNodes: number + totalEdges: number + iterations: number + converged: boolean + durationMs: number +} + +export interface TrustGraphService { + computeTrustScores(communityId: string | null): Promise + getTrustScore(did: string, communityId: string | null): Promise +} + +// --------------------------------------------------------------------------- +// Pure EigenTrust implementation (exported for simulation tests) +// --------------------------------------------------------------------------- + +type Edge = { target: string; weight: number } + +interface EigenTrustResult { + scores: Map + iterations: number + converged: boolean +} + +/** + * Run the EigenTrust algorithm on an in-memory graph. + * + * @param edges - Adjacency list: source DID -> list of {target, weight} + * @param seedDids - Set of seed DIDs (initial trust = 1.0) + * @param maxIterations - Maximum number of iterations + * @param convergenceThreshold - Stop when max change < this value + * @returns Trust scores map and convergence metadata + */ +export function runEigenTrust( + edges: Map, + seedDids: Set, + maxIterations: number, + convergenceThreshold: number +): Map +export function runEigenTrust( + edges: Map, + seedDids: Set, + maxIterations: number, + convergenceThreshold: number, + returnMetadata: true +): EigenTrustResult +export function runEigenTrust( + edges: Map, + seedDids: Set, + maxIterations: number, + convergenceThreshold: number, + returnMetadata?: boolean +): Map | EigenTrustResult { + // Collect all nodes + const allNodes = new Set() + for (const [source, targets] of edges) { + allNodes.add(source) + for (const { target } of targets) { + allNodes.add(target) + } + } + + if (allNodes.size === 0) { + const empty = new Map() + if (returnMetadata) { + return { scores: empty, iterations: 0, converged: true } + } + return empty + } + + // Initialize trust: seeds = 1.0, others = 0.0 + const trust = new Map() + const seedTrust = new Map() + for (const node of allNodes) { + const isSeed = seedDids.has(node) + trust.set(node, isSeed ? 1.0 : 0.0) + seedTrust.set(node, isSeed ? 1.0 : 0.0) + } + + // If no seeds, all trust remains at 0 + if (seedDids.size === 0) { + if (returnMetadata) { + return { scores: trust, iterations: 0, converged: true } + } + return trust + } + + // Compute total outgoing weight per node + const totalOutgoing = new Map() + for (const [source, targets] of edges) { + let total = 0 + for (const { weight } of targets) { + total += weight + } + totalOutgoing.set(source, total) + } + + // Build incoming edges: target -> [{source, weight}] + const incoming = new Map() + for (const [source, targets] of edges) { + for (const { target, weight } of targets) { + const existing = incoming.get(target) + if (existing) { + existing.push({ source, weight }) + } else { + incoming.set(target, [{ source, weight }]) + } + } + } + + // Iterate with double-buffering: read from previous iteration, write to new map + let iterations = 0 + let converged = false + + for (let iter = 0; iter < maxIterations; iter++) { + iterations = iter + 1 + let maxChange = 0 + const nextTrust = new Map() + + for (const node of allNodes) { + const seed = seedTrust.get(node) ?? 0 + let incomingTrust = 0 + + const inEdges = incoming.get(node) + if (inEdges) { + for (const { source, weight } of inEdges) { + const sourceTrust = trust.get(source) ?? 0 + const sourceOutgoing = totalOutgoing.get(source) ?? 1 + incomingTrust += sourceTrust * (weight / sourceOutgoing) + } + } + + const newTrust = 0.5 * seed + 0.5 * incomingTrust + const oldTrust = trust.get(node) ?? 0 + const change = Math.abs(newTrust - oldTrust) + if (change > maxChange) maxChange = change + + nextTrust.set(node, newTrust) + } + + // Swap: copy nextTrust into trust for next iteration + for (const [node, score] of nextTrust) { + trust.set(node, score) + } + + if (maxChange < convergenceThreshold) { + converged = true + break + } + } + + if (returnMetadata) { + return { scores: trust, iterations, converged } + } + return trust +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +const DEFAULT_TRUST_SCORE = 0.1 +const MAX_ITERATIONS = 20 +const CONVERGENCE_THRESHOLD = 0.001 + +export function createTrustGraphService(db: Database, logger: Logger): TrustGraphService { + async function computeTrustScores(communityId: string | null): Promise { + const start = Date.now() + + // 1. Load interaction graph edges + const communityFilter = communityId ? eq(interactionGraph.communityId, communityId) : sql`true` + + const edgeRows = await db + .select({ + source_did: interactionGraph.sourceDid, + target_did: interactionGraph.targetDid, + weight: interactionGraph.weight, + }) + .from(interactionGraph) + .where(communityFilter) + + if (edgeRows.length === 0) { + logger.info({ communityId }, 'No edges found, skipping trust computation') + return { + totalNodes: 0, + totalEdges: 0, + iterations: 0, + converged: true, + durationMs: Date.now() - start, + } + } + + // Build adjacency list + const edges = new Map() + const allNodes = new Set() + + for (const row of edgeRows) { + allNodes.add(row.source_did) + allNodes.add(row.target_did) + const existing = edges.get(row.source_did) + if (existing) { + existing.push({ target: row.target_did, weight: row.weight }) + } else { + edges.set(row.source_did, [{ target: row.target_did, weight: row.weight }]) + } + } + + // 2. Get trust seeds (empty string = global scope) + const seedFilter = communityId + ? or(eq(trustSeeds.communityId, communityId), eq(trustSeeds.communityId, '')) + : eq(trustSeeds.communityId, '') + + const seedRows = await db.select({ did: trustSeeds.did }).from(trustSeeds).where(seedFilter) + + // Also include admins/moderators as seeds + const adminRows = await db + .select({ did: users.did }) + .from(users) + .where(inArray(users.role, ['admin', 'moderator'])) + + const seedDids = new Set() + for (const row of seedRows) { + seedDids.add(row.did) + } + for (const row of adminRows) { + seedDids.add(row.did) + } + + // 3. Run EigenTrust + const result = runEigenTrust(edges, seedDids, MAX_ITERATIONS, CONVERGENCE_THRESHOLD, true) + + // 4. Upsert results to trust_scores (empty string = global scope) + const effectiveCommunityId = communityId ?? '' + for (const [did, score] of result.scores) { + await db + .insert(trustScores) + .values({ + did, + communityId: effectiveCommunityId, + score, + computedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [trustScores.did, trustScores.communityId], + set: { + score, + computedAt: new Date(), + }, + }) + } + + const durationMs = Date.now() - start + + logger.info( + { + communityId, + totalNodes: allNodes.size, + totalEdges: edgeRows.length, + iterations: result.iterations, + converged: result.converged, + durationMs, + }, + 'Trust computation completed' + ) + + return { + totalNodes: allNodes.size, + totalEdges: edgeRows.length, + iterations: result.iterations, + converged: result.converged, + durationMs, + } + } + + async function getTrustScore(did: string, communityId: string | null): Promise { + const effectiveCommunityId = communityId ?? '' + const filter = and(eq(trustScores.did, did), eq(trustScores.communityId, effectiveCommunityId)) + + const rows = await db.select({ score: trustScores.score }).from(trustScores).where(filter) + + const row = rows[0] + if (!row) { + return DEFAULT_TRUST_SCORE + } + + return row.score + } + + return { computeTrustScores, getTrustScore } +} diff --git a/src/setup/service.ts b/src/setup/service.ts index 7719a8f..cac5fe9 100644 --- a/src/setup/service.ts +++ b/src/setup/service.ts @@ -1,51 +1,49 @@ -import { eq, sql } from "drizzle-orm"; -import { communitySettings } from "../db/schema/community-settings.js"; -import type { Database } from "../db/index.js"; -import type { Logger } from "../lib/logger.js"; -import type { PlcDidService } from "../services/plc-did.js"; +import { eq, sql } from 'drizzle-orm' +import { communitySettings } from '../db/schema/community-settings.js' +import type { Database } from '../db/index.js' +import type { Logger } from '../lib/logger.js' +import type { PlcDidService } from '../services/plc-did.js' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** Result of getStatus(): either not initialized, or initialized with name. */ -export type SetupStatus = - | { initialized: false } - | { initialized: true; communityName: string }; +export type SetupStatus = { initialized: false } | { initialized: true; communityName: string } /** Parameters for community initialization. */ export interface InitializeParams { /** DID of the authenticated user who becomes admin */ - did: string; + did: string /** Optional community name override */ - communityName?: string | undefined; + communityName?: string | undefined /** Community handle (e.g. "community.barazo.forum"). Required for PLC DID generation. */ - handle?: string | undefined; + handle?: string | undefined /** Community service endpoint (e.g. "https://community.barazo.forum"). Required for PLC DID generation. */ - serviceEndpoint?: string | undefined; + serviceEndpoint?: string | undefined } /** Result of initialize(): either success with details, or already initialized. */ export type InitializeResult = | { - initialized: true; - adminDid: string; - communityName: string; - communityDid?: string | undefined; + initialized: true + adminDid: string + communityName: string + communityDid?: string | undefined } - | { alreadyInitialized: true }; + | { alreadyInitialized: true } /** Setup service interface for dependency injection and testing. */ export interface SetupService { - getStatus(): Promise; - initialize(params: InitializeParams): Promise; + getStatus(): Promise + initialize(params: InitializeParams): Promise } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const DEFAULT_COMMUNITY_NAME = "Barazo Community"; +const DEFAULT_COMMUNITY_NAME = 'Barazo Community' // --------------------------------------------------------------------------- // Factory @@ -66,7 +64,7 @@ const DEFAULT_COMMUNITY_NAME = "Barazo Community"; export function createSetupService( db: Database, logger: Logger, - plcDidService?: PlcDidService, + plcDidService?: PlcDidService ): SetupService { /** * Check whether the community has been initialized. @@ -81,18 +79,18 @@ export function createSetupService( communityName: communitySettings.communityName, }) .from(communitySettings) - .where(eq(communitySettings.id, "default")); + .where(eq(communitySettings.id, 'default')) - const row = rows[0]; + const row = rows[0] if (!row || !row.initialized) { - return { initialized: false }; + return { initialized: false } } - return { initialized: true, communityName: row.communityName }; + return { initialized: true, communityName: row.communityName } } catch (err: unknown) { - logger.error({ err }, "Failed to get setup status"); - throw err; + logger.error({ err }, 'Failed to get setup status') + throw err } } @@ -110,41 +108,33 @@ export function createSetupService( * @param params - Initialization parameters * @returns InitializeResult with the new state or conflict indicator */ - async function initialize( - params: InitializeParams, - ): Promise { - const { did, communityName, handle, serviceEndpoint } = params; + async function initialize(params: InitializeParams): Promise { + const { did, communityName, handle, serviceEndpoint } = params try { // Generate PLC DID if handle and serviceEndpoint are provided - let communityDid: string | undefined; - let signingKeyHex: string | undefined; - let rotationKeyHex: string | undefined; + let communityDid: string | undefined + let signingKeyHex: string | undefined + let rotationKeyHex: string | undefined if (handle && serviceEndpoint && plcDidService) { - logger.info( - { handle, serviceEndpoint }, - "Generating PLC DID during community setup", - ); + logger.info({ handle, serviceEndpoint }, 'Generating PLC DID during community setup') const didResult = await plcDidService.generateDid({ handle, serviceEndpoint, - }); + }) - communityDid = didResult.did; - signingKeyHex = didResult.signingKey; - rotationKeyHex = didResult.rotationKey; + communityDid = didResult.did + signingKeyHex = didResult.signingKey + rotationKeyHex = didResult.rotationKey - logger.info( - { communityDid, handle }, - "PLC DID generated successfully", - ); + logger.info({ communityDid, handle }, 'PLC DID generated successfully') } else if (handle && serviceEndpoint && !plcDidService) { logger.warn( { handle, serviceEndpoint }, - "PLC DID generation requested but PlcDidService not available", - ); + 'PLC DID generation requested but PlcDidService not available' + ) } // Atomic upsert: INSERT new row, or UPDATE existing if not yet initialized. @@ -152,7 +142,7 @@ export function createSetupService( const rows = await db .insert(communitySettings) .values({ - id: "default", + id: 'default', initialized: true, adminDid: did, communityName: communityName ?? DEFAULT_COMMUNITY_NAME, @@ -167,13 +157,10 @@ export function createSetupService( set: { initialized: true, adminDid: did, - communityName: communityName - ? communityName - : sql`${communitySettings.communityName}`, + communityName: communityName ? communityName : sql`${communitySettings.communityName}`, communityDid: communityDid ?? sql`${communitySettings.communityDid}`, handle: handle ?? sql`${communitySettings.handle}`, - serviceEndpoint: - serviceEndpoint ?? sql`${communitySettings.serviceEndpoint}`, + serviceEndpoint: serviceEndpoint ?? sql`${communitySettings.serviceEndpoint}`, signingKey: signingKeyHex ?? sql`${communitySettings.signingKey}`, rotationKey: rotationKeyHex ?? sql`${communitySettings.rotationKey}`, updatedAt: new Date(), @@ -183,36 +170,33 @@ export function createSetupService( .returning({ communityName: communitySettings.communityName, communityDid: communitySettings.communityDid, - }); + }) - const row = rows[0]; + const row = rows[0] if (!row) { - logger.warn( - { did }, - "Setup initialize attempted on already-initialized community", - ); - return { alreadyInitialized: true }; + logger.warn({ did }, 'Setup initialize attempted on already-initialized community') + return { alreadyInitialized: true } } - const finalName = row.communityName; - logger.info({ did, communityName: finalName }, "Community initialized"); + const finalName = row.communityName + logger.info({ did, communityName: finalName }, 'Community initialized') const result: InitializeResult = { initialized: true, adminDid: did, communityName: finalName, - }; + } if (row.communityDid) { - result.communityDid = row.communityDid; + result.communityDid = row.communityDid } - return result; + return result } catch (err: unknown) { - logger.error({ err, did }, "Failed to initialize community"); - throw err; + logger.error({ err, did }, 'Failed to initialize community') + throw err } } - return { getStatus, initialize }; + return { getStatus, initialize } } diff --git a/src/validation/admin-settings.ts b/src/validation/admin-settings.ts index b795498..0f291c2 100644 --- a/src/validation/admin-settings.ts +++ b/src/validation/admin-settings.ts @@ -1,58 +1,54 @@ -import { z } from "zod/v4"; -import { maturityRatingSchema } from "./categories.js"; -import { reactionSetSchema } from "./reactions.js"; +import { z } from 'zod/v4' +import { maturityRatingSchema } from './categories.js' +import { reactionSetSchema } from './reactions.js' // --------------------------------------------------------------------------- // Request schemas // --------------------------------------------------------------------------- /** Hex color code pattern: # followed by 3, 4, 6, or 8 hex digits. */ -const hexColorPattern = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; +const hexColorPattern = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/ /** Schema for updating community settings (all fields optional). */ export const updateSettingsSchema = z.object({ communityName: z .string() .trim() - .min(1, "Community name is required") - .max(100, "Community name must be at most 100 characters") + .min(1, 'Community name is required') + .max(100, 'Community name must be at most 100 characters') .optional(), maturityRating: maturityRatingSchema.optional(), reactionSet: reactionSetSchema.optional(), communityDescription: z .string() .trim() - .max(500, "Community description must be at most 500 characters") - .optional(), - communityLogoUrl: z - .url("Community logo must be a valid URL") + .max(500, 'Community description must be at most 500 characters') .optional(), + communityLogoUrl: z.url('Community logo must be a valid URL').optional(), primaryColor: z .string() - .regex(hexColorPattern, "Primary color must be a valid hex color (e.g., #ff0000)") + .regex(hexColorPattern, 'Primary color must be a valid hex color (e.g., #ff0000)') .optional(), accentColor: z .string() - .regex(hexColorPattern, "Accent color must be a valid hex color (e.g., #00ff00)") + .regex(hexColorPattern, 'Accent color must be a valid hex color (e.g., #00ff00)') .optional(), jurisdictionCountry: z .string() - .length(2, "Jurisdiction country must be a 2-letter ISO 3166-1 alpha-2 code") - .regex(/^[A-Z]{2}$/, "Jurisdiction country must be uppercase letters") + .length(2, 'Jurisdiction country must be a 2-letter ISO 3166-1 alpha-2 code') + .regex(/^[A-Z]{2}$/, 'Jurisdiction country must be uppercase letters') .nullable() .optional(), ageThreshold: z .number() - .int("Age threshold must be an integer") - .min(13, "Age threshold must be at least 13") - .max(18, "Age threshold must be at most 18") - .optional(), - requireLoginForMature: z - .boolean() + .int('Age threshold must be an integer') + .min(13, 'Age threshold must be at least 13') + .max(18, 'Age threshold must be at most 18') .optional(), -}); + requireLoginForMature: z.boolean().optional(), +}) -export type UpdateSettingsInput = z.infer; +export type UpdateSettingsInput = z.infer // --------------------------------------------------------------------------- // Response schemas (for OpenAPI documentation) @@ -76,6 +72,6 @@ export const settingsResponseSchema = z.object({ requireLoginForMature: z.boolean(), createdAt: z.string(), updatedAt: z.string(), -}); +}) -export type SettingsResponse = z.infer; +export type SettingsResponse = z.infer diff --git a/src/validation/anti-spam.ts b/src/validation/anti-spam.ts index c89cb39..94a07f3 100644 --- a/src/validation/anti-spam.ts +++ b/src/validation/anti-spam.ts @@ -1,20 +1,18 @@ -import { z } from "zod"; +import { z } from 'zod' export const wordFilterSchema = z.object({ - words: z - .array(z.string().min(1).max(100)) - .max(500), -}); + words: z.array(z.string().min(1).max(100)).max(500), +}) export const queueActionSchema = z.object({ - action: z.enum(["approve", "reject"]), -}); + action: z.enum(['approve', 'reject']), +}) export const queueQuerySchema = z.object({ - status: z.enum(["pending", "approved", "rejected"]).default("pending"), + status: z.enum(['pending', 'approved', 'rejected']).default('pending'), queueReason: z - .enum(["word_filter", "first_post", "link_hold", "burst", "topic_delay"]) + .enum(['word_filter', 'first_post', 'link_hold', 'burst', 'topic_delay']) .optional(), cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(25), -}); +}) diff --git a/src/validation/block-mute.ts b/src/validation/block-mute.ts index a018dd3..46b18e6 100644 --- a/src/validation/block-mute.ts +++ b/src/validation/block-mute.ts @@ -1,14 +1,14 @@ -import { z } from "zod/v4"; +import { z } from 'zod/v4' // --------------------------------------------------------------------------- // Param schemas for block/mute action endpoints // --------------------------------------------------------------------------- -const didRegex = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/; +const didRegex = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/ /** Schema for validating :did route parameter. */ export const didParamSchema = z.object({ - did: z.string().regex(didRegex, "Invalid DID format"), -}); + did: z.string().regex(didRegex, 'Invalid DID format'), +}) -export type DidParam = z.infer; +export type DidParam = z.infer diff --git a/src/validation/categories.ts b/src/validation/categories.ts index 994ee03..3a85326 100644 --- a/src/validation/categories.ts +++ b/src/validation/categories.ts @@ -1,90 +1,87 @@ -import { z } from "zod/v4"; +import { z } from 'zod/v4' // --------------------------------------------------------------------------- // Shared enums // --------------------------------------------------------------------------- /** Valid maturity rating values for categories and communities. */ -export const maturityRatingSchema = z.enum(["safe", "mature", "adult"]); +export const maturityRatingSchema = z.enum(['safe', 'mature', 'adult']) -export type MaturityRating = z.infer; +export type MaturityRating = z.infer // --------------------------------------------------------------------------- // Request schemas // --------------------------------------------------------------------------- /** Slug pattern: lowercase alphanumeric segments separated by single hyphens. */ -const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ /** Schema for creating a new category. */ export const createCategorySchema = z.object({ name: z .string() .trim() - .min(1, "Name is required") - .max(100, "Name must be at most 100 characters"), + .min(1, 'Name is required') + .max(100, 'Name must be at most 100 characters'), slug: z .string() - .min(1, "Slug is required") - .max(50, "Slug must be at most 50 characters") + .min(1, 'Slug is required') + .max(50, 'Slug must be at most 50 characters') .regex( slugPattern, - "Slug must be lowercase alphanumeric with single hyphens (e.g. 'general-discussion')", + "Slug must be lowercase alphanumeric with single hyphens (e.g. 'general-discussion')" ), - description: z - .string() - .max(500, "Description must be at most 500 characters") - .optional(), + description: z.string().max(500, 'Description must be at most 500 characters').optional(), parentId: z.string().optional(), sortOrder: z .number() - .int("Sort order must be an integer") - .min(0, "Sort order must be non-negative") + .int('Sort order must be an integer') + .min(0, 'Sort order must be non-negative') .optional(), maturityRating: maturityRatingSchema.optional(), -}); +}) -export type CreateCategoryInput = z.infer; +export type CreateCategoryInput = z.infer /** Schema for updating an existing category (all fields optional). */ export const updateCategorySchema = z.object({ name: z .string() .trim() - .min(1, "Name must not be empty") - .max(100, "Name must be at most 100 characters") + .min(1, 'Name must not be empty') + .max(100, 'Name must be at most 100 characters') .optional(), slug: z .string() - .min(1, "Slug must not be empty") - .max(50, "Slug must be at most 50 characters") + .min(1, 'Slug must not be empty') + .max(50, 'Slug must be at most 50 characters') .regex( slugPattern, - "Slug must be lowercase alphanumeric with single hyphens (e.g. 'general-discussion')", + "Slug must be lowercase alphanumeric with single hyphens (e.g. 'general-discussion')" ) .optional(), description: z .string() - .max(500, "Description must be at most 500 characters") + .max(500, 'Description must be at most 500 characters') .nullable() .optional(), parentId: z.string().nullable().optional(), sortOrder: z .number() - .int("Sort order must be an integer") - .min(0, "Sort order must be non-negative") + .int('Sort order must be an integer') + .min(0, 'Sort order must be non-negative') .optional(), maturityRating: maturityRatingSchema.optional(), -}); +}) -export type UpdateCategoryInput = z.infer; +export type UpdateCategoryInput = z.infer /** Schema for updating community/category maturity rating. */ export const updateMaturitySchema = z.object({ maturityRating: maturityRatingSchema, -}); +}) -export type UpdateMaturityInput = z.infer; +export type UpdateMaturityInput = z.infer // --------------------------------------------------------------------------- // Query schemas @@ -93,9 +90,9 @@ export type UpdateMaturityInput = z.infer; /** Schema for listing categories with optional filtering. */ export const categoryQuerySchema = z.object({ parentId: z.string().optional(), -}); +}) -export type CategoryQueryInput = z.infer; +export type CategoryQueryInput = z.infer // --------------------------------------------------------------------------- // Response schemas (for OpenAPI documentation) @@ -113,38 +110,37 @@ export const categoryResponseSchema = z.object({ maturityRating: maturityRatingSchema, createdAt: z.string(), updatedAt: z.string(), -}); +}) -export type CategoryResponse = z.infer; +export type CategoryResponse = z.infer /** Schema describing a category with its children (tree structure). */ -export const categoryTreeResponseSchema: z.ZodType = - z.lazy(() => - z.object({ - id: z.string(), - slug: z.string(), - name: z.string(), - description: z.string().nullable(), - parentId: z.string().nullable(), - sortOrder: z.number(), - communityDid: z.string(), - maturityRating: maturityRatingSchema, - createdAt: z.string(), - updatedAt: z.string(), - children: z.array(categoryTreeResponseSchema), - }), - ); +export const categoryTreeResponseSchema: z.ZodType = z.lazy(() => + z.object({ + id: z.string(), + slug: z.string(), + name: z.string(), + description: z.string().nullable(), + parentId: z.string().nullable(), + sortOrder: z.number(), + communityDid: z.string(), + maturityRating: maturityRatingSchema, + createdAt: z.string(), + updatedAt: z.string(), + children: z.array(categoryTreeResponseSchema), + }) +) export interface CategoryTreeResponse { - id: string; - slug: string; - name: string; - description: string | null; - parentId: string | null; - sortOrder: number; - communityDid: string; - maturityRating: "safe" | "mature" | "adult"; - createdAt: string; - updatedAt: string; - children: CategoryTreeResponse[]; + id: string + slug: string + name: string + description: string | null + parentId: string | null + sortOrder: number + communityDid: string + maturityRating: 'safe' | 'mature' | 'adult' + createdAt: string + updatedAt: string + children: CategoryTreeResponse[] } diff --git a/src/validation/community-profiles.ts b/src/validation/community-profiles.ts index bdd1ef6..39d7f56 100644 --- a/src/validation/community-profiles.ts +++ b/src/validation/community-profiles.ts @@ -1,9 +1,9 @@ -import { z } from "zod/v4"; +import { z } from 'zod/v4' /** Schema for PUT /api/communities/:communityDid/profile body. */ export const updateCommunityProfileSchema = z.object({ displayName: z.string().max(256).nullable().optional(), bio: z.string().max(2048).nullable().optional(), -}); +}) -export type UpdateCommunityProfileInput = z.infer; +export type UpdateCommunityProfileInput = z.infer diff --git a/src/validation/global-filters.ts b/src/validation/global-filters.ts index bebbee6..a2588ad 100644 --- a/src/validation/global-filters.ts +++ b/src/validation/global-filters.ts @@ -1,44 +1,44 @@ -import { z } from "zod"; +import { z } from 'zod' // --------------------------------------------------------------------------- // Community filter schemas // --------------------------------------------------------------------------- export const communityFilterQuerySchema = z.object({ - status: z.enum(["active", "warned", "filtered"]).optional(), + status: z.enum(['active', 'warned', 'filtered']).optional(), cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(25), -}); +}) -export type CommunityFilterQueryInput = z.infer; +export type CommunityFilterQueryInput = z.infer export const updateCommunityFilterSchema = z.object({ - status: z.enum(["active", "warned", "filtered"]), + status: z.enum(['active', 'warned', 'filtered']), reason: z.string().max(1000).optional(), adminDid: z.string().min(1).optional(), -}); +}) -export type UpdateCommunityFilterInput = z.infer; +export type UpdateCommunityFilterInput = z.infer // --------------------------------------------------------------------------- // Account filter schemas // --------------------------------------------------------------------------- export const accountFilterQuerySchema = z.object({ - status: z.enum(["active", "warned", "filtered"]).optional(), + status: z.enum(['active', 'warned', 'filtered']).optional(), communityDid: z.string().optional(), cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(25), -}); +}) -export type AccountFilterQueryInput = z.infer; +export type AccountFilterQueryInput = z.infer export const updateAccountFilterSchema = z.object({ - status: z.enum(["active", "warned", "filtered"]), + status: z.enum(['active', 'warned', 'filtered']), reason: z.string().max(1000).optional(), -}); +}) -export type UpdateAccountFilterInput = z.infer; +export type UpdateAccountFilterInput = z.infer // --------------------------------------------------------------------------- // Global report schemas @@ -46,6 +46,6 @@ export type UpdateAccountFilterInput = z.infer export const globalReportQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(25), -}); +}) -export type GlobalReportQueryInput = z.infer; +export type GlobalReportQueryInput = z.infer diff --git a/src/validation/moderation.ts b/src/validation/moderation.ts index 9aa4307..926812c 100644 --- a/src/validation/moderation.ts +++ b/src/validation/moderation.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from 'zod' // --------------------------------------------------------------------------- // Moderation action schemas @@ -6,33 +6,31 @@ import { z } from "zod"; export const lockTopicSchema = z.object({ reason: z.string().max(500).optional(), -}); +}) export const pinTopicSchema = z.object({ reason: z.string().max(500).optional(), -}); +}) export const modDeleteSchema = z.object({ reason: z.string().min(1).max(500), -}); +}) export const banUserSchema = z.object({ did: z.string().min(1), reason: z.string().min(1).max(500), -}); +}) export const unbanUserSchema = z.object({ did: z.string().min(1), reason: z.string().max(500).optional(), -}); +}) export const moderationLogQuerySchema = z.object({ cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(25), - action: z - .enum(["lock", "unlock", "pin", "unpin", "delete", "ban", "unban"]) - .optional(), -}); + action: z.enum(['lock', 'unlock', 'pin', 'unpin', 'delete', 'ban', 'unban']).optional(), +}) // --------------------------------------------------------------------------- // Report schemas @@ -40,32 +38,19 @@ export const moderationLogQuerySchema = z.object({ export const createReportSchema = z.object({ targetUri: z.string().min(1), - reasonType: z.enum([ - "spam", - "sexual", - "harassment", - "violation", - "misleading", - "other", - ]), + reasonType: z.enum(['spam', 'sexual', 'harassment', 'violation', 'misleading', 'other']), description: z.string().max(1000).optional(), -}); +}) export const reportQuerySchema = z.object({ - status: z.enum(["pending", "resolved"]).optional(), + status: z.enum(['pending', 'resolved']).optional(), cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(25), -}); +}) export const resolveReportSchema = z.object({ - resolutionType: z.enum([ - "dismissed", - "warned", - "labeled", - "removed", - "banned", - ]), -}); + resolutionType: z.enum(['dismissed', 'warned', 'labeled', 'removed', 'banned']), +}) // --------------------------------------------------------------------------- // Admin moderation schemas @@ -83,11 +68,11 @@ export const moderationThresholdsSchema = z.object({ burstPostCount: z.number().int().min(2).max(50).optional(), burstWindowMinutes: z.number().int().min(1).max(60).optional(), trustedPostThreshold: z.number().int().min(1).max(100).optional(), -}); +}) export const reportedUsersQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(25), -}); +}) // --------------------------------------------------------------------------- // Appeal schemas @@ -95,9 +80,9 @@ export const reportedUsersQuerySchema = z.object({ export const appealReportSchema = z.object({ reason: z.string().min(1).max(1000), -}); +}) export const myReportsQuerySchema = z.object({ cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(25), -}); +}) diff --git a/src/validation/notifications.ts b/src/validation/notifications.ts index a68dbb0..c15588a 100644 --- a/src/validation/notifications.ts +++ b/src/validation/notifications.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import { z } from 'zod/v4' // --------------------------------------------------------------------------- // Query schemas @@ -15,11 +15,11 @@ export const notificationQuerySchema = z.object({ cursor: z.string().optional(), unreadOnly: z .string() - .transform((val) => val === "true") + .transform((val) => val === 'true') .optional(), -}); +}) -export type NotificationQueryInput = z.infer; +export type NotificationQueryInput = z.infer // --------------------------------------------------------------------------- // Body schemas @@ -29,6 +29,6 @@ export type NotificationQueryInput = z.infer; export const markReadSchema = z.object({ notificationId: z.number().int().positive().optional(), all: z.boolean().optional(), -}); +}) -export type MarkReadInput = z.infer; +export type MarkReadInput = z.infer diff --git a/src/validation/onboarding.ts b/src/validation/onboarding.ts index 2dee7e7..19fcfc4 100644 --- a/src/validation/onboarding.ts +++ b/src/validation/onboarding.ts @@ -1,19 +1,19 @@ -import { z } from "zod/v4"; +import { z } from 'zod/v4' // --------------------------------------------------------------------------- // Field type enum // --------------------------------------------------------------------------- export const onboardingFieldTypeSchema = z.enum([ - "age_confirmation", - "tos_acceptance", - "newsletter_email", - "custom_text", - "custom_select", - "custom_checkbox", -]); + 'age_confirmation', + 'tos_acceptance', + 'newsletter_email', + 'custom_text', + 'custom_select', + 'custom_checkbox', +]) -export type OnboardingFieldType = z.infer; +export type OnboardingFieldType = z.infer // --------------------------------------------------------------------------- // Config schemas per field type @@ -21,7 +21,7 @@ export type OnboardingFieldType = z.infer; const selectConfigSchema = z.object({ options: z.array(z.string().min(1).max(200)).min(2).max(20), -}); +}) // --------------------------------------------------------------------------- // Admin CRUD schemas @@ -29,14 +29,18 @@ const selectConfigSchema = z.object({ export const createOnboardingFieldSchema = z.object({ fieldType: onboardingFieldTypeSchema, - label: z.string().trim().min(1, "Label is required").max(200, "Label must be at most 200 characters"), + label: z + .string() + .trim() + .min(1, 'Label is required') + .max(200, 'Label must be at most 200 characters'), description: z.string().trim().max(500).nullable().optional(), isMandatory: z.boolean().default(true), sortOrder: z.number().int().min(0).default(0), config: z.record(z.string(), z.unknown()).nullable().optional(), -}); +}) -export type CreateOnboardingFieldInput = z.infer; +export type CreateOnboardingFieldInput = z.infer export const updateOnboardingFieldSchema = z.object({ label: z.string().trim().min(1).max(200).optional(), @@ -44,31 +48,35 @@ export const updateOnboardingFieldSchema = z.object({ isMandatory: z.boolean().optional(), sortOrder: z.number().int().min(0).optional(), config: z.record(z.string(), z.unknown()).nullable().optional(), -}); +}) -export type UpdateOnboardingFieldInput = z.infer; +export type UpdateOnboardingFieldInput = z.infer -export const reorderFieldsSchema = z.array( - z.object({ - id: z.string().min(1), - sortOrder: z.number().int().min(0), - }), -).min(1); +export const reorderFieldsSchema = z + .array( + z.object({ + id: z.string().min(1), + sortOrder: z.number().int().min(0), + }) + ) + .min(1) -export type ReorderFieldsInput = z.infer; +export type ReorderFieldsInput = z.infer // --------------------------------------------------------------------------- // User submission schema // --------------------------------------------------------------------------- -export const submitOnboardingSchema = z.array( - z.object({ - fieldId: z.string().min(1), - response: z.unknown(), - }), -).min(1); +export const submitOnboardingSchema = z + .array( + z.object({ + fieldId: z.string().min(1), + response: z.unknown(), + }) + ) + .min(1) -export type SubmitOnboardingInput = z.infer; +export type SubmitOnboardingInput = z.infer // --------------------------------------------------------------------------- // Validation helpers @@ -81,46 +89,46 @@ export type SubmitOnboardingInput = z.infer; export function validateFieldResponse( fieldType: OnboardingFieldType, response: unknown, - config: Record | null | undefined, + config: Record | null | undefined ): string | null { switch (fieldType) { - case "age_confirmation": { - if (typeof response !== "number") return "Age confirmation must be a number"; - const validAges = [0, 13, 14, 15, 16, 18]; - if (!validAges.includes(response)) return "Invalid age value"; - return null; + case 'age_confirmation': { + if (typeof response !== 'number') return 'Age confirmation must be a number' + const validAges = [0, 13, 14, 15, 16, 18] + if (!validAges.includes(response)) return 'Invalid age value' + return null } - case "tos_acceptance": { - if (response !== true) return "Terms of service must be accepted"; - return null; + case 'tos_acceptance': { + if (response !== true) return 'Terms of service must be accepted' + return null } - case "newsletter_email": { - if (typeof response !== "string") return "Email must be a string"; - if (response.length === 0) return null; // optional empty is fine - const emailResult = z.email().safeParse(response); - if (!emailResult.success) return "Invalid email format"; - return null; + case 'newsletter_email': { + if (typeof response !== 'string') return 'Email must be a string' + if (response.length === 0) return null // optional empty is fine + const emailResult = z.email().safeParse(response) + if (!emailResult.success) return 'Invalid email format' + return null } - case "custom_text": { - if (typeof response !== "string") return "Response must be a string"; - if (response.length > 1000) return "Response must be at most 1000 characters"; - return null; + case 'custom_text': { + if (typeof response !== 'string') return 'Response must be a string' + if (response.length > 1000) return 'Response must be at most 1000 characters' + return null } - case "custom_select": { - if (typeof response !== "string") return "Selection must be a string"; + case 'custom_select': { + if (typeof response !== 'string') return 'Selection must be a string' if (config) { - const parsed = selectConfigSchema.safeParse(config); + const parsed = selectConfigSchema.safeParse(config) if (parsed.success && !parsed.data.options.includes(response)) { - return "Invalid selection"; + return 'Invalid selection' } } - return null; + return null } - case "custom_checkbox": { - if (typeof response !== "boolean") return "Checkbox must be true or false"; - return null; + case 'custom_checkbox': { + if (typeof response !== 'boolean') return 'Checkbox must be true or false' + return null } default: - return "Unknown field type"; + return 'Unknown field type' } } diff --git a/src/validation/profiles.ts b/src/validation/profiles.ts index 11783bf..11e03fd 100644 --- a/src/validation/profiles.ts +++ b/src/validation/profiles.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import { z } from 'zod/v4' // --------------------------------------------------------------------------- // Body schemas @@ -6,52 +6,22 @@ import { z } from "zod/v4"; /** Schema for PUT /api/users/me/preferences body. */ export const userPreferencesSchema = z.object({ - maturityLevel: z - .enum(["sfw", "mature"]) - .optional(), - mutedWords: z - .array(z.string().min(1).max(200)) - .max(500) - .optional(), - blockedDids: z - .array(z.string().min(1)) - .max(1000) - .optional(), - mutedDids: z - .array(z.string().min(1)) - .max(1000) - .optional(), - crossPostBluesky: z - .boolean() - .optional(), - crossPostFrontpage: z - .boolean() - .optional(), -}); + maturityLevel: z.enum(['sfw', 'mature']).optional(), + mutedWords: z.array(z.string().min(1).max(200)).max(500).optional(), + blockedDids: z.array(z.string().min(1)).max(1000).optional(), + mutedDids: z.array(z.string().min(1)).max(1000).optional(), + crossPostBluesky: z.boolean().optional(), + crossPostFrontpage: z.boolean().optional(), +}) -export type UserPreferencesInput = z.infer; +export type UserPreferencesInput = z.infer /** Schema for PUT /api/users/me/communities/:communityId/preferences body. */ export const communityPreferencesSchema = z.object({ - maturityOverride: z - .enum(["sfw", "mature"]) - .nullable() - .optional(), - mutedWords: z - .array(z.string().min(1).max(200)) - .max(500) - .nullable() - .optional(), - blockedDids: z - .array(z.string().min(1)) - .max(1000) - .nullable() - .optional(), - mutedDids: z - .array(z.string().min(1)) - .max(1000) - .nullable() - .optional(), + maturityOverride: z.enum(['sfw', 'mature']).nullable().optional(), + mutedWords: z.array(z.string().min(1).max(200)).max(500).nullable().optional(), + blockedDids: z.array(z.string().min(1)).max(1000).nullable().optional(), + mutedDids: z.array(z.string().min(1)).max(1000).nullable().optional(), notificationPrefs: z .object({ replies: z.boolean(), @@ -61,20 +31,22 @@ export const communityPreferencesSchema = z.object({ }) .nullable() .optional(), -}); +}) -export type CommunityPreferencesInput = z.infer; +export type CommunityPreferencesInput = z.infer /** Valid declared age values: 0 = "rather not say", then jurisdiction thresholds + 18 */ -const VALID_DECLARED_AGES = [0, 13, 14, 15, 16, 18] as const; +const VALID_DECLARED_AGES = [0, 13, 14, 15, 16, 18] as const /** Schema for POST /api/users/me/age-declaration body. */ export const ageDeclarationSchema = z.object({ - declaredAge: z.number().refine( - (val): val is (typeof VALID_DECLARED_AGES)[number] => - (VALID_DECLARED_AGES as readonly number[]).includes(val), - { message: "declaredAge must be one of: 0, 13, 14, 15, 16, 18" }, - ), -}); - -export type AgeDeclarationInput = z.infer; + declaredAge: z + .number() + .refine( + (val): val is (typeof VALID_DECLARED_AGES)[number] => + (VALID_DECLARED_AGES as readonly number[]).includes(val), + { message: 'declaredAge must be one of: 0, 13, 14, 15, 16, 18' } + ), +}) + +export type AgeDeclarationInput = z.infer diff --git a/src/validation/reactions.ts b/src/validation/reactions.ts index 83df379..472c54a 100644 --- a/src/validation/reactions.ts +++ b/src/validation/reactions.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import { z } from 'zod/v4' // --------------------------------------------------------------------------- // Helpers @@ -10,8 +10,8 @@ import { z } from "zod/v4"; * characters (grapheme clusters), not UTF-16 code units. */ function graphemeLength(str: string): number { - const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); - return [...segmenter.segment(str)].length; + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + return [...segmenter.segment(str)].length } // --------------------------------------------------------------------------- @@ -20,21 +20,17 @@ function graphemeLength(str: string): number { /** Schema for creating a reaction on a topic or reply. */ export const createReactionSchema = z.object({ - subjectUri: z - .string() - .min(1, "Subject URI is required"), - subjectCid: z - .string() - .min(1, "Subject CID is required"), + subjectUri: z.string().min(1, 'Subject URI is required'), + subjectCid: z.string().min(1, 'Subject CID is required'), type: z .string() .trim() - .min(1, "Reaction type is required") - .max(300, "Reaction type exceeds maximum byte length") - .refine((val) => graphemeLength(val) <= 30, "Reaction type must be at most 30 graphemes"), -}); + .min(1, 'Reaction type is required') + .max(300, 'Reaction type exceeds maximum byte length') + .refine((val) => graphemeLength(val) <= 30, 'Reaction type must be at most 30 graphemes'), +}) -export type CreateReactionInput = z.infer; +export type CreateReactionInput = z.infer // --------------------------------------------------------------------------- // Query schemas @@ -42,7 +38,7 @@ export type CreateReactionInput = z.infer; /** Schema for listing reactions with pagination and optional type filter. */ export const reactionQuerySchema = z.object({ - subjectUri: z.string().min(1, "Subject URI is required"), + subjectUri: z.string().min(1, 'Subject URI is required'), type: z.string().optional(), cursor: z.string().optional(), limit: z @@ -51,9 +47,9 @@ export const reactionQuerySchema = z.object({ .pipe(z.number().int().min(1).max(100)) .optional() .default(25), -}); +}) -export type ReactionQueryInput = z.infer; +export type ReactionQueryInput = z.infer // --------------------------------------------------------------------------- // Admin settings extension @@ -65,17 +61,14 @@ export const reactionSetSchema = z z .string() .trim() - .min(1, "Reaction type must not be empty") - .max(300, "Reaction type exceeds maximum byte length") - .refine((val) => graphemeLength(val) <= 30, "Reaction type must be at most 30 graphemes"), + .min(1, 'Reaction type must not be empty') + .max(300, 'Reaction type exceeds maximum byte length') + .refine((val) => graphemeLength(val) <= 30, 'Reaction type must be at most 30 graphemes') ) - .min(1, "Reaction set must contain at least one reaction type") - .refine( - (arr) => new Set(arr).size === arr.length, - "Reaction set must contain unique values", - ); + .min(1, 'Reaction set must contain at least one reaction type') + .refine((arr) => new Set(arr).size === arr.length, 'Reaction set must contain unique values') -export type ReactionSet = z.infer; +export type ReactionSet = z.infer // --------------------------------------------------------------------------- // Response schemas (for OpenAPI documentation) @@ -90,14 +83,14 @@ export const reactionResponseSchema = z.object({ type: z.string(), cid: z.string(), createdAt: z.string(), -}); +}) -export type ReactionResponse = z.infer; +export type ReactionResponse = z.infer /** Schema for a paginated reaction list response. */ export const reactionListResponseSchema = z.object({ reactions: z.array(reactionResponseSchema), cursor: z.string().nullable(), -}); +}) -export type ReactionListResponse = z.infer; +export type ReactionListResponse = z.infer diff --git a/src/validation/replies.ts b/src/validation/replies.ts index b8a9435..6fc23f3 100644 --- a/src/validation/replies.ts +++ b/src/validation/replies.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import { z } from 'zod/v4' // --------------------------------------------------------------------------- // Self-label schemas (com.atproto.label.defs#selfLabels) @@ -6,11 +6,11 @@ import { z } from "zod/v4"; const selfLabelSchema = z.object({ val: z.string().max(128), -}); +}) const selfLabelsSchema = z.object({ values: z.array(selfLabelSchema).max(10), -}); +}) // --------------------------------------------------------------------------- // Request schemas @@ -20,27 +20,24 @@ const selfLabelsSchema = z.object({ export const createReplySchema = z.object({ content: z .string() - .min(1, "Content is required") - .max(50000, "Content must be at most 50,000 characters"), - parentUri: z - .string() - .min(1, "Parent URI must not be empty") - .optional(), + .min(1, 'Content is required') + .max(50000, 'Content must be at most 50,000 characters'), + parentUri: z.string().min(1, 'Parent URI must not be empty').optional(), labels: selfLabelsSchema.optional(), -}); +}) -export type CreateReplyInput = z.infer; +export type CreateReplyInput = z.infer /** Schema for updating an existing reply (content and optional labels). */ export const updateReplySchema = z.object({ content: z .string() - .min(1, "Content must not be empty") - .max(50000, "Content must be at most 50,000 characters"), + .min(1, 'Content must not be empty') + .max(50000, 'Content must be at most 50,000 characters'), labels: selfLabelsSchema.optional(), -}); +}) -export type UpdateReplyInput = z.infer; +export type UpdateReplyInput = z.infer // --------------------------------------------------------------------------- // Query schemas @@ -55,9 +52,9 @@ export const replyQuerySchema = z.object({ .pipe(z.number().int().min(1).max(100)) .optional() .default(25), -}); +}) -export type ReplyQueryInput = z.infer; +export type ReplyQueryInput = z.infer // --------------------------------------------------------------------------- // Response schemas (for OpenAPI documentation) @@ -80,14 +77,14 @@ export const replyResponseSchema = z.object({ reactionCount: z.number(), createdAt: z.string(), indexedAt: z.string(), -}); +}) -export type ReplyResponse = z.infer; +export type ReplyResponse = z.infer /** Schema for a paginated reply list response. */ export const replyListResponseSchema = z.object({ replies: z.array(replyResponseSchema), cursor: z.string().nullable(), -}); +}) -export type ReplyListResponse = z.infer; +export type ReplyListResponse = z.infer diff --git a/src/validation/search.ts b/src/validation/search.ts index a40c372..7efa46d 100644 --- a/src/validation/search.ts +++ b/src/validation/search.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import { z } from 'zod/v4' // --------------------------------------------------------------------------- // Query schemas @@ -8,13 +8,13 @@ import { z } from "zod/v4"; export const searchQuerySchema = z.object({ q: z .string() - .min(1, "Search query is required") - .max(500, "Search query must be at most 500 characters"), + .min(1, 'Search query is required') + .max(500, 'Search query must be at most 500 characters'), category: z.string().optional(), author: z.string().optional(), dateFrom: z.iso.datetime().optional(), dateTo: z.iso.datetime().optional(), - type: z.enum(["topics", "replies", "all"]).default("all"), + type: z.enum(['topics', 'replies', 'all']).default('all'), limit: z .string() .transform((val) => Number(val)) @@ -22,9 +22,9 @@ export const searchQuerySchema = z.object({ .optional() .default(25), cursor: z.string().optional(), -}); +}) -export type SearchQueryInput = z.infer; +export type SearchQueryInput = z.infer // --------------------------------------------------------------------------- // Response schemas (for OpenAPI documentation) @@ -32,7 +32,7 @@ export type SearchQueryInput = z.infer; /** Schema describing a single search result in API responses. */ export const searchResultSchema = z.object({ - type: z.enum(["topic", "reply"]), + type: z.enum(['topic', 'reply']), uri: z.string(), rkey: z.string(), authorDid: z.string(), @@ -47,16 +47,16 @@ export const searchResultSchema = z.object({ // Reply-specific context rootUri: z.string().nullable(), rootTitle: z.string().nullable(), -}); +}) -export type SearchResult = z.infer; +export type SearchResult = z.infer /** Schema for the search response. */ export const searchResponseSchema = z.object({ results: z.array(searchResultSchema), cursor: z.string().nullable(), total: z.number(), - searchMode: z.enum(["fulltext", "hybrid"]), -}); + searchMode: z.enum(['fulltext', 'hybrid']), +}) -export type SearchResponse = z.infer; +export type SearchResponse = z.infer diff --git a/src/validation/sybil.ts b/src/validation/sybil.ts new file mode 100644 index 0000000..46b4a42 --- /dev/null +++ b/src/validation/sybil.ts @@ -0,0 +1,67 @@ +import { z } from 'zod' + +// --------------------------------------------------------------------------- +// Trust seed schemas +// --------------------------------------------------------------------------- + +export const trustSeedCreateSchema = z.object({ + did: z.string().min(1), + communityId: z.string().optional(), + reason: z.string().max(500).optional(), +}) + +export const trustSeedQuerySchema = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), +}) + +// --------------------------------------------------------------------------- +// Sybil cluster schemas +// --------------------------------------------------------------------------- + +export const clusterQuerySchema = z.object({ + status: z.enum(['flagged', 'dismissed', 'monitoring', 'banned']).optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), + sort: z.enum(['detected_at', 'member_count', 'confidence']).optional(), +}) + +export const clusterStatusUpdateSchema = z.object({ + status: z.enum(['dismissed', 'monitoring', 'banned']), +}) + +// --------------------------------------------------------------------------- +// PDS trust factor schemas +// --------------------------------------------------------------------------- + +export const pdsTrustUpdateSchema = z.object({ + pdsHost: z + .string() + .min(1) + .max(253) + .regex( + /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, + 'Must be a valid hostname' + ), + trustFactor: z.number().min(0.0).max(1.0), +}) + +export const pdsTrustQuerySchema = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), +}) + +// --------------------------------------------------------------------------- +// Behavioral flag schemas +// --------------------------------------------------------------------------- + +export const behavioralFlagUpdateSchema = z.object({ + status: z.enum(['dismissed', 'action_taken']), +}) + +export const behavioralFlagQuerySchema = z.object({ + flagType: z.enum(['burst_voting', 'content_similarity', 'low_diversity']).optional(), + status: z.enum(['pending', 'dismissed', 'action_taken']).optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), +}) diff --git a/src/validation/topics.ts b/src/validation/topics.ts index 7ec9698..2551dba 100644 --- a/src/validation/topics.ts +++ b/src/validation/topics.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import { z } from 'zod/v4' // --------------------------------------------------------------------------- // Self-label schemas (com.atproto.label.defs#selfLabels) @@ -6,11 +6,11 @@ import { z } from "zod/v4"; const selfLabelSchema = z.object({ val: z.string().max(128), -}); +}) const selfLabelsSchema = z.object({ values: z.array(selfLabelSchema).max(10), -}); +}) // --------------------------------------------------------------------------- // Request schemas @@ -21,55 +21,44 @@ export const createTopicSchema = z.object({ title: z .string() .trim() - .min(1, "Title is required") - .max(200, "Title must be at most 200 characters"), + .min(1, 'Title is required') + .max(200, 'Title must be at most 200 characters'), content: z .string() - .min(1, "Content is required") - .max(100000, "Content must be at most 100,000 characters"), - category: z - .string() - .trim() - .min(1, "Category is required"), + .min(1, 'Content is required') + .max(100000, 'Content must be at most 100,000 characters'), + category: z.string().trim().min(1, 'Category is required'), tags: z - .array( - z.string().trim().min(1).max(30, "Tag must be at most 30 characters"), - ) - .max(5, "At most 5 tags allowed") + .array(z.string().trim().min(1).max(30, 'Tag must be at most 30 characters')) + .max(5, 'At most 5 tags allowed') .optional(), labels: selfLabelsSchema.optional(), -}); +}) -export type CreateTopicInput = z.infer; +export type CreateTopicInput = z.infer /** Schema for updating an existing topic (all fields optional). */ export const updateTopicSchema = z.object({ title: z .string() .trim() - .min(1, "Title must not be empty") - .max(200, "Title must be at most 200 characters") + .min(1, 'Title must not be empty') + .max(200, 'Title must be at most 200 characters') .optional(), content: z .string() - .min(1, "Content must not be empty") - .max(100000, "Content must be at most 100,000 characters") - .optional(), - category: z - .string() - .trim() - .min(1, "Category must not be empty") + .min(1, 'Content must not be empty') + .max(100000, 'Content must be at most 100,000 characters') .optional(), + category: z.string().trim().min(1, 'Category must not be empty').optional(), tags: z - .array( - z.string().trim().min(1).max(30, "Tag must be at most 30 characters"), - ) - .max(5, "At most 5 tags allowed") + .array(z.string().trim().min(1).max(30, 'Tag must be at most 30 characters')) + .max(5, 'At most 5 tags allowed') .optional(), labels: selfLabelsSchema.optional(), -}); +}) -export type UpdateTopicInput = z.infer; +export type UpdateTopicInput = z.infer // --------------------------------------------------------------------------- // Query schemas @@ -86,9 +75,9 @@ export const topicQuerySchema = z.object({ .default(25), category: z.string().optional(), tag: z.string().optional(), -}); +}) -export type TopicQueryInput = z.infer; +export type TopicQueryInput = z.infer // --------------------------------------------------------------------------- // Response schemas (for OpenAPI documentation) @@ -112,14 +101,14 @@ export const topicResponseSchema = z.object({ lastActivityAt: z.string(), createdAt: z.string(), indexedAt: z.string(), -}); +}) -export type TopicResponse = z.infer; +export type TopicResponse = z.infer /** Schema for a paginated topic list response. */ export const topicListResponseSchema = z.object({ topics: z.array(topicResponseSchema), cursor: z.string().nullable(), -}); +}) -export type TopicListResponse = z.infer; +export type TopicListResponse = z.infer diff --git a/tests/helpers/mock-db.ts b/tests/helpers/mock-db.ts index 0c5258c..2fffa48 100644 --- a/tests/helpers/mock-db.ts +++ b/tests/helpers/mock-db.ts @@ -5,24 +5,27 @@ // Import in any route test file to avoid duplicating this boilerplate. // --------------------------------------------------------------------------- -import { vi } from "vitest"; +import { vi } from 'vitest' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -export type MockFn = ReturnType; +export type MockFn = ReturnType export interface DbChain { - values: MockFn; - onConflictDoUpdate: MockFn; - onConflictDoNothing: MockFn; - set: MockFn; - from: MockFn; - where: MockFn; - orderBy: MockFn; - limit: MockFn; - returning: MockFn; + values: MockFn + onConflictDoUpdate: MockFn + onConflictDoNothing: MockFn + set: MockFn + from: MockFn + leftJoin: MockFn + where: MockFn + groupBy: MockFn + having: MockFn + orderBy: MockFn + limit: MockFn + returning: MockFn } // --------------------------------------------------------------------------- @@ -44,11 +47,14 @@ export function createChainableProxy(terminalResult: unknown = []): DbChain { onConflictDoNothing: vi.fn(), set: vi.fn(), from: vi.fn(), + leftJoin: vi.fn(), where: vi.fn(), + groupBy: vi.fn(), + having: vi.fn(), orderBy: vi.fn(), limit: vi.fn(), returning: vi.fn(), - }; + } // Build a thenable wrapper that spreads the chain's actual methods // so test overrides (e.g. chain.returning.mockResolvedValueOnce) work @@ -56,29 +62,38 @@ export function createChainableProxy(terminalResult: unknown = []): DbChain { ...chain, then: (resolve: (val: unknown) => void, reject?: (err: unknown) => void) => Promise.resolve(terminalResult).then(resolve, reject), - }); + }) const methods: (keyof DbChain)[] = [ - "values", "onConflictDoUpdate", "onConflictDoNothing", - "set", "from", - ]; + 'values', + 'onConflictDoUpdate', + 'onConflictDoNothing', + 'set', + 'from', + 'leftJoin', + ] for (const m of methods) { - chain[m].mockImplementation(() => chain); + chain[m].mockImplementation(() => chain) } // Terminal methods return thenables so `await db.insert().values().returning()` works // and `await db.select().from().where().orderBy()` works // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain - chain.orderBy.mockImplementation(() => makeThenable()); + chain.orderBy.mockImplementation(() => makeThenable()) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain + chain.limit.mockImplementation(() => makeThenable()) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain - chain.limit.mockImplementation(() => makeThenable()); + chain.returning.mockImplementation(() => makeThenable()) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain - chain.returning.mockImplementation(() => makeThenable()); + chain.where.mockImplementation(() => makeThenable()) + // groupBy chains to having; having is terminal (thenable) + chain.groupBy.mockImplementation(() => chain) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain - chain.where.mockImplementation(() => makeThenable()); + chain.having.mockImplementation(() => makeThenable()) - return chain; + return chain } // --------------------------------------------------------------------------- @@ -86,13 +101,13 @@ export function createChainableProxy(terminalResult: unknown = []): DbChain { // --------------------------------------------------------------------------- export interface MockDb { - insert: MockFn; - select: MockFn; - selectDistinct: MockFn; - update: MockFn; - delete: MockFn; - transaction: MockFn; - execute: MockFn; + insert: MockFn + select: MockFn + selectDistinct: MockFn + update: MockFn + delete: MockFn + transaction: MockFn + execute: MockFn } /** @@ -107,7 +122,7 @@ export function createMockDb(): MockDb { delete: vi.fn(), transaction: vi.fn(), execute: vi.fn(), - }; + } } /** @@ -115,17 +130,17 @@ export function createMockDb(): MockDb { * Returns the new selectChain for per-test mock setup. */ export function resetDbMocks(mockDb: MockDb): DbChain { - const selectChain = createChainableProxy([]); - const selectDistinctChain = createChainableProxy([]); - mockDb.insert.mockReturnValue(createChainableProxy()); - mockDb.select.mockReturnValue(selectChain); - mockDb.selectDistinct.mockReturnValue(selectDistinctChain); - mockDb.update.mockReturnValue(createChainableProxy([])); - mockDb.delete.mockReturnValue(createChainableProxy()); + const selectChain = createChainableProxy([]) + const selectDistinctChain = createChainableProxy([]) + mockDb.insert.mockReturnValue(createChainableProxy()) + mockDb.select.mockReturnValue(selectChain) + mockDb.selectDistinct.mockReturnValue(selectDistinctChain) + mockDb.update.mockReturnValue(createChainableProxy([])) + mockDb.delete.mockReturnValue(createChainableProxy()) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async for Drizzle transaction mock mockDb.transaction.mockImplementation(async (fn: (tx: MockDb) => Promise) => { - return await fn(mockDb); - }); - mockDb.execute.mockReset(); - return selectChain; + return await fn(mockDb) + }) + mockDb.execute.mockReset() + return selectChain } diff --git a/tests/integration/firehose/account-deletion.test.ts b/tests/integration/firehose/account-deletion.test.ts index 93e37ea..66c0c37 100644 --- a/tests/integration/firehose/account-deletion.test.ts +++ b/tests/integration/firehose/account-deletion.test.ts @@ -1,33 +1,32 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; -import { eq } from "drizzle-orm"; -import { createDb } from "../../../src/db/index.js"; -import type { Database } from "../../../src/db/index.js"; -import { topics } from "../../../src/db/schema/topics.js"; -import { replies } from "../../../src/db/schema/replies.js"; -import { reactions } from "../../../src/db/schema/reactions.js"; -import { users } from "../../../src/db/schema/users.js"; -import { trackedRepos } from "../../../src/db/schema/tracked-repos.js"; -import { TopicIndexer } from "../../../src/firehose/indexers/topic.js"; -import { ReplyIndexer } from "../../../src/firehose/indexers/reply.js"; -import { ReactionIndexer } from "../../../src/firehose/indexers/reaction.js"; -import { RecordHandler } from "../../../src/firehose/handlers/record.js"; -import { IdentityHandler } from "../../../src/firehose/handlers/identity.js"; -import type { IdentityEvent } from "../../../src/firehose/types.js"; -import type { AccountAgeService } from "../../../src/services/account-age.js"; -import type postgres from "postgres"; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest' +import { eq } from 'drizzle-orm' +import { createDb } from '../../../src/db/index.js' +import type { Database } from '../../../src/db/index.js' +import { topics } from '../../../src/db/schema/topics.js' +import { replies } from '../../../src/db/schema/replies.js' +import { reactions } from '../../../src/db/schema/reactions.js' +import { users } from '../../../src/db/schema/users.js' +import { trackedRepos } from '../../../src/db/schema/tracked-repos.js' +import { TopicIndexer } from '../../../src/firehose/indexers/topic.js' +import { ReplyIndexer } from '../../../src/firehose/indexers/reply.js' +import { ReactionIndexer } from '../../../src/firehose/indexers/reaction.js' +import { RecordHandler } from '../../../src/firehose/handlers/record.js' +import { IdentityHandler } from '../../../src/firehose/handlers/identity.js' +import type { IdentityEvent } from '../../../src/firehose/types.js' +import type { AccountAgeService } from '../../../src/services/account-age.js' +import type postgres from 'postgres' /** Stub that skips PLC resolution and always returns 'trusted'. */ function createStubAccountAgeService(): AccountAgeService { return { // eslint-disable-next-line @typescript-eslint/require-await resolveCreationDate: async () => null, - determineTrustStatus: () => "trusted", - }; + determineTrustStatus: () => 'trusted', + } } const DATABASE_URL = - process.env["DATABASE_URL"] ?? - "postgresql://barazo:barazo_dev@localhost:5432/barazo"; + process.env['DATABASE_URL'] ?? 'postgresql://barazo:barazo_dev@localhost:5432/barazo' function createLogger() { return { @@ -35,327 +34,293 @@ function createLogger() { error: () => undefined, warn: () => undefined, debug: () => undefined, - }; + } } /** Asserts a single-row query result and returns the row. */ function one(rows: T[]): T { - expect(rows).toHaveLength(1); - return rows[0] as T; + expect(rows).toHaveLength(1) + return rows[0] as T } -describe("firehose account deletion (integration)", () => { - let db: Database; - let client: postgres.Sql; - let recordHandler: RecordHandler; - let identityHandler: IdentityHandler; +describe('firehose account deletion (integration)', () => { + let db: Database + let client: postgres.Sql + let recordHandler: RecordHandler + let identityHandler: IdentityHandler - const deletedUserDid = "did:plc:deleted-user"; - const survivingUserDid = "did:plc:surviving-user"; + const deletedUserDid = 'did:plc:deleted-user' + const survivingUserDid = 'did:plc:surviving-user' beforeAll(() => { - const conn = createDb(DATABASE_URL); - db = conn.db; - client = conn.client; + const conn = createDb(DATABASE_URL) + db = conn.db + client = conn.client - const logger = createLogger(); - const topicIndexer = new TopicIndexer(db, logger as never); - const replyIndexer = new ReplyIndexer(db, logger as never); - const reactionIndexer = new ReactionIndexer(db, logger as never); + const logger = createLogger() + const topicIndexer = new TopicIndexer(db, logger as never) + const replyIndexer = new ReplyIndexer(db, logger as never) + const reactionIndexer = new ReactionIndexer(db, logger as never) recordHandler = new RecordHandler( { topic: topicIndexer, reply: replyIndexer, reaction: reactionIndexer }, db, logger as never, - createStubAccountAgeService(), - ); + createStubAccountAgeService() + ) - identityHandler = new IdentityHandler(db, logger as never); - }); + identityHandler = new IdentityHandler(db, logger as never) + }) afterAll(async () => { - await client.end(); - }); + await client.end() + }) beforeEach(async () => { // Clean all tables - await db.delete(reactions); - await db.delete(replies); - await db.delete(topics); - await db.delete(trackedRepos); - await db.delete(users); - }); + await db.delete(reactions) + await db.delete(replies) + await db.delete(topics) + await db.delete(trackedRepos) + await db.delete(users) + }) async function populateDataForBothUsers(): Promise { // Create data for the user that will be deleted await recordHandler.handle({ id: 100, - action: "create", + action: 'create', did: deletedUserDid, - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "del-topic1", + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'del-topic1', record: { - title: "Deleted user topic", - content: "This will be purged", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-15T10:00:00.000Z", + title: 'Deleted user topic', + content: 'This will be purged', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-15T10:00:00.000Z', }, - cid: "bafydeltopic1", + cid: 'bafydeltopic1', live: true, - }); + }) // Deleted user's reply on their own topic - const deletedTopicUri = `at://${deletedUserDid}/forum.barazo.topic.post/del-topic1`; + const deletedTopicUri = `at://${deletedUserDid}/forum.barazo.topic.post/del-topic1` await recordHandler.handle({ id: 101, - action: "create", + action: 'create', did: deletedUserDid, - rev: "rev1", - collection: "forum.barazo.topic.reply", - rkey: "del-reply1", + rev: 'rev1', + collection: 'forum.barazo.topic.reply', + rkey: 'del-reply1', record: { - content: "Deleted user reply", - root: { uri: deletedTopicUri, cid: "bafydeltopic1" }, - parent: { uri: deletedTopicUri, cid: "bafydeltopic1" }, - community: "did:plc:community", - createdAt: "2026-01-15T11:00:00.000Z", + content: 'Deleted user reply', + root: { uri: deletedTopicUri, cid: 'bafydeltopic1' }, + parent: { uri: deletedTopicUri, cid: 'bafydeltopic1' }, + community: 'did:plc:community', + createdAt: '2026-01-15T11:00:00.000Z', }, - cid: "bafydelreply1", + cid: 'bafydelreply1', live: true, - }); + }) // Create data for the surviving user await recordHandler.handle({ id: 200, - action: "create", + action: 'create', did: survivingUserDid, - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "surv-topic1", + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'surv-topic1', record: { - title: "Surviving user topic", - content: "This should remain", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-15T10:00:00.000Z", + title: 'Surviving user topic', + content: 'This should remain', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-15T10:00:00.000Z', }, - cid: "bafysurvtopic1", + cid: 'bafysurvtopic1', live: true, - }); + }) // Surviving user's reaction on deleted user's topic await recordHandler.handle({ id: 201, - action: "create", + action: 'create', did: survivingUserDid, - rev: "rev1", - collection: "forum.barazo.interaction.reaction", - rkey: "surv-react1", + rev: 'rev1', + collection: 'forum.barazo.interaction.reaction', + rkey: 'surv-react1', record: { - subject: { uri: deletedTopicUri, cid: "bafydeltopic1" }, - type: "like", - community: "did:plc:community", - createdAt: "2026-01-15T12:00:00.000Z", + subject: { uri: deletedTopicUri, cid: 'bafydeltopic1' }, + type: 'like', + community: 'did:plc:community', + createdAt: '2026-01-15T12:00:00.000Z', }, - cid: "bafysurvreact1", + cid: 'bafysurvreact1', live: true, - }); + }) // Deleted user's reaction on surviving user's topic - const survivingTopicUri = `at://${survivingUserDid}/forum.barazo.topic.post/surv-topic1`; + const survivingTopicUri = `at://${survivingUserDid}/forum.barazo.topic.post/surv-topic1` await recordHandler.handle({ id: 102, - action: "create", + action: 'create', did: deletedUserDid, - rev: "rev1", - collection: "forum.barazo.interaction.reaction", - rkey: "del-react1", + rev: 'rev1', + collection: 'forum.barazo.interaction.reaction', + rkey: 'del-react1', record: { - subject: { uri: survivingTopicUri, cid: "bafysurvtopic1" }, - type: "like", - community: "did:plc:community", - createdAt: "2026-01-15T13:00:00.000Z", + subject: { uri: survivingTopicUri, cid: 'bafysurvtopic1' }, + type: 'like', + community: 'did:plc:community', + createdAt: '2026-01-15T13:00:00.000Z', }, - cid: "bafydelreact1", + cid: 'bafydelreact1', live: true, - }); + }) // Add deleted user to tracked repos - await db - .insert(trackedRepos) - .values({ did: deletedUserDid }) - .onConflictDoNothing(); + await db.insert(trackedRepos).values({ did: deletedUserDid }).onConflictDoNothing() } - it("purges all data for deleted account", async () => { - await populateDataForBothUsers(); + it('purges all data for deleted account', async () => { + await populateDataForBothUsers() // Verify data exists before deletion - const topicsBefore = await db - .select() - .from(topics) - .where(eq(topics.authorDid, deletedUserDid)); - expect(topicsBefore).toHaveLength(1); + const topicsBefore = await db.select().from(topics).where(eq(topics.authorDid, deletedUserDid)) + expect(topicsBefore).toHaveLength(1) const repliesBefore = await db .select() .from(replies) - .where(eq(replies.authorDid, deletedUserDid)); - expect(repliesBefore).toHaveLength(1); + .where(eq(replies.authorDid, deletedUserDid)) + expect(repliesBefore).toHaveLength(1) const reactionsBefore = await db .select() .from(reactions) - .where(eq(reactions.authorDid, deletedUserDid)); - expect(reactionsBefore).toHaveLength(1); + .where(eq(reactions.authorDid, deletedUserDid)) + expect(reactionsBefore).toHaveLength(1) - const userBefore = await db - .select() - .from(users) - .where(eq(users.did, deletedUserDid)); - expect(userBefore).toHaveLength(1); + const userBefore = await db.select().from(users).where(eq(users.did, deletedUserDid)) + expect(userBefore).toHaveLength(1) const trackedBefore = await db .select() .from(trackedRepos) - .where(eq(trackedRepos.did, deletedUserDid)); - expect(trackedBefore).toHaveLength(1); + .where(eq(trackedRepos.did, deletedUserDid)) + expect(trackedBefore).toHaveLength(1) // Fire deletion event const deletionEvent: IdentityEvent = { id: 999, did: deletedUserDid, - handle: "deleted.user", + handle: 'deleted.user', isActive: false, - status: "deleted", - }; + status: 'deleted', + } - await identityHandler.handle(deletionEvent); + await identityHandler.handle(deletionEvent) // Verify all data for deleted user is gone - const topicsAfter = await db - .select() - .from(topics) - .where(eq(topics.authorDid, deletedUserDid)); - expect(topicsAfter).toHaveLength(0); + const topicsAfter = await db.select().from(topics).where(eq(topics.authorDid, deletedUserDid)) + expect(topicsAfter).toHaveLength(0) const repliesAfter = await db .select() .from(replies) - .where(eq(replies.authorDid, deletedUserDid)); - expect(repliesAfter).toHaveLength(0); + .where(eq(replies.authorDid, deletedUserDid)) + expect(repliesAfter).toHaveLength(0) const reactionsAfter = await db .select() .from(reactions) - .where(eq(reactions.authorDid, deletedUserDid)); - expect(reactionsAfter).toHaveLength(0); + .where(eq(reactions.authorDid, deletedUserDid)) + expect(reactionsAfter).toHaveLength(0) - const userAfter = await db - .select() - .from(users) - .where(eq(users.did, deletedUserDid)); - expect(userAfter).toHaveLength(0); + const userAfter = await db.select().from(users).where(eq(users.did, deletedUserDid)) + expect(userAfter).toHaveLength(0) const trackedAfter = await db .select() .from(trackedRepos) - .where(eq(trackedRepos.did, deletedUserDid)); - expect(trackedAfter).toHaveLength(0); - }); + .where(eq(trackedRepos.did, deletedUserDid)) + expect(trackedAfter).toHaveLength(0) + }) it("preserves other users' data during deletion", async () => { - await populateDataForBothUsers(); + await populateDataForBothUsers() // Fire deletion for one user const deletionEvent: IdentityEvent = { id: 1000, did: deletedUserDid, - handle: "deleted.user", + handle: 'deleted.user', isActive: false, - status: "deleted", - }; + status: 'deleted', + } - await identityHandler.handle(deletionEvent); + await identityHandler.handle(deletionEvent) // Verify surviving user's topic is untouched const survivingTopics = one( - await db - .select() - .from(topics) - .where(eq(topics.authorDid, survivingUserDid)), - ); - expect(survivingTopics.title).toBe("Surviving user topic"); + await db.select().from(topics).where(eq(topics.authorDid, survivingUserDid)) + ) + expect(survivingTopics.title).toBe('Surviving user topic') // Verify surviving user still exists - const survivingUser = one( - await db - .select() - .from(users) - .where(eq(users.did, survivingUserDid)), - ); - expect(survivingUser.did).toBe(survivingUserDid); + const survivingUser = one(await db.select().from(users).where(eq(users.did, survivingUserDid))) + expect(survivingUser.did).toBe(survivingUserDid) // Surviving user's reaction on deleted user's topic should still exist // (it's owned by the surviving user, even though the subject is gone) const survivingReactions = one( - await db - .select() - .from(reactions) - .where(eq(reactions.authorDid, survivingUserDid)), - ); - expect(survivingReactions.authorDid).toBe(survivingUserDid); - }); - - it("handles active identity event (user handle update)", async () => { + await db.select().from(reactions).where(eq(reactions.authorDid, survivingUserDid)) + ) + expect(survivingReactions.authorDid).toBe(survivingUserDid) + }) + + it('handles active identity event (user handle update)', async () => { // First create the user via a record event await recordHandler.handle({ id: 300, - action: "create", - did: "did:plc:handle-test", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "ht-topic1", + action: 'create', + did: 'did:plc:handle-test', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'ht-topic1', record: { - title: "Handle test", - content: "Testing handle update", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-15T10:00:00.000Z", + title: 'Handle test', + content: 'Testing handle update', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-15T10:00:00.000Z', }, - cid: "bafyht1", + cid: 'bafyht1', live: true, - }); + }) // User stub has DID as handle const userBefore = one( - await db - .select() - .from(users) - .where(eq(users.did, "did:plc:handle-test")), - ); - expect(userBefore.handle).toBe("did:plc:handle-test"); + await db.select().from(users).where(eq(users.did, 'did:plc:handle-test')) + ) + expect(userBefore.handle).toBe('did:plc:handle-test') // Fire identity active event with real handle const identityEvent: IdentityEvent = { id: 301, - did: "did:plc:handle-test", - handle: "real-handle.bsky.social", + did: 'did:plc:handle-test', + handle: 'real-handle.bsky.social', isActive: true, - status: "active", - }; + status: 'active', + } - await identityHandler.handle(identityEvent); + await identityHandler.handle(identityEvent) // Verify handle was updated - const userAfter = one( - await db - .select() - .from(users) - .where(eq(users.did, "did:plc:handle-test")), - ); - expect(userAfter.handle).toBe("real-handle.bsky.social"); - }); -}); + const userAfter = one(await db.select().from(users).where(eq(users.did, 'did:plc:handle-test'))) + expect(userAfter.handle).toBe('real-handle.bsky.social') + }) +}) diff --git a/tests/integration/firehose/record-processing.test.ts b/tests/integration/firehose/record-processing.test.ts index 6f1625e..049be83 100644 --- a/tests/integration/firehose/record-processing.test.ts +++ b/tests/integration/firehose/record-processing.test.ts @@ -1,31 +1,30 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; -import { eq } from "drizzle-orm"; -import { createDb } from "../../../src/db/index.js"; -import type { Database } from "../../../src/db/index.js"; -import { topics } from "../../../src/db/schema/topics.js"; -import { replies } from "../../../src/db/schema/replies.js"; -import { reactions } from "../../../src/db/schema/reactions.js"; -import { users } from "../../../src/db/schema/users.js"; -import { TopicIndexer } from "../../../src/firehose/indexers/topic.js"; -import { ReplyIndexer } from "../../../src/firehose/indexers/reply.js"; -import { ReactionIndexer } from "../../../src/firehose/indexers/reaction.js"; -import { RecordHandler } from "../../../src/firehose/handlers/record.js"; -import type { RecordEvent } from "../../../src/firehose/types.js"; -import type { AccountAgeService } from "../../../src/services/account-age.js"; -import type postgres from "postgres"; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest' +import { eq } from 'drizzle-orm' +import { createDb } from '../../../src/db/index.js' +import type { Database } from '../../../src/db/index.js' +import { topics } from '../../../src/db/schema/topics.js' +import { replies } from '../../../src/db/schema/replies.js' +import { reactions } from '../../../src/db/schema/reactions.js' +import { users } from '../../../src/db/schema/users.js' +import { TopicIndexer } from '../../../src/firehose/indexers/topic.js' +import { ReplyIndexer } from '../../../src/firehose/indexers/reply.js' +import { ReactionIndexer } from '../../../src/firehose/indexers/reaction.js' +import { RecordHandler } from '../../../src/firehose/handlers/record.js' +import type { RecordEvent } from '../../../src/firehose/types.js' +import type { AccountAgeService } from '../../../src/services/account-age.js' +import type postgres from 'postgres' /** Stub that skips PLC resolution and always returns 'trusted'. */ function createStubAccountAgeService(): AccountAgeService { return { // eslint-disable-next-line @typescript-eslint/require-await resolveCreationDate: async () => null, - determineTrustStatus: () => "trusted", - }; + determineTrustStatus: () => 'trusted', + } } const DATABASE_URL = - process.env["DATABASE_URL"] ?? - "postgresql://barazo:barazo_dev@localhost:5432/barazo"; + process.env['DATABASE_URL'] ?? 'postgresql://barazo:barazo_dev@localhost:5432/barazo' function createLogger() { return { @@ -33,311 +32,280 @@ function createLogger() { error: () => undefined, warn: () => undefined, debug: () => undefined, - }; + } } /** Asserts a single-row query result and returns the row. */ function one(rows: T[]): T { - expect(rows).toHaveLength(1); - return rows[0] as T; + expect(rows).toHaveLength(1) + return rows[0] as T } -describe("firehose record processing (integration)", () => { - let db: Database; - let client: postgres.Sql; - let handler: RecordHandler; +describe('firehose record processing (integration)', () => { + let db: Database + let client: postgres.Sql + let handler: RecordHandler beforeAll(() => { - const conn = createDb(DATABASE_URL); - db = conn.db; - client = conn.client; + const conn = createDb(DATABASE_URL) + db = conn.db + client = conn.client - const logger = createLogger(); - const topicIndexer = new TopicIndexer(db, logger as never); - const replyIndexer = new ReplyIndexer(db, logger as never); - const reactionIndexer = new ReactionIndexer(db, logger as never); + const logger = createLogger() + const topicIndexer = new TopicIndexer(db, logger as never) + const replyIndexer = new ReplyIndexer(db, logger as never) + const reactionIndexer = new ReactionIndexer(db, logger as never) handler = new RecordHandler( { topic: topicIndexer, reply: replyIndexer, reaction: reactionIndexer }, db, logger as never, - createStubAccountAgeService(), - ); - }); + createStubAccountAgeService() + ) + }) afterAll(async () => { - await client.end(); - }); + await client.end() + }) beforeEach(async () => { // Clean tables in correct FK-safe order - await db.delete(reactions); - await db.delete(replies); - await db.delete(topics); - await db.delete(users); - }); + await db.delete(reactions) + await db.delete(replies) + await db.delete(topics) + await db.delete(users) + }) - describe("topic lifecycle", () => { + describe('topic lifecycle', () => { const topicEvent: RecordEvent = { id: 1, - action: "create", - did: "did:plc:integ-user1", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "topic1", + action: 'create', + did: 'did:plc:integ-user1', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'topic1', record: { - title: "Integration Test Topic", - content: "This is a test topic for integration testing.", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-15T10:00:00.000Z", + title: 'Integration Test Topic', + content: 'This is a test topic for integration testing.', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-15T10:00:00.000Z', }, - cid: "bafytopic1", + cid: 'bafytopic1', live: true, - }; + } - it("creates a topic and upserts user stub", async () => { - await handler.handle(topicEvent); + it('creates a topic and upserts user stub', async () => { + await handler.handle(topicEvent) const topic = one( await db .select() .from(topics) - .where( - eq( - topics.uri, - "at://did:plc:integ-user1/forum.barazo.topic.post/topic1", - ), - ), - ); - - expect(topic.title).toBe("Integration Test Topic"); - expect(topic.authorDid).toBe("did:plc:integ-user1"); - expect(topic.category).toBe("general"); - expect(topic.communityDid).toBe("did:plc:community"); - expect(topic.replyCount).toBe(0); - expect(topic.reactionCount).toBe(0); + .where(eq(topics.uri, 'at://did:plc:integ-user1/forum.barazo.topic.post/topic1')) + ) + + expect(topic.title).toBe('Integration Test Topic') + expect(topic.authorDid).toBe('did:plc:integ-user1') + expect(topic.category).toBe('general') + expect(topic.communityDid).toBe('did:plc:community') + expect(topic.replyCount).toBe(0) + expect(topic.reactionCount).toBe(0) // Verify user stub was created - const user = one( - await db - .select() - .from(users) - .where(eq(users.did, "did:plc:integ-user1")), - ); + const user = one(await db.select().from(users).where(eq(users.did, 'did:plc:integ-user1'))) - expect(user.handle).toBe("did:plc:integ-user1"); // Stub uses DID as handle - }); + expect(user.handle).toBe('did:plc:integ-user1') // Stub uses DID as handle + }) - it("updates a topic", async () => { - await handler.handle(topicEvent); + it('updates a topic', async () => { + await handler.handle(topicEvent) const updateEvent: RecordEvent = { id: 2, - action: "update", - did: "did:plc:integ-user1", - rev: "rev2", - collection: "forum.barazo.topic.post", - rkey: "topic1", + action: 'update', + did: 'did:plc:integ-user1', + rev: 'rev2', + collection: 'forum.barazo.topic.post', + rkey: 'topic1', record: { - title: "Updated Topic Title", - content: "Updated content for the topic.", - community: "did:plc:community", - category: "discussion", - createdAt: "2026-01-15T10:00:00.000Z", + title: 'Updated Topic Title', + content: 'Updated content for the topic.', + community: 'did:plc:community', + category: 'discussion', + createdAt: '2026-01-15T10:00:00.000Z', }, - cid: "bafytopic1v2", + cid: 'bafytopic1v2', live: true, - }; + } - await handler.handle(updateEvent); + await handler.handle(updateEvent) const topic = one( await db .select() .from(topics) - .where( - eq( - topics.uri, - "at://did:plc:integ-user1/forum.barazo.topic.post/topic1", - ), - ), - ); - - expect(topic.title).toBe("Updated Topic Title"); - expect(topic.content).toBe("Updated content for the topic."); - expect(topic.category).toBe("discussion"); - expect(topic.cid).toBe("bafytopic1v2"); - }); - - it("deletes a topic", async () => { - await handler.handle(topicEvent); + .where(eq(topics.uri, 'at://did:plc:integ-user1/forum.barazo.topic.post/topic1')) + ) + + expect(topic.title).toBe('Updated Topic Title') + expect(topic.content).toBe('Updated content for the topic.') + expect(topic.category).toBe('discussion') + expect(topic.cid).toBe('bafytopic1v2') + }) + + it('deletes a topic', async () => { + await handler.handle(topicEvent) const deleteEvent: RecordEvent = { id: 3, - action: "delete", - did: "did:plc:integ-user1", - rev: "rev3", - collection: "forum.barazo.topic.post", - rkey: "topic1", + action: 'delete', + did: 'did:plc:integ-user1', + rev: 'rev3', + collection: 'forum.barazo.topic.post', + rkey: 'topic1', live: true, - }; + } - await handler.handle(deleteEvent); + await handler.handle(deleteEvent) const result = await db .select() .from(topics) - .where( - eq( - topics.uri, - "at://did:plc:integ-user1/forum.barazo.topic.post/topic1", - ), - ); + .where(eq(topics.uri, 'at://did:plc:integ-user1/forum.barazo.topic.post/topic1')) - expect(result).toHaveLength(0); - }); - }); + expect(result).toHaveLength(0) + }) + }) - describe("reply with count updates", () => { - const topicUri = - "at://did:plc:integ-user1/forum.barazo.topic.post/topic1"; + describe('reply with count updates', () => { + const topicUri = 'at://did:plc:integ-user1/forum.barazo.topic.post/topic1' beforeEach(async () => { // Create a topic first for replies to attach to await handler.handle({ id: 10, - action: "create", - did: "did:plc:integ-user1", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "topic1", + action: 'create', + did: 'did:plc:integ-user1', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'topic1', record: { - title: "Parent Topic", - content: "Topic for reply tests", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-15T10:00:00.000Z", + title: 'Parent Topic', + content: 'Topic for reply tests', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-15T10:00:00.000Z', }, - cid: "bafytopic1", + cid: 'bafytopic1', live: true, - }); - }); + }) + }) - it("creates a reply and increments reply count", async () => { + it('creates a reply and increments reply count', async () => { await handler.handle({ id: 11, - action: "create", - did: "did:plc:integ-user2", - rev: "rev1", - collection: "forum.barazo.topic.reply", - rkey: "reply1", + action: 'create', + did: 'did:plc:integ-user2', + rev: 'rev1', + collection: 'forum.barazo.topic.reply', + rkey: 'reply1', record: { - content: "This is a reply", - root: { uri: topicUri, cid: "bafytopic1" }, - parent: { uri: topicUri, cid: "bafytopic1" }, - community: "did:plc:community", - createdAt: "2026-01-15T11:00:00.000Z", + content: 'This is a reply', + root: { uri: topicUri, cid: 'bafytopic1' }, + parent: { uri: topicUri, cid: 'bafytopic1' }, + community: 'did:plc:community', + createdAt: '2026-01-15T11:00:00.000Z', }, - cid: "bafyreply1", + cid: 'bafyreply1', live: true, - }); + }) // Verify reply exists const reply = one( await db .select() .from(replies) - .where( - eq( - replies.uri, - "at://did:plc:integ-user2/forum.barazo.topic.reply/reply1", - ), - ), - ); + .where(eq(replies.uri, 'at://did:plc:integ-user2/forum.barazo.topic.reply/reply1')) + ) - expect(reply.content).toBe("This is a reply"); - expect(reply.rootUri).toBe(topicUri); + expect(reply.content).toBe('This is a reply') + expect(reply.rootUri).toBe(topicUri) // Verify reply count incremented - const topic = one( - await db.select().from(topics).where(eq(topics.uri, topicUri)), - ); + const topic = one(await db.select().from(topics).where(eq(topics.uri, topicUri))) - expect(topic.replyCount).toBe(1); - }); + expect(topic.replyCount).toBe(1) + }) - it("handles multiple replies and correct count", async () => { + it('handles multiple replies and correct count', async () => { // Add two replies for (let i = 1; i <= 2; i++) { await handler.handle({ id: 20 + i, - action: "create", + action: 'create', did: `did:plc:integ-user${String(i + 1)}`, - rev: "rev1", - collection: "forum.barazo.topic.reply", + rev: 'rev1', + collection: 'forum.barazo.topic.reply', rkey: `reply${String(i)}`, record: { content: `Reply ${String(i)}`, - root: { uri: topicUri, cid: "bafytopic1" }, - parent: { uri: topicUri, cid: "bafytopic1" }, - community: "did:plc:community", + root: { uri: topicUri, cid: 'bafytopic1' }, + parent: { uri: topicUri, cid: 'bafytopic1' }, + community: 'did:plc:community', createdAt: `2026-01-15T1${String(i)}:00:00.000Z`, }, cid: `bafyreply${String(i)}`, live: true, - }); + }) } - const topic = one( - await db.select().from(topics).where(eq(topics.uri, topicUri)), - ); + const topic = one(await db.select().from(topics).where(eq(topics.uri, topicUri))) - expect(topic.replyCount).toBe(2); - }); - }); + expect(topic.replyCount).toBe(2) + }) + }) - describe("reaction with count updates", () => { - const topicUri = - "at://did:plc:integ-user1/forum.barazo.topic.post/topic1"; + describe('reaction with count updates', () => { + const topicUri = 'at://did:plc:integ-user1/forum.barazo.topic.post/topic1' beforeEach(async () => { await handler.handle({ id: 30, - action: "create", - did: "did:plc:integ-user1", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "topic1", + action: 'create', + did: 'did:plc:integ-user1', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'topic1', record: { - title: "Reactable Topic", - content: "Topic for reaction tests", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-15T10:00:00.000Z", + title: 'Reactable Topic', + content: 'Topic for reaction tests', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-15T10:00:00.000Z', }, - cid: "bafytopic1", + cid: 'bafytopic1', live: true, - }); - }); + }) + }) - it("creates a reaction and increments reaction count on topic", async () => { + it('creates a reaction and increments reaction count on topic', async () => { await handler.handle({ id: 31, - action: "create", - did: "did:plc:integ-user2", - rev: "rev1", - collection: "forum.barazo.interaction.reaction", - rkey: "react1", + action: 'create', + did: 'did:plc:integ-user2', + rev: 'rev1', + collection: 'forum.barazo.interaction.reaction', + rkey: 'react1', record: { - subject: { uri: topicUri, cid: "bafytopic1" }, - type: "like", - community: "did:plc:community", - createdAt: "2026-01-15T12:00:00.000Z", + subject: { uri: topicUri, cid: 'bafytopic1' }, + type: 'like', + community: 'did:plc:community', + createdAt: '2026-01-15T12:00:00.000Z', }, - cid: "bafyreact1", + cid: 'bafyreact1', live: true, - }); + }) // Verify reaction exists const reaction = one( @@ -345,165 +313,149 @@ describe("firehose record processing (integration)", () => { .select() .from(reactions) .where( - eq( - reactions.uri, - "at://did:plc:integ-user2/forum.barazo.interaction.reaction/react1", - ), - ), - ); + eq(reactions.uri, 'at://did:plc:integ-user2/forum.barazo.interaction.reaction/react1') + ) + ) - expect(reaction.type).toBe("like"); - expect(reaction.subjectUri).toBe(topicUri); + expect(reaction.type).toBe('like') + expect(reaction.subjectUri).toBe(topicUri) // Verify reaction count incremented on topic - const topic = one( - await db.select().from(topics).where(eq(topics.uri, topicUri)), - ); + const topic = one(await db.select().from(topics).where(eq(topics.uri, topicUri))) - expect(topic.reactionCount).toBe(1); - }); - }); + expect(topic.reactionCount).toBe(1) + }) + }) - describe("idempotent replay", () => { - it("replaying a topic create is idempotent (upsert)", async () => { + describe('idempotent replay', () => { + it('replaying a topic create is idempotent (upsert)', async () => { const event: RecordEvent = { id: 40, - action: "create", - did: "did:plc:integ-user1", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "idem-topic1", + action: 'create', + did: 'did:plc:integ-user1', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'idem-topic1', record: { - title: "Idempotent Topic", - content: "Original content", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-15T10:00:00.000Z", + title: 'Idempotent Topic', + content: 'Original content', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-15T10:00:00.000Z', }, - cid: "bafyidem1", + cid: 'bafyidem1', live: false, - }; + } // Process same event twice - await handler.handle(event); - await handler.handle(event); + await handler.handle(event) + await handler.handle(event) const result = await db .select() .from(topics) - .where( - eq( - topics.uri, - "at://did:plc:integ-user1/forum.barazo.topic.post/idem-topic1", - ), - ); + .where(eq(topics.uri, 'at://did:plc:integ-user1/forum.barazo.topic.post/idem-topic1')) // Should still be exactly one row - expect(result).toHaveLength(1); - const topic = one(result); - expect(topic.title).toBe("Idempotent Topic"); - }); + expect(result).toHaveLength(1) + const topic = one(result) + expect(topic.title).toBe('Idempotent Topic') + }) - it("replaying a reply create does not duplicate rows", async () => { - const topicUri = - "at://did:plc:integ-user1/forum.barazo.topic.post/idem-topic2"; + it('replaying a reply create does not duplicate rows', async () => { + const topicUri = 'at://did:plc:integ-user1/forum.barazo.topic.post/idem-topic2' // Create topic await handler.handle({ id: 50, - action: "create", - did: "did:plc:integ-user1", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "idem-topic2", + action: 'create', + did: 'did:plc:integ-user1', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'idem-topic2', record: { - title: "Topic for replay test", - content: "Content", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-15T10:00:00.000Z", + title: 'Topic for replay test', + content: 'Content', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-15T10:00:00.000Z', }, - cid: "bafyidem2", + cid: 'bafyidem2', live: false, - }); + }) const replyEvent: RecordEvent = { id: 51, - action: "create", - did: "did:plc:integ-user2", - rev: "rev1", - collection: "forum.barazo.topic.reply", - rkey: "idem-reply1", + action: 'create', + did: 'did:plc:integ-user2', + rev: 'rev1', + collection: 'forum.barazo.topic.reply', + rkey: 'idem-reply1', record: { - content: "Replay test reply", - root: { uri: topicUri, cid: "bafyidem2" }, - parent: { uri: topicUri, cid: "bafyidem2" }, - community: "did:plc:community", - createdAt: "2026-01-15T11:00:00.000Z", + content: 'Replay test reply', + root: { uri: topicUri, cid: 'bafyidem2' }, + parent: { uri: topicUri, cid: 'bafyidem2' }, + community: 'did:plc:community', + createdAt: '2026-01-15T11:00:00.000Z', }, - cid: "bafyidemreply1", + cid: 'bafyidemreply1', live: false, - }; + } // Reply uses onConflictDoNothing, so second insert is a no-op for the row. // In practice, Tap handles replay deduplication. - await handler.handle(replyEvent); - await handler.handle(replyEvent); + await handler.handle(replyEvent) + await handler.handle(replyEvent) const replyRows = await db .select() .from(replies) - .where( - eq( - replies.uri, - "at://did:plc:integ-user2/forum.barazo.topic.reply/idem-reply1", - ), - ); + .where(eq(replies.uri, 'at://did:plc:integ-user2/forum.barazo.topic.reply/idem-reply1')) // Exactly one reply row (onConflictDoNothing) - expect(replyRows).toHaveLength(1); - }); - }); + expect(replyRows).toHaveLength(1) + }) + }) - describe("unsupported and invalid records", () => { - it("skips unsupported collections", async () => { + describe('unsupported and invalid records', () => { + it('skips unsupported collections', async () => { const event: RecordEvent = { id: 60, - action: "create", - did: "did:plc:integ-user1", - rev: "rev1", - collection: "app.bsky.feed.post", - rkey: "post1", - record: { text: "Hello world" }, - cid: "bafypost1", + action: 'create', + did: 'did:plc:integ-user1', + rev: 'rev1', + collection: 'app.bsky.feed.post', + rkey: 'post1', + record: { text: 'Hello world' }, + cid: 'bafypost1', live: true, - }; + } // Should not throw - await handler.handle(event); + await handler.handle(event) // No topic should be created - const result = await db.select().from(topics); - expect(result).toHaveLength(0); - }); + const result = await db.select().from(topics) + expect(result).toHaveLength(0) + }) - it("skips invalid record data", async () => { + it('skips invalid record data', async () => { const event: RecordEvent = { id: 61, - action: "create", - did: "did:plc:integ-user1", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "bad1", - record: { invalid: "data" }, - cid: "bafybad1", + action: 'create', + did: 'did:plc:integ-user1', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'bad1', + record: { invalid: 'data' }, + cid: 'bafybad1', live: true, - }; + } - await handler.handle(event); + await handler.handle(event) - const result = await db.select().from(topics); - expect(result).toHaveLength(0); - }); - }); -}); + const result = await db.select().from(topics) + expect(result).toHaveLength(0) + }) + }) +}) diff --git a/tests/integration/health.test.ts b/tests/integration/health.test.ts index 3ab1e54..002e94e 100644 --- a/tests/integration/health.test.ts +++ b/tests/integration/health.test.ts @@ -1,85 +1,83 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { buildApp } from "../../src/app.js"; -import type { FastifyInstance } from "fastify"; +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { buildApp } from '../../src/app.js' +import type { FastifyInstance } from 'fastify' interface HealthResponse { - status: string; - version: string; - uptime: number; + status: string + version: string + uptime: number } interface ReadyResponse { - status: string; - checks: Record; + status: string + checks: Record } /** * Integration test: requires PostgreSQL and Valkey running. * Uses docker-compose.dev.yml services (start with `pnpm dev:infra` from workspace root). */ -describe("health routes (integration)", () => { - let app: FastifyInstance; +describe('health routes (integration)', () => { + let app: FastifyInstance beforeAll(async () => { app = await buildApp({ DATABASE_URL: - process.env["DATABASE_URL"] ?? - "postgresql://barazo:barazo_dev@localhost:5432/barazo", - VALKEY_URL: process.env["VALKEY_URL"] ?? "redis://localhost:6379", - TAP_URL: process.env["TAP_URL"] ?? "http://localhost:2480", - TAP_ADMIN_PASSWORD: - process.env["TAP_ADMIN_PASSWORD"] ?? "tap_dev_secret", - HOST: "0.0.0.0", + process.env['DATABASE_URL'] ?? 'postgresql://barazo:barazo_dev@localhost:5432/barazo', + VALKEY_URL: process.env['VALKEY_URL'] ?? 'redis://localhost:6379', + TAP_URL: process.env['TAP_URL'] ?? 'http://localhost:2480', + TAP_ADMIN_PASSWORD: process.env['TAP_ADMIN_PASSWORD'] ?? 'tap_dev_secret', + HOST: '0.0.0.0', PORT: 0, - LOG_LEVEL: "silent", - CORS_ORIGINS: "http://localhost:3001", - COMMUNITY_MODE: "single" as const, - COMMUNITY_NAME: "Test Community", + LOG_LEVEL: 'silent', + CORS_ORIGINS: 'http://localhost:3001', + COMMUNITY_MODE: 'single' as const, + COMMUNITY_NAME: 'Test Community', RATE_LIMIT_AUTH: 10, RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, OAUTH_CLIENT_ID: - "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fapi%2Fauth%2Fcallback", - OAUTH_REDIRECT_URI: "http://127.0.0.1:3000/api/auth/callback", - SESSION_SECRET: "integration-test-secret-minimum-32-chars", + 'http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fapi%2Fauth%2Fcallback', + OAUTH_REDIRECT_URI: 'http://127.0.0.1:3000/api/auth/callback', + SESSION_SECRET: 'integration-test-secret-minimum-32-chars', OAUTH_SESSION_TTL: 604800, OAUTH_ACCESS_TOKEN_TTL: 900, - }); + }) - await app.cache.connect(); - await app.ready(); - }); + await app.cache.connect() + await app.ready() + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("GET /api/health returns 200 with version", async () => { + it('GET /api/health returns 200 with version', async () => { const response = await app.inject({ - method: "GET", - url: "/api/health", - }); + method: 'GET', + url: '/api/health', + }) - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.status).toBe("healthy"); - expect(body.version).toBe("0.1.0"); - expect(typeof body.uptime).toBe("number"); - }); + expect(response.statusCode).toBe(200) + const body = response.json() + expect(body.status).toBe('healthy') + expect(body.version).toBe('0.1.0') + expect(typeof body.uptime).toBe('number') + }) - it("GET /api/health/ready returns 200 when all services healthy", async () => { + it('GET /api/health/ready returns 200 when all services healthy', async () => { const response = await app.inject({ - method: "GET", - url: "/api/health/ready", - }); + method: 'GET', + url: '/api/health/ready', + }) - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.status).toBe("ready"); - expect(body.checks["database"]?.status).toBe("healthy"); - expect(body.checks["cache"]?.status).toBe("healthy"); - expect(typeof body.checks["database"]?.latency).toBe("number"); - expect(typeof body.checks["cache"]?.latency).toBe("number"); - }); -}); + expect(response.statusCode).toBe(200) + const body = response.json() + expect(body.status).toBe('ready') + expect(body.checks['database']?.status).toBe('healthy') + expect(body.checks['cache']?.status).toBe('healthy') + expect(typeof body.checks['database']?.latency).toBe('number') + expect(typeof body.checks['cache']?.latency).toBe('number') + }) +}) diff --git a/tests/integration/plc-did-live.test.ts b/tests/integration/plc-did-live.test.ts index 70224fb..da1b61d 100644 --- a/tests/integration/plc-did-live.test.ts +++ b/tests/integration/plc-did-live.test.ts @@ -21,85 +21,92 @@ * logged so the DID could be updated later if needed. */ -import { describe, it, expect } from "vitest"; -import { createPlcDidService } from "../../src/services/plc-did.js"; -import type { Logger } from "../../src/lib/logger.js"; +import { describe, it, expect } from 'vitest' +import { createPlcDidService } from '../../src/services/plc-did.js' +import type { Logger } from '../../src/lib/logger.js' -const SHOULD_RUN = process.env.LIVE_PLC_TEST === "1"; +const SHOULD_RUN = process.env.LIVE_PLC_TEST === '1' function createTestLogger(): Logger { return { - info: (...args: unknown[]) => { process.stdout.write(`[INFO] ${args.join(" ")}\n`); }, - error: (...args: unknown[]) => { process.stderr.write(`[ERROR] ${args.join(" ")}\n`); }, - warn: (...args: unknown[]) => { process.stderr.write(`[WARN] ${args.join(" ")}\n`); }, - debug: () => { /* empty */ }, - fatal: (...args: unknown[]) => { process.stderr.write(`[FATAL] ${args.join(" ")}\n`); }, - trace: () => { /* empty */ }, + info: (...args: unknown[]) => { + process.stdout.write(`[INFO] ${args.join(' ')}\n`) + }, + error: (...args: unknown[]) => { + process.stderr.write(`[ERROR] ${args.join(' ')}\n`) + }, + warn: (...args: unknown[]) => { + process.stderr.write(`[WARN] ${args.join(' ')}\n`) + }, + debug: () => { + /* empty */ + }, + fatal: (...args: unknown[]) => { + process.stderr.write(`[FATAL] ${args.join(' ')}\n`) + }, + trace: () => { + /* empty */ + }, child: () => createTestLogger(), - silent: () => { /* empty */ }, - level: "info", - } as unknown as Logger; + silent: () => { + /* empty */ + }, + level: 'info', + } as unknown as Logger } -describe.skipIf(!SHOULD_RUN)( - "PLC DID live integration (handle + serviceEndpoint)", - () => { - it("creates a DID on plc.directory with handle and serviceEndpoint", async () => { - const logger = createTestLogger(); - const service = createPlcDidService(logger); +describe.skipIf(!SHOULD_RUN)('PLC DID live integration (handle + serviceEndpoint)', () => { + it('creates a DID on plc.directory with handle and serviceEndpoint', async () => { + const logger = createTestLogger() + const service = createPlcDidService(logger) - // Use a unique timestamp-based handle to avoid collisions - const timestamp = Date.now(); - const handle = `test-${String(timestamp)}.barazo.forum`; - const serviceEndpoint = `https://test-${String(timestamp)}.barazo.forum`; + // Use a unique timestamp-based handle to avoid collisions + const timestamp = Date.now() + const handle = `test-${String(timestamp)}.barazo.forum` + const serviceEndpoint = `https://test-${String(timestamp)}.barazo.forum` - process.stdout.write("\n=== PLC DID Live Test ===\n"); - process.stdout.write(`Handle: ${handle}\n`); - process.stdout.write(`Service Endpoint: ${serviceEndpoint}\n`); + process.stdout.write('\n=== PLC DID Live Test ===\n') + process.stdout.write(`Handle: ${handle}\n`) + process.stdout.write(`Service Endpoint: ${serviceEndpoint}\n`) - const result = await service.generateDid({ - handle, - serviceEndpoint, - }); + const result = await service.generateDid({ + handle, + serviceEndpoint, + }) - // Verify the result structure - expect(result.did).toMatch(/^did:plc:[a-z2-7]{24}$/); - expect(result.signingKey).toMatch(/^[0-9a-f]{64}$/); - expect(result.rotationKey).toMatch(/^[0-9a-f]{64}$/); + // Verify the result structure + expect(result.did).toMatch(/^did:plc:[a-z2-7]{24}$/) + expect(result.signingKey).toMatch(/^[0-9a-f]{64}$/) + expect(result.rotationKey).toMatch(/^[0-9a-f]{64}$/) - process.stdout.write(`\nGenerated DID: ${result.did}\n`); - process.stdout.write(`Signing Key (hex): ${result.signingKey}\n`); - process.stdout.write(`Rotation Key (hex): ${result.rotationKey}\n`); - process.stdout.write(`\nVerify at: https://plc.directory/${result.did}\n`); - process.stdout.write("=== End PLC DID Live Test ===\n\n"); + process.stdout.write(`\nGenerated DID: ${result.did}\n`) + process.stdout.write(`Signing Key (hex): ${result.signingKey}\n`) + process.stdout.write(`Rotation Key (hex): ${result.rotationKey}\n`) + process.stdout.write(`\nVerify at: https://plc.directory/${result.did}\n`) + process.stdout.write('=== End PLC DID Live Test ===\n\n') - // Verify the DID is resolvable from plc.directory - const verifyResponse = await fetch( - `https://plc.directory/${result.did}`, - ); - expect(verifyResponse.status).toBe(200); + // Verify the DID is resolvable from plc.directory + const verifyResponse = await fetch(`https://plc.directory/${result.did}`) + expect(verifyResponse.status).toBe(200) - const didDoc = (await verifyResponse.json()) as Record; - expect(didDoc.id).toBe(result.did); + const didDoc = (await verifyResponse.json()) as Record + expect(didDoc.id).toBe(result.did) - // Verify alsoKnownAs contains our handle - const alsoKnownAs = didDoc.alsoKnownAs as string[]; - expect(alsoKnownAs).toContain(`at://${handle}`); + // Verify alsoKnownAs contains our handle + const alsoKnownAs = didDoc.alsoKnownAs as string[] + expect(alsoKnownAs).toContain(`at://${handle}`) - // Verify service endpoint - const services = didDoc.service as Array<{ - id: string; - type: string; - serviceEndpoint: string; - }>; - const pdsService = services.find( - (s) => s.type === "AtprotoPersonalDataServer", - ); - expect(pdsService).toBeDefined(); - expect(pdsService?.serviceEndpoint).toBe(serviceEndpoint); - }, 30_000); // 30s timeout for network call - }, -); + // Verify service endpoint + const services = didDoc.service as Array<{ + id: string + type: string + serviceEndpoint: string + }> + const pdsService = services.find((s) => s.type === 'AtprotoPersonalDataServer') + expect(pdsService).toBeDefined() + expect(pdsService?.serviceEndpoint).toBe(serviceEndpoint) + }, 30_000) // 30s timeout for network call +}) /** * Setup wizard integration: verify the initialize endpoint passes diff --git a/tests/unit/auth/middleware.test.ts b/tests/unit/auth/middleware.test.ts index 0e0ec43..dddebdc 100644 --- a/tests/unit/auth/middleware.test.ts +++ b/tests/unit/auth/middleware.test.ts @@ -1,16 +1,16 @@ -import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import { createAuthMiddleware } from "../../../src/auth/middleware.js"; -import type { RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService, Session } from "../../../src/auth/session.js"; -import type { Logger } from "../../../src/lib/logger.js"; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import { createAuthMiddleware } from '../../../src/auth/middleware.js' +import type { RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService, Session } from '../../../src/auth/session.js' +import type { Logger } from '../../../src/lib/logger.js' // --------------------------------------------------------------------------- // Standalone mock functions (avoids @typescript-eslint/unbound-method) // --------------------------------------------------------------------------- -const validateAccessTokenFn = vi.fn<(...args: unknown[]) => Promise>(); +const validateAccessTokenFn = vi.fn<(...args: unknown[]) => Promise>() function createMockSessionService(): SessionService { return { @@ -19,12 +19,12 @@ function createMockSessionService(): SessionService { refreshSession: vi.fn(), deleteSession: vi.fn(), deleteAllSessionsForDid: vi.fn(), - }; + } } // Logger mock functions -const logErrorFn = vi.fn(); -const logWarnFn = vi.fn(); +const logErrorFn = vi.fn() +const logWarnFn = vi.fn() function createMockLogger(): Logger { return { @@ -36,225 +36,227 @@ function createMockLogger(): Logger { trace: vi.fn(), child: vi.fn(), silent: vi.fn(), - level: "silent", - } as unknown as Logger; + level: 'silent', + } as unknown as Logger } // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- -const VALID_TOKEN = "a".repeat(64); +const VALID_TOKEN = 'a'.repeat(64) const VALID_SESSION: Session = { - sid: "s".repeat(64), - did: "did:plc:abc123", - handle: "alice.bsky.social", - accessTokenHash: "h".repeat(64), + sid: 's'.repeat(64), + did: 'did:plc:abc123', + handle: 'alice.bsky.social', + accessTokenHash: 'h'.repeat(64), accessTokenExpiresAt: Date.now() + 900_000, createdAt: Date.now() - 60_000, -}; +} // --------------------------------------------------------------------------- // requireAuth tests // --------------------------------------------------------------------------- -describe("requireAuth middleware", () => { - let app: FastifyInstance; +describe('requireAuth middleware', () => { + let app: FastifyInstance beforeAll(async () => { - const mockSessionService = createMockSessionService(); - const mockLogger = createMockLogger(); + const mockSessionService = createMockSessionService() + const mockLogger = createMockLogger() - const { requireAuth } = createAuthMiddleware(mockSessionService, mockLogger); + const { requireAuth } = createAuthMiddleware(mockSessionService, mockLogger) - app = Fastify({ logger: false }); + app = Fastify({ logger: false }) // Fastify requires decoration before hooks can set properties - app.decorateRequest("user", undefined); + app.decorateRequest('user', undefined) - app.get("/test", { preHandler: [requireAuth] }, (request) => { - return { user: request.user }; - }); + app.get('/test', { preHandler: [requireAuth] }, (request) => { + return { user: request.user } + }) - await app.ready(); - }); + await app.ready() + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - it("returns 401 for missing Authorization header", async () => { + it('returns 401 for missing Authorization header', async () => { const response = await app.inject({ - method: "GET", - url: "/test", - }); + method: 'GET', + url: '/test', + }) - expect(response.statusCode).toBe(401); - expect(response.json<{ error: string }>()).toStrictEqual({ error: "Authentication required" }); - }); + expect(response.statusCode).toBe(401) + expect(response.json<{ error: string }>()).toStrictEqual({ error: 'Authentication required' }) + }) - it("returns 401 for non-Bearer authorization scheme", async () => { + it('returns 401 for non-Bearer authorization scheme', async () => { const response = await app.inject({ - method: "GET", - url: "/test", - headers: { authorization: "Basic dXNlcjpwYXNz" }, - }); + method: 'GET', + url: '/test', + headers: { authorization: 'Basic dXNlcjpwYXNz' }, + }) - expect(response.statusCode).toBe(401); - expect(response.json<{ error: string }>()).toStrictEqual({ error: "Authentication required" }); - }); + expect(response.statusCode).toBe(401) + expect(response.json<{ error: string }>()).toStrictEqual({ error: 'Authentication required' }) + }) - it("returns 401 for empty Bearer token", async () => { + it('returns 401 for empty Bearer token', async () => { const response = await app.inject({ - method: "GET", - url: "/test", - headers: { authorization: "Bearer " }, - }); + method: 'GET', + url: '/test', + headers: { authorization: 'Bearer ' }, + }) - expect(response.statusCode).toBe(401); - expect(response.json<{ error: string }>()).toStrictEqual({ error: "Authentication required" }); - }); + expect(response.statusCode).toBe(401) + expect(response.json<{ error: string }>()).toStrictEqual({ error: 'Authentication required' }) + }) - it("returns 401 for invalid/expired token", async () => { - validateAccessTokenFn.mockResolvedValueOnce(undefined); + it('returns 401 for invalid/expired token', async () => { + validateAccessTokenFn.mockResolvedValueOnce(undefined) const response = await app.inject({ - method: "GET", - url: "/test", + method: 'GET', + url: '/test', headers: { authorization: `Bearer ${VALID_TOKEN}` }, - }); + }) - expect(response.statusCode).toBe(401); - expect(response.json<{ error: string }>()).toStrictEqual({ error: "Invalid or expired token" }); - expect(validateAccessTokenFn).toHaveBeenCalledWith(VALID_TOKEN); - }); + expect(response.statusCode).toBe(401) + expect(response.json<{ error: string }>()).toStrictEqual({ error: 'Invalid or expired token' }) + expect(validateAccessTokenFn).toHaveBeenCalledWith(VALID_TOKEN) + }) - it("sets request.user and returns 200 for valid token", async () => { - validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION); + it('sets request.user and returns 200 for valid token', async () => { + validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) const response = await app.inject({ - method: "GET", - url: "/test", + method: 'GET', + url: '/test', headers: { authorization: `Bearer ${VALID_TOKEN}` }, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) - const body = response.json<{ user: RequestUser }>(); + const body = response.json<{ user: RequestUser }>() expect(body.user).toStrictEqual({ did: VALID_SESSION.did, handle: VALID_SESSION.handle, sid: VALID_SESSION.sid, - }); - expect(validateAccessTokenFn).toHaveBeenCalledWith(VALID_TOKEN); - }); + }) + expect(validateAccessTokenFn).toHaveBeenCalledWith(VALID_TOKEN) + }) - it("returns 502 when sessionService throws", async () => { - validateAccessTokenFn.mockRejectedValueOnce(new Error("Valkey connection lost")); + it('returns 502 when sessionService throws', async () => { + validateAccessTokenFn.mockRejectedValueOnce(new Error('Valkey connection lost')) const response = await app.inject({ - method: "GET", - url: "/test", + method: 'GET', + url: '/test', headers: { authorization: `Bearer ${VALID_TOKEN}` }, - }); + }) - expect(response.statusCode).toBe(502); - expect(response.json<{ error: string }>()).toStrictEqual({ error: "Service temporarily unavailable" }); - expect(logErrorFn).toHaveBeenCalledOnce(); - }); -}); + expect(response.statusCode).toBe(502) + expect(response.json<{ error: string }>()).toStrictEqual({ + error: 'Service temporarily unavailable', + }) + expect(logErrorFn).toHaveBeenCalledOnce() + }) +}) // --------------------------------------------------------------------------- // optionalAuth tests // --------------------------------------------------------------------------- -describe("optionalAuth middleware", () => { - let app: FastifyInstance; +describe('optionalAuth middleware', () => { + let app: FastifyInstance beforeAll(async () => { - const mockSessionService = createMockSessionService(); - const mockLogger = createMockLogger(); + const mockSessionService = createMockSessionService() + const mockLogger = createMockLogger() - const { optionalAuth } = createAuthMiddleware(mockSessionService, mockLogger); + const { optionalAuth } = createAuthMiddleware(mockSessionService, mockLogger) - app = Fastify({ logger: false }); + app = Fastify({ logger: false }) // Fastify requires decoration before hooks can set properties - app.decorateRequest("user", undefined); + app.decorateRequest('user', undefined) - app.get("/test", { preHandler: [optionalAuth] }, (request) => { - return { user: request.user ?? null }; - }); + app.get('/test', { preHandler: [optionalAuth] }, (request) => { + return { user: request.user ?? null } + }) - await app.ready(); - }); + await app.ready() + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - it("sets request.user for valid token", async () => { - validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION); + it('sets request.user for valid token', async () => { + validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) const response = await app.inject({ - method: "GET", - url: "/test", + method: 'GET', + url: '/test', headers: { authorization: `Bearer ${VALID_TOKEN}` }, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) - const body = response.json<{ user: RequestUser }>(); + const body = response.json<{ user: RequestUser }>() expect(body.user).toStrictEqual({ did: VALID_SESSION.did, handle: VALID_SESSION.handle, sid: VALID_SESSION.sid, - }); - }); + }) + }) - it("continues with request.user undefined for missing Authorization header", async () => { + it('continues with request.user undefined for missing Authorization header', async () => { const response = await app.inject({ - method: "GET", - url: "/test", - }); + method: 'GET', + url: '/test', + }) - expect(response.statusCode).toBe(200); - expect(response.json<{ user: null }>()).toStrictEqual({ user: null }); - }); + expect(response.statusCode).toBe(200) + expect(response.json<{ user: null }>()).toStrictEqual({ user: null }) + }) - it("continues with request.user undefined for invalid token", async () => { - validateAccessTokenFn.mockResolvedValueOnce(undefined); + it('continues with request.user undefined for invalid token', async () => { + validateAccessTokenFn.mockResolvedValueOnce(undefined) const response = await app.inject({ - method: "GET", - url: "/test", + method: 'GET', + url: '/test', headers: { authorization: `Bearer ${VALID_TOKEN}` }, - }); + }) - expect(response.statusCode).toBe(200); - expect(response.json<{ user: null }>()).toStrictEqual({ user: null }); - }); + expect(response.statusCode).toBe(200) + expect(response.json<{ user: null }>()).toStrictEqual({ user: null }) + }) - it("continues with request.user undefined when sessionService throws and logs warning", async () => { - validateAccessTokenFn.mockRejectedValueOnce(new Error("Valkey connection lost")); + it('continues with request.user undefined when sessionService throws and logs warning', async () => { + validateAccessTokenFn.mockRejectedValueOnce(new Error('Valkey connection lost')) const response = await app.inject({ - method: "GET", - url: "/test", + method: 'GET', + url: '/test', headers: { authorization: `Bearer ${VALID_TOKEN}` }, - }); + }) - expect(response.statusCode).toBe(200); - expect(response.json<{ user: null }>()).toStrictEqual({ user: null }); + expect(response.statusCode).toBe(200) + expect(response.json<{ user: null }>()).toStrictEqual({ user: null }) - expect(logWarnFn).toHaveBeenCalledOnce(); - }); -}); + expect(logWarnFn).toHaveBeenCalledOnce() + }) +}) diff --git a/tests/unit/auth/oauth-client.test.ts b/tests/unit/auth/oauth-client.test.ts index 4a35c7a..0094784 100644 --- a/tests/unit/auth/oauth-client.test.ts +++ b/tests/unit/auth/oauth-client.test.ts @@ -1,48 +1,46 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import type { Cache } from "../../../src/cache/index.js"; -import type { Logger } from "../../../src/lib/logger.js"; -import type { Env } from "../../../src/config/env.js"; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { Cache } from '../../../src/cache/index.js' +import type { Logger } from '../../../src/lib/logger.js' +import type { Env } from '../../../src/config/env.js' // Track constructor calls and mock event listener -const constructorArgs: Record[] = []; -const mockAddEventListener = vi.fn(); -const mockJwks = { keys: [] }; +const constructorArgs: Record[] = [] +const mockAddEventListener = vi.fn() +const mockJwks = { keys: [] } -vi.mock("@atproto/oauth-client-node", () => { +vi.mock('@atproto/oauth-client-node', () => { return { NodeOAuthClient: class MockNodeOAuthClient { - clientMetadata: Record; - jwks: { keys: unknown[] }; - addEventListener = mockAddEventListener; + clientMetadata: Record + jwks: { keys: unknown[] } + addEventListener = mockAddEventListener constructor(options: { clientMetadata: Record }) { - constructorArgs.push(options as Record); - this.clientMetadata = options.clientMetadata; - this.jwks = mockJwks; + constructorArgs.push(options as Record) + this.clientMetadata = options.clientMetadata + this.jwks = mockJwks } }, - }; -}); + } +}) // Import after mock setup -const { createOAuthClient } = await import( - "../../../src/auth/oauth-client.js" -); +const { createOAuthClient } = await import('../../../src/auth/oauth-client.js') function createMockCache() { - const setFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue("OK"); - const getFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(null); - const delFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(1); + const setFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue('OK') + const getFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(null) + const delFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(1) return { cache: { set: setFn, get: getFn, del: delFn } as unknown as Cache, setFn, getFn, delFn, - }; + } } function createMockLogger() { - const infoFn = vi.fn(); + const infoFn = vi.fn() return { logger: { debug: vi.fn(), @@ -54,328 +52,322 @@ function createMockLogger() { child: vi.fn(), } as unknown as Logger, infoFn, - }; + } } function createMockEnv(overrides: Partial = {}): Env { return { - DATABASE_URL: "postgresql://localhost/barazo", - VALKEY_URL: "redis://localhost:6379", - TAP_URL: "https://tap.example.com", - TAP_ADMIN_PASSWORD: "test-password", - HOST: "0.0.0.0", + DATABASE_URL: 'postgresql://localhost/barazo', + VALKEY_URL: 'redis://localhost:6379', + TAP_URL: 'https://tap.example.com', + TAP_ADMIN_PASSWORD: 'test-password', + HOST: '0.0.0.0', PORT: 3000, - LOG_LEVEL: "info", - CORS_ORIGINS: "http://localhost:3001", - COMMUNITY_MODE: "single", - COMMUNITY_NAME: "Barazo Community", + LOG_LEVEL: 'info', + CORS_ORIGINS: 'http://localhost:3001', + COMMUNITY_MODE: 'single', + COMMUNITY_NAME: 'Barazo Community', RATE_LIMIT_AUTH: 10, RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, - OAUTH_CLIENT_ID: "http://localhost", - OAUTH_REDIRECT_URI: "http://127.0.0.1:3000/api/auth/callback", - SESSION_SECRET: "a".repeat(32), + OAUTH_CLIENT_ID: 'http://localhost', + OAUTH_REDIRECT_URI: 'http://127.0.0.1:3000/api/auth/callback', + SESSION_SECRET: 'a'.repeat(32), OAUTH_SESSION_TTL: 604800, OAUTH_ACCESS_TOKEN_TTL: 900, ...overrides, - } as Env; + } as Env } /** Get the most recent constructor options */ function getLastConstructorOptions(): Record { - expect(constructorArgs.length).toBeGreaterThan(0); - return constructorArgs[constructorArgs.length - 1] as Record; + expect(constructorArgs.length).toBeGreaterThan(0) + return constructorArgs[constructorArgs.length - 1] as Record } -describe("createOAuthClient", () => { - let cacheMocks: ReturnType; - let logMocks: ReturnType; +describe('createOAuthClient', () => { + let cacheMocks: ReturnType + let logMocks: ReturnType beforeEach(() => { - constructorArgs.length = 0; - vi.clearAllMocks(); - cacheMocks = createMockCache(); - logMocks = createMockLogger(); - }); - - describe("loopback mode detection", () => { - it("detects loopback mode when OAUTH_CLIENT_ID starts with http://localhost", () => { + constructorArgs.length = 0 + vi.clearAllMocks() + cacheMocks = createMockCache() + logMocks = createMockLogger() + }) + + describe('loopback mode detection', () => { + it('detects loopback mode when OAUTH_CLIENT_ID starts with http://localhost', () => { const env = createMockEnv({ - OAUTH_CLIENT_ID: "http://localhost", - OAUTH_REDIRECT_URI: "http://127.0.0.1:3000/api/auth/callback", - }); + OAUTH_CLIENT_ID: 'http://localhost', + OAUTH_REDIRECT_URI: 'http://127.0.0.1:3000/api/auth/callback', + }) - createOAuthClient(env, cacheMocks.cache, logMocks.logger); + createOAuthClient(env, cacheMocks.cache, logMocks.logger) - const options = getLastConstructorOptions(); - const metadata = options.clientMetadata as { client_id: string }; - const clientId = metadata.client_id; + const options = getLastConstructorOptions() + const metadata = options.clientMetadata as { client_id: string } + const clientId = metadata.client_id // Loopback client_id encodes redirect_uri and scope as query params - expect(clientId).toContain("http://localhost?"); - expect(clientId).toContain("redirect_uri="); - expect(clientId).toContain("scope="); - expect(clientId).toContain(encodeURIComponent("http://127.0.0.1:3000/api/auth/callback")); - expect(clientId).toContain(encodeURIComponent("atproto repo:forum.barazo.topic.post repo:forum.barazo.topic.reply repo:forum.barazo.interaction.reaction")); - }); - - it("uses production client_id when not starting with http://localhost", () => { + expect(clientId).toContain('http://localhost?') + expect(clientId).toContain('redirect_uri=') + expect(clientId).toContain('scope=') + expect(clientId).toContain(encodeURIComponent('http://127.0.0.1:3000/api/auth/callback')) + expect(clientId).toContain( + encodeURIComponent( + 'atproto repo:forum.barazo.topic.post repo:forum.barazo.topic.reply repo:forum.barazo.interaction.reaction' + ) + ) + }) + + it('uses production client_id when not starting with http://localhost', () => { const env = createMockEnv({ - OAUTH_CLIENT_ID: "https://forum.barazo.forum/oauth-client-metadata.json", - OAUTH_REDIRECT_URI: "https://forum.barazo.forum/api/auth/callback", - }); + OAUTH_CLIENT_ID: 'https://forum.barazo.forum/oauth-client-metadata.json', + OAUTH_REDIRECT_URI: 'https://forum.barazo.forum/api/auth/callback', + }) - createOAuthClient(env, cacheMocks.cache, logMocks.logger); + createOAuthClient(env, cacheMocks.cache, logMocks.logger) - const options = getLastConstructorOptions(); - const metadata = options.clientMetadata as { client_id: string }; - expect(metadata.client_id).toBe( - "https://forum.barazo.forum/oauth-client-metadata.json", - ); - }); - }); + const options = getLastConstructorOptions() + const metadata = options.clientMetadata as { client_id: string } + expect(metadata.client_id).toBe('https://forum.barazo.forum/oauth-client-metadata.json') + }) + }) - describe("client metadata", () => { - it("sets required OAuth metadata fields", () => { - const env = createMockEnv(); + describe('client metadata', () => { + it('sets required OAuth metadata fields', () => { + const env = createMockEnv() - createOAuthClient(env, cacheMocks.cache, logMocks.logger); + createOAuthClient(env, cacheMocks.cache, logMocks.logger) - const options = getLastConstructorOptions(); + const options = getLastConstructorOptions() const metadata = options.clientMetadata as { - client_name: string; - scope: string; - grant_types: string[]; - response_types: string[]; - application_type: string; - token_endpoint_auth_method: string; - dpop_bound_access_tokens: boolean; - }; - - expect(metadata.client_name).toBe("Barazo Forum"); - expect(metadata.scope).toBe("atproto repo:forum.barazo.topic.post repo:forum.barazo.topic.reply repo:forum.barazo.interaction.reaction"); - expect(metadata.grant_types).toEqual(["authorization_code", "refresh_token"]); - expect(metadata.response_types).toEqual(["code"]); - expect(metadata.application_type).toBe("web"); - expect(metadata.token_endpoint_auth_method).toBe("none"); - expect(metadata.dpop_bound_access_tokens).toBe(true); - }); - - it("includes redirect_uris from env", () => { + client_name: string + scope: string + grant_types: string[] + response_types: string[] + application_type: string + token_endpoint_auth_method: string + dpop_bound_access_tokens: boolean + } + + expect(metadata.client_name).toBe('Barazo Forum') + expect(metadata.scope).toBe( + 'atproto repo:forum.barazo.topic.post repo:forum.barazo.topic.reply repo:forum.barazo.interaction.reaction' + ) + expect(metadata.grant_types).toEqual(['authorization_code', 'refresh_token']) + expect(metadata.response_types).toEqual(['code']) + expect(metadata.application_type).toBe('web') + expect(metadata.token_endpoint_auth_method).toBe('none') + expect(metadata.dpop_bound_access_tokens).toBe(true) + }) + + it('includes redirect_uris from env', () => { const env = createMockEnv({ - OAUTH_REDIRECT_URI: "http://127.0.0.1:3000/api/auth/callback", - }); + OAUTH_REDIRECT_URI: 'http://127.0.0.1:3000/api/auth/callback', + }) - createOAuthClient(env, cacheMocks.cache, logMocks.logger); + createOAuthClient(env, cacheMocks.cache, logMocks.logger) - const options = getLastConstructorOptions(); - const metadata = options.clientMetadata as { redirect_uris: string[] }; - expect(metadata.redirect_uris).toEqual([ - "http://127.0.0.1:3000/api/auth/callback", - ]); - }); + const options = getLastConstructorOptions() + const metadata = options.clientMetadata as { redirect_uris: string[] } + expect(metadata.redirect_uris).toEqual(['http://127.0.0.1:3000/api/auth/callback']) + }) - it("derives client_uri from OAUTH_CLIENT_ID in production mode", () => { + it('derives client_uri from OAUTH_CLIENT_ID in production mode', () => { const env = createMockEnv({ - OAUTH_CLIENT_ID: "https://forum.barazo.forum/oauth-client-metadata.json", - }); + OAUTH_CLIENT_ID: 'https://forum.barazo.forum/oauth-client-metadata.json', + }) - createOAuthClient(env, cacheMocks.cache, logMocks.logger); + createOAuthClient(env, cacheMocks.cache, logMocks.logger) - const options = getLastConstructorOptions(); - const metadata = options.clientMetadata as { client_uri: string }; - expect(metadata.client_uri).toBe("https://forum.barazo.forum"); - }); + const options = getLastConstructorOptions() + const metadata = options.clientMetadata as { client_uri: string } + expect(metadata.client_uri).toBe('https://forum.barazo.forum') + }) - it("uses http://localhost as client_uri in loopback mode", () => { + it('uses http://localhost as client_uri in loopback mode', () => { const env = createMockEnv({ - OAUTH_CLIENT_ID: "http://localhost", - }); - - createOAuthClient(env, cacheMocks.cache, logMocks.logger); - - const options = getLastConstructorOptions(); - const metadata = options.clientMetadata as { client_uri: string }; - expect(metadata.client_uri).toBe("http://localhost"); - }); - }); - - describe("stores and lock", () => { - it("provides stateStore, sessionStore, and requestLock", () => { - const env = createMockEnv(); - - createOAuthClient(env, cacheMocks.cache, logMocks.logger); - - const options = getLastConstructorOptions(); - expect(options.stateStore).toBeDefined(); - expect(options.sessionStore).toBeDefined(); - expect(options.requestLock).toBeDefined(); - expect(typeof options.requestLock).toBe("function"); - }); - }); - - describe("event listeners", () => { - it("registers updated and deleted event listeners", () => { - const env = createMockEnv(); - - createOAuthClient(env, cacheMocks.cache, logMocks.logger); - - expect(mockAddEventListener).toHaveBeenCalledTimes(2); - expect(mockAddEventListener).toHaveBeenCalledWith( - "updated", - expect.any(Function), - ); - expect(mockAddEventListener).toHaveBeenCalledWith( - "deleted", - expect.any(Function), - ); - }); - }); - - describe("logging", () => { - it("logs creation info in loopback mode", () => { + OAUTH_CLIENT_ID: 'http://localhost', + }) + + createOAuthClient(env, cacheMocks.cache, logMocks.logger) + + const options = getLastConstructorOptions() + const metadata = options.clientMetadata as { client_uri: string } + expect(metadata.client_uri).toBe('http://localhost') + }) + }) + + describe('stores and lock', () => { + it('provides stateStore, sessionStore, and requestLock', () => { + const env = createMockEnv() + + createOAuthClient(env, cacheMocks.cache, logMocks.logger) + + const options = getLastConstructorOptions() + expect(options.stateStore).toBeDefined() + expect(options.sessionStore).toBeDefined() + expect(options.requestLock).toBeDefined() + expect(typeof options.requestLock).toBe('function') + }) + }) + + describe('event listeners', () => { + it('registers updated and deleted event listeners', () => { + const env = createMockEnv() + + createOAuthClient(env, cacheMocks.cache, logMocks.logger) + + expect(mockAddEventListener).toHaveBeenCalledTimes(2) + expect(mockAddEventListener).toHaveBeenCalledWith('updated', expect.any(Function)) + expect(mockAddEventListener).toHaveBeenCalledWith('deleted', expect.any(Function)) + }) + }) + + describe('logging', () => { + it('logs creation info in loopback mode', () => { const env = createMockEnv({ - OAUTH_CLIENT_ID: "http://localhost", - }); + OAUTH_CLIENT_ID: 'http://localhost', + }) - createOAuthClient(env, cacheMocks.cache, logMocks.logger); + createOAuthClient(env, cacheMocks.cache, logMocks.logger) expect(logMocks.infoFn).toHaveBeenCalledWith( - { loopback: true, clientId: "(loopback)" }, - "Creating OAuth client", - ); - }); + { loopback: true, clientId: '(loopback)' }, + 'Creating OAuth client' + ) + }) - it("logs creation info in production mode", () => { + it('logs creation info in production mode', () => { const env = createMockEnv({ - OAUTH_CLIENT_ID: "https://forum.barazo.forum/oauth-client-metadata.json", - }); + OAUTH_CLIENT_ID: 'https://forum.barazo.forum/oauth-client-metadata.json', + }) - createOAuthClient(env, cacheMocks.cache, logMocks.logger); + createOAuthClient(env, cacheMocks.cache, logMocks.logger) expect(logMocks.infoFn).toHaveBeenCalledWith( { loopback: false, - clientId: "https://forum.barazo.forum/oauth-client-metadata.json", + clientId: 'https://forum.barazo.forum/oauth-client-metadata.json', }, - "Creating OAuth client", - ); - }); - }); -}); + 'Creating OAuth client' + ) + }) + }) +}) -describe("requestLock (via createOAuthClient internals)", () => { - let cacheMocks: ReturnType; - let logMocks: ReturnType; +describe('requestLock (via createOAuthClient internals)', () => { + let cacheMocks: ReturnType + let logMocks: ReturnType beforeEach(() => { - vi.useFakeTimers(); - constructorArgs.length = 0; - vi.clearAllMocks(); - cacheMocks = createMockCache(); - logMocks = createMockLogger(); - }); + vi.useFakeTimers() + constructorArgs.length = 0 + vi.clearAllMocks() + cacheMocks = createMockCache() + logMocks = createMockLogger() + }) afterEach(() => { - vi.useRealTimers(); - }); + vi.useRealTimers() + }) - it("acquires lock, executes function, and releases lock", async () => { - const env = createMockEnv(); + it('acquires lock, executes function, and releases lock', async () => { + const env = createMockEnv() - createOAuthClient(env, cacheMocks.cache, logMocks.logger); + createOAuthClient(env, cacheMocks.cache, logMocks.logger) - const options = getLastConstructorOptions(); + const options = getLastConstructorOptions() const requestLock = options.requestLock as ( name: string, - fn: () => T | PromiseLike, - ) => Promise; + fn: () => T | PromiseLike + ) => Promise // Mock successful lock acquisition - cacheMocks.setFn.mockResolvedValueOnce("OK"); + cacheMocks.setFn.mockResolvedValueOnce('OK') - const result = await requestLock("test-lock", () => "test-result"); + const result = await requestLock('test-lock', () => 'test-result') - expect(result).toBe("test-result"); + expect(result).toBe('test-result') expect(cacheMocks.setFn).toHaveBeenCalledWith( - "barazo:oauth:lock:test-lock", - "1", - "EX", + 'barazo:oauth:lock:test-lock', + '1', + 'EX', 10, - "NX", - ); + 'NX' + ) // Lock released after function execution - expect(cacheMocks.delFn).toHaveBeenCalledWith("barazo:oauth:lock:test-lock"); - }); + expect(cacheMocks.delFn).toHaveBeenCalledWith('barazo:oauth:lock:test-lock') + }) - it("releases lock even when function throws", async () => { - const env = createMockEnv(); + it('releases lock even when function throws', async () => { + const env = createMockEnv() - createOAuthClient(env, cacheMocks.cache, logMocks.logger); + createOAuthClient(env, cacheMocks.cache, logMocks.logger) - const options = getLastConstructorOptions(); + const options = getLastConstructorOptions() const requestLock = options.requestLock as ( name: string, - fn: () => T | PromiseLike, - ) => Promise; + fn: () => T | PromiseLike + ) => Promise - cacheMocks.setFn.mockResolvedValueOnce("OK"); + cacheMocks.setFn.mockResolvedValueOnce('OK') await expect( - requestLock("test-lock", () => { - throw new Error("function error"); - }), - ).rejects.toThrow("function error"); + requestLock('test-lock', () => { + throw new Error('function error') + }) + ).rejects.toThrow('function error') // Lock was still released - expect(cacheMocks.delFn).toHaveBeenCalledWith("barazo:oauth:lock:test-lock"); - }); + expect(cacheMocks.delFn).toHaveBeenCalledWith('barazo:oauth:lock:test-lock') + }) - it("retries once when lock is not acquired", async () => { - const env = createMockEnv(); + it('retries once when lock is not acquired', async () => { + const env = createMockEnv() - createOAuthClient(env, cacheMocks.cache, logMocks.logger); + createOAuthClient(env, cacheMocks.cache, logMocks.logger) - const options = getLastConstructorOptions(); + const options = getLastConstructorOptions() const requestLock = options.requestLock as ( name: string, - fn: () => T | PromiseLike, - ) => Promise; + fn: () => T | PromiseLike + ) => Promise // First attempt fails (null = not acquired), second succeeds - cacheMocks.setFn - .mockResolvedValueOnce(null as unknown as "OK") - .mockResolvedValueOnce("OK"); + cacheMocks.setFn.mockResolvedValueOnce(null as unknown as 'OK').mockResolvedValueOnce('OK') - const promise = requestLock("test-lock", () => 42); - await vi.advanceTimersByTimeAsync(1000); - const result = await promise; + const promise = requestLock('test-lock', () => 42) + await vi.advanceTimersByTimeAsync(1000) + const result = await promise - expect(result).toBe(42); - expect(cacheMocks.setFn).toHaveBeenCalledTimes(2); - }); + expect(result).toBe(42) + expect(cacheMocks.setFn).toHaveBeenCalledTimes(2) + }) - it("throws when lock cannot be acquired after retry", async () => { - const env = createMockEnv(); + it('throws when lock cannot be acquired after retry', async () => { + const env = createMockEnv() - createOAuthClient(env, cacheMocks.cache, logMocks.logger); + createOAuthClient(env, cacheMocks.cache, logMocks.logger) - const options = getLastConstructorOptions(); + const options = getLastConstructorOptions() const requestLock = options.requestLock as ( name: string, - fn: () => T | PromiseLike, - ) => Promise; + fn: () => T | PromiseLike + ) => Promise // Both attempts fail cacheMocks.setFn - .mockResolvedValueOnce(null as unknown as "OK") - .mockResolvedValueOnce(null as unknown as "OK"); + .mockResolvedValueOnce(null as unknown as 'OK') + .mockResolvedValueOnce(null as unknown as 'OK') - const promise = requestLock("test-lock", () => "should not run"); + const promise = requestLock('test-lock', () => 'should not run') // Attach rejection handler before advancing timers to avoid unhandled rejection - const expectation = expect(promise).rejects.toThrow("Could not acquire OAuth lock: test-lock"); - await vi.advanceTimersByTimeAsync(1000); - await expectation; - }); -}); + const expectation = expect(promise).rejects.toThrow('Could not acquire OAuth lock: test-lock') + await vi.advanceTimersByTimeAsync(1000) + await expectation + }) +}) diff --git a/tests/unit/auth/oauth-metadata.test.ts b/tests/unit/auth/oauth-metadata.test.ts index 101a885..0abee8c 100644 --- a/tests/unit/auth/oauth-metadata.test.ts +++ b/tests/unit/auth/oauth-metadata.test.ts @@ -1,146 +1,146 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { NodeOAuthClient } from "@atproto/oauth-client-node"; -import { oauthMetadataRoutes } from "../../../src/routes/oauth-metadata.js"; +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { NodeOAuthClient } from '@atproto/oauth-client-node' +import { oauthMetadataRoutes } from '../../../src/routes/oauth-metadata.js' const mockClientMetadata = { - client_id: "https://forum.barazo.forum/oauth-client-metadata.json", - client_name: "Barazo Forum", - client_uri: "https://forum.barazo.forum", - redirect_uris: ["https://forum.barazo.forum/api/auth/callback"], - scope: "atproto transition:generic", - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - application_type: "web", - token_endpoint_auth_method: "none", + client_id: 'https://forum.barazo.forum/oauth-client-metadata.json', + client_name: 'Barazo Forum', + client_uri: 'https://forum.barazo.forum', + redirect_uris: ['https://forum.barazo.forum/api/auth/callback'], + scope: 'atproto transition:generic', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + application_type: 'web', + token_endpoint_auth_method: 'none', dpop_bound_access_tokens: true, -}; +} const mockJwks = { keys: [ { - kty: "EC", - crv: "P-256", - x: "test-x-coordinate", - y: "test-y-coordinate", - kid: "test-key-id", + kty: 'EC', + crv: 'P-256', + x: 'test-x-coordinate', + y: 'test-y-coordinate', + kid: 'test-key-id', }, ], -}; +} function createMockOAuthClient(): NodeOAuthClient { return { clientMetadata: mockClientMetadata, jwks: mockJwks, - } as unknown as NodeOAuthClient; + } as unknown as NodeOAuthClient } -describe("OAuth metadata routes", () => { - let app: FastifyInstance; - let mockClient: NodeOAuthClient; +describe('OAuth metadata routes', () => { + let app: FastifyInstance + let mockClient: NodeOAuthClient beforeAll(async () => { - mockClient = createMockOAuthClient(); - app = Fastify({ logger: false }); - await app.register(oauthMetadataRoutes(mockClient)); - await app.ready(); - }); + mockClient = createMockOAuthClient() + app = Fastify({ logger: false }) + await app.register(oauthMetadataRoutes(mockClient)) + await app.ready() + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - describe("GET /oauth-client-metadata.json", () => { - it("returns client metadata as JSON", async () => { + describe('GET /oauth-client-metadata.json', () => { + it('returns client metadata as JSON', async () => { const response = await app.inject({ - method: "GET", - url: "/oauth-client-metadata.json", - }); + method: 'GET', + url: '/oauth-client-metadata.json', + }) - expect(response.statusCode).toBe(200); - expect(JSON.parse(response.body)).toEqual(mockClientMetadata); - }); + expect(response.statusCode).toBe(200) + expect(JSON.parse(response.body)).toEqual(mockClientMetadata) + }) - it("sets Content-Type to application/json", async () => { + it('sets Content-Type to application/json', async () => { const response = await app.inject({ - method: "GET", - url: "/oauth-client-metadata.json", - }); + method: 'GET', + url: '/oauth-client-metadata.json', + }) - expect(response.headers["content-type"]).toContain("application/json"); - }); + expect(response.headers['content-type']).toContain('application/json') + }) - it("sets Cache-Control headers for caching", async () => { + it('sets Cache-Control headers for caching', async () => { const response = await app.inject({ - method: "GET", - url: "/oauth-client-metadata.json", - }); - - expect(response.headers["cache-control"]).toBe( - "public, max-age=3600, stale-while-revalidate=86400", - ); - }); - }); - - describe("GET /jwks.json", () => { - it("returns JWKS as JSON", async () => { + method: 'GET', + url: '/oauth-client-metadata.json', + }) + + expect(response.headers['cache-control']).toBe( + 'public, max-age=3600, stale-while-revalidate=86400' + ) + }) + }) + + describe('GET /jwks.json', () => { + it('returns JWKS as JSON', async () => { const response = await app.inject({ - method: "GET", - url: "/jwks.json", - }); + method: 'GET', + url: '/jwks.json', + }) - expect(response.statusCode).toBe(200); - expect(JSON.parse(response.body)).toEqual(mockJwks); - }); + expect(response.statusCode).toBe(200) + expect(JSON.parse(response.body)).toEqual(mockJwks) + }) - it("sets Content-Type to application/json", async () => { + it('sets Content-Type to application/json', async () => { const response = await app.inject({ - method: "GET", - url: "/jwks.json", - }); + method: 'GET', + url: '/jwks.json', + }) - expect(response.headers["content-type"]).toContain("application/json"); - }); + expect(response.headers['content-type']).toContain('application/json') + }) - it("sets Cache-Control headers for caching", async () => { + it('sets Cache-Control headers for caching', async () => { const response = await app.inject({ - method: "GET", - url: "/jwks.json", - }); + method: 'GET', + url: '/jwks.json', + }) - expect(response.headers["cache-control"]).toBe( - "public, max-age=3600, stale-while-revalidate=86400", - ); - }); - }); + expect(response.headers['cache-control']).toBe( + 'public, max-age=3600, stale-while-revalidate=86400' + ) + }) + }) - describe("GET /jwks.json with empty keys", () => { - let emptyApp: FastifyInstance; + describe('GET /jwks.json with empty keys', () => { + let emptyApp: FastifyInstance beforeAll(async () => { const emptyClient = { clientMetadata: mockClientMetadata, jwks: { keys: [] }, - } as unknown as NodeOAuthClient; + } as unknown as NodeOAuthClient - emptyApp = Fastify({ logger: false }); - await emptyApp.register(oauthMetadataRoutes(emptyClient)); - await emptyApp.ready(); - }); + emptyApp = Fastify({ logger: false }) + await emptyApp.register(oauthMetadataRoutes(emptyClient)) + await emptyApp.ready() + }) afterAll(async () => { - await emptyApp.close(); - }); + await emptyApp.close() + }) - it("returns empty keys array when no keys configured", async () => { + it('returns empty keys array when no keys configured', async () => { const response = await emptyApp.inject({ - method: "GET", - url: "/jwks.json", - }); - - expect(response.statusCode).toBe(200); - expect(JSON.parse(response.body)).toEqual({ keys: [] }); - }); - }); -}); + method: 'GET', + url: '/jwks.json', + }) + + expect(response.statusCode).toBe(200) + expect(JSON.parse(response.body)).toEqual({ keys: [] }) + }) + }) +}) diff --git a/tests/unit/auth/oauth-stores.test.ts b/tests/unit/auth/oauth-stores.test.ts index 53c05c7..ba79129 100644 --- a/tests/unit/auth/oauth-stores.test.ts +++ b/tests/unit/auth/oauth-stores.test.ts @@ -1,26 +1,26 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { ValkeyStateStore, ValkeySessionStore } from "../../../src/auth/oauth-stores.js"; -import type { Cache } from "../../../src/cache/index.js"; -import type { Logger } from "../../../src/lib/logger.js"; -import type { NodeSavedState, NodeSavedSession } from "@atproto/oauth-client-node"; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ValkeyStateStore, ValkeySessionStore } from '../../../src/auth/oauth-stores.js' +import type { Cache } from '../../../src/cache/index.js' +import type { Logger } from '../../../src/lib/logger.js' +import type { NodeSavedState, NodeSavedSession } from '@atproto/oauth-client-node' function createMockCache() { - const setFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue("OK"); - const getFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(null); - const delFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(1); + const setFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue('OK') + const getFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(null) + const delFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(1) return { cache: { set: setFn, get: getFn, del: delFn } as unknown as Cache, setFn, getFn, delFn, - }; + } } function createMockLogger() { - const debugFn = vi.fn(); - const infoFn = vi.fn(); - const warnFn = vi.fn(); - const errorFn = vi.fn(); + const debugFn = vi.fn() + const infoFn = vi.fn() + const warnFn = vi.fn() + const errorFn = vi.fn() return { logger: { debug: debugFn, @@ -35,269 +35,255 @@ function createMockLogger() { infoFn, warnFn, errorFn, - }; + } } // Minimal mock data that satisfies the type shape const mockState: NodeSavedState = { - dpopJwk: { kty: "EC", crv: "P-256", x: "test-x", y: "test-y" }, - iss: "https://pds.example.com", - verifier: "test-verifier", - appState: "test-app-state", -} as unknown as NodeSavedState; + dpopJwk: { kty: 'EC', crv: 'P-256', x: 'test-x', y: 'test-y' }, + iss: 'https://pds.example.com', + verifier: 'test-verifier', + appState: 'test-app-state', +} as unknown as NodeSavedState const mockSession: NodeSavedSession = { - dpopJwk: { kty: "EC", crv: "P-256", x: "test-x", y: "test-y" }, + dpopJwk: { kty: 'EC', crv: 'P-256', x: 'test-x', y: 'test-y' }, tokenSet: { - access_token: "test-access-token", - refresh_token: "test-refresh-token", - token_type: "DPoP", + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + token_type: 'DPoP', expires_at: Date.now() + 900000, - scope: "atproto transition:generic", - sub: "did:plc:test-user-123", - aud: "https://pds.example.com", - iss: "https://pds.example.com", + scope: 'atproto transition:generic', + sub: 'did:plc:test-user-123', + aud: 'https://pds.example.com', + iss: 'https://pds.example.com', }, -} as unknown as NodeSavedSession; +} as unknown as NodeSavedSession -describe("ValkeyStateStore", () => { - let setFn: ReturnType["setFn"]; - let getFn: ReturnType["getFn"]; - let delFn: ReturnType["delFn"]; - let debugFn: ReturnType["debugFn"]; - let errorFn: ReturnType["errorFn"]; - let store: ValkeyStateStore; +describe('ValkeyStateStore', () => { + let setFn: ReturnType['setFn'] + let getFn: ReturnType['getFn'] + let delFn: ReturnType['delFn'] + let debugFn: ReturnType['debugFn'] + let errorFn: ReturnType['errorFn'] + let store: ValkeyStateStore beforeEach(() => { - const mocks = createMockCache(); - const logMocks = createMockLogger(); - setFn = mocks.setFn; - getFn = mocks.getFn; - delFn = mocks.delFn; - debugFn = logMocks.debugFn; - errorFn = logMocks.errorFn; - store = new ValkeyStateStore(mocks.cache, logMocks.logger); - }); - - describe("set", () => { - it("stores state with correct key prefix and 5-minute TTL", async () => { - await store.set("abc123", mockState); + const mocks = createMockCache() + const logMocks = createMockLogger() + setFn = mocks.setFn + getFn = mocks.getFn + delFn = mocks.delFn + debugFn = logMocks.debugFn + errorFn = logMocks.errorFn + store = new ValkeyStateStore(mocks.cache, logMocks.logger) + }) + + describe('set', () => { + it('stores state with correct key prefix and 5-minute TTL', async () => { + await store.set('abc123', mockState) expect(setFn).toHaveBeenCalledWith( - "barazo:oauth:state:abc123", + 'barazo:oauth:state:abc123', JSON.stringify(mockState), - "EX", - 300, - ); - }); + 'EX', + 300 + ) + }) - it("logs debug on success", async () => { - await store.set("abc123", mockState); + it('logs debug on success', async () => { + await store.set('abc123', mockState) expect(debugFn).toHaveBeenCalledWith( - { key: "barazo:oauth:state:abc123" }, - "OAuth state stored", - ); - }); - - it("logs error and rethrows on cache failure", async () => { - const error = new Error("Valkey connection refused"); - setFn.mockRejectedValueOnce(error); - - await expect(store.set("abc123", mockState)).rejects.toThrow( - "Valkey connection refused", - ); + { key: 'barazo:oauth:state:abc123' }, + 'OAuth state stored' + ) + }) + + it('logs error and rethrows on cache failure', async () => { + const error = new Error('Valkey connection refused') + setFn.mockRejectedValueOnce(error) + + await expect(store.set('abc123', mockState)).rejects.toThrow('Valkey connection refused') expect(errorFn).toHaveBeenCalledWith( - { err: error, key: "barazo:oauth:state:abc123" }, - "Failed to store OAuth state", - ); - }); - }); + { err: error, key: 'barazo:oauth:state:abc123' }, + 'Failed to store OAuth state' + ) + }) + }) - describe("get", () => { - it("returns undefined when key not found", async () => { - getFn.mockResolvedValueOnce(null); + describe('get', () => { + it('returns undefined when key not found', async () => { + getFn.mockResolvedValueOnce(null) - const result = await store.get("nonexistent"); + const result = await store.get('nonexistent') - expect(result).toBeUndefined(); - expect(getFn).toHaveBeenCalledWith("barazo:oauth:state:nonexistent"); - }); + expect(result).toBeUndefined() + expect(getFn).toHaveBeenCalledWith('barazo:oauth:state:nonexistent') + }) - it("returns deserialized state when found", async () => { - getFn.mockResolvedValueOnce(JSON.stringify(mockState)); + it('returns deserialized state when found', async () => { + getFn.mockResolvedValueOnce(JSON.stringify(mockState)) - const result = await store.get("abc123"); + const result = await store.get('abc123') - expect(result).toEqual(mockState); - }); + expect(result).toEqual(mockState) + }) - it("logs error and rethrows on cache failure", async () => { - const error = new Error("Valkey timeout"); - getFn.mockRejectedValueOnce(error); + it('logs error and rethrows on cache failure', async () => { + const error = new Error('Valkey timeout') + getFn.mockRejectedValueOnce(error) - await expect(store.get("abc123")).rejects.toThrow("Valkey timeout"); + await expect(store.get('abc123')).rejects.toThrow('Valkey timeout') expect(errorFn).toHaveBeenCalledWith( - { err: error, key: "barazo:oauth:state:abc123" }, - "Failed to retrieve OAuth state", - ); - }); - }); + { err: error, key: 'barazo:oauth:state:abc123' }, + 'Failed to retrieve OAuth state' + ) + }) + }) - describe("del", () => { - it("deletes with correct key prefix", async () => { - await store.del("abc123"); + describe('del', () => { + it('deletes with correct key prefix', async () => { + await store.del('abc123') - expect(delFn).toHaveBeenCalledWith("barazo:oauth:state:abc123"); - }); + expect(delFn).toHaveBeenCalledWith('barazo:oauth:state:abc123') + }) - it("logs debug on success", async () => { - await store.del("abc123"); + it('logs debug on success', async () => { + await store.del('abc123') expect(debugFn).toHaveBeenCalledWith( - { key: "barazo:oauth:state:abc123" }, - "OAuth state deleted", - ); - }); + { key: 'barazo:oauth:state:abc123' }, + 'OAuth state deleted' + ) + }) - it("logs error and rethrows on cache failure", async () => { - const error = new Error("Valkey error"); - delFn.mockRejectedValueOnce(error); + it('logs error and rethrows on cache failure', async () => { + const error = new Error('Valkey error') + delFn.mockRejectedValueOnce(error) - await expect(store.del("abc123")).rejects.toThrow("Valkey error"); + await expect(store.del('abc123')).rejects.toThrow('Valkey error') expect(errorFn).toHaveBeenCalledWith( - { err: error, key: "barazo:oauth:state:abc123" }, - "Failed to delete OAuth state", - ); - }); - }); -}); - -describe("ValkeySessionStore", () => { - let setFn: ReturnType["setFn"]; - let getFn: ReturnType["getFn"]; - let delFn: ReturnType["delFn"]; - let errorFn: ReturnType["errorFn"]; - let store: ValkeySessionStore; - let cache: Cache; - const defaultTtl = 604800; // 7 days + { err: error, key: 'barazo:oauth:state:abc123' }, + 'Failed to delete OAuth state' + ) + }) + }) +}) + +describe('ValkeySessionStore', () => { + let setFn: ReturnType['setFn'] + let getFn: ReturnType['getFn'] + let delFn: ReturnType['delFn'] + let errorFn: ReturnType['errorFn'] + let store: ValkeySessionStore + let cache: Cache + const defaultTtl = 604800 // 7 days beforeEach(() => { - const mocks = createMockCache(); - const logMocks = createMockLogger(); - cache = mocks.cache; - setFn = mocks.setFn; - getFn = mocks.getFn; - delFn = mocks.delFn; - errorFn = logMocks.errorFn; - store = new ValkeySessionStore(mocks.cache, logMocks.logger, defaultTtl); - }); - - describe("set", () => { - it("stores session with correct key prefix and configured TTL", async () => { - const sub = "did:plc:test-user-123"; - await store.set(sub, mockSession); + const mocks = createMockCache() + const logMocks = createMockLogger() + cache = mocks.cache + setFn = mocks.setFn + getFn = mocks.getFn + delFn = mocks.delFn + errorFn = logMocks.errorFn + store = new ValkeySessionStore(mocks.cache, logMocks.logger, defaultTtl) + }) + + describe('set', () => { + it('stores session with correct key prefix and configured TTL', async () => { + const sub = 'did:plc:test-user-123' + await store.set(sub, mockSession) expect(setFn).toHaveBeenCalledWith( - "barazo:oauth:session:did:plc:test-user-123", + 'barazo:oauth:session:did:plc:test-user-123', JSON.stringify(mockSession), - "EX", - 604800, - ); - }); + 'EX', + 604800 + ) + }) - it("uses custom TTL when provided", async () => { - const logMocks = createMockLogger(); - const customStore = new ValkeySessionStore(cache, logMocks.logger, 3600); - await customStore.set("did:plc:test", mockSession); + it('uses custom TTL when provided', async () => { + const logMocks = createMockLogger() + const customStore = new ValkeySessionStore(cache, logMocks.logger, 3600) + await customStore.set('did:plc:test', mockSession) expect(setFn).toHaveBeenCalledWith( - "barazo:oauth:session:did:plc:test", + 'barazo:oauth:session:did:plc:test', JSON.stringify(mockSession), - "EX", - 3600, - ); - }); - - it("logs error and rethrows on cache failure", async () => { - const error = new Error("Valkey write error"); - setFn.mockRejectedValueOnce(error); - - await expect( - store.set("did:plc:test", mockSession), - ).rejects.toThrow("Valkey write error"); + 'EX', + 3600 + ) + }) + + it('logs error and rethrows on cache failure', async () => { + const error = new Error('Valkey write error') + setFn.mockRejectedValueOnce(error) + + await expect(store.set('did:plc:test', mockSession)).rejects.toThrow('Valkey write error') expect(errorFn).toHaveBeenCalledWith( - { err: error, key: "barazo:oauth:session:did:plc:test" }, - "Failed to store OAuth session", - ); - }); - }); - - describe("get", () => { - it("returns undefined when session not found", async () => { - getFn.mockResolvedValueOnce(null); - - const result = await store.get("did:plc:nonexistent"); - - expect(result).toBeUndefined(); - }); - - it("returns deserialized session when found", async () => { - getFn.mockResolvedValueOnce(JSON.stringify(mockSession)); - - const result = await store.get("did:plc:test-user-123"); - - expect(result).toEqual(mockSession); - expect(getFn).toHaveBeenCalledWith( - "barazo:oauth:session:did:plc:test-user-123", - ); - }); - - it("logs error and rethrows on cache failure", async () => { - const error = new Error("Valkey read error"); - getFn.mockRejectedValueOnce(error); - - await expect(store.get("did:plc:test")).rejects.toThrow( - "Valkey read error", - ); - }); - }); - - describe("del", () => { - it("deletes with correct key prefix", async () => { - await store.del("did:plc:test-user-123"); - - expect(delFn).toHaveBeenCalledWith( - "barazo:oauth:session:did:plc:test-user-123", - ); - }); - - it("logs error and rethrows on cache failure", async () => { - const error = new Error("Valkey delete error"); - delFn.mockRejectedValueOnce(error); - - await expect(store.del("did:plc:test")).rejects.toThrow( - "Valkey delete error", - ); - }); - }); - - describe("JSON serialization", () => { - it("round-trips session data correctly through JSON", async () => { + { err: error, key: 'barazo:oauth:session:did:plc:test' }, + 'Failed to store OAuth session' + ) + }) + }) + + describe('get', () => { + it('returns undefined when session not found', async () => { + getFn.mockResolvedValueOnce(null) + + const result = await store.get('did:plc:nonexistent') + + expect(result).toBeUndefined() + }) + + it('returns deserialized session when found', async () => { + getFn.mockResolvedValueOnce(JSON.stringify(mockSession)) + + const result = await store.get('did:plc:test-user-123') + + expect(result).toEqual(mockSession) + expect(getFn).toHaveBeenCalledWith('barazo:oauth:session:did:plc:test-user-123') + }) + + it('logs error and rethrows on cache failure', async () => { + const error = new Error('Valkey read error') + getFn.mockRejectedValueOnce(error) + + await expect(store.get('did:plc:test')).rejects.toThrow('Valkey read error') + }) + }) + + describe('del', () => { + it('deletes with correct key prefix', async () => { + await store.del('did:plc:test-user-123') + + expect(delFn).toHaveBeenCalledWith('barazo:oauth:session:did:plc:test-user-123') + }) + + it('logs error and rethrows on cache failure', async () => { + const error = new Error('Valkey delete error') + delFn.mockRejectedValueOnce(error) + + await expect(store.del('did:plc:test')).rejects.toThrow('Valkey delete error') + }) + }) + + describe('JSON serialization', () => { + it('round-trips session data correctly through JSON', async () => { // Simulate set then get - let storedData: string | null = null; - setFn.mockImplementation( - (_key: unknown, value: unknown) => { - storedData = value as string; - return Promise.resolve("OK"); - }, - ); - getFn.mockImplementation(() => Promise.resolve(storedData)); - - await store.set("did:plc:roundtrip", mockSession); - const retrieved = await store.get("did:plc:roundtrip"); - - expect(retrieved).toEqual(mockSession); - }); - }); -}); + let storedData: string | null = null + setFn.mockImplementation((_key: unknown, value: unknown) => { + storedData = value as string + return Promise.resolve('OK') + }) + getFn.mockImplementation(() => Promise.resolve(storedData)) + + await store.set('did:plc:roundtrip', mockSession) + const retrieved = await store.get('did:plc:roundtrip') + + expect(retrieved).toEqual(mockSession) + }) + }) +}) diff --git a/tests/unit/auth/require-admin.test.ts b/tests/unit/auth/require-admin.test.ts index 6863aca..942e2ed 100644 --- a/tests/unit/auth/require-admin.test.ts +++ b/tests/unit/auth/require-admin.test.ts @@ -1,32 +1,32 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import { createRequireAdmin } from "../../../src/auth/require-admin.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import { createRequireAdmin } from '../../../src/auth/require-admin.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' // --------------------------------------------------------------------------- // Mock database // --------------------------------------------------------------------------- interface MockUserRow { - did: string; - handle: string; - role: string; + did: string + handle: string + role: string } -const mockDbSelect = vi.fn(); -const mockDbFrom = vi.fn(); -const mockDbWhere = vi.fn(); +const mockDbSelect = vi.fn() +const mockDbFrom = vi.fn() +const mockDbWhere = vi.fn() function createMockDb() { // Chain: db.select().from(users).where(eq(users.did, did)) - mockDbWhere.mockReturnValue([]); - mockDbFrom.mockReturnValue({ where: mockDbWhere }); - mockDbSelect.mockReturnValue({ from: mockDbFrom }); + mockDbWhere.mockReturnValue([]) + mockDbFrom.mockReturnValue({ where: mockDbWhere }) + mockDbSelect.mockReturnValue({ from: mockDbFrom }) return { select: mockDbSelect, - }; + } } // --------------------------------------------------------------------------- @@ -39,7 +39,7 @@ function createMockAuthMiddleware(): AuthMiddleware { // Simulate setting user - tests will set request.user before calling }), optionalAuth: vi.fn(), - }; + } } // --------------------------------------------------------------------------- @@ -47,169 +47,156 @@ function createMockAuthMiddleware(): AuthMiddleware { // --------------------------------------------------------------------------- const ADMIN_USER: RequestUser = { - did: "did:plc:admin123", - handle: "admin.bsky.social", - sid: "s".repeat(64), -}; + did: 'did:plc:admin123', + handle: 'admin.bsky.social', + sid: 's'.repeat(64), +} const REGULAR_USER: RequestUser = { - did: "did:plc:user456", - handle: "user.bsky.social", - sid: "s".repeat(64), -}; + did: 'did:plc:user456', + handle: 'user.bsky.social', + sid: 's'.repeat(64), +} const ADMIN_DB_ROW: MockUserRow = { did: ADMIN_USER.did, handle: ADMIN_USER.handle, - role: "admin", -}; + role: 'admin', +} const REGULAR_DB_ROW: MockUserRow = { did: REGULAR_USER.did, handle: REGULAR_USER.handle, - role: "user", -}; + role: 'user', +} const MODERATOR_DB_ROW: MockUserRow = { - did: "did:plc:mod789", - handle: "mod.bsky.social", - role: "moderator", -}; + did: 'did:plc:mod789', + handle: 'mod.bsky.social', + role: 'moderator', +} // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describe("requireAdmin middleware", () => { - let app: FastifyInstance; - let mockAuthMiddleware: AuthMiddleware; +describe('requireAdmin middleware', () => { + let app: FastifyInstance + let mockAuthMiddleware: AuthMiddleware beforeEach(async () => { - vi.clearAllMocks(); + vi.clearAllMocks() - const mockDb = createMockDb(); - mockAuthMiddleware = createMockAuthMiddleware(); + const mockDb = createMockDb() + mockAuthMiddleware = createMockAuthMiddleware() - const requireAdmin = createRequireAdmin( - mockDb as never, - mockAuthMiddleware, - ); + const requireAdmin = createRequireAdmin(mockDb as never, mockAuthMiddleware) - app = Fastify({ logger: false }); - app.decorateRequest("user", undefined as RequestUser | undefined); + app = Fastify({ logger: false }) + app.decorateRequest('user', undefined as RequestUser | undefined) - app.get("/admin-test", { preHandler: [requireAdmin] }, (request) => { - return { user: request.user }; - }); + app.get('/admin-test', { preHandler: [requireAdmin] }, (request) => { + return { user: request.user } + }) - await app.ready(); - }); + await app.ready() + }) afterEach(async () => { - await app.close(); - }); + await app.close() + }) - it("returns 401 when requireAuth rejects (no token)", async () => { + it('returns 401 when requireAuth rejects (no token)', async () => { // Make requireAuth return 401 - vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation( - async (_request, reply) => { - await reply.status(401).send({ error: "Authentication required" }); - }, - ); + vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (_request, reply) => { + await reply.status(401).send({ error: 'Authentication required' }) + }) const response = await app.inject({ - method: "GET", - url: "/admin-test", - }); + method: 'GET', + url: '/admin-test', + }) - expect(response.statusCode).toBe(401); + expect(response.statusCode).toBe(401) expect(response.json<{ error: string }>()).toStrictEqual({ - error: "Authentication required", - }); - }); + error: 'Authentication required', + }) + }) - it("returns 403 when user is not found in database", async () => { + it('returns 403 when user is not found in database', async () => { // requireAuth passes and sets user - vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation( - async (request, _reply) => { - request.user = ADMIN_USER; - }, - ); + vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (request, _reply) => { + request.user = ADMIN_USER + }) // User not found in DB - mockDbWhere.mockResolvedValueOnce([]); + mockDbWhere.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/admin-test", - }); + method: 'GET', + url: '/admin-test', + }) - expect(response.statusCode).toBe(403); + expect(response.statusCode).toBe(403) expect(response.json<{ error: string }>()).toStrictEqual({ - error: "Admin access required", - }); - }); + error: 'Admin access required', + }) + }) it("returns 403 when user has role 'user'", async () => { - vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation( - async (request, _reply) => { - request.user = REGULAR_USER; - }, - ); + vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (request, _reply) => { + request.user = REGULAR_USER + }) - mockDbWhere.mockResolvedValueOnce([REGULAR_DB_ROW]); + mockDbWhere.mockResolvedValueOnce([REGULAR_DB_ROW]) const response = await app.inject({ - method: "GET", - url: "/admin-test", - }); + method: 'GET', + url: '/admin-test', + }) - expect(response.statusCode).toBe(403); + expect(response.statusCode).toBe(403) expect(response.json<{ error: string }>()).toStrictEqual({ - error: "Admin access required", - }); - }); + error: 'Admin access required', + }) + }) it("returns 403 when user has role 'moderator'", async () => { - vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation( - async (request, _reply) => { - request.user = { - did: MODERATOR_DB_ROW.did, - handle: MODERATOR_DB_ROW.handle, - sid: "s".repeat(64), - }; - }, - ); - - mockDbWhere.mockResolvedValueOnce([MODERATOR_DB_ROW]); + vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (request, _reply) => { + request.user = { + did: MODERATOR_DB_ROW.did, + handle: MODERATOR_DB_ROW.handle, + sid: 's'.repeat(64), + } + }) + + mockDbWhere.mockResolvedValueOnce([MODERATOR_DB_ROW]) const response = await app.inject({ - method: "GET", - url: "/admin-test", - }); + method: 'GET', + url: '/admin-test', + }) - expect(response.statusCode).toBe(403); + expect(response.statusCode).toBe(403) expect(response.json<{ error: string }>()).toStrictEqual({ - error: "Admin access required", - }); - }); + error: 'Admin access required', + }) + }) - it("passes through for admin user and returns 200", async () => { - vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation( - async (request, _reply) => { - request.user = ADMIN_USER; - }, - ); + it('passes through for admin user and returns 200', async () => { + vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (request, _reply) => { + request.user = ADMIN_USER + }) - mockDbWhere.mockResolvedValueOnce([ADMIN_DB_ROW]); + mockDbWhere.mockResolvedValueOnce([ADMIN_DB_ROW]) const response = await app.inject({ - method: "GET", - url: "/admin-test", - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ user: RequestUser }>(); - expect(body.user).toStrictEqual(ADMIN_USER); - }); -}); + method: 'GET', + url: '/admin-test', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ user: RequestUser }>() + expect(body.user).toStrictEqual(ADMIN_USER) + }) +}) diff --git a/tests/unit/auth/require-moderator.test.ts b/tests/unit/auth/require-moderator.test.ts index 6fe060c..e79e562 100644 --- a/tests/unit/auth/require-moderator.test.ts +++ b/tests/unit/auth/require-moderator.test.ts @@ -1,24 +1,24 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import { createRequireModerator } from "../../../src/auth/require-moderator.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { Logger } from "../../../src/lib/logger.js"; -import { createMockDb, resetDbMocks, createChainableProxy } from "../../helpers/mock-db.js"; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import { createRequireModerator } from '../../../src/auth/require-moderator.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { Logger } from '../../../src/lib/logger.js' +import { createMockDb, resetDbMocks, createChainableProxy } from '../../helpers/mock-db.js' // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const ADMIN_DID = "did:plc:admin456"; -const MOD_DID = "did:plc:mod789"; +const TEST_DID = 'did:plc:testuser123' +const ADMIN_DID = 'did:plc:admin456' +const MOD_DID = 'did:plc:mod789' // --------------------------------------------------------------------------- // Mock setup // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() const mockLogger: Logger = { info: vi.fn(), warn: vi.fn(), @@ -27,157 +27,133 @@ const mockLogger: Logger = { fatal: vi.fn(), trace: vi.fn(), child: vi.fn().mockReturnThis(), - level: "info", + level: 'info', silent: vi.fn(), -} as unknown as Logger; +} as unknown as Logger function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { - const requireAuth: AuthMiddleware["requireAuth"] = async (request, reply) => { + const requireAuth: AuthMiddleware['requireAuth'] = async (request, reply) => { if (user) { - request.user = user; + request.user = user } else { - await reply.status(401).send({ error: "Authentication required" }); + await reply.status(401).send({ error: 'Authentication required' }) } - }; + } return { requireAuth: vi.fn(requireAuth), optionalAuth: vi.fn(), - }; + } } -describe("requireModerator middleware", () => { - let app: FastifyInstance; +describe('requireModerator middleware', () => { + let app: FastifyInstance beforeEach(() => { - resetDbMocks(mockDb); - vi.clearAllMocks(); - }); - - it("returns 401 when not authenticated", async () => { - const authMiddleware = createMockAuthMiddleware(undefined); - const requireModerator = createRequireModerator( - mockDb as never, - authMiddleware, - mockLogger, - ); - - app = Fastify(); - app.decorateRequest("user", undefined as RequestUser | undefined); - app.get("/test", { preHandler: [requireModerator] }, () => ({ ok: true })); - await app.ready(); - - const res = await app.inject({ method: "GET", url: "/test" }); - expect(res.statusCode).toBe(401); - }); + resetDbMocks(mockDb) + vi.clearAllMocks() + }) + + it('returns 401 when not authenticated', async () => { + const authMiddleware = createMockAuthMiddleware(undefined) + const requireModerator = createRequireModerator(mockDb as never, authMiddleware, mockLogger) + + app = Fastify() + app.decorateRequest('user', undefined as RequestUser | undefined) + app.get('/test', { preHandler: [requireModerator] }, () => ({ ok: true })) + await app.ready() + + const res = await app.inject({ method: 'GET', url: '/test' }) + expect(res.statusCode).toBe(401) + }) it("returns 403 when user has role 'user'", async () => { - const user: RequestUser = { did: TEST_DID, handle: "test.bsky.social", sid: "a".repeat(64) }; - const authMiddleware = createMockAuthMiddleware(user); - const requireModerator = createRequireModerator( - mockDb as never, - authMiddleware, - mockLogger, - ); + const user: RequestUser = { did: TEST_DID, handle: 'test.bsky.social', sid: 'a'.repeat(64) } + const authMiddleware = createMockAuthMiddleware(user) + const requireModerator = createRequireModerator(mockDb as never, authMiddleware, mockLogger) // Mock DB to return user with role "user" - const selectChain = createChainableProxy([{ did: TEST_DID, role: "user" }]); - mockDb.select.mockReturnValue(selectChain); - - app = Fastify(); - app.decorateRequest("user", undefined as RequestUser | undefined); - app.get("/test", { preHandler: [requireModerator] }, () => ({ ok: true })); - await app.ready(); - - const res = await app.inject({ method: "GET", url: "/test" }); - expect(res.statusCode).toBe(403); - }); - - it("allows moderator access", async () => { - const user: RequestUser = { did: MOD_DID, handle: "mod.bsky.social", sid: "b".repeat(64) }; - const authMiddleware = createMockAuthMiddleware(user); - const requireModerator = createRequireModerator( - mockDb as never, - authMiddleware, - mockLogger, - ); - - const selectChain = createChainableProxy([{ did: MOD_DID, role: "moderator" }]); - mockDb.select.mockReturnValue(selectChain); - - app = Fastify(); - app.decorateRequest("user", undefined as RequestUser | undefined); - app.get("/test", { preHandler: [requireModerator] }, () => ({ ok: true })); - await app.ready(); - - const res = await app.inject({ method: "GET", url: "/test" }); - expect(res.statusCode).toBe(200); - expect(res.json()).toEqual({ ok: true }); - }); - - it("allows admin access", async () => { - const user: RequestUser = { did: ADMIN_DID, handle: "admin.bsky.social", sid: "c".repeat(64) }; - const authMiddleware = createMockAuthMiddleware(user); - const requireModerator = createRequireModerator( - mockDb as never, - authMiddleware, - mockLogger, - ); - - const selectChain = createChainableProxy([{ did: ADMIN_DID, role: "admin" }]); - mockDb.select.mockReturnValue(selectChain); - - app = Fastify(); - app.decorateRequest("user", undefined as RequestUser | undefined); - app.get("/test", { preHandler: [requireModerator] }, () => ({ ok: true })); - await app.ready(); - - const res = await app.inject({ method: "GET", url: "/test" }); - expect(res.statusCode).toBe(200); - expect(res.json()).toEqual({ ok: true }); - }); - - it("returns 403 when user not found in database", async () => { - const user: RequestUser = { did: TEST_DID, handle: "test.bsky.social", sid: "d".repeat(64) }; - const authMiddleware = createMockAuthMiddleware(user); - const requireModerator = createRequireModerator( - mockDb as never, - authMiddleware, - mockLogger, - ); - - const selectChain = createChainableProxy([]); - mockDb.select.mockReturnValue(selectChain); - - app = Fastify(); - app.decorateRequest("user", undefined as RequestUser | undefined); - app.get("/test", { preHandler: [requireModerator] }, () => ({ ok: true })); - await app.ready(); - - const res = await app.inject({ method: "GET", url: "/test" }); - expect(res.statusCode).toBe(403); - }); - - it("logs moderator access with audit info", async () => { - const user: RequestUser = { did: MOD_DID, handle: "mod.bsky.social", sid: "e".repeat(64) }; - const authMiddleware = createMockAuthMiddleware(user); - const requireModerator = createRequireModerator( - mockDb as never, - authMiddleware, - mockLogger, - ); - - const selectChain = createChainableProxy([{ did: MOD_DID, role: "moderator" }]); - mockDb.select.mockReturnValue(selectChain); - - app = Fastify(); - app.decorateRequest("user", undefined as RequestUser | undefined); - app.get("/test", { preHandler: [requireModerator] }, () => ({ ok: true })); - await app.ready(); - - await app.inject({ method: "GET", url: "/test" }); + const selectChain = createChainableProxy([{ did: TEST_DID, role: 'user' }]) + mockDb.select.mockReturnValue(selectChain) + + app = Fastify() + app.decorateRequest('user', undefined as RequestUser | undefined) + app.get('/test', { preHandler: [requireModerator] }, () => ({ ok: true })) + await app.ready() + + const res = await app.inject({ method: 'GET', url: '/test' }) + expect(res.statusCode).toBe(403) + }) + + it('allows moderator access', async () => { + const user: RequestUser = { did: MOD_DID, handle: 'mod.bsky.social', sid: 'b'.repeat(64) } + const authMiddleware = createMockAuthMiddleware(user) + const requireModerator = createRequireModerator(mockDb as never, authMiddleware, mockLogger) + + const selectChain = createChainableProxy([{ did: MOD_DID, role: 'moderator' }]) + mockDb.select.mockReturnValue(selectChain) + + app = Fastify() + app.decorateRequest('user', undefined as RequestUser | undefined) + app.get('/test', { preHandler: [requireModerator] }, () => ({ ok: true })) + await app.ready() + + const res = await app.inject({ method: 'GET', url: '/test' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true }) + }) + + it('allows admin access', async () => { + const user: RequestUser = { did: ADMIN_DID, handle: 'admin.bsky.social', sid: 'c'.repeat(64) } + const authMiddleware = createMockAuthMiddleware(user) + const requireModerator = createRequireModerator(mockDb as never, authMiddleware, mockLogger) + + const selectChain = createChainableProxy([{ did: ADMIN_DID, role: 'admin' }]) + mockDb.select.mockReturnValue(selectChain) + + app = Fastify() + app.decorateRequest('user', undefined as RequestUser | undefined) + app.get('/test', { preHandler: [requireModerator] }, () => ({ ok: true })) + await app.ready() + + const res = await app.inject({ method: 'GET', url: '/test' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true }) + }) + + it('returns 403 when user not found in database', async () => { + const user: RequestUser = { did: TEST_DID, handle: 'test.bsky.social', sid: 'd'.repeat(64) } + const authMiddleware = createMockAuthMiddleware(user) + const requireModerator = createRequireModerator(mockDb as never, authMiddleware, mockLogger) + + const selectChain = createChainableProxy([]) + mockDb.select.mockReturnValue(selectChain) + + app = Fastify() + app.decorateRequest('user', undefined as RequestUser | undefined) + app.get('/test', { preHandler: [requireModerator] }, () => ({ ok: true })) + await app.ready() + + const res = await app.inject({ method: 'GET', url: '/test' }) + expect(res.statusCode).toBe(403) + }) + + it('logs moderator access with audit info', async () => { + const user: RequestUser = { did: MOD_DID, handle: 'mod.bsky.social', sid: 'e'.repeat(64) } + const authMiddleware = createMockAuthMiddleware(user) + const requireModerator = createRequireModerator(mockDb as never, authMiddleware, mockLogger) + + const selectChain = createChainableProxy([{ did: MOD_DID, role: 'moderator' }]) + mockDb.select.mockReturnValue(selectChain) + + app = Fastify() + app.decorateRequest('user', undefined as RequestUser | undefined) + app.get('/test', { preHandler: [requireModerator] }, () => ({ ok: true })) + await app.ready() + + await app.inject({ method: 'GET', url: '/test' }) expect(mockLogger.info).toHaveBeenCalledWith( expect.objectContaining({ did: MOD_DID }), - expect.stringContaining("access granted"), - ); - }); -}); + expect.stringContaining('access granted') + ) + }) +}) diff --git a/tests/unit/auth/require-operator.test.ts b/tests/unit/auth/require-operator.test.ts index 7ab0e57..d3565ac 100644 --- a/tests/unit/auth/require-operator.test.ts +++ b/tests/unit/auth/require-operator.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import { createRequireOperator } from "../../../src/auth/require-operator.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { Env } from "../../../src/config/env.js"; -import type { Logger } from "../../../src/lib/logger.js"; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import { createRequireOperator } from '../../../src/auth/require-operator.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { Env } from '../../../src/config/env.js' +import type { Logger } from '../../../src/lib/logger.js' // --------------------------------------------------------------------------- // Mock auth middleware @@ -16,15 +16,15 @@ function createMockAuthMiddleware(): AuthMiddleware { // Simulate setting user - tests will set request.user before calling }), optionalAuth: vi.fn(), - }; + } } // --------------------------------------------------------------------------- // Mock logger // --------------------------------------------------------------------------- -const logInfoFn = vi.fn(); -const logWarnFn = vi.fn(); +const logInfoFn = vi.fn() +const logWarnFn = vi.fn() function createMockLogger(): Logger { return { @@ -36,8 +36,8 @@ function createMockLogger(): Logger { trace: vi.fn(), child: vi.fn(), silent: vi.fn(), - level: "silent", - } as unknown as Logger; + level: 'silent', + } as unknown as Logger } // --------------------------------------------------------------------------- @@ -45,12 +45,12 @@ function createMockLogger(): Logger { // --------------------------------------------------------------------------- function createMockEnv( - overrides: Partial> = {}, -): Pick { + overrides: Partial> = {} +): Pick { return { - COMMUNITY_MODE: overrides.COMMUNITY_MODE ?? "global", - OPERATOR_DIDS: overrides.OPERATOR_DIDS ?? ["did:plc:operator123"], - }; + COMMUNITY_MODE: overrides.COMMUNITY_MODE ?? 'global', + OPERATOR_DIDS: overrides.OPERATOR_DIDS ?? ['did:plc:operator123'], + } } // --------------------------------------------------------------------------- @@ -58,57 +58,53 @@ function createMockEnv( // --------------------------------------------------------------------------- const OPERATOR_USER: RequestUser = { - did: "did:plc:operator123", - handle: "operator.bsky.social", - sid: "s".repeat(64), -}; + did: 'did:plc:operator123', + handle: 'operator.bsky.social', + sid: 's'.repeat(64), +} const NON_OPERATOR_USER: RequestUser = { - did: "did:plc:user456", - handle: "user.bsky.social", - sid: "s".repeat(64), -}; + did: 'did:plc:user456', + handle: 'user.bsky.social', + sid: 's'.repeat(64), +} // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describe("requireOperator middleware", () => { - let app: FastifyInstance; - let mockAuthMiddleware: AuthMiddleware; +describe('requireOperator middleware', () => { + let app: FastifyInstance + let mockAuthMiddleware: AuthMiddleware afterEach(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) // Helper to build a Fastify app with the operator middleware async function buildApp( - envOverrides: Partial> = {}, - withLogger = true, + envOverrides: Partial> = {}, + withLogger = true ): Promise { - mockAuthMiddleware = createMockAuthMiddleware(); - const mockEnv = createMockEnv(envOverrides); - const mockLogger = withLogger ? createMockLogger() : undefined; + mockAuthMiddleware = createMockAuthMiddleware() + const mockEnv = createMockEnv(envOverrides) + const mockLogger = withLogger ? createMockLogger() : undefined - const requireOperator = createRequireOperator( - mockEnv as Env, - mockAuthMiddleware, - mockLogger, - ); + const requireOperator = createRequireOperator(mockEnv as Env, mockAuthMiddleware, mockLogger) - app = Fastify({ logger: false }); - app.decorateRequest("user", undefined as RequestUser | undefined); + app = Fastify({ logger: false }) + app.decorateRequest('user', undefined as RequestUser | undefined) - app.get("/operator-test", { preHandler: [requireOperator] }, (request) => { - return { user: request.user }; - }); + app.get('/operator-test', { preHandler: [requireOperator] }, (request) => { + return { user: request.user } + }) - await app.ready(); - return app; + await app.ready() + return app } // ------------------------------------------------------------------------- @@ -116,92 +112,86 @@ describe("requireOperator middleware", () => { // ------------------------------------------------------------------------- it("returns 404 if COMMUNITY_MODE is 'single'", async () => { - await buildApp({ COMMUNITY_MODE: "single" }); + await buildApp({ COMMUNITY_MODE: 'single' }) const response = await app.inject({ - method: "GET", - url: "/operator-test", - }); + method: 'GET', + url: '/operator-test', + }) - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(404) expect(response.json<{ error: string }>()).toStrictEqual({ - error: "Not found", - }); + error: 'Not found', + }) // requireAuth should NOT have been called - expect(mockAuthMiddleware.requireAuth).not.toHaveBeenCalled(); - }); + expect(mockAuthMiddleware.requireAuth).not.toHaveBeenCalled() + }) // ------------------------------------------------------------------------- // Authentication check (delegated to requireAuth) // ------------------------------------------------------------------------- - it("returns 401 when requireAuth rejects (no token)", async () => { - await buildApp({ COMMUNITY_MODE: "global" }); + it('returns 401 when requireAuth rejects (no token)', async () => { + await buildApp({ COMMUNITY_MODE: 'global' }) - vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation( - async (_request, reply) => { - await reply.status(401).send({ error: "Authentication required" }); - }, - ); + vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (_request, reply) => { + await reply.status(401).send({ error: 'Authentication required' }) + }) const response = await app.inject({ - method: "GET", - url: "/operator-test", - }); + method: 'GET', + url: '/operator-test', + }) - expect(response.statusCode).toBe(401); + expect(response.statusCode).toBe(401) expect(response.json<{ error: string }>()).toStrictEqual({ - error: "Authentication required", - }); - }); + error: 'Authentication required', + }) + }) // ------------------------------------------------------------------------- // Operator DID check // ------------------------------------------------------------------------- - it("returns 403 if user DID is not in OPERATOR_DIDS", async () => { + it('returns 403 if user DID is not in OPERATOR_DIDS', async () => { await buildApp({ - COMMUNITY_MODE: "global", - OPERATOR_DIDS: ["did:plc:operator123"], - }); + COMMUNITY_MODE: 'global', + OPERATOR_DIDS: ['did:plc:operator123'], + }) - vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation( - async (request, _reply) => { - request.user = NON_OPERATOR_USER; - }, - ); + vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (request, _reply) => { + request.user = NON_OPERATOR_USER + }) const response = await app.inject({ - method: "GET", - url: "/operator-test", - }); + method: 'GET', + url: '/operator-test', + }) - expect(response.statusCode).toBe(403); + expect(response.statusCode).toBe(403) expect(response.json<{ error: string }>()).toStrictEqual({ - error: "Operator access required", - }); - }); + error: 'Operator access required', + }) + }) - it("returns 403 when requireAuth passes but request.user is not set", async () => { - await buildApp({ COMMUNITY_MODE: "global" }); + it('returns 403 when requireAuth passes but request.user is not set', async () => { + await buildApp({ COMMUNITY_MODE: 'global' }) // requireAuth passes without setting request.user - vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation( - async (_request, _reply) => { - // intentionally do not set request.user - }, - ); + vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (_request, _reply) => { + // intentionally do not set request.user + }) const response = await app.inject({ - method: "GET", - url: "/operator-test", - }); + method: 'GET', + url: '/operator-test', + }) - expect(response.statusCode).toBe(403); + expect(response.statusCode).toBe(403) expect(response.json<{ error: string }>()).toStrictEqual({ - error: "Operator access required", - }); - }); + error: 'Operator access required', + }) + }) // ------------------------------------------------------------------------- // Success path @@ -209,115 +199,105 @@ describe("requireOperator middleware", () => { it("grants access if user DID is in OPERATOR_DIDS and mode is 'global'", async () => { await buildApp({ - COMMUNITY_MODE: "global", - OPERATOR_DIDS: ["did:plc:operator123"], - }); + COMMUNITY_MODE: 'global', + OPERATOR_DIDS: ['did:plc:operator123'], + }) - vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation( - async (request, _reply) => { - request.user = OPERATOR_USER; - }, - ); + vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (request, _reply) => { + request.user = OPERATOR_USER + }) const response = await app.inject({ - method: "GET", - url: "/operator-test", - }); + method: 'GET', + url: '/operator-test', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ user: RequestUser }>(); - expect(body.user).toStrictEqual(OPERATOR_USER); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ user: RequestUser }>() + expect(body.user).toStrictEqual(OPERATOR_USER) + }) - it("grants access when OPERATOR_DIDS contains multiple DIDs", async () => { + it('grants access when OPERATOR_DIDS contains multiple DIDs', async () => { await buildApp({ - COMMUNITY_MODE: "global", - OPERATOR_DIDS: ["did:plc:other999", "did:plc:operator123", "did:plc:another888"], - }); + COMMUNITY_MODE: 'global', + OPERATOR_DIDS: ['did:plc:other999', 'did:plc:operator123', 'did:plc:another888'], + }) - vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation( - async (request, _reply) => { - request.user = OPERATOR_USER; - }, - ); + vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (request, _reply) => { + request.user = OPERATOR_USER + }) const response = await app.inject({ - method: "GET", - url: "/operator-test", - }); + method: 'GET', + url: '/operator-test', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ user: RequestUser }>(); - expect(body.user).toStrictEqual(OPERATOR_USER); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ user: RequestUser }>() + expect(body.user).toStrictEqual(OPERATOR_USER) + }) // ------------------------------------------------------------------------- // Audit logging // ------------------------------------------------------------------------- - it("logs audit trail when operator access is denied (DID not in list)", async () => { + it('logs audit trail when operator access is denied (DID not in list)', async () => { await buildApp({ - COMMUNITY_MODE: "global", - OPERATOR_DIDS: ["did:plc:operator123"], - }); + COMMUNITY_MODE: 'global', + OPERATOR_DIDS: ['did:plc:operator123'], + }) - vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation( - async (request, _reply) => { - request.user = NON_OPERATOR_USER; - }, - ); + vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (request, _reply) => { + request.user = NON_OPERATOR_USER + }) await app.inject({ - method: "GET", - url: "/operator-test", - }); + method: 'GET', + url: '/operator-test', + }) expect(logWarnFn).toHaveBeenCalledWith( - { did: NON_OPERATOR_USER.did, url: "/operator-test", method: "GET" }, - "Operator access denied: DID not in OPERATOR_DIDS", - ); - }); + { did: NON_OPERATOR_USER.did, url: '/operator-test', method: 'GET' }, + 'Operator access denied: DID not in OPERATOR_DIDS' + ) + }) - it("logs audit trail when operator access is denied (no user after auth)", async () => { - await buildApp({ COMMUNITY_MODE: "global" }); + it('logs audit trail when operator access is denied (no user after auth)', async () => { + await buildApp({ COMMUNITY_MODE: 'global' }) - vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation( - async (_request, _reply) => { - // intentionally do not set request.user - }, - ); + vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (_request, _reply) => { + // intentionally do not set request.user + }) await app.inject({ - method: "GET", - url: "/operator-test", - }); + method: 'GET', + url: '/operator-test', + }) expect(logWarnFn).toHaveBeenCalledWith( - { url: "/operator-test", method: "GET" }, - "Operator access denied: no user after auth", - ); - }); + { url: '/operator-test', method: 'GET' }, + 'Operator access denied: no user after auth' + ) + }) - it("logs audit trail when operator access is granted", async () => { + it('logs audit trail when operator access is granted', async () => { await buildApp({ - COMMUNITY_MODE: "global", - OPERATOR_DIDS: ["did:plc:operator123"], - }); + COMMUNITY_MODE: 'global', + OPERATOR_DIDS: ['did:plc:operator123'], + }) - vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation( - async (request, _reply) => { - request.user = OPERATOR_USER; - }, - ); + vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (request, _reply) => { + request.user = OPERATOR_USER + }) await app.inject({ - method: "GET", - url: "/operator-test", - }); + method: 'GET', + url: '/operator-test', + }) expect(logInfoFn).toHaveBeenCalledWith( - { did: OPERATOR_USER.did, url: "/operator-test", method: "GET" }, - "Operator access granted", - ); - }); -}); + { did: OPERATOR_USER.did, url: '/operator-test', method: 'GET' }, + 'Operator access granted' + ) + }) +}) diff --git a/tests/unit/auth/scopes.test.ts b/tests/unit/auth/scopes.test.ts index 9ed6e70..4f83f24 100644 --- a/tests/unit/auth/scopes.test.ts +++ b/tests/unit/auth/scopes.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from 'vitest' import { BARAZO_BASE_SCOPES, CROSSPOST_ADDITIONAL_SCOPES, @@ -6,88 +6,88 @@ import { FALLBACK_SCOPE, hasCrossPostScopes, isFallbackScope, -} from "../../../src/auth/scopes.js"; - -describe("scope constants", () => { - it("BARAZO_BASE_SCOPES includes all forum collections", () => { - expect(BARAZO_BASE_SCOPES).toContain("repo:forum.barazo.topic.post"); - expect(BARAZO_BASE_SCOPES).toContain("repo:forum.barazo.topic.reply"); - expect(BARAZO_BASE_SCOPES).toContain("repo:forum.barazo.interaction.reaction"); - expect(BARAZO_BASE_SCOPES.startsWith("atproto ")).toBe(true); - }); - - it("BARAZO_BASE_SCOPES does not include cross-post collections", () => { - expect(BARAZO_BASE_SCOPES).not.toContain("app.bsky.feed.post"); - expect(BARAZO_BASE_SCOPES).not.toContain("fyi.frontpage.post"); - }); - - it("CROSSPOST_ADDITIONAL_SCOPES includes Bluesky and Frontpage", () => { - expect(CROSSPOST_ADDITIONAL_SCOPES).toContain("repo:app.bsky.feed.post?action=create"); - expect(CROSSPOST_ADDITIONAL_SCOPES).toContain("repo:fyi.frontpage.post?action=create"); - expect(CROSSPOST_ADDITIONAL_SCOPES).toContain("blob:image/*"); - }); - - it("BARAZO_CROSSPOST_SCOPES combines base and cross-post scopes", () => { - expect(BARAZO_CROSSPOST_SCOPES).toContain(BARAZO_BASE_SCOPES); - expect(BARAZO_CROSSPOST_SCOPES).toContain(CROSSPOST_ADDITIONAL_SCOPES); - }); - - it("FALLBACK_SCOPE is the legacy generic scope", () => { - expect(FALLBACK_SCOPE).toBe("atproto transition:generic"); - }); -}); - -describe("hasCrossPostScopes", () => { - it("returns true for full cross-post scopes", () => { - expect(hasCrossPostScopes(BARAZO_CROSSPOST_SCOPES)).toBe(true); - }); - - it("returns true for fallback scope (transition:generic)", () => { - expect(hasCrossPostScopes(FALLBACK_SCOPE)).toBe(true); - }); - - it("returns false for base scopes only", () => { - expect(hasCrossPostScopes(BARAZO_BASE_SCOPES)).toBe(false); - }); - - it("returns false when only Bluesky scope is present", () => { - const partial = `${BARAZO_BASE_SCOPES} repo:app.bsky.feed.post?action=create`; - expect(hasCrossPostScopes(partial)).toBe(false); - }); - - it("returns false when only Frontpage scope is present", () => { - const partial = `${BARAZO_BASE_SCOPES} repo:fyi.frontpage.post?action=create`; - expect(hasCrossPostScopes(partial)).toBe(false); - }); - - it("returns true when both cross-post scopes are present without action qualifier", () => { - const scope = "atproto repo:app.bsky.feed.post repo:fyi.frontpage.post"; - expect(hasCrossPostScopes(scope)).toBe(true); - }); - - it("returns false for empty string", () => { - expect(hasCrossPostScopes("")).toBe(false); - }); -}); - -describe("isFallbackScope", () => { - it("returns true for transition:generic", () => { - expect(isFallbackScope(FALLBACK_SCOPE)).toBe(true); - }); - - it("returns true when transition:generic is part of larger scope", () => { - expect(isFallbackScope("atproto transition:generic repo:extra")).toBe(true); - }); - - it("returns false for granular scopes", () => { - expect(isFallbackScope(BARAZO_BASE_SCOPES)).toBe(false); - }); - - it("returns false for cross-post scopes", () => { - expect(isFallbackScope(BARAZO_CROSSPOST_SCOPES)).toBe(false); - }); - - it("returns false for empty string", () => { - expect(isFallbackScope("")).toBe(false); - }); -}); +} from '../../../src/auth/scopes.js' + +describe('scope constants', () => { + it('BARAZO_BASE_SCOPES includes all forum collections', () => { + expect(BARAZO_BASE_SCOPES).toContain('repo:forum.barazo.topic.post') + expect(BARAZO_BASE_SCOPES).toContain('repo:forum.barazo.topic.reply') + expect(BARAZO_BASE_SCOPES).toContain('repo:forum.barazo.interaction.reaction') + expect(BARAZO_BASE_SCOPES.startsWith('atproto ')).toBe(true) + }) + + it('BARAZO_BASE_SCOPES does not include cross-post collections', () => { + expect(BARAZO_BASE_SCOPES).not.toContain('app.bsky.feed.post') + expect(BARAZO_BASE_SCOPES).not.toContain('fyi.frontpage.post') + }) + + it('CROSSPOST_ADDITIONAL_SCOPES includes Bluesky and Frontpage', () => { + expect(CROSSPOST_ADDITIONAL_SCOPES).toContain('repo:app.bsky.feed.post?action=create') + expect(CROSSPOST_ADDITIONAL_SCOPES).toContain('repo:fyi.frontpage.post?action=create') + expect(CROSSPOST_ADDITIONAL_SCOPES).toContain('blob:image/*') + }) + + it('BARAZO_CROSSPOST_SCOPES combines base and cross-post scopes', () => { + expect(BARAZO_CROSSPOST_SCOPES).toContain(BARAZO_BASE_SCOPES) + expect(BARAZO_CROSSPOST_SCOPES).toContain(CROSSPOST_ADDITIONAL_SCOPES) + }) + + it('FALLBACK_SCOPE is the legacy generic scope', () => { + expect(FALLBACK_SCOPE).toBe('atproto transition:generic') + }) +}) + +describe('hasCrossPostScopes', () => { + it('returns true for full cross-post scopes', () => { + expect(hasCrossPostScopes(BARAZO_CROSSPOST_SCOPES)).toBe(true) + }) + + it('returns true for fallback scope (transition:generic)', () => { + expect(hasCrossPostScopes(FALLBACK_SCOPE)).toBe(true) + }) + + it('returns false for base scopes only', () => { + expect(hasCrossPostScopes(BARAZO_BASE_SCOPES)).toBe(false) + }) + + it('returns false when only Bluesky scope is present', () => { + const partial = `${BARAZO_BASE_SCOPES} repo:app.bsky.feed.post?action=create` + expect(hasCrossPostScopes(partial)).toBe(false) + }) + + it('returns false when only Frontpage scope is present', () => { + const partial = `${BARAZO_BASE_SCOPES} repo:fyi.frontpage.post?action=create` + expect(hasCrossPostScopes(partial)).toBe(false) + }) + + it('returns true when both cross-post scopes are present without action qualifier', () => { + const scope = 'atproto repo:app.bsky.feed.post repo:fyi.frontpage.post' + expect(hasCrossPostScopes(scope)).toBe(true) + }) + + it('returns false for empty string', () => { + expect(hasCrossPostScopes('')).toBe(false) + }) +}) + +describe('isFallbackScope', () => { + it('returns true for transition:generic', () => { + expect(isFallbackScope(FALLBACK_SCOPE)).toBe(true) + }) + + it('returns true when transition:generic is part of larger scope', () => { + expect(isFallbackScope('atproto transition:generic repo:extra')).toBe(true) + }) + + it('returns false for granular scopes', () => { + expect(isFallbackScope(BARAZO_BASE_SCOPES)).toBe(false) + }) + + it('returns false for cross-post scopes', () => { + expect(isFallbackScope(BARAZO_CROSSPOST_SCOPES)).toBe(false) + }) + + it('returns false for empty string', () => { + expect(isFallbackScope('')).toBe(false) + }) +}) diff --git a/tests/unit/auth/session.test.ts b/tests/unit/auth/session.test.ts index b4ef897..64b0734 100644 --- a/tests/unit/auth/session.test.ts +++ b/tests/unit/auth/session.test.ts @@ -1,22 +1,22 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import crypto from "node:crypto"; -import { createSessionService } from "../../../src/auth/session.js"; -import type { SessionService, SessionConfig } from "../../../src/auth/session.js"; -import type { Cache } from "../../../src/cache/index.js"; -import type { Logger } from "../../../src/lib/logger.js"; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import crypto from 'node:crypto' +import { createSessionService } from '../../../src/auth/session.js' +import type { SessionService, SessionConfig } from '../../../src/auth/session.js' +import type { Cache } from '../../../src/cache/index.js' +import type { Logger } from '../../../src/lib/logger.js' // --------------------------------------------------------------------------- // Helpers -- mirrors the mock pattern from oauth-stores.test.ts // --------------------------------------------------------------------------- function createMockCache() { - const setFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue("OK"); - const getFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(null); - const delFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(1); - const saddFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(1); - const smembersFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue([]); - const sremFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(1); - const expireFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(1); + const setFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue('OK') + const getFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(null) + const delFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(1) + const saddFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(1) + const smembersFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue([]) + const sremFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(1) + const expireFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(1) return { cache: { set: setFn, @@ -34,14 +34,14 @@ function createMockCache() { smembersFn, sremFn, expireFn, - }; + } } function createMockLogger() { - const debugFn = vi.fn(); - const infoFn = vi.fn(); - const warnFn = vi.fn(); - const errorFn = vi.fn(); + const debugFn = vi.fn() + const infoFn = vi.fn() + const warnFn = vi.fn() + const errorFn = vi.fn() return { logger: { debug: debugFn, @@ -56,111 +56,113 @@ function createMockLogger() { infoFn, warnFn, errorFn, - }; + } } /** SHA-256 hash helper for test assertions */ function sha256(value: string): string { - return crypto.createHash("sha256").update(value).digest("hex"); + return crypto.createHash('sha256').update(value).digest('hex') } const defaultConfig: SessionConfig = { sessionTtl: 604800, // 7 days accessTokenTtl: 900, // 15 min -}; +} -const testDid = "did:plc:test-user-123"; -const testHandle = "alice.bsky.social"; +const testDid = 'did:plc:test-user-123' +const testHandle = 'alice.bsky.social' /** * Build a mock persisted session (as stored in Valkey). * Uses accessTokenHash (never raw accessToken). */ -function buildPersistedSession(overrides: { - sid?: string; - did?: string; - handle?: string; - accessTokenHash?: string; - accessTokenExpiresAt?: number; - createdAt?: number; -} = {}) { +function buildPersistedSession( + overrides: { + sid?: string + did?: string + handle?: string + accessTokenHash?: string + accessTokenExpiresAt?: number + createdAt?: number + } = {} +) { return { - sid: overrides.sid ?? "a".repeat(64), + sid: overrides.sid ?? 'a'.repeat(64), did: overrides.did ?? testDid, handle: overrides.handle ?? testHandle, - accessTokenHash: overrides.accessTokenHash ?? sha256("b".repeat(64)), + accessTokenHash: overrides.accessTokenHash ?? sha256('b'.repeat(64)), accessTokenExpiresAt: overrides.accessTokenExpiresAt ?? Date.now() + 900_000, createdAt: overrides.createdAt ?? Date.now(), - }; + } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describe("SessionService", () => { - let _cache: Cache; - let setFn: ReturnType["setFn"]; - let getFn: ReturnType["getFn"]; - let delFn: ReturnType["delFn"]; - let saddFn: ReturnType["saddFn"]; - let smembersFn: ReturnType["smembersFn"]; - let sremFn: ReturnType["sremFn"]; - let expireFn: ReturnType["expireFn"]; - let debugFn: ReturnType["debugFn"]; - let errorFn: ReturnType["errorFn"]; - let service: SessionService; +describe('SessionService', () => { + let _cache: Cache + let setFn: ReturnType['setFn'] + let getFn: ReturnType['getFn'] + let delFn: ReturnType['delFn'] + let saddFn: ReturnType['saddFn'] + let smembersFn: ReturnType['smembersFn'] + let sremFn: ReturnType['sremFn'] + let expireFn: ReturnType['expireFn'] + let debugFn: ReturnType['debugFn'] + let errorFn: ReturnType['errorFn'] + let service: SessionService beforeEach(() => { - const mocks = createMockCache(); - const logMocks = createMockLogger(); - _cache = mocks.cache; - setFn = mocks.setFn; - getFn = mocks.getFn; - delFn = mocks.delFn; - saddFn = mocks.saddFn; - smembersFn = mocks.smembersFn; - sremFn = mocks.sremFn; - expireFn = mocks.expireFn; - debugFn = logMocks.debugFn; - errorFn = logMocks.errorFn; - service = createSessionService(mocks.cache, logMocks.logger, defaultConfig); - }); + const mocks = createMockCache() + const logMocks = createMockLogger() + _cache = mocks.cache + setFn = mocks.setFn + getFn = mocks.getFn + delFn = mocks.delFn + saddFn = mocks.saddFn + smembersFn = mocks.smembersFn + sremFn = mocks.sremFn + expireFn = mocks.expireFn + debugFn = logMocks.debugFn + errorFn = logMocks.errorFn + service = createSessionService(mocks.cache, logMocks.logger, defaultConfig) + }) // ------------------------------------------------------------------------- // createSession // ------------------------------------------------------------------------- - describe("createSession", () => { - it("creates session with valid did and handle", async () => { - const session = await service.createSession(testDid, testHandle); + describe('createSession', () => { + it('creates session with valid did and handle', async () => { + const session = await service.createSession(testDid, testHandle) - expect(session.did).toBe(testDid); - expect(session.handle).toBe(testHandle); - }); + expect(session.did).toBe(testDid) + expect(session.handle).toBe(testHandle) + }) - it("generates a unique session ID (64 hex chars)", async () => { - const session = await service.createSession(testDid, testHandle); + it('generates a unique session ID (64 hex chars)', async () => { + const session = await service.createSession(testDid, testHandle) - expect(session.sid).toMatch(/^[a-f0-9]{64}$/); - }); + expect(session.sid).toMatch(/^[a-f0-9]{64}$/) + }) - it("generates a unique access token (64 hex chars)", async () => { - const session = await service.createSession(testDid, testHandle); + it('generates a unique access token (64 hex chars)', async () => { + const session = await service.createSession(testDid, testHandle) - expect(session.accessToken).toMatch(/^[a-f0-9]{64}$/); - }); + expect(session.accessToken).toMatch(/^[a-f0-9]{64}$/) + }) - it("generates different IDs on each call", async () => { - const session1 = await service.createSession(testDid, testHandle); - const session2 = await service.createSession(testDid, testHandle); + it('generates different IDs on each call', async () => { + const session1 = await service.createSession(testDid, testHandle) + const session2 = await service.createSession(testDid, testHandle) - expect(session1.sid).not.toBe(session2.sid); - expect(session1.accessToken).not.toBe(session2.accessToken); - }); + expect(session1.sid).not.toBe(session2.sid) + expect(session1.accessToken).not.toBe(session2.accessToken) + }) - it("stores session data with accessTokenHash (not raw token) in Valkey", async () => { - const session = await service.createSession(testDid, testHandle); - const tokenHash = sha256(session.accessToken); + it('stores session data with accessTokenHash (not raw token) in Valkey', async () => { + const session = await service.createSession(testDid, testHandle) + const tokenHash = sha256(session.accessToken) // The persisted data should have accessTokenHash, NOT accessToken const persisted = { @@ -170,67 +172,61 @@ describe("SessionService", () => { accessTokenHash: tokenHash, accessTokenExpiresAt: session.accessTokenExpiresAt, createdAt: session.createdAt, - }; + } expect(setFn).toHaveBeenCalledWith( `barazo:session:data:${session.sid}`, JSON.stringify(persisted), - "EX", - 604800, - ); - }); + 'EX', + 604800 + ) + }) - it("stores access token hash mapping with correct TTL", async () => { - const session = await service.createSession(testDid, testHandle); - const tokenHash = sha256(session.accessToken); + it('stores access token hash mapping with correct TTL', async () => { + const session = await service.createSession(testDid, testHandle) + const tokenHash = sha256(session.accessToken) expect(setFn).toHaveBeenCalledWith( `barazo:session:access:${tokenHash}`, session.sid, - "EX", - 900, - ); - }); + 'EX', + 900 + ) + }) - it("adds session ID to DID index set", async () => { - const session = await service.createSession(testDid, testHandle); + it('adds session ID to DID index set', async () => { + const session = await service.createSession(testDid, testHandle) - expect(saddFn).toHaveBeenCalledWith( - `barazo:session:did:${testDid}`, - session.sid, - ); - }); + expect(saddFn).toHaveBeenCalledWith(`barazo:session:did:${testDid}`, session.sid) + }) - it("refreshes TTL on DID index set", async () => { - await service.createSession(testDid, testHandle); + it('refreshes TTL on DID index set', async () => { + await service.createSession(testDid, testHandle) - expect(expireFn).toHaveBeenCalledWith( - `barazo:session:did:${testDid}`, - 604800, - ); - }); + expect(expireFn).toHaveBeenCalledWith(`barazo:session:did:${testDid}`, 604800) + }) - it("sets accessTokenExpiresAt in the future", async () => { - const before = Date.now(); - const session = await service.createSession(testDid, testHandle); - const after = Date.now(); + it('sets accessTokenExpiresAt in the future', async () => { + const before = Date.now() + const session = await service.createSession(testDid, testHandle) + const after = Date.now() // accessTokenExpiresAt should be ~900 seconds (15 min) from now - expect(session.accessTokenExpiresAt).toBeGreaterThanOrEqual(before + 900 * 1000); - expect(session.accessTokenExpiresAt).toBeLessThanOrEqual(after + 900 * 1000); - }); + expect(session.accessTokenExpiresAt).toBeGreaterThanOrEqual(before + 900 * 1000) + expect(session.accessTokenExpiresAt).toBeLessThanOrEqual(after + 900 * 1000) + }) - it("sets createdAt to approximately now", async () => { - const before = Date.now(); - const session = await service.createSession(testDid, testHandle); - const after = Date.now(); + it('sets createdAt to approximately now', async () => { + const before = Date.now() + const session = await service.createSession(testDid, testHandle) + const after = Date.now() - expect(session.createdAt).toBeGreaterThanOrEqual(before); - expect(session.createdAt).toBeLessThanOrEqual(after); - }); + expect(session.createdAt).toBeGreaterThanOrEqual(before) + expect(session.createdAt).toBeLessThanOrEqual(after) + }) - it("returns SessionWithToken including both accessToken and accessTokenHash", async () => { - const session = await service.createSession(testDid, testHandle); + it('returns SessionWithToken including both accessToken and accessTokenHash', async () => { + const session = await service.createSession(testDid, testHandle) expect(session).toEqual( expect.objectContaining({ @@ -241,177 +237,175 @@ describe("SessionService", () => { accessTokenHash: expect.stringMatching(/^[a-f0-9]{64}$/) as string, accessTokenExpiresAt: expect.any(Number) as number, createdAt: expect.any(Number) as number, - }), - ); + }) + ) // accessTokenHash should be the SHA-256 of accessToken - expect(session.accessTokenHash).toBe(sha256(session.accessToken)); - }); + expect(session.accessTokenHash).toBe(sha256(session.accessToken)) + }) - it("logs debug on success without raw tokens", async () => { - const session = await service.createSession(testDid, testHandle); + it('logs debug on success without raw tokens', async () => { + const session = await service.createSession(testDid, testHandle) expect(debugFn).toHaveBeenCalledWith( expect.objectContaining({ did: testDid, sid: session.sid.slice(0, 8), }), - "Session created", - ); + 'Session created' + ) // Verify no debug call contains the full access token for (const call of debugFn.mock.calls) { - const logObj = JSON.stringify(call); - expect(logObj).not.toContain(session.accessToken); + const logObj = JSON.stringify(call) + expect(logObj).not.toContain(session.accessToken) } - }); + }) - it("logs error and rethrows on cache failure", async () => { - const error = new Error("Valkey connection refused"); - setFn.mockRejectedValueOnce(error); + it('logs error and rethrows on cache failure', async () => { + const error = new Error('Valkey connection refused') + setFn.mockRejectedValueOnce(error) - await expect( - service.createSession(testDid, testHandle), - ).rejects.toThrow("Valkey connection refused"); - expect(errorFn).toHaveBeenCalled(); - }); - }); + await expect(service.createSession(testDid, testHandle)).rejects.toThrow( + 'Valkey connection refused' + ) + expect(errorFn).toHaveBeenCalled() + }) + }) // ------------------------------------------------------------------------- // validateAccessToken // ------------------------------------------------------------------------- - describe("validateAccessToken", () => { - it("returns session when access token is valid", async () => { - const rawToken = "b".repeat(64); - const tokenHash = sha256(rawToken); - const persisted = buildPersistedSession({ accessTokenHash: tokenHash }); + describe('validateAccessToken', () => { + it('returns session when access token is valid', async () => { + const rawToken = 'b'.repeat(64) + const tokenHash = sha256(rawToken) + const persisted = buildPersistedSession({ accessTokenHash: tokenHash }) // First get: access token hash → sid - getFn.mockResolvedValueOnce(persisted.sid); + getFn.mockResolvedValueOnce(persisted.sid) // Second get: session data - getFn.mockResolvedValueOnce(JSON.stringify(persisted)); + getFn.mockResolvedValueOnce(JSON.stringify(persisted)) - const result = await service.validateAccessToken(rawToken); + const result = await service.validateAccessToken(rawToken) - expect(result).toEqual(persisted); - expect(getFn).toHaveBeenCalledWith(`barazo:session:access:${tokenHash}`); - expect(getFn).toHaveBeenCalledWith(`barazo:session:data:${persisted.sid}`); - }); + expect(result).toEqual(persisted) + expect(getFn).toHaveBeenCalledWith(`barazo:session:access:${tokenHash}`) + expect(getFn).toHaveBeenCalledWith(`barazo:session:data:${persisted.sid}`) + }) - it("returns undefined when access token not found", async () => { - getFn.mockResolvedValueOnce(null); + it('returns undefined when access token not found', async () => { + getFn.mockResolvedValueOnce(null) - const result = await service.validateAccessToken("nonexistent-token"); + const result = await service.validateAccessToken('nonexistent-token') - expect(result).toBeUndefined(); - }); + expect(result).toBeUndefined() + }) - it("returns undefined when session data not found (orphaned token)", async () => { + it('returns undefined when session data not found (orphaned token)', async () => { // Access token hash lookup returns a sid - getFn.mockResolvedValueOnce("a".repeat(64)); + getFn.mockResolvedValueOnce('a'.repeat(64)) // But session data is gone - getFn.mockResolvedValueOnce(null); + getFn.mockResolvedValueOnce(null) - const result = await service.validateAccessToken("some-token"); + const result = await service.validateAccessToken('some-token') - expect(result).toBeUndefined(); - }); + expect(result).toBeUndefined() + }) - it("never logs raw access tokens", async () => { - const rawToken = "c".repeat(64); - getFn.mockResolvedValueOnce(null); + it('never logs raw access tokens', async () => { + const rawToken = 'c'.repeat(64) + getFn.mockResolvedValueOnce(null) - await service.validateAccessToken(rawToken); + await service.validateAccessToken(rawToken) for (const call of debugFn.mock.calls) { - const logObj = JSON.stringify(call); - expect(logObj).not.toContain(rawToken); + const logObj = JSON.stringify(call) + expect(logObj).not.toContain(rawToken) } - }); + }) - it("logs error and rethrows on cache failure", async () => { - const error = new Error("Valkey timeout"); - getFn.mockRejectedValueOnce(error); + it('logs error and rethrows on cache failure', async () => { + const error = new Error('Valkey timeout') + getFn.mockRejectedValueOnce(error) - await expect( - service.validateAccessToken("some-token"), - ).rejects.toThrow("Valkey timeout"); - expect(errorFn).toHaveBeenCalled(); - }); - }); + await expect(service.validateAccessToken('some-token')).rejects.toThrow('Valkey timeout') + expect(errorFn).toHaveBeenCalled() + }) + }) // ------------------------------------------------------------------------- // refreshSession // ------------------------------------------------------------------------- - describe("refreshSession", () => { - it("returns updated session with new access token", async () => { - const oldTokenHash = sha256("old-token-" + "x".repeat(54)); + describe('refreshSession', () => { + it('returns updated session with new access token', async () => { + const oldTokenHash = sha256('old-token-' + 'x'.repeat(54)) const persisted = buildPersistedSession({ accessTokenHash: oldTokenHash, accessTokenExpiresAt: Date.now() - 1000, createdAt: Date.now() - 600_000, - }); + }) - getFn.mockResolvedValueOnce(JSON.stringify(persisted)); + getFn.mockResolvedValueOnce(JSON.stringify(persisted)) - const result = await service.refreshSession(persisted.sid); + const result = await service.refreshSession(persisted.sid) if (result === undefined) { - expect.fail("Expected session to be defined"); + expect.fail('Expected session to be defined') } - expect(result.sid).toBe(persisted.sid); - expect(result.did).toBe(testDid); - expect(result.handle).toBe(testHandle); + expect(result.sid).toBe(persisted.sid) + expect(result.did).toBe(testDid) + expect(result.handle).toBe(testHandle) // New access token should be a fresh 64-char hex string - expect(result.accessToken).toMatch(/^[a-f0-9]{64}$/); + expect(result.accessToken).toMatch(/^[a-f0-9]{64}$/) // New accessTokenHash should match the new access token - expect(result.accessTokenHash).toBe(sha256(result.accessToken)); - expect(result.accessTokenHash).not.toBe(oldTokenHash); + expect(result.accessTokenHash).toBe(sha256(result.accessToken)) + expect(result.accessTokenHash).not.toBe(oldTokenHash) // New expiry should be in the future - expect(result.accessTokenExpiresAt).toBeGreaterThan(Date.now()); + expect(result.accessTokenExpiresAt).toBeGreaterThan(Date.now()) // createdAt should remain the same - expect(result.createdAt).toBe(persisted.createdAt); - }); + expect(result.createdAt).toBe(persisted.createdAt) + }) - it("deletes old access token lookup", async () => { - const oldTokenHash = sha256("old-token-" + "x".repeat(54)); - const persisted = buildPersistedSession({ accessTokenHash: oldTokenHash }); + it('deletes old access token lookup', async () => { + const oldTokenHash = sha256('old-token-' + 'x'.repeat(54)) + const persisted = buildPersistedSession({ accessTokenHash: oldTokenHash }) - getFn.mockResolvedValueOnce(JSON.stringify(persisted)); + getFn.mockResolvedValueOnce(JSON.stringify(persisted)) - await service.refreshSession(persisted.sid); + await service.refreshSession(persisted.sid) - expect(delFn).toHaveBeenCalledWith(`barazo:session:access:${oldTokenHash}`); - }); + expect(delFn).toHaveBeenCalledWith(`barazo:session:access:${oldTokenHash}`) + }) - it("creates new access token lookup", async () => { - const persisted = buildPersistedSession(); + it('creates new access token lookup', async () => { + const persisted = buildPersistedSession() - getFn.mockResolvedValueOnce(JSON.stringify(persisted)); + getFn.mockResolvedValueOnce(JSON.stringify(persisted)) - const result = await service.refreshSession(persisted.sid); + const result = await service.refreshSession(persisted.sid) if (result === undefined) { - expect.fail("Expected session to be defined"); + expect.fail('Expected session to be defined') } - const newTokenHash = sha256(result.accessToken); + const newTokenHash = sha256(result.accessToken) expect(setFn).toHaveBeenCalledWith( `barazo:session:access:${newTokenHash}`, persisted.sid, - "EX", - 900, - ); - }); + 'EX', + 900 + ) + }) - it("updates session data with new accessTokenHash (not raw token)", async () => { - const persisted = buildPersistedSession(); + it('updates session data with new accessTokenHash (not raw token)', async () => { + const persisted = buildPersistedSession() - getFn.mockResolvedValueOnce(JSON.stringify(persisted)); + getFn.mockResolvedValueOnce(JSON.stringify(persisted)) - const result = await service.refreshSession(persisted.sid); + const result = await service.refreshSession(persisted.sid) if (result === undefined) { - expect.fail("Expected session to be defined"); + expect.fail('Expected session to be defined') } // The persisted form should have accessTokenHash but NOT accessToken @@ -422,203 +416,211 @@ describe("SessionService", () => { accessTokenHash: result.accessTokenHash, accessTokenExpiresAt: result.accessTokenExpiresAt, createdAt: result.createdAt, - }; + } expect(setFn).toHaveBeenCalledWith( `barazo:session:data:${persisted.sid}`, JSON.stringify(expectedPersisted), - "EX", - 604800, - ); - }); + 'EX', + 604800 + ) + }) - it("returns undefined when session ID not found", async () => { - getFn.mockResolvedValueOnce(null); + it('returns undefined when session ID not found', async () => { + getFn.mockResolvedValueOnce(null) - const result = await service.refreshSession("nonexistent-sid"); + const result = await service.refreshSession('nonexistent-sid') - expect(result).toBeUndefined(); - }); + expect(result).toBeUndefined() + }) - it("logs debug on success", async () => { - const persisted = buildPersistedSession(); + it('logs debug on success', async () => { + const persisted = buildPersistedSession() - getFn.mockResolvedValueOnce(JSON.stringify(persisted)); + getFn.mockResolvedValueOnce(JSON.stringify(persisted)) - await service.refreshSession(persisted.sid); + await service.refreshSession(persisted.sid) expect(debugFn).toHaveBeenCalledWith( expect.objectContaining({ sid: persisted.sid.slice(0, 8), }), - "Session refreshed", - ); - }); + 'Session refreshed' + ) + }) - it("logs error and rethrows on cache failure", async () => { - const error = new Error("Valkey error"); - getFn.mockRejectedValueOnce(error); + it('logs error and rethrows on cache failure', async () => { + const error = new Error('Valkey error') + getFn.mockRejectedValueOnce(error) - await expect( - service.refreshSession("some-sid"), - ).rejects.toThrow("Valkey error"); - expect(errorFn).toHaveBeenCalled(); - }); - }); + await expect(service.refreshSession('some-sid')).rejects.toThrow('Valkey error') + expect(errorFn).toHaveBeenCalled() + }) + }) // ------------------------------------------------------------------------- // deleteSession // ------------------------------------------------------------------------- - describe("deleteSession", () => { - it("deletes session data", async () => { - const persisted = buildPersistedSession(); + describe('deleteSession', () => { + it('deletes session data', async () => { + const persisted = buildPersistedSession() - getFn.mockResolvedValueOnce(JSON.stringify(persisted)); + getFn.mockResolvedValueOnce(JSON.stringify(persisted)) - await service.deleteSession(persisted.sid); + await service.deleteSession(persisted.sid) - expect(delFn).toHaveBeenCalledWith(`barazo:session:data:${persisted.sid}`); - }); + expect(delFn).toHaveBeenCalledWith(`barazo:session:data:${persisted.sid}`) + }) - it("deletes access token lookup using stored hash", async () => { - const tokenHash = sha256("b".repeat(64)); - const persisted = buildPersistedSession({ accessTokenHash: tokenHash }); + it('deletes access token lookup using stored hash', async () => { + const tokenHash = sha256('b'.repeat(64)) + const persisted = buildPersistedSession({ accessTokenHash: tokenHash }) - getFn.mockResolvedValueOnce(JSON.stringify(persisted)); + getFn.mockResolvedValueOnce(JSON.stringify(persisted)) - await service.deleteSession(persisted.sid); + await service.deleteSession(persisted.sid) - expect(delFn).toHaveBeenCalledWith(`barazo:session:access:${tokenHash}`); - }); + expect(delFn).toHaveBeenCalledWith(`barazo:session:access:${tokenHash}`) + }) - it("removes session ID from DID index set", async () => { - const persisted = buildPersistedSession(); + it('removes session ID from DID index set', async () => { + const persisted = buildPersistedSession() - getFn.mockResolvedValueOnce(JSON.stringify(persisted)); + getFn.mockResolvedValueOnce(JSON.stringify(persisted)) - await service.deleteSession(persisted.sid); + await service.deleteSession(persisted.sid) - expect(sremFn).toHaveBeenCalledWith( - `barazo:session:did:${testDid}`, - persisted.sid, - ); - }); + expect(sremFn).toHaveBeenCalledWith(`barazo:session:did:${testDid}`, persisted.sid) + }) - it("does not throw when session does not exist", async () => { - getFn.mockResolvedValueOnce(null); + it('does not throw when session does not exist', async () => { + getFn.mockResolvedValueOnce(null) - await expect(service.deleteSession("nonexistent-sid")).resolves.toBeUndefined(); - }); + await expect(service.deleteSession('nonexistent-sid')).resolves.toBeUndefined() + }) - it("logs debug on success", async () => { - const persisted = buildPersistedSession(); + it('logs debug on success', async () => { + const persisted = buildPersistedSession() - getFn.mockResolvedValueOnce(JSON.stringify(persisted)); + getFn.mockResolvedValueOnce(JSON.stringify(persisted)) - await service.deleteSession(persisted.sid); + await service.deleteSession(persisted.sid) expect(debugFn).toHaveBeenCalledWith( expect.objectContaining({ sid: persisted.sid.slice(0, 8) }), - "Session deleted", - ); - }); + 'Session deleted' + ) + }) - it("logs error and rethrows on cache failure", async () => { - const error = new Error("Valkey error"); - getFn.mockRejectedValueOnce(error); + it('logs error and rethrows on cache failure', async () => { + const error = new Error('Valkey error') + getFn.mockRejectedValueOnce(error) - await expect(service.deleteSession("some-sid")).rejects.toThrow("Valkey error"); - expect(errorFn).toHaveBeenCalled(); - }); - }); + await expect(service.deleteSession('some-sid')).rejects.toThrow('Valkey error') + expect(errorFn).toHaveBeenCalled() + }) + }) // ------------------------------------------------------------------------- // deleteAllSessionsForDid // ------------------------------------------------------------------------- - describe("deleteAllSessionsForDid", () => { - it("deletes all sessions for a DID", async () => { - const sid1 = "a".repeat(64); - const sid2 = "b".repeat(64); + describe('deleteAllSessionsForDid', () => { + it('deletes all sessions for a DID', async () => { + const sid1 = 'a'.repeat(64) + const sid2 = 'b'.repeat(64) const session1 = buildPersistedSession({ sid: sid1, - accessTokenHash: sha256("c".repeat(64)), - }); + accessTokenHash: sha256('c'.repeat(64)), + }) const session2 = buildPersistedSession({ sid: sid2, - accessTokenHash: sha256("d".repeat(64)), - }); + accessTokenHash: sha256('d'.repeat(64)), + }) // smembers returns the set of session IDs - smembersFn.mockResolvedValueOnce([sid1, sid2]); + smembersFn.mockResolvedValueOnce([sid1, sid2]) // For each session, get returns the session data (for deleteSession) - getFn.mockResolvedValueOnce(JSON.stringify(session1)); - getFn.mockResolvedValueOnce(JSON.stringify(session2)); - - const count = await service.deleteAllSessionsForDid(testDid); - - expect(count).toBe(2); - expect(smembersFn).toHaveBeenCalledWith(`barazo:session:did:${testDid}`); - }); - - it("returns count of deleted sessions", async () => { - const sid1 = "a".repeat(64); - const sid2 = "b".repeat(64); - const sid3 = "c".repeat(64); - - smembersFn.mockResolvedValueOnce([sid1, sid2, sid3]); - getFn.mockResolvedValueOnce(JSON.stringify(buildPersistedSession({ - sid: sid1, accessTokenHash: sha256("x".repeat(64)), - }))); - getFn.mockResolvedValueOnce(JSON.stringify(buildPersistedSession({ - sid: sid2, accessTokenHash: sha256("y".repeat(64)), - }))); - getFn.mockResolvedValueOnce(JSON.stringify(buildPersistedSession({ - sid: sid3, accessTokenHash: sha256("z".repeat(64)), - }))); - - const count = await service.deleteAllSessionsForDid(testDid); - - expect(count).toBe(3); - }); - - it("removes the DID index set", async () => { - smembersFn.mockResolvedValueOnce(["a".repeat(64)]); - getFn.mockResolvedValueOnce(JSON.stringify(buildPersistedSession())); - - await service.deleteAllSessionsForDid(testDid); - - expect(delFn).toHaveBeenCalledWith(`barazo:session:did:${testDid}`); - }); - - it("returns 0 when DID has no sessions", async () => { - smembersFn.mockResolvedValueOnce([]); - - const count = await service.deleteAllSessionsForDid(testDid); - - expect(count).toBe(0); - }); - - it("logs debug with count on success", async () => { - smembersFn.mockResolvedValueOnce(["a".repeat(64)]); - getFn.mockResolvedValueOnce(JSON.stringify(buildPersistedSession())); - - await service.deleteAllSessionsForDid(testDid); + getFn.mockResolvedValueOnce(JSON.stringify(session1)) + getFn.mockResolvedValueOnce(JSON.stringify(session2)) + + const count = await service.deleteAllSessionsForDid(testDid) + + expect(count).toBe(2) + expect(smembersFn).toHaveBeenCalledWith(`barazo:session:did:${testDid}`) + }) + + it('returns count of deleted sessions', async () => { + const sid1 = 'a'.repeat(64) + const sid2 = 'b'.repeat(64) + const sid3 = 'c'.repeat(64) + + smembersFn.mockResolvedValueOnce([sid1, sid2, sid3]) + getFn.mockResolvedValueOnce( + JSON.stringify( + buildPersistedSession({ + sid: sid1, + accessTokenHash: sha256('x'.repeat(64)), + }) + ) + ) + getFn.mockResolvedValueOnce( + JSON.stringify( + buildPersistedSession({ + sid: sid2, + accessTokenHash: sha256('y'.repeat(64)), + }) + ) + ) + getFn.mockResolvedValueOnce( + JSON.stringify( + buildPersistedSession({ + sid: sid3, + accessTokenHash: sha256('z'.repeat(64)), + }) + ) + ) + + const count = await service.deleteAllSessionsForDid(testDid) + + expect(count).toBe(3) + }) + + it('removes the DID index set', async () => { + smembersFn.mockResolvedValueOnce(['a'.repeat(64)]) + getFn.mockResolvedValueOnce(JSON.stringify(buildPersistedSession())) + + await service.deleteAllSessionsForDid(testDid) + + expect(delFn).toHaveBeenCalledWith(`barazo:session:did:${testDid}`) + }) + + it('returns 0 when DID has no sessions', async () => { + smembersFn.mockResolvedValueOnce([]) + + const count = await service.deleteAllSessionsForDid(testDid) + + expect(count).toBe(0) + }) + + it('logs debug with count on success', async () => { + smembersFn.mockResolvedValueOnce(['a'.repeat(64)]) + getFn.mockResolvedValueOnce(JSON.stringify(buildPersistedSession())) + + await service.deleteAllSessionsForDid(testDid) expect(debugFn).toHaveBeenCalledWith( expect.objectContaining({ did: testDid, count: 1 }), - "All sessions deleted for DID", - ); - }); - - it("logs error and rethrows on cache failure", async () => { - const error = new Error("Valkey error"); - smembersFn.mockRejectedValueOnce(error); - - await expect( - service.deleteAllSessionsForDid(testDid), - ).rejects.toThrow("Valkey error"); - expect(errorFn).toHaveBeenCalled(); - }); - }); -}); + 'All sessions deleted for DID' + ) + }) + + it('logs error and rethrows on cache failure', async () => { + const error = new Error('Valkey error') + smembersFn.mockRejectedValueOnce(error) + + await expect(service.deleteAllSessionsForDid(testDid)).rejects.toThrow('Valkey error') + expect(errorFn).toHaveBeenCalled() + }) + }) +}) diff --git a/tests/unit/config/env.test.ts b/tests/unit/config/env.test.ts index de209fd..2671035 100644 --- a/tests/unit/config/env.test.ts +++ b/tests/unit/config/env.test.ts @@ -1,91 +1,92 @@ -import { describe, it, expect } from "vitest"; -import { envSchema, parseEnv } from "../../../src/config/env.js"; +import { describe, it, expect } from 'vitest' +import { envSchema, parseEnv } from '../../../src/config/env.js' -describe("envSchema", () => { +describe('envSchema', () => { const validEnv = { - DATABASE_URL: "postgresql://barazo:barazo_dev@localhost:5432/barazo", - VALKEY_URL: "redis://localhost:6379", - TAP_URL: "http://localhost:2480", - TAP_ADMIN_PASSWORD: "tap_dev_secret", - OAUTH_CLIENT_ID: "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fapi%2Fauth%2Fcallback", - OAUTH_REDIRECT_URI: "http://127.0.0.1:3000/api/auth/callback", - SESSION_SECRET: "a-very-long-session-secret-that-is-at-least-32-characters", - HOST: "0.0.0.0", - PORT: "3000", - LOG_LEVEL: "info", - CORS_ORIGINS: "http://localhost:3001", - COMMUNITY_MODE: "single", - }; - - it("parses valid environment variables", () => { - const result = envSchema.safeParse(validEnv); - expect(result.success).toBe(true); + DATABASE_URL: 'postgresql://barazo:barazo_dev@localhost:5432/barazo', + VALKEY_URL: 'redis://localhost:6379', + TAP_URL: 'http://localhost:2480', + TAP_ADMIN_PASSWORD: 'tap_dev_secret', + OAUTH_CLIENT_ID: + 'http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fapi%2Fauth%2Fcallback', + OAUTH_REDIRECT_URI: 'http://127.0.0.1:3000/api/auth/callback', + SESSION_SECRET: 'a-very-long-session-secret-that-is-at-least-32-characters', + HOST: '0.0.0.0', + PORT: '3000', + LOG_LEVEL: 'info', + CORS_ORIGINS: 'http://localhost:3001', + COMMUNITY_MODE: 'single', + } + + it('parses valid environment variables', () => { + const result = envSchema.safeParse(validEnv) + expect(result.success).toBe(true) if (result.success) { - expect(result.data.PORT).toBe(3000); - expect(result.data.LOG_LEVEL).toBe("info"); - expect(result.data.COMMUNITY_MODE).toBe("single"); + expect(result.data.PORT).toBe(3000) + expect(result.data.LOG_LEVEL).toBe('info') + expect(result.data.COMMUNITY_MODE).toBe('single') } - }); - - it("rejects missing DATABASE_URL", () => { - const { DATABASE_URL: _, ...env } = validEnv; - const result = envSchema.safeParse(env); - expect(result.success).toBe(false); - }); - - it("rejects missing VALKEY_URL", () => { - const { VALKEY_URL: _, ...env } = validEnv; - const result = envSchema.safeParse(env); - expect(result.success).toBe(false); - }); - - it("rejects missing TAP_URL", () => { - const { TAP_URL: _, ...env } = validEnv; - const result = envSchema.safeParse(env); - expect(result.success).toBe(false); - }); - - it("rejects missing TAP_ADMIN_PASSWORD", () => { - const { TAP_ADMIN_PASSWORD: _, ...env } = validEnv; - const result = envSchema.safeParse(env); - expect(result.success).toBe(false); - }); - - it("rejects missing OAUTH_CLIENT_ID", () => { - const { OAUTH_CLIENT_ID: _, ...env } = validEnv; - const result = envSchema.safeParse(env); - expect(result.success).toBe(false); - }); - - it("rejects missing OAUTH_REDIRECT_URI", () => { - const { OAUTH_REDIRECT_URI: _, ...env } = validEnv; - const result = envSchema.safeParse(env); - expect(result.success).toBe(false); - }); - - it("rejects missing SESSION_SECRET", () => { - const { SESSION_SECRET: _, ...env } = validEnv; - const result = envSchema.safeParse(env); - expect(result.success).toBe(false); - }); - - it("rejects SESSION_SECRET shorter than 32 characters", () => { + }) + + it('rejects missing DATABASE_URL', () => { + const { DATABASE_URL: _, ...env } = validEnv + const result = envSchema.safeParse(env) + expect(result.success).toBe(false) + }) + + it('rejects missing VALKEY_URL', () => { + const { VALKEY_URL: _, ...env } = validEnv + const result = envSchema.safeParse(env) + expect(result.success).toBe(false) + }) + + it('rejects missing TAP_URL', () => { + const { TAP_URL: _, ...env } = validEnv + const result = envSchema.safeParse(env) + expect(result.success).toBe(false) + }) + + it('rejects missing TAP_ADMIN_PASSWORD', () => { + const { TAP_ADMIN_PASSWORD: _, ...env } = validEnv + const result = envSchema.safeParse(env) + expect(result.success).toBe(false) + }) + + it('rejects missing OAUTH_CLIENT_ID', () => { + const { OAUTH_CLIENT_ID: _, ...env } = validEnv + const result = envSchema.safeParse(env) + expect(result.success).toBe(false) + }) + + it('rejects missing OAUTH_REDIRECT_URI', () => { + const { OAUTH_REDIRECT_URI: _, ...env } = validEnv + const result = envSchema.safeParse(env) + expect(result.success).toBe(false) + }) + + it('rejects missing SESSION_SECRET', () => { + const { SESSION_SECRET: _, ...env } = validEnv + const result = envSchema.safeParse(env) + expect(result.success).toBe(false) + }) + + it('rejects SESSION_SECRET shorter than 32 characters', () => { const result = envSchema.safeParse({ ...validEnv, - SESSION_SECRET: "too-short", - }); - expect(result.success).toBe(false); - }); + SESSION_SECRET: 'too-short', + }) + expect(result.success).toBe(false) + }) - it("accepts SESSION_SECRET of exactly 32 characters", () => { + it('accepts SESSION_SECRET of exactly 32 characters', () => { const result = envSchema.safeParse({ ...validEnv, - SESSION_SECRET: "a".repeat(32), - }); - expect(result.success).toBe(true); - }); + SESSION_SECRET: 'a'.repeat(32), + }) + expect(result.success).toBe(true) + }) - it("applies default values for optional fields", () => { + it('applies default values for optional fields', () => { const result = envSchema.safeParse({ DATABASE_URL: validEnv.DATABASE_URL, VALKEY_URL: validEnv.VALKEY_URL, @@ -94,122 +95,118 @@ describe("envSchema", () => { OAUTH_CLIENT_ID: validEnv.OAUTH_CLIENT_ID, OAUTH_REDIRECT_URI: validEnv.OAUTH_REDIRECT_URI, SESSION_SECRET: validEnv.SESSION_SECRET, - }); - expect(result.success).toBe(true); + }) + expect(result.success).toBe(true) if (result.success) { - expect(result.data.HOST).toBe("0.0.0.0"); - expect(result.data.PORT).toBe(3000); - expect(result.data.LOG_LEVEL).toBe("info"); - expect(result.data.CORS_ORIGINS).toBe("http://localhost:3001"); - expect(result.data.COMMUNITY_MODE).toBe("single"); - expect(result.data.RATE_LIMIT_AUTH).toBe(10); - expect(result.data.RATE_LIMIT_WRITE).toBe(10); - expect(result.data.RATE_LIMIT_READ_ANON).toBe(100); - expect(result.data.RATE_LIMIT_READ_AUTH).toBe(300); - expect(result.data.OAUTH_SESSION_TTL).toBe(604800); - expect(result.data.OAUTH_ACCESS_TOKEN_TTL).toBe(900); + expect(result.data.HOST).toBe('0.0.0.0') + expect(result.data.PORT).toBe(3000) + expect(result.data.LOG_LEVEL).toBe('info') + expect(result.data.CORS_ORIGINS).toBe('http://localhost:3001') + expect(result.data.COMMUNITY_MODE).toBe('single') + expect(result.data.RATE_LIMIT_AUTH).toBe(10) + expect(result.data.RATE_LIMIT_WRITE).toBe(10) + expect(result.data.RATE_LIMIT_READ_ANON).toBe(100) + expect(result.data.RATE_LIMIT_READ_AUTH).toBe(300) + expect(result.data.OAUTH_SESSION_TTL).toBe(604800) + expect(result.data.OAUTH_ACCESS_TOKEN_TTL).toBe(900) } - }); + }) - it("rejects invalid PORT (non-numeric)", () => { - const result = envSchema.safeParse({ ...validEnv, PORT: "abc" }); - expect(result.success).toBe(false); - }); + it('rejects invalid PORT (non-numeric)', () => { + const result = envSchema.safeParse({ ...validEnv, PORT: 'abc' }) + expect(result.success).toBe(false) + }) - it("rejects invalid COMMUNITY_MODE", () => { + it('rejects invalid COMMUNITY_MODE', () => { const result = envSchema.safeParse({ ...validEnv, - COMMUNITY_MODE: "invalid", - }); - expect(result.success).toBe(false); - }); + COMMUNITY_MODE: 'invalid', + }) + expect(result.success).toBe(false) + }) - it("accepts global COMMUNITY_MODE", () => { + it('accepts global COMMUNITY_MODE', () => { const result = envSchema.safeParse({ ...validEnv, - COMMUNITY_MODE: "global", - }); - expect(result.success).toBe(true); + COMMUNITY_MODE: 'global', + }) + expect(result.success).toBe(true) if (result.success) { - expect(result.data.COMMUNITY_MODE).toBe("global"); + expect(result.data.COMMUNITY_MODE).toBe('global') } - }); + }) - it("accepts optional GLITCHTIP_DSN", () => { + it('accepts optional GLITCHTIP_DSN', () => { const result = envSchema.safeParse({ ...validEnv, - GLITCHTIP_DSN: "https://key@glitchtip.example.com/1", - }); - expect(result.success).toBe(true); + GLITCHTIP_DSN: 'https://key@glitchtip.example.com/1', + }) + expect(result.success).toBe(true) if (result.success) { - expect(result.data.GLITCHTIP_DSN).toBe( - "https://key@glitchtip.example.com/1", - ); + expect(result.data.GLITCHTIP_DSN).toBe('https://key@glitchtip.example.com/1') } - }); + }) - it("accepts optional EMBEDDING_URL", () => { + it('accepts optional EMBEDDING_URL', () => { const result = envSchema.safeParse({ ...validEnv, - EMBEDDING_URL: "https://api.openrouter.ai/v1/embeddings", - }); - expect(result.success).toBe(true); + EMBEDDING_URL: 'https://api.openrouter.ai/v1/embeddings', + }) + expect(result.success).toBe(true) if (result.success) { - expect(result.data.EMBEDDING_URL).toBe( - "https://api.openrouter.ai/v1/embeddings", - ); + expect(result.data.EMBEDDING_URL).toBe('https://api.openrouter.ai/v1/embeddings') } - }); + }) - it("parses OAUTH_SESSION_TTL from string to number", () => { + it('parses OAUTH_SESSION_TTL from string to number', () => { const result = envSchema.safeParse({ ...validEnv, - OAUTH_SESSION_TTL: "86400", - }); - expect(result.success).toBe(true); + OAUTH_SESSION_TTL: '86400', + }) + expect(result.success).toBe(true) if (result.success) { - expect(result.data.OAUTH_SESSION_TTL).toBe(86400); + expect(result.data.OAUTH_SESSION_TTL).toBe(86400) } - }); + }) - it("rejects non-positive OAUTH_SESSION_TTL", () => { + it('rejects non-positive OAUTH_SESSION_TTL', () => { const result = envSchema.safeParse({ ...validEnv, - OAUTH_SESSION_TTL: "0", - }); - expect(result.success).toBe(false); - }); + OAUTH_SESSION_TTL: '0', + }) + expect(result.success).toBe(false) + }) - it("rejects non-integer OAUTH_SESSION_TTL", () => { + it('rejects non-integer OAUTH_SESSION_TTL', () => { const result = envSchema.safeParse({ ...validEnv, - OAUTH_SESSION_TTL: "3.5", - }); - expect(result.success).toBe(false); - }); + OAUTH_SESSION_TTL: '3.5', + }) + expect(result.success).toBe(false) + }) - it("parses OAUTH_ACCESS_TOKEN_TTL from string to number", () => { + it('parses OAUTH_ACCESS_TOKEN_TTL from string to number', () => { const result = envSchema.safeParse({ ...validEnv, - OAUTH_ACCESS_TOKEN_TTL: "1800", - }); - expect(result.success).toBe(true); + OAUTH_ACCESS_TOKEN_TTL: '1800', + }) + expect(result.success).toBe(true) if (result.success) { - expect(result.data.OAUTH_ACCESS_TOKEN_TTL).toBe(1800); + expect(result.data.OAUTH_ACCESS_TOKEN_TTL).toBe(1800) } - }); + }) - it("rejects non-positive OAUTH_ACCESS_TOKEN_TTL", () => { + it('rejects non-positive OAUTH_ACCESS_TOKEN_TTL', () => { const result = envSchema.safeParse({ ...validEnv, - OAUTH_ACCESS_TOKEN_TTL: "-1", - }); - expect(result.success).toBe(false); - }); -}); - -describe("parseEnv", () => { - it("throws on invalid environment", () => { - expect(() => parseEnv({})).toThrow(); - }); -}); + OAUTH_ACCESS_TOKEN_TTL: '-1', + }) + expect(result.success).toBe(false) + }) +}) + +describe('parseEnv', () => { + it('throws on invalid environment', () => { + expect(() => parseEnv({})).toThrow() + }) +}) diff --git a/tests/unit/db/schema/account-filters.test.ts b/tests/unit/db/schema/account-filters.test.ts index 172e9a3..6818e41 100644 --- a/tests/unit/db/schema/account-filters.test.ts +++ b/tests/unit/db/schema/account-filters.test.ts @@ -1,85 +1,85 @@ -import { describe, it, expect } from "vitest"; -import { accountFilters } from "../../../../src/db/schema/account-filters.js"; -import { getTableName, getTableColumns } from "drizzle-orm"; -import { getTableConfig } from "drizzle-orm/pg-core"; +import { describe, it, expect } from 'vitest' +import { accountFilters } from '../../../../src/db/schema/account-filters.js' +import { getTableName, getTableColumns } from 'drizzle-orm' +import { getTableConfig } from 'drizzle-orm/pg-core' -describe("account-filters schema", () => { - it("should have the correct table name", () => { - expect(getTableName(accountFilters)).toBe("account_filters"); - }); +describe('account-filters schema', () => { + it('should have the correct table name', () => { + expect(getTableName(accountFilters)).toBe('account_filters') + }) - it("should have all required columns", () => { - const columns = getTableColumns(accountFilters); - const columnNames = Object.keys(columns); + it('should have all required columns', () => { + const columns = getTableColumns(accountFilters) + const columnNames = Object.keys(columns) - expect(columnNames).toContain("id"); - expect(columnNames).toContain("did"); - expect(columnNames).toContain("communityDid"); - expect(columnNames).toContain("status"); - expect(columnNames).toContain("reason"); - expect(columnNames).toContain("reportCount"); - expect(columnNames).toContain("banCount"); - expect(columnNames).toContain("lastReviewedAt"); - expect(columnNames).toContain("filteredBy"); - expect(columnNames).toContain("createdAt"); - expect(columnNames).toContain("updatedAt"); - }); + expect(columnNames).toContain('id') + expect(columnNames).toContain('did') + expect(columnNames).toContain('communityDid') + expect(columnNames).toContain('status') + expect(columnNames).toContain('reason') + expect(columnNames).toContain('reportCount') + expect(columnNames).toContain('banCount') + expect(columnNames).toContain('lastReviewedAt') + expect(columnNames).toContain('filteredBy') + expect(columnNames).toContain('createdAt') + expect(columnNames).toContain('updatedAt') + }) - it("should have id as primary key (serial)", () => { - const columns = getTableColumns(accountFilters); - expect(columns.id.primary).toBe(true); - }); + it('should have id as primary key (serial)', () => { + const columns = getTableColumns(accountFilters) + expect(columns.id.primary).toBe(true) + }) - it("should mark required columns as not null", () => { - const columns = getTableColumns(accountFilters); - expect(columns.did.notNull).toBe(true); - expect(columns.communityDid.notNull).toBe(true); - expect(columns.status.notNull).toBe(true); - expect(columns.reportCount.notNull).toBe(true); - expect(columns.banCount.notNull).toBe(true); - expect(columns.createdAt.notNull).toBe(true); - expect(columns.updatedAt.notNull).toBe(true); - }); + it('should mark required columns as not null', () => { + const columns = getTableColumns(accountFilters) + expect(columns.did.notNull).toBe(true) + expect(columns.communityDid.notNull).toBe(true) + expect(columns.status.notNull).toBe(true) + expect(columns.reportCount.notNull).toBe(true) + expect(columns.banCount.notNull).toBe(true) + expect(columns.createdAt.notNull).toBe(true) + expect(columns.updatedAt.notNull).toBe(true) + }) - it("should allow nullable optional fields", () => { - const columns = getTableColumns(accountFilters); - expect(columns.reason.notNull).toBe(false); - expect(columns.lastReviewedAt.notNull).toBe(false); - expect(columns.filteredBy.notNull).toBe(false); - }); + it('should allow nullable optional fields', () => { + const columns = getTableColumns(accountFilters) + expect(columns.reason.notNull).toBe(false) + expect(columns.lastReviewedAt.notNull).toBe(false) + expect(columns.filteredBy.notNull).toBe(false) + }) - it("should have status enum values of active, warned, filtered", () => { - const columns = getTableColumns(accountFilters); - expect(columns.status.enumValues).toEqual(["active", "warned", "filtered"]); - }); + it('should have status enum values of active, warned, filtered', () => { + const columns = getTableColumns(accountFilters) + expect(columns.status.enumValues).toEqual(['active', 'warned', 'filtered']) + }) - it("should default status to active", () => { - const columns = getTableColumns(accountFilters); - expect(columns.status.default).toBeDefined(); - }); + it('should default status to active', () => { + const columns = getTableColumns(accountFilters) + expect(columns.status.default).toBeDefined() + }) - it("should default reportCount to 0", () => { - const columns = getTableColumns(accountFilters); - expect(columns.reportCount.default).toBeDefined(); - }); + it('should default reportCount to 0', () => { + const columns = getTableColumns(accountFilters) + expect(columns.reportCount.default).toBeDefined() + }) - it("should default banCount to 0", () => { - const columns = getTableColumns(accountFilters); - expect(columns.banCount.default).toBeDefined(); - }); + it('should default banCount to 0', () => { + const columns = getTableColumns(accountFilters) + expect(columns.banCount.default).toBeDefined() + }) - it("should have a unique index on (did, communityDid)", () => { - const config = getTableConfig(accountFilters); + it('should have a unique index on (did, communityDid)', () => { + const config = getTableConfig(accountFilters) const uniqueIdx = config.indexes.find( - (idx) => idx.config.name === "account_filters_did_community_idx", - ); - expect(uniqueIdx).toBeDefined(); - expect(uniqueIdx?.config.unique).toBe(true); - }); + (idx) => idx.config.name === 'account_filters_did_community_idx' + ) + expect(uniqueIdx).toBeDefined() + expect(uniqueIdx?.config.unique).toBe(true) + }) - it("should have default timestamps for createdAt and updatedAt", () => { - const columns = getTableColumns(accountFilters); - expect(columns.createdAt.default).toBeDefined(); - expect(columns.updatedAt.default).toBeDefined(); - }); -}); + it('should have default timestamps for createdAt and updatedAt', () => { + const columns = getTableColumns(accountFilters) + expect(columns.createdAt.default).toBeDefined() + expect(columns.updatedAt.default).toBeDefined() + }) +}) diff --git a/tests/unit/db/schema/categories.test.ts b/tests/unit/db/schema/categories.test.ts index 15d54e7..2f087fc 100644 --- a/tests/unit/db/schema/categories.test.ts +++ b/tests/unit/db/schema/categories.test.ts @@ -1,64 +1,64 @@ -import { describe, it, expect } from "vitest"; -import { getTableName, getTableColumns } from "drizzle-orm"; -import { categories } from "../../../../src/db/schema/categories.js"; +import { describe, it, expect } from 'vitest' +import { getTableName, getTableColumns } from 'drizzle-orm' +import { categories } from '../../../../src/db/schema/categories.js' -describe("categories schema", () => { - const columns = getTableColumns(categories); +describe('categories schema', () => { + const columns = getTableColumns(categories) - it("has the correct table name", () => { - expect(getTableName(categories)).toBe("categories"); - }); + it('has the correct table name', () => { + expect(getTableName(categories)).toBe('categories') + }) - it("uses id as primary key", () => { - expect(columns.id.primary).toBe(true); - }); + it('uses id as primary key', () => { + expect(columns.id.primary).toBe(true) + }) - it("has all required columns", () => { - const columnNames = Object.keys(columns); + it('has all required columns', () => { + const columnNames = Object.keys(columns) const expected = [ - "id", - "slug", - "name", - "description", - "parentId", - "sortOrder", - "communityDid", - "maturityRating", - "createdAt", - "updatedAt", - ]; + 'id', + 'slug', + 'name', + 'description', + 'parentId', + 'sortOrder', + 'communityDid', + 'maturityRating', + 'createdAt', + 'updatedAt', + ] for (const col of expected) { - expect(columnNames).toContain(col); + expect(columnNames).toContain(col) } - }); + }) - it("has non-nullable required columns", () => { - expect(columns.id.notNull).toBe(true); - expect(columns.slug.notNull).toBe(true); - expect(columns.name.notNull).toBe(true); - expect(columns.communityDid.notNull).toBe(true); - expect(columns.maturityRating.notNull).toBe(true); - expect(columns.createdAt.notNull).toBe(true); - expect(columns.updatedAt.notNull).toBe(true); - }); + it('has non-nullable required columns', () => { + expect(columns.id.notNull).toBe(true) + expect(columns.slug.notNull).toBe(true) + expect(columns.name.notNull).toBe(true) + expect(columns.communityDid.notNull).toBe(true) + expect(columns.maturityRating.notNull).toBe(true) + expect(columns.createdAt.notNull).toBe(true) + expect(columns.updatedAt.notNull).toBe(true) + }) - it("has nullable optional columns", () => { - expect(columns.description.notNull).toBe(false); - expect(columns.parentId.notNull).toBe(false); - }); + it('has nullable optional columns', () => { + expect(columns.description.notNull).toBe(false) + expect(columns.parentId.notNull).toBe(false) + }) - it("has default value for sortOrder", () => { - expect(columns.sortOrder.hasDefault).toBe(true); - }); + it('has default value for sortOrder', () => { + expect(columns.sortOrder.hasDefault).toBe(true) + }) - it("has default value for maturityRating", () => { - expect(columns.maturityRating.hasDefault).toBe(true); - }); + it('has default value for maturityRating', () => { + expect(columns.maturityRating.hasDefault).toBe(true) + }) - it("has default values for timestamps", () => { - expect(columns.createdAt.hasDefault).toBe(true); - expect(columns.updatedAt.hasDefault).toBe(true); - }); -}); + it('has default values for timestamps', () => { + expect(columns.createdAt.hasDefault).toBe(true) + expect(columns.updatedAt.hasDefault).toBe(true) + }) +}) diff --git a/tests/unit/db/schema/community-filters.test.ts b/tests/unit/db/schema/community-filters.test.ts index 2c8da23..8a2518a 100644 --- a/tests/unit/db/schema/community-filters.test.ts +++ b/tests/unit/db/schema/community-filters.test.ts @@ -1,67 +1,67 @@ -import { describe, it, expect } from "vitest"; -import { communityFilters } from "../../../../src/db/schema/community-filters.js"; -import { getTableName, getTableColumns } from "drizzle-orm"; +import { describe, it, expect } from 'vitest' +import { communityFilters } from '../../../../src/db/schema/community-filters.js' +import { getTableName, getTableColumns } from 'drizzle-orm' -describe("community-filters schema", () => { - it("should have the correct table name", () => { - expect(getTableName(communityFilters)).toBe("community_filters"); - }); +describe('community-filters schema', () => { + it('should have the correct table name', () => { + expect(getTableName(communityFilters)).toBe('community_filters') + }) - it("should have all required columns", () => { - const columns = getTableColumns(communityFilters); - const columnNames = Object.keys(columns); + it('should have all required columns', () => { + const columns = getTableColumns(communityFilters) + const columnNames = Object.keys(columns) - expect(columnNames).toContain("communityDid"); - expect(columnNames).toContain("status"); - expect(columnNames).toContain("adminDid"); - expect(columnNames).toContain("reason"); - expect(columnNames).toContain("reportCount"); - expect(columnNames).toContain("lastReviewedAt"); - expect(columnNames).toContain("filteredBy"); - expect(columnNames).toContain("createdAt"); - expect(columnNames).toContain("updatedAt"); - }); + expect(columnNames).toContain('communityDid') + expect(columnNames).toContain('status') + expect(columnNames).toContain('adminDid') + expect(columnNames).toContain('reason') + expect(columnNames).toContain('reportCount') + expect(columnNames).toContain('lastReviewedAt') + expect(columnNames).toContain('filteredBy') + expect(columnNames).toContain('createdAt') + expect(columnNames).toContain('updatedAt') + }) - it("should have communityDid as primary key", () => { - const columns = getTableColumns(communityFilters); - expect(columns.communityDid.primary).toBe(true); - }); + it('should have communityDid as primary key', () => { + const columns = getTableColumns(communityFilters) + expect(columns.communityDid.primary).toBe(true) + }) - it("should mark required columns as not null", () => { - const columns = getTableColumns(communityFilters); - expect(columns.communityDid.notNull).toBe(true); - expect(columns.status.notNull).toBe(true); - expect(columns.reportCount.notNull).toBe(true); - expect(columns.createdAt.notNull).toBe(true); - expect(columns.updatedAt.notNull).toBe(true); - }); + it('should mark required columns as not null', () => { + const columns = getTableColumns(communityFilters) + expect(columns.communityDid.notNull).toBe(true) + expect(columns.status.notNull).toBe(true) + expect(columns.reportCount.notNull).toBe(true) + expect(columns.createdAt.notNull).toBe(true) + expect(columns.updatedAt.notNull).toBe(true) + }) - it("should allow nullable optional fields", () => { - const columns = getTableColumns(communityFilters); - expect(columns.adminDid.notNull).toBe(false); - expect(columns.reason.notNull).toBe(false); - expect(columns.lastReviewedAt.notNull).toBe(false); - expect(columns.filteredBy.notNull).toBe(false); - }); + it('should allow nullable optional fields', () => { + const columns = getTableColumns(communityFilters) + expect(columns.adminDid.notNull).toBe(false) + expect(columns.reason.notNull).toBe(false) + expect(columns.lastReviewedAt.notNull).toBe(false) + expect(columns.filteredBy.notNull).toBe(false) + }) - it("should have status enum values of active, warned, filtered", () => { - const columns = getTableColumns(communityFilters); - expect(columns.status.enumValues).toEqual(["active", "warned", "filtered"]); - }); + it('should have status enum values of active, warned, filtered', () => { + const columns = getTableColumns(communityFilters) + expect(columns.status.enumValues).toEqual(['active', 'warned', 'filtered']) + }) - it("should default status to active", () => { - const columns = getTableColumns(communityFilters); - expect(columns.status.default).toBeDefined(); - }); + it('should default status to active', () => { + const columns = getTableColumns(communityFilters) + expect(columns.status.default).toBeDefined() + }) - it("should default reportCount to 0", () => { - const columns = getTableColumns(communityFilters); - expect(columns.reportCount.default).toBeDefined(); - }); + it('should default reportCount to 0', () => { + const columns = getTableColumns(communityFilters) + expect(columns.reportCount.default).toBeDefined() + }) - it("should have default timestamps for createdAt and updatedAt", () => { - const columns = getTableColumns(communityFilters); - expect(columns.createdAt.default).toBeDefined(); - expect(columns.updatedAt.default).toBeDefined(); - }); -}); + it('should have default timestamps for createdAt and updatedAt', () => { + const columns = getTableColumns(communityFilters) + expect(columns.createdAt.default).toBeDefined() + expect(columns.updatedAt.default).toBeDefined() + }) +}) diff --git a/tests/unit/db/schema/community-settings.test.ts b/tests/unit/db/schema/community-settings.test.ts index 411b0e0..e94b021 100644 --- a/tests/unit/db/schema/community-settings.test.ts +++ b/tests/unit/db/schema/community-settings.test.ts @@ -1,98 +1,98 @@ -import { describe, it, expect } from "vitest"; -import { getTableName, getTableColumns } from "drizzle-orm"; -import { communitySettings } from "../../../../src/db/schema/community-settings.js"; +import { describe, it, expect } from 'vitest' +import { getTableName, getTableColumns } from 'drizzle-orm' +import { communitySettings } from '../../../../src/db/schema/community-settings.js' -describe("communitySettings schema", () => { - const columns = getTableColumns(communitySettings); +describe('communitySettings schema', () => { + const columns = getTableColumns(communitySettings) - it("has the correct table name", () => { - expect(getTableName(communitySettings)).toBe("community_settings"); - }); + it('has the correct table name', () => { + expect(getTableName(communitySettings)).toBe('community_settings') + }) - it("uses id as primary key", () => { - expect(columns.id.primary).toBe(true); - }); + it('uses id as primary key', () => { + expect(columns.id.primary).toBe(true) + }) - it("has all required columns", () => { - const columnNames = Object.keys(columns); + it('has all required columns', () => { + const columnNames = Object.keys(columns) const expected = [ - "id", - "initialized", - "communityDid", - "adminDid", - "communityName", - "maturityRating", - "reactionSet", - "handle", - "serviceEndpoint", - "signingKey", - "rotationKey", - "createdAt", - "updatedAt", - ]; + 'id', + 'initialized', + 'communityDid', + 'adminDid', + 'communityName', + 'maturityRating', + 'reactionSet', + 'handle', + 'serviceEndpoint', + 'signingKey', + 'rotationKey', + 'createdAt', + 'updatedAt', + ] for (const col of expected) { - expect(columnNames).toContain(col); + expect(columnNames).toContain(col) } - }); - - it("has default value for id", () => { - expect(columns.id.hasDefault).toBe(true); - }); - - it("has default value for initialized (false)", () => { - expect(columns.initialized.hasDefault).toBe(true); - }); - - it("has nullable communityDid", () => { - expect(columns.communityDid.notNull).toBe(false); - }); - - it("has nullable adminDid", () => { - expect(columns.adminDid.notNull).toBe(false); - }); - - it("has nullable handle", () => { - expect(columns.handle.notNull).toBe(false); - }); - - it("has nullable serviceEndpoint", () => { - expect(columns.serviceEndpoint.notNull).toBe(false); - }); - - it("has nullable signingKey", () => { - expect(columns.signingKey.notNull).toBe(false); - }); - - it("has nullable rotationKey", () => { - expect(columns.rotationKey.notNull).toBe(false); - }); - - it("has default value for communityName", () => { - expect(columns.communityName.hasDefault).toBe(true); - }); - - it("has default values for timestamps", () => { - expect(columns.createdAt.hasDefault).toBe(true); - expect(columns.updatedAt.hasDefault).toBe(true); - }); - - it("has non-nullable required columns", () => { - expect(columns.id.notNull).toBe(true); - expect(columns.initialized.notNull).toBe(true); - expect(columns.communityName.notNull).toBe(true); - expect(columns.maturityRating.notNull).toBe(true); - expect(columns.reactionSet.notNull).toBe(true); - expect(columns.createdAt.notNull).toBe(true); - expect(columns.updatedAt.notNull).toBe(true); - }); - - it("has default value for maturityRating", () => { - expect(columns.maturityRating.hasDefault).toBe(true); - }); - - it("has default value for reactionSet", () => { - expect(columns.reactionSet.hasDefault).toBe(true); - }); -}); + }) + + it('has default value for id', () => { + expect(columns.id.hasDefault).toBe(true) + }) + + it('has default value for initialized (false)', () => { + expect(columns.initialized.hasDefault).toBe(true) + }) + + it('has nullable communityDid', () => { + expect(columns.communityDid.notNull).toBe(false) + }) + + it('has nullable adminDid', () => { + expect(columns.adminDid.notNull).toBe(false) + }) + + it('has nullable handle', () => { + expect(columns.handle.notNull).toBe(false) + }) + + it('has nullable serviceEndpoint', () => { + expect(columns.serviceEndpoint.notNull).toBe(false) + }) + + it('has nullable signingKey', () => { + expect(columns.signingKey.notNull).toBe(false) + }) + + it('has nullable rotationKey', () => { + expect(columns.rotationKey.notNull).toBe(false) + }) + + it('has default value for communityName', () => { + expect(columns.communityName.hasDefault).toBe(true) + }) + + it('has default values for timestamps', () => { + expect(columns.createdAt.hasDefault).toBe(true) + expect(columns.updatedAt.hasDefault).toBe(true) + }) + + it('has non-nullable required columns', () => { + expect(columns.id.notNull).toBe(true) + expect(columns.initialized.notNull).toBe(true) + expect(columns.communityName.notNull).toBe(true) + expect(columns.maturityRating.notNull).toBe(true) + expect(columns.reactionSet.notNull).toBe(true) + expect(columns.createdAt.notNull).toBe(true) + expect(columns.updatedAt.notNull).toBe(true) + }) + + it('has default value for maturityRating', () => { + expect(columns.maturityRating.hasDefault).toBe(true) + }) + + it('has default value for reactionSet', () => { + expect(columns.reactionSet.hasDefault).toBe(true) + }) +}) diff --git a/tests/unit/db/schema/cross-posts.test.ts b/tests/unit/db/schema/cross-posts.test.ts index c93ff88..91a5fa4 100644 --- a/tests/unit/db/schema/cross-posts.test.ts +++ b/tests/unit/db/schema/cross-posts.test.ts @@ -1,52 +1,52 @@ -import { describe, it, expect } from "vitest"; -import { crossPosts } from "../../../../src/db/schema/cross-posts.js"; -import { getTableName, getTableColumns } from "drizzle-orm"; - -describe("cross-posts schema", () => { - it("should have the correct table name", () => { - expect(getTableName(crossPosts)).toBe("cross_posts"); - }); - - it("should have all required columns", () => { - const columns = getTableColumns(crossPosts); - const columnNames = Object.keys(columns); - - expect(columnNames).toContain("id"); - expect(columnNames).toContain("topicUri"); - expect(columnNames).toContain("service"); - expect(columnNames).toContain("crossPostUri"); - expect(columnNames).toContain("crossPostCid"); - expect(columnNames).toContain("authorDid"); - expect(columnNames).toContain("createdAt"); - }); - - it("should have id as primary key", () => { - const columns = getTableColumns(crossPosts); - expect(columns.id.primary).toBe(true); - }); - - it("should mark required columns as not null", () => { - const columns = getTableColumns(crossPosts); - expect(columns.topicUri.notNull).toBe(true); - expect(columns.service.notNull).toBe(true); - expect(columns.crossPostUri.notNull).toBe(true); - expect(columns.crossPostCid.notNull).toBe(true); - expect(columns.authorDid.notNull).toBe(true); - expect(columns.createdAt.notNull).toBe(true); - }); - - it("should have exactly 7 columns", () => { - const columns = getTableColumns(crossPosts); - expect(Object.keys(columns)).toHaveLength(7); - }); - - it("should have a default value for id", () => { - const columns = getTableColumns(crossPosts); - expect(columns.id.hasDefault).toBe(true); - }); - - it("should have a default value for createdAt", () => { - const columns = getTableColumns(crossPosts); - expect(columns.createdAt.hasDefault).toBe(true); - }); -}); +import { describe, it, expect } from 'vitest' +import { crossPosts } from '../../../../src/db/schema/cross-posts.js' +import { getTableName, getTableColumns } from 'drizzle-orm' + +describe('cross-posts schema', () => { + it('should have the correct table name', () => { + expect(getTableName(crossPosts)).toBe('cross_posts') + }) + + it('should have all required columns', () => { + const columns = getTableColumns(crossPosts) + const columnNames = Object.keys(columns) + + expect(columnNames).toContain('id') + expect(columnNames).toContain('topicUri') + expect(columnNames).toContain('service') + expect(columnNames).toContain('crossPostUri') + expect(columnNames).toContain('crossPostCid') + expect(columnNames).toContain('authorDid') + expect(columnNames).toContain('createdAt') + }) + + it('should have id as primary key', () => { + const columns = getTableColumns(crossPosts) + expect(columns.id.primary).toBe(true) + }) + + it('should mark required columns as not null', () => { + const columns = getTableColumns(crossPosts) + expect(columns.topicUri.notNull).toBe(true) + expect(columns.service.notNull).toBe(true) + expect(columns.crossPostUri.notNull).toBe(true) + expect(columns.crossPostCid.notNull).toBe(true) + expect(columns.authorDid.notNull).toBe(true) + expect(columns.createdAt.notNull).toBe(true) + }) + + it('should have exactly 7 columns', () => { + const columns = getTableColumns(crossPosts) + expect(Object.keys(columns)).toHaveLength(7) + }) + + it('should have a default value for id', () => { + const columns = getTableColumns(crossPosts) + expect(columns.id.hasDefault).toBe(true) + }) + + it('should have a default value for createdAt', () => { + const columns = getTableColumns(crossPosts) + expect(columns.createdAt.hasDefault).toBe(true) + }) +}) diff --git a/tests/unit/db/schema/interaction-graph.test.ts b/tests/unit/db/schema/interaction-graph.test.ts new file mode 100644 index 0000000..d035c6b --- /dev/null +++ b/tests/unit/db/schema/interaction-graph.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest' +import { interactionGraph } from '../../../../src/db/schema/interaction-graph.js' +import { getTableName, getTableColumns } from 'drizzle-orm' +import { getTableConfig } from 'drizzle-orm/pg-core' + +describe('interaction-graph schema', () => { + it('should have the correct table name', () => { + expect(getTableName(interactionGraph)).toBe('interaction_graph') + }) + + it('should have all required columns', () => { + const columns = getTableColumns(interactionGraph) + const columnNames = Object.keys(columns) + + expect(columnNames).toContain('sourceDid') + expect(columnNames).toContain('targetDid') + expect(columnNames).toContain('communityId') + expect(columnNames).toContain('interactionType') + expect(columnNames).toContain('weight') + expect(columnNames).toContain('firstInteractionAt') + expect(columnNames).toContain('lastInteractionAt') + }) + + it('should mark all columns as not null', () => { + const columns = getTableColumns(interactionGraph) + expect(columns.sourceDid.notNull).toBe(true) + expect(columns.targetDid.notNull).toBe(true) + expect(columns.communityId.notNull).toBe(true) + expect(columns.interactionType.notNull).toBe(true) + expect(columns.weight.notNull).toBe(true) + expect(columns.firstInteractionAt.notNull).toBe(true) + expect(columns.lastInteractionAt.notNull).toBe(true) + }) + + it('should have interactionType enum values', () => { + const columns = getTableColumns(interactionGraph) + expect(columns.interactionType.enumValues).toEqual([ + 'reply', + 'reaction', + 'topic_coparticipation', + ]) + }) + + it('should default weight to 1', () => { + const columns = getTableColumns(interactionGraph) + expect(columns.weight.default).toBeDefined() + }) + + it('should have composite primary key on (sourceDid, targetDid, communityId, interactionType)', () => { + const config = getTableConfig(interactionGraph) + // Composite PK exists + expect(config.primaryKeys.length).toBeGreaterThanOrEqual(1) + const pk = config.primaryKeys[0] + expect(pk).toBeDefined() + if (pk) expect(pk.columns.length).toBe(4) + }) + + it('should have composite index on (sourceDid, targetDid, communityId)', () => { + const config = getTableConfig(interactionGraph) + const idx = config.indexes.find( + (i) => i.config.name === 'interaction_graph_source_target_community_idx' + ) + expect(idx).toBeDefined() + }) +}) diff --git a/tests/unit/db/schema/moderation-actions.test.ts b/tests/unit/db/schema/moderation-actions.test.ts index 27064b5..60dd17a 100644 --- a/tests/unit/db/schema/moderation-actions.test.ts +++ b/tests/unit/db/schema/moderation-actions.test.ts @@ -1,43 +1,43 @@ -import { describe, it, expect } from "vitest"; -import { moderationActions } from "../../../../src/db/schema/moderation-actions.js"; -import { getTableName, getTableColumns } from "drizzle-orm"; +import { describe, it, expect } from 'vitest' +import { moderationActions } from '../../../../src/db/schema/moderation-actions.js' +import { getTableName, getTableColumns } from 'drizzle-orm' -describe("moderationActions schema", () => { - it("should have the correct table name", () => { - expect(getTableName(moderationActions)).toBe("moderation_actions"); - }); +describe('moderationActions schema', () => { + it('should have the correct table name', () => { + expect(getTableName(moderationActions)).toBe('moderation_actions') + }) - it("should have all required columns", () => { - const columns = getTableColumns(moderationActions); - const columnNames = Object.keys(columns); + it('should have all required columns', () => { + const columns = getTableColumns(moderationActions) + const columnNames = Object.keys(columns) - expect(columnNames).toContain("id"); - expect(columnNames).toContain("action"); - expect(columnNames).toContain("targetUri"); - expect(columnNames).toContain("targetDid"); - expect(columnNames).toContain("moderatorDid"); - expect(columnNames).toContain("communityDid"); - expect(columnNames).toContain("reason"); - expect(columnNames).toContain("createdAt"); - }); + expect(columnNames).toContain('id') + expect(columnNames).toContain('action') + expect(columnNames).toContain('targetUri') + expect(columnNames).toContain('targetDid') + expect(columnNames).toContain('moderatorDid') + expect(columnNames).toContain('communityDid') + expect(columnNames).toContain('reason') + expect(columnNames).toContain('createdAt') + }) - it("should have id as primary key", () => { - const columns = getTableColumns(moderationActions); - expect(columns.id.primary).toBe(true); - }); + it('should have id as primary key', () => { + const columns = getTableColumns(moderationActions) + expect(columns.id.primary).toBe(true) + }) - it("should mark required columns as not null", () => { - const columns = getTableColumns(moderationActions); - expect(columns.action.notNull).toBe(true); - expect(columns.moderatorDid.notNull).toBe(true); - expect(columns.communityDid.notNull).toBe(true); - expect(columns.createdAt.notNull).toBe(true); - }); + it('should mark required columns as not null', () => { + const columns = getTableColumns(moderationActions) + expect(columns.action.notNull).toBe(true) + expect(columns.moderatorDid.notNull).toBe(true) + expect(columns.communityDid.notNull).toBe(true) + expect(columns.createdAt.notNull).toBe(true) + }) - it("should allow nullable reason and target fields", () => { - const columns = getTableColumns(moderationActions); - expect(columns.reason.notNull).toBe(false); - expect(columns.targetUri.notNull).toBe(false); - expect(columns.targetDid.notNull).toBe(false); - }); -}); + it('should allow nullable reason and target fields', () => { + const columns = getTableColumns(moderationActions) + expect(columns.reason.notNull).toBe(false) + expect(columns.targetUri.notNull).toBe(false) + expect(columns.targetDid.notNull).toBe(false) + }) +}) diff --git a/tests/unit/db/schema/notifications.test.ts b/tests/unit/db/schema/notifications.test.ts index f00d736..ac00d5c 100644 --- a/tests/unit/db/schema/notifications.test.ts +++ b/tests/unit/db/schema/notifications.test.ts @@ -1,54 +1,54 @@ -import { describe, it, expect } from "vitest"; -import { notifications } from "../../../../src/db/schema/notifications.js"; -import { getTableName, getTableColumns } from "drizzle-orm"; - -describe("notifications schema", () => { - it("should have the correct table name", () => { - expect(getTableName(notifications)).toBe("notifications"); - }); - - it("should have all required columns", () => { - const columns = getTableColumns(notifications); - const columnNames = Object.keys(columns); - - expect(columnNames).toContain("id"); - expect(columnNames).toContain("recipientDid"); - expect(columnNames).toContain("type"); - expect(columnNames).toContain("subjectUri"); - expect(columnNames).toContain("actorDid"); - expect(columnNames).toContain("communityDid"); - expect(columnNames).toContain("read"); - expect(columnNames).toContain("createdAt"); - }); - - it("should have id as primary key", () => { - const columns = getTableColumns(notifications); - expect(columns.id.primary).toBe(true); - }); - - it("should mark required columns as not null", () => { - const columns = getTableColumns(notifications); - expect(columns.recipientDid.notNull).toBe(true); - expect(columns.type.notNull).toBe(true); - expect(columns.subjectUri.notNull).toBe(true); - expect(columns.actorDid.notNull).toBe(true); - expect(columns.communityDid.notNull).toBe(true); - expect(columns.read.notNull).toBe(true); - expect(columns.createdAt.notNull).toBe(true); - }); - - it("should have exactly 8 columns", () => { - const columns = getTableColumns(notifications); - expect(Object.keys(columns)).toHaveLength(8); - }); - - it("should have a default value for read (false)", () => { - const columns = getTableColumns(notifications); - expect(columns.read.hasDefault).toBe(true); - }); - - it("should have a default value for createdAt", () => { - const columns = getTableColumns(notifications); - expect(columns.createdAt.hasDefault).toBe(true); - }); -}); +import { describe, it, expect } from 'vitest' +import { notifications } from '../../../../src/db/schema/notifications.js' +import { getTableName, getTableColumns } from 'drizzle-orm' + +describe('notifications schema', () => { + it('should have the correct table name', () => { + expect(getTableName(notifications)).toBe('notifications') + }) + + it('should have all required columns', () => { + const columns = getTableColumns(notifications) + const columnNames = Object.keys(columns) + + expect(columnNames).toContain('id') + expect(columnNames).toContain('recipientDid') + expect(columnNames).toContain('type') + expect(columnNames).toContain('subjectUri') + expect(columnNames).toContain('actorDid') + expect(columnNames).toContain('communityDid') + expect(columnNames).toContain('read') + expect(columnNames).toContain('createdAt') + }) + + it('should have id as primary key', () => { + const columns = getTableColumns(notifications) + expect(columns.id.primary).toBe(true) + }) + + it('should mark required columns as not null', () => { + const columns = getTableColumns(notifications) + expect(columns.recipientDid.notNull).toBe(true) + expect(columns.type.notNull).toBe(true) + expect(columns.subjectUri.notNull).toBe(true) + expect(columns.actorDid.notNull).toBe(true) + expect(columns.communityDid.notNull).toBe(true) + expect(columns.read.notNull).toBe(true) + expect(columns.createdAt.notNull).toBe(true) + }) + + it('should have exactly 8 columns', () => { + const columns = getTableColumns(notifications) + expect(Object.keys(columns)).toHaveLength(8) + }) + + it('should have a default value for read (false)', () => { + const columns = getTableColumns(notifications) + expect(columns.read.hasDefault).toBe(true) + }) + + it('should have a default value for createdAt', () => { + const columns = getTableColumns(notifications) + expect(columns.createdAt.hasDefault).toBe(true) + }) +}) diff --git a/tests/unit/db/schema/ozone-labels.test.ts b/tests/unit/db/schema/ozone-labels.test.ts index 23069ac..1ac5da1 100644 --- a/tests/unit/db/schema/ozone-labels.test.ts +++ b/tests/unit/db/schema/ozone-labels.test.ts @@ -1,63 +1,63 @@ -import { describe, it, expect } from "vitest"; -import { ozoneLabels } from "../../../../src/db/schema/ozone-labels.js"; -import { getTableName, getTableColumns } from "drizzle-orm"; -import { getTableConfig } from "drizzle-orm/pg-core"; - -describe("ozone-labels schema", () => { - it("should have the correct table name", () => { - expect(getTableName(ozoneLabels)).toBe("ozone_labels"); - }); - - it("should have all required columns", () => { - const columns = getTableColumns(ozoneLabels); - const columnNames = Object.keys(columns); - - expect(columnNames).toContain("id"); - expect(columnNames).toContain("src"); - expect(columnNames).toContain("uri"); - expect(columnNames).toContain("val"); - expect(columnNames).toContain("neg"); - expect(columnNames).toContain("cts"); - expect(columnNames).toContain("exp"); - expect(columnNames).toContain("indexedAt"); - }); - - it("should have id as primary key (serial)", () => { - const columns = getTableColumns(ozoneLabels); - expect(columns.id.primary).toBe(true); - }); - - it("should mark required columns as not null", () => { - const columns = getTableColumns(ozoneLabels); - expect(columns.src.notNull).toBe(true); - expect(columns.uri.notNull).toBe(true); - expect(columns.val.notNull).toBe(true); - expect(columns.neg.notNull).toBe(true); - expect(columns.cts.notNull).toBe(true); - expect(columns.indexedAt.notNull).toBe(true); - }); - - it("should allow nullable optional fields", () => { - const columns = getTableColumns(ozoneLabels); - expect(columns.exp.notNull).toBe(false); - }); - - it("should default neg to false", () => { - const columns = getTableColumns(ozoneLabels); - expect(columns.neg.default).toBeDefined(); - }); - - it("should have a unique index on (src, uri, val)", () => { - const config = getTableConfig(ozoneLabels); +import { describe, it, expect } from 'vitest' +import { ozoneLabels } from '../../../../src/db/schema/ozone-labels.js' +import { getTableName, getTableColumns } from 'drizzle-orm' +import { getTableConfig } from 'drizzle-orm/pg-core' + +describe('ozone-labels schema', () => { + it('should have the correct table name', () => { + expect(getTableName(ozoneLabels)).toBe('ozone_labels') + }) + + it('should have all required columns', () => { + const columns = getTableColumns(ozoneLabels) + const columnNames = Object.keys(columns) + + expect(columnNames).toContain('id') + expect(columnNames).toContain('src') + expect(columnNames).toContain('uri') + expect(columnNames).toContain('val') + expect(columnNames).toContain('neg') + expect(columnNames).toContain('cts') + expect(columnNames).toContain('exp') + expect(columnNames).toContain('indexedAt') + }) + + it('should have id as primary key (serial)', () => { + const columns = getTableColumns(ozoneLabels) + expect(columns.id.primary).toBe(true) + }) + + it('should mark required columns as not null', () => { + const columns = getTableColumns(ozoneLabels) + expect(columns.src.notNull).toBe(true) + expect(columns.uri.notNull).toBe(true) + expect(columns.val.notNull).toBe(true) + expect(columns.neg.notNull).toBe(true) + expect(columns.cts.notNull).toBe(true) + expect(columns.indexedAt.notNull).toBe(true) + }) + + it('should allow nullable optional fields', () => { + const columns = getTableColumns(ozoneLabels) + expect(columns.exp.notNull).toBe(false) + }) + + it('should default neg to false', () => { + const columns = getTableColumns(ozoneLabels) + expect(columns.neg.default).toBeDefined() + }) + + it('should have a unique index on (src, uri, val)', () => { + const config = getTableConfig(ozoneLabels) const uniqueIdx = config.indexes.find( - (idx) => idx.config.name === "ozone_labels_src_uri_val_idx", - ); - expect(uniqueIdx).toBeDefined(); - expect(uniqueIdx?.config.unique).toBe(true); - }); - - it("should have default timestamp for indexedAt", () => { - const columns = getTableColumns(ozoneLabels); - expect(columns.indexedAt.default).toBeDefined(); - }); -}); + (idx) => idx.config.name === 'ozone_labels_src_uri_val_idx' + ) + expect(uniqueIdx).toBeDefined() + expect(uniqueIdx?.config.unique).toBe(true) + }) + + it('should have default timestamp for indexedAt', () => { + const columns = getTableColumns(ozoneLabels) + expect(columns.indexedAt.default).toBeDefined() + }) +}) diff --git a/tests/unit/db/schema/reactions.test.ts b/tests/unit/db/schema/reactions.test.ts index fd9e6e7..67610cc 100644 --- a/tests/unit/db/schema/reactions.test.ts +++ b/tests/unit/db/schema/reactions.test.ts @@ -1,51 +1,51 @@ -import { describe, it, expect } from "vitest"; -import { getTableName, getTableColumns } from "drizzle-orm"; -import { reactions } from "../../../../src/db/schema/reactions.js"; +import { describe, it, expect } from 'vitest' +import { getTableName, getTableColumns } from 'drizzle-orm' +import { reactions } from '../../../../src/db/schema/reactions.js' -describe("reactions schema", () => { - const columns = getTableColumns(reactions); +describe('reactions schema', () => { + const columns = getTableColumns(reactions) - it("has the correct table name", () => { - expect(getTableName(reactions)).toBe("reactions"); - }); + it('has the correct table name', () => { + expect(getTableName(reactions)).toBe('reactions') + }) - it("uses uri as primary key", () => { - expect(columns.uri.primary).toBe(true); - }); + it('uses uri as primary key', () => { + expect(columns.uri.primary).toBe(true) + }) - it("has all required columns", () => { - const columnNames = Object.keys(columns); + it('has all required columns', () => { + const columnNames = Object.keys(columns) const expected = [ - "uri", - "rkey", - "authorDid", - "subjectUri", - "subjectCid", - "type", - "communityDid", - "cid", - "createdAt", - "indexedAt", - ]; + 'uri', + 'rkey', + 'authorDid', + 'subjectUri', + 'subjectCid', + 'type', + 'communityDid', + 'cid', + 'createdAt', + 'indexedAt', + ] for (const col of expected) { - expect(columnNames).toContain(col); + expect(columnNames).toContain(col) } - }); - - it("has non-nullable required columns", () => { - expect(columns.uri.notNull).toBe(true); - expect(columns.rkey.notNull).toBe(true); - expect(columns.authorDid.notNull).toBe(true); - expect(columns.subjectUri.notNull).toBe(true); - expect(columns.subjectCid.notNull).toBe(true); - expect(columns.type.notNull).toBe(true); - expect(columns.communityDid.notNull).toBe(true); - expect(columns.cid.notNull).toBe(true); - }); - - it("has default value for indexed_at", () => { - expect(columns.indexedAt.hasDefault).toBe(true); - }); -}); + }) + + it('has non-nullable required columns', () => { + expect(columns.uri.notNull).toBe(true) + expect(columns.rkey.notNull).toBe(true) + expect(columns.authorDid.notNull).toBe(true) + expect(columns.subjectUri.notNull).toBe(true) + expect(columns.subjectCid.notNull).toBe(true) + expect(columns.type.notNull).toBe(true) + expect(columns.communityDid.notNull).toBe(true) + expect(columns.cid.notNull).toBe(true) + }) + + it('has default value for indexed_at', () => { + expect(columns.indexedAt.hasDefault).toBe(true) + }) +}) diff --git a/tests/unit/db/schema/replies.test.ts b/tests/unit/db/schema/replies.test.ts index f20fc06..96ec17a 100644 --- a/tests/unit/db/schema/replies.test.ts +++ b/tests/unit/db/schema/replies.test.ts @@ -1,69 +1,69 @@ -import { describe, it, expect } from "vitest"; -import { getTableName, getTableColumns } from "drizzle-orm"; -import { replies } from "../../../../src/db/schema/replies.js"; +import { describe, it, expect } from 'vitest' +import { getTableName, getTableColumns } from 'drizzle-orm' +import { replies } from '../../../../src/db/schema/replies.js' -describe("replies schema", () => { - const columns = getTableColumns(replies); +describe('replies schema', () => { + const columns = getTableColumns(replies) - it("has the correct table name", () => { - expect(getTableName(replies)).toBe("replies"); - }); + it('has the correct table name', () => { + expect(getTableName(replies)).toBe('replies') + }) - it("uses uri as primary key", () => { - expect(columns.uri.primary).toBe(true); - }); + it('uses uri as primary key', () => { + expect(columns.uri.primary).toBe(true) + }) - it("has all required columns", () => { - const columnNames = Object.keys(columns); + it('has all required columns', () => { + const columnNames = Object.keys(columns) const expected = [ - "uri", - "rkey", - "authorDid", - "content", - "contentFormat", - "rootUri", - "rootCid", - "parentUri", - "parentCid", - "communityDid", - "cid", - "labels", - "reactionCount", - "createdAt", - "indexedAt", + 'uri', + 'rkey', + 'authorDid', + 'content', + 'contentFormat', + 'rootUri', + 'rootCid', + 'parentUri', + 'parentCid', + 'communityDid', + 'cid', + 'labels', + 'reactionCount', + 'createdAt', + 'indexedAt', // Note: search_vector (tsvector) and embedding (vector) columns exist // in the database but are managed outside Drizzle schema (migration 0010). - ]; + ] for (const col of expected) { - expect(columnNames).toContain(col); + expect(columnNames).toContain(col) } - }); + }) - it("has non-nullable required columns", () => { - expect(columns.uri.notNull).toBe(true); - expect(columns.rkey.notNull).toBe(true); - expect(columns.authorDid.notNull).toBe(true); - expect(columns.content.notNull).toBe(true); - expect(columns.rootUri.notNull).toBe(true); - expect(columns.rootCid.notNull).toBe(true); - expect(columns.parentUri.notNull).toBe(true); - expect(columns.parentCid.notNull).toBe(true); - expect(columns.communityDid.notNull).toBe(true); - expect(columns.cid.notNull).toBe(true); - }); + it('has non-nullable required columns', () => { + expect(columns.uri.notNull).toBe(true) + expect(columns.rkey.notNull).toBe(true) + expect(columns.authorDid.notNull).toBe(true) + expect(columns.content.notNull).toBe(true) + expect(columns.rootUri.notNull).toBe(true) + expect(columns.rootCid.notNull).toBe(true) + expect(columns.parentUri.notNull).toBe(true) + expect(columns.parentCid.notNull).toBe(true) + expect(columns.communityDid.notNull).toBe(true) + expect(columns.cid.notNull).toBe(true) + }) - it("has nullable optional columns", () => { - expect(columns.contentFormat.notNull).toBe(false); - expect(columns.labels.notNull).toBe(false); - }); + it('has nullable optional columns', () => { + expect(columns.contentFormat.notNull).toBe(false) + expect(columns.labels.notNull).toBe(false) + }) - it("has default value for reaction count", () => { - expect(columns.reactionCount.hasDefault).toBe(true); - }); + it('has default value for reaction count', () => { + expect(columns.reactionCount.hasDefault).toBe(true) + }) - it("has default value for indexed_at", () => { - expect(columns.indexedAt.hasDefault).toBe(true); - }); -}); + it('has default value for indexed_at', () => { + expect(columns.indexedAt.hasDefault).toBe(true) + }) +}) diff --git a/tests/unit/db/schema/reports.test.ts b/tests/unit/db/schema/reports.test.ts index 2d7321d..26cb086 100644 --- a/tests/unit/db/schema/reports.test.ts +++ b/tests/unit/db/schema/reports.test.ts @@ -1,56 +1,56 @@ -import { describe, it, expect } from "vitest"; -import { reports } from "../../../../src/db/schema/reports.js"; -import { getTableName, getTableColumns } from "drizzle-orm"; +import { describe, it, expect } from 'vitest' +import { reports } from '../../../../src/db/schema/reports.js' +import { getTableName, getTableColumns } from 'drizzle-orm' -describe("reports schema", () => { - it("should have the correct table name", () => { - expect(getTableName(reports)).toBe("reports"); - }); +describe('reports schema', () => { + it('should have the correct table name', () => { + expect(getTableName(reports)).toBe('reports') + }) - it("should have all required columns", () => { - const columns = getTableColumns(reports); - const columnNames = Object.keys(columns); + it('should have all required columns', () => { + const columns = getTableColumns(reports) + const columnNames = Object.keys(columns) - expect(columnNames).toContain("id"); - expect(columnNames).toContain("reporterDid"); - expect(columnNames).toContain("targetUri"); - expect(columnNames).toContain("targetDid"); - expect(columnNames).toContain("reasonType"); - expect(columnNames).toContain("description"); - expect(columnNames).toContain("communityDid"); - expect(columnNames).toContain("status"); - expect(columnNames).toContain("resolutionType"); - expect(columnNames).toContain("resolvedBy"); - expect(columnNames).toContain("resolvedAt"); - expect(columnNames).toContain("createdAt"); - }); + expect(columnNames).toContain('id') + expect(columnNames).toContain('reporterDid') + expect(columnNames).toContain('targetUri') + expect(columnNames).toContain('targetDid') + expect(columnNames).toContain('reasonType') + expect(columnNames).toContain('description') + expect(columnNames).toContain('communityDid') + expect(columnNames).toContain('status') + expect(columnNames).toContain('resolutionType') + expect(columnNames).toContain('resolvedBy') + expect(columnNames).toContain('resolvedAt') + expect(columnNames).toContain('createdAt') + }) - it("should have id as primary key", () => { - const columns = getTableColumns(reports); - expect(columns.id.primary).toBe(true); - }); + it('should have id as primary key', () => { + const columns = getTableColumns(reports) + expect(columns.id.primary).toBe(true) + }) - it("should mark required columns as not null", () => { - const columns = getTableColumns(reports); - expect(columns.reporterDid.notNull).toBe(true); - expect(columns.targetUri.notNull).toBe(true); - expect(columns.targetDid.notNull).toBe(true); - expect(columns.reasonType.notNull).toBe(true); - expect(columns.communityDid.notNull).toBe(true); - expect(columns.status.notNull).toBe(true); - expect(columns.createdAt.notNull).toBe(true); - }); + it('should mark required columns as not null', () => { + const columns = getTableColumns(reports) + expect(columns.reporterDid.notNull).toBe(true) + expect(columns.targetUri.notNull).toBe(true) + expect(columns.targetDid.notNull).toBe(true) + expect(columns.reasonType.notNull).toBe(true) + expect(columns.communityDid.notNull).toBe(true) + expect(columns.status.notNull).toBe(true) + expect(columns.createdAt.notNull).toBe(true) + }) - it("should allow nullable resolution fields", () => { - const columns = getTableColumns(reports); - expect(columns.description.notNull).toBe(false); - expect(columns.resolutionType.notNull).toBe(false); - expect(columns.resolvedBy.notNull).toBe(false); - expect(columns.resolvedAt.notNull).toBe(false); - }); + it('should allow nullable resolution fields', () => { + const columns = getTableColumns(reports) + expect(columns.description.notNull).toBe(false) + expect(columns.resolutionType.notNull).toBe(false) + expect(columns.resolvedBy.notNull).toBe(false) + expect(columns.resolvedAt.notNull).toBe(false) + }) - it("should default status to pending", () => { - const columns = getTableColumns(reports); - expect(columns.status.default).toBeDefined(); - }); -}); + it('should default status to pending', () => { + const columns = getTableColumns(reports) + expect(columns.status.default).toBeDefined() + }) +}) diff --git a/tests/unit/db/schema/sybil-cluster-members.test.ts b/tests/unit/db/schema/sybil-cluster-members.test.ts new file mode 100644 index 0000000..3c0d463 --- /dev/null +++ b/tests/unit/db/schema/sybil-cluster-members.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { sybilClusterMembers } from '../../../../src/db/schema/sybil-cluster-members.js' +import { getTableName, getTableColumns } from 'drizzle-orm' +import { getTableConfig } from 'drizzle-orm/pg-core' + +describe('sybil-cluster-members schema', () => { + it('should have the correct table name', () => { + expect(getTableName(sybilClusterMembers)).toBe('sybil_cluster_members') + }) + + it('should have all required columns', () => { + const columns = getTableColumns(sybilClusterMembers) + const columnNames = Object.keys(columns) + + expect(columnNames).toContain('clusterId') + expect(columnNames).toContain('did') + expect(columnNames).toContain('roleInCluster') + expect(columnNames).toContain('joinedAt') + }) + + it('should mark all columns as not null', () => { + const columns = getTableColumns(sybilClusterMembers) + expect(columns.clusterId.notNull).toBe(true) + expect(columns.did.notNull).toBe(true) + expect(columns.roleInCluster.notNull).toBe(true) + expect(columns.joinedAt.notNull).toBe(true) + }) + + it('should have roleInCluster enum values', () => { + const columns = getTableColumns(sybilClusterMembers) + expect(columns.roleInCluster.enumValues).toEqual(['core', 'peripheral']) + }) + + it('should have composite primary key on (clusterId, did)', () => { + const config = getTableConfig(sybilClusterMembers) + expect(config.primaryKeys.length).toBeGreaterThanOrEqual(1) + const pk = config.primaryKeys[0] + expect(pk).toBeDefined() + if (pk) expect(pk.columns.length).toBe(2) + }) + + it('should have foreign key on clusterId referencing sybil_clusters', () => { + const config = getTableConfig(sybilClusterMembers) + expect(config.foreignKeys.length).toBeGreaterThanOrEqual(1) + }) +}) diff --git a/tests/unit/db/schema/sybil-clusters.test.ts b/tests/unit/db/schema/sybil-clusters.test.ts new file mode 100644 index 0000000..8c2084f --- /dev/null +++ b/tests/unit/db/schema/sybil-clusters.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest' +import { sybilClusters } from '../../../../src/db/schema/sybil-clusters.js' +import { getTableName, getTableColumns } from 'drizzle-orm' +import { getTableConfig } from 'drizzle-orm/pg-core' + +describe('sybil-clusters schema', () => { + it('should have the correct table name', () => { + expect(getTableName(sybilClusters)).toBe('sybil_clusters') + }) + + it('should have all required columns', () => { + const columns = getTableColumns(sybilClusters) + const columnNames = Object.keys(columns) + + expect(columnNames).toContain('id') + expect(columnNames).toContain('clusterHash') + expect(columnNames).toContain('internalEdgeCount') + expect(columnNames).toContain('externalEdgeCount') + expect(columnNames).toContain('memberCount') + expect(columnNames).toContain('status') + expect(columnNames).toContain('reviewedBy') + expect(columnNames).toContain('reviewedAt') + expect(columnNames).toContain('detectedAt') + expect(columnNames).toContain('updatedAt') + }) + + it('should have id as primary key (serial)', () => { + const columns = getTableColumns(sybilClusters) + expect(columns.id.primary).toBe(true) + }) + + it('should mark required columns as not null', () => { + const columns = getTableColumns(sybilClusters) + expect(columns.clusterHash.notNull).toBe(true) + expect(columns.internalEdgeCount.notNull).toBe(true) + expect(columns.externalEdgeCount.notNull).toBe(true) + expect(columns.memberCount.notNull).toBe(true) + expect(columns.status.notNull).toBe(true) + expect(columns.detectedAt.notNull).toBe(true) + expect(columns.updatedAt.notNull).toBe(true) + }) + + it('should allow nullable reviewedBy and reviewedAt', () => { + const columns = getTableColumns(sybilClusters) + expect(columns.reviewedBy.notNull).toBe(false) + expect(columns.reviewedAt.notNull).toBe(false) + }) + + it('should have status enum values', () => { + const columns = getTableColumns(sybilClusters) + expect(columns.status.enumValues).toEqual(['flagged', 'dismissed', 'monitoring', 'banned']) + }) + + it('should default status to flagged', () => { + const columns = getTableColumns(sybilClusters) + expect(columns.status.default).toBeDefined() + }) + + it('should have unique index on clusterHash', () => { + const config = getTableConfig(sybilClusters) + const idx = config.indexes.find((i) => i.config.name === 'sybil_clusters_hash_idx') + expect(idx).toBeDefined() + expect(idx?.config.unique).toBe(true) + }) +}) diff --git a/tests/unit/db/schema/topics.test.ts b/tests/unit/db/schema/topics.test.ts index 39bb9b4..0063661 100644 --- a/tests/unit/db/schema/topics.test.ts +++ b/tests/unit/db/schema/topics.test.ts @@ -1,85 +1,85 @@ -import { describe, it, expect } from "vitest"; -import { getTableName, getTableColumns } from "drizzle-orm"; -import { topics } from "../../../../src/db/schema/topics.js"; +import { describe, it, expect } from 'vitest' +import { getTableName, getTableColumns } from 'drizzle-orm' +import { topics } from '../../../../src/db/schema/topics.js' -describe("topics schema", () => { - const columns = getTableColumns(topics); +describe('topics schema', () => { + const columns = getTableColumns(topics) - it("has the correct table name", () => { - expect(getTableName(topics)).toBe("topics"); - }); + it('has the correct table name', () => { + expect(getTableName(topics)).toBe('topics') + }) - it("uses uri as primary key", () => { - expect(columns.uri.primary).toBe(true); - }); + it('uses uri as primary key', () => { + expect(columns.uri.primary).toBe(true) + }) - it("has all required columns", () => { - const columnNames = Object.keys(columns); + it('has all required columns', () => { + const columnNames = Object.keys(columns) const expected = [ - "uri", - "rkey", - "authorDid", - "title", - "content", - "contentFormat", - "category", - "tags", - "communityDid", - "cid", - "labels", - "replyCount", - "reactionCount", - "lastActivityAt", - "createdAt", - "indexedAt", + 'uri', + 'rkey', + 'authorDid', + 'title', + 'content', + 'contentFormat', + 'category', + 'tags', + 'communityDid', + 'cid', + 'labels', + 'replyCount', + 'reactionCount', + 'lastActivityAt', + 'createdAt', + 'indexedAt', // Note: search_vector (tsvector) and embedding (vector) columns exist // in the database but are managed outside Drizzle schema (migration 0010). - ]; + ] for (const col of expected) { - expect(columnNames).toContain(col); + expect(columnNames).toContain(col) } - }); + }) - it("has non-nullable required columns", () => { - expect(columns.uri.notNull).toBe(true); - expect(columns.rkey.notNull).toBe(true); - expect(columns.authorDid.notNull).toBe(true); - expect(columns.title.notNull).toBe(true); - expect(columns.content.notNull).toBe(true); - expect(columns.category.notNull).toBe(true); - expect(columns.communityDid.notNull).toBe(true); - expect(columns.cid.notNull).toBe(true); - }); + it('has non-nullable required columns', () => { + expect(columns.uri.notNull).toBe(true) + expect(columns.rkey.notNull).toBe(true) + expect(columns.authorDid.notNull).toBe(true) + expect(columns.title.notNull).toBe(true) + expect(columns.content.notNull).toBe(true) + expect(columns.category.notNull).toBe(true) + expect(columns.communityDid.notNull).toBe(true) + expect(columns.cid.notNull).toBe(true) + }) - it("has nullable optional columns", () => { - expect(columns.contentFormat.notNull).toBe(false); - expect(columns.tags.notNull).toBe(false); - expect(columns.labels.notNull).toBe(false); - }); + it('has nullable optional columns', () => { + expect(columns.contentFormat.notNull).toBe(false) + expect(columns.tags.notNull).toBe(false) + expect(columns.labels.notNull).toBe(false) + }) - it("has default values for counts", () => { - expect(columns.replyCount.hasDefault).toBe(true); - expect(columns.reactionCount.hasDefault).toBe(true); - }); + it('has default values for counts', () => { + expect(columns.replyCount.hasDefault).toBe(true) + expect(columns.reactionCount.hasDefault).toBe(true) + }) - it("has default values for timestamps", () => { - expect(columns.indexedAt.hasDefault).toBe(true); - }); + it('has default values for timestamps', () => { + expect(columns.indexedAt.hasDefault).toBe(true) + }) - it("has moderation flag columns with defaults", () => { - const columnNames = Object.keys(columns); - expect(columnNames).toContain("isLocked"); - expect(columnNames).toContain("isPinned"); - expect(columnNames).toContain("isModDeleted"); + it('has moderation flag columns with defaults', () => { + const columnNames = Object.keys(columns) + expect(columnNames).toContain('isLocked') + expect(columnNames).toContain('isPinned') + expect(columnNames).toContain('isModDeleted') - expect(columns.isLocked.notNull).toBe(true); - expect(columns.isPinned.notNull).toBe(true); - expect(columns.isModDeleted.notNull).toBe(true); + expect(columns.isLocked.notNull).toBe(true) + expect(columns.isPinned.notNull).toBe(true) + expect(columns.isModDeleted.notNull).toBe(true) - expect(columns.isLocked.hasDefault).toBe(true); - expect(columns.isPinned.hasDefault).toBe(true); - expect(columns.isModDeleted.hasDefault).toBe(true); - }); -}); + expect(columns.isLocked.hasDefault).toBe(true) + expect(columns.isPinned.hasDefault).toBe(true) + expect(columns.isModDeleted.hasDefault).toBe(true) + }) +}) diff --git a/tests/unit/db/schema/tracked-repos.test.ts b/tests/unit/db/schema/tracked-repos.test.ts index 5c454a7..636c730 100644 --- a/tests/unit/db/schema/tracked-repos.test.ts +++ b/tests/unit/db/schema/tracked-repos.test.ts @@ -1,26 +1,26 @@ -import { describe, it, expect } from "vitest"; -import { getTableName, getTableColumns } from "drizzle-orm"; -import { trackedRepos } from "../../../../src/db/schema/tracked-repos.js"; +import { describe, it, expect } from 'vitest' +import { getTableName, getTableColumns } from 'drizzle-orm' +import { trackedRepos } from '../../../../src/db/schema/tracked-repos.js' -describe("tracked_repos schema", () => { - const columns = getTableColumns(trackedRepos); +describe('tracked_repos schema', () => { + const columns = getTableColumns(trackedRepos) - it("has the correct table name", () => { - expect(getTableName(trackedRepos)).toBe("tracked_repos"); - }); + it('has the correct table name', () => { + expect(getTableName(trackedRepos)).toBe('tracked_repos') + }) - it("uses did as primary key", () => { - expect(columns.did.primary).toBe(true); - }); + it('uses did as primary key', () => { + expect(columns.did.primary).toBe(true) + }) - it("has tracked_at column with default", () => { - expect(columns.trackedAt).toBeDefined(); - expect(columns.trackedAt.hasDefault).toBe(true); - expect(columns.trackedAt.notNull).toBe(true); - }); + it('has tracked_at column with default', () => { + expect(columns.trackedAt).toBeDefined() + expect(columns.trackedAt.hasDefault).toBe(true) + expect(columns.trackedAt.notNull).toBe(true) + }) - it("has only did and tracked_at columns", () => { - const columnNames = Object.keys(columns); - expect(columnNames).toEqual(["did", "trackedAt"]); - }); -}); + it('has only did and tracked_at columns', () => { + const columnNames = Object.keys(columns) + expect(columnNames).toEqual(['did', 'trackedAt']) + }) +}) diff --git a/tests/unit/db/schema/trust-scores.test.ts b/tests/unit/db/schema/trust-scores.test.ts new file mode 100644 index 0000000..8bba942 --- /dev/null +++ b/tests/unit/db/schema/trust-scores.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { trustScores } from '../../../../src/db/schema/trust-scores.js' +import { getTableName, getTableColumns } from 'drizzle-orm' +import { getTableConfig } from 'drizzle-orm/pg-core' + +describe('trust-scores schema', () => { + it('should have the correct table name', () => { + expect(getTableName(trustScores)).toBe('trust_scores') + }) + + it('should have all required columns', () => { + const columns = getTableColumns(trustScores) + const columnNames = Object.keys(columns) + + expect(columnNames).toContain('did') + expect(columnNames).toContain('communityId') + expect(columnNames).toContain('score') + expect(columnNames).toContain('computedAt') + }) + + it('should mark did and score as not null', () => { + const columns = getTableColumns(trustScores) + expect(columns.did.notNull).toBe(true) + expect(columns.score.notNull).toBe(true) + expect(columns.computedAt.notNull).toBe(true) + }) + + it('should use non-null communityId with empty string sentinel for global scores', () => { + const columns = getTableColumns(trustScores) + expect(columns.communityId.notNull).toBe(true) + }) + + it('should have composite primary key on (did, communityId)', () => { + const config = getTableConfig(trustScores) + expect(config.primaryKeys.length).toBeGreaterThanOrEqual(1) + const pk = config.primaryKeys[0] + expect(pk).toBeDefined() + if (pk) expect(pk.columns.length).toBe(2) + }) + + it('should have index on (did, communityId)', () => { + const config = getTableConfig(trustScores) + const idx = config.indexes.find((i) => i.config.name === 'trust_scores_did_community_idx') + expect(idx).toBeDefined() + }) +}) diff --git a/tests/unit/db/schema/trust-seeds.test.ts b/tests/unit/db/schema/trust-seeds.test.ts new file mode 100644 index 0000000..98a9014 --- /dev/null +++ b/tests/unit/db/schema/trust-seeds.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { trustSeeds } from '../../../../src/db/schema/trust-seeds.js' +import { getTableName, getTableColumns } from 'drizzle-orm' +import { getTableConfig } from 'drizzle-orm/pg-core' + +describe('trust-seeds schema', () => { + it('should have the correct table name', () => { + expect(getTableName(trustSeeds)).toBe('trust_seeds') + }) + + it('should have all required columns', () => { + const columns = getTableColumns(trustSeeds) + const columnNames = Object.keys(columns) + + expect(columnNames).toContain('id') + expect(columnNames).toContain('did') + expect(columnNames).toContain('communityId') + expect(columnNames).toContain('addedBy') + expect(columnNames).toContain('reason') + expect(columnNames).toContain('createdAt') + }) + + it('should have id as primary key (serial)', () => { + const columns = getTableColumns(trustSeeds) + expect(columns.id.primary).toBe(true) + }) + + it('should mark required columns as not null', () => { + const columns = getTableColumns(trustSeeds) + expect(columns.did.notNull).toBe(true) + expect(columns.addedBy.notNull).toBe(true) + expect(columns.createdAt.notNull).toBe(true) + }) + + it('should use non-null communityId with empty string sentinel for global seeds', () => { + const columns = getTableColumns(trustSeeds) + expect(columns.communityId.notNull).toBe(true) + }) + + it('should allow nullable reason', () => { + const columns = getTableColumns(trustSeeds) + expect(columns.reason.notNull).toBe(false) + }) + + it('should have a unique index on (did, communityId)', () => { + const config = getTableConfig(trustSeeds) + const uniqueIdx = config.indexes.find( + (idx) => idx.config.name === 'trust_seeds_did_community_idx' + ) + expect(uniqueIdx).toBeDefined() + expect(uniqueIdx?.config.unique).toBe(true) + }) +}) diff --git a/tests/unit/db/schema/user-preferences.test.ts b/tests/unit/db/schema/user-preferences.test.ts index 9f5a4f3..b45fc45 100644 --- a/tests/unit/db/schema/user-preferences.test.ts +++ b/tests/unit/db/schema/user-preferences.test.ts @@ -1,121 +1,119 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from 'vitest' import { userPreferences, userCommunityPreferences, -} from "../../../../src/db/schema/user-preferences.js"; -import { getTableName, getTableColumns } from "drizzle-orm"; +} from '../../../../src/db/schema/user-preferences.js' +import { getTableName, getTableColumns } from 'drizzle-orm' // =========================================================================== // userPreferences schema // =========================================================================== -describe("userPreferences schema", () => { - it("should have the correct table name", () => { - expect(getTableName(userPreferences)).toBe("user_preferences"); - }); - - it("should have all required columns", () => { - const columns = getTableColumns(userPreferences); - const columnNames = Object.keys(columns); - - expect(columnNames).toContain("did"); - expect(columnNames).toContain("maturityLevel"); - expect(columnNames).toContain("declaredAge"); - expect(columnNames).toContain("mutedWords"); - expect(columnNames).toContain("blockedDids"); - expect(columnNames).toContain("mutedDids"); - expect(columnNames).toContain("crossPostBluesky"); - expect(columnNames).toContain("crossPostFrontpage"); - expect(columnNames).toContain("updatedAt"); - }); - - it("should have did as primary key", () => { - const columns = getTableColumns(userPreferences); - expect(columns.did.primary).toBe(true); - }); - - it("should mark required columns as not null", () => { - const columns = getTableColumns(userPreferences); - expect(columns.did.notNull).toBe(true); - expect(columns.maturityLevel.notNull).toBe(true); - expect(columns.mutedWords.notNull).toBe(true); - expect(columns.blockedDids.notNull).toBe(true); - expect(columns.mutedDids.notNull).toBe(true); - expect(columns.crossPostBluesky.notNull).toBe(true); - expect(columns.crossPostFrontpage.notNull).toBe(true); - expect(columns.updatedAt.notNull).toBe(true); - }); - - it("should allow declaredAge to be nullable", () => { - const columns = getTableColumns(userPreferences); - expect(columns.declaredAge.notNull).toBe(false); - }); - - it("should have exactly 10 columns", () => { - const columns = getTableColumns(userPreferences); - expect(Object.keys(columns)).toHaveLength(10); - }); - - it("should have default values for maturityLevel, mutedWords, blockedDids, mutedDids, crossPost*, updatedAt", () => { - const columns = getTableColumns(userPreferences); - expect(columns.maturityLevel.hasDefault).toBe(true); - expect(columns.mutedWords.hasDefault).toBe(true); - expect(columns.blockedDids.hasDefault).toBe(true); - expect(columns.mutedDids.hasDefault).toBe(true); - expect(columns.crossPostBluesky.hasDefault).toBe(true); - expect(columns.crossPostFrontpage.hasDefault).toBe(true); - expect(columns.updatedAt.hasDefault).toBe(true); - }); -}); +describe('userPreferences schema', () => { + it('should have the correct table name', () => { + expect(getTableName(userPreferences)).toBe('user_preferences') + }) + + it('should have all required columns', () => { + const columns = getTableColumns(userPreferences) + const columnNames = Object.keys(columns) + + expect(columnNames).toContain('did') + expect(columnNames).toContain('maturityLevel') + expect(columnNames).toContain('declaredAge') + expect(columnNames).toContain('mutedWords') + expect(columnNames).toContain('blockedDids') + expect(columnNames).toContain('mutedDids') + expect(columnNames).toContain('crossPostBluesky') + expect(columnNames).toContain('crossPostFrontpage') + expect(columnNames).toContain('updatedAt') + }) + + it('should have did as primary key', () => { + const columns = getTableColumns(userPreferences) + expect(columns.did.primary).toBe(true) + }) + + it('should mark required columns as not null', () => { + const columns = getTableColumns(userPreferences) + expect(columns.did.notNull).toBe(true) + expect(columns.maturityLevel.notNull).toBe(true) + expect(columns.mutedWords.notNull).toBe(true) + expect(columns.blockedDids.notNull).toBe(true) + expect(columns.mutedDids.notNull).toBe(true) + expect(columns.crossPostBluesky.notNull).toBe(true) + expect(columns.crossPostFrontpage.notNull).toBe(true) + expect(columns.updatedAt.notNull).toBe(true) + }) + + it('should allow declaredAge to be nullable', () => { + const columns = getTableColumns(userPreferences) + expect(columns.declaredAge.notNull).toBe(false) + }) + + it('should have exactly 10 columns', () => { + const columns = getTableColumns(userPreferences) + expect(Object.keys(columns)).toHaveLength(10) + }) + + it('should have default values for maturityLevel, mutedWords, blockedDids, mutedDids, crossPost*, updatedAt', () => { + const columns = getTableColumns(userPreferences) + expect(columns.maturityLevel.hasDefault).toBe(true) + expect(columns.mutedWords.hasDefault).toBe(true) + expect(columns.blockedDids.hasDefault).toBe(true) + expect(columns.mutedDids.hasDefault).toBe(true) + expect(columns.crossPostBluesky.hasDefault).toBe(true) + expect(columns.crossPostFrontpage.hasDefault).toBe(true) + expect(columns.updatedAt.hasDefault).toBe(true) + }) +}) // =========================================================================== // userCommunityPreferences schema // =========================================================================== -describe("userCommunityPreferences schema", () => { - it("should have the correct table name", () => { - expect(getTableName(userCommunityPreferences)).toBe( - "user_community_preferences", - ); - }); - - it("should have all required columns", () => { - const columns = getTableColumns(userCommunityPreferences); - const columnNames = Object.keys(columns); - - expect(columnNames).toContain("did"); - expect(columnNames).toContain("communityDid"); - expect(columnNames).toContain("maturityOverride"); - expect(columnNames).toContain("mutedWords"); - expect(columnNames).toContain("blockedDids"); - expect(columnNames).toContain("mutedDids"); - expect(columnNames).toContain("notificationPrefs"); - expect(columnNames).toContain("updatedAt"); - }); - - it("should have exactly 8 columns", () => { - const columns = getTableColumns(userCommunityPreferences); - expect(Object.keys(columns)).toHaveLength(8); - }); - - it("should mark did and communityDid as not null", () => { - const columns = getTableColumns(userCommunityPreferences); - expect(columns.did.notNull).toBe(true); - expect(columns.communityDid.notNull).toBe(true); - }); - - it("should mark updatedAt as not null with default", () => { - const columns = getTableColumns(userCommunityPreferences); - expect(columns.updatedAt.notNull).toBe(true); - expect(columns.updatedAt.hasDefault).toBe(true); - }); - - it("should allow optional columns to be nullable", () => { - const columns = getTableColumns(userCommunityPreferences); - expect(columns.maturityOverride.notNull).toBe(false); - expect(columns.mutedWords.notNull).toBe(false); - expect(columns.blockedDids.notNull).toBe(false); - expect(columns.mutedDids.notNull).toBe(false); - expect(columns.notificationPrefs.notNull).toBe(false); - }); -}); +describe('userCommunityPreferences schema', () => { + it('should have the correct table name', () => { + expect(getTableName(userCommunityPreferences)).toBe('user_community_preferences') + }) + + it('should have all required columns', () => { + const columns = getTableColumns(userCommunityPreferences) + const columnNames = Object.keys(columns) + + expect(columnNames).toContain('did') + expect(columnNames).toContain('communityDid') + expect(columnNames).toContain('maturityOverride') + expect(columnNames).toContain('mutedWords') + expect(columnNames).toContain('blockedDids') + expect(columnNames).toContain('mutedDids') + expect(columnNames).toContain('notificationPrefs') + expect(columnNames).toContain('updatedAt') + }) + + it('should have exactly 8 columns', () => { + const columns = getTableColumns(userCommunityPreferences) + expect(Object.keys(columns)).toHaveLength(8) + }) + + it('should mark did and communityDid as not null', () => { + const columns = getTableColumns(userCommunityPreferences) + expect(columns.did.notNull).toBe(true) + expect(columns.communityDid.notNull).toBe(true) + }) + + it('should mark updatedAt as not null with default', () => { + const columns = getTableColumns(userCommunityPreferences) + expect(columns.updatedAt.notNull).toBe(true) + expect(columns.updatedAt.hasDefault).toBe(true) + }) + + it('should allow optional columns to be nullable', () => { + const columns = getTableColumns(userCommunityPreferences) + expect(columns.maturityOverride.notNull).toBe(false) + expect(columns.mutedWords.notNull).toBe(false) + expect(columns.blockedDids.notNull).toBe(false) + expect(columns.mutedDids.notNull).toBe(false) + expect(columns.notificationPrefs.notNull).toBe(false) + }) +}) diff --git a/tests/unit/firehose/cursor.test.ts b/tests/unit/firehose/cursor.test.ts index 717cb44..aaddab8 100644 --- a/tests/unit/firehose/cursor.test.ts +++ b/tests/unit/firehose/cursor.test.ts @@ -1,93 +1,93 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { CursorStore } from "../../../src/firehose/cursor.js"; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { CursorStore } from '../../../src/firehose/cursor.js' function createMockDb() { return { select: vi.fn(), update: vi.fn(), insert: vi.fn(), - }; + } } -describe("CursorStore", () => { +describe('CursorStore', () => { beforeEach(() => { - vi.useFakeTimers(); - }); + vi.useFakeTimers() + }) afterEach(() => { - vi.useRealTimers(); - }); + vi.useRealTimers() + }) - describe("getCursor", () => { - it("returns null when no cursor exists", async () => { - const mockDb = createMockDb(); + describe('getCursor', () => { + it('returns null when no cursor exists', async () => { + const mockDb = createMockDb() mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), - }); - const store = new CursorStore(mockDb as never); - const cursor = await store.getCursor(); - expect(cursor).toBeNull(); - }); + }) + const store = new CursorStore(mockDb as never) + const cursor = await store.getCursor() + expect(cursor).toBeNull() + }) - it("returns cursor value when it exists", async () => { - const mockDb = createMockDb(); + it('returns cursor value when it exists', async () => { + const mockDb = createMockDb() mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([{ cursor: 42n }]), }), - }); - const store = new CursorStore(mockDb as never); - const cursor = await store.getCursor(); - expect(cursor).toBe(42n); - }); - }); + }) + const store = new CursorStore(mockDb as never) + const cursor = await store.getCursor() + expect(cursor).toBe(42n) + }) + }) - describe("saveCursor", () => { - it("debounces writes", async () => { - const mockDb = createMockDb(); + describe('saveCursor', () => { + it('debounces writes', async () => { + const mockDb = createMockDb() const setFn = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined), - }); - mockDb.update.mockReturnValue({ set: setFn }); - const store = new CursorStore(mockDb as never, 5000); + }) + mockDb.update.mockReturnValue({ set: setFn }) + const store = new CursorStore(mockDb as never, 5000) // Multiple rapid saves should not trigger immediate writes - store.saveCursor(1n); - store.saveCursor(2n); - store.saveCursor(3n); + store.saveCursor(1n) + store.saveCursor(2n) + store.saveCursor(3n) // No write yet (debounced) - expect(mockDb.update).not.toHaveBeenCalled(); + expect(mockDb.update).not.toHaveBeenCalled() // After debounce period, only the latest value should be written - await vi.advanceTimersByTimeAsync(5000); - expect(mockDb.update).toHaveBeenCalledTimes(1); - }); - }); + await vi.advanceTimersByTimeAsync(5000) + expect(mockDb.update).toHaveBeenCalledTimes(1) + }) + }) - describe("flush", () => { - it("force-writes the current cursor value", async () => { - const mockDb = createMockDb(); + describe('flush', () => { + it('force-writes the current cursor value', async () => { + const mockDb = createMockDb() const setFn = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined), - }); - mockDb.update.mockReturnValue({ set: setFn }); - const store = new CursorStore(mockDb as never, 5000); + }) + mockDb.update.mockReturnValue({ set: setFn }) + const store = new CursorStore(mockDb as never, 5000) - store.saveCursor(10n); - expect(mockDb.update).not.toHaveBeenCalled(); + store.saveCursor(10n) + expect(mockDb.update).not.toHaveBeenCalled() - await store.flush(); - expect(mockDb.update).toHaveBeenCalledTimes(1); - }); + await store.flush() + expect(mockDb.update).toHaveBeenCalledTimes(1) + }) - it("is a no-op if no cursor was saved", async () => { - const mockDb = createMockDb(); - const store = new CursorStore(mockDb as never, 5000); - await store.flush(); - expect(mockDb.update).not.toHaveBeenCalled(); - }); - }); -}); + it('is a no-op if no cursor was saved', async () => { + const mockDb = createMockDb() + const store = new CursorStore(mockDb as never, 5000) + await store.flush() + expect(mockDb.update).not.toHaveBeenCalled() + }) + }) +}) diff --git a/tests/unit/firehose/handlers/identity.test.ts b/tests/unit/firehose/handlers/identity.test.ts index 130f81b..f12be01 100644 --- a/tests/unit/firehose/handlers/identity.test.ts +++ b/tests/unit/firehose/handlers/identity.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi } from "vitest"; -import { IdentityHandler } from "../../../../src/firehose/handlers/identity.js"; -import type { IdentityEvent } from "../../../../src/firehose/types.js"; +import { describe, it, expect, vi } from 'vitest' +import { IdentityHandler } from '../../../../src/firehose/handlers/identity.js' +import type { IdentityEvent } from '../../../../src/firehose/types.js' function createMockDb() { return { @@ -22,10 +22,10 @@ function createMockDb() { delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined), }), - }; - return fn(mockTx); + } + return fn(mockTx) }), - }; + } } function createMockLogger() { @@ -34,88 +34,88 @@ function createMockLogger() { error: vi.fn(), warn: vi.fn(), debug: vi.fn(), - }; + } } -describe("IdentityHandler", () => { - describe("deleted status", () => { - it("purges all data for the DID in a transaction", async () => { - const db = createMockDb(); - const logger = createMockLogger(); - const handler = new IdentityHandler(db as never, logger as never); +describe('IdentityHandler', () => { + describe('deleted status', () => { + it('purges all data for the DID in a transaction', async () => { + const db = createMockDb() + const logger = createMockLogger() + const handler = new IdentityHandler(db as never, logger as never) const event: IdentityEvent = { id: 1, - did: "did:plc:deleted", - handle: "deleted.bsky.social", + did: 'did:plc:deleted', + handle: 'deleted.bsky.social', isActive: false, - status: "deleted", - }; + status: 'deleted', + } - await handler.handle(event); + await handler.handle(event) - expect(db.transaction).toHaveBeenCalledTimes(1); - expect(logger.info).toHaveBeenCalled(); - }); - }); + expect(db.transaction).toHaveBeenCalledTimes(1) + expect(logger.info).toHaveBeenCalled() + }) + }) - describe("active status", () => { - it("upserts user with handle", async () => { - const db = createMockDb(); - const logger = createMockLogger(); - const handler = new IdentityHandler(db as never, logger as never); + describe('active status', () => { + it('upserts user with handle', async () => { + const db = createMockDb() + const logger = createMockLogger() + const handler = new IdentityHandler(db as never, logger as never) const event: IdentityEvent = { id: 2, - did: "did:plc:active", - handle: "active.bsky.social", + did: 'did:plc:active', + handle: 'active.bsky.social', isActive: true, - status: "active", - }; + status: 'active', + } - await handler.handle(event); + await handler.handle(event) - expect(db.insert).toHaveBeenCalledTimes(1); - }); - }); + expect(db.insert).toHaveBeenCalledTimes(1) + }) + }) - describe("deactivated status", () => { - it("logs the status change", async () => { - const db = createMockDb(); - const logger = createMockLogger(); - const handler = new IdentityHandler(db as never, logger as never); + describe('deactivated status', () => { + it('logs the status change', async () => { + const db = createMockDb() + const logger = createMockLogger() + const handler = new IdentityHandler(db as never, logger as never) const event: IdentityEvent = { id: 3, - did: "did:plc:deactivated", - handle: "deactivated.bsky.social", + did: 'did:plc:deactivated', + handle: 'deactivated.bsky.social', isActive: false, - status: "deactivated", - }; + status: 'deactivated', + } - await handler.handle(event); + await handler.handle(event) - expect(logger.info).toHaveBeenCalled(); - }); - }); + expect(logger.info).toHaveBeenCalled() + }) + }) - describe("takendown status", () => { - it("logs the status change", async () => { - const db = createMockDb(); - const logger = createMockLogger(); - const handler = new IdentityHandler(db as never, logger as never); + describe('takendown status', () => { + it('logs the status change', async () => { + const db = createMockDb() + const logger = createMockLogger() + const handler = new IdentityHandler(db as never, logger as never) const event: IdentityEvent = { id: 4, - did: "did:plc:takendown", - handle: "takendown.bsky.social", + did: 'did:plc:takendown', + handle: 'takendown.bsky.social', isActive: false, - status: "takendown", - }; + status: 'takendown', + } - await handler.handle(event); + await handler.handle(event) - expect(logger.info).toHaveBeenCalled(); - }); - }); -}); + expect(logger.info).toHaveBeenCalled() + }) + }) +}) diff --git a/tests/unit/firehose/handlers/record.test.ts b/tests/unit/firehose/handlers/record.test.ts index cc91c76..4d285f2 100644 --- a/tests/unit/firehose/handlers/record.test.ts +++ b/tests/unit/firehose/handlers/record.test.ts @@ -1,13 +1,13 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { RecordHandler } from "../../../../src/firehose/handlers/record.js"; -import type { RecordEvent } from "../../../../src/firehose/types.js"; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { RecordHandler } from '../../../../src/firehose/handlers/record.js' +import type { RecordEvent } from '../../../../src/firehose/types.js' function createMockIndexer() { return { handleCreate: vi.fn().mockResolvedValue(undefined), handleUpdate: vi.fn().mockResolvedValue(undefined), handleDelete: vi.fn().mockResolvedValue(undefined), - }; + } } function createMockDb() { @@ -27,7 +27,7 @@ function createMockDb() { where: vi.fn().mockResolvedValue(undefined), }), }), - }; + } } function createMockLogger() { @@ -36,32 +36,32 @@ function createMockLogger() { error: vi.fn(), warn: vi.fn(), debug: vi.fn(), - }; + } } function createMockAccountAgeService() { return { resolveCreationDate: vi.fn().mockResolvedValue(null), - determineTrustStatus: vi.fn().mockReturnValue("trusted" as const), - }; + determineTrustStatus: vi.fn().mockReturnValue('trusted' as const), + } } -describe("RecordHandler", () => { - let topicIndexer: ReturnType; - let replyIndexer: ReturnType; - let reactionIndexer: ReturnType; - let db: ReturnType; - let logger: ReturnType; - let accountAgeService: ReturnType; - let handler: RecordHandler; +describe('RecordHandler', () => { + let topicIndexer: ReturnType + let replyIndexer: ReturnType + let reactionIndexer: ReturnType + let db: ReturnType + let logger: ReturnType + let accountAgeService: ReturnType + let handler: RecordHandler beforeEach(() => { - topicIndexer = createMockIndexer(); - replyIndexer = createMockIndexer(); - reactionIndexer = createMockIndexer(); - db = createMockDb(); - logger = createMockLogger(); - accountAgeService = createMockAccountAgeService(); + topicIndexer = createMockIndexer() + replyIndexer = createMockIndexer() + reactionIndexer = createMockIndexer() + db = createMockDb() + logger = createMockLogger() + accountAgeService = createMockAccountAgeService() handler = new RecordHandler( { topic: topicIndexer, @@ -70,425 +70,423 @@ describe("RecordHandler", () => { } as never, db as never, logger as never, - accountAgeService as never, - ); - }); + accountAgeService as never + ) + }) - describe("dispatch routing", () => { - it("dispatches topic create to topic indexer", async () => { + describe('dispatch routing', () => { + it('dispatches topic create to topic indexer', async () => { const event: RecordEvent = { id: 1, - action: "create", - did: "did:plc:test", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "abc123", + action: 'create', + did: 'did:plc:test', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'abc123', record: { - title: "Test", - content: "Content", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-01T00:00:00.000Z", + title: 'Test', + content: 'Content', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-01T00:00:00.000Z', }, - cid: "bafyabc", + cid: 'bafyabc', live: true, - }; + } - await handler.handle(event); + await handler.handle(event) - expect(topicIndexer.handleCreate).toHaveBeenCalledTimes(1); - expect(replyIndexer.handleCreate).not.toHaveBeenCalled(); - }); + expect(topicIndexer.handleCreate).toHaveBeenCalledTimes(1) + expect(replyIndexer.handleCreate).not.toHaveBeenCalled() + }) - it("dispatches reply create to reply indexer", async () => { + it('dispatches reply create to reply indexer', async () => { const event: RecordEvent = { id: 2, - action: "create", - did: "did:plc:test", - rev: "rev1", - collection: "forum.barazo.topic.reply", - rkey: "reply1", + action: 'create', + did: 'did:plc:test', + rev: 'rev1', + collection: 'forum.barazo.topic.reply', + rkey: 'reply1', record: { - content: "Reply", - root: { uri: "at://did:plc:test/forum.barazo.topic.post/t1", cid: "bafyt" }, - parent: { uri: "at://did:plc:test/forum.barazo.topic.post/t1", cid: "bafyt" }, - community: "did:plc:community", - createdAt: "2026-01-01T00:00:00.000Z", + content: 'Reply', + root: { uri: 'at://did:plc:test/forum.barazo.topic.post/t1', cid: 'bafyt' }, + parent: { uri: 'at://did:plc:test/forum.barazo.topic.post/t1', cid: 'bafyt' }, + community: 'did:plc:community', + createdAt: '2026-01-01T00:00:00.000Z', }, - cid: "bafyreply", + cid: 'bafyreply', live: true, - }; + } - await handler.handle(event); + await handler.handle(event) - expect(replyIndexer.handleCreate).toHaveBeenCalledTimes(1); - }); + expect(replyIndexer.handleCreate).toHaveBeenCalledTimes(1) + }) - it("dispatches reaction create to reaction indexer", async () => { + it('dispatches reaction create to reaction indexer', async () => { const event: RecordEvent = { id: 3, - action: "create", - did: "did:plc:test", - rev: "rev1", - collection: "forum.barazo.interaction.reaction", - rkey: "react1", + action: 'create', + did: 'did:plc:test', + rev: 'rev1', + collection: 'forum.barazo.interaction.reaction', + rkey: 'react1', record: { - subject: { uri: "at://did:plc:test/forum.barazo.topic.post/t1", cid: "bafyt" }, - type: "like", - community: "did:plc:community", - createdAt: "2026-01-01T00:00:00.000Z", + subject: { uri: 'at://did:plc:test/forum.barazo.topic.post/t1', cid: 'bafyt' }, + type: 'like', + community: 'did:plc:community', + createdAt: '2026-01-01T00:00:00.000Z', }, - cid: "bafyreact", + cid: 'bafyreact', live: true, - }; + } - await handler.handle(event); + await handler.handle(event) - expect(reactionIndexer.handleCreate).toHaveBeenCalledTimes(1); - }); + expect(reactionIndexer.handleCreate).toHaveBeenCalledTimes(1) + }) - it("dispatches update to the correct indexer", async () => { + it('dispatches update to the correct indexer', async () => { const event: RecordEvent = { id: 4, - action: "update", - did: "did:plc:test", - rev: "rev2", - collection: "forum.barazo.topic.post", - rkey: "abc123", + action: 'update', + did: 'did:plc:test', + rev: 'rev2', + collection: 'forum.barazo.topic.post', + rkey: 'abc123', record: { - title: "Updated", - content: "Updated content", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-01T00:00:00.000Z", + title: 'Updated', + content: 'Updated content', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-01T00:00:00.000Z', }, - cid: "bafynew", + cid: 'bafynew', live: true, - }; + } - await handler.handle(event); + await handler.handle(event) - expect(topicIndexer.handleUpdate).toHaveBeenCalledTimes(1); - }); + expect(topicIndexer.handleUpdate).toHaveBeenCalledTimes(1) + }) - it("dispatches delete to the correct indexer", async () => { + it('dispatches delete to the correct indexer', async () => { const event: RecordEvent = { id: 5, - action: "delete", - did: "did:plc:test", - rev: "rev3", - collection: "forum.barazo.topic.post", - rkey: "abc123", + action: 'delete', + did: 'did:plc:test', + rev: 'rev3', + collection: 'forum.barazo.topic.post', + rkey: 'abc123', live: true, - }; + } - await handler.handle(event); + await handler.handle(event) - expect(topicIndexer.handleDelete).toHaveBeenCalledTimes(1); - }); - }); + expect(topicIndexer.handleDelete).toHaveBeenCalledTimes(1) + }) + }) - describe("validation rejection", () => { - it("skips events for unsupported collections", async () => { + describe('validation rejection', () => { + it('skips events for unsupported collections', async () => { const event: RecordEvent = { id: 6, - action: "create", - did: "did:plc:test", - rev: "rev1", - collection: "com.example.unknown", - rkey: "abc123", - record: { foo: "bar" }, - cid: "bafyabc", + action: 'create', + did: 'did:plc:test', + rev: 'rev1', + collection: 'com.example.unknown', + rkey: 'abc123', + record: { foo: 'bar' }, + cid: 'bafyabc', live: true, - }; + } - await handler.handle(event); + await handler.handle(event) - expect(topicIndexer.handleCreate).not.toHaveBeenCalled(); - expect(replyIndexer.handleCreate).not.toHaveBeenCalled(); - expect(reactionIndexer.handleCreate).not.toHaveBeenCalled(); - }); + expect(topicIndexer.handleCreate).not.toHaveBeenCalled() + expect(replyIndexer.handleCreate).not.toHaveBeenCalled() + expect(reactionIndexer.handleCreate).not.toHaveBeenCalled() + }) - it("skips create events with invalid records", async () => { + it('skips create events with invalid records', async () => { const event: RecordEvent = { id: 7, - action: "create", - did: "did:plc:test", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "abc123", - record: { invalid: "data" }, - cid: "bafyabc", + action: 'create', + did: 'did:plc:test', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'abc123', + record: { invalid: 'data' }, + cid: 'bafyabc', live: true, - }; + } - await handler.handle(event); + await handler.handle(event) - expect(topicIndexer.handleCreate).not.toHaveBeenCalled(); - }); - }); + expect(topicIndexer.handleCreate).not.toHaveBeenCalled() + }) + }) - describe("error catching", () => { - it("catches and logs indexer errors without throwing", async () => { - topicIndexer.handleCreate.mockRejectedValue(new Error("DB error")); + describe('error catching', () => { + it('catches and logs indexer errors without throwing', async () => { + topicIndexer.handleCreate.mockRejectedValue(new Error('DB error')) const event: RecordEvent = { id: 8, - action: "create", - did: "did:plc:test", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "abc123", + action: 'create', + did: 'did:plc:test', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'abc123', record: { - title: "Test", - content: "Content", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-01T00:00:00.000Z", + title: 'Test', + content: 'Content', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-01T00:00:00.000Z', }, - cid: "bafyabc", + cid: 'bafyabc', live: true, - }; + } // Should NOT throw - await expect(handler.handle(event)).resolves.toBeUndefined(); - expect(logger.error).toHaveBeenCalled(); - }); - }); + await expect(handler.handle(event)).resolves.toBeUndefined() + expect(logger.error).toHaveBeenCalled() + }) + }) - describe("user upsert with trust check", () => { - it("upserts a user stub on create events", async () => { + describe('user upsert with trust check', () => { + it('upserts a user stub on create events', async () => { const event: RecordEvent = { id: 9, - action: "create", - did: "did:plc:newuser", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "abc123", + action: 'create', + did: 'did:plc:newuser', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'abc123', record: { - title: "Test", - content: "Content", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-01T00:00:00.000Z", + title: 'Test', + content: 'Content', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-01T00:00:00.000Z', }, - cid: "bafyabc", + cid: 'bafyabc', live: true, - }; + } - await handler.handle(event); + await handler.handle(event) - expect(db.select).toHaveBeenCalled(); - expect(accountAgeService.resolveCreationDate).toHaveBeenCalledWith("did:plc:newuser"); - expect(db.insert).toHaveBeenCalled(); - }); + expect(db.select).toHaveBeenCalled() + expect(accountAgeService.resolveCreationDate).toHaveBeenCalledWith('did:plc:newuser') + expect(db.insert).toHaveBeenCalled() + }) it("passes trust status 'new' to indexer for new accounts", async () => { - accountAgeService.determineTrustStatus.mockReturnValue("new"); + accountAgeService.determineTrustStatus.mockReturnValue('new') const event: RecordEvent = { id: 11, - action: "create", - did: "did:plc:brandnew", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "abc123", + action: 'create', + did: 'did:plc:brandnew', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'abc123', record: { - title: "Test", - content: "Content", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-01T00:00:00.000Z", + title: 'Test', + content: 'Content', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-01T00:00:00.000Z', }, - cid: "bafyabc", + cid: 'bafyabc', live: true, - }; + } - await handler.handle(event); + await handler.handle(event) - const call = topicIndexer.handleCreate.mock.calls[0] as [{ trustStatus: string }]; - expect(call[0].trustStatus).toBe("new"); - }); + const call = topicIndexer.handleCreate.mock.calls[0] as [{ trustStatus: string }] + expect(call[0].trustStatus).toBe('new') + }) it("passes trust status 'trusted' to indexer for established accounts", async () => { - accountAgeService.determineTrustStatus.mockReturnValue("trusted"); + accountAgeService.determineTrustStatus.mockReturnValue('trusted') const event: RecordEvent = { id: 12, - action: "create", - did: "did:plc:established", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "abc123", + action: 'create', + did: 'did:plc:established', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'abc123', record: { - title: "Test", - content: "Content", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-01T00:00:00.000Z", + title: 'Test', + content: 'Content', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-01T00:00:00.000Z', }, - cid: "bafyabc", + cid: 'bafyabc', live: true, - }; + } - await handler.handle(event); + await handler.handle(event) - const call = topicIndexer.handleCreate.mock.calls[0] as [{ trustStatus: string }]; - expect(call[0].trustStatus).toBe("trusted"); - }); + const call = topicIndexer.handleCreate.mock.calls[0] as [{ trustStatus: string }] + expect(call[0].trustStatus).toBe('trusted') + }) - it("checks stored accountCreatedAt for existing users", async () => { - const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + it('checks stored accountCreatedAt for existing users', async () => { + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000) db.select.mockReturnValue({ from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([ - { did: "did:plc:existing", accountCreatedAt: twoHoursAgo }, - ]), + where: vi + .fn() + .mockResolvedValue([{ did: 'did:plc:existing', accountCreatedAt: twoHoursAgo }]), }), - }); - accountAgeService.determineTrustStatus.mockReturnValue("new"); + }) + accountAgeService.determineTrustStatus.mockReturnValue('new') const event: RecordEvent = { id: 13, - action: "create", - did: "did:plc:existing", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "abc123", + action: 'create', + did: 'did:plc:existing', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'abc123', record: { - title: "Test", - content: "Content", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-01T00:00:00.000Z", + title: 'Test', + content: 'Content', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-01T00:00:00.000Z', }, - cid: "bafyabc", + cid: 'bafyabc', live: true, - }; + } - await handler.handle(event); + await handler.handle(event) // Should use determineTrustStatus with the stored date, not resolve again - expect(accountAgeService.determineTrustStatus).toHaveBeenCalledWith(twoHoursAgo); - expect(accountAgeService.resolveCreationDate).not.toHaveBeenCalled(); - }); + expect(accountAgeService.determineTrustStatus).toHaveBeenCalledWith(twoHoursAgo) + expect(accountAgeService.resolveCreationDate).not.toHaveBeenCalled() + }) - it("resolves PLC creation date for existing users without accountCreatedAt", async () => { + it('resolves PLC creation date for existing users without accountCreatedAt', async () => { db.select.mockReturnValue({ from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([ - { did: "did:plc:legacy", accountCreatedAt: null }, - ]), + where: vi.fn().mockResolvedValue([{ did: 'did:plc:legacy', accountCreatedAt: null }]), }), - }); - const resolvedDate = new Date("2026-01-01T00:00:00.000Z"); - accountAgeService.resolveCreationDate.mockResolvedValue(resolvedDate); + }) + const resolvedDate = new Date('2026-01-01T00:00:00.000Z') + accountAgeService.resolveCreationDate.mockResolvedValue(resolvedDate) const event: RecordEvent = { id: 14, - action: "create", - did: "did:plc:legacy", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "abc123", + action: 'create', + did: 'did:plc:legacy', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'abc123', record: { - title: "Test", - content: "Content", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-01T00:00:00.000Z", + title: 'Test', + content: 'Content', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-01T00:00:00.000Z', }, - cid: "bafyabc", + cid: 'bafyabc', live: true, - }; + } - await handler.handle(event); + await handler.handle(event) - expect(accountAgeService.resolveCreationDate).toHaveBeenCalledWith("did:plc:legacy"); - expect(db.update).toHaveBeenCalled(); - }); + expect(accountAgeService.resolveCreationDate).toHaveBeenCalledWith('did:plc:legacy') + expect(db.update).toHaveBeenCalled() + }) - it("does not call accountAgeService for update events", async () => { + it('does not call accountAgeService for update events', async () => { const event: RecordEvent = { id: 15, - action: "update", - did: "did:plc:test", - rev: "rev2", - collection: "forum.barazo.topic.post", - rkey: "abc123", + action: 'update', + did: 'did:plc:test', + rev: 'rev2', + collection: 'forum.barazo.topic.post', + rkey: 'abc123', record: { - title: "Updated", - content: "Updated content", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-01T00:00:00.000Z", + title: 'Updated', + content: 'Updated content', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-01T00:00:00.000Z', }, - cid: "bafynew", + cid: 'bafynew', live: true, - }; + } - await handler.handle(event); + await handler.handle(event) - expect(accountAgeService.resolveCreationDate).not.toHaveBeenCalled(); - }); + expect(accountAgeService.resolveCreationDate).not.toHaveBeenCalled() + }) it("defaults to 'trusted' when upsert fails", async () => { db.select.mockReturnValue({ from: vi.fn().mockReturnValue({ - where: vi.fn().mockRejectedValue(new Error("DB error")), + where: vi.fn().mockRejectedValue(new Error('DB error')), }), - }); + }) const event: RecordEvent = { id: 16, - action: "create", - did: "did:plc:dberror", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "abc123", + action: 'create', + did: 'did:plc:dberror', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'abc123', record: { - title: "Test", - content: "Content", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-01T00:00:00.000Z", + title: 'Test', + content: 'Content', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-01T00:00:00.000Z', }, - cid: "bafyabc", + cid: 'bafyabc', live: true, - }; + } - await handler.handle(event); + await handler.handle(event) // Should still call the indexer with trusted (fail open) - const call = topicIndexer.handleCreate.mock.calls[0] as [{ trustStatus: string }]; - expect(call[0].trustStatus).toBe("trusted"); - }); - }); + const call = topicIndexer.handleCreate.mock.calls[0] as [{ trustStatus: string }] + expect(call[0].trustStatus).toBe('trusted') + }) + }) - describe("live flag", () => { - it("passes live flag through to indexer", async () => { + describe('live flag', () => { + it('passes live flag through to indexer', async () => { const event: RecordEvent = { id: 10, - action: "create", - did: "did:plc:test", - rev: "rev1", - collection: "forum.barazo.topic.post", - rkey: "abc123", + action: 'create', + did: 'did:plc:test', + rev: 'rev1', + collection: 'forum.barazo.topic.post', + rkey: 'abc123', record: { - title: "Test", - content: "Content", - community: "did:plc:community", - category: "general", - createdAt: "2026-01-01T00:00:00.000Z", + title: 'Test', + content: 'Content', + community: 'did:plc:community', + category: 'general', + createdAt: '2026-01-01T00:00:00.000Z', }, - cid: "bafyabc", + cid: 'bafyabc', live: false, - }; + } - await handler.handle(event); + await handler.handle(event) - const call = topicIndexer.handleCreate.mock.calls[0] as [{ live: boolean }]; - expect(call[0].live).toBe(false); - }); - }); -}); + const call = topicIndexer.handleCreate.mock.calls[0] as [{ live: boolean }] + expect(call[0].live).toBe(false) + }) + }) +}) diff --git a/tests/unit/firehose/indexers/reaction.test.ts b/tests/unit/firehose/indexers/reaction.test.ts index 5319784..d6508f4 100644 --- a/tests/unit/firehose/indexers/reaction.test.ts +++ b/tests/unit/firehose/indexers/reaction.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from "vitest"; -import { ReactionIndexer } from "../../../../src/firehose/indexers/reaction.js"; +import { describe, it, expect, vi } from 'vitest' +import { ReactionIndexer } from '../../../../src/firehose/indexers/reaction.js' function createMockDb() { const mockTx = { @@ -14,9 +14,9 @@ function createMockDb() { }), }), delete: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ uri: "deleted" }]), + where: vi.fn().mockResolvedValue([{ uri: 'deleted' }]), }), - }; + } return { insert: vi.fn().mockReturnValue({ @@ -30,13 +30,13 @@ function createMockDb() { }), }), delete: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ uri: "deleted" }]), + where: vi.fn().mockResolvedValue([{ uri: 'deleted' }]), }), - transaction: vi.fn().mockImplementation( - async (fn: (tx: typeof mockTx) => Promise) => fn(mockTx), - ), + transaction: vi + .fn() + .mockImplementation(async (fn: (tx: typeof mockTx) => Promise) => fn(mockTx)), _tx: mockTx, - }; + } } function createMockLogger() { @@ -45,55 +45,55 @@ function createMockLogger() { error: vi.fn(), warn: vi.fn(), debug: vi.fn(), - }; + } } -describe("ReactionIndexer", () => { +describe('ReactionIndexer', () => { const baseParams = { - uri: "at://did:plc:test/forum.barazo.interaction.reaction/react1", - rkey: "react1", - did: "did:plc:test", - cid: "bafyreact", + uri: 'at://did:plc:test/forum.barazo.interaction.reaction/react1', + rkey: 'react1', + did: 'did:plc:test', + cid: 'bafyreact', live: true, - }; + } - describe("handleCreate", () => { - it("upserts a reaction and increments count in a transaction", async () => { - const db = createMockDb(); - const logger = createMockLogger(); - const indexer = new ReactionIndexer(db as never, logger as never); + describe('handleCreate', () => { + it('upserts a reaction and increments count in a transaction', async () => { + const db = createMockDb() + const logger = createMockLogger() + const indexer = new ReactionIndexer(db as never, logger as never) await indexer.handleCreate({ ...baseParams, record: { subject: { - uri: "at://did:plc:test/forum.barazo.topic.post/topic1", - cid: "bafytopic", + uri: 'at://did:plc:test/forum.barazo.topic.post/topic1', + cid: 'bafytopic', }, - type: "like", - community: "did:plc:community", - createdAt: "2026-01-01T00:00:00.000Z", + type: 'like', + community: 'did:plc:community', + createdAt: '2026-01-01T00:00:00.000Z', }, - }); + }) - expect(db.transaction).toHaveBeenCalledTimes(1); - }); - }); + expect(db.transaction).toHaveBeenCalledTimes(1) + }) + }) - describe("handleDelete", () => { - it("deletes a reaction and decrements count in a transaction", async () => { - const db = createMockDb(); - const logger = createMockLogger(); - const indexer = new ReactionIndexer(db as never, logger as never); + describe('handleDelete', () => { + it('deletes a reaction and decrements count in a transaction', async () => { + const db = createMockDb() + const logger = createMockLogger() + const indexer = new ReactionIndexer(db as never, logger as never) await indexer.handleDelete({ uri: baseParams.uri, rkey: baseParams.rkey, did: baseParams.did, - subjectUri: "at://did:plc:test/forum.barazo.topic.post/topic1", - }); + subjectUri: 'at://did:plc:test/forum.barazo.topic.post/topic1', + }) - expect(db.transaction).toHaveBeenCalledTimes(1); - }); - }); -}); + expect(db.transaction).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/tests/unit/firehose/indexers/reply.test.ts b/tests/unit/firehose/indexers/reply.test.ts index 99f7e2b..f2ebae1 100644 --- a/tests/unit/firehose/indexers/reply.test.ts +++ b/tests/unit/firehose/indexers/reply.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from "vitest"; -import { ReplyIndexer } from "../../../../src/firehose/indexers/reply.js"; +import { describe, it, expect, vi } from 'vitest' +import { ReplyIndexer } from '../../../../src/firehose/indexers/reply.js' function createMockDb() { const mockTx = { @@ -14,9 +14,9 @@ function createMockDb() { }), }), delete: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ uri: "deleted" }]), + where: vi.fn().mockResolvedValue([{ uri: 'deleted' }]), }), - }; + } return { insert: vi.fn().mockReturnValue({ @@ -30,13 +30,13 @@ function createMockDb() { }), }), delete: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ uri: "deleted" }]), + where: vi.fn().mockResolvedValue([{ uri: 'deleted' }]), }), - transaction: vi.fn().mockImplementation( - async (fn: (tx: typeof mockTx) => Promise) => fn(mockTx), - ), + transaction: vi + .fn() + .mockImplementation(async (fn: (tx: typeof mockTx) => Promise) => fn(mockTx)), _tx: mockTx, - }; + } } function createMockLogger() { @@ -45,74 +45,74 @@ function createMockLogger() { error: vi.fn(), warn: vi.fn(), debug: vi.fn(), - }; + } } -describe("ReplyIndexer", () => { +describe('ReplyIndexer', () => { const baseParams = { - uri: "at://did:plc:test/forum.barazo.topic.reply/reply1", - rkey: "reply1", - did: "did:plc:test", - cid: "bafyreply", + uri: 'at://did:plc:test/forum.barazo.topic.reply/reply1', + rkey: 'reply1', + did: 'did:plc:test', + cid: 'bafyreply', live: true, - }; + } - describe("handleCreate", () => { - it("inserts a reply and increments topic reply count in a transaction", async () => { - const db = createMockDb(); - const logger = createMockLogger(); - const indexer = new ReplyIndexer(db as never, logger as never); + describe('handleCreate', () => { + it('inserts a reply and increments topic reply count in a transaction', async () => { + const db = createMockDb() + const logger = createMockLogger() + const indexer = new ReplyIndexer(db as never, logger as never) await indexer.handleCreate({ ...baseParams, record: { - content: "A reply", - root: { uri: "at://did:plc:test/forum.barazo.topic.post/topic1", cid: "bafytopic" }, - parent: { uri: "at://did:plc:test/forum.barazo.topic.post/topic1", cid: "bafytopic" }, - community: "did:plc:community", - createdAt: "2026-01-01T00:00:00.000Z", + content: 'A reply', + root: { uri: 'at://did:plc:test/forum.barazo.topic.post/topic1', cid: 'bafytopic' }, + parent: { uri: 'at://did:plc:test/forum.barazo.topic.post/topic1', cid: 'bafytopic' }, + community: 'did:plc:community', + createdAt: '2026-01-01T00:00:00.000Z', }, - }); + }) - expect(db.transaction).toHaveBeenCalledTimes(1); - }); - }); + expect(db.transaction).toHaveBeenCalledTimes(1) + }) + }) - describe("handleUpdate", () => { - it("updates reply content", async () => { - const db = createMockDb(); - const logger = createMockLogger(); - const indexer = new ReplyIndexer(db as never, logger as never); + describe('handleUpdate', () => { + it('updates reply content', async () => { + const db = createMockDb() + const logger = createMockLogger() + const indexer = new ReplyIndexer(db as never, logger as never) await indexer.handleUpdate({ ...baseParams, record: { - content: "Updated reply", - root: { uri: "at://did:plc:test/forum.barazo.topic.post/topic1", cid: "bafytopic" }, - parent: { uri: "at://did:plc:test/forum.barazo.topic.post/topic1", cid: "bafytopic" }, - community: "did:plc:community", - createdAt: "2026-01-01T00:00:00.000Z", + content: 'Updated reply', + root: { uri: 'at://did:plc:test/forum.barazo.topic.post/topic1', cid: 'bafytopic' }, + parent: { uri: 'at://did:plc:test/forum.barazo.topic.post/topic1', cid: 'bafytopic' }, + community: 'did:plc:community', + createdAt: '2026-01-01T00:00:00.000Z', }, - }); + }) - expect(db.update).toHaveBeenCalledTimes(1); - }); - }); + expect(db.update).toHaveBeenCalledTimes(1) + }) + }) - describe("handleDelete", () => { - it("deletes a reply and decrements count in a transaction", async () => { - const db = createMockDb(); - const logger = createMockLogger(); - const indexer = new ReplyIndexer(db as never, logger as never); + describe('handleDelete', () => { + it('deletes a reply and decrements count in a transaction', async () => { + const db = createMockDb() + const logger = createMockLogger() + const indexer = new ReplyIndexer(db as never, logger as never) await indexer.handleDelete({ uri: baseParams.uri, rkey: baseParams.rkey, did: baseParams.did, - rootUri: "at://did:plc:test/forum.barazo.topic.post/topic1", - }); + rootUri: 'at://did:plc:test/forum.barazo.topic.post/topic1', + }) - expect(db.transaction).toHaveBeenCalledTimes(1); - }); - }); -}); + expect(db.transaction).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/tests/unit/firehose/indexers/topic.test.ts b/tests/unit/firehose/indexers/topic.test.ts index 4a25144..196b85b 100644 --- a/tests/unit/firehose/indexers/topic.test.ts +++ b/tests/unit/firehose/indexers/topic.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from "vitest"; -import { TopicIndexer } from "../../../../src/firehose/indexers/topic.js"; +import { describe, it, expect, vi } from 'vitest' +import { TopicIndexer } from '../../../../src/firehose/indexers/topic.js' function createMockDb() { return { @@ -16,7 +16,7 @@ function createMockDb() { delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined), }), - }; + } } function createMockLogger() { @@ -25,96 +25,96 @@ function createMockLogger() { error: vi.fn(), warn: vi.fn(), debug: vi.fn(), - }; + } } -describe("TopicIndexer", () => { +describe('TopicIndexer', () => { const baseParams = { - uri: "at://did:plc:test/forum.barazo.topic.post/abc123", - rkey: "abc123", - did: "did:plc:test", - cid: "bafyabc", + uri: 'at://did:plc:test/forum.barazo.topic.post/abc123', + rkey: 'abc123', + did: 'did:plc:test', + cid: 'bafyabc', live: true, - }; + } - describe("handleCreate", () => { - it("upserts a topic record", async () => { - const db = createMockDb(); - const logger = createMockLogger(); - const indexer = new TopicIndexer(db as never, logger as never); + describe('handleCreate', () => { + it('upserts a topic record', async () => { + const db = createMockDb() + const logger = createMockLogger() + const indexer = new TopicIndexer(db as never, logger as never) await indexer.handleCreate({ ...baseParams, record: { - title: "Test Topic", - content: "Content here", - contentFormat: "markdown", - community: "did:plc:community", - category: "general", - tags: ["test"], - createdAt: "2026-01-01T00:00:00.000Z", + title: 'Test Topic', + content: 'Content here', + contentFormat: 'markdown', + community: 'did:plc:community', + category: 'general', + tags: ['test'], + createdAt: '2026-01-01T00:00:00.000Z', }, - }); + }) - expect(db.insert).toHaveBeenCalledTimes(1); - }); + expect(db.insert).toHaveBeenCalledTimes(1) + }) - it("includes labels when present", async () => { - const db = createMockDb(); - const logger = createMockLogger(); - const indexer = new TopicIndexer(db as never, logger as never); + it('includes labels when present', async () => { + const db = createMockDb() + const logger = createMockLogger() + const indexer = new TopicIndexer(db as never, logger as never) await indexer.handleCreate({ ...baseParams, record: { - title: "Test", - content: "Content", - community: "did:plc:community", - category: "general", - labels: { values: [{ val: "nsfw" }] }, - createdAt: "2026-01-01T00:00:00.000Z", + title: 'Test', + content: 'Content', + community: 'did:plc:community', + category: 'general', + labels: { values: [{ val: 'nsfw' }] }, + createdAt: '2026-01-01T00:00:00.000Z', }, - }); + }) - expect(db.insert).toHaveBeenCalledTimes(1); - }); - }); + expect(db.insert).toHaveBeenCalledTimes(1) + }) + }) - describe("handleUpdate", () => { - it("updates topic content", async () => { - const db = createMockDb(); - const logger = createMockLogger(); - const indexer = new TopicIndexer(db as never, logger as never); + describe('handleUpdate', () => { + it('updates topic content', async () => { + const db = createMockDb() + const logger = createMockLogger() + const indexer = new TopicIndexer(db as never, logger as never) await indexer.handleUpdate({ ...baseParams, record: { - title: "Updated Title", - content: "Updated content", - community: "did:plc:community", - category: "updated", - tags: ["updated"], - createdAt: "2026-01-01T00:00:00.000Z", + title: 'Updated Title', + content: 'Updated content', + community: 'did:plc:community', + category: 'updated', + tags: ['updated'], + createdAt: '2026-01-01T00:00:00.000Z', }, - }); + }) - expect(db.update).toHaveBeenCalledTimes(1); - }); - }); + expect(db.update).toHaveBeenCalledTimes(1) + }) + }) - describe("handleDelete", () => { - it("deletes a topic by URI", async () => { - const db = createMockDb(); - const logger = createMockLogger(); - const indexer = new TopicIndexer(db as never, logger as never); + describe('handleDelete', () => { + it('deletes a topic by URI', async () => { + const db = createMockDb() + const logger = createMockLogger() + const indexer = new TopicIndexer(db as never, logger as never) await indexer.handleDelete({ uri: baseParams.uri, rkey: baseParams.rkey, did: baseParams.did, - }); + }) - expect(db.delete).toHaveBeenCalledTimes(1); - }); - }); -}); + expect(db.delete).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/tests/unit/firehose/repo-manager.test.ts b/tests/unit/firehose/repo-manager.test.ts index fc0613c..cb1f3c1 100644 --- a/tests/unit/firehose/repo-manager.test.ts +++ b/tests/unit/firehose/repo-manager.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from "vitest"; -import { RepoManager } from "../../../src/firehose/repo-manager.js"; +import { describe, it, expect, vi } from 'vitest' +import { RepoManager } from '../../../src/firehose/repo-manager.js' function createMockDb() { return { @@ -14,14 +14,14 @@ function createMockDb() { select: vi.fn().mockReturnValue({ from: vi.fn().mockResolvedValue([]), }), - }; + } } function createMockTap() { return { addRepos: vi.fn<(dids: string[]) => Promise>().mockResolvedValue(undefined), removeRepos: vi.fn<(dids: string[]) => Promise>().mockResolvedValue(undefined), - }; + } } function createMockLogger() { @@ -30,102 +30,102 @@ function createMockLogger() { error: vi.fn(), warn: vi.fn(), debug: vi.fn(), - }; + } } -describe("RepoManager", () => { - describe("trackRepo", () => { - it("inserts into tracked_repos and calls tap.addRepos", async () => { - const db = createMockDb(); - const tap = createMockTap(); - const logger = createMockLogger(); - const manager = new RepoManager(db as never, tap, logger as never); - - await manager.trackRepo("did:plc:test"); - - expect(db.insert).toHaveBeenCalledTimes(1); - expect(tap.addRepos).toHaveBeenCalledWith(["did:plc:test"]); - }); - }); - - describe("untrackRepo", () => { - it("deletes from tracked_repos and calls tap.removeRepos", async () => { - const db = createMockDb(); - const tap = createMockTap(); - const logger = createMockLogger(); - const manager = new RepoManager(db as never, tap, logger as never); - - await manager.untrackRepo("did:plc:test"); - - expect(db.delete).toHaveBeenCalledTimes(1); - expect(tap.removeRepos).toHaveBeenCalledWith(["did:plc:test"]); - }); - }); - - describe("restoreTrackedRepos", () => { - it("loads all DIDs and calls addRepos in batches", async () => { - const db = createMockDb(); +describe('RepoManager', () => { + describe('trackRepo', () => { + it('inserts into tracked_repos and calls tap.addRepos', async () => { + const db = createMockDb() + const tap = createMockTap() + const logger = createMockLogger() + const manager = new RepoManager(db as never, tap, logger as never) + + await manager.trackRepo('did:plc:test') + + expect(db.insert).toHaveBeenCalledTimes(1) + expect(tap.addRepos).toHaveBeenCalledWith(['did:plc:test']) + }) + }) + + describe('untrackRepo', () => { + it('deletes from tracked_repos and calls tap.removeRepos', async () => { + const db = createMockDb() + const tap = createMockTap() + const logger = createMockLogger() + const manager = new RepoManager(db as never, tap, logger as never) + + await manager.untrackRepo('did:plc:test') + + expect(db.delete).toHaveBeenCalledTimes(1) + expect(tap.removeRepos).toHaveBeenCalledWith(['did:plc:test']) + }) + }) + + describe('restoreTrackedRepos', () => { + it('loads all DIDs and calls addRepos in batches', async () => { + const db = createMockDb() const dids = Array.from({ length: 150 }, (_, i) => ({ did: `did:plc:user${String(i)}`, - })); + })) db.select.mockReturnValue({ from: vi.fn().mockResolvedValue(dids), - }); + }) - const tap = createMockTap(); - const logger = createMockLogger(); - const manager = new RepoManager(db as never, tap, logger as never); + const tap = createMockTap() + const logger = createMockLogger() + const manager = new RepoManager(db as never, tap, logger as never) - await manager.restoreTrackedRepos(); + await manager.restoreTrackedRepos() - expect(tap.addRepos).toHaveBeenCalledTimes(2); - const firstCall = tap.addRepos.mock.calls[0] as [string[]]; - expect(firstCall[0]).toHaveLength(100); - const secondCall = tap.addRepos.mock.calls[1] as [string[]]; - expect(secondCall[0]).toHaveLength(50); - }); + expect(tap.addRepos).toHaveBeenCalledTimes(2) + const firstCall = tap.addRepos.mock.calls[0] as [string[]] + expect(firstCall[0]).toHaveLength(100) + const secondCall = tap.addRepos.mock.calls[1] as [string[]] + expect(secondCall[0]).toHaveLength(50) + }) - it("does nothing when no repos are tracked", async () => { - const db = createMockDb(); - const tap = createMockTap(); - const logger = createMockLogger(); - const manager = new RepoManager(db as never, tap, logger as never); + it('does nothing when no repos are tracked', async () => { + const db = createMockDb() + const tap = createMockTap() + const logger = createMockLogger() + const manager = new RepoManager(db as never, tap, logger as never) - await manager.restoreTrackedRepos(); + await manager.restoreTrackedRepos() - expect(tap.addRepos).not.toHaveBeenCalled(); - }); - }); + expect(tap.addRepos).not.toHaveBeenCalled() + }) + }) - describe("isTracked", () => { - it("returns true when DID is tracked", async () => { - const db = createMockDb(); + describe('isTracked', () => { + it('returns true when DID is tracked', async () => { + const db = createMockDb() db.select.mockReturnValue({ from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ did: "did:plc:test" }]), + where: vi.fn().mockResolvedValue([{ did: 'did:plc:test' }]), }), - }); - const tap = createMockTap(); - const logger = createMockLogger(); - const manager = new RepoManager(db as never, tap, logger as never); + }) + const tap = createMockTap() + const logger = createMockLogger() + const manager = new RepoManager(db as never, tap, logger as never) - const result = await manager.isTracked("did:plc:test"); - expect(result).toBe(true); - }); + const result = await manager.isTracked('did:plc:test') + expect(result).toBe(true) + }) - it("returns false when DID is not tracked", async () => { - const db = createMockDb(); + it('returns false when DID is not tracked', async () => { + const db = createMockDb() db.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), - }); - const tap = createMockTap(); - const logger = createMockLogger(); - const manager = new RepoManager(db as never, tap, logger as never); - - const result = await manager.isTracked("did:plc:unknown"); - expect(result).toBe(false); - }); - }); -}); + }) + const tap = createMockTap() + const logger = createMockLogger() + const manager = new RepoManager(db as never, tap, logger as never) + + const result = await manager.isTracked('did:plc:unknown') + expect(result).toBe(false) + }) + }) +}) diff --git a/tests/unit/firehose/service.test.ts b/tests/unit/firehose/service.test.ts index af116a3..358afd6 100644 --- a/tests/unit/firehose/service.test.ts +++ b/tests/unit/firehose/service.test.ts @@ -1,32 +1,32 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { FirehoseService } from "../../../src/firehose/service.js"; -import type { Env } from "../../../src/config/env.js"; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { FirehoseService } from '../../../src/firehose/service.js' +import type { Env } from '../../../src/config/env.js' // Mock the Tap and SimpleIndexer from @atproto/tap -vi.mock("@atproto/tap", () => { +vi.mock('@atproto/tap', () => { const mockChannel = { start: vi.fn().mockResolvedValue(undefined), destroy: vi.fn().mockResolvedValue(undefined), - }; + } class MockTap { - addRepos = vi.fn().mockResolvedValue(undefined); - removeRepos = vi.fn().mockResolvedValue(undefined); - channel = vi.fn().mockReturnValue(mockChannel); + addRepos = vi.fn().mockResolvedValue(undefined) + removeRepos = vi.fn().mockResolvedValue(undefined) + channel = vi.fn().mockReturnValue(mockChannel) } class MockSimpleIndexer { - identity = vi.fn().mockReturnThis(); - record = vi.fn().mockReturnThis(); - error = vi.fn().mockReturnThis(); + identity = vi.fn().mockReturnThis() + record = vi.fn().mockReturnThis() + error = vi.fn().mockReturnThis() } return { Tap: MockTap, SimpleIndexer: MockSimpleIndexer, _mockChannel: mockChannel, - }; -}); + } +}) function createMockDb() { return { @@ -50,7 +50,7 @@ function createMockDb() { where: vi.fn().mockResolvedValue(undefined), }), transaction: vi.fn(), - }; + } } function createMockLogger() { @@ -60,98 +60,98 @@ function createMockLogger() { warn: vi.fn(), debug: vi.fn(), child: vi.fn().mockReturnThis(), - }; + } } function createMinimalEnv(): Env { return { - DATABASE_URL: "postgresql://barazo:barazo_dev@localhost:5432/barazo", - VALKEY_URL: "redis://localhost:6379", - TAP_URL: "http://localhost:2480", - TAP_ADMIN_PASSWORD: "test_secret", - HOST: "0.0.0.0", + DATABASE_URL: 'postgresql://barazo:barazo_dev@localhost:5432/barazo', + VALKEY_URL: 'redis://localhost:6379', + TAP_URL: 'http://localhost:2480', + TAP_ADMIN_PASSWORD: 'test_secret', + HOST: '0.0.0.0', PORT: 3000, - LOG_LEVEL: "silent", - CORS_ORIGINS: "http://localhost:3001", - COMMUNITY_MODE: "single" as const, - COMMUNITY_NAME: "Test Community", + LOG_LEVEL: 'silent', + CORS_ORIGINS: 'http://localhost:3001', + COMMUNITY_MODE: 'single' as const, + COMMUNITY_NAME: 'Test Community', RATE_LIMIT_AUTH: 10, RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, - }; + } } -describe("FirehoseService", () => { - let db: ReturnType; - let logger: ReturnType; - let env: Env; +describe('FirehoseService', () => { + let db: ReturnType + let logger: ReturnType + let env: Env beforeEach(() => { - vi.clearAllMocks(); - db = createMockDb(); - logger = createMockLogger(); - env = createMinimalEnv(); - }); - - describe("lifecycle", () => { - it("creates a service instance", () => { - const service = new FirehoseService(db as never, logger as never, env); - expect(service).toBeDefined(); - }); - - it("starts without throwing", async () => { + vi.clearAllMocks() + db = createMockDb() + logger = createMockLogger() + env = createMinimalEnv() + }) + + describe('lifecycle', () => { + it('creates a service instance', () => { + const service = new FirehoseService(db as never, logger as never, env) + expect(service).toBeDefined() + }) + + it('starts without throwing', async () => { // Mock restoreTrackedRepos to find no repos db.select.mockReturnValue({ from: vi.fn().mockResolvedValue([]), - }); + }) - const service = new FirehoseService(db as never, logger as never, env); - await expect(service.start()).resolves.toBeUndefined(); - }); + const service = new FirehoseService(db as never, logger as never, env) + await expect(service.start()).resolves.toBeUndefined() + }) - it("stops without throwing", async () => { + it('stops without throwing', async () => { db.select.mockReturnValue({ from: vi.fn().mockResolvedValue([]), - }); - - const service = new FirehoseService(db as never, logger as never, env); - await service.start(); - await expect(service.stop()).resolves.toBeUndefined(); - }); - }); - - describe("getStatus", () => { - it("returns status before start", () => { - const service = new FirehoseService(db as never, logger as never, env); - const status = service.getStatus(); - expect(status.connected).toBe(false); - expect(status.lastEventId).toBeNull(); - }); - - it("returns connected status after start", async () => { + }) + + const service = new FirehoseService(db as never, logger as never, env) + await service.start() + await expect(service.stop()).resolves.toBeUndefined() + }) + }) + + describe('getStatus', () => { + it('returns status before start', () => { + const service = new FirehoseService(db as never, logger as never, env) + const status = service.getStatus() + expect(status.connected).toBe(false) + expect(status.lastEventId).toBeNull() + }) + + it('returns connected status after start', async () => { db.select.mockReturnValue({ from: vi.fn().mockResolvedValue([]), - }); + }) - const service = new FirehoseService(db as never, logger as never, env); - await service.start(); - const status = service.getStatus(); - expect(status.connected).toBe(true); - }); - }); + const service = new FirehoseService(db as never, logger as never, env) + await service.start() + const status = service.getStatus() + expect(status.connected).toBe(true) + }) + }) - describe("error handling", () => { - it("does not throw when start fails", async () => { + describe('error handling', () => { + it('does not throw when start fails', async () => { // Make restoreTrackedRepos fail db.select.mockReturnValue({ - from: vi.fn().mockRejectedValue(new Error("DB down")), - }); + from: vi.fn().mockRejectedValue(new Error('DB down')), + }) - const service = new FirehoseService(db as never, logger as never, env); + const service = new FirehoseService(db as never, logger as never, env) // start() should catch errors internally - await expect(service.start()).resolves.toBeUndefined(); - expect(logger.error).toHaveBeenCalled(); - }); - }); -}); + await expect(service.start()).resolves.toBeUndefined() + expect(logger.error).toHaveBeenCalled() + }) + }) +}) diff --git a/tests/unit/firehose/types.test.ts b/tests/unit/firehose/types.test.ts index 79b7c2b..591c14b 100644 --- a/tests/unit/firehose/types.test.ts +++ b/tests/unit/firehose/types.test.ts @@ -1,49 +1,40 @@ -import { describe, it, expect } from "vitest"; -import { - SUPPORTED_COLLECTIONS, - COLLECTION_MAP, -} from "../../../src/firehose/types.js"; - -describe("firehose types", () => { - describe("SUPPORTED_COLLECTIONS", () => { - it("contains topic post collection", () => { - expect(SUPPORTED_COLLECTIONS).toContain("forum.barazo.topic.post"); - }); - - it("contains topic reply collection", () => { - expect(SUPPORTED_COLLECTIONS).toContain("forum.barazo.topic.reply"); - }); - - it("contains reaction collection", () => { - expect(SUPPORTED_COLLECTIONS).toContain( - "forum.barazo.interaction.reaction", - ); - }); - - it("has exactly 3 supported collections", () => { - expect(SUPPORTED_COLLECTIONS).toHaveLength(3); - }); - }); - - describe("COLLECTION_MAP", () => { +import { describe, it, expect } from 'vitest' +import { SUPPORTED_COLLECTIONS, COLLECTION_MAP } from '../../../src/firehose/types.js' + +describe('firehose types', () => { + describe('SUPPORTED_COLLECTIONS', () => { + it('contains topic post collection', () => { + expect(SUPPORTED_COLLECTIONS).toContain('forum.barazo.topic.post') + }) + + it('contains topic reply collection', () => { + expect(SUPPORTED_COLLECTIONS).toContain('forum.barazo.topic.reply') + }) + + it('contains reaction collection', () => { + expect(SUPPORTED_COLLECTIONS).toContain('forum.barazo.interaction.reaction') + }) + + it('has exactly 3 supported collections', () => { + expect(SUPPORTED_COLLECTIONS).toHaveLength(3) + }) + }) + + describe('COLLECTION_MAP', () => { it("maps topic post to 'topic'", () => { - expect(COLLECTION_MAP["forum.barazo.topic.post"]).toBe("topic"); - }); + expect(COLLECTION_MAP['forum.barazo.topic.post']).toBe('topic') + }) it("maps topic reply to 'reply'", () => { - expect(COLLECTION_MAP["forum.barazo.topic.reply"]).toBe("reply"); - }); + expect(COLLECTION_MAP['forum.barazo.topic.reply']).toBe('reply') + }) it("maps reaction to 'reaction'", () => { - expect(COLLECTION_MAP["forum.barazo.interaction.reaction"]).toBe( - "reaction", - ); - }); - - it("returns undefined for unsupported collection", () => { - expect( - COLLECTION_MAP["com.example.unknown" as keyof typeof COLLECTION_MAP], - ).toBeUndefined(); - }); - }); -}); + expect(COLLECTION_MAP['forum.barazo.interaction.reaction']).toBe('reaction') + }) + + it('returns undefined for unsupported collection', () => { + expect(COLLECTION_MAP['com.example.unknown' as keyof typeof COLLECTION_MAP]).toBeUndefined() + }) + }) +}) diff --git a/tests/unit/firehose/validation.test.ts b/tests/unit/firehose/validation.test.ts index 793c514..5f45964 100644 --- a/tests/unit/firehose/validation.test.ts +++ b/tests/unit/firehose/validation.test.ts @@ -1,109 +1,103 @@ -import { describe, it, expect } from "vitest"; -import { validateRecord } from "../../../src/firehose/validation.js"; +import { describe, it, expect } from 'vitest' +import { validateRecord } from '../../../src/firehose/validation.js' -describe("validateRecord", () => { - describe("topic post validation", () => { +describe('validateRecord', () => { + describe('topic post validation', () => { const validTopic = { - title: "Test Topic", - content: "Some content here", - contentFormat: "markdown", - community: "did:plc:abc123", - category: "general", - tags: ["test"], - createdAt: "2026-01-01T00:00:00.000Z", - }; + title: 'Test Topic', + content: 'Some content here', + contentFormat: 'markdown', + community: 'did:plc:abc123', + category: 'general', + tags: ['test'], + createdAt: '2026-01-01T00:00:00.000Z', + } - it("accepts a valid topic post", () => { - const result = validateRecord("forum.barazo.topic.post", validTopic); - expect(result.success).toBe(true); - }); + it('accepts a valid topic post', () => { + const result = validateRecord('forum.barazo.topic.post', validTopic) + expect(result.success).toBe(true) + }) - it("rejects a topic post with missing title", () => { - const { title: _, ...invalid } = validTopic; - const result = validateRecord("forum.barazo.topic.post", invalid); - expect(result.success).toBe(false); - }); + it('rejects a topic post with missing title', () => { + const { title: _, ...invalid } = validTopic + const result = validateRecord('forum.barazo.topic.post', invalid) + expect(result.success).toBe(false) + }) - it("rejects a topic post with empty content", () => { - const result = validateRecord("forum.barazo.topic.post", { + it('rejects a topic post with empty content', () => { + const result = validateRecord('forum.barazo.topic.post', { ...validTopic, - content: "", - }); - expect(result.success).toBe(false); - }); - }); + content: '', + }) + expect(result.success).toBe(false) + }) + }) - describe("topic reply validation", () => { + describe('topic reply validation', () => { const validReply = { - content: "A reply", - root: { uri: "at://did:plc:abc/forum.barazo.topic.post/123", cid: "bafyabc" }, - parent: { uri: "at://did:plc:abc/forum.barazo.topic.post/123", cid: "bafyabc" }, - community: "did:plc:abc123", - createdAt: "2026-01-01T00:00:00.000Z", - }; + content: 'A reply', + root: { uri: 'at://did:plc:abc/forum.barazo.topic.post/123', cid: 'bafyabc' }, + parent: { uri: 'at://did:plc:abc/forum.barazo.topic.post/123', cid: 'bafyabc' }, + community: 'did:plc:abc123', + createdAt: '2026-01-01T00:00:00.000Z', + } - it("accepts a valid topic reply", () => { - const result = validateRecord("forum.barazo.topic.reply", validReply); - expect(result.success).toBe(true); - }); + it('accepts a valid topic reply', () => { + const result = validateRecord('forum.barazo.topic.reply', validReply) + expect(result.success).toBe(true) + }) - it("rejects a reply with missing root ref", () => { - const { root: _, ...invalid } = validReply; - const result = validateRecord("forum.barazo.topic.reply", invalid); - expect(result.success).toBe(false); - }); - }); + it('rejects a reply with missing root ref', () => { + const { root: _, ...invalid } = validReply + const result = validateRecord('forum.barazo.topic.reply', invalid) + expect(result.success).toBe(false) + }) + }) - describe("reaction validation", () => { + describe('reaction validation', () => { const validReaction = { - subject: { uri: "at://did:plc:abc/forum.barazo.topic.post/123", cid: "bafyabc" }, - type: "like", - community: "did:plc:abc123", - createdAt: "2026-01-01T00:00:00.000Z", - }; + subject: { uri: 'at://did:plc:abc/forum.barazo.topic.post/123', cid: 'bafyabc' }, + type: 'like', + community: 'did:plc:abc123', + createdAt: '2026-01-01T00:00:00.000Z', + } - it("accepts a valid reaction", () => { - const result = validateRecord( - "forum.barazo.interaction.reaction", - validReaction, - ); - expect(result.success).toBe(true); - }); + it('accepts a valid reaction', () => { + const result = validateRecord('forum.barazo.interaction.reaction', validReaction) + expect(result.success).toBe(true) + }) - it("rejects a reaction with missing type", () => { - const { type: _, ...invalid } = validReaction; - const result = validateRecord( - "forum.barazo.interaction.reaction", - invalid, - ); - expect(result.success).toBe(false); - }); - }); + it('rejects a reaction with missing type', () => { + const { type: _, ...invalid } = validReaction + const result = validateRecord('forum.barazo.interaction.reaction', invalid) + expect(result.success).toBe(false) + }) + }) - describe("unknown collection", () => { - it("rejects an unknown collection", () => { - const result = validateRecord("com.example.unknown", { foo: "bar" }); - expect(result.success).toBe(false); + describe('unknown collection', () => { + it('rejects an unknown collection', () => { + const result = validateRecord('com.example.unknown', { foo: 'bar' }) + expect(result.success).toBe(false) if (!result.success) { - expect(result.error).toContain("Unsupported collection"); + expect(result.error).toContain('Unsupported collection') } - }); - }); + }) + }) - describe("size limit", () => { - it("rejects records exceeding 64KB", () => { + describe('size limit', () => { + it('rejects records exceeding 64KB', () => { const oversized = { - title: "Test", - content: "x".repeat(65_537), - community: "did:plc:abc123", - category: "general", - createdAt: "2026-01-01T00:00:00.000Z", - }; - const result = validateRecord("forum.barazo.topic.post", oversized); - expect(result.success).toBe(false); + title: 'Test', + content: 'x'.repeat(65_537), + community: 'did:plc:abc123', + category: 'general', + createdAt: '2026-01-01T00:00:00.000Z', + } + const result = validateRecord('forum.barazo.topic.post', oversized) + expect(result.success).toBe(false) if (!result.success) { - expect(result.error).toContain("exceeds maximum size"); + expect(result.error).toContain('exceeds maximum size') } - }); - }); -}); + }) + }) +}) diff --git a/tests/unit/jobs/compute-trust-graph.test.ts b/tests/unit/jobs/compute-trust-graph.test.ts new file mode 100644 index 0000000..2cd98f5 --- /dev/null +++ b/tests/unit/jobs/compute-trust-graph.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createTrustGraphJob } from '../../../src/jobs/compute-trust-graph.js' +import type { TrustGraphJob } from '../../../src/jobs/compute-trust-graph.js' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockLogger() { + return { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn().mockReturnThis(), + } +} + +function createMockTrustGraphService() { + return { + computeTrustScores: vi.fn().mockResolvedValue({ + totalNodes: 10, + totalEdges: 20, + iterations: 5, + converged: true, + durationMs: 42, + }), + getTrustScore: vi.fn().mockResolvedValue(0.5), + } +} + +function createMockSybilDetectorService() { + return { + detectClusters: vi.fn().mockResolvedValue({ + clustersDetected: 1, + totalLowTrustDids: 5, + durationMs: 15, + }), + } +} + +function createMockBehavioralHeuristicsService() { + return { + detectBurstVoting: vi.fn().mockResolvedValue([]), + detectContentSimilarity: vi.fn().mockResolvedValue([]), + detectLowDiversity: vi.fn().mockResolvedValue([]), + runAll: vi.fn().mockResolvedValue([]), + } +} + +describe('TrustGraphJob', () => { + let job: TrustGraphJob + let trustGraphService: ReturnType + let sybilDetectorService: ReturnType + let behavioralHeuristicsService: ReturnType + let logger: ReturnType + + beforeEach(() => { + logger = createMockLogger() + trustGraphService = createMockTrustGraphService() + sybilDetectorService = createMockSybilDetectorService() + behavioralHeuristicsService = createMockBehavioralHeuristicsService() + job = createTrustGraphJob( + trustGraphService as never, + sybilDetectorService as never, + behavioralHeuristicsService as never, + logger as never + ) + }) + + describe('run', () => { + it('should orchestrate trust computation, behavioral heuristics, and sybil detection', async () => { + const result = await job.run('community1') + + expect(trustGraphService.computeTrustScores).toHaveBeenCalledWith('community1') + expect(behavioralHeuristicsService.runAll).toHaveBeenCalledWith('community1') + expect(sybilDetectorService.detectClusters).toHaveBeenCalledWith('community1') + expect(result.trustComputation.totalNodes).toBe(10) + expect(result.behavioralFlags).toEqual([]) + expect(result.sybilDetection.clustersDetected).toBe(1) + expect(result.durationMs).toBeGreaterThanOrEqual(0) + }) + + it('should orchestrate with null communityId for global computation', async () => { + const result = await job.run(null) + + expect(trustGraphService.computeTrustScores).toHaveBeenCalledWith(null) + expect(behavioralHeuristicsService.runAll).toHaveBeenCalledWith(null) + expect(sybilDetectorService.detectClusters).toHaveBeenCalledWith(null) + expect(result.trustComputation.converged).toBe(true) + }) + + it('should log errors on failure', async () => { + const error = new Error('DB connection failed') + trustGraphService.computeTrustScores.mockRejectedValueOnce(error) + + await expect(job.run('community1')).rejects.toThrow('DB connection failed') + expect(logger.error).toHaveBeenCalled() + }) + }) + + describe('getStatus', () => { + it('should return idle status before any run', () => { + const status = job.getStatus() + + expect(status.state).toBe('idle') + expect(status.lastComputedAt).toBeNull() + }) + + it('should return completed status after successful run', async () => { + await job.run('community1') + + const status = job.getStatus() + + expect(status.state).toBe('completed') + expect(status.lastComputedAt).toBeInstanceOf(Date) + expect(status.lastDurationMs).toBeGreaterThanOrEqual(0) + }) + }) +}) diff --git a/tests/unit/lib/anti-spam.test.ts b/tests/unit/lib/anti-spam.test.ts index 1602f8a..15f511f 100644 --- a/tests/unit/lib/anti-spam.test.ts +++ b/tests/unit/lib/anti-spam.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createChainableProxy, createMockDb } from "../../helpers/mock-db.js"; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createChainableProxy, createMockDb } from '../../helpers/mock-db.js' // --------------------------------------------------------------------------- // Mock cache (ioredis-compatible) @@ -8,13 +8,13 @@ import { createChainableProxy, createMockDb } from "../../helpers/mock-db.js"; function createMockCache() { return { get: vi.fn().mockResolvedValue(null), - set: vi.fn().mockResolvedValue("OK"), + set: vi.fn().mockResolvedValue('OK'), del: vi.fn().mockResolvedValue(1), zadd: vi.fn().mockResolvedValue(1), zcard: vi.fn().mockResolvedValue(0), zremrangebyscore: vi.fn().mockResolvedValue(0), expire: vi.fn().mockResolvedValue(1), - }; + } } // Import after mocks set up @@ -29,498 +29,464 @@ import { checkBurstDetection, loadAntiSpamSettings, runAntiSpamChecks, -} from "../../../src/lib/anti-spam.js"; +} from '../../../src/lib/anti-spam.js' // --------------------------------------------------------------------------- // checkWordFilter // --------------------------------------------------------------------------- -describe("checkWordFilter", () => { - it("returns no match for empty filter list", () => { - const result = checkWordFilter("some content here", "Title", []); - expect(result.matches).toBe(false); - expect(result.matchedWords).toHaveLength(0); - }); - - it("matches exact word in content", () => { - const result = checkWordFilter("this is spam content", undefined, ["spam"]); - expect(result.matches).toBe(true); - expect(result.matchedWords).toContain("spam"); - }); - - it("matches word in title", () => { - const result = checkWordFilter("clean content", "Spam title", ["spam"]); - expect(result.matches).toBe(true); - expect(result.matchedWords).toContain("spam"); - }); - - it("is case-insensitive", () => { - const result = checkWordFilter("SPAM here", undefined, ["spam"]); - expect(result.matches).toBe(true); - }); - - it("does NOT match partial words", () => { - const result = checkWordFilter("this is unspammy content", undefined, [ - "spam", - ]); - expect(result.matches).toBe(false); - }); - - it("matches multiple words", () => { - const result = checkWordFilter("spam and scam content", undefined, [ - "spam", - "scam", - "fraud", - ]); - expect(result.matches).toBe(true); - expect(result.matchedWords).toContain("spam"); - expect(result.matchedWords).toContain("scam"); - expect(result.matchedWords).not.toContain("fraud"); - }); - - it("handles special regex characters in filter words", () => { +describe('checkWordFilter', () => { + it('returns no match for empty filter list', () => { + const result = checkWordFilter('some content here', 'Title', []) + expect(result.matches).toBe(false) + expect(result.matchedWords).toHaveLength(0) + }) + + it('matches exact word in content', () => { + const result = checkWordFilter('this is spam content', undefined, ['spam']) + expect(result.matches).toBe(true) + expect(result.matchedWords).toContain('spam') + }) + + it('matches word in title', () => { + const result = checkWordFilter('clean content', 'Spam title', ['spam']) + expect(result.matches).toBe(true) + expect(result.matchedWords).toContain('spam') + }) + + it('is case-insensitive', () => { + const result = checkWordFilter('SPAM here', undefined, ['spam']) + expect(result.matches).toBe(true) + }) + + it('does NOT match partial words', () => { + const result = checkWordFilter('this is unspammy content', undefined, ['spam']) + expect(result.matches).toBe(false) + }) + + it('matches multiple words', () => { + const result = checkWordFilter('spam and scam content', undefined, ['spam', 'scam', 'fraud']) + expect(result.matches).toBe(true) + expect(result.matchedWords).toContain('spam') + expect(result.matchedWords).toContain('scam') + expect(result.matchedWords).not.toContain('fraud') + }) + + it('handles special regex characters in filter words', () => { // Word boundary \b requires a word/non-word transition. // Use a filter term with special regex chars that still has word boundaries. - const result = checkWordFilter( - "visit site.com today", - undefined, - ["site.com"], - ); - expect(result.matches).toBe(true); + const result = checkWordFilter('visit site.com today', undefined, ['site.com']) + expect(result.matches).toBe(true) // The dot is escaped so "siteXcom" should NOT match - const result2 = checkWordFilter( - "visit siteXcom today", - undefined, - ["site.com"], - ); - expect(result2.matches).toBe(false); - }); -}); + const result2 = checkWordFilter('visit siteXcom today', undefined, ['site.com']) + expect(result2.matches).toBe(false) + }) +}) // --------------------------------------------------------------------------- // checkForUrls // --------------------------------------------------------------------------- -describe("checkForUrls", () => { - it("detects http URLs", () => { - expect(checkForUrls("visit http://example.com")).toBe(true); - }); +describe('checkForUrls', () => { + it('detects http URLs', () => { + expect(checkForUrls('visit http://example.com')).toBe(true) + }) - it("detects https URLs", () => { - expect(checkForUrls("visit https://example.com/page")).toBe(true); - }); + it('detects https URLs', () => { + expect(checkForUrls('visit https://example.com/page')).toBe(true) + }) - it("detects www URLs", () => { - expect(checkForUrls("visit www.example.com")).toBe(true); - }); + it('detects www URLs', () => { + expect(checkForUrls('visit www.example.com')).toBe(true) + }) - it("returns false for plain text without URLs", () => { - expect(checkForUrls("just some plain text")).toBe(false); - }); + it('returns false for plain text without URLs', () => { + expect(checkForUrls('just some plain text')).toBe(false) + }) - it("detects URL in middle of text", () => { - expect(checkForUrls("click https://test.io/path here")).toBe(true); - }); -}); + it('detects URL in middle of text', () => { + expect(checkForUrls('click https://test.io/path here')).toBe(true) + }) +}) // --------------------------------------------------------------------------- // isNewAccount // --------------------------------------------------------------------------- -describe("isNewAccount", () => { - const mockDb = createMockDb(); +describe('isNewAccount', () => { + const mockDb = createMockDb() beforeEach(() => { - mockDb.select.mockReset(); - }); + mockDb.select.mockReset() + }) - it("returns true when no trust record exists", async () => { + it('returns true when no trust record exists', async () => { // First query: account_trust -- empty result - const trustChain = createChainableProxy([]); - mockDb.select.mockReturnValueOnce(trustChain); + const trustChain = createChainableProxy([]) + mockDb.select.mockReturnValueOnce(trustChain) - const result = await isNewAccount( - mockDb as never, - "did:plc:user1", - "did:plc:community1", - 7, - ); - expect(result).toBe(true); - }); - - it("returns false when newAccountDays is 0 (disabled)", async () => { - const result = await isNewAccount( - mockDb as never, - "did:plc:user1", - "did:plc:community1", - 0, - ); - expect(result).toBe(false); - }); - - it("returns true when account has approved posts but is recent", async () => { + const result = await isNewAccount(mockDb as never, 'did:plc:user1', 'did:plc:community1', 7) + expect(result).toBe(true) + }) + + it('returns false when newAccountDays is 0 (disabled)', async () => { + const result = await isNewAccount(mockDb as never, 'did:plc:user1', 'did:plc:community1', 0) + expect(result).toBe(false) + }) + + it('returns true when account has approved posts but is recent', async () => { // account_trust query -- has posts - const trustChain = createChainableProxy([{ approvedPostCount: 5 }]); - mockDb.select.mockReturnValueOnce(trustChain); + const trustChain = createChainableProxy([{ approvedPostCount: 5 }]) + mockDb.select.mockReturnValueOnce(trustChain) // users.firstSeenAt -- recent (1 day ago) - const oneDayAgo = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); - const userChain = createChainableProxy([{ firstSeenAt: oneDayAgo }]); - mockDb.select.mockReturnValueOnce(userChain); + const oneDayAgo = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000) + const userChain = createChainableProxy([{ firstSeenAt: oneDayAgo }]) + mockDb.select.mockReturnValueOnce(userChain) - const result = await isNewAccount( - mockDb as never, - "did:plc:user1", - "did:plc:community1", - 7, - ); - expect(result).toBe(true); - }); - - it("returns false when account is old enough", async () => { + const result = await isNewAccount(mockDb as never, 'did:plc:user1', 'did:plc:community1', 7) + expect(result).toBe(true) + }) + + it('returns false when account is old enough', async () => { // account_trust query - const trustChain = createChainableProxy([{ approvedPostCount: 5 }]); - mockDb.select.mockReturnValueOnce(trustChain); + const trustChain = createChainableProxy([{ approvedPostCount: 5 }]) + mockDb.select.mockReturnValueOnce(trustChain) // users.firstSeenAt -- 10 days ago - const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); - const userChain = createChainableProxy([{ firstSeenAt: tenDaysAgo }]); - mockDb.select.mockReturnValueOnce(userChain); + const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000) + const userChain = createChainableProxy([{ firstSeenAt: tenDaysAgo }]) + mockDb.select.mockReturnValueOnce(userChain) - const result = await isNewAccount( - mockDb as never, - "did:plc:user1", - "did:plc:community1", - 7, - ); - expect(result).toBe(false); - }); -}); + const result = await isNewAccount(mockDb as never, 'did:plc:user1', 'did:plc:community1', 7) + expect(result).toBe(false) + }) +}) // --------------------------------------------------------------------------- // isAccountTrusted // --------------------------------------------------------------------------- -describe("isAccountTrusted", () => { - const mockDb = createMockDb(); +describe('isAccountTrusted', () => { + const mockDb = createMockDb() beforeEach(() => { - mockDb.select.mockReset(); - }); + mockDb.select.mockReset() + }) - it("returns false when no trust record exists", async () => { - const chain = createChainableProxy([]); - mockDb.select.mockReturnValue(chain); + it('returns false when no trust record exists', async () => { + const chain = createChainableProxy([]) + mockDb.select.mockReturnValue(chain) const result = await isAccountTrusted( mockDb as never, - "did:plc:user1", - "did:plc:community1", - 10, - ); - expect(result).toBe(false); - }); + 'did:plc:user1', + 'did:plc:community1', + 10 + ) + expect(result).toBe(false) + }) - it("returns true when isTrusted flag is set", async () => { - const chain = createChainableProxy([{ isTrusted: true }]); - mockDb.select.mockReturnValue(chain); + it('returns true when isTrusted flag is set', async () => { + const chain = createChainableProxy([{ isTrusted: true }]) + mockDb.select.mockReturnValue(chain) const result = await isAccountTrusted( mockDb as never, - "did:plc:user1", - "did:plc:community1", - 10, - ); - expect(result).toBe(true); - }); + 'did:plc:user1', + 'did:plc:community1', + 10 + ) + expect(result).toBe(true) + }) - it("returns false when isTrusted flag is false", async () => { - const chain = createChainableProxy([{ isTrusted: false }]); - mockDb.select.mockReturnValue(chain); + it('returns false when isTrusted flag is false', async () => { + const chain = createChainableProxy([{ isTrusted: false }]) + mockDb.select.mockReturnValue(chain) const result = await isAccountTrusted( mockDb as never, - "did:plc:user1", - "did:plc:community1", - 10, - ); - expect(result).toBe(false); - }); -}); + 'did:plc:user1', + 'did:plc:community1', + 10 + ) + expect(result).toBe(false) + }) +}) // --------------------------------------------------------------------------- // needsFirstPostModeration // --------------------------------------------------------------------------- -describe("needsFirstPostModeration", () => { - const mockDb = createMockDb(); +describe('needsFirstPostModeration', () => { + const mockDb = createMockDb() beforeEach(() => { - mockDb.select.mockReset(); - }); + mockDb.select.mockReset() + }) - it("returns false when disabled (count = 0)", async () => { + it('returns false when disabled (count = 0)', async () => { const result = await needsFirstPostModeration( mockDb as never, - "did:plc:user1", - "did:plc:community1", - 0, - ); - expect(result).toBe(false); - }); + 'did:plc:user1', + 'did:plc:community1', + 0 + ) + expect(result).toBe(false) + }) - it("returns true when no trust record exists", async () => { - const chain = createChainableProxy([]); - mockDb.select.mockReturnValue(chain); + it('returns true when no trust record exists', async () => { + const chain = createChainableProxy([]) + mockDb.select.mockReturnValue(chain) const result = await needsFirstPostModeration( mockDb as never, - "did:plc:user1", - "did:plc:community1", - 3, - ); - expect(result).toBe(true); - }); + 'did:plc:user1', + 'did:plc:community1', + 3 + ) + expect(result).toBe(true) + }) - it("returns true when approved count is below threshold", async () => { - const chain = createChainableProxy([{ approvedPostCount: 2 }]); - mockDb.select.mockReturnValue(chain); + it('returns true when approved count is below threshold', async () => { + const chain = createChainableProxy([{ approvedPostCount: 2 }]) + mockDb.select.mockReturnValue(chain) const result = await needsFirstPostModeration( mockDb as never, - "did:plc:user1", - "did:plc:community1", - 3, - ); - expect(result).toBe(true); - }); + 'did:plc:user1', + 'did:plc:community1', + 3 + ) + expect(result).toBe(true) + }) - it("returns false when approved count meets threshold", async () => { - const chain = createChainableProxy([{ approvedPostCount: 3 }]); - mockDb.select.mockReturnValue(chain); + it('returns false when approved count meets threshold', async () => { + const chain = createChainableProxy([{ approvedPostCount: 3 }]) + mockDb.select.mockReturnValue(chain) const result = await needsFirstPostModeration( mockDb as never, - "did:plc:user1", - "did:plc:community1", - 3, - ); - expect(result).toBe(false); - }); -}); + 'did:plc:user1', + 'did:plc:community1', + 3 + ) + expect(result).toBe(false) + }) +}) // --------------------------------------------------------------------------- // canCreateTopic // --------------------------------------------------------------------------- -describe("canCreateTopic", () => { - const mockDb = createMockDb(); +describe('canCreateTopic', () => { + const mockDb = createMockDb() beforeEach(() => { - mockDb.select.mockReset(); - }); + mockDb.select.mockReset() + }) - it("returns true when feature is disabled", async () => { + it('returns true when feature is disabled', async () => { const result = await canCreateTopic( mockDb as never, - "did:plc:user1", - "did:plc:community1", - false, - ); - expect(result).toBe(true); - }); + 'did:plc:user1', + 'did:plc:community1', + false + ) + expect(result).toBe(true) + }) - it("returns false when no trust record and feature is enabled", async () => { - const chain = createChainableProxy([]); - mockDb.select.mockReturnValue(chain); + it('returns false when no trust record and feature is enabled', async () => { + const chain = createChainableProxy([]) + mockDb.select.mockReturnValue(chain) const result = await canCreateTopic( mockDb as never, - "did:plc:user1", - "did:plc:community1", - true, - ); - expect(result).toBe(false); - }); + 'did:plc:user1', + 'did:plc:community1', + true + ) + expect(result).toBe(false) + }) - it("returns false when approved post count is 0", async () => { - const chain = createChainableProxy([{ approvedPostCount: 0 }]); - mockDb.select.mockReturnValue(chain); + it('returns false when approved post count is 0', async () => { + const chain = createChainableProxy([{ approvedPostCount: 0 }]) + mockDb.select.mockReturnValue(chain) const result = await canCreateTopic( mockDb as never, - "did:plc:user1", - "did:plc:community1", - true, - ); - expect(result).toBe(false); - }); + 'did:plc:user1', + 'did:plc:community1', + true + ) + expect(result).toBe(false) + }) - it("returns true when approved post count > 0", async () => { - const chain = createChainableProxy([{ approvedPostCount: 1 }]); - mockDb.select.mockReturnValue(chain); + it('returns true when approved post count > 0', async () => { + const chain = createChainableProxy([{ approvedPostCount: 1 }]) + mockDb.select.mockReturnValue(chain) const result = await canCreateTopic( mockDb as never, - "did:plc:user1", - "did:plc:community1", - true, - ); - expect(result).toBe(true); - }); -}); + 'did:plc:user1', + 'did:plc:community1', + true + ) + expect(result).toBe(true) + }) +}) // --------------------------------------------------------------------------- // checkWriteRateLimit // --------------------------------------------------------------------------- -describe("checkWriteRateLimit", () => { - it("returns false when under the limit", async () => { - const cache = createMockCache(); - cache.zcard.mockResolvedValue(2); +describe('checkWriteRateLimit', () => { + it('returns false when under the limit', async () => { + const cache = createMockCache() + cache.zcard.mockResolvedValue(2) const result = await checkWriteRateLimit( cache as never, - "did:plc:user1", - "did:plc:community1", + 'did:plc:user1', + 'did:plc:community1', true, // new account { newAccountWriteRatePerMin: 3, establishedWriteRatePerMin: 10, - } as never, - ); - expect(result).toBe(false); - }); + } as never + ) + expect(result).toBe(false) + }) - it("returns true when at the limit", async () => { - const cache = createMockCache(); - cache.zcard.mockResolvedValue(3); + it('returns true when at the limit', async () => { + const cache = createMockCache() + cache.zcard.mockResolvedValue(3) const result = await checkWriteRateLimit( cache as never, - "did:plc:user1", - "did:plc:community1", + 'did:plc:user1', + 'did:plc:community1', true, { newAccountWriteRatePerMin: 3, establishedWriteRatePerMin: 10, - } as never, - ); - expect(result).toBe(true); - }); + } as never + ) + expect(result).toBe(true) + }) - it("uses established rate for non-new accounts", async () => { - const cache = createMockCache(); - cache.zcard.mockResolvedValue(5); + it('uses established rate for non-new accounts', async () => { + const cache = createMockCache() + cache.zcard.mockResolvedValue(5) const result = await checkWriteRateLimit( cache as never, - "did:plc:user1", - "did:plc:community1", + 'did:plc:user1', + 'did:plc:community1', false, { newAccountWriteRatePerMin: 3, establishedWriteRatePerMin: 10, - } as never, - ); - expect(result).toBe(false); - }); + } as never + ) + expect(result).toBe(false) + }) - it("fails open when cache errors", async () => { - const cache = createMockCache(); - cache.zremrangebyscore.mockRejectedValue(new Error("connection lost")); + it('fails open when cache errors', async () => { + const cache = createMockCache() + cache.zremrangebyscore.mockRejectedValue(new Error('connection lost')) const result = await checkWriteRateLimit( cache as never, - "did:plc:user1", - "did:plc:community1", + 'did:plc:user1', + 'did:plc:community1', true, { newAccountWriteRatePerMin: 3, establishedWriteRatePerMin: 10, - } as never, - ); - expect(result).toBe(false); - }); -}); + } as never + ) + expect(result).toBe(false) + }) +}) // --------------------------------------------------------------------------- // checkBurstDetection // --------------------------------------------------------------------------- -describe("checkBurstDetection", () => { - it("returns false when under threshold", async () => { - const cache = createMockCache(); - cache.zcard.mockResolvedValue(3); +describe('checkBurstDetection', () => { + it('returns false when under threshold', async () => { + const cache = createMockCache() + cache.zcard.mockResolvedValue(3) const result = await checkBurstDetection( cache as never, - "did:plc:user1", - "did:plc:community1", - { burstPostCount: 5, burstWindowMinutes: 10 } as never, - ); - expect(result).toBe(false); - }); + 'did:plc:user1', + 'did:plc:community1', + { burstPostCount: 5, burstWindowMinutes: 10 } as never + ) + expect(result).toBe(false) + }) - it("returns true when at threshold", async () => { - const cache = createMockCache(); - cache.zcard.mockResolvedValue(5); + it('returns true when at threshold', async () => { + const cache = createMockCache() + cache.zcard.mockResolvedValue(5) const result = await checkBurstDetection( cache as never, - "did:plc:user1", - "did:plc:community1", - { burstPostCount: 5, burstWindowMinutes: 10 } as never, - ); - expect(result).toBe(true); - }); + 'did:plc:user1', + 'did:plc:community1', + { burstPostCount: 5, burstWindowMinutes: 10 } as never + ) + expect(result).toBe(true) + }) - it("fails open when cache errors", async () => { - const cache = createMockCache(); - cache.zremrangebyscore.mockRejectedValue(new Error("connection lost")); + it('fails open when cache errors', async () => { + const cache = createMockCache() + cache.zremrangebyscore.mockRejectedValue(new Error('connection lost')) const result = await checkBurstDetection( cache as never, - "did:plc:user1", - "did:plc:community1", - { burstPostCount: 5, burstWindowMinutes: 10 } as never, - ); - expect(result).toBe(false); - }); -}); + 'did:plc:user1', + 'did:plc:community1', + { burstPostCount: 5, burstWindowMinutes: 10 } as never + ) + expect(result).toBe(false) + }) +}) // --------------------------------------------------------------------------- // loadAntiSpamSettings // --------------------------------------------------------------------------- -describe("loadAntiSpamSettings", () => { - const mockDb = createMockDb(); +describe('loadAntiSpamSettings', () => { + const mockDb = createMockDb() beforeEach(() => { - mockDb.select.mockReset(); - }); + mockDb.select.mockReset() + }) - it("returns defaults when no settings exist", async () => { - const cache = createMockCache(); - const chain = createChainableProxy([]); - mockDb.select.mockReturnValue(chain); + it('returns defaults when no settings exist', async () => { + const cache = createMockCache() + const chain = createChainableProxy([]) + mockDb.select.mockReturnValue(chain) const settings = await loadAntiSpamSettings( mockDb as never, cache as never, - "did:plc:community1", - ); + 'did:plc:community1' + ) - expect(settings.firstPostQueueCount).toBe(3); - expect(settings.newAccountDays).toBe(7); - expect(settings.linkHoldEnabled).toBe(true); - expect(settings.burstPostCount).toBe(5); - }); + expect(settings.firstPostQueueCount).toBe(3) + expect(settings.newAccountDays).toBe(7) + expect(settings.linkHoldEnabled).toBe(true) + expect(settings.burstPostCount).toBe(5) + }) - it("returns cached settings when available", async () => { - const cache = createMockCache(); + it('returns cached settings when available', async () => { + const cache = createMockCache() const cached = JSON.stringify({ - wordFilter: ["bad"], + wordFilter: ['bad'], firstPostQueueCount: 5, newAccountDays: 14, newAccountWriteRatePerMin: 2, @@ -530,36 +496,36 @@ describe("loadAntiSpamSettings", () => { burstPostCount: 10, burstWindowMinutes: 5, trustedPostThreshold: 20, - }); - cache.get.mockResolvedValue(cached); + }) + cache.get.mockResolvedValue(cached) const settings = await loadAntiSpamSettings( mockDb as never, cache as never, - "did:plc:community1", - ); + 'did:plc:community1' + ) - expect(settings.firstPostQueueCount).toBe(5); - expect(settings.newAccountDays).toBe(14); - expect(settings.wordFilter).toEqual(["bad"]); + expect(settings.firstPostQueueCount).toBe(5) + expect(settings.newAccountDays).toBe(14) + expect(settings.wordFilter).toEqual(['bad']) // Should not query DB - expect(mockDb.select).not.toHaveBeenCalled(); - }); -}); + expect(mockDb.select).not.toHaveBeenCalled() + }) +}) // --------------------------------------------------------------------------- // runAntiSpamChecks (orchestrator) // --------------------------------------------------------------------------- -describe("runAntiSpamChecks", () => { - const mockDb = createMockDb(); +describe('runAntiSpamChecks', () => { + const mockDb = createMockDb() beforeEach(() => { - mockDb.select.mockReset(); - }); + mockDb.select.mockReset() + }) - it("bypasses all checks for trusted accounts", async () => { - const cache = createMockCache(); + it('bypasses all checks for trusted accounts', async () => { + const cache = createMockCache() // loadAntiSpamSettings - return defaults const settingsChain = createChainableProxy([ @@ -577,29 +543,29 @@ describe("runAntiSpamChecks", () => { burstWindowMinutes: 10, trustedPostThreshold: 10, }, - wordFilter: ["badword"], + wordFilter: ['badword'], }, - ]); - mockDb.select.mockReturnValueOnce(settingsChain); + ]) + mockDb.select.mockReturnValueOnce(settingsChain) // isAccountTrusted - return true - const trustChain = createChainableProxy([{ isTrusted: true }]); - mockDb.select.mockReturnValueOnce(trustChain); + const trustChain = createChainableProxy([{ isTrusted: true }]) + mockDb.select.mockReturnValueOnce(trustChain) const result = await runAntiSpamChecks(mockDb as never, cache as never, { - authorDid: "did:plc:trusted", - communityDid: "did:plc:community1", - contentType: "topic", - title: "Contains badword", - content: "This has badword in it", - }); + authorDid: 'did:plc:trusted', + communityDid: 'did:plc:community1', + contentType: 'topic', + title: 'Contains badword', + content: 'This has badword in it', + }) - expect(result.held).toBe(false); - expect(result.reasons).toHaveLength(0); - }); + expect(result.held).toBe(false) + expect(result.reasons).toHaveLength(0) + }) - it("bypasses all checks for moderators", async () => { - const cache = createMockCache(); + it('bypasses all checks for moderators', async () => { + const cache = createMockCache() // loadAntiSpamSettings const settingsChain = createChainableProxy([ @@ -617,33 +583,33 @@ describe("runAntiSpamChecks", () => { burstWindowMinutes: 10, trustedPostThreshold: 10, }, - wordFilter: ["badword"], + wordFilter: ['badword'], }, - ]); - mockDb.select.mockReturnValueOnce(settingsChain); + ]) + mockDb.select.mockReturnValueOnce(settingsChain) // isAccountTrusted - not trusted - const trustChain = createChainableProxy([]); - mockDb.select.mockReturnValueOnce(trustChain); + const trustChain = createChainableProxy([]) + mockDb.select.mockReturnValueOnce(trustChain) // user role check - moderator - const userChain = createChainableProxy([{ role: "moderator" }]); - mockDb.select.mockReturnValueOnce(userChain); + const userChain = createChainableProxy([{ role: 'moderator' }]) + mockDb.select.mockReturnValueOnce(userChain) const result = await runAntiSpamChecks(mockDb as never, cache as never, { - authorDid: "did:plc:moderator", - communityDid: "did:plc:community1", - contentType: "topic", - title: "Contains badword", - content: "This has badword", - }); + authorDid: 'did:plc:moderator', + communityDid: 'did:plc:community1', + contentType: 'topic', + title: 'Contains badword', + content: 'This has badword', + }) - expect(result.held).toBe(false); - expect(result.reasons).toHaveLength(0); - }); + expect(result.held).toBe(false) + expect(result.reasons).toHaveLength(0) + }) - it("flags content matching word filter", async () => { - const cache = createMockCache(); + it('flags content matching word filter', async () => { + const cache = createMockCache() // loadAntiSpamSettings const settingsChain = createChainableProxy([ @@ -661,38 +627,38 @@ describe("runAntiSpamChecks", () => { burstWindowMinutes: 10, trustedPostThreshold: 10, }, - wordFilter: ["spam", "scam"], + wordFilter: ['spam', 'scam'], }, - ]); - mockDb.select.mockReturnValueOnce(settingsChain); + ]) + mockDb.select.mockReturnValueOnce(settingsChain) // isAccountTrusted - false - const trustChain = createChainableProxy([]); - mockDb.select.mockReturnValueOnce(trustChain); + const trustChain = createChainableProxy([]) + mockDb.select.mockReturnValueOnce(trustChain) // user role check - regular user - const userChain = createChainableProxy([{ role: "user" }]); - mockDb.select.mockReturnValueOnce(userChain); + const userChain = createChainableProxy([{ role: 'user' }]) + mockDb.select.mockReturnValueOnce(userChain) // isNewAccount - account_trust empty - const newTrustChain = createChainableProxy([]); - mockDb.select.mockReturnValueOnce(newTrustChain); + const newTrustChain = createChainableProxy([]) + mockDb.select.mockReturnValueOnce(newTrustChain) const result = await runAntiSpamChecks(mockDb as never, cache as never, { - authorDid: "did:plc:newuser", - communityDid: "did:plc:community1", - contentType: "reply", - content: "This is spam content", - }); + authorDid: 'did:plc:newuser', + communityDid: 'did:plc:community1', + contentType: 'reply', + content: 'This is spam content', + }) - expect(result.held).toBe(true); + expect(result.held).toBe(true) expect(result.reasons).toEqual( expect.arrayContaining([ expect.objectContaining({ - reason: "word_filter", - matchedWords: expect.arrayContaining(["spam"]) as string[], + reason: 'word_filter', + matchedWords: expect.arrayContaining(['spam']) as string[], }), - ]), - ); - }); -}); + ]) + ) + }) +}) diff --git a/tests/unit/lib/block-mute.test.ts b/tests/unit/lib/block-mute.test.ts index 5d6bf04..9d9383f 100644 --- a/tests/unit/lib/block-mute.test.ts +++ b/tests/unit/lib/block-mute.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from 'vitest' // --------------------------------------------------------------------------- // We test loadBlockMuteLists as a pure function with a mock DB. @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // We need to import after setting up any mocks, but this module has no // side-effect imports that need mocking, so direct import is fine. -import { loadBlockMuteLists } from "../../../src/lib/block-mute.js"; +import { loadBlockMuteLists } from '../../../src/lib/block-mute.js' // --------------------------------------------------------------------------- // Mock DB @@ -16,79 +16,83 @@ function createMockDb() { const chain = { from: vi.fn(), where: vi.fn(), - }; + } // select().from().where() chain - chain.from.mockReturnValue(chain); - chain.where.mockResolvedValue([]); + chain.from.mockReturnValue(chain) + chain.where.mockResolvedValue([]) return { select: vi.fn().mockReturnValue(chain), chain, - }; + } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describe("loadBlockMuteLists", () => { - let mockDb: ReturnType; +describe('loadBlockMuteLists', () => { + let mockDb: ReturnType beforeEach(() => { - mockDb = createMockDb(); - }); + mockDb = createMockDb() + }) - it("returns empty lists for undefined user (unauthenticated)", async () => { - const result = await loadBlockMuteLists(undefined, mockDb); + it('returns empty lists for undefined user (unauthenticated)', async () => { + const result = await loadBlockMuteLists(undefined, mockDb) - expect(result).toEqual({ blockedDids: [], mutedDids: [] }); + expect(result).toEqual({ blockedDids: [], mutedDids: [] }) // Should not have queried the DB at all - expect(mockDb.select).not.toHaveBeenCalled(); - }); + expect(mockDb.select).not.toHaveBeenCalled() + }) - it("returns lists from preferences when they exist", async () => { - const blockedDids = ["did:plc:blocked1", "did:plc:blocked2"]; - const mutedDids = ["did:plc:muted1"]; + it('returns lists from preferences when they exist', async () => { + const blockedDids = ['did:plc:blocked1', 'did:plc:blocked2'] + const mutedDids = ['did:plc:muted1'] - mockDb.chain.where.mockResolvedValueOnce([{ blockedDids, mutedDids }]); + mockDb.chain.where.mockResolvedValueOnce([{ blockedDids, mutedDids }]) - const result = await loadBlockMuteLists("did:plc:testuser", mockDb); + const result = await loadBlockMuteLists('did:plc:testuser', mockDb) - expect(result).toEqual({ blockedDids, mutedDids }); - expect(mockDb.select).toHaveBeenCalledOnce(); - }); + expect(result).toEqual({ blockedDids, mutedDids }) + expect(mockDb.select).toHaveBeenCalledOnce() + }) - it("returns empty lists when no preferences row exists", async () => { + it('returns empty lists when no preferences row exists', async () => { // Default mock returns empty array (no rows) - mockDb.chain.where.mockResolvedValueOnce([]); - - const result = await loadBlockMuteLists("did:plc:testuser", mockDb); - - expect(result).toEqual({ blockedDids: [], mutedDids: [] }); - expect(mockDb.select).toHaveBeenCalledOnce(); - }); - - it("returns empty blockedDids when field is null in DB", async () => { - mockDb.chain.where.mockResolvedValueOnce([{ - blockedDids: null, - mutedDids: ["did:plc:muted1"], - }]); - - const result = await loadBlockMuteLists("did:plc:testuser", mockDb); - - expect(result.blockedDids).toEqual([]); - expect(result.mutedDids).toEqual(["did:plc:muted1"]); - }); - - it("returns empty mutedDids when field is null in DB", async () => { - mockDb.chain.where.mockResolvedValueOnce([{ - blockedDids: ["did:plc:blocked1"], - mutedDids: null, - }]); - - const result = await loadBlockMuteLists("did:plc:testuser", mockDb); - - expect(result.blockedDids).toEqual(["did:plc:blocked1"]); - expect(result.mutedDids).toEqual([]); - }); -}); + mockDb.chain.where.mockResolvedValueOnce([]) + + const result = await loadBlockMuteLists('did:plc:testuser', mockDb) + + expect(result).toEqual({ blockedDids: [], mutedDids: [] }) + expect(mockDb.select).toHaveBeenCalledOnce() + }) + + it('returns empty blockedDids when field is null in DB', async () => { + mockDb.chain.where.mockResolvedValueOnce([ + { + blockedDids: null, + mutedDids: ['did:plc:muted1'], + }, + ]) + + const result = await loadBlockMuteLists('did:plc:testuser', mockDb) + + expect(result.blockedDids).toEqual([]) + expect(result.mutedDids).toEqual(['did:plc:muted1']) + }) + + it('returns empty mutedDids when field is null in DB', async () => { + mockDb.chain.where.mockResolvedValueOnce([ + { + blockedDids: ['did:plc:blocked1'], + mutedDids: null, + }, + ]) + + const result = await loadBlockMuteLists('did:plc:testuser', mockDb) + + expect(result.blockedDids).toEqual(['did:plc:blocked1']) + expect(result.mutedDids).toEqual([]) + }) +}) diff --git a/tests/unit/lib/content-filter.test.ts b/tests/unit/lib/content-filter.test.ts index 63a8f37..94bc308 100644 --- a/tests/unit/lib/content-filter.test.ts +++ b/tests/unit/lib/content-filter.test.ts @@ -1,118 +1,118 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from 'vitest' import { resolveMaxMaturity, maturityAllows, allowedRatings, -} from "../../../src/lib/content-filter.js"; -import type { MaturityUser } from "../../../src/lib/content-filter.js"; -import type { MaturityRating } from "../../../src/lib/maturity.js"; +} from '../../../src/lib/content-filter.js' +import type { MaturityUser } from '../../../src/lib/content-filter.js' +import type { MaturityRating } from '../../../src/lib/maturity.js' // --------------------------------------------------------------------------- // resolveMaxMaturity // --------------------------------------------------------------------------- -describe("resolveMaxMaturity", () => { +describe('resolveMaxMaturity', () => { it("returns 'safe' for unauthenticated user", () => { - expect(resolveMaxMaturity(undefined)).toBe("safe"); - }); + expect(resolveMaxMaturity(undefined)).toBe('safe') + }) it("returns 'safe' when declaredAge is null", () => { - const user: MaturityUser = { declaredAge: null, maturityPref: "mature" }; - expect(resolveMaxMaturity(user)).toBe("safe"); - }); + const user: MaturityUser = { declaredAge: null, maturityPref: 'mature' } + expect(resolveMaxMaturity(user)).toBe('safe') + }) it("returns 'safe' when declaredAge is 0 (rather not say)", () => { - const user: MaturityUser = { declaredAge: 0, maturityPref: "mature" }; - expect(resolveMaxMaturity(user)).toBe("safe"); - }); + const user: MaturityUser = { declaredAge: 0, maturityPref: 'mature' } + expect(resolveMaxMaturity(user)).toBe('safe') + }) - it("returns maturityPref when declaredAge meets default threshold (16)", () => { - const user: MaturityUser = { declaredAge: 16, maturityPref: "mature" }; - expect(resolveMaxMaturity(user, 16)).toBe("mature"); - }); + it('returns maturityPref when declaredAge meets default threshold (16)', () => { + const user: MaturityUser = { declaredAge: 16, maturityPref: 'mature' } + expect(resolveMaxMaturity(user, 16)).toBe('mature') + }) it("returns 'safe' when declaredAge below community threshold", () => { - const user: MaturityUser = { declaredAge: 14, maturityPref: "mature" }; - expect(resolveMaxMaturity(user, 16)).toBe("safe"); - }); + const user: MaturityUser = { declaredAge: 14, maturityPref: 'mature' } + expect(resolveMaxMaturity(user, 16)).toBe('safe') + }) - it("returns maturityPref when declaredAge meets lower threshold (13)", () => { - const user: MaturityUser = { declaredAge: 13, maturityPref: "mature" }; - expect(resolveMaxMaturity(user, 13)).toBe("mature"); - }); + it('returns maturityPref when declaredAge meets lower threshold (13)', () => { + const user: MaturityUser = { declaredAge: 13, maturityPref: 'mature' } + expect(resolveMaxMaturity(user, 13)).toBe('mature') + }) it("returns 'safe' when declaredAge is 13 and threshold is 14", () => { - const user: MaturityUser = { declaredAge: 13, maturityPref: "mature" }; - expect(resolveMaxMaturity(user, 14)).toBe("safe"); - }); + const user: MaturityUser = { declaredAge: 13, maturityPref: 'mature' } + expect(resolveMaxMaturity(user, 14)).toBe('safe') + }) - it("defaults threshold to 16 when not provided", () => { - const user: MaturityUser = { declaredAge: 16, maturityPref: "mature" }; - expect(resolveMaxMaturity(user)).toBe("mature"); - }); + it('defaults threshold to 16 when not provided', () => { + const user: MaturityUser = { declaredAge: 16, maturityPref: 'mature' } + expect(resolveMaxMaturity(user)).toBe('mature') + }) it("returns 'safe' when declaredAge is undefined", () => { - const user: MaturityUser = { declaredAge: undefined, maturityPref: "adult" }; - expect(resolveMaxMaturity(user)).toBe("safe"); - }); + const user: MaturityUser = { declaredAge: undefined, maturityPref: 'adult' } + expect(resolveMaxMaturity(user)).toBe('safe') + }) it("returns 'adult' when declaredAge meets threshold and pref is adult", () => { - const user: MaturityUser = { declaredAge: 18, maturityPref: "adult" }; - expect(resolveMaxMaturity(user, 16)).toBe("adult"); - }); + const user: MaturityUser = { declaredAge: 18, maturityPref: 'adult' } + expect(resolveMaxMaturity(user, 16)).toBe('adult') + }) it("returns 'safe' when declaredAge meets threshold but pref is safe", () => { - const user: MaturityUser = { declaredAge: 18, maturityPref: "safe" }; - expect(resolveMaxMaturity(user, 16)).toBe("safe"); - }); -}); + const user: MaturityUser = { declaredAge: 18, maturityPref: 'safe' } + expect(resolveMaxMaturity(user, 16)).toBe('safe') + }) +}) // --------------------------------------------------------------------------- // maturityAllows // --------------------------------------------------------------------------- -describe("maturityAllows", () => { +describe('maturityAllows', () => { const cases: Array<[MaturityRating, MaturityRating, boolean]> = [ // [maxAllowed, contentRating, expected] - ["safe", "safe", true], - ["safe", "mature", false], - ["safe", "adult", false], - ["mature", "safe", true], - ["mature", "mature", true], - ["mature", "adult", false], - ["adult", "safe", true], - ["adult", "mature", true], - ["adult", "adult", true], - ]; + ['safe', 'safe', true], + ['safe', 'mature', false], + ['safe', 'adult', false], + ['mature', 'safe', true], + ['mature', 'mature', true], + ['mature', 'adult', false], + ['adult', 'safe', true], + ['adult', 'mature', true], + ['adult', 'adult', true], + ] for (const [maxAllowed, contentRating, expected] of cases) { it(`maxAllowed=${maxAllowed}, content=${contentRating} -> ${String(expected)}`, () => { - expect(maturityAllows(maxAllowed, contentRating)).toBe(expected); - }); + expect(maturityAllows(maxAllowed, contentRating)).toBe(expected) + }) } -}); +}) // --------------------------------------------------------------------------- // allowedRatings // --------------------------------------------------------------------------- -describe("allowedRatings", () => { +describe('allowedRatings', () => { it("returns only 'safe' for safe max level", () => { - expect(allowedRatings("safe")).toEqual(["safe"]); - }); + expect(allowedRatings('safe')).toEqual(['safe']) + }) it("returns 'safe' and 'mature' for mature max level", () => { - const result = allowedRatings("mature"); - expect(result).toHaveLength(2); - expect(result).toContain("safe"); - expect(result).toContain("mature"); - }); - - it("returns all ratings for adult max level", () => { - const result = allowedRatings("adult"); - expect(result).toHaveLength(3); - expect(result).toContain("safe"); - expect(result).toContain("mature"); - expect(result).toContain("adult"); - }); -}); + const result = allowedRatings('mature') + expect(result).toHaveLength(2) + expect(result).toContain('safe') + expect(result).toContain('mature') + }) + + it('returns all ratings for adult max level', () => { + const result = allowedRatings('adult') + expect(result).toHaveLength(3) + expect(result).toContain('safe') + expect(result).toContain('mature') + expect(result).toContain('adult') + }) +}) diff --git a/tests/unit/lib/handle-resolver.test.ts b/tests/unit/lib/handle-resolver.test.ts index 641fc71..9c37c63 100644 --- a/tests/unit/lib/handle-resolver.test.ts +++ b/tests/unit/lib/handle-resolver.test.ts @@ -1,229 +1,217 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createHandleResolver } from "../../../src/lib/handle-resolver.js"; -import type { Cache } from "../../../src/cache/index.js"; -import type { Database } from "../../../src/db/index.js"; -import type { Logger } from "../../../src/lib/logger.js"; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createHandleResolver } from '../../../src/lib/handle-resolver.js' +import type { Cache } from '../../../src/cache/index.js' +import type { Database } from '../../../src/db/index.js' +import type { Logger } from '../../../src/lib/logger.js' // --------------------------------------------------------------------------- // Mock functions // --------------------------------------------------------------------------- -const cacheGetFn = vi.fn<(...args: unknown[]) => Promise>(); -const cacheSetFn = vi.fn<(...args: unknown[]) => Promise>(); +const cacheGetFn = vi.fn<(...args: unknown[]) => Promise>() +const cacheSetFn = vi.fn<(...args: unknown[]) => Promise>() const mockCache = { get: cacheGetFn, set: cacheSetFn, -} as unknown as Cache; +} as unknown as Cache -const dbSelectFn = vi.fn(); +const dbSelectFn = vi.fn() const mockDb = { select: dbSelectFn, -} as unknown as Database; +} as unknown as Database const mockLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), -} as unknown as Logger; +} as unknown as Logger // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:test123456789"; -const TEST_HANDLE = "alice.bsky.social"; +const TEST_DID = 'did:plc:test123456789' +const TEST_HANDLE = 'alice.bsky.social' // --------------------------------------------------------------------------- // Test suite // --------------------------------------------------------------------------- -describe("handle-resolver", () => { +describe('handle-resolver', () => { beforeEach(() => { - vi.clearAllMocks(); - vi.restoreAllMocks(); - }); + vi.clearAllMocks() + vi.restoreAllMocks() + }) - it("returns handle from Valkey cache when available", async () => { - cacheGetFn.mockResolvedValueOnce(TEST_HANDLE); + it('returns handle from Valkey cache when available', async () => { + cacheGetFn.mockResolvedValueOnce(TEST_HANDLE) - const resolver = createHandleResolver(mockCache, mockDb, mockLogger); - const handle = await resolver.resolve(TEST_DID); + const resolver = createHandleResolver(mockCache, mockDb, mockLogger) + const handle = await resolver.resolve(TEST_DID) - expect(handle).toBe(TEST_HANDLE); - expect(cacheGetFn).toHaveBeenCalledWith(`barazo:handle:${TEST_DID}`); - expect(dbSelectFn).not.toHaveBeenCalled(); - }); + expect(handle).toBe(TEST_HANDLE) + expect(cacheGetFn).toHaveBeenCalledWith(`barazo:handle:${TEST_DID}`) + expect(dbSelectFn).not.toHaveBeenCalled() + }) - it("falls back to DB when cache misses", async () => { - cacheGetFn.mockResolvedValueOnce(null); + it('falls back to DB when cache misses', async () => { + cacheGetFn.mockResolvedValueOnce(null) // Mock the Drizzle chain: db.select().from().where().limit() - const limitFn = vi.fn().mockResolvedValueOnce([{ handle: TEST_HANDLE }]); - const whereFn = vi.fn().mockReturnValue({ limit: limitFn }); - const fromFn = vi.fn().mockReturnValue({ where: whereFn }); - dbSelectFn.mockReturnValue({ from: fromFn }); + const limitFn = vi.fn().mockResolvedValueOnce([{ handle: TEST_HANDLE }]) + const whereFn = vi.fn().mockReturnValue({ limit: limitFn }) + const fromFn = vi.fn().mockReturnValue({ where: whereFn }) + dbSelectFn.mockReturnValue({ from: fromFn }) - const resolver = createHandleResolver(mockCache, mockDb, mockLogger); - const handle = await resolver.resolve(TEST_DID); + const resolver = createHandleResolver(mockCache, mockDb, mockLogger) + const handle = await resolver.resolve(TEST_DID) - expect(handle).toBe(TEST_HANDLE); + expect(handle).toBe(TEST_HANDLE) // Should cache the result - expect(cacheSetFn).toHaveBeenCalledWith( - `barazo:handle:${TEST_DID}`, - TEST_HANDLE, - "EX", - 3600, - ); - }); + expect(cacheSetFn).toHaveBeenCalledWith(`barazo:handle:${TEST_DID}`, TEST_HANDLE, 'EX', 3600) + }) - it("skips DB result when handle equals DID (not yet resolved)", async () => { - cacheGetFn.mockResolvedValueOnce(null); + it('skips DB result when handle equals DID (not yet resolved)', async () => { + cacheGetFn.mockResolvedValueOnce(null) // DB has DID as handle (placeholder from before handle resolution) - const limitFn = vi.fn().mockResolvedValueOnce([{ handle: TEST_DID }]); - const whereFn = vi.fn().mockReturnValue({ limit: limitFn }); - const fromFn = vi.fn().mockReturnValue({ where: whereFn }); - dbSelectFn.mockReturnValue({ from: fromFn }); + const limitFn = vi.fn().mockResolvedValueOnce([{ handle: TEST_DID }]) + const whereFn = vi.fn().mockReturnValue({ limit: limitFn }) + const fromFn = vi.fn().mockReturnValue({ where: whereFn }) + dbSelectFn.mockReturnValue({ from: fromFn }) // Mock PLC directory fetch const plcDoc = { id: TEST_DID, alsoKnownAs: [`at://${TEST_HANDLE}`], - }; - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - new Response(JSON.stringify(plcDoc), { status: 200 }), - ); + } + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(plcDoc), { status: 200 }) + ) - const resolver = createHandleResolver(mockCache, mockDb, mockLogger); - const handle = await resolver.resolve(TEST_DID); + const resolver = createHandleResolver(mockCache, mockDb, mockLogger) + const handle = await resolver.resolve(TEST_DID) - expect(handle).toBe(TEST_HANDLE); - }); + expect(handle).toBe(TEST_HANDLE) + }) - it("falls back to PLC directory when cache and DB miss", async () => { - cacheGetFn.mockResolvedValueOnce(null); + it('falls back to PLC directory when cache and DB miss', async () => { + cacheGetFn.mockResolvedValueOnce(null) // DB returns no results - const limitFn = vi.fn().mockResolvedValueOnce([]); - const whereFn = vi.fn().mockReturnValue({ limit: limitFn }); - const fromFn = vi.fn().mockReturnValue({ where: whereFn }); - dbSelectFn.mockReturnValue({ from: fromFn }); + const limitFn = vi.fn().mockResolvedValueOnce([]) + const whereFn = vi.fn().mockReturnValue({ limit: limitFn }) + const fromFn = vi.fn().mockReturnValue({ where: whereFn }) + dbSelectFn.mockReturnValue({ from: fromFn }) // Mock PLC directory fetch const plcDoc = { id: TEST_DID, alsoKnownAs: [`at://${TEST_HANDLE}`], - }; - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - new Response(JSON.stringify(plcDoc), { status: 200 }), - ); + } + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(plcDoc), { status: 200 }) + ) - const resolver = createHandleResolver(mockCache, mockDb, mockLogger); - const handle = await resolver.resolve(TEST_DID); + const resolver = createHandleResolver(mockCache, mockDb, mockLogger) + const handle = await resolver.resolve(TEST_DID) - expect(handle).toBe(TEST_HANDLE); + expect(handle).toBe(TEST_HANDLE) // Should cache the result - expect(cacheSetFn).toHaveBeenCalledWith( - `barazo:handle:${TEST_DID}`, - TEST_HANDLE, - "EX", - 3600, - ); - }); + expect(cacheSetFn).toHaveBeenCalledWith(`barazo:handle:${TEST_DID}`, TEST_HANDLE, 'EX', 3600) + }) - it("returns DID as fallback when all resolution methods fail", async () => { - cacheGetFn.mockResolvedValueOnce(null); + it('returns DID as fallback when all resolution methods fail', async () => { + cacheGetFn.mockResolvedValueOnce(null) // DB returns no results - const limitFn = vi.fn().mockResolvedValueOnce([]); - const whereFn = vi.fn().mockReturnValue({ limit: limitFn }); - const fromFn = vi.fn().mockReturnValue({ where: whereFn }); - dbSelectFn.mockReturnValue({ from: fromFn }); + const limitFn = vi.fn().mockResolvedValueOnce([]) + const whereFn = vi.fn().mockReturnValue({ limit: limitFn }) + const fromFn = vi.fn().mockReturnValue({ where: whereFn }) + dbSelectFn.mockReturnValue({ from: fromFn }) // PLC directory returns 404 - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - new Response("Not found", { status: 404 }), - ); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('Not found', { status: 404 })) - const resolver = createHandleResolver(mockCache, mockDb, mockLogger); - const handle = await resolver.resolve(TEST_DID); + const resolver = createHandleResolver(mockCache, mockDb, mockLogger) + const handle = await resolver.resolve(TEST_DID) - expect(handle).toBe(TEST_DID); - }); + expect(handle).toBe(TEST_DID) + }) - it("handles PLC directory network errors gracefully", async () => { - cacheGetFn.mockResolvedValueOnce(null); + it('handles PLC directory network errors gracefully', async () => { + cacheGetFn.mockResolvedValueOnce(null) // DB returns no results - const limitFn = vi.fn().mockResolvedValueOnce([]); - const whereFn = vi.fn().mockReturnValue({ limit: limitFn }); - const fromFn = vi.fn().mockReturnValue({ where: whereFn }); - dbSelectFn.mockReturnValue({ from: fromFn }); + const limitFn = vi.fn().mockResolvedValueOnce([]) + const whereFn = vi.fn().mockReturnValue({ limit: limitFn }) + const fromFn = vi.fn().mockReturnValue({ where: whereFn }) + dbSelectFn.mockReturnValue({ from: fromFn }) // PLC directory fetch throws - vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("Network error")); + vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('Network error')) - const resolver = createHandleResolver(mockCache, mockDb, mockLogger); - const handle = await resolver.resolve(TEST_DID); + const resolver = createHandleResolver(mockCache, mockDb, mockLogger) + const handle = await resolver.resolve(TEST_DID) // Falls back to DID - expect(handle).toBe(TEST_DID); - }); + expect(handle).toBe(TEST_DID) + }) - it("skips PLC lookup for did:web DIDs", async () => { - const webDid = "did:web:example.com"; - cacheGetFn.mockResolvedValueOnce(null); + it('skips PLC lookup for did:web DIDs', async () => { + const webDid = 'did:web:example.com' + cacheGetFn.mockResolvedValueOnce(null) // DB returns no results - const limitFn = vi.fn().mockResolvedValueOnce([]); - const whereFn = vi.fn().mockReturnValue({ limit: limitFn }); - const fromFn = vi.fn().mockReturnValue({ where: whereFn }); - dbSelectFn.mockReturnValue({ from: fromFn }); + const limitFn = vi.fn().mockResolvedValueOnce([]) + const whereFn = vi.fn().mockReturnValue({ limit: limitFn }) + const fromFn = vi.fn().mockReturnValue({ where: whereFn }) + dbSelectFn.mockReturnValue({ from: fromFn }) - const fetchSpy = vi.spyOn(globalThis, "fetch"); + const fetchSpy = vi.spyOn(globalThis, 'fetch') - const resolver = createHandleResolver(mockCache, mockDb, mockLogger); - const handle = await resolver.resolve(webDid); + const resolver = createHandleResolver(mockCache, mockDb, mockLogger) + const handle = await resolver.resolve(webDid) // Should not call PLC directory for did:web - expect(fetchSpy).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled() // Falls back to DID - expect(handle).toBe(webDid); - }); + expect(handle).toBe(webDid) + }) - it("handles cache errors gracefully and continues resolution", async () => { - cacheGetFn.mockRejectedValueOnce(new Error("Valkey down")); + it('handles cache errors gracefully and continues resolution', async () => { + cacheGetFn.mockRejectedValueOnce(new Error('Valkey down')) // DB has the handle - const limitFn = vi.fn().mockResolvedValueOnce([{ handle: TEST_HANDLE }]); - const whereFn = vi.fn().mockReturnValue({ limit: limitFn }); - const fromFn = vi.fn().mockReturnValue({ where: whereFn }); - dbSelectFn.mockReturnValue({ from: fromFn }); + const limitFn = vi.fn().mockResolvedValueOnce([{ handle: TEST_HANDLE }]) + const whereFn = vi.fn().mockReturnValue({ limit: limitFn }) + const fromFn = vi.fn().mockReturnValue({ where: whereFn }) + dbSelectFn.mockReturnValue({ from: fromFn }) - const resolver = createHandleResolver(mockCache, mockDb, mockLogger); - const handle = await resolver.resolve(TEST_DID); + const resolver = createHandleResolver(mockCache, mockDb, mockLogger) + const handle = await resolver.resolve(TEST_DID) - expect(handle).toBe(TEST_HANDLE); - }); + expect(handle).toBe(TEST_HANDLE) + }) - it("handles missing alsoKnownAs in PLC document", async () => { - cacheGetFn.mockResolvedValueOnce(null); + it('handles missing alsoKnownAs in PLC document', async () => { + cacheGetFn.mockResolvedValueOnce(null) - const limitFn = vi.fn().mockResolvedValueOnce([]); - const whereFn = vi.fn().mockReturnValue({ limit: limitFn }); - const fromFn = vi.fn().mockReturnValue({ where: whereFn }); - dbSelectFn.mockReturnValue({ from: fromFn }); + const limitFn = vi.fn().mockResolvedValueOnce([]) + const whereFn = vi.fn().mockReturnValue({ limit: limitFn }) + const fromFn = vi.fn().mockReturnValue({ where: whereFn }) + dbSelectFn.mockReturnValue({ from: fromFn }) // PLC document without alsoKnownAs - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - new Response(JSON.stringify({ id: TEST_DID }), { status: 200 }), - ); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ id: TEST_DID }), { status: 200 }) + ) - const resolver = createHandleResolver(mockCache, mockDb, mockLogger); - const handle = await resolver.resolve(TEST_DID); + const resolver = createHandleResolver(mockCache, mockDb, mockLogger) + const handle = await resolver.resolve(TEST_DID) - expect(handle).toBe(TEST_DID); - }); -}); + expect(handle).toBe(TEST_DID) + }) +}) diff --git a/tests/unit/lib/jurisdiction.test.ts b/tests/unit/lib/jurisdiction.test.ts index a0199bc..ca4365a 100644 --- a/tests/unit/lib/jurisdiction.test.ts +++ b/tests/unit/lib/jurisdiction.test.ts @@ -1,79 +1,79 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from 'vitest' import { getAgeThreshold, getSupportedCountries, DEFAULT_AGE_THRESHOLD, JURISDICTION_AGE_THRESHOLDS, -} from "../../../src/lib/jurisdiction.js"; +} from '../../../src/lib/jurisdiction.js' -describe("jurisdiction", () => { - describe("getAgeThreshold", () => { - it("returns 13 for Belgium (BE)", () => { - expect(getAgeThreshold("BE")).toBe(13); - }); +describe('jurisdiction', () => { + describe('getAgeThreshold', () => { + it('returns 13 for Belgium (BE)', () => { + expect(getAgeThreshold('BE')).toBe(13) + }) - it("returns 14 for Italy (IT)", () => { - expect(getAgeThreshold("IT")).toBe(14); - }); + it('returns 14 for Italy (IT)', () => { + expect(getAgeThreshold('IT')).toBe(14) + }) - it("returns 15 for France (FR)", () => { - expect(getAgeThreshold("FR")).toBe(15); - }); + it('returns 15 for France (FR)', () => { + expect(getAgeThreshold('FR')).toBe(15) + }) - it("returns 16 for Netherlands (NL)", () => { - expect(getAgeThreshold("NL")).toBe(16); - }); + it('returns 16 for Netherlands (NL)', () => { + expect(getAgeThreshold('NL')).toBe(16) + }) - it("returns 13 for US", () => { - expect(getAgeThreshold("US")).toBe(13); - }); + it('returns 13 for US', () => { + expect(getAgeThreshold('US')).toBe(13) + }) - it("returns default (16) for unknown country", () => { - expect(getAgeThreshold("ZZ")).toBe(DEFAULT_AGE_THRESHOLD); - }); + it('returns default (16) for unknown country', () => { + expect(getAgeThreshold('ZZ')).toBe(DEFAULT_AGE_THRESHOLD) + }) - it("returns default (16) for null", () => { - expect(getAgeThreshold(null)).toBe(DEFAULT_AGE_THRESHOLD); - }); + it('returns default (16) for null', () => { + expect(getAgeThreshold(null)).toBe(DEFAULT_AGE_THRESHOLD) + }) - it("returns default (16) for undefined", () => { - expect(getAgeThreshold(undefined)).toBe(DEFAULT_AGE_THRESHOLD); - }); + it('returns default (16) for undefined', () => { + expect(getAgeThreshold(undefined)).toBe(DEFAULT_AGE_THRESHOLD) + }) - it("handles lowercase country codes", () => { - expect(getAgeThreshold("be")).toBe(13); - expect(getAgeThreshold("nl")).toBe(16); - }); - }); + it('handles lowercase country codes', () => { + expect(getAgeThreshold('be')).toBe(13) + expect(getAgeThreshold('nl')).toBe(16) + }) + }) - describe("getSupportedCountries", () => { - it("returns a sorted array of country codes", () => { - const countries = getSupportedCountries(); - expect(countries.length).toBeGreaterThan(0); - expect(countries).toEqual([...countries].sort()); - }); + describe('getSupportedCountries', () => { + it('returns a sorted array of country codes', () => { + const countries = getSupportedCountries() + expect(countries.length).toBeGreaterThan(0) + expect(countries).toEqual([...countries].sort()) + }) - it("includes expected countries", () => { - const countries = getSupportedCountries(); - expect(countries).toContain("NL"); - expect(countries).toContain("US"); - expect(countries).toContain("DE"); - expect(countries).toContain("FR"); - }); - }); + it('includes expected countries', () => { + const countries = getSupportedCountries() + expect(countries).toContain('NL') + expect(countries).toContain('US') + expect(countries).toContain('DE') + expect(countries).toContain('FR') + }) + }) - describe("JURISDICTION_AGE_THRESHOLDS", () => { - it("all thresholds are between 13 and 18", () => { + describe('JURISDICTION_AGE_THRESHOLDS', () => { + it('all thresholds are between 13 and 18', () => { for (const [code, threshold] of Object.entries(JURISDICTION_AGE_THRESHOLDS)) { - expect(threshold, `${code} threshold out of range`).toBeGreaterThanOrEqual(13); - expect(threshold, `${code} threshold out of range`).toBeLessThanOrEqual(18); + expect(threshold, `${code} threshold out of range`).toBeGreaterThanOrEqual(13) + expect(threshold, `${code} threshold out of range`).toBeLessThanOrEqual(18) } - }); - }); + }) + }) - describe("DEFAULT_AGE_THRESHOLD", () => { - it("is 16", () => { - expect(DEFAULT_AGE_THRESHOLD).toBe(16); - }); - }); -}); + describe('DEFAULT_AGE_THRESHOLD', () => { + it('is 16', () => { + expect(DEFAULT_AGE_THRESHOLD).toBe(16) + }) + }) +}) diff --git a/tests/unit/lib/maturity.test.ts b/tests/unit/lib/maturity.test.ts index 5c40e27..53633af 100644 --- a/tests/unit/lib/maturity.test.ts +++ b/tests/unit/lib/maturity.test.ts @@ -1,80 +1,76 @@ -import { describe, it, expect } from "vitest"; -import { - isMaturityLowerThan, - isMaturityAtMost, - ratingsAtMost, -} from "../../../src/lib/maturity.js"; -import type { MaturityRating } from "../../../src/lib/maturity.js"; +import { describe, it, expect } from 'vitest' +import { isMaturityLowerThan, isMaturityAtMost, ratingsAtMost } from '../../../src/lib/maturity.js' +import type { MaturityRating } from '../../../src/lib/maturity.js' // --------------------------------------------------------------------------- // isMaturityLowerThan // --------------------------------------------------------------------------- -describe("isMaturityLowerThan", () => { +describe('isMaturityLowerThan', () => { const cases: Array<[MaturityRating, MaturityRating, boolean]> = [ - ["safe", "safe", false], - ["safe", "mature", true], - ["safe", "adult", true], - ["mature", "safe", false], - ["mature", "mature", false], - ["mature", "adult", true], - ["adult", "safe", false], - ["adult", "mature", false], - ["adult", "adult", false], - ]; + ['safe', 'safe', false], + ['safe', 'mature', true], + ['safe', 'adult', true], + ['mature', 'safe', false], + ['mature', 'mature', false], + ['mature', 'adult', true], + ['adult', 'safe', false], + ['adult', 'mature', false], + ['adult', 'adult', false], + ] for (const [a, b, expected] of cases) { it(`${a} < ${b} -> ${String(expected)}`, () => { - expect(isMaturityLowerThan(a, b)).toBe(expected); - }); + expect(isMaturityLowerThan(a, b)).toBe(expected) + }) } -}); +}) // --------------------------------------------------------------------------- // isMaturityAtMost // --------------------------------------------------------------------------- -describe("isMaturityAtMost", () => { +describe('isMaturityAtMost', () => { const cases: Array<[MaturityRating, MaturityRating, boolean]> = [ - ["safe", "safe", true], - ["safe", "mature", true], - ["safe", "adult", true], - ["mature", "safe", false], - ["mature", "mature", true], - ["mature", "adult", true], - ["adult", "safe", false], - ["adult", "mature", false], - ["adult", "adult", true], - ]; + ['safe', 'safe', true], + ['safe', 'mature', true], + ['safe', 'adult', true], + ['mature', 'safe', false], + ['mature', 'mature', true], + ['mature', 'adult', true], + ['adult', 'safe', false], + ['adult', 'mature', false], + ['adult', 'adult', true], + ] for (const [a, b, expected] of cases) { it(`${a} <= ${b} -> ${String(expected)}`, () => { - expect(isMaturityAtMost(a, b)).toBe(expected); - }); + expect(isMaturityAtMost(a, b)).toBe(expected) + }) } -}); +}) // --------------------------------------------------------------------------- // ratingsAtMost // --------------------------------------------------------------------------- -describe("ratingsAtMost", () => { +describe('ratingsAtMost', () => { it("returns only 'safe' for safe max level", () => { - expect(ratingsAtMost("safe")).toEqual(["safe"]); - }); + expect(ratingsAtMost('safe')).toEqual(['safe']) + }) it("returns 'safe' and 'mature' for mature max level", () => { - const result = ratingsAtMost("mature"); - expect(result).toHaveLength(2); - expect(result).toContain("safe"); - expect(result).toContain("mature"); - }); + const result = ratingsAtMost('mature') + expect(result).toHaveLength(2) + expect(result).toContain('safe') + expect(result).toContain('mature') + }) - it("returns all ratings for adult max level", () => { - const result = ratingsAtMost("adult"); - expect(result).toHaveLength(3); - expect(result).toContain("safe"); - expect(result).toContain("mature"); - expect(result).toContain("adult"); - }); -}); + it('returns all ratings for adult max level', () => { + const result = ratingsAtMost('adult') + expect(result).toHaveLength(3) + expect(result).toContain('safe') + expect(result).toContain('mature') + expect(result).toContain('adult') + }) +}) diff --git a/tests/unit/lib/muted-words.test.ts b/tests/unit/lib/muted-words.test.ts index a60715d..702afba 100644 --- a/tests/unit/lib/muted-words.test.ts +++ b/tests/unit/lib/muted-words.test.ts @@ -1,8 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { - loadMutedWords, - contentMatchesMutedWords, -} from "../../../src/lib/muted-words.js"; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { loadMutedWords, contentMatchesMutedWords } from '../../../src/lib/muted-words.js' // --------------------------------------------------------------------------- // Mock DB @@ -12,170 +9,130 @@ function createMockDb() { const chain = { from: vi.fn(), where: vi.fn(), - }; - chain.from.mockReturnValue(chain); - chain.where.mockResolvedValue([]); + } + chain.from.mockReturnValue(chain) + chain.where.mockResolvedValue([]) return { select: vi.fn().mockReturnValue(chain), chain, - }; + } } // --------------------------------------------------------------------------- // loadMutedWords // --------------------------------------------------------------------------- -describe("loadMutedWords", () => { - let mockDb: ReturnType; +describe('loadMutedWords', () => { + let mockDb: ReturnType beforeEach(() => { - mockDb = createMockDb(); - }); + mockDb = createMockDb() + }) - it("returns empty array for unauthenticated user", async () => { - const result = await loadMutedWords(undefined, undefined, mockDb); - expect(result).toEqual([]); - expect(mockDb.select).not.toHaveBeenCalled(); - }); + it('returns empty array for unauthenticated user', async () => { + const result = await loadMutedWords(undefined, undefined, mockDb) + expect(result).toEqual([]) + expect(mockDb.select).not.toHaveBeenCalled() + }) - it("returns global muted words when no community override", async () => { - mockDb.chain.where.mockResolvedValueOnce([ - { mutedWords: ["spam", "nsfw"] }, - ]); + it('returns global muted words when no community override', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ mutedWords: ['spam', 'nsfw'] }]) - const result = await loadMutedWords("did:plc:user1", undefined, mockDb); - expect(result).toEqual(["spam", "nsfw"]); - }); + const result = await loadMutedWords('did:plc:user1', undefined, mockDb) + expect(result).toEqual(['spam', 'nsfw']) + }) - it("returns empty array when no preferences row exists", async () => { - mockDb.chain.where.mockResolvedValueOnce([]); + it('returns empty array when no preferences row exists', async () => { + mockDb.chain.where.mockResolvedValueOnce([]) - const result = await loadMutedWords("did:plc:user1", undefined, mockDb); - expect(result).toEqual([]); - }); + const result = await loadMutedWords('did:plc:user1', undefined, mockDb) + expect(result).toEqual([]) + }) - it("returns empty array when mutedWords is null", async () => { - mockDb.chain.where.mockResolvedValueOnce([{ mutedWords: null }]); + it('returns empty array when mutedWords is null', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ mutedWords: null }]) - const result = await loadMutedWords("did:plc:user1", undefined, mockDb); - expect(result).toEqual([]); - }); + const result = await loadMutedWords('did:plc:user1', undefined, mockDb) + expect(result).toEqual([]) + }) - it("merges global + per-community muted words (deduplicated)", async () => { + it('merges global + per-community muted words (deduplicated)', async () => { // First call: global prefs - mockDb.chain.where.mockResolvedValueOnce([ - { mutedWords: ["spam", "crypto"] }, - ]); + mockDb.chain.where.mockResolvedValueOnce([{ mutedWords: ['spam', 'crypto'] }]) // Second call: community prefs - mockDb.chain.where.mockResolvedValueOnce([ - { mutedWords: ["politics", "crypto"] }, - ]); - - const result = await loadMutedWords( - "did:plc:user1", - "did:plc:community1", - mockDb, - ); - expect(result).toEqual( - expect.arrayContaining(["spam", "crypto", "politics"]), - ); - expect(result).toHaveLength(3); // deduplicated - }); - - it("uses only global words when community override is null", async () => { + mockDb.chain.where.mockResolvedValueOnce([{ mutedWords: ['politics', 'crypto'] }]) + + const result = await loadMutedWords('did:plc:user1', 'did:plc:community1', mockDb) + expect(result).toEqual(expect.arrayContaining(['spam', 'crypto', 'politics'])) + expect(result).toHaveLength(3) // deduplicated + }) + + it('uses only global words when community override is null', async () => { // First call: global prefs - mockDb.chain.where.mockResolvedValueOnce([ - { mutedWords: ["spam"] }, - ]); + mockDb.chain.where.mockResolvedValueOnce([{ mutedWords: ['spam'] }]) // Second call: community prefs with null mutedWords - mockDb.chain.where.mockResolvedValueOnce([ - { mutedWords: null }, - ]); - - const result = await loadMutedWords( - "did:plc:user1", - "did:plc:community1", - mockDb, - ); - expect(result).toEqual(["spam"]); - }); -}); + mockDb.chain.where.mockResolvedValueOnce([{ mutedWords: null }]) + + const result = await loadMutedWords('did:plc:user1', 'did:plc:community1', mockDb) + expect(result).toEqual(['spam']) + }) +}) // --------------------------------------------------------------------------- // contentMatchesMutedWords // --------------------------------------------------------------------------- -describe("contentMatchesMutedWords", () => { - it("returns false for empty muted words list", () => { - expect(contentMatchesMutedWords("hello world", [])).toBe(false); - }); - - it("returns false when no words match", () => { - expect(contentMatchesMutedWords("hello world", ["spam", "crypto"])).toBe( - false, - ); - }); - - it("matches case-insensitively", () => { - expect(contentMatchesMutedWords("This is SPAM content", ["spam"])).toBe( - true, - ); - expect(contentMatchesMutedWords("this is spam content", ["SPAM"])).toBe( - true, - ); - }); - - it("matches word boundaries (not partial words)", () => { +describe('contentMatchesMutedWords', () => { + it('returns false for empty muted words list', () => { + expect(contentMatchesMutedWords('hello world', [])).toBe(false) + }) + + it('returns false when no words match', () => { + expect(contentMatchesMutedWords('hello world', ['spam', 'crypto'])).toBe(false) + }) + + it('matches case-insensitively', () => { + expect(contentMatchesMutedWords('This is SPAM content', ['spam'])).toBe(true) + expect(contentMatchesMutedWords('this is spam content', ['SPAM'])).toBe(true) + }) + + it('matches word boundaries (not partial words)', () => { // "class" should NOT match "classification" - expect(contentMatchesMutedWords("classification system", ["class"])).toBe( - false, - ); + expect(contentMatchesMutedWords('classification system', ['class'])).toBe(false) // But should match standalone "class" - expect(contentMatchesMutedWords("this class is good", ["class"])).toBe( - true, - ); - }); - - it("matches at start and end of content", () => { - expect(contentMatchesMutedWords("spam is bad", ["spam"])).toBe(true); - expect(contentMatchesMutedWords("this is spam", ["spam"])).toBe(true); - }); - - it("matches multi-word phrases", () => { - expect( - contentMatchesMutedWords("buy crypto now for gains", ["buy crypto"]), - ).toBe(true); - }); - - it("handles content with punctuation around words", () => { - expect(contentMatchesMutedWords("is this spam?", ["spam"])).toBe(true); - expect(contentMatchesMutedWords("(spam) detected", ["spam"])).toBe(true); - expect(contentMatchesMutedWords("'spam' alert", ["spam"])).toBe(true); - }); - - it("handles empty content", () => { - expect(contentMatchesMutedWords("", ["spam"])).toBe(false); - }); - - it("matches title + content combined", () => { - expect( - contentMatchesMutedWords("Buy now", ["crypto"], "Crypto trading tips"), - ).toBe(true); - }); - - it("returns false when title and content both miss", () => { - expect( - contentMatchesMutedWords("Hello world", ["crypto"], "General discussion"), - ).toBe(false); - }); - - it("escapes regex special characters in muted words", () => { - expect( - contentMatchesMutedWords("price is $100", ["$100"]), - ).toBe(true); - expect( - contentMatchesMutedWords("use (parens) here", ["(parens)"]), - ).toBe(true); - }); -}); + expect(contentMatchesMutedWords('this class is good', ['class'])).toBe(true) + }) + + it('matches at start and end of content', () => { + expect(contentMatchesMutedWords('spam is bad', ['spam'])).toBe(true) + expect(contentMatchesMutedWords('this is spam', ['spam'])).toBe(true) + }) + + it('matches multi-word phrases', () => { + expect(contentMatchesMutedWords('buy crypto now for gains', ['buy crypto'])).toBe(true) + }) + + it('handles content with punctuation around words', () => { + expect(contentMatchesMutedWords('is this spam?', ['spam'])).toBe(true) + expect(contentMatchesMutedWords('(spam) detected', ['spam'])).toBe(true) + expect(contentMatchesMutedWords("'spam' alert", ['spam'])).toBe(true) + }) + + it('handles empty content', () => { + expect(contentMatchesMutedWords('', ['spam'])).toBe(false) + }) + + it('matches title + content combined', () => { + expect(contentMatchesMutedWords('Buy now', ['crypto'], 'Crypto trading tips')).toBe(true) + }) + + it('returns false when title and content both miss', () => { + expect(contentMatchesMutedWords('Hello world', ['crypto'], 'General discussion')).toBe(false) + }) + + it('escapes regex special characters in muted words', () => { + expect(contentMatchesMutedWords('price is $100', ['$100'])).toBe(true) + expect(contentMatchesMutedWords('use (parens) here', ['(parens)'])).toBe(true) + }) +}) diff --git a/tests/unit/lib/onboarding-gate.test.ts b/tests/unit/lib/onboarding-gate.test.ts index 9eb3439..d3ad51d 100644 --- a/tests/unit/lib/onboarding-gate.test.ts +++ b/tests/unit/lib/onboarding-gate.test.ts @@ -1,20 +1,20 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createMockDb, createChainableProxy } from "../../helpers/mock-db.js"; -import type { MockDb } from "../../helpers/mock-db.js"; -import { checkOnboardingComplete } from "../../../src/lib/onboarding-gate.js"; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createMockDb, createChainableProxy } from '../../helpers/mock-db.js' +import type { MockDb } from '../../helpers/mock-db.js' +import { checkOnboardingComplete } from '../../../src/lib/onboarding-gate.js' -const mockDb = createMockDb(); +const mockDb = createMockDb() -const COMMUNITY_DID = "did:plc:community123"; -const USER_DID = "did:plc:testuser123"; -const TEST_NOW = "2026-02-15T12:00:00.000Z"; +const COMMUNITY_DID = 'did:plc:community123' +const USER_DID = 'did:plc:testuser123' +const TEST_NOW = '2026-02-15T12:00:00.000Z' function sampleField(overrides?: Record) { return { - id: "field-001", + id: 'field-001', communityDid: COMMUNITY_DID, - fieldType: "custom_text", - label: "Intro", + fieldType: 'custom_text', + label: 'Intro', description: null, isMandatory: true, sortOrder: 0, @@ -22,118 +22,95 @@ function sampleField(overrides?: Record) { createdAt: new Date(TEST_NOW), updatedAt: new Date(TEST_NOW), ...overrides, - }; + } } function sampleResponse(overrides?: Record) { return { did: USER_DID, communityDid: COMMUNITY_DID, - fieldId: "field-001", - response: "hello", + fieldId: 'field-001', + response: 'hello', completedAt: new Date(TEST_NOW), ...overrides, - }; + } } function resetMocks(): void { - vi.clearAllMocks(); - mockDb.select.mockReset(); - mockDb.insert.mockReturnValue(createChainableProxy()); - mockDb.update.mockReturnValue(createChainableProxy([])); - mockDb.delete.mockReturnValue(createChainableProxy()); + vi.clearAllMocks() + mockDb.select.mockReset() + mockDb.insert.mockReturnValue(createChainableProxy()) + mockDb.update.mockReturnValue(createChainableProxy([])) + mockDb.delete.mockReturnValue(createChainableProxy()) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async for Drizzle transaction mock mockDb.transaction.mockImplementation(async (fn: (tx: MockDb) => Promise) => { - return await fn(mockDb); - }); - mockDb.execute.mockReset(); + return await fn(mockDb) + }) + mockDb.execute.mockReset() } function queueSelectResults(...results: unknown[][]): void { for (const result of results) { - mockDb.select.mockReturnValueOnce(createChainableProxy(result)); + mockDb.select.mockReturnValueOnce(createChainableProxy(result)) } } -describe("checkOnboardingComplete", () => { +describe('checkOnboardingComplete', () => { beforeEach(() => { - resetMocks(); - }); + resetMocks() + }) - it("returns complete=true when community has no onboarding fields", async () => { - queueSelectResults([]); // no mandatory fields + it('returns complete=true when community has no onboarding fields', async () => { + queueSelectResults([]) // no mandatory fields - const result = await checkOnboardingComplete( - mockDb as never, - USER_DID, - COMMUNITY_DID, - ); + const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) - expect(result.complete).toBe(true); - expect(result.missingFields).toEqual([]); - }); + expect(result.complete).toBe(true) + expect(result.missingFields).toEqual([]) + }) - it("returns complete=true when user has completed all mandatory fields", async () => { - const field = sampleField(); - queueSelectResults([field], [sampleResponse()]); + it('returns complete=true when user has completed all mandatory fields', async () => { + const field = sampleField() + queueSelectResults([field], [sampleResponse()]) - const result = await checkOnboardingComplete( - mockDb as never, - USER_DID, - COMMUNITY_DID, - ); + const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) - expect(result.complete).toBe(true); - expect(result.missingFields).toEqual([]); - }); + expect(result.complete).toBe(true) + expect(result.missingFields).toEqual([]) + }) it("returns complete=false with missing fields when user hasn't completed mandatory fields", async () => { - const field = sampleField(); - queueSelectResults([field], []); // no responses + const field = sampleField() + queueSelectResults([field], []) // no responses - const result = await checkOnboardingComplete( - mockDb as never, - USER_DID, - COMMUNITY_DID, - ); + const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) - expect(result.complete).toBe(false); + expect(result.complete).toBe(false) expect(result.missingFields).toEqual([ - { id: "field-001", label: "Intro", fieldType: "custom_text" }, - ]); - }); + { id: 'field-001', label: 'Intro', fieldType: 'custom_text' }, + ]) + }) - it("returns complete=false when some mandatory fields are missing", async () => { - const field1 = sampleField({ id: "field-001", label: "Intro" }); - const field2 = sampleField({ id: "field-002", label: "ToS", fieldType: "tos_acceptance" }); + it('returns complete=false when some mandatory fields are missing', async () => { + const field1 = sampleField({ id: 'field-001', label: 'Intro' }) + const field2 = sampleField({ id: 'field-002', label: 'ToS', fieldType: 'tos_acceptance' }) // Only field-001 answered - queueSelectResults( - [field1, field2], - [sampleResponse({ fieldId: "field-001" })], - ); - - const result = await checkOnboardingComplete( - mockDb as never, - USER_DID, - COMMUNITY_DID, - ); - - expect(result.complete).toBe(false); - expect(result.missingFields).toHaveLength(1); - expect(result.missingFields[0]?.id).toBe("field-002"); - }); - - it("only checks mandatory fields (ignores optional)", async () => { + queueSelectResults([field1, field2], [sampleResponse({ fieldId: 'field-001' })]) + + const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) + + expect(result.complete).toBe(false) + expect(result.missingFields).toHaveLength(1) + expect(result.missingFields[0]?.id).toBe('field-002') + }) + + it('only checks mandatory fields (ignores optional)', async () => { // Only query returns mandatory fields, so optional are not fetched - queueSelectResults([], []); // no mandatory fields, no responses + queueSelectResults([], []) // no mandatory fields, no responses - const result = await checkOnboardingComplete( - mockDb as never, - USER_DID, - COMMUNITY_DID, - ); + const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) - expect(result.complete).toBe(true); - }); -}); + expect(result.complete).toBe(true) + }) +}) diff --git a/tests/unit/lib/resolve-authors.test.ts b/tests/unit/lib/resolve-authors.test.ts index 9951d99..0b52e2e 100644 --- a/tests/unit/lib/resolve-authors.test.ts +++ b/tests/unit/lib/resolve-authors.test.ts @@ -1,99 +1,137 @@ -import { describe, it, expect, vi } from "vitest"; -import { resolveAuthors } from "../../../src/lib/resolve-authors.js"; +import { describe, it, expect, vi } from 'vitest' +import { resolveAuthors } from '../../../src/lib/resolve-authors.js' -function createMockDb(usersRows: Record[], profileRows: Record[]) { +function createMockDb( + usersRows: Record[], + profileRows: Record[] +) { const selectChain = { from: vi.fn().mockReturnThis(), where: vi.fn(), - }; - selectChain.where - .mockResolvedValueOnce(usersRows) - .mockResolvedValueOnce(profileRows); + } + selectChain.where.mockResolvedValueOnce(usersRows).mockResolvedValueOnce(profileRows) return { select: vi.fn().mockReturnValue(selectChain), - }; + } } -describe("resolveAuthors", () => { - const didAlice = "did:plc:alice111"; - const didBob = "did:plc:bob222"; - const communityDid = "did:plc:community123"; +describe('resolveAuthors', () => { + const didAlice = 'did:plc:alice111' + const didBob = 'did:plc:bob222' + const communityDid = 'did:plc:community123' - it("returns empty map for empty DID list", async () => { - const db = createMockDb([], []); - const result = await resolveAuthors([], null, db as never); - expect(result.size).toBe(0); - expect(db.select).not.toHaveBeenCalled(); - }); + it('returns empty map for empty DID list', async () => { + const db = createMockDb([], []) + const result = await resolveAuthors([], null, db as never) + expect(result.size).toBe(0) + expect(db.select).not.toHaveBeenCalled() + }) - it("resolves profiles from users table with no community context", async () => { + it('resolves profiles from users table with no community context', async () => { const db = createMockDb( [ - { did: didAlice, handle: "alice.bsky.social", displayName: "Alice", avatarUrl: "https://cdn.example.com/alice.jpg", bannerUrl: null, bio: null }, - { did: didBob, handle: "bob.bsky.social", displayName: null, avatarUrl: null, bannerUrl: null, bio: null }, + { + did: didAlice, + handle: 'alice.bsky.social', + displayName: 'Alice', + avatarUrl: 'https://cdn.example.com/alice.jpg', + bannerUrl: null, + bio: null, + }, + { + did: didBob, + handle: 'bob.bsky.social', + displayName: null, + avatarUrl: null, + bannerUrl: null, + bio: null, + }, ], - [], - ); + [] + ) - const result = await resolveAuthors([didAlice, didBob], null, db as never); + const result = await resolveAuthors([didAlice, didBob], null, db as never) - expect(result.size).toBe(2); + expect(result.size).toBe(2) expect(result.get(didAlice)).toEqual({ did: didAlice, - handle: "alice.bsky.social", - displayName: "Alice", - avatarUrl: "https://cdn.example.com/alice.jpg", - }); + handle: 'alice.bsky.social', + displayName: 'Alice', + avatarUrl: 'https://cdn.example.com/alice.jpg', + }) expect(result.get(didBob)).toEqual({ did: didBob, - handle: "bob.bsky.social", + handle: 'bob.bsky.social', displayName: null, avatarUrl: null, - }); - }); + }) + }) - it("applies community profile overrides when communityDid is provided", async () => { + it('applies community profile overrides when communityDid is provided', async () => { const db = createMockDb( [ - { did: didAlice, handle: "alice.bsky.social", displayName: "Alice", avatarUrl: "https://cdn.example.com/alice.jpg", bannerUrl: null, bio: null }, + { + did: didAlice, + handle: 'alice.bsky.social', + displayName: 'Alice', + avatarUrl: 'https://cdn.example.com/alice.jpg', + bannerUrl: null, + bio: null, + }, ], [ - { did: didAlice, communityDid, displayName: "Alice in Community", avatarUrl: "https://cdn.example.com/alice-community.jpg", bannerUrl: null, bio: null }, - ], - ); - - const result = await resolveAuthors([didAlice], communityDid, db as never); + { + did: didAlice, + communityDid, + displayName: 'Alice in Community', + avatarUrl: 'https://cdn.example.com/alice-community.jpg', + bannerUrl: null, + bio: null, + }, + ] + ) + + const result = await resolveAuthors([didAlice], communityDid, db as never) expect(result.get(didAlice)).toEqual({ did: didAlice, - handle: "alice.bsky.social", - displayName: "Alice in Community", - avatarUrl: "https://cdn.example.com/alice-community.jpg", - }); - }); + handle: 'alice.bsky.social', + displayName: 'Alice in Community', + avatarUrl: 'https://cdn.example.com/alice-community.jpg', + }) + }) - it("deduplicates DIDs before querying", async () => { + it('deduplicates DIDs before querying', async () => { const db = createMockDb( - [{ did: didAlice, handle: "alice.bsky.social", displayName: "Alice", avatarUrl: null, bannerUrl: null, bio: null }], - [], - ); + [ + { + did: didAlice, + handle: 'alice.bsky.social', + displayName: 'Alice', + avatarUrl: null, + bannerUrl: null, + bio: null, + }, + ], + [] + ) - const result = await resolveAuthors([didAlice, didAlice, didAlice], null, db as never); + const result = await resolveAuthors([didAlice, didAlice, didAlice], null, db as never) - expect(result.size).toBe(1); - }); + expect(result.size).toBe(1) + }) - it("returns fallback for DIDs not found in users table", async () => { - const db = createMockDb([], []); + it('returns fallback for DIDs not found in users table', async () => { + const db = createMockDb([], []) - const result = await resolveAuthors([didAlice], null, db as never); + const result = await resolveAuthors([didAlice], null, db as never) expect(result.get(didAlice)).toEqual({ did: didAlice, handle: didAlice, displayName: null, avatarUrl: null, - }); - }); -}); + }) + }) +}) diff --git a/tests/unit/lib/resolve-profile.test.ts b/tests/unit/lib/resolve-profile.test.ts index 2a80c21..00e7036 100644 --- a/tests/unit/lib/resolve-profile.test.ts +++ b/tests/unit/lib/resolve-profile.test.ts @@ -1,139 +1,139 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from 'vitest' import { resolveProfile, type SourceProfile, type CommunityOverride, -} from "../../../src/lib/resolve-profile.js"; +} from '../../../src/lib/resolve-profile.js' // --------------------------------------------------------------------------- // resolveProfile // --------------------------------------------------------------------------- const baseSource: SourceProfile = { - did: "did:plc:abc123", - handle: "alice.bsky.social", - displayName: "Alice", - avatarUrl: "https://cdn.example.com/avatar.jpg", - bannerUrl: "https://cdn.example.com/banner.jpg", - bio: "Hello from the AT Protocol", -}; - -describe("resolveProfile", () => { - it("returns source profile values when override is null", () => { - const result = resolveProfile(baseSource, null); + did: 'did:plc:abc123', + handle: 'alice.bsky.social', + displayName: 'Alice', + avatarUrl: 'https://cdn.example.com/avatar.jpg', + bannerUrl: 'https://cdn.example.com/banner.jpg', + bio: 'Hello from the AT Protocol', +} + +describe('resolveProfile', () => { + it('returns source profile values when override is null', () => { + const result = resolveProfile(baseSource, null) expect(result).toEqual({ - did: "did:plc:abc123", - handle: "alice.bsky.social", - displayName: "Alice", - avatarUrl: "https://cdn.example.com/avatar.jpg", - bannerUrl: "https://cdn.example.com/banner.jpg", - bio: "Hello from the AT Protocol", - }); - }); - - it("uses all override values when every field is set", () => { + did: 'did:plc:abc123', + handle: 'alice.bsky.social', + displayName: 'Alice', + avatarUrl: 'https://cdn.example.com/avatar.jpg', + bannerUrl: 'https://cdn.example.com/banner.jpg', + bio: 'Hello from the AT Protocol', + }) + }) + + it('uses all override values when every field is set', () => { const override: CommunityOverride = { - displayName: "Alice in Wonderland", - avatarUrl: "https://cdn.example.com/community-avatar.jpg", - bannerUrl: "https://cdn.example.com/community-banner.jpg", - bio: "Community-specific bio", - }; + displayName: 'Alice in Wonderland', + avatarUrl: 'https://cdn.example.com/community-avatar.jpg', + bannerUrl: 'https://cdn.example.com/community-banner.jpg', + bio: 'Community-specific bio', + } - const result = resolveProfile(baseSource, override); + const result = resolveProfile(baseSource, override) expect(result).toEqual({ - did: "did:plc:abc123", - handle: "alice.bsky.social", - displayName: "Alice in Wonderland", - avatarUrl: "https://cdn.example.com/community-avatar.jpg", - bannerUrl: "https://cdn.example.com/community-banner.jpg", - bio: "Community-specific bio", - }); - }); - - it("falls back to source for null override fields", () => { + did: 'did:plc:abc123', + handle: 'alice.bsky.social', + displayName: 'Alice in Wonderland', + avatarUrl: 'https://cdn.example.com/community-avatar.jpg', + bannerUrl: 'https://cdn.example.com/community-banner.jpg', + bio: 'Community-specific bio', + }) + }) + + it('falls back to source for null override fields', () => { const override: CommunityOverride = { - displayName: "Community Alice", + displayName: 'Community Alice', avatarUrl: null, bannerUrl: null, - bio: "Override bio only", - }; + bio: 'Override bio only', + } - const result = resolveProfile(baseSource, override); + const result = resolveProfile(baseSource, override) expect(result).toEqual({ - did: "did:plc:abc123", - handle: "alice.bsky.social", - displayName: "Community Alice", - avatarUrl: "https://cdn.example.com/avatar.jpg", - bannerUrl: "https://cdn.example.com/banner.jpg", - bio: "Override bio only", - }); - }); - - it("returns all nulls for nullable fields when source is all null and no override", () => { + did: 'did:plc:abc123', + handle: 'alice.bsky.social', + displayName: 'Community Alice', + avatarUrl: 'https://cdn.example.com/avatar.jpg', + bannerUrl: 'https://cdn.example.com/banner.jpg', + bio: 'Override bio only', + }) + }) + + it('returns all nulls for nullable fields when source is all null and no override', () => { const nullSource: SourceProfile = { - did: "did:plc:empty", - handle: "empty.bsky.social", + did: 'did:plc:empty', + handle: 'empty.bsky.social', displayName: null, avatarUrl: null, bannerUrl: null, bio: null, - }; + } - const result = resolveProfile(nullSource, null); + const result = resolveProfile(nullSource, null) expect(result).toEqual({ - did: "did:plc:empty", - handle: "empty.bsky.social", + did: 'did:plc:empty', + handle: 'empty.bsky.social', displayName: null, avatarUrl: null, bannerUrl: null, bio: null, - }); - }); + }) + }) - it("uses override values when source nullable fields are all null", () => { + it('uses override values when source nullable fields are all null', () => { const nullSource: SourceProfile = { - did: "did:plc:empty", - handle: "empty.bsky.social", + did: 'did:plc:empty', + handle: 'empty.bsky.social', displayName: null, avatarUrl: null, bannerUrl: null, bio: null, - }; + } const override: CommunityOverride = { - displayName: "Community Name", - avatarUrl: "https://cdn.example.com/override-avatar.jpg", - bannerUrl: "https://cdn.example.com/override-banner.jpg", - bio: "Override bio", - }; + displayName: 'Community Name', + avatarUrl: 'https://cdn.example.com/override-avatar.jpg', + bannerUrl: 'https://cdn.example.com/override-banner.jpg', + bio: 'Override bio', + } - const result = resolveProfile(nullSource, override); + const result = resolveProfile(nullSource, override) expect(result).toEqual({ - did: "did:plc:empty", - handle: "empty.bsky.social", - displayName: "Community Name", - avatarUrl: "https://cdn.example.com/override-avatar.jpg", - bannerUrl: "https://cdn.example.com/override-banner.jpg", - bio: "Override bio", - }); - }); - - it("always takes did and handle from source, never from override", () => { + did: 'did:plc:empty', + handle: 'empty.bsky.social', + displayName: 'Community Name', + avatarUrl: 'https://cdn.example.com/override-avatar.jpg', + bannerUrl: 'https://cdn.example.com/override-banner.jpg', + bio: 'Override bio', + }) + }) + + it('always takes did and handle from source, never from override', () => { const override: CommunityOverride = { - displayName: "Override Name", + displayName: 'Override Name', avatarUrl: null, bannerUrl: null, bio: null, - }; + } - const result = resolveProfile(baseSource, override); + const result = resolveProfile(baseSource, override) - expect(result.did).toBe(baseSource.did); - expect(result.handle).toBe(baseSource.handle); - }); -}); + expect(result.did).toBe(baseSource.did) + expect(result.handle).toBe(baseSource.handle) + }) +}) diff --git a/tests/unit/lib/storage.test.ts b/tests/unit/lib/storage.test.ts index 1d7cc1d..93e438a 100644 --- a/tests/unit/lib/storage.test.ts +++ b/tests/unit/lib/storage.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect, afterEach, vi } from "vitest"; -import { existsSync } from "node:fs"; -import { readFile, rm, mkdtemp } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { createLocalStorage } from "../../../src/lib/storage.js"; +import { describe, it, expect, afterEach, vi } from 'vitest' +import { existsSync } from 'node:fs' +import { readFile, rm, mkdtemp } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createLocalStorage } from '../../../src/lib/storage.js' // --------------------------------------------------------------------------- // Mock logger @@ -18,127 +18,97 @@ const mockLogger = { trace: vi.fn(), child: vi.fn(), silent: vi.fn(), - level: "debug", -} as never; + level: 'debug', +} as never // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describe("createLocalStorage", () => { - let tmpDir: string; +describe('createLocalStorage', () => { + let tmpDir: string afterEach(async () => { if (tmpDir && existsSync(tmpDir)) { - await rm(tmpDir, { recursive: true, force: true }); + await rm(tmpDir, { recursive: true, force: true }) } - }); + }) - it("stores a file and returns a valid URL", async () => { - tmpDir = await mkdtemp(join(tmpdir(), "barazo-storage-")); - const storage = createLocalStorage( - tmpDir, - "http://localhost:3000", - mockLogger, - ); + it('stores a file and returns a valid URL', async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'barazo-storage-')) + const storage = createLocalStorage(tmpDir, 'http://localhost:3000', mockLogger) - const data = Buffer.from("fake-image-data"); - const url = await storage.store(data, "image/webp", "avatars"); + const data = Buffer.from('fake-image-data') + const url = await storage.store(data, 'image/webp', 'avatars') - expect(url).toMatch( - /^http:\/\/localhost:3000\/uploads\/avatars\/avatars-[a-f0-9-]+\.webp$/, - ); + expect(url).toMatch(/^http:\/\/localhost:3000\/uploads\/avatars\/avatars-[a-f0-9-]+\.webp$/) // Verify the file was actually written - const relativePath = url.split("/uploads/")[1]; - expect(relativePath).toBeDefined(); - const filepath = join(tmpDir, relativePath ?? ""); - const written = await readFile(filepath); - expect(written.toString()).toBe("fake-image-data"); - }); - - it("creates subdirectory if it does not exist", async () => { - tmpDir = await mkdtemp(join(tmpdir(), "barazo-storage-")); - const storage = createLocalStorage( - tmpDir, - "http://localhost:3000", - mockLogger, - ); - - const data = Buffer.from("test"); - await storage.store(data, "image/png", "banners"); - - expect(existsSync(join(tmpDir, "banners"))).toBe(true); - }); - - it("maps MIME types to correct extensions", async () => { - tmpDir = await mkdtemp(join(tmpdir(), "barazo-storage-")); - const storage = createLocalStorage( - tmpDir, - "http://localhost:3000", - mockLogger, - ); - - const data = Buffer.from("test"); - - const jpegUrl = await storage.store(data, "image/jpeg", "test"); - expect(jpegUrl).toMatch(/\.jpg$/); - - const pngUrl = await storage.store(data, "image/png", "test"); - expect(pngUrl).toMatch(/\.png$/); - - const gifUrl = await storage.store(data, "image/gif", "test"); - expect(gifUrl).toMatch(/\.gif$/); - - const unknownUrl = await storage.store( - data, - "application/octet-stream", - "test", - ); - expect(unknownUrl).toMatch(/\.bin$/); - }); - - it("deletes a stored file", async () => { - tmpDir = await mkdtemp(join(tmpdir(), "barazo-storage-")); - const storage = createLocalStorage( - tmpDir, - "http://localhost:3000", - mockLogger, - ); - - const data = Buffer.from("to-be-deleted"); - const url = await storage.store(data, "image/webp", "avatars"); + const relativePath = url.split('/uploads/')[1] + expect(relativePath).toBeDefined() + const filepath = join(tmpDir, relativePath ?? '') + const written = await readFile(filepath) + expect(written.toString()).toBe('fake-image-data') + }) + + it('creates subdirectory if it does not exist', async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'barazo-storage-')) + const storage = createLocalStorage(tmpDir, 'http://localhost:3000', mockLogger) + + const data = Buffer.from('test') + await storage.store(data, 'image/png', 'banners') + + expect(existsSync(join(tmpDir, 'banners'))).toBe(true) + }) + + it('maps MIME types to correct extensions', async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'barazo-storage-')) + const storage = createLocalStorage(tmpDir, 'http://localhost:3000', mockLogger) + + const data = Buffer.from('test') + + const jpegUrl = await storage.store(data, 'image/jpeg', 'test') + expect(jpegUrl).toMatch(/\.jpg$/) + + const pngUrl = await storage.store(data, 'image/png', 'test') + expect(pngUrl).toMatch(/\.png$/) + + const gifUrl = await storage.store(data, 'image/gif', 'test') + expect(gifUrl).toMatch(/\.gif$/) + + const unknownUrl = await storage.store(data, 'application/octet-stream', 'test') + expect(unknownUrl).toMatch(/\.bin$/) + }) + + it('deletes a stored file', async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'barazo-storage-')) + const storage = createLocalStorage(tmpDir, 'http://localhost:3000', mockLogger) + + const data = Buffer.from('to-be-deleted') + const url = await storage.store(data, 'image/webp', 'avatars') // File exists before delete - const relativePath = url.split("/uploads/")[1] ?? ""; - const filepath = join(tmpDir, relativePath); - expect(existsSync(filepath)).toBe(true); - - await storage.delete(url); - expect(existsSync(filepath)).toBe(false); - }); - - it("delete is best-effort (does not throw for missing files)", async () => { - tmpDir = await mkdtemp(join(tmpdir(), "barazo-storage-")); - const storage = createLocalStorage( - tmpDir, - "http://localhost:3000", - mockLogger, - ); + const relativePath = url.split('/uploads/')[1] ?? '' + const filepath = join(tmpDir, relativePath) + expect(existsSync(filepath)).toBe(true) + + await storage.delete(url) + expect(existsSync(filepath)).toBe(false) + }) + + it('delete is best-effort (does not throw for missing files)', async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'barazo-storage-')) + const storage = createLocalStorage(tmpDir, 'http://localhost:3000', mockLogger) // Should not throw - await storage.delete("http://localhost:3000/uploads/avatars/nonexistent.webp"); - }); + await storage.delete('http://localhost:3000/uploads/avatars/nonexistent.webp') + }) - it("delete ignores URLs without /uploads/ path", async () => { - tmpDir = await mkdtemp(join(tmpdir(), "barazo-storage-")); - const storage = createLocalStorage( - tmpDir, - "http://localhost:3000", - mockLogger, - ); + it('delete ignores URLs without /uploads/ path', async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'barazo-storage-')) + const storage = createLocalStorage(tmpDir, 'http://localhost:3000', mockLogger) // Should not throw and should not attempt file deletion - await storage.delete("http://example.com/some-other-path.jpg"); - }); -}); + await storage.delete('http://example.com/some-other-path.jpg') + }) +}) diff --git a/tests/unit/routes/admin-settings.test.ts b/tests/unit/routes/admin-settings.test.ts index f3a84ab..24bd9df 100644 --- a/tests/unit/routes/admin-settings.test.ts +++ b/tests/unit/routes/admin-settings.test.ts @@ -1,35 +1,35 @@ -import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { type DbChain, createChainableProxy, createMockDb } from "../../helpers/mock-db.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' // Import routes -import { adminSettingsRoutes } from "../../../src/routes/admin-settings.js"; +import { adminSettingsRoutes } from '../../../src/routes/admin-settings.js' // --------------------------------------------------------------------------- // Mock env (minimal subset for admin-settings routes) // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_DID: 'did:plc:community123', RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); -const ADMIN_DID = "did:plc:admin999"; -const TEST_NOW = "2026-02-13T12:00:00.000Z"; +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) +const ADMIN_DID = 'did:plc:admin999' +const TEST_NOW = '2026-02-13T12:00:00.000Z' // --------------------------------------------------------------------------- // Mock user builders @@ -41,34 +41,34 @@ function testUser(overrides?: Partial): RequestUser { handle: TEST_HANDLE, sid: TEST_SID, ...overrides, - }; + } } function adminUser(): RequestUser { - return testUser({ did: ADMIN_DID, handle: "admin.bsky.social" }); + return testUser({ did: ADMIN_DID, handle: 'admin.bsky.social' }) } // --------------------------------------------------------------------------- // Chainable mock DB (shared helper) // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() -let selectChain: DbChain; -let updateChain: DbChain; +let selectChain: DbChain +let updateChain: DbChain function resetAllDbMocks(): void { - selectChain = createChainableProxy([]); - updateChain = createChainableProxy([]); - mockDb.insert.mockReturnValue(createChainableProxy()); - mockDb.select.mockReturnValue(selectChain); - mockDb.update.mockReturnValue(updateChain); - mockDb.delete.mockReturnValue(createChainableProxy()); + selectChain = createChainableProxy([]) + updateChain = createChainableProxy([]) + mockDb.insert.mockReturnValue(createChainableProxy()) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(updateChain) + mockDb.delete.mockReturnValue(createChainableProxy()) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { - await fn(mockDb); - }); - mockDb.execute.mockReset(); + await fn(mockDb) + }) + mockDb.execute.mockReset() } // --------------------------------------------------------------------------- @@ -79,18 +79,18 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } // --------------------------------------------------------------------------- @@ -98,17 +98,20 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { // --------------------------------------------------------------------------- function createMockRequireAdmin(user?: RequestUser) { - return async (request: { user?: RequestUser }, reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise } }) => { + return async ( + request: { user?: RequestUser }, + reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise } } + ) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user if (user.did !== ADMIN_DID) { - await reply.status(403).send({ error: "Admin access required" }); - return; + await reply.status(403).send({ error: 'Admin access required' }) + return } - }; + } } // --------------------------------------------------------------------------- @@ -117,13 +120,13 @@ function createMockRequireAdmin(user?: RequestUser) { function sampleCommunitySettings(overrides?: Record) { return { - id: "default", + id: 'default', initialized: true, - communityDid: "did:plc:community123", + communityDid: 'did:plc:community123', adminDid: ADMIN_DID, - communityName: "Test Community", - maturityRating: "safe", - reactionSet: ["like"], + communityName: 'Test Community', + maturityRating: 'safe', + reactionSet: ['like'], moderationThresholds: { autoBlockReportCount: 5, warnThreshold: 3 }, wordFilter: [], communityDescription: null, @@ -136,23 +139,23 @@ function sampleCommunitySettings(overrides?: Record) { createdAt: new Date(TEST_NOW), updatedAt: new Date(TEST_NOW), ...overrides, - }; + } } function sampleCategoryRow(overrides?: Record) { return { - id: "cat-001", - slug: "general", - name: "General Discussion", - description: "Talk about anything", + id: 'cat-001', + slug: 'general', + name: 'General Discussion', + description: 'Talk about anything', parentId: null, sortOrder: 0, - communityDid: "did:plc:community123", - maturityRating: "safe", + communityDid: 'did:plc:community123', + maturityRating: 'safe', createdAt: new Date(TEST_NOW), updatedAt: new Date(TEST_NOW), ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -160,764 +163,795 @@ function sampleCategoryRow(overrides?: Record) { // --------------------------------------------------------------------------- async function buildTestApp(user?: RequestUser): Promise { - const app = Fastify({ logger: false }); - - const authMiddleware = createMockAuthMiddleware(user); - const requireAdmin = createMockRequireAdmin(user); - - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", authMiddleware); - app.decorate("requireAdmin", requireAdmin as never); - app.decorate("firehose", {} as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); - - await app.register(adminSettingsRoutes()); - await app.ready(); - - return app; + const app = Fastify({ logger: false }) + + const authMiddleware = createMockAuthMiddleware(user) + const requireAdmin = createMockRequireAdmin(user) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', authMiddleware) + app.decorate('requireAdmin', requireAdmin as never) + app.decorate('firehose', {} as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + + await app.register(adminSettingsRoutes()) + await app.ready() + + return app } // =========================================================================== // Test suite // =========================================================================== -describe("admin settings routes", () => { +describe('admin settings routes', () => { // ========================================================================= // GET /api/admin/settings // ========================================================================= - describe("GET /api/admin/settings", () => { - let app: FastifyInstance; + describe('GET /api/admin/settings', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser()); - }); + app = await buildTestApp(adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns community settings", async () => { - const settings = sampleCommunitySettings(); - selectChain.where.mockResolvedValueOnce([settings]); + it('returns community settings', async () => { + const settings = sampleCommunitySettings() + selectChain.where.mockResolvedValueOnce([settings]) const response = await app.inject({ - method: "GET", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, - }); + method: 'GET', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - id: string; - communityName: string; - maturityRating: string; - initialized: boolean; - }>(); - expect(body.id).toBe("default"); - expect(body.communityName).toBe("Test Community"); - expect(body.maturityRating).toBe("safe"); - expect(body.initialized).toBe(true); - expect(body).toHaveProperty("createdAt"); - expect(body).toHaveProperty("updatedAt"); - }); - - it("returns 404 if no settings row exists", async () => { - selectChain.where.mockResolvedValueOnce([]); + id: string + communityName: string + maturityRating: string + initialized: boolean + }>() + expect(body.id).toBe('default') + expect(body.communityName).toBe('Test Community') + expect(body.maturityRating).toBe('safe') + expect(body.initialized).toBe(true) + expect(body).toHaveProperty('createdAt') + expect(body).toHaveProperty('updatedAt') + }) + + it('returns 404 if no settings row exists', async () => { + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, - }); + method: 'GET', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, + }) - expect(response.statusCode).toBe(404); - const body = response.json<{ message: string }>(); - expect(body.message).toContain("settings"); - }); + expect(response.statusCode).toBe(404) + const body = response.json<{ message: string }>() + expect(body.message).toContain('settings') + }) - it("returns 401 when unauthenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when unauthenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "GET", - url: "/api/admin/settings", - }); + method: 'GET', + url: '/api/admin/settings', + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("returns 403 when non-admin user", async () => { - const regularApp = await buildTestApp(testUser()); + it('returns 403 when non-admin user', async () => { + const regularApp = await buildTestApp(testUser()) const response = await regularApp.inject({ - method: "GET", - url: "/api/admin/settings", - headers: { authorization: "Bearer user-token" }, - }); + method: 'GET', + url: '/api/admin/settings', + headers: { authorization: 'Bearer user-token' }, + }) - expect(response.statusCode).toBe(403); - await regularApp.close(); - }); - }); + expect(response.statusCode).toBe(403) + await regularApp.close() + }) + }) // ========================================================================= // PUT /api/admin/settings // ========================================================================= - describe("PUT /api/admin/settings", () => { - let app: FastifyInstance; + describe('PUT /api/admin/settings', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser()); - }); + app = await buildTestApp(adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("updates communityName", async () => { - const settings = sampleCommunitySettings(); + it('updates communityName', async () => { + const settings = sampleCommunitySettings() // Fetch current settings - selectChain.where.mockResolvedValueOnce([settings]); + selectChain.where.mockResolvedValueOnce([settings]) // Update returns updated row updateChain.returning.mockResolvedValueOnce([ - { ...settings, communityName: "New Name", updatedAt: new Date() }, - ]); + { ...settings, communityName: 'New Name', updatedAt: new Date() }, + ]) const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - communityName: "New Name", + communityName: 'New Name', }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ communityName: string }>(); - expect(body.communityName).toBe("New Name"); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ communityName: string }>() + expect(body.communityName).toBe('New Name') + }) - it("updates maturityRating when lowering (no validation needed)", async () => { - const settings = sampleCommunitySettings({ maturityRating: "mature" }); + it('updates maturityRating when lowering (no validation needed)', async () => { + const settings = sampleCommunitySettings({ maturityRating: 'mature' }) // Fetch current settings - selectChain.where.mockResolvedValueOnce([settings]); + selectChain.where.mockResolvedValueOnce([settings]) // No category check needed when lowering // Update returns updated row updateChain.returning.mockResolvedValueOnce([ - { ...settings, maturityRating: "safe", updatedAt: new Date() }, - ]); + { ...settings, maturityRating: 'safe', updatedAt: new Date() }, + ]) const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - maturityRating: "safe", + maturityRating: 'safe', }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ maturityRating: string }>(); - expect(body.maturityRating).toBe("safe"); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ maturityRating: string }>() + expect(body.maturityRating).toBe('safe') + }) - it("updates maturityRating when raising with compatible categories", async () => { - const settings = sampleCommunitySettings({ maturityRating: "safe" }); + it('updates maturityRating when raising with compatible categories', async () => { + const settings = sampleCommunitySettings({ maturityRating: 'safe' }) // Fetch current settings - selectChain.where.mockResolvedValueOnce([settings]); + selectChain.where.mockResolvedValueOnce([settings]) // Category check: all categories are >= "mature" (the new target) - selectChain.where.mockResolvedValueOnce([]); // no incompatible categories + selectChain.where.mockResolvedValueOnce([]) // no incompatible categories // Update returns updated row updateChain.returning.mockResolvedValueOnce([ - { ...settings, maturityRating: "mature", updatedAt: new Date() }, - ]); + { ...settings, maturityRating: 'mature', updatedAt: new Date() }, + ]) const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - maturityRating: "mature", + maturityRating: 'mature', }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ maturityRating: string }>(); - expect(body.maturityRating).toBe("mature"); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ maturityRating: string }>() + expect(body.maturityRating).toBe('mature') + }) - it("returns 409 when raising maturity with incompatible categories", async () => { - const settings = sampleCommunitySettings({ maturityRating: "safe" }); + it('returns 409 when raising maturity with incompatible categories', async () => { + const settings = sampleCommunitySettings({ maturityRating: 'safe' }) // Fetch current settings - selectChain.where.mockResolvedValueOnce([settings]); + selectChain.where.mockResolvedValueOnce([settings]) // Category check: some categories have rating lower than new target const incompatibleCategories = [ - sampleCategoryRow({ id: "cat-001", slug: "general", name: "General Discussion", maturityRating: "safe" }), - sampleCategoryRow({ id: "cat-002", slug: "help", name: "Help", maturityRating: "safe" }), - ]; - selectChain.where.mockResolvedValueOnce(incompatibleCategories); + sampleCategoryRow({ + id: 'cat-001', + slug: 'general', + name: 'General Discussion', + maturityRating: 'safe', + }), + sampleCategoryRow({ id: 'cat-002', slug: 'help', name: 'Help', maturityRating: 'safe' }), + ] + selectChain.where.mockResolvedValueOnce(incompatibleCategories) const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - maturityRating: "mature", + maturityRating: 'mature', }, - }); - - expect(response.statusCode).toBe(409); - const body = response.json<{ message: string; details: { categories: Array<{ id: string; slug: string; name: string; maturityRating: string }> } }>(); - expect(body.message).toContain("categories"); - expect(body.details.categories).toHaveLength(2); - expect(body.details.categories[0]?.slug).toBe("general"); - expect(body.details.categories[1]?.slug).toBe("help"); - }); - - it("returns 409 with affected category details when raising to adult", async () => { - const settings = sampleCommunitySettings({ maturityRating: "safe" }); - selectChain.where.mockResolvedValueOnce([settings]); + }) + + expect(response.statusCode).toBe(409) + const body = response.json<{ + message: string + details: { + categories: Array<{ id: string; slug: string; name: string; maturityRating: string }> + } + }>() + expect(body.message).toContain('categories') + expect(body.details.categories).toHaveLength(2) + expect(body.details.categories[0]?.slug).toBe('general') + expect(body.details.categories[1]?.slug).toBe('help') + }) + + it('returns 409 with affected category details when raising to adult', async () => { + const settings = sampleCommunitySettings({ maturityRating: 'safe' }) + selectChain.where.mockResolvedValueOnce([settings]) // One category at "safe", one at "mature" -- both below "adult" const incompatibleCategories = [ - sampleCategoryRow({ id: "cat-001", slug: "general", name: "General", maturityRating: "safe" }), - sampleCategoryRow({ id: "cat-003", slug: "mature-stuff", name: "Mature Stuff", maturityRating: "mature" }), - ]; - selectChain.where.mockResolvedValueOnce(incompatibleCategories); + sampleCategoryRow({ + id: 'cat-001', + slug: 'general', + name: 'General', + maturityRating: 'safe', + }), + sampleCategoryRow({ + id: 'cat-003', + slug: 'mature-stuff', + name: 'Mature Stuff', + maturityRating: 'mature', + }), + ] + selectChain.where.mockResolvedValueOnce(incompatibleCategories) const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - maturityRating: "adult", + maturityRating: 'adult', }, - }); + }) - expect(response.statusCode).toBe(409); - const body = response.json<{ details: { categories: Array<{ id: string; maturityRating: string }> } }>(); - expect(body.details.categories).toHaveLength(2); - }); + expect(response.statusCode).toBe(409) + const body = response.json<{ + details: { categories: Array<{ id: string; maturityRating: string }> } + }>() + expect(body.details.categories).toHaveLength(2) + }) - it("updates both communityName and maturityRating", async () => { - const settings = sampleCommunitySettings({ maturityRating: "safe" }); + it('updates both communityName and maturityRating', async () => { + const settings = sampleCommunitySettings({ maturityRating: 'safe' }) // Fetch current settings - selectChain.where.mockResolvedValueOnce([settings]); + selectChain.where.mockResolvedValueOnce([settings]) // Category check for maturity raise: no incompatible categories - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) // Update returns updated row updateChain.returning.mockResolvedValueOnce([ - { ...settings, communityName: "Mature Community", maturityRating: "mature", updatedAt: new Date() }, - ]); + { + ...settings, + communityName: 'Mature Community', + maturityRating: 'mature', + updatedAt: new Date(), + }, + ]) const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - communityName: "Mature Community", - maturityRating: "mature", + communityName: 'Mature Community', + maturityRating: 'mature', }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ communityName: string; maturityRating: string }>(); - expect(body.communityName).toBe("Mature Community"); - expect(body.maturityRating).toBe("mature"); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ communityName: string; maturityRating: string }>() + expect(body.communityName).toBe('Mature Community') + expect(body.maturityRating).toBe('mature') + }) - it("returns 404 if no settings row exists", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 if no settings row exists', async () => { + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - communityName: "New Name", + communityName: 'New Name', }, - }); + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 404 if settings row deleted during update", async () => { - const settings = sampleCommunitySettings(); - selectChain.where.mockResolvedValueOnce([settings]); - updateChain.returning.mockResolvedValueOnce([]); + it('returns 404 if settings row deleted during update', async () => { + const settings = sampleCommunitySettings() + selectChain.where.mockResolvedValueOnce([settings]) + updateChain.returning.mockResolvedValueOnce([]) const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - communityName: "New Name", + communityName: 'New Name', }, - }); + }) - expect(response.statusCode).toBe(404); - const body = response.json<{ message: string }>(); - expect(body.message).toContain("after update"); - }); + expect(response.statusCode).toBe(404) + const body = response.json<{ message: string }>() + expect(body.message).toContain('after update') + }) - it("sets updatedAt on update", async () => { - const settings = sampleCommunitySettings(); - selectChain.where.mockResolvedValueOnce([settings]); - const updatedRow = { ...settings, communityName: "Updated", updatedAt: new Date() }; - updateChain.returning.mockResolvedValueOnce([updatedRow]); + it('sets updatedAt on update', async () => { + const settings = sampleCommunitySettings() + selectChain.where.mockResolvedValueOnce([settings]) + const updatedRow = { ...settings, communityName: 'Updated', updatedAt: new Date() } + updateChain.returning.mockResolvedValueOnce([updatedRow]) const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - communityName: "Updated", + communityName: 'Updated', }, - }); + }) - expect(response.statusCode).toBe(200); - expect(mockDb.update).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(200) + expect(mockDb.update).toHaveBeenCalled() + }) - it("returns 400 for communityName too long", async () => { + it('returns 400 for communityName too long', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - communityName: "A".repeat(101), + communityName: 'A'.repeat(101), }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for empty communityName", async () => { + it('returns 400 for empty communityName', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - communityName: "", + communityName: '', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for invalid maturityRating", async () => { + it('returns 400 for invalid maturityRating', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - maturityRating: "invalid", + maturityRating: 'invalid', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for empty body", async () => { + it('returns 400 for empty body', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 401 when unauthenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when unauthenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "PUT", - url: "/api/admin/settings", - payload: { communityName: "Unauth" }, - }); + method: 'PUT', + url: '/api/admin/settings', + payload: { communityName: 'Unauth' }, + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("returns 403 when non-admin user", async () => { - const regularApp = await buildTestApp(testUser()); + it('returns 403 when non-admin user', async () => { + const regularApp = await buildTestApp(testUser()) const response = await regularApp.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer user-token" }, - payload: { communityName: "Forbidden" }, - }); - - expect(response.statusCode).toBe(403); - await regularApp.close(); - }); - - it("updates jurisdictionCountry", async () => { - const settings = sampleCommunitySettings(); - selectChain.where.mockResolvedValueOnce([settings]); + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer user-token' }, + payload: { communityName: 'Forbidden' }, + }) + + expect(response.statusCode).toBe(403) + await regularApp.close() + }) + + it('updates jurisdictionCountry', async () => { + const settings = sampleCommunitySettings() + selectChain.where.mockResolvedValueOnce([settings]) const updatedRow = { ...settings, - jurisdictionCountry: "NL", + jurisdictionCountry: 'NL', updatedAt: new Date(), - }; - updateChain.returning.mockResolvedValueOnce([updatedRow]); + } + updateChain.returning.mockResolvedValueOnce([updatedRow]) const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - jurisdictionCountry: "NL", + jurisdictionCountry: 'NL', }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ jurisdictionCountry: string }>(); - expect(body.jurisdictionCountry).toBe("NL"); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ jurisdictionCountry: string }>() + expect(body.jurisdictionCountry).toBe('NL') + }) - it("updates ageThreshold", async () => { - const settings = sampleCommunitySettings(); - selectChain.where.mockResolvedValueOnce([settings]); + it('updates ageThreshold', async () => { + const settings = sampleCommunitySettings() + selectChain.where.mockResolvedValueOnce([settings]) const updatedRow = { ...settings, ageThreshold: 13, updatedAt: new Date(), - }; - updateChain.returning.mockResolvedValueOnce([updatedRow]); + } + updateChain.returning.mockResolvedValueOnce([updatedRow]) const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { ageThreshold: 13, }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ ageThreshold: number }>(); - expect(body.ageThreshold).toBe(13); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ ageThreshold: number }>() + expect(body.ageThreshold).toBe(13) + }) - it("updates requireLoginForMature", async () => { - const settings = sampleCommunitySettings(); - selectChain.where.mockResolvedValueOnce([settings]); + it('updates requireLoginForMature', async () => { + const settings = sampleCommunitySettings() + selectChain.where.mockResolvedValueOnce([settings]) const updatedRow = { ...settings, requireLoginForMature: false, updatedAt: new Date(), - }; - updateChain.returning.mockResolvedValueOnce([updatedRow]); + } + updateChain.returning.mockResolvedValueOnce([updatedRow]) const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { requireLoginForMature: false, }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ requireLoginForMature: boolean }>(); - expect(body.requireLoginForMature).toBe(false); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ requireLoginForMature: boolean }>() + expect(body.requireLoginForMature).toBe(false) + }) - it("clears jurisdictionCountry with null", async () => { - const settings = sampleCommunitySettings({ jurisdictionCountry: "NL" }); - selectChain.where.mockResolvedValueOnce([settings]); + it('clears jurisdictionCountry with null', async () => { + const settings = sampleCommunitySettings({ jurisdictionCountry: 'NL' }) + selectChain.where.mockResolvedValueOnce([settings]) const updatedRow = { ...settings, jurisdictionCountry: null, updatedAt: new Date(), - }; - updateChain.returning.mockResolvedValueOnce([updatedRow]); + } + updateChain.returning.mockResolvedValueOnce([updatedRow]) const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { jurisdictionCountry: null, }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ jurisdictionCountry: string | null }>(); - expect(body.jurisdictionCountry).toBeNull(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ jurisdictionCountry: string | null }>() + expect(body.jurisdictionCountry).toBeNull() + }) - it("returns 400 for ageThreshold below 13", async () => { + it('returns 400 for ageThreshold below 13', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { ageThreshold: 12, }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for ageThreshold above 18", async () => { + it('returns 400 for ageThreshold above 18', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { ageThreshold: 19, }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("does not check categories when maturityRating stays the same", async () => { - const settings = sampleCommunitySettings({ maturityRating: "mature" }); - selectChain.where.mockResolvedValueOnce([settings]); + it('does not check categories when maturityRating stays the same', async () => { + const settings = sampleCommunitySettings({ maturityRating: 'mature' }) + selectChain.where.mockResolvedValueOnce([settings]) // Should NOT query categories since maturity isn't changing updateChain.returning.mockResolvedValueOnce([ - { ...settings, communityName: "Renamed", updatedAt: new Date() }, - ]); + { ...settings, communityName: 'Renamed', updatedAt: new Date() }, + ]) const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - communityName: "Renamed", - maturityRating: "mature", // same as current + communityName: 'Renamed', + maturityRating: 'mature', // same as current }, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) // Only one select call: fetch current settings. No category check. - expect(mockDb.select).toHaveBeenCalledTimes(1); - }); + expect(mockDb.select).toHaveBeenCalledTimes(1) + }) - it("updates branding fields", async () => { - const settings = sampleCommunitySettings(); - selectChain.where.mockResolvedValueOnce([settings]); + it('updates branding fields', async () => { + const settings = sampleCommunitySettings() + selectChain.where.mockResolvedValueOnce([settings]) const updatedRow = { ...settings, - communityDescription: "A great community", - communityLogoUrl: "https://example.com/logo.png", - primaryColor: "#ff0000", - accentColor: "#00ff00", + communityDescription: 'A great community', + communityLogoUrl: 'https://example.com/logo.png', + primaryColor: '#ff0000', + accentColor: '#00ff00', updatedAt: new Date(), - }; - updateChain.returning.mockResolvedValueOnce([updatedRow]); + } + updateChain.returning.mockResolvedValueOnce([updatedRow]) const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - communityDescription: "A great community", - communityLogoUrl: "https://example.com/logo.png", - primaryColor: "#ff0000", - accentColor: "#00ff00", + communityDescription: 'A great community', + communityLogoUrl: 'https://example.com/logo.png', + primaryColor: '#ff0000', + accentColor: '#00ff00', }, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - communityDescription: string; - communityLogoUrl: string; - primaryColor: string; - accentColor: string; - }>(); - expect(body.communityDescription).toBe("A great community"); - expect(body.communityLogoUrl).toBe("https://example.com/logo.png"); - expect(body.primaryColor).toBe("#ff0000"); - expect(body.accentColor).toBe("#00ff00"); - }); - - it("returns 400 for communityDescription too long", async () => { + communityDescription: string + communityLogoUrl: string + primaryColor: string + accentColor: string + }>() + expect(body.communityDescription).toBe('A great community') + expect(body.communityLogoUrl).toBe('https://example.com/logo.png') + expect(body.primaryColor).toBe('#ff0000') + expect(body.accentColor).toBe('#00ff00') + }) + + it('returns 400 for communityDescription too long', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - communityDescription: "A".repeat(501), + communityDescription: 'A'.repeat(501), }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for invalid communityLogoUrl", async () => { + it('returns 400 for invalid communityLogoUrl', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - communityLogoUrl: "not-a-url", + communityLogoUrl: 'not-a-url', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for invalid primaryColor", async () => { + it('returns 400 for invalid primaryColor', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - primaryColor: "red", + primaryColor: 'red', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for invalid accentColor", async () => { + it('returns 400 for invalid accentColor', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/settings", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/settings', + headers: { authorization: 'Bearer admin-token' }, payload: { - accentColor: "#xyz", + accentColor: '#xyz', }, - }); + }) - expect(response.statusCode).toBe(400); - }); - }); + expect(response.statusCode).toBe(400) + }) + }) // ========================================================================= // GET /api/admin/stats // ========================================================================= - describe("GET /api/admin/stats", () => { - let app: FastifyInstance; + describe('GET /api/admin/stats', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser()); - }); + app = await buildTestApp(adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); - - it("returns community statistics", async () => { - mockDb.execute.mockResolvedValueOnce([{ - topic_count: "42", - reply_count: "100", - user_count: "15", - category_count: "5", - report_count: "3", - recent_topics: "10", - recent_replies: "25", - recent_users: "5", - }]); + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns community statistics', async () => { + mockDb.execute.mockResolvedValueOnce([ + { + topic_count: '42', + reply_count: '100', + user_count: '15', + category_count: '5', + report_count: '3', + recent_topics: '10', + recent_replies: '25', + recent_users: '5', + }, + ]) const response = await app.inject({ - method: "GET", - url: "/api/admin/stats", - headers: { authorization: "Bearer admin-token" }, - }); + method: 'GET', + url: '/api/admin/stats', + headers: { authorization: 'Bearer admin-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - topicCount: number; - replyCount: number; - userCount: number; - categoryCount: number; - reportCount: number; - recentTopics: number; - recentReplies: number; - recentUsers: number; - }>(); - expect(body.topicCount).toBe(42); - expect(body.replyCount).toBe(100); - expect(body.userCount).toBe(15); - expect(body.categoryCount).toBe(5); - expect(body.reportCount).toBe(3); - expect(body.recentTopics).toBe(10); - expect(body.recentReplies).toBe(25); - expect(body.recentUsers).toBe(5); - }); - - it("returns zeros when no data exists", async () => { - mockDb.execute.mockResolvedValueOnce([{ - topic_count: "0", - reply_count: "0", - user_count: "0", - category_count: "0", - report_count: "0", - recent_topics: "0", - recent_replies: "0", - recent_users: "0", - }]); + topicCount: number + replyCount: number + userCount: number + categoryCount: number + reportCount: number + recentTopics: number + recentReplies: number + recentUsers: number + }>() + expect(body.topicCount).toBe(42) + expect(body.replyCount).toBe(100) + expect(body.userCount).toBe(15) + expect(body.categoryCount).toBe(5) + expect(body.reportCount).toBe(3) + expect(body.recentTopics).toBe(10) + expect(body.recentReplies).toBe(25) + expect(body.recentUsers).toBe(5) + }) + + it('returns zeros when no data exists', async () => { + mockDb.execute.mockResolvedValueOnce([ + { + topic_count: '0', + reply_count: '0', + user_count: '0', + category_count: '0', + report_count: '0', + recent_topics: '0', + recent_replies: '0', + recent_users: '0', + }, + ]) const response = await app.inject({ - method: "GET", - url: "/api/admin/stats", - headers: { authorization: "Bearer admin-token" }, - }); + method: 'GET', + url: '/api/admin/stats', + headers: { authorization: 'Bearer admin-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - topicCount: number; - replyCount: number; - }>(); - expect(body.topicCount).toBe(0); - expect(body.replyCount).toBe(0); - }); + topicCount: number + replyCount: number + }>() + expect(body.topicCount).toBe(0) + expect(body.replyCount).toBe(0) + }) - it("returns 401 when unauthenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when unauthenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "GET", - url: "/api/admin/stats", - }); + method: 'GET', + url: '/api/admin/stats', + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("returns 403 when non-admin user", async () => { - const regularApp = await buildTestApp(testUser()); + it('returns 403 when non-admin user', async () => { + const regularApp = await buildTestApp(testUser()) const response = await regularApp.inject({ - method: "GET", - url: "/api/admin/stats", - headers: { authorization: "Bearer user-token" }, - }); - - expect(response.statusCode).toBe(403); - await regularApp.close(); - }); - }); -}); + method: 'GET', + url: '/api/admin/stats', + headers: { authorization: 'Bearer user-token' }, + }) + + expect(response.statusCode).toBe(403) + await regularApp.close() + }) + }) +}) diff --git a/tests/unit/routes/admin-sybil.test.ts b/tests/unit/routes/admin-sybil.test.ts new file mode 100644 index 0000000..ec8c75d --- /dev/null +++ b/tests/unit/routes/admin-sybil.test.ts @@ -0,0 +1,1069 @@ +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' + +import { adminSybilRoutes } from '../../../src/routes/admin-sybil.js' + +// --------------------------------------------------------------------------- +// Mock env +// --------------------------------------------------------------------------- + +const mockEnv = { + COMMUNITY_DID: 'did:plc:community123', + RATE_LIMIT_WRITE: 10, + RATE_LIMIT_READ_ANON: 100, + RATE_LIMIT_READ_AUTH: 300, +} as Env + +// --------------------------------------------------------------------------- +// Test constants +// --------------------------------------------------------------------------- + +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) +const ADMIN_DID = 'did:plc:admin999' +const TEST_NOW = '2026-02-13T12:00:00.000Z' + +// --------------------------------------------------------------------------- +// Mock user builders +// --------------------------------------------------------------------------- + +function testUser(overrides?: Partial): RequestUser { + return { + did: TEST_DID, + handle: TEST_HANDLE, + sid: TEST_SID, + ...overrides, + } +} + +function adminUser(): RequestUser { + return testUser({ did: ADMIN_DID, handle: 'admin.bsky.social' }) +} + +// --------------------------------------------------------------------------- +// Mock DB +// --------------------------------------------------------------------------- + +const mockDb = createMockDb() + +let selectChain: DbChain +let updateChain: DbChain +let insertChain: DbChain +let deleteChain: DbChain + +function resetAllDbMocks(): void { + selectChain = createChainableProxy([]) + updateChain = createChainableProxy([]) + insertChain = createChainableProxy([]) + deleteChain = createChainableProxy([]) + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(updateChain) + mockDb.delete.mockReturnValue(deleteChain) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction + mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { + await fn(mockDb) + }) + mockDb.execute.mockReset() +} + +// --------------------------------------------------------------------------- +// Mock cache +// --------------------------------------------------------------------------- + +function createMockCache() { + return { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue('OK'), + del: vi.fn().mockResolvedValue(1), + quit: vi.fn().mockResolvedValue('OK'), + } +} + +// --------------------------------------------------------------------------- +// Mock requireAdmin +// --------------------------------------------------------------------------- + +function createMockRequireAdmin(user?: RequestUser) { + return async ( + request: { user?: RequestUser }, + reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise } } + ) => { + if (!user) { + await reply.status(401).send({ error: 'Authentication required' }) + return + } + request.user = user + if (user.did !== ADMIN_DID) { + await reply.status(403).send({ error: 'Admin access required' }) + return + } + } +} + +// --------------------------------------------------------------------------- +// Mock auth middleware +// --------------------------------------------------------------------------- + +function createMockAuthMiddleware(user?: RequestUser) { + return { + requireAuth: async ( + request: { user?: RequestUser }, + reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise } } + ) => { + if (!user) { + await reply.status(401).send({ error: 'Authentication required' }) + return + } + request.user = user + }, + optionalAuth: (request: { user?: RequestUser }) => { + if (user) { + request.user = user + } + return Promise.resolve() + }, + } +} + +// --------------------------------------------------------------------------- +// Sample data builders +// --------------------------------------------------------------------------- + +function sampleTrustSeed(overrides?: Record) { + return { + id: 1, + did: 'did:plc:seed001', + communityId: '', + addedBy: ADMIN_DID, + reason: 'Trusted community member', + createdAt: new Date(TEST_NOW), + ...overrides, + } +} + +function sampleSybilCluster(overrides?: Record) { + return { + id: 1, + clusterHash: 'abc123hash', + internalEdgeCount: 15, + externalEdgeCount: 2, + memberCount: 5, + status: 'flagged' as const, + reviewedBy: null, + reviewedAt: null, + detectedAt: new Date(TEST_NOW), + updatedAt: new Date(TEST_NOW), + ...overrides, + } +} + +function samplePdsTrust(overrides?: Record) { + return { + id: 1, + pdsHost: 'bsky.social', + trustFactor: 1.0, + isDefault: true, + updatedAt: new Date(TEST_NOW), + ...overrides, + } +} + +function sampleBehavioralFlag(overrides?: Record) { + return { + id: 1, + flagType: 'burst_voting' as const, + affectedDids: ['did:plc:user1', 'did:plc:user2'], + details: 'Burst voting detected', + communityDid: null, + status: 'pending' as const, + detectedAt: new Date(TEST_NOW), + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// Test app builder +// --------------------------------------------------------------------------- + +const mockTrustGraphService = { + computeTrustScores: vi.fn().mockResolvedValue({ + totalNodes: 0, + totalEdges: 0, + iterations: 0, + converged: true, + durationMs: 0, + }), + getTrustScore: vi.fn().mockResolvedValue(0.1), +} + +async function buildTestApp(user?: RequestUser): Promise { + const app = Fastify({ logger: false }) + + const authMiddleware = createMockAuthMiddleware(user) + const requireAdmin = createMockRequireAdmin(user) + const cache = createMockCache() + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', authMiddleware as never) + app.decorate('requireAdmin', requireAdmin as never) + app.decorate('cache', cache as never) + app.decorate('firehose', {} as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('trustGraphService', mockTrustGraphService as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + + await app.register(adminSybilRoutes()) + await app.ready() + + return app +} + +// =========================================================================== +// Test suite +// =========================================================================== + +describe('admin sybil routes', () => { + // ========================================================================= + // Trust Seeds + // ========================================================================= + + describe('GET /api/admin/trust-seeds', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns paginated list of trust seeds with implicit seeds', async () => { + const seed = sampleTrustSeed() + + // First select: db.select({seed, handle, displayName}).from(trustSeeds).leftJoin(users).where().orderBy().limit() + const explicitChain = createChainableProxy([ + { seed, handle: 'seed-user.bsky.social', displayName: 'Seed User' }, + ]) + // Second select: db.select().from(users).where() for implicit seeds + const implicitChain = createChainableProxy([ + { + did: 'did:plc:mod001', + handle: 'mod.bsky.social', + displayName: 'Mod', + role: 'moderator', + firstSeenAt: new Date(TEST_NOW), + }, + ]) + + mockDb.select.mockReturnValueOnce(explicitChain).mockReturnValueOnce(implicitChain) + + const response = await app.inject({ + method: 'GET', + url: '/api/admin/trust-seeds', + headers: { authorization: 'Bearer admin-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + seeds: Array<{ did: string; implicit: boolean; handle: string | null }> + cursor: string | null + }>() + expect(body.seeds.length).toBeGreaterThanOrEqual(1) + // Should include both explicit and implicit seeds + const explicitSeed = body.seeds.find((s) => s.did === 'did:plc:seed001') + expect(explicitSeed).toBeDefined() + expect(explicitSeed?.implicit).toBe(false) + expect(explicitSeed?.handle).toBe('seed-user.bsky.social') + }) + + it('returns 401 when unauthenticated', async () => { + const noAuthApp = await buildTestApp(undefined) + + const response = await noAuthApp.inject({ + method: 'GET', + url: '/api/admin/trust-seeds', + }) + + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) + + it('returns 403 when non-admin user', async () => { + const regularApp = await buildTestApp(testUser()) + + const response = await regularApp.inject({ + method: 'GET', + url: '/api/admin/trust-seeds', + headers: { authorization: 'Bearer user-token' }, + }) + + expect(response.statusCode).toBe(403) + await regularApp.close() + }) + }) + + describe('POST /api/admin/trust-seeds', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('creates a trust seed when DID exists', async () => { + // User lookup: db.select({did, handle, displayName}).from(users).where() + const userLookupChain = createChainableProxy([ + { did: 'did:plc:newuser', handle: 'newuser.bsky.social', displayName: 'New User' }, + ]) + mockDb.select.mockReturnValueOnce(userLookupChain) + // Insert returning + const newSeed = sampleTrustSeed({ did: 'did:plc:newuser', id: 2 }) + insertChain.returning.mockResolvedValueOnce([newSeed]) + + const response = await app.inject({ + method: 'POST', + url: '/api/admin/trust-seeds', + headers: { authorization: 'Bearer admin-token' }, + payload: { + did: 'did:plc:newuser', + reason: 'Trusted', + }, + }) + + expect(response.statusCode).toBe(201) + const body = response.json<{ did: string; id: number; handle: string }>() + expect(body.did).toBe('did:plc:newuser') + expect(body.handle).toBe('newuser.bsky.social') + }) + + it('returns 404 when DID not found in users table', async () => { + const emptyChain = createChainableProxy([]) + mockDb.select.mockReturnValueOnce(emptyChain) + + const response = await app.inject({ + method: 'POST', + url: '/api/admin/trust-seeds', + headers: { authorization: 'Bearer admin-token' }, + payload: { + did: 'did:plc:nonexistent', + }, + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns 400 for empty did', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/admin/trust-seeds', + headers: { authorization: 'Bearer admin-token' }, + payload: { + did: '', + }, + }) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('DELETE /api/admin/trust-seeds/:id', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('deletes a trust seed and returns 204', async () => { + selectChain.where.mockResolvedValueOnce([{ id: 1 }]) + + const response = await app.inject({ + method: 'DELETE', + url: '/api/admin/trust-seeds/1', + headers: { authorization: 'Bearer admin-token' }, + }) + + expect(response.statusCode).toBe(204) + expect(mockDb.delete).toHaveBeenCalled() + }) + + it('returns 404 when seed not found', async () => { + selectChain.where.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'DELETE', + url: '/api/admin/trust-seeds/999', + headers: { authorization: 'Bearer admin-token' }, + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns 400 for invalid ID', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/admin/trust-seeds/abc', + headers: { authorization: 'Bearer admin-token' }, + }) + + expect(response.statusCode).toBe(400) + }) + }) + + // ========================================================================= + // Sybil Clusters + // ========================================================================= + + describe('GET /api/admin/sybil-clusters', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns paginated list of sybil clusters', async () => { + const cluster = sampleSybilCluster() + selectChain.limit.mockResolvedValueOnce([cluster]) + + const response = await app.inject({ + method: 'GET', + url: '/api/admin/sybil-clusters', + headers: { authorization: 'Bearer admin-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + clusters: Array<{ id: number; status: string }> + cursor: string | null + }>() + expect(body.clusters).toHaveLength(1) + expect(body.clusters[0]?.status).toBe('flagged') + }) + + it('filters by status', async () => { + selectChain.limit.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'GET', + url: '/api/admin/sybil-clusters?status=banned', + headers: { authorization: 'Bearer admin-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ clusters: unknown[] }>() + expect(body.clusters).toHaveLength(0) + }) + }) + + describe('GET /api/admin/sybil-clusters/:id', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns cluster detail with enriched members', async () => { + const cluster = sampleSybilCluster() + // First select: cluster lookup + const clusterChain = createChainableProxy([cluster]) + // Second select: enriched members with leftJoin + const membersChain = createChainableProxy([ + { + did: 'did:plc:member1', + roleInCluster: 'core', + joinedAt: new Date(TEST_NOW), + handle: 'member1.bsky.social', + displayName: 'Member One', + reputationScore: 50, + accountCreatedAt: new Date(TEST_NOW), + trustScore: 0.8, + }, + { + did: 'did:plc:member2', + roleInCluster: 'peripheral', + joinedAt: new Date(TEST_NOW), + handle: null, + displayName: null, + reputationScore: 10, + accountCreatedAt: null, + trustScore: null, + }, + ]) + + mockDb.select.mockReturnValueOnce(clusterChain).mockReturnValueOnce(membersChain) + + const response = await app.inject({ + method: 'GET', + url: '/api/admin/sybil-clusters/1', + headers: { authorization: 'Bearer admin-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + id: number + suspicionRatio: number + members: Array<{ + did: string + roleInCluster: string + handle: string | null + trustScore: number | null + }> + }>() + expect(body.id).toBe(1) + expect(body.suspicionRatio).toBeCloseTo(15 / 17) // 15 internal / (15 + 2) total + expect(body.members).toHaveLength(2) + expect(body.members[0]?.roleInCluster).toBe('core') + expect(body.members[0]?.handle).toBe('member1.bsky.social') + expect(body.members[0]?.trustScore).toBe(0.8) + }) + + it('returns 404 when cluster not found', async () => { + const emptyChain = createChainableProxy([]) + mockDb.select.mockReturnValueOnce(emptyChain) + + const response = await app.inject({ + method: 'GET', + url: '/api/admin/sybil-clusters/999', + headers: { authorization: 'Bearer admin-token' }, + }) + + expect(response.statusCode).toBe(404) + }) + }) + + describe('PUT /api/admin/sybil-clusters/:id', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('updates cluster status', async () => { + const cluster = sampleSybilCluster() + const clusterLookup = createChainableProxy([cluster]) + mockDb.select.mockReturnValueOnce(clusterLookup) + updateChain.returning.mockResolvedValueOnce([ + { ...cluster, status: 'monitoring', updatedAt: new Date() }, + ]) + + const response = await app.inject({ + method: 'PUT', + url: '/api/admin/sybil-clusters/1', + headers: { authorization: 'Bearer admin-token' }, + payload: { status: 'monitoring' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ status: string; suspicionRatio: number }>() + expect(body.status).toBe('monitoring') + expect(body.suspicionRatio).toBeDefined() + }) + + it('propagates ban to cluster members when status is banned', async () => { + const cluster = sampleSybilCluster() + const clusterLookup = createChainableProxy([cluster]) + mockDb.select.mockReturnValueOnce(clusterLookup) + updateChain.returning.mockResolvedValueOnce([ + { ...cluster, status: 'banned', reviewedBy: ADMIN_DID, updatedAt: new Date() }, + ]) + // Members query for ban propagation + const membersChain = createChainableProxy([ + { did: 'did:plc:member1' }, + { did: 'did:plc:member2' }, + ]) + mockDb.select.mockReturnValueOnce(membersChain) + + const response = await app.inject({ + method: 'PUT', + url: '/api/admin/sybil-clusters/1', + headers: { authorization: 'Bearer admin-token' }, + payload: { status: 'banned' }, + }) + + expect(response.statusCode).toBe(200) + // Verify that update was called for ban propagation + // mockDb.update is called: once for cluster status + once per member + expect(mockDb.update).toHaveBeenCalled() + }) + + it('returns 404 when cluster not found', async () => { + const emptyChain = createChainableProxy([]) + mockDb.select.mockReturnValueOnce(emptyChain) + + const response = await app.inject({ + method: 'PUT', + url: '/api/admin/sybil-clusters/999', + headers: { authorization: 'Bearer admin-token' }, + payload: { status: 'dismissed' }, + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns 400 for invalid status', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/admin/sybil-clusters/1', + headers: { authorization: 'Bearer admin-token' }, + payload: { status: 'invalid' }, + }) + + expect(response.statusCode).toBe(400) + }) + }) + + // ========================================================================= + // PDS Trust + // ========================================================================= + + describe('GET /api/admin/pds-trust', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns list of PDS trust factors with defaults', async () => { + const factor = samplePdsTrust() + selectChain.limit.mockResolvedValueOnce([factor]) + + const response = await app.inject({ + method: 'GET', + url: '/api/admin/pds-trust', + headers: { authorization: 'Bearer admin-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + factors: Array<{ pdsHost: string; trustFactor: number; isDefault: boolean }> + }>() + expect(body.factors).toHaveLength(1) + expect(body.factors[0]?.pdsHost).toBe('bsky.social') + expect(body.factors[0]?.isDefault).toBe(true) + }) + }) + + describe('PUT /api/admin/pds-trust', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('creates an override for a specific PDS host', async () => { + const newFactor = samplePdsTrust({ + id: 2, + pdsHost: 'custom.pds.example.com', + trustFactor: 0.5, + isDefault: false, + }) + insertChain.returning.mockResolvedValueOnce([newFactor]) + + const response = await app.inject({ + method: 'PUT', + url: '/api/admin/pds-trust', + headers: { authorization: 'Bearer admin-token' }, + payload: { + pdsHost: 'custom.pds.example.com', + trustFactor: 0.5, + }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ pdsHost: string; trustFactor: number }>() + expect(body.pdsHost).toBe('custom.pds.example.com') + expect(body.trustFactor).toBe(0.5) + }) + + it('returns 400 for trust factor out of range', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/admin/pds-trust', + headers: { authorization: 'Bearer admin-token' }, + payload: { + pdsHost: 'example.com', + trustFactor: 1.5, + }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('returns 400 for invalid hostname', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/admin/pds-trust', + headers: { authorization: 'Bearer admin-token' }, + payload: { + pdsHost: 'not a hostname', + trustFactor: 0.5, + }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('returns 400 for negative trust factor', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/admin/pds-trust', + headers: { authorization: 'Bearer admin-token' }, + payload: { + pdsHost: 'example.com', + trustFactor: -0.1, + }, + }) + + expect(response.statusCode).toBe(400) + }) + }) + + // ========================================================================= + // Trust Graph Admin + // ========================================================================= + + describe('POST /api/admin/trust-graph/recompute', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns 202 when recompute is triggered', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/admin/trust-graph/recompute', + headers: { authorization: 'Bearer admin-token' }, + }) + + expect(response.statusCode).toBe(202) + const body = response.json<{ message: string; startedAt: string }>() + expect(body.message).toContain('recomputation started') + expect(body.startedAt).toBeDefined() + }) + + it('returns 429 when rate limited (recompute within 1 hour)', async () => { + // Build a fresh app with a cache that returns a recent timestamp + const rateLimitedApp = Fastify({ logger: false }) + const recentTime = String(Date.now() - 5 * 60 * 1000) // 5 min ago + const mockCache = createMockCache() + mockCache.get.mockResolvedValue(recentTime) + + rateLimitedApp.decorate('db', mockDb as never) + rateLimitedApp.decorate('env', mockEnv) + rateLimitedApp.decorate('authMiddleware', createMockAuthMiddleware(adminUser()) as never) + rateLimitedApp.decorate('requireAdmin', createMockRequireAdmin(adminUser()) as never) + rateLimitedApp.decorate('cache', mockCache as never) + rateLimitedApp.decorate('firehose', {} as never) + rateLimitedApp.decorate('oauthClient', {} as never) + rateLimitedApp.decorate('sessionService', {} as SessionService) + rateLimitedApp.decorate('setupService', {} as SetupService) + rateLimitedApp.decorate('trustGraphService', mockTrustGraphService as never) + rateLimitedApp.decorateRequest('user', undefined as RequestUser | undefined) + + await rateLimitedApp.register(adminSybilRoutes()) + await rateLimitedApp.ready() + + const response = await rateLimitedApp.inject({ + method: 'POST', + url: '/api/admin/trust-graph/recompute', + headers: { authorization: 'Bearer admin-token' }, + }) + + expect(response.statusCode).toBe(429) + await rateLimitedApp.close() + }) + }) + + describe('GET /api/admin/trust-graph/status', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns trust graph computation stats', async () => { + // Three parallel db.select({count}).from() queries + // First two resolve at .from() (no .where()), third at .where() + const nodeCountChain = createChainableProxy([{ nodeCount: 42 }]) + // Override from() to be thenable since it's the terminal call for this query + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain + nodeCountChain.from.mockImplementation(() => ({ + ...nodeCountChain, + then: (resolve: (val: unknown) => void, reject?: (err: unknown) => void) => + Promise.resolve([{ nodeCount: 42 }]).then(resolve, reject), + })) + + const edgeCountChain = createChainableProxy([{ edgeCount: 100 }]) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain + edgeCountChain.from.mockImplementation(() => ({ + ...edgeCountChain, + then: (resolve: (val: unknown) => void, reject?: (err: unknown) => void) => + Promise.resolve([{ edgeCount: 100 }]).then(resolve, reject), + })) + + const flaggedCountChain = createChainableProxy([{ flaggedCount: 3 }]) + + mockDb.select + .mockReturnValueOnce(nodeCountChain) // trust_scores count + .mockReturnValueOnce(edgeCountChain) // interaction_graph count + .mockReturnValueOnce(flaggedCountChain) // sybil_clusters flagged count + + const response = await app.inject({ + method: 'GET', + url: '/api/admin/trust-graph/status', + headers: { authorization: 'Bearer admin-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + lastComputedAt: string | null + totalNodes: number + totalEdges: number + computationDurationMs: number | null + clustersFlagged: number + nextScheduledAt: string | null + }>() + expect(body.totalNodes).toBe(42) + expect(body.totalEdges).toBe(100) + expect(body.clustersFlagged).toBe(3) + }) + }) + + // ========================================================================= + // Behavioral Flags + // ========================================================================= + + describe('GET /api/admin/behavioral-flags', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns paginated list of behavioral flags', async () => { + const flag = sampleBehavioralFlag() + selectChain.limit.mockResolvedValueOnce([flag]) + + const response = await app.inject({ + method: 'GET', + url: '/api/admin/behavioral-flags', + headers: { authorization: 'Bearer admin-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + flags: Array<{ id: number; flagType: string; status: string }> + cursor: string | null + }>() + expect(body.flags).toHaveLength(1) + expect(body.flags[0]?.flagType).toBe('burst_voting') + expect(body.flags[0]?.status).toBe('pending') + }) + + it('filters by flag type and status', async () => { + selectChain.limit.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'GET', + url: '/api/admin/behavioral-flags?flagType=low_diversity&status=pending', + headers: { authorization: 'Bearer admin-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ flags: unknown[] }>() + expect(body.flags).toHaveLength(0) + }) + }) + + describe('PUT /api/admin/behavioral-flags/:id', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('updates flag status to dismissed', async () => { + const flag = sampleBehavioralFlag() + selectChain.where.mockResolvedValueOnce([flag]) + updateChain.returning.mockResolvedValueOnce([{ ...flag, status: 'dismissed' }]) + + const response = await app.inject({ + method: 'PUT', + url: '/api/admin/behavioral-flags/1', + headers: { authorization: 'Bearer admin-token' }, + payload: { status: 'dismissed' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ status: string }>() + expect(body.status).toBe('dismissed') + }) + + it('updates flag status to action_taken', async () => { + const flag = sampleBehavioralFlag() + selectChain.where.mockResolvedValueOnce([flag]) + updateChain.returning.mockResolvedValueOnce([{ ...flag, status: 'action_taken' }]) + + const response = await app.inject({ + method: 'PUT', + url: '/api/admin/behavioral-flags/1', + headers: { authorization: 'Bearer admin-token' }, + payload: { status: 'action_taken' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ status: string }>() + expect(body.status).toBe('action_taken') + }) + + it('returns 404 when flag not found', async () => { + selectChain.where.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'PUT', + url: '/api/admin/behavioral-flags/999', + headers: { authorization: 'Bearer admin-token' }, + payload: { status: 'dismissed' }, + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns 400 for invalid status', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/admin/behavioral-flags/1', + headers: { authorization: 'Bearer admin-token' }, + payload: { status: 'invalid' }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('returns 400 for invalid flag ID', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/admin/behavioral-flags/abc', + headers: { authorization: 'Bearer admin-token' }, + payload: { status: 'dismissed' }, + }) + + expect(response.statusCode).toBe(400) + }) + }) +}) diff --git a/tests/unit/routes/auth.test.ts b/tests/unit/routes/auth.test.ts index 6451a5f..97a10ba 100644 --- a/tests/unit/routes/auth.test.ts +++ b/tests/unit/routes/auth.test.ts @@ -1,64 +1,73 @@ -import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; -import Fastify from "fastify"; -import cookie from "@fastify/cookie"; -import type { FastifyInstance } from "fastify"; -import type { SessionService, SessionWithToken, Session } from "../../../src/auth/session.js"; -import type { Env } from "../../../src/config/env.js"; -import { authRoutes } from "../../../src/routes/auth.js"; -import type { HandleResolver } from "../../../src/lib/handle-resolver.js"; -import { BARAZO_BASE_SCOPES, BARAZO_CROSSPOST_SCOPES, FALLBACK_SCOPE } from "../../../src/auth/scopes.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import cookie from '@fastify/cookie' +import type { FastifyInstance } from 'fastify' +import type { SessionService, SessionWithToken, Session } from '../../../src/auth/session.js' +import type { Env } from '../../../src/config/env.js' +import { authRoutes } from '../../../src/routes/auth.js' +import type { HandleResolver } from '../../../src/lib/handle-resolver.js' +import { + BARAZO_BASE_SCOPES, + BARAZO_CROSSPOST_SCOPES, + FALLBACK_SCOPE, +} from '../../../src/auth/scopes.js' // --------------------------------------------------------------------------- // Mock env (minimal subset needed by auth routes) // --------------------------------------------------------------------------- const mockEnv = { - OAUTH_CLIENT_ID: "http://localhost", + OAUTH_CLIENT_ID: 'http://localhost', OAUTH_SESSION_TTL: 604800, OAUTH_ACCESS_TOKEN_TTL: 900, - CORS_ORIGINS: "http://localhost:3000", -} as Env; + CORS_ORIGINS: 'http://localhost:3000', +} as Env // --------------------------------------------------------------------------- // Standalone mock functions (avoids @typescript-eslint/unbound-method) // --------------------------------------------------------------------------- // Database mock functions -const dbSelectFn = vi.fn(); -const dbInsertFn = vi.fn(); -const dbFromFn = vi.fn(); -const dbWhereFn = vi.fn(); -const dbValuesFn = vi.fn(); -const dbOnConflictDoUpdateFn = vi.fn(); +const dbSelectFn = vi.fn() +const dbInsertFn = vi.fn() +const dbFromFn = vi.fn() +const dbWhereFn = vi.fn() +const dbValuesFn = vi.fn() +const dbOnConflictDoUpdateFn = vi.fn() function createMockDb() { // Default: no preferences found (crossPostScopesGranted = false) - dbWhereFn.mockResolvedValue([]); - dbFromFn.mockReturnValue({ where: dbWhereFn }); - dbSelectFn.mockReturnValue({ from: dbFromFn }); - dbOnConflictDoUpdateFn.mockResolvedValue(undefined); - dbValuesFn.mockReturnValue({ onConflictDoUpdate: dbOnConflictDoUpdateFn }); - dbInsertFn.mockReturnValue({ values: dbValuesFn }); + dbWhereFn.mockResolvedValue([]) + dbFromFn.mockReturnValue({ where: dbWhereFn }) + dbSelectFn.mockReturnValue({ from: dbFromFn }) + dbOnConflictDoUpdateFn.mockResolvedValue(undefined) + dbValuesFn.mockReturnValue({ onConflictDoUpdate: dbOnConflictDoUpdateFn }) + dbInsertFn.mockReturnValue({ values: dbValuesFn }) return { select: dbSelectFn, insert: dbInsertFn, - }; + } } // OAuth client mock functions -const authorizeFn = vi.fn<(...args: unknown[]) => Promise>(); -const callbackFn = vi.fn<(...args: unknown[]) => Promise<{ session: { did: string; tokenSet?: { scope?: string } }; state: string | null }>>(); +const authorizeFn = vi.fn<(...args: unknown[]) => Promise>() +const callbackFn = + vi.fn< + ( + ...args: unknown[] + ) => Promise<{ session: { did: string; tokenSet?: { scope?: string } }; state: string | null }> + >() // Session service mock functions -const createSessionFn = vi.fn<(...args: unknown[]) => Promise>(); -const validateAccessTokenFn = vi.fn<(...args: unknown[]) => Promise>(); -const refreshSessionFn = vi.fn<(...args: unknown[]) => Promise>(); -const deleteSessionFn = vi.fn<(...args: unknown[]) => Promise>(); -const deleteAllSessionsForDidFn = vi.fn<(...args: unknown[]) => Promise>(); +const createSessionFn = vi.fn<(...args: unknown[]) => Promise>() +const validateAccessTokenFn = vi.fn<(...args: unknown[]) => Promise>() +const refreshSessionFn = vi.fn<(...args: unknown[]) => Promise>() +const deleteSessionFn = vi.fn<(...args: unknown[]) => Promise>() +const deleteAllSessionsForDidFn = vi.fn<(...args: unknown[]) => Promise>() // Handle resolver mock function -const resolveFn = vi.fn<(...args: unknown[]) => Promise>(); +const resolveFn = vi.fn<(...args: unknown[]) => Promise>() // --------------------------------------------------------------------------- // Mock objects using standalone fns @@ -69,7 +78,7 @@ const mockOAuthClient = { callback: callbackFn, clientMetadata: {}, jwks: { keys: [] }, -}; +} const mockSessionService: SessionService = { createSession: createSessionFn, @@ -77,22 +86,22 @@ const mockSessionService: SessionService = { refreshSession: refreshSessionFn, deleteSession: deleteSessionFn, deleteAllSessionsForDid: deleteAllSessionsForDidFn, -}; +} const mockHandleResolver: HandleResolver = { resolve: resolveFn, -}; +} // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:test123456789"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); -const TEST_ACCESS_TOKEN = "b".repeat(64); -const TEST_ACCESS_TOKEN_HASH = "c".repeat(64); -const TEST_EXPIRES_AT = Date.now() + 900_000; +const TEST_DID = 'did:plc:test123456789' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) +const TEST_ACCESS_TOKEN = 'b'.repeat(64) +const TEST_ACCESS_TOKEN_HASH = 'c'.repeat(64) +const TEST_EXPIRES_AT = Date.now() + 900_000 function makeMockSessionWithToken(): SessionWithToken { return { @@ -103,7 +112,7 @@ function makeMockSessionWithToken(): SessionWithToken { accessTokenExpiresAt: TEST_EXPIRES_AT, createdAt: Date.now(), accessToken: TEST_ACCESS_TOKEN, - }; + } } function makeMockSession(): Session { @@ -114,635 +123,616 @@ function makeMockSession(): Session { accessTokenHash: TEST_ACCESS_TOKEN_HASH, accessTokenExpiresAt: TEST_EXPIRES_AT, createdAt: Date.now(), - }; + } } // --------------------------------------------------------------------------- // Test suite // --------------------------------------------------------------------------- -describe("auth routes", () => { - let app: FastifyInstance; +describe('auth routes', () => { + let app: FastifyInstance beforeAll(async () => { - app = Fastify({ logger: false }); + app = Fastify({ logger: false }) // Register cookie plugin - await app.register(cookie, { secret: "a".repeat(32) }); + await app.register(cookie, { secret: 'a'.repeat(32) }) // Decorate with mocks - app.decorate("env", mockEnv); - app.decorate("sessionService", mockSessionService); - app.decorate("handleResolver", mockHandleResolver); - app.decorate("profileSync", { syncProfile: vi.fn().mockResolvedValue({ displayName: null, avatarUrl: null, bannerUrl: null, bio: null }) }); - app.decorate("db", createMockDb()); + app.decorate('env', mockEnv) + app.decorate('sessionService', mockSessionService) + app.decorate('handleResolver', mockHandleResolver) + app.decorate('profileSync', { + syncProfile: vi + .fn() + .mockResolvedValue({ displayName: null, avatarUrl: null, bannerUrl: null, bio: null }), + }) + app.decorate('db', createMockDb()) // Register auth routes (cast needed because mock is not full NodeOAuthClient) - await app.register( - authRoutes(mockOAuthClient as Parameters[0]), - ); - await app.ready(); - }); + await app.register(authRoutes(mockOAuthClient as Parameters[0])) + await app.ready() + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); + vi.clearAllMocks() // Reset db mocks to default behavior - dbWhereFn.mockResolvedValue([]); - dbFromFn.mockReturnValue({ where: dbWhereFn }); - dbSelectFn.mockReturnValue({ from: dbFromFn }); - dbOnConflictDoUpdateFn.mockResolvedValue(undefined); - dbValuesFn.mockReturnValue({ onConflictDoUpdate: dbOnConflictDoUpdateFn }); - dbInsertFn.mockReturnValue({ values: dbValuesFn }); - }); + dbWhereFn.mockResolvedValue([]) + dbFromFn.mockReturnValue({ where: dbWhereFn }) + dbSelectFn.mockReturnValue({ from: dbFromFn }) + dbOnConflictDoUpdateFn.mockResolvedValue(undefined) + dbValuesFn.mockReturnValue({ onConflictDoUpdate: dbOnConflictDoUpdateFn }) + dbInsertFn.mockReturnValue({ values: dbValuesFn }) + }) // ========================================================================= // GET /api/auth/login // ========================================================================= - describe("GET /api/auth/login", () => { - it("returns redirect URL for valid handle", async () => { - const redirectUrl = new URL("https://pds.example.com/oauth/authorize?code=abc"); - authorizeFn.mockResolvedValueOnce(redirectUrl); + describe('GET /api/auth/login', () => { + it('returns redirect URL for valid handle', async () => { + const redirectUrl = new URL('https://pds.example.com/oauth/authorize?code=abc') + authorizeFn.mockResolvedValueOnce(redirectUrl) const response = await app.inject({ - method: "GET", - url: "/api/auth/login?handle=alice.bsky.social", - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ url: string }>(); - expect(body.url).toBe(redirectUrl.toString()); - expect(authorizeFn).toHaveBeenCalledWith( - "alice.bsky.social", - { scope: BARAZO_BASE_SCOPES }, - ); - }); - - it("returns 400 for missing handle", async () => { + method: 'GET', + url: '/api/auth/login?handle=alice.bsky.social', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ url: string }>() + expect(body.url).toBe(redirectUrl.toString()) + expect(authorizeFn).toHaveBeenCalledWith('alice.bsky.social', { scope: BARAZO_BASE_SCOPES }) + }) + + it('returns 400 for missing handle', async () => { const response = await app.inject({ - method: "GET", - url: "/api/auth/login", - }); + method: 'GET', + url: '/api/auth/login', + }) - expect(response.statusCode).toBe(400); - const body = response.json<{ error: string }>(); - expect(body.error).toBe("Invalid handle"); - }); + expect(response.statusCode).toBe(400) + const body = response.json<{ error: string }>() + expect(body.error).toBe('Invalid handle') + }) - it("returns 400 for empty handle", async () => { + it('returns 400 for empty handle', async () => { const response = await app.inject({ - method: "GET", - url: "/api/auth/login?handle=", - }); + method: 'GET', + url: '/api/auth/login?handle=', + }) - expect(response.statusCode).toBe(400); - const body = response.json<{ error: string }>(); - expect(body.error).toBe("Invalid handle"); - }); + expect(response.statusCode).toBe(400) + const body = response.json<{ error: string }>() + expect(body.error).toBe('Invalid handle') + }) - it("returns 400 for whitespace-only handle", async () => { + it('returns 400 for whitespace-only handle', async () => { const response = await app.inject({ - method: "GET", - url: "/api/auth/login?handle=%20%20", - }); + method: 'GET', + url: '/api/auth/login?handle=%20%20', + }) - expect(response.statusCode).toBe(400); - const body = response.json<{ error: string }>(); - expect(body.error).toBe("Invalid handle"); - }); + expect(response.statusCode).toBe(400) + const body = response.json<{ error: string }>() + expect(body.error).toBe('Invalid handle') + }) - it("returns 502 when OAuth client throws", async () => { - authorizeFn.mockRejectedValueOnce(new Error("PDS unreachable")); + it('returns 502 when OAuth client throws', async () => { + authorizeFn.mockRejectedValueOnce(new Error('PDS unreachable')) const response = await app.inject({ - method: "GET", - url: "/api/auth/login?handle=alice.bsky.social", - }); + method: 'GET', + url: '/api/auth/login?handle=alice.bsky.social', + }) - expect(response.statusCode).toBe(502); - const body = response.json<{ error: string }>(); - expect(body.error).toBe("Failed to initiate login"); - }); - }); + expect(response.statusCode).toBe(502) + const body = response.json<{ error: string }>() + expect(body.error).toBe('Failed to initiate login') + }) + }) // ========================================================================= // GET /api/auth/callback // ========================================================================= - describe("GET /api/auth/callback", () => { - it("redirects to frontend and sets cookie for valid callback", async () => { - const mockSession = makeMockSessionWithToken(); - const mockOAuthSession = { did: TEST_DID }; + describe('GET /api/auth/callback', () => { + it('redirects to frontend and sets cookie for valid callback', async () => { + const mockSession = makeMockSessionWithToken() + const mockOAuthSession = { did: TEST_DID } callbackFn.mockResolvedValueOnce({ session: mockOAuthSession, - state: "some-state", - }); - resolveFn.mockResolvedValueOnce(TEST_HANDLE); - createSessionFn.mockResolvedValueOnce(mockSession); + state: 'some-state', + }) + resolveFn.mockResolvedValueOnce(TEST_HANDLE) + createSessionFn.mockResolvedValueOnce(mockSession) const response = await app.inject({ - method: "GET", - url: "/api/auth/callback?iss=https://pds.example.com&code=test-code&state=test-state", - }); + method: 'GET', + url: '/api/auth/callback?iss=https://pds.example.com&code=test-code&state=test-state', + }) - expect(response.statusCode).toBe(302); + expect(response.statusCode).toBe(302) // Verify redirect URL points to frontend callback with success flag - const location = response.headers.location as string; - expect(location).toContain("/auth/callback"); - expect(location).toContain("success=true"); + const location = response.headers.location as string + expect(location).toContain('/auth/callback') + expect(location).toContain('success=true') // Verify handle was resolved from DID and session created with resolved handle - expect(resolveFn).toHaveBeenCalledWith(TEST_DID); - expect(createSessionFn).toHaveBeenCalledWith(TEST_DID, TEST_HANDLE); + expect(resolveFn).toHaveBeenCalledWith(TEST_DID) + expect(createSessionFn).toHaveBeenCalledWith(TEST_DID, TEST_HANDLE) // Verify cookie was set - const cookies = response.cookies; - const refreshCookie = cookies.find( - (c: { name: string }) => c.name === "barazo_refresh", - ); - expect(refreshCookie).toBeDefined(); - expect(refreshCookie?.value).toBe(TEST_SID); - expect(refreshCookie?.httpOnly).toBe(true); - expect(refreshCookie?.sameSite).toBe("Lax"); - expect(refreshCookie?.path).toBe("/api/auth"); - }); - - it("returns 400 for missing iss param", async () => { + const cookies = response.cookies + const refreshCookie = cookies.find((c: { name: string }) => c.name === 'barazo_refresh') + expect(refreshCookie).toBeDefined() + expect(refreshCookie?.value).toBe(TEST_SID) + expect(refreshCookie?.httpOnly).toBe(true) + expect(refreshCookie?.sameSite).toBe('Lax') + expect(refreshCookie?.path).toBe('/api/auth') + }) + + it('returns 400 for missing iss param', async () => { const response = await app.inject({ - method: "GET", - url: "/api/auth/callback?code=test-code&state=test-state", - }); + method: 'GET', + url: '/api/auth/callback?code=test-code&state=test-state', + }) - expect(response.statusCode).toBe(400); - const body = response.json<{ error: string }>(); - expect(body.error).toBe("Invalid callback parameters"); - }); + expect(response.statusCode).toBe(400) + const body = response.json<{ error: string }>() + expect(body.error).toBe('Invalid callback parameters') + }) - it("returns 400 for missing code param", async () => { + it('returns 400 for missing code param', async () => { const response = await app.inject({ - method: "GET", - url: "/api/auth/callback?iss=https://pds.example.com&state=test-state", - }); + method: 'GET', + url: '/api/auth/callback?iss=https://pds.example.com&state=test-state', + }) - expect(response.statusCode).toBe(400); - const body = response.json<{ error: string }>(); - expect(body.error).toBe("Invalid callback parameters"); - }); + expect(response.statusCode).toBe(400) + const body = response.json<{ error: string }>() + expect(body.error).toBe('Invalid callback parameters') + }) - it("returns 400 for missing state param", async () => { + it('returns 400 for missing state param', async () => { const response = await app.inject({ - method: "GET", - url: "/api/auth/callback?iss=https://pds.example.com&code=test-code", - }); + method: 'GET', + url: '/api/auth/callback?iss=https://pds.example.com&code=test-code', + }) - expect(response.statusCode).toBe(400); - const body = response.json<{ error: string }>(); - expect(body.error).toBe("Invalid callback parameters"); - }); + expect(response.statusCode).toBe(400) + const body = response.json<{ error: string }>() + expect(body.error).toBe('Invalid callback parameters') + }) - it("redirects to frontend with error when OAuth client throws", async () => { - callbackFn.mockRejectedValueOnce(new Error("Token exchange failed")); + it('redirects to frontend with error when OAuth client throws', async () => { + callbackFn.mockRejectedValueOnce(new Error('Token exchange failed')) const response = await app.inject({ - method: "GET", - url: "/api/auth/callback?iss=https://pds.example.com&code=test-code&state=test-state", - }); + method: 'GET', + url: '/api/auth/callback?iss=https://pds.example.com&code=test-code&state=test-state', + }) - expect(response.statusCode).toBe(302); - const location = response.headers.location as string; - expect(location).toContain("/auth/callback"); - expect(location).toContain("error="); - }); - }); + expect(response.statusCode).toBe(302) + const location = response.headers.location as string + expect(location).toContain('/auth/callback') + expect(location).toContain('error=') + }) + }) // ========================================================================= // POST /api/auth/refresh // ========================================================================= - describe("POST /api/auth/refresh", () => { - it("returns new access token when valid refresh cookie", async () => { - const mockSession = makeMockSessionWithToken(); - refreshSessionFn.mockResolvedValueOnce(mockSession); + describe('POST /api/auth/refresh', () => { + it('returns new access token when valid refresh cookie', async () => { + const mockSession = makeMockSessionWithToken() + refreshSessionFn.mockResolvedValueOnce(mockSession) const response = await app.inject({ - method: "POST", - url: "/api/auth/refresh", + method: 'POST', + url: '/api/auth/refresh', cookies: { barazo_refresh: TEST_SID }, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - accessToken: string; - expiresAt: number; - }>(); - expect(body.accessToken).toBe(TEST_ACCESS_TOKEN); - expect(body.expiresAt).toBe(TEST_EXPIRES_AT); + accessToken: string + expiresAt: number + }>() + expect(body.accessToken).toBe(TEST_ACCESS_TOKEN) + expect(body.expiresAt).toBe(TEST_EXPIRES_AT) // Verify refresh cookie was re-set - const cookies = response.cookies; - const refreshCookie = cookies.find( - (c: { name: string }) => c.name === "barazo_refresh", - ); - expect(refreshCookie).toBeDefined(); - expect(refreshCookie?.value).toBe(TEST_SID); - }); - - it("returns 401 when no cookie", async () => { + const cookies = response.cookies + const refreshCookie = cookies.find((c: { name: string }) => c.name === 'barazo_refresh') + expect(refreshCookie).toBeDefined() + expect(refreshCookie?.value).toBe(TEST_SID) + }) + + it('returns 401 when no cookie', async () => { const response = await app.inject({ - method: "POST", - url: "/api/auth/refresh", - }); + method: 'POST', + url: '/api/auth/refresh', + }) - expect(response.statusCode).toBe(401); - const body = response.json<{ error: string }>(); - expect(body.error).toBe("No refresh token"); - }); + expect(response.statusCode).toBe(401) + const body = response.json<{ error: string }>() + expect(body.error).toBe('No refresh token') + }) - it("returns 401 when session expired and clears cookie", async () => { - refreshSessionFn.mockResolvedValueOnce(undefined); + it('returns 401 when session expired and clears cookie', async () => { + refreshSessionFn.mockResolvedValueOnce(undefined) const response = await app.inject({ - method: "POST", - url: "/api/auth/refresh", + method: 'POST', + url: '/api/auth/refresh', cookies: { barazo_refresh: TEST_SID }, - }); + }) - expect(response.statusCode).toBe(401); - const body = response.json<{ error: string }>(); - expect(body.error).toBe("Session expired"); + expect(response.statusCode).toBe(401) + const body = response.json<{ error: string }>() + expect(body.error).toBe('Session expired') // Verify cookie was cleared - const cookies = response.cookies; - const refreshCookie = cookies.find( - (c: { name: string }) => c.name === "barazo_refresh", - ); - expect(refreshCookie).toBeDefined(); - expect(refreshCookie?.value).toBe(""); - }); - }); + const cookies = response.cookies + const refreshCookie = cookies.find((c: { name: string }) => c.name === 'barazo_refresh') + expect(refreshCookie).toBeDefined() + expect(refreshCookie?.value).toBe('') + }) + }) // ========================================================================= // DELETE /api/auth/session // ========================================================================= - describe("DELETE /api/auth/session", () => { - it("returns 204 and clears cookie", async () => { - deleteSessionFn.mockResolvedValueOnce(undefined); + describe('DELETE /api/auth/session', () => { + it('returns 204 and clears cookie', async () => { + deleteSessionFn.mockResolvedValueOnce(undefined) const response = await app.inject({ - method: "DELETE", - url: "/api/auth/session", + method: 'DELETE', + url: '/api/auth/session', cookies: { barazo_refresh: TEST_SID }, - }); + }) - expect(response.statusCode).toBe(204); - expect(response.body).toBe(""); + expect(response.statusCode).toBe(204) + expect(response.body).toBe('') - expect(deleteSessionFn).toHaveBeenCalledWith(TEST_SID); + expect(deleteSessionFn).toHaveBeenCalledWith(TEST_SID) // Verify cookie was cleared - const cookies = response.cookies; - const refreshCookie = cookies.find( - (c: { name: string }) => c.name === "barazo_refresh", - ); - expect(refreshCookie).toBeDefined(); - expect(refreshCookie?.value).toBe(""); - }); - - it("returns 204 when no cookie (idempotent)", async () => { + const cookies = response.cookies + const refreshCookie = cookies.find((c: { name: string }) => c.name === 'barazo_refresh') + expect(refreshCookie).toBeDefined() + expect(refreshCookie?.value).toBe('') + }) + + it('returns 204 when no cookie (idempotent)', async () => { const response = await app.inject({ - method: "DELETE", - url: "/api/auth/session", - }); + method: 'DELETE', + url: '/api/auth/session', + }) - expect(response.statusCode).toBe(204); - expect(response.body).toBe(""); - expect(deleteSessionFn).not.toHaveBeenCalled(); - }); - }); + expect(response.statusCode).toBe(204) + expect(response.body).toBe('') + expect(deleteSessionFn).not.toHaveBeenCalled() + }) + }) // ========================================================================= // GET /api/auth/me // ========================================================================= - describe("GET /api/auth/me", () => { - it("returns user info for valid Bearer token", async () => { - const mockSession = makeMockSession(); - validateAccessTokenFn.mockResolvedValueOnce(mockSession); + describe('GET /api/auth/me', () => { + it('returns user info for valid Bearer token', async () => { + const mockSession = makeMockSession() + validateAccessTokenFn.mockResolvedValueOnce(mockSession) const response = await app.inject({ - method: "GET", - url: "/api/auth/me", + method: 'GET', + url: '/api/auth/me', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}`, }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ did: string; handle: string }>(); - expect(body.did).toBe(TEST_DID); - expect(body.handle).toBe(TEST_HANDLE); + expect(response.statusCode).toBe(200) + const body = response.json<{ did: string; handle: string }>() + expect(body.did).toBe(TEST_DID) + expect(body.handle).toBe(TEST_HANDLE) - expect(validateAccessTokenFn).toHaveBeenCalledWith(TEST_ACCESS_TOKEN); - }); + expect(validateAccessTokenFn).toHaveBeenCalledWith(TEST_ACCESS_TOKEN) + }) - it("returns 401 for missing Authorization header", async () => { + it('returns 401 for missing Authorization header', async () => { const response = await app.inject({ - method: "GET", - url: "/api/auth/me", - }); + method: 'GET', + url: '/api/auth/me', + }) - expect(response.statusCode).toBe(401); - const body = response.json<{ error: string }>(); - expect(body.error).toBe("Authentication required"); - }); + expect(response.statusCode).toBe(401) + const body = response.json<{ error: string }>() + expect(body.error).toBe('Authentication required') + }) - it("returns 401 for non-Bearer authorization", async () => { + it('returns 401 for non-Bearer authorization', async () => { const response = await app.inject({ - method: "GET", - url: "/api/auth/me", + method: 'GET', + url: '/api/auth/me', headers: { - authorization: "Basic dXNlcjpwYXNz", + authorization: 'Basic dXNlcjpwYXNz', }, - }); + }) - expect(response.statusCode).toBe(401); - const body = response.json<{ error: string }>(); - expect(body.error).toBe("Authentication required"); - }); + expect(response.statusCode).toBe(401) + const body = response.json<{ error: string }>() + expect(body.error).toBe('Authentication required') + }) - it("returns 401 for invalid/expired token", async () => { - validateAccessTokenFn.mockResolvedValueOnce(undefined); + it('returns 401 for invalid/expired token', async () => { + validateAccessTokenFn.mockResolvedValueOnce(undefined) const response = await app.inject({ - method: "GET", - url: "/api/auth/me", + method: 'GET', + url: '/api/auth/me', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}`, }, - }); + }) - expect(response.statusCode).toBe(401); - const body = response.json<{ error: string }>(); - expect(body.error).toBe("Invalid or expired token"); - }); + expect(response.statusCode).toBe(401) + const body = response.json<{ error: string }>() + expect(body.error).toBe('Invalid or expired token') + }) - it("returns 502 when session service throws", async () => { - validateAccessTokenFn.mockRejectedValueOnce(new Error("Valkey down")); + it('returns 502 when session service throws', async () => { + validateAccessTokenFn.mockRejectedValueOnce(new Error('Valkey down')) const response = await app.inject({ - method: "GET", - url: "/api/auth/me", + method: 'GET', + url: '/api/auth/me', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}`, }, - }); + }) - expect(response.statusCode).toBe(502); - const body = response.json<{ error: string }>(); - expect(body.error).toBe("Service temporarily unavailable"); - }); - }); + expect(response.statusCode).toBe(502) + const body = response.json<{ error: string }>() + expect(body.error).toBe('Service temporarily unavailable') + }) + }) // ========================================================================= // Service error handling // ========================================================================= - describe("service error handling", () => { - it("returns 502 when refresh service throws", async () => { - refreshSessionFn.mockRejectedValueOnce(new Error("Valkey down")); + describe('service error handling', () => { + it('returns 502 when refresh service throws', async () => { + refreshSessionFn.mockRejectedValueOnce(new Error('Valkey down')) const response = await app.inject({ - method: "POST", - url: "/api/auth/refresh", + method: 'POST', + url: '/api/auth/refresh', cookies: { barazo_refresh: TEST_SID }, - }); + }) - expect(response.statusCode).toBe(502); - const body = response.json<{ error: string }>(); - expect(body.error).toBe("Service temporarily unavailable"); - }); + expect(response.statusCode).toBe(502) + const body = response.json<{ error: string }>() + expect(body.error).toBe('Service temporarily unavailable') + }) - it("returns 502 when delete service throws", async () => { - deleteSessionFn.mockRejectedValueOnce(new Error("Valkey down")); + it('returns 502 when delete service throws', async () => { + deleteSessionFn.mockRejectedValueOnce(new Error('Valkey down')) const response = await app.inject({ - method: "DELETE", - url: "/api/auth/session", + method: 'DELETE', + url: '/api/auth/session', cookies: { barazo_refresh: TEST_SID }, - }); + }) - expect(response.statusCode).toBe(502); - const body = response.json<{ error: string }>(); - expect(body.error).toBe("Service temporarily unavailable"); - }); - }); + expect(response.statusCode).toBe(502) + const body = response.json<{ error: string }>() + expect(body.error).toBe('Service temporarily unavailable') + }) + }) // ========================================================================= // OAuth scope refinement // ========================================================================= - describe("granular scope fallback", () => { - it("falls back to transition:generic when granular scopes are rejected", async () => { - const fallbackUrl = new URL("https://pds.example.com/oauth/authorize?code=fallback"); + describe('granular scope fallback', () => { + it('falls back to transition:generic when granular scopes are rejected', async () => { + const fallbackUrl = new URL('https://pds.example.com/oauth/authorize?code=fallback') // First call (granular) fails, second call (fallback) succeeds authorizeFn - .mockRejectedValueOnce(new Error("Unsupported scope")) - .mockResolvedValueOnce(fallbackUrl); + .mockRejectedValueOnce(new Error('Unsupported scope')) + .mockResolvedValueOnce(fallbackUrl) const response = await app.inject({ - method: "GET", - url: "/api/auth/login?handle=alice.bsky.social", - }); + method: 'GET', + url: '/api/auth/login?handle=alice.bsky.social', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ url: string }>(); - expect(body.url).toBe(fallbackUrl.toString()); + expect(response.statusCode).toBe(200) + const body = response.json<{ url: string }>() + expect(body.url).toBe(fallbackUrl.toString()) // First call with granular scopes - expect(authorizeFn).toHaveBeenNthCalledWith( - 1, - "alice.bsky.social", - { scope: BARAZO_BASE_SCOPES }, - ); + expect(authorizeFn).toHaveBeenNthCalledWith(1, 'alice.bsky.social', { + scope: BARAZO_BASE_SCOPES, + }) // Second call with fallback - expect(authorizeFn).toHaveBeenNthCalledWith( - 2, - "alice.bsky.social", - { scope: FALLBACK_SCOPE }, - ); - }); + expect(authorizeFn).toHaveBeenNthCalledWith(2, 'alice.bsky.social', { scope: FALLBACK_SCOPE }) + }) - it("requests cross-post scopes when crosspost=true", async () => { - const redirectUrl = new URL("https://pds.example.com/oauth/authorize?code=abc"); - authorizeFn.mockResolvedValueOnce(redirectUrl); + it('requests cross-post scopes when crosspost=true', async () => { + const redirectUrl = new URL('https://pds.example.com/oauth/authorize?code=abc') + authorizeFn.mockResolvedValueOnce(redirectUrl) const response = await app.inject({ - method: "GET", - url: "/api/auth/login?handle=alice.bsky.social&crosspost=true", - }); - - expect(response.statusCode).toBe(200); - expect(authorizeFn).toHaveBeenCalledWith( - "alice.bsky.social", - { scope: BARAZO_CROSSPOST_SCOPES }, - ); - }); - }); - - describe("GET /api/auth/crosspost-authorize", () => { - it("requires authentication", async () => { + method: 'GET', + url: '/api/auth/login?handle=alice.bsky.social&crosspost=true', + }) + + expect(response.statusCode).toBe(200) + expect(authorizeFn).toHaveBeenCalledWith('alice.bsky.social', { + scope: BARAZO_CROSSPOST_SCOPES, + }) + }) + }) + + describe('GET /api/auth/crosspost-authorize', () => { + it('requires authentication', async () => { const response = await app.inject({ - method: "GET", - url: "/api/auth/crosspost-authorize", - }); + method: 'GET', + url: '/api/auth/crosspost-authorize', + }) - expect(response.statusCode).toBe(401); - }); + expect(response.statusCode).toBe(401) + }) - it("returns redirect URL with cross-post scopes", async () => { - const mockSession = makeMockSession(); - validateAccessTokenFn.mockResolvedValueOnce(mockSession); + it('returns redirect URL with cross-post scopes', async () => { + const mockSession = makeMockSession() + validateAccessTokenFn.mockResolvedValueOnce(mockSession) - const redirectUrl = new URL("https://pds.example.com/oauth/authorize?scope=crosspost"); - authorizeFn.mockResolvedValueOnce(redirectUrl); + const redirectUrl = new URL('https://pds.example.com/oauth/authorize?scope=crosspost') + authorizeFn.mockResolvedValueOnce(redirectUrl) const response = await app.inject({ - method: "GET", - url: "/api/auth/crosspost-authorize", + method: 'GET', + url: '/api/auth/crosspost-authorize', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}` }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ url: string }>(); - expect(body.url).toBe(redirectUrl.toString()); - expect(authorizeFn).toHaveBeenCalledWith( - TEST_HANDLE, - { scope: BARAZO_CROSSPOST_SCOPES }, - ); - }); - }); - - describe("crossPostScopesGranted in responses", () => { - it("/me returns crossPostScopesGranted from user preferences", async () => { - const mockSession = makeMockSession(); - validateAccessTokenFn.mockResolvedValueOnce(mockSession); - dbWhereFn.mockResolvedValueOnce([{ crossPostScopesGranted: true }]); + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ url: string }>() + expect(body.url).toBe(redirectUrl.toString()) + expect(authorizeFn).toHaveBeenCalledWith(TEST_HANDLE, { scope: BARAZO_CROSSPOST_SCOPES }) + }) + }) + + describe('crossPostScopesGranted in responses', () => { + it('/me returns crossPostScopesGranted from user preferences', async () => { + const mockSession = makeMockSession() + validateAccessTokenFn.mockResolvedValueOnce(mockSession) + dbWhereFn.mockResolvedValueOnce([{ crossPostScopesGranted: true }]) const response = await app.inject({ - method: "GET", - url: "/api/auth/me", + method: 'GET', + url: '/api/auth/me', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}` }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ crossPostScopesGranted: boolean }>(); - expect(body.crossPostScopesGranted).toBe(true); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ crossPostScopesGranted: boolean }>() + expect(body.crossPostScopesGranted).toBe(true) + }) - it("/me defaults crossPostScopesGranted to false when no preferences", async () => { - const mockSession = makeMockSession(); - validateAccessTokenFn.mockResolvedValueOnce(mockSession); - dbWhereFn.mockResolvedValueOnce([]); + it('/me defaults crossPostScopesGranted to false when no preferences', async () => { + const mockSession = makeMockSession() + validateAccessTokenFn.mockResolvedValueOnce(mockSession) + dbWhereFn.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/auth/me", + method: 'GET', + url: '/api/auth/me', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}` }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ crossPostScopesGranted: boolean }>(); - expect(body.crossPostScopesGranted).toBe(false); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ crossPostScopesGranted: boolean }>() + expect(body.crossPostScopesGranted).toBe(false) + }) - it("/refresh returns crossPostScopesGranted", async () => { - const mockSession = makeMockSessionWithToken(); - refreshSessionFn.mockResolvedValueOnce(mockSession); - dbWhereFn.mockResolvedValueOnce([{ crossPostScopesGranted: true }]); + it('/refresh returns crossPostScopesGranted', async () => { + const mockSession = makeMockSessionWithToken() + refreshSessionFn.mockResolvedValueOnce(mockSession) + dbWhereFn.mockResolvedValueOnce([{ crossPostScopesGranted: true }]) const response = await app.inject({ - method: "POST", - url: "/api/auth/refresh", + method: 'POST', + url: '/api/auth/refresh', cookies: { barazo_refresh: TEST_SID }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ crossPostScopesGranted: boolean }>(); - expect(body.crossPostScopesGranted).toBe(true); - }); - }); -}); + expect(response.statusCode).toBe(200) + const body = response.json<{ crossPostScopesGranted: boolean }>() + expect(body.crossPostScopesGranted).toBe(true) + }) + }) +}) // =========================================================================== // Production-mode cookie security // =========================================================================== -describe("auth routes (production mode)", () => { - let prodApp: FastifyInstance; +describe('auth routes (production mode)', () => { + let prodApp: FastifyInstance const prodEnv = { - OAUTH_CLIENT_ID: "https://forum.barazo.forum/oauth-client-metadata.json", + OAUTH_CLIENT_ID: 'https://forum.barazo.forum/oauth-client-metadata.json', OAUTH_SESSION_TTL: 604800, OAUTH_ACCESS_TOKEN_TTL: 900, RATE_LIMIT_AUTH: 10, - CORS_ORIGINS: "https://forum.barazo.forum", - } as Env; + CORS_ORIGINS: 'https://forum.barazo.forum', + } as Env beforeAll(async () => { - prodApp = Fastify({ logger: false }); - await prodApp.register(cookie, { secret: "a".repeat(32) }); - prodApp.decorate("env", prodEnv); - prodApp.decorate("sessionService", mockSessionService); - prodApp.decorate("handleResolver", mockHandleResolver); - prodApp.decorate("profileSync", { syncProfile: vi.fn().mockResolvedValue({ displayName: null, avatarUrl: null, bannerUrl: null, bio: null }) }); - prodApp.decorate("db", createMockDb()); - await prodApp.register( - authRoutes(mockOAuthClient as Parameters[0]), - ); - await prodApp.ready(); - }); + prodApp = Fastify({ logger: false }) + await prodApp.register(cookie, { secret: 'a'.repeat(32) }) + prodApp.decorate('env', prodEnv) + prodApp.decorate('sessionService', mockSessionService) + prodApp.decorate('handleResolver', mockHandleResolver) + prodApp.decorate('profileSync', { + syncProfile: vi + .fn() + .mockResolvedValue({ displayName: null, avatarUrl: null, bannerUrl: null, bio: null }), + }) + prodApp.decorate('db', createMockDb()) + await prodApp.register(authRoutes(mockOAuthClient as Parameters[0])) + await prodApp.ready() + }) afterAll(async () => { - await prodApp.close(); - }); + await prodApp.close() + }) beforeEach(() => { - vi.clearAllMocks(); - dbWhereFn.mockResolvedValue([]); - dbFromFn.mockReturnValue({ where: dbWhereFn }); - dbSelectFn.mockReturnValue({ from: dbFromFn }); - }); + vi.clearAllMocks() + dbWhereFn.mockResolvedValue([]) + dbFromFn.mockReturnValue({ where: dbWhereFn }) + dbSelectFn.mockReturnValue({ from: dbFromFn }) + }) - it("sets secure cookie in production mode", async () => { - const mockSession = makeMockSessionWithToken(); - const mockOAuthSession = { did: TEST_DID }; + it('sets secure cookie in production mode', async () => { + const mockSession = makeMockSessionWithToken() + const mockOAuthSession = { did: TEST_DID } callbackFn.mockResolvedValueOnce({ session: mockOAuthSession, - state: "some-state", - }); - resolveFn.mockResolvedValueOnce(TEST_HANDLE); - createSessionFn.mockResolvedValueOnce(mockSession); + state: 'some-state', + }) + resolveFn.mockResolvedValueOnce(TEST_HANDLE) + createSessionFn.mockResolvedValueOnce(mockSession) const response = await prodApp.inject({ - method: "GET", - url: "/api/auth/callback?iss=https://pds.example.com&code=test-code&state=test-state", - }); - - expect(response.statusCode).toBe(302); - - const cookies = response.cookies; - const refreshCookie = cookies.find( - (c: { name: string }) => c.name === "barazo_refresh", - ); - expect(refreshCookie).toBeDefined(); - expect(refreshCookie?.secure).toBe(true); - }); -}); + method: 'GET', + url: '/api/auth/callback?iss=https://pds.example.com&code=test-code&state=test-state', + }) + + expect(response.statusCode).toBe(302) + + const cookies = response.cookies + const refreshCookie = cookies.find((c: { name: string }) => c.name === 'barazo_refresh') + expect(refreshCookie).toBeDefined() + expect(refreshCookie?.secure).toBe(true) + }) +}) diff --git a/tests/unit/routes/block-mute.test.ts b/tests/unit/routes/block-mute.test.ts index 4733973..ac8253a 100644 --- a/tests/unit/routes/block-mute.test.ts +++ b/tests/unit/routes/block-mute.test.ts @@ -1,50 +1,35 @@ -import { - describe, - it, - expect, - beforeAll, - afterAll, - vi, - beforeEach, -} from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { - AuthMiddleware, - RequestUser, -} from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { - type DbChain, - createChainableProxy, - createMockDb, -} from "../../helpers/mock-db.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' // Import routes -import { blockMuteRoutes } from "../../../src/routes/block-mute.js"; +import { blockMuteRoutes } from '../../../src/routes/block-mute.js' // --------------------------------------------------------------------------- // Mock env // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_DID: 'did:plc:community123', RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); -const TARGET_DID = "did:plc:targetuser456"; -const INVALID_DID = "not-a-did"; +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) +const TARGET_DID = 'did:plc:targetuser456' +const INVALID_DID = 'not-a-did' // --------------------------------------------------------------------------- // Mock user builders @@ -56,25 +41,25 @@ function testUser(overrides?: Partial): RequestUser { handle: TEST_HANDLE, sid: TEST_SID, ...overrides, - }; + } } // --------------------------------------------------------------------------- // Chainable mock DB // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() -let selectChain: DbChain; -let insertChain: DbChain; +let selectChain: DbChain +let insertChain: DbChain function resetAllDbMocks(): void { - selectChain = createChainableProxy([]); - insertChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); - mockDb.select.mockReturnValue(selectChain); - mockDb.update.mockReturnValue(createChainableProxy([])); - mockDb.delete.mockReturnValue(createChainableProxy()); + selectChain = createChainableProxy([]) + insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(createChainableProxy([])) + mockDb.delete.mockReturnValue(createChainableProxy()) } // --------------------------------------------------------------------------- @@ -85,18 +70,18 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } // --------------------------------------------------------------------------- @@ -104,50 +89,50 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { // --------------------------------------------------------------------------- async function buildTestApp(user?: RequestUser): Promise { - const app = Fastify({ logger: false }); - - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", createMockAuthMiddleware(user)); - app.decorate("firehose", {} as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); - - await app.register(blockMuteRoutes()); - await app.ready(); - - return app; + const app = Fastify({ logger: false }) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', createMockAuthMiddleware(user)) + app.decorate('firehose', {} as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + + await app.register(blockMuteRoutes()) + await app.ready() + + return app } // =========================================================================== // Test suite // =========================================================================== -describe("block/mute routes", () => { +describe('block/mute routes', () => { // ========================================================================= // POST /api/users/me/block/:did // ========================================================================= - describe("POST /api/users/me/block/:did", () => { - let app: FastifyInstance; + describe('POST /api/users/me/block/:did', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("adds DID to blocked list", async () => { + it('adds DID to blocked list', async () => { // Current preferences: empty blockedDids selectChain.where.mockResolvedValueOnce([ { @@ -156,21 +141,21 @@ describe("block/mute routes", () => { mutedDids: [], updatedAt: new Date(), }, - ]); + ]) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/users/me/block/${encodeURIComponent(TARGET_DID)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean }>(); - expect(body.success).toBe(true); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) + expect(mockDb.insert).toHaveBeenCalledOnce() + }) - it("is idempotent when DID is already blocked", async () => { + it('is idempotent when DID is already blocked', async () => { // Current preferences: TARGET_DID already in blockedDids selectChain.where.mockResolvedValueOnce([ { @@ -179,104 +164,104 @@ describe("block/mute routes", () => { mutedDids: [], updatedAt: new Date(), }, - ]); + ]) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/users/me/block/${encodeURIComponent(TARGET_DID)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean }>(); - expect(body.success).toBe(true); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) // Should NOT upsert since already blocked - expect(mockDb.insert).not.toHaveBeenCalled(); - }); + expect(mockDb.insert).not.toHaveBeenCalled() + }) - it("creates preferences row when none exists", async () => { + it('creates preferences row when none exists', async () => { // No preferences row found - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/users/me/block/${encodeURIComponent(TARGET_DID)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean }>(); - expect(body.success).toBe(true); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) + expect(mockDb.insert).toHaveBeenCalledOnce() + }) - it("returns 400 for invalid DID format", async () => { + it('returns 400 for invalid DID format', async () => { const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/users/me/block/${encodeURIComponent(INVALID_DID)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "POST", + method: 'POST', url: `/api/users/me/block/${encodeURIComponent(TARGET_DID)}`, - }); + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) + }) // ========================================================================= // DELETE /api/users/me/block/:did // ========================================================================= - describe("DELETE /api/users/me/block/:did", () => { - let app: FastifyInstance; + describe('DELETE /api/users/me/block/:did', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("removes DID from blocked list", async () => { + it('removes DID from blocked list', async () => { // Current preferences: TARGET_DID in blockedDids selectChain.where.mockResolvedValueOnce([ { did: TEST_DID, - blockedDids: [TARGET_DID, "did:plc:other"], + blockedDids: [TARGET_DID, 'did:plc:other'], mutedDids: [], updatedAt: new Date(), }, - ]); + ]) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/users/me/block/${encodeURIComponent(TARGET_DID)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean }>(); - expect(body.success).toBe(true); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) + expect(mockDb.insert).toHaveBeenCalledOnce() + }) - it("succeeds even when DID is not in blocked list", async () => { + it('succeeds even when DID is not in blocked list', async () => { // Current preferences: TARGET_DID NOT in blockedDids selectChain.where.mockResolvedValueOnce([ { @@ -285,63 +270,63 @@ describe("block/mute routes", () => { mutedDids: [], updatedAt: new Date(), }, - ]); + ]) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/users/me/block/${encodeURIComponent(TARGET_DID)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean }>(); - expect(body.success).toBe(true); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) + }) - it("returns 400 for invalid DID format", async () => { + it('returns 400 for invalid DID format', async () => { const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/users/me/block/${encodeURIComponent(INVALID_DID)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/users/me/block/${encodeURIComponent(TARGET_DID)}`, - }); + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) + }) // ========================================================================= // POST /api/users/me/mute/:did // ========================================================================= - describe("POST /api/users/me/mute/:did", () => { - let app: FastifyInstance; + describe('POST /api/users/me/mute/:did', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("adds DID to muted list", async () => { + it('adds DID to muted list', async () => { // Current preferences: empty mutedDids selectChain.where.mockResolvedValueOnce([ { @@ -350,21 +335,21 @@ describe("block/mute routes", () => { mutedDids: [], updatedAt: new Date(), }, - ]); + ]) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/users/me/mute/${encodeURIComponent(TARGET_DID)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean }>(); - expect(body.success).toBe(true); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) + expect(mockDb.insert).toHaveBeenCalledOnce() + }) - it("is idempotent when DID is already muted", async () => { + it('is idempotent when DID is already muted', async () => { // Current preferences: TARGET_DID already in mutedDids selectChain.where.mockResolvedValueOnce([ { @@ -373,104 +358,104 @@ describe("block/mute routes", () => { mutedDids: [TARGET_DID], updatedAt: new Date(), }, - ]); + ]) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/users/me/mute/${encodeURIComponent(TARGET_DID)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean }>(); - expect(body.success).toBe(true); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) // Should NOT upsert since already muted - expect(mockDb.insert).not.toHaveBeenCalled(); - }); + expect(mockDb.insert).not.toHaveBeenCalled() + }) - it("creates preferences row when none exists", async () => { + it('creates preferences row when none exists', async () => { // No preferences row found - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/users/me/mute/${encodeURIComponent(TARGET_DID)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean }>(); - expect(body.success).toBe(true); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) + expect(mockDb.insert).toHaveBeenCalledOnce() + }) - it("returns 400 for invalid DID format", async () => { + it('returns 400 for invalid DID format', async () => { const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/users/me/mute/${encodeURIComponent(INVALID_DID)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "POST", + method: 'POST', url: `/api/users/me/mute/${encodeURIComponent(TARGET_DID)}`, - }); + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) + }) // ========================================================================= // DELETE /api/users/me/mute/:did // ========================================================================= - describe("DELETE /api/users/me/mute/:did", () => { - let app: FastifyInstance; + describe('DELETE /api/users/me/mute/:did', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("removes DID from muted list", async () => { + it('removes DID from muted list', async () => { // Current preferences: TARGET_DID in mutedDids selectChain.where.mockResolvedValueOnce([ { did: TEST_DID, blockedDids: [], - mutedDids: [TARGET_DID, "did:plc:other"], + mutedDids: [TARGET_DID, 'did:plc:other'], updatedAt: new Date(), }, - ]); + ]) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/users/me/mute/${encodeURIComponent(TARGET_DID)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean }>(); - expect(body.success).toBe(true); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) + expect(mockDb.insert).toHaveBeenCalledOnce() + }) - it("succeeds even when DID is not in muted list", async () => { + it('succeeds even when DID is not in muted list', async () => { // Current preferences: TARGET_DID NOT in mutedDids selectChain.where.mockResolvedValueOnce([ { @@ -479,39 +464,39 @@ describe("block/mute routes", () => { mutedDids: [], updatedAt: new Date(), }, - ]); + ]) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/users/me/mute/${encodeURIComponent(TARGET_DID)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean }>(); - expect(body.success).toBe(true); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) + }) - it("returns 400 for invalid DID format", async () => { + it('returns 400 for invalid DID format', async () => { const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/users/me/mute/${encodeURIComponent(INVALID_DID)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/users/me/mute/${encodeURIComponent(TARGET_DID)}`, - }); + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); - }); -}); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) + }) +}) diff --git a/tests/unit/routes/categories.test.ts b/tests/unit/routes/categories.test.ts index 988ab8f..ea768ec 100644 --- a/tests/unit/routes/categories.test.ts +++ b/tests/unit/routes/categories.test.ts @@ -1,39 +1,39 @@ -import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { type DbChain, createChainableProxy, createMockDb } from "../../helpers/mock-db.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' // Import routes (no PDS mocking needed -- categories are local-only) -import { categoryRoutes } from "../../../src/routes/categories.js"; +import { categoryRoutes } from '../../../src/routes/categories.js' // --------------------------------------------------------------------------- // Mock env (minimal subset for category routes) // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_DID: 'did:plc:community123', RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); -const ADMIN_DID = "did:plc:admin999"; -const TEST_NOW = "2026-02-13T12:00:00.000Z"; +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) +const ADMIN_DID = 'did:plc:admin999' +const TEST_NOW = '2026-02-13T12:00:00.000Z' -const CATEGORY_ID_1 = "cat-001"; -const CATEGORY_ID_2 = "cat-002"; -const CATEGORY_ID_3 = "cat-003"; +const CATEGORY_ID_1 = 'cat-001' +const CATEGORY_ID_2 = 'cat-002' +const CATEGORY_ID_3 = 'cat-003' // --------------------------------------------------------------------------- // Mock user builders @@ -45,37 +45,37 @@ function testUser(overrides?: Partial): RequestUser { handle: TEST_HANDLE, sid: TEST_SID, ...overrides, - }; + } } function adminUser(): RequestUser { - return testUser({ did: ADMIN_DID, handle: "admin.bsky.social" }); + return testUser({ did: ADMIN_DID, handle: 'admin.bsky.social' }) } // --------------------------------------------------------------------------- // Chainable mock DB (shared helper) // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() -let insertChain: DbChain; -let selectChain: DbChain; -let updateChain: DbChain; -let deleteChain: DbChain; +let insertChain: DbChain +let selectChain: DbChain +let updateChain: DbChain +let deleteChain: DbChain function resetAllDbMocks(): void { - insertChain = createChainableProxy(); - selectChain = createChainableProxy([]); - updateChain = createChainableProxy([]); - deleteChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); - mockDb.select.mockReturnValue(selectChain); - mockDb.update.mockReturnValue(updateChain); - mockDb.delete.mockReturnValue(deleteChain); + insertChain = createChainableProxy() + selectChain = createChainableProxy([]) + updateChain = createChainableProxy([]) + deleteChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(updateChain) + mockDb.delete.mockReturnValue(deleteChain) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { - await fn(mockDb); - }); + await fn(mockDb) + }) } // --------------------------------------------------------------------------- @@ -86,18 +86,18 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } // --------------------------------------------------------------------------- @@ -110,17 +110,20 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { * Otherwise, 403. */ function createMockRequireAdmin(user?: RequestUser) { - return async (request: { user?: RequestUser }, reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise } }) => { + return async ( + request: { user?: RequestUser }, + reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise } } + ) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user if (user.did !== ADMIN_DID) { - await reply.status(403).send({ error: "Admin access required" }); - return; + await reply.status(403).send({ error: 'Admin access required' }) + return } - }; + } } // --------------------------------------------------------------------------- @@ -130,17 +133,17 @@ function createMockRequireAdmin(user?: RequestUser) { function sampleCategoryRow(overrides?: Record) { return { id: CATEGORY_ID_1, - slug: "general", - name: "General Discussion", - description: "Talk about anything", + slug: 'general', + name: 'General Discussion', + description: 'Talk about anything', parentId: null, sortOrder: 0, - communityDid: "did:plc:community123", - maturityRating: "safe", + communityDid: 'did:plc:community123', + maturityRating: 'safe', createdAt: new Date(TEST_NOW), updatedAt: new Date(TEST_NOW), ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -149,16 +152,16 @@ function sampleCategoryRow(overrides?: Record) { function sampleCommunitySettings(overrides?: Record) { return { - id: "default", + id: 'default', initialized: true, - communityDid: "did:plc:community123", + communityDid: 'did:plc:community123', adminDid: ADMIN_DID, - communityName: "Test Community", - maturityRating: "safe", + communityName: 'Test Community', + maturityRating: 'safe', createdAt: new Date(TEST_NOW), updatedAt: new Date(TEST_NOW), ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -166,946 +169,955 @@ function sampleCommunitySettings(overrides?: Record) { // --------------------------------------------------------------------------- async function buildTestApp(user?: RequestUser): Promise { - const app = Fastify({ logger: false }); - - const authMiddleware = createMockAuthMiddleware(user); - const requireAdmin = createMockRequireAdmin(user); - - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", authMiddleware); - app.decorate("requireAdmin", requireAdmin as never); - app.decorate("firehose", {} as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); - - await app.register(categoryRoutes()); - await app.ready(); - - return app; + const app = Fastify({ logger: false }) + + const authMiddleware = createMockAuthMiddleware(user) + const requireAdmin = createMockRequireAdmin(user) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', authMiddleware) + app.decorate('requireAdmin', requireAdmin as never) + app.decorate('firehose', {} as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + + await app.register(categoryRoutes()) + await app.ready() + + return app } // =========================================================================== // Test suite // =========================================================================== -describe("category routes", () => { +describe('category routes', () => { // ========================================================================= // GET /api/categories (list / tree) // ========================================================================= - describe("GET /api/categories", () => { - let app: FastifyInstance; + describe('GET /api/categories', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns empty array when no categories exist", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns empty array when no categories exist', async () => { + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/categories", - }); + method: 'GET', + url: '/api/categories', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ categories: unknown[] }>(); - expect(body.categories).toEqual([]); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ categories: unknown[] }>() + expect(body.categories).toEqual([]) + }) - it("returns categories as tree structure", async () => { - const parent = sampleCategoryRow(); + it('returns categories as tree structure', async () => { + const parent = sampleCategoryRow() const child = sampleCategoryRow({ id: CATEGORY_ID_2, - slug: "child", - name: "Child Category", + slug: 'child', + name: 'Child Category', parentId: CATEGORY_ID_1, sortOrder: 1, - }); + }) - selectChain.where.mockResolvedValueOnce([parent, child]); + selectChain.where.mockResolvedValueOnce([parent, child]) const response = await app.inject({ - method: "GET", - url: "/api/categories", - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ categories: Array<{ id: string; children: Array<{ id: string }> }> }>(); + method: 'GET', + url: '/api/categories', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + categories: Array<{ id: string; children: Array<{ id: string }> }> + }>() // Top-level should only have the parent - expect(body.categories).toHaveLength(1); - expect(body.categories[0]?.id).toBe(CATEGORY_ID_1); + expect(body.categories).toHaveLength(1) + expect(body.categories[0]?.id).toBe(CATEGORY_ID_1) // Child should be nested - expect(body.categories[0]?.children).toHaveLength(1); - expect(body.categories[0]?.children[0]?.id).toBe(CATEGORY_ID_2); - }); + expect(body.categories[0]?.children).toHaveLength(1) + expect(body.categories[0]?.children[0]?.id).toBe(CATEGORY_ID_2) + }) - it("returns deeply nested tree structure", async () => { - const root = sampleCategoryRow(); + it('returns deeply nested tree structure', async () => { + const root = sampleCategoryRow() const child = sampleCategoryRow({ id: CATEGORY_ID_2, - slug: "child", - name: "Child", + slug: 'child', + name: 'Child', parentId: CATEGORY_ID_1, - }); + }) const grandchild = sampleCategoryRow({ id: CATEGORY_ID_3, - slug: "grandchild", - name: "Grandchild", + slug: 'grandchild', + name: 'Grandchild', parentId: CATEGORY_ID_2, - }); + }) - selectChain.where.mockResolvedValueOnce([root, child, grandchild]); + selectChain.where.mockResolvedValueOnce([root, child, grandchild]) const response = await app.inject({ - method: "GET", - url: "/api/categories", - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ categories: Array<{ id: string; children: Array<{ id: string; children: Array<{ id: string }> }> }> }>(); - expect(body.categories).toHaveLength(1); - expect(body.categories[0]?.children).toHaveLength(1); - expect(body.categories[0]?.children[0]?.children).toHaveLength(1); - expect(body.categories[0]?.children[0]?.children[0]?.id).toBe(CATEGORY_ID_3); - }); - - it("filters by parentId query parameter", async () => { - selectChain.where.mockResolvedValueOnce([ - sampleCategoryRow({ parentId: CATEGORY_ID_1 }), - ]); + method: 'GET', + url: '/api/categories', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + categories: Array<{ + id: string + children: Array<{ id: string; children: Array<{ id: string }> }> + }> + }>() + expect(body.categories).toHaveLength(1) + expect(body.categories[0]?.children).toHaveLength(1) + expect(body.categories[0]?.children[0]?.children).toHaveLength(1) + expect(body.categories[0]?.children[0]?.children[0]?.id).toBe(CATEGORY_ID_3) + }) + + it('filters by parentId query parameter', async () => { + selectChain.where.mockResolvedValueOnce([sampleCategoryRow({ parentId: CATEGORY_ID_1 })]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/categories?parentId=${CATEGORY_ID_1}`, - }); + }) - expect(response.statusCode).toBe(200); - expect(selectChain.where).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(200) + expect(selectChain.where).toHaveBeenCalled() + }) - it("includes maturityRating per category", async () => { - const category = sampleCategoryRow({ maturityRating: "mature" }); - selectChain.where.mockResolvedValueOnce([category]); + it('includes maturityRating per category', async () => { + const category = sampleCategoryRow({ maturityRating: 'mature' }) + selectChain.where.mockResolvedValueOnce([category]) const response = await app.inject({ - method: "GET", - url: "/api/categories", - }); + method: 'GET', + url: '/api/categories', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ categories: Array<{ maturityRating: string }> }>(); - expect(body.categories[0]?.maturityRating).toBe("mature"); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ categories: Array<{ maturityRating: string }> }>() + expect(body.categories[0]?.maturityRating).toBe('mature') + }) - it("works without authentication (public endpoint)", async () => { - const noAuthApp = await buildTestApp(undefined); - selectChain.where.mockResolvedValueOnce([]); + it('works without authentication (public endpoint)', async () => { + const noAuthApp = await buildTestApp(undefined) + selectChain.where.mockResolvedValueOnce([]) const response = await noAuthApp.inject({ - method: "GET", - url: "/api/categories", - }); + method: 'GET', + url: '/api/categories', + }) - expect(response.statusCode).toBe(200); - await noAuthApp.close(); - }); - }); + expect(response.statusCode).toBe(200) + await noAuthApp.close() + }) + }) // ========================================================================= // GET /api/categories/:slug // ========================================================================= - describe("GET /api/categories/:slug", () => { - let app: FastifyInstance; + describe('GET /api/categories/:slug', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns a single category by slug with topicCount", async () => { - const category = sampleCategoryRow(); + it('returns a single category by slug with topicCount', async () => { + const category = sampleCategoryRow() // First query: find category by slug - selectChain.where.mockResolvedValueOnce([category]); + selectChain.where.mockResolvedValueOnce([category]) // Second query: count topics - selectChain.where.mockResolvedValueOnce([{ count: 5 }]); + selectChain.where.mockResolvedValueOnce([{ count: 5 }]) const response = await app.inject({ - method: "GET", - url: "/api/categories/general", - }); + method: 'GET', + url: '/api/categories/general', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ slug: string; topicCount: number }>(); - expect(body.slug).toBe("general"); - expect(body.topicCount).toBe(5); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ slug: string; topicCount: number }>() + expect(body.slug).toBe('general') + expect(body.topicCount).toBe(5) + }) - it("returns 404 for non-existent category", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 for non-existent category', async () => { + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/categories/nonexistent", - }); + method: 'GET', + url: '/api/categories/nonexistent', + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("works without authentication (public endpoint)", async () => { - const noAuthApp = await buildTestApp(undefined); - const category = sampleCategoryRow(); - selectChain.where.mockResolvedValueOnce([category]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); + it('works without authentication (public endpoint)', async () => { + const noAuthApp = await buildTestApp(undefined) + const category = sampleCategoryRow() + selectChain.where.mockResolvedValueOnce([category]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) const response = await noAuthApp.inject({ - method: "GET", - url: "/api/categories/general", - }); + method: 'GET', + url: '/api/categories/general', + }) - expect(response.statusCode).toBe(200); - await noAuthApp.close(); - }); - }); + expect(response.statusCode).toBe(200) + await noAuthApp.close() + }) + }) // ========================================================================= // POST /api/admin/categories // ========================================================================= - describe("POST /api/admin/categories", () => { - let app: FastifyInstance; + describe('POST /api/admin/categories', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser()); - }); + app = await buildTestApp(adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("creates a category and returns 201", async () => { + it('creates a category and returns 201', async () => { // Query community settings for maturity default - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]); + selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]) // Check slug uniqueness: no existing category - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) // Insert returns created row - insertChain.returning.mockResolvedValueOnce([sampleCategoryRow()]); + insertChain.returning.mockResolvedValueOnce([sampleCategoryRow()]) const response = await app.inject({ - method: "POST", - url: "/api/admin/categories", - headers: { authorization: "Bearer admin-token" }, + method: 'POST', + url: '/api/admin/categories', + headers: { authorization: 'Bearer admin-token' }, payload: { - name: "General Discussion", - slug: "general", - description: "Talk about anything", + name: 'General Discussion', + slug: 'general', + description: 'Talk about anything', }, - }); + }) - expect(response.statusCode).toBe(201); - const body = response.json<{ id: string; slug: string }>(); - expect(body.slug).toBe("general"); - }); + expect(response.statusCode).toBe(201) + const body = response.json<{ id: string; slug: string }>() + expect(body.slug).toBe('general') + }) - it("creates a category with explicit maturityRating", async () => { - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]); - selectChain.where.mockResolvedValueOnce([]); - insertChain.returning.mockResolvedValueOnce([ - sampleCategoryRow({ maturityRating: "mature" }), - ]); + it('creates a category with explicit maturityRating', async () => { + selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]) + selectChain.where.mockResolvedValueOnce([]) + insertChain.returning.mockResolvedValueOnce([sampleCategoryRow({ maturityRating: 'mature' })]) const response = await app.inject({ - method: "POST", - url: "/api/admin/categories", - headers: { authorization: "Bearer admin-token" }, + method: 'POST', + url: '/api/admin/categories', + headers: { authorization: 'Bearer admin-token' }, payload: { - name: "Mature Content", - slug: "mature-content", - maturityRating: "mature", + name: 'Mature Content', + slug: 'mature-content', + maturityRating: 'mature', }, - }); + }) - expect(response.statusCode).toBe(201); - }); + expect(response.statusCode).toBe(201) + }) - it("defaults maturityRating to community default when not provided", async () => { - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings({ maturityRating: "mature" })]); - selectChain.where.mockResolvedValueOnce([]); - insertChain.returning.mockResolvedValueOnce([ - sampleCategoryRow({ maturityRating: "mature" }), - ]); + it('defaults maturityRating to community default when not provided', async () => { + selectChain.where.mockResolvedValueOnce([ + sampleCommunitySettings({ maturityRating: 'mature' }), + ]) + selectChain.where.mockResolvedValueOnce([]) + insertChain.returning.mockResolvedValueOnce([sampleCategoryRow({ maturityRating: 'mature' })]) const response = await app.inject({ - method: "POST", - url: "/api/admin/categories", - headers: { authorization: "Bearer admin-token" }, + method: 'POST', + url: '/api/admin/categories', + headers: { authorization: 'Bearer admin-token' }, payload: { - name: "Defaults to Mature", - slug: "defaults-mature", + name: 'Defaults to Mature', + slug: 'defaults-mature', }, - }); + }) - expect(response.statusCode).toBe(201); - expect(mockDb.insert).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(201) + expect(mockDb.insert).toHaveBeenCalled() + }) - it("returns 400 for maturityRating lower than community default", async () => { + it('returns 400 for maturityRating lower than community default', async () => { // Community default is "mature", trying to set "safe" - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings({ maturityRating: "mature" })]); + selectChain.where.mockResolvedValueOnce([ + sampleCommunitySettings({ maturityRating: 'mature' }), + ]) const response = await app.inject({ - method: "POST", - url: "/api/admin/categories", - headers: { authorization: "Bearer admin-token" }, + method: 'POST', + url: '/api/admin/categories', + headers: { authorization: 'Bearer admin-token' }, payload: { - name: "Too Low", - slug: "too-low", - maturityRating: "safe", + name: 'Too Low', + slug: 'too-low', + maturityRating: 'safe', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 409 if slug already exists in community", async () => { - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]); + it('returns 409 if slug already exists in community', async () => { + selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]) // Slug check: existing category found - selectChain.where.mockResolvedValueOnce([sampleCategoryRow()]); + selectChain.where.mockResolvedValueOnce([sampleCategoryRow()]) const response = await app.inject({ - method: "POST", - url: "/api/admin/categories", - headers: { authorization: "Bearer admin-token" }, + method: 'POST', + url: '/api/admin/categories', + headers: { authorization: 'Bearer admin-token' }, payload: { - name: "Duplicate", - slug: "general", + name: 'Duplicate', + slug: 'general', }, - }); + }) - expect(response.statusCode).toBe(409); - }); + expect(response.statusCode).toBe(409) + }) - it("returns 400 for invalid slug format", async () => { + it('returns 400 for invalid slug format', async () => { const response = await app.inject({ - method: "POST", - url: "/api/admin/categories", - headers: { authorization: "Bearer admin-token" }, + method: 'POST', + url: '/api/admin/categories', + headers: { authorization: 'Bearer admin-token' }, payload: { - name: "Bad Slug", - slug: "INVALID SLUG!", + name: 'Bad Slug', + slug: 'INVALID SLUG!', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for missing name", async () => { + it('returns 400 for missing name', async () => { const response = await app.inject({ - method: "POST", - url: "/api/admin/categories", - headers: { authorization: "Bearer admin-token" }, + method: 'POST', + url: '/api/admin/categories', + headers: { authorization: 'Bearer admin-token' }, payload: { - slug: "no-name", + slug: 'no-name', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for missing slug", async () => { + it('returns 400 for missing slug', async () => { const response = await app.inject({ - method: "POST", - url: "/api/admin/categories", - headers: { authorization: "Bearer admin-token" }, + method: 'POST', + url: '/api/admin/categories', + headers: { authorization: 'Bearer admin-token' }, payload: { - name: "No Slug", + name: 'No Slug', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("validates parentId exists", async () => { - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]); - selectChain.where.mockResolvedValueOnce([]); // slug check - selectChain.where.mockResolvedValueOnce([]); // parent lookup: not found + it('validates parentId exists', async () => { + selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]) + selectChain.where.mockResolvedValueOnce([]) // slug check + selectChain.where.mockResolvedValueOnce([]) // parent lookup: not found const response = await app.inject({ - method: "POST", - url: "/api/admin/categories", - headers: { authorization: "Bearer admin-token" }, + method: 'POST', + url: '/api/admin/categories', + headers: { authorization: 'Bearer admin-token' }, payload: { - name: "Orphan", - slug: "orphan", - parentId: "nonexistent-parent", + name: 'Orphan', + slug: 'orphan', + parentId: 'nonexistent-parent', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("creates a category with valid parentId", async () => { - const parent = sampleCategoryRow(); - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]); - selectChain.where.mockResolvedValueOnce([]); // slug check - selectChain.where.mockResolvedValueOnce([parent]); // parent lookup: found + it('creates a category with valid parentId', async () => { + const parent = sampleCategoryRow() + selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]) + selectChain.where.mockResolvedValueOnce([]) // slug check + selectChain.where.mockResolvedValueOnce([parent]) // parent lookup: found // No cycle check needed since parent has no parentId insertChain.returning.mockResolvedValueOnce([ sampleCategoryRow({ id: CATEGORY_ID_2, - slug: "child", - name: "Child Category", + slug: 'child', + name: 'Child Category', parentId: CATEGORY_ID_1, }), - ]); + ]) const response = await app.inject({ - method: "POST", - url: "/api/admin/categories", - headers: { authorization: "Bearer admin-token" }, + method: 'POST', + url: '/api/admin/categories', + headers: { authorization: 'Bearer admin-token' }, payload: { - name: "Child Category", - slug: "child", + name: 'Child Category', + slug: 'child', parentId: CATEGORY_ID_1, }, - }); + }) - expect(response.statusCode).toBe(201); - }); + expect(response.statusCode).toBe(201) + }) - it("returns 401 when unauthenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when unauthenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "POST", - url: "/api/admin/categories", + method: 'POST', + url: '/api/admin/categories', payload: { - name: "Unauth", - slug: "unauth", + name: 'Unauth', + slug: 'unauth', }, - }); + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("returns 403 when non-admin user", async () => { - const regularApp = await buildTestApp(testUser()); + it('returns 403 when non-admin user', async () => { + const regularApp = await buildTestApp(testUser()) const response = await regularApp.inject({ - method: "POST", - url: "/api/admin/categories", - headers: { authorization: "Bearer user-token" }, + method: 'POST', + url: '/api/admin/categories', + headers: { authorization: 'Bearer user-token' }, payload: { - name: "Forbidden", - slug: "forbidden", + name: 'Forbidden', + slug: 'forbidden', }, - }); + }) - expect(response.statusCode).toBe(403); - await regularApp.close(); - }); - }); + expect(response.statusCode).toBe(403) + await regularApp.close() + }) + }) // ========================================================================= // PUT /api/admin/categories/:id // ========================================================================= - describe("PUT /api/admin/categories/:id", () => { - let app: FastifyInstance; + describe('PUT /api/admin/categories/:id', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser()); - }); + app = await buildTestApp(adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("updates a category name", async () => { - const existing = sampleCategoryRow(); + it('updates a category name', async () => { + const existing = sampleCategoryRow() // Find category by id - selectChain.where.mockResolvedValueOnce([existing]); + selectChain.where.mockResolvedValueOnce([existing]) // Fetch community settings for maturity validation (even though maturity isn't changing) - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]); + selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]) // Update returns updated row updateChain.returning.mockResolvedValueOnce([ - { ...existing, name: "Updated Name", updatedAt: new Date() }, - ]); + { ...existing, name: 'Updated Name', updatedAt: new Date() }, + ]) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}`, - headers: { authorization: "Bearer admin-token" }, + headers: { authorization: 'Bearer admin-token' }, payload: { - name: "Updated Name", + name: 'Updated Name', }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ name: string }>(); - expect(body.name).toBe("Updated Name"); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ name: string }>() + expect(body.name).toBe('Updated Name') + }) - it("returns 404 when category not found", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 when category not found', async () => { + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "PUT", - url: "/api/admin/categories/nonexistent", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/categories/nonexistent', + headers: { authorization: 'Bearer admin-token' }, payload: { - name: "Ghost", + name: 'Ghost', }, - }); + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("validates maturity cannot be lower than community default on update", async () => { - const existing = sampleCategoryRow({ maturityRating: "mature" }); - selectChain.where.mockResolvedValueOnce([existing]); + it('validates maturity cannot be lower than community default on update', async () => { + const existing = sampleCategoryRow({ maturityRating: 'mature' }) + selectChain.where.mockResolvedValueOnce([existing]) // Community default is "mature" - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings({ maturityRating: "mature" })]); + selectChain.where.mockResolvedValueOnce([ + sampleCommunitySettings({ maturityRating: 'mature' }), + ]) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}`, - headers: { authorization: "Bearer admin-token" }, + headers: { authorization: 'Bearer admin-token' }, payload: { - maturityRating: "safe", + maturityRating: 'safe', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("validates parentId exists on update", async () => { - const existing = sampleCategoryRow(); - selectChain.where.mockResolvedValueOnce([existing]); // find category - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]); // community settings - selectChain.where.mockResolvedValueOnce([]); // parent lookup: not found + it('validates parentId exists on update', async () => { + const existing = sampleCategoryRow() + selectChain.where.mockResolvedValueOnce([existing]) // find category + selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]) // community settings + selectChain.where.mockResolvedValueOnce([]) // parent lookup: not found const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}`, - headers: { authorization: "Bearer admin-token" }, + headers: { authorization: 'Bearer admin-token' }, payload: { - parentId: "nonexistent-parent", + parentId: 'nonexistent-parent', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("detects circular reference on update (self-reference)", async () => { - const existing = sampleCategoryRow(); - selectChain.where.mockResolvedValueOnce([existing]); // find category - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]); // community settings - selectChain.where.mockResolvedValueOnce([existing]); // parent lookup: found (itself) + it('detects circular reference on update (self-reference)', async () => { + const existing = sampleCategoryRow() + selectChain.where.mockResolvedValueOnce([existing]) // find category + selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]) // community settings + selectChain.where.mockResolvedValueOnce([existing]) // parent lookup: found (itself) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}`, - headers: { authorization: "Bearer admin-token" }, + headers: { authorization: 'Bearer admin-token' }, payload: { parentId: CATEGORY_ID_1, // self-reference }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("detects circular reference on update (indirect cycle)", async () => { + it('detects circular reference on update (indirect cycle)', async () => { // Category B has parent A. Now try to set A's parent to B. - const catA = sampleCategoryRow({ id: CATEGORY_ID_1, parentId: null }); - const catB = sampleCategoryRow({ id: CATEGORY_ID_2, parentId: CATEGORY_ID_1 }); + const catA = sampleCategoryRow({ id: CATEGORY_ID_1, parentId: null }) + const catB = sampleCategoryRow({ id: CATEGORY_ID_2, parentId: CATEGORY_ID_1 }) - selectChain.where.mockResolvedValueOnce([catA]); // find category A - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]); // community settings - selectChain.where.mockResolvedValueOnce([catB]); // parent lookup: B exists + selectChain.where.mockResolvedValueOnce([catA]) // find category A + selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]) // community settings + selectChain.where.mockResolvedValueOnce([catB]) // parent lookup: B exists // Walk chain: B's parent is A (the category being updated) -> cycle // The route fetches all categories to check ancestors - selectChain.where.mockResolvedValueOnce([catA, catB]); // all categories for cycle check + selectChain.where.mockResolvedValueOnce([catA, catB]) // all categories for cycle check const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}`, - headers: { authorization: "Bearer admin-token" }, + headers: { authorization: 'Bearer admin-token' }, payload: { parentId: CATEGORY_ID_2, }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("validates slug uniqueness on update", async () => { - const existing = sampleCategoryRow(); - const otherCategory = sampleCategoryRow({ id: CATEGORY_ID_2, slug: "taken" }); + it('validates slug uniqueness on update', async () => { + const existing = sampleCategoryRow() + const otherCategory = sampleCategoryRow({ id: CATEGORY_ID_2, slug: 'taken' }) - selectChain.where.mockResolvedValueOnce([existing]); // find category - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]); // community settings - selectChain.where.mockResolvedValueOnce([otherCategory]); // slug check: already taken + selectChain.where.mockResolvedValueOnce([existing]) // find category + selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]) // community settings + selectChain.where.mockResolvedValueOnce([otherCategory]) // slug check: already taken const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}`, - headers: { authorization: "Bearer admin-token" }, + headers: { authorization: 'Bearer admin-token' }, payload: { - slug: "taken", + slug: 'taken', }, - }); + }) - expect(response.statusCode).toBe(409); - }); + expect(response.statusCode).toBe(409) + }) - it("explicitly sets updatedAt on update", async () => { - const existing = sampleCategoryRow(); - selectChain.where.mockResolvedValueOnce([existing]); - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]); - const updatedRow = { ...existing, name: "New Name", updatedAt: new Date() }; - updateChain.returning.mockResolvedValueOnce([updatedRow]); + it('explicitly sets updatedAt on update', async () => { + const existing = sampleCategoryRow() + selectChain.where.mockResolvedValueOnce([existing]) + selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]) + const updatedRow = { ...existing, name: 'New Name', updatedAt: new Date() } + updateChain.returning.mockResolvedValueOnce([updatedRow]) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}`, - headers: { authorization: "Bearer admin-token" }, + headers: { authorization: 'Bearer admin-token' }, payload: { - name: "New Name", + name: 'New Name', }, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) // Verify update was called (which includes updatedAt) - expect(mockDb.update).toHaveBeenCalled(); - }); + expect(mockDb.update).toHaveBeenCalled() + }) - it("returns 401 when unauthenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when unauthenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}`, - payload: { name: "Unauth" }, - }); + payload: { name: 'Unauth' }, + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("returns 403 when non-admin user", async () => { - const regularApp = await buildTestApp(testUser()); + it('returns 403 when non-admin user', async () => { + const regularApp = await buildTestApp(testUser()) const response = await regularApp.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}`, - headers: { authorization: "Bearer user-token" }, - payload: { name: "Forbidden" }, - }); + headers: { authorization: 'Bearer user-token' }, + payload: { name: 'Forbidden' }, + }) - expect(response.statusCode).toBe(403); - await regularApp.close(); - }); - }); + expect(response.statusCode).toBe(403) + await regularApp.close() + }) + }) // ========================================================================= // DELETE /api/admin/categories/:id // ========================================================================= - describe("DELETE /api/admin/categories/:id", () => { - let app: FastifyInstance; + describe('DELETE /api/admin/categories/:id', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser()); - }); + app = await buildTestApp(adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("deletes a category and returns 204", async () => { - const existing = sampleCategoryRow(); - selectChain.where.mockResolvedValueOnce([existing]); // find category - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); // topic count: 0 - selectChain.where.mockResolvedValueOnce([]); // child categories: none + it('deletes a category and returns 204', async () => { + const existing = sampleCategoryRow() + selectChain.where.mockResolvedValueOnce([existing]) // find category + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) // topic count: 0 + selectChain.where.mockResolvedValueOnce([]) // child categories: none const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/admin/categories/${CATEGORY_ID_1}`, - headers: { authorization: "Bearer admin-token" }, - }); + headers: { authorization: 'Bearer admin-token' }, + }) - expect(response.statusCode).toBe(204); - expect(mockDb.delete).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(204) + expect(mockDb.delete).toHaveBeenCalled() + }) - it("returns 404 when category not found", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 when category not found', async () => { + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "DELETE", - url: "/api/admin/categories/nonexistent", - headers: { authorization: "Bearer admin-token" }, - }); + method: 'DELETE', + url: '/api/admin/categories/nonexistent', + headers: { authorization: 'Bearer admin-token' }, + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 409 when category has topics", async () => { - const existing = sampleCategoryRow(); - selectChain.where.mockResolvedValueOnce([existing]); // find category - selectChain.where.mockResolvedValueOnce([{ count: 3 }]); // topic count: 3 + it('returns 409 when category has topics', async () => { + const existing = sampleCategoryRow() + selectChain.where.mockResolvedValueOnce([existing]) // find category + selectChain.where.mockResolvedValueOnce([{ count: 3 }]) // topic count: 3 const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/admin/categories/${CATEGORY_ID_1}`, - headers: { authorization: "Bearer admin-token" }, - }); - - expect(response.statusCode).toBe(409); - const body = response.json<{ message: string }>(); - expect(body.message).toContain("3"); - }); - - it("returns 409 when category has children", async () => { - const existing = sampleCategoryRow(); - selectChain.where.mockResolvedValueOnce([existing]); // find category - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); // topic count: 0 + headers: { authorization: 'Bearer admin-token' }, + }) + + expect(response.statusCode).toBe(409) + const body = response.json<{ message: string }>() + expect(body.message).toContain('3') + }) + + it('returns 409 when category has children', async () => { + const existing = sampleCategoryRow() + selectChain.where.mockResolvedValueOnce([existing]) // find category + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) // topic count: 0 selectChain.where.mockResolvedValueOnce([ sampleCategoryRow({ id: CATEGORY_ID_2, parentId: CATEGORY_ID_1 }), - ]); // child categories: one found + ]) // child categories: one found const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/admin/categories/${CATEGORY_ID_1}`, - headers: { authorization: "Bearer admin-token" }, - }); + headers: { authorization: 'Bearer admin-token' }, + }) - expect(response.statusCode).toBe(409); - const body = response.json<{ message: string }>(); - expect(body.message).toContain("child"); - }); + expect(response.statusCode).toBe(409) + const body = response.json<{ message: string }>() + expect(body.message).toContain('child') + }) - it("returns 401 when unauthenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when unauthenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/admin/categories/${CATEGORY_ID_1}`, - }); + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("returns 403 when non-admin user", async () => { - const regularApp = await buildTestApp(testUser()); + it('returns 403 when non-admin user', async () => { + const regularApp = await buildTestApp(testUser()) const response = await regularApp.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/admin/categories/${CATEGORY_ID_1}`, - headers: { authorization: "Bearer user-token" }, - }); + headers: { authorization: 'Bearer user-token' }, + }) - expect(response.statusCode).toBe(403); - await regularApp.close(); - }); - }); + expect(response.statusCode).toBe(403) + await regularApp.close() + }) + }) // ========================================================================= // PUT /api/admin/categories/:id/maturity // ========================================================================= - describe("PUT /api/admin/categories/:id/maturity", () => { - let app: FastifyInstance; + describe('PUT /api/admin/categories/:id/maturity', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser()); - }); + app = await buildTestApp(adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); - - it("updates maturity rating", async () => { - const existing = sampleCategoryRow({ maturityRating: "safe" }); - selectChain.where.mockResolvedValueOnce([existing]); // find category - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings({ maturityRating: "safe" })]); // community settings + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('updates maturity rating', async () => { + const existing = sampleCategoryRow({ maturityRating: 'safe' }) + selectChain.where.mockResolvedValueOnce([existing]) // find category + selectChain.where.mockResolvedValueOnce([sampleCommunitySettings({ maturityRating: 'safe' })]) // community settings updateChain.returning.mockResolvedValueOnce([ - { ...existing, maturityRating: "mature", updatedAt: new Date() }, - ]); + { ...existing, maturityRating: 'mature', updatedAt: new Date() }, + ]) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}/maturity`, - headers: { authorization: "Bearer admin-token" }, + headers: { authorization: 'Bearer admin-token' }, payload: { - maturityRating: "mature", + maturityRating: 'mature', }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ maturityRating: string }>(); - expect(body.maturityRating).toBe("mature"); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ maturityRating: string }>() + expect(body.maturityRating).toBe('mature') + }) - it("returns 400 when maturity is lower than community default", async () => { - const existing = sampleCategoryRow({ maturityRating: "mature" }); - selectChain.where.mockResolvedValueOnce([existing]); // find category - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings({ maturityRating: "mature" })]); // community default is "mature" + it('returns 400 when maturity is lower than community default', async () => { + const existing = sampleCategoryRow({ maturityRating: 'mature' }) + selectChain.where.mockResolvedValueOnce([existing]) // find category + selectChain.where.mockResolvedValueOnce([ + sampleCommunitySettings({ maturityRating: 'mature' }), + ]) // community default is "mature" const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}/maturity`, - headers: { authorization: "Bearer admin-token" }, + headers: { authorization: 'Bearer admin-token' }, payload: { - maturityRating: "safe", // lower than community "mature" + maturityRating: 'safe', // lower than community "mature" }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 404 when category not found", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 when category not found', async () => { + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "PUT", - url: "/api/admin/categories/nonexistent/maturity", - headers: { authorization: "Bearer admin-token" }, + method: 'PUT', + url: '/api/admin/categories/nonexistent/maturity', + headers: { authorization: 'Bearer admin-token' }, payload: { - maturityRating: "mature", + maturityRating: 'mature', }, - }); + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 400 for invalid maturity value", async () => { + it('returns 400 for invalid maturity value', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}/maturity`, - headers: { authorization: "Bearer admin-token" }, + headers: { authorization: 'Bearer admin-token' }, payload: { - maturityRating: "invalid", + maturityRating: 'invalid', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("explicitly sets updatedAt", async () => { - const existing = sampleCategoryRow(); - selectChain.where.mockResolvedValueOnce([existing]); - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]); + it('explicitly sets updatedAt', async () => { + const existing = sampleCategoryRow() + selectChain.where.mockResolvedValueOnce([existing]) + selectChain.where.mockResolvedValueOnce([sampleCommunitySettings()]) updateChain.returning.mockResolvedValueOnce([ - { ...existing, maturityRating: "mature", updatedAt: new Date() }, - ]); + { ...existing, maturityRating: 'mature', updatedAt: new Date() }, + ]) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}/maturity`, - headers: { authorization: "Bearer admin-token" }, + headers: { authorization: 'Bearer admin-token' }, payload: { - maturityRating: "mature", + maturityRating: 'mature', }, - }); + }) - expect(response.statusCode).toBe(200); - expect(mockDb.update).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(200) + expect(mockDb.update).toHaveBeenCalled() + }) - it("maturity hierarchy: safe < mature < adult", async () => { + it('maturity hierarchy: safe < mature < adult', async () => { // Community default is "safe", setting to "adult" should work - const existing = sampleCategoryRow({ maturityRating: "safe" }); - selectChain.where.mockResolvedValueOnce([existing]); - selectChain.where.mockResolvedValueOnce([sampleCommunitySettings({ maturityRating: "safe" })]); + const existing = sampleCategoryRow({ maturityRating: 'safe' }) + selectChain.where.mockResolvedValueOnce([existing]) + selectChain.where.mockResolvedValueOnce([sampleCommunitySettings({ maturityRating: 'safe' })]) updateChain.returning.mockResolvedValueOnce([ - { ...existing, maturityRating: "adult", updatedAt: new Date() }, - ]); + { ...existing, maturityRating: 'adult', updatedAt: new Date() }, + ]) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}/maturity`, - headers: { authorization: "Bearer admin-token" }, + headers: { authorization: 'Bearer admin-token' }, payload: { - maturityRating: "adult", + maturityRating: 'adult', }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ maturityRating: string }>(); - expect(body.maturityRating).toBe("adult"); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ maturityRating: string }>() + expect(body.maturityRating).toBe('adult') + }) - it("returns 401 when unauthenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when unauthenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}/maturity`, - payload: { maturityRating: "mature" }, - }); + payload: { maturityRating: 'mature' }, + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("returns 403 when non-admin user", async () => { - const regularApp = await buildTestApp(testUser()); + it('returns 403 when non-admin user', async () => { + const regularApp = await buildTestApp(testUser()) const response = await regularApp.inject({ - method: "PUT", + method: 'PUT', url: `/api/admin/categories/${CATEGORY_ID_1}/maturity`, - headers: { authorization: "Bearer user-token" }, - payload: { maturityRating: "mature" }, - }); - - expect(response.statusCode).toBe(403); - await regularApp.close(); - }); - }); -}); + headers: { authorization: 'Bearer user-token' }, + payload: { maturityRating: 'mature' }, + }) + + expect(response.statusCode).toBe(403) + await regularApp.close() + }) + }) +}) diff --git a/tests/unit/routes/community-profiles.test.ts b/tests/unit/routes/community-profiles.test.ts index 6d5432b..24c01cd 100644 --- a/tests/unit/routes/community-profiles.test.ts +++ b/tests/unit/routes/community-profiles.test.ts @@ -1,50 +1,35 @@ -import { - describe, - it, - expect, - beforeAll, - afterAll, - vi, - beforeEach, -} from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { - AuthMiddleware, - RequestUser, -} from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { - type DbChain, - createChainableProxy, - createMockDb, -} from "../../helpers/mock-db.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' // Import routes -import { communityProfileRoutes } from "../../../src/routes/community-profiles.js"; +import { communityProfileRoutes } from '../../../src/routes/community-profiles.js' // --------------------------------------------------------------------------- // Mock env // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_DID: 'did:plc:community123', RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); -const COMMUNITY_DID = "did:plc:community456"; -const TEST_NOW = "2026-02-14T12:00:00.000Z"; +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) +const COMMUNITY_DID = 'did:plc:community456' +const TEST_NOW = '2026-02-14T12:00:00.000Z' // --------------------------------------------------------------------------- // Mock user builders @@ -56,7 +41,7 @@ function testUser(overrides?: Partial): RequestUser { handle: TEST_HANDLE, sid: TEST_SID, ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -67,52 +52,52 @@ function sampleUserRow(overrides?: Record) { return { did: TEST_DID, handle: TEST_HANDLE, - displayName: "Alice", - avatarUrl: "https://example.com/avatar.jpg", - bannerUrl: "https://example.com/banner.jpg", - bio: "Global bio", - role: "user", + displayName: 'Alice', + avatarUrl: 'https://example.com/avatar.jpg', + bannerUrl: 'https://example.com/banner.jpg', + bio: 'Global bio', + role: 'user', isBanned: false, reputationScore: 0, firstSeenAt: new Date(TEST_NOW), lastActiveAt: new Date(TEST_NOW), declaredAge: null, - maturityPref: "safe", + maturityPref: 'safe', ...overrides, - }; + } } function sampleOverrideRow(overrides?: Record) { return { did: TEST_DID, communityDid: COMMUNITY_DID, - displayName: "Community Alice", + displayName: 'Community Alice', avatarUrl: null, bannerUrl: null, - bio: "Community-specific bio", + bio: 'Community-specific bio', updatedAt: new Date(TEST_NOW), ...overrides, - }; + } } // --------------------------------------------------------------------------- // Chainable mock DB // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() -let selectChain: DbChain; -let insertChain: DbChain; -let deleteChain: DbChain; +let selectChain: DbChain +let insertChain: DbChain +let deleteChain: DbChain function resetAllDbMocks(): void { - selectChain = createChainableProxy([]); - insertChain = createChainableProxy(); - deleteChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); - mockDb.select.mockReturnValue(selectChain); - mockDb.update.mockReturnValue(createChainableProxy([])); - mockDb.delete.mockReturnValue(deleteChain); + selectChain = createChainableProxy([]) + insertChain = createChainableProxy() + deleteChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(createChainableProxy([])) + mockDb.delete.mockReturnValue(deleteChain) } // --------------------------------------------------------------------------- @@ -123,18 +108,18 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } // --------------------------------------------------------------------------- @@ -142,319 +127,319 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { // --------------------------------------------------------------------------- async function buildTestApp(user?: RequestUser): Promise { - const app = Fastify({ logger: false }); - - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", createMockAuthMiddleware(user)); - app.decorate("firehose", {} as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); - - await app.register(communityProfileRoutes()); - await app.ready(); - - return app; + const app = Fastify({ logger: false }) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', createMockAuthMiddleware(user)) + app.decorate('firehose', {} as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + + await app.register(communityProfileRoutes()) + await app.ready() + + return app } // =========================================================================== // Test suite // =========================================================================== -describe("community profile routes", () => { +describe('community profile routes', () => { // ========================================================================= // GET /api/communities/:communityDid/profile // ========================================================================= - describe("GET /api/communities/:communityDid/profile", () => { - let app: FastifyInstance; + describe('GET /api/communities/:communityDid/profile', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns source profile when no override exists", async () => { + it('returns source profile when no override exists', async () => { // 1st select: user by DID - selectChain.where.mockResolvedValueOnce([sampleUserRow()]); + selectChain.where.mockResolvedValueOnce([sampleUserRow()]) // 2nd select: community_profiles row (none) - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/communities/${COMMUNITY_DID}/profile`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - did: string; - handle: string; - displayName: string; - avatarUrl: string; - bannerUrl: string; - bio: string; - communityDid: string; - hasOverride: boolean; + did: string + handle: string + displayName: string + avatarUrl: string + bannerUrl: string + bio: string + communityDid: string + hasOverride: boolean source: { - displayName: string; - avatarUrl: string; - bannerUrl: string; - bio: string; - }; - }>(); - expect(body.did).toBe(TEST_DID); - expect(body.handle).toBe(TEST_HANDLE); - expect(body.displayName).toBe("Alice"); - expect(body.avatarUrl).toBe("https://example.com/avatar.jpg"); - expect(body.bannerUrl).toBe("https://example.com/banner.jpg"); - expect(body.bio).toBe("Global bio"); - expect(body.communityDid).toBe(COMMUNITY_DID); - expect(body.hasOverride).toBe(false); - expect(body.source.displayName).toBe("Alice"); - expect(body.source.avatarUrl).toBe("https://example.com/avatar.jpg"); - }); - - it("returns merged profile when override exists (override fields take precedence)", async () => { + displayName: string + avatarUrl: string + bannerUrl: string + bio: string + } + }>() + expect(body.did).toBe(TEST_DID) + expect(body.handle).toBe(TEST_HANDLE) + expect(body.displayName).toBe('Alice') + expect(body.avatarUrl).toBe('https://example.com/avatar.jpg') + expect(body.bannerUrl).toBe('https://example.com/banner.jpg') + expect(body.bio).toBe('Global bio') + expect(body.communityDid).toBe(COMMUNITY_DID) + expect(body.hasOverride).toBe(false) + expect(body.source.displayName).toBe('Alice') + expect(body.source.avatarUrl).toBe('https://example.com/avatar.jpg') + }) + + it('returns merged profile when override exists (override fields take precedence)', async () => { // 1st select: user by DID - selectChain.where.mockResolvedValueOnce([sampleUserRow()]); + selectChain.where.mockResolvedValueOnce([sampleUserRow()]) // 2nd select: community_profiles row with overrides - selectChain.where.mockResolvedValueOnce([sampleOverrideRow()]); + selectChain.where.mockResolvedValueOnce([sampleOverrideRow()]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/communities/${COMMUNITY_DID}/profile`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - did: string; - handle: string; - displayName: string; - avatarUrl: string; - bannerUrl: string; - bio: string; - communityDid: string; - hasOverride: boolean; + did: string + handle: string + displayName: string + avatarUrl: string + bannerUrl: string + bio: string + communityDid: string + hasOverride: boolean source: { - displayName: string; - avatarUrl: string; - bannerUrl: string; - bio: string; - }; - }>(); + displayName: string + avatarUrl: string + bannerUrl: string + bio: string + } + }>() // Override fields take precedence - expect(body.displayName).toBe("Community Alice"); - expect(body.bio).toBe("Community-specific bio"); + expect(body.displayName).toBe('Community Alice') + expect(body.bio).toBe('Community-specific bio') // Null override fields fall back to source - expect(body.avatarUrl).toBe("https://example.com/avatar.jpg"); - expect(body.bannerUrl).toBe("https://example.com/banner.jpg"); - expect(body.hasOverride).toBe(true); + expect(body.avatarUrl).toBe('https://example.com/avatar.jpg') + expect(body.bannerUrl).toBe('https://example.com/banner.jpg') + expect(body.hasOverride).toBe(true) // Source always shows original values - expect(body.source.displayName).toBe("Alice"); - expect(body.source.bio).toBe("Global bio"); - }); + expect(body.source.displayName).toBe('Alice') + expect(body.source.bio).toBe('Global bio') + }) - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "GET", + method: 'GET', url: `/api/communities/${COMMUNITY_DID}/profile`, - }); + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("returns 404 when user record not found", async () => { + it('returns 404 when user record not found', async () => { // User not found in users table - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/communities/${COMMUNITY_DID}/profile`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(404); - }); - }); + expect(response.statusCode).toBe(404) + }) + }) // ========================================================================= // PUT /api/communities/:communityDid/profile // ========================================================================= - describe("PUT /api/communities/:communityDid/profile", () => { - let app: FastifyInstance; + describe('PUT /api/communities/:communityDid/profile', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("creates new override and returns success", async () => { + it('creates new override and returns success', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/communities/${COMMUNITY_DID}/profile`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - displayName: "Community Alice", - bio: "Community-specific bio", + displayName: 'Community Alice', + bio: 'Community-specific bio', }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean }>(); - expect(body.success).toBe(true); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) + expect(mockDb.insert).toHaveBeenCalledOnce() + }) - it("updates existing override", async () => { + it('updates existing override', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/communities/${COMMUNITY_DID}/profile`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - displayName: "Updated Name", + displayName: 'Updated Name', }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean }>(); - expect(body.success).toBe(true); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) + expect(mockDb.insert).toHaveBeenCalledOnce() + }) - it("clears fields when null values are sent", async () => { + it('clears fields when null values are sent', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/communities/${COMMUNITY_DID}/profile`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { displayName: null, bio: null, }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean }>(); - expect(body.success).toBe(true); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) + expect(mockDb.insert).toHaveBeenCalledOnce() + }) - it("accepts empty body (no changes)", async () => { + it('accepts empty body (no changes)', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/communities/${COMMUNITY_DID}/profile`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(200); - }); + expect(response.statusCode).toBe(200) + }) - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "PUT", + method: 'PUT', url: `/api/communities/${COMMUNITY_DID}/profile`, - payload: { displayName: "Test" }, - }); + payload: { displayName: 'Test' }, + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("returns 400 for displayName exceeding 256 characters", async () => { + it('returns 400 for displayName exceeding 256 characters', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/communities/${COMMUNITY_DID}/profile`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - displayName: "x".repeat(257), + displayName: 'x'.repeat(257), }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for bio exceeding 2048 characters", async () => { + it('returns 400 for bio exceeding 2048 characters', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/communities/${COMMUNITY_DID}/profile`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - bio: "x".repeat(2049), + bio: 'x'.repeat(2049), }, - }); + }) - expect(response.statusCode).toBe(400); - }); - }); + expect(response.statusCode).toBe(400) + }) + }) // ========================================================================= // DELETE /api/communities/:communityDid/profile // ========================================================================= - describe("DELETE /api/communities/:communityDid/profile", () => { - let app: FastifyInstance; + describe('DELETE /api/communities/:communityDid/profile', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("removes override row and returns 204", async () => { + it('removes override row and returns 204', async () => { const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/communities/${COMMUNITY_DID}/profile`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(204); - expect(mockDb.delete).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(204) + expect(mockDb.delete).toHaveBeenCalledOnce() + }) - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/communities/${COMMUNITY_DID}/profile`, - }); + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); - }); -}); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) + }) +}) diff --git a/tests/unit/routes/global-filters.test.ts b/tests/unit/routes/global-filters.test.ts index 1ec55e9..18c4964 100644 --- a/tests/unit/routes/global-filters.test.ts +++ b/tests/unit/routes/global-filters.test.ts @@ -1,29 +1,34 @@ -import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { type DbChain, createChainableProxy, createMockDb, type MockDb } from "../../helpers/mock-db.js"; -import { createRequireOperator } from "../../../src/auth/require-operator.js"; -import { globalFilterRoutes } from "../../../src/routes/global-filters.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { + type DbChain, + createChainableProxy, + createMockDb, + type MockDb, +} from '../../helpers/mock-db.js' +import { createRequireOperator } from '../../../src/auth/require-operator.js' +import { globalFilterRoutes } from '../../../src/routes/global-filters.js' // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const OPERATOR_DID = "did:plc:operator123"; -const OPERATOR_HANDLE = "operator.bsky.social"; -const OPERATOR_SID = "o".repeat(64); +const OPERATOR_DID = 'did:plc:operator123' +const OPERATOR_HANDLE = 'operator.bsky.social' +const OPERATOR_SID = 'o'.repeat(64) -const NON_OPERATOR_DID = "did:plc:regularuser456"; -const NON_OPERATOR_HANDLE = "regular.bsky.social"; -const NON_OPERATOR_SID = "r".repeat(64); +const NON_OPERATOR_DID = 'did:plc:regularuser456' +const NON_OPERATOR_HANDLE = 'regular.bsky.social' +const NON_OPERATOR_SID = 'r'.repeat(64) -const TEST_COMMUNITY_DID = "did:plc:community789"; -const TEST_ACCOUNT_DID = "did:plc:account999"; -const TEST_NOW = new Date("2026-02-13T12:00:00.000Z"); +const TEST_COMMUNITY_DID = 'did:plc:community789' +const TEST_ACCOUNT_DID = 'did:plc:account999' +const TEST_NOW = new Date('2026-02-13T12:00:00.000Z') // --------------------------------------------------------------------------- // Mock user builders @@ -35,7 +40,7 @@ function operatorUser(overrides?: Partial): RequestUser { handle: OPERATOR_HANDLE, sid: OPERATOR_SID, ...overrides, - }; + } } function nonOperatorUser(overrides?: Partial): RequestUser { @@ -44,7 +49,7 @@ function nonOperatorUser(overrides?: Partial): RequestUser { handle: NON_OPERATOR_HANDLE, sid: NON_OPERATOR_SID, ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -52,43 +57,43 @@ function nonOperatorUser(overrides?: Partial): RequestUser { // --------------------------------------------------------------------------- const globalMockEnv = { - COMMUNITY_MODE: "global", + COMMUNITY_MODE: 'global', OPERATOR_DIDS: [OPERATOR_DID], RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as unknown as Env; +} as unknown as Env const singleMockEnv = { - COMMUNITY_MODE: "single", - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_MODE: 'single', + COMMUNITY_DID: 'did:plc:community123', OPERATOR_DIDS: [OPERATOR_DID], RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as unknown as Env; +} as unknown as Env // --------------------------------------------------------------------------- // Chainable mock DB // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() -let insertChain: DbChain; -let selectChain: DbChain; +let insertChain: DbChain +let selectChain: DbChain function resetAllDbMocks(): void { - insertChain = createChainableProxy(); - selectChain = createChainableProxy([]); - mockDb.insert.mockReturnValue(insertChain); - mockDb.select.mockReturnValue(selectChain); - mockDb.update.mockReturnValue(createChainableProxy([])); - mockDb.delete.mockReturnValue(createChainableProxy()); + insertChain = createChainableProxy() + selectChain = createChainableProxy([]) + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(createChainableProxy([])) + mockDb.delete.mockReturnValue(createChainableProxy()) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction mockDb.transaction.mockImplementation(async (fn: (tx: MockDb) => Promise) => { - await fn(mockDb); - }); - mockDb.execute.mockReset(); + await fn(mockDb) + }) + mockDb.execute.mockReset() } // --------------------------------------------------------------------------- @@ -99,18 +104,18 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } // --------------------------------------------------------------------------- @@ -120,7 +125,7 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { function sampleCommunityFilterRow(overrides?: Record) { return { communityDid: TEST_COMMUNITY_DID, - status: "active", + status: 'active', adminDid: null, reason: null, reportCount: 0, @@ -129,15 +134,15 @@ function sampleCommunityFilterRow(overrides?: Record) { createdAt: TEST_NOW, updatedAt: TEST_NOW, ...overrides, - }; + } } function sampleAccountFilterRow(overrides?: Record) { return { id: 1, did: TEST_ACCOUNT_DID, - communityDid: "__global__", - status: "active", + communityDid: '__global__', + status: 'active', reason: null, reportCount: 0, banCount: 0, @@ -146,7 +151,7 @@ function sampleAccountFilterRow(overrides?: Record) { createdAt: TEST_NOW, updatedAt: TEST_NOW, ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -155,1015 +160,1023 @@ function sampleAccountFilterRow(overrides?: Record) { async function buildTestApp( user?: RequestUser, - env: Env = globalMockEnv, + env: Env = globalMockEnv ): Promise { - const app = Fastify({ logger: false }); + const app = Fastify({ logger: false }) - const authMiddleware = createMockAuthMiddleware(user); - const requireOperator = createRequireOperator(env, authMiddleware); + const authMiddleware = createMockAuthMiddleware(user) + const requireOperator = createRequireOperator(env, authMiddleware) - app.decorate("db", mockDb as never); - app.decorate("env", env); - app.decorate("authMiddleware", authMiddleware); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorate("requireOperator", requireOperator); - app.decorateRequest("user", undefined as RequestUser | undefined); + app.decorate('db', mockDb as never) + app.decorate('env', env) + app.decorate('authMiddleware', authMiddleware) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorate('requireOperator', requireOperator) + app.decorateRequest('user', undefined as RequestUser | undefined) - await app.register(globalFilterRoutes()); - await app.ready(); + await app.register(globalFilterRoutes()) + await app.ready() - return app; + return app } // =========================================================================== // Test suite // =========================================================================== -describe("global filter routes", () => { +describe('global filter routes', () => { // ========================================================================= // Access control: single mode returns 404 // ========================================================================= - describe("single community mode (all routes return 404)", () => { - let app: FastifyInstance; + describe('single community mode (all routes return 404)', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(operatorUser(), singleMockEnv); - }); + app = await buildTestApp(operatorUser(), singleMockEnv) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("GET /api/global/filters/communities returns 404", async () => { + it('GET /api/global/filters/communities returns 404', async () => { const response = await app.inject({ - method: "GET", - url: "/api/global/filters/communities", - headers: { authorization: "Bearer test-token" }, - }); - expect(response.statusCode).toBe(404); - }); - - it("PUT /api/global/filters/communities/:did returns 404", async () => { + method: 'GET', + url: '/api/global/filters/communities', + headers: { authorization: 'Bearer test-token' }, + }) + expect(response.statusCode).toBe(404) + }) + + it('PUT /api/global/filters/communities/:did returns 404', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/communities/${TEST_COMMUNITY_DID}`, - headers: { authorization: "Bearer test-token" }, - payload: { status: "filtered" }, - }); - expect(response.statusCode).toBe(404); - }); + headers: { authorization: 'Bearer test-token' }, + payload: { status: 'filtered' }, + }) + expect(response.statusCode).toBe(404) + }) - it("GET /api/global/filters/accounts returns 404", async () => { + it('GET /api/global/filters/accounts returns 404', async () => { const response = await app.inject({ - method: "GET", - url: "/api/global/filters/accounts", - headers: { authorization: "Bearer test-token" }, - }); - expect(response.statusCode).toBe(404); - }); - - it("PUT /api/global/filters/accounts/:did returns 404", async () => { + method: 'GET', + url: '/api/global/filters/accounts', + headers: { authorization: 'Bearer test-token' }, + }) + expect(response.statusCode).toBe(404) + }) + + it('PUT /api/global/filters/accounts/:did returns 404', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/accounts/${TEST_ACCOUNT_DID}`, - headers: { authorization: "Bearer test-token" }, - payload: { status: "filtered" }, - }); - expect(response.statusCode).toBe(404); - }); + headers: { authorization: 'Bearer test-token' }, + payload: { status: 'filtered' }, + }) + expect(response.statusCode).toBe(404) + }) - it("GET /api/global/reports/communities returns 404", async () => { + it('GET /api/global/reports/communities returns 404', async () => { const response = await app.inject({ - method: "GET", - url: "/api/global/reports/communities", - headers: { authorization: "Bearer test-token" }, - }); - expect(response.statusCode).toBe(404); - }); - }); + method: 'GET', + url: '/api/global/reports/communities', + headers: { authorization: 'Bearer test-token' }, + }) + expect(response.statusCode).toBe(404) + }) + }) // ========================================================================= // Access control: non-operator returns 403 // ========================================================================= - describe("non-operator user (all routes return 403)", () => { - let app: FastifyInstance; + describe('non-operator user (all routes return 403)', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(nonOperatorUser()); - }); + app = await buildTestApp(nonOperatorUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("GET /api/global/filters/communities returns 403", async () => { + it('GET /api/global/filters/communities returns 403', async () => { const response = await app.inject({ - method: "GET", - url: "/api/global/filters/communities", - headers: { authorization: "Bearer test-token" }, - }); - expect(response.statusCode).toBe(403); - }); - - it("PUT /api/global/filters/communities/:did returns 403", async () => { + method: 'GET', + url: '/api/global/filters/communities', + headers: { authorization: 'Bearer test-token' }, + }) + expect(response.statusCode).toBe(403) + }) + + it('PUT /api/global/filters/communities/:did returns 403', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/communities/${TEST_COMMUNITY_DID}`, - headers: { authorization: "Bearer test-token" }, - payload: { status: "filtered" }, - }); - expect(response.statusCode).toBe(403); - }); + headers: { authorization: 'Bearer test-token' }, + payload: { status: 'filtered' }, + }) + expect(response.statusCode).toBe(403) + }) - it("GET /api/global/filters/accounts returns 403", async () => { + it('GET /api/global/filters/accounts returns 403', async () => { const response = await app.inject({ - method: "GET", - url: "/api/global/filters/accounts", - headers: { authorization: "Bearer test-token" }, - }); - expect(response.statusCode).toBe(403); - }); - - it("PUT /api/global/filters/accounts/:did returns 403", async () => { + method: 'GET', + url: '/api/global/filters/accounts', + headers: { authorization: 'Bearer test-token' }, + }) + expect(response.statusCode).toBe(403) + }) + + it('PUT /api/global/filters/accounts/:did returns 403', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/accounts/${TEST_ACCOUNT_DID}`, - headers: { authorization: "Bearer test-token" }, - payload: { status: "filtered" }, - }); - expect(response.statusCode).toBe(403); - }); + headers: { authorization: 'Bearer test-token' }, + payload: { status: 'filtered' }, + }) + expect(response.statusCode).toBe(403) + }) - it("GET /api/global/reports/communities returns 403", async () => { + it('GET /api/global/reports/communities returns 403', async () => { const response = await app.inject({ - method: "GET", - url: "/api/global/reports/communities", - headers: { authorization: "Bearer test-token" }, - }); - expect(response.statusCode).toBe(403); - }); - }); + method: 'GET', + url: '/api/global/reports/communities', + headers: { authorization: 'Bearer test-token' }, + }) + expect(response.statusCode).toBe(403) + }) + }) // ========================================================================= // Access control: unauthenticated returns 401 // ========================================================================= - describe("unauthenticated user (all routes return 401)", () => { - let app: FastifyInstance; + describe('unauthenticated user (all routes return 401)', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(undefined); - }); + app = await buildTestApp(undefined) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("GET /api/global/filters/communities returns 401", async () => { + it('GET /api/global/filters/communities returns 401', async () => { const response = await app.inject({ - method: "GET", - url: "/api/global/filters/communities", - }); - expect(response.statusCode).toBe(401); - }); + method: 'GET', + url: '/api/global/filters/communities', + }) + expect(response.statusCode).toBe(401) + }) - it("PUT /api/global/filters/communities/:did returns 401", async () => { + it('PUT /api/global/filters/communities/:did returns 401', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/communities/${TEST_COMMUNITY_DID}`, - payload: { status: "filtered" }, - }); - expect(response.statusCode).toBe(401); - }); + payload: { status: 'filtered' }, + }) + expect(response.statusCode).toBe(401) + }) - it("GET /api/global/filters/accounts returns 401", async () => { + it('GET /api/global/filters/accounts returns 401', async () => { const response = await app.inject({ - method: "GET", - url: "/api/global/filters/accounts", - }); - expect(response.statusCode).toBe(401); - }); + method: 'GET', + url: '/api/global/filters/accounts', + }) + expect(response.statusCode).toBe(401) + }) - it("PUT /api/global/filters/accounts/:did returns 401", async () => { + it('PUT /api/global/filters/accounts/:did returns 401', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/accounts/${TEST_ACCOUNT_DID}`, - payload: { status: "filtered" }, - }); - expect(response.statusCode).toBe(401); - }); + payload: { status: 'filtered' }, + }) + expect(response.statusCode).toBe(401) + }) - it("GET /api/global/reports/communities returns 401", async () => { + it('GET /api/global/reports/communities returns 401', async () => { const response = await app.inject({ - method: "GET", - url: "/api/global/reports/communities", - }); - expect(response.statusCode).toBe(401); - }); - }); + method: 'GET', + url: '/api/global/reports/communities', + }) + expect(response.statusCode).toBe(401) + }) + }) // ========================================================================= // GET /api/global/filters/communities // ========================================================================= - describe("GET /api/global/filters/communities", () => { - let app: FastifyInstance; + describe('GET /api/global/filters/communities', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(operatorUser()); - }); + app = await buildTestApp(operatorUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns empty list when no filters exist", async () => { - selectChain.limit.mockResolvedValueOnce([]); + it('returns empty list when no filters exist', async () => { + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/global/filters/communities", - headers: { authorization: "Bearer test-token" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ filters: unknown[]; cursor: string | null }>(); - expect(body.filters).toEqual([]); - expect(body.cursor).toBeNull(); - }); - - it("returns community filters with serialized dates", async () => { + method: 'GET', + url: '/api/global/filters/communities', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ filters: unknown[]; cursor: string | null }>() + expect(body.filters).toEqual([]) + expect(body.cursor).toBeNull() + }) + + it('returns community filters with serialized dates', async () => { const row = sampleCommunityFilterRow({ - status: "warned", - reason: "Spam reports", + status: 'warned', + reason: 'Spam reports', filteredBy: OPERATOR_DID, lastReviewedAt: TEST_NOW, - }); - selectChain.limit.mockResolvedValueOnce([row]); + }) + selectChain.limit.mockResolvedValueOnce([row]) const response = await app.inject({ - method: "GET", - url: "/api/global/filters/communities", - headers: { authorization: "Bearer test-token" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ filters: Array>; cursor: string | null }>(); - expect(body.filters).toHaveLength(1); - expect(body.filters[0]?.communityDid).toBe(TEST_COMMUNITY_DID); - expect(body.filters[0]?.status).toBe("warned"); - expect(body.filters[0]?.reason).toBe("Spam reports"); - expect(body.filters[0]?.filteredBy).toBe(OPERATOR_DID); - expect(body.filters[0]?.createdAt).toBe(TEST_NOW.toISOString()); - expect(body.filters[0]?.updatedAt).toBe(TEST_NOW.toISOString()); - expect(body.filters[0]?.lastReviewedAt).toBe(TEST_NOW.toISOString()); - expect(body.cursor).toBeNull(); - }); - - it("returns null for lastReviewedAt when not set", async () => { - const row = sampleCommunityFilterRow({ lastReviewedAt: null }); - selectChain.limit.mockResolvedValueOnce([row]); + method: 'GET', + url: '/api/global/filters/communities', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + filters: Array> + cursor: string | null + }>() + expect(body.filters).toHaveLength(1) + expect(body.filters[0]?.communityDid).toBe(TEST_COMMUNITY_DID) + expect(body.filters[0]?.status).toBe('warned') + expect(body.filters[0]?.reason).toBe('Spam reports') + expect(body.filters[0]?.filteredBy).toBe(OPERATOR_DID) + expect(body.filters[0]?.createdAt).toBe(TEST_NOW.toISOString()) + expect(body.filters[0]?.updatedAt).toBe(TEST_NOW.toISOString()) + expect(body.filters[0]?.lastReviewedAt).toBe(TEST_NOW.toISOString()) + expect(body.cursor).toBeNull() + }) + + it('returns null for lastReviewedAt when not set', async () => { + const row = sampleCommunityFilterRow({ lastReviewedAt: null }) + selectChain.limit.mockResolvedValueOnce([row]) const response = await app.inject({ - method: "GET", - url: "/api/global/filters/communities", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/communities', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ filters: Array> }>(); - expect(body.filters[0]?.lastReviewedAt).toBeNull(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ filters: Array> }>() + expect(body.filters[0]?.lastReviewedAt).toBeNull() + }) - it("returns pagination cursor when more results exist", async () => { + it('returns pagination cursor when more results exist', async () => { // Default limit=25, so return 26 rows to trigger hasMore const rows = Array.from({ length: 26 }, (_, i) => sampleCommunityFilterRow({ communityDid: `did:plc:community${String(i)}`, - updatedAt: new Date(`2026-02-${String(13 - Math.floor(i / 2)).padStart(2, "0")}T12:00:00.000Z`), - }), - ); - selectChain.limit.mockResolvedValueOnce(rows); + updatedAt: new Date( + `2026-02-${String(13 - Math.floor(i / 2)).padStart(2, '0')}T12:00:00.000Z` + ), + }) + ) + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", - url: "/api/global/filters/communities", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/communities', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ filters: unknown[]; cursor: string | null }>(); - expect(body.filters).toHaveLength(25); - expect(body.cursor).toBeTruthy(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ filters: unknown[]; cursor: string | null }>() + expect(body.filters).toHaveLength(25) + expect(body.cursor).toBeTruthy() + }) - it("returns null cursor when fewer results than limit", async () => { - const rows = [sampleCommunityFilterRow()]; - selectChain.limit.mockResolvedValueOnce(rows); + it('returns null cursor when fewer results than limit', async () => { + const rows = [sampleCommunityFilterRow()] + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", - url: "/api/global/filters/communities?limit=10", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/communities?limit=10', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ filters: unknown[]; cursor: string | null }>(); - expect(body.filters).toHaveLength(1); - expect(body.cursor).toBeNull(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ filters: unknown[]; cursor: string | null }>() + expect(body.filters).toHaveLength(1) + expect(body.cursor).toBeNull() + }) - it("filters by status query parameter", async () => { - selectChain.limit.mockResolvedValueOnce([]); + it('filters by status query parameter', async () => { + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/global/filters/communities?status=filtered", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/communities?status=filtered', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - expect(selectChain.where).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(200) + expect(selectChain.where).toHaveBeenCalled() + }) - it("accepts cursor parameter for pagination", async () => { + it('accepts cursor parameter for pagination', async () => { const cursor = Buffer.from( - JSON.stringify({ updatedAt: TEST_NOW.toISOString(), id: TEST_COMMUNITY_DID }), - ).toString("base64"); - selectChain.limit.mockResolvedValueOnce([]); + JSON.stringify({ updatedAt: TEST_NOW.toISOString(), id: TEST_COMMUNITY_DID }) + ).toString('base64') + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/global/filters/communities?cursor=${encodeURIComponent(cursor)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - }); + expect(response.statusCode).toBe(200) + }) - it("respects custom limit", async () => { - selectChain.limit.mockResolvedValueOnce([]); + it('respects custom limit', async () => { + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/global/filters/communities?limit=5", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/communities?limit=5', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - expect(selectChain.limit).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(200) + expect(selectChain.limit).toHaveBeenCalled() + }) - it("returns 400 for invalid limit (over max)", async () => { + it('returns 400 for invalid limit (over max)', async () => { const response = await app.inject({ - method: "GET", - url: "/api/global/filters/communities?limit=999", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/communities?limit=999', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for invalid limit (zero)", async () => { + it('returns 400 for invalid limit (zero)', async () => { const response = await app.inject({ - method: "GET", - url: "/api/global/filters/communities?limit=0", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/communities?limit=0', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for non-numeric limit", async () => { + it('returns 400 for non-numeric limit', async () => { const response = await app.inject({ - method: "GET", - url: "/api/global/filters/communities?limit=abc", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/communities?limit=abc', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(400); - }); - }); + expect(response.statusCode).toBe(400) + }) + }) // ========================================================================= // PUT /api/global/filters/communities/:did // ========================================================================= - describe("PUT /api/global/filters/communities/:did", () => { - let app: FastifyInstance; + describe('PUT /api/global/filters/communities/:did', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(operatorUser()); - }); + app = await buildTestApp(operatorUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("upserts a community filter and returns the result", async () => { + it('upserts a community filter and returns the result', async () => { const upsertedRow = sampleCommunityFilterRow({ - status: "filtered", - reason: "Repeated violations", + status: 'filtered', + reason: 'Repeated violations', filteredBy: OPERATOR_DID, lastReviewedAt: TEST_NOW, - }); - insertChain.returning.mockResolvedValueOnce([upsertedRow]); + }) + insertChain.returning.mockResolvedValueOnce([upsertedRow]) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/communities/${TEST_COMMUNITY_DID}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - status: "filtered", - reason: "Repeated violations", + status: 'filtered', + reason: 'Repeated violations', }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json>(); - expect(body.communityDid).toBe(TEST_COMMUNITY_DID); - expect(body.status).toBe("filtered"); - expect(body.reason).toBe("Repeated violations"); - expect(body.filteredBy).toBe(OPERATOR_DID); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); - - it("upserts with adminDid", async () => { - const adminDid = "did:plc:communityadmin"; + }) + + expect(response.statusCode).toBe(200) + const body = response.json>() + expect(body.communityDid).toBe(TEST_COMMUNITY_DID) + expect(body.status).toBe('filtered') + expect(body.reason).toBe('Repeated violations') + expect(body.filteredBy).toBe(OPERATOR_DID) + expect(mockDb.insert).toHaveBeenCalledOnce() + }) + + it('upserts with adminDid', async () => { + const adminDid = 'did:plc:communityadmin' const upsertedRow = sampleCommunityFilterRow({ - status: "warned", + status: 'warned', adminDid, filteredBy: OPERATOR_DID, lastReviewedAt: TEST_NOW, - }); - insertChain.returning.mockResolvedValueOnce([upsertedRow]); + }) + insertChain.returning.mockResolvedValueOnce([upsertedRow]) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/communities/${TEST_COMMUNITY_DID}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - status: "warned", + status: 'warned', adminDid, }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json>(); - expect(body.adminDid).toBe(adminDid); - }); + expect(response.statusCode).toBe(200) + const body = response.json>() + expect(body.adminDid).toBe(adminDid) + }) - it("upserts with status only (reason and adminDid optional)", async () => { + it('upserts with status only (reason and adminDid optional)', async () => { const upsertedRow = sampleCommunityFilterRow({ - status: "active", + status: 'active', filteredBy: OPERATOR_DID, lastReviewedAt: TEST_NOW, - }); - insertChain.returning.mockResolvedValueOnce([upsertedRow]); + }) + insertChain.returning.mockResolvedValueOnce([upsertedRow]) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/communities/${TEST_COMMUNITY_DID}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - status: "active", + status: 'active', }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json>(); - expect(body.status).toBe("active"); - }); + expect(response.statusCode).toBe(200) + const body = response.json>() + expect(body.status).toBe('active') + }) - it("returns 400 for missing status", async () => { + it('returns 400 for missing status', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/communities/${TEST_COMMUNITY_DID}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - reason: "No status provided", + reason: 'No status provided', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for invalid status value", async () => { + it('returns 400 for invalid status value', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/communities/${TEST_COMMUNITY_DID}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - status: "invalid_status", + status: 'invalid_status', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for reason exceeding max length", async () => { + it('returns 400 for reason exceeding max length', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/communities/${TEST_COMMUNITY_DID}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - status: "filtered", - reason: "A".repeat(1001), + status: 'filtered', + reason: 'A'.repeat(1001), }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 when upsert fails (no row returned)", async () => { - insertChain.returning.mockResolvedValueOnce([]); + it('returns 400 when upsert fails (no row returned)', async () => { + insertChain.returning.mockResolvedValueOnce([]) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/communities/${TEST_COMMUNITY_DID}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - status: "filtered", + status: 'filtered', }, - }); + }) - expect(response.statusCode).toBe(400); - }); - }); + expect(response.statusCode).toBe(400) + }) + }) // ========================================================================= // GET /api/global/filters/accounts // ========================================================================= - describe("GET /api/global/filters/accounts", () => { - let app: FastifyInstance; + describe('GET /api/global/filters/accounts', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(operatorUser()); - }); + app = await buildTestApp(operatorUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns empty list when no account filters exist", async () => { - selectChain.limit.mockResolvedValueOnce([]); + it('returns empty list when no account filters exist', async () => { + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/global/filters/accounts", - headers: { authorization: "Bearer test-token" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ filters: unknown[]; cursor: string | null }>(); - expect(body.filters).toEqual([]); - expect(body.cursor).toBeNull(); - }); - - it("returns account filters with serialized dates", async () => { + method: 'GET', + url: '/api/global/filters/accounts', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ filters: unknown[]; cursor: string | null }>() + expect(body.filters).toEqual([]) + expect(body.cursor).toBeNull() + }) + + it('returns account filters with serialized dates', async () => { const row = sampleAccountFilterRow({ - status: "warned", - reason: "Spam behavior", + status: 'warned', + reason: 'Spam behavior', filteredBy: OPERATOR_DID, lastReviewedAt: TEST_NOW, - }); - selectChain.limit.mockResolvedValueOnce([row]); + }) + selectChain.limit.mockResolvedValueOnce([row]) const response = await app.inject({ - method: "GET", - url: "/api/global/filters/accounts", - headers: { authorization: "Bearer test-token" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ filters: Array>; cursor: string | null }>(); - expect(body.filters).toHaveLength(1); - expect(body.filters[0]?.did).toBe(TEST_ACCOUNT_DID); - expect(body.filters[0]?.communityDid).toBe("__global__"); - expect(body.filters[0]?.status).toBe("warned"); - expect(body.filters[0]?.reason).toBe("Spam behavior"); - expect(body.filters[0]?.reportCount).toBe(0); - expect(body.filters[0]?.banCount).toBe(0); - expect(body.filters[0]?.createdAt).toBe(TEST_NOW.toISOString()); - expect(body.filters[0]?.updatedAt).toBe(TEST_NOW.toISOString()); - expect(body.filters[0]?.lastReviewedAt).toBe(TEST_NOW.toISOString()); - expect(body.cursor).toBeNull(); - }); - - it("returns null for lastReviewedAt when not set", async () => { - const row = sampleAccountFilterRow({ lastReviewedAt: null }); - selectChain.limit.mockResolvedValueOnce([row]); + method: 'GET', + url: '/api/global/filters/accounts', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + filters: Array> + cursor: string | null + }>() + expect(body.filters).toHaveLength(1) + expect(body.filters[0]?.did).toBe(TEST_ACCOUNT_DID) + expect(body.filters[0]?.communityDid).toBe('__global__') + expect(body.filters[0]?.status).toBe('warned') + expect(body.filters[0]?.reason).toBe('Spam behavior') + expect(body.filters[0]?.reportCount).toBe(0) + expect(body.filters[0]?.banCount).toBe(0) + expect(body.filters[0]?.createdAt).toBe(TEST_NOW.toISOString()) + expect(body.filters[0]?.updatedAt).toBe(TEST_NOW.toISOString()) + expect(body.filters[0]?.lastReviewedAt).toBe(TEST_NOW.toISOString()) + expect(body.cursor).toBeNull() + }) + + it('returns null for lastReviewedAt when not set', async () => { + const row = sampleAccountFilterRow({ lastReviewedAt: null }) + selectChain.limit.mockResolvedValueOnce([row]) const response = await app.inject({ - method: "GET", - url: "/api/global/filters/accounts", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/accounts', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ filters: Array> }>(); - expect(body.filters[0]?.lastReviewedAt).toBeNull(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ filters: Array> }>() + expect(body.filters[0]?.lastReviewedAt).toBeNull() + }) - it("returns pagination cursor when more results exist", async () => { + it('returns pagination cursor when more results exist', async () => { // Default limit=25, so return 26 rows to trigger hasMore const rows = Array.from({ length: 26 }, (_, i) => sampleAccountFilterRow({ id: i + 1, did: `did:plc:account${String(i)}`, - updatedAt: new Date(`2026-02-${String(13 - Math.floor(i / 2)).padStart(2, "0")}T12:00:00.000Z`), - }), - ); - selectChain.limit.mockResolvedValueOnce(rows); + updatedAt: new Date( + `2026-02-${String(13 - Math.floor(i / 2)).padStart(2, '0')}T12:00:00.000Z` + ), + }) + ) + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", - url: "/api/global/filters/accounts", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/accounts', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ filters: unknown[]; cursor: string | null }>(); - expect(body.filters).toHaveLength(25); - expect(body.cursor).toBeTruthy(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ filters: unknown[]; cursor: string | null }>() + expect(body.filters).toHaveLength(25) + expect(body.cursor).toBeTruthy() + }) - it("returns null cursor when fewer results than limit", async () => { - const rows = [sampleAccountFilterRow()]; - selectChain.limit.mockResolvedValueOnce(rows); + it('returns null cursor when fewer results than limit', async () => { + const rows = [sampleAccountFilterRow()] + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", - url: "/api/global/filters/accounts?limit=10", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/accounts?limit=10', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ filters: unknown[]; cursor: string | null }>(); - expect(body.filters).toHaveLength(1); - expect(body.cursor).toBeNull(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ filters: unknown[]; cursor: string | null }>() + expect(body.filters).toHaveLength(1) + expect(body.cursor).toBeNull() + }) - it("filters by status query parameter", async () => { - selectChain.limit.mockResolvedValueOnce([]); + it('filters by status query parameter', async () => { + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/global/filters/accounts?status=filtered", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/accounts?status=filtered', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - expect(selectChain.where).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(200) + expect(selectChain.where).toHaveBeenCalled() + }) - it("filters by communityDid query parameter", async () => { - selectChain.limit.mockResolvedValueOnce([]); + it('filters by communityDid query parameter', async () => { + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/global/filters/accounts?communityDid=${TEST_COMMUNITY_DID}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - expect(selectChain.where).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(200) + expect(selectChain.where).toHaveBeenCalled() + }) - it("accepts cursor parameter for pagination", async () => { + it('accepts cursor parameter for pagination', async () => { const cursor = Buffer.from( - JSON.stringify({ updatedAt: TEST_NOW.toISOString(), id: 42 }), - ).toString("base64"); - selectChain.limit.mockResolvedValueOnce([]); + JSON.stringify({ updatedAt: TEST_NOW.toISOString(), id: 42 }) + ).toString('base64') + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/global/filters/accounts?cursor=${encodeURIComponent(cursor)}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - }); + expect(response.statusCode).toBe(200) + }) - it("respects custom limit", async () => { - selectChain.limit.mockResolvedValueOnce([]); + it('respects custom limit', async () => { + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/global/filters/accounts?limit=5", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/accounts?limit=5', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - expect(selectChain.limit).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(200) + expect(selectChain.limit).toHaveBeenCalled() + }) - it("returns 400 for invalid limit (over max)", async () => { + it('returns 400 for invalid limit (over max)', async () => { const response = await app.inject({ - method: "GET", - url: "/api/global/filters/accounts?limit=999", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/accounts?limit=999', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for invalid limit (zero)", async () => { + it('returns 400 for invalid limit (zero)', async () => { const response = await app.inject({ - method: "GET", - url: "/api/global/filters/accounts?limit=0", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/accounts?limit=0', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for non-numeric limit", async () => { + it('returns 400 for non-numeric limit', async () => { const response = await app.inject({ - method: "GET", - url: "/api/global/filters/accounts?limit=abc", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/filters/accounts?limit=abc', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(400); - }); - }); + expect(response.statusCode).toBe(400) + }) + }) // ========================================================================= // PUT /api/global/filters/accounts/:did // ========================================================================= - describe("PUT /api/global/filters/accounts/:did", () => { - let app: FastifyInstance; + describe('PUT /api/global/filters/accounts/:did', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(operatorUser()); - }); + app = await buildTestApp(operatorUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("upserts an account filter and returns the result", async () => { + it('upserts an account filter and returns the result', async () => { const upsertedRow = sampleAccountFilterRow({ - status: "filtered", - reason: "Abusive behavior", + status: 'filtered', + reason: 'Abusive behavior', filteredBy: OPERATOR_DID, lastReviewedAt: TEST_NOW, - }); - insertChain.returning.mockResolvedValueOnce([upsertedRow]); + }) + insertChain.returning.mockResolvedValueOnce([upsertedRow]) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/accounts/${TEST_ACCOUNT_DID}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - status: "filtered", - reason: "Abusive behavior", + status: 'filtered', + reason: 'Abusive behavior', }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json>(); - expect(body.did).toBe(TEST_ACCOUNT_DID); - expect(body.communityDid).toBe("__global__"); - expect(body.status).toBe("filtered"); - expect(body.reason).toBe("Abusive behavior"); - expect(body.filteredBy).toBe(OPERATOR_DID); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); - - it("upserts with status only (reason optional)", async () => { + }) + + expect(response.statusCode).toBe(200) + const body = response.json>() + expect(body.did).toBe(TEST_ACCOUNT_DID) + expect(body.communityDid).toBe('__global__') + expect(body.status).toBe('filtered') + expect(body.reason).toBe('Abusive behavior') + expect(body.filteredBy).toBe(OPERATOR_DID) + expect(mockDb.insert).toHaveBeenCalledOnce() + }) + + it('upserts with status only (reason optional)', async () => { const upsertedRow = sampleAccountFilterRow({ - status: "warned", + status: 'warned', filteredBy: OPERATOR_DID, lastReviewedAt: TEST_NOW, - }); - insertChain.returning.mockResolvedValueOnce([upsertedRow]); + }) + insertChain.returning.mockResolvedValueOnce([upsertedRow]) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/accounts/${TEST_ACCOUNT_DID}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - status: "warned", + status: 'warned', }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json>(); - expect(body.status).toBe("warned"); - }); + expect(response.statusCode).toBe(200) + const body = response.json>() + expect(body.status).toBe('warned') + }) - it("sets communityDid to __global__ sentinel", async () => { + it('sets communityDid to __global__ sentinel', async () => { const upsertedRow = sampleAccountFilterRow({ - status: "active", - communityDid: "__global__", + status: 'active', + communityDid: '__global__', filteredBy: OPERATOR_DID, lastReviewedAt: TEST_NOW, - }); - insertChain.returning.mockResolvedValueOnce([upsertedRow]); + }) + insertChain.returning.mockResolvedValueOnce([upsertedRow]) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/accounts/${TEST_ACCOUNT_DID}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - status: "active", + status: 'active', }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json>(); - expect(body.communityDid).toBe("__global__"); - }); + expect(response.statusCode).toBe(200) + const body = response.json>() + expect(body.communityDid).toBe('__global__') + }) - it("returns 400 for missing status", async () => { + it('returns 400 for missing status', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/accounts/${TEST_ACCOUNT_DID}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - reason: "No status provided", + reason: 'No status provided', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for invalid status value", async () => { + it('returns 400 for invalid status value', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/accounts/${TEST_ACCOUNT_DID}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - status: "banned", + status: 'banned', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for reason exceeding max length", async () => { + it('returns 400 for reason exceeding max length', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/accounts/${TEST_ACCOUNT_DID}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - status: "filtered", - reason: "A".repeat(1001), + status: 'filtered', + reason: 'A'.repeat(1001), }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 when upsert fails (no row returned)", async () => { - insertChain.returning.mockResolvedValueOnce([]); + it('returns 400 when upsert fails (no row returned)', async () => { + insertChain.returning.mockResolvedValueOnce([]) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/global/filters/accounts/${TEST_ACCOUNT_DID}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - status: "filtered", + status: 'filtered', }, - }); + }) - expect(response.statusCode).toBe(400); - }); - }); + expect(response.statusCode).toBe(400) + }) + }) // ========================================================================= // GET /api/global/reports/communities // ========================================================================= - describe("GET /api/global/reports/communities", () => { - let app: FastifyInstance; + describe('GET /api/global/reports/communities', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(operatorUser()); - }); + app = await buildTestApp(operatorUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns aggregated report counts", async () => { + it('returns aggregated report counts', async () => { const rows = [ - { community_did: "did:plc:community1", report_count: 15, topic_count: 42 }, - { community_did: "did:plc:community2", report_count: 7, topic_count: 20 }, - ]; - mockDb.execute.mockResolvedValueOnce(rows); + { community_did: 'did:plc:community1', report_count: 15, topic_count: 42 }, + { community_did: 'did:plc:community2', report_count: 7, topic_count: 20 }, + ] + mockDb.execute.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", - url: "/api/global/reports/communities", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/reports/communities', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - communities: Array<{ communityDid: string; reportCount: number; topicCount: number }>; - }>(); - expect(body.communities).toHaveLength(2); - expect(body.communities[0]?.communityDid).toBe("did:plc:community1"); - expect(body.communities[0]?.reportCount).toBe(15); - expect(body.communities[0]?.topicCount).toBe(42); - expect(body.communities[1]?.communityDid).toBe("did:plc:community2"); - expect(body.communities[1]?.reportCount).toBe(7); - expect(body.communities[1]?.topicCount).toBe(20); - }); - - it("returns empty list when no reports exist", async () => { - mockDb.execute.mockResolvedValueOnce([]); + communities: Array<{ communityDid: string; reportCount: number; topicCount: number }> + }>() + expect(body.communities).toHaveLength(2) + expect(body.communities[0]?.communityDid).toBe('did:plc:community1') + expect(body.communities[0]?.reportCount).toBe(15) + expect(body.communities[0]?.topicCount).toBe(42) + expect(body.communities[1]?.communityDid).toBe('did:plc:community2') + expect(body.communities[1]?.reportCount).toBe(7) + expect(body.communities[1]?.topicCount).toBe(20) + }) + + it('returns empty list when no reports exist', async () => { + mockDb.execute.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/global/reports/communities", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/reports/communities', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ communities: unknown[] }>(); - expect(body.communities).toEqual([]); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ communities: unknown[] }>() + expect(body.communities).toEqual([]) + }) - it("respects custom limit", async () => { - mockDb.execute.mockResolvedValueOnce([]); + it('respects custom limit', async () => { + mockDb.execute.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/global/reports/communities?limit=5", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/reports/communities?limit=5', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - expect(mockDb.execute).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(200) + expect(mockDb.execute).toHaveBeenCalledOnce() + }) - it("uses default limit of 25 when not specified", async () => { - mockDb.execute.mockResolvedValueOnce([]); + it('uses default limit of 25 when not specified', async () => { + mockDb.execute.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/global/reports/communities", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/reports/communities', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - expect(mockDb.execute).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(200) + expect(mockDb.execute).toHaveBeenCalledOnce() + }) - it("handles communities with zero topic counts", async () => { - const rows = [ - { community_did: "did:plc:community1", report_count: 3, topic_count: 0 }, - ]; - mockDb.execute.mockResolvedValueOnce(rows); + it('handles communities with zero topic counts', async () => { + const rows = [{ community_did: 'did:plc:community1', report_count: 3, topic_count: 0 }] + mockDb.execute.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", - url: "/api/global/reports/communities", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/global/reports/communities', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - communities: Array<{ communityDid: string; reportCount: number; topicCount: number }>; - }>(); - expect(body.communities).toHaveLength(1); - expect(body.communities[0]?.topicCount).toBe(0); - }); - }); -}); + communities: Array<{ communityDid: string; reportCount: number; topicCount: number }> + }>() + expect(body.communities).toHaveLength(1) + expect(body.communities[0]?.topicCount).toBe(0) + }) + }) +}) diff --git a/tests/unit/routes/health.test.ts b/tests/unit/routes/health.test.ts index 6b16350..fcfa1aa 100644 --- a/tests/unit/routes/health.test.ts +++ b/tests/unit/routes/health.test.ts @@ -1,114 +1,114 @@ -import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; -import { buildApp } from "../../../src/app.js"; -import type { FastifyInstance } from "fastify"; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import { buildApp } from '../../../src/app.js' +import type { FastifyInstance } from 'fastify' // Mock @atproto/oauth-client-node to avoid crypto operations -vi.mock("@atproto/oauth-client-node", () => { +vi.mock('@atproto/oauth-client-node', () => { return { NodeOAuthClient: class MockNodeOAuthClient { - clientMetadata = {}; - jwks = { keys: [] }; - addEventListener = vi.fn(); + clientMetadata = {} + jwks = { keys: [] } + addEventListener = vi.fn() }, - }; -}); + } +}) // Mock @atproto/tap to avoid real network connections -vi.mock("@atproto/tap", () => { +vi.mock('@atproto/tap', () => { const mockChannel = { start: vi.fn().mockResolvedValue(undefined), destroy: vi.fn().mockResolvedValue(undefined), - }; + } class MockTap { - addRepos = vi.fn().mockResolvedValue(undefined); - removeRepos = vi.fn().mockResolvedValue(undefined); - channel = vi.fn().mockReturnValue(mockChannel); + addRepos = vi.fn().mockResolvedValue(undefined) + removeRepos = vi.fn().mockResolvedValue(undefined) + channel = vi.fn().mockReturnValue(mockChannel) } class MockSimpleIndexer { - identity = vi.fn().mockReturnThis(); - record = vi.fn().mockReturnThis(); - error = vi.fn().mockReturnThis(); + identity = vi.fn().mockReturnThis() + record = vi.fn().mockReturnThis() + error = vi.fn().mockReturnThis() } return { Tap: MockTap, SimpleIndexer: MockSimpleIndexer, - }; -}); + } +}) interface HealthResponse { - status: string; - version: string; - uptime: number; + status: string + version: string + uptime: number } interface ReadyResponse { - status: string; - checks: Record; + status: string + checks: Record } -describe("health routes", () => { - let app: FastifyInstance; +describe('health routes', () => { + let app: FastifyInstance beforeAll(async () => { app = await buildApp({ - DATABASE_URL: "postgresql://barazo:barazo_dev@localhost:5432/barazo", - VALKEY_URL: "redis://localhost:6379", - TAP_URL: "http://localhost:2480", - TAP_ADMIN_PASSWORD: "tap_dev_secret", - HOST: "0.0.0.0", + DATABASE_URL: 'postgresql://barazo:barazo_dev@localhost:5432/barazo', + VALKEY_URL: 'redis://localhost:6379', + TAP_URL: 'http://localhost:2480', + TAP_ADMIN_PASSWORD: 'tap_dev_secret', + HOST: '0.0.0.0', PORT: 0, - LOG_LEVEL: "silent", - CORS_ORIGINS: "http://localhost:3001", - COMMUNITY_MODE: "single" as const, - COMMUNITY_NAME: "Test Community", + LOG_LEVEL: 'silent', + CORS_ORIGINS: 'http://localhost:3001', + COMMUNITY_MODE: 'single' as const, + COMMUNITY_NAME: 'Test Community', RATE_LIMIT_AUTH: 10, RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, - OAUTH_CLIENT_ID: "http://localhost", - OAUTH_REDIRECT_URI: "http://127.0.0.1:3000/api/auth/callback", - SESSION_SECRET: "a".repeat(32), + OAUTH_CLIENT_ID: 'http://localhost', + OAUTH_REDIRECT_URI: 'http://127.0.0.1:3000/api/auth/callback', + SESSION_SECRET: 'a'.repeat(32), OAUTH_SESSION_TTL: 604800, OAUTH_ACCESS_TOKEN_TTL: 900, - }); - await app.ready(); - }); + }) + await app.ready() + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - describe("GET /api/health", () => { - it("returns 200 with status healthy", async () => { + describe('GET /api/health', () => { + it('returns 200 with status healthy', async () => { const response = await app.inject({ - method: "GET", - url: "/api/health", - }); + method: 'GET', + url: '/api/health', + }) - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.status).toBe("healthy"); - expect(body.version).toBe("0.1.0"); - expect(typeof body.uptime).toBe("number"); - }); - }); + expect(response.statusCode).toBe(200) + const body = response.json() + expect(body.status).toBe('healthy') + expect(body.version).toBe('0.1.0') + expect(typeof body.uptime).toBe('number') + }) + }) - describe("GET /api/health/ready", () => { - it("returns dependency check results including firehose", async () => { + describe('GET /api/health/ready', () => { + it('returns dependency check results including firehose', async () => { const response = await app.inject({ - method: "GET", - url: "/api/health/ready", - }); + method: 'GET', + url: '/api/health/ready', + }) - const body = response.json(); - expect(body).toHaveProperty("status"); - expect(body).toHaveProperty("checks"); - expect(body.checks).toHaveProperty("database"); - expect(body.checks).toHaveProperty("cache"); - expect(body.checks).toHaveProperty("firehose"); - }); - }); -}); + const body = response.json() + expect(body).toHaveProperty('status') + expect(body).toHaveProperty('checks') + expect(body.checks).toHaveProperty('database') + expect(body.checks).toHaveProperty('cache') + expect(body.checks).toHaveProperty('firehose') + }) + }) +}) diff --git a/tests/unit/routes/maturity-filtering.test.ts b/tests/unit/routes/maturity-filtering.test.ts index 6543714..58ea1d3 100644 --- a/tests/unit/routes/maturity-filtering.test.ts +++ b/tests/unit/routes/maturity-filtering.test.ts @@ -1,61 +1,61 @@ -import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { type DbChain, createMockDb, resetDbMocks } from "../../helpers/mock-db.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createMockDb, resetDbMocks } from '../../helpers/mock-db.js' // --------------------------------------------------------------------------- // Mock PDS client module (must be before importing routes) // --------------------------------------------------------------------------- -vi.mock("../../../src/lib/pds-client.js", () => ({ +vi.mock('../../../src/lib/pds-client.js', () => ({ createPdsClient: () => ({ createRecord: vi.fn(), updateRecord: vi.fn(), deleteRecord: vi.fn(), }), -})); +})) // Import routes AFTER mocking -import { topicRoutes } from "../../../src/routes/topics.js"; -import { replyRoutes } from "../../../src/routes/replies.js"; +import { topicRoutes } from '../../../src/routes/topics.js' +import { replyRoutes } from '../../../src/routes/replies.js' // --------------------------------------------------------------------------- // Mock env // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_DID: 'did:plc:community123', RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); -const TEST_NOW = "2026-02-13T12:00:00.000Z"; +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) +const TEST_NOW = '2026-02-13T12:00:00.000Z' function testUser(overrides?: Partial): RequestUser { - return { did: TEST_DID, handle: TEST_HANDLE, sid: TEST_SID, ...overrides }; + return { did: TEST_DID, handle: TEST_HANDLE, sid: TEST_SID, ...overrides } } // --------------------------------------------------------------------------- // Chainable mock DB (shared helper) // --------------------------------------------------------------------------- -const mockDb = createMockDb(); -let selectChain: DbChain; +const mockDb = createMockDb() +let selectChain: DbChain function resetAllDbMocks(): void { - selectChain = resetDbMocks(mockDb); + selectChain = resetDbMocks(mockDb) } // --------------------------------------------------------------------------- @@ -66,16 +66,16 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { - if (user) request.user = user; - return Promise.resolve(); + if (user) request.user = user + return Promise.resolve() }, - }; + } } // --------------------------------------------------------------------------- @@ -92,7 +92,7 @@ const mockFirehose = { start: vi.fn(), stop: vi.fn(), getStatus: vi.fn().mockReturnValue({ connected: true, lastEventId: null }), -}; +} // --------------------------------------------------------------------------- // Sample rows @@ -101,15 +101,15 @@ const mockFirehose = { function sampleTopicRow(overrides?: Record) { return { uri: `at://${TEST_DID}/forum.barazo.topic.post/abc123`, - rkey: "abc123", + rkey: 'abc123', authorDid: TEST_DID, - title: "Test Topic", - content: "Content here", + title: 'Test Topic', + content: 'Content here', contentFormat: null, - category: "general", + category: 'general', tags: [], - communityDid: "did:plc:community123", - cid: "bafyreiabc", + communityDid: 'did:plc:community123', + cid: 'bafyreiabc', labels: null, replyCount: 0, reactionCount: 0, @@ -118,27 +118,27 @@ function sampleTopicRow(overrides?: Record) { indexedAt: new Date(TEST_NOW), embedding: null, ...overrides, - }; + } } function sampleReplyRow(overrides?: Record) { return { uri: `at://${TEST_DID}/forum.barazo.topic.reply/reply001`, - rkey: "reply001", + rkey: 'reply001', authorDid: TEST_DID, - content: "A reply", + content: 'A reply', contentFormat: null, rootUri: `at://${TEST_DID}/forum.barazo.topic.post/abc123`, - rootCid: "bafyreiabc", + rootCid: 'bafyreiabc', parentUri: `at://${TEST_DID}/forum.barazo.topic.post/abc123`, - parentCid: "bafyreiabc", - communityDid: "did:plc:community123", - cid: "bafyreireply", + parentCid: 'bafyreiabc', + communityDid: 'did:plc:community123', + cid: 'bafyreireply', reactionCount: 0, createdAt: new Date(TEST_NOW), indexedAt: new Date(TEST_NOW), ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -146,292 +146,269 @@ function sampleReplyRow(overrides?: Record) { // --------------------------------------------------------------------------- async function buildTestApp(user?: RequestUser): Promise { - const app = Fastify({ logger: false }); - - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", createMockAuthMiddleware(user)); - app.decorate("firehose", mockFirehose as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); - - await app.register(topicRoutes()); - await app.register(replyRoutes()); - await app.ready(); - - return app; + const app = Fastify({ logger: false }) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', createMockAuthMiddleware(user)) + app.decorate('firehose', mockFirehose as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + + await app.register(topicRoutes()) + await app.register(replyRoutes()) + await app.ready() + + return app } // =========================================================================== // Test suite: Maturity Filtering // =========================================================================== -describe("maturity filtering", () => { +describe('maturity filtering', () => { // ========================================================================= // GET /api/topics - maturity filtering on list // ========================================================================= - describe("GET /api/topics maturity filtering", () => { - let app: FastifyInstance; + describe('GET /api/topics maturity filtering', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("filters topics to safe-only categories for unauthenticated users", async () => { - const noAuthApp = await buildTestApp(undefined); + it('filters topics to safe-only categories for unauthenticated users', async () => { + const noAuthApp = await buildTestApp(undefined) // No user profile query (unauthenticated) // Community settings: ageThreshold - selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]); + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) // Categories query: return only safe categories - selectChain.where.mockResolvedValueOnce([{ slug: "general" }]); + selectChain.where.mockResolvedValueOnce([{ slug: 'general' }]) // Topics query - selectChain.limit.mockResolvedValueOnce([sampleTopicRow({ category: "general" })]); + selectChain.limit.mockResolvedValueOnce([sampleTopicRow({ category: 'general' })]) const response = await noAuthApp.inject({ - method: "GET", - url: "/api/topics", - }); + method: 'GET', + url: '/api/topics', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: Array<{ category: string }> }>(); - expect(body.topics).toHaveLength(1); - expect(body.topics[0]?.category).toBe("general"); + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: Array<{ category: string }> }>() + expect(body.topics).toHaveLength(1) + expect(body.topics[0]?.category).toBe('general') - await noAuthApp.close(); - }); + await noAuthApp.close() + }) - it("filters topics to safe-only when user has no age declaration", async () => { + it('filters topics to safe-only when user has no age declaration', async () => { // User profile: no declaredAge → maxMaturity = "safe" - selectChain.where.mockResolvedValueOnce([ - { declaredAge: null, maturityPref: "mature" }, - ]); + selectChain.where.mockResolvedValueOnce([{ declaredAge: null, maturityPref: 'mature' }]) // Community settings: ageThreshold - selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]); + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) // Categories: only safe categories returned (DB would filter) - selectChain.where.mockResolvedValueOnce([{ slug: "general" }]); + selectChain.where.mockResolvedValueOnce([{ slug: 'general' }]) // Topics - selectChain.limit.mockResolvedValueOnce([sampleTopicRow()]); + selectChain.limit.mockResolvedValueOnce([sampleTopicRow()]) const response = await app.inject({ - method: "GET", - url: "/api/topics", - }); + method: 'GET', + url: '/api/topics', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: unknown[] }>(); - expect(body.topics).toHaveLength(1); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: unknown[] }>() + expect(body.topics).toHaveLength(1) + }) - it("includes mature categories when user has age declared and maturityPref=mature", async () => { + it('includes mature categories when user has age declared and maturityPref=mature', async () => { // User profile: age declared, maturityPref = "mature" - selectChain.where.mockResolvedValueOnce([ - { declaredAge: 18, maturityPref: "mature" }, - ]); + selectChain.where.mockResolvedValueOnce([{ declaredAge: 18, maturityPref: 'mature' }]) // Community settings: ageThreshold - selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]); + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) // Categories: both safe and mature categories - selectChain.where.mockResolvedValueOnce([ - { slug: "general" }, - { slug: "mature-talk" }, - ]); + selectChain.where.mockResolvedValueOnce([{ slug: 'general' }, { slug: 'mature-talk' }]) // Topics from both categories selectChain.limit.mockResolvedValueOnce([ - sampleTopicRow({ category: "general" }), + sampleTopicRow({ category: 'general' }), sampleTopicRow({ - category: "mature-talk", + category: 'mature-talk', uri: `at://${TEST_DID}/forum.barazo.topic.post/def456`, - rkey: "def456", + rkey: 'def456', }), - ]); + ]) const response = await app.inject({ - method: "GET", - url: "/api/topics", - }); + method: 'GET', + url: '/api/topics', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: Array<{ category: string }> }>(); - expect(body.topics).toHaveLength(2); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: Array<{ category: string }> }>() + expect(body.topics).toHaveLength(2) + }) - it("returns empty when no categories match allowed maturity", async () => { + it('returns empty when no categories match allowed maturity', async () => { // User profile: age not declared - selectChain.where.mockResolvedValueOnce([ - { declaredAge: null, maturityPref: "safe" }, - ]); + selectChain.where.mockResolvedValueOnce([{ declaredAge: null, maturityPref: 'safe' }]) // Community settings: ageThreshold - selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]); + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) // Categories: no safe categories exist - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/topics", - }); + method: 'GET', + url: '/api/topics', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: unknown[]; cursor: string | null }>(); - expect(body.topics).toEqual([]); - expect(body.cursor).toBeNull(); - }); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: unknown[]; cursor: string | null }>() + expect(body.topics).toEqual([]) + expect(body.cursor).toBeNull() + }) + }) // ========================================================================= // GET /api/topics/:topicUri/replies - maturity check // ========================================================================= - describe("GET /api/topics/:topicUri/replies maturity check", () => { - let app: FastifyInstance; + describe('GET /api/topics/:topicUri/replies maturity check', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("allows replies when topic category is within user maturity level", async () => { - const topicUri = `at://${TEST_DID}/forum.barazo.topic.post/abc123`; + it('allows replies when topic category is within user maturity level', async () => { + const topicUri = `at://${TEST_DID}/forum.barazo.topic.post/abc123` // Topic lookup - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) // Category maturity lookup: safe - selectChain.where.mockResolvedValueOnce([{ maturityRating: "safe" }]); + selectChain.where.mockResolvedValueOnce([{ maturityRating: 'safe' }]) // User profile: safe maturity - selectChain.where.mockResolvedValueOnce([ - { declaredAge: null, maturityPref: "safe" }, - ]); + selectChain.where.mockResolvedValueOnce([{ declaredAge: null, maturityPref: 'safe' }]) // Community settings: ageThreshold - selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]); + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) // Replies query - selectChain.limit.mockResolvedValueOnce([sampleReplyRow()]); + selectChain.limit.mockResolvedValueOnce([sampleReplyRow()]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodeURIComponent(topicUri)}/replies`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ replies: unknown[] }>(); - expect(body.replies).toHaveLength(1); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ replies: unknown[] }>() + expect(body.replies).toHaveLength(1) + }) - it("returns 403 when topic category exceeds user maturity level", async () => { - const topicUri = `at://${TEST_DID}/forum.barazo.topic.post/abc123`; + it('returns 403 when topic category exceeds user maturity level', async () => { + const topicUri = `at://${TEST_DID}/forum.barazo.topic.post/abc123` // Topic lookup - selectChain.where.mockResolvedValueOnce([ - sampleTopicRow({ category: "adult-stuff" }), - ]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow({ category: 'adult-stuff' })]) // Category maturity lookup: adult - selectChain.where.mockResolvedValueOnce([{ maturityRating: "adult" }]); + selectChain.where.mockResolvedValueOnce([{ maturityRating: 'adult' }]) // User profile: safe maturity (no age declared) - selectChain.where.mockResolvedValueOnce([ - { declaredAge: null, maturityPref: "safe" }, - ]); + selectChain.where.mockResolvedValueOnce([{ declaredAge: null, maturityPref: 'safe' }]) // Community settings: ageThreshold - selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]); + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodeURIComponent(topicUri)}/replies`, - }); + }) - expect(response.statusCode).toBe(403); - }); + expect(response.statusCode).toBe(403) + }) - it("allows mature content when user has declared age and maturityPref=mature", async () => { - const topicUri = `at://${TEST_DID}/forum.barazo.topic.post/abc123`; + it('allows mature content when user has declared age and maturityPref=mature', async () => { + const topicUri = `at://${TEST_DID}/forum.barazo.topic.post/abc123` // Topic lookup - selectChain.where.mockResolvedValueOnce([ - sampleTopicRow({ category: "mature-talk" }), - ]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow({ category: 'mature-talk' })]) // Category maturity lookup: mature - selectChain.where.mockResolvedValueOnce([{ maturityRating: "mature" }]); + selectChain.where.mockResolvedValueOnce([{ maturityRating: 'mature' }]) // User profile: age declared, mature pref - selectChain.where.mockResolvedValueOnce([ - { declaredAge: 18, maturityPref: "mature" }, - ]); + selectChain.where.mockResolvedValueOnce([{ declaredAge: 18, maturityPref: 'mature' }]) // Community settings: ageThreshold - selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]); + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) // Replies query - selectChain.limit.mockResolvedValueOnce([sampleReplyRow()]); + selectChain.limit.mockResolvedValueOnce([sampleReplyRow()]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodeURIComponent(topicUri)}/replies`, - }); + }) - expect(response.statusCode).toBe(200); - }); + expect(response.statusCode).toBe(200) + }) - it("returns 403 for unauthenticated user on mature topic", async () => { - const noAuthApp = await buildTestApp(undefined); - const topicUri = `at://${TEST_DID}/forum.barazo.topic.post/abc123`; + it('returns 403 for unauthenticated user on mature topic', async () => { + const noAuthApp = await buildTestApp(undefined) + const topicUri = `at://${TEST_DID}/forum.barazo.topic.post/abc123` // Topic lookup - selectChain.where.mockResolvedValueOnce([ - sampleTopicRow({ category: "mature-talk" }), - ]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow({ category: 'mature-talk' })]) // Category maturity lookup: mature - selectChain.where.mockResolvedValueOnce([{ maturityRating: "mature" }]); + selectChain.where.mockResolvedValueOnce([{ maturityRating: 'mature' }]) // No user profile query (unauthenticated) // Community settings: ageThreshold - selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]); + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) const response = await noAuthApp.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodeURIComponent(topicUri)}/replies`, - }); + }) - expect(response.statusCode).toBe(403); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(403) + await noAuthApp.close() + }) - it("defaults to safe when category not found", async () => { - const topicUri = `at://${TEST_DID}/forum.barazo.topic.post/abc123`; + it('defaults to safe when category not found', async () => { + const topicUri = `at://${TEST_DID}/forum.barazo.topic.post/abc123` // Topic lookup - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) // Category not found: empty result → defaults to "safe" - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) // User profile - selectChain.where.mockResolvedValueOnce([ - { declaredAge: null, maturityPref: "safe" }, - ]); + selectChain.where.mockResolvedValueOnce([{ declaredAge: null, maturityPref: 'safe' }]) // Community settings: ageThreshold - selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]); + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) // Replies query - selectChain.limit.mockResolvedValueOnce([]); + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodeURIComponent(topicUri)}/replies`, - }); + }) // safe <= safe → allowed - expect(response.statusCode).toBe(200); - }); - }); -}); + expect(response.statusCode).toBe(200) + }) + }) +}) diff --git a/tests/unit/routes/moderation-appeals.test.ts b/tests/unit/routes/moderation-appeals.test.ts index a79eed4..39a3ec2 100644 --- a/tests/unit/routes/moderation-appeals.test.ts +++ b/tests/unit/routes/moderation-appeals.test.ts @@ -1,49 +1,50 @@ -import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { type DbChain, createChainableProxy, createMockDb } from "../../helpers/mock-db.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' // --------------------------------------------------------------------------- // Mock requireModerator module (must be before importing routes) // --------------------------------------------------------------------------- -const mockRequireModerator = vi.fn<(request: FastifyRequest, reply: FastifyReply) => Promise>(); +const mockRequireModerator = + vi.fn<(request: FastifyRequest, reply: FastifyReply) => Promise>() -vi.mock("../../../src/auth/require-moderator.js", () => ({ +vi.mock('../../../src/auth/require-moderator.js', () => ({ createRequireModerator: () => mockRequireModerator, -})); +})) // Import routes AFTER mocking -import { moderationRoutes } from "../../../src/routes/moderation.js"; +import { moderationRoutes } from '../../../src/routes/moderation.js' // --------------------------------------------------------------------------- // Mock env (minimal subset for moderation routes) // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_DID: 'did:plc:community123', RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); -const ADMIN_DID = "did:plc:admin999"; -const OTHER_DID = "did:plc:otheruser456"; -const COMMUNITY_DID = "did:plc:community123"; +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) +const ADMIN_DID = 'did:plc:admin999' +const OTHER_DID = 'did:plc:otheruser456' +const COMMUNITY_DID = 'did:plc:community123' -const TEST_TOPIC_URI = `at://${OTHER_DID}/forum.barazo.topic.post/topic123`; -const TEST_NOW = "2026-02-13T12:00:00.000Z"; +const TEST_TOPIC_URI = `at://${OTHER_DID}/forum.barazo.topic.post/topic123` +const TEST_NOW = '2026-02-13T12:00:00.000Z' // --------------------------------------------------------------------------- // Mock user builders @@ -55,33 +56,33 @@ function testUser(overrides?: Partial): RequestUser { handle: TEST_HANDLE, sid: TEST_SID, ...overrides, - }; + } } // --------------------------------------------------------------------------- // Chainable mock DB (shared helper) // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() -let insertChain: DbChain; -let selectChain: DbChain; -let updateChain: DbChain; -let deleteChain: DbChain; +let insertChain: DbChain +let selectChain: DbChain +let updateChain: DbChain +let deleteChain: DbChain function resetAllDbMocks(): void { - insertChain = createChainableProxy(); - selectChain = createChainableProxy([]); - updateChain = createChainableProxy([]); - deleteChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); - mockDb.select.mockReturnValue(selectChain); - mockDb.update.mockReturnValue(updateChain); - mockDb.delete.mockReturnValue(deleteChain); + insertChain = createChainableProxy() + selectChain = createChainableProxy([]) + updateChain = createChainableProxy([]) + deleteChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(updateChain) + mockDb.delete.mockReturnValue(deleteChain) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { - return await fn(mockDb); - }); + return await fn(mockDb) + }) // Add groupBy support for reported users endpoint // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle query chain @@ -94,9 +95,9 @@ function resetAllDbMocks(): void { limit: selectChain.limit, returning: selectChain.returning, groupBy: vi.fn().mockImplementation(() => chainResult), - }; - return chainResult; - }); + } + return chainResult + }) } // --------------------------------------------------------------------------- @@ -107,18 +108,18 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } // --------------------------------------------------------------------------- @@ -126,17 +127,20 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { // --------------------------------------------------------------------------- function createMockRequireAdmin(user?: RequestUser) { - return async (request: { user?: RequestUser }, reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise } }) => { + return async ( + request: { user?: RequestUser }, + reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise } } + ) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user if (user.did !== ADMIN_DID) { - await reply.status(403).send({ error: "Admin access required" }); - return; + await reply.status(403).send({ error: 'Admin access required' }) + return } - }; + } } // --------------------------------------------------------------------------- @@ -149,19 +153,19 @@ function sampleReport(overrides?: Record) { reporterDid: TEST_DID, targetUri: TEST_TOPIC_URI, targetDid: OTHER_DID, - reasonType: "spam", + reasonType: 'spam', description: null, communityDid: COMMUNITY_DID, - status: "pending", + status: 'pending', resolutionType: null, resolvedBy: null, resolvedAt: null, appealReason: null, appealedAt: null, - appealStatus: "none", + appealStatus: 'none', createdAt: new Date(TEST_NOW), ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -169,348 +173,345 @@ function sampleReport(overrides?: Record) { // --------------------------------------------------------------------------- async function buildTestApp(user?: RequestUser): Promise { - const app = Fastify({ logger: false }); - - const authMiddleware = createMockAuthMiddleware(user); - const requireAdmin = createMockRequireAdmin(undefined); - - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", authMiddleware); - app.decorate("requireAdmin", requireAdmin as never); - app.decorate("firehose", {} as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); - - await app.register(moderationRoutes()); - await app.ready(); - - return app; + const app = Fastify({ logger: false }) + + const authMiddleware = createMockAuthMiddleware(user) + const requireAdmin = createMockRequireAdmin(undefined) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', authMiddleware) + app.decorate('requireAdmin', requireAdmin as never) + app.decorate('firehose', {} as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + + await app.register(moderationRoutes()) + await app.ready() + + return app } // =========================================================================== // Test suite // =========================================================================== -describe("moderation appeal routes", () => { +describe('moderation appeal routes', () => { // ========================================================================= // GET /api/moderation/my-reports // ========================================================================= - describe("GET /api/moderation/my-reports", () => { - let app: FastifyInstance; + describe('GET /api/moderation/my-reports', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) it("returns the caller's reports (paginated)", async () => { - const reportRows = [ - sampleReport({ id: 2 }), - sampleReport({ id: 1 }), - ]; - selectChain.limit.mockResolvedValueOnce(reportRows); + const reportRows = [sampleReport({ id: 2 }), sampleReport({ id: 1 })] + selectChain.limit.mockResolvedValueOnce(reportRows) const response = await app.inject({ - method: "GET", - url: "/api/moderation/my-reports", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/moderation/my-reports', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ reports: Array<{ - id: number; - reporterDid: string; - appealStatus: string; - appealReason: string | null; - appealedAt: string | null; - createdAt: string; - }>; - cursor: string | null; - }>(); - expect(body.reports).toHaveLength(2); - expect(body.reports[0]?.id).toBe(2); - expect(body.reports[0]?.reporterDid).toBe(TEST_DID); - expect(body.reports[0]?.appealStatus).toBe("none"); - expect(body.reports[0]?.appealReason).toBeNull(); - expect(body.reports[0]?.appealedAt).toBeNull(); - expect(body.cursor).toBeNull(); - }); - - it("returns empty list when no reports", async () => { - selectChain.limit.mockResolvedValueOnce([]); + id: number + reporterDid: string + appealStatus: string + appealReason: string | null + appealedAt: string | null + createdAt: string + }> + cursor: string | null + }>() + expect(body.reports).toHaveLength(2) + expect(body.reports[0]?.id).toBe(2) + expect(body.reports[0]?.reporterDid).toBe(TEST_DID) + expect(body.reports[0]?.appealStatus).toBe('none') + expect(body.reports[0]?.appealReason).toBeNull() + expect(body.reports[0]?.appealedAt).toBeNull() + expect(body.cursor).toBeNull() + }) + + it('returns empty list when no reports', async () => { + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/moderation/my-reports", - headers: { authorization: "Bearer test-token" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ reports: unknown[]; cursor: string | null }>(); - expect(body.reports).toEqual([]); - expect(body.cursor).toBeNull(); - }); - - it("returns cursor when more results exist", async () => { - const baseDate = new Date("2026-02-13T12:00:00.000Z"); + method: 'GET', + url: '/api/moderation/my-reports', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ reports: unknown[]; cursor: string | null }>() + expect(body.reports).toEqual([]) + expect(body.cursor).toBeNull() + }) + + it('returns cursor when more results exist', async () => { + const baseDate = new Date('2026-02-13T12:00:00.000Z') const reportRows = Array.from({ length: 26 }, (_, i) => { - const d = new Date(baseDate.getTime() - i * 3600000); - return sampleReport({ id: 26 - i, createdAt: d }); - }); - selectChain.limit.mockResolvedValueOnce(reportRows); + const d = new Date(baseDate.getTime() - i * 3600000) + return sampleReport({ id: 26 - i, createdAt: d }) + }) + selectChain.limit.mockResolvedValueOnce(reportRows) const response = await app.inject({ - method: "GET", - url: "/api/moderation/my-reports", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/moderation/my-reports', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ reports: unknown[]; cursor: string | null }>(); - expect(body.reports).toHaveLength(25); - expect(body.cursor).toBeTruthy(); - }); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ reports: unknown[]; cursor: string | null }>() + expect(body.reports).toHaveLength(25) + expect(body.cursor).toBeTruthy() + }) + }) - describe("GET /api/moderation/my-reports (unauthenticated)", () => { - let app: FastifyInstance; + describe('GET /api/moderation/my-reports (unauthenticated)', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(undefined); - }); + app = await buildTestApp(undefined) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("returns 401 without auth", async () => { + it('returns 401 without auth', async () => { const response = await app.inject({ - method: "GET", - url: "/api/moderation/my-reports", - }); + method: 'GET', + url: '/api/moderation/my-reports', + }) - expect(response.statusCode).toBe(401); - }); - }); + expect(response.statusCode).toBe(401) + }) + }) // ========================================================================= // POST /api/moderation/reports/:id/appeal // ========================================================================= - describe("POST /api/moderation/reports/:id/appeal", () => { - let app: FastifyInstance; + describe('POST /api/moderation/reports/:id/appeal', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("successfully appeals a dismissed, resolved report", async () => { + it('successfully appeals a dismissed, resolved report', async () => { // Report found: resolved + dismissed + appealStatus none + reporter is current user selectChain.where.mockResolvedValueOnce([ sampleReport({ - status: "resolved", - resolutionType: "dismissed", - resolvedBy: "did:plc:mod1", + status: 'resolved', + resolutionType: 'dismissed', + resolvedBy: 'did:plc:mod1', resolvedAt: new Date(TEST_NOW), - appealStatus: "none", + appealStatus: 'none', }), - ]); + ]) // Update returning const appealedReport = sampleReport({ - status: "pending", - resolutionType: "dismissed", - resolvedBy: "did:plc:mod1", + status: 'pending', + resolutionType: 'dismissed', + resolvedBy: 'did:plc:mod1', resolvedAt: new Date(TEST_NOW), - appealReason: "I disagree with the dismissal", + appealReason: 'I disagree with the dismissal', appealedAt: new Date(), - appealStatus: "pending", - }); - updateChain.returning.mockResolvedValueOnce([appealedReport]); + appealStatus: 'pending', + }) + updateChain.returning.mockResolvedValueOnce([appealedReport]) const response = await app.inject({ - method: "POST", - url: "/api/moderation/reports/1/appeal", - headers: { authorization: "Bearer test-token" }, - payload: { reason: "I disagree with the dismissal" }, - }); + method: 'POST', + url: '/api/moderation/reports/1/appeal', + headers: { authorization: 'Bearer test-token' }, + payload: { reason: 'I disagree with the dismissal' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - id: number; - status: string; - appealReason: string; - appealStatus: string; - }>(); - expect(body.id).toBe(1); - expect(body.status).toBe("pending"); - expect(body.appealReason).toBe("I disagree with the dismissal"); - expect(body.appealStatus).toBe("pending"); - - expect(mockDb.update).toHaveBeenCalled(); - }); - - it("returns 404 if report not found", async () => { - selectChain.where.mockResolvedValueOnce([]); + id: number + status: string + appealReason: string + appealStatus: string + }>() + expect(body.id).toBe(1) + expect(body.status).toBe('pending') + expect(body.appealReason).toBe('I disagree with the dismissal') + expect(body.appealStatus).toBe('pending') + + expect(mockDb.update).toHaveBeenCalled() + }) + + it('returns 404 if report not found', async () => { + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "POST", - url: "/api/moderation/reports/999/appeal", - headers: { authorization: "Bearer test-token" }, - payload: { reason: "Please reconsider" }, - }); + method: 'POST', + url: '/api/moderation/reports/999/appeal', + headers: { authorization: 'Bearer test-token' }, + payload: { reason: 'Please reconsider' }, + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 403 if user is not the original reporter", async () => { + it('returns 403 if user is not the original reporter', async () => { selectChain.where.mockResolvedValueOnce([ sampleReport({ reporterDid: OTHER_DID, // different from TEST_DID - status: "resolved", - resolutionType: "dismissed", - appealStatus: "none", + status: 'resolved', + resolutionType: 'dismissed', + appealStatus: 'none', }), - ]); + ]) const response = await app.inject({ - method: "POST", - url: "/api/moderation/reports/1/appeal", - headers: { authorization: "Bearer test-token" }, - payload: { reason: "I want to appeal" }, - }); + method: 'POST', + url: '/api/moderation/reports/1/appeal', + headers: { authorization: 'Bearer test-token' }, + payload: { reason: 'I want to appeal' }, + }) - expect(response.statusCode).toBe(403); - }); + expect(response.statusCode).toBe(403) + }) - it("returns 400 if report is not resolved", async () => { + it('returns 400 if report is not resolved', async () => { selectChain.where.mockResolvedValueOnce([ sampleReport({ - status: "pending", + status: 'pending', resolutionType: null, - appealStatus: "none", + appealStatus: 'none', }), - ]); + ]) const response = await app.inject({ - method: "POST", - url: "/api/moderation/reports/1/appeal", - headers: { authorization: "Bearer test-token" }, - payload: { reason: "Please reconsider" }, - }); + method: 'POST', + url: '/api/moderation/reports/1/appeal', + headers: { authorization: 'Bearer test-token' }, + payload: { reason: 'Please reconsider' }, + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 if report is not dismissed", async () => { + it('returns 400 if report is not dismissed', async () => { selectChain.where.mockResolvedValueOnce([ sampleReport({ - status: "resolved", - resolutionType: "warned", - resolvedBy: "did:plc:mod1", + status: 'resolved', + resolutionType: 'warned', + resolvedBy: 'did:plc:mod1', resolvedAt: new Date(TEST_NOW), - appealStatus: "none", + appealStatus: 'none', }), - ]); + ]) const response = await app.inject({ - method: "POST", - url: "/api/moderation/reports/1/appeal", - headers: { authorization: "Bearer test-token" }, - payload: { reason: "I disagree" }, - }); + method: 'POST', + url: '/api/moderation/reports/1/appeal', + headers: { authorization: 'Bearer test-token' }, + payload: { reason: 'I disagree' }, + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 409 if already appealed", async () => { + it('returns 409 if already appealed', async () => { selectChain.where.mockResolvedValueOnce([ sampleReport({ - status: "resolved", - resolutionType: "dismissed", - resolvedBy: "did:plc:mod1", + status: 'resolved', + resolutionType: 'dismissed', + resolvedBy: 'did:plc:mod1', resolvedAt: new Date(TEST_NOW), - appealStatus: "pending", - appealReason: "First appeal", + appealStatus: 'pending', + appealReason: 'First appeal', appealedAt: new Date(TEST_NOW), }), - ]); + ]) const response = await app.inject({ - method: "POST", - url: "/api/moderation/reports/1/appeal", - headers: { authorization: "Bearer test-token" }, - payload: { reason: "Second appeal attempt" }, - }); + method: 'POST', + url: '/api/moderation/reports/1/appeal', + headers: { authorization: 'Bearer test-token' }, + payload: { reason: 'Second appeal attempt' }, + }) - expect(response.statusCode).toBe(409); - }); + expect(response.statusCode).toBe(409) + }) - it("returns 400 for invalid/empty reason", async () => { + it('returns 400 for invalid/empty reason', async () => { const response = await app.inject({ - method: "POST", - url: "/api/moderation/reports/1/appeal", - headers: { authorization: "Bearer test-token" }, - payload: { reason: "" }, - }); + method: 'POST', + url: '/api/moderation/reports/1/appeal', + headers: { authorization: 'Bearer test-token' }, + payload: { reason: '' }, + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for missing reason", async () => { + it('returns 400 for missing reason', async () => { const response = await app.inject({ - method: "POST", - url: "/api/moderation/reports/1/appeal", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/moderation/reports/1/appeal', + headers: { authorization: 'Bearer test-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(400); - }); - }); + expect(response.statusCode).toBe(400) + }) + }) - describe("POST /api/moderation/reports/:id/appeal (unauthenticated)", () => { - let app: FastifyInstance; + describe('POST /api/moderation/reports/:id/appeal (unauthenticated)', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(undefined); - }); + app = await buildTestApp(undefined) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("returns 401 without auth", async () => { + it('returns 401 without auth', async () => { const response = await app.inject({ - method: "POST", - url: "/api/moderation/reports/1/appeal", - payload: { reason: "Please reconsider" }, - }); - - expect(response.statusCode).toBe(401); - }); - }); -}); + method: 'POST', + url: '/api/moderation/reports/1/appeal', + payload: { reason: 'Please reconsider' }, + }) + + expect(response.statusCode).toBe(401) + }) + }) +}) diff --git a/tests/unit/routes/moderation-queue.test.ts b/tests/unit/routes/moderation-queue.test.ts index 0f58369..e50aa47 100644 --- a/tests/unit/routes/moderation-queue.test.ts +++ b/tests/unit/routes/moderation-queue.test.ts @@ -1,43 +1,43 @@ -import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { type DbChain, createChainableProxy, createMockDb } from "../../helpers/mock-db.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' // --------------------------------------------------------------------------- // Mock require-moderator // --------------------------------------------------------------------------- -const mockRequireModerator = vi.fn(); +const mockRequireModerator = vi.fn() -vi.mock("../../../src/auth/require-moderator.js", () => ({ +vi.mock('../../../src/auth/require-moderator.js', () => ({ createRequireModerator: () => mockRequireModerator, -})); +})) // Import routes AFTER mocking -import { moderationQueueRoutes } from "../../../src/routes/moderation-queue.js"; +import { moderationQueueRoutes } from '../../../src/routes/moderation-queue.js' // --------------------------------------------------------------------------- // Mock env // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", -} as Env; + COMMUNITY_DID: 'did:plc:community123', +} as Env // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const MOD_DID = "did:plc:moderator999"; -const AUTHOR_DID = "did:plc:author123"; -const CONTENT_URI = "at://did:plc:author123/forum.barazo.topic.post/abc123"; +const MOD_DID = 'did:plc:moderator999' +const AUTHOR_DID = 'did:plc:author123' +const CONTENT_URI = 'at://did:plc:author123/forum.barazo.topic.post/abc123' function modUser(): RequestUser { - return { did: MOD_DID, handle: "mod.bsky.social", sid: "a".repeat(64) }; + return { did: MOD_DID, handle: 'mod.bsky.social', sid: 'a'.repeat(64) } } // --------------------------------------------------------------------------- @@ -45,54 +45,54 @@ function modUser(): RequestUser { // --------------------------------------------------------------------------- interface QueueItem { - id: number; - contentUri: string; - contentType: string; - authorDid: string; - communityDid: string; - queueReason: string; - matchedWords: string[] | null; - status: string; - reviewedBy: string | null; - createdAt: string; - reviewedAt: string | null; + id: number + contentUri: string + contentType: string + authorDid: string + communityDid: string + queueReason: string + matchedWords: string[] | null + status: string + reviewedBy: string | null + createdAt: string + reviewedAt: string | null } interface QueueListResponse { - items: QueueItem[]; - cursor: string | null; + items: QueueItem[] + cursor: string | null } interface WordFilterResponse { - words: string[]; + words: string[] } // --------------------------------------------------------------------------- // Mock DB and cache // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() const mockCache = { get: vi.fn().mockResolvedValue(null), - set: vi.fn().mockResolvedValue("OK"), + set: vi.fn().mockResolvedValue('OK'), del: vi.fn().mockResolvedValue(1), -}; +} -let insertChain: DbChain; -let selectChain: DbChain; -let updateChain: DbChain; +let insertChain: DbChain +let selectChain: DbChain +let updateChain: DbChain function resetAllDbMocks(): void { - insertChain = createChainableProxy(); - selectChain = createChainableProxy([]); - updateChain = createChainableProxy([]); - mockDb.insert.mockReturnValue(insertChain); - mockDb.select.mockReturnValue(selectChain); - mockDb.update.mockReturnValue(updateChain); + insertChain = createChainableProxy() + selectChain = createChainableProxy([]) + updateChain = createChainableProxy([]) + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(updateChain) // eslint-disable-next-line @typescript-eslint/no-misused-promises mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { - await fn(mockDb); - }); + await fn(mockDb) + }) } // --------------------------------------------------------------------------- @@ -103,160 +103,160 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } async function buildTestApp(user?: RequestUser): Promise { - const app = Fastify({ logger: false }); + const app = Fastify({ logger: false }) - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", createMockAuthMiddleware(user)); - app.decorate("cache", mockCache as never); - app.decorate("requireAdmin", mockRequireModerator); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorateRequest("user", undefined as RequestUser | undefined); + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', createMockAuthMiddleware(user)) + app.decorate('cache', mockCache as never) + app.decorate('requireAdmin', mockRequireModerator) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorateRequest('user', undefined as RequestUser | undefined) mockRequireModerator.mockImplementation((request: { user: RequestUser | undefined }) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); - }); + return Promise.resolve() + }) - await app.register(moderationQueueRoutes()); - await app.ready(); + await app.register(moderationQueueRoutes()) + await app.ready() - return app; + return app } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describe("moderation queue routes", () => { - let app: FastifyInstance; +describe('moderation queue routes', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(modUser()); - }); + app = await buildTestApp(modUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - resetAllDbMocks(); - vi.clearAllMocks(); + resetAllDbMocks() + vi.clearAllMocks() mockRequireModerator.mockImplementation((request: { user: RequestUser | undefined }) => { - request.user = modUser(); - return Promise.resolve(); - }); - }); + request.user = modUser() + return Promise.resolve() + }) + }) - describe("GET /api/moderation/queue", () => { - it("returns empty queue when no pending items", async () => { - selectChain = createChainableProxy([]); - mockDb.select.mockReturnValue(selectChain); + describe('GET /api/moderation/queue', () => { + it('returns empty queue when no pending items', async () => { + selectChain = createChainableProxy([]) + mockDb.select.mockReturnValue(selectChain) const response = await app.inject({ - method: "GET", - url: "/api/moderation/queue", - }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.items).toEqual([]); - expect(body.cursor).toBeNull(); - }); - - it("returns queue items with cursor pagination", async () => { - const now = new Date(); + method: 'GET', + url: '/api/moderation/queue', + }) + + expect(response.statusCode).toBe(200) + const body = response.json() + expect(body.items).toEqual([]) + expect(body.cursor).toBeNull() + }) + + it('returns queue items with cursor pagination', async () => { + const now = new Date() const items = [ { id: 2, contentUri: CONTENT_URI, - contentType: "topic", + contentType: 'topic', authorDid: AUTHOR_DID, - communityDid: "did:plc:community123", - queueReason: "word_filter", - matchedWords: ["spam"], - status: "pending", + communityDid: 'did:plc:community123', + queueReason: 'word_filter', + matchedWords: ['spam'], + status: 'pending', reviewedBy: null, createdAt: now, reviewedAt: null, }, { id: 1, - contentUri: "at://did:plc:author123/forum.barazo.topic.post/def456", - contentType: "reply", + contentUri: 'at://did:plc:author123/forum.barazo.topic.post/def456', + contentType: 'reply', authorDid: AUTHOR_DID, - communityDid: "did:plc:community123", - queueReason: "first_post", + communityDid: 'did:plc:community123', + queueReason: 'first_post', matchedWords: null, - status: "pending", + status: 'pending', reviewedBy: null, createdAt: new Date(now.getTime() - 1000), reviewedAt: null, }, - ]; + ] - selectChain = createChainableProxy(items); - mockDb.select.mockReturnValue(selectChain); + selectChain = createChainableProxy(items) + mockDb.select.mockReturnValue(selectChain) const response = await app.inject({ - method: "GET", - url: "/api/moderation/queue?status=pending", - }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.items).toHaveLength(2); - expect(body.items[0].queueReason).toBe("word_filter"); - expect(body.items[0].matchedWords).toEqual(["spam"]); - }); - }); - - describe("PUT /api/moderation/queue/:id", () => { - it("approves a queued item", async () => { - const now = new Date(); + method: 'GET', + url: '/api/moderation/queue?status=pending', + }) + + expect(response.statusCode).toBe(200) + const body = response.json() + expect(body.items).toHaveLength(2) + expect(body.items[0].queueReason).toBe('word_filter') + expect(body.items[0].matchedWords).toEqual(['spam']) + }) + }) + + describe('PUT /api/moderation/queue/:id', () => { + it('approves a queued item', async () => { + const now = new Date() const queueItem = { id: 1, contentUri: CONTENT_URI, - contentType: "topic", + contentType: 'topic', authorDid: AUTHOR_DID, - communityDid: "did:plc:community123", - queueReason: "word_filter", - matchedWords: ["spam"], - status: "pending", + communityDid: 'did:plc:community123', + queueReason: 'word_filter', + matchedWords: ['spam'], + status: 'pending', reviewedBy: null, createdAt: now, reviewedAt: null, - }; + } // First select: fetch queue item - const fetchChain = createChainableProxy([queueItem]); - mockDb.select.mockReturnValueOnce(fetchChain); + const fetchChain = createChainableProxy([queueItem]) + mockDb.select.mockReturnValueOnce(fetchChain) // Inside transaction: // other pending items for same URI - const otherPendingChain = createChainableProxy([]); - mockDb.select.mockReturnValueOnce(otherPendingChain); + const otherPendingChain = createChainableProxy([]) + mockDb.select.mockReturnValueOnce(otherPendingChain) // existing trust record - const trustChain = createChainableProxy([]); - mockDb.select.mockReturnValueOnce(trustChain); + const trustChain = createChainableProxy([]) + mockDb.select.mockReturnValueOnce(trustChain) // community settings for threshold const settingsChain = createChainableProxy([ { @@ -264,120 +264,120 @@ describe("moderation queue routes", () => { trustedPostThreshold: 10, }, }, - ]); - mockDb.select.mockReturnValueOnce(settingsChain); + ]) + mockDb.select.mockReturnValueOnce(settingsChain) // Final select: updated queue item - const updatedItem = { ...queueItem, status: "approved", reviewedBy: MOD_DID, reviewedAt: now }; - const finalChain = createChainableProxy([updatedItem]); - mockDb.select.mockReturnValueOnce(finalChain); + const updatedItem = { ...queueItem, status: 'approved', reviewedBy: MOD_DID, reviewedAt: now } + const finalChain = createChainableProxy([updatedItem]) + mockDb.select.mockReturnValueOnce(finalChain) const response = await app.inject({ - method: "PUT", - url: "/api/moderation/queue/1", - payload: { action: "approve" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.status).toBe("approved"); - expect(body.reviewedBy).toBe(MOD_DID); - }); - - it("rejects already-reviewed items with 409", async () => { + method: 'PUT', + url: '/api/moderation/queue/1', + payload: { action: 'approve' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json() + expect(body.status).toBe('approved') + expect(body.reviewedBy).toBe(MOD_DID) + }) + + it('rejects already-reviewed items with 409', async () => { const queueItem = { id: 1, contentUri: CONTENT_URI, - contentType: "topic", + contentType: 'topic', authorDid: AUTHOR_DID, - communityDid: "did:plc:community123", - queueReason: "word_filter", + communityDid: 'did:plc:community123', + queueReason: 'word_filter', matchedWords: null, - status: "approved", + status: 'approved', reviewedBy: MOD_DID, createdAt: new Date(), reviewedAt: new Date(), - }; + } - const fetchChain = createChainableProxy([queueItem]); - mockDb.select.mockReturnValue(fetchChain); + const fetchChain = createChainableProxy([queueItem]) + mockDb.select.mockReturnValue(fetchChain) const response = await app.inject({ - method: "PUT", - url: "/api/moderation/queue/1", - payload: { action: "approve" }, - }); + method: 'PUT', + url: '/api/moderation/queue/1', + payload: { action: 'approve' }, + }) - expect(response.statusCode).toBe(409); - }); + expect(response.statusCode).toBe(409) + }) - it("returns 404 for non-existent queue item", async () => { - const fetchChain = createChainableProxy([]); - mockDb.select.mockReturnValue(fetchChain); + it('returns 404 for non-existent queue item', async () => { + const fetchChain = createChainableProxy([]) + mockDb.select.mockReturnValue(fetchChain) const response = await app.inject({ - method: "PUT", - url: "/api/moderation/queue/999", - payload: { action: "approve" }, - }); + method: 'PUT', + url: '/api/moderation/queue/999', + payload: { action: 'approve' }, + }) - expect(response.statusCode).toBe(404); - }); - }); + expect(response.statusCode).toBe(404) + }) + }) - describe("GET /api/admin/moderation/word-filter", () => { - it("returns current word filter list", async () => { - const chain = createChainableProxy([{ wordFilter: ["spam", "scam"] }]); - mockDb.select.mockReturnValue(chain); + describe('GET /api/admin/moderation/word-filter', () => { + it('returns current word filter list', async () => { + const chain = createChainableProxy([{ wordFilter: ['spam', 'scam'] }]) + mockDb.select.mockReturnValue(chain) const response = await app.inject({ - method: "GET", - url: "/api/admin/moderation/word-filter", - }); + method: 'GET', + url: '/api/admin/moderation/word-filter', + }) - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.words).toEqual(["spam", "scam"]); - }); + expect(response.statusCode).toBe(200) + const body = response.json() + expect(body.words).toEqual(['spam', 'scam']) + }) - it("returns empty array when no filter set", async () => { - const chain = createChainableProxy([{ wordFilter: [] }]); - mockDb.select.mockReturnValue(chain); + it('returns empty array when no filter set', async () => { + const chain = createChainableProxy([{ wordFilter: [] }]) + mockDb.select.mockReturnValue(chain) const response = await app.inject({ - method: "GET", - url: "/api/admin/moderation/word-filter", - }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.words).toEqual([]); - }); - }); - - describe("PUT /api/admin/moderation/word-filter", () => { - it("updates word filter list", async () => { + method: 'GET', + url: '/api/admin/moderation/word-filter', + }) + + expect(response.statusCode).toBe(200) + const body = response.json() + expect(body.words).toEqual([]) + }) + }) + + describe('PUT /api/admin/moderation/word-filter', () => { + it('updates word filter list', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/moderation/word-filter", - payload: { words: ["Spam", "SCAM", "fraud"] }, - }); + method: 'PUT', + url: '/api/admin/moderation/word-filter', + payload: { words: ['Spam', 'SCAM', 'fraud'] }, + }) - expect(response.statusCode).toBe(200); - const body = response.json(); + expect(response.statusCode).toBe(200) + const body = response.json() // Should be deduplicated and lowercased - expect(body.words).toEqual(["spam", "scam", "fraud"]); - expect(mockDb.update).toHaveBeenCalled(); - }); + expect(body.words).toEqual(['spam', 'scam', 'fraud']) + expect(mockDb.update).toHaveBeenCalled() + }) - it("rejects invalid payload", async () => { + it('rejects invalid payload', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/moderation/word-filter", - payload: { words: "" }, - }); - - expect(response.statusCode).toBe(400); - }); - }); -}); + method: 'PUT', + url: '/api/admin/moderation/word-filter', + payload: { words: '' }, + }) + + expect(response.statusCode).toBe(400) + }) + }) +}) diff --git a/tests/unit/routes/moderation.test.ts b/tests/unit/routes/moderation.test.ts index c9b816c..fd56575 100644 --- a/tests/unit/routes/moderation.test.ts +++ b/tests/unit/routes/moderation.test.ts @@ -1,50 +1,51 @@ -import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { type DbChain, createChainableProxy, createMockDb } from "../../helpers/mock-db.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' // --------------------------------------------------------------------------- // Mock requireModerator module (must be before importing routes) // --------------------------------------------------------------------------- -const mockRequireModerator = vi.fn<(request: FastifyRequest, reply: FastifyReply) => Promise>(); +const mockRequireModerator = + vi.fn<(request: FastifyRequest, reply: FastifyReply) => Promise>() -vi.mock("../../../src/auth/require-moderator.js", () => ({ +vi.mock('../../../src/auth/require-moderator.js', () => ({ createRequireModerator: () => mockRequireModerator, -})); +})) // Import routes AFTER mocking -import { moderationRoutes } from "../../../src/routes/moderation.js"; +import { moderationRoutes } from '../../../src/routes/moderation.js' // --------------------------------------------------------------------------- // Mock env (minimal subset for moderation routes) // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_DID: 'did:plc:community123', RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); -const ADMIN_DID = "did:plc:admin999"; -const OTHER_DID = "did:plc:otheruser456"; -const COMMUNITY_DID = "did:plc:community123"; +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) +const ADMIN_DID = 'did:plc:admin999' +const OTHER_DID = 'did:plc:otheruser456' +const COMMUNITY_DID = 'did:plc:community123' -const TEST_TOPIC_URI = `at://${OTHER_DID}/forum.barazo.topic.post/topic123`; -const TEST_REPLY_URI = `at://${OTHER_DID}/forum.barazo.topic.reply/reply123`; -const TEST_NOW = "2026-02-13T12:00:00.000Z"; +const TEST_TOPIC_URI = `at://${OTHER_DID}/forum.barazo.topic.post/topic123` +const TEST_REPLY_URI = `at://${OTHER_DID}/forum.barazo.topic.reply/reply123` +const TEST_NOW = '2026-02-13T12:00:00.000Z' // --------------------------------------------------------------------------- // Mock user builders @@ -56,37 +57,37 @@ function testUser(overrides?: Partial): RequestUser { handle: TEST_HANDLE, sid: TEST_SID, ...overrides, - }; + } } function adminUser(): RequestUser { - return testUser({ did: ADMIN_DID, handle: "admin.bsky.social" }); + return testUser({ did: ADMIN_DID, handle: 'admin.bsky.social' }) } // --------------------------------------------------------------------------- // Chainable mock DB (shared helper) // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() -let insertChain: DbChain; -let selectChain: DbChain; -let updateChain: DbChain; -let deleteChain: DbChain; +let insertChain: DbChain +let selectChain: DbChain +let updateChain: DbChain +let deleteChain: DbChain function resetAllDbMocks(): void { - insertChain = createChainableProxy(); - selectChain = createChainableProxy([]); - updateChain = createChainableProxy([]); - deleteChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); - mockDb.select.mockReturnValue(selectChain); - mockDb.update.mockReturnValue(updateChain); - mockDb.delete.mockReturnValue(deleteChain); + insertChain = createChainableProxy() + selectChain = createChainableProxy([]) + updateChain = createChainableProxy([]) + deleteChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(updateChain) + mockDb.delete.mockReturnValue(deleteChain) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { - return await fn(mockDb); - }); + return await fn(mockDb) + }) // Add groupBy support for reported users endpoint // groupBy returns a chainable that ends with orderBy -> limit -> then @@ -100,9 +101,9 @@ function resetAllDbMocks(): void { limit: selectChain.limit, returning: selectChain.returning, groupBy: vi.fn().mockImplementation(() => chainResult), - }; - return chainResult; - }); + } + return chainResult + }) } // --------------------------------------------------------------------------- @@ -113,18 +114,18 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } // --------------------------------------------------------------------------- @@ -132,17 +133,20 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { // --------------------------------------------------------------------------- function createMockRequireAdmin(user?: RequestUser) { - return async (request: { user?: RequestUser }, reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise } }) => { + return async ( + request: { user?: RequestUser }, + reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise } } + ) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user if (user.did !== ADMIN_DID) { - await reply.status(403).send({ error: "Admin access required" }); - return; + await reply.status(403).send({ error: 'Admin access required' }) + return } - }; + } } // --------------------------------------------------------------------------- @@ -152,15 +156,15 @@ function createMockRequireAdmin(user?: RequestUser) { function sampleTopicRow(overrides?: Record) { return { uri: TEST_TOPIC_URI, - rkey: "topic123", + rkey: 'topic123', authorDid: OTHER_DID, - title: "Test Topic", - content: "Test content", + title: 'Test Topic', + content: 'Test content', contentFormat: null, - category: "general", + category: 'general', tags: null, communityDid: COMMUNITY_DID, - cid: "bafyreitopic123", + cid: 'bafyreitopic123', labels: null, replyCount: 0, reactionCount: 0, @@ -172,52 +176,52 @@ function sampleTopicRow(overrides?: Record) { isModDeleted: false, embedding: null, ...overrides, - }; + } } function sampleReplyRow(overrides?: Record) { return { uri: TEST_REPLY_URI, - rkey: "reply123", + rkey: 'reply123', authorDid: OTHER_DID, - content: "Test reply", + content: 'Test reply', contentFormat: null, rootUri: TEST_TOPIC_URI, - rootCid: "bafyreitopic123", + rootCid: 'bafyreitopic123', parentUri: TEST_TOPIC_URI, - parentCid: "bafyreitopic123", + parentCid: 'bafyreitopic123', communityDid: COMMUNITY_DID, - cid: "bafyreireply123", + cid: 'bafyreireply123', labels: null, reactionCount: 0, createdAt: new Date(TEST_NOW), indexedAt: new Date(TEST_NOW), embedding: null, ...overrides, - }; + } } function sampleUserRow(overrides?: Record) { return { did: OTHER_DID, - handle: "bob.bsky.social", - displayName: "Bob", + handle: 'bob.bsky.social', + displayName: 'Bob', avatarUrl: null, - role: "user", + role: 'user', isBanned: false, reputationScore: 0, firstSeenAt: new Date(TEST_NOW), lastActiveAt: new Date(TEST_NOW), declaredAge: null, - maturityPref: "safe", + maturityPref: 'safe', ...overrides, - }; + } } function sampleModerationAction(overrides?: Record) { return { id: 1, - action: "lock", + action: 'lock', targetUri: TEST_TOPIC_URI, targetDid: null, moderatorDid: TEST_DID, @@ -225,7 +229,7 @@ function sampleModerationAction(overrides?: Record) { reason: null, createdAt: new Date(TEST_NOW), ...overrides, - }; + } } function sampleReport(overrides?: Record) { @@ -234,1064 +238,1081 @@ function sampleReport(overrides?: Record) { reporterDid: TEST_DID, targetUri: TEST_TOPIC_URI, targetDid: OTHER_DID, - reasonType: "spam", + reasonType: 'spam', description: null, communityDid: COMMUNITY_DID, - status: "pending", + status: 'pending', resolutionType: null, resolvedBy: null, resolvedAt: null, createdAt: new Date(TEST_NOW), ...overrides, - }; + } } // --------------------------------------------------------------------------- // Helper: build app with mocked deps // --------------------------------------------------------------------------- -async function buildTestApp(user?: RequestUser, adminUserObj?: RequestUser): Promise { - const app = Fastify({ logger: false }); - - const authMiddleware = createMockAuthMiddleware(user); - const requireAdmin = createMockRequireAdmin(adminUserObj); - - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", authMiddleware); - app.decorate("requireAdmin", requireAdmin as never); - app.decorate("firehose", {} as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); - - await app.register(moderationRoutes()); - await app.ready(); - - return app; +async function buildTestApp( + user?: RequestUser, + adminUserObj?: RequestUser +): Promise { + const app = Fastify({ logger: false }) + + const authMiddleware = createMockAuthMiddleware(user) + const requireAdmin = createMockRequireAdmin(adminUserObj) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', authMiddleware) + app.decorate('requireAdmin', requireAdmin as never) + app.decorate('firehose', {} as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + + await app.register(moderationRoutes()) + await app.ready() + + return app } // =========================================================================== // Test suite // =========================================================================== -describe("moderation routes", () => { +describe('moderation routes', () => { // ========================================================================= // POST /api/moderation/lock/:id // ========================================================================= - describe("POST /api/moderation/lock/:id", () => { - let app: FastifyInstance; + describe('POST /api/moderation/lock/:id', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); + vi.clearAllMocks() + resetAllDbMocks() // Default: requireModerator passes and sets user mockRequireModerator.mockImplementation((request) => { - request.user = testUser(); - return Promise.resolve(); - }); - }); + request.user = testUser() + return Promise.resolve() + }) + }) - it("locks an unlocked topic and returns isLocked: true", async () => { + it('locks an unlocked topic and returns isLocked: true', async () => { // Topic lookup -> unlocked topic found - selectChain.where.mockResolvedValueOnce([sampleTopicRow({ isLocked: false })]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow({ isLocked: false })]) - const encodedUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/moderation/lock/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - payload: { reason: "Duplicate discussion" }, - }); + headers: { authorization: 'Bearer test-token' }, + payload: { reason: 'Duplicate discussion' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ uri: string; isLocked: boolean }>(); - expect(body.uri).toBe(TEST_TOPIC_URI); - expect(body.isLocked).toBe(true); + expect(response.statusCode).toBe(200) + const body = response.json<{ uri: string; isLocked: boolean }>() + expect(body.uri).toBe(TEST_TOPIC_URI) + expect(body.isLocked).toBe(true) // Should have used transaction for update + log - expect(mockDb.transaction).toHaveBeenCalledOnce(); - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.insert).toHaveBeenCalled(); - }); + expect(mockDb.transaction).toHaveBeenCalledOnce() + expect(mockDb.update).toHaveBeenCalled() + expect(mockDb.insert).toHaveBeenCalled() + }) - it("unlocks a locked topic and returns isLocked: false", async () => { - selectChain.where.mockResolvedValueOnce([sampleTopicRow({ isLocked: true })]); + it('unlocks a locked topic and returns isLocked: false', async () => { + selectChain.where.mockResolvedValueOnce([sampleTopicRow({ isLocked: true })]) - const encodedUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/moderation/lock/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ uri: string; isLocked: boolean }>(); - expect(body.uri).toBe(TEST_TOPIC_URI); - expect(body.isLocked).toBe(false); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ uri: string; isLocked: boolean }>() + expect(body.uri).toBe(TEST_TOPIC_URI) + expect(body.isLocked).toBe(false) + }) - it("returns 404 for non-existent topic", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 for non-existent topic', async () => { + selectChain.where.mockResolvedValueOnce([]) - const encodedUri = encodeURIComponent("at://did:plc:nobody/forum.barazo.topic.post/ghost"); + const encodedUri = encodeURIComponent('at://did:plc:nobody/forum.barazo.topic.post/ghost') const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/moderation/lock/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 403 for non-moderators", async () => { + it('returns 403 for non-moderators', async () => { mockRequireModerator.mockImplementation(async (_request, reply) => { - await reply.status(403).send({ error: "Moderator access required" }); - }); + await reply.status(403).send({ error: 'Moderator access required' }) + }) - const encodedUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/moderation/lock/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(403); - }); - }); + expect(response.statusCode).toBe(403) + }) + }) // ========================================================================= // POST /api/moderation/pin/:id // ========================================================================= - describe("POST /api/moderation/pin/:id", () => { - let app: FastifyInstance; + describe('POST /api/moderation/pin/:id', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); + vi.clearAllMocks() + resetAllDbMocks() mockRequireModerator.mockImplementation((request) => { - request.user = testUser(); - return Promise.resolve(); - }); - }); + request.user = testUser() + return Promise.resolve() + }) + }) - it("pins an unpinned topic", async () => { - selectChain.where.mockResolvedValueOnce([sampleTopicRow({ isPinned: false })]); + it('pins an unpinned topic', async () => { + selectChain.where.mockResolvedValueOnce([sampleTopicRow({ isPinned: false })]) - const encodedUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/moderation/pin/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - payload: { reason: "Important announcement" }, - }); + headers: { authorization: 'Bearer test-token' }, + payload: { reason: 'Important announcement' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ uri: string; isPinned: boolean }>(); - expect(body.uri).toBe(TEST_TOPIC_URI); - expect(body.isPinned).toBe(true); + expect(response.statusCode).toBe(200) + const body = response.json<{ uri: string; isPinned: boolean }>() + expect(body.uri).toBe(TEST_TOPIC_URI) + expect(body.isPinned).toBe(true) - expect(mockDb.transaction).toHaveBeenCalledOnce(); - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.insert).toHaveBeenCalled(); - }); + expect(mockDb.transaction).toHaveBeenCalledOnce() + expect(mockDb.update).toHaveBeenCalled() + expect(mockDb.insert).toHaveBeenCalled() + }) - it("unpins a pinned topic", async () => { - selectChain.where.mockResolvedValueOnce([sampleTopicRow({ isPinned: true })]); + it('unpins a pinned topic', async () => { + selectChain.where.mockResolvedValueOnce([sampleTopicRow({ isPinned: true })]) - const encodedUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/moderation/pin/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ uri: string; isPinned: boolean }>(); - expect(body.uri).toBe(TEST_TOPIC_URI); - expect(body.isPinned).toBe(false); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ uri: string; isPinned: boolean }>() + expect(body.uri).toBe(TEST_TOPIC_URI) + expect(body.isPinned).toBe(false) + }) - it("returns 404 for non-existent topic", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 for non-existent topic', async () => { + selectChain.where.mockResolvedValueOnce([]) - const encodedUri = encodeURIComponent("at://did:plc:nobody/forum.barazo.topic.post/ghost"); + const encodedUri = encodeURIComponent('at://did:plc:nobody/forum.barazo.topic.post/ghost') const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/moderation/pin/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(404); - }); - }); + expect(response.statusCode).toBe(404) + }) + }) // ========================================================================= // POST /api/moderation/delete/:id // ========================================================================= - describe("POST /api/moderation/delete/:id", () => { - let app: FastifyInstance; + describe('POST /api/moderation/delete/:id', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); + vi.clearAllMocks() + resetAllDbMocks() mockRequireModerator.mockImplementation((request) => { - request.user = testUser(); - return Promise.resolve(); - }); - }); + request.user = testUser() + return Promise.resolve() + }) + }) - it("mod-deletes a topic and returns isModDeleted: true", async () => { + it('mod-deletes a topic and returns isModDeleted: true', async () => { // Topic found, not yet mod-deleted - selectChain.where.mockResolvedValueOnce([sampleTopicRow({ isModDeleted: false })]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow({ isModDeleted: false })]) - const encodedUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/moderation/delete/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - payload: { reason: "Violates community guidelines" }, - }); + headers: { authorization: 'Bearer test-token' }, + payload: { reason: 'Violates community guidelines' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ uri: string; isModDeleted: boolean }>(); - expect(body.uri).toBe(TEST_TOPIC_URI); - expect(body.isModDeleted).toBe(true); + expect(response.statusCode).toBe(200) + const body = response.json<{ uri: string; isModDeleted: boolean }>() + expect(body.uri).toBe(TEST_TOPIC_URI) + expect(body.isModDeleted).toBe(true) - expect(mockDb.transaction).toHaveBeenCalledOnce(); - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.insert).toHaveBeenCalled(); - }); + expect(mockDb.transaction).toHaveBeenCalledOnce() + expect(mockDb.update).toHaveBeenCalled() + expect(mockDb.insert).toHaveBeenCalled() + }) - it("mod-deletes a reply (removes from index)", async () => { + it('mod-deletes a reply (removes from index)', async () => { // Topic query returns nothing (not a topic) - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) // Reply query returns a reply - selectChain.where.mockResolvedValueOnce([sampleReplyRow()]); + selectChain.where.mockResolvedValueOnce([sampleReplyRow()]) - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/moderation/delete/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - payload: { reason: "Spam content" }, - }); + headers: { authorization: 'Bearer test-token' }, + payload: { reason: 'Spam content' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ uri: string; isModDeleted: boolean }>(); - expect(body.uri).toBe(TEST_REPLY_URI); - expect(body.isModDeleted).toBe(true); + expect(response.statusCode).toBe(200) + const body = response.json<{ uri: string; isModDeleted: boolean }>() + expect(body.uri).toBe(TEST_REPLY_URI) + expect(body.isModDeleted).toBe(true) - expect(mockDb.transaction).toHaveBeenCalledOnce(); + expect(mockDb.transaction).toHaveBeenCalledOnce() // Should delete reply + decrement reply count + insert mod action - expect(mockDb.delete).toHaveBeenCalled(); - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.insert).toHaveBeenCalled(); - }); + expect(mockDb.delete).toHaveBeenCalled() + expect(mockDb.update).toHaveBeenCalled() + expect(mockDb.insert).toHaveBeenCalled() + }) - it("returns 400 when reason is missing", async () => { - const encodedUri = encodeURIComponent(TEST_TOPIC_URI); + it('returns 400 when reason is missing', async () => { + const encodedUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/moderation/delete/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 404 when content not found (neither topic nor reply)", async () => { + it('returns 404 when content not found (neither topic nor reply)', async () => { // Topic query returns nothing - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) // Reply query returns nothing - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) - const encodedUri = encodeURIComponent("at://did:plc:nobody/forum.barazo.topic.post/ghost"); + const encodedUri = encodeURIComponent('at://did:plc:nobody/forum.barazo.topic.post/ghost') const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/moderation/delete/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - payload: { reason: "Test reason" }, - }); + headers: { authorization: 'Bearer test-token' }, + payload: { reason: 'Test reason' }, + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 409 when topic is already mod-deleted", async () => { - selectChain.where.mockResolvedValueOnce([sampleTopicRow({ isModDeleted: true })]); + it('returns 409 when topic is already mod-deleted', async () => { + selectChain.where.mockResolvedValueOnce([sampleTopicRow({ isModDeleted: true })]) - const encodedUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/moderation/delete/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - payload: { reason: "Already deleted" }, - }); + headers: { authorization: 'Bearer test-token' }, + payload: { reason: 'Already deleted' }, + }) - expect(response.statusCode).toBe(409); - }); - }); + expect(response.statusCode).toBe(409) + }) + }) // ========================================================================= // POST /api/moderation/ban // ========================================================================= - describe("POST /api/moderation/ban", () => { - let app: FastifyInstance; + describe('POST /api/moderation/ban', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser(), adminUser()); - }); + app = await buildTestApp(adminUser(), adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("bans a regular user and returns isBanned: true", async () => { + it('bans a regular user and returns isBanned: true', async () => { // User lookup -> regular user found, not banned - selectChain.where.mockResolvedValueOnce([sampleUserRow({ isBanned: false })]); + selectChain.where.mockResolvedValueOnce([sampleUserRow({ isBanned: false })]) const response = await app.inject({ - method: "POST", - url: "/api/moderation/ban", - headers: { authorization: "Bearer test-token" }, - payload: { did: OTHER_DID, reason: "Repeated harassment" }, - }); + method: 'POST', + url: '/api/moderation/ban', + headers: { authorization: 'Bearer test-token' }, + payload: { did: OTHER_DID, reason: 'Repeated harassment' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ did: string; isBanned: boolean }>(); - expect(body.did).toBe(OTHER_DID); - expect(body.isBanned).toBe(true); + expect(response.statusCode).toBe(200) + const body = response.json<{ did: string; isBanned: boolean }>() + expect(body.did).toBe(OTHER_DID) + expect(body.isBanned).toBe(true) - expect(mockDb.transaction).toHaveBeenCalledOnce(); - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.insert).toHaveBeenCalled(); - }); + expect(mockDb.transaction).toHaveBeenCalledOnce() + expect(mockDb.update).toHaveBeenCalled() + expect(mockDb.insert).toHaveBeenCalled() + }) - it("unbans a banned user", async () => { - selectChain.where.mockResolvedValueOnce([sampleUserRow({ isBanned: true })]); + it('unbans a banned user', async () => { + selectChain.where.mockResolvedValueOnce([sampleUserRow({ isBanned: true })]) const response = await app.inject({ - method: "POST", - url: "/api/moderation/ban", - headers: { authorization: "Bearer test-token" }, - payload: { did: OTHER_DID, reason: "Appeal accepted" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ did: string; isBanned: boolean }>(); - expect(body.did).toBe(OTHER_DID); - expect(body.isBanned).toBe(false); - }); - - it("returns 400 when trying to ban self", async () => { + method: 'POST', + url: '/api/moderation/ban', + headers: { authorization: 'Bearer test-token' }, + payload: { did: OTHER_DID, reason: 'Appeal accepted' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ did: string; isBanned: boolean }>() + expect(body.did).toBe(OTHER_DID) + expect(body.isBanned).toBe(false) + }) + + it('returns 400 when trying to ban self', async () => { const response = await app.inject({ - method: "POST", - url: "/api/moderation/ban", - headers: { authorization: "Bearer test-token" }, - payload: { did: ADMIN_DID, reason: "Self ban" }, - }); + method: 'POST', + url: '/api/moderation/ban', + headers: { authorization: 'Bearer test-token' }, + payload: { did: ADMIN_DID, reason: 'Self ban' }, + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 403 when trying to ban another admin", async () => { - const otherAdmin = sampleUserRow({ did: "did:plc:otheradmin", role: "admin" }); - selectChain.where.mockResolvedValueOnce([otherAdmin]); + it('returns 403 when trying to ban another admin', async () => { + const otherAdmin = sampleUserRow({ did: 'did:plc:otheradmin', role: 'admin' }) + selectChain.where.mockResolvedValueOnce([otherAdmin]) const response = await app.inject({ - method: "POST", - url: "/api/moderation/ban", - headers: { authorization: "Bearer test-token" }, - payload: { did: "did:plc:otheradmin", reason: "Ban admin" }, - }); + method: 'POST', + url: '/api/moderation/ban', + headers: { authorization: 'Bearer test-token' }, + payload: { did: 'did:plc:otheradmin', reason: 'Ban admin' }, + }) - expect(response.statusCode).toBe(403); - }); + expect(response.statusCode).toBe(403) + }) - it("returns 404 when user not found", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 when user not found', async () => { + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "POST", - url: "/api/moderation/ban", - headers: { authorization: "Bearer test-token" }, - payload: { did: "did:plc:nonexistent", reason: "Nobody here" }, - }); + method: 'POST', + url: '/api/moderation/ban', + headers: { authorization: 'Bearer test-token' }, + payload: { did: 'did:plc:nonexistent', reason: 'Nobody here' }, + }) - expect(response.statusCode).toBe(404); - }); - }); + expect(response.statusCode).toBe(404) + }) + }) - describe("POST /api/moderation/ban (non-admin)", () => { - let app: FastifyInstance; + describe('POST /api/moderation/ban (non-admin)', () => { + let app: FastifyInstance beforeAll(async () => { // Non-admin user: will be blocked by requireAdmin - app = await buildTestApp(testUser(), testUser()); - }); + app = await buildTestApp(testUser(), testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("returns 403 for non-admin user", async () => { + it('returns 403 for non-admin user', async () => { const response = await app.inject({ - method: "POST", - url: "/api/moderation/ban", - headers: { authorization: "Bearer test-token" }, - payload: { did: OTHER_DID, reason: "Not allowed" }, - }); + method: 'POST', + url: '/api/moderation/ban', + headers: { authorization: 'Bearer test-token' }, + payload: { did: OTHER_DID, reason: 'Not allowed' }, + }) - expect(response.statusCode).toBe(403); - }); - }); + expect(response.statusCode).toBe(403) + }) + }) // ========================================================================= // GET /api/moderation/log // ========================================================================= - describe("GET /api/moderation/log", () => { - let app: FastifyInstance; + describe('GET /api/moderation/log', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); + vi.clearAllMocks() + resetAllDbMocks() mockRequireModerator.mockImplementation((request) => { - request.user = testUser(); - return Promise.resolve(); - }); - }); + request.user = testUser() + return Promise.resolve() + }) + }) - it("returns paginated moderation actions", async () => { + it('returns paginated moderation actions', async () => { const actions = [ - sampleModerationAction({ id: 3, action: "lock" }), - sampleModerationAction({ id: 2, action: "pin" }), - ]; - selectChain.limit.mockResolvedValueOnce(actions); + sampleModerationAction({ id: 3, action: 'lock' }), + sampleModerationAction({ id: 2, action: 'pin' }), + ] + selectChain.limit.mockResolvedValueOnce(actions) const response = await app.inject({ - method: "GET", - url: "/api/moderation/log", - headers: { authorization: "Bearer test-token" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ actions: Array<{ id: number; action: string; createdAt: string }>; cursor: string | null }>(); - expect(body.actions).toHaveLength(2); - expect(body.actions[0]?.action).toBe("lock"); - expect(body.actions[0]?.createdAt).toBe(TEST_NOW); - expect(body.cursor).toBeNull(); - }); - - it("filters by action type", async () => { - selectChain.limit.mockResolvedValueOnce([]); + method: 'GET', + url: '/api/moderation/log', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + actions: Array<{ id: number; action: string; createdAt: string }> + cursor: string | null + }>() + expect(body.actions).toHaveLength(2) + expect(body.actions[0]?.action).toBe('lock') + expect(body.actions[0]?.createdAt).toBe(TEST_NOW) + expect(body.cursor).toBeNull() + }) + + it('filters by action type', async () => { + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/moderation/log?action=ban", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/moderation/log?action=ban', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ actions: unknown[]; cursor: string | null }>(); - expect(body.actions).toEqual([]); - expect(body.cursor).toBeNull(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ actions: unknown[]; cursor: string | null }>() + expect(body.actions).toEqual([]) + expect(body.cursor).toBeNull() + }) - it("returns empty list when no actions exist", async () => { - selectChain.limit.mockResolvedValueOnce([]); + it('returns empty list when no actions exist', async () => { + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/moderation/log", - headers: { authorization: "Bearer test-token" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ actions: unknown[]; cursor: string | null }>(); - expect(body.actions).toEqual([]); - expect(body.cursor).toBeNull(); - }); - - it("returns cursor when more results exist", async () => { + method: 'GET', + url: '/api/moderation/log', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ actions: unknown[]; cursor: string | null }>() + expect(body.actions).toEqual([]) + expect(body.cursor).toBeNull() + }) + + it('returns cursor when more results exist', async () => { // Default limit is 25; return 26 items to trigger cursor - const baseDate = new Date("2026-02-13T12:00:00.000Z"); + const baseDate = new Date('2026-02-13T12:00:00.000Z') const actions = Array.from({ length: 26 }, (_, i) => { - const d = new Date(baseDate.getTime() - i * 3600000); // subtract i hours - return sampleModerationAction({ id: 26 - i, createdAt: d }); - }); - selectChain.limit.mockResolvedValueOnce(actions); + const d = new Date(baseDate.getTime() - i * 3600000) // subtract i hours + return sampleModerationAction({ id: 26 - i, createdAt: d }) + }) + selectChain.limit.mockResolvedValueOnce(actions) const response = await app.inject({ - method: "GET", - url: "/api/moderation/log", - headers: { authorization: "Bearer test-token" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ actions: unknown[]; cursor: string | null }>(); - expect(body.actions).toHaveLength(25); - expect(body.cursor).toBeTruthy(); - }); - }); + method: 'GET', + url: '/api/moderation/log', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ actions: unknown[]; cursor: string | null }>() + expect(body.actions).toHaveLength(25) + expect(body.cursor).toBeTruthy() + }) + }) // ========================================================================= // POST /api/moderation/report // ========================================================================= - describe("POST /api/moderation/report", () => { - let app: FastifyInstance; + describe('POST /api/moderation/report', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("creates a report successfully", async () => { + it('creates a report successfully', async () => { // Topic exists - selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]); + selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) // No existing report (duplicate check) - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) // Insert returning - insertChain.returning.mockResolvedValueOnce([sampleReport()]); + insertChain.returning.mockResolvedValueOnce([sampleReport()]) const response = await app.inject({ - method: "POST", - url: "/api/moderation/report", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/moderation/report', + headers: { authorization: 'Bearer test-token' }, payload: { targetUri: TEST_TOPIC_URI, - reasonType: "spam", - description: "This is spam", + reasonType: 'spam', + description: 'This is spam', }, - }); - - expect(response.statusCode).toBe(201); - const body = response.json<{ id: number; reporterDid: string; targetUri: string; reasonType: string; status: string }>(); - expect(body.id).toBe(1); - expect(body.reporterDid).toBe(TEST_DID); - expect(body.targetUri).toBe(TEST_TOPIC_URI); - expect(body.reasonType).toBe("spam"); - expect(body.status).toBe("pending"); - }); - - it("returns 400 for invalid URI format (no DID)", async () => { + }) + + expect(response.statusCode).toBe(201) + const body = response.json<{ + id: number + reporterDid: string + targetUri: string + reasonType: string + status: string + }>() + expect(body.id).toBe(1) + expect(body.reporterDid).toBe(TEST_DID) + expect(body.targetUri).toBe(TEST_TOPIC_URI) + expect(body.reasonType).toBe('spam') + expect(body.status).toBe('pending') + }) + + it('returns 400 for invalid URI format (no DID)', async () => { const response = await app.inject({ - method: "POST", - url: "/api/moderation/report", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/moderation/report', + headers: { authorization: 'Bearer test-token' }, payload: { - targetUri: "invalid-uri", - reasonType: "spam", + targetUri: 'invalid-uri', + reasonType: 'spam', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 when reporting own content", async () => { + it('returns 400 when reporting own content', async () => { // URI contains the reporter's own DID - const ownContentUri = `at://${TEST_DID}/forum.barazo.topic.post/mytopic`; + const ownContentUri = `at://${TEST_DID}/forum.barazo.topic.post/mytopic` const response = await app.inject({ - method: "POST", - url: "/api/moderation/report", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/moderation/report', + headers: { authorization: 'Bearer test-token' }, payload: { targetUri: ownContentUri, - reasonType: "spam", + reasonType: 'spam', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 404 when target content not found", async () => { + it('returns 404 when target content not found', async () => { // Topic query returns nothing - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) // Reply query returns nothing - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) - const nonExistentUri = `at://${OTHER_DID}/forum.barazo.topic.post/ghost`; + const nonExistentUri = `at://${OTHER_DID}/forum.barazo.topic.post/ghost` const response = await app.inject({ - method: "POST", - url: "/api/moderation/report", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/moderation/report', + headers: { authorization: 'Bearer test-token' }, payload: { targetUri: nonExistentUri, - reasonType: "harassment", + reasonType: 'harassment', }, - }); + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 409 for duplicate report", async () => { + it('returns 409 for duplicate report', async () => { // Topic exists - selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]); + selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) // Existing report found (duplicate check) - selectChain.where.mockResolvedValueOnce([{ id: 1 }]); + selectChain.where.mockResolvedValueOnce([{ id: 1 }]) const response = await app.inject({ - method: "POST", - url: "/api/moderation/report", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/moderation/report', + headers: { authorization: 'Bearer test-token' }, payload: { targetUri: TEST_TOPIC_URI, - reasonType: "spam", + reasonType: 'spam', }, - }); + }) - expect(response.statusCode).toBe(409); - }); - }); + expect(response.statusCode).toBe(409) + }) + }) - describe("POST /api/moderation/report (unauthenticated)", () => { - let app: FastifyInstance; + describe('POST /api/moderation/report (unauthenticated)', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(undefined); - }); + app = await buildTestApp(undefined) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("returns 401 without auth", async () => { + it('returns 401 without auth', async () => { const response = await app.inject({ - method: "POST", - url: "/api/moderation/report", + method: 'POST', + url: '/api/moderation/report', payload: { targetUri: TEST_TOPIC_URI, - reasonType: "spam", + reasonType: 'spam', }, - }); + }) - expect(response.statusCode).toBe(401); - }); - }); + expect(response.statusCode).toBe(401) + }) + }) // ========================================================================= // GET /api/moderation/reports // ========================================================================= - describe("GET /api/moderation/reports", () => { - let app: FastifyInstance; + describe('GET /api/moderation/reports', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); + vi.clearAllMocks() + resetAllDbMocks() mockRequireModerator.mockImplementation((request) => { - request.user = testUser(); - return Promise.resolve(); - }); - }); - - it("returns paginated reports", async () => { - const reportRows = [ - sampleReport({ id: 2 }), - sampleReport({ id: 1 }), - ]; - selectChain.limit.mockResolvedValueOnce(reportRows); + request.user = testUser() + return Promise.resolve() + }) + }) + + it('returns paginated reports', async () => { + const reportRows = [sampleReport({ id: 2 }), sampleReport({ id: 1 })] + selectChain.limit.mockResolvedValueOnce(reportRows) const response = await app.inject({ - method: "GET", - url: "/api/moderation/reports", - headers: { authorization: "Bearer test-token" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ reports: Array<{ id: number; status: string; createdAt: string }>; cursor: string | null }>(); - expect(body.reports).toHaveLength(2); - expect(body.reports[0]?.id).toBe(2); - expect(body.reports[0]?.createdAt).toBe(TEST_NOW); - expect(body.cursor).toBeNull(); - }); - - it("filters by status", async () => { - selectChain.limit.mockResolvedValueOnce([]); + method: 'GET', + url: '/api/moderation/reports', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + reports: Array<{ id: number; status: string; createdAt: string }> + cursor: string | null + }>() + expect(body.reports).toHaveLength(2) + expect(body.reports[0]?.id).toBe(2) + expect(body.reports[0]?.createdAt).toBe(TEST_NOW) + expect(body.cursor).toBeNull() + }) + + it('filters by status', async () => { + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/moderation/reports?status=pending", - headers: { authorization: "Bearer test-token" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ reports: unknown[]; cursor: string | null }>(); - expect(body.reports).toEqual([]); - expect(body.cursor).toBeNull(); - }); - - it("returns cursor when more results exist", async () => { - const baseDate = new Date("2026-02-13T12:00:00.000Z"); + method: 'GET', + url: '/api/moderation/reports?status=pending', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ reports: unknown[]; cursor: string | null }>() + expect(body.reports).toEqual([]) + expect(body.cursor).toBeNull() + }) + + it('returns cursor when more results exist', async () => { + const baseDate = new Date('2026-02-13T12:00:00.000Z') const reportRows = Array.from({ length: 26 }, (_, i) => { - const d = new Date(baseDate.getTime() - i * 3600000); // subtract i hours - return sampleReport({ id: 26 - i, createdAt: d }); - }); - selectChain.limit.mockResolvedValueOnce(reportRows); + const d = new Date(baseDate.getTime() - i * 3600000) // subtract i hours + return sampleReport({ id: 26 - i, createdAt: d }) + }) + selectChain.limit.mockResolvedValueOnce(reportRows) const response = await app.inject({ - method: "GET", - url: "/api/moderation/reports", - headers: { authorization: "Bearer test-token" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ reports: unknown[]; cursor: string | null }>(); - expect(body.reports).toHaveLength(25); - expect(body.cursor).toBeTruthy(); - }); - }); + method: 'GET', + url: '/api/moderation/reports', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ reports: unknown[]; cursor: string | null }>() + expect(body.reports).toHaveLength(25) + expect(body.cursor).toBeTruthy() + }) + }) // ========================================================================= // PUT /api/moderation/reports/:id // ========================================================================= - describe("PUT /api/moderation/reports/:id", () => { - let app: FastifyInstance; + describe('PUT /api/moderation/reports/:id', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); + vi.clearAllMocks() + resetAllDbMocks() mockRequireModerator.mockImplementation((request) => { - request.user = testUser(); - return Promise.resolve(); - }); - }); + request.user = testUser() + return Promise.resolve() + }) + }) - it("resolves a pending report", async () => { + it('resolves a pending report', async () => { // Report found, status pending - selectChain.where.mockResolvedValueOnce([sampleReport({ status: "pending" })]); + selectChain.where.mockResolvedValueOnce([sampleReport({ status: 'pending' })]) // Update returning const resolvedReport = sampleReport({ - status: "resolved", - resolutionType: "dismissed", + status: 'resolved', + resolutionType: 'dismissed', resolvedBy: TEST_DID, resolvedAt: new Date(TEST_NOW), - }); - updateChain.returning.mockResolvedValueOnce([resolvedReport]); + }) + updateChain.returning.mockResolvedValueOnce([resolvedReport]) const response = await app.inject({ - method: "PUT", - url: "/api/moderation/reports/1", - headers: { authorization: "Bearer test-token" }, - payload: { resolutionType: "dismissed" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ id: number; status: string; resolutionType: string; resolvedBy: string }>(); - expect(body.id).toBe(1); - expect(body.status).toBe("resolved"); - expect(body.resolutionType).toBe("dismissed"); - expect(body.resolvedBy).toBe(TEST_DID); - }); - - it("returns 404 for non-existent report", async () => { - selectChain.where.mockResolvedValueOnce([]); + method: 'PUT', + url: '/api/moderation/reports/1', + headers: { authorization: 'Bearer test-token' }, + payload: { resolutionType: 'dismissed' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + id: number + status: string + resolutionType: string + resolvedBy: string + }>() + expect(body.id).toBe(1) + expect(body.status).toBe('resolved') + expect(body.resolutionType).toBe('dismissed') + expect(body.resolvedBy).toBe(TEST_DID) + }) + + it('returns 404 for non-existent report', async () => { + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "PUT", - url: "/api/moderation/reports/999", - headers: { authorization: "Bearer test-token" }, - payload: { resolutionType: "dismissed" }, - }); + method: 'PUT', + url: '/api/moderation/reports/999', + headers: { authorization: 'Bearer test-token' }, + payload: { resolutionType: 'dismissed' }, + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 409 for already resolved report", async () => { - selectChain.where.mockResolvedValueOnce([sampleReport({ status: "resolved" })]); + it('returns 409 for already resolved report', async () => { + selectChain.where.mockResolvedValueOnce([sampleReport({ status: 'resolved' })]) const response = await app.inject({ - method: "PUT", - url: "/api/moderation/reports/1", - headers: { authorization: "Bearer test-token" }, - payload: { resolutionType: "warned" }, - }); + method: 'PUT', + url: '/api/moderation/reports/1', + headers: { authorization: 'Bearer test-token' }, + payload: { resolutionType: 'warned' }, + }) - expect(response.statusCode).toBe(409); - }); - }); + expect(response.statusCode).toBe(409) + }) + }) // ========================================================================= // GET /api/admin/reports/users // ========================================================================= - describe("GET /api/admin/reports/users", () => { - let app: FastifyInstance; + describe('GET /api/admin/reports/users', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser(), adminUser()); - }); + app = await buildTestApp(adminUser(), adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns most-reported users", async () => { + it('returns most-reported users', async () => { const reportedUsers = [ { did: OTHER_DID, reportCount: 5 }, - { did: "did:plc:badactor", reportCount: 3 }, - ]; - selectChain.limit.mockResolvedValueOnce(reportedUsers); + { did: 'did:plc:badactor', reportCount: 3 }, + ] + selectChain.limit.mockResolvedValueOnce(reportedUsers) const response = await app.inject({ - method: "GET", - url: "/api/admin/reports/users", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/admin/reports/users', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ users: Array<{ did: string; reportCount: number }> }>(); - expect(body.users).toHaveLength(2); - expect(body.users[0]?.did).toBe(OTHER_DID); - expect(body.users[0]?.reportCount).toBe(5); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ users: Array<{ did: string; reportCount: number }> }>() + expect(body.users).toHaveLength(2) + expect(body.users[0]?.did).toBe(OTHER_DID) + expect(body.users[0]?.reportCount).toBe(5) + }) - it("returns empty list when no reported users", async () => { - selectChain.limit.mockResolvedValueOnce([]); + it('returns empty list when no reported users', async () => { + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/admin/reports/users", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/admin/reports/users', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ users: unknown[] }>(); - expect(body.users).toEqual([]); - }); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ users: unknown[] }>() + expect(body.users).toEqual([]) + }) + }) // ========================================================================= // GET /api/admin/moderation/thresholds // ========================================================================= - describe("GET /api/admin/moderation/thresholds", () => { - let app: FastifyInstance; + describe('GET /api/admin/moderation/thresholds', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser(), adminUser()); - }); + app = await buildTestApp(adminUser(), adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns default thresholds when no settings exist", async () => { + it('returns default thresholds when no settings exist', async () => { // No community settings row - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/admin/moderation/thresholds", - headers: { authorization: "Bearer test-token" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ autoBlockReportCount: number; warnThreshold: number }>(); - expect(body.autoBlockReportCount).toBe(5); - expect(body.warnThreshold).toBe(3); - }); - - it("returns stored thresholds from community settings", async () => { + method: 'GET', + url: '/api/admin/moderation/thresholds', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ autoBlockReportCount: number; warnThreshold: number }>() + expect(body.autoBlockReportCount).toBe(5) + expect(body.warnThreshold).toBe(3) + }) + + it('returns stored thresholds from community settings', async () => { selectChain.where.mockResolvedValueOnce([ { moderationThresholds: { autoBlockReportCount: 10, warnThreshold: 7 } }, - ]); + ]) const response = await app.inject({ - method: "GET", - url: "/api/admin/moderation/thresholds", - headers: { authorization: "Bearer test-token" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ autoBlockReportCount: number; warnThreshold: number }>(); - expect(body.autoBlockReportCount).toBe(10); - expect(body.warnThreshold).toBe(7); - }); - }); + method: 'GET', + url: '/api/admin/moderation/thresholds', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ autoBlockReportCount: number; warnThreshold: number }>() + expect(body.autoBlockReportCount).toBe(10) + expect(body.warnThreshold).toBe(7) + }) + }) // ========================================================================= // PUT /api/admin/moderation/thresholds // ========================================================================= - describe("PUT /api/admin/moderation/thresholds", () => { - let app: FastifyInstance; + describe('PUT /api/admin/moderation/thresholds', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser(), adminUser()); - }); + app = await buildTestApp(adminUser(), adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("updates thresholds successfully", async () => { + it('updates thresholds successfully', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/moderation/thresholds", - headers: { authorization: "Bearer test-token" }, + method: 'PUT', + url: '/api/admin/moderation/thresholds', + headers: { authorization: 'Bearer test-token' }, payload: { autoBlockReportCount: 10, warnThreshold: 5, }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ autoBlockReportCount: number; warnThreshold: number }>(); - expect(body.autoBlockReportCount).toBe(10); - expect(body.warnThreshold).toBe(5); + expect(response.statusCode).toBe(200) + const body = response.json<{ autoBlockReportCount: number; warnThreshold: number }>() + expect(body.autoBlockReportCount).toBe(10) + expect(body.warnThreshold).toBe(5) - expect(mockDb.update).toHaveBeenCalled(); - }); + expect(mockDb.update).toHaveBeenCalled() + }) - it("returns 400 for invalid threshold values", async () => { + it('returns 400 for invalid threshold values', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/moderation/thresholds", - headers: { authorization: "Bearer test-token" }, + method: 'PUT', + url: '/api/admin/moderation/thresholds', + headers: { authorization: 'Bearer test-token' }, payload: { autoBlockReportCount: 0, // min is 1 warnThreshold: 5, }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for threshold exceeding maximum", async () => { + it('returns 400 for threshold exceeding maximum', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/moderation/thresholds", - headers: { authorization: "Bearer test-token" }, + method: 'PUT', + url: '/api/admin/moderation/thresholds', + headers: { authorization: 'Bearer test-token' }, payload: { autoBlockReportCount: 101, // max is 100 warnThreshold: 5, }, - }); + }) - expect(response.statusCode).toBe(400); - }); - }); + expect(response.statusCode).toBe(400) + }) + }) - describe("PUT /api/admin/moderation/thresholds (non-admin)", () => { - let app: FastifyInstance; + describe('PUT /api/admin/moderation/thresholds (non-admin)', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser(), testUser()); - }); + app = await buildTestApp(testUser(), testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("returns 403 for non-admin user", async () => { + it('returns 403 for non-admin user', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/moderation/thresholds", - headers: { authorization: "Bearer test-token" }, + method: 'PUT', + url: '/api/admin/moderation/thresholds', + headers: { authorization: 'Bearer test-token' }, payload: { autoBlockReportCount: 10, warnThreshold: 5, }, - }); + }) - expect(response.statusCode).toBe(403); - }); - }); -}); + expect(response.statusCode).toBe(403) + }) + }) +}) diff --git a/tests/unit/routes/notifications.test.ts b/tests/unit/routes/notifications.test.ts index de89e2a..016c110 100644 --- a/tests/unit/routes/notifications.test.ts +++ b/tests/unit/routes/notifications.test.ts @@ -1,37 +1,37 @@ -import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { type DbChain, createChainableProxy, createMockDb } from "../../helpers/mock-db.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' // Import routes -import { notificationRoutes } from "../../../src/routes/notifications.js"; +import { notificationRoutes } from '../../../src/routes/notifications.js' // --------------------------------------------------------------------------- // Mock env // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_DID: 'did:plc:community123', RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); -const ACTOR_DID = "did:plc:actor456"; -const COMMUNITY_DID = "did:plc:community123"; -const TEST_SUBJECT_URI = `at://${ACTOR_DID}/forum.barazo.topic.post/topic123`; -const TEST_NOW = "2026-02-14T12:00:00.000Z"; +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) +const ACTOR_DID = 'did:plc:actor456' +const COMMUNITY_DID = 'did:plc:community123' +const TEST_SUBJECT_URI = `at://${ACTOR_DID}/forum.barazo.topic.post/topic123` +const TEST_NOW = '2026-02-14T12:00:00.000Z' // --------------------------------------------------------------------------- // Mock user builders @@ -43,29 +43,29 @@ function testUser(overrides?: Partial): RequestUser { handle: TEST_HANDLE, sid: TEST_SID, ...overrides, - }; + } } // --------------------------------------------------------------------------- // Chainable mock DB // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() -let selectChain: DbChain; -let updateChain: DbChain; +let selectChain: DbChain +let updateChain: DbChain function resetAllDbMocks(): void { - selectChain = createChainableProxy([]); - updateChain = createChainableProxy([]); - mockDb.insert.mockReturnValue(createChainableProxy()); - mockDb.select.mockReturnValue(selectChain); - mockDb.update.mockReturnValue(updateChain); - mockDb.delete.mockReturnValue(createChainableProxy()); + selectChain = createChainableProxy([]) + updateChain = createChainableProxy([]) + mockDb.insert.mockReturnValue(createChainableProxy()) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(updateChain) + mockDb.delete.mockReturnValue(createChainableProxy()) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { - return await fn(mockDb); - }); + return await fn(mockDb) + }) } // --------------------------------------------------------------------------- @@ -76,18 +76,18 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } // --------------------------------------------------------------------------- @@ -98,14 +98,14 @@ function sampleNotificationRow(overrides?: Record) { return { id: 1, recipientDid: TEST_DID, - type: "reply", + type: 'reply', subjectUri: TEST_SUBJECT_URI, actorDid: ACTOR_DID, communityDid: COMMUNITY_DID, read: false, createdAt: new Date(TEST_NOW), ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -113,62 +113,62 @@ function sampleNotificationRow(overrides?: Record) { // --------------------------------------------------------------------------- async function buildTestApp(user?: RequestUser): Promise { - const app = Fastify({ logger: false }); - - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", createMockAuthMiddleware(user)); - app.decorate("firehose", {} as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); - - await app.register(notificationRoutes()); - await app.ready(); - - return app; + const app = Fastify({ logger: false }) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', createMockAuthMiddleware(user)) + app.decorate('firehose', {} as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + + await app.register(notificationRoutes()) + await app.ready() + + return app } // =========================================================================== // Test suite // =========================================================================== -describe("notification routes", () => { +describe('notification routes', () => { // ========================================================================= // GET /api/notifications // ========================================================================= - describe("GET /api/notifications", () => { - let app: FastifyInstance; + describe('GET /api/notifications', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "GET", - url: "/api/notifications", - }); + method: 'GET', + url: '/api/notifications', + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("returns empty list for user with no notifications", async () => { + it('returns empty list for user with no notifications', async () => { // The route does two select queries: // 1. select().from().where().orderBy().limit() -- notification list // 2. select({ count }).from().where() -- total count @@ -185,40 +185,40 @@ describe("notification routes", () => { orderBy: selectChain.orderBy, limit: selectChain.limit, returning: selectChain.returning, - }; - selectChain.where.mockReturnValueOnce(chainableThenable); - selectChain.limit.mockResolvedValueOnce([]); + } + selectChain.where.mockReturnValueOnce(chainableThenable) + selectChain.limit.mockResolvedValueOnce([]) // Second select().from().where() for count - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) const response = await app.inject({ - method: "GET", - url: "/api/notifications", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/notifications', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - notifications: unknown[]; - cursor: string | null; - total: number; - }>(); - expect(body.notifications).toEqual([]); - expect(body.cursor).toBeNull(); - expect(body.total).toBe(0); - }); - - it("returns notifications ordered by unread first", async () => { + notifications: unknown[] + cursor: string | null + total: number + }>() + expect(body.notifications).toEqual([]) + expect(body.cursor).toBeNull() + expect(body.total).toBe(0) + }) + + it('returns notifications ordered by unread first', async () => { const unreadNotification = sampleNotificationRow({ id: 2, read: false, - createdAt: new Date("2026-02-14T11:00:00.000Z"), - }); + createdAt: new Date('2026-02-14T11:00:00.000Z'), + }) const readNotification = sampleNotificationRow({ id: 1, read: true, - createdAt: new Date("2026-02-14T10:00:00.000Z"), - }); + createdAt: new Date('2026-02-14T10:00:00.000Z'), + }) const chainableThenable = { ...selectChain, @@ -227,36 +227,38 @@ describe("notification routes", () => { orderBy: selectChain.orderBy, limit: selectChain.limit, returning: selectChain.returning, - }; - selectChain.where.mockReturnValueOnce(chainableThenable); - selectChain.limit.mockResolvedValueOnce([unreadNotification, readNotification]); - selectChain.where.mockResolvedValueOnce([{ count: 2 }]); + } + selectChain.where.mockReturnValueOnce(chainableThenable) + selectChain.limit.mockResolvedValueOnce([unreadNotification, readNotification]) + selectChain.where.mockResolvedValueOnce([{ count: 2 }]) const response = await app.inject({ - method: "GET", - url: "/api/notifications", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/notifications', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - notifications: Array<{ id: number; read: boolean }>; - total: number; - }>(); - expect(body.notifications).toHaveLength(2); - expect(body.notifications[0]?.read).toBe(false); - expect(body.notifications[1]?.read).toBe(true); - expect(body.total).toBe(2); - }); - - it("supports pagination with cursor", async () => { + notifications: Array<{ id: number; read: boolean }> + total: number + }>() + expect(body.notifications).toHaveLength(2) + expect(body.notifications[0]?.read).toBe(false) + expect(body.notifications[1]?.read).toBe(true) + expect(body.total).toBe(2) + }) + + it('supports pagination with cursor', async () => { // Return limit + 1 to signal more pages exist const rows = Array.from({ length: 26 }, (_, i) => sampleNotificationRow({ id: i + 1, - createdAt: new Date(`2026-02-14T${String(12 - Math.floor(i / 2)).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}:00.000Z`), - }), - ); + createdAt: new Date( + `2026-02-14T${String(12 - Math.floor(i / 2)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00.000Z` + ), + }) + ) const chainableThenable = { ...selectChain, @@ -265,30 +267,30 @@ describe("notification routes", () => { orderBy: selectChain.orderBy, limit: selectChain.limit, returning: selectChain.returning, - }; - selectChain.where.mockReturnValueOnce(chainableThenable); - selectChain.limit.mockResolvedValueOnce(rows); - selectChain.where.mockResolvedValueOnce([{ count: 50 }]); + } + selectChain.where.mockReturnValueOnce(chainableThenable) + selectChain.limit.mockResolvedValueOnce(rows) + selectChain.where.mockResolvedValueOnce([{ count: 50 }]) const response = await app.inject({ - method: "GET", - url: "/api/notifications", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/notifications', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - notifications: unknown[]; - cursor: string | null; - total: number; - }>(); - expect(body.notifications).toHaveLength(25); - expect(body.cursor).toBeTruthy(); - expect(body.total).toBe(50); - }); - - it("returns null cursor when fewer items than limit", async () => { - const rows = [sampleNotificationRow()]; + notifications: unknown[] + cursor: string | null + total: number + }>() + expect(body.notifications).toHaveLength(25) + expect(body.cursor).toBeTruthy() + expect(body.total).toBe(50) + }) + + it('returns null cursor when fewer items than limit', async () => { + const rows = [sampleNotificationRow()] const chainableThenable = { ...selectChain, then: (resolve: (val: unknown) => void, reject?: (err: unknown) => void) => @@ -296,27 +298,27 @@ describe("notification routes", () => { orderBy: selectChain.orderBy, limit: selectChain.limit, returning: selectChain.returning, - }; - selectChain.where.mockReturnValueOnce(chainableThenable); - selectChain.limit.mockResolvedValueOnce(rows); - selectChain.where.mockResolvedValueOnce([{ count: 1 }]); + } + selectChain.where.mockReturnValueOnce(chainableThenable) + selectChain.limit.mockResolvedValueOnce(rows) + selectChain.where.mockResolvedValueOnce([{ count: 1 }]) const response = await app.inject({ - method: "GET", - url: "/api/notifications?limit=25", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/notifications?limit=25', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - notifications: unknown[]; - cursor: string | null; - }>(); - expect(body.notifications).toHaveLength(1); - expect(body.cursor).toBeNull(); - }); - - it("serializes notification dates as ISO strings", async () => { + notifications: unknown[] + cursor: string | null + }>() + expect(body.notifications).toHaveLength(1) + expect(body.cursor).toBeNull() + }) + + it('serializes notification dates as ISO strings', async () => { const chainableThenable = { ...selectChain, then: (resolve: (val: unknown) => void, reject?: (err: unknown) => void) => @@ -324,192 +326,192 @@ describe("notification routes", () => { orderBy: selectChain.orderBy, limit: selectChain.limit, returning: selectChain.returning, - }; - selectChain.where.mockReturnValueOnce(chainableThenable); - selectChain.limit.mockResolvedValueOnce([sampleNotificationRow()]); - selectChain.where.mockResolvedValueOnce([{ count: 1 }]); + } + selectChain.where.mockReturnValueOnce(chainableThenable) + selectChain.limit.mockResolvedValueOnce([sampleNotificationRow()]) + selectChain.where.mockResolvedValueOnce([{ count: 1 }]) const response = await app.inject({ - method: "GET", - url: "/api/notifications", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/notifications', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ notifications: Array<{ - createdAt: string; - type: string; - actorDid: string; - }>; - }>(); - expect(body.notifications[0]?.createdAt).toBe(TEST_NOW); - expect(body.notifications[0]?.type).toBe("reply"); - expect(body.notifications[0]?.actorDid).toBe(ACTOR_DID); - }); - - it("returns 400 for invalid limit", async () => { + createdAt: string + type: string + actorDid: string + }> + }>() + expect(body.notifications[0]?.createdAt).toBe(TEST_NOW) + expect(body.notifications[0]?.type).toBe('reply') + expect(body.notifications[0]?.actorDid).toBe(ACTOR_DID) + }) + + it('returns 400 for invalid limit', async () => { const response = await app.inject({ - method: "GET", - url: "/api/notifications?limit=abc", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/notifications?limit=abc', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for limit exceeding max (101)", async () => { + it('returns 400 for limit exceeding max (101)', async () => { const response = await app.inject({ - method: "GET", - url: "/api/notifications?limit=101", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/notifications?limit=101', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for limit below min (0)", async () => { + it('returns 400 for limit below min (0)', async () => { const response = await app.inject({ - method: "GET", - url: "/api/notifications?limit=0", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/notifications?limit=0', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(400); - }); - }); + expect(response.statusCode).toBe(400) + }) + }) // ========================================================================= // PUT /api/notifications/read // ========================================================================= - describe("PUT /api/notifications/read", () => { - let app: FastifyInstance; + describe('PUT /api/notifications/read', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("marks single notification as read", async () => { + it('marks single notification as read', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/notifications/read", - headers: { authorization: "Bearer test-token" }, + method: 'PUT', + url: '/api/notifications/read', + headers: { authorization: 'Bearer test-token' }, payload: { notificationId: 42 }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean }>(); - expect(body.success).toBe(true); - expect(mockDb.update).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) + expect(mockDb.update).toHaveBeenCalledOnce() + }) - it("marks all notifications as read", async () => { + it('marks all notifications as read', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/notifications/read", - headers: { authorization: "Bearer test-token" }, + method: 'PUT', + url: '/api/notifications/read', + headers: { authorization: 'Bearer test-token' }, payload: { all: true }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean }>(); - expect(body.success).toBe(true); - expect(mockDb.update).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) + expect(mockDb.update).toHaveBeenCalledOnce() + }) - it("returns 400 when neither notificationId nor all provided", async () => { + it('returns 400 when neither notificationId nor all provided', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/notifications/read", - headers: { authorization: "Bearer test-token" }, + method: 'PUT', + url: '/api/notifications/read', + headers: { authorization: 'Bearer test-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "PUT", - url: "/api/notifications/read", + method: 'PUT', + url: '/api/notifications/read', payload: { all: true }, - }); + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) + }) // ========================================================================= // GET /api/notifications/count // ========================================================================= - describe("GET /api/notifications/count", () => { - let app: FastifyInstance; + describe('GET /api/notifications/count', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns unread count", async () => { - selectChain.where.mockResolvedValueOnce([{ count: 5 }]); + it('returns unread count', async () => { + selectChain.where.mockResolvedValueOnce([{ count: 5 }]) const response = await app.inject({ - method: "GET", - url: "/api/notifications/count", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/notifications/count', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ unread: number }>(); - expect(body.unread).toBe(5); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ unread: number }>() + expect(body.unread).toBe(5) + }) - it("returns zero when no unread notifications", async () => { - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); + it('returns zero when no unread notifications', async () => { + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) const response = await app.inject({ - method: "GET", - url: "/api/notifications/count", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/notifications/count', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ unread: number }>(); - expect(body.unread).toBe(0); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ unread: number }>() + expect(body.unread).toBe(0) + }) - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "GET", - url: "/api/notifications/count", - }); - - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); - }); -}); + method: 'GET', + url: '/api/notifications/count', + }) + + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) + }) +}) diff --git a/tests/unit/routes/onboarding.test.ts b/tests/unit/routes/onboarding.test.ts index 4d5348c..716771c 100644 --- a/tests/unit/routes/onboarding.test.ts +++ b/tests/unit/routes/onboarding.test.ts @@ -1,36 +1,36 @@ -import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { createChainableProxy, createMockDb } from "../../helpers/mock-db.js"; -import type { MockDb } from "../../helpers/mock-db.js"; -import { onboardingRoutes } from "../../../src/routes/onboarding.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { createChainableProxy, createMockDb } from '../../helpers/mock-db.js' +import type { MockDb } from '../../helpers/mock-db.js' +import { onboardingRoutes } from '../../../src/routes/onboarding.js' // --------------------------------------------------------------------------- // Mock env // --------------------------------------------------------------------------- -const COMMUNITY_DID = "did:plc:community123"; +const COMMUNITY_DID = 'did:plc:community123' const mockEnv = { COMMUNITY_DID, RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); -const ADMIN_DID = "did:plc:admin999"; -const TEST_NOW = "2026-02-15T12:00:00.000Z"; +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) +const ADMIN_DID = 'did:plc:admin999' +const TEST_NOW = '2026-02-15T12:00:00.000Z' // --------------------------------------------------------------------------- // Mock user builders @@ -42,18 +42,18 @@ function testUser(overrides?: Partial): RequestUser { handle: TEST_HANDLE, sid: TEST_SID, ...overrides, - }; + } } function adminUser(): RequestUser { - return testUser({ did: ADMIN_DID, handle: "admin.bsky.social" }); + return testUser({ did: ADMIN_DID, handle: 'admin.bsky.social' }) } // --------------------------------------------------------------------------- // Mock DB // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() /** * Queue select results. Each call to db.select() will consume the next result. @@ -61,20 +61,20 @@ const mockDb = createMockDb(); */ function queueSelectResults(...results: unknown[][]): void { for (const result of results) { - mockDb.select.mockReturnValueOnce(createChainableProxy(result)); + mockDb.select.mockReturnValueOnce(createChainableProxy(result)) } } function resetAllDbMocks(): void { - mockDb.select.mockReset(); - mockDb.insert.mockReturnValue(createChainableProxy()); - mockDb.update.mockReturnValue(createChainableProxy([])); - mockDb.delete.mockReturnValue(createChainableProxy()); + mockDb.select.mockReset() + mockDb.insert.mockReturnValue(createChainableProxy()) + mockDb.update.mockReturnValue(createChainableProxy([])) + mockDb.delete.mockReturnValue(createChainableProxy()) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async for Drizzle transaction mock mockDb.transaction.mockImplementation(async (fn: (tx: MockDb) => Promise) => { - return await fn(mockDb); - }); - mockDb.execute.mockReset(); + return await fn(mockDb) + }) + mockDb.execute.mockReset() } // --------------------------------------------------------------------------- @@ -85,32 +85,35 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } function createMockRequireAdmin(user?: RequestUser) { - return async (request: { user?: RequestUser }, reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise } }) => { + return async ( + request: { user?: RequestUser }, + reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise } } + ) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user if (user.did !== ADMIN_DID) { - await reply.status(403).send({ error: "Admin access required" }); - return; + await reply.status(403).send({ error: 'Admin access required' }) + return } - }; + } } // --------------------------------------------------------------------------- @@ -119,29 +122,29 @@ function createMockRequireAdmin(user?: RequestUser) { function sampleField(overrides?: Record) { return { - id: "field-001", + id: 'field-001', communityDid: COMMUNITY_DID, - fieldType: "custom_text", - label: "What brings you here?", - description: "Tell us about yourself", + fieldType: 'custom_text', + label: 'What brings you here?', + description: 'Tell us about yourself', isMandatory: true, sortOrder: 0, config: null, createdAt: new Date(TEST_NOW), updatedAt: new Date(TEST_NOW), ...overrides, - }; + } } function sampleResponse(overrides?: Record) { return { did: TEST_DID, communityDid: COMMUNITY_DID, - fieldId: "field-001", - response: "I love forums", + fieldId: 'field-001', + response: 'I love forums', completedAt: new Date(TEST_NOW), ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -149,618 +152,626 @@ function sampleResponse(overrides?: Record) { // --------------------------------------------------------------------------- async function buildTestApp(user?: RequestUser): Promise { - const app = Fastify({ logger: false }); - - const authMiddleware = createMockAuthMiddleware(user); - const requireAdmin = createMockRequireAdmin(user); - - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", authMiddleware); - app.decorate("requireAdmin", requireAdmin as never); - app.decorate("firehose", {} as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); - - await app.register(onboardingRoutes()); - await app.ready(); - - return app; + const app = Fastify({ logger: false }) + + const authMiddleware = createMockAuthMiddleware(user) + const requireAdmin = createMockRequireAdmin(user) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', authMiddleware) + app.decorate('requireAdmin', requireAdmin as never) + app.decorate('firehose', {} as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + + await app.register(onboardingRoutes()) + await app.ready() + + return app } // =========================================================================== // Admin routes // =========================================================================== -describe("onboarding admin routes", () => { +describe('onboarding admin routes', () => { // ========================================================================= // GET /api/admin/onboarding-fields // ========================================================================= - describe("GET /api/admin/onboarding-fields", () => { - let app: FastifyInstance; + describe('GET /api/admin/onboarding-fields', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser()); - }); + app = await buildTestApp(adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns empty array when no fields configured", async () => { - queueSelectResults([]); + it('returns empty array when no fields configured', async () => { + queueSelectResults([]) const response = await app.inject({ - method: "GET", - url: "/api/admin/onboarding-fields", - headers: { authorization: "Bearer admin-token" }, - }); + method: 'GET', + url: '/api/admin/onboarding-fields', + headers: { authorization: 'Bearer admin-token' }, + }) - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual([]); - }); + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual([]) + }) - it("returns fields sorted by sortOrder", async () => { + it('returns fields sorted by sortOrder', async () => { const fields = [ - sampleField({ id: "field-001", sortOrder: 0 }), - sampleField({ id: "field-002", sortOrder: 1, label: "Accept ToS", fieldType: "tos_acceptance" }), - ]; - queueSelectResults(fields); + sampleField({ id: 'field-001', sortOrder: 0 }), + sampleField({ + id: 'field-002', + sortOrder: 1, + label: 'Accept ToS', + fieldType: 'tos_acceptance', + }), + ] + queueSelectResults(fields) const response = await app.inject({ - method: "GET", - url: "/api/admin/onboarding-fields", - headers: { authorization: "Bearer admin-token" }, - }); + method: 'GET', + url: '/api/admin/onboarding-fields', + headers: { authorization: 'Bearer admin-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ id: string }[]>(); - expect(body).toHaveLength(2); - expect(body[0]?.id).toBe("field-001"); - expect(body[1]?.id).toBe("field-002"); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ id: string }[]>() + expect(body).toHaveLength(2) + expect(body[0]?.id).toBe('field-001') + expect(body[1]?.id).toBe('field-002') + }) - it("rejects unauthenticated request", async () => { - const unauthApp = await buildTestApp(); + it('rejects unauthenticated request', async () => { + const unauthApp = await buildTestApp() const response = await unauthApp.inject({ - method: "GET", - url: "/api/admin/onboarding-fields", - }); + method: 'GET', + url: '/api/admin/onboarding-fields', + }) - expect(response.statusCode).toBe(401); - await unauthApp.close(); - }); + expect(response.statusCode).toBe(401) + await unauthApp.close() + }) - it("rejects non-admin user", async () => { - const nonAdminApp = await buildTestApp(testUser()); + it('rejects non-admin user', async () => { + const nonAdminApp = await buildTestApp(testUser()) const response = await nonAdminApp.inject({ - method: "GET", - url: "/api/admin/onboarding-fields", - headers: { authorization: "Bearer user-token" }, - }); + method: 'GET', + url: '/api/admin/onboarding-fields', + headers: { authorization: 'Bearer user-token' }, + }) - expect(response.statusCode).toBe(403); - await nonAdminApp.close(); - }); - }); + expect(response.statusCode).toBe(403) + await nonAdminApp.close() + }) + }) // ========================================================================= // POST /api/admin/onboarding-fields // ========================================================================= - describe("POST /api/admin/onboarding-fields", () => { - let app: FastifyInstance; + describe('POST /api/admin/onboarding-fields', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser()); - }); + app = await buildTestApp(adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("creates a new onboarding field", async () => { - const created = sampleField(); - const insertChain = createChainableProxy([created]); - mockDb.insert.mockReturnValueOnce(insertChain); + it('creates a new onboarding field', async () => { + const created = sampleField() + const insertChain = createChainableProxy([created]) + mockDb.insert.mockReturnValueOnce(insertChain) const response = await app.inject({ - method: "POST", - url: "/api/admin/onboarding-fields", + method: 'POST', + url: '/api/admin/onboarding-fields', headers: { - authorization: "Bearer admin-token", - "content-type": "application/json", + authorization: 'Bearer admin-token', + 'content-type': 'application/json', }, payload: { - fieldType: "custom_text", - label: "What brings you here?", - description: "Tell us about yourself", + fieldType: 'custom_text', + label: 'What brings you here?', + description: 'Tell us about yourself', }, - }); - - expect(response.statusCode).toBe(201); - const body = response.json<{ id: string; fieldType: string }>(); - expect(body.id).toBe("field-001"); - expect(body.fieldType).toBe("custom_text"); - }); - - it("creates a tos_acceptance field", async () => { - const created = sampleField({ fieldType: "tos_acceptance", label: "Accept our Terms", config: { tosUrl: "https://example.com/tos" } }); - const insertChain = createChainableProxy([created]); - mockDb.insert.mockReturnValueOnce(insertChain); + }) + + expect(response.statusCode).toBe(201) + const body = response.json<{ id: string; fieldType: string }>() + expect(body.id).toBe('field-001') + expect(body.fieldType).toBe('custom_text') + }) + + it('creates a tos_acceptance field', async () => { + const created = sampleField({ + fieldType: 'tos_acceptance', + label: 'Accept our Terms', + config: { tosUrl: 'https://example.com/tos' }, + }) + const insertChain = createChainableProxy([created]) + mockDb.insert.mockReturnValueOnce(insertChain) const response = await app.inject({ - method: "POST", - url: "/api/admin/onboarding-fields", + method: 'POST', + url: '/api/admin/onboarding-fields', headers: { - authorization: "Bearer admin-token", - "content-type": "application/json", + authorization: 'Bearer admin-token', + 'content-type': 'application/json', }, payload: { - fieldType: "tos_acceptance", - label: "Accept our Terms", - config: { tosUrl: "https://example.com/tos" }, + fieldType: 'tos_acceptance', + label: 'Accept our Terms', + config: { tosUrl: 'https://example.com/tos' }, }, - }); + }) - expect(response.statusCode).toBe(201); - }); + expect(response.statusCode).toBe(201) + }) - it("rejects invalid field type", async () => { + it('rejects invalid field type', async () => { const response = await app.inject({ - method: "POST", - url: "/api/admin/onboarding-fields", + method: 'POST', + url: '/api/admin/onboarding-fields', headers: { - authorization: "Bearer admin-token", - "content-type": "application/json", + authorization: 'Bearer admin-token', + 'content-type': 'application/json', }, payload: { - fieldType: "invalid_type", - label: "Test", + fieldType: 'invalid_type', + label: 'Test', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("rejects empty label", async () => { + it('rejects empty label', async () => { const response = await app.inject({ - method: "POST", - url: "/api/admin/onboarding-fields", + method: 'POST', + url: '/api/admin/onboarding-fields', headers: { - authorization: "Bearer admin-token", - "content-type": "application/json", + authorization: 'Bearer admin-token', + 'content-type': 'application/json', }, payload: { - fieldType: "custom_text", - label: "", + fieldType: 'custom_text', + label: '', }, - }); + }) - expect(response.statusCode).toBe(400); - }); - }); + expect(response.statusCode).toBe(400) + }) + }) // ========================================================================= // PUT /api/admin/onboarding-fields/:id // ========================================================================= - describe("PUT /api/admin/onboarding-fields/:id", () => { - let app: FastifyInstance; + describe('PUT /api/admin/onboarding-fields/:id', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser()); - }); + app = await buildTestApp(adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("updates a field label", async () => { - const updated = sampleField({ label: "Updated label" }); - const updateChain = createChainableProxy([updated]); - mockDb.update.mockReturnValueOnce(updateChain); + it('updates a field label', async () => { + const updated = sampleField({ label: 'Updated label' }) + const updateChain = createChainableProxy([updated]) + mockDb.update.mockReturnValueOnce(updateChain) const response = await app.inject({ - method: "PUT", - url: "/api/admin/onboarding-fields/field-001", + method: 'PUT', + url: '/api/admin/onboarding-fields/field-001', headers: { - authorization: "Bearer admin-token", - "content-type": "application/json", + authorization: 'Bearer admin-token', + 'content-type': 'application/json', }, - payload: { label: "Updated label" }, - }); + payload: { label: 'Updated label' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ label: string }>(); - expect(body.label).toBe("Updated label"); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ label: string }>() + expect(body.label).toBe('Updated label') + }) - it("returns 404 when field not found", async () => { - const updateChain = createChainableProxy([]); - mockDb.update.mockReturnValueOnce(updateChain); + it('returns 404 when field not found', async () => { + const updateChain = createChainableProxy([]) + mockDb.update.mockReturnValueOnce(updateChain) const response = await app.inject({ - method: "PUT", - url: "/api/admin/onboarding-fields/nonexistent", + method: 'PUT', + url: '/api/admin/onboarding-fields/nonexistent', headers: { - authorization: "Bearer admin-token", - "content-type": "application/json", + authorization: 'Bearer admin-token', + 'content-type': 'application/json', }, - payload: { label: "Updated" }, - }); + payload: { label: 'Updated' }, + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("rejects empty update body", async () => { + it('rejects empty update body', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/onboarding-fields/field-001", + method: 'PUT', + url: '/api/admin/onboarding-fields/field-001', headers: { - authorization: "Bearer admin-token", - "content-type": "application/json", + authorization: 'Bearer admin-token', + 'content-type': 'application/json', }, payload: {}, - }); + }) - expect(response.statusCode).toBe(400); - }); - }); + expect(response.statusCode).toBe(400) + }) + }) // ========================================================================= // DELETE /api/admin/onboarding-fields/:id // ========================================================================= - describe("DELETE /api/admin/onboarding-fields/:id", () => { - let app: FastifyInstance; + describe('DELETE /api/admin/onboarding-fields/:id', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser()); - }); + app = await buildTestApp(adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("deletes a field and cleans up responses", async () => { - const deleteChain = createChainableProxy([sampleField()]); - mockDb.delete.mockReturnValueOnce(deleteChain); + it('deletes a field and cleans up responses', async () => { + const deleteChain = createChainableProxy([sampleField()]) + mockDb.delete.mockReturnValueOnce(deleteChain) // Second delete call for user responses cleanup - const deleteResponsesChain = createChainableProxy([]); - mockDb.delete.mockReturnValueOnce(deleteResponsesChain); + const deleteResponsesChain = createChainableProxy([]) + mockDb.delete.mockReturnValueOnce(deleteResponsesChain) const response = await app.inject({ - method: "DELETE", - url: "/api/admin/onboarding-fields/field-001", - headers: { authorization: "Bearer admin-token" }, - }); + method: 'DELETE', + url: '/api/admin/onboarding-fields/field-001', + headers: { authorization: 'Bearer admin-token' }, + }) - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ success: true }); - }); + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ success: true }) + }) - it("returns 404 when field not found", async () => { - const deleteChain = createChainableProxy([]); - mockDb.delete.mockReturnValueOnce(deleteChain); + it('returns 404 when field not found', async () => { + const deleteChain = createChainableProxy([]) + mockDb.delete.mockReturnValueOnce(deleteChain) const response = await app.inject({ - method: "DELETE", - url: "/api/admin/onboarding-fields/nonexistent", - headers: { authorization: "Bearer admin-token" }, - }); + method: 'DELETE', + url: '/api/admin/onboarding-fields/nonexistent', + headers: { authorization: 'Bearer admin-token' }, + }) - expect(response.statusCode).toBe(404); - }); - }); + expect(response.statusCode).toBe(404) + }) + }) // ========================================================================= // PUT /api/admin/onboarding-fields/reorder // ========================================================================= - describe("PUT /api/admin/onboarding-fields/reorder", () => { - let app: FastifyInstance; + describe('PUT /api/admin/onboarding-fields/reorder', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(adminUser()); - }); + app = await buildTestApp(adminUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("reorders fields and returns updated list", async () => { + it('reorders fields and returns updated list', async () => { // Update calls for each field - mockDb.update.mockReturnValue(createChainableProxy([])); + mockDb.update.mockReturnValue(createChainableProxy([])) // Select after reorder returns new ordering const reorderedFields = [ - sampleField({ id: "field-002", sortOrder: 0 }), - sampleField({ id: "field-001", sortOrder: 1 }), - ]; - queueSelectResults(reorderedFields); + sampleField({ id: 'field-002', sortOrder: 0 }), + sampleField({ id: 'field-001', sortOrder: 1 }), + ] + queueSelectResults(reorderedFields) const response = await app.inject({ - method: "PUT", - url: "/api/admin/onboarding-fields/reorder", + method: 'PUT', + url: '/api/admin/onboarding-fields/reorder', headers: { - authorization: "Bearer admin-token", - "content-type": "application/json", + authorization: 'Bearer admin-token', + 'content-type': 'application/json', }, payload: [ - { id: "field-002", sortOrder: 0 }, - { id: "field-001", sortOrder: 1 }, + { id: 'field-002', sortOrder: 0 }, + { id: 'field-001', sortOrder: 1 }, ], - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ id: string }[]>(); - expect(body).toHaveLength(2); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ id: string }[]>() + expect(body).toHaveLength(2) + }) - it("rejects empty reorder array", async () => { + it('rejects empty reorder array', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/admin/onboarding-fields/reorder", + method: 'PUT', + url: '/api/admin/onboarding-fields/reorder', headers: { - authorization: "Bearer admin-token", - "content-type": "application/json", + authorization: 'Bearer admin-token', + 'content-type': 'application/json', }, payload: [], - }); + }) - expect(response.statusCode).toBe(400); - }); - }); -}); + expect(response.statusCode).toBe(400) + }) + }) +}) // =========================================================================== // User routes // =========================================================================== -describe("onboarding user routes", () => { +describe('onboarding user routes', () => { // ========================================================================= // GET /api/onboarding/status // ========================================================================= - describe("GET /api/onboarding/status", () => { - let app: FastifyInstance; + describe('GET /api/onboarding/status', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns complete=true when no onboarding fields exist", async () => { - queueSelectResults([], []); // fields, responses + it('returns complete=true when no onboarding fields exist', async () => { + queueSelectResults([], []) // fields, responses const response = await app.inject({ - method: "GET", - url: "/api/onboarding/status", - headers: { authorization: "Bearer user-token" }, - }); + method: 'GET', + url: '/api/onboarding/status', + headers: { authorization: 'Bearer user-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ complete: boolean; fields: unknown[] }>(); - expect(body.complete).toBe(true); - expect(body.fields).toEqual([]); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ complete: boolean; fields: unknown[] }>() + expect(body.complete).toBe(true) + expect(body.fields).toEqual([]) + }) - it("returns complete=false when mandatory field not answered", async () => { - const field = sampleField({ isMandatory: true }); - queueSelectResults([field], []); // fields, no responses + it('returns complete=false when mandatory field not answered', async () => { + const field = sampleField({ isMandatory: true }) + queueSelectResults([field], []) // fields, no responses const response = await app.inject({ - method: "GET", - url: "/api/onboarding/status", - headers: { authorization: "Bearer user-token" }, - }); + method: 'GET', + url: '/api/onboarding/status', + headers: { authorization: 'Bearer user-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ complete: boolean; fields: { completed: boolean }[] }>(); - expect(body.complete).toBe(false); - expect(body.fields[0]?.completed).toBe(false); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ complete: boolean; fields: { completed: boolean }[] }>() + expect(body.complete).toBe(false) + expect(body.fields[0]?.completed).toBe(false) + }) - it("returns complete=true when all mandatory fields answered", async () => { - const field = sampleField({ isMandatory: true }); - queueSelectResults([field], [sampleResponse()]); + it('returns complete=true when all mandatory fields answered', async () => { + const field = sampleField({ isMandatory: true }) + queueSelectResults([field], [sampleResponse()]) const response = await app.inject({ - method: "GET", - url: "/api/onboarding/status", - headers: { authorization: "Bearer user-token" }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ complete: boolean; fields: { completed: boolean }[] }>(); - expect(body.complete).toBe(true); - expect(body.fields[0]?.completed).toBe(true); - }); - - it("ignores optional fields for completeness check", async () => { - const mandatoryField = sampleField({ id: "field-001", isMandatory: true }); - const optionalField = sampleField({ id: "field-002", isMandatory: false, label: "Newsletter", fieldType: "newsletter_email" }); + method: 'GET', + url: '/api/onboarding/status', + headers: { authorization: 'Bearer user-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ complete: boolean; fields: { completed: boolean }[] }>() + expect(body.complete).toBe(true) + expect(body.fields[0]?.completed).toBe(true) + }) + + it('ignores optional fields for completeness check', async () => { + const mandatoryField = sampleField({ id: 'field-001', isMandatory: true }) + const optionalField = sampleField({ + id: 'field-002', + isMandatory: false, + label: 'Newsletter', + fieldType: 'newsletter_email', + }) // Only mandatory field answered queueSelectResults( [mandatoryField, optionalField], - [sampleResponse({ fieldId: "field-001" })], - ); + [sampleResponse({ fieldId: 'field-001' })] + ) const response = await app.inject({ - method: "GET", - url: "/api/onboarding/status", - headers: { authorization: "Bearer user-token" }, - }); + method: 'GET', + url: '/api/onboarding/status', + headers: { authorization: 'Bearer user-token' }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ complete: boolean }>(); - expect(body.complete).toBe(true); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ complete: boolean }>() + expect(body.complete).toBe(true) + }) - it("rejects unauthenticated request", async () => { - const unauthApp = await buildTestApp(); + it('rejects unauthenticated request', async () => { + const unauthApp = await buildTestApp() const response = await unauthApp.inject({ - method: "GET", - url: "/api/onboarding/status", - }); + method: 'GET', + url: '/api/onboarding/status', + }) - expect(response.statusCode).toBe(401); - await unauthApp.close(); - }); - }); + expect(response.statusCode).toBe(401) + await unauthApp.close() + }) + }) // ========================================================================= // POST /api/onboarding/submit // ========================================================================= - describe("POST /api/onboarding/submit", () => { - let app: FastifyInstance; + describe('POST /api/onboarding/submit', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("submits valid responses and returns complete=true", async () => { - const field = sampleField({ fieldType: "custom_text" }); + it('submits valid responses and returns complete=true', async () => { + const field = sampleField({ fieldType: 'custom_text' }) // 1. Fetch fields for validation - queueSelectResults([field]); + queueSelectResults([field]) // 2. Insert (upsert) chain - mockDb.insert.mockReturnValueOnce(createChainableProxy([])); + mockDb.insert.mockReturnValueOnce(createChainableProxy([])) // 3. Fetch all responses for completeness check - queueSelectResults([sampleResponse()]); + queueSelectResults([sampleResponse()]) const response = await app.inject({ - method: "POST", - url: "/api/onboarding/submit", + method: 'POST', + url: '/api/onboarding/submit', headers: { - authorization: "Bearer user-token", - "content-type": "application/json", + authorization: 'Bearer user-token', + 'content-type': 'application/json', }, - payload: [ - { fieldId: "field-001", response: "I love forums" }, - ], - }); + payload: [{ fieldId: 'field-001', response: 'I love forums' }], + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ success: boolean; complete: boolean }>(); - expect(body.success).toBe(true); - expect(body.complete).toBe(true); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean; complete: boolean }>() + expect(body.success).toBe(true) + expect(body.complete).toBe(true) + }) - it("rejects submission with unknown field", async () => { - queueSelectResults([]); // no fields in community + it('rejects submission with unknown field', async () => { + queueSelectResults([]) // no fields in community const response = await app.inject({ - method: "POST", - url: "/api/onboarding/submit", + method: 'POST', + url: '/api/onboarding/submit', headers: { - authorization: "Bearer user-token", - "content-type": "application/json", + authorization: 'Bearer user-token', + 'content-type': 'application/json', }, - payload: [ - { fieldId: "unknown-field", response: "test" }, - ], - }); + payload: [{ fieldId: 'unknown-field', response: 'test' }], + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("rejects invalid tos_acceptance value (false)", async () => { - const field = sampleField({ fieldType: "tos_acceptance", label: "Accept ToS" }); - queueSelectResults([field]); + it('rejects invalid tos_acceptance value (false)', async () => { + const field = sampleField({ fieldType: 'tos_acceptance', label: 'Accept ToS' }) + queueSelectResults([field]) const response = await app.inject({ - method: "POST", - url: "/api/onboarding/submit", + method: 'POST', + url: '/api/onboarding/submit', headers: { - authorization: "Bearer user-token", - "content-type": "application/json", + authorization: 'Bearer user-token', + 'content-type': 'application/json', }, - payload: [ - { fieldId: "field-001", response: false }, - ], - }); + payload: [{ fieldId: 'field-001', response: false }], + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("rejects empty submission array", async () => { + it('rejects empty submission array', async () => { const response = await app.inject({ - method: "POST", - url: "/api/onboarding/submit", + method: 'POST', + url: '/api/onboarding/submit', headers: { - authorization: "Bearer user-token", - "content-type": "application/json", + authorization: 'Bearer user-token', + 'content-type': 'application/json', }, payload: [], - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("rejects unauthenticated request", async () => { - const unauthApp = await buildTestApp(); + it('rejects unauthenticated request', async () => { + const unauthApp = await buildTestApp() const response = await unauthApp.inject({ - method: "POST", - url: "/api/onboarding/submit", - headers: { "content-type": "application/json" }, - payload: [{ fieldId: "field-001", response: "test" }], - }); - - expect(response.statusCode).toBe(401); - await unauthApp.close(); - }); - }); -}); + method: 'POST', + url: '/api/onboarding/submit', + headers: { 'content-type': 'application/json' }, + payload: [{ fieldId: 'field-001', response: 'test' }], + }) + + expect(response.statusCode).toBe(401) + await unauthApp.close() + }) + }) +}) diff --git a/tests/unit/routes/openapi.test.ts b/tests/unit/routes/openapi.test.ts index 933d836..3f45edb 100644 --- a/tests/unit/routes/openapi.test.ts +++ b/tests/unit/routes/openapi.test.ts @@ -1,38 +1,38 @@ -import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import swagger from "@fastify/swagger"; -import type { Env } from "../../../src/config/env.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import swagger from '@fastify/swagger' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' // --------------------------------------------------------------------------- // Mock PDS client module (must be before importing routes) // --------------------------------------------------------------------------- -vi.mock("../../../src/lib/pds-client.js", () => ({ +vi.mock('../../../src/lib/pds-client.js', () => ({ createPdsClient: () => ({ createRecord: vi.fn(), updateRecord: vi.fn(), deleteRecord: vi.fn(), }), -})); +})) // Import routes AFTER mocking -import { topicRoutes } from "../../../src/routes/topics.js"; -import { replyRoutes } from "../../../src/routes/replies.js"; +import { topicRoutes } from '../../../src/routes/topics.js' +import { replyRoutes } from '../../../src/routes/replies.js' // --------------------------------------------------------------------------- // Mock env // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_DID: 'did:plc:community123', RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Mock DB (minimal, routes won't be called) @@ -44,7 +44,7 @@ const mockDb = { update: vi.fn(), delete: vi.fn(), transaction: vi.fn(), -}; +} // --------------------------------------------------------------------------- // Auth middleware mock @@ -58,7 +58,7 @@ function createMockAuthMiddleware(): AuthMiddleware { optionalAuth: async (_request, _reply) => { // No-op for OpenAPI spec tests }, - }; + } } // --------------------------------------------------------------------------- @@ -75,243 +75,240 @@ const mockFirehose = { start: vi.fn(), stop: vi.fn(), getStatus: vi.fn().mockReturnValue({ connected: true, lastEventId: null }), -}; +} // --------------------------------------------------------------------------- // Helper: build app with Swagger + routes for OpenAPI testing // --------------------------------------------------------------------------- async function buildOpenApiApp(): Promise { - const app = Fastify({ logger: false }); - - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", createMockAuthMiddleware()); - app.decorate("firehose", mockFirehose as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); + const app = Fastify({ logger: false }) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', createMockAuthMiddleware()) + app.decorate('firehose', mockFirehose as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) // Register Swagger (same config as app.ts) await app.register(swagger, { openapi: { - openapi: "3.1.0", + openapi: '3.1.0', info: { - title: "Barazo Forum API", - description: - "AT Protocol forum AppView -- portable identity, federated communities.", - version: "0.1.0", + title: 'Barazo Forum API', + description: 'AT Protocol forum AppView -- portable identity, federated communities.', + version: '0.1.0', }, servers: [ { - url: "http://localhost:3000", - description: "Primary server", + url: 'http://localhost:3000', + description: 'Primary server', }, ], components: { securitySchemes: { bearerAuth: { - type: "http", - scheme: "bearer", - description: "Access token from /api/auth/callback or /api/auth/refresh", + type: 'http', + scheme: 'bearer', + description: 'Access token from /api/auth/callback or /api/auth/refresh', }, }, }, }, - }); + }) // Register routes (so their schemas appear in OpenAPI) - await app.register(topicRoutes()); - await app.register(replyRoutes()); + await app.register(topicRoutes()) + await app.register(replyRoutes()) // OpenAPI spec endpoint - app.get("/api/openapi.json", { schema: { hide: true } }, async (_request, reply) => { - return reply - .header("Content-Type", "application/json") - .send(app.swagger()); - }); - - await app.ready(); - return app; + app.get('/api/openapi.json', { schema: { hide: true } }, async (_request, reply) => { + return reply.header('Content-Type', 'application/json').send(app.swagger()) + }) + + await app.ready() + return app } // =========================================================================== // Test suite // =========================================================================== -describe("OpenAPI spec endpoint", () => { - let app: FastifyInstance; +describe('OpenAPI spec endpoint', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildOpenApiApp(); - }); + app = await buildOpenApiApp() + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("GET /api/openapi.json returns 200", async () => { + it('GET /api/openapi.json returns 200', async () => { const response = await app.inject({ - method: "GET", - url: "/api/openapi.json", - }); + method: 'GET', + url: '/api/openapi.json', + }) - expect(response.statusCode).toBe(200); - }); + expect(response.statusCode).toBe(200) + }) - it("returns valid JSON with Content-Type application/json", async () => { + it('returns valid JSON with Content-Type application/json', async () => { const response = await app.inject({ - method: "GET", - url: "/api/openapi.json", - }); + method: 'GET', + url: '/api/openapi.json', + }) - expect(response.headers["content-type"]).toContain("application/json"); + expect(response.headers['content-type']).toContain('application/json') // Should not throw when parsing - const body = response.json>(); - expect(body).toBeDefined(); - expect(typeof body).toBe("object"); - }); + const body = response.json>() + expect(body).toBeDefined() + expect(typeof body).toBe('object') + }) - it("contains openapi version 3.1.0", async () => { + it('contains openapi version 3.1.0', async () => { const response = await app.inject({ - method: "GET", - url: "/api/openapi.json", - }); + method: 'GET', + url: '/api/openapi.json', + }) - const body = response.json<{ openapi: string }>(); - expect(body.openapi).toBe("3.1.0"); - }); + const body = response.json<{ openapi: string }>() + expect(body.openapi).toBe('3.1.0') + }) - it("contains API info with correct title and version", async () => { + it('contains API info with correct title and version', async () => { const response = await app.inject({ - method: "GET", - url: "/api/openapi.json", - }); + method: 'GET', + url: '/api/openapi.json', + }) const body = response.json<{ - info: { title: string; version: string; description: string }; - }>(); - expect(body.info.title).toBe("Barazo Forum API"); - expect(body.info.version).toBe("0.1.0"); - expect(body.info.description).toBeTruthy(); - }); - - it("contains topic paths", async () => { + info: { title: string; version: string; description: string } + }>() + expect(body.info.title).toBe('Barazo Forum API') + expect(body.info.version).toBe('0.1.0') + expect(body.info.description).toBeTruthy() + }) + + it('contains topic paths', async () => { const response = await app.inject({ - method: "GET", - url: "/api/openapi.json", - }); + method: 'GET', + url: '/api/openapi.json', + }) - const body = response.json<{ paths: Record }>(); - expect(body.paths).toBeDefined(); - expect(body.paths["/api/topics"]).toBeDefined(); - expect(body.paths["/api/topics/{uri}"]).toBeDefined(); - }); + const body = response.json<{ paths: Record }>() + expect(body.paths).toBeDefined() + expect(body.paths['/api/topics']).toBeDefined() + expect(body.paths['/api/topics/{uri}']).toBeDefined() + }) - it("contains reply paths", async () => { + it('contains reply paths', async () => { const response = await app.inject({ - method: "GET", - url: "/api/openapi.json", - }); + method: 'GET', + url: '/api/openapi.json', + }) - const body = response.json<{ paths: Record }>(); - expect(body.paths).toBeDefined(); - expect(body.paths["/api/topics/{topicUri}/replies"]).toBeDefined(); - expect(body.paths["/api/replies/{uri}"]).toBeDefined(); - }); + const body = response.json<{ paths: Record }>() + expect(body.paths).toBeDefined() + expect(body.paths['/api/topics/{topicUri}/replies']).toBeDefined() + expect(body.paths['/api/replies/{uri}']).toBeDefined() + }) - it("contains bearerAuth security scheme", async () => { + it('contains bearerAuth security scheme', async () => { const response = await app.inject({ - method: "GET", - url: "/api/openapi.json", - }); + method: 'GET', + url: '/api/openapi.json', + }) const body = response.json<{ components: { securitySchemes: { - bearerAuth: { type: string; scheme: string }; - }; - }; - }>(); - expect(body.components.securitySchemes.bearerAuth).toBeDefined(); - expect(body.components.securitySchemes.bearerAuth.type).toBe("http"); - expect(body.components.securitySchemes.bearerAuth.scheme).toBe("bearer"); - }); - - it("topic POST endpoint has correct HTTP methods", async () => { + bearerAuth: { type: string; scheme: string } + } + } + }>() + expect(body.components.securitySchemes.bearerAuth).toBeDefined() + expect(body.components.securitySchemes.bearerAuth.type).toBe('http') + expect(body.components.securitySchemes.bearerAuth.scheme).toBe('bearer') + }) + + it('topic POST endpoint has correct HTTP methods', async () => { const response = await app.inject({ - method: "GET", - url: "/api/openapi.json", - }); + method: 'GET', + url: '/api/openapi.json', + }) const body = response.json<{ - paths: Record>; - }>(); - const topicsPath = body.paths["/api/topics"]; - expect(topicsPath).toBeDefined(); + paths: Record> + }>() + const topicsPath = body.paths['/api/topics'] + expect(topicsPath).toBeDefined() // Should have POST and GET methods - expect(topicsPath?.post).toBeDefined(); - expect(topicsPath?.get).toBeDefined(); - }); + expect(topicsPath?.post).toBeDefined() + expect(topicsPath?.get).toBeDefined() + }) - it("topic CRUD endpoints have correct HTTP methods", async () => { + it('topic CRUD endpoints have correct HTTP methods', async () => { const response = await app.inject({ - method: "GET", - url: "/api/openapi.json", - }); + method: 'GET', + url: '/api/openapi.json', + }) const body = response.json<{ - paths: Record>; - }>(); - const topicByUriPath = body.paths["/api/topics/{uri}"]; - expect(topicByUriPath).toBeDefined(); + paths: Record> + }>() + const topicByUriPath = body.paths['/api/topics/{uri}'] + expect(topicByUriPath).toBeDefined() // Should have GET, PUT, DELETE methods - expect(topicByUriPath?.get).toBeDefined(); - expect(topicByUriPath?.put).toBeDefined(); - expect(topicByUriPath?.delete).toBeDefined(); - }); + expect(topicByUriPath?.get).toBeDefined() + expect(topicByUriPath?.put).toBeDefined() + expect(topicByUriPath?.delete).toBeDefined() + }) - it("reply endpoints have correct HTTP methods", async () => { + it('reply endpoints have correct HTTP methods', async () => { const response = await app.inject({ - method: "GET", - url: "/api/openapi.json", - }); + method: 'GET', + url: '/api/openapi.json', + }) const body = response.json<{ - paths: Record>; - }>(); + paths: Record> + }>() // POST + GET on topic replies - const topicRepliesPath = body.paths["/api/topics/{topicUri}/replies"]; - expect(topicRepliesPath?.post).toBeDefined(); - expect(topicRepliesPath?.get).toBeDefined(); + const topicRepliesPath = body.paths['/api/topics/{topicUri}/replies'] + expect(topicRepliesPath?.post).toBeDefined() + expect(topicRepliesPath?.get).toBeDefined() // PUT + DELETE on individual replies - const replyByUriPath = body.paths["/api/replies/{uri}"]; - expect(replyByUriPath?.put).toBeDefined(); - expect(replyByUriPath?.delete).toBeDefined(); - }); + const replyByUriPath = body.paths['/api/replies/{uri}'] + expect(replyByUriPath?.put).toBeDefined() + expect(replyByUriPath?.delete).toBeDefined() + }) - it("protected endpoints reference bearerAuth security", async () => { + it('protected endpoints reference bearerAuth security', async () => { const response = await app.inject({ - method: "GET", - url: "/api/openapi.json", - }); + method: 'GET', + url: '/api/openapi.json', + }) const body = response.json<{ - paths: Record> }>>; - }>(); + paths: Record> }>> + }>() // POST /api/topics should require bearerAuth - const postTopics = body.paths["/api/topics"]?.post; - expect(postTopics?.security).toBeDefined(); + const postTopics = body.paths['/api/topics']?.post + expect(postTopics?.security).toBeDefined() expect(postTopics?.security).toEqual( - expect.arrayContaining([expect.objectContaining({ bearerAuth: [] })]), - ); - }); -}); + expect.arrayContaining([expect.objectContaining({ bearerAuth: [] })]) + ) + }) +}) diff --git a/tests/unit/routes/profiles.test.ts b/tests/unit/routes/profiles.test.ts index 8dca8f8..dbf1092 100644 --- a/tests/unit/routes/profiles.test.ts +++ b/tests/unit/routes/profiles.test.ts @@ -1,50 +1,35 @@ -import { - describe, - it, - expect, - beforeAll, - afterAll, - vi, - beforeEach, -} from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { - AuthMiddleware, - RequestUser, -} from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { - type DbChain, - createChainableProxy, - createMockDb, -} from "../../helpers/mock-db.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' // Import routes -import { profileRoutes } from "../../../src/routes/profiles.js"; +import { profileRoutes } from '../../../src/routes/profiles.js' // --------------------------------------------------------------------------- // Mock env // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_DID: 'did:plc:community123', RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); -const COMMUNITY_DID = "did:plc:community123"; -const TEST_NOW = "2026-02-14T12:00:00.000Z"; +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) +const COMMUNITY_DID = 'did:plc:community123' +const TEST_NOW = '2026-02-14T12:00:00.000Z' // --------------------------------------------------------------------------- // Mock user builders @@ -56,7 +41,7 @@ function testUser(overrides?: Partial): RequestUser { handle: TEST_HANDLE, sid: TEST_SID, ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -67,25 +52,25 @@ function sampleUserRow(overrides?: Record) { return { did: TEST_DID, handle: TEST_HANDLE, - displayName: "Alice", - avatarUrl: "https://example.com/avatar.jpg", - bannerUrl: "https://example.com/banner.jpg", - bio: "Hello, I am Alice", - role: "user", + displayName: 'Alice', + avatarUrl: 'https://example.com/avatar.jpg', + bannerUrl: 'https://example.com/banner.jpg', + bio: 'Hello, I am Alice', + role: 'user', isBanned: false, reputationScore: 0, firstSeenAt: new Date(TEST_NOW), lastActiveAt: new Date(TEST_NOW), declaredAge: null, - maturityPref: "safe", + maturityPref: 'safe', ...overrides, - }; + } } function samplePrefsRow(overrides?: Record) { return { did: TEST_DID, - maturityLevel: "sfw", + maturityLevel: 'sfw', declaredAge: null, mutedWords: [], blockedDids: [], @@ -94,7 +79,7 @@ function samplePrefsRow(overrides?: Record) { crossPostFrontpage: false, updatedAt: new Date(TEST_NOW), ...overrides, - }; + } } function sampleCommunityPrefsRow(overrides?: Record) { @@ -108,34 +93,34 @@ function sampleCommunityPrefsRow(overrides?: Record) { notificationPrefs: null, updatedAt: new Date(TEST_NOW), ...overrides, - }; + } } // --------------------------------------------------------------------------- // Chainable mock DB // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() -let selectChain: DbChain; -let selectDistinctChain: DbChain; -let insertChain: DbChain; -let deleteChain: DbChain; +let selectChain: DbChain +let selectDistinctChain: DbChain +let insertChain: DbChain +let deleteChain: DbChain function resetAllDbMocks(): void { - selectChain = createChainableProxy([]); - selectDistinctChain = createChainableProxy([]); - insertChain = createChainableProxy(); - deleteChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); - mockDb.select.mockReturnValue(selectChain); - mockDb.selectDistinct.mockReturnValue(selectDistinctChain); - mockDb.update.mockReturnValue(createChainableProxy([])); - mockDb.delete.mockReturnValue(deleteChain); + selectChain = createChainableProxy([]) + selectDistinctChain = createChainableProxy([]) + insertChain = createChainableProxy() + deleteChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.selectDistinct.mockReturnValue(selectDistinctChain) + mockDb.update.mockReturnValue(createChainableProxy([])) + mockDb.delete.mockReturnValue(deleteChain) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { - return await fn(mockDb); - }); + return await fn(mockDb) + }) } // --------------------------------------------------------------------------- @@ -146,18 +131,18 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } // --------------------------------------------------------------------------- @@ -172,662 +157,674 @@ const mockLogger = { trace: vi.fn(), fatal: vi.fn(), child: vi.fn().mockReturnThis(), -}; +} // --------------------------------------------------------------------------- // Helper: build app with mocked deps // --------------------------------------------------------------------------- async function buildTestApp(user?: RequestUser): Promise { - const app = Fastify({ logger: false }); - - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", createMockAuthMiddleware(user)); - app.decorate("firehose", {} as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); + const app = Fastify({ logger: false }) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', createMockAuthMiddleware(user)) + app.decorate('firehose', {} as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorate('trustGraphService', { + computeTrustScores: vi.fn().mockResolvedValue({ + totalNodes: 0, + totalEdges: 0, + iterations: 0, + converged: true, + durationMs: 0, + }), + getTrustScore: vi.fn().mockResolvedValue(1.0), + } as never) + app.decorateRequest('user', undefined as RequestUser | undefined) // Override the logger so we can capture log calls - app.log.info = mockLogger.info; - app.log.warn = mockLogger.warn; - app.log.error = mockLogger.error; + app.log.info = mockLogger.info + app.log.warn = mockLogger.warn + app.log.error = mockLogger.error - await app.register(profileRoutes()); - await app.ready(); + await app.register(profileRoutes()) + await app.ready() - return app; + return app } // =========================================================================== // Test suite // =========================================================================== -describe("profile routes", () => { +describe('profile routes', () => { // ========================================================================= // GET /api/users/:handle // ========================================================================= - describe("GET /api/users/:handle", () => { - let app: FastifyInstance; + describe('GET /api/users/:handle', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns profile with activity summary", async () => { + it('returns profile with activity summary', async () => { // 1st select: user by handle - selectChain.where.mockResolvedValueOnce([sampleUserRow()]); + selectChain.where.mockResolvedValueOnce([sampleUserRow()]) // 2nd select: topic count - selectChain.where.mockResolvedValueOnce([{ count: 5 }]); + selectChain.where.mockResolvedValueOnce([{ count: 5 }]) // 3rd select: reply count - selectChain.where.mockResolvedValueOnce([{ count: 10 }]); + selectChain.where.mockResolvedValueOnce([{ count: 10 }]) // 4th select: reactions on topics - selectChain.where.mockResolvedValueOnce([{ count: 3 }]); + selectChain.where.mockResolvedValueOnce([{ count: 3 }]) // 5th select: reactions on replies - selectChain.where.mockResolvedValueOnce([{ count: 2 }]); + selectChain.where.mockResolvedValueOnce([{ count: 2 }]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/users/${TEST_HANDLE}`, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - did: string; - handle: string; - displayName: string; - bannerUrl: string | null; - bio: string | null; - role: string; + did: string + handle: string + displayName: string + bannerUrl: string | null + bio: string | null + role: string activity: { - topicCount: number; - replyCount: number; - reactionsReceived: number; - }; - }>(); - expect(body.did).toBe(TEST_DID); - expect(body.handle).toBe(TEST_HANDLE); - expect(body.displayName).toBe("Alice"); - expect(body.bannerUrl).toBe("https://example.com/banner.jpg"); - expect(body.bio).toBe("Hello, I am Alice"); - expect(body.role).toBe("user"); - expect(body.activity.topicCount).toBe(5); - expect(body.activity.replyCount).toBe(10); - expect(body.activity.reactionsReceived).toBe(5); - }); - - it("returns null for bannerUrl and bio when not set", async () => { - selectChain.where.mockResolvedValueOnce([ - sampleUserRow({ bannerUrl: null, bio: null }), - ]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); + topicCount: number + replyCount: number + reactionsReceived: number + } + }>() + expect(body.did).toBe(TEST_DID) + expect(body.handle).toBe(TEST_HANDLE) + expect(body.displayName).toBe('Alice') + expect(body.bannerUrl).toBe('https://example.com/banner.jpg') + expect(body.bio).toBe('Hello, I am Alice') + expect(body.role).toBe('user') + expect(body.activity.topicCount).toBe(5) + expect(body.activity.replyCount).toBe(10) + expect(body.activity.reactionsReceived).toBe(5) + }) + + it('returns null for bannerUrl and bio when not set', async () => { + selectChain.where.mockResolvedValueOnce([sampleUserRow({ bannerUrl: null, bio: null })]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/users/${TEST_HANDLE}`, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - bannerUrl: string | null; - bio: string | null; - }>(); - expect(body.bannerUrl).toBeNull(); - expect(body.bio).toBeNull(); - }); - - it("resolves profile through community override when communityDid is provided", async () => { + bannerUrl: string | null + bio: string | null + }>() + expect(body.bannerUrl).toBeNull() + expect(body.bio).toBeNull() + }) + + it('resolves profile through community override when communityDid is provided', async () => { // 1st select: user by handle - selectChain.where.mockResolvedValueOnce([sampleUserRow()]); + selectChain.where.mockResolvedValueOnce([sampleUserRow()]) // 2nd select: topic count - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) // 3rd select: reply count - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) // 4th select: reactions on topics - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) // 5th select: reactions on replies - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) // 6th select: community_profiles override selectChain.where.mockResolvedValueOnce([ { did: TEST_DID, communityDid: COMMUNITY_DID, - displayName: "Community Alice", + displayName: 'Community Alice', avatarUrl: null, - bannerUrl: "https://example.com/community-banner.jpg", + bannerUrl: 'https://example.com/community-banner.jpg', bio: null, updatedAt: new Date(TEST_NOW), }, - ]); + ]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/users/${TEST_HANDLE}?communityDid=${COMMUNITY_DID}`, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - did: string; - handle: string; - displayName: string; - avatarUrl: string | null; - bannerUrl: string | null; - bio: string | null; - }>(); + did: string + handle: string + displayName: string + avatarUrl: string | null + bannerUrl: string | null + bio: string | null + }>() // Community override takes precedence for displayName and bannerUrl - expect(body.displayName).toBe("Community Alice"); - expect(body.bannerUrl).toBe("https://example.com/community-banner.jpg"); + expect(body.displayName).toBe('Community Alice') + expect(body.bannerUrl).toBe('https://example.com/community-banner.jpg') // Falls back to source for avatarUrl and bio (override is null) - expect(body.avatarUrl).toBe("https://example.com/avatar.jpg"); - expect(body.bio).toBe("Hello, I am Alice"); - }); + expect(body.avatarUrl).toBe('https://example.com/avatar.jpg') + expect(body.bio).toBe('Hello, I am Alice') + }) - it("returns source profile when communityDid has no override row", async () => { + it('returns source profile when communityDid has no override row', async () => { // 1st select: user by handle - selectChain.where.mockResolvedValueOnce([sampleUserRow()]); + selectChain.where.mockResolvedValueOnce([sampleUserRow()]) // 2nd-5th select: activity counts - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) // 6th select: no community_profiles row - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/users/${TEST_HANDLE}?communityDid=${COMMUNITY_DID}`, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - displayName: string; - bannerUrl: string | null; - bio: string | null; - }>(); + displayName: string + bannerUrl: string | null + bio: string | null + }>() // Falls back to source values - expect(body.displayName).toBe("Alice"); - expect(body.bannerUrl).toBe("https://example.com/banner.jpg"); - expect(body.bio).toBe("Hello, I am Alice"); - }); + expect(body.displayName).toBe('Alice') + expect(body.bannerUrl).toBe('https://example.com/banner.jpg') + expect(body.bio).toBe('Hello, I am Alice') + }) - it("returns 404 for unknown handle", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 for unknown handle', async () => { + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/users/nonexistent.bsky.social", - }); + method: 'GET', + url: '/api/users/nonexistent.bsky.social', + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("serializes dates as ISO strings", async () => { - selectChain.where.mockResolvedValueOnce([sampleUserRow()]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); + it('serializes dates as ISO strings', async () => { + selectChain.where.mockResolvedValueOnce([sampleUserRow()]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/users/${TEST_HANDLE}`, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - firstSeenAt: string; - lastActiveAt: string; - }>(); - expect(body.firstSeenAt).toBe(TEST_NOW); - expect(body.lastActiveAt).toBe(TEST_NOW); - }); - }); + firstSeenAt: string + lastActiveAt: string + }>() + expect(body.firstSeenAt).toBe(TEST_NOW) + expect(body.lastActiveAt).toBe(TEST_NOW) + }) + }) // ========================================================================= // GET /api/users/:handle/reputation // ========================================================================= - describe("GET /api/users/:handle/reputation", () => { - let app: FastifyInstance; + describe('GET /api/users/:handle/reputation', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns computed reputation (topics*5 + replies*2 + reactions*1)", async () => { + it('returns computed reputation (topics*5 + replies*2 + reactions*1)', async () => { // User lookup - selectChain.where.mockResolvedValueOnce([sampleUserRow()]); + selectChain.where.mockResolvedValueOnce([sampleUserRow()]) // Topics: 3 - selectChain.where.mockResolvedValueOnce([{ count: 3 }]); + selectChain.where.mockResolvedValueOnce([{ count: 3 }]) // Replies: 7 - selectChain.where.mockResolvedValueOnce([{ count: 7 }]); + selectChain.where.mockResolvedValueOnce([{ count: 7 }]) // Reactions on topics: 4 - selectChain.where.mockResolvedValueOnce([{ count: 4 }]); + selectChain.where.mockResolvedValueOnce([{ count: 4 }]) // Reactions on replies: 6 - selectChain.where.mockResolvedValueOnce([{ count: 6 }]); + selectChain.where.mockResolvedValueOnce([{ count: 6 }]) + // PDS trust factor lookup (returns 1.0 so it doesn't affect the base formula) + selectChain.where.mockResolvedValueOnce([{ trustFactor: 1.0 }]) // selectDistinct for topic communities and reply communities - selectDistinctChain.where.mockResolvedValueOnce([]); - selectDistinctChain.where.mockResolvedValueOnce([]); + selectDistinctChain.where.mockResolvedValueOnce([]) + selectDistinctChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/users/${TEST_HANDLE}/reputation`, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - did: string; - handle: string; - reputation: number; + did: string + handle: string + reputation: number breakdown: { - topicCount: number; - replyCount: number; - reactionsReceived: number; - }; - }>(); - expect(body.did).toBe(TEST_DID); + topicCount: number + replyCount: number + reactionsReceived: number + } + }>() + expect(body.did).toBe(TEST_DID) // reputation = (3 * 5) + (7 * 2) + (4 + 6) * 1 = 15 + 14 + 10 = 39 - expect(body.reputation).toBe(39); - expect(body.breakdown.topicCount).toBe(3); - expect(body.breakdown.replyCount).toBe(7); - expect(body.breakdown.reactionsReceived).toBe(10); - }); + expect(body.reputation).toBe(39) + expect(body.breakdown.topicCount).toBe(3) + expect(body.breakdown.replyCount).toBe(7) + expect(body.breakdown.reactionsReceived).toBe(10) + }) - it("returns 404 for unknown handle", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 for unknown handle', async () => { + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/users/nonexistent.bsky.social/reputation", - }); - - expect(response.statusCode).toBe(404); - }); - - it("returns zero reputation for user with no activity", async () => { - selectChain.where.mockResolvedValueOnce([sampleUserRow()]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); + method: 'GET', + url: '/api/users/nonexistent.bsky.social/reputation', + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns zero reputation for user with no activity', async () => { + selectChain.where.mockResolvedValueOnce([sampleUserRow()]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // PDS trust factor lookup + selectChain.where.mockResolvedValueOnce([{ trustFactor: 1.0 }]) // selectDistinct for topic communities and reply communities - selectDistinctChain.where.mockResolvedValueOnce([]); - selectDistinctChain.where.mockResolvedValueOnce([]); + selectDistinctChain.where.mockResolvedValueOnce([]) + selectDistinctChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/users/${TEST_HANDLE}/reputation`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ reputation: number; communityCount: number }>(); - expect(body.reputation).toBe(0); - expect(body.communityCount).toBe(0); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ reputation: number; communityCount: number }>() + expect(body.reputation).toBe(0) + expect(body.communityCount).toBe(0) + }) - it("includes communityCount in reputation response", async () => { + it('includes communityCount in reputation response', async () => { // User lookup - selectChain.where.mockResolvedValueOnce([sampleUserRow()]); + selectChain.where.mockResolvedValueOnce([sampleUserRow()]) // Topics: 3 - selectChain.where.mockResolvedValueOnce([{ count: 3 }]); + selectChain.where.mockResolvedValueOnce([{ count: 3 }]) // Replies: 7 - selectChain.where.mockResolvedValueOnce([{ count: 7 }]); + selectChain.where.mockResolvedValueOnce([{ count: 7 }]) // Reactions on topics: 4 - selectChain.where.mockResolvedValueOnce([{ count: 4 }]); + selectChain.where.mockResolvedValueOnce([{ count: 4 }]) // Reactions on replies: 6 - selectChain.where.mockResolvedValueOnce([{ count: 6 }]); + selectChain.where.mockResolvedValueOnce([{ count: 6 }]) + // PDS trust factor lookup + selectChain.where.mockResolvedValueOnce([{ trustFactor: 1.0 }]) // Distinct communities from topics selectDistinctChain.where.mockResolvedValueOnce([ - { communityDid: "did:plc:comm-a" }, - { communityDid: "did:plc:comm-b" }, - ]); + { communityDid: 'did:plc:comm-a' }, + { communityDid: 'did:plc:comm-b' }, + ]) // Distinct communities from replies selectDistinctChain.where.mockResolvedValueOnce([ - { communityDid: "did:plc:comm-b" }, - { communityDid: "did:plc:comm-c" }, - ]); + { communityDid: 'did:plc:comm-b' }, + { communityDid: 'did:plc:comm-c' }, + ]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/users/${TEST_HANDLE}/reputation`, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - did: string; - handle: string; - reputation: number; + did: string + handle: string + reputation: number breakdown: { - topicCount: number; - replyCount: number; - reactionsReceived: number; - }; - communityCount: number; - }>(); - expect(body.communityCount).toBe(3); // comm-a, comm-b, comm-c (deduplicated) - }); - - it("counts distinct communities across topics and replies", async () => { + topicCount: number + replyCount: number + reactionsReceived: number + } + communityCount: number + }>() + expect(body.communityCount).toBe(3) // comm-a, comm-b, comm-c (deduplicated) + }) + + it('counts distinct communities across topics and replies', async () => { // User lookup - selectChain.where.mockResolvedValueOnce([sampleUserRow()]); + selectChain.where.mockResolvedValueOnce([sampleUserRow()]) // Topics: 2 - selectChain.where.mockResolvedValueOnce([{ count: 2 }]); + selectChain.where.mockResolvedValueOnce([{ count: 2 }]) // Replies: 1 - selectChain.where.mockResolvedValueOnce([{ count: 1 }]); + selectChain.where.mockResolvedValueOnce([{ count: 1 }]) // Reactions on topics: 0 - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) // Reactions on replies: 0 - selectChain.where.mockResolvedValueOnce([{ count: 0 }]); + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // PDS trust factor lookup + selectChain.where.mockResolvedValueOnce([{ trustFactor: 1.0 }]) // Topic communities -- user created topics only in comm-a - selectDistinctChain.where.mockResolvedValueOnce([ - { communityDid: "did:plc:comm-a" }, - ]); + selectDistinctChain.where.mockResolvedValueOnce([{ communityDid: 'did:plc:comm-a' }]) // Reply communities -- user replied only in comm-b (different community) - selectDistinctChain.where.mockResolvedValueOnce([ - { communityDid: "did:plc:comm-b" }, - ]); + selectDistinctChain.where.mockResolvedValueOnce([{ communityDid: 'did:plc:comm-b' }]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/users/${TEST_HANDLE}/reputation`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ communityCount: number }>(); + expect(response.statusCode).toBe(200) + const body = response.json<{ communityCount: number }>() // 2 distinct communities: comm-a (from topics) + comm-b (from replies) - expect(body.communityCount).toBe(2); - }); - }); + expect(body.communityCount).toBe(2) + }) + }) // ========================================================================= // POST /api/users/me/age-declaration // ========================================================================= - describe("POST /api/users/me/age-declaration", () => { - let app: FastifyInstance; + describe('POST /api/users/me/age-declaration', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("stores declared age and returns it", async () => { + it('stores declared age and returns it', async () => { const response = await app.inject({ - method: "POST", - url: "/api/users/me/age-declaration", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/users/me/age-declaration', + headers: { authorization: 'Bearer test-token' }, payload: { declaredAge: 16 }, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - success: boolean; - declaredAge: number; - }>(); - expect(body.success).toBe(true); - expect(body.declaredAge).toBe(16); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); - - it("accepts declaredAge 0 (rather not say)", async () => { + success: boolean + declaredAge: number + }>() + expect(body.success).toBe(true) + expect(body.declaredAge).toBe(16) + expect(mockDb.insert).toHaveBeenCalledOnce() + }) + + it('accepts declaredAge 0 (rather not say)', async () => { const response = await app.inject({ - method: "POST", - url: "/api/users/me/age-declaration", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/users/me/age-declaration', + headers: { authorization: 'Bearer test-token' }, payload: { declaredAge: 0 }, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - success: boolean; - declaredAge: number; - }>(); - expect(body.success).toBe(true); - expect(body.declaredAge).toBe(0); - }); + success: boolean + declaredAge: number + }>() + expect(body.success).toBe(true) + expect(body.declaredAge).toBe(0) + }) - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "POST", - url: "/api/users/me/age-declaration", + method: 'POST', + url: '/api/users/me/age-declaration', payload: { declaredAge: 16 }, - }); + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("returns 400 when declaredAge is invalid", async () => { + it('returns 400 when declaredAge is invalid', async () => { const response = await app.inject({ - method: "POST", - url: "/api/users/me/age-declaration", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/users/me/age-declaration', + headers: { authorization: 'Bearer test-token' }, payload: { declaredAge: 17 }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 when body is empty", async () => { + it('returns 400 when body is empty', async () => { const response = await app.inject({ - method: "POST", - url: "/api/users/me/age-declaration", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/users/me/age-declaration', + headers: { authorization: 'Bearer test-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(400); - }); - }); + expect(response.statusCode).toBe(400) + }) + }) // ========================================================================= // GET /api/users/me/preferences // ========================================================================= - describe("GET /api/users/me/preferences", () => { - let app: FastifyInstance; + describe('GET /api/users/me/preferences', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns existing preferences", async () => { + it('returns existing preferences', async () => { selectChain.where.mockResolvedValueOnce([ - samplePrefsRow({ maturityLevel: "mature", crossPostBluesky: true }), - ]); + samplePrefsRow({ maturityLevel: 'mature', crossPostBluesky: true }), + ]) const response = await app.inject({ - method: "GET", - url: "/api/users/me/preferences", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/users/me/preferences', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - maturityLevel: string; - crossPostBluesky: boolean; - }>(); - expect(body.maturityLevel).toBe("mature"); - expect(body.crossPostBluesky).toBe(true); - }); + maturityLevel: string + crossPostBluesky: boolean + }>() + expect(body.maturityLevel).toBe('mature') + expect(body.crossPostBluesky).toBe(true) + }) - it("returns defaults when no preferences row exists", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns defaults when no preferences row exists', async () => { + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/users/me/preferences", - headers: { authorization: "Bearer test-token" }, - }); + method: 'GET', + url: '/api/users/me/preferences', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - maturityLevel: string; - mutedWords: string[]; - crossPostBluesky: boolean; - crossPostFrontpage: boolean; - }>(); - expect(body.maturityLevel).toBe("sfw"); - expect(body.mutedWords).toEqual([]); - expect(body.crossPostBluesky).toBe(false); - expect(body.crossPostFrontpage).toBe(false); - }); - - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + maturityLevel: string + mutedWords: string[] + crossPostBluesky: boolean + crossPostFrontpage: boolean + }>() + expect(body.maturityLevel).toBe('sfw') + expect(body.mutedWords).toEqual([]) + expect(body.crossPostBluesky).toBe(false) + expect(body.crossPostFrontpage).toBe(false) + }) + + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "GET", - url: "/api/users/me/preferences", - }); + method: 'GET', + url: '/api/users/me/preferences', + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) + }) // ========================================================================= // PUT /api/users/me/preferences // ========================================================================= - describe("PUT /api/users/me/preferences", () => { - let app: FastifyInstance; + describe('PUT /api/users/me/preferences', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("upserts preferences and returns updated values", async () => { + it('upserts preferences and returns updated values', async () => { // After upsert, the select returns updated prefs selectChain.where.mockResolvedValueOnce([ samplePrefsRow({ - maturityLevel: "mature", - mutedWords: ["spoiler"], + maturityLevel: 'mature', + mutedWords: ['spoiler'], }), - ]); + ]) const response = await app.inject({ - method: "PUT", - url: "/api/users/me/preferences", - headers: { authorization: "Bearer test-token" }, + method: 'PUT', + url: '/api/users/me/preferences', + headers: { authorization: 'Bearer test-token' }, payload: { - maturityLevel: "mature", - mutedWords: ["spoiler"], + maturityLevel: 'mature', + mutedWords: ['spoiler'], }, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - maturityLevel: string; - mutedWords: string[]; - }>(); - expect(body.maturityLevel).toBe("mature"); - expect(body.mutedWords).toEqual(["spoiler"]); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); + maturityLevel: string + mutedWords: string[] + }>() + expect(body.maturityLevel).toBe('mature') + expect(body.mutedWords).toEqual(['spoiler']) + expect(mockDb.insert).toHaveBeenCalledOnce() + }) - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "PUT", - url: "/api/users/me/preferences", - payload: { maturityLevel: "sfw" }, - }); + method: 'PUT', + url: '/api/users/me/preferences', + payload: { maturityLevel: 'sfw' }, + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("returns 400 for invalid maturityLevel", async () => { + it('returns 400 for invalid maturityLevel', async () => { const response = await app.inject({ - method: "PUT", - url: "/api/users/me/preferences", - headers: { authorization: "Bearer test-token" }, - payload: { maturityLevel: "invalid" }, - }); + method: 'PUT', + url: '/api/users/me/preferences', + headers: { authorization: 'Bearer test-token' }, + payload: { maturityLevel: 'invalid' }, + }) - expect(response.statusCode).toBe(400); - }); - }); + expect(response.statusCode).toBe(400) + }) + }) // ========================================================================= // GET /api/users/me/communities/:communityId/preferences // ========================================================================= - describe("GET /api/users/me/communities/:communityId/preferences", () => { - let app: FastifyInstance; + describe('GET /api/users/me/communities/:communityId/preferences', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns existing community preferences", async () => { + it('returns existing community preferences', async () => { selectChain.where.mockResolvedValueOnce([ sampleCommunityPrefsRow({ - maturityOverride: "mature", + maturityOverride: 'mature', notificationPrefs: { replies: true, reactions: false, @@ -835,196 +832,196 @@ describe("profile routes", () => { modActions: true, }, }), - ]); + ]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/users/me/communities/${COMMUNITY_DID}/preferences`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - communityDid: string; - maturityOverride: string; - notificationPrefs: { replies: boolean }; - }>(); - expect(body.communityDid).toBe(COMMUNITY_DID); - expect(body.maturityOverride).toBe("mature"); - expect(body.notificationPrefs.replies).toBe(true); - }); - - it("returns defaults when no row exists", async () => { - selectChain.where.mockResolvedValueOnce([]); + communityDid: string + maturityOverride: string + notificationPrefs: { replies: boolean } + }>() + expect(body.communityDid).toBe(COMMUNITY_DID) + expect(body.maturityOverride).toBe('mature') + expect(body.notificationPrefs.replies).toBe(true) + }) + + it('returns defaults when no row exists', async () => { + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/users/me/communities/${COMMUNITY_DID}/preferences`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - communityDid: string; - maturityOverride: null; - mutedWords: null; - notificationPrefs: null; - }>(); - expect(body.communityDid).toBe(COMMUNITY_DID); - expect(body.maturityOverride).toBeNull(); - expect(body.mutedWords).toBeNull(); - expect(body.notificationPrefs).toBeNull(); - }); - - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + communityDid: string + maturityOverride: null + mutedWords: null + notificationPrefs: null + }>() + expect(body.communityDid).toBe(COMMUNITY_DID) + expect(body.maturityOverride).toBeNull() + expect(body.mutedWords).toBeNull() + expect(body.notificationPrefs).toBeNull() + }) + + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "GET", + method: 'GET', url: `/api/users/me/communities/${COMMUNITY_DID}/preferences`, - }); + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) + }) // ========================================================================= // PUT /api/users/me/communities/:communityId/preferences // ========================================================================= - describe("PUT /api/users/me/communities/:communityId/preferences", () => { - let app: FastifyInstance; + describe('PUT /api/users/me/communities/:communityId/preferences', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("upserts community preferences and returns updated values", async () => { + it('upserts community preferences and returns updated values', async () => { // After upsert, the select returns updated prefs selectChain.where.mockResolvedValueOnce([ sampleCommunityPrefsRow({ - maturityOverride: "sfw", - mutedWords: ["spam"], + maturityOverride: 'sfw', + mutedWords: ['spam'], }), - ]); + ]) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/users/me/communities/${COMMUNITY_DID}/preferences`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - maturityOverride: "sfw", - mutedWords: ["spam"], + maturityOverride: 'sfw', + mutedWords: ['spam'], }, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - communityDid: string; - maturityOverride: string; - mutedWords: string[]; - }>(); - expect(body.communityDid).toBe(COMMUNITY_DID); - expect(body.maturityOverride).toBe("sfw"); - expect(body.mutedWords).toEqual(["spam"]); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); - - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + communityDid: string + maturityOverride: string + mutedWords: string[] + }>() + expect(body.communityDid).toBe(COMMUNITY_DID) + expect(body.maturityOverride).toBe('sfw') + expect(body.mutedWords).toEqual(['spam']) + expect(mockDb.insert).toHaveBeenCalledOnce() + }) + + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "PUT", + method: 'PUT', url: `/api/users/me/communities/${COMMUNITY_DID}/preferences`, - payload: { maturityOverride: "sfw" }, - }); + payload: { maturityOverride: 'sfw' }, + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("returns 400 for invalid maturityOverride", async () => { + it('returns 400 for invalid maturityOverride', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/users/me/communities/${COMMUNITY_DID}/preferences`, - headers: { authorization: "Bearer test-token" }, - payload: { maturityOverride: "adult" }, - }); + headers: { authorization: 'Bearer test-token' }, + payload: { maturityOverride: 'adult' }, + }) - expect(response.statusCode).toBe(400); - }); - }); + expect(response.statusCode).toBe(400) + }) + }) // ========================================================================= // DELETE /api/users/me // ========================================================================= - describe("DELETE /api/users/me", () => { - let app: FastifyInstance; + describe('DELETE /api/users/me', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("deletes all data and returns 204", async () => { + it('deletes all data and returns 204', async () => { const response = await app.inject({ - method: "DELETE", - url: "/api/users/me", - headers: { authorization: "Bearer test-token" }, - }); + method: 'DELETE', + url: '/api/users/me', + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(204); + expect(response.statusCode).toBe(204) // Transaction should be called once - expect(mockDb.transaction).toHaveBeenCalledOnce(); + expect(mockDb.transaction).toHaveBeenCalledOnce() // Multiple delete calls within transaction (reactions, notifications x2, // reports, replies, topics, community profiles, community prefs, user prefs, users) - expect(mockDb.delete).toHaveBeenCalled(); + expect(mockDb.delete).toHaveBeenCalled() // Check at least 9 delete calls (one per table) - expect(mockDb.delete.mock.calls.length).toBeGreaterThanOrEqual(9); - }); + expect(mockDb.delete.mock.calls.length).toBeGreaterThanOrEqual(9) + }) - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) const response = await noAuthApp.inject({ - method: "DELETE", - url: "/api/users/me", - }); + method: 'DELETE', + url: '/api/users/me', + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("logs the GDPR purge with the user DID", async () => { + it('logs the GDPR purge with the user DID', async () => { await app.inject({ - method: "DELETE", - url: "/api/users/me", - headers: { authorization: "Bearer test-token" }, - }); + method: 'DELETE', + url: '/api/users/me', + headers: { authorization: 'Bearer test-token' }, + }) expect(mockLogger.info).toHaveBeenCalledWith( { did: TEST_DID }, - "GDPR Art. 17: all indexed data purged for user", - ); - }); - }); -}); + 'GDPR Art. 17: all indexed data purged for user' + ) + }) + }) +}) diff --git a/tests/unit/routes/reactions.test.ts b/tests/unit/routes/reactions.test.ts index fc28a15..df78e4d 100644 --- a/tests/unit/routes/reactions.test.ts +++ b/tests/unit/routes/reactions.test.ts @@ -1,59 +1,66 @@ -import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { type DbChain, createChainableProxy, createMockDb } from "../../helpers/mock-db.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' // --------------------------------------------------------------------------- // Mock PDS client module (must be before importing routes) // --------------------------------------------------------------------------- -const createRecordFn = vi.fn<(did: string, collection: string, record: Record) => Promise<{ uri: string; cid: string }>>(); -const deleteRecordFn = vi.fn<(did: string, collection: string, rkey: string) => Promise>(); - -vi.mock("../../../src/lib/pds-client.js", () => ({ +const createRecordFn = + vi.fn< + ( + did: string, + collection: string, + record: Record + ) => Promise<{ uri: string; cid: string }> + >() +const deleteRecordFn = vi.fn<(did: string, collection: string, rkey: string) => Promise>() + +vi.mock('../../../src/lib/pds-client.js', () => ({ createPdsClient: () => ({ createRecord: createRecordFn, deleteRecord: deleteRecordFn, updateRecord: vi.fn(), }), -})); +})) // Import routes AFTER mocking -import { reactionRoutes } from "../../../src/routes/reactions.js"; +import { reactionRoutes } from '../../../src/routes/reactions.js' // --------------------------------------------------------------------------- // Mock env (minimal subset for reaction routes) // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_DID: 'did:plc:community123', RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); -const OTHER_DID = "did:plc:otheruser456"; -const COMMUNITY_DID = "did:plc:community123"; +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) +const OTHER_DID = 'did:plc:otheruser456' +const COMMUNITY_DID = 'did:plc:community123' -const TEST_TOPIC_URI = `at://${OTHER_DID}/forum.barazo.topic.post/topic123`; -const TEST_TOPIC_CID = "bafyreitopic123"; -const TEST_REPLY_URI = `at://${OTHER_DID}/forum.barazo.topic.reply/reply123`; -const TEST_REPLY_CID = "bafyreireply123"; +const TEST_TOPIC_URI = `at://${OTHER_DID}/forum.barazo.topic.post/topic123` +const TEST_TOPIC_CID = 'bafyreitopic123' +const TEST_REPLY_URI = `at://${OTHER_DID}/forum.barazo.topic.reply/reply123` +const TEST_REPLY_CID = 'bafyreireply123' -const TEST_REACTION_URI = `at://${TEST_DID}/forum.barazo.interaction.reaction/react123`; -const TEST_REACTION_CID = "bafyreireact123"; -const TEST_NOW = "2026-02-13T12:00:00.000Z"; +const TEST_REACTION_URI = `at://${TEST_DID}/forum.barazo.interaction.reaction/react123` +const TEST_REACTION_CID = 'bafyreireact123' +const TEST_NOW = '2026-02-13T12:00:00.000Z' // --------------------------------------------------------------------------- // Mock user builders @@ -65,54 +72,54 @@ function testUser(overrides?: Partial): RequestUser { handle: TEST_HANDLE, sid: TEST_SID, ...overrides, - }; + } } // --------------------------------------------------------------------------- // Mock firehose repo manager // --------------------------------------------------------------------------- -const isTrackedFn = vi.fn<(did: string) => Promise>(); -const trackRepoFn = vi.fn<(did: string) => Promise>(); +const isTrackedFn = vi.fn<(did: string) => Promise>() +const trackRepoFn = vi.fn<(did: string) => Promise>() const mockRepoManager = { isTracked: isTrackedFn, trackRepo: trackRepoFn, untrackRepo: vi.fn(), restoreTrackedRepos: vi.fn(), -}; +} const mockFirehose = { getRepoManager: () => mockRepoManager, start: vi.fn(), stop: vi.fn(), getStatus: vi.fn().mockReturnValue({ connected: true, lastEventId: null }), -}; +} // --------------------------------------------------------------------------- // Chainable mock DB (shared helper) // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() -let insertChain: DbChain; -let selectChain: DbChain; -let updateChain: DbChain; -let deleteChain: DbChain; +let insertChain: DbChain +let selectChain: DbChain +let updateChain: DbChain +let deleteChain: DbChain function resetAllDbMocks(): void { - insertChain = createChainableProxy(); - selectChain = createChainableProxy([]); - updateChain = createChainableProxy([]); - deleteChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); - mockDb.select.mockReturnValue(selectChain); - mockDb.update.mockReturnValue(updateChain); - mockDb.delete.mockReturnValue(deleteChain); + insertChain = createChainableProxy() + selectChain = createChainableProxy([]) + updateChain = createChainableProxy([]) + deleteChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(updateChain) + mockDb.delete.mockReturnValue(deleteChain) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { - return await fn(mockDb); - }); + return await fn(mockDb) + }) } // --------------------------------------------------------------------------- @@ -123,18 +130,18 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } // --------------------------------------------------------------------------- @@ -144,17 +151,17 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { function sampleReactionRow(overrides?: Record) { return { uri: TEST_REACTION_URI, - rkey: "react123", + rkey: 'react123', authorDid: TEST_DID, subjectUri: TEST_TOPIC_URI, subjectCid: TEST_TOPIC_CID, - type: "like", + type: 'like', communityDid: COMMUNITY_DID, cid: TEST_REACTION_CID, createdAt: new Date(TEST_NOW), indexedAt: new Date(TEST_NOW), ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -162,691 +169,708 @@ function sampleReactionRow(overrides?: Record) { // --------------------------------------------------------------------------- async function buildTestApp(user?: RequestUser): Promise { - const app = Fastify({ logger: false }); - - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", createMockAuthMiddleware(user)); - app.decorate("firehose", mockFirehose as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); - - await app.register(reactionRoutes()); - await app.ready(); - - return app; + const app = Fastify({ logger: false }) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', createMockAuthMiddleware(user)) + app.decorate('firehose', mockFirehose as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorate('interactionGraphService', { + recordReply: vi.fn().mockResolvedValue(undefined), + recordReaction: vi.fn().mockResolvedValue(undefined), + recordCoParticipation: vi.fn().mockResolvedValue(undefined), + } as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + + await app.register(reactionRoutes()) + await app.ready() + + return app } // =========================================================================== // Test suite // =========================================================================== -describe("reaction routes", () => { +describe('reaction routes', () => { // ========================================================================= // POST /api/reactions // ========================================================================= - describe("POST /api/reactions", () => { - let app: FastifyInstance; + describe('POST /api/reactions', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); + vi.clearAllMocks() + resetAllDbMocks() // Default mocks for successful create - createRecordFn.mockResolvedValue({ uri: TEST_REACTION_URI, cid: TEST_REACTION_CID }); - isTrackedFn.mockResolvedValue(true); - }); + createRecordFn.mockResolvedValue({ uri: TEST_REACTION_URI, cid: TEST_REACTION_CID }) + isTrackedFn.mockResolvedValue(true) + }) - it("creates a reaction on a topic and returns 201", async () => { + it('creates a reaction on a topic and returns 201', async () => { // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) // 1. Community settings query -> reactionSet includes "like" - selectChain.where.mockResolvedValueOnce([{ reactionSet: ["like", "heart"] }]); + selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like', 'heart'] }]) // 2. Subject existence check -> topic found - selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]); + selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) // 3. Insert returning - insertChain.returning.mockResolvedValueOnce([sampleReactionRow()]); + insertChain.returning.mockResolvedValueOnce([sampleReactionRow()]) const response = await app.inject({ - method: "POST", - url: "/api/reactions", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/reactions', + headers: { authorization: 'Bearer test-token' }, payload: { subjectUri: TEST_TOPIC_URI, subjectCid: TEST_TOPIC_CID, - type: "like", + type: 'like', }, - }); - - expect(response.statusCode).toBe(201); - const body = response.json<{ uri: string; cid: string; rkey: string; type: string; subjectUri: string }>(); - expect(body.uri).toBe(TEST_REACTION_URI); - expect(body.cid).toBe(TEST_REACTION_CID); - expect(body.type).toBe("like"); - expect(body.subjectUri).toBe(TEST_TOPIC_URI); + }) + + expect(response.statusCode).toBe(201) + const body = response.json<{ + uri: string + cid: string + rkey: string + type: string + subjectUri: string + }>() + expect(body.uri).toBe(TEST_REACTION_URI) + expect(body.cid).toBe(TEST_REACTION_CID) + expect(body.type).toBe('like') + expect(body.subjectUri).toBe(TEST_TOPIC_URI) // Should have called PDS createRecord - expect(createRecordFn).toHaveBeenCalledOnce(); - expect(createRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID); - expect(createRecordFn.mock.calls[0]?.[1]).toBe("forum.barazo.interaction.reaction"); + expect(createRecordFn).toHaveBeenCalledOnce() + expect(createRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID) + expect(createRecordFn.mock.calls[0]?.[1]).toBe('forum.barazo.interaction.reaction') // Should have inserted into DB - expect(mockDb.insert).toHaveBeenCalledOnce(); + expect(mockDb.insert).toHaveBeenCalledOnce() // Should have incremented reaction count - expect(mockDb.update).toHaveBeenCalledOnce(); - }); + expect(mockDb.update).toHaveBeenCalledOnce() + }) - it("creates a reaction on a reply and returns 201", async () => { + it('creates a reaction on a reply and returns 201', async () => { // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) // 1. Community settings - selectChain.where.mockResolvedValueOnce([{ reactionSet: ["like"] }]); + selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) // 2. Subject existence check -> reply found - selectChain.where.mockResolvedValueOnce([{ uri: TEST_REPLY_URI }]); + selectChain.where.mockResolvedValueOnce([{ uri: TEST_REPLY_URI }]) // 3. Insert returning const replyReaction = sampleReactionRow({ subjectUri: TEST_REPLY_URI, subjectCid: TEST_REPLY_CID, - }); - insertChain.returning.mockResolvedValueOnce([replyReaction]); + }) + insertChain.returning.mockResolvedValueOnce([replyReaction]) const response = await app.inject({ - method: "POST", - url: "/api/reactions", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/reactions', + headers: { authorization: 'Bearer test-token' }, payload: { subjectUri: TEST_REPLY_URI, subjectCid: TEST_REPLY_CID, - type: "like", + type: 'like', }, - }); + }) - expect(response.statusCode).toBe(201); - const body = response.json<{ subjectUri: string }>(); - expect(body.subjectUri).toBe(TEST_REPLY_URI); - }); + expect(response.statusCode).toBe(201) + const body = response.json<{ subjectUri: string }>() + expect(body.subjectUri).toBe(TEST_REPLY_URI) + }) it("tracks new user's repo on first reaction", async () => { - isTrackedFn.mockResolvedValue(false); - trackRepoFn.mockResolvedValue(undefined); + isTrackedFn.mockResolvedValue(false) + trackRepoFn.mockResolvedValue(undefined) // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]); - selectChain.where.mockResolvedValueOnce([{ reactionSet: ["like"] }]); - selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]); - insertChain.returning.mockResolvedValueOnce([sampleReactionRow()]); + selectChain.where.mockResolvedValueOnce([]) + selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) + selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) + insertChain.returning.mockResolvedValueOnce([sampleReactionRow()]) const response = await app.inject({ - method: "POST", - url: "/api/reactions", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/reactions', + headers: { authorization: 'Bearer test-token' }, payload: { subjectUri: TEST_TOPIC_URI, subjectCid: TEST_TOPIC_CID, - type: "like", + type: 'like', }, - }); + }) - expect(response.statusCode).toBe(201); - expect(isTrackedFn).toHaveBeenCalledWith(TEST_DID); - expect(trackRepoFn).toHaveBeenCalledWith(TEST_DID); - }); + expect(response.statusCode).toBe(201) + expect(isTrackedFn).toHaveBeenCalledWith(TEST_DID) + expect(trackRepoFn).toHaveBeenCalledWith(TEST_DID) + }) - it("returns 400 for missing subjectUri", async () => { + it('returns 400 for missing subjectUri', async () => { const response = await app.inject({ - method: "POST", - url: "/api/reactions", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/reactions', + headers: { authorization: 'Bearer test-token' }, payload: { subjectCid: TEST_TOPIC_CID, - type: "like", + type: 'like', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for missing subjectCid", async () => { + it('returns 400 for missing subjectCid', async () => { const response = await app.inject({ - method: "POST", - url: "/api/reactions", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/reactions', + headers: { authorization: 'Bearer test-token' }, payload: { subjectUri: TEST_TOPIC_URI, - type: "like", + type: 'like', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for missing type", async () => { + it('returns 400 for missing type', async () => { const response = await app.inject({ - method: "POST", - url: "/api/reactions", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/reactions', + headers: { authorization: 'Bearer test-token' }, payload: { subjectUri: TEST_TOPIC_URI, subjectCid: TEST_TOPIC_CID, }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for type exceeding max length", async () => { + it('returns 400 for type exceeding max length', async () => { const response = await app.inject({ - method: "POST", - url: "/api/reactions", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/reactions', + headers: { authorization: 'Bearer test-token' }, payload: { subjectUri: TEST_TOPIC_URI, subjectCid: TEST_TOPIC_CID, - type: "a".repeat(31), + type: 'a'.repeat(31), }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for empty body", async () => { + it('returns 400 for empty body', async () => { const response = await app.inject({ - method: "POST", - url: "/api/reactions", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/reactions', + headers: { authorization: 'Bearer test-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) it("returns 400 when reaction type is not in community's reaction set", async () => { // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) // Community only allows "like" - selectChain.where.mockResolvedValueOnce([{ reactionSet: ["like"] }]); + selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) const response = await app.inject({ - method: "POST", - url: "/api/reactions", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/reactions', + headers: { authorization: 'Bearer test-token' }, payload: { subjectUri: TEST_TOPIC_URI, subjectCid: TEST_TOPIC_CID, - type: "heart", + type: 'heart', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) it("uses default reaction set ['like'] when no settings exist", async () => { // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) // No settings row found - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) // Subject exists - selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]); - insertChain.returning.mockResolvedValueOnce([sampleReactionRow()]); + selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) + insertChain.returning.mockResolvedValueOnce([sampleReactionRow()]) const response = await app.inject({ - method: "POST", - url: "/api/reactions", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/reactions', + headers: { authorization: 'Bearer test-token' }, payload: { subjectUri: TEST_TOPIC_URI, subjectCid: TEST_TOPIC_CID, - type: "like", + type: 'like', }, - }); + }) - expect(response.statusCode).toBe(201); - }); + expect(response.statusCode).toBe(201) + }) - it("returns 404 when subject does not exist", async () => { + it('returns 404 when subject does not exist', async () => { // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]); - selectChain.where.mockResolvedValueOnce([{ reactionSet: ["like"] }]); + selectChain.where.mockResolvedValueOnce([]) + selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) // Subject not found - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ - method: "POST", - url: "/api/reactions", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/reactions', + headers: { authorization: 'Bearer test-token' }, payload: { subjectUri: TEST_TOPIC_URI, subjectCid: TEST_TOPIC_CID, - type: "like", + type: 'like', }, - }); + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 404 when subject URI has unknown collection", async () => { + it('returns 404 when subject URI has unknown collection', async () => { // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]); - selectChain.where.mockResolvedValueOnce([{ reactionSet: ["like"] }]); + selectChain.where.mockResolvedValueOnce([]) + selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) // Unknown collection -> subjectExists stays false const response = await app.inject({ - method: "POST", - url: "/api/reactions", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/reactions', + headers: { authorization: 'Bearer test-token' }, payload: { subjectUri: `at://${OTHER_DID}/some.unknown.collection/xyz123`, - subjectCid: "bafyreixyz", - type: "like", + subjectCid: 'bafyreixyz', + type: 'like', }, - }); + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 409 when duplicate reaction (unique constraint)", async () => { + it('returns 409 when duplicate reaction (unique constraint)', async () => { // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]); - selectChain.where.mockResolvedValueOnce([{ reactionSet: ["like"] }]); - selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]); + selectChain.where.mockResolvedValueOnce([]) + selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) + selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) // onConflictDoNothing -> returning() returns empty array - insertChain.returning.mockResolvedValueOnce([]); + insertChain.returning.mockResolvedValueOnce([]) const response = await app.inject({ - method: "POST", - url: "/api/reactions", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/reactions', + headers: { authorization: 'Bearer test-token' }, payload: { subjectUri: TEST_TOPIC_URI, subjectCid: TEST_TOPIC_CID, - type: "like", + type: 'like', }, - }); + }) - expect(response.statusCode).toBe(409); - }); + expect(response.statusCode).toBe(409) + }) - it("returns 502 when PDS write fails", async () => { + it('returns 502 when PDS write fails', async () => { // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]); - selectChain.where.mockResolvedValueOnce([{ reactionSet: ["like"] }]); - selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]); - createRecordFn.mockRejectedValueOnce(new Error("PDS unreachable")); + selectChain.where.mockResolvedValueOnce([]) + selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) + selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) + createRecordFn.mockRejectedValueOnce(new Error('PDS unreachable')) const response = await app.inject({ - method: "POST", - url: "/api/reactions", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/reactions', + headers: { authorization: 'Bearer test-token' }, payload: { subjectUri: TEST_TOPIC_URI, subjectCid: TEST_TOPIC_CID, - type: "like", + type: 'like', }, - }); + }) - expect(response.statusCode).toBe(502); - }); - }); + expect(response.statusCode).toBe(502) + }) + }) - describe("POST /api/reactions (unauthenticated)", () => { - let app: FastifyInstance; + describe('POST /api/reactions (unauthenticated)', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(undefined); - }); + app = await buildTestApp(undefined) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("returns 401 without auth", async () => { + it('returns 401 without auth', async () => { const response = await app.inject({ - method: "POST", - url: "/api/reactions", + method: 'POST', + url: '/api/reactions', payload: { subjectUri: TEST_TOPIC_URI, subjectCid: TEST_TOPIC_CID, - type: "like", + type: 'like', }, - }); + }) - expect(response.statusCode).toBe(401); - }); - }); + expect(response.statusCode).toBe(401) + }) + }) // ========================================================================= // DELETE /api/reactions/:uri // ========================================================================= - describe("DELETE /api/reactions/:uri", () => { - let app: FastifyInstance; + describe('DELETE /api/reactions/:uri', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - deleteRecordFn.mockResolvedValue(undefined); - }); + vi.clearAllMocks() + resetAllDbMocks() + deleteRecordFn.mockResolvedValue(undefined) + }) - it("deletes a reaction when user is the author (deletes from PDS + DB)", async () => { - const existingReaction = sampleReactionRow(); - selectChain.where.mockResolvedValueOnce([existingReaction]); + it('deletes a reaction when user is the author (deletes from PDS + DB)', async () => { + const existingReaction = sampleReactionRow() + selectChain.where.mockResolvedValueOnce([existingReaction]) - const encodedUri = encodeURIComponent(TEST_REACTION_URI); + const encodedUri = encodeURIComponent(TEST_REACTION_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/reactions/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(204); + expect(response.statusCode).toBe(204) // Should have deleted from PDS - expect(deleteRecordFn).toHaveBeenCalledOnce(); - expect(deleteRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID); - expect(deleteRecordFn.mock.calls[0]?.[1]).toBe("forum.barazo.interaction.reaction"); - expect(deleteRecordFn.mock.calls[0]?.[2]).toBe("react123"); + expect(deleteRecordFn).toHaveBeenCalledOnce() + expect(deleteRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID) + expect(deleteRecordFn.mock.calls[0]?.[1]).toBe('forum.barazo.interaction.reaction') + expect(deleteRecordFn.mock.calls[0]?.[2]).toBe('react123') // Should have used transaction for DB delete + count decrement - expect(mockDb.transaction).toHaveBeenCalledOnce(); - expect(mockDb.delete).toHaveBeenCalled(); - expect(mockDb.update).toHaveBeenCalled(); - }); + expect(mockDb.transaction).toHaveBeenCalledOnce() + expect(mockDb.delete).toHaveBeenCalled() + expect(mockDb.update).toHaveBeenCalled() + }) - it("decrements reaction count on the subject topic", async () => { + it('decrements reaction count on the subject topic', async () => { const existingReaction = sampleReactionRow({ subjectUri: TEST_TOPIC_URI, - }); - selectChain.where.mockResolvedValueOnce([existingReaction]); + }) + selectChain.where.mockResolvedValueOnce([existingReaction]) - const encodedUri = encodeURIComponent(TEST_REACTION_URI); + const encodedUri = encodeURIComponent(TEST_REACTION_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/reactions/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(204); - expect(mockDb.update).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(204) + expect(mockDb.update).toHaveBeenCalled() + }) - it("decrements reaction count on the subject reply", async () => { + it('decrements reaction count on the subject reply', async () => { const existingReaction = sampleReactionRow({ subjectUri: TEST_REPLY_URI, - }); - selectChain.where.mockResolvedValueOnce([existingReaction]); + }) + selectChain.where.mockResolvedValueOnce([existingReaction]) - const encodedUri = encodeURIComponent(TEST_REACTION_URI); + const encodedUri = encodeURIComponent(TEST_REACTION_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/reactions/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(204); - expect(mockDb.update).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(204) + expect(mockDb.update).toHaveBeenCalled() + }) - it("returns 403 when user is not the author", async () => { - const existingReaction = sampleReactionRow({ authorDid: OTHER_DID }); - selectChain.where.mockResolvedValueOnce([existingReaction]); + it('returns 403 when user is not the author', async () => { + const existingReaction = sampleReactionRow({ authorDid: OTHER_DID }) + selectChain.where.mockResolvedValueOnce([existingReaction]) - const encodedUri = encodeURIComponent(TEST_REACTION_URI); + const encodedUri = encodeURIComponent(TEST_REACTION_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/reactions/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(403); - }); + expect(response.statusCode).toBe(403) + }) - it("returns 404 when reaction does not exist", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 when reaction does not exist', async () => { + selectChain.where.mockResolvedValueOnce([]) - const encodedUri = encodeURIComponent("at://did:plc:nobody/forum.barazo.interaction.reaction/ghost"); + const encodedUri = encodeURIComponent( + 'at://did:plc:nobody/forum.barazo.interaction.reaction/ghost' + ) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/reactions/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 502 when PDS delete fails", async () => { - const existingReaction = sampleReactionRow(); - selectChain.where.mockResolvedValueOnce([existingReaction]); - deleteRecordFn.mockRejectedValueOnce(new Error("PDS delete failed")); + it('returns 502 when PDS delete fails', async () => { + const existingReaction = sampleReactionRow() + selectChain.where.mockResolvedValueOnce([existingReaction]) + deleteRecordFn.mockRejectedValueOnce(new Error('PDS delete failed')) - const encodedUri = encodeURIComponent(TEST_REACTION_URI); + const encodedUri = encodeURIComponent(TEST_REACTION_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/reactions/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(502); - }); - }); + expect(response.statusCode).toBe(502) + }) + }) - describe("DELETE /api/reactions/:uri (unauthenticated)", () => { - let app: FastifyInstance; + describe('DELETE /api/reactions/:uri (unauthenticated)', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(undefined); - }); + app = await buildTestApp(undefined) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("returns 401 without auth", async () => { - const encodedUri = encodeURIComponent(TEST_REACTION_URI); + it('returns 401 without auth', async () => { + const encodedUri = encodeURIComponent(TEST_REACTION_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/reactions/${encodedUri}`, headers: {}, - }); + }) - expect(response.statusCode).toBe(401); - }); - }); + expect(response.statusCode).toBe(401) + }) + }) // ========================================================================= // GET /api/reactions // ========================================================================= - describe("GET /api/reactions", () => { - let app: FastifyInstance; + describe('GET /api/reactions', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns empty list when no reactions exist", async () => { - selectChain.limit.mockResolvedValueOnce([]); + it('returns empty list when no reactions exist', async () => { + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/reactions?subjectUri=${encodeURIComponent(TEST_TOPIC_URI)}`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ reactions: unknown[]; cursor: string | null }>(); - expect(body.reactions).toEqual([]); - expect(body.cursor).toBeNull(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ reactions: unknown[]; cursor: string | null }>() + expect(body.reactions).toEqual([]) + expect(body.cursor).toBeNull() + }) - it("returns reactions with pagination cursor", async () => { + it('returns reactions with pagination cursor', async () => { const rows = [ sampleReactionRow(), sampleReactionRow({ uri: `at://${TEST_DID}/forum.barazo.interaction.reaction/react456`, - rkey: "react456", - type: "heart", + rkey: 'react456', + type: 'heart', }), sampleReactionRow({ uri: `at://${TEST_DID}/forum.barazo.interaction.reaction/react789`, - rkey: "react789", + rkey: 'react789', }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + ] + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/reactions?subjectUri=${encodeURIComponent(TEST_TOPIC_URI)}&limit=2`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ reactions: unknown[]; cursor: string | null }>(); - expect(body.reactions).toHaveLength(2); - expect(body.cursor).toBeTruthy(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ reactions: unknown[]; cursor: string | null }>() + expect(body.reactions).toHaveLength(2) + expect(body.cursor).toBeTruthy() + }) - it("returns null cursor when fewer items than limit", async () => { - const rows = [sampleReactionRow()]; - selectChain.limit.mockResolvedValueOnce(rows); + it('returns null cursor when fewer items than limit', async () => { + const rows = [sampleReactionRow()] + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/reactions?subjectUri=${encodeURIComponent(TEST_TOPIC_URI)}&limit=25`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ reactions: unknown[]; cursor: string | null }>(); - expect(body.reactions).toHaveLength(1); - expect(body.cursor).toBeNull(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ reactions: unknown[]; cursor: string | null }>() + expect(body.reactions).toHaveLength(1) + expect(body.cursor).toBeNull() + }) - it("filters by type", async () => { - selectChain.limit.mockResolvedValueOnce([]); + it('filters by type', async () => { + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/reactions?subjectUri=${encodeURIComponent(TEST_TOPIC_URI)}&type=heart`, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) // The type filter should be part of the WHERE clause - expect(selectChain.where).toHaveBeenCalled(); - }); + expect(selectChain.where).toHaveBeenCalled() + }) - it("accepts cursor parameter", async () => { - const cursor = Buffer.from(JSON.stringify({ createdAt: TEST_NOW, uri: TEST_REACTION_URI })).toString("base64"); - selectChain.limit.mockResolvedValueOnce([]); + it('accepts cursor parameter', async () => { + const cursor = Buffer.from( + JSON.stringify({ createdAt: TEST_NOW, uri: TEST_REACTION_URI }) + ).toString('base64') + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/reactions?subjectUri=${encodeURIComponent(TEST_TOPIC_URI)}&cursor=${encodeURIComponent(cursor)}`, - }); + }) - expect(response.statusCode).toBe(200); - }); + expect(response.statusCode).toBe(200) + }) - it("returns 400 for missing subjectUri", async () => { + it('returns 400 for missing subjectUri', async () => { const response = await app.inject({ - method: "GET", - url: "/api/reactions", - }); + method: 'GET', + url: '/api/reactions', + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for invalid limit (over max)", async () => { + it('returns 400 for invalid limit (over max)', async () => { const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/reactions?subjectUri=${encodeURIComponent(TEST_TOPIC_URI)}&limit=999`, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for invalid limit (zero)", async () => { + it('returns 400 for invalid limit (zero)', async () => { const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/reactions?subjectUri=${encodeURIComponent(TEST_TOPIC_URI)}&limit=0`, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for non-numeric limit", async () => { + it('returns 400 for non-numeric limit', async () => { const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/reactions?subjectUri=${encodeURIComponent(TEST_TOPIC_URI)}&limit=abc`, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("works without authentication (public endpoint)", async () => { - const noAuthApp = await buildTestApp(undefined); - selectChain.limit.mockResolvedValueOnce([]); + it('works without authentication (public endpoint)', async () => { + const noAuthApp = await buildTestApp(undefined) + selectChain.limit.mockResolvedValueOnce([]) const response = await noAuthApp.inject({ - method: "GET", + method: 'GET', url: `/api/reactions?subjectUri=${encodeURIComponent(TEST_TOPIC_URI)}`, - }); + }) - expect(response.statusCode).toBe(200); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(200) + await noAuthApp.close() + }) - it("respects custom limit", async () => { - selectChain.limit.mockResolvedValueOnce([]); + it('respects custom limit', async () => { + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/reactions?subjectUri=${encodeURIComponent(TEST_TOPIC_URI)}&limit=5`, - }); + }) - expect(response.statusCode).toBe(200); - expect(selectChain.limit).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(200) + expect(selectChain.limit).toHaveBeenCalled() + }) - it("serializes reaction dates as ISO strings", async () => { - const rows = [sampleReactionRow()]; - selectChain.limit.mockResolvedValueOnce(rows); + it('serializes reaction dates as ISO strings', async () => { + const rows = [sampleReactionRow()] + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/reactions?subjectUri=${encodeURIComponent(TEST_TOPIC_URI)}`, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ reactions: Array<{ createdAt: string; uri: string; type: string }> }>(); - expect(body.reactions[0]?.createdAt).toBe(TEST_NOW); - expect(body.reactions[0]?.uri).toBe(TEST_REACTION_URI); - expect(body.reactions[0]?.type).toBe("like"); - }); - }); -}); + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + reactions: Array<{ createdAt: string; uri: string; type: string }> + }>() + expect(body.reactions[0]?.createdAt).toBe(TEST_NOW) + expect(body.reactions[0]?.uri).toBe(TEST_REACTION_URI) + expect(body.reactions[0]?.type).toBe('like') + }) + }) +}) diff --git a/tests/unit/routes/replies.test.ts b/tests/unit/routes/replies.test.ts index 3b8aa4a..2c4c1c8 100644 --- a/tests/unit/routes/replies.test.ts +++ b/tests/unit/routes/replies.test.ts @@ -1,30 +1,45 @@ -import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { type DbChain, createChainableProxy, createMockDb } from "../../helpers/mock-db.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' // --------------------------------------------------------------------------- // Mock PDS client module (must be before importing routes) // --------------------------------------------------------------------------- -const createRecordFn = vi.fn<(did: string, collection: string, record: Record) => Promise<{ uri: string; cid: string }>>(); -const updateRecordFn = vi.fn<(did: string, collection: string, rkey: string, record: Record) => Promise<{ uri: string; cid: string }>>(); -const deleteRecordFn = vi.fn<(did: string, collection: string, rkey: string) => Promise>(); - -vi.mock("../../../src/lib/pds-client.js", () => ({ +const createRecordFn = + vi.fn< + ( + did: string, + collection: string, + record: Record + ) => Promise<{ uri: string; cid: string }> + >() +const updateRecordFn = + vi.fn< + ( + did: string, + collection: string, + rkey: string, + record: Record + ) => Promise<{ uri: string; cid: string }> + >() +const deleteRecordFn = vi.fn<(did: string, collection: string, rkey: string) => Promise>() + +vi.mock('../../../src/lib/pds-client.js', () => ({ createPdsClient: () => ({ createRecord: createRecordFn, updateRecord: updateRecordFn, deleteRecord: deleteRecordFn, }), -})); +})) // Mock anti-spam module (tested separately in anti-spam.test.ts) -vi.mock("../../../src/lib/anti-spam.js", () => ({ +vi.mock('../../../src/lib/anti-spam.js', () => ({ loadAntiSpamSettings: vi.fn().mockResolvedValue({ wordFilter: [], firstPostQueueCount: 3, @@ -41,45 +56,45 @@ vi.mock("../../../src/lib/anti-spam.js", () => ({ isAccountTrusted: vi.fn().mockResolvedValue(true), checkWriteRateLimit: vi.fn().mockResolvedValue(false), runAntiSpamChecks: vi.fn().mockResolvedValue({ held: false, reasons: [] }), -})); +})) // Import routes AFTER mocking -import { replyRoutes } from "../../../src/routes/replies.js"; +import { replyRoutes } from '../../../src/routes/replies.js' // --------------------------------------------------------------------------- // Mock env (minimal subset for reply routes) // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_DID: 'did:plc:community123', RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) -const TEST_TOPIC_URI = `at://${TEST_DID}/forum.barazo.topic.post/abc123`; -const TEST_TOPIC_CID = "bafyreiatopic123"; -const TEST_TOPIC_RKEY = "abc123"; +const TEST_TOPIC_URI = `at://${TEST_DID}/forum.barazo.topic.post/abc123` +const TEST_TOPIC_CID = 'bafyreiatopic123' +const TEST_TOPIC_RKEY = 'abc123' -const TEST_REPLY_URI = `at://${TEST_DID}/forum.barazo.topic.reply/reply001`; -const TEST_REPLY_CID = "bafyreireply001"; -const TEST_REPLY_RKEY = "reply001"; +const TEST_REPLY_URI = `at://${TEST_DID}/forum.barazo.topic.reply/reply001` +const TEST_REPLY_CID = 'bafyreireply001' +const TEST_REPLY_RKEY = 'reply001' -const TEST_PARENT_REPLY_URI = `at://${TEST_DID}/forum.barazo.topic.reply/parentreply001`; -const TEST_PARENT_REPLY_CID = "bafyreiparentreply001"; +const TEST_PARENT_REPLY_URI = `at://${TEST_DID}/forum.barazo.topic.reply/parentreply001` +const TEST_PARENT_REPLY_CID = 'bafyreiparentreply001' -const TEST_NOW = "2026-02-13T12:00:00.000Z"; +const TEST_NOW = '2026-02-13T12:00:00.000Z' -const MOD_DID = "did:plc:moderator999"; -const OTHER_DID = "did:plc:otheruser456"; +const MOD_DID = 'did:plc:moderator999' +const OTHER_DID = 'did:plc:otheruser456' // --------------------------------------------------------------------------- // Mock user builders @@ -91,54 +106,54 @@ function testUser(overrides?: Partial): RequestUser { handle: TEST_HANDLE, sid: TEST_SID, ...overrides, - }; + } } // --------------------------------------------------------------------------- // Mock firehose repo manager // --------------------------------------------------------------------------- -const isTrackedFn = vi.fn<(did: string) => Promise>(); -const trackRepoFn = vi.fn<(did: string) => Promise>(); +const isTrackedFn = vi.fn<(did: string) => Promise>() +const trackRepoFn = vi.fn<(did: string) => Promise>() const mockRepoManager = { isTracked: isTrackedFn, trackRepo: trackRepoFn, untrackRepo: vi.fn(), restoreTrackedRepos: vi.fn(), -}; +} const mockFirehose = { getRepoManager: () => mockRepoManager, start: vi.fn(), stop: vi.fn(), getStatus: vi.fn().mockReturnValue({ connected: true, lastEventId: null }), -}; +} // --------------------------------------------------------------------------- // Chainable mock DB (shared helper) // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() -let insertChain: DbChain; -let selectChain: DbChain; -let updateChain: DbChain; -let deleteChain: DbChain; +let insertChain: DbChain +let selectChain: DbChain +let updateChain: DbChain +let deleteChain: DbChain function resetAllDbMocks(): void { - insertChain = createChainableProxy(); - selectChain = createChainableProxy([]); - updateChain = createChainableProxy([]); - deleteChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); - mockDb.select.mockReturnValue(selectChain); - mockDb.update.mockReturnValue(updateChain); - mockDb.delete.mockReturnValue(deleteChain); + insertChain = createChainableProxy() + selectChain = createChainableProxy([]) + updateChain = createChainableProxy([]) + deleteChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(updateChain) + mockDb.delete.mockReturnValue(deleteChain) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { - await fn(mockDb); - }); + await fn(mockDb) + }) } // --------------------------------------------------------------------------- @@ -149,18 +164,18 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } // --------------------------------------------------------------------------- @@ -172,12 +187,12 @@ function sampleTopicRow(overrides?: Record) { uri: TEST_TOPIC_URI, rkey: TEST_TOPIC_RKEY, authorDid: TEST_DID, - title: "Test Topic Title", - content: "Test topic content goes here", + title: 'Test Topic Title', + content: 'Test topic content goes here', contentFormat: null, - category: "general", - tags: ["test", "example"], - communityDid: "did:plc:community123", + category: 'general', + tags: ['test', 'example'], + communityDid: 'did:plc:community123', cid: TEST_TOPIC_CID, labels: null, replyCount: 0, @@ -187,7 +202,7 @@ function sampleTopicRow(overrides?: Record) { indexedAt: new Date(TEST_NOW), embedding: null, ...overrides, - }; + } } function sampleReplyRow(overrides?: Record) { @@ -195,13 +210,13 @@ function sampleReplyRow(overrides?: Record) { uri: TEST_REPLY_URI, rkey: TEST_REPLY_RKEY, authorDid: TEST_DID, - content: "This is a test reply", + content: 'This is a test reply', contentFormat: null, rootUri: TEST_TOPIC_URI, rootCid: TEST_TOPIC_CID, parentUri: TEST_TOPIC_URI, parentCid: TEST_TOPIC_CID, - communityDid: "did:plc:community123", + communityDid: 'did:plc:community123', cid: TEST_REPLY_CID, labels: null, reactionCount: 0, @@ -209,7 +224,7 @@ function sampleReplyRow(overrides?: Record) { indexedAt: new Date(TEST_NOW), embedding: null, ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -217,542 +232,563 @@ function sampleReplyRow(overrides?: Record) { // --------------------------------------------------------------------------- async function buildTestApp(user?: RequestUser): Promise { - const app = Fastify({ logger: false }); - - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", createMockAuthMiddleware(user)); - app.decorate("firehose", mockFirehose as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); - - await app.register(replyRoutes()); - await app.ready(); - - return app; + const app = Fastify({ logger: false }) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', createMockAuthMiddleware(user)) + app.decorate('firehose', mockFirehose as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorate('interactionGraphService', { + recordReply: vi.fn().mockResolvedValue(undefined), + recordReaction: vi.fn().mockResolvedValue(undefined), + recordCoParticipation: vi.fn().mockResolvedValue(undefined), + } as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + + await app.register(replyRoutes()) + await app.ready() + + return app } // =========================================================================== // Test suite // =========================================================================== -describe("reply routes", () => { +describe('reply routes', () => { // ========================================================================= // POST /api/topics/:topicUri/replies // ========================================================================= - describe("POST /api/topics/:topicUri/replies", () => { - let app: FastifyInstance; + describe('POST /api/topics/:topicUri/replies', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); + vi.clearAllMocks() + resetAllDbMocks() // Default mocks for successful create - createRecordFn.mockResolvedValue({ uri: TEST_REPLY_URI, cid: TEST_REPLY_CID }); - isTrackedFn.mockResolvedValue(true); - }); + createRecordFn.mockResolvedValue({ uri: TEST_REPLY_URI, cid: TEST_REPLY_CID }) + isTrackedFn.mockResolvedValue(true) + }) - it("creates a reply to a topic and returns 201", async () => { + it('creates a reply to a topic and returns 201', async () => { // First select: look up topic - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "This is my reply to the topic.", + content: 'This is my reply to the topic.', }, - }); + }) - expect(response.statusCode).toBe(201); - const body = response.json<{ uri: string; cid: string }>(); - expect(body.uri).toBe(TEST_REPLY_URI); - expect(body.cid).toBe(TEST_REPLY_CID); + expect(response.statusCode).toBe(201) + const body = response.json<{ uri: string; cid: string }>() + expect(body.uri).toBe(TEST_REPLY_URI) + expect(body.cid).toBe(TEST_REPLY_CID) // Should have called PDS createRecord - expect(createRecordFn).toHaveBeenCalledOnce(); - expect(createRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID); - expect(createRecordFn.mock.calls[0]?.[1]).toBe("forum.barazo.topic.reply"); + expect(createRecordFn).toHaveBeenCalledOnce() + expect(createRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID) + expect(createRecordFn.mock.calls[0]?.[1]).toBe('forum.barazo.topic.reply') // Verify record content - const record = createRecordFn.mock.calls[0]?.[2] as Record; - expect(record.content).toBe("This is my reply to the topic."); - expect(record.community).toBe("did:plc:community123"); - expect((record.root as Record).uri).toBe(TEST_TOPIC_URI); - expect((record.root as Record).cid).toBe(TEST_TOPIC_CID); + const record = createRecordFn.mock.calls[0]?.[2] as Record + expect(record.content).toBe('This is my reply to the topic.') + expect(record.community).toBe('did:plc:community123') + expect((record.root as Record).uri).toBe(TEST_TOPIC_URI) + expect((record.root as Record).cid).toBe(TEST_TOPIC_CID) // parent should also point to topic when no parentUri provided - expect((record.parent as Record).uri).toBe(TEST_TOPIC_URI); - expect((record.parent as Record).cid).toBe(TEST_TOPIC_CID); + expect((record.parent as Record).uri).toBe(TEST_TOPIC_URI) + expect((record.parent as Record).cid).toBe(TEST_TOPIC_CID) // Should have inserted into DB - expect(mockDb.insert).toHaveBeenCalledOnce(); + expect(mockDb.insert).toHaveBeenCalledOnce() // Should have updated topic replyCount + lastActivityAt - expect(mockDb.update).toHaveBeenCalled(); - }); + expect(mockDb.update).toHaveBeenCalled() + }) - it("creates a threaded reply (with parentUri) and returns 201", async () => { + it('creates a threaded reply (with parentUri) and returns 201', async () => { // First select: look up topic - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) // Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) // Second select: look up parent reply - selectChain.where.mockResolvedValueOnce([sampleReplyRow({ - uri: TEST_PARENT_REPLY_URI, - cid: TEST_PARENT_REPLY_CID, - })]); + selectChain.where.mockResolvedValueOnce([ + sampleReplyRow({ + uri: TEST_PARENT_REPLY_URI, + cid: TEST_PARENT_REPLY_CID, + }), + ]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "This is a threaded reply.", + content: 'This is a threaded reply.', parentUri: TEST_PARENT_REPLY_URI, }, - }); + }) - expect(response.statusCode).toBe(201); + expect(response.statusCode).toBe(201) // Verify record has correct parent reference - const record = createRecordFn.mock.calls[0]?.[2] as Record; - expect((record.root as Record).uri).toBe(TEST_TOPIC_URI); - expect((record.parent as Record).uri).toBe(TEST_PARENT_REPLY_URI); - expect((record.parent as Record).cid).toBe(TEST_PARENT_REPLY_CID); - }); + const record = createRecordFn.mock.calls[0]?.[2] as Record + expect((record.root as Record).uri).toBe(TEST_TOPIC_URI) + expect((record.parent as Record).uri).toBe(TEST_PARENT_REPLY_URI) + expect((record.parent as Record).cid).toBe(TEST_PARENT_REPLY_CID) + }) - it("returns 400 when parentUri reply not found", async () => { + it('returns 400 when parentUri reply not found', async () => { // First select: look up topic - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) // Second select: parent reply not found - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "Reply to missing parent.", - parentUri: "at://did:plc:nobody/forum.barazo.topic.reply/ghost", + content: 'Reply to missing parent.', + parentUri: 'at://did:plc:nobody/forum.barazo.topic.reply/ghost', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) it("tracks new user's repo on first post", async () => { - isTrackedFn.mockResolvedValue(false); - trackRepoFn.mockResolvedValue(undefined); - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + isTrackedFn.mockResolvedValue(false) + trackRepoFn.mockResolvedValue(undefined) + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "First ever post reply.", + content: 'First ever post reply.', }, - }); + }) - expect(response.statusCode).toBe(201); - expect(isTrackedFn).toHaveBeenCalledWith(TEST_DID); - expect(trackRepoFn).toHaveBeenCalledWith(TEST_DID); - }); + expect(response.statusCode).toBe(201) + expect(isTrackedFn).toHaveBeenCalledWith(TEST_DID) + expect(trackRepoFn).toHaveBeenCalledWith(TEST_DID) + }) - it("does not track already-tracked user", async () => { - isTrackedFn.mockResolvedValue(true); - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + it('does not track already-tracked user', async () => { + isTrackedFn.mockResolvedValue(true) + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "Already tracked reply.", + content: 'Already tracked reply.', }, - }); + }) - expect(response.statusCode).toBe(201); - expect(isTrackedFn).toHaveBeenCalledWith(TEST_DID); - expect(trackRepoFn).not.toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(201) + expect(isTrackedFn).toHaveBeenCalledWith(TEST_DID) + expect(trackRepoFn).not.toHaveBeenCalled() + }) - it("returns 404 when topic does not exist", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 when topic does not exist', async () => { + selectChain.where.mockResolvedValueOnce([]) - const encodedTopicUri = encodeURIComponent("at://did:plc:nobody/forum.barazo.topic.post/ghost"); + const encodedTopicUri = encodeURIComponent( + 'at://did:plc:nobody/forum.barazo.topic.post/ghost' + ) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "Reply to nonexistent topic.", + content: 'Reply to nonexistent topic.', }, - }); + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 400 for missing content", async () => { - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + it('returns 400 for missing content', async () => { + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for empty content", async () => { - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + it('returns 400 for empty content', async () => { + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "", + content: '', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for content exceeding max length", async () => { - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + it('returns 400 for content exceeding max length', async () => { + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "A".repeat(50001), + content: 'A'.repeat(50001), }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 502 when PDS write fails", async () => { - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); - createRecordFn.mockRejectedValueOnce(new Error("PDS unreachable")); + it('returns 502 when PDS write fails', async () => { + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) + createRecordFn.mockRejectedValueOnce(new Error('PDS unreachable')) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "Should fail because PDS is down.", + content: 'Should fail because PDS is down.', }, - }); + }) - expect(response.statusCode).toBe(502); - }); + expect(response.statusCode).toBe(502) + }) - it("creates a reply with self-labels and includes them in PDS record and DB insert", async () => { - const labels = { values: [{ val: "nsfw" }, { val: "spoiler" }] }; - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + it('creates a reply with self-labels and includes them in PDS record and DB insert', async () => { + const labels = { values: [{ val: 'nsfw' }, { val: 'spoiler' }] } + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "This reply has self-labels.", + content: 'This reply has self-labels.', labels, }, - }); + }) - expect(response.statusCode).toBe(201); + expect(response.statusCode).toBe(201) // Verify PDS record includes labels - expect(createRecordFn).toHaveBeenCalledOnce(); - const pdsRecord = createRecordFn.mock.calls[0]?.[2] as Record; - expect(pdsRecord.labels).toEqual(labels); + expect(createRecordFn).toHaveBeenCalledOnce() + const pdsRecord = createRecordFn.mock.calls[0]?.[2] as Record + expect(pdsRecord.labels).toEqual(labels) // Verify DB insert includes labels - expect(mockDb.insert).toHaveBeenCalledOnce(); - const insertValues = insertChain.values.mock.calls[0]?.[0] as Record; - expect(insertValues.labels).toEqual(labels); - }); + expect(mockDb.insert).toHaveBeenCalledOnce() + const insertValues = insertChain.values.mock.calls[0]?.[0] as Record + expect(insertValues.labels).toEqual(labels) + }) - it("creates a reply without labels (backwards compatible)", async () => { - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + it('creates a reply without labels (backwards compatible)', async () => { + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "This reply has no labels.", + content: 'This reply has no labels.', }, - }); + }) - expect(response.statusCode).toBe(201); + expect(response.statusCode).toBe(201) // Verify PDS record does NOT include labels key - const pdsRecord = createRecordFn.mock.calls[0]?.[2] as Record; - expect(pdsRecord).not.toHaveProperty("labels"); + const pdsRecord = createRecordFn.mock.calls[0]?.[2] as Record + expect(pdsRecord).not.toHaveProperty('labels') // Verify DB insert has labels: null - const insertValues = insertChain.values.mock.calls[0]?.[0] as Record; - expect(insertValues.labels).toBeNull(); - }); - }); + const insertValues = insertChain.values.mock.calls[0]?.[0] as Record + expect(insertValues.labels).toBeNull() + }) + }) - describe("POST /api/topics/:topicUri/replies (unauthenticated)", () => { - let app: FastifyInstance; + describe('POST /api/topics/:topicUri/replies (unauthenticated)', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(undefined); - }); + app = await buildTestApp(undefined) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("returns 401 without auth", async () => { - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + it('returns 401 without auth', async () => { + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, payload: { - content: "Unauth reply.", + content: 'Unauth reply.', }, - }); + }) - expect(response.statusCode).toBe(401); - }); - }); + expect(response.statusCode).toBe(401) + }) + }) // ========================================================================= // GET /api/topics/:topicUri/replies // ========================================================================= - describe("GET /api/topics/:topicUri/replies", () => { - let app: FastifyInstance; + describe('GET /api/topics/:topicUri/replies', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns empty list when no replies exist", async () => { + it('returns empty list when no replies exist', async () => { // First select: look up topic - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) // Second: replies query ends with .limit() - selectChain.limit.mockResolvedValueOnce([]); + selectChain.limit.mockResolvedValueOnce([]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ replies: unknown[]; cursor: string | null }>(); - expect(body.replies).toEqual([]); - expect(body.cursor).toBeNull(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ replies: unknown[]; cursor: string | null }>() + expect(body.replies).toEqual([]) + expect(body.cursor).toBeNull() + }) - it("returns replies with pagination cursor", async () => { + it('returns replies with pagination cursor', async () => { // First: look up topic - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) // limit=2 means fetch 3 items const rows = [ sampleReplyRow(), - sampleReplyRow({ uri: `at://${TEST_DID}/forum.barazo.topic.reply/reply002`, rkey: "reply002" }), - sampleReplyRow({ uri: `at://${TEST_DID}/forum.barazo.topic.reply/reply003`, rkey: "reply003" }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + sampleReplyRow({ + uri: `at://${TEST_DID}/forum.barazo.topic.reply/reply002`, + rkey: 'reply002', + }), + sampleReplyRow({ + uri: `at://${TEST_DID}/forum.barazo.topic.reply/reply003`, + rkey: 'reply003', + }), + ] + selectChain.limit.mockResolvedValueOnce(rows) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies?limit=2`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ replies: unknown[]; cursor: string | null }>(); - expect(body.replies).toHaveLength(2); - expect(body.cursor).toBeTruthy(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ replies: unknown[]; cursor: string | null }>() + expect(body.replies).toHaveLength(2) + expect(body.cursor).toBeTruthy() + }) - it("returns null cursor when fewer items than limit", async () => { - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); - selectChain.limit.mockResolvedValueOnce([sampleReplyRow()]); + it('returns null cursor when fewer items than limit', async () => { + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) + selectChain.limit.mockResolvedValueOnce([sampleReplyRow()]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies?limit=25`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ replies: unknown[]; cursor: string | null }>(); - expect(body.replies).toHaveLength(1); - expect(body.cursor).toBeNull(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ replies: unknown[]; cursor: string | null }>() + expect(body.replies).toHaveLength(1) + expect(body.cursor).toBeNull() + }) - it("includes depth field in reply responses", async () => { - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + it('includes depth field in reply responses', async () => { + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) // A direct reply (parentUri === rootUri) should have depth 0 const directReply = sampleReplyRow({ parentUri: TEST_TOPIC_URI, parentCid: TEST_TOPIC_CID, - }); + }) // A nested reply (parentUri !== rootUri) should have depth 1 const nestedReply = sampleReplyRow({ uri: `at://${TEST_DID}/forum.barazo.topic.reply/nested001`, - rkey: "nested001", + rkey: 'nested001', parentUri: TEST_REPLY_URI, parentCid: TEST_REPLY_CID, - }); - selectChain.limit.mockResolvedValueOnce([directReply, nestedReply]); + }) + selectChain.limit.mockResolvedValueOnce([directReply, nestedReply]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ replies: Array<{ depth: number; parentUri: string }> }>(); - expect(body.replies).toHaveLength(2); - expect(body.replies[0]?.depth).toBe(0); - expect(body.replies[1]?.depth).toBe(1); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ replies: Array<{ depth: number; parentUri: string }> }>() + expect(body.replies).toHaveLength(2) + expect(body.replies[0]?.depth).toBe(0) + expect(body.replies[1]?.depth).toBe(1) + }) - it("returns 404 when topic does not exist", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 when topic does not exist', async () => { + selectChain.where.mockResolvedValueOnce([]) - const encodedTopicUri = encodeURIComponent("at://did:plc:nobody/forum.barazo.topic.post/ghost"); + const encodedTopicUri = encodeURIComponent( + 'at://did:plc:nobody/forum.barazo.topic.post/ghost' + ) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies`, - }); + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 400 for invalid limit (over max)", async () => { - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + it('returns 400 for invalid limit (over max)', async () => { + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies?limit=999`, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for invalid limit (zero)", async () => { - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + it('returns 400 for invalid limit (zero)', async () => { + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies?limit=0`, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for non-numeric limit", async () => { - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + it('returns 400 for non-numeric limit', async () => { + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies?limit=abc`, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("accepts cursor parameter", async () => { - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); - const cursor = Buffer.from(JSON.stringify({ createdAt: TEST_NOW, uri: TEST_REPLY_URI })).toString("base64"); - selectChain.limit.mockResolvedValueOnce([]); + it('accepts cursor parameter', async () => { + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) + const cursor = Buffer.from( + JSON.stringify({ createdAt: TEST_NOW, uri: TEST_REPLY_URI }) + ).toString('base64') + selectChain.limit.mockResolvedValueOnce([]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies?cursor=${encodeURIComponent(cursor)}`, - }); + }) - expect(response.statusCode).toBe(200); - }); + expect(response.statusCode).toBe(200) + }) - it("works without authentication (public endpoint)", async () => { - const noAuthApp = await buildTestApp(undefined); - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); - selectChain.limit.mockResolvedValueOnce([]); + it('works without authentication (public endpoint)', async () => { + const noAuthApp = await buildTestApp(undefined) + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) + selectChain.limit.mockResolvedValueOnce([]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await noAuthApp.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies`, - }); + }) - expect(response.statusCode).toBe(200); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(200) + await noAuthApp.close() + }) - it("includes labels in reply list response", async () => { - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); - const labels = { values: [{ val: "nsfw" }] }; + it('includes labels in reply list response', async () => { + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) + const labels = { values: [{ val: 'nsfw' }] } const rows = [ sampleReplyRow({ labels }), sampleReplyRow({ uri: `at://${TEST_DID}/forum.barazo.topic.reply/nolabel`, - rkey: "nolabel", + rkey: 'nolabel', labels: null, }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + ] + selectChain.limit.mockResolvedValueOnce(rows) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ replies: Array<{ uri: string; labels: { values: Array<{ val: string }> } | null }> }>(); - expect(body.replies).toHaveLength(2); - expect(body.replies[0]?.labels).toEqual(labels); - expect(body.replies[1]?.labels).toBeNull(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ + replies: Array<{ uri: string; labels: { values: Array<{ val: string }> } | null }> + }>() + expect(body.replies).toHaveLength(2) + expect(body.replies[0]?.labels).toEqual(labels) + expect(body.replies[1]?.labels).toBeNull() + }) - it("excludes replies by blocked users from list", async () => { - const blockedDid = "did:plc:blockeduser"; + it('excludes replies by blocked users from list', async () => { + const blockedDid = 'did:plc:blockeduser' // Query order for authenticated GET /api/topics/:topicUri/replies: // 1. Topic lookup (where) @@ -760,76 +796,82 @@ describe("reply routes", () => { // 3. User profile (where) -- if authenticated // 4. Block/mute preferences (where) // 5. Replies query (limit) - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) // Category maturity - selectChain.where.mockResolvedValueOnce([{ maturityRating: "safe" }]); + selectChain.where.mockResolvedValueOnce([{ maturityRating: 'safe' }]) // User profile - selectChain.where.mockResolvedValueOnce([{ declaredAge: null, maturityPref: "safe" }]); + selectChain.where.mockResolvedValueOnce([{ declaredAge: null, maturityPref: 'safe' }]) // Community settings: ageThreshold - selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]); + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) // Block/mute preferences - selectChain.where.mockResolvedValueOnce([{ - blockedDids: [blockedDid], - mutedDids: [], - }]); + selectChain.where.mockResolvedValueOnce([ + { + blockedDids: [blockedDid], + mutedDids: [], + }, + ]) // Return only non-blocked replies - const rows = [ - sampleReplyRow({ authorDid: TEST_DID }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + const rows = [sampleReplyRow({ authorDid: TEST_DID })] + selectChain.limit.mockResolvedValueOnce(rows) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ replies: Array<{ authorDid: string; isMuted: boolean }> }>(); - expect(body.replies.every((r) => r.authorDid !== blockedDid)).toBe(true); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ replies: Array<{ authorDid: string; isMuted: boolean }> }>() + expect(body.replies.every((r) => r.authorDid !== blockedDid)).toBe(true) + }) - it("annotates replies by muted users with isMuted: true", async () => { - const mutedDid = "did:plc:muteduser"; + it('annotates replies by muted users with isMuted: true', async () => { + const mutedDid = 'did:plc:muteduser' - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) // Category maturity - selectChain.where.mockResolvedValueOnce([{ maturityRating: "safe" }]); + selectChain.where.mockResolvedValueOnce([{ maturityRating: 'safe' }]) // User profile - selectChain.where.mockResolvedValueOnce([{ declaredAge: null, maturityPref: "safe" }]); + selectChain.where.mockResolvedValueOnce([{ declaredAge: null, maturityPref: 'safe' }]) // Community settings: ageThreshold - selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]); + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) // Block/mute preferences - selectChain.where.mockResolvedValueOnce([{ - blockedDids: [], - mutedDids: [mutedDid], - }]); + selectChain.where.mockResolvedValueOnce([ + { + blockedDids: [], + mutedDids: [mutedDid], + }, + ]) const rows = [ - sampleReplyRow({ authorDid: mutedDid, uri: `at://${mutedDid}/forum.barazo.topic.reply/m1`, rkey: "m1" }), + sampleReplyRow({ + authorDid: mutedDid, + uri: `at://${mutedDid}/forum.barazo.topic.reply/m1`, + rkey: 'm1', + }), sampleReplyRow({ authorDid: TEST_DID }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + ] + selectChain.limit.mockResolvedValueOnce(rows) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ replies: Array<{ authorDid: string; isMuted: boolean }> }>(); - expect(body.replies).toHaveLength(2); + expect(response.statusCode).toBe(200) + const body = response.json<{ replies: Array<{ authorDid: string; isMuted: boolean }> }>() + expect(body.replies).toHaveLength(2) - const mutedReply = body.replies.find((r) => r.authorDid === mutedDid); - const normalReply = body.replies.find((r) => r.authorDid === TEST_DID); - expect(mutedReply?.isMuted).toBe(true); - expect(normalReply?.isMuted).toBe(false); - }); + const mutedReply = body.replies.find((r) => r.authorDid === mutedDid) + const normalReply = body.replies.find((r) => r.authorDid === TEST_DID) + expect(mutedReply?.isMuted).toBe(true) + expect(normalReply?.isMuted).toBe(false) + }) - it("includes author profile on each reply", async () => { - resetAllDbMocks(); + it('includes author profile on each reply', async () => { + resetAllDbMocks() // Mock chain for authenticated GET /api/topics/:topicUri/replies: // 1. Topic lookup .where (terminal) @@ -841,451 +883,492 @@ describe("reply routes", () => { // 7. resolveAuthors users .where (terminal) // 8. loadMutedWords global .where (terminal) - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); // 1: topic lookup - selectChain.where.mockResolvedValueOnce([{ maturityRating: "safe" }]); // 2: category maturity - selectChain.where.mockResolvedValueOnce([{ declaredAge: null, maturityPref: "safe" }]); // 3: user profile - selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]); // 4: community settings - selectChain.where.mockResolvedValueOnce([{ // 5: block/mute - blockedDids: [], - mutedDids: [], - }]); - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- thenable mock for Drizzle chain - selectChain.where.mockImplementationOnce(() => selectChain); // 6: replies .where + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) // 1: topic lookup + selectChain.where.mockResolvedValueOnce([{ maturityRating: 'safe' }]) // 2: category maturity + selectChain.where.mockResolvedValueOnce([{ declaredAge: null, maturityPref: 'safe' }]) // 3: user profile + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) // 4: community settings + selectChain.where.mockResolvedValueOnce([ + { + // 5: block/mute + blockedDids: [], + mutedDids: [], + }, + ]) + + selectChain.where.mockImplementationOnce(() => selectChain) // 6: replies .where const rows = [ sampleReplyRow({ authorDid: TEST_DID }), - sampleReplyRow({ authorDid: OTHER_DID, uri: `at://${OTHER_DID}/forum.barazo.topic.reply/o1`, rkey: "o1" }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); - - selectChain.where.mockResolvedValueOnce([ // 7: resolveAuthors users - { did: TEST_DID, handle: TEST_HANDLE, displayName: "Alice", avatarUrl: "https://cdn.example.com/alice.jpg", bannerUrl: null, bio: null }, - { did: OTHER_DID, handle: "bob.bsky.social", displayName: "Bob", avatarUrl: null, bannerUrl: null, bio: null }, - ]); - selectChain.where.mockResolvedValueOnce([]); // 8: loadMutedWords global + sampleReplyRow({ + authorDid: OTHER_DID, + uri: `at://${OTHER_DID}/forum.barazo.topic.reply/o1`, + rkey: 'o1', + }), + ] + selectChain.limit.mockResolvedValueOnce(rows) + + selectChain.where.mockResolvedValueOnce([ + // 7: resolveAuthors users + { + did: TEST_DID, + handle: TEST_HANDLE, + displayName: 'Alice', + avatarUrl: 'https://cdn.example.com/alice.jpg', + bannerUrl: null, + bio: null, + }, + { + did: OTHER_DID, + handle: 'bob.bsky.social', + displayName: 'Bob', + avatarUrl: null, + bannerUrl: null, + bio: null, + }, + ]) + selectChain.where.mockResolvedValueOnce([]) // 8: loadMutedWords global - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies`, - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ replies: Array<{ authorDid: string; author: { did: string; handle: string; displayName: string | null; avatarUrl: string | null } }> }>(); - expect(body.replies).toHaveLength(2); + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + replies: Array<{ + authorDid: string + author: { + did: string + handle: string + displayName: string | null + avatarUrl: string | null + } + }> + }>() + expect(body.replies).toHaveLength(2) // Verify resolved author profile data (not just DID fallback) - const aliceReply = body.replies.find((r) => r.authorDid === TEST_DID); + const aliceReply = body.replies.find((r) => r.authorDid === TEST_DID) expect(aliceReply?.author).toEqual({ did: TEST_DID, handle: TEST_HANDLE, - displayName: "Alice", - avatarUrl: "https://cdn.example.com/alice.jpg", - }); + displayName: 'Alice', + avatarUrl: 'https://cdn.example.com/alice.jpg', + }) - const bobReply = body.replies.find((r) => r.authorDid === OTHER_DID); + const bobReply = body.replies.find((r) => r.authorDid === OTHER_DID) expect(bobReply?.author).toEqual({ did: OTHER_DID, - handle: "bob.bsky.social", - displayName: "Bob", + handle: 'bob.bsky.social', + displayName: 'Bob', avatarUrl: null, - }); - }); + }) + }) - it("returns isMuted: false for all replies when unauthenticated", async () => { - const noAuthApp = await buildTestApp(undefined); + it('returns isMuted: false for all replies when unauthenticated', async () => { + const noAuthApp = await buildTestApp(undefined) // For unauthenticated users, no user profile query - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) // Category maturity - selectChain.where.mockResolvedValueOnce([{ maturityRating: "safe" }]); + selectChain.where.mockResolvedValueOnce([{ maturityRating: 'safe' }]) // No user profile or block/mute query for unauthenticated // Replies query const rows = [ sampleReplyRow({ authorDid: TEST_DID }), - sampleReplyRow({ authorDid: OTHER_DID, uri: `at://${OTHER_DID}/forum.barazo.topic.reply/o1`, rkey: "o1" }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + sampleReplyRow({ + authorDid: OTHER_DID, + uri: `at://${OTHER_DID}/forum.barazo.topic.reply/o1`, + rkey: 'o1', + }), + ] + selectChain.limit.mockResolvedValueOnce(rows) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await noAuthApp.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ replies: Array<{ authorDid: string; isMuted: boolean }> }>(); - expect(body.replies).toHaveLength(2); - expect(body.replies.every((r) => !r.isMuted)).toBe(true); + expect(response.statusCode).toBe(200) + const body = response.json<{ replies: Array<{ authorDid: string; isMuted: boolean }> }>() + expect(body.replies).toHaveLength(2) + expect(body.replies.every((r) => !r.isMuted)).toBe(true) - await noAuthApp.close(); - }); - }); + await noAuthApp.close() + }) + }) // ========================================================================= // PUT /api/replies/:uri // ========================================================================= - describe("PUT /api/replies/:uri", () => { - let app: FastifyInstance; + describe('PUT /api/replies/:uri', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - updateRecordFn.mockResolvedValue({ uri: TEST_REPLY_URI, cid: "bafyreinewcid" }); - }); - - it("updates a reply when user is the author", async () => { - const existingRow = sampleReplyRow(); - selectChain.where.mockResolvedValueOnce([existingRow]); - const updatedRow = { ...existingRow, content: "Updated reply content", cid: "bafyreinewcid" }; - updateChain.returning.mockResolvedValueOnce([updatedRow]); - - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + vi.clearAllMocks() + resetAllDbMocks() + updateRecordFn.mockResolvedValue({ uri: TEST_REPLY_URI, cid: 'bafyreinewcid' }) + }) + + it('updates a reply when user is the author', async () => { + const existingRow = sampleReplyRow() + selectChain.where.mockResolvedValueOnce([existingRow]) + const updatedRow = { ...existingRow, content: 'Updated reply content', cid: 'bafyreinewcid' } + updateChain.returning.mockResolvedValueOnce([updatedRow]) + + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/replies/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "Updated reply content", + content: 'Updated reply content', }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ content: string }>(); - expect(body.content).toBe("Updated reply content"); - expect(updateRecordFn).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ content: string }>() + expect(body.content).toBe('Updated reply content') + expect(updateRecordFn).toHaveBeenCalledOnce() + }) - it("returns 403 when user is not the author", async () => { - const existingRow = sampleReplyRow({ authorDid: OTHER_DID }); - selectChain.where.mockResolvedValueOnce([existingRow]); + it('returns 403 when user is not the author', async () => { + const existingRow = sampleReplyRow({ authorDid: OTHER_DID }) + selectChain.where.mockResolvedValueOnce([existingRow]) - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/replies/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "Attempted edit by non-author.", + content: 'Attempted edit by non-author.', }, - }); + }) - expect(response.statusCode).toBe(403); - }); + expect(response.statusCode).toBe(403) + }) - it("returns 404 when reply does not exist", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 when reply does not exist', async () => { + selectChain.where.mockResolvedValueOnce([]) - const encodedUri = encodeURIComponent("at://did:plc:nobody/forum.barazo.topic.reply/ghost"); + const encodedUri = encodeURIComponent('at://did:plc:nobody/forum.barazo.topic.reply/ghost') const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/replies/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "Ghost reply edit.", + content: 'Ghost reply edit.', }, - }); + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 400 for missing content", async () => { - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + it('returns 400 for missing content', async () => { + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/replies/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for empty content", async () => { - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + it('returns 400 for empty content', async () => { + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/replies/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "", + content: '', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for content exceeding max length", async () => { - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + it('returns 400 for content exceeding max length', async () => { + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/replies/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "A".repeat(50001), + content: 'A'.repeat(50001), }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 502 when PDS update fails", async () => { - const existingRow = sampleReplyRow(); - selectChain.where.mockResolvedValueOnce([existingRow]); - updateRecordFn.mockRejectedValueOnce(new Error("PDS error")); + it('returns 502 when PDS update fails', async () => { + const existingRow = sampleReplyRow() + selectChain.where.mockResolvedValueOnce([existingRow]) + updateRecordFn.mockRejectedValueOnce(new Error('PDS error')) - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/replies/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "Will fail to update.", + content: 'Will fail to update.', }, - }); - - expect(response.statusCode).toBe(502); - }); - - it("updates a reply with self-labels (PDS record + DB)", async () => { - const existingRow = sampleReplyRow(); - selectChain.where.mockResolvedValueOnce([existingRow]); - const labels = { values: [{ val: "nsfw" }, { val: "spoiler" }] }; - const updatedRow = { ...existingRow, content: "Updated with labels", labels, cid: "bafyreinewcid" }; - updateChain.returning.mockResolvedValueOnce([updatedRow]); + }) + + expect(response.statusCode).toBe(502) + }) + + it('updates a reply with self-labels (PDS record + DB)', async () => { + const existingRow = sampleReplyRow() + selectChain.where.mockResolvedValueOnce([existingRow]) + const labels = { values: [{ val: 'nsfw' }, { val: 'spoiler' }] } + const updatedRow = { + ...existingRow, + content: 'Updated with labels', + labels, + cid: 'bafyreinewcid', + } + updateChain.returning.mockResolvedValueOnce([updatedRow]) - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/replies/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - payload: { content: "Updated with labels", labels }, - }); + headers: { authorization: 'Bearer test-token' }, + payload: { content: 'Updated with labels', labels }, + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ labels: { values: Array<{ val: string }> } }>(); - expect(body.labels).toEqual(labels); + expect(response.statusCode).toBe(200) + const body = response.json<{ labels: { values: Array<{ val: string }> } }>() + expect(body.labels).toEqual(labels) // Verify PDS record includes labels - expect(updateRecordFn).toHaveBeenCalledOnce(); - const pdsRecord = updateRecordFn.mock.calls[0]?.[3] as Record; - expect(pdsRecord.labels).toEqual(labels); + expect(updateRecordFn).toHaveBeenCalledOnce() + const pdsRecord = updateRecordFn.mock.calls[0]?.[3] as Record + expect(pdsRecord.labels).toEqual(labels) // Verify DB update includes labels - const dbUpdateSet = updateChain.set.mock.calls[0]?.[0] as Record; - expect(dbUpdateSet.labels).toEqual(labels); - }); - - it("does not change existing labels when labels field is omitted from update", async () => { - const existingLabels = { values: [{ val: "nsfw" }] }; - const existingRow = sampleReplyRow({ labels: existingLabels }); - selectChain.where.mockResolvedValueOnce([existingRow]); - const updatedRow = { ...existingRow, content: "New content", cid: "bafyreinewcid" }; - updateChain.returning.mockResolvedValueOnce([updatedRow]); - - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + const dbUpdateSet = updateChain.set.mock.calls[0]?.[0] as Record + expect(dbUpdateSet.labels).toEqual(labels) + }) + + it('does not change existing labels when labels field is omitted from update', async () => { + const existingLabels = { values: [{ val: 'nsfw' }] } + const existingRow = sampleReplyRow({ labels: existingLabels }) + selectChain.where.mockResolvedValueOnce([existingRow]) + const updatedRow = { ...existingRow, content: 'New content', cid: 'bafyreinewcid' } + updateChain.returning.mockResolvedValueOnce([updatedRow]) + + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/replies/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - payload: { content: "New content" }, - }); + headers: { authorization: 'Bearer test-token' }, + payload: { content: 'New content' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) // PDS record should preserve existing labels - const pdsRecord = updateRecordFn.mock.calls[0]?.[3] as Record; - expect(pdsRecord.labels).toEqual(existingLabels); + const pdsRecord = updateRecordFn.mock.calls[0]?.[3] as Record + expect(pdsRecord.labels).toEqual(existingLabels) // DB update should NOT include labels key (partial update) - const dbUpdateSet = updateChain.set.mock.calls[0]?.[0] as Record; - expect(dbUpdateSet).not.toHaveProperty("labels"); - }); - }); + const dbUpdateSet = updateChain.set.mock.calls[0]?.[0] as Record + expect(dbUpdateSet).not.toHaveProperty('labels') + }) + }) - describe("PUT /api/replies/:uri (unauthenticated)", () => { - let app: FastifyInstance; + describe('PUT /api/replies/:uri (unauthenticated)', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(undefined); - }); + app = await buildTestApp(undefined) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("returns 401 without auth", async () => { - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + it('returns 401 without auth', async () => { + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/replies/${encodedUri}`, - payload: { content: "Unauth edit." }, - }); + payload: { content: 'Unauth edit.' }, + }) - expect(response.statusCode).toBe(401); - }); - }); + expect(response.statusCode).toBe(401) + }) + }) // ========================================================================= // DELETE /api/replies/:uri // ========================================================================= - describe("DELETE /api/replies/:uri", () => { - let app: FastifyInstance; + describe('DELETE /api/replies/:uri', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - deleteRecordFn.mockResolvedValue(undefined); - }); + vi.clearAllMocks() + resetAllDbMocks() + deleteRecordFn.mockResolvedValue(undefined) + }) - it("deletes a reply when user is the author (deletes from PDS + DB)", async () => { - const existingRow = sampleReplyRow(); - selectChain.where.mockResolvedValueOnce([existingRow]); + it('deletes a reply when user is the author (deletes from PDS + DB)', async () => { + const existingRow = sampleReplyRow() + selectChain.where.mockResolvedValueOnce([existingRow]) - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/replies/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(204); + expect(response.statusCode).toBe(204) // Should have deleted from PDS - expect(deleteRecordFn).toHaveBeenCalledOnce(); - expect(deleteRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID); + expect(deleteRecordFn).toHaveBeenCalledOnce() + expect(deleteRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID) // Should have deleted from DB - expect(mockDb.delete).toHaveBeenCalled(); + expect(mockDb.delete).toHaveBeenCalled() // Should have decremented topic replyCount - expect(mockDb.update).toHaveBeenCalled(); - }); + expect(mockDb.update).toHaveBeenCalled() + }) - it("deletes reply as moderator (index-only delete, not from PDS)", async () => { - const modApp = await buildTestApp(testUser({ did: MOD_DID, handle: "mod.bsky.social" })); + it('deletes reply as moderator (index-only delete, not from PDS)', async () => { + const modApp = await buildTestApp(testUser({ did: MOD_DID, handle: 'mod.bsky.social' })) - const existingRow = sampleReplyRow({ authorDid: OTHER_DID }); + const existingRow = sampleReplyRow({ authorDid: OTHER_DID }) // First select: find reply - selectChain.where.mockResolvedValueOnce([existingRow]); + selectChain.where.mockResolvedValueOnce([existingRow]) // Second select: check user role - selectChain.where.mockResolvedValueOnce([{ did: MOD_DID, role: "moderator" }]); + selectChain.where.mockResolvedValueOnce([{ did: MOD_DID, role: 'moderator' }]) - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await modApp.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/replies/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(204); - expect(deleteRecordFn).not.toHaveBeenCalled(); - expect(mockDb.delete).toHaveBeenCalled(); + expect(response.statusCode).toBe(204) + expect(deleteRecordFn).not.toHaveBeenCalled() + expect(mockDb.delete).toHaveBeenCalled() - await modApp.close(); - }); + await modApp.close() + }) - it("deletes reply as admin (index-only delete, not from PDS)", async () => { - const adminApp = await buildTestApp(testUser({ did: MOD_DID, handle: "admin.bsky.social" })); + it('deletes reply as admin (index-only delete, not from PDS)', async () => { + const adminApp = await buildTestApp(testUser({ did: MOD_DID, handle: 'admin.bsky.social' })) - const existingRow = sampleReplyRow({ authorDid: OTHER_DID }); - selectChain.where.mockResolvedValueOnce([existingRow]); - selectChain.where.mockResolvedValueOnce([{ did: MOD_DID, role: "admin" }]); + const existingRow = sampleReplyRow({ authorDid: OTHER_DID }) + selectChain.where.mockResolvedValueOnce([existingRow]) + selectChain.where.mockResolvedValueOnce([{ did: MOD_DID, role: 'admin' }]) - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await adminApp.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/replies/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(204); - expect(deleteRecordFn).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(204) + expect(deleteRecordFn).not.toHaveBeenCalled() - await adminApp.close(); - }); + await adminApp.close() + }) - it("returns 403 when non-author regular user tries to delete", async () => { - const existingRow = sampleReplyRow({ authorDid: OTHER_DID }); - selectChain.where.mockResolvedValueOnce([existingRow]); - selectChain.where.mockResolvedValueOnce([{ did: TEST_DID, role: "user" }]); + it('returns 403 when non-author regular user tries to delete', async () => { + const existingRow = sampleReplyRow({ authorDid: OTHER_DID }) + selectChain.where.mockResolvedValueOnce([existingRow]) + selectChain.where.mockResolvedValueOnce([{ did: TEST_DID, role: 'user' }]) - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/replies/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(403); - }); + expect(response.statusCode).toBe(403) + }) - it("returns 404 when reply does not exist", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 when reply does not exist', async () => { + selectChain.where.mockResolvedValueOnce([]) - const encodedUri = encodeURIComponent("at://did:plc:nobody/forum.barazo.topic.reply/ghost"); + const encodedUri = encodeURIComponent('at://did:plc:nobody/forum.barazo.topic.reply/ghost') const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/replies/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 502 when PDS delete fails", async () => { - const existingRow = sampleReplyRow(); - selectChain.where.mockResolvedValueOnce([existingRow]); - deleteRecordFn.mockRejectedValueOnce(new Error("PDS delete failed")); + it('returns 502 when PDS delete fails', async () => { + const existingRow = sampleReplyRow() + selectChain.where.mockResolvedValueOnce([existingRow]) + deleteRecordFn.mockRejectedValueOnce(new Error('PDS delete failed')) - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/replies/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(502); - }); - }); + expect(response.statusCode).toBe(502) + }) + }) - describe("DELETE /api/replies/:uri (unauthenticated)", () => { - let app: FastifyInstance; + describe('DELETE /api/replies/:uri (unauthenticated)', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(undefined); - }); + app = await buildTestApp(undefined) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("returns 401 without auth", async () => { - const encodedUri = encodeURIComponent(TEST_REPLY_URI); + it('returns 401 without auth', async () => { + const encodedUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/replies/${encodedUri}`, headers: {}, - }); + }) - expect(response.statusCode).toBe(401); - }); - }); -}); + expect(response.statusCode).toBe(401) + }) + }) +}) diff --git a/tests/unit/routes/search.test.ts b/tests/unit/routes/search.test.ts index a638deb..d8a7451 100644 --- a/tests/unit/routes/search.test.ts +++ b/tests/unit/routes/search.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { RequestUser } from "../../../src/auth/middleware.js"; +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { RequestUser } from '../../../src/auth/middleware.js' // --------------------------------------------------------------------------- // Mock DB with execute method (search uses raw SQL, not Drizzle query builder) @@ -9,32 +9,32 @@ import type { RequestUser } from "../../../src/auth/middleware.js"; const mockDb = { execute: vi.fn(), -}; +} // --------------------------------------------------------------------------- // Mock embedding service // --------------------------------------------------------------------------- -const mockIsEnabled = vi.fn().mockReturnValue(false); -const mockGenerateEmbedding = vi.fn().mockResolvedValue(null); +const mockIsEnabled = vi.fn().mockReturnValue(false) +const mockGenerateEmbedding = vi.fn().mockResolvedValue(null) -vi.mock("../../../src/services/embedding.js", () => ({ +vi.mock('../../../src/services/embedding.js', () => ({ createEmbeddingService: vi.fn(() => ({ isEnabled: mockIsEnabled, generateEmbedding: mockGenerateEmbedding, })), -})); +})) // Import routes AFTER mocking -import { searchRoutes } from "../../../src/routes/search.js"; +import { searchRoutes } from '../../../src/routes/search.js' // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_COMMUNITY_DID = "did:plc:community123"; -const TEST_NOW = new Date("2026-02-13T12:00:00.000Z"); +const TEST_DID = 'did:plc:testuser123' +const TEST_COMMUNITY_DID = 'did:plc:community123' +const TEST_NOW = new Date('2026-02-13T12:00:00.000Z') // --------------------------------------------------------------------------- // Sample row builders (snake_case to match raw SQL output) @@ -43,34 +43,34 @@ const TEST_NOW = new Date("2026-02-13T12:00:00.000Z"); function sampleTopicRow(overrides?: Record) { return { uri: `at://${TEST_DID}/forum.barazo.topic.post/topic123`, - rkey: "topic123", + rkey: 'topic123', author_did: TEST_DID, - title: "Test Topic Title", - content: "This is a test topic body content for search testing.", - category: "general", + title: 'Test Topic Title', + content: 'This is a test topic body content for search testing.', + category: 'general', community_did: TEST_COMMUNITY_DID, reply_count: 5, reaction_count: 3, created_at: TEST_NOW, rank: 0.75, ...overrides, - }; + } } function sampleReplyRow(overrides?: Record) { return { uri: `at://${TEST_DID}/forum.barazo.topic.reply/reply123`, - rkey: "reply123", + rkey: 'reply123', author_did: TEST_DID, - content: "This is a reply to the test topic.", + content: 'This is a reply to the test topic.', community_did: TEST_COMMUNITY_DID, reaction_count: 1, created_at: TEST_NOW, root_uri: `at://${TEST_DID}/forum.barazo.topic.post/topic123`, - root_title: "Test Topic Title", + root_title: 'Test Topic Title', rank: 0.6, ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -78,457 +78,451 @@ function sampleReplyRow(overrides?: Record) { // --------------------------------------------------------------------------- async function buildTestApp(): Promise { - const app = Fastify({ logger: false }); + const app = Fastify({ logger: false }) - app.decorateRequest("user", undefined as RequestUser | undefined); - app.decorate("db", mockDb as never); - app.decorate("env", { + app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorate('db', mockDb as never) + app.decorate('env', { EMBEDDING_URL: undefined, AI_EMBEDDING_DIMENSIONS: 768, - } as never); - app.decorate("authMiddleware", { + } as never) + app.decorate('authMiddleware', { requireAuth: vi.fn((_req: unknown, _reply: unknown) => Promise.resolve()), optionalAuth: vi.fn((_req: unknown, _reply: unknown) => Promise.resolve()), - } as never); - app.decorate("cache", {} as never); + } as never) + app.decorate('cache', {} as never) - await app.register(searchRoutes()); - await app.ready(); + await app.register(searchRoutes()) + await app.ready() - return app; + return app } // =========================================================================== // Test suite // =========================================================================== -describe("search routes", () => { - let app: FastifyInstance; +describe('search routes', () => { + let app: FastifyInstance beforeEach(async () => { - vi.clearAllMocks(); - mockDb.execute.mockResolvedValue([]); - mockIsEnabled.mockReturnValue(false); - mockGenerateEmbedding.mockResolvedValue(null); + vi.clearAllMocks() + mockDb.execute.mockResolvedValue([]) + mockIsEnabled.mockReturnValue(false) + mockGenerateEmbedding.mockResolvedValue(null) - app = await buildTestApp(); - }); + app = await buildTestApp() + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) // ========================================================================= // Validation // ========================================================================= - it("returns 400 when q is missing", async () => { + it('returns 400 when q is missing', async () => { const response = await app.inject({ - method: "GET", - url: "/api/search", - }); + method: 'GET', + url: '/api/search', + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 when q is empty", async () => { + it('returns 400 when q is empty', async () => { const response = await app.inject({ - method: "GET", - url: "/api/search?q=", - }); + method: 'GET', + url: '/api/search?q=', + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) // ========================================================================= // Full-text search: basic results // ========================================================================= - it("returns empty results when no matches", async () => { - mockDb.execute.mockResolvedValue([]); + it('returns empty results when no matches', async () => { + mockDb.execute.mockResolvedValue([]) const response = await app.inject({ - method: "GET", - url: "/api/search?q=nonexistent", - }); + method: 'GET', + url: '/api/search?q=nonexistent', + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - results: unknown[]; - cursor: string | null; - total: number; - searchMode: string; - }>(); - expect(body.results).toEqual([]); - expect(body.cursor).toBeNull(); - expect(body.total).toBe(0); - }); - - it("returns topic results from full-text search", async () => { - const topicRow = sampleTopicRow(); + results: unknown[] + cursor: string | null + total: number + searchMode: string + }>() + expect(body.results).toEqual([]) + expect(body.cursor).toBeNull() + expect(body.total).toBe(0) + }) + + it('returns topic results from full-text search', async () => { + const topicRow = sampleTopicRow() // First execute: topic search - mockDb.execute.mockResolvedValueOnce([topicRow]); + mockDb.execute.mockResolvedValueOnce([topicRow]) // Second execute: reply search (empty) - mockDb.execute.mockResolvedValueOnce([]); + mockDb.execute.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/search?q=test", - }); + method: 'GET', + url: '/api/search?q=test', + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ results: Array<{ - type: string; - uri: string; - authorDid: string; - title: string | null; - content: string; - category: string | null; - communityDid: string; - replyCount: number | null; - reactionCount: number; - rank: number; - }>; - searchMode: string; - }>(); - expect(body.results).toHaveLength(1); - expect(body.results[0]?.type).toBe("topic"); - expect(body.results[0]?.uri).toBe(topicRow.uri); - expect(body.results[0]?.authorDid).toBe(TEST_DID); - expect(body.results[0]?.title).toBe("Test Topic Title"); - expect(body.results[0]?.category).toBe("general"); - expect(body.results[0]?.communityDid).toBe(TEST_COMMUNITY_DID); - expect(body.results[0]?.replyCount).toBe(5); - expect(body.results[0]?.reactionCount).toBe(3); - }); - - it("returns reply results with root topic context", async () => { - const replyRow = sampleReplyRow(); + type: string + uri: string + authorDid: string + title: string | null + content: string + category: string | null + communityDid: string + replyCount: number | null + reactionCount: number + rank: number + }> + searchMode: string + }>() + expect(body.results).toHaveLength(1) + expect(body.results[0]?.type).toBe('topic') + expect(body.results[0]?.uri).toBe(topicRow.uri) + expect(body.results[0]?.authorDid).toBe(TEST_DID) + expect(body.results[0]?.title).toBe('Test Topic Title') + expect(body.results[0]?.category).toBe('general') + expect(body.results[0]?.communityDid).toBe(TEST_COMMUNITY_DID) + expect(body.results[0]?.replyCount).toBe(5) + expect(body.results[0]?.reactionCount).toBe(3) + }) + + it('returns reply results with root topic context', async () => { + const replyRow = sampleReplyRow() // First execute: topic search (empty) - mockDb.execute.mockResolvedValueOnce([]); + mockDb.execute.mockResolvedValueOnce([]) // Second execute: reply search - mockDb.execute.mockResolvedValueOnce([replyRow]); + mockDb.execute.mockResolvedValueOnce([replyRow]) const response = await app.inject({ - method: "GET", - url: "/api/search?q=reply", - }); + method: 'GET', + url: '/api/search?q=reply', + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ results: Array<{ - type: string; - uri: string; - rootUri: string | null; - rootTitle: string | null; - title: string | null; - category: string | null; - }>; - }>(); - expect(body.results).toHaveLength(1); - expect(body.results[0]?.type).toBe("reply"); - expect(body.results[0]?.rootUri).toBe(replyRow.root_uri); - expect(body.results[0]?.rootTitle).toBe("Test Topic Title"); + type: string + uri: string + rootUri: string | null + rootTitle: string | null + title: string | null + category: string | null + }> + }>() + expect(body.results).toHaveLength(1) + expect(body.results[0]?.type).toBe('reply') + expect(body.results[0]?.rootUri).toBe(replyRow.root_uri) + expect(body.results[0]?.rootTitle).toBe('Test Topic Title') // Replies have no own title or category - expect(body.results[0]?.title).toBeNull(); - expect(body.results[0]?.category).toBeNull(); - }); + expect(body.results[0]?.title).toBeNull() + expect(body.results[0]?.category).toBeNull() + }) // ========================================================================= // Filters // ========================================================================= - it("applies category filter", async () => { + it('applies category filter', async () => { // Topic search returns results matching category - mockDb.execute.mockResolvedValueOnce([ - sampleTopicRow({ category: "support" }), - ]); + mockDb.execute.mockResolvedValueOnce([sampleTopicRow({ category: 'support' })]) // Reply search (no category filter for replies) - mockDb.execute.mockResolvedValueOnce([]); + mockDb.execute.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/search?q=help&category=support", - }); + method: 'GET', + url: '/api/search?q=help&category=support', + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - results: Array<{ category: string | null }>; - }>(); - expect(body.results).toHaveLength(1); - expect(body.results[0]?.category).toBe("support"); + results: Array<{ category: string | null }> + }>() + expect(body.results).toHaveLength(1) + expect(body.results[0]?.category).toBe('support') // db.execute should have been called (verifying it was invoked with the filter) - expect(mockDb.execute).toHaveBeenCalled(); - }); + expect(mockDb.execute).toHaveBeenCalled() + }) - it("applies author filter", async () => { - const authorDid = "did:plc:specific_author"; - mockDb.execute.mockResolvedValueOnce([ - sampleTopicRow({ author_did: authorDid }), - ]); - mockDb.execute.mockResolvedValueOnce([]); + it('applies author filter', async () => { + const authorDid = 'did:plc:specific_author' + mockDb.execute.mockResolvedValueOnce([sampleTopicRow({ author_did: authorDid })]) + mockDb.execute.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/search?q=post&author=${encodeURIComponent(authorDid)}`, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - results: Array<{ authorDid: string }>; - }>(); - expect(body.results).toHaveLength(1); - expect(body.results[0]?.authorDid).toBe(authorDid); - }); + results: Array<{ authorDid: string }> + }>() + expect(body.results).toHaveLength(1) + expect(body.results[0]?.authorDid).toBe(authorDid) + }) - it("applies date range filters", async () => { - mockDb.execute.mockResolvedValueOnce([sampleTopicRow()]); - mockDb.execute.mockResolvedValueOnce([]); + it('applies date range filters', async () => { + mockDb.execute.mockResolvedValueOnce([sampleTopicRow()]) + mockDb.execute.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/search?q=test&dateFrom=2026-01-01T00:00:00Z&dateTo=2026-03-01T00:00:00Z", - }); + method: 'GET', + url: '/api/search?q=test&dateFrom=2026-01-01T00:00:00Z&dateTo=2026-03-01T00:00:00Z', + }) - expect(response.statusCode).toBe(200); - expect(response.json<{ results: unknown[] }>().results).toHaveLength(1); + expect(response.statusCode).toBe(200) + expect(response.json<{ results: unknown[] }>().results).toHaveLength(1) // The date filters are embedded in the SQL; we verify the query succeeded - expect(mockDb.execute).toHaveBeenCalled(); - }); + expect(mockDb.execute).toHaveBeenCalled() + }) // ========================================================================= // Type filter // ========================================================================= it("handles type filter 'topics' (only topics searched)", async () => { - mockDb.execute.mockResolvedValueOnce([sampleTopicRow()]); + mockDb.execute.mockResolvedValueOnce([sampleTopicRow()]) const response = await app.inject({ - method: "GET", - url: "/api/search?q=test&type=topics", - }); + method: 'GET', + url: '/api/search?q=test&type=topics', + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - results: Array<{ type: string }>; - }>(); - expect(body.results).toHaveLength(1); - expect(body.results[0]?.type).toBe("topic"); + results: Array<{ type: string }> + }>() + expect(body.results).toHaveLength(1) + expect(body.results[0]?.type).toBe('topic') // Only one execute call -- topics only, no reply search - expect(mockDb.execute).toHaveBeenCalledTimes(1); - }); + expect(mockDb.execute).toHaveBeenCalledTimes(1) + }) it("handles type filter 'replies' (only replies searched)", async () => { - mockDb.execute.mockResolvedValueOnce([sampleReplyRow()]); + mockDb.execute.mockResolvedValueOnce([sampleReplyRow()]) const response = await app.inject({ - method: "GET", - url: "/api/search?q=test&type=replies", - }); + method: 'GET', + url: '/api/search?q=test&type=replies', + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - results: Array<{ type: string }>; - }>(); - expect(body.results).toHaveLength(1); - expect(body.results[0]?.type).toBe("reply"); + results: Array<{ type: string }> + }>() + expect(body.results).toHaveLength(1) + expect(body.results[0]?.type).toBe('reply') // Only one execute call -- replies only, no topic search - expect(mockDb.execute).toHaveBeenCalledTimes(1); - }); + expect(mockDb.execute).toHaveBeenCalledTimes(1) + }) // ========================================================================= // Pagination // ========================================================================= - it("returns cursor for pagination when more results exist", async () => { + it('returns cursor for pagination when more results exist', async () => { // Default limit is 25. Route fetches limit+1=26. // Return 26 topic results to trigger hasMore. const rows = Array.from({ length: 26 }, (_, i) => sampleTopicRow({ - uri: `at://${TEST_DID}/forum.barazo.topic.post/topic${String(i).padStart(3, "0")}`, - rkey: `topic${String(i).padStart(3, "0")}`, + uri: `at://${TEST_DID}/forum.barazo.topic.post/topic${String(i).padStart(3, '0')}`, + rkey: `topic${String(i).padStart(3, '0')}`, rank: 1.0 - i * 0.01, - }), - ); + }) + ) // Topics search returns 26 rows - mockDb.execute.mockResolvedValueOnce(rows); + mockDb.execute.mockResolvedValueOnce(rows) // Replies search returns empty - mockDb.execute.mockResolvedValueOnce([]); + mockDb.execute.mockResolvedValueOnce([]) // Count query for topics - mockDb.execute.mockResolvedValueOnce([{ count: "50" }]); + mockDb.execute.mockResolvedValueOnce([{ count: '50' }]) // Count query for replies - mockDb.execute.mockResolvedValueOnce([{ count: "10" }]); + mockDb.execute.mockResolvedValueOnce([{ count: '10' }]) const response = await app.inject({ - method: "GET", - url: "/api/search?q=test", - }); + method: 'GET', + url: '/api/search?q=test', + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - results: unknown[]; - cursor: string | null; - total: number; - }>(); + results: unknown[] + cursor: string | null + total: number + }>() // Should return exactly 25 (limit), not 26 - expect(body.results).toHaveLength(25); - expect(body.cursor).toBeTruthy(); - expect(body.total).toBe(60); // 50 topics + 10 replies - }); + expect(body.results).toHaveLength(25) + expect(body.cursor).toBeTruthy() + expect(body.total).toBe(60) // 50 topics + 10 replies + }) - it("returns null cursor when fewer results than limit", async () => { - mockDb.execute.mockResolvedValueOnce([sampleTopicRow()]); - mockDb.execute.mockResolvedValueOnce([]); + it('returns null cursor when fewer results than limit', async () => { + mockDb.execute.mockResolvedValueOnce([sampleTopicRow()]) + mockDb.execute.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/search?q=test", - }); + method: 'GET', + url: '/api/search?q=test', + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - cursor: string | null; - total: number; - }>(); - expect(body.cursor).toBeNull(); - expect(body.total).toBe(1); - }); + cursor: string | null + total: number + }>() + expect(body.cursor).toBeNull() + expect(body.total).toBe(1) + }) // ========================================================================= // Search mode reporting // ========================================================================= it("reports searchMode as 'fulltext' when no embedding URL", async () => { - mockDb.execute.mockResolvedValueOnce([]); - mockDb.execute.mockResolvedValueOnce([]); + mockDb.execute.mockResolvedValueOnce([]) + mockDb.execute.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/search?q=test", - }); + method: 'GET', + url: '/api/search?q=test', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ searchMode: string }>(); - expect(body.searchMode).toBe("fulltext"); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ searchMode: string }>() + expect(body.searchMode).toBe('fulltext') + }) it("reports searchMode as 'hybrid' when embedding service is available and returns embeddings", async () => { // Configure embedding service as enabled with working embeddings - mockIsEnabled.mockReturnValue(true); - mockGenerateEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + mockIsEnabled.mockReturnValue(true) + mockGenerateEmbedding.mockResolvedValue([0.1, 0.2, 0.3]) // Rebuild app to pick up updated mock state - const hybridApp = await buildTestApp(); + const hybridApp = await buildTestApp() // Full-text topic results - mockDb.execute.mockResolvedValueOnce([sampleTopicRow()]); + mockDb.execute.mockResolvedValueOnce([sampleTopicRow()]) // Full-text reply results - mockDb.execute.mockResolvedValueOnce([]); + mockDb.execute.mockResolvedValueOnce([]) // Vector topic results - mockDb.execute.mockResolvedValueOnce([]); + mockDb.execute.mockResolvedValueOnce([]) // Vector reply results - mockDb.execute.mockResolvedValueOnce([]); + mockDb.execute.mockResolvedValueOnce([]) const response = await hybridApp.inject({ - method: "GET", - url: "/api/search?q=semantic+query", - }); + method: 'GET', + url: '/api/search?q=semantic+query', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ searchMode: string }>(); - expect(body.searchMode).toBe("hybrid"); + expect(response.statusCode).toBe(200) + const body = response.json<{ searchMode: string }>() + expect(body.searchMode).toBe('hybrid') - await hybridApp.close(); - }); + await hybridApp.close() + }) - it("falls back to fulltext when embedding service is enabled but returns null", async () => { - mockIsEnabled.mockReturnValue(true); - mockGenerateEmbedding.mockResolvedValue(null); + it('falls back to fulltext when embedding service is enabled but returns null', async () => { + mockIsEnabled.mockReturnValue(true) + mockGenerateEmbedding.mockResolvedValue(null) - const fallbackApp = await buildTestApp(); + const fallbackApp = await buildTestApp() - mockDb.execute.mockResolvedValueOnce([]); - mockDb.execute.mockResolvedValueOnce([]); + mockDb.execute.mockResolvedValueOnce([]) + mockDb.execute.mockResolvedValueOnce([]) const response = await fallbackApp.inject({ - method: "GET", - url: "/api/search?q=test", - }); + method: 'GET', + url: '/api/search?q=test', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ searchMode: string }>(); - expect(body.searchMode).toBe("fulltext"); + expect(response.statusCode).toBe(200) + const body = response.json<{ searchMode: string }>() + expect(body.searchMode).toBe('fulltext') - await fallbackApp.close(); - }); + await fallbackApp.close() + }) // ========================================================================= // Content snippeting // ========================================================================= - it("truncates long content to snippet", async () => { - const longContent = "A".repeat(500); - mockDb.execute.mockResolvedValueOnce([ - sampleTopicRow({ content: longContent }), - ]); - mockDb.execute.mockResolvedValueOnce([]); + it('truncates long content to snippet', async () => { + const longContent = 'A'.repeat(500) + mockDb.execute.mockResolvedValueOnce([sampleTopicRow({ content: longContent })]) + mockDb.execute.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/search?q=test", - }); + method: 'GET', + url: '/api/search?q=test', + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - results: Array<{ content: string }>; - }>(); + results: Array<{ content: string }> + }>() // createSnippet truncates at 300 chars + "..." - expect(body.results[0]?.content.length).toBeLessThanOrEqual(303); - expect(body.results[0]?.content).toContain("..."); - }); + expect(body.results[0]?.content.length).toBeLessThanOrEqual(303) + expect(body.results[0]?.content).toContain('...') + }) // ========================================================================= // Date serialization // ========================================================================= - it("serializes Date objects as ISO strings in results", async () => { + it('serializes Date objects as ISO strings in results', async () => { mockDb.execute.mockResolvedValueOnce([ - sampleTopicRow({ created_at: new Date("2026-02-13T12:00:00.000Z") }), - ]); - mockDb.execute.mockResolvedValueOnce([]); + sampleTopicRow({ created_at: new Date('2026-02-13T12:00:00.000Z') }), + ]) + mockDb.execute.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/search?q=test", - }); + method: 'GET', + url: '/api/search?q=test', + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - results: Array<{ createdAt: string }>; - }>(); - expect(body.results[0]?.createdAt).toBe("2026-02-13T12:00:00.000Z"); - }); + results: Array<{ createdAt: string }> + }>() + expect(body.results[0]?.createdAt).toBe('2026-02-13T12:00:00.000Z') + }) - it("handles string dates from DB gracefully", async () => { + it('handles string dates from DB gracefully', async () => { mockDb.execute.mockResolvedValueOnce([ - sampleTopicRow({ created_at: "2026-02-13T12:00:00.000Z" }), - ]); - mockDb.execute.mockResolvedValueOnce([]); + sampleTopicRow({ created_at: '2026-02-13T12:00:00.000Z' }), + ]) + mockDb.execute.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/search?q=test", - }); + method: 'GET', + url: '/api/search?q=test', + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) const body = response.json<{ - results: Array<{ createdAt: string }>; - }>(); - expect(body.results[0]?.createdAt).toBe("2026-02-13T12:00:00.000Z"); - }); -}); + results: Array<{ createdAt: string }> + }>() + expect(body.results[0]?.createdAt).toBe('2026-02-13T12:00:00.000Z') + }) +}) diff --git a/tests/unit/routes/setup.test.ts b/tests/unit/routes/setup.test.ts index d684039..5730aca 100644 --- a/tests/unit/routes/setup.test.ts +++ b/tests/unit/routes/setup.test.ts @@ -1,25 +1,25 @@ -import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import { setupRoutes } from "../../../src/routes/setup.js"; -import type { SetupService, SetupStatus, InitializeResult } from "../../../src/setup/service.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService, Session } from "../../../src/auth/session.js"; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import { setupRoutes } from '../../../src/routes/setup.js' +import type { SetupService, SetupStatus, InitializeResult } from '../../../src/setup/service.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService, Session } from '../../../src/auth/session.js' // --------------------------------------------------------------------------- // Standalone mock functions // --------------------------------------------------------------------------- -const getStatusFn = vi.fn<() => Promise>(); -const initializeFn = vi.fn<(...args: unknown[]) => Promise>(); +const getStatusFn = vi.fn<() => Promise>() +const initializeFn = vi.fn<(...args: unknown[]) => Promise>() const mockSetupService: SetupService = { getStatus: getStatusFn, - initialize: initializeFn as SetupService["initialize"], -}; + initialize: initializeFn as SetupService['initialize'], +} // Session validation mock (used by auth middleware) -const validateAccessTokenFn = vi.fn<(...args: unknown[]) => Promise>(); +const validateAccessTokenFn = vi.fn<(...args: unknown[]) => Promise>() const mockSessionService: SessionService = { createSession: vi.fn(), @@ -27,41 +27,41 @@ const mockSessionService: SessionService = { refreshSession: vi.fn(), deleteSession: vi.fn(), deleteAllSessionsForDid: vi.fn(), -}; +} // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:test123456789"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "s".repeat(64); -const TEST_ACCESS_TOKEN = "a".repeat(64); +const TEST_DID = 'did:plc:test123456789' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 's'.repeat(64) +const TEST_ACCESS_TOKEN = 'a'.repeat(64) function makeMockSession(): Session { return { sid: TEST_SID, did: TEST_DID, handle: TEST_HANDLE, - accessTokenHash: "h".repeat(64), + accessTokenHash: 'h'.repeat(64), accessTokenExpiresAt: Date.now() + 900_000, createdAt: Date.now(), - }; + } } // --------------------------------------------------------------------------- // Test suite // --------------------------------------------------------------------------- -describe("setup routes", () => { - let app: FastifyInstance; +describe('setup routes', () => { + let app: FastifyInstance beforeAll(async () => { - app = Fastify({ logger: false }); + app = Fastify({ logger: false }) // Create real auth middleware using mock session service // (matches the codebase pattern from middleware.ts) - const { createAuthMiddleware } = await import("../../../src/auth/middleware.js"); + const { createAuthMiddleware } = await import('../../../src/auth/middleware.js') const mockLogger = { info: vi.fn(), error: vi.fn(), @@ -71,344 +71,326 @@ describe("setup routes", () => { trace: vi.fn(), child: vi.fn(), silent: vi.fn(), - level: "silent", - }; + level: 'silent', + } const authMiddleware: AuthMiddleware = createAuthMiddleware( mockSessionService, - mockLogger as never, - ); + mockLogger as never + ) // Decorate with mocks - app.decorate("setupService", mockSetupService); - app.decorate("authMiddleware", authMiddleware); + app.decorate('setupService', mockSetupService) + app.decorate('authMiddleware', authMiddleware) // Fastify requires decoration before hooks can set properties - app.decorateRequest("user", undefined as RequestUser | undefined); + app.decorateRequest('user', undefined as RequestUser | undefined) // Register setup routes - await app.register(setupRoutes()); - await app.ready(); - }); + await app.register(setupRoutes()) + await app.ready() + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) // ========================================================================= // GET /api/setup/status // ========================================================================= - describe("GET /api/setup/status", () => { - it("returns { initialized: false } when no settings row exists", async () => { - getStatusFn.mockResolvedValueOnce({ initialized: false }); + describe('GET /api/setup/status', () => { + it('returns { initialized: false } when no settings row exists', async () => { + getStatusFn.mockResolvedValueOnce({ initialized: false }) const response = await app.inject({ - method: "GET", - url: "/api/setup/status", - }); + method: 'GET', + url: '/api/setup/status', + }) - expect(response.statusCode).toBe(200); - expect(response.json()).toStrictEqual({ initialized: false }); - }); + expect(response.statusCode).toBe(200) + expect(response.json()).toStrictEqual({ initialized: false }) + }) - it("returns { initialized: false } when settings exist but not initialized", async () => { - getStatusFn.mockResolvedValueOnce({ initialized: false }); + it('returns { initialized: false } when settings exist but not initialized', async () => { + getStatusFn.mockResolvedValueOnce({ initialized: false }) const response = await app.inject({ - method: "GET", - url: "/api/setup/status", - }); + method: 'GET', + url: '/api/setup/status', + }) - expect(response.statusCode).toBe(200); - expect(response.json()).toStrictEqual({ initialized: false }); - }); + expect(response.statusCode).toBe(200) + expect(response.json()).toStrictEqual({ initialized: false }) + }) - it("returns { initialized: true, communityName } when initialized", async () => { + it('returns { initialized: true, communityName } when initialized', async () => { getStatusFn.mockResolvedValueOnce({ initialized: true, - communityName: "My Forum", - }); + communityName: 'My Forum', + }) const response = await app.inject({ - method: "GET", - url: "/api/setup/status", - }); + method: 'GET', + url: '/api/setup/status', + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) expect(response.json()).toStrictEqual({ initialized: true, - communityName: "My Forum", - }); - }); + communityName: 'My Forum', + }) + }) - it("returns 502 when service throws", async () => { - getStatusFn.mockRejectedValueOnce(new Error("DB down")); + it('returns 502 when service throws', async () => { + getStatusFn.mockRejectedValueOnce(new Error('DB down')) const response = await app.inject({ - method: "GET", - url: "/api/setup/status", - }); + method: 'GET', + url: '/api/setup/status', + }) - expect(response.statusCode).toBe(502); - expect(response.json<{ error: string }>().error).toBe( - "Service temporarily unavailable", - ); - }); - }); + expect(response.statusCode).toBe(502) + expect(response.json<{ error: string }>().error).toBe('Service temporarily unavailable') + }) + }) // ========================================================================= // POST /api/setup/initialize // ========================================================================= - describe("POST /api/setup/initialize", () => { - it("returns 401 without authentication", async () => { + describe('POST /api/setup/initialize', () => { + it('returns 401 without authentication', async () => { const response = await app.inject({ - method: "POST", - url: "/api/setup/initialize", + method: 'POST', + url: '/api/setup/initialize', payload: {}, - }); + }) - expect(response.statusCode).toBe(401); - expect(response.json<{ error: string }>().error).toBe( - "Authentication required", - ); - expect(initializeFn).not.toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(401) + expect(response.json<{ error: string }>().error).toBe('Authentication required') + expect(initializeFn).not.toHaveBeenCalled() + }) - it("returns 200 and sets admin DID for first authenticated user", async () => { - validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()); + it('returns 200 and sets admin DID for first authenticated user', async () => { + validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()) initializeFn.mockResolvedValueOnce({ initialized: true, adminDid: TEST_DID, - communityName: "Barazo Community", - }); + communityName: 'Barazo Community', + }) const response = await app.inject({ - method: "POST", - url: "/api/setup/initialize", + method: 'POST', + url: '/api/setup/initialize', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}`, }, payload: {}, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) expect(response.json()).toStrictEqual({ initialized: true, adminDid: TEST_DID, - communityName: "Barazo Community", - }); + communityName: 'Barazo Community', + }) expect(initializeFn).toHaveBeenCalledWith({ did: TEST_DID, communityName: undefined, handle: undefined, serviceEndpoint: undefined, - }); - }); + }) + }) - it("returns 409 when already initialized", async () => { - validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()); - initializeFn.mockResolvedValueOnce({ alreadyInitialized: true }); + it('returns 409 when already initialized', async () => { + validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()) + initializeFn.mockResolvedValueOnce({ alreadyInitialized: true }) const response = await app.inject({ - method: "POST", - url: "/api/setup/initialize", + method: 'POST', + url: '/api/setup/initialize', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}`, }, payload: {}, - }); + }) - expect(response.statusCode).toBe(409); - expect(response.json<{ error: string }>().error).toBe( - "Community already initialized", - ); - }); + expect(response.statusCode).toBe(409) + expect(response.json<{ error: string }>().error).toBe('Community already initialized') + }) - it("accepts optional communityName in request body", async () => { - validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()); + it('accepts optional communityName in request body', async () => { + validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()) initializeFn.mockResolvedValueOnce({ initialized: true, adminDid: TEST_DID, - communityName: "Custom Forum Name", - }); + communityName: 'Custom Forum Name', + }) const response = await app.inject({ - method: "POST", - url: "/api/setup/initialize", + method: 'POST', + url: '/api/setup/initialize', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}`, - "content-type": "application/json", + 'content-type': 'application/json', }, - payload: { communityName: "Custom Forum Name" }, - }); + payload: { communityName: 'Custom Forum Name' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) expect(response.json()).toStrictEqual({ initialized: true, adminDid: TEST_DID, - communityName: "Custom Forum Name", - }); + communityName: 'Custom Forum Name', + }) expect(initializeFn).toHaveBeenCalledWith({ did: TEST_DID, - communityName: "Custom Forum Name", + communityName: 'Custom Forum Name', handle: undefined, serviceEndpoint: undefined, - }); - }); + }) + }) - it("returns 400 for invalid communityName (empty string)", async () => { - validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()); + it('returns 400 for invalid communityName (empty string)', async () => { + validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()) const response = await app.inject({ - method: "POST", - url: "/api/setup/initialize", + method: 'POST', + url: '/api/setup/initialize', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}`, - "content-type": "application/json", + 'content-type': 'application/json', }, - payload: { communityName: "" }, - }); + payload: { communityName: '' }, + }) - expect(response.statusCode).toBe(400); - expect(response.json<{ error: string }>().error).toBe( - "Invalid request body", - ); - }); + expect(response.statusCode).toBe(400) + expect(response.json<{ error: string }>().error).toBe('Invalid request body') + }) - it("returns 400 for communityName exceeding max length", async () => { - validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()); + it('returns 400 for communityName exceeding max length', async () => { + validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()) const response = await app.inject({ - method: "POST", - url: "/api/setup/initialize", + method: 'POST', + url: '/api/setup/initialize', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}`, - "content-type": "application/json", + 'content-type': 'application/json', }, - payload: { communityName: "x".repeat(256) }, - }); + payload: { communityName: 'x'.repeat(256) }, + }) - expect(response.statusCode).toBe(400); - expect(response.json<{ error: string }>().error).toBe( - "Invalid request body", - ); - }); + expect(response.statusCode).toBe(400) + expect(response.json<{ error: string }>().error).toBe('Invalid request body') + }) - it("returns 400 for invalid communityName (whitespace only)", async () => { - validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()); + it('returns 400 for invalid communityName (whitespace only)', async () => { + validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()) const response = await app.inject({ - method: "POST", - url: "/api/setup/initialize", + method: 'POST', + url: '/api/setup/initialize', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}`, - "content-type": "application/json", + 'content-type': 'application/json', }, - payload: { communityName: " " }, - }); + payload: { communityName: ' ' }, + }) - expect(response.statusCode).toBe(400); - expect(response.json<{ error: string }>().error).toBe( - "Invalid request body", - ); - }); + expect(response.statusCode).toBe(400) + expect(response.json<{ error: string }>().error).toBe('Invalid request body') + }) - it("passes handle and serviceEndpoint to service when provided", async () => { - validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()); + it('passes handle and serviceEndpoint to service when provided', async () => { + validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()) initializeFn.mockResolvedValueOnce({ initialized: true, adminDid: TEST_DID, - communityName: "Barazo Community", - communityDid: "did:plc:generated123", - }); + communityName: 'Barazo Community', + communityDid: 'did:plc:generated123', + }) const response = await app.inject({ - method: "POST", - url: "/api/setup/initialize", + method: 'POST', + url: '/api/setup/initialize', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}`, - "content-type": "application/json", + 'content-type': 'application/json', }, payload: { - communityName: "My Forum", - handle: "forum.example.com", - serviceEndpoint: "https://forum.example.com", + communityName: 'My Forum', + handle: 'forum.example.com', + serviceEndpoint: 'https://forum.example.com', }, - }); + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) expect(initializeFn).toHaveBeenCalledWith({ did: TEST_DID, - communityName: "My Forum", - handle: "forum.example.com", - serviceEndpoint: "https://forum.example.com", - }); - }); + communityName: 'My Forum', + handle: 'forum.example.com', + serviceEndpoint: 'https://forum.example.com', + }) + }) - it("returns 400 for invalid serviceEndpoint (not a URL)", async () => { - validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()); + it('returns 400 for invalid serviceEndpoint (not a URL)', async () => { + validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()) const response = await app.inject({ - method: "POST", - url: "/api/setup/initialize", + method: 'POST', + url: '/api/setup/initialize', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}`, - "content-type": "application/json", + 'content-type': 'application/json', }, payload: { - serviceEndpoint: "not-a-url", + serviceEndpoint: 'not-a-url', }, - }); + }) - expect(response.statusCode).toBe(400); - expect(response.json<{ error: string }>().error).toBe( - "Invalid request body", - ); - }); + expect(response.statusCode).toBe(400) + expect(response.json<{ error: string }>().error).toBe('Invalid request body') + }) - it("returns 400 for empty handle", async () => { - validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()); + it('returns 400 for empty handle', async () => { + validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()) const response = await app.inject({ - method: "POST", - url: "/api/setup/initialize", + method: 'POST', + url: '/api/setup/initialize', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}`, - "content-type": "application/json", + 'content-type': 'application/json', }, payload: { - handle: "", + handle: '', }, - }); + }) - expect(response.statusCode).toBe(400); - expect(response.json<{ error: string }>().error).toBe( - "Invalid request body", - ); - }); + expect(response.statusCode).toBe(400) + expect(response.json<{ error: string }>().error).toBe('Invalid request body') + }) - it("returns 502 when service throws", async () => { - validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()); - initializeFn.mockRejectedValueOnce(new Error("DB down")); + it('returns 502 when service throws', async () => { + validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()) + initializeFn.mockRejectedValueOnce(new Error('DB down')) const response = await app.inject({ - method: "POST", - url: "/api/setup/initialize", + method: 'POST', + url: '/api/setup/initialize', headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}`, }, payload: {}, - }); - - expect(response.statusCode).toBe(502); - expect(response.json<{ error: string }>().error).toBe( - "Service temporarily unavailable", - ); - }); - }); -}); + }) + + expect(response.statusCode).toBe(502) + expect(response.json<{ error: string }>().error).toBe('Service temporarily unavailable') + }) + }) +}) diff --git a/tests/unit/routes/topics-replies-integration.test.ts b/tests/unit/routes/topics-replies-integration.test.ts index c57b478..c1113a8 100644 --- a/tests/unit/routes/topics-replies-integration.test.ts +++ b/tests/unit/routes/topics-replies-integration.test.ts @@ -1,30 +1,45 @@ -import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { type DbChain, createChainableProxy, createMockDb } from "../../helpers/mock-db.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' // --------------------------------------------------------------------------- // Mock PDS client module (must be before importing routes) // --------------------------------------------------------------------------- -const createRecordFn = vi.fn<(did: string, collection: string, record: Record) => Promise<{ uri: string; cid: string }>>(); -const updateRecordFn = vi.fn<(did: string, collection: string, rkey: string, record: Record) => Promise<{ uri: string; cid: string }>>(); -const deleteRecordFn = vi.fn<(did: string, collection: string, rkey: string) => Promise>(); - -vi.mock("../../../src/lib/pds-client.js", () => ({ +const createRecordFn = + vi.fn< + ( + did: string, + collection: string, + record: Record + ) => Promise<{ uri: string; cid: string }> + >() +const updateRecordFn = + vi.fn< + ( + did: string, + collection: string, + rkey: string, + record: Record + ) => Promise<{ uri: string; cid: string }> + >() +const deleteRecordFn = vi.fn<(did: string, collection: string, rkey: string) => Promise>() + +vi.mock('../../../src/lib/pds-client.js', () => ({ createPdsClient: () => ({ createRecord: createRecordFn, updateRecord: updateRecordFn, deleteRecord: deleteRecordFn, }), -})); +})) // Mock anti-spam module (tested separately in anti-spam.test.ts) -vi.mock("../../../src/lib/anti-spam.js", () => ({ +vi.mock('../../../src/lib/anti-spam.js', () => ({ loadAntiSpamSettings: vi.fn().mockResolvedValue({ wordFilter: [], firstPostQueueCount: 3, @@ -42,43 +57,43 @@ vi.mock("../../../src/lib/anti-spam.js", () => ({ checkWriteRateLimit: vi.fn().mockResolvedValue(false), canCreateTopic: vi.fn().mockResolvedValue(true), runAntiSpamChecks: vi.fn().mockResolvedValue({ held: false, reasons: [] }), -})); +})) // Import routes AFTER mocking -import { topicRoutes } from "../../../src/routes/topics.js"; -import { replyRoutes } from "../../../src/routes/replies.js"; +import { topicRoutes } from '../../../src/routes/topics.js' +import { replyRoutes } from '../../../src/routes/replies.js' // --------------------------------------------------------------------------- // Mock env // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_DID: 'did:plc:community123', RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) -const TEST_TOPIC_URI = `at://${TEST_DID}/forum.barazo.topic.post/topic001`; -const TEST_TOPIC_CID = "bafyreiatopic001"; -const TEST_TOPIC_RKEY = "topic001"; +const TEST_TOPIC_URI = `at://${TEST_DID}/forum.barazo.topic.post/topic001` +const TEST_TOPIC_CID = 'bafyreiatopic001' +const TEST_TOPIC_RKEY = 'topic001' -const TEST_REPLY_URI = `at://${TEST_DID}/forum.barazo.topic.reply/reply001`; -const TEST_REPLY_CID = "bafyreireply001"; -const TEST_REPLY_RKEY = "reply001"; +const TEST_REPLY_URI = `at://${TEST_DID}/forum.barazo.topic.reply/reply001` +const TEST_REPLY_CID = 'bafyreireply001' +const TEST_REPLY_RKEY = 'reply001' -const TEST_PARENT_REPLY_URI = `at://${TEST_DID}/forum.barazo.topic.reply/parentreply001`; -const TEST_PARENT_REPLY_CID = "bafyreiparentreply001"; +const TEST_PARENT_REPLY_URI = `at://${TEST_DID}/forum.barazo.topic.reply/parentreply001` +const TEST_PARENT_REPLY_CID = 'bafyreiparentreply001' -const TEST_NOW = "2026-02-13T12:00:00.000Z"; +const TEST_NOW = '2026-02-13T12:00:00.000Z' // --------------------------------------------------------------------------- // Mock user builder @@ -90,54 +105,54 @@ function testUser(overrides?: Partial): RequestUser { handle: TEST_HANDLE, sid: TEST_SID, ...overrides, - }; + } } // --------------------------------------------------------------------------- // Mock firehose repo manager // --------------------------------------------------------------------------- -const isTrackedFn = vi.fn<(did: string) => Promise>(); -const trackRepoFn = vi.fn<(did: string) => Promise>(); +const isTrackedFn = vi.fn<(did: string) => Promise>() +const trackRepoFn = vi.fn<(did: string) => Promise>() const mockRepoManager = { isTracked: isTrackedFn, trackRepo: trackRepoFn, untrackRepo: vi.fn(), restoreTrackedRepos: vi.fn(), -}; +} const mockFirehose = { getRepoManager: () => mockRepoManager, start: vi.fn(), stop: vi.fn(), getStatus: vi.fn().mockReturnValue({ connected: true, lastEventId: null }), -}; +} // --------------------------------------------------------------------------- // Chainable mock DB (shared helper) // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() -let insertChain: DbChain; -let selectChain: DbChain; -let updateChain: DbChain; -let deleteChain: DbChain; +let insertChain: DbChain +let selectChain: DbChain +let updateChain: DbChain +let deleteChain: DbChain function resetAllDbMocks(): void { - insertChain = createChainableProxy(); - selectChain = createChainableProxy([]); - updateChain = createChainableProxy([]); - deleteChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); - mockDb.select.mockReturnValue(selectChain); - mockDb.update.mockReturnValue(updateChain); - mockDb.delete.mockReturnValue(deleteChain); + insertChain = createChainableProxy() + selectChain = createChainableProxy([]) + updateChain = createChainableProxy([]) + deleteChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(updateChain) + mockDb.delete.mockReturnValue(deleteChain) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { - await fn(mockDb); - }); + await fn(mockDb) + }) } // --------------------------------------------------------------------------- @@ -148,18 +163,18 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } // --------------------------------------------------------------------------- @@ -171,12 +186,12 @@ function sampleTopicRow(overrides?: Record) { uri: TEST_TOPIC_URI, rkey: TEST_TOPIC_RKEY, authorDid: TEST_DID, - title: "Test Topic Title", - content: "Test topic content goes here", + title: 'Test Topic Title', + content: 'Test topic content goes here', contentFormat: null, - category: "general", - tags: ["test", "example"], - communityDid: "did:plc:community123", + category: 'general', + tags: ['test', 'example'], + communityDid: 'did:plc:community123', cid: TEST_TOPIC_CID, labels: null, replyCount: 0, @@ -186,7 +201,7 @@ function sampleTopicRow(overrides?: Record) { indexedAt: new Date(TEST_NOW), embedding: null, ...overrides, - }; + } } function sampleReplyRow(overrides?: Record) { @@ -194,13 +209,13 @@ function sampleReplyRow(overrides?: Record) { uri: TEST_REPLY_URI, rkey: TEST_REPLY_RKEY, authorDid: TEST_DID, - content: "This is a test reply", + content: 'This is a test reply', contentFormat: null, rootUri: TEST_TOPIC_URI, rootCid: TEST_TOPIC_CID, parentUri: TEST_TOPIC_URI, parentCid: TEST_TOPIC_CID, - communityDid: "did:plc:community123", + communityDid: 'did:plc:community123', cid: TEST_REPLY_CID, labels: null, reactionCount: 0, @@ -208,7 +223,7 @@ function sampleReplyRow(overrides?: Record) { indexedAt: new Date(TEST_NOW), embedding: null, ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -216,533 +231,544 @@ function sampleReplyRow(overrides?: Record) { // --------------------------------------------------------------------------- async function buildTestApp(user?: RequestUser): Promise { - const app = Fastify({ logger: false }); - - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", createMockAuthMiddleware(user)); - app.decorate("firehose", mockFirehose as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); + const app = Fastify({ logger: false }) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', createMockAuthMiddleware(user)) + app.decorate('firehose', mockFirehose as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorate('interactionGraphService', { + recordReply: vi.fn().mockResolvedValue(undefined), + recordReaction: vi.fn().mockResolvedValue(undefined), + recordCoParticipation: vi.fn().mockResolvedValue(undefined), + } as never) + app.decorateRequest('user', undefined as RequestUser | undefined) // Register BOTH route sets so we can test cross-endpoint behavior - await app.register(topicRoutes()); - await app.register(replyRoutes()); - await app.ready(); + await app.register(topicRoutes()) + await app.register(replyRoutes()) + await app.ready() - return app; + return app } // =========================================================================== // Test suite: cross-endpoint topic + reply interactions // =========================================================================== -describe("topics + replies cross-endpoint integration", () => { +describe('topics + replies cross-endpoint integration', () => { // ========================================================================= // Create topic, then create reply -- verify replyCount increment // ========================================================================= - describe("create topic then create reply", () => { - let app: FastifyInstance; + describe('create topic then create reply', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - isTrackedFn.mockResolvedValue(true); - }); + vi.clearAllMocks() + resetAllDbMocks() + isTrackedFn.mockResolvedValue(true) + }) - it("creating a reply calls update on topic (replyCount + lastActivityAt)", async () => { + it('creating a reply calls update on topic (replyCount + lastActivityAt)', async () => { // Mock PDS: topic creation - createRecordFn.mockResolvedValueOnce({ uri: TEST_TOPIC_URI, cid: TEST_TOPIC_CID }); + createRecordFn.mockResolvedValueOnce({ uri: TEST_TOPIC_URI, cid: TEST_TOPIC_CID }) // Step 1: Create topic const topicResponse = await app.inject({ - method: "POST", - url: "/api/topics", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/topics', + headers: { authorization: 'Bearer test-token' }, payload: { - title: "My Topic", - content: "Topic body content.", - category: "general", + title: 'My Topic', + content: 'Topic body content.', + category: 'general', }, - }); + }) - expect(topicResponse.statusCode).toBe(201); - const topicBody = topicResponse.json<{ uri: string }>(); - expect(topicBody.uri).toBe(TEST_TOPIC_URI); + expect(topicResponse.statusCode).toBe(201) + const topicBody = topicResponse.json<{ uri: string }>() + expect(topicBody.uri).toBe(TEST_TOPIC_URI) // Reset mocks between topic and reply creation but keep chains fresh - vi.clearAllMocks(); - resetAllDbMocks(); - isTrackedFn.mockResolvedValue(true); + vi.clearAllMocks() + resetAllDbMocks() + isTrackedFn.mockResolvedValue(true) // Mock PDS: reply creation - createRecordFn.mockResolvedValueOnce({ uri: TEST_REPLY_URI, cid: TEST_REPLY_CID }); + createRecordFn.mockResolvedValueOnce({ uri: TEST_REPLY_URI, cid: TEST_REPLY_CID }) // Mock: topic lookup for reply creation - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) // Step 2: Create reply to the topic - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const replyResponse = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "This is my reply.", + content: 'This is my reply.', }, - }); + }) - expect(replyResponse.statusCode).toBe(201); + expect(replyResponse.statusCode).toBe(201) // Verify: reply was inserted into DB - expect(mockDb.insert).toHaveBeenCalledOnce(); + expect(mockDb.insert).toHaveBeenCalledOnce() // Verify: topic replyCount was updated (db.update was called) - expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.update).toHaveBeenCalled() // Verify: the update set includes replyCount increment - expect(updateChain.set).toHaveBeenCalled(); - const setCall = updateChain.set.mock.calls[0]?.[0] as Record; - expect(setCall).toBeDefined(); + expect(updateChain.set).toHaveBeenCalled() + const setCall = updateChain.set.mock.calls[0]?.[0] as Record + expect(setCall).toBeDefined() // replyCount should be a SQL expression (not a plain number) - expect(setCall.replyCount).toBeDefined(); + expect(setCall.replyCount).toBeDefined() // lastActivityAt should be set - expect(setCall.lastActivityAt).toBeDefined(); - }); + expect(setCall.lastActivityAt).toBeDefined() + }) - it("reply creation returns the reply URI and CID", async () => { - createRecordFn.mockResolvedValueOnce({ uri: TEST_REPLY_URI, cid: TEST_REPLY_CID }); - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + it('reply creation returns the reply URI and CID', async () => { + createRecordFn.mockResolvedValueOnce({ uri: TEST_REPLY_URI, cid: TEST_REPLY_CID }) + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "Reply content here.", + content: 'Reply content here.', }, - }); + }) - expect(response.statusCode).toBe(201); - const body = response.json<{ uri: string; cid: string }>(); - expect(body.uri).toBe(TEST_REPLY_URI); - expect(body.cid).toBe(TEST_REPLY_CID); - }); - }); + expect(response.statusCode).toBe(201) + const body = response.json<{ uri: string; cid: string }>() + expect(body.uri).toBe(TEST_REPLY_URI) + expect(body.cid).toBe(TEST_REPLY_CID) + }) + }) // ========================================================================= // Create topic, create reply, delete reply -- verify replyCount decrement // ========================================================================= - describe("create reply then delete reply", () => { - let app: FastifyInstance; + describe('create reply then delete reply', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - isTrackedFn.mockResolvedValue(true); - deleteRecordFn.mockResolvedValue(undefined); - }); + vi.clearAllMocks() + resetAllDbMocks() + isTrackedFn.mockResolvedValue(true) + deleteRecordFn.mockResolvedValue(undefined) + }) - it("deleting a reply decrements replyCount using GREATEST", async () => { + it('deleting a reply decrements replyCount using GREATEST', async () => { // Mock: reply lookup for delete - const existingReply = sampleReplyRow(); - selectChain.where.mockResolvedValueOnce([existingReply]); + const existingReply = sampleReplyRow() + selectChain.where.mockResolvedValueOnce([existingReply]) - const encodedReplyUri = encodeURIComponent(TEST_REPLY_URI); + const encodedReplyUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/replies/${encodedReplyUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(204); + expect(response.statusCode).toBe(204) // Verify: reply was deleted from DB - expect(mockDb.delete).toHaveBeenCalled(); + expect(mockDb.delete).toHaveBeenCalled() // Verify: topic replyCount was decremented - expect(mockDb.update).toHaveBeenCalled(); - expect(updateChain.set).toHaveBeenCalled(); + expect(mockDb.update).toHaveBeenCalled() + expect(updateChain.set).toHaveBeenCalled() - const setCall = updateChain.set.mock.calls[0]?.[0] as Record; - expect(setCall).toBeDefined(); + const setCall = updateChain.set.mock.calls[0]?.[0] as Record + expect(setCall).toBeDefined() // replyCount should be a SQL expression with GREATEST - expect(setCall.replyCount).toBeDefined(); - }); + expect(setCall.replyCount).toBeDefined() + }) - it("delete reply also deletes from PDS when user is author", async () => { - const existingReply = sampleReplyRow(); - selectChain.where.mockResolvedValueOnce([existingReply]); + it('delete reply also deletes from PDS when user is author', async () => { + const existingReply = sampleReplyRow() + selectChain.where.mockResolvedValueOnce([existingReply]) - const encodedReplyUri = encodeURIComponent(TEST_REPLY_URI); + const encodedReplyUri = encodeURIComponent(TEST_REPLY_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/replies/${encodedReplyUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(204); + expect(response.statusCode).toBe(204) // Author delete: should delete from PDS - expect(deleteRecordFn).toHaveBeenCalledOnce(); - expect(deleteRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID); - expect(deleteRecordFn.mock.calls[0]?.[1]).toBe("forum.barazo.topic.reply"); - expect(deleteRecordFn.mock.calls[0]?.[2]).toBe(TEST_REPLY_RKEY); - }); - }); + expect(deleteRecordFn).toHaveBeenCalledOnce() + expect(deleteRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID) + expect(deleteRecordFn.mock.calls[0]?.[1]).toBe('forum.barazo.topic.reply') + expect(deleteRecordFn.mock.calls[0]?.[2]).toBe(TEST_REPLY_RKEY) + }) + }) // ========================================================================= // Delete topic cascades replies // ========================================================================= - describe("delete topic cascades replies", () => { - let app: FastifyInstance; + describe('delete topic cascades replies', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - deleteRecordFn.mockResolvedValue(undefined); - }); + vi.clearAllMocks() + resetAllDbMocks() + deleteRecordFn.mockResolvedValue(undefined) + }) - it("deleting a topic deletes all its replies via transaction", async () => { - const existingTopic = sampleTopicRow(); + it('deleting a topic deletes all its replies via transaction', async () => { + const existingTopic = sampleTopicRow() // Topic lookup - selectChain.where.mockResolvedValueOnce([existingTopic]); + selectChain.where.mockResolvedValueOnce([existingTopic]) - const encodedUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/topics/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(204); + expect(response.statusCode).toBe(204) // Should have deleted from PDS (author delete) - expect(deleteRecordFn).toHaveBeenCalledOnce(); + expect(deleteRecordFn).toHaveBeenCalledOnce() // Transaction should have been used - expect(mockDb.transaction).toHaveBeenCalledOnce(); + expect(mockDb.transaction).toHaveBeenCalledOnce() // Inside the transaction, both replies and topic should be deleted. // The transaction mock calls fn(mockDb), so mockDb.delete is called for: // 1. replies (cascade), 2. topic itself, 3. cross-posts cleanup (fire-and-forget) - expect(mockDb.delete).toHaveBeenCalledTimes(3); - }); + expect(mockDb.delete).toHaveBeenCalledTimes(3) + }) - it("topic cascade delete uses rootUri to find related replies", async () => { - const existingTopic = sampleTopicRow(); - selectChain.where.mockResolvedValueOnce([existingTopic]); + it('topic cascade delete uses rootUri to find related replies', async () => { + const existingTopic = sampleTopicRow() + selectChain.where.mockResolvedValueOnce([existingTopic]) - const encodedUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/topics/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(204); + expect(response.statusCode).toBe(204) // Verify delete was called (replies, topic, and cross-posts cleanup) - expect(mockDb.delete).toHaveBeenCalledTimes(3); + expect(mockDb.delete).toHaveBeenCalledTimes(3) // All delete calls should have used .where() - expect(deleteChain.where).toHaveBeenCalledTimes(3); - }); - }); + expect(deleteChain.where).toHaveBeenCalledTimes(3) + }) + }) // ========================================================================= // Reply to non-existent topic returns 404 // ========================================================================= - describe("reply to non-existent topic", () => { - let app: FastifyInstance; + describe('reply to non-existent topic', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - isTrackedFn.mockResolvedValue(true); - }); + vi.clearAllMocks() + resetAllDbMocks() + isTrackedFn.mockResolvedValue(true) + }) - it("returns 404 when topic does not exist", async () => { + it('returns 404 when topic does not exist', async () => { // Topic lookup returns empty - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) - const nonExistentUri = encodeURIComponent("at://did:plc:nobody/forum.barazo.topic.post/nonexistent"); + const nonExistentUri = encodeURIComponent( + 'at://did:plc:nobody/forum.barazo.topic.post/nonexistent' + ) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${nonExistentUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "Reply to a ghost topic.", + content: 'Reply to a ghost topic.', }, - }); + }) - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(404) // Should NOT have written to PDS - expect(createRecordFn).not.toHaveBeenCalled(); + expect(createRecordFn).not.toHaveBeenCalled() // Should NOT have inserted into DB - expect(mockDb.insert).not.toHaveBeenCalled(); - }); + expect(mockDb.insert).not.toHaveBeenCalled() + }) - it("returns 404 error body with descriptive message", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 error body with descriptive message', async () => { + selectChain.where.mockResolvedValueOnce([]) - const nonExistentUri = encodeURIComponent("at://did:plc:nobody/forum.barazo.topic.post/nonexistent"); + const nonExistentUri = encodeURIComponent( + 'at://did:plc:nobody/forum.barazo.topic.post/nonexistent' + ) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${nonExistentUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "Reply to missing topic.", + content: 'Reply to missing topic.', }, - }); + }) - expect(response.statusCode).toBe(404); - const body = response.json<{ error: string }>(); - expect(body.error).toBeDefined(); - }); - }); + expect(response.statusCode).toBe(404) + const body = response.json<{ error: string }>() + expect(body.error).toBeDefined() + }) + }) // ========================================================================= // Threaded reply with invalid parentUri returns 400 // ========================================================================= - describe("threaded reply with invalid parentUri", () => { - let app: FastifyInstance; + describe('threaded reply with invalid parentUri', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - isTrackedFn.mockResolvedValue(true); - }); + vi.clearAllMocks() + resetAllDbMocks() + isTrackedFn.mockResolvedValue(true) + }) - it("returns 400 when parentUri reply does not exist", async () => { + it('returns 400 when parentUri reply does not exist', async () => { // Topic lookup succeeds - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) // Parent reply lookup fails - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "Threaded reply to non-existent parent.", - parentUri: "at://did:plc:nobody/forum.barazo.topic.reply/ghost", + content: 'Threaded reply to non-existent parent.', + parentUri: 'at://did:plc:nobody/forum.barazo.topic.reply/ghost', }, - }); + }) - expect(response.statusCode).toBe(400); + expect(response.statusCode).toBe(400) // Should NOT have written to PDS - expect(createRecordFn).not.toHaveBeenCalled(); - }); + expect(createRecordFn).not.toHaveBeenCalled() + }) - it("returns 400 error body with descriptive message", async () => { - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); - selectChain.where.mockResolvedValueOnce([]); + it('returns 400 error body with descriptive message', async () => { + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) + selectChain.where.mockResolvedValueOnce([]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "Invalid parent reply reference.", - parentUri: "at://did:plc:ghost/forum.barazo.topic.reply/missing", + content: 'Invalid parent reply reference.', + parentUri: 'at://did:plc:ghost/forum.barazo.topic.reply/missing', }, - }); + }) - expect(response.statusCode).toBe(400); - const body = response.json<{ error: string }>(); - expect(body.error).toBeDefined(); - }); + expect(response.statusCode).toBe(400) + const body = response.json<{ error: string }>() + expect(body.error).toBeDefined() + }) - it("succeeds when parentUri points to a valid reply", async () => { - createRecordFn.mockResolvedValueOnce({ uri: TEST_REPLY_URI, cid: TEST_REPLY_CID }); + it('succeeds when parentUri points to a valid reply', async () => { + createRecordFn.mockResolvedValueOnce({ uri: TEST_REPLY_URI, cid: TEST_REPLY_CID }) // Topic lookup succeeds - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) // Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]); + selectChain.where.mockResolvedValueOnce([]) // Parent reply lookup succeeds - selectChain.where.mockResolvedValueOnce([sampleReplyRow({ - uri: TEST_PARENT_REPLY_URI, - cid: TEST_PARENT_REPLY_CID, - })]); - - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + selectChain.where.mockResolvedValueOnce([ + sampleReplyRow({ + uri: TEST_PARENT_REPLY_URI, + cid: TEST_PARENT_REPLY_CID, + }), + ]) + + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "Valid threaded reply.", + content: 'Valid threaded reply.', parentUri: TEST_PARENT_REPLY_URI, }, - }); + }) - expect(response.statusCode).toBe(201); + expect(response.statusCode).toBe(201) // Verify the PDS record has correct parent reference - const record = createRecordFn.mock.calls[0]?.[2] as Record; - const parentRef = record.parent as { uri: string; cid: string }; - expect(parentRef.uri).toBe(TEST_PARENT_REPLY_URI); - expect(parentRef.cid).toBe(TEST_PARENT_REPLY_CID); + const record = createRecordFn.mock.calls[0]?.[2] as Record + const parentRef = record.parent as { uri: string; cid: string } + expect(parentRef.uri).toBe(TEST_PARENT_REPLY_URI) + expect(parentRef.cid).toBe(TEST_PARENT_REPLY_CID) // Root should still point to the topic - const rootRef = record.root as { uri: string; cid: string }; - expect(rootRef.uri).toBe(TEST_TOPIC_URI); - expect(rootRef.cid).toBe(TEST_TOPIC_CID); - }); - }); + const rootRef = record.root as { uri: string; cid: string } + expect(rootRef.uri).toBe(TEST_TOPIC_URI) + expect(rootRef.cid).toBe(TEST_TOPIC_CID) + }) + }) // ========================================================================= // Full lifecycle: create topic -> reply -> get replies -> delete // ========================================================================= - describe("full topic-reply lifecycle", () => { - let app: FastifyInstance; + describe('full topic-reply lifecycle', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - isTrackedFn.mockResolvedValue(true); - }); + vi.clearAllMocks() + resetAllDbMocks() + isTrackedFn.mockResolvedValue(true) + }) - it("can create a topic, add a reply, list replies, and delete topic", async () => { + it('can create a topic, add a reply, list replies, and delete topic', async () => { // Step 1: Create topic - createRecordFn.mockResolvedValueOnce({ uri: TEST_TOPIC_URI, cid: TEST_TOPIC_CID }); + createRecordFn.mockResolvedValueOnce({ uri: TEST_TOPIC_URI, cid: TEST_TOPIC_CID }) const topicResponse = await app.inject({ - method: "POST", - url: "/api/topics", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/topics', + headers: { authorization: 'Bearer test-token' }, payload: { - title: "Lifecycle Topic", - content: "Testing the full lifecycle.", - category: "general", + title: 'Lifecycle Topic', + content: 'Testing the full lifecycle.', + category: 'general', }, - }); + }) - expect(topicResponse.statusCode).toBe(201); + expect(topicResponse.statusCode).toBe(201) // Reset mocks for next step - vi.clearAllMocks(); - resetAllDbMocks(); - isTrackedFn.mockResolvedValue(true); + vi.clearAllMocks() + resetAllDbMocks() + isTrackedFn.mockResolvedValue(true) // Step 2: Create reply - createRecordFn.mockResolvedValueOnce({ uri: TEST_REPLY_URI, cid: TEST_REPLY_CID }); - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + createRecordFn.mockResolvedValueOnce({ uri: TEST_REPLY_URI, cid: TEST_REPLY_CID }) + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI); + const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const replyResponse = await app.inject({ - method: "POST", + method: 'POST', url: `/api/topics/${encodedTopicUri}/replies`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - content: "Lifecycle reply.", + content: 'Lifecycle reply.', }, - }); + }) - expect(replyResponse.statusCode).toBe(201); + expect(replyResponse.statusCode).toBe(201) // Reset for next step - vi.clearAllMocks(); - resetAllDbMocks(); + vi.clearAllMocks() + resetAllDbMocks() // Step 3: List replies - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); - selectChain.limit.mockResolvedValueOnce([sampleReplyRow()]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) + selectChain.limit.mockResolvedValueOnce([sampleReplyRow()]) const listResponse = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedTopicUri}/replies`, - }); + }) - expect(listResponse.statusCode).toBe(200); - const listBody = listResponse.json<{ replies: unknown[] }>(); - expect(listBody.replies).toHaveLength(1); + expect(listResponse.statusCode).toBe(200) + const listBody = listResponse.json<{ replies: unknown[] }>() + expect(listBody.replies).toHaveLength(1) // Reset for next step - vi.clearAllMocks(); - resetAllDbMocks(); - deleteRecordFn.mockResolvedValue(undefined); + vi.clearAllMocks() + resetAllDbMocks() + deleteRecordFn.mockResolvedValue(undefined) // Step 4: Delete topic (should cascade) - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) const deleteResponse = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/topics/${encodedTopicUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(deleteResponse.statusCode).toBe(204); + expect(deleteResponse.statusCode).toBe(204) // Should have cascade-deleted replies via transaction // Delete count: 1. replies (cascade), 2. topic, 3. cross-posts cleanup - expect(mockDb.transaction).toHaveBeenCalledOnce(); - expect(mockDb.delete).toHaveBeenCalledTimes(3); - }); - }); -}); + expect(mockDb.transaction).toHaveBeenCalledOnce() + expect(mockDb.delete).toHaveBeenCalledTimes(3) + }) + }) +}) diff --git a/tests/unit/routes/topics.test.ts b/tests/unit/routes/topics.test.ts index ec54e8a..9738249 100644 --- a/tests/unit/routes/topics.test.ts +++ b/tests/unit/routes/topics.test.ts @@ -1,30 +1,45 @@ -import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; -import Fastify from "fastify"; -import type { FastifyInstance } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { AuthMiddleware, RequestUser } from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import { type DbChain, createChainableProxy, createMockDb } from "../../helpers/mock-db.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' // --------------------------------------------------------------------------- // Mock PDS client module (must be before importing routes) // --------------------------------------------------------------------------- -const createRecordFn = vi.fn<(did: string, collection: string, record: Record) => Promise<{ uri: string; cid: string }>>(); -const updateRecordFn = vi.fn<(did: string, collection: string, rkey: string, record: Record) => Promise<{ uri: string; cid: string }>>(); -const deleteRecordFn = vi.fn<(did: string, collection: string, rkey: string) => Promise>(); - -vi.mock("../../../src/lib/pds-client.js", () => ({ +const createRecordFn = + vi.fn< + ( + did: string, + collection: string, + record: Record + ) => Promise<{ uri: string; cid: string }> + >() +const updateRecordFn = + vi.fn< + ( + did: string, + collection: string, + rkey: string, + record: Record + ) => Promise<{ uri: string; cid: string }> + >() +const deleteRecordFn = vi.fn<(did: string, collection: string, rkey: string) => Promise>() + +vi.mock('../../../src/lib/pds-client.js', () => ({ createPdsClient: () => ({ createRecord: createRecordFn, updateRecord: updateRecordFn, deleteRecord: deleteRecordFn, }), -})); +})) // Mock anti-spam module (tested separately in anti-spam.test.ts) -vi.mock("../../../src/lib/anti-spam.js", () => ({ +vi.mock('../../../src/lib/anti-spam.js', () => ({ loadAntiSpamSettings: vi.fn().mockResolvedValue({ wordFilter: [], firstPostQueueCount: 3, @@ -42,36 +57,36 @@ vi.mock("../../../src/lib/anti-spam.js", () => ({ checkWriteRateLimit: vi.fn().mockResolvedValue(false), canCreateTopic: vi.fn().mockResolvedValue(true), runAntiSpamChecks: vi.fn().mockResolvedValue({ held: false, reasons: [] }), -})); +})) // Import routes AFTER mocking -import { topicRoutes } from "../../../src/routes/topics.js"; +import { topicRoutes } from '../../../src/routes/topics.js' // --------------------------------------------------------------------------- // Mock env (minimal subset for topic routes) // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_DID: 'did:plc:community123', RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); -const TEST_URI = `at://${TEST_DID}/forum.barazo.topic.post/abc123`; -const TEST_RKEY = "abc123"; -const TEST_CID = "bafyreiabc123456789"; -const TEST_NOW = "2026-02-13T12:00:00.000Z"; +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) +const TEST_URI = `at://${TEST_DID}/forum.barazo.topic.post/abc123` +const TEST_RKEY = 'abc123' +const TEST_CID = 'bafyreiabc123456789' +const TEST_NOW = '2026-02-13T12:00:00.000Z' -const MOD_DID = "did:plc:moderator999"; -const OTHER_DID = "did:plc:otheruser456"; +const MOD_DID = 'did:plc:moderator999' +const OTHER_DID = 'did:plc:otheruser456' // --------------------------------------------------------------------------- // Mock user builders @@ -83,54 +98,54 @@ function testUser(overrides?: Partial): RequestUser { handle: TEST_HANDLE, sid: TEST_SID, ...overrides, - }; + } } // --------------------------------------------------------------------------- // Mock firehose repo manager // --------------------------------------------------------------------------- -const isTrackedFn = vi.fn<(did: string) => Promise>(); -const trackRepoFn = vi.fn<(did: string) => Promise>(); +const isTrackedFn = vi.fn<(did: string) => Promise>() +const trackRepoFn = vi.fn<(did: string) => Promise>() const mockRepoManager = { isTracked: isTrackedFn, trackRepo: trackRepoFn, untrackRepo: vi.fn(), restoreTrackedRepos: vi.fn(), -}; +} const mockFirehose = { getRepoManager: () => mockRepoManager, start: vi.fn(), stop: vi.fn(), getStatus: vi.fn().mockReturnValue({ connected: true, lastEventId: null }), -}; +} // --------------------------------------------------------------------------- // Chainable mock DB (shared helper) // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() -let insertChain: DbChain; -let selectChain: DbChain; -let updateChain: DbChain; -let deleteChain: DbChain; +let insertChain: DbChain +let selectChain: DbChain +let updateChain: DbChain +let deleteChain: DbChain function resetAllDbMocks(): void { - insertChain = createChainableProxy(); - selectChain = createChainableProxy([]); - updateChain = createChainableProxy([]); - deleteChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); - mockDb.select.mockReturnValue(selectChain); - mockDb.update.mockReturnValue(updateChain); - mockDb.delete.mockReturnValue(deleteChain); + insertChain = createChainableProxy() + selectChain = createChainableProxy([]) + updateChain = createChainableProxy([]) + deleteChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(updateChain) + mockDb.delete.mockReturnValue(deleteChain) // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { - await fn(mockDb); - }); + await fn(mockDb) + }) } // --------------------------------------------------------------------------- @@ -141,18 +156,18 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } // --------------------------------------------------------------------------- @@ -164,12 +179,12 @@ function sampleTopicRow(overrides?: Record) { uri: TEST_URI, rkey: TEST_RKEY, authorDid: TEST_DID, - title: "Test Topic Title", - content: "Test topic content goes here", + title: 'Test Topic Title', + content: 'Test topic content goes here', contentFormat: null, - category: "general", - tags: ["test", "example"], - communityDid: "did:plc:community123", + category: 'general', + tags: ['test', 'example'], + communityDid: 'did:plc:community123', cid: TEST_CID, labels: null, replyCount: 0, @@ -179,7 +194,7 @@ function sampleTopicRow(overrides?: Record) { indexedAt: new Date(TEST_NOW), embedding: null, ...overrides, - }; + } } // --------------------------------------------------------------------------- @@ -187,22 +202,22 @@ function sampleTopicRow(overrides?: Record) { // --------------------------------------------------------------------------- async function buildTestApp(user?: RequestUser): Promise { - const app = Fastify({ logger: false }); - - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", createMockAuthMiddleware(user)); - app.decorate("firehose", mockFirehose as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); - - await app.register(topicRoutes()); - await app.ready(); - - return app; + const app = Fastify({ logger: false }) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', createMockAuthMiddleware(user)) + app.decorate('firehose', mockFirehose as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + + await app.register(topicRoutes()) + await app.ready() + + return app } // --------------------------------------------------------------------------- @@ -218,600 +233,605 @@ async function buildTestApp(user?: RequestUser): Promise { * @param authenticated - Whether the request user is authenticated (adds user profile query) * @param allowedSlugs - Category slugs to return as allowed (default: ["general"]) */ -function setupMaturityMocks( - authenticated: boolean, - allowedSlugs: string[] = ["general"], -): void { +function setupMaturityMocks(authenticated: boolean, allowedSlugs: string[] = ['general']): void { if (authenticated) { // User profile query: return a user with safe maturity (age not declared) - selectChain.where.mockResolvedValueOnce([ - { declaredAge: null, maturityPref: "safe" }, - ]); + selectChain.where.mockResolvedValueOnce([{ declaredAge: null, maturityPref: 'safe' }]) } // Community settings: ageThreshold - selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]); + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) // Categories query: return allowed category slugs - selectChain.where.mockResolvedValueOnce( - allowedSlugs.map((slug) => ({ slug })), - ); + selectChain.where.mockResolvedValueOnce(allowedSlugs.map((slug) => ({ slug }))) } // =========================================================================== // Test suite // =========================================================================== -describe("topic routes", () => { +describe('topic routes', () => { // ========================================================================= // POST /api/topics // ========================================================================= - describe("POST /api/topics", () => { - let app: FastifyInstance; + describe('POST /api/topics', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); + vi.clearAllMocks() + resetAllDbMocks() // Default mocks for successful create - createRecordFn.mockResolvedValue({ uri: TEST_URI, cid: TEST_CID }); - isTrackedFn.mockResolvedValue(true); - }); + createRecordFn.mockResolvedValue({ uri: TEST_URI, cid: TEST_CID }) + isTrackedFn.mockResolvedValue(true) + }) - it("creates a topic and returns 201", async () => { + it('creates a topic and returns 201', async () => { const response = await app.inject({ - method: "POST", - url: "/api/topics", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/topics', + headers: { authorization: 'Bearer test-token' }, payload: { - title: "My First Topic", - content: "This is the body of my topic.", - category: "general", - tags: ["hello", "world"], + title: 'My First Topic', + content: 'This is the body of my topic.', + category: 'general', + tags: ['hello', 'world'], }, - }); + }) - expect(response.statusCode).toBe(201); - const body = response.json<{ uri: string; cid: string }>(); - expect(body.uri).toBe(TEST_URI); - expect(body.cid).toBe(TEST_CID); + expect(response.statusCode).toBe(201) + const body = response.json<{ uri: string; cid: string }>() + expect(body.uri).toBe(TEST_URI) + expect(body.cid).toBe(TEST_CID) // Should have called PDS createRecord - expect(createRecordFn).toHaveBeenCalledOnce(); - expect(createRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID); - expect(createRecordFn.mock.calls[0]?.[1]).toBe("forum.barazo.topic.post"); + expect(createRecordFn).toHaveBeenCalledOnce() + expect(createRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID) + expect(createRecordFn.mock.calls[0]?.[1]).toBe('forum.barazo.topic.post') // Should have inserted into DB - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); + expect(mockDb.insert).toHaveBeenCalledOnce() + }) - it("creates a topic without optional tags", async () => { + it('creates a topic without optional tags', async () => { const response = await app.inject({ - method: "POST", - url: "/api/topics", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/topics', + headers: { authorization: 'Bearer test-token' }, payload: { - title: "Tagless Topic", - content: "No tags here.", - category: "support", + title: 'Tagless Topic', + content: 'No tags here.', + category: 'support', }, - }); + }) - expect(response.statusCode).toBe(201); - }); + expect(response.statusCode).toBe(201) + }) it("tracks new user's repo on first post", async () => { - isTrackedFn.mockResolvedValue(false); - trackRepoFn.mockResolvedValue(undefined); + isTrackedFn.mockResolvedValue(false) + trackRepoFn.mockResolvedValue(undefined) const response = await app.inject({ - method: "POST", - url: "/api/topics", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/topics', + headers: { authorization: 'Bearer test-token' }, payload: { - title: "First Post", - content: "This is my first ever post.", - category: "introductions", + title: 'First Post', + content: 'This is my first ever post.', + category: 'introductions', }, - }); + }) - expect(response.statusCode).toBe(201); - expect(isTrackedFn).toHaveBeenCalledWith(TEST_DID); - expect(trackRepoFn).toHaveBeenCalledWith(TEST_DID); - }); + expect(response.statusCode).toBe(201) + expect(isTrackedFn).toHaveBeenCalledWith(TEST_DID) + expect(trackRepoFn).toHaveBeenCalledWith(TEST_DID) + }) - it("does not track already-tracked user", async () => { - isTrackedFn.mockResolvedValue(true); + it('does not track already-tracked user', async () => { + isTrackedFn.mockResolvedValue(true) const response = await app.inject({ - method: "POST", - url: "/api/topics", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/topics', + headers: { authorization: 'Bearer test-token' }, payload: { - title: "Another Post", - content: "Already tracked.", - category: "general", + title: 'Another Post', + content: 'Already tracked.', + category: 'general', }, - }); + }) - expect(response.statusCode).toBe(201); - expect(isTrackedFn).toHaveBeenCalledWith(TEST_DID); - expect(trackRepoFn).not.toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(201) + expect(isTrackedFn).toHaveBeenCalledWith(TEST_DID) + expect(trackRepoFn).not.toHaveBeenCalled() + }) - it("returns 400 for missing title", async () => { + it('returns 400 for missing title', async () => { const response = await app.inject({ - method: "POST", - url: "/api/topics", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/topics', + headers: { authorization: 'Bearer test-token' }, payload: { - content: "No title provided.", - category: "general", + content: 'No title provided.', + category: 'general', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for missing content", async () => { + it('returns 400 for missing content', async () => { const response = await app.inject({ - method: "POST", - url: "/api/topics", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/topics', + headers: { authorization: 'Bearer test-token' }, payload: { - title: "No Content", - category: "general", + title: 'No Content', + category: 'general', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for missing category", async () => { + it('returns 400 for missing category', async () => { const response = await app.inject({ - method: "POST", - url: "/api/topics", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/topics', + headers: { authorization: 'Bearer test-token' }, payload: { - title: "No Category", - content: "Missing required field.", + title: 'No Category', + content: 'Missing required field.', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for title exceeding max length", async () => { + it('returns 400 for title exceeding max length', async () => { const response = await app.inject({ - method: "POST", - url: "/api/topics", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/topics', + headers: { authorization: 'Bearer test-token' }, payload: { - title: "A".repeat(201), - content: "Valid content.", - category: "general", + title: 'A'.repeat(201), + content: 'Valid content.', + category: 'general', }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for too many tags", async () => { + it('returns 400 for too many tags', async () => { const response = await app.inject({ - method: "POST", - url: "/api/topics", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/topics', + headers: { authorization: 'Bearer test-token' }, payload: { - title: "Too Many Tags", - content: "Tags overload.", - category: "general", - tags: ["a", "b", "c", "d", "e", "f"], + title: 'Too Many Tags', + content: 'Tags overload.', + category: 'general', + tags: ['a', 'b', 'c', 'd', 'e', 'f'], }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for empty body", async () => { + it('returns 400 for empty body', async () => { const response = await app.inject({ - method: "POST", - url: "/api/topics", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/topics', + headers: { authorization: 'Bearer test-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 502 when PDS write fails", async () => { - createRecordFn.mockRejectedValueOnce(new Error("PDS unreachable")); + it('returns 502 when PDS write fails', async () => { + createRecordFn.mockRejectedValueOnce(new Error('PDS unreachable')) const response = await app.inject({ - method: "POST", - url: "/api/topics", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/topics', + headers: { authorization: 'Bearer test-token' }, payload: { - title: "PDS Fail Topic", - content: "Should fail because PDS is down.", - category: "general", + title: 'PDS Fail Topic', + content: 'Should fail because PDS is down.', + category: 'general', }, - }); + }) - expect(response.statusCode).toBe(502); - }); + expect(response.statusCode).toBe(502) + }) - it("creates a topic with self-labels and includes them in PDS record and DB insert", async () => { - const labels = { values: [{ val: "nsfw" }, { val: "spoiler" }] }; + it('creates a topic with self-labels and includes them in PDS record and DB insert', async () => { + const labels = { values: [{ val: 'nsfw' }, { val: 'spoiler' }] } const response = await app.inject({ - method: "POST", - url: "/api/topics", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/topics', + headers: { authorization: 'Bearer test-token' }, payload: { - title: "Labeled Topic", - content: "This topic has self-labels.", - category: "general", + title: 'Labeled Topic', + content: 'This topic has self-labels.', + category: 'general', labels, }, - }); + }) - expect(response.statusCode).toBe(201); + expect(response.statusCode).toBe(201) // Verify PDS record includes labels - expect(createRecordFn).toHaveBeenCalledOnce(); - const pdsRecord = createRecordFn.mock.calls[0]?.[2] as Record; - expect(pdsRecord.labels).toEqual(labels); + expect(createRecordFn).toHaveBeenCalledOnce() + const pdsRecord = createRecordFn.mock.calls[0]?.[2] as Record + expect(pdsRecord.labels).toEqual(labels) // Verify DB insert includes labels - expect(mockDb.insert).toHaveBeenCalledOnce(); - const insertValues = insertChain.values.mock.calls[0]?.[0] as Record; - expect(insertValues.labels).toEqual(labels); - }); + expect(mockDb.insert).toHaveBeenCalledOnce() + const insertValues = insertChain.values.mock.calls[0]?.[0] as Record + expect(insertValues.labels).toEqual(labels) + }) - it("creates a topic without labels (backwards compatible)", async () => { + it('creates a topic without labels (backwards compatible)', async () => { const response = await app.inject({ - method: "POST", - url: "/api/topics", - headers: { authorization: "Bearer test-token" }, + method: 'POST', + url: '/api/topics', + headers: { authorization: 'Bearer test-token' }, payload: { - title: "No Labels Topic", - content: "This topic has no labels.", - category: "general", + title: 'No Labels Topic', + content: 'This topic has no labels.', + category: 'general', }, - }); + }) - expect(response.statusCode).toBe(201); + expect(response.statusCode).toBe(201) // Verify PDS record does NOT include labels key - const pdsRecord = createRecordFn.mock.calls[0]?.[2] as Record; - expect(pdsRecord).not.toHaveProperty("labels"); + const pdsRecord = createRecordFn.mock.calls[0]?.[2] as Record + expect(pdsRecord).not.toHaveProperty('labels') // Verify DB insert has labels: null - const insertValues = insertChain.values.mock.calls[0]?.[0] as Record; - expect(insertValues.labels).toBeNull(); - }); - }); + const insertValues = insertChain.values.mock.calls[0]?.[0] as Record + expect(insertValues.labels).toBeNull() + }) + }) - describe("POST /api/topics (unauthenticated)", () => { - let app: FastifyInstance; + describe('POST /api/topics (unauthenticated)', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(undefined); - }); + app = await buildTestApp(undefined) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("returns 401 without auth", async () => { + it('returns 401 without auth', async () => { const response = await app.inject({ - method: "POST", - url: "/api/topics", + method: 'POST', + url: '/api/topics', payload: { - title: "Unauth Topic", - content: "Should not work.", - category: "general", + title: 'Unauth Topic', + content: 'Should not work.', + category: 'general', }, - }); + }) - expect(response.statusCode).toBe(401); - }); - }); + expect(response.statusCode).toBe(401) + }) + }) // ========================================================================= // GET /api/topics (list) // ========================================================================= - describe("GET /api/topics", () => { - let app: FastifyInstance; + describe('GET /api/topics', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns empty list when no topics exist", async () => { - setupMaturityMocks(true); + it('returns empty list when no topics exist', async () => { + setupMaturityMocks(true) // The list query ends with .limit() -- make it resolve to empty - selectChain.limit.mockResolvedValueOnce([]); + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/topics", - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: unknown[]; cursor: string | null }>(); - expect(body.topics).toEqual([]); - expect(body.cursor).toBeNull(); - }); - - it("returns topics with pagination cursor", async () => { - setupMaturityMocks(true); + method: 'GET', + url: '/api/topics', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: unknown[]; cursor: string | null }>() + expect(body.topics).toEqual([]) + expect(body.cursor).toBeNull() + }) + + it('returns topics with pagination cursor', async () => { + setupMaturityMocks(true) // Request limit=2 -> route fetches limit+1=3 items // Return 3 items to trigger "hasMore" const rows = [ sampleTopicRow(), - sampleTopicRow({ uri: `at://${TEST_DID}/forum.barazo.topic.post/def456`, rkey: "def456" }), - sampleTopicRow({ uri: `at://${TEST_DID}/forum.barazo.topic.post/ghi789`, rkey: "ghi789" }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + sampleTopicRow({ uri: `at://${TEST_DID}/forum.barazo.topic.post/def456`, rkey: 'def456' }), + sampleTopicRow({ uri: `at://${TEST_DID}/forum.barazo.topic.post/ghi789`, rkey: 'ghi789' }), + ] + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", - url: "/api/topics?limit=2", - }); + method: 'GET', + url: '/api/topics?limit=2', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: unknown[]; cursor: string | null }>(); - expect(body.topics).toHaveLength(2); - expect(body.cursor).toBeTruthy(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: unknown[]; cursor: string | null }>() + expect(body.topics).toHaveLength(2) + expect(body.cursor).toBeTruthy() + }) - it("returns null cursor when fewer items than limit", async () => { - setupMaturityMocks(true); - const rows = [sampleTopicRow()]; - selectChain.limit.mockResolvedValueOnce(rows); + it('returns null cursor when fewer items than limit', async () => { + setupMaturityMocks(true) + const rows = [sampleTopicRow()] + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", - url: "/api/topics?limit=25", - }); + method: 'GET', + url: '/api/topics?limit=25', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: unknown[]; cursor: string | null }>(); - expect(body.topics).toHaveLength(1); - expect(body.cursor).toBeNull(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: unknown[]; cursor: string | null }>() + expect(body.topics).toHaveLength(1) + expect(body.cursor).toBeNull() + }) - it("filters by category", async () => { - setupMaturityMocks(true, ["general", "support"]); - selectChain.limit.mockResolvedValueOnce([]); + it('filters by category', async () => { + setupMaturityMocks(true, ['general', 'support']) + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/topics?category=support", - }); + method: 'GET', + url: '/api/topics?category=support', + }) - expect(response.statusCode).toBe(200); - expect(selectChain.where).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(200) + expect(selectChain.where).toHaveBeenCalled() + }) - it("filters by tag", async () => { - setupMaturityMocks(true); - selectChain.limit.mockResolvedValueOnce([]); + it('filters by tag', async () => { + setupMaturityMocks(true) + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/topics?tag=help", - }); + method: 'GET', + url: '/api/topics?tag=help', + }) - expect(response.statusCode).toBe(200); - expect(selectChain.where).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(200) + expect(selectChain.where).toHaveBeenCalled() + }) - it("respects custom limit", async () => { - setupMaturityMocks(true); - selectChain.limit.mockResolvedValueOnce([]); + it('respects custom limit', async () => { + setupMaturityMocks(true) + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", - url: "/api/topics?limit=5", - }); + method: 'GET', + url: '/api/topics?limit=5', + }) - expect(response.statusCode).toBe(200); - expect(selectChain.limit).toHaveBeenCalled(); - }); + expect(response.statusCode).toBe(200) + expect(selectChain.limit).toHaveBeenCalled() + }) - it("returns 400 for invalid limit (over max)", async () => { + it('returns 400 for invalid limit (over max)', async () => { const response = await app.inject({ - method: "GET", - url: "/api/topics?limit=999", - }); + method: 'GET', + url: '/api/topics?limit=999', + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for invalid limit (zero)", async () => { + it('returns 400 for invalid limit (zero)', async () => { const response = await app.inject({ - method: "GET", - url: "/api/topics?limit=0", - }); + method: 'GET', + url: '/api/topics?limit=0', + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for non-numeric limit", async () => { + it('returns 400 for non-numeric limit', async () => { const response = await app.inject({ - method: "GET", - url: "/api/topics?limit=abc", - }); + method: 'GET', + url: '/api/topics?limit=abc', + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("accepts cursor parameter", async () => { - setupMaturityMocks(true); - const cursor = Buffer.from(JSON.stringify({ lastActivityAt: TEST_NOW, uri: TEST_URI })).toString("base64"); - selectChain.limit.mockResolvedValueOnce([]); + it('accepts cursor parameter', async () => { + setupMaturityMocks(true) + const cursor = Buffer.from( + JSON.stringify({ lastActivityAt: TEST_NOW, uri: TEST_URI }) + ).toString('base64') + selectChain.limit.mockResolvedValueOnce([]) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics?cursor=${encodeURIComponent(cursor)}`, - }); + }) - expect(response.statusCode).toBe(200); - }); + expect(response.statusCode).toBe(200) + }) - it("works without authentication (public endpoint)", async () => { - const noAuthApp = await buildTestApp(undefined); - setupMaturityMocks(false); // no user profile query when unauthenticated - selectChain.limit.mockResolvedValueOnce([]); + it('works without authentication (public endpoint)', async () => { + const noAuthApp = await buildTestApp(undefined) + setupMaturityMocks(false) // no user profile query when unauthenticated + selectChain.limit.mockResolvedValueOnce([]) const response = await noAuthApp.inject({ - method: "GET", - url: "/api/topics", - }); + method: 'GET', + url: '/api/topics', + }) - expect(response.statusCode).toBe(200); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(200) + await noAuthApp.close() + }) - it("includes labels in topic list response", async () => { - setupMaturityMocks(true); - const labels = { values: [{ val: "nsfw" }] }; + it('includes labels in topic list response', async () => { + setupMaturityMocks(true) + const labels = { values: [{ val: 'nsfw' }] } const rows = [ sampleTopicRow({ labels }), sampleTopicRow({ uri: `at://${TEST_DID}/forum.barazo.topic.post/nolabel`, - rkey: "nolabel", + rkey: 'nolabel', labels: null, }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + ] + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", - url: "/api/topics", - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: Array<{ uri: string; labels: { values: Array<{ val: string }> } | null }> }>(); - expect(body.topics).toHaveLength(2); - expect(body.topics[0]?.labels).toEqual(labels); - expect(body.topics[1]?.labels).toBeNull(); - }); - - it("excludes topics by blocked users from list", async () => { - const blockedDid = "did:plc:blockeduser"; + method: 'GET', + url: '/api/topics', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + topics: Array<{ uri: string; labels: { values: Array<{ val: string }> } | null }> + }>() + expect(body.topics).toHaveLength(2) + expect(body.topics[0]?.labels).toEqual(labels) + expect(body.topics[1]?.labels).toBeNull() + }) + + it('excludes topics by blocked users from list', async () => { + const blockedDid = 'did:plc:blockeduser' // Query order for authenticated GET /api/topics: // 1. User profile (maturity) // 2. Allowed categories (maturity) // 3. Block/mute preferences // 4. Topics query (limit) - setupMaturityMocks(true); + setupMaturityMocks(true) // Block/mute preferences query - selectChain.where.mockResolvedValueOnce([{ - blockedDids: [blockedDid], - mutedDids: [], - }]); + selectChain.where.mockResolvedValueOnce([ + { + blockedDids: [blockedDid], + mutedDids: [], + }, + ]) // Return only non-blocked topics (the route should have applied the filter) - const rows = [ - sampleTopicRow({ authorDid: TEST_DID }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + const rows = [sampleTopicRow({ authorDid: TEST_DID })] + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", - url: "/api/topics", - }); + method: 'GET', + url: '/api/topics', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: Array<{ authorDid: string; isMuted: boolean }> }>(); + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: Array<{ authorDid: string; isMuted: boolean }> }>() // The blocked user's topics should not appear at all - expect(body.topics.every((t) => t.authorDid !== blockedDid)).toBe(true); - }); + expect(body.topics.every((t) => t.authorDid !== blockedDid)).toBe(true) + }) - it("annotates topics by muted users with isMuted: true", async () => { - const mutedDid = "did:plc:muteduser"; + it('annotates topics by muted users with isMuted: true', async () => { + const mutedDid = 'did:plc:muteduser' - setupMaturityMocks(true); + setupMaturityMocks(true) // Block/mute preferences query - selectChain.where.mockResolvedValueOnce([{ - blockedDids: [], - mutedDids: [mutedDid], - }]); + selectChain.where.mockResolvedValueOnce([ + { + blockedDids: [], + mutedDids: [mutedDid], + }, + ]) const rows = [ - sampleTopicRow({ authorDid: mutedDid, uri: `at://${mutedDid}/forum.barazo.topic.post/m1`, rkey: "m1" }), + sampleTopicRow({ + authorDid: mutedDid, + uri: `at://${mutedDid}/forum.barazo.topic.post/m1`, + rkey: 'm1', + }), sampleTopicRow({ authorDid: TEST_DID }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + ] + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", - url: "/api/topics", - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: Array<{ authorDid: string; isMuted: boolean }> }>(); - expect(body.topics).toHaveLength(2); - - const mutedTopic = body.topics.find((t) => t.authorDid === mutedDid); - const normalTopic = body.topics.find((t) => t.authorDid === TEST_DID); - expect(mutedTopic?.isMuted).toBe(true); - expect(normalTopic?.isMuted).toBe(false); - }); - - it("returns isMuted: false for all topics when unauthenticated", async () => { - const noAuthApp = await buildTestApp(undefined); - setupMaturityMocks(false); // no user profile query + method: 'GET', + url: '/api/topics', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: Array<{ authorDid: string; isMuted: boolean }> }>() + expect(body.topics).toHaveLength(2) + + const mutedTopic = body.topics.find((t) => t.authorDid === mutedDid) + const normalTopic = body.topics.find((t) => t.authorDid === TEST_DID) + expect(mutedTopic?.isMuted).toBe(true) + expect(normalTopic?.isMuted).toBe(false) + }) + + it('returns isMuted: false for all topics when unauthenticated', async () => { + const noAuthApp = await buildTestApp(undefined) + setupMaturityMocks(false) // no user profile query // No block/mute preferences query for unauthenticated users const rows = [ sampleTopicRow({ authorDid: TEST_DID }), - sampleTopicRow({ authorDid: OTHER_DID, uri: `at://${OTHER_DID}/forum.barazo.topic.post/o1`, rkey: "o1" }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + sampleTopicRow({ + authorDid: OTHER_DID, + uri: `at://${OTHER_DID}/forum.barazo.topic.post/o1`, + rkey: 'o1', + }), + ] + selectChain.limit.mockResolvedValueOnce(rows) const response = await noAuthApp.inject({ - method: "GET", - url: "/api/topics", - }); + method: 'GET', + url: '/api/topics', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: Array<{ authorDid: string; isMuted: boolean }> }>(); - expect(body.topics).toHaveLength(2); - expect(body.topics.every((t) => !t.isMuted)).toBe(true); + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: Array<{ authorDid: string; isMuted: boolean }> }>() + expect(body.topics).toHaveLength(2) + expect(body.topics.every((t) => !t.isMuted)).toBe(true) - await noAuthApp.close(); - }); + await noAuthApp.close() + }) - it("includes author profile in topic response", async () => { - resetAllDbMocks(); - setupMaturityMocks(true); + it('includes author profile in topic response', async () => { + resetAllDbMocks() + setupMaturityMocks(true) // Topics query (terminal via .limit) - selectChain.limit.mockResolvedValueOnce([ - sampleTopicRow({ authorDid: TEST_DID }), - ]); + selectChain.limit.mockResolvedValueOnce([sampleTopicRow({ authorDid: TEST_DID })]) // After maturity mocks (3 .where calls consumed), 4 more .where calls follow: // 4. loadBlockMuteLists .where (terminal) @@ -822,519 +842,527 @@ describe("topic routes", () => { // - Call 5 returns the chain (not a Promise) for .orderBy().limit() to work // - Call 7 returns the author user row - selectChain.where.mockResolvedValueOnce([]); // 4: loadBlockMuteLists - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- thenable mock for Drizzle chain - selectChain.where.mockImplementationOnce(() => selectChain); // 5: topics .where - selectChain.where.mockResolvedValueOnce([]); // 6: loadMutedWords global - selectChain.where.mockResolvedValueOnce([ // 7: resolveAuthors users - { did: TEST_DID, handle: TEST_HANDLE, displayName: "Alice", avatarUrl: "https://cdn.example.com/alice.jpg", bannerUrl: null, bio: null }, - ]); + selectChain.where.mockResolvedValueOnce([]) // 4: loadBlockMuteLists + + selectChain.where.mockImplementationOnce(() => selectChain) // 5: topics .where + selectChain.where.mockResolvedValueOnce([]) // 6: loadMutedWords global + selectChain.where.mockResolvedValueOnce([ + // 7: resolveAuthors users + { + did: TEST_DID, + handle: TEST_HANDLE, + displayName: 'Alice', + avatarUrl: 'https://cdn.example.com/alice.jpg', + bannerUrl: null, + bio: null, + }, + ]) const res = await app.inject({ - method: "GET", - url: "/api/topics", - headers: { authorization: "Bearer test" }, - }); + method: 'GET', + url: '/api/topics', + headers: { authorization: 'Bearer test' }, + }) - expect(res.statusCode).toBe(200); - const body = JSON.parse(res.payload) as { topics: Array<{ author: unknown }> }; + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.payload) as { topics: Array<{ author: unknown }> } expect(body.topics[0].author).toEqual({ did: TEST_DID, handle: TEST_HANDLE, - displayName: "Alice", - avatarUrl: "https://cdn.example.com/alice.jpg", - }); - }); - }); + displayName: 'Alice', + avatarUrl: 'https://cdn.example.com/alice.jpg', + }) + }) + }) // ========================================================================= // GET /api/topics/:uri (single topic) // ========================================================================= - describe("GET /api/topics/:uri", () => { - let app: FastifyInstance; + describe('GET /api/topics/:uri', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("returns a single topic by URI", async () => { - const row = sampleTopicRow(); + it('returns a single topic by URI', async () => { + const row = sampleTopicRow() // select().from(topics).where() is the terminal call - selectChain.where.mockResolvedValueOnce([row]); + selectChain.where.mockResolvedValueOnce([row]) - const encodedUri = encodeURIComponent(TEST_URI); + const encodedUri = encodeURIComponent(TEST_URI) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedUri}`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ uri: string; title: string }>(); - expect(body.uri).toBe(TEST_URI); - expect(body.title).toBe("Test Topic Title"); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ uri: string; title: string }>() + expect(body.uri).toBe(TEST_URI) + expect(body.title).toBe('Test Topic Title') + }) - it("returns 404 for non-existent topic", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 for non-existent topic', async () => { + selectChain.where.mockResolvedValueOnce([]) - const encodedUri = encodeURIComponent("at://did:plc:nonexistent/forum.barazo.topic.post/xyz"); + const encodedUri = encodeURIComponent('at://did:plc:nonexistent/forum.barazo.topic.post/xyz') const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedUri}`, - }); + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("works without authentication (public endpoint)", async () => { - const noAuthApp = await buildTestApp(undefined); - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]); + it('works without authentication (public endpoint)', async () => { + const noAuthApp = await buildTestApp(undefined) + selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) - const encodedUri = encodeURIComponent(TEST_URI); + const encodedUri = encodeURIComponent(TEST_URI) const response = await noAuthApp.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedUri}`, - }); + }) - expect(response.statusCode).toBe(200); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(200) + await noAuthApp.close() + }) - it("includes labels in single topic response", async () => { - const labels = { values: [{ val: "spoiler" }, { val: "nsfw" }] }; - const row = sampleTopicRow({ labels }); - selectChain.where.mockResolvedValueOnce([row]); + it('includes labels in single topic response', async () => { + const labels = { values: [{ val: 'spoiler' }, { val: 'nsfw' }] } + const row = sampleTopicRow({ labels }) + selectChain.where.mockResolvedValueOnce([row]) - const encodedUri = encodeURIComponent(TEST_URI); + const encodedUri = encodeURIComponent(TEST_URI) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedUri}`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ uri: string; labels: { values: Array<{ val: string }> } }>(); - expect(body.labels).toEqual(labels); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ uri: string; labels: { values: Array<{ val: string }> } }>() + expect(body.labels).toEqual(labels) + }) - it("returns null labels when topic has no labels", async () => { - const row = sampleTopicRow({ labels: null }); - selectChain.where.mockResolvedValueOnce([row]); + it('returns null labels when topic has no labels', async () => { + const row = sampleTopicRow({ labels: null }) + selectChain.where.mockResolvedValueOnce([row]) - const encodedUri = encodeURIComponent(TEST_URI); + const encodedUri = encodeURIComponent(TEST_URI) const response = await app.inject({ - method: "GET", + method: 'GET', url: `/api/topics/${encodedUri}`, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ uri: string; labels: null }>(); - expect(body.labels).toBeNull(); - }); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ uri: string; labels: null }>() + expect(body.labels).toBeNull() + }) + }) // ========================================================================= // PUT /api/topics/:uri // ========================================================================= - describe("PUT /api/topics/:uri", () => { - let app: FastifyInstance; + describe('PUT /api/topics/:uri', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - updateRecordFn.mockResolvedValue({ uri: TEST_URI, cid: "bafyreinewcid" }); - }); + vi.clearAllMocks() + resetAllDbMocks() + updateRecordFn.mockResolvedValue({ uri: TEST_URI, cid: 'bafyreinewcid' }) + }) - it("updates a topic when user is the author", async () => { - const existingRow = sampleTopicRow(); + it('updates a topic when user is the author', async () => { + const existingRow = sampleTopicRow() // First: select().from(topics).where() -> find topic - selectChain.where.mockResolvedValueOnce([existingRow]); + selectChain.where.mockResolvedValueOnce([existingRow]) // Then: update().set().where().returning() -> return updated row - const updatedRow = { ...existingRow, title: "Updated Title", cid: "bafyreinewcid" }; - updateChain.returning.mockResolvedValueOnce([updatedRow]); + const updatedRow = { ...existingRow, title: 'Updated Title', cid: 'bafyreinewcid' } + updateChain.returning.mockResolvedValueOnce([updatedRow]) - const encodedUri = encodeURIComponent(TEST_URI); + const encodedUri = encodeURIComponent(TEST_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/topics/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - title: "Updated Title", + title: 'Updated Title', }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ title: string }>(); - expect(body.title).toBe("Updated Title"); - expect(updateRecordFn).toHaveBeenCalledOnce(); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ title: string }>() + expect(body.title).toBe('Updated Title') + expect(updateRecordFn).toHaveBeenCalledOnce() + }) - it("returns 403 when user is not the author", async () => { - const existingRow = sampleTopicRow({ authorDid: OTHER_DID }); - selectChain.where.mockResolvedValueOnce([existingRow]); + it('returns 403 when user is not the author', async () => { + const existingRow = sampleTopicRow({ authorDid: OTHER_DID }) + selectChain.where.mockResolvedValueOnce([existingRow]) - const encodedUri = encodeURIComponent(TEST_URI); + const encodedUri = encodeURIComponent(TEST_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/topics/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - title: "Attempted Edit", + title: 'Attempted Edit', }, - }); + }) - expect(response.statusCode).toBe(403); - }); + expect(response.statusCode).toBe(403) + }) - it("returns 404 when topic does not exist", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 when topic does not exist', async () => { + selectChain.where.mockResolvedValueOnce([]) - const encodedUri = encodeURIComponent("at://did:plc:nobody/forum.barazo.topic.post/ghost"); + const encodedUri = encodeURIComponent('at://did:plc:nobody/forum.barazo.topic.post/ghost') const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/topics/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - title: "Ghost Topic", + title: 'Ghost Topic', }, - }); + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 400 for title exceeding max length", async () => { + it('returns 400 for title exceeding max length', async () => { const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/topics/${encodeURIComponent(TEST_URI)}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - title: "A".repeat(201), + title: 'A'.repeat(201), }, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 502 when PDS update fails", async () => { - const existingRow = sampleTopicRow(); - selectChain.where.mockResolvedValueOnce([existingRow]); - updateRecordFn.mockRejectedValueOnce(new Error("PDS error")); + it('returns 502 when PDS update fails', async () => { + const existingRow = sampleTopicRow() + selectChain.where.mockResolvedValueOnce([existingRow]) + updateRecordFn.mockRejectedValueOnce(new Error('PDS error')) - const encodedUri = encodeURIComponent(TEST_URI); + const encodedUri = encodeURIComponent(TEST_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/topics/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { - title: "Will Fail", + title: 'Will Fail', }, - }); + }) - expect(response.statusCode).toBe(502); - }); + expect(response.statusCode).toBe(502) + }) - it("accepts empty update (all fields optional)", async () => { - const existingRow = sampleTopicRow(); - selectChain.where.mockResolvedValueOnce([existingRow]); - updateChain.returning.mockResolvedValueOnce([existingRow]); + it('accepts empty update (all fields optional)', async () => { + const existingRow = sampleTopicRow() + selectChain.where.mockResolvedValueOnce([existingRow]) + updateChain.returning.mockResolvedValueOnce([existingRow]) - const encodedUri = encodeURIComponent(TEST_URI); + const encodedUri = encodeURIComponent(TEST_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/topics/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: {}, - }); + }) - expect(response.statusCode).toBe(200); - }); + expect(response.statusCode).toBe(200) + }) - it("updates a topic with self-labels (PDS record + DB)", async () => { - const existingRow = sampleTopicRow(); - selectChain.where.mockResolvedValueOnce([existingRow]); - const labels = { values: [{ val: "nsfw" }, { val: "spoiler" }] }; - const updatedRow = { ...existingRow, labels, cid: "bafyreinewcid" }; - updateChain.returning.mockResolvedValueOnce([updatedRow]); + it('updates a topic with self-labels (PDS record + DB)', async () => { + const existingRow = sampleTopicRow() + selectChain.where.mockResolvedValueOnce([existingRow]) + const labels = { values: [{ val: 'nsfw' }, { val: 'spoiler' }] } + const updatedRow = { ...existingRow, labels, cid: 'bafyreinewcid' } + updateChain.returning.mockResolvedValueOnce([updatedRow]) - const encodedUri = encodeURIComponent(TEST_URI); + const encodedUri = encodeURIComponent(TEST_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/topics/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, + headers: { authorization: 'Bearer test-token' }, payload: { labels }, - }); + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ labels: { values: Array<{ val: string }> } }>(); - expect(body.labels).toEqual(labels); + expect(response.statusCode).toBe(200) + const body = response.json<{ labels: { values: Array<{ val: string }> } }>() + expect(body.labels).toEqual(labels) // Verify PDS record includes labels - expect(updateRecordFn).toHaveBeenCalledOnce(); - const pdsRecord = updateRecordFn.mock.calls[0]?.[3] as Record; - expect(pdsRecord.labels).toEqual(labels); + expect(updateRecordFn).toHaveBeenCalledOnce() + const pdsRecord = updateRecordFn.mock.calls[0]?.[3] as Record + expect(pdsRecord.labels).toEqual(labels) // Verify DB update includes labels - const dbUpdateSet = updateChain.set.mock.calls[0]?.[0] as Record; - expect(dbUpdateSet.labels).toEqual(labels); - }); - - it("does not change existing labels when labels field is omitted from update", async () => { - const existingLabels = { values: [{ val: "nsfw" }] }; - const existingRow = sampleTopicRow({ labels: existingLabels }); - selectChain.where.mockResolvedValueOnce([existingRow]); - const updatedRow = { ...existingRow, title: "New Title", cid: "bafyreinewcid" }; - updateChain.returning.mockResolvedValueOnce([updatedRow]); - - const encodedUri = encodeURIComponent(TEST_URI); + const dbUpdateSet = updateChain.set.mock.calls[0]?.[0] as Record + expect(dbUpdateSet.labels).toEqual(labels) + }) + + it('does not change existing labels when labels field is omitted from update', async () => { + const existingLabels = { values: [{ val: 'nsfw' }] } + const existingRow = sampleTopicRow({ labels: existingLabels }) + selectChain.where.mockResolvedValueOnce([existingRow]) + const updatedRow = { ...existingRow, title: 'New Title', cid: 'bafyreinewcid' } + updateChain.returning.mockResolvedValueOnce([updatedRow]) + + const encodedUri = encodeURIComponent(TEST_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/topics/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - payload: { title: "New Title" }, - }); + headers: { authorization: 'Bearer test-token' }, + payload: { title: 'New Title' }, + }) - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200) // PDS record should preserve existing labels - const pdsRecord = updateRecordFn.mock.calls[0]?.[3] as Record; - expect(pdsRecord.labels).toEqual(existingLabels); + const pdsRecord = updateRecordFn.mock.calls[0]?.[3] as Record + expect(pdsRecord.labels).toEqual(existingLabels) // DB update should NOT include labels key (partial update) - const dbUpdateSet = updateChain.set.mock.calls[0]?.[0] as Record; - expect(dbUpdateSet).not.toHaveProperty("labels"); - }); - }); + const dbUpdateSet = updateChain.set.mock.calls[0]?.[0] as Record + expect(dbUpdateSet).not.toHaveProperty('labels') + }) + }) - describe("PUT /api/topics/:uri (unauthenticated)", () => { - let app: FastifyInstance; + describe('PUT /api/topics/:uri (unauthenticated)', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(undefined); - }); + app = await buildTestApp(undefined) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("returns 401 without auth", async () => { - const encodedUri = encodeURIComponent(TEST_URI); + it('returns 401 without auth', async () => { + const encodedUri = encodeURIComponent(TEST_URI) const response = await app.inject({ - method: "PUT", + method: 'PUT', url: `/api/topics/${encodedUri}`, - payload: { title: "Unauth Edit" }, - }); + payload: { title: 'Unauth Edit' }, + }) - expect(response.statusCode).toBe(401); - }); - }); + expect(response.statusCode).toBe(401) + }) + }) // ========================================================================= // DELETE /api/topics/:uri // ========================================================================= - describe("DELETE /api/topics/:uri", () => { - let app: FastifyInstance; + describe('DELETE /api/topics/:uri', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(testUser()); - }); + app = await buildTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - deleteRecordFn.mockResolvedValue(undefined); - }); + vi.clearAllMocks() + resetAllDbMocks() + deleteRecordFn.mockResolvedValue(undefined) + }) - it("deletes a topic when user is the author (deletes from PDS + DB)", async () => { - const existingRow = sampleTopicRow(); // authorDid = TEST_DID + it('deletes a topic when user is the author (deletes from PDS + DB)', async () => { + const existingRow = sampleTopicRow() // authorDid = TEST_DID // First select: find topic - selectChain.where.mockResolvedValueOnce([existingRow]); + selectChain.where.mockResolvedValueOnce([existingRow]) // Author === user, so NO second select (no role lookup needed) - const encodedUri = encodeURIComponent(TEST_URI); + const encodedUri = encodeURIComponent(TEST_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/topics/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(204); + expect(response.statusCode).toBe(204) // Should have deleted from PDS - expect(deleteRecordFn).toHaveBeenCalledOnce(); - expect(deleteRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID); + expect(deleteRecordFn).toHaveBeenCalledOnce() + expect(deleteRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID) // Should have deleted from DB (replies + topics) - expect(mockDb.delete).toHaveBeenCalled(); - }); + expect(mockDb.delete).toHaveBeenCalled() + }) - it("deletes topic as moderator (index-only delete, not from PDS)", async () => { - const modApp = await buildTestApp(testUser({ did: MOD_DID, handle: "mod.bsky.social" })); + it('deletes topic as moderator (index-only delete, not from PDS)', async () => { + const modApp = await buildTestApp(testUser({ did: MOD_DID, handle: 'mod.bsky.social' })) - const existingRow = sampleTopicRow({ authorDid: OTHER_DID }); + const existingRow = sampleTopicRow({ authorDid: OTHER_DID }) // First select: find topic - selectChain.where.mockResolvedValueOnce([existingRow]); + selectChain.where.mockResolvedValueOnce([existingRow]) // Second select: check user role (moderator is not author) - selectChain.where.mockResolvedValueOnce([{ did: MOD_DID, role: "moderator" }]); + selectChain.where.mockResolvedValueOnce([{ did: MOD_DID, role: 'moderator' }]) - const encodedUri = encodeURIComponent(TEST_URI); + const encodedUri = encodeURIComponent(TEST_URI) const response = await modApp.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/topics/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(204); + expect(response.statusCode).toBe(204) // Moderator should NOT delete from PDS - expect(deleteRecordFn).not.toHaveBeenCalled(); + expect(deleteRecordFn).not.toHaveBeenCalled() // But should delete from DB index - expect(mockDb.delete).toHaveBeenCalled(); + expect(mockDb.delete).toHaveBeenCalled() - await modApp.close(); - }); + await modApp.close() + }) - it("deletes topic as admin (index-only delete, not from PDS)", async () => { - const adminApp = await buildTestApp(testUser({ did: MOD_DID, handle: "admin.bsky.social" })); + it('deletes topic as admin (index-only delete, not from PDS)', async () => { + const adminApp = await buildTestApp(testUser({ did: MOD_DID, handle: 'admin.bsky.social' })) - const existingRow = sampleTopicRow({ authorDid: OTHER_DID }); - selectChain.where.mockResolvedValueOnce([existingRow]); - selectChain.where.mockResolvedValueOnce([{ did: MOD_DID, role: "admin" }]); + const existingRow = sampleTopicRow({ authorDid: OTHER_DID }) + selectChain.where.mockResolvedValueOnce([existingRow]) + selectChain.where.mockResolvedValueOnce([{ did: MOD_DID, role: 'admin' }]) - const encodedUri = encodeURIComponent(TEST_URI); + const encodedUri = encodeURIComponent(TEST_URI) const response = await adminApp.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/topics/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(204); - expect(deleteRecordFn).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(204) + expect(deleteRecordFn).not.toHaveBeenCalled() - await adminApp.close(); - }); + await adminApp.close() + }) - it("returns 403 when non-author regular user tries to delete", async () => { - const existingRow = sampleTopicRow({ authorDid: OTHER_DID }); - selectChain.where.mockResolvedValueOnce([existingRow]); + it('returns 403 when non-author regular user tries to delete', async () => { + const existingRow = sampleTopicRow({ authorDid: OTHER_DID }) + selectChain.where.mockResolvedValueOnce([existingRow]) // User role lookup: regular user - selectChain.where.mockResolvedValueOnce([{ did: TEST_DID, role: "user" }]); + selectChain.where.mockResolvedValueOnce([{ did: TEST_DID, role: 'user' }]) - const encodedUri = encodeURIComponent(TEST_URI); + const encodedUri = encodeURIComponent(TEST_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/topics/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(403); - }); + expect(response.statusCode).toBe(403) + }) - it("returns 404 when topic does not exist", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('returns 404 when topic does not exist', async () => { + selectChain.where.mockResolvedValueOnce([]) - const encodedUri = encodeURIComponent("at://did:plc:nobody/forum.barazo.topic.post/ghost"); + const encodedUri = encodeURIComponent('at://did:plc:nobody/forum.barazo.topic.post/ghost') const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/topics/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(404); - }); + expect(response.statusCode).toBe(404) + }) - it("returns 502 when PDS delete fails", async () => { - const existingRow = sampleTopicRow(); // author = TEST_DID - selectChain.where.mockResolvedValueOnce([existingRow]); - deleteRecordFn.mockRejectedValueOnce(new Error("PDS delete failed")); + it('returns 502 when PDS delete fails', async () => { + const existingRow = sampleTopicRow() // author = TEST_DID + selectChain.where.mockResolvedValueOnce([existingRow]) + deleteRecordFn.mockRejectedValueOnce(new Error('PDS delete failed')) - const encodedUri = encodeURIComponent(TEST_URI); + const encodedUri = encodeURIComponent(TEST_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/topics/${encodedUri}`, - headers: { authorization: "Bearer test-token" }, - }); + headers: { authorization: 'Bearer test-token' }, + }) - expect(response.statusCode).toBe(502); - }); - }); + expect(response.statusCode).toBe(502) + }) + }) - describe("DELETE /api/topics/:uri (unauthenticated)", () => { - let app: FastifyInstance; + describe('DELETE /api/topics/:uri (unauthenticated)', () => { + let app: FastifyInstance beforeAll(async () => { - app = await buildTestApp(undefined); - }); + app = await buildTestApp(undefined) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) - it("returns 401 without auth", async () => { - const encodedUri = encodeURIComponent(TEST_URI); + it('returns 401 without auth', async () => { + const encodedUri = encodeURIComponent(TEST_URI) const response = await app.inject({ - method: "DELETE", + method: 'DELETE', url: `/api/topics/${encodedUri}`, headers: {}, - }); + }) - expect(response.statusCode).toBe(401); - }); - }); + expect(response.statusCode).toBe(401) + }) + }) // ========================================================================= // GET /api/topics (global mode) // ========================================================================= - describe("GET /api/topics (global mode)", () => { + describe('GET /api/topics (global mode)', () => { const globalMockEnv = { ...mockEnv, - COMMUNITY_MODE: "global" as const, + COMMUNITY_MODE: 'global' as const, COMMUNITY_DID: undefined, - } as Env; + } as Env - let app: FastifyInstance; + let app: FastifyInstance async function buildGlobalTestApp(user?: RequestUser): Promise { - const globalApp = Fastify({ logger: false }); - - globalApp.decorate("db", mockDb as never); - globalApp.decorate("env", globalMockEnv); - globalApp.decorate("authMiddleware", createMockAuthMiddleware(user)); - globalApp.decorate("firehose", mockFirehose as never); - globalApp.decorate("oauthClient", {} as never); - globalApp.decorate("sessionService", {} as SessionService); - globalApp.decorate("setupService", {} as SetupService); - globalApp.decorate("cache", {} as never); - globalApp.decorateRequest("user", undefined as RequestUser | undefined); - - await globalApp.register(topicRoutes()); - await globalApp.ready(); - - return globalApp; + const globalApp = Fastify({ logger: false }) + + globalApp.decorate('db', mockDb as never) + globalApp.decorate('env', globalMockEnv) + globalApp.decorate('authMiddleware', createMockAuthMiddleware(user)) + globalApp.decorate('firehose', mockFirehose as never) + globalApp.decorate('oauthClient', {} as never) + globalApp.decorate('sessionService', {} as SessionService) + globalApp.decorate('setupService', {} as SetupService) + globalApp.decorate('cache', {} as never) + globalApp.decorateRequest('user', undefined as RequestUser | undefined) + + await globalApp.register(topicRoutes()) + await globalApp.ready() + + return globalApp } /** @@ -1349,235 +1377,225 @@ describe("topic routes", () => { * 6. Topics query -> selectChain.limit */ function setupGlobalMaturityMocks(opts: { - authenticated: boolean; - userProfile?: { declaredAge: number | null; maturityPref: string }; - communities: Array<{ communityDid: string | null; maturityRating: string }>; - categorySlugs: string[]; + authenticated: boolean + userProfile?: { declaredAge: number | null; maturityPref: string } + communities: Array<{ communityDid: string | null; maturityRating: string }> + categorySlugs: string[] }): void { if (opts.authenticated) { // User profile query - const profile = opts.userProfile ?? { declaredAge: null, maturityPref: "safe" }; - selectChain.where.mockResolvedValueOnce([profile]); + const profile = opts.userProfile ?? { declaredAge: null, maturityPref: 'safe' } + selectChain.where.mockResolvedValueOnce([profile]) } // Community settings: ageThreshold - selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]); + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) // Community settings query (all communities) - selectChain.where.mockResolvedValueOnce(opts.communities); + selectChain.where.mockResolvedValueOnce(opts.communities) // Category slugs query (filtered by allowed communities + maturity) - selectChain.where.mockResolvedValueOnce( - opts.categorySlugs.map((slug) => ({ slug })), - ); + selectChain.where.mockResolvedValueOnce(opts.categorySlugs.map((slug) => ({ slug }))) } beforeAll(async () => { - app = await buildGlobalTestApp(testUser()); - }); + app = await buildGlobalTestApp(testUser()) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - }); + vi.clearAllMocks() + resetAllDbMocks() + }) - it("excludes topics from adult-rated communities in global mode", async () => { + it('excludes topics from adult-rated communities in global mode', async () => { setupGlobalMaturityMocks({ authenticated: true, - userProfile: { declaredAge: 18, maturityPref: "adult" }, + userProfile: { declaredAge: 18, maturityPref: 'adult' }, communities: [ - { communityDid: "did:plc:sfw-community", maturityRating: "safe" }, - { communityDid: "did:plc:adult-community", maturityRating: "adult" }, + { communityDid: 'did:plc:sfw-community', maturityRating: 'safe' }, + { communityDid: 'did:plc:adult-community', maturityRating: 'adult' }, ], - categorySlugs: ["general"], - }); + categorySlugs: ['general'], + }) // Topics query: return one topic from the SFW community - const rows = [ - sampleTopicRow({ communityDid: "did:plc:sfw-community" }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + const rows = [sampleTopicRow({ communityDid: 'did:plc:sfw-community' })] + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", - url: "/api/topics", - }); + method: 'GET', + url: '/api/topics', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: Array<{ communityDid: string }> }>(); + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: Array<{ communityDid: string }> }>() // Adult community topics should not be present - expect(body.topics.every((t) => t.communityDid !== "did:plc:adult-community")).toBe(true); - expect(body.topics).toHaveLength(1); - }); + expect(body.topics.every((t) => t.communityDid !== 'did:plc:adult-community')).toBe(true) + expect(body.topics).toHaveLength(1) + }) - it("excludes mature-rated communities for SFW-only users", async () => { + it('excludes mature-rated communities for SFW-only users', async () => { setupGlobalMaturityMocks({ authenticated: true, - userProfile: { declaredAge: null, maturityPref: "safe" }, + userProfile: { declaredAge: null, maturityPref: 'safe' }, communities: [ - { communityDid: "did:plc:sfw-community", maturityRating: "safe" }, - { communityDid: "did:plc:mature-community", maturityRating: "mature" }, + { communityDid: 'did:plc:sfw-community', maturityRating: 'safe' }, + { communityDid: 'did:plc:mature-community', maturityRating: 'mature' }, ], - categorySlugs: ["general"], - }); + categorySlugs: ['general'], + }) // Topics from SFW community only - const rows = [ - sampleTopicRow({ communityDid: "did:plc:sfw-community" }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + const rows = [sampleTopicRow({ communityDid: 'did:plc:sfw-community' })] + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", - url: "/api/topics", - }); + method: 'GET', + url: '/api/topics', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: Array<{ communityDid: string }> }>(); - expect(body.topics.every((t) => t.communityDid !== "did:plc:mature-community")).toBe(true); - expect(body.topics).toHaveLength(1); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: Array<{ communityDid: string }> }>() + expect(body.topics.every((t) => t.communityDid !== 'did:plc:mature-community')).toBe(true) + expect(body.topics).toHaveLength(1) + }) - it("includes mature-rated communities for users with mature preference", async () => { + it('includes mature-rated communities for users with mature preference', async () => { setupGlobalMaturityMocks({ authenticated: true, - userProfile: { declaredAge: 18, maturityPref: "mature" }, + userProfile: { declaredAge: 18, maturityPref: 'mature' }, communities: [ - { communityDid: "did:plc:sfw-community", maturityRating: "safe" }, - { communityDid: "did:plc:mature-community", maturityRating: "mature" }, + { communityDid: 'did:plc:sfw-community', maturityRating: 'safe' }, + { communityDid: 'did:plc:mature-community', maturityRating: 'mature' }, ], - categorySlugs: ["general", "nsfw-general"], - }); + categorySlugs: ['general', 'nsfw-general'], + }) // Topics from both allowed communities const rows = [ - sampleTopicRow({ communityDid: "did:plc:sfw-community" }), + sampleTopicRow({ communityDid: 'did:plc:sfw-community' }), sampleTopicRow({ - communityDid: "did:plc:mature-community", + communityDid: 'did:plc:mature-community', uri: `at://${TEST_DID}/forum.barazo.topic.post/mature1`, - rkey: "mature1", + rkey: 'mature1', }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + ] + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", - url: "/api/topics", - }); + method: 'GET', + url: '/api/topics', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: Array<{ communityDid: string }> }>(); - expect(body.topics).toHaveLength(2); - const communityDids = body.topics.map((t) => t.communityDid); - expect(communityDids).toContain("did:plc:sfw-community"); - expect(communityDids).toContain("did:plc:mature-community"); - }); + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: Array<{ communityDid: string }> }>() + expect(body.topics).toHaveLength(2) + const communityDids = body.topics.map((t) => t.communityDid) + expect(communityDids).toContain('did:plc:sfw-community') + expect(communityDids).toContain('did:plc:mature-community') + }) - it("always includes SFW communities in global mode", async () => { - const noAuthApp = await buildGlobalTestApp(undefined); + it('always includes SFW communities in global mode', async () => { + const noAuthApp = await buildGlobalTestApp(undefined) setupGlobalMaturityMocks({ authenticated: false, communities: [ - { communityDid: "did:plc:sfw1", maturityRating: "safe" }, - { communityDid: "did:plc:sfw2", maturityRating: "safe" }, - { communityDid: "did:plc:mature1", maturityRating: "mature" }, + { communityDid: 'did:plc:sfw1', maturityRating: 'safe' }, + { communityDid: 'did:plc:sfw2', maturityRating: 'safe' }, + { communityDid: 'did:plc:mature1', maturityRating: 'mature' }, ], - categorySlugs: ["general", "support"], - }); + categorySlugs: ['general', 'support'], + }) const rows = [ - sampleTopicRow({ communityDid: "did:plc:sfw1" }), + sampleTopicRow({ communityDid: 'did:plc:sfw1' }), sampleTopicRow({ - communityDid: "did:plc:sfw2", + communityDid: 'did:plc:sfw2', uri: `at://${TEST_DID}/forum.barazo.topic.post/sfw2topic`, - rkey: "sfw2topic", + rkey: 'sfw2topic', }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + ] + selectChain.limit.mockResolvedValueOnce(rows) const response = await noAuthApp.inject({ - method: "GET", - url: "/api/topics", - }); + method: 'GET', + url: '/api/topics', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: Array<{ communityDid: string }> }>(); - expect(body.topics).toHaveLength(2); - const communityDids = body.topics.map((t) => t.communityDid); - expect(communityDids).toContain("did:plc:sfw1"); - expect(communityDids).toContain("did:plc:sfw2"); + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: Array<{ communityDid: string }> }>() + expect(body.topics).toHaveLength(2) + const communityDids = body.topics.map((t) => t.communityDid) + expect(communityDids).toContain('did:plc:sfw1') + expect(communityDids).toContain('did:plc:sfw2') - await noAuthApp.close(); - }); + await noAuthApp.close() + }) - it("excludes adult communities even for users with adult maturity level", async () => { + it('excludes adult communities even for users with adult maturity level', async () => { setupGlobalMaturityMocks({ authenticated: true, - userProfile: { declaredAge: 18, maturityPref: "adult" }, + userProfile: { declaredAge: 18, maturityPref: 'adult' }, communities: [ - { communityDid: "did:plc:sfw-community", maturityRating: "safe" }, - { communityDid: "did:plc:adult-community", maturityRating: "adult" }, + { communityDid: 'did:plc:sfw-community', maturityRating: 'safe' }, + { communityDid: 'did:plc:adult-community', maturityRating: 'adult' }, ], - categorySlugs: ["general"], - }); - const rows = [ - sampleTopicRow({ communityDid: "did:plc:sfw-community" }), - ]; - selectChain.limit.mockResolvedValueOnce(rows); + categorySlugs: ['general'], + }) + const rows = [sampleTopicRow({ communityDid: 'did:plc:sfw-community' })] + selectChain.limit.mockResolvedValueOnce(rows) const response = await app.inject({ - method: "GET", - url: "/api/topics", - }); + method: 'GET', + url: '/api/topics', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: Array<{ communityDid: string }> }>(); + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: Array<{ communityDid: string }> }>() // Even though user has adult maturity, adult communities are NEVER shown in global mode - expect(body.topics.every((t) => t.communityDid !== "did:plc:adult-community")).toBe(true); - }); + expect(body.topics.every((t) => t.communityDid !== 'did:plc:adult-community')).toBe(true) + }) - it("returns empty result when no communities pass the filter", async () => { + it('returns empty result when no communities pass the filter', async () => { // In global mode, when all communities are adult-rated, the handler // should return early without even querying categories or topics. // unauthenticated user: no user profile query - const noAuthApp = await buildGlobalTestApp(undefined); + const noAuthApp = await buildGlobalTestApp(undefined) // Community settings query: only adult community selectChain.where.mockResolvedValueOnce([ - { communityDid: "did:plc:adult-only", maturityRating: "adult" }, - ]); + { communityDid: 'did:plc:adult-only', maturityRating: 'adult' }, + ]) // No further mocks needed -- handler should return early const response = await noAuthApp.inject({ - method: "GET", - url: "/api/topics", - }); + method: 'GET', + url: '/api/topics', + }) - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: unknown[]; cursor: string | null }>(); - expect(body.topics).toEqual([]); - expect(body.cursor).toBeNull(); + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: unknown[]; cursor: string | null }>() + expect(body.topics).toEqual([]) + expect(body.cursor).toBeNull() - await noAuthApp.close(); - }); + await noAuthApp.close() + }) - it("returns empty result when no categories pass the maturity filter in global mode", async () => { + it('returns empty result when no categories pass the maturity filter in global mode', async () => { setupGlobalMaturityMocks({ authenticated: true, - userProfile: { declaredAge: null, maturityPref: "safe" }, - communities: [ - { communityDid: "did:plc:sfw-community", maturityRating: "safe" }, - ], + userProfile: { declaredAge: null, maturityPref: 'safe' }, + communities: [{ communityDid: 'did:plc:sfw-community', maturityRating: 'safe' }], categorySlugs: [], // No categories pass the filter - }); + }) const response = await app.inject({ - method: "GET", - url: "/api/topics", - }); - - expect(response.statusCode).toBe(200); - const body = response.json<{ topics: unknown[]; cursor: string | null }>(); - expect(body.topics).toEqual([]); - expect(body.cursor).toBeNull(); - }); - }); -}); + method: 'GET', + url: '/api/topics', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ topics: unknown[]; cursor: string | null }>() + expect(body.topics).toEqual([]) + expect(body.cursor).toBeNull() + }) + }) +}) diff --git a/tests/unit/routes/uploads.test.ts b/tests/unit/routes/uploads.test.ts index 30abf0a..a88ba28 100644 --- a/tests/unit/routes/uploads.test.ts +++ b/tests/unit/routes/uploads.test.ts @@ -1,68 +1,53 @@ -import { - describe, - it, - expect, - beforeAll, - afterAll, - vi, - beforeEach, -} from "vitest"; -import Fastify from "fastify"; -import multipart from "@fastify/multipart"; -import type { FastifyInstance } from "fastify"; -import type { Env } from "../../../src/config/env.js"; -import type { - AuthMiddleware, - RequestUser, -} from "../../../src/auth/middleware.js"; -import type { SessionService } from "../../../src/auth/session.js"; -import type { SetupService } from "../../../src/setup/service.js"; -import type { StorageService } from "../../../src/lib/storage.js"; -import { - type DbChain, - createChainableProxy, - createMockDb, -} from "../../helpers/mock-db.js"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import multipart from '@fastify/multipart' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import type { StorageService } from '../../../src/lib/storage.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' // --------------------------------------------------------------------------- // Mock sharp -- must be hoisted before route import // --------------------------------------------------------------------------- -vi.mock("sharp", () => { +vi.mock('sharp', () => { const mockSharpInstance = { resize: vi.fn().mockReturnThis(), webp: vi.fn().mockReturnThis(), - toBuffer: vi.fn().mockResolvedValue(Buffer.from("processed-image")), - }; + toBuffer: vi.fn().mockResolvedValue(Buffer.from('processed-image')), + } return { default: vi.fn(() => mockSharpInstance), __mockInstance: mockSharpInstance, - }; -}); + } +}) // Import routes after mocks -import { uploadRoutes } from "../../../src/routes/uploads.js"; +import { uploadRoutes } from '../../../src/routes/uploads.js' // --------------------------------------------------------------------------- // Mock env // --------------------------------------------------------------------------- const mockEnv = { - COMMUNITY_DID: "did:plc:community123", + COMMUNITY_DID: 'did:plc:community123', UPLOAD_MAX_SIZE_BYTES: 5_242_880, RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, -} as Env; +} as Env // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_HANDLE = "alice.bsky.social"; -const TEST_SID = "a".repeat(64); -const COMMUNITY_DID = "did:plc:community456"; +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'alice.bsky.social' +const TEST_SID = 'a'.repeat(64) +const COMMUNITY_DID = 'did:plc:community456' // --------------------------------------------------------------------------- // Mock user builders @@ -74,25 +59,25 @@ function testUser(overrides?: Partial): RequestUser { handle: TEST_HANDLE, sid: TEST_SID, ...overrides, - }; + } } // --------------------------------------------------------------------------- // Mock DB // --------------------------------------------------------------------------- -const mockDb = createMockDb(); +const mockDb = createMockDb() -let insertChain: DbChain; +let insertChain: DbChain function resetAllDbMocks(): void { - const selectChain = createChainableProxy([]); - insertChain = createChainableProxy(); - const deleteChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); - mockDb.select.mockReturnValue(selectChain); - mockDb.update.mockReturnValue(createChainableProxy([])); - mockDb.delete.mockReturnValue(deleteChain); + const selectChain = createChainableProxy([]) + insertChain = createChainableProxy() + const deleteChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(createChainableProxy([])) + mockDb.delete.mockReturnValue(deleteChain) } // --------------------------------------------------------------------------- @@ -101,9 +86,9 @@ function resetAllDbMocks(): void { function createMockStorage(): StorageService { return { - store: vi.fn().mockResolvedValue("http://localhost:3000/uploads/avatars/test.webp"), + store: vi.fn().mockResolvedValue('http://localhost:3000/uploads/avatars/test.webp'), delete: vi.fn().mockResolvedValue(undefined), - }; + } } // --------------------------------------------------------------------------- @@ -114,18 +99,18 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { return { requireAuth: async (request, reply) => { if (!user) { - await reply.status(401).send({ error: "Authentication required" }); - return; + await reply.status(401).send({ error: 'Authentication required' }) + return } - request.user = user; + request.user = user }, optionalAuth: (request, _reply) => { if (user) { - request.user = user; + request.user = user } - return Promise.resolve(); + return Promise.resolve() }, - }; + } } // --------------------------------------------------------------------------- @@ -134,32 +119,32 @@ function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { async function buildTestApp( user?: RequestUser, - storageOverride?: StorageService, + storageOverride?: StorageService ): Promise { - const app = Fastify({ logger: false }); + const app = Fastify({ logger: false }) // Register multipart before routes (required for request.file()) await app.register(multipart, { limits: { fileSize: mockEnv.UPLOAD_MAX_SIZE_BYTES }, - }); + }) - const storage = storageOverride ?? createMockStorage(); + const storage = storageOverride ?? createMockStorage() - app.decorate("db", mockDb as never); - app.decorate("env", mockEnv); - app.decorate("authMiddleware", createMockAuthMiddleware(user)); - app.decorate("storage", storage); - app.decorate("firehose", {} as never); - app.decorate("oauthClient", {} as never); - app.decorate("sessionService", {} as SessionService); - app.decorate("setupService", {} as SetupService); - app.decorate("cache", {} as never); - app.decorateRequest("user", undefined as RequestUser | undefined); + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', createMockAuthMiddleware(user)) + app.decorate('storage', storage) + app.decorate('firehose', {} as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) - await app.register(uploadRoutes()); - await app.ready(); + await app.register(uploadRoutes()) + await app.ready() - return app; + return app } // --------------------------------------------------------------------------- @@ -169,288 +154,268 @@ async function buildTestApp( function createMultipartPayload( filename: string, mimetype: string, - data: Buffer, + data: Buffer ): { body: string; contentType: string } { - const boundary = `----TestBoundary${String(Date.now())}`; + const boundary = `----TestBoundary${String(Date.now())}` const body = [ `--${boundary}`, `Content-Disposition: form-data; name="file"; filename="${filename}"`, `Content-Type: ${mimetype}`, - "", - data.toString("binary"), + '', + data.toString('binary'), `--${boundary}--`, - ].join("\r\n"); + ].join('\r\n') return { body, contentType: `multipart/form-data; boundary=${boundary}`, - }; + } } // =========================================================================== // Test suite // =========================================================================== -describe("upload routes", () => { +describe('upload routes', () => { // ========================================================================= // POST /api/communities/:communityDid/profile/avatar // ========================================================================= - describe("POST /api/communities/:communityDid/profile/avatar", () => { - let app: FastifyInstance; - let mockStorage: StorageService; + describe('POST /api/communities/:communityDid/profile/avatar', () => { + let app: FastifyInstance + let mockStorage: StorageService beforeAll(async () => { - mockStorage = createMockStorage(); - app = await buildTestApp(testUser(), mockStorage); - }); + mockStorage = createMockStorage() + app = await buildTestApp(testUser(), mockStorage) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - (mockStorage.store as ReturnType).mockResolvedValue( - "http://localhost:3000/uploads/avatars/test.webp", - ); - }); - - it("uploads avatar and returns URL", async () => { - const imageData = Buffer.from("fake-png-data"); - const { body, contentType } = createMultipartPayload( - "avatar.png", - "image/png", - imageData, - ); + vi.clearAllMocks() + resetAllDbMocks() + ;(mockStorage.store as ReturnType).mockResolvedValue( + 'http://localhost:3000/uploads/avatars/test.webp' + ) + }) + + it('uploads avatar and returns URL', async () => { + const imageData = Buffer.from('fake-png-data') + const { body, contentType } = createMultipartPayload('avatar.png', 'image/png', imageData) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/communities/${COMMUNITY_DID}/profile/avatar`, headers: { - authorization: "Bearer test-token", - "content-type": contentType, + authorization: 'Bearer test-token', + 'content-type': contentType, }, body, - }); + }) - expect(response.statusCode).toBe(200); - const result = response.json<{ url: string }>(); - expect(result.url).toBe( - "http://localhost:3000/uploads/avatars/test.webp", - ); + expect(response.statusCode).toBe(200) + const result = response.json<{ url: string }>() + expect(result.url).toBe('http://localhost:3000/uploads/avatars/test.webp') // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockStorage.store).toHaveBeenCalledOnce(); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); + expect(mockStorage.store).toHaveBeenCalledOnce() + expect(mockDb.insert).toHaveBeenCalledOnce() + }) - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); - const imageData = Buffer.from("fake-png-data"); - const { body, contentType } = createMultipartPayload( - "avatar.png", - "image/png", - imageData, - ); + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) + const imageData = Buffer.from('fake-png-data') + const { body, contentType } = createMultipartPayload('avatar.png', 'image/png', imageData) const response = await noAuthApp.inject({ - method: "POST", + method: 'POST', url: `/api/communities/${COMMUNITY_DID}/profile/avatar`, - headers: { "content-type": contentType }, + headers: { 'content-type': contentType }, body, - }); + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("returns 400 when no file is uploaded", async () => { + it('returns 400 when no file is uploaded', async () => { const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/communities/${COMMUNITY_DID}/profile/avatar`, headers: { - authorization: "Bearer test-token", - "content-type": "multipart/form-data; boundary=----EmptyBoundary", + authorization: 'Bearer test-token', + 'content-type': 'multipart/form-data; boundary=----EmptyBoundary', }, - body: "------EmptyBoundary--\r\n", - }); + body: '------EmptyBoundary--\r\n', + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("returns 400 for invalid MIME type", async () => { + it('returns 400 for invalid MIME type', async () => { const { body, contentType } = createMultipartPayload( - "doc.pdf", - "application/pdf", - Buffer.from("not-an-image"), - ); + 'doc.pdf', + 'application/pdf', + Buffer.from('not-an-image') + ) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/communities/${COMMUNITY_DID}/profile/avatar`, headers: { - authorization: "Bearer test-token", - "content-type": contentType, + authorization: 'Bearer test-token', + 'content-type': contentType, }, body, - }); + }) - expect(response.statusCode).toBe(400); - }); + expect(response.statusCode).toBe(400) + }) - it("accepts JPEG files", async () => { + it('accepts JPEG files', async () => { const { body, contentType } = createMultipartPayload( - "photo.jpg", - "image/jpeg", - Buffer.from("jpeg-data"), - ); + 'photo.jpg', + 'image/jpeg', + Buffer.from('jpeg-data') + ) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/communities/${COMMUNITY_DID}/profile/avatar`, headers: { - authorization: "Bearer test-token", - "content-type": contentType, + authorization: 'Bearer test-token', + 'content-type': contentType, }, body, - }); + }) - expect(response.statusCode).toBe(200); - }); + expect(response.statusCode).toBe(200) + }) - it("accepts WebP files", async () => { + it('accepts WebP files', async () => { const { body, contentType } = createMultipartPayload( - "photo.webp", - "image/webp", - Buffer.from("webp-data"), - ); + 'photo.webp', + 'image/webp', + Buffer.from('webp-data') + ) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/communities/${COMMUNITY_DID}/profile/avatar`, headers: { - authorization: "Bearer test-token", - "content-type": contentType, + authorization: 'Bearer test-token', + 'content-type': contentType, }, body, - }); + }) - expect(response.statusCode).toBe(200); - }); + expect(response.statusCode).toBe(200) + }) - it("accepts GIF files", async () => { + it('accepts GIF files', async () => { const { body, contentType } = createMultipartPayload( - "anim.gif", - "image/gif", - Buffer.from("gif-data"), - ); + 'anim.gif', + 'image/gif', + Buffer.from('gif-data') + ) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/communities/${COMMUNITY_DID}/profile/avatar`, headers: { - authorization: "Bearer test-token", - "content-type": contentType, + authorization: 'Bearer test-token', + 'content-type': contentType, }, body, - }); + }) - expect(response.statusCode).toBe(200); - }); - }); + expect(response.statusCode).toBe(200) + }) + }) // ========================================================================= // POST /api/communities/:communityDid/profile/banner // ========================================================================= - describe("POST /api/communities/:communityDid/profile/banner", () => { - let app: FastifyInstance; - let mockStorage: StorageService; + describe('POST /api/communities/:communityDid/profile/banner', () => { + let app: FastifyInstance + let mockStorage: StorageService beforeAll(async () => { - mockStorage = createMockStorage(); - app = await buildTestApp(testUser(), mockStorage); - }); + mockStorage = createMockStorage() + app = await buildTestApp(testUser(), mockStorage) + }) afterAll(async () => { - await app.close(); - }); + await app.close() + }) beforeEach(() => { - vi.clearAllMocks(); - resetAllDbMocks(); - (mockStorage.store as ReturnType).mockResolvedValue( - "http://localhost:3000/uploads/banners/test.webp", - ); - }); - - it("uploads banner and returns URL", async () => { - const imageData = Buffer.from("fake-png-data"); - const { body, contentType } = createMultipartPayload( - "banner.png", - "image/png", - imageData, - ); + vi.clearAllMocks() + resetAllDbMocks() + ;(mockStorage.store as ReturnType).mockResolvedValue( + 'http://localhost:3000/uploads/banners/test.webp' + ) + }) + + it('uploads banner and returns URL', async () => { + const imageData = Buffer.from('fake-png-data') + const { body, contentType } = createMultipartPayload('banner.png', 'image/png', imageData) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/communities/${COMMUNITY_DID}/profile/banner`, headers: { - authorization: "Bearer test-token", - "content-type": contentType, + authorization: 'Bearer test-token', + 'content-type': contentType, }, body, - }); + }) - expect(response.statusCode).toBe(200); - const result = response.json<{ url: string }>(); - expect(result.url).toBe( - "http://localhost:3000/uploads/banners/test.webp", - ); + expect(response.statusCode).toBe(200) + const result = response.json<{ url: string }>() + expect(result.url).toBe('http://localhost:3000/uploads/banners/test.webp') // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockStorage.store).toHaveBeenCalledOnce(); - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); + expect(mockStorage.store).toHaveBeenCalledOnce() + expect(mockDb.insert).toHaveBeenCalledOnce() + }) - it("returns 401 when not authenticated", async () => { - const noAuthApp = await buildTestApp(undefined); - const imageData = Buffer.from("fake-png-data"); - const { body, contentType } = createMultipartPayload( - "banner.png", - "image/png", - imageData, - ); + it('returns 401 when not authenticated', async () => { + const noAuthApp = await buildTestApp(undefined) + const imageData = Buffer.from('fake-png-data') + const { body, contentType } = createMultipartPayload('banner.png', 'image/png', imageData) const response = await noAuthApp.inject({ - method: "POST", + method: 'POST', url: `/api/communities/${COMMUNITY_DID}/profile/banner`, - headers: { "content-type": contentType }, + headers: { 'content-type': contentType }, body, - }); + }) - expect(response.statusCode).toBe(401); - await noAuthApp.close(); - }); + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) - it("returns 400 for invalid MIME type", async () => { + it('returns 400 for invalid MIME type', async () => { const { body, contentType } = createMultipartPayload( - "doc.txt", - "text/plain", - Buffer.from("not-an-image"), - ); + 'doc.txt', + 'text/plain', + Buffer.from('not-an-image') + ) const response = await app.inject({ - method: "POST", + method: 'POST', url: `/api/communities/${COMMUNITY_DID}/profile/banner`, headers: { - authorization: "Bearer test-token", - "content-type": contentType, + authorization: 'Bearer test-token', + 'content-type': contentType, }, body, - }); + }) - expect(response.statusCode).toBe(400); - }); - }); -}); + expect(response.statusCode).toBe(400) + }) + }) +}) diff --git a/tests/unit/services/account-age.test.ts b/tests/unit/services/account-age.test.ts index 2657a2b..d4e4c6b 100644 --- a/tests/unit/services/account-age.test.ts +++ b/tests/unit/services/account-age.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createAccountAgeService } from "../../../src/services/account-age.js"; -import type { AccountAgeService } from "../../../src/services/account-age.js"; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createAccountAgeService } from '../../../src/services/account-age.js' +import type { AccountAgeService } from '../../../src/services/account-age.js' function createMockLogger() { return { @@ -8,136 +8,136 @@ function createMockLogger() { error: vi.fn(), warn: vi.fn(), debug: vi.fn(), - }; + } } -describe("AccountAgeService", () => { - let service: AccountAgeService; - let logger: ReturnType; +describe('AccountAgeService', () => { + let service: AccountAgeService + let logger: ReturnType beforeEach(() => { - logger = createMockLogger(); - service = createAccountAgeService(logger as never); - vi.stubGlobal("fetch", vi.fn()); - }); + logger = createMockLogger() + service = createAccountAgeService(logger as never) + vi.stubGlobal('fetch', vi.fn()) + }) afterEach(() => { - vi.restoreAllMocks(); - }); + vi.restoreAllMocks() + }) - describe("resolveCreationDate", () => { - it("returns null for non-PLC DIDs", async () => { - const result = await service.resolveCreationDate("did:web:example.com"); + describe('resolveCreationDate', () => { + it('returns null for non-PLC DIDs', async () => { + const result = await service.resolveCreationDate('did:web:example.com') - expect(result).toBeNull(); + expect(result).toBeNull() expect(logger.debug).toHaveBeenCalledWith( - { did: "did:web:example.com" }, - "Non-PLC DID, cannot resolve account creation date", - ); - }); + { did: 'did:web:example.com' }, + 'Non-PLC DID, cannot resolve account creation date' + ) + }) - it("resolves creation date from PLC directory audit log", async () => { - const createdAt = "2026-02-14T10:00:00.000Z"; + it('resolves creation date from PLC directory audit log', async () => { + const createdAt = '2026-02-14T10:00:00.000Z' const mockResponse = { ok: true, json: vi.fn().mockResolvedValue([ - { createdAt, type: "plc_operation" }, - { createdAt: "2026-02-15T10:00:00.000Z", type: "plc_operation" }, + { createdAt, type: 'plc_operation' }, + { createdAt: '2026-02-15T10:00:00.000Z', type: 'plc_operation' }, ]), - }; - vi.mocked(fetch).mockResolvedValue(mockResponse as never); + } + vi.mocked(fetch).mockResolvedValue(mockResponse as never) - const result = await service.resolveCreationDate("did:plc:abc123"); + const result = await service.resolveCreationDate('did:plc:abc123') - expect(result).toEqual(new Date(createdAt)); + expect(result).toEqual(new Date(createdAt)) expect(fetch).toHaveBeenCalledWith( - "https://plc.directory/did%3Aplc%3Aabc123/log/audit", + 'https://plc.directory/did%3Aplc%3Aabc123/log/audit', expect.objectContaining({ - headers: { Accept: "application/json" }, - }), - ); - }); + headers: { Accept: 'application/json' }, + }) + ) + }) - it("returns null on HTTP error", async () => { - const mockResponse = { ok: false, status: 404 }; - vi.mocked(fetch).mockResolvedValue(mockResponse as never); + it('returns null on HTTP error', async () => { + const mockResponse = { ok: false, status: 404 } + vi.mocked(fetch).mockResolvedValue(mockResponse as never) - const result = await service.resolveCreationDate("did:plc:missing"); + const result = await service.resolveCreationDate('did:plc:missing') - expect(result).toBeNull(); - expect(logger.warn).toHaveBeenCalled(); - }); + expect(result).toBeNull() + expect(logger.warn).toHaveBeenCalled() + }) - it("returns null on empty audit log", async () => { + it('returns null on empty audit log', async () => { const mockResponse = { ok: true, json: vi.fn().mockResolvedValue([]), - }; - vi.mocked(fetch).mockResolvedValue(mockResponse as never); + } + vi.mocked(fetch).mockResolvedValue(mockResponse as never) - const result = await service.resolveCreationDate("did:plc:empty"); + const result = await service.resolveCreationDate('did:plc:empty') - expect(result).toBeNull(); - }); + expect(result).toBeNull() + }) - it("returns null on invalid createdAt timestamp", async () => { + it('returns null on invalid createdAt timestamp', async () => { const mockResponse = { ok: true, - json: vi.fn().mockResolvedValue([{ createdAt: "not-a-date" }]), - }; - vi.mocked(fetch).mockResolvedValue(mockResponse as never); + json: vi.fn().mockResolvedValue([{ createdAt: 'not-a-date' }]), + } + vi.mocked(fetch).mockResolvedValue(mockResponse as never) - const result = await service.resolveCreationDate("did:plc:invalid"); + const result = await service.resolveCreationDate('did:plc:invalid') - expect(result).toBeNull(); - expect(logger.warn).toHaveBeenCalled(); - }); + expect(result).toBeNull() + expect(logger.warn).toHaveBeenCalled() + }) - it("returns null on network error", async () => { - vi.mocked(fetch).mockRejectedValue(new Error("Network error")); + it('returns null on network error', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('Network error')) - const result = await service.resolveCreationDate("did:plc:network"); + const result = await service.resolveCreationDate('did:plc:network') - expect(result).toBeNull(); - expect(logger.warn).toHaveBeenCalled(); - }); + expect(result).toBeNull() + expect(logger.warn).toHaveBeenCalled() + }) - it("returns null when first entry lacks createdAt field", async () => { + it('returns null when first entry lacks createdAt field', async () => { const mockResponse = { ok: true, - json: vi.fn().mockResolvedValue([{ type: "plc_operation" }]), - }; - vi.mocked(fetch).mockResolvedValue(mockResponse as never); + json: vi.fn().mockResolvedValue([{ type: 'plc_operation' }]), + } + vi.mocked(fetch).mockResolvedValue(mockResponse as never) - const result = await service.resolveCreationDate("did:plc:nocreated"); + const result = await service.resolveCreationDate('did:plc:nocreated') - expect(result).toBeNull(); - }); - }); + expect(result).toBeNull() + }) + }) - describe("determineTrustStatus", () => { + describe('determineTrustStatus', () => { it("returns 'trusted' for null accountCreatedAt", () => { - expect(service.determineTrustStatus(null)).toBe("trusted"); - }); + expect(service.determineTrustStatus(null)).toBe('trusted') + }) it("returns 'new' for account created less than 24h ago", () => { - const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); - expect(service.determineTrustStatus(oneHourAgo)).toBe("new"); - }); + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000) + expect(service.determineTrustStatus(oneHourAgo)).toBe('new') + }) it("returns 'trusted' for account created more than 24h ago", () => { - const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); - expect(service.determineTrustStatus(twoDaysAgo)).toBe("trusted"); - }); + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000) + expect(service.determineTrustStatus(twoDaysAgo)).toBe('trusted') + }) it("returns 'trusted' for account created exactly 24h ago", () => { - const exactlyDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); - expect(service.determineTrustStatus(exactlyDayAgo)).toBe("trusted"); - }); + const exactlyDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000) + expect(service.determineTrustStatus(exactlyDayAgo)).toBe('trusted') + }) it("returns 'new' for account created 23h59m ago", () => { - const almostDayAgo = new Date(Date.now() - (24 * 60 * 60 * 1000 - 60_000)); - expect(service.determineTrustStatus(almostDayAgo)).toBe("new"); - }); - }); -}); + const almostDayAgo = new Date(Date.now() - (24 * 60 * 60 * 1000 - 60_000)) + expect(service.determineTrustStatus(almostDayAgo)).toBe('new') + }) + }) +}) diff --git a/tests/unit/services/ban-propagation.test.ts b/tests/unit/services/ban-propagation.test.ts index 62e6006..e1707c2 100644 --- a/tests/unit/services/ban-propagation.test.ts +++ b/tests/unit/services/ban-propagation.test.ts @@ -1,13 +1,13 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { checkBanPropagation } from "../../../src/services/ban-propagation.js"; -import { createMockDb, resetDbMocks } from "../../helpers/mock-db.js"; -import type { MockDb } from "../../helpers/mock-db.js"; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { checkBanPropagation } from '../../../src/services/ban-propagation.js' +import { createMockDb, resetDbMocks } from '../../helpers/mock-db.js' +import type { MockDb } from '../../helpers/mock-db.js' // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TARGET_DID = "did:plc:target123"; +const TARGET_DID = 'did:plc:target123' // --------------------------------------------------------------------------- // Mock logger @@ -22,9 +22,9 @@ function createMockLogger() { fatal: vi.fn(), trace: vi.fn(), child: vi.fn(), - level: "info", + level: 'info', silent: vi.fn(), - }; + } } // --------------------------------------------------------------------------- @@ -36,155 +36,155 @@ function createMockCache() { del: vi.fn().mockResolvedValue(undefined), get: vi.fn(), set: vi.fn(), - }; + } } // --------------------------------------------------------------------------- // Setup // --------------------------------------------------------------------------- -let mockDb: MockDb; -let mockLogger: ReturnType; -let mockCache: ReturnType; +let mockDb: MockDb +let mockLogger: ReturnType +let mockCache: ReturnType beforeEach(() => { - vi.clearAllMocks(); - mockDb = createMockDb(); - resetDbMocks(mockDb); - mockLogger = createMockLogger(); - mockCache = createMockCache(); -}); + vi.clearAllMocks() + mockDb = createMockDb() + resetDbMocks(mockDb) + mockLogger = createMockLogger() + mockCache = createMockCache() +}) // =========================================================================== // checkBanPropagation // =========================================================================== -describe("checkBanPropagation", () => { - it("returns propagated=false and banCount=0 when user has no bans", async () => { - mockDb.execute.mockResolvedValue([{ ban_count: 0 }]); +describe('checkBanPropagation', () => { + it('returns propagated=false and banCount=0 when user has no bans', async () => { + mockDb.execute.mockResolvedValue([{ ban_count: 0 }]) const result = await checkBanPropagation( mockDb as never, mockCache as never, mockLogger as never, - TARGET_DID, - ); + TARGET_DID + ) - expect(result).toEqual({ propagated: false, banCount: 0 }); - expect(mockDb.insert).not.toHaveBeenCalled(); - expect(mockCache.del).not.toHaveBeenCalled(); - expect(mockLogger.info).not.toHaveBeenCalled(); - }); + expect(result).toEqual({ propagated: false, banCount: 0 }) + expect(mockDb.insert).not.toHaveBeenCalled() + expect(mockCache.del).not.toHaveBeenCalled() + expect(mockLogger.info).not.toHaveBeenCalled() + }) - it("returns propagated=false and banCount=1 when banned in only 1 community", async () => { - mockDb.execute.mockResolvedValue([{ ban_count: 1 }]); + it('returns propagated=false and banCount=1 when banned in only 1 community', async () => { + mockDb.execute.mockResolvedValue([{ ban_count: 1 }]) const result = await checkBanPropagation( mockDb as never, mockCache as never, mockLogger as never, - TARGET_DID, - ); + TARGET_DID + ) - expect(result).toEqual({ propagated: false, banCount: 1 }); - expect(mockDb.insert).not.toHaveBeenCalled(); - expect(mockCache.del).not.toHaveBeenCalled(); - }); + expect(result).toEqual({ propagated: false, banCount: 1 }) + expect(mockDb.insert).not.toHaveBeenCalled() + expect(mockCache.del).not.toHaveBeenCalled() + }) - it("returns propagated=true and creates account filter when banned in 2+ communities", async () => { - mockDb.execute.mockResolvedValue([{ ban_count: 2 }]); + it('returns propagated=true and creates account filter when banned in 2+ communities', async () => { + mockDb.execute.mockResolvedValue([{ ban_count: 2 }]) const result = await checkBanPropagation( mockDb as never, mockCache as never, mockLogger as never, - TARGET_DID, - ); + TARGET_DID + ) - expect(result).toEqual({ propagated: true, banCount: 2 }); - expect(mockDb.insert).toHaveBeenCalled(); - expect(mockCache.del).toHaveBeenCalledWith(`account-filter:${TARGET_DID}`); + expect(result).toEqual({ propagated: true, banCount: 2 }) + expect(mockDb.insert).toHaveBeenCalled() + expect(mockCache.del).toHaveBeenCalledWith(`account-filter:${TARGET_DID}`) expect(mockLogger.info).toHaveBeenCalledWith( { targetDid: TARGET_DID, banCount: 2 }, - "Account auto-filtered due to cross-community bans", - ); - }); + 'Account auto-filtered due to cross-community bans' + ) + }) - it("returns propagated=true when banned in more than 2 communities", async () => { - mockDb.execute.mockResolvedValue([{ ban_count: 5 }]); + it('returns propagated=true when banned in more than 2 communities', async () => { + mockDb.execute.mockResolvedValue([{ ban_count: 5 }]) const result = await checkBanPropagation( mockDb as never, mockCache as never, mockLogger as never, - TARGET_DID, - ); + TARGET_DID + ) - expect(result).toEqual({ propagated: true, banCount: 5 }); - expect(mockDb.insert).toHaveBeenCalled(); + expect(result).toEqual({ propagated: true, banCount: 5 }) + expect(mockDb.insert).toHaveBeenCalled() expect(mockLogger.info).toHaveBeenCalledWith( { targetDid: TARGET_DID, banCount: 5 }, - "Account auto-filtered due to cross-community bans", - ); - }); + 'Account auto-filtered due to cross-community bans' + ) + }) - it("correctly counts only communities with latest action = ban (ignores unbans)", async () => { + it('correctly counts only communities with latest action = ban (ignores unbans)', async () => { // When the SQL query correctly handles unbans, the returned ban_count // reflects only communities where the latest action is "ban". // e.g., user banned in 3 communities but unbanned in 2 -> ban_count = 1 - mockDb.execute.mockResolvedValue([{ ban_count: 1 }]); + mockDb.execute.mockResolvedValue([{ ban_count: 1 }]) const result = await checkBanPropagation( mockDb as never, mockCache as never, mockLogger as never, - TARGET_DID, - ); + TARGET_DID + ) - expect(result).toEqual({ propagated: false, banCount: 1 }); - expect(mockDb.insert).not.toHaveBeenCalled(); - }); + expect(result).toEqual({ propagated: false, banCount: 1 }) + expect(mockDb.insert).not.toHaveBeenCalled() + }) - it("handles cache.del failure gracefully (non-critical)", async () => { - mockDb.execute.mockResolvedValue([{ ban_count: 3 }]); - mockCache.del.mockRejectedValue(new Error("Cache unavailable")); + it('handles cache.del failure gracefully (non-critical)', async () => { + mockDb.execute.mockResolvedValue([{ ban_count: 3 }]) + mockCache.del.mockRejectedValue(new Error('Cache unavailable')) const result = await checkBanPropagation( mockDb as never, mockCache as never, mockLogger as never, - TARGET_DID, - ); + TARGET_DID + ) // Should still succeed despite cache error - expect(result).toEqual({ propagated: true, banCount: 3 }); - expect(mockDb.insert).toHaveBeenCalled(); - expect(mockLogger.info).toHaveBeenCalled(); - }); + expect(result).toEqual({ propagated: true, banCount: 3 }) + expect(mockDb.insert).toHaveBeenCalled() + expect(mockLogger.info).toHaveBeenCalled() + }) - it("defaults banCount to 0 when result row is missing ban_count", async () => { - mockDb.execute.mockResolvedValue([{}]); + it('defaults banCount to 0 when result row is missing ban_count', async () => { + mockDb.execute.mockResolvedValue([{}]) const result = await checkBanPropagation( mockDb as never, mockCache as never, mockLogger as never, - TARGET_DID, - ); + TARGET_DID + ) - expect(result).toEqual({ propagated: false, banCount: 0 }); - }); + expect(result).toEqual({ propagated: false, banCount: 0 }) + }) - it("defaults banCount to 0 when result array is empty", async () => { - mockDb.execute.mockResolvedValue([]); + it('defaults banCount to 0 when result array is empty', async () => { + mockDb.execute.mockResolvedValue([]) const result = await checkBanPropagation( mockDb as never, mockCache as never, mockLogger as never, - TARGET_DID, - ); + TARGET_DID + ) - expect(result).toEqual({ propagated: false, banCount: 0 }); - }); -}); + expect(result).toEqual({ propagated: false, banCount: 0 }) + }) +}) diff --git a/tests/unit/services/behavioral-heuristics.test.ts b/tests/unit/services/behavioral-heuristics.test.ts new file mode 100644 index 0000000..6b96eda --- /dev/null +++ b/tests/unit/services/behavioral-heuristics.test.ts @@ -0,0 +1,291 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + computeTrigrams, + jaccardSimilarity, + createBehavioralHeuristicsService, +} from '../../../src/services/behavioral-heuristics.js' +import { createMockDb, createChainableProxy, resetDbMocks } from '../../helpers/mock-db.js' +import type { DbChain } from '../../helpers/mock-db.js' + +// --------------------------------------------------------------------------- +// Pure function tests +// --------------------------------------------------------------------------- + +describe('computeTrigrams', () => { + it('computes trigrams from simple text', () => { + const trigrams = computeTrigrams('hello') + expect(trigrams.has('hel')).toBe(true) + expect(trigrams.has('ell')).toBe(true) + expect(trigrams.has('llo')).toBe(true) + expect(trigrams.size).toBe(3) + }) + + it('normalizes to lowercase', () => { + const trigrams = computeTrigrams('HELLO') + expect(trigrams.has('hel')).toBe(true) + }) + + it('strips non-alphanumeric characters', () => { + const trigrams = computeTrigrams('hi! there.') + // Should normalize to "hi there" + expect(trigrams.has('hi ')).toBe(true) + expect(trigrams.has('i t')).toBe(true) + }) + + it('returns empty set for short text', () => { + const trigrams = computeTrigrams('ab') + expect(trigrams.size).toBe(0) + }) + + it('handles empty string', () => { + const trigrams = computeTrigrams('') + expect(trigrams.size).toBe(0) + }) +}) + +describe('jaccardSimilarity', () => { + it('returns 1 for identical sets', () => { + const a = new Set(['abc', 'bcd', 'cde']) + const b = new Set(['abc', 'bcd', 'cde']) + expect(jaccardSimilarity(a, b)).toBe(1) + }) + + it('returns 0 for disjoint sets', () => { + const a = new Set(['abc', 'bcd']) + const b = new Set(['xyz', 'yzw']) + expect(jaccardSimilarity(a, b)).toBe(0) + }) + + it('returns correct value for partial overlap', () => { + const a = new Set(['abc', 'bcd', 'cde']) + const b = new Set(['abc', 'bcd', 'xyz']) + // Intersection: 2, Union: 4 + expect(jaccardSimilarity(a, b)).toBeCloseTo(0.5) + }) + + it('returns 1 for two empty sets', () => { + expect(jaccardSimilarity(new Set(), new Set())).toBe(1) + }) + + it('returns 0 when one set is empty', () => { + const a = new Set(['abc']) + const b = new Set() + expect(jaccardSimilarity(a, b)).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// Service tests +// --------------------------------------------------------------------------- + +describe('BehavioralHeuristicsService', () => { + const mockDb = createMockDb() + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn().mockReturnThis(), + level: 'info', + silent: vi.fn(), + } + + let selectChain: DbChain + let insertChain: DbChain + + beforeEach(() => { + vi.clearAllMocks() + resetDbMocks(mockDb) + selectChain = createChainableProxy([]) + insertChain = createChainableProxy() + mockDb.select.mockReturnValue(selectChain) + mockDb.insert.mockReturnValue(insertChain) + }) + + describe('detectBurstVoting', () => { + it('returns empty array when no burst voting detected', async () => { + // db.select().from().where().groupBy().having() returns [] + const burstChain = createChainableProxy([]) + mockDb.select.mockReturnValueOnce(burstChain) + + const service = createBehavioralHeuristicsService(mockDb as never, mockLogger as never) + const flags = await service.detectBurstVoting(null) + + expect(flags).toHaveLength(0) + }) + + it('detects burst voting and persists flag', async () => { + // db.select().from().where().groupBy().having() returns spammer rows + const burstChain = createChainableProxy([ + { authorDid: 'did:plc:spammer1', reactionCount: 25 }, + ]) + mockDb.select.mockReturnValueOnce(burstChain) + + const service = createBehavioralHeuristicsService(mockDb as never, mockLogger as never) + const flags = await service.detectBurstVoting(null) + + expect(flags).toHaveLength(1) + expect(flags[0]?.flagType).toBe('burst_voting') + expect(flags[0]?.affectedDids).toContain('did:plc:spammer1') + expect(mockDb.insert).toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalled() + }) + + it('scopes burst voting detection to a community', async () => { + const burstChain = createChainableProxy([]) + mockDb.select.mockReturnValueOnce(burstChain) + + const service = createBehavioralHeuristicsService(mockDb as never, mockLogger as never) + await service.detectBurstVoting('community123') + + expect(mockDb.select).toHaveBeenCalled() + }) + + it('handles errors gracefully', async () => { + // Make the select chain throw + const errorChain = createChainableProxy() + errorChain.from.mockReturnValue(errorChain) + errorChain.where.mockReturnValue(errorChain) + errorChain.groupBy.mockReturnValue(errorChain) + errorChain.having.mockRejectedValueOnce(new Error('DB down')) + mockDb.select.mockReturnValueOnce(errorChain) + + const service = createBehavioralHeuristicsService(mockDb as never, mockLogger as never) + const flags = await service.detectBurstVoting(null) + + expect(flags).toHaveLength(0) + expect(mockLogger.error).toHaveBeenCalled() + }) + }) + + describe('detectContentSimilarity', () => { + it('returns empty array when no similar content found', async () => { + const selectChain = createChainableProxy([]) + mockDb.select.mockReturnValue(selectChain) + + const service = createBehavioralHeuristicsService(mockDb as never, mockLogger as never) + const flags = await service.detectContentSimilarity(null) + + expect(flags).toHaveLength(0) + }) + + it('detects similar content from different DIDs', async () => { + // The same content posted by 3 different DIDs + const similarContent = + 'This is a test post with enough content to generate meaningful trigrams for comparison purposes' + const topicRows = [ + { authorDid: 'did:plc:user1', content: similarContent, uri: 'at://did:plc:user1/topic/1' }, + { authorDid: 'did:plc:user2', content: similarContent, uri: 'at://did:plc:user2/topic/2' }, + { authorDid: 'did:plc:user3', content: similarContent, uri: 'at://did:plc:user3/topic/3' }, + ] + + // First select: topics + const topicSelectChain = createChainableProxy(topicRows) + // Second select: replies + const replySelectChain = createChainableProxy([]) + + mockDb.select.mockReturnValueOnce(topicSelectChain).mockReturnValueOnce(replySelectChain) + + // Insert for persisting the flag + const insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) + + const service = createBehavioralHeuristicsService(mockDb as never, mockLogger as never) + const flags = await service.detectContentSimilarity(null) + + expect(flags).toHaveLength(1) + expect(flags[0]?.flagType).toBe('content_similarity') + expect(flags[0]?.affectedDids).toHaveLength(3) + }) + + it('does not flag content from the same DID', async () => { + const content = 'This is a test post with enough content for trigrams comparison and analysis' + const topicRows = [ + { authorDid: 'did:plc:user1', content, uri: 'at://did:plc:user1/topic/1' }, + { authorDid: 'did:plc:user1', content, uri: 'at://did:plc:user1/topic/2' }, + { authorDid: 'did:plc:user1', content, uri: 'at://did:plc:user1/topic/3' }, + ] + + const topicSelectChain = createChainableProxy(topicRows) + const replySelectChain = createChainableProxy([]) + mockDb.select.mockReturnValueOnce(topicSelectChain).mockReturnValueOnce(replySelectChain) + + const service = createBehavioralHeuristicsService(mockDb as never, mockLogger as never) + const flags = await service.detectContentSimilarity(null) + + expect(flags).toHaveLength(0) + }) + }) + + describe('detectLowDiversity', () => { + it('returns empty array when no low diversity detected', async () => { + // db.select().from().where().groupBy().having() returns [] + const diversityChain = createChainableProxy([]) + mockDb.select.mockReturnValueOnce(diversityChain) + + const service = createBehavioralHeuristicsService(mockDb as never, mockLogger as never) + const flags = await service.detectLowDiversity(null) + + expect(flags).toHaveLength(0) + }) + + it('detects low interaction diversity', async () => { + // db.select().from().where().groupBy().having() returns puppets + const diversityChain = createChainableProxy([ + { authorDid: 'did:plc:puppet1', totalInteractions: 15, uniqueTargets: 2 }, + ]) + mockDb.select.mockReturnValueOnce(diversityChain) + + const service = createBehavioralHeuristicsService(mockDb as never, mockLogger as never) + const flags = await service.detectLowDiversity(null) + + expect(flags).toHaveLength(1) + expect(flags[0]?.flagType).toBe('low_diversity') + expect(flags[0]?.affectedDids).toContain('did:plc:puppet1') + }) + + it('handles errors gracefully', async () => { + const errorChain = createChainableProxy() + errorChain.from.mockReturnValue(errorChain) + errorChain.where.mockReturnValue(errorChain) + errorChain.groupBy.mockReturnValue(errorChain) + errorChain.having.mockRejectedValueOnce(new Error('DB timeout')) + mockDb.select.mockReturnValueOnce(errorChain) + + const service = createBehavioralHeuristicsService(mockDb as never, mockLogger as never) + const flags = await service.detectLowDiversity(null) + + expect(flags).toHaveLength(0) + expect(mockLogger.error).toHaveBeenCalled() + }) + }) + + describe('runAll', () => { + it('aggregates flags from all heuristics', async () => { + // Burst voting: db.select().from().where().groupBy().having() -> spammer + const burstChain = createChainableProxy([ + { authorDid: 'did:plc:spammer1', reactionCount: 25 }, + ]) + // Content similarity: topics -> empty, replies -> empty + const topicSelectChain = createChainableProxy([]) + const replySelectChain = createChainableProxy([]) + // Low diversity: db.select().from().where().groupBy().having() -> empty + const diversityChain = createChainableProxy([]) + + mockDb.select + .mockReturnValueOnce(burstChain) // burst voting query + .mockReturnValueOnce(topicSelectChain) // content similarity: topics + .mockReturnValueOnce(replySelectChain) // content similarity: replies + .mockReturnValueOnce(diversityChain) // low diversity query + + const service = createBehavioralHeuristicsService(mockDb as never, mockLogger as never) + const flags = await service.runAll(null) + + // At least 1 flag from burst voting + expect(flags.length).toBeGreaterThanOrEqual(1) + expect(flags.some((f) => f.flagType === 'burst_voting')).toBe(true) + }) + }) +}) diff --git a/tests/unit/services/cluster-diversity.test.ts b/tests/unit/services/cluster-diversity.test.ts new file mode 100644 index 0000000..dcc9124 --- /dev/null +++ b/tests/unit/services/cluster-diversity.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { computeClusterDiversityFactor } from '../../../src/services/cluster-diversity.js' + +describe('computeClusterDiversityFactor', () => { + it('should return 1.0 when voter is not in any flagged cluster', () => { + const factor = computeClusterDiversityFactor(false, 0) + expect(factor).toBe(1.0) + }) + + it('should return log2(1 + count) when in a flagged cluster', () => { + // count=3 -> log2(4) = 2.0 + const factor = computeClusterDiversityFactor(true, 3) + expect(factor).toBeCloseTo(2.0, 5) + }) + + it('should return log2(1) = 0 when in cluster with zero external interactions', () => { + const factor = computeClusterDiversityFactor(true, 0) + expect(factor).toBeCloseTo(0.0, 5) + }) + + it('should return log2(2) = 1 when in cluster with 1 external interaction', () => { + const factor = computeClusterDiversityFactor(true, 1) + expect(factor).toBeCloseTo(1.0, 5) + }) + + it('should return log2(8) = 3 when in cluster with 7 external interactions', () => { + const factor = computeClusterDiversityFactor(true, 7) + expect(factor).toBeCloseTo(3.0, 5) + }) +}) diff --git a/tests/unit/services/cross-post.test.ts b/tests/unit/services/cross-post.test.ts index 4e968cd..307ceee 100644 --- a/tests/unit/services/cross-post.test.ts +++ b/tests/unit/services/cross-post.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createCrossPostService } from "../../../src/services/cross-post.js"; -import type { PdsClient, PdsWriteResult } from "../../../src/lib/pds-client.js"; -import type { NotificationService } from "../../../src/services/notification.js"; -import { createMockDb, createChainableProxy } from "../../helpers/mock-db.js"; -import type { DbChain } from "../../helpers/mock-db.js"; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createCrossPostService } from '../../../src/services/cross-post.js' +import type { PdsClient, PdsWriteResult } from '../../../src/lib/pds-client.js' +import type { NotificationService } from '../../../src/services/notification.js' +import { createMockDb, createChainableProxy } from '../../helpers/mock-db.js' +import type { DbChain } from '../../helpers/mock-db.js' // --------------------------------------------------------------------------- // Mock logger @@ -17,26 +17,41 @@ const mockLogger = { fatal: vi.fn(), trace: vi.fn(), child: vi.fn().mockReturnThis(), - level: "info", + level: 'info', silent: vi.fn(), -}; +} // --------------------------------------------------------------------------- // Mock PDS client // --------------------------------------------------------------------------- function createMockPdsClient(): PdsClient & { - createRecord: ReturnType; - updateRecord: ReturnType; - deleteRecord: ReturnType; - uploadBlob: ReturnType; + createRecord: ReturnType + updateRecord: ReturnType + deleteRecord: ReturnType + uploadBlob: ReturnType } { return { - createRecord: vi.fn<(did: string, collection: string, record: Record) => Promise>(), - updateRecord: vi.fn<(did: string, collection: string, rkey: string, record: Record) => Promise>(), + createRecord: + vi.fn< + ( + did: string, + collection: string, + record: Record + ) => Promise + >(), + updateRecord: + vi.fn< + ( + did: string, + collection: string, + rkey: string, + record: Record + ) => Promise + >(), deleteRecord: vi.fn<(did: string, collection: string, rkey: string) => Promise>(), uploadBlob: vi.fn(), - }; + } } // --------------------------------------------------------------------------- @@ -44,12 +59,12 @@ function createMockPdsClient(): PdsClient & { // --------------------------------------------------------------------------- function createMockNotificationService(): NotificationService & { - notifyOnReply: ReturnType; - notifyOnReaction: ReturnType; - notifyOnModAction: ReturnType; - notifyOnMentions: ReturnType; - notifyOnCrossPostFailure: ReturnType; - notifyOnCrossPostScopeRevoked: ReturnType; + notifyOnReply: ReturnType + notifyOnReaction: ReturnType + notifyOnModAction: ReturnType + notifyOnMentions: ReturnType + notifyOnCrossPostFailure: ReturnType + notifyOnCrossPostScopeRevoked: ReturnType } { return { notifyOnReply: vi.fn().mockResolvedValue(undefined), @@ -58,81 +73,84 @@ function createMockNotificationService(): NotificationService & { notifyOnMentions: vi.fn().mockResolvedValue(undefined), notifyOnCrossPostFailure: vi.fn().mockResolvedValue(undefined), notifyOnCrossPostScopeRevoked: vi.fn().mockResolvedValue(undefined), - }; + } } // --------------------------------------------------------------------------- // Mock OG image generation (avoid actual sharp calls in unit tests) // --------------------------------------------------------------------------- -vi.mock("../../../src/services/og-image.js", () => ({ - generateOgImage: vi.fn().mockResolvedValue(Buffer.from("fake-png-data")), -})); +vi.mock('../../../src/services/og-image.js', () => ({ + generateOgImage: vi.fn().mockResolvedValue(Buffer.from('fake-png-data')), +})) // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const TEST_DID = "did:plc:testuser123"; -const TEST_COMMUNITY_DID = "did:plc:community123"; -const TEST_TOPIC_URI = `at://${TEST_DID}/forum.barazo.topic.post/abc123`; -const TEST_BLUESKY_URI = `at://${TEST_DID}/app.bsky.feed.post/bsky001`; -const TEST_BLUESKY_CID = "bafyreibsky001"; -const TEST_FRONTPAGE_URI = `at://${TEST_DID}/fyi.frontpage.post/fp001`; -const TEST_FRONTPAGE_CID = "bafyreifp001"; -const TEST_PUBLIC_URL = "https://forum.example.com"; -const TEST_COMMUNITY_NAME = "Test Community"; -const TEST_BLOB_REF = { $type: "blob", ref: { $link: "bafyblob123" }, mimeType: "image/png", size: 1234 }; +const TEST_DID = 'did:plc:testuser123' +const TEST_COMMUNITY_DID = 'did:plc:community123' +const TEST_TOPIC_URI = `at://${TEST_DID}/forum.barazo.topic.post/abc123` +const TEST_BLUESKY_URI = `at://${TEST_DID}/app.bsky.feed.post/bsky001` +const TEST_BLUESKY_CID = 'bafyreibsky001' +const TEST_FRONTPAGE_URI = `at://${TEST_DID}/fyi.frontpage.post/fp001` +const TEST_FRONTPAGE_CID = 'bafyreifp001' +const TEST_PUBLIC_URL = 'https://forum.example.com' +const TEST_COMMUNITY_NAME = 'Test Community' +const TEST_BLOB_REF = { + $type: 'blob', + ref: { $link: 'bafyblob123' }, + mimeType: 'image/png', + size: 1234, +} // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describe("cross-post service", () => { - let mockPds: ReturnType; - let mockDb: ReturnType; - let mockNotifications: ReturnType; - let insertChain: DbChain; - let selectChain: DbChain; - let scopeSelectChain: DbChain; - let deleteChain: DbChain; - let updateChain: DbChain; +describe('cross-post service', () => { + let mockPds: ReturnType + let mockDb: ReturnType + let mockNotifications: ReturnType + let insertChain: DbChain + let selectChain: DbChain + let scopeSelectChain: DbChain + let deleteChain: DbChain + let updateChain: DbChain beforeEach(() => { - vi.clearAllMocks(); - mockPds = createMockPdsClient(); - mockDb = createMockDb(); - mockNotifications = createMockNotificationService(); - insertChain = createChainableProxy(); - selectChain = createChainableProxy([]); - deleteChain = createChainableProxy(); - updateChain = createChainableProxy([]); + vi.clearAllMocks() + mockPds = createMockPdsClient() + mockDb = createMockDb() + mockNotifications = createMockNotificationService() + insertChain = createChainableProxy() + selectChain = createChainableProxy([]) + deleteChain = createChainableProxy() + updateChain = createChainableProxy([]) // The scope check (first select) should return crossPostScopesGranted=true by default - scopeSelectChain = createChainableProxy([{ crossPostScopesGranted: true }]); + scopeSelectChain = createChainableProxy([{ crossPostScopesGranted: true }]) // First select call returns scope check result, subsequent calls return empty - mockDb.select - .mockReturnValueOnce(scopeSelectChain) - .mockReturnValue(selectChain); - mockDb.insert.mockReturnValue(insertChain); - mockDb.delete.mockReturnValue(deleteChain); - mockDb.update.mockReturnValue(updateChain); + mockDb.select.mockReturnValueOnce(scopeSelectChain).mockReturnValue(selectChain) + mockDb.insert.mockReturnValue(insertChain) + mockDb.delete.mockReturnValue(deleteChain) + mockDb.update.mockReturnValue(updateChain) // Default: blob upload succeeds - mockPds.uploadBlob.mockResolvedValue(TEST_BLOB_REF); - }); + mockPds.uploadBlob.mockResolvedValue(TEST_BLOB_REF) + }) // ========================================================================= // crossPostTopic // ========================================================================= - describe("crossPostTopic", () => { - it("cross-posts to Bluesky when enabled", async () => { + describe('crossPostTopic', () => { + it('cross-posts to Bluesky when enabled', async () => { mockPds.createRecord.mockResolvedValue({ uri: TEST_BLUESKY_URI, cid: TEST_BLUESKY_CID, - }); + }) const service = createCrossPostService( mockPds, @@ -144,48 +162,46 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) await service.crossPostTopic({ did: TEST_DID, topicUri: TEST_TOPIC_URI, - title: "My Topic", - content: "Topic content here.", - category: "general", + title: 'My Topic', + content: 'Topic content here.', + category: 'general', communityDid: TEST_COMMUNITY_DID, - }); + }) - expect(mockPds.createRecord).toHaveBeenCalledOnce(); + expect(mockPds.createRecord).toHaveBeenCalledOnce() const [did, collection, record] = mockPds.createRecord.mock.calls[0] as [ string, string, Record, - ]; - expect(did).toBe(TEST_DID); - expect(collection).toBe("app.bsky.feed.post"); - expect(record.$type).toBe("app.bsky.feed.post"); - expect(record.text).toContain("My Topic"); - expect((record.embed as Record).$type).toBe( - "app.bsky.embed.external", - ); + ] + expect(did).toBe(TEST_DID) + expect(collection).toBe('app.bsky.feed.post') + expect(record.$type).toBe('app.bsky.feed.post') + expect(record.text).toContain('My Topic') + expect((record.embed as Record).$type).toBe('app.bsky.embed.external') // Should insert cross-post record into DB - expect(mockDb.insert).toHaveBeenCalledOnce(); + expect(mockDb.insert).toHaveBeenCalledOnce() expect(mockLogger.info).toHaveBeenCalledWith( expect.objectContaining({ topicUri: TEST_TOPIC_URI, crossPostUri: TEST_BLUESKY_URI, }) as Record, - "Cross-posted topic to Bluesky", - ); - }); + 'Cross-posted topic to Bluesky' + ) + }) - it("includes OG image as thumb in Bluesky embed", async () => { + it('includes OG image as thumb in Bluesky embed', async () => { mockPds.createRecord.mockResolvedValue({ uri: TEST_BLUESKY_URI, cid: TEST_BLUESKY_CID, - }); + }) const service = createCrossPostService( mockPds, @@ -197,46 +213,46 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) await service.crossPostTopic({ did: TEST_DID, topicUri: TEST_TOPIC_URI, - title: "OG Image Topic", - content: "Testing OG image.", - category: "general", + title: 'OG Image Topic', + content: 'Testing OG image.', + category: 'general', communityDid: TEST_COMMUNITY_DID, - }); + }) // Should upload blob first - expect(mockPds.uploadBlob).toHaveBeenCalledOnce(); + expect(mockPds.uploadBlob).toHaveBeenCalledOnce() expect(mockPds.uploadBlob).toHaveBeenCalledWith( TEST_DID, expect.any(Buffer) as Buffer, - "image/png", - ); + 'image/png' + ) // Embed should include thumb with the blob reference const [, , record] = mockPds.createRecord.mock.calls[0] as [ string, string, Record, - ]; - const embed = record.embed as Record; - const external = embed.external as Record; - expect(external.thumb).toBe(TEST_BLOB_REF); - }); + ] + const embed = record.embed as Record + const external = embed.external as Record + expect(external.thumb).toBe(TEST_BLOB_REF) + }) - it("still cross-posts without thumb when OG image generation fails", async () => { + it('still cross-posts without thumb when OG image generation fails', async () => { // Mock OG image failure - const { generateOgImage } = await import("../../../src/services/og-image.js"); - vi.mocked(generateOgImage).mockRejectedValueOnce(new Error("Image generation failed")); + const { generateOgImage } = await import('../../../src/services/og-image.js') + vi.mocked(generateOgImage).mockRejectedValueOnce(new Error('Image generation failed')) mockPds.createRecord.mockResolvedValue({ uri: TEST_BLUESKY_URI, cid: TEST_BLUESKY_CID, - }); + }) const service = createCrossPostService( mockPds, @@ -248,44 +264,44 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) await service.crossPostTopic({ did: TEST_DID, topicUri: TEST_TOPIC_URI, - title: "No Thumb Topic", - content: "OG image will fail.", - category: "general", + title: 'No Thumb Topic', + content: 'OG image will fail.', + category: 'general', communityDid: TEST_COMMUNITY_DID, - }); + }) // Should still create the post (without thumb) - expect(mockPds.createRecord).toHaveBeenCalledOnce(); + expect(mockPds.createRecord).toHaveBeenCalledOnce() const [, , record] = mockPds.createRecord.mock.calls[0] as [ string, string, Record, - ]; - const embed = record.embed as Record; - const external = embed.external as Record; - expect(external.thumb).toBeUndefined(); + ] + const embed = record.embed as Record + const external = embed.external as Record + expect(external.thumb).toBeUndefined() // Should log warning expect(mockLogger.warn).toHaveBeenCalledWith( expect.objectContaining({ topicUri: TEST_TOPIC_URI, }) as Record, - expect.stringContaining("OG image") as string, - ); - }); + expect.stringContaining('OG image') as string + ) + }) - it("still cross-posts without thumb when blob upload fails", async () => { - mockPds.uploadBlob.mockRejectedValueOnce(new Error("Upload failed")); + it('still cross-posts without thumb when blob upload fails', async () => { + mockPds.uploadBlob.mockRejectedValueOnce(new Error('Upload failed')) mockPds.createRecord.mockResolvedValue({ uri: TEST_BLUESKY_URI, cid: TEST_BLUESKY_CID, - }); + }) const service = createCrossPostService( mockPds, @@ -297,36 +313,36 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) await service.crossPostTopic({ did: TEST_DID, topicUri: TEST_TOPIC_URI, - title: "Upload Fail Topic", - content: "Blob upload will fail.", - category: "general", + title: 'Upload Fail Topic', + content: 'Blob upload will fail.', + category: 'general', communityDid: TEST_COMMUNITY_DID, - }); + }) // Should still create the post - expect(mockPds.createRecord).toHaveBeenCalledOnce(); + expect(mockPds.createRecord).toHaveBeenCalledOnce() // Thumb should not be set const [, , record] = mockPds.createRecord.mock.calls[0] as [ string, string, Record, - ]; - const embed = record.embed as Record; - const external = embed.external as Record; - expect(external.thumb).toBeUndefined(); - }); + ] + const embed = record.embed as Record + const external = embed.external as Record + expect(external.thumb).toBeUndefined() + }) - it("cross-posts to Frontpage when enabled", async () => { + it('cross-posts to Frontpage when enabled', async () => { mockPds.createRecord.mockResolvedValue({ uri: TEST_FRONTPAGE_URI, cid: TEST_FRONTPAGE_CID, - }); + }) const service = createCrossPostService( mockPds, @@ -338,33 +354,33 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) await service.crossPostTopic({ did: TEST_DID, topicUri: TEST_TOPIC_URI, - title: "Frontpage Topic", - content: "Content for Frontpage.", - category: "general", + title: 'Frontpage Topic', + content: 'Content for Frontpage.', + category: 'general', communityDid: TEST_COMMUNITY_DID, - }); + }) - expect(mockPds.createRecord).toHaveBeenCalledOnce(); + expect(mockPds.createRecord).toHaveBeenCalledOnce() const [did, collection, record] = mockPds.createRecord.mock.calls[0] as [ string, string, Record, - ]; - expect(did).toBe(TEST_DID); - expect(collection).toBe("fyi.frontpage.post"); - expect(record.title).toBe("Frontpage Topic"); - expect(record.url).toBe(`${TEST_PUBLIC_URL}/topics/abc123`); + ] + expect(did).toBe(TEST_DID) + expect(collection).toBe('fyi.frontpage.post') + expect(record.title).toBe('Frontpage Topic') + expect(record.url).toBe(`${TEST_PUBLIC_URL}/topics/abc123`) - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); + expect(mockDb.insert).toHaveBeenCalledOnce() + }) - it("cross-posts to both Bluesky and Frontpage concurrently when both enabled", async () => { + it('cross-posts to both Bluesky and Frontpage concurrently when both enabled', async () => { mockPds.createRecord .mockResolvedValueOnce({ uri: TEST_BLUESKY_URI, @@ -373,7 +389,7 @@ describe("cross-post service", () => { .mockResolvedValueOnce({ uri: TEST_FRONTPAGE_URI, cid: TEST_FRONTPAGE_CID, - }); + }) const service = createCrossPostService( mockPds, @@ -385,27 +401,27 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) await service.crossPostTopic({ did: TEST_DID, topicUri: TEST_TOPIC_URI, - title: "Dual Post", - content: "Content for both.", - category: "general", + title: 'Dual Post', + content: 'Content for both.', + category: 'general', communityDid: TEST_COMMUNITY_DID, - }); + }) // Both services called - expect(mockPds.createRecord).toHaveBeenCalledTimes(2); + expect(mockPds.createRecord).toHaveBeenCalledTimes(2) // Both DB inserts - expect(mockDb.insert).toHaveBeenCalledTimes(2); + expect(mockDb.insert).toHaveBeenCalledTimes(2) // Both logged - expect(mockLogger.info).toHaveBeenCalledTimes(2); - }); + expect(mockLogger.info).toHaveBeenCalledTimes(2) + }) - it("does nothing when both services are disabled", async () => { + it('does nothing when both services are disabled', async () => { const service = createCrossPostService( mockPds, mockDb as never, @@ -416,27 +432,27 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) await service.crossPostTopic({ did: TEST_DID, topicUri: TEST_TOPIC_URI, - title: "No Cross-Post", - content: "Should not go anywhere.", - category: "general", + title: 'No Cross-Post', + content: 'Should not go anywhere.', + category: 'general', communityDid: TEST_COMMUNITY_DID, - }); + }) - expect(mockPds.createRecord).not.toHaveBeenCalled(); + expect(mockPds.createRecord).not.toHaveBeenCalled() // insert not called for cross-posts (scope check uses select, not insert) - }); + }) - it("skips cross-posting when user has not authorized scopes", async () => { + it('skips cross-posting when user has not authorized scopes', async () => { // Override: scope check returns false - mockDb.select.mockReset(); - const noScopeChain = createChainableProxy([{ crossPostScopesGranted: false }]); - mockDb.select.mockReturnValue(noScopeChain); + mockDb.select.mockReset() + const noScopeChain = createChainableProxy([{ crossPostScopesGranted: false }]) + mockDb.select.mockReturnValue(noScopeChain) const service = createCrossPostService( mockPds, @@ -448,29 +464,27 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) await service.crossPostTopic({ did: TEST_DID, topicUri: TEST_TOPIC_URI, - title: "No Scopes", - content: "User has not authorized.", - category: "general", + title: 'No Scopes', + content: 'User has not authorized.', + category: 'general', communityDid: TEST_COMMUNITY_DID, - }); + }) - expect(mockPds.createRecord).not.toHaveBeenCalled(); + expect(mockPds.createRecord).not.toHaveBeenCalled() expect(mockLogger.info).toHaveBeenCalledWith( expect.objectContaining({ did: TEST_DID }) as Record, - "Skipping cross-post: user has not authorized cross-post scopes", - ); - }); + 'Skipping cross-post: user has not authorized cross-post scopes' + ) + }) - it("notifies user when Bluesky cross-post fails", async () => { - mockPds.createRecord.mockRejectedValue( - new Error("Bluesky PDS unreachable"), - ); + it('notifies user when Bluesky cross-post fails', async () => { + mockPds.createRecord.mockRejectedValue(new Error('Bluesky PDS unreachable')) const service = createCrossPostService( mockPds, @@ -482,43 +496,41 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) await service.crossPostTopic({ did: TEST_DID, topicUri: TEST_TOPIC_URI, - title: "Will Fail", - content: "Bluesky is down.", - category: "general", + title: 'Will Fail', + content: 'Bluesky is down.', + category: 'general', communityDid: TEST_COMMUNITY_DID, - }); + }) // Error still logged expect(mockLogger.error).toHaveBeenCalledWith( expect.objectContaining({ topicUri: TEST_TOPIC_URI, - service: "bluesky", + service: 'bluesky', }) as Record, - "Failed to cross-post to Bluesky", - ); + 'Failed to cross-post to Bluesky' + ) // User should be notified expect(mockNotifications.notifyOnCrossPostFailure).toHaveBeenCalledWith({ topicUri: TEST_TOPIC_URI, authorDid: TEST_DID, - service: "bluesky", + service: 'bluesky', communityDid: TEST_COMMUNITY_DID, - }); + }) // DB insert should NOT be called since PDS failed - expect(mockDb.insert).not.toHaveBeenCalled(); - }); + expect(mockDb.insert).not.toHaveBeenCalled() + }) - it("notifies user when Frontpage cross-post fails", async () => { - mockPds.createRecord.mockRejectedValue( - new Error("Frontpage PDS error"), - ); + it('notifies user when Frontpage cross-post fails', async () => { + mockPds.createRecord.mockRejectedValue(new Error('Frontpage PDS error')) const service = createCrossPostService( mockPds, @@ -530,30 +542,28 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) await service.crossPostTopic({ did: TEST_DID, topicUri: TEST_TOPIC_URI, - title: "FP Fail", - content: "Frontpage is down.", - category: "general", + title: 'FP Fail', + content: 'Frontpage is down.', + category: 'general', communityDid: TEST_COMMUNITY_DID, - }); + }) expect(mockNotifications.notifyOnCrossPostFailure).toHaveBeenCalledWith({ topicUri: TEST_TOPIC_URI, authorDid: TEST_DID, - service: "frontpage", + service: 'frontpage', communityDid: TEST_COMMUNITY_DID, - }); - }); + }) + }) - it("notifies for each failed service independently", async () => { - mockPds.createRecord.mockRejectedValue( - new Error("PDS error"), - ); + it('notifies for each failed service independently', async () => { + mockPds.createRecord.mockRejectedValue(new Error('PDS error')) const service = createCrossPostService( mockPds, @@ -565,35 +575,35 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) await service.crossPostTopic({ did: TEST_DID, topicUri: TEST_TOPIC_URI, - title: "Both Fail", - content: "Everything is broken.", - category: "general", + title: 'Both Fail', + content: 'Everything is broken.', + category: 'general', communityDid: TEST_COMMUNITY_DID, - }); + }) // Two failure notifications (one per service) - expect(mockNotifications.notifyOnCrossPostFailure).toHaveBeenCalledTimes(2); + expect(mockNotifications.notifyOnCrossPostFailure).toHaveBeenCalledTimes(2) expect(mockNotifications.notifyOnCrossPostFailure).toHaveBeenCalledWith( - expect.objectContaining({ service: "bluesky" }) as Record, - ); + expect.objectContaining({ service: 'bluesky' }) as Record + ) expect(mockNotifications.notifyOnCrossPostFailure).toHaveBeenCalledWith( - expect.objectContaining({ service: "frontpage" }) as Record, - ); - }); + expect.objectContaining({ service: 'frontpage' }) as Record + ) + }) - it("continues Frontpage when Bluesky fails", async () => { + it('continues Frontpage when Bluesky fails', async () => { mockPds.createRecord - .mockRejectedValueOnce(new Error("Bluesky PDS error")) + .mockRejectedValueOnce(new Error('Bluesky PDS error')) .mockResolvedValueOnce({ uri: TEST_FRONTPAGE_URI, cid: TEST_FRONTPAGE_CID, - }); + }) const service = createCrossPostService( mockPds, @@ -605,49 +615,49 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) await service.crossPostTopic({ did: TEST_DID, topicUri: TEST_TOPIC_URI, - title: "Partial Success", - content: "One succeeds, one fails.", - category: "general", + title: 'Partial Success', + content: 'One succeeds, one fails.', + category: 'general', communityDid: TEST_COMMUNITY_DID, - }); + }) // Bluesky error logged expect(mockLogger.error).toHaveBeenCalledWith( - expect.objectContaining({ service: "bluesky" }) as Record, - "Failed to cross-post to Bluesky", - ); + expect.objectContaining({ service: 'bluesky' }) as Record, + 'Failed to cross-post to Bluesky' + ) // Bluesky failure notification sent - expect(mockNotifications.notifyOnCrossPostFailure).toHaveBeenCalledOnce(); + expect(mockNotifications.notifyOnCrossPostFailure).toHaveBeenCalledOnce() expect(mockNotifications.notifyOnCrossPostFailure).toHaveBeenCalledWith( - expect.objectContaining({ service: "bluesky" }) as Record, - ); + expect.objectContaining({ service: 'bluesky' }) as Record + ) // Frontpage success logged expect(mockLogger.info).toHaveBeenCalledWith( expect.objectContaining({ crossPostUri: TEST_FRONTPAGE_URI, }) as Record, - "Cross-posted topic to Frontpage", - ); + 'Cross-posted topic to Frontpage' + ) // Only the Frontpage cross-post stored in DB - expect(mockDb.insert).toHaveBeenCalledOnce(); - }); + expect(mockDb.insert).toHaveBeenCalledOnce() + }) - it("builds correct Bluesky post text with title and truncated content", async () => { + it('builds correct Bluesky post text with title and truncated content', async () => { mockPds.createRecord.mockResolvedValue({ uri: TEST_BLUESKY_URI, cid: TEST_BLUESKY_CID, - }); + }) - const longContent = "A".repeat(500); + const longContent = 'A'.repeat(500) const service = createCrossPostService( mockPds, @@ -659,34 +669,34 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) await service.crossPostTopic({ did: TEST_DID, topicUri: TEST_TOPIC_URI, - title: "Short Title", + title: 'Short Title', content: longContent, - category: "general", + category: 'general', communityDid: TEST_COMMUNITY_DID, - }); + }) const [, , record] = mockPds.createRecord.mock.calls[0] as [ string, string, Record, - ]; - const postText = record.text as string; + ] + const postText = record.text as string // Post text should not exceed 300 chars - expect(postText.length).toBeLessThanOrEqual(300); - expect(postText).toContain("Short Title"); - }); + expect(postText.length).toBeLessThanOrEqual(300) + expect(postText).toContain('Short Title') + }) - it("builds correct topic URL from AT URI", async () => { + it('builds correct topic URL from AT URI', async () => { mockPds.createRecord.mockResolvedValue({ uri: TEST_BLUESKY_URI, cid: TEST_BLUESKY_CID, - }); + }) const service = createCrossPostService( mockPds, @@ -698,33 +708,33 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) await service.crossPostTopic({ did: TEST_DID, topicUri: TEST_TOPIC_URI, - title: "URL Test", - content: "Content.", - category: "general", + title: 'URL Test', + content: 'Content.', + category: 'general', communityDid: TEST_COMMUNITY_DID, - }); + }) const [, , record] = mockPds.createRecord.mock.calls[0] as [ string, string, Record, - ]; - const embed = record.embed as Record; - const external = embed.external as Record; - expect(external.uri).toBe(`${TEST_PUBLIC_URL}/topics/abc123`); - }); + ] + const embed = record.embed as Record + const external = embed.external as Record + expect(external.uri).toBe(`${TEST_PUBLIC_URL}/topics/abc123`) + }) - it("does not generate OG image for Frontpage-only cross-posts", async () => { + it('does not generate OG image for Frontpage-only cross-posts', async () => { mockPds.createRecord.mockResolvedValue({ uri: TEST_FRONTPAGE_URI, cid: TEST_FRONTPAGE_CID, - }); + }) const service = createCrossPostService( mockPds, @@ -736,58 +746,58 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) await service.crossPostTopic({ did: TEST_DID, topicUri: TEST_TOPIC_URI, - title: "FP Only", - content: "No OG needed.", - category: "general", + title: 'FP Only', + content: 'No OG needed.', + category: 'general', communityDid: TEST_COMMUNITY_DID, - }); + }) // Should NOT upload a blob (Frontpage doesn't use thumbnails) - expect(mockPds.uploadBlob).not.toHaveBeenCalled(); - }); - }); + expect(mockPds.uploadBlob).not.toHaveBeenCalled() + }) + }) // ========================================================================= // deleteCrossPosts // ========================================================================= - describe("deleteCrossPosts", () => { + describe('deleteCrossPosts', () => { beforeEach(() => { // deleteCrossPosts doesn't do a scope check, so the first select // should return the cross-posts select chain (not the scope chain) - mockDb.select.mockReset(); - mockDb.select.mockReturnValue(selectChain); - }); + mockDb.select.mockReset() + mockDb.select.mockReturnValue(selectChain) + }) - it("deletes all cross-posts for a topic from PDS and DB", async () => { + it('deletes all cross-posts for a topic from PDS and DB', async () => { selectChain.where.mockResolvedValueOnce([ { - id: "cp-1", + id: 'cp-1', topicUri: TEST_TOPIC_URI, - service: "bluesky", + service: 'bluesky', crossPostUri: TEST_BLUESKY_URI, crossPostCid: TEST_BLUESKY_CID, authorDid: TEST_DID, createdAt: new Date(), }, { - id: "cp-2", + id: 'cp-2', topicUri: TEST_TOPIC_URI, - service: "frontpage", + service: 'frontpage', crossPostUri: TEST_FRONTPAGE_URI, crossPostCid: TEST_FRONTPAGE_CID, authorDid: TEST_DID, createdAt: new Date(), }, - ]); + ]) - mockPds.deleteRecord.mockResolvedValue(undefined); + mockPds.deleteRecord.mockResolvedValue(undefined) const service = createCrossPostService( mockPds, @@ -799,45 +809,35 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) - await service.deleteCrossPosts(TEST_TOPIC_URI, TEST_DID); + await service.deleteCrossPosts(TEST_TOPIC_URI, TEST_DID) // Should delete both records from PDS - expect(mockPds.deleteRecord).toHaveBeenCalledTimes(2); - expect(mockPds.deleteRecord).toHaveBeenCalledWith( - TEST_DID, - "app.bsky.feed.post", - "bsky001", - ); - expect(mockPds.deleteRecord).toHaveBeenCalledWith( - TEST_DID, - "fyi.frontpage.post", - "fp001", - ); + expect(mockPds.deleteRecord).toHaveBeenCalledTimes(2) + expect(mockPds.deleteRecord).toHaveBeenCalledWith(TEST_DID, 'app.bsky.feed.post', 'bsky001') + expect(mockPds.deleteRecord).toHaveBeenCalledWith(TEST_DID, 'fyi.frontpage.post', 'fp001') // Should delete DB rows - expect(mockDb.delete).toHaveBeenCalledOnce(); - expect(mockLogger.info).toHaveBeenCalledTimes(2); - }); + expect(mockDb.delete).toHaveBeenCalledOnce() + expect(mockLogger.info).toHaveBeenCalledTimes(2) + }) - it("cleans up DB rows even when PDS delete fails", async () => { + it('cleans up DB rows even when PDS delete fails', async () => { selectChain.where.mockResolvedValueOnce([ { - id: "cp-1", + id: 'cp-1', topicUri: TEST_TOPIC_URI, - service: "bluesky", + service: 'bluesky', crossPostUri: TEST_BLUESKY_URI, crossPostCid: TEST_BLUESKY_CID, authorDid: TEST_DID, createdAt: new Date(), }, - ]); + ]) - mockPds.deleteRecord.mockRejectedValue( - new Error("PDS delete failed"), - ); + mockPds.deleteRecord.mockRejectedValue(new Error('PDS delete failed')) const service = createCrossPostService( mockPds, @@ -849,27 +849,27 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) // Should NOT throw - await service.deleteCrossPosts(TEST_TOPIC_URI, TEST_DID); + await service.deleteCrossPosts(TEST_TOPIC_URI, TEST_DID) // Warning logged for PDS failure expect(mockLogger.warn).toHaveBeenCalledWith( expect.objectContaining({ crossPostUri: TEST_BLUESKY_URI, - service: "bluesky", + service: 'bluesky', }) as Record, - "Failed to delete cross-post from PDS (best-effort)", - ); + 'Failed to delete cross-post from PDS (best-effort)' + ) // DB rows still deleted - expect(mockDb.delete).toHaveBeenCalledOnce(); - }); + expect(mockDb.delete).toHaveBeenCalledOnce() + }) - it("does nothing when no cross-posts exist for the topic", async () => { - selectChain.where.mockResolvedValueOnce([]); + it('does nothing when no cross-posts exist for the topic', async () => { + selectChain.where.mockResolvedValueOnce([]) const service = createCrossPostService( mockPds, @@ -881,14 +881,14 @@ describe("cross-post service", () => { publicUrl: TEST_PUBLIC_URL, communityName: TEST_COMMUNITY_NAME, }, - mockNotifications, - ); + mockNotifications + ) - await service.deleteCrossPosts(TEST_TOPIC_URI, TEST_DID); + await service.deleteCrossPosts(TEST_TOPIC_URI, TEST_DID) - expect(mockPds.deleteRecord).not.toHaveBeenCalled(); + expect(mockPds.deleteRecord).not.toHaveBeenCalled() // DB delete still called (no-op if no rows match) - expect(mockDb.delete).toHaveBeenCalledOnce(); - }); - }); -}); + expect(mockDb.delete).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/tests/unit/services/embedding.test.ts b/tests/unit/services/embedding.test.ts index bccd244..30b9e89 100644 --- a/tests/unit/services/embedding.test.ts +++ b/tests/unit/services/embedding.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createEmbeddingService } from "../../../src/services/embedding.js"; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createEmbeddingService } from '../../../src/services/embedding.js' // --------------------------------------------------------------------------- // Mock logger @@ -13,221 +13,185 @@ const mockLogger = { fatal: vi.fn(), trace: vi.fn(), child: vi.fn().mockReturnThis(), - level: "info", + level: 'info', silent: vi.fn(), -}; +} // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describe("embedding service", () => { +describe('embedding service', () => { beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) // ========================================================================= // Disabled mode (no URL) // ========================================================================= - describe("when disabled (no URL)", () => { - it("isEnabled returns false when URL is undefined", () => { - const service = createEmbeddingService( - undefined, - 768, - mockLogger as never, - ); - expect(service.isEnabled()).toBe(false); - }); - - it("isEnabled returns false when URL is empty string", () => { - const service = createEmbeddingService("", 768, mockLogger as never); - expect(service.isEnabled()).toBe(false); - }); - - it("generateEmbedding returns null when disabled", async () => { - const service = createEmbeddingService( - undefined, - 768, - mockLogger as never, - ); - const result = await service.generateEmbedding("test query"); - expect(result).toBeNull(); - }); - }); + describe('when disabled (no URL)', () => { + it('isEnabled returns false when URL is undefined', () => { + const service = createEmbeddingService(undefined, 768, mockLogger as never) + expect(service.isEnabled()).toBe(false) + }) + + it('isEnabled returns false when URL is empty string', () => { + const service = createEmbeddingService('', 768, mockLogger as never) + expect(service.isEnabled()).toBe(false) + }) + + it('generateEmbedding returns null when disabled', async () => { + const service = createEmbeddingService(undefined, 768, mockLogger as never) + const result = await service.generateEmbedding('test query') + expect(result).toBeNull() + }) + }) // ========================================================================= // Enabled mode // ========================================================================= - describe("when enabled", () => { - const TEST_URL = "http://localhost:11434/api/embeddings"; - const TEST_DIMENSIONS = 768; - const mockFetch = vi.fn(); + describe('when enabled', () => { + const TEST_URL = 'http://localhost:11434/api/embeddings' + const TEST_DIMENSIONS = 768 + const mockFetch = vi.fn() beforeEach(() => { - vi.stubGlobal("fetch", mockFetch); - }); + vi.stubGlobal('fetch', mockFetch) + }) afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("isEnabled returns true when URL is provided", () => { - const service = createEmbeddingService( - TEST_URL, - TEST_DIMENSIONS, - mockLogger as never, - ); - expect(service.isEnabled()).toBe(true); - }); - - it("generateEmbedding returns embedding array on success", async () => { - const expectedEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5]; + vi.unstubAllGlobals() + }) + + it('isEnabled returns true when URL is provided', () => { + const service = createEmbeddingService(TEST_URL, TEST_DIMENSIONS, mockLogger as never) + expect(service.isEnabled()).toBe(true) + }) + + it('generateEmbedding returns embedding array on success', async () => { + const expectedEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5] mockFetch.mockResolvedValue({ ok: true, - json: () => Promise.resolve({ - data: [{ embedding: expectedEmbedding }], - }), - }); - - const service = createEmbeddingService( - TEST_URL, - TEST_DIMENSIONS, - mockLogger as never, - ); - const result = await service.generateEmbedding("test query"); - - expect(result).toEqual(expectedEmbedding); - }); - - it("calls the correct URL with correct payload", async () => { + json: () => + Promise.resolve({ + data: [{ embedding: expectedEmbedding }], + }), + }) + + const service = createEmbeddingService(TEST_URL, TEST_DIMENSIONS, mockLogger as never) + const result = await service.generateEmbedding('test query') + + expect(result).toEqual(expectedEmbedding) + }) + + it('calls the correct URL with correct payload', async () => { mockFetch.mockResolvedValue({ ok: true, - json: () => Promise.resolve({ - data: [{ embedding: [0.1, 0.2, 0.3] }], - }), - }); - - const service = createEmbeddingService( - TEST_URL, - TEST_DIMENSIONS, - mockLogger as never, - ); - await service.generateEmbedding("hello world"); - - expect(mockFetch).toHaveBeenCalledOnce(); - expect(mockFetch.mock.calls[0]?.[0]).toBe(TEST_URL); - - const fetchOptions = mockFetch.mock.calls[0]?.[1] as RequestInit; - expect(fetchOptions.method).toBe("POST"); + json: () => + Promise.resolve({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + }), + }) + + const service = createEmbeddingService(TEST_URL, TEST_DIMENSIONS, mockLogger as never) + await service.generateEmbedding('hello world') + + expect(mockFetch).toHaveBeenCalledOnce() + expect(mockFetch.mock.calls[0]?.[0]).toBe(TEST_URL) + + const fetchOptions = mockFetch.mock.calls[0]?.[1] as RequestInit + expect(fetchOptions.method).toBe('POST') expect(fetchOptions.headers).toEqual({ - "Content-Type": "application/json", - }); + 'Content-Type': 'application/json', + }) const body = JSON.parse(fetchOptions.body as string) as { - input: string; - model: string; - dimensions: number; - }; - expect(body.input).toBe("hello world"); - expect(body.model).toBe("default"); - expect(body.dimensions).toBe(TEST_DIMENSIONS); - }); - - it("returns null on API error (non-OK status)", async () => { + input: string + model: string + dimensions: number + } + expect(body.input).toBe('hello world') + expect(body.model).toBe('default') + expect(body.dimensions).toBe(TEST_DIMENSIONS) + }) + + it('returns null on API error (non-OK status)', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500, - }); + }) - const service = createEmbeddingService( - TEST_URL, - TEST_DIMENSIONS, - mockLogger as never, - ); - const result = await service.generateEmbedding("test query"); + const service = createEmbeddingService(TEST_URL, TEST_DIMENSIONS, mockLogger as never) + const result = await service.generateEmbedding('test query') - expect(result).toBeNull(); + expect(result).toBeNull() expect(mockLogger.warn).toHaveBeenCalledWith( { status: 500, url: TEST_URL }, - "Embedding API returned non-OK status", - ); - }); + 'Embedding API returned non-OK status' + ) + }) - it("returns null on network error", async () => { - mockFetch.mockRejectedValue(new Error("fetch failed")); + it('returns null on network error', async () => { + mockFetch.mockRejectedValue(new Error('fetch failed')) - const service = createEmbeddingService( - TEST_URL, - TEST_DIMENSIONS, - mockLogger as never, - ); - const result = await service.generateEmbedding("test query"); + const service = createEmbeddingService(TEST_URL, TEST_DIMENSIONS, mockLogger as never) + const result = await service.generateEmbedding('test query') - expect(result).toBeNull(); + expect(result).toBeNull() expect(mockLogger.warn).toHaveBeenCalledWith( { err: expect.any(Error) as Error }, - "Failed to generate embedding", - ); - }); + 'Failed to generate embedding' + ) + }) - it("returns null on invalid response format (missing data)", async () => { + it('returns null on invalid response format (missing data)', async () => { mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ data: [] }), - }); + }) - const service = createEmbeddingService( - TEST_URL, - TEST_DIMENSIONS, - mockLogger as never, - ); - const result = await service.generateEmbedding("test query"); + const service = createEmbeddingService(TEST_URL, TEST_DIMENSIONS, mockLogger as never) + const result = await service.generateEmbedding('test query') - expect(result).toBeNull(); + expect(result).toBeNull() expect(mockLogger.warn).toHaveBeenCalledWith( - "Embedding API returned empty or invalid embedding", - ); - }); + 'Embedding API returned empty or invalid embedding' + ) + }) - it("returns null on invalid response format (empty embedding)", async () => { + it('returns null on invalid response format (empty embedding)', async () => { mockFetch.mockResolvedValue({ ok: true, - json: () => Promise.resolve({ - data: [{ embedding: [] }], - }), - }); - - const service = createEmbeddingService( - TEST_URL, - TEST_DIMENSIONS, - mockLogger as never, - ); - const result = await service.generateEmbedding("test query"); - - expect(result).toBeNull(); + json: () => + Promise.resolve({ + data: [{ embedding: [] }], + }), + }) + + const service = createEmbeddingService(TEST_URL, TEST_DIMENSIONS, mockLogger as never) + const result = await service.generateEmbedding('test query') + + expect(result).toBeNull() expect(mockLogger.warn).toHaveBeenCalledWith( - "Embedding API returned empty or invalid embedding", - ); - }); + 'Embedding API returned empty or invalid embedding' + ) + }) - it("returns null on invalid response format (non-array embedding)", async () => { + it('returns null on invalid response format (non-array embedding)', async () => { mockFetch.mockResolvedValue({ ok: true, - json: () => Promise.resolve({ - data: [{ embedding: "not-an-array" }], - }), - }); - - const service = createEmbeddingService( - TEST_URL, - TEST_DIMENSIONS, - mockLogger as never, - ); - const result = await service.generateEmbedding("test query"); - - expect(result).toBeNull(); - }); - }); -}); + json: () => + Promise.resolve({ + data: [{ embedding: 'not-an-array' }], + }), + }) + + const service = createEmbeddingService(TEST_URL, TEST_DIMENSIONS, mockLogger as never) + const result = await service.generateEmbedding('test query') + + expect(result).toBeNull() + }) + }) +}) diff --git a/tests/unit/services/interaction-graph.test.ts b/tests/unit/services/interaction-graph.test.ts new file mode 100644 index 0000000..da61ea3 --- /dev/null +++ b/tests/unit/services/interaction-graph.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createInteractionGraphService } from '../../../src/services/interaction-graph.js' +import type { InteractionGraphService } from '../../../src/services/interaction-graph.js' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockLogger() { + return { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn().mockReturnThis(), + } +} + +function createMockDb() { + return { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + execute: vi.fn(), + transaction: vi.fn(), + } +} + +function makeChain(result: unknown = []) { + const thenFn = (resolve: (val: unknown) => void, reject?: (err: unknown) => void) => + Promise.resolve(result).then(resolve, reject) + + const terminal = vi.fn().mockImplementation(() => ({ then: thenFn })) + + const chain: Record> = {} + chain.from = vi.fn().mockReturnValue(chain) + chain.where = terminal + chain.values = vi.fn().mockReturnValue(chain) + chain.onConflictDoUpdate = terminal + chain.onConflictDoNothing = terminal + chain.set = vi.fn().mockReturnValue(chain) + chain.orderBy = terminal + chain.limit = terminal + chain.returning = terminal + chain.leftJoin = vi.fn().mockReturnValue(chain) + + return chain +} + +describe('InteractionGraphService', () => { + let service: InteractionGraphService + let mockDb: ReturnType + let logger: ReturnType + + beforeEach(() => { + mockDb = createMockDb() + logger = createMockLogger() + service = createInteractionGraphService(mockDb as never, logger as never) + }) + + describe('recordReply', () => { + it('should upsert an interaction of type reply', async () => { + const chain = makeChain() + mockDb.insert.mockReturnValue(chain) + + await service.recordReply('did:replier', 'did:author', 'community1') + + expect(mockDb.insert).toHaveBeenCalled() + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + sourceDid: 'did:replier', + targetDid: 'did:author', + communityId: 'community1', + interactionType: 'reply', + }) + ) + expect(chain.onConflictDoUpdate).toHaveBeenCalled() + }) + + it('should skip self-interaction', async () => { + await service.recordReply('did:same', 'did:same', 'community1') + + expect(mockDb.insert).not.toHaveBeenCalled() + }) + }) + + describe('recordReaction', () => { + it('should upsert an interaction of type reaction', async () => { + const chain = makeChain() + mockDb.insert.mockReturnValue(chain) + + await service.recordReaction('did:reactor', 'did:author', 'community1') + + expect(mockDb.insert).toHaveBeenCalled() + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + sourceDid: 'did:reactor', + targetDid: 'did:author', + communityId: 'community1', + interactionType: 'reaction', + }) + ) + }) + + it('should skip self-interaction', async () => { + await service.recordReaction('did:same', 'did:same', 'community1') + + expect(mockDb.insert).not.toHaveBeenCalled() + }) + }) + + describe('recordCoParticipation', () => { + it('should create pairwise interactions for topic participants', async () => { + const replyAuthors = [{ authorDid: 'did:a' }, { authorDid: 'did:b' }, { authorDid: 'did:c' }] + + const selectChain = makeChain(replyAuthors) + const insertChain = makeChain() + + mockDb.select.mockReturnValue(selectChain) + mockDb.insert.mockReturnValue(insertChain) + + await service.recordCoParticipation( + 'at://did:plc:xxx/forum.barazo.topic.post/abc', + 'community1' + ) + + // 3 authors -> 3 pairs (a-b, a-c, b-c) + expect(mockDb.insert).toHaveBeenCalledTimes(3) + }) + + it('should skip if more than 50 unique authors', async () => { + const manyAuthors = Array.from({ length: 51 }, (_, i) => ({ + authorDid: `did:author${String(i)}`, + })) + + const selectChain = makeChain(manyAuthors) + mockDb.select.mockReturnValue(selectChain) + + await service.recordCoParticipation( + 'at://did:plc:xxx/forum.barazo.topic.post/abc', + 'community1' + ) + + expect(mockDb.insert).not.toHaveBeenCalled() + }) + + it('should skip if only one author', async () => { + const singleAuthor = [{ authorDid: 'did:a' }] + const selectChain = makeChain(singleAuthor) + mockDb.select.mockReturnValue(selectChain) + + await service.recordCoParticipation( + 'at://did:plc:xxx/forum.barazo.topic.post/abc', + 'community1' + ) + + expect(mockDb.insert).not.toHaveBeenCalled() + }) + }) +}) diff --git a/tests/unit/services/notification.test.ts b/tests/unit/services/notification.test.ts index 1303b09..24992a9 100644 --- a/tests/unit/services/notification.test.ts +++ b/tests/unit/services/notification.test.ts @@ -1,22 +1,22 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createNotificationService, extractMentions } from "../../../src/services/notification.js"; -import type { NotificationService } from "../../../src/services/notification.js"; -import { createMockDb, createChainableProxy, resetDbMocks } from "../../helpers/mock-db.js"; -import type { MockDb } from "../../helpers/mock-db.js"; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createNotificationService, extractMentions } from '../../../src/services/notification.js' +import type { NotificationService } from '../../../src/services/notification.js' +import { createMockDb, createChainableProxy, resetDbMocks } from '../../helpers/mock-db.js' +import type { MockDb } from '../../helpers/mock-db.js' // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- -const ACTOR_DID = "did:plc:actor123"; -const TOPIC_AUTHOR_DID = "did:plc:topicauthor456"; -const REPLY_AUTHOR_DID = "did:plc:replyauthor789"; -const MODERATOR_DID = "did:plc:mod999"; -const COMMUNITY_DID = "did:plc:community123"; +const ACTOR_DID = 'did:plc:actor123' +const TOPIC_AUTHOR_DID = 'did:plc:topicauthor456' +const REPLY_AUTHOR_DID = 'did:plc:replyauthor789' +const MODERATOR_DID = 'did:plc:mod999' +const COMMUNITY_DID = 'did:plc:community123' -const TOPIC_URI = `at://${TOPIC_AUTHOR_DID}/forum.barazo.topic.post/topic1`; -const REPLY_URI = `at://${ACTOR_DID}/forum.barazo.topic.reply/reply1`; -const PARENT_REPLY_URI = `at://${REPLY_AUTHOR_DID}/forum.barazo.topic.reply/parentreply1`; +const TOPIC_URI = `at://${TOPIC_AUTHOR_DID}/forum.barazo.topic.post/topic1` +const REPLY_URI = `at://${ACTOR_DID}/forum.barazo.topic.reply/reply1` +const PARENT_REPLY_URI = `at://${REPLY_AUTHOR_DID}/forum.barazo.topic.reply/parentreply1` // --------------------------------------------------------------------------- // Mock logger @@ -30,85 +30,85 @@ const mockLogger = { fatal: vi.fn(), trace: vi.fn(), child: vi.fn(() => mockLogger), - level: "info", + level: 'info', silent: vi.fn(), -}; +} // --------------------------------------------------------------------------- // Setup // --------------------------------------------------------------------------- -let mockDb: MockDb; -let service: NotificationService; +let mockDb: MockDb +let service: NotificationService beforeEach(() => { - vi.clearAllMocks(); - mockDb = createMockDb(); - resetDbMocks(mockDb); - service = createNotificationService(mockDb as never, mockLogger as never); -}); + vi.clearAllMocks() + mockDb = createMockDb() + resetDbMocks(mockDb) + service = createNotificationService(mockDb as never, mockLogger as never) +}) // =========================================================================== // extractMentions // =========================================================================== -describe("extractMentions", () => { - it("extracts single AT Protocol handle", () => { - const result = extractMentions("Hello @alice.bsky.social, welcome!"); - expect(result).toEqual(["alice.bsky.social"]); - }); - - it("extracts multiple handles", () => { - const result = extractMentions("cc @alice.bsky.social @bob.example.com"); - expect(result).toEqual(["alice.bsky.social", "bob.example.com"]); - }); - - it("deduplicates handles (case-insensitive)", () => { - const result = extractMentions("@Alice.Bsky.Social and @alice.bsky.social"); - expect(result).toEqual(["alice.bsky.social"]); - }); - - it("ignores bare @word without a dot", () => { - const result = extractMentions("Hello @everyone, this is a test"); - expect(result).toEqual([]); - }); - - it("limits to 10 unique mentions", () => { - const handles = Array.from({ length: 15 }, (_, i) => `@user${String(i)}.bsky.social`); - const content = handles.join(" "); - const result = extractMentions(content); - expect(result).toHaveLength(10); - }); - - it("returns empty array for content without mentions", () => { - const result = extractMentions("No mentions here at all."); - expect(result).toEqual([]); - }); - - it("handles handles with hyphens", () => { - const result = extractMentions("Hey @my-handle.bsky.social"); - expect(result).toEqual(["my-handle.bsky.social"]); - }); - - it("handles handles with subdomains", () => { - const result = extractMentions("@user.example.co.uk mentioned"); - expect(result).toEqual(["user.example.co.uk"]); - }); -}); +describe('extractMentions', () => { + it('extracts single AT Protocol handle', () => { + const result = extractMentions('Hello @alice.bsky.social, welcome!') + expect(result).toEqual(['alice.bsky.social']) + }) + + it('extracts multiple handles', () => { + const result = extractMentions('cc @alice.bsky.social @bob.example.com') + expect(result).toEqual(['alice.bsky.social', 'bob.example.com']) + }) + + it('deduplicates handles (case-insensitive)', () => { + const result = extractMentions('@Alice.Bsky.Social and @alice.bsky.social') + expect(result).toEqual(['alice.bsky.social']) + }) + + it('ignores bare @word without a dot', () => { + const result = extractMentions('Hello @everyone, this is a test') + expect(result).toEqual([]) + }) + + it('limits to 10 unique mentions', () => { + const handles = Array.from({ length: 15 }, (_, i) => `@user${String(i)}.bsky.social`) + const content = handles.join(' ') + const result = extractMentions(content) + expect(result).toHaveLength(10) + }) + + it('returns empty array for content without mentions', () => { + const result = extractMentions('No mentions here at all.') + expect(result).toEqual([]) + }) + + it('handles handles with hyphens', () => { + const result = extractMentions('Hey @my-handle.bsky.social') + expect(result).toEqual(['my-handle.bsky.social']) + }) + + it('handles handles with subdomains', () => { + const result = extractMentions('@user.example.co.uk mentioned') + expect(result).toEqual(['user.example.co.uk']) + }) +}) // =========================================================================== // notifyOnReply // =========================================================================== -describe("notifyOnReply", () => { - it("notifies topic author when someone replies", async () => { +describe('notifyOnReply', () => { + it('notifies topic author when someone replies', async () => { // Mock: select topic author - const selectChain = createChainableProxy([{ authorDid: TOPIC_AUTHOR_DID }]); - mockDb.select.mockReturnValue(selectChain); + const selectChain = createChainableProxy([{ authorDid: TOPIC_AUTHOR_DID }]) + mockDb.select.mockReturnValue(selectChain) // Mock: insert notification - const insertChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); + const insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) await service.notifyOnReply({ replyUri: REPLY_URI, @@ -116,18 +116,18 @@ describe("notifyOnReply", () => { topicUri: TOPIC_URI, parentUri: TOPIC_URI, // direct reply to topic communityDid: COMMUNITY_DID, - }); + }) - expect(mockDb.insert).toHaveBeenCalled(); - }); + expect(mockDb.insert).toHaveBeenCalled() + }) - it("does not notify when replying to own topic", async () => { + it('does not notify when replying to own topic', async () => { // Actor IS the topic author - const selectChain = createChainableProxy([{ authorDid: ACTOR_DID }]); - mockDb.select.mockReturnValue(selectChain); + const selectChain = createChainableProxy([{ authorDid: ACTOR_DID }]) + mockDb.select.mockReturnValue(selectChain) - const insertChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); + const insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) await service.notifyOnReply({ replyUri: REPLY_URI, @@ -135,24 +135,22 @@ describe("notifyOnReply", () => { topicUri: `at://${ACTOR_DID}/forum.barazo.topic.post/topic1`, parentUri: `at://${ACTOR_DID}/forum.barazo.topic.post/topic1`, communityDid: COMMUNITY_DID, - }); + }) // insert should not be called for notifications (only select for topic lookup) - expect(mockDb.insert).not.toHaveBeenCalled(); - }); + expect(mockDb.insert).not.toHaveBeenCalled() + }) - it("notifies both topic author and parent reply author for nested replies", async () => { + it('notifies both topic author and parent reply author for nested replies', async () => { // First select: topic author - const topicSelectChain = createChainableProxy([{ authorDid: TOPIC_AUTHOR_DID }]); + const topicSelectChain = createChainableProxy([{ authorDid: TOPIC_AUTHOR_DID }]) // Second select: parent reply author - const parentSelectChain = createChainableProxy([{ authorDid: REPLY_AUTHOR_DID }]); + const parentSelectChain = createChainableProxy([{ authorDid: REPLY_AUTHOR_DID }]) - mockDb.select - .mockReturnValueOnce(topicSelectChain) - .mockReturnValueOnce(parentSelectChain); + mockDb.select.mockReturnValueOnce(topicSelectChain).mockReturnValueOnce(parentSelectChain) - const insertChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); + const insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) await service.notifyOnReply({ replyUri: REPLY_URI, @@ -160,23 +158,21 @@ describe("notifyOnReply", () => { topicUri: TOPIC_URI, parentUri: PARENT_REPLY_URI, // nested reply communityDid: COMMUNITY_DID, - }); + }) // Should insert two notifications: one for topic author, one for parent reply author - expect(mockDb.insert).toHaveBeenCalledTimes(2); - }); + expect(mockDb.insert).toHaveBeenCalledTimes(2) + }) - it("does not duplicate notification when parent reply author is topic author", async () => { + it('does not duplicate notification when parent reply author is topic author', async () => { // Same author for topic and parent reply - const topicSelectChain = createChainableProxy([{ authorDid: TOPIC_AUTHOR_DID }]); - const parentSelectChain = createChainableProxy([{ authorDid: TOPIC_AUTHOR_DID }]); + const topicSelectChain = createChainableProxy([{ authorDid: TOPIC_AUTHOR_DID }]) + const parentSelectChain = createChainableProxy([{ authorDid: TOPIC_AUTHOR_DID }]) - mockDb.select - .mockReturnValueOnce(topicSelectChain) - .mockReturnValueOnce(parentSelectChain); + mockDb.select.mockReturnValueOnce(topicSelectChain).mockReturnValueOnce(parentSelectChain) - const insertChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); + const insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) await service.notifyOnReply({ replyUri: REPLY_URI, @@ -184,16 +180,14 @@ describe("notifyOnReply", () => { topicUri: TOPIC_URI, parentUri: PARENT_REPLY_URI, communityDid: COMMUNITY_DID, - }); + }) // Only one notification (topic author = parent reply author) - expect(mockDb.insert).toHaveBeenCalledTimes(1); - }); + expect(mockDb.insert).toHaveBeenCalledTimes(1) + }) - it("logs error and does not throw on DB failure", async () => { - mockDb.select.mockReturnValue( - createChainableProxy(Promise.reject(new Error("DB error"))), - ); + it('logs error and does not throw on DB failure', async () => { + mockDb.select.mockReturnValue(createChainableProxy(Promise.reject(new Error('DB error')))) await expect( service.notifyOnReply({ @@ -202,234 +196,230 @@ describe("notifyOnReply", () => { topicUri: TOPIC_URI, parentUri: TOPIC_URI, communityDid: COMMUNITY_DID, - }), - ).resolves.toBeUndefined(); + }) + ).resolves.toBeUndefined() - expect(mockLogger.error).toHaveBeenCalled(); - }); -}); + expect(mockLogger.error).toHaveBeenCalled() + }) +}) // =========================================================================== // notifyOnReaction // =========================================================================== -describe("notifyOnReaction", () => { - it("notifies topic author when their topic gets a reaction", async () => { - const selectChain = createChainableProxy([{ authorDid: TOPIC_AUTHOR_DID }]); - mockDb.select.mockReturnValue(selectChain); +describe('notifyOnReaction', () => { + it('notifies topic author when their topic gets a reaction', async () => { + const selectChain = createChainableProxy([{ authorDid: TOPIC_AUTHOR_DID }]) + mockDb.select.mockReturnValue(selectChain) - const insertChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); + const insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) await service.notifyOnReaction({ subjectUri: TOPIC_URI, actorDid: ACTOR_DID, communityDid: COMMUNITY_DID, - }); + }) - expect(mockDb.insert).toHaveBeenCalled(); - }); + expect(mockDb.insert).toHaveBeenCalled() + }) - it("notifies reply author when their reply gets a reaction", async () => { + it('notifies reply author when their reply gets a reaction', async () => { // First select (topic lookup): no match - const noMatchChain = createChainableProxy([]); + const noMatchChain = createChainableProxy([]) // Second select (reply lookup): match - const replyChain = createChainableProxy([{ authorDid: REPLY_AUTHOR_DID }]); + const replyChain = createChainableProxy([{ authorDid: REPLY_AUTHOR_DID }]) - mockDb.select - .mockReturnValueOnce(noMatchChain) - .mockReturnValueOnce(replyChain); + mockDb.select.mockReturnValueOnce(noMatchChain).mockReturnValueOnce(replyChain) - const insertChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); + const insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) await service.notifyOnReaction({ subjectUri: PARENT_REPLY_URI, actorDid: ACTOR_DID, communityDid: COMMUNITY_DID, - }); + }) - expect(mockDb.insert).toHaveBeenCalled(); - }); + expect(mockDb.insert).toHaveBeenCalled() + }) - it("does not notify when reacting to own content", async () => { - const selectChain = createChainableProxy([{ authorDid: ACTOR_DID }]); - mockDb.select.mockReturnValue(selectChain); + it('does not notify when reacting to own content', async () => { + const selectChain = createChainableProxy([{ authorDid: ACTOR_DID }]) + mockDb.select.mockReturnValue(selectChain) - const insertChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); + const insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) await service.notifyOnReaction({ subjectUri: `at://${ACTOR_DID}/forum.barazo.topic.post/mytopic`, actorDid: ACTOR_DID, communityDid: COMMUNITY_DID, - }); + }) - expect(mockDb.insert).not.toHaveBeenCalled(); - }); -}); + expect(mockDb.insert).not.toHaveBeenCalled() + }) +}) // =========================================================================== // notifyOnModAction // =========================================================================== -describe("notifyOnModAction", () => { - it("notifies content author of moderation action", async () => { - const insertChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); +describe('notifyOnModAction', () => { + it('notifies content author of moderation action', async () => { + const insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) await service.notifyOnModAction({ targetUri: TOPIC_URI, moderatorDid: MODERATOR_DID, targetDid: TOPIC_AUTHOR_DID, communityDid: COMMUNITY_DID, - }); + }) - expect(mockDb.insert).toHaveBeenCalled(); - }); + expect(mockDb.insert).toHaveBeenCalled() + }) - it("does not notify when moderator acts on own content", async () => { - const insertChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); + it('does not notify when moderator acts on own content', async () => { + const insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) await service.notifyOnModAction({ targetUri: TOPIC_URI, moderatorDid: MODERATOR_DID, targetDid: MODERATOR_DID, // same person communityDid: COMMUNITY_DID, - }); + }) - expect(mockDb.insert).not.toHaveBeenCalled(); - }); -}); + expect(mockDb.insert).not.toHaveBeenCalled() + }) +}) // =========================================================================== // notifyOnMentions // =========================================================================== -describe("notifyOnMentions", () => { - it("resolves handles to DIDs and creates mention notifications", async () => { +describe('notifyOnMentions', () => { + it('resolves handles to DIDs and creates mention notifications', async () => { // Select: resolve handles const userSelectChain = createChainableProxy([ - { did: "did:plc:mentioned1", handle: "alice.bsky.social" }, - ]); - mockDb.select.mockReturnValue(userSelectChain); + { did: 'did:plc:mentioned1', handle: 'alice.bsky.social' }, + ]) + mockDb.select.mockReturnValue(userSelectChain) - const insertChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); + const insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) await service.notifyOnMentions({ - content: "Hey @alice.bsky.social check this out", + content: 'Hey @alice.bsky.social check this out', subjectUri: REPLY_URI, actorDid: ACTOR_DID, communityDid: COMMUNITY_DID, - }); + }) - expect(mockDb.insert).toHaveBeenCalled(); - }); + expect(mockDb.insert).toHaveBeenCalled() + }) - it("does not create notifications for unresolved handles", async () => { + it('does not create notifications for unresolved handles', async () => { // No users found for the handle - const emptySelectChain = createChainableProxy([]); - mockDb.select.mockReturnValue(emptySelectChain); + const emptySelectChain = createChainableProxy([]) + mockDb.select.mockReturnValue(emptySelectChain) await service.notifyOnMentions({ - content: "Hey @unknown.example.com", + content: 'Hey @unknown.example.com', subjectUri: REPLY_URI, actorDid: ACTOR_DID, communityDid: COMMUNITY_DID, - }); + }) - expect(mockDb.insert).not.toHaveBeenCalled(); - }); + expect(mockDb.insert).not.toHaveBeenCalled() + }) - it("does not create notification for self-mention", async () => { - const userSelectChain = createChainableProxy([ - { did: ACTOR_DID, handle: "me.bsky.social" }, - ]); - mockDb.select.mockReturnValue(userSelectChain); + it('does not create notification for self-mention', async () => { + const userSelectChain = createChainableProxy([{ did: ACTOR_DID, handle: 'me.bsky.social' }]) + mockDb.select.mockReturnValue(userSelectChain) - const insertChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); + const insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) await service.notifyOnMentions({ - content: "I am @me.bsky.social", + content: 'I am @me.bsky.social', subjectUri: REPLY_URI, actorDid: ACTOR_DID, communityDid: COMMUNITY_DID, - }); + }) - expect(mockDb.insert).not.toHaveBeenCalled(); - }); + expect(mockDb.insert).not.toHaveBeenCalled() + }) - it("skips when content has no mentions", async () => { + it('skips when content has no mentions', async () => { await service.notifyOnMentions({ - content: "No mentions here", + content: 'No mentions here', subjectUri: REPLY_URI, actorDid: ACTOR_DID, communityDid: COMMUNITY_DID, - }); + }) // Should not even query the DB - expect(mockDb.select).not.toHaveBeenCalled(); - expect(mockDb.insert).not.toHaveBeenCalled(); - }); -}); + expect(mockDb.select).not.toHaveBeenCalled() + expect(mockDb.insert).not.toHaveBeenCalled() + }) +}) // =========================================================================== // notifyOnCrossPostFailure // =========================================================================== -describe("notifyOnCrossPostFailure", () => { - it("creates a cross_post_failed notification for the topic author", async () => { - const insertChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); +describe('notifyOnCrossPostFailure', () => { + it('creates a cross_post_failed notification for the topic author', async () => { + const insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) await service.notifyOnCrossPostFailure({ topicUri: TOPIC_URI, authorDid: ACTOR_DID, - service: "bluesky", + service: 'bluesky', communityDid: COMMUNITY_DID, - }); + }) - expect(mockDb.insert).toHaveBeenCalled(); - }); + expect(mockDb.insert).toHaveBeenCalled() + }) - it("creates separate notifications for different failed services", async () => { - const insertChain = createChainableProxy(); - mockDb.insert.mockReturnValue(insertChain); + it('creates separate notifications for different failed services', async () => { + const insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) await service.notifyOnCrossPostFailure({ topicUri: TOPIC_URI, authorDid: ACTOR_DID, - service: "bluesky", + service: 'bluesky', communityDid: COMMUNITY_DID, - }); + }) await service.notifyOnCrossPostFailure({ topicUri: TOPIC_URI, authorDid: ACTOR_DID, - service: "frontpage", + service: 'frontpage', communityDid: COMMUNITY_DID, - }); + }) - expect(mockDb.insert).toHaveBeenCalledTimes(2); - }); + expect(mockDb.insert).toHaveBeenCalledTimes(2) + }) - it("logs error and does not throw on DB failure", async () => { - const insertChain = createChainableProxy(); - insertChain.values.mockRejectedValue(new Error("DB error")); - mockDb.insert.mockReturnValue(insertChain); + it('logs error and does not throw on DB failure', async () => { + const insertChain = createChainableProxy() + insertChain.values.mockRejectedValue(new Error('DB error')) + mockDb.insert.mockReturnValue(insertChain) await expect( service.notifyOnCrossPostFailure({ topicUri: TOPIC_URI, authorDid: ACTOR_DID, - service: "bluesky", + service: 'bluesky', communityDid: COMMUNITY_DID, - }), - ).resolves.toBeUndefined(); + }) + ).resolves.toBeUndefined() - expect(mockLogger.error).toHaveBeenCalled(); - }); -}); + expect(mockLogger.error).toHaveBeenCalled() + }) +}) diff --git a/tests/unit/services/og-image.test.ts b/tests/unit/services/og-image.test.ts index 507cd5a..16b673b 100644 --- a/tests/unit/services/og-image.test.ts +++ b/tests/unit/services/og-image.test.ts @@ -1,205 +1,205 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from 'vitest' import { generateOgImage, generateOgSvg, wrapText, escapeXml, -} from "../../../src/services/og-image.js"; +} from '../../../src/services/og-image.js' // =========================================================================== // escapeXml // =========================================================================== -describe("escapeXml", () => { - it("escapes ampersands", () => { - expect(escapeXml("A & B")).toBe("A & B"); - }); +describe('escapeXml', () => { + it('escapes ampersands', () => { + expect(escapeXml('A & B')).toBe('A & B') + }) - it("escapes angle brackets", () => { + it('escapes angle brackets', () => { expect(escapeXml("")).toBe( - "<script>alert('xss')</script>", - ); - }); + '<script>alert('xss')</script>' + ) + }) - it("escapes quotes", () => { - expect(escapeXml('He said "hello"')).toBe("He said "hello""); - }); + it('escapes quotes', () => { + expect(escapeXml('He said "hello"')).toBe('He said "hello"') + }) - it("returns empty string unchanged", () => { - expect(escapeXml("")).toBe(""); - }); + it('returns empty string unchanged', () => { + expect(escapeXml('')).toBe('') + }) - it("returns plain text unchanged", () => { - expect(escapeXml("Hello World")).toBe("Hello World"); - }); -}); + it('returns plain text unchanged', () => { + expect(escapeXml('Hello World')).toBe('Hello World') + }) +}) // =========================================================================== // wrapText // =========================================================================== -describe("wrapText", () => { - it("returns single line when text fits", () => { - const result = wrapText("Short title", 40, 3); - expect(result).toEqual(["Short title"]); - }); +describe('wrapText', () => { + it('returns single line when text fits', () => { + const result = wrapText('Short title', 40, 3) + expect(result).toEqual(['Short title']) + }) - it("wraps text at word boundaries", () => { - const result = wrapText("This is a longer title that should wrap", 20, 3); - expect(result.length).toBeGreaterThan(1); + it('wraps text at word boundaries', () => { + const result = wrapText('This is a longer title that should wrap', 20, 3) + expect(result.length).toBeGreaterThan(1) // Each line should be at most 20 characters (approximately) for (const line of result) { // Allow some overflow for long words - expect(line.length).toBeLessThanOrEqual(25); + expect(line.length).toBeLessThanOrEqual(25) } - }); - - it("limits to maxLines", () => { - const longText = "word ".repeat(50).trim(); - const result = wrapText(longText, 20, 3); - expect(result.length).toBeLessThanOrEqual(3); - }); - - it("adds ellipsis when text is truncated", () => { - const longText = "word ".repeat(50).trim(); - const result = wrapText(longText, 20, 2); - const lastLine = result[result.length - 1]; - expect(lastLine).toContain("\u2026"); - }); - - it("handles empty string", () => { - const result = wrapText("", 40, 3); - expect(result).toEqual([]); - }); - - it("handles single long word", () => { - const result = wrapText("Supercalifragilisticexpialidocious", 10, 3); - expect(result.length).toBeGreaterThanOrEqual(1); - }); -}); + }) + + it('limits to maxLines', () => { + const longText = 'word '.repeat(50).trim() + const result = wrapText(longText, 20, 3) + expect(result.length).toBeLessThanOrEqual(3) + }) + + it('adds ellipsis when text is truncated', () => { + const longText = 'word '.repeat(50).trim() + const result = wrapText(longText, 20, 2) + const lastLine = result[result.length - 1] + expect(lastLine).toContain('\u2026') + }) + + it('handles empty string', () => { + const result = wrapText('', 40, 3) + expect(result).toEqual([]) + }) + + it('handles single long word', () => { + const result = wrapText('Supercalifragilisticexpialidocious', 10, 3) + expect(result.length).toBeGreaterThanOrEqual(1) + }) +}) // =========================================================================== // generateOgSvg // =========================================================================== -describe("generateOgSvg", () => { - it("returns valid SVG string with correct dimensions", () => { +describe('generateOgSvg', () => { + it('returns valid SVG string with correct dimensions', () => { const svg = generateOgSvg({ - title: "My Topic", - category: "general", - communityName: "Test Community", - }); + title: 'My Topic', + category: 'general', + communityName: 'Test Community', + }) - expect(svg).toContain('width="1200"'); - expect(svg).toContain('height="630"'); - expect(svg).toContain("xmlns="); - }); + expect(svg).toContain('width="1200"') + expect(svg).toContain('height="630"') + expect(svg).toContain('xmlns=') + }) - it("includes the topic title", () => { + it('includes the topic title', () => { const svg = generateOgSvg({ - title: "My Amazing Topic", - category: "general", - communityName: "Test Community", - }); + title: 'My Amazing Topic', + category: 'general', + communityName: 'Test Community', + }) - expect(svg).toContain("My Amazing Topic"); - }); + expect(svg).toContain('My Amazing Topic') + }) - it("includes the category name", () => { + it('includes the category name', () => { const svg = generateOgSvg({ - title: "Topic", - category: "announcements", - communityName: "Test Community", - }); + title: 'Topic', + category: 'announcements', + communityName: 'Test Community', + }) - expect(svg).toContain("ANNOUNCEMENTS"); - }); + expect(svg).toContain('ANNOUNCEMENTS') + }) - it("includes the community name", () => { + it('includes the community name', () => { const svg = generateOgSvg({ - title: "Topic", - category: "general", - communityName: "My Forum", - }); + title: 'Topic', + category: 'general', + communityName: 'My Forum', + }) - expect(svg).toContain("My Forum"); - }); + expect(svg).toContain('My Forum') + }) - it("includes Barazo branding", () => { + it('includes Barazo branding', () => { const svg = generateOgSvg({ - title: "Topic", - category: "general", - communityName: "Test Community", - }); + title: 'Topic', + category: 'general', + communityName: 'Test Community', + }) - expect(svg).toContain("barazo.forum"); - }); + expect(svg).toContain('barazo.forum') + }) - it("escapes special characters in title", () => { + it('escapes special characters in title', () => { const svg = generateOgSvg({ - title: "Using