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