diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c91929e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.github +.env +.env.* +!.env.example +node_modules +dist +coverage +uploads +*.log +npm-debug.log* +.agents +.codex +.vscode +.idea +postman +docs diff --git a/.env.example b/.env.example index a575a3c..834e107 100644 --- a/.env.example +++ b/.env.example @@ -6,16 +6,44 @@ HOST=0.0.0.0 PUBLIC_BASE_URL=http://localhost:4000 PUBLIC_APP_URL=https://oudelaa.com RESPONSE_ENVELOPE_ENABLED=false +SWAGGER_ENABLED=true GLOBAL_PREFIX=api/v1 # Add every frontend origin used by web/mobile debug tools. CORS_ORIGINS=http://localhost:3000,http://192.168.1.14:3000,http://192.168.1.14:5173 MONGODB_URI=mongodb://127.0.0.1:27017/oudelaa +MONGODB_AUTO_INDEX=true +MONGODB_MIN_POOL_SIZE=5 +MONGODB_MAX_POOL_SIZE=100 +MONGODB_MAX_IDLE_TIME_MS=60000 +MONGODB_SERVER_SELECTION_TIMEOUT_MS=10000 +MONGODB_SOCKET_TIMEOUT_MS=45000 +# Search keeps the same API contract. "auto" uses Atlas Search when available +# and falls back to MongoDB regex search on local/community deployments. +SEARCH_ENGINE=auto +SEARCH_ATLAS_USER_INDEX=users_search +SEARCH_ATLAS_POST_INDEX=posts_search +SEARCH_FALLBACK_ENABLED=true +SEARCH_ATLAS_RETRY_SECONDS=300 JWT_ACCESS_SECRET=change_me_access_secret JWT_ACCESS_EXPIRES_IN=15m JWT_REFRESH_SECRET=change_me_refresh_secret JWT_REFRESH_EXPIRES_IN=30d BCRYPT_SALT_ROUNDS=12 REFRESH_TOKEN_HASH_SECRET= +HTTP_BODY_LIMIT=1mb +# Controller execution deadline. Override individual long-running routes with @RequestTimeout(). +HTTP_REQUEST_TIMEOUT_MS=30000 +# Low-level slow-client protection and connection reuse limits. +HTTP_SERVER_REQUEST_TIMEOUT_MS=120000 +HTTP_HEADERS_TIMEOUT_MS=15000 +HTTP_KEEP_ALIVE_TIMEOUT_MS=5000 +HTTP_MAX_REQUESTS_PER_SOCKET=1000 +# Time allowed for in-flight controller requests after SIGTERM/SIGINT. +SHUTDOWN_GRACE_PERIOD_MS=30000 +METRICS_EVENT_LOOP_RESOLUTION_MS=20 +METRICS_EVENT_LOOP_LAG_WARN_MS=100 +METRICS_MAX_ROUTES=500 +MODERATION_BLOCKED_TERMS= PASSWORD_RESET_CODE_EXPIRES_MINUTES=10 PASSWORD_RESET_MAX_ATTEMPTS=5 PASSWORD_RESET_TOKEN_SECRET= @@ -31,6 +59,10 @@ REQUEST_LOGGING_ENABLED=true FEED_CACHE_ENABLED=true FEED_CACHE_USER_TTL_SECONDS=15 FEED_CACHE_TRENDING_TTL_SECONDS=30 +FEED_CACHE_RANKING_PROFILE_TTL_SECONDS=30 +FEED_TIMING_LOGS_ENABLED=false +HTTP_COMPRESSION_ENABLED=true +HTTP_COMPRESSION_THRESHOLD_BYTES=1024 REDIS_ENABLED=false REDIS_URL= @@ -50,6 +82,7 @@ QUEUE_REMOVE_ON_COMPLETE=true QUEUE_WORKER_CONCURRENCY=5 STORAGE_PROVIDER=local +MEDIA_ACCESS_MODE=direct STORAGE_BASE_PATH=uploads # In Docker/production with local storage, mount a persistent volume to /app/uploads # or to the runtime path resolved from STORAGE_BASE_PATH. @@ -113,7 +146,10 @@ AI_MUSIC_MODEL=lyria-002 SUPERADMIN_EMAIL=admin@oudelaa.com SUPERADMIN_PASSWORD=SuperAdminStrongPass123! +# Required in production. Generate with bcrypt and leave SUPERADMIN_PASSWORD empty there. +SUPERADMIN_PASSWORD_HASH= SUPERADMIN_ACCESS_SECRET=change_me_superadmin_access_secret SUPERADMIN_ACCESS_EXPIRES_IN=15m SUPERADMIN_REFRESH_SECRET=change_me_superadmin_refresh_secret SUPERADMIN_REFRESH_EXPIRES_IN=30d +SUPERADMIN_TOTP_SECRET= diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c429525..fb250d4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,13 +1,81 @@ name: Deploy To Ghaymah +permissions: + contents: read + +concurrency: + group: oudelaa-production-${{ github.ref }} + cancel-in-progress: true + on: + pull_request: + branches: + - main push: branches: - main jobs: - deploy: + quality: runs-on: ubuntu-latest + services: + mongodb: + image: mongo:7 + ports: + - 27017:27017 + options: >- + --health-cmd "mongosh --quiet --eval 'db.runCommand({ ping: 1 })'" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install locked dependencies + run: npm ci + + - name: Static quality gates + run: | + npm run lint + npm run build + + - name: Unit, integration, and coverage gates + run: npm run test:coverage + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ + if-no-files-found: error + retention-days: 14 + + - name: Performance tooling tests + run: npm run test:perf + + - name: End-to-end tests + run: npm run test:e2e + env: + E2E_MONGODB_URI: mongodb://127.0.0.1:27017/oudelaa-e2e + + - name: Dependency security audit + run: npm audit --audit-level=high + + - name: Verify production image + run: docker build --tag oudelaa-api:${{ github.sha }} . + + deploy: + needs: quality + runs-on: ubuntu-latest + environment: production steps: - name: Checkout code @@ -47,4 +115,4 @@ jobs: --no-auto-update - name: Deploy - run: $HOME/ghaymah/bin/gy resource app launch --no-auto-update \ No newline at end of file + run: $HOME/ghaymah/bin/gy resource app launch --no-auto-update diff --git a/.gitignore b/.gitignore index 8258388..dede9e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Build node_modules dist +coverage uploads stream_*/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/Dockerfile b/Dockerfile index 1d0ff5c..de71dd2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,16 +14,26 @@ WORKDIR /app ENV NODE_ENV=production ENV VIDEO_PROCESSING_FFMPEG_PATH=/usr/bin/ffmpeg +ENV NODE_OPTIONS=--enable-source-maps RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.edge.kernel.org/g' /etc/apk/repositories \ && apk update \ - && apk add --no-cache ffmpeg + && apk add --no-cache ffmpeg tini COPY package*.json ./ -RUN npm ci --omit=dev +RUN npm ci --omit=dev \ + && npm cache clean --force -COPY --from=builder /app/dist ./dist +COPY --from=builder --chown=node:node /app/dist ./dist +RUN mkdir -p /app/uploads \ + && chown -R node:node /app/uploads + +USER node EXPOSE 4000 -CMD ["node", "dist/main.js"] \ No newline at end of file +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:4000/api/v1/health || exit 1 + +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["node", "dist/main.js"] diff --git a/PERFORMANCE_TESTING.md b/PERFORMANCE_TESTING.md index 9ee9dca..8499ba1 100644 --- a/PERFORMANCE_TESTING.md +++ b/PERFORMANCE_TESTING.md @@ -55,10 +55,36 @@ Supported options: - `--concurrency` - `--timeout` - `--warmup` +- `--min-success-rate` (percentage from `0` to `100`) +- `--min-rps` (minimum requests per second) +- `--max-p95` (maximum p95 latency in milliseconds) +- `--max-p99` (maximum p99 latency in milliseconds) - `--header "Key: Value"` - `--body` - `--body-file` +### Automated performance gates + +Add one or more gates to turn a benchmark into a CI regression check: + +```powershell +node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/health --duration 15 --concurrency 20 --min-success-rate 99.9 --min-rps 1000 --max-p95 100 --max-p99 150 +``` + +Or run the provided health baseline: + +```powershell +npm run perf:health:gate +``` + +All configured gates must pass. The JSON summary contains the individual gate results, followed by a readable `PASS`/`FAIL` report. A performance regression exits with code `2`; invalid arguments or runtime errors exit with code `1`. Calibrate thresholds on production-like staging hardware rather than copying local numbers into deployment policy. + +The gate parser, evaluator, and process exit behavior have a standalone test suite: + +```powershell +npm run test:perf +``` + ## 5. What to watch - `requestsPerSecond`: throughput diff --git a/README.md b/README.md index c6d5b9f..5d567ff 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,18 @@ # Oudelaa Backend +Advanced search deployment and compatibility notes are documented in +[`docs/SEARCH_ARCHITECTURE.md`](docs/SEARCH_ARCHITECTURE.md). + +Home feed candidate generation, personalization, safety, diversity, and cursor behavior are +documented in [`docs/FEED_RANKING.md`](docs/FEED_RANKING.md). + Production-oriented NestJS backend for a social media platform. Frontend handoff notes and WebSocket event docs live in [docs/FRONTEND_INTEGRATION.md](docs/FRONTEND_INTEGRATION.md). + +Capacity baselines and production limitations are tracked in +[`docs/SYSTEM_STRENGTH_REPORT.md`](docs/SYSTEM_STRENGTH_REPORT.md), with repeatable CI performance +gates documented in [`PERFORMANCE_TESTING.md`](PERFORMANCE_TESTING.md). + +For S3 deployments, authenticated clients can request a short-lived, user-scoped direct upload +with `POST /api/v1/media/uploads/presign`. Existing multipart endpoints remain supported. diff --git a/docs/FEED_RANKING.md b/docs/FEED_RANKING.md new file mode 100644 index 0000000..8312e3a --- /dev/null +++ b/docs/FEED_RANKING.md @@ -0,0 +1,71 @@ +# Home feed ranking + +The home feed contract remains `GET /api/v1/feed/me`; no query parameter or response field was removed or renamed. + +## Candidate generation + +Each request merges two bounded candidate pools: + +- Recent eligible posts (65%). +- High-quality eligible posts ranked by saves, shares, comments, and likes (35%). + +The merged pool is deduplicated and capped between 400 and 1,000 candidates depending on the requested page size. The default feed remains focused on followed accounts and falls back to public discovery only when the default following feed is empty. + +## Hard safety filters + +The ranking layer cannot override these rules: + +- Deleted and archived posts are excluded. +- Posts with `moderationStatus=hidden` are excluded. +- Blocked or blocking authors are excluded. +- Disabled and missing authors are excluded after population. +- Posts reported by the current viewer are excluded unless the report was rejected. +- Public, followers-only, and own-post visibility rules remain enforced. + +## Personalized signals + +A short-lived ranking profile is computed from the viewer's last 90 days of: + +- Likes (weight 1.5). +- Comments (weight 3). +- Saves (weight 4). +- Shares (weight 5). + +Those events produce capped affinities for authors, post types, and hashtags. Existing profile preferences, follows, location, and requested post type remain signals. The profile is cached for 30 seconds by default. + +## Scoring + +Raw counters use `log1p` normalization so viral totals cannot grow without bound and erase personalization. Ranking combines: + +- Log-normalized engagement quality. +- Exponential freshness decay with a small boost for posts younger than six hours. +- Follow, author affinity, post-type affinity, and hashtag affinity. +- Profile interests and a bounded geographic boost. +- Small verification and follower-count priors. +- A penalty for posts the viewer already interacted with, reducing repetition without hiding them. + +## Diversity + +After relevance scoring, a deterministic greedy reranker applies penalties for: + +- Consecutive posts from the same author. +- Repeated appearances by the same author. +- Overrepresented post types. +- Repeated hashtags. + +This preserves strong content while avoiding a page dominated by one creator or one format. + +## Cursor stability + +New cursors contain an opaque version, offset, and ranking timestamp. Every page in the cursor chain applies the same `createdAt <= rankedAt` window and computes freshness against the same timestamp, so newly created posts do not shift already paginated results. Legacy numeric offset cursors remain supported. + +## Configuration + +```dotenv +FEED_CACHE_ENABLED=true +FEED_CACHE_USER_TTL_SECONDS=15 +FEED_CACHE_TRENDING_TTL_SECONDS=30 +FEED_CACHE_RANKING_PROFILE_TTL_SECONDS=30 +``` + +Viewer-level watch duration and explicit "Not interested" events do not exist in the current data model. They should be added as future ranking signals when the mobile client starts emitting those events; global `viewCount` and `playCount` are used only as weak quality priors. diff --git a/docs/SEARCH_ARCHITECTURE.md b/docs/SEARCH_ARCHITECTURE.md new file mode 100644 index 0000000..6649951 --- /dev/null +++ b/docs/SEARCH_ARCHITECTURE.md @@ -0,0 +1,80 @@ +# Search architecture + +The public API contract is unchanged: + +- `GET /api/v1/search` +- `GET /api/v1/search/users` +- `GET /api/v1/search/posts` +- `GET /api/v1/search/hashtags` +- `GET /api/v1/search/suggestions` + +The existing `q`, `type`, `page`, `limit`, and `sortOrder` query parameters and all response and pagination shapes remain compatible with existing clients. + +## Search engines + +`SEARCH_ENGINE` accepts: + +- `auto` (default): use Atlas Search and automatically use compatibility search if Atlas Search or its indexes are unavailable. +- `atlas`: prefer Atlas Search. With `SEARCH_FALLBACK_ENABLED=true`, service remains available during an Atlas Search failure. +- `regex`: use the original MongoDB regex implementation only. + +When Atlas Search fails, the service logs one warning and waits `SEARCH_ATLAS_RETRY_SECONDS` before retrying it. Requests continue through the compatibility implementation during that interval. + +## Ranking + +User ranking combines: + +1. Exact username relevance. +2. Username, stage name, and display name autocomplete relevance. +3. One-character typo tolerance for queries of at least three characters. +4. Whether the viewer follows the result. +5. Whether the viewer recently liked or saved posts from the result. +6. Verification and a logarithmic follower-count boost. + +Post ranking combines: + +1. Content, hashtag, style, maqam, and rhythm relevance. +2. Typo tolerance for queries of at least four characters. +3. Follow and recent interaction affinity with the author. +4. A logarithmic engagement boost. +5. A seven-day recency decay. + +Blocked users, disabled authors, deleted or archived posts, moderation state, and visibility are hard filters. They never become ranking signals and cannot be bypassed by a high search score. + +## Atlas Search index deployment + +The index definitions are stored in: + +- `ops/atlas-search/users_search.json` +- `ops/atlas-search/posts_search.json` + +Set `MONGODB_URI` to the target Atlas database and run: + +```bash +npm run search:sync-indexes +``` + +The script creates missing indexes and updates existing indexes using `SEARCH_ATLAS_USER_INDEX` and `SEARCH_ATLAS_POST_INDEX`. Atlas builds indexes asynchronously; `SEARCH_ENGINE=auto` continues using compatibility search until they become queryable. + +Recommended production configuration: + +```dotenv +SEARCH_ENGINE=auto +SEARCH_ATLAS_USER_INDEX=users_search +SEARCH_ATLAS_POST_INDEX=posts_search +SEARCH_FALLBACK_ENABLED=true +SEARCH_ATLAS_RETRY_SECONDS=300 +``` + +For local MongoDB Community development, keep `auto` for production parity or use `regex` to suppress the initial Atlas capability check. + +## Verification + +Run: + +```bash +npm run build +npm test -- --runInBand src/modules/search/search.service.spec.ts +``` + +The tests verify response compatibility, Atlas result ordering, privacy clauses, and fallback retry throttling. diff --git a/docs/SYSTEM_STRENGTH_REPORT.md b/docs/SYSTEM_STRENGTH_REPORT.md new file mode 100644 index 0000000..b6a08d6 --- /dev/null +++ b/docs/SYSTEM_STRENGTH_REPORT.md @@ -0,0 +1,105 @@ +# System strength report + +Date: 2026-07-22 + +## Result + +The backend is functionally stable and performs well in the tested local environment. It is not yet possible to claim production-scale capacity because the test used a single Node.js process, local MongoDB, a small dataset, and disabled Redis/S3/Atlas dependencies. + +## Verification summary + +| Area | Result | +| --- | --- | +| TypeScript build | Passed | +| ESLint | Passed | +| Unit/integration suites | 85/85 passed | +| Unit/integration tests | 947/947 passed | +| End-to-end scenarios | 14/14 passed | +| Dependency audit | 0 cached advisory findings; live audit runs in CI | +| Statement coverage | 93.08% | +| Branch coverage | 67.97% | +| Function coverage | 92.93% | +| Line coverage | 93.26% | + +The executable service layer is also protected by an aggregate coverage gate: 92.81% statements, +93.02% lines, 93.67% functions, and 74.96% branches across 43 service files. `npm run +test:coverage` enforces both the project-wide gates and these service-layer gates, and the same +command is mandatory in CI. + +## Performance results + +Tests ran on one local process and are useful for regression comparison, not public capacity promises. + +| Workload | Concurrency | Throughput | Average | p95 | p99 | Success | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Liveness health | 20 | 2357 req/s | 8.47 ms | 12.64 ms | 15.92 ms | 100% | +| Cached personalized feed | 20 | 567 req/s | 35.20 ms | 40.78 ms | 45.25 ms | 100% | +| Liveness burst | 100 | 2448 req/s | 40.71 ms | 48.89 ms | 72.62 ms | 100% | +| Uncached personalized feed | 20 | 65 req/s | 304.34 ms | 392.81 ms | 445.92 ms | 100% | +| Uncached compatibility search | 10 | 109 req/s | 91.38 ms | 100.38 ms | 106.79 ms | 100% | +| Hardened health gate | 50 | 2041 req/s | 24.40 ms | 39.85 ms | 48.57 ms | 100% | +| Cold-cache/coalesced feed gate | 30 | 336 req/s | 88.79 ms | 116.14 ms | 168.69 ms | 100% | + +Cold startup was approximately 4.8 seconds. + +## Security checks + +- Forged JWT was rejected with 401. +- NoSQL-shaped login input was rejected with 400. +- Unknown privileged fields were rejected with 400. +- Malformed JSON was rejected with 400. +- A request above the configured body limit was rejected with 413. +- Login throttling returned 429 after the allowed attempts were consumed. +- Production CORS returned the configured origin and did not return an allow-origin header for an untrusted origin. +- HSTS, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy were present in production mode. +- The npm audit reported zero known vulnerabilities. + +## Concurrency and realtime checks + +- Twenty simultaneous duplicate like requests produced one like and a correct counter. +- Authenticated Socket.IO clients connected to both `chat` and `notifications` namespaces. +- An anonymous Socket.IO client was disconnected. +- Refresh-token rotation, notification delivery, feed exclusion, comments, uploads, and superadmin sessions passed end-to-end. + +## Improvements made during testing + +- Added `/api/v1/health/ready`, which checks MongoDB, Redis when enabled, and storage, and returns 503 when degraded. +- Added readiness unit and end-to-end coverage. +- Added real Socket.IO end-to-end tests. +- Added concurrent idempotency coverage for likes. +- Improved startup benchmark diagnostics to include the child exit code and recent logs. +- Corrected local storage configuration to use direct media access. +- Added local single-flight and ownership-safe Redis leases to prevent cache stampedes. +- Added configurable MongoDB connection pools and disabled automatic production index builds by default. +- Added response compression and bounded Node HTTP transport settings. +- Added request deadlines, graceful request draining, and drain-aware readiness. +- Added event-loop, in-flight request, timeout, and bounded-cardinality route metrics. +- Added direct, user-scoped S3 PUT uploads so large media can bypass Node memory. +- Added CI performance gates and made unit, E2E, audit, and Docker build checks mandatory before deployment. +- Added behavioral, security, failure-path, controller-delegation, repository-persistence, and + bootstrap tests, increasing the regression suite to 947 tests. +- Added enforced project-wide and service-layer coverage gates so the achieved baseline cannot + silently regress. +- Hardened the production container with a non-root user, `tini`, a healthcheck, and secret-safe Docker context exclusions. + +## Remaining production validation + +Before a high-traffic launch, run the same tests in staging with production-sized data and production topology: + +1. Multiple application replicas behind the real load balancer. +2. Redis enabled for cache, throttling, queues, and Socket.IO fan-out. +3. S3-compatible storage, signed media URLs, and CDN behavior. +4. Atlas Search indexes and representative Arabic/English datasets. +5. Long soak tests (2-8 hours), failover tests, and MongoDB/Redis latency injection. +6. Continue raising branch coverage, prioritizing rare infrastructure and dependency-failure combinations. +7. Run an external DAST/SAST and penetration test before handling sensitive production data. + +## Current assessment + +- Functional correctness: strong. +- Local performance: strong with cache; acceptable but database-bound without cache. +- Security controls: strong baseline. +- Operational readiness: good after adding dependency readiness checks. +- Automated test maturity: strong baseline, with more than 90% statement, line, and function coverage + plus separate runtime-service gates. +- Production-scale confidence: pending staging tests with real infrastructure and data volume. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..c40c251 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,29 @@ +import eslint from '@eslint/js'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist/**', 'node_modules/**', 'coverage/**'] }, + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.ts'], + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + }, + rules: { + 'no-undef': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, + }, + eslintConfigPrettier, +); diff --git a/jest.config.js b/jest.config.js index 04fb757..c3ccbc7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,10 +2,26 @@ module.exports = { moduleFileExtensions: ['js', 'json', 'ts'], rootDir: '.', testEnvironment: 'node', + setupFiles: ['/test/setup-env.ts'], testRegex: '.*\\.spec\\.ts$', transform: { '^.+\\.(t|j)s$': 'ts-jest', }, collectCoverageFrom: ['src/**/*.(t|j)s'], coverageDirectory: './coverage', + coverageReporters: ['text', 'json-summary', 'lcov'], + coverageThreshold: { + global: { + statements: 90, + lines: 90, + functions: 90, + branches: 40, + }, + './src/**/*.service.ts': { + statements: 50, + lines: 50, + functions: 50, + branches: 30, + }, + }, }; diff --git a/ops/atlas-search/posts_search.json b/ops/atlas-search/posts_search.json new file mode 100644 index 0000000..78e57a4 --- /dev/null +++ b/ops/atlas-search/posts_search.json @@ -0,0 +1,44 @@ +{ + "mappings": { + "dynamic": false, + "fields": { + "content": { + "type": "string", + "analyzer": "lucene.standard" + }, + "contentTop": { + "type": "string", + "analyzer": "lucene.standard" + }, + "contentBottom": { + "type": "string", + "analyzer": "lucene.standard" + }, + "hashtags": [ + { + "type": "autocomplete", + "tokenization": "edgeGram", + "minGrams": 2, + "maxGrams": 15, + "foldDiacritics": true + }, + { + "type": "string", + "analyzer": "lucene.keyword" + } + ], + "style": { + "type": "string", + "analyzer": "lucene.standard" + }, + "maqam": { + "type": "string", + "analyzer": "lucene.standard" + }, + "rhythmSignature": { + "type": "string", + "analyzer": "lucene.standard" + } + } + } +} diff --git a/ops/atlas-search/users_search.json b/ops/atlas-search/users_search.json new file mode 100644 index 0000000..64d2d66 --- /dev/null +++ b/ops/atlas-search/users_search.json @@ -0,0 +1,46 @@ +{ + "mappings": { + "dynamic": false, + "fields": { + "username": [ + { + "type": "autocomplete", + "tokenization": "edgeGram", + "minGrams": 2, + "maxGrams": 15, + "foldDiacritics": true + }, + { + "type": "string", + "analyzer": "lucene.keyword" + } + ], + "name": [ + { + "type": "autocomplete", + "tokenization": "edgeGram", + "minGrams": 2, + "maxGrams": 15, + "foldDiacritics": true + }, + { + "type": "string", + "analyzer": "lucene.standard" + } + ], + "stageName": [ + { + "type": "autocomplete", + "tokenization": "edgeGram", + "minGrams": 2, + "maxGrams": 15, + "foldDiacritics": true + }, + { + "type": "string", + "analyzer": "lucene.standard" + } + ] + } + } +} diff --git a/package-lock.json b/package-lock.json index 2790d9e..1dfa5a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,31 +8,36 @@ "name": "oudelaa-backend", "version": "1.0.0", "license": "UNLICENSED", + "engines": { + "node": ">=20 <25" + }, "dependencies": { "@aws-sdk/client-s3": "^3.1041.0", "@aws-sdk/lib-storage": "^3.1041.0", "@aws-sdk/s3-request-presigner": "^3.1041.0", - "@nestjs/common": "^10.4.0", - "@nestjs/config": "^3.2.3", - "@nestjs/core": "^10.4.0", - "@nestjs/jwt": "^10.2.0", - "@nestjs/mongoose": "^10.1.0", - "@nestjs/passport": "^10.0.3", - "@nestjs/platform-express": "^10.4.0", - "@nestjs/platform-socket.io": "^10.4.0", - "@nestjs/swagger": "^8.1.0", - "@nestjs/websockets": "^10.4.0", + "@nestjs/common": "^11.1.28", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.1.28", + "@nestjs/jwt": "^11.0.2", + "@nestjs/mongoose": "^11.0.4", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.1.28", + "@nestjs/platform-socket.io": "^11.1.28", + "@nestjs/swagger": "^11.4.6", + "@nestjs/websockets": "^11.1.28", "@socket.io/redis-adapter": "^8.3.0", "@types/passport-google-oauth20": "^2.0.17", - "bcrypt": "^5.1.1", + "bcrypt": "^6.0.0", "bullmq": "^5.76.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "compression": "^1.8.1", + "dotenv": "^16.4.5", "google-auth-library": "^10.6.2", "ioredis": "^5.10.1", "joi": "^17.13.3", "mongoose": "^8.6.0", - "nodemailer": "^8.0.5", + "nodemailer": "^9.0.3", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", @@ -41,10 +46,12 @@ "socket.io": "^4.8.0" }, "devDependencies": { - "@nestjs/cli": "^10.4.5", - "@nestjs/schematics": "^10.2.3", - "@nestjs/testing": "^10.4.0", + "@eslint/js": "^9.39.4", + "@nestjs/cli": "^11.0.24", + "@nestjs/schematics": "^11.1.0", + "@nestjs/testing": "^11.1.28", "@types/bcrypt": "^5.0.2", + "@types/compression": "^1.8.1", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/node": "^20.16.5", @@ -54,38 +61,41 @@ "eslint": "^9.11.1", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", + "globals": "^16.5.0", "jest": "^29.7.0", "prettier": "^3.3.3", + "socket.io-client": "^4.8.0", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.6.2" + "typescript": "^5.6.2", + "typescript-eslint": "^8.64.0" } }, "node_modules/@angular-devkit/core": { - "version": "17.3.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz", - "integrity": "sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==", + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.27.tgz", + "integrity": "sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "8.12.0", - "ajv-formats": "2.1.1", - "jsonc-parser": "3.2.1", - "picomatch": "4.0.1", + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", "rxjs": "7.8.1", "source-map": "0.7.4" }, "engines": { - "node": "^18.13.0 || >=20.9.0", + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" }, "peerDependencies": { - "chokidar": "^3.5.2" + "chokidar": "^4.0.0" }, "peerDependenciesMeta": { "chokidar": { @@ -93,6 +103,24 @@ } } }, + "node_modules/@angular-devkit/core/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/@angular-devkit/core/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -104,35 +132,35 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "17.3.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.11.tgz", - "integrity": "sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==", + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.27.tgz", + "integrity": "sha512-/PZmyAlb2NGWPikRRuiWLdfHQd8Wrx6lX4HqvTcaDhlU43M3T0ud4PH2T3QDp7BzHYY92xtD8iPxX2asg67G1A==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.11", - "jsonc-parser": "3.2.1", - "magic-string": "0.30.8", + "@angular-devkit/core": "19.2.27", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", "ora": "5.4.1", "rxjs": "7.8.1" }, "engines": { - "node": "^18.13.0 || >=20.9.0", + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, "node_modules/@angular-devkit/schematics-cli": { - "version": "17.3.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-17.3.11.tgz", - "integrity": "sha512-kcOMqp+PHAKkqRad7Zd7PbpqJ0LqLaNZdY1+k66lLWmkEBozgq8v4ASn/puPWf9Bo0HpCiK+EzLf0VHE8Z/y6Q==", + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.27.tgz", + "integrity": "sha512-wHYH6SVXVykhLzovUHtYor3Nl4SpIiITi7r9DQDaKYUD4hpRBx25W6N9eGuakT9Vd5tV/x6wmvQFWQZQwFB7eA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.11", - "@angular-devkit/schematics": "17.3.11", + "@angular-devkit/core": "19.2.27", + "@angular-devkit/schematics": "19.2.27", + "@inquirer/prompts": "7.3.2", "ansi-colors": "4.1.3", - "inquirer": "9.2.15", "symbol-observable": "4.0.0", "yargs-parser": "21.1.1" }, @@ -140,79 +168,39 @@ "schematics": "bin/schematics.js" }, "engines": { - "node": "^18.13.0 || >=20.9.0", + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, - "node_modules/@angular-devkit/schematics-cli/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/inquirer": { - "version": "9.2.15", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.2.15.tgz", - "integrity": "sha512-vI2w4zl/mDluHt9YEQ/543VTCwPKWiHzKtm9dM2V0NdFcqEexDAjUHzO1oA60HRNaVifGXXM1tRRNluLVHa0Kg==", + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", + "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", "dev": true, "license": "MIT", "dependencies": { - "@ljharb/through": "^2.3.12", - "ansi-escapes": "^4.3.2", - "chalk": "^5.3.0", - "cli-cursor": "^3.1.0", - "cli-width": "^4.1.0", - "external-editor": "^3.1.0", - "figures": "^3.2.0", - "lodash": "^4.17.21", - "mute-stream": "1.0.0", - "ora": "^5.4.1", - "run-async": "^3.0.0", - "rxjs": "^7.8.1", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0" + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" }, "engines": { "node": ">=18" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/run-async": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", - "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@angular-devkit/schematics/node_modules/rxjs": { @@ -1136,13 +1124,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -1151,9 +1139,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -1161,21 +1149,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -1191,31 +1179,6 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@babel/core/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -1227,14 +1190,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -1244,14 +1207,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -1271,9 +1234,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -1281,29 +1244,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1323,9 +1286,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -1333,9 +1296,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -1343,9 +1306,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -1353,27 +1316,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -1622,73 +1585,48 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@babel/traverse/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1803,31 +1741,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@eslint/config-array/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", @@ -1895,29 +1808,34 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@eslint/eslintrc/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, "engines": { - "node": ">=6.0" + "node": ">=18" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -1933,13 +1851,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@eslint/eslintrc/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/js": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", @@ -2044,115 +1955,362 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@ioredis/commands": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", "license": "MIT" }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2195,9 +2353,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -2645,19 +2803,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@ljharb/through": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.14.tgz", - "integrity": "sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", @@ -2667,30 +2812,10 @@ "node": ">=8" } }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", - "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", - "license": "BSD-3-Clause", - "dependencies": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" - } - }, "node_modules/@microsoft/tsdoc": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", - "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "license": "MIT" }, "node_modules/@mongodb-js/saslprep": { @@ -2781,40 +2906,39 @@ ] }, "node_modules/@nestjs/cli": { - "version": "10.4.9", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", - "integrity": "sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==", + "version": "11.0.24", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.24.tgz", + "integrity": "sha512-aIHxQLSYtXShifA3zwWIeznEsZnNa3Iz2QRykFj+sl9IcbERBHr5nH87FRgywM+He3NxoF5WazHfR8FsmVeWxw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.11", - "@angular-devkit/schematics": "17.3.11", - "@angular-devkit/schematics-cli": "17.3.11", - "@nestjs/schematics": "^10.0.1", - "chalk": "4.1.2", - "chokidar": "3.6.0", + "@angular-devkit/core": "19.2.27", + "@angular-devkit/schematics": "19.2.27", + "@angular-devkit/schematics-cli": "19.2.27", + "@inquirer/prompts": "7.10.1", + "@nestjs/schematics": "^11.0.1", + "ansis": "4.2.0", + "chokidar": "4.0.3", "cli-table3": "0.6.5", "commander": "4.1.1", - "fork-ts-checker-webpack-plugin": "9.0.2", - "glob": "10.4.5", - "inquirer": "8.2.6", + "fork-ts-checker-webpack-plugin": "9.1.0", + "glob": "13.0.6", "node-emoji": "1.11.0", "ora": "5.4.1", - "tree-kill": "1.2.2", "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.2.0", - "typescript": "5.7.2", - "webpack": "5.97.1", + "typescript": "5.9.3", + "webpack": "5.106.2", "webpack-node-externals": "3.0.0" }, "bin": { "nest": "bin/nest.js" }, "engines": { - "node": ">= 16.14" + "node": ">= 20.11" }, "peerDependencies": { - "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0", + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", "@swc/core": "^1.3.62" }, "peerDependenciesMeta": { @@ -2826,106 +2950,15 @@ } } }, - "node_modules/@nestjs/cli/node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@nestjs/cli/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@nestjs/cli/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/@nestjs/cli/node_modules/typescript": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", - "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/@nestjs/cli/node_modules/webpack": { - "version": "5.97.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", - "integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", - "browserslist": "^4.24.0", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, "node_modules/@nestjs/common": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", - "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.28.tgz", + "integrity": "sha512-bRImsxibie+AM7xjdwcrm/gr5YeacI65kSBNzTufa1Ib5iwziaY/lqMtRh9THq6pbV4e1HP9aI2ZxGUumnmaoQ==", "license": "MIT", "dependencies": { - "file-type": "20.4.1", + "file-type": "21.3.4", "iterare": "1.2.1", + "load-esm": "1.0.3", "tslib": "2.8.1", "uid": "2.0.2" }, @@ -2934,8 +2967,8 @@ "url": "https://opencollective.com/nest" }, "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, @@ -2949,43 +2982,56 @@ } }, "node_modules/@nestjs/config": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.3.0.tgz", - "integrity": "sha512-pdGTp8m9d0ZCrjTpjkUbZx6gyf2IKf+7zlkrPNMsJzYZ4bFRRTpXrnj+556/5uiI6AfL5mMrJc2u7dB6bvM+VA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", + "integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==", "license": "MIT", "dependencies": { - "dotenv": "16.4.5", - "dotenv-expand": "10.0.0", - "lodash": "4.17.21" + "dotenv": "17.4.1", + "dotenv-expand": "12.0.3", + "lodash": "4.18.1" }, "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/common": "^10.0.0 || ^11.0.0", "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/config/node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@nestjs/core": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.22.tgz", - "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", - "hasInstallScript": true, + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.28.tgz", + "integrity": "sha512-06m63xIRj8+l8uOeh/8LnYupGubkyu4f+bPKIadaSui6vK9KpXgoz7HveT1yOVLcEt0M0oCOEW5EuEXZkEmBBQ==", "license": "MIT", "dependencies": { - "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "3.3.0", + "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, + "engines": { + "node": ">= 20" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/nest" }, "peerDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/microservices": "^10.0.0", - "@nestjs/platform-express": "^10.0.0", - "@nestjs/websockets": "^10.0.0", + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, @@ -3002,27 +3048,27 @@ } }, "node_modules/@nestjs/jwt": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", - "integrity": "sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", "license": "MIT", "dependencies": { - "@types/jsonwebtoken": "9.0.5", - "jsonwebtoken": "9.0.2" + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" }, "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" } }, "node_modules/@nestjs/mapped-types": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.6.tgz", - "integrity": "sha512-84ze+CPfp1OWdpRi1/lOu59hOhTz38eVzJvRKrg9ykRFwDz+XleKfMsG0gUqNZYFa6v53XYzeD+xItt8uDW7NQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", + "integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==", "license": "MIT", "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/common": "^10.0.0 || ^11.0.0", "class-transformer": "^0.4.0 || ^0.5.0", - "class-validator": "^0.13.0 || ^0.14.0", + "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", "reflect-metadata": "^0.1.12 || ^0.2.0" }, "peerDependenciesMeta": { @@ -3035,37 +3081,37 @@ } }, "node_modules/@nestjs/mongoose": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/mongoose/-/mongoose-10.1.0.tgz", - "integrity": "sha512-1ExAnZUfh2QffEaGjqYGgVPy/sYBQCVLCLqVgkcClKx/BCd0QNgND8MB70lwyobp3nm/+nbGQqBpu9F3/hgOCw==", + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/mongoose/-/mongoose-11.0.4.tgz", + "integrity": "sha512-LUOlUeSOfbjdIu22QwOmczv2CzJQr9LUBo2mOfbXrGCu2svpr5Hiu71zBFrb/9UC+H8BjGMKbBOq1nEbMF6ZJA==", "license": "MIT", "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", - "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", - "mongoose": "^6.0.2 || ^7.0.0 || ^8.0.0", + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "mongoose": "^7.0.0 || ^8.0.0 || ^9.0.0", "rxjs": "^7.0.0" } }, "node_modules/@nestjs/passport": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz", - "integrity": "sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", "license": "MIT", "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", - "passport": "^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0" + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" } }, "node_modules/@nestjs/platform-express": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", - "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.28.tgz", + "integrity": "sha512-hU+9Sz4m+onHrR5AmelI59QKmY/Re546bPnygnpqqeQdHDiJpBgjWbL4t6Jr73CBpS60cpyng7WzjgphNB9iwA==", "license": "MIT", "dependencies": { - "body-parser": "1.20.4", - "cors": "2.8.5", - "express": "4.22.1", - "multer": "2.0.2", + "cors": "2.8.6", + "express": "5.2.1", + "multer": "2.2.0", + "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "funding": { @@ -3073,17 +3119,17 @@ "url": "https://opencollective.com/nest" }, "peerDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0" + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0" } }, "node_modules/@nestjs/platform-socket.io": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.22.tgz", - "integrity": "sha512-xxGw3R0Ihr51/Omq23z3//bKmCXyVKaikxbH0/pkwqMsQrxkUv9NabNUZ22b4Jnlwwi02X+zlwo8GRa9u8oV9g==", + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.28.tgz", + "integrity": "sha512-vY+GmU2jBcymvgm5rEnftUx4qNxK8cDJmXjl1/1NcpITTNJo0vg07xYR43MwXHcMqe7b0jwqt5+UCTzxqQFIqA==", "license": "MIT", "dependencies": { - "socket.io": "4.8.1", + "socket.io": "4.8.3", "tslib": "2.8.1" }, "funding": { @@ -3091,93 +3137,126 @@ "url": "https://opencollective.com/nest" }, "peerDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/websockets": "^10.0.0", + "@nestjs/common": "^11.0.0", + "@nestjs/websockets": "^11.0.0", "rxjs": "^7.1.0" } }, - "node_modules/@nestjs/platform-socket.io/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@nestjs/platform-socket.io/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/@nestjs/platform-socket.io/node_modules/socket.io": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", - "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.3.2", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, "node_modules/@nestjs/schematics": { - "version": "10.2.3", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", - "integrity": "sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", + "integrity": "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.11", - "@angular-devkit/schematics": "17.3.11", - "comment-json": "4.2.5", + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "comment-json": "5.0.0", "jsonc-parser": "3.3.1", "pluralize": "8.0.0" }, "peerDependencies": { + "prettier": "^3.0.0", "typescript": ">=4.8.2" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } } }, - "node_modules/@nestjs/schematics/node_modules/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", + "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", "dev": true, - "license": "MIT" - }, - "node_modules/@nestjs/swagger": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-8.1.1.tgz", - "integrity": "sha512-5Mda7H1DKnhKtlsb0C7PYshcvILv8UFyUotHzxmWh0G65Z21R3LZH/J8wmpnlzL4bmXIfr42YwbEwRxgzpJ5sQ==", "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "^0.15.0", - "@nestjs/mapped-types": "2.0.6", - "js-yaml": "4.1.0", - "lodash": "4.17.21", - "path-to-regexp": "3.3.0", - "swagger-ui-dist": "5.18.2" + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" }, "peerDependencies": { - "@fastify/static": "^6.0.0 || ^7.0.0", - "@nestjs/common": "^9.0.0 || ^10.0.0", - "@nestjs/core": "^9.0.0 || ^10.0.0", + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", + "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@nestjs/schematics/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@nestjs/swagger": { + "version": "11.4.6", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.6.tgz", + "integrity": "sha512-Le136h2WC7HGsd70+WyK1qrm+Zq7kFxBLkYC1JgAVqNRCt8kNh7bMF7Qkn65D5j2t/aks0+VbWmUVlYIwPrs3A==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.1", + "js-yaml": "5.2.1", + "lodash": "4.18.1", + "path-to-regexp": "8.4.2", + "swagger-ui-dist": "5.32.8" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", "class-transformer": "*", "class-validator": "*", "reflect-metadata": "^0.1.12 || ^0.2.0" @@ -3195,9 +3274,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.22.tgz", - "integrity": "sha512-HO9aPus3bAedAC+jKVAA8jTdaj4fs5M9fing4giHrcYV2txe9CvC1l1WAjwQ9RDhEHdugjY4y+FZA/U/YqPZrA==", + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.28.tgz", + "integrity": "sha512-B+VgRxeLaH7jkOMgAyUP3N3rpFlisQ7JRxixRbgHvG6a0VgKbbkNSofKExexCgKmQQak80undb3+2kE1lUBmRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3208,10 +3287,10 @@ "url": "https://opencollective.com/nest" }, "peerDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0", - "@nestjs/microservices": "^10.0.0", - "@nestjs/platform-express": "^10.0.0" + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0" }, "peerDependenciesMeta": { "@nestjs/microservices": { @@ -3223,9 +3302,9 @@ } }, "node_modules/@nestjs/websockets": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.22.tgz", - "integrity": "sha512-OLd4i0Faq7vgdtB5vVUrJ54hWEtcXy9poJ6n7kbbh/5ms+KffUl+wwGsbe7uSXLrkoyI8xXU6fZPkFArI+XiRg==", + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.28.tgz", + "integrity": "sha512-jeyclAURCJTN8S8lctDhfLdiJeDKjZmYWWLav653Fb9hl9c+zx5jPhavI8Xk5++R8u+lX9qzaRxtsjEoxTtjyw==", "license": "MIT", "dependencies": { "iterare": "1.2.1", @@ -3233,9 +3312,9 @@ "tslib": "2.8.1" }, "peerDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0", - "@nestjs/platform-socket.io": "^10.0.0", + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-socket.io": "^11.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, @@ -3258,24 +3337,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@nuxtjs/opencollective": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", - "integrity": "sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==", - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "consola": "^2.15.0", - "node-fetch": "^2.6.1" - }, - "bin": { - "opencollective": "bin/opencollective.js" - }, - "engines": { - "node": ">=8.0.0", - "npm": ">=5.0.0" - } - }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -3286,17 +3347,6 @@ "@noble/hashes": "^1.1.5" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -4112,12 +4162,6 @@ } } }, - "node_modules/@socket.io/redis-adapter/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/@socket.io/redis-adapter/node_modules/uid2": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/uid2/-/uid2-1.0.0.tgz", @@ -4128,14 +4172,13 @@ } }, "node_modules/@tokenizer/inflate": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", - "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", "license": "MIT", "dependencies": { - "debug": "^4.4.0", - "fflate": "^0.8.2", - "token-types": "^6.0.0" + "debug": "^4.4.3", + "token-types": "^6.1.1" }, "engines": { "node": ">=18" @@ -4145,29 +4188,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/@tokenizer/inflate/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@tokenizer/inflate/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", @@ -4267,6 +4287,17 @@ "@types/node": "*" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -4407,11 +4438,12 @@ "license": "MIT" }, "node_modules/@types/jsonwebtoken": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", - "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "license": "MIT", "dependencies": { + "@types/ms": "*", "@types/node": "*" } }, @@ -4428,6 +4460,12 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", @@ -4629,6 +4667,288 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", + "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/type-utils": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.64.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", + "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", + "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.64.0", + "@typescript-eslint/types": "^8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz", + "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz", + "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", + "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", + "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz", + "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.64.0", + "@typescript-eslint/tsconfig-utils": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", + "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz", + "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -4804,12 +5124,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC" - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -4842,7 +5156,6 @@ "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" }, @@ -4873,52 +5186,17 @@ "node": ">=0.4.0" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agent-base/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/agent-base/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -4986,6 +5264,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4995,6 +5274,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5006,6 +5286,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -5039,26 +5329,6 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "license": "ISC" - }, - "node_modules/are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -5072,12 +5342,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, "node_modules/array-timsort": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", @@ -5229,6 +5493,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -5283,17 +5548,17 @@ } }, "node_modules/bcrypt": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", - "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.11", - "node-addon-api": "^5.0.0" + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" }, "engines": { - "node": ">= 10.0.0" + "node": ">= 18" } }, "node_modules/bignumber.js": { @@ -5305,19 +5570,6 @@ "node": "*" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -5331,27 +5583,40 @@ } }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/bowser": { @@ -5361,9 +5626,10 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -5523,25 +5789,6 @@ "node": ">= 0.8" } }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -5616,6 +5863,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -5639,44 +5887,26 @@ } }, "node_modules/chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", "dev": true, "license": "MIT" }, "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", - "engines": { - "node": ">=10" } }, "node_modules/chrome-trace-event": { @@ -5772,13 +6002,13 @@ } }, "node_modules/cli-width": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, "license": "ISC", "engines": { - "node": ">= 10" + "node": ">= 12" } }, "node_modules/cliui": { @@ -5855,6 +6085,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5867,17 +6098,9 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "license": "ISC", - "bin": { - "color-support": "bin.js" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -5902,17 +6125,14 @@ } }, "node_modules/comment-json": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz", - "integrity": "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz", + "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==", "dev": true, "license": "MIT", "dependencies": { "array-timsort": "^1.0.3", - "core-util-is": "^1.0.3", - "esprima": "^4.0.1", - "has-own-prop": "^2.0.0", - "repeat-string": "^1.6.1" + "esprima": "^4.0.1" }, "engines": { "node": ">= 6" @@ -5928,10 +6148,65 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -5949,28 +6224,17 @@ "typedarray": "^0.0.6" } }, - "node_modules/consola": { - "version": "2.15.3", - "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", - "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==", - "license": "MIT" - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "license": "ISC" - }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -5999,10 +6263,13 @@ } }, "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, "node_modules/cookiejar": { "version": "2.1.4", @@ -6011,17 +6278,10 @@ "dev": true, "license": "MIT" }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -6029,6 +6289,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cosmiconfig": { @@ -6058,6 +6322,29 @@ } } }, + "node_modules/cosmiconfig/node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -6124,12 +6411,20 @@ } }, "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "ms": "2.0.0" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/dedent": { @@ -6177,24 +6472,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -6205,12 +6482,6 @@ "node": ">=0.4.0" } }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT" - }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -6229,21 +6500,12 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", + "optional": true, "engines": { "node": ">=8" } @@ -6302,12 +6564,18 @@ } }, "node_modules/dotenv-expand": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", - "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, "engines": { "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, "node_modules/dunder-proto": { @@ -6324,13 +6592,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -6370,6 +6631,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -6382,9 +6644,9 @@ } }, "node_modules/engine.io": { - "version": "6.6.6", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz", - "integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==", + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.12", @@ -6396,12 +6658,26 @@ "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.18.3" + "ws": "~8.21.0" }, "engines": { "node": ">=10.2.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, "node_modules/engine.io-parser": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", @@ -6411,29 +6687,6 @@ "node": ">=10.0.0" } }, - "node_modules/engine.io/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/engine.io/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -6481,8 +6734,7 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -6692,24 +6944,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/eslint/node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -6730,13 +6964,6 @@ "dev": true, "license": "MIT" }, - "node_modules/eslint/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -6891,56 +7118,94 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { - "node": ">= 0.10.0" + "node": ">= 18" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, - "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" + "node_modules/express/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, "node_modules/extend": { "version": "3.0.2", @@ -6948,21 +7213,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6997,6 +7247,23 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -7007,6 +7274,24 @@ "bser": "2.1.1" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -7030,38 +7315,6 @@ "node": "^12.20 || >= 14.13" } }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "license": "MIT" - }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -7076,18 +7329,18 @@ } }, "node_modules/file-type": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.4.1.tgz", - "integrity": "sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==", + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", "license": "MIT", "dependencies": { - "@tokenizer/inflate": "^0.2.6", - "strtok3": "^10.2.0", - "token-types": "^6.0.0", + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sindresorhus/file-type?sponsor=1" @@ -7107,21 +7360,24 @@ } }, "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/find-up": { @@ -7162,33 +7418,16 @@ "dev": true, "license": "ISC" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fork-ts-checker-webpack-plugin": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.0.2.tgz", - "integrity": "sha512-Uochze2R8peoN1XqlSi/rGUkDQpRogtLFocP9+PGu68zk1BDAKXfdeCdyVZpgTk8V8WFVQXdEz426VKjXLO1Gg==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", + "integrity": "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.16.7", "chalk": "^4.1.2", - "chokidar": "^3.5.3", + "chokidar": "^4.0.1", "cosmiconfig": "^8.2.0", "deepmerge": "^4.2.2", "fs-extra": "^10.0.0", @@ -7200,8 +7439,7 @@ "tapable": "^2.2.1" }, "engines": { - "node": ">=12.13.0", - "yarn": ">=1.0.0" + "node": ">=14.21.3" }, "peerDependencies": { "typescript": ">3.6.0", @@ -7209,17 +7447,17 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -7265,12 +7503,12 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/fs-extra": { @@ -7288,36 +7526,6 @@ "node": ">=12" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/fs-monkey": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", @@ -7329,6 +7537,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -7355,33 +7564,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/gauge/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, "node_modules/gaxios": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", @@ -7405,23 +7587,6 @@ "node": ">= 14" } }, - "node_modules/gaxios/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/gaxios/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -7435,12 +7600,6 @@ "node": ">= 14" } }, - "node_modules/gaxios/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/gaxios/node_modules/node-fetch": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", @@ -7540,40 +7699,23 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", @@ -7581,36 +7723,49 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", "engines": { @@ -7651,27 +7806,6 @@ "node": ">=18" } }, - "node_modules/google-auth-library/node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/google-auth-library/node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/google-logging-utils": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", @@ -7736,34 +7870,12 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-own-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", - "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -7792,16 +7904,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "license": "ISC" - }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7837,42 +7943,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/https-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -7884,15 +7954,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ieee754": { @@ -7977,6 +8051,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -7989,33 +8064,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/inquirer": { - "version": "8.2.6", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", - "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.21", - "mute-stream": "0.0.8", - "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^6.0.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/ioredis": { "version": "5.10.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", @@ -8040,29 +8088,6 @@ "url": "https://opencollective.com/ioredis" } }, - "node_modules/ioredis/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/ioredis/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -8079,19 +8104,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -8122,6 +8134,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8170,11 +8183,17 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8276,31 +8295,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/istanbul-lib-source-maps/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8334,22 +8328,6 @@ "node": ">=6" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -9012,9 +8990,9 @@ } }, "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "version": "17.13.4", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz", + "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.3.0", @@ -9032,15 +9010,25 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", + "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/jsesc": { @@ -9107,16 +9095,16 @@ } }, "node_modules/jsonc-parser": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", - "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "dev": true, "license": "MIT" }, "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -9127,12 +9115,12 @@ } }, "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", "license": "MIT", "dependencies": { - "jws": "^3.2.2", + "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", @@ -9148,16 +9136,10 @@ "npm": ">=6" } }, - "node_modules/jsonwebtoken/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/jwa": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", - "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "license": "MIT", "dependencies": { "buffer-equal-constant-time": "^1.0.1", @@ -9166,12 +9148,12 @@ } }, "node_modules/jws": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", - "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "jwa": "^1.4.2", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -9241,6 +9223,25 @@ "dev": true, "license": "MIT" }, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, "node_modules/loader-runner": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", @@ -9272,9 +9273,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.defaults": { @@ -9382,40 +9383,13 @@ } }, "node_modules/magic-string": { - "version": "0.30.8", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", - "integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==", + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/make-error": { @@ -9445,12 +9419,12 @@ } }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/memfs": { @@ -9473,10 +9447,13 @@ "license": "MIT" }, "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", + "engines": { + "node": ">=18" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -9492,6 +9469,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9524,18 +9502,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -9571,6 +9537,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9583,6 +9550,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9598,49 +9566,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mongodb": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", @@ -9719,12 +9644,6 @@ "url": "https://opencollective.com/mongoose" } }, - "node_modules/mongoose/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/mpath": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", @@ -9746,35 +9665,12 @@ "node": ">=14.0.0" } }, - "node_modules/mquery/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/mquery/node_modules/ms": { + "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/msgpackr": { "version": "1.11.12", "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", @@ -9807,29 +9703,55 @@ } }, "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" + "type-is": "^1.6.18" }, "engines": { "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" } }, "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, - "license": "ISC" + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } }, "node_modules/natural-compare": { "version": "1.4.0", @@ -9861,10 +9783,13 @@ "license": "MIT" }, "node_modules/node-addon-api": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", - "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", - "license": "MIT" + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } }, "node_modules/node-domexception": { "version": "1.0.0", @@ -9896,46 +9821,15 @@ "lodash": "^4.17.21" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" } }, "node_modules/node-gyp-build-optional-packages": { @@ -9968,29 +9862,14 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", - "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", "license": "MIT-0", "engines": { "node": ">=6.0.0" } }, - "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "license": "ISC", - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -10020,19 +9899,6 @@ "node": ">=8" } }, - "node_modules/npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, "node_modules/oauth": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", @@ -10081,6 +9947,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -10148,16 +10023,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -10200,13 +10065,6 @@ "node": ">=6" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -10330,6 +10188,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10353,34 +10212,41 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/path-to-regexp": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", - "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", - "license": "MIT" + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, "node_modules/path-type": { "version": "4.0.0", @@ -10405,9 +10271,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz", - "integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -10627,12 +10493,13 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -10642,27 +10509,31 @@ } }, "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", + "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/react-is": { @@ -10687,29 +10558,17 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">= 14.18.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/redis-errors": { @@ -10739,16 +10598,6 @@ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, - "node_modules/repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10854,51 +10703,20 @@ "dev": true, "license": "ISC" }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "dev": true, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, "engines": { - "node": ">=0.12.0" + "node": ">= 18" } }, "node_modules/rxjs": { @@ -10956,9 +10774,9 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -11002,72 +10820,73 @@ } }, "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" + "node": ">= 18" }, - "engines": { - "node": ">= 0.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/setprototypeof": { @@ -11100,14 +10919,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -11119,13 +10938,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -11226,19 +11045,36 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", - "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", "license": "MIT", "dependencies": { "debug": "~4.4.1", - "ws": "~8.18.3" + "ws": "~8.21.0" } }, - "node_modules/socket.io-adapter/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/socket.io-client": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.0.tgz", + "integrity": "sha512-C0jdhD5yQahMws9alf/yvtsMGTaIDBnZ8Rb5HU56svyq0l5LIrGzIDZZD5pHQlmzxLuU91Gz+VpQMKgCTNYtkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -11252,12 +11088,6 @@ } } }, - "node_modules/socket.io-adapter/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/socket.io-parser": { "version": "4.2.6", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", @@ -11271,52 +11101,6 @@ "node": ">=10.0.0" } }, - "node_modules/socket.io-parser/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io-parser/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/socket.io/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -11444,21 +11228,6 @@ } }, "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", @@ -11474,19 +11243,6 @@ } }, "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -11569,24 +11325,6 @@ "node": ">=14.18.0" } }, - "node_modules/superagent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/superagent/node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -11600,13 +11338,6 @@ "node": ">=4.0.0" } }, - "node_modules/superagent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/supertest": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", @@ -11622,20 +11353,11 @@ "node": ">=14.18.0" } }, - "node_modules/supertest/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -11658,9 +11380,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.18.2.tgz", - "integrity": "sha512-J+y4mCw/zXh1FOj5wGJvnAajq6XgHOyywsa9yITmwxIlJbMqITq3gYRZHaeqLVH/eV/HOPphE6NjF+nbSNC5Zw==", + "version": "5.32.8", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz", + "integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -11706,51 +11428,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/terser": { "version": "5.46.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", @@ -11899,24 +11576,34 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { - "os-tmpdir": "~1.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=0.6.0" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/tmpl": { @@ -11978,14 +11665,17 @@ "node": ">=18" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", - "bin": { - "tree-kill": "cli.js" + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, "node_modules/ts-jest": { @@ -12203,18 +11893,61 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -12235,6 +11968,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz", + "integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.64.0", + "@typescript-eslint/parser": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -12453,12 +12210,11 @@ } }, "node_modules/webpack": { - "version": "5.105.4", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", - "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", + "version": "5.106.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", + "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -12476,9 +12232,8 @@ "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", - "mime-types": "^2.1.27", + "mime-db": "^1.54.0", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", @@ -12528,7 +12283,6 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -12543,18 +12297,26 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/webpack/node_modules/schema-utils": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -12598,15 +12360,6 @@ "node": ">= 8" } }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "license": "ISC", - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -12639,25 +12392,6 @@ "node": ">=8" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -12686,9 +12420,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -12706,13 +12440,13 @@ } } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, "engines": { - "node": ">=0.4" + "node": ">=0.4.0" } }, "node_modules/y18n": { @@ -12783,6 +12517,19 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 3ce5fc9..60cf522 100644 --- a/package.json +++ b/package.json @@ -4,44 +4,54 @@ "private": true, "description": "Production-ready social media backend with NestJS and MongoDB", "license": "UNLICENSED", + "engines": { + "node": ">=20 <25" + }, "scripts": { "build": "nest build", "start": "nest start", "start:dev": "nest start --watch", "start:prod": "node dist/main", - "lint": "eslint \"src/**/*.ts\" --fix", + "lint": "eslint \"src/**/*.ts\"", + "lint:fix": "eslint \"src/**/*.ts\" --fix", "test": "jest", + "test:coverage": "jest --runInBand --coverage && node scripts/check-coverage.js", "test:watch": "jest --watch", "test:e2e": "jest --runInBand --config ./test/jest-e2e.json", + "test:perf": "node --test scripts/load-test.test.js", "perf:load": "node scripts/load-test.js", "perf:health": "node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/health --duration 15 --concurrency 20", - "perf:startup": "node scripts/startup-benchmark.js" + "perf:health:gate": "node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/health --duration 15 --concurrency 20 --min-success-rate 99.9 --min-rps 1000 --max-p95 100 --max-p99 150", + "perf:startup": "node scripts/startup-benchmark.js", + "search:sync-indexes": "node scripts/sync-atlas-search-indexes.js" }, "dependencies": { "@aws-sdk/client-s3": "^3.1041.0", "@aws-sdk/lib-storage": "^3.1041.0", "@aws-sdk/s3-request-presigner": "^3.1041.0", - "@nestjs/common": "^10.4.0", - "@nestjs/config": "^3.2.3", - "@nestjs/core": "^10.4.0", - "@nestjs/jwt": "^10.2.0", - "@nestjs/mongoose": "^10.1.0", - "@nestjs/passport": "^10.0.3", - "@nestjs/platform-express": "^10.4.0", - "@nestjs/platform-socket.io": "^10.4.0", - "@nestjs/swagger": "^8.1.0", - "@nestjs/websockets": "^10.4.0", + "@nestjs/common": "^11.1.28", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.1.28", + "@nestjs/jwt": "^11.0.2", + "@nestjs/mongoose": "^11.0.4", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.1.28", + "@nestjs/platform-socket.io": "^11.1.28", + "@nestjs/swagger": "^11.4.6", + "@nestjs/websockets": "^11.1.28", "@socket.io/redis-adapter": "^8.3.0", "@types/passport-google-oauth20": "^2.0.17", - "bcrypt": "^5.1.1", + "bcrypt": "^6.0.0", "bullmq": "^5.76.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "compression": "^1.8.1", + "dotenv": "^16.4.5", "google-auth-library": "^10.6.2", "ioredis": "^5.10.1", "joi": "^17.13.3", "mongoose": "^8.6.0", - "nodemailer": "^8.0.5", + "nodemailer": "^9.0.3", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", @@ -50,10 +60,12 @@ "socket.io": "^4.8.0" }, "devDependencies": { - "@nestjs/cli": "^10.4.5", - "@nestjs/schematics": "^10.2.3", - "@nestjs/testing": "^10.4.0", + "@eslint/js": "^9.39.4", + "@nestjs/cli": "^11.0.24", + "@nestjs/schematics": "^11.1.0", + "@nestjs/testing": "^11.1.28", "@types/bcrypt": "^5.0.2", + "@types/compression": "^1.8.1", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/node": "^20.16.5", @@ -63,14 +75,17 @@ "eslint": "^9.11.1", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", + "globals": "^16.5.0", "jest": "^29.7.0", "prettier": "^3.3.3", + "socket.io-client": "^4.8.0", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.6.2" + "typescript": "^5.6.2", + "typescript-eslint": "^8.64.0" } } diff --git a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json index f82f9e2..5f5329c 100644 --- a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json +++ b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json @@ -7155,7 +7155,185 @@ "pm.expect(json.audioUrl).to.exist;", "pm.expect(json.mimeType).to.exist;", "pm.expect(json.sizeBytes).to.be.a('number');", - "pm.environment.set('generatedMusicUrl', json.audioUrl);" + "pm.environment.set('generatedMusicUrl', json.audioUrl);", + "if (json.archiveItemId) { pm.environment.set('aiArchiveItemId', json.archiveItemId); }" + ] + } + } + ] + }, + { + "name": "Get My AI Music Archive", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/media/ai/music/archive?page={{aiArchivePage}}&limit={{aiArchiveLimit}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an('array');", + "pm.expect(json.pagination).to.have.property('hasMore');", + "if (json.items.length > 0) { pm.environment.set('aiArchiveItemId', json.items[0].id); }" + ] + } + } + ] + }, + { + "name": "Get AI Music Archive Item", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.id).to.eql(pm.environment.get('aiArchiveItemId'));", + "pm.expect(json.audioUrl).to.exist;" + ] + } + } + ] + }, + { + "name": "Rename AI Music Archive Item", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}", + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"{{aiArchiveNewTitle}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.title).to.eql(pm.environment.get('aiArchiveNewTitle'));" + ] + } + } + ] + }, + { + "name": "Share AI Music Archive Item", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}/share" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 201 or 200', function () { pm.expect(pm.response.code).to.be.oneOf([200, 201]); });", + "const json = pm.response.json();", + "pm.expect(json.shareUrl).to.exist;", + "pm.expect(json.audioUrl).to.exist;" + ] + } + } + ] + }, + { + "name": "Share AI Music Archive Item To Feed", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}/share-to-feed", + "body": { + "mode": "raw", + "raw": "{\n \"content\": \"{{aiArchiveShareContent}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 201 or 200', function () { pm.expect(pm.response.code).to.be.oneOf([200, 201]); });", + "const json = pm.response.json();", + "pm.expect(json.audioUrl).to.exist;", + "pm.environment.set('ownPostId', json.id || json._id);" + ] + } + } + ] + }, + { + "name": "Delete AI Music Archive Item", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.message).to.eql('Archive item deleted');" ] } } @@ -10818,6 +10996,26 @@ { "key": "socketAccessToken", "value": "{{accessToken}}" + }, + { + "key": "aiArchivePage", + "value": "1" + }, + { + "key": "aiArchiveLimit", + "value": "20" + }, + { + "key": "aiArchiveItemId", + "value": "" + }, + { + "key": "aiArchiveNewTitle", + "value": "Renamed AI Music" + }, + { + "key": "aiArchiveShareContent", + "value": "Shared from my AI music archive" } ] } diff --git a/postman/Oudelaa-Mobile.postman_collection.json b/postman/Oudelaa-Mobile.postman_collection.json index 24c5625..c496a15 100644 --- a/postman/Oudelaa-Mobile.postman_collection.json +++ b/postman/Oudelaa-Mobile.postman_collection.json @@ -6710,7 +6710,185 @@ "pm.expect(json.audioUrl).to.exist;", "pm.expect(json.mimeType).to.exist;", "pm.expect(json.sizeBytes).to.be.a('number');", - "pm.environment.set('generatedMusicUrl', json.audioUrl);" + "pm.environment.set('generatedMusicUrl', json.audioUrl);", + "if (json.archiveItemId) { pm.environment.set('aiArchiveItemId', json.archiveItemId); }" + ] + } + } + ] + }, + { + "name": "Get My AI Music Archive", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/media/ai/music/archive?page={{aiArchivePage}}&limit={{aiArchiveLimit}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an('array');", + "pm.expect(json.pagination).to.have.property('hasMore');", + "if (json.items.length > 0) { pm.environment.set('aiArchiveItemId', json.items[0].id); }" + ] + } + } + ] + }, + { + "name": "Get AI Music Archive Item", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.id).to.eql(pm.environment.get('aiArchiveItemId'));", + "pm.expect(json.audioUrl).to.exist;" + ] + } + } + ] + }, + { + "name": "Rename AI Music Archive Item", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}", + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"{{aiArchiveNewTitle}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.title).to.eql(pm.environment.get('aiArchiveNewTitle'));" + ] + } + } + ] + }, + { + "name": "Share AI Music Archive Item", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}/share" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 201 or 200', function () { pm.expect(pm.response.code).to.be.oneOf([200, 201]); });", + "const json = pm.response.json();", + "pm.expect(json.shareUrl).to.exist;", + "pm.expect(json.audioUrl).to.exist;" + ] + } + } + ] + }, + { + "name": "Share AI Music Archive Item To Feed", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}/share-to-feed", + "body": { + "mode": "raw", + "raw": "{\n \"content\": \"{{aiArchiveShareContent}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 201 or 200', function () { pm.expect(pm.response.code).to.be.oneOf([200, 201]); });", + "const json = pm.response.json();", + "pm.expect(json.audioUrl).to.exist;", + "pm.environment.set('ownPostId', json.id || json._id);" + ] + } + } + ] + }, + { + "name": "Delete AI Music Archive Item", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.message).to.eql('Archive item deleted');" ] } } @@ -8192,6 +8370,26 @@ { "key": "socketAccessToken", "value": "{{accessToken}}" + }, + { + "key": "aiArchivePage", + "value": "1" + }, + { + "key": "aiArchiveLimit", + "value": "20" + }, + { + "key": "aiArchiveItemId", + "value": "" + }, + { + "key": "aiArchiveNewTitle", + "value": "Renamed AI Music" + }, + { + "key": "aiArchiveShareContent", + "value": "Shared from my AI music archive" } ] } diff --git a/scripts/check-coverage.js b/scripts/check-coverage.js new file mode 100644 index 0000000..7d7b352 --- /dev/null +++ b/scripts/check-coverage.js @@ -0,0 +1,53 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const summaryPath = path.resolve(process.cwd(), 'coverage', 'coverage-summary.json'); +const thresholds = { + statements: 90, + lines: 90, + functions: 90, + branches: 70, +}; + +if (!fs.existsSync(summaryPath)) { + console.error(`Coverage summary was not found at ${summaryPath}`); + process.exit(1); +} + +const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); +const serviceEntries = Object.entries(summary).filter(([file]) => + file.replace(/\\/g, '/').endsWith('.service.ts'), +); + +if (!serviceEntries.length) { + console.error('Coverage summary did not contain any service files'); + process.exit(1); +} + +let failed = false; +console.log(`\nRuntime service coverage (${serviceEntries.length} files):`); + +for (const [metric, minimum] of Object.entries(thresholds)) { + const totals = serviceEntries.reduce( + (result, [, coverage]) => ({ + covered: result.covered + coverage[metric].covered, + total: result.total + coverage[metric].total, + }), + { covered: 0, total: 0 }, + ); + const percentage = totals.total === 0 ? 100 : (totals.covered / totals.total) * 100; + const formatted = percentage.toFixed(2); + const status = percentage >= minimum ? 'PASS' : 'FAIL'; + console.log( + ` ${metric.padEnd(10)} ${formatted.padStart(6)}% ` + + `(${totals.covered}/${totals.total}, required ${minimum}%) ${status}`, + ); + failed ||= percentage < minimum; +} + +if (failed) { + console.error('\nRuntime service coverage gate failed.'); + process.exit(1); +} + +console.log('\nRuntime service coverage gate passed.'); diff --git a/scripts/load-test.js b/scripts/load-test.js index 7a5fe5a..2d87f86 100644 --- a/scripts/load-test.js +++ b/scripts/load-test.js @@ -11,6 +11,12 @@ function parseArgs(argv) { warmup: 5, headers: {}, body: undefined, + gates: { + minSuccessRate: undefined, + minRequestsPerSecond: undefined, + maxP95Ms: undefined, + maxP99Ms: undefined, + }, }; for (let index = 0; index < argv.length; index += 1) { @@ -53,6 +59,34 @@ function parseArgs(argv) { continue; } + if (arg === '--min-success-rate') { + assertOptionValue(arg, next); + options.gates.minSuccessRate = Number(next); + index += 1; + continue; + } + + if (arg === '--min-rps') { + assertOptionValue(arg, next); + options.gates.minRequestsPerSecond = Number(next); + index += 1; + continue; + } + + if (arg === '--max-p95') { + assertOptionValue(arg, next); + options.gates.maxP95Ms = Number(next); + index += 1; + continue; + } + + if (arg === '--max-p99') { + assertOptionValue(arg, next); + options.gates.maxP99Ms = Number(next); + index += 1; + continue; + } + if (arg === '--header' && next) { const separatorIndex = next.indexOf(':'); if (separatorIndex === -1) { @@ -95,6 +129,14 @@ function parseArgs(argv) { throw new Error('warmup must be zero or a positive integer'); } + validateGate(options.gates.minSuccessRate, 'min-success-rate', { + min: 0, + max: 100, + }); + validateGate(options.gates.minRequestsPerSecond, 'min-rps', { min: 0 }); + validateGate(options.gates.maxP95Ms, 'max-p95', { min: 0 }); + validateGate(options.gates.maxP99Ms, 'max-p99', { min: 0 }); + if (options.body && !options.headers['Content-Type']) { options.headers['Content-Type'] = 'application/json'; } @@ -102,6 +144,27 @@ function parseArgs(argv) { return options; } +function assertOptionValue(option, value) { + if (value === undefined) { + throw new Error(`${option} requires a value`); + } +} + +function validateGate(value, name, limits) { + if (value === undefined) { + return; + } + + const aboveMaximum = limits.max !== undefined && value > limits.max; + if (!Number.isFinite(value) || value < limits.min || aboveMaximum) { + const range = + limits.max === undefined + ? `at least ${limits.min}` + : `between ${limits.min} and ${limits.max}`; + throw new Error(`${name} must be a finite number ${range}`); + } +} + function percentile(sortedValues, p) { if (!sortedValues.length) { return 0; @@ -124,6 +187,9 @@ function average(values) { function printUsage() { console.log('Usage: node scripts/load-test.js --url [--duration 15] [--concurrency 20]'); console.log('Optional: --method POST --header "Authorization: Bearer " --body "{\"key\":\"value\"}"'); + console.log( + 'Performance gates: --min-success-rate 99.9 --min-rps 100 --max-p95 250 --max-p99 500', + ); } async function warmup(options) { @@ -218,6 +284,86 @@ function buildSummary(options, results, totalDurationMs) { }; } +function evaluatePerformanceGates(summary, gates) { + const definitions = [ + { + configuredValue: gates.minSuccessRate, + metric: 'successRate', + label: 'Success rate', + actual: summary.successRate, + operator: '>=', + unit: '%', + passes: (actual, threshold) => actual >= threshold, + }, + { + configuredValue: gates.minRequestsPerSecond, + metric: 'requestsPerSecond', + label: 'Requests/second', + actual: summary.requestsPerSecond, + operator: '>=', + unit: ' req/s', + passes: (actual, threshold) => actual >= threshold, + }, + { + configuredValue: gates.maxP95Ms, + metric: 'latencyMs.p95', + label: 'Latency p95', + actual: summary.latencyMs.p95, + operator: '<=', + unit: ' ms', + passes: (actual, threshold) => actual <= threshold, + }, + { + configuredValue: gates.maxP99Ms, + metric: 'latencyMs.p99', + label: 'Latency p99', + actual: summary.latencyMs.p99, + operator: '<=', + unit: ' ms', + passes: (actual, threshold) => actual <= threshold, + }, + ]; + + const checks = definitions + .filter((definition) => definition.configuredValue !== undefined) + .map((definition) => ({ + metric: definition.metric, + label: definition.label, + actual: definition.actual, + operator: definition.operator, + threshold: definition.configuredValue, + unit: definition.unit, + passed: definition.passes(definition.actual, definition.configuredValue), + })); + + return { + configured: checks.length > 0, + passed: checks.every((check) => check.passed), + checks, + }; +} + +function printGateReport(gateResult) { + console.log(''); + + if (!gateResult.configured) { + console.log('Performance gates: not configured'); + return; + } + + const passedChecks = gateResult.checks.filter((check) => check.passed).length; + console.log( + `Performance gates: ${gateResult.passed ? 'PASSED' : 'FAILED'} (${passedChecks}/${gateResult.checks.length})`, + ); + + for (const check of gateResult.checks) { + const status = check.passed ? 'PASS' : 'FAIL'; + console.log( + `[${status}] ${check.label}: ${check.actual}${check.unit} ${check.operator} ${check.threshold}${check.unit}`, + ); + } +} + async function main() { if (process.argv.includes('--help')) { printUsage(); @@ -249,12 +395,27 @@ async function main() { const totalDurationMs = performance.now() - startedAt; const summary = buildSummary(options, results, totalDurationMs); + const performanceGates = evaluatePerformanceGates(summary, options.gates); + summary.performanceGates = performanceGates; console.log(''); console.log(JSON.stringify(summary, null, 2)); + printGateReport(performanceGates); + + if (!performanceGates.passed) { + process.exitCode = 2; + } } -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; -}); +if (require.main === module) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} + +module.exports = { + buildSummary, + evaluatePerformanceGates, + parseArgs, +}; diff --git a/scripts/load-test.test.js b/scripts/load-test.test.js new file mode 100644 index 0000000..6ffb34d --- /dev/null +++ b/scripts/load-test.test.js @@ -0,0 +1,123 @@ +const assert = require('node:assert/strict'); +const { execFile } = require('node:child_process'); +const { createServer } = require('node:http'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { evaluatePerformanceGates, parseArgs } = require('./load-test'); + +const passingSummary = { + requestsPerSecond: 250.25, + successRate: 99.95, + latencyMs: { p95: 120.5, p99: 180.75 }, +}; + +test('parseArgs reads all optional performance gates', () => { + const options = parseArgs([ + '--min-success-rate', + '99.9', + '--min-rps', + '200', + '--max-p95', + '150', + '--max-p99', + '250', + ]); + + assert.deepEqual(options.gates, { + minSuccessRate: 99.9, + minRequestsPerSecond: 200, + maxP95Ms: 150, + maxP99Ms: 250, + }); +}); + +test('parseArgs rejects invalid gate values', () => { + assert.throws( + () => parseArgs(['--min-success-rate', '100.1']), + /min-success-rate must be a finite number between 0 and 100/, + ); + assert.throws(() => parseArgs(['--min-rps', '-1']), /min-rps must be a finite number/); + assert.throws(() => parseArgs(['--max-p95']), /--max-p95 requires a value/); +}); + +test('evaluatePerformanceGates passes when every threshold is satisfied', () => { + const result = evaluatePerformanceGates(passingSummary, { + minSuccessRate: 99.9, + minRequestsPerSecond: 200, + maxP95Ms: 150, + maxP99Ms: 200, + }); + + assert.equal(result.configured, true); + assert.equal(result.passed, true); + assert.equal(result.checks.length, 4); + assert.ok(result.checks.every((check) => check.passed)); +}); + +test('evaluatePerformanceGates reports every failed threshold', () => { + const result = evaluatePerformanceGates(passingSummary, { + minSuccessRate: 100, + minRequestsPerSecond: 300, + maxP95Ms: 100, + maxP99Ms: 150, + }); + + assert.equal(result.passed, false); + assert.deepEqual( + result.checks.filter((check) => !check.passed).map((check) => check.metric), + ['successRate', 'requestsPerSecond', 'latencyMs.p95', 'latencyMs.p99'], + ); +}); + +test('load-test process exits with code 2 and a clear report when a gate fails', async () => { + const server = createServer((_request, response) => { + response.statusCode = 503; + response.end('unavailable'); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + + try { + const address = server.address(); + assert.ok(address && typeof address === 'object'); + + const result = await runLoadTest([ + '--url', + `http://127.0.0.1:${address.port}`, + '--duration', + '0.05', + '--concurrency', + '1', + '--warmup', + '0', + '--min-success-rate', + '100', + ]); + + assert.equal(result.exitCode, 2); + assert.match(result.stdout, /Performance gates: FAILED \(0\/1\)/); + assert.match(result.stdout, /\[FAIL\] Success rate:/); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +function runLoadTest(args) { + const scriptPath = path.join(__dirname, 'load-test.js'); + + return new Promise((resolve) => { + execFile(process.execPath, [scriptPath, ...args], (error, stdout, stderr) => { + resolve({ + exitCode: error && typeof error.code === 'number' ? error.code : 0, + stdout, + stderr, + }); + }); + }); +} diff --git a/scripts/startup-benchmark.js b/scripts/startup-benchmark.js index 31b70af..d26f22b 100644 --- a/scripts/startup-benchmark.js +++ b/scripts/startup-benchmark.js @@ -139,6 +139,16 @@ async function main() { 2, ), ); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + [ + reason, + `Child exit code: ${child.exitCode ?? 'still-running'}`, + `Recent stdout: ${stdoutLines.join(' | ') || 'none'}`, + `Recent stderr: ${stderrLines.join(' | ') || 'none'}`, + ].join('\n'), + ); } finally { await terminate(child); } diff --git a/scripts/sync-atlas-search-indexes.js b/scripts/sync-atlas-search-indexes.js new file mode 100644 index 0000000..af895e9 --- /dev/null +++ b/scripts/sync-atlas-search-indexes.js @@ -0,0 +1,58 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const mongoose = require('mongoose'); + +const projectRoot = path.resolve(__dirname, '..'); +require('dotenv').config({ path: path.join(projectRoot, '.env') }); + +const indexSpecs = [ + { + collection: 'users', + name: process.env.SEARCH_ATLAS_USER_INDEX || 'users_search', + definitionPath: path.join(projectRoot, 'ops', 'atlas-search', 'users_search.json'), + }, + { + collection: 'posts', + name: process.env.SEARCH_ATLAS_POST_INDEX || 'posts_search', + definitionPath: path.join(projectRoot, 'ops', 'atlas-search', 'posts_search.json'), + }, +]; + +async function syncIndex(database, spec) { + const collection = database.collection(spec.collection); + const definition = JSON.parse(fs.readFileSync(spec.definitionPath, 'utf8')); + const existing = await collection.listSearchIndexes(spec.name).toArray(); + + if (existing.length) { + await collection.updateSearchIndex(spec.name, definition); + process.stdout.write(`Updated Atlas Search index ${spec.name} on ${spec.collection}\n`); + return; + } + + await collection.createSearchIndex({ name: spec.name, definition }); + process.stdout.write(`Created Atlas Search index ${spec.name} on ${spec.collection}\n`); +} + +async function main() { + const uri = process.env.MONGODB_URI; + if (!uri) { + throw new Error('MONGODB_URI is required'); + } + + await mongoose.connect(uri, { serverSelectionTimeoutMS: 15_000 }); + try { + if (!mongoose.connection.db) { + throw new Error('MongoDB connection has no selected database'); + } + for (const spec of indexSpecs) { + await syncIndex(mongoose.connection.db, spec); + } + } finally { + await mongoose.disconnect(); + } +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/src/adapter-contracts.spec.ts b/src/adapter-contracts.spec.ts new file mode 100644 index 0000000..916c8d1 --- /dev/null +++ b/src/adapter-contracts.spec.ts @@ -0,0 +1,372 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import * as ts from 'typescript'; + +const sourceRoot = resolve(__dirname); +const validObjectId = '507f1f77bcf86cd799439011'; + +type Constructor = new (...args: any[]) => any; +type Operation = { dependency: number; method: string; args: unknown[] }; + +function collectAdapterFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + return collectAdapterFiles(path); + } + return entry.name.endsWith('.controller.ts') || entry.name.endsWith('.repository.ts') ? [path] : []; + }); +} + +function publicMethodParameters(file: string, className: string): Map { + const source = ts.createSourceFile( + file, + readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const methods = new Map(); + + source.forEachChild((node) => { + if (!ts.isClassDeclaration(node) || node.name?.text !== className) return; + for (const member of node.members) { + if (!ts.isMethodDeclaration(member) || !member.name) continue; + const isPrivate = member.modifiers?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword, + ); + if (isPrivate) continue; + methods.set( + member.name.getText(source).replace(/^['"]|['"]$/g, ''), + member.parameters.map((parameter) => parameter.name.getText(source)), + ); + } + }); + + return methods; +} + +function createTransportValue(): any { + const target = function transportValue() { + return value; + }; + Object.assign(target, { + status: 'ready', + success: true, + checks: {}, + items: [], + data: [], + user: { + sub: validObjectId, + userId: validObjectId, + id: validObjectId, + email: 'member@example.com', + permissions: [], + }, + }); + const value: any = new Proxy(target, { + apply: () => value, + get: (object, property) => { + if (property === 'then') return undefined; + if (property === Symbol.iterator) return function* emptyIterator() {}; + if (property === Symbol.toPrimitive) return () => validObjectId; + if (property === 'toString' || property === 'valueOf') return () => validObjectId; + if (property === 'map' || property === 'filter' || property === 'slice') return () => []; + if (Reflect.has(object, property)) return Reflect.get(object, property); + return value; + }, + }); + return value; +} + +function representativePayload() { + return { + id: validObjectId, + _id: validObjectId, + sub: validObjectId, + userId: validObjectId, + authorId: validObjectId, + ownerId: validObjectId, + ownerAdminId: validObjectId, + postId: validObjectId, + commentId: validObjectId, + targetId: validObjectId, + conversationId: validObjectId, + participantId: validObjectId, + recipientId: validObjectId, + parentCommentId: validObjectId, + email: 'member@example.com', + username: 'member', + name: 'Member', + content: 'contract probe', + title: 'Contract probe', + reason: 'contract probe', + type: 'post', + targetType: 'post', + resourceType: 'post', + status: 'active', + action: 'activate', + isActive: true, + isDisabled: false, + targetIds: [validObjectId], + participantIds: [validObjectId], + userIds: [validObjectId], + deviceId: 'device-contract', + fcmToken: 'token-contract', + imageUrls: [], + tags: [], + page: 1, + limit: 2, + }; +} + +function argumentFor(parameterName: string): unknown { + const name = parameterName.toLowerCase(); + const upload = { + originalname: 'image.jpg', + mimetype: 'image/jpeg', + size: 3, + buffer: Buffer.from([0xff, 0xd8, 0xff]), + }; + if (name === 'req' || name === 'request' || name.endsWith('request')) { + return { + user: representativePayload(), + headers: { 'x-request-id': 'contract-request' }, + method: 'GET', + originalUrl: '/contract', + }; + } + if (name === 'res' || name.includes('response')) { + const response: any = new Proxy( + {}, + { + get: (target, property) => { + if (!Reflect.has(target, property)) { + Reflect.set(target, property, jest.fn(() => response)); + } + return Reflect.get(target, property); + }, + }, + ); + return response; + } + if (name === 'user' || name.includes('currentuser')) return representativePayload(); + if (name.includes('files')) { + return Object.assign([upload], { + imageFiles: [upload], + shopImageFiles: [upload], + avatarFile: [upload], + audioFile: [upload], + videoFile: [upload], + }); + } + if (name.includes('file')) return upload; + if (/(^|_)(ids|idlist)$/.test(name) || name.endsWith('ids')) return [validObjectId]; + if (name.endsWith('id') || name === 'jti') return validObjectId; + if (name.includes('limit')) return 2; + if (name.includes('skip') || name.includes('offset')) return 0; + if (name.includes('page')) return 1; + if (name.includes('direction')) return -1; + if (name.includes('session')) return undefined; + if (name.includes('email')) return 'member@example.com'; + if (name.endsWith('names') || name === 'usernames') return ['member']; + if (name.includes('text') || name.includes('content') || name.includes('title') || name.includes('reason')) { + return 'contract probe'; + } + if (name === 'q' || name.includes('search')) return 'contract'; + if (name.includes('sort')) return { createdAt: -1 }; + if (name.includes('filter')) return {}; + return representativePayload(); +} + +function createControllerDependency(index: number, operations: Operation[]) { + const methods = new Map(); + return new Proxy( + {}, + { + get: (_target, property) => { + if (property === 'then') return undefined; + if (!methods.has(property)) { + methods.set( + property, + jest.fn((...args: unknown[]) => { + operations.push({ dependency: index, method: String(property), args }); + return createTransportValue(); + }), + ); + } + return methods.get(property); + }, + }, + ); +} + +function createRepositoryModel(index: number, operations: Operation[]) { + const document: any = { + id: validObjectId, + _id: validObjectId, + participantIds: [validObjectId], + read: false, + modifiedCount: 1, + deletedCount: 1, + populate: jest.fn(async () => document), + save: jest.fn(async (...args: unknown[]): Promise => { + operations.push({ dependency: index, method: 'document.save', args }); + return document; + }), + }; + const createQuery = () => { + const queryResult: any[] = []; + Object.assign(queryResult, document); + const query: any = new Proxy( + {}, + { + get: (_target, property) => { + if (property === 'then') return undefined; + if (property === 'exec') { + return jest.fn(async () => { + operations.push({ dependency: index, method: 'query.exec', args: [] }); + return queryResult; + }); + } + return jest.fn((...args: unknown[]) => { + operations.push({ dependency: index, method: `query.${String(property)}`, args }); + return query; + }); + }, + }, + ); + return query; + }; + const methods = new Map(); + const modelTarget = function modelConstructor() { + return document; + }; + + return new Proxy(modelTarget, { + construct: (_target, args) => { + operations.push({ dependency: index, method: 'new', args }); + return document; + }, + get: (_target, property) => { + if (property === 'collection') return { name: `collection_${index}` }; + if (!methods.has(property)) { + methods.set( + property, + jest.fn((...args: unknown[]) => { + operations.push({ dependency: index, method: String(property), args }); + return property === 'create' + ? Promise.resolve(Array.isArray(args[0]) ? [document] : document) + : createQuery(); + }), + ); + } + return methods.get(property); + }, + }); +} + +describe('controller and repository contracts', () => { + const files = collectAdapterFiles(sourceRoot); + + it.each(files.filter((file) => file.endsWith('.controller.ts')))( + '%s delegates every public handler or enforces its documented local rejection', + async (file) => { + const moduleExports = require(file) as Record; + const controllers = Object.values(moduleExports).filter( + (exported): exported is Constructor => + typeof exported === 'function' && exported.name.endsWith('Controller'), + ); + expect(controllers).not.toHaveLength(0); + + for (const Controller of controllers) { + const operations: Operation[] = []; + const dependencies = Array.from({ length: Controller.length }, (_, index) => + createControllerDependency(index, operations), + ); + const instance = Reflect.construct(Controller, dependencies); + const methods = publicMethodParameters(file, Controller.name); + expect(methods.size).toBeGreaterThan(0); + + for (const [method, parameters] of methods) { + const key = `${Controller.name}.${method}`; + const args = parameters.map(argumentFor); + const before = operations.length; + + if (key === 'ChatController.messagesMissingConversationId') { + await expect(Promise.resolve().then(() => instance[method](...args))).rejects.toThrow( + 'conversationId is required', + ); + expect(operations).toHaveLength(before); + continue; + } + if (key === 'AuthController.googleAuth') { + await expect(Promise.resolve(instance[method](...args))).resolves.toBeUndefined(); + expect(operations).toHaveLength(before); + continue; + } + + try { + await instance[method](...args); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${key} failed its delegation contract: ${detail}`); + } + const delegated = operations.slice(before); + if (!delegated.length) { + throw new Error(`${key} did not delegate to an injected dependency`); + } + expect(delegated[0]).toEqual( + expect.objectContaining({ dependency: expect.any(Number), method: expect.any(String) }), + ); + } + } + }, + ); + + it.each(files.filter((file) => file.endsWith('.repository.ts')))( + '%s routes every public operation through its persistence model', + async (file) => { + const moduleExports = require(file) as Record; + const repositories = Object.values(moduleExports).filter( + (exported): exported is Constructor => + typeof exported === 'function' && exported.name.endsWith('Repository'), + ); + expect(repositories).not.toHaveLength(0); + + for (const Repository of repositories) { + const operations: Operation[] = []; + const dependencies = Array.from({ length: Repository.length }, (_, index) => + createRepositoryModel(index, operations), + ); + const instance = Reflect.construct(Repository, dependencies); + const methods = publicMethodParameters(file, Repository.name); + expect(methods.size).toBeGreaterThan(0); + + for (const [method, parameters] of methods) { + const before = operations.length; + const args = parameters.map(argumentFor); + if (`${Repository.name}.${method}` === 'FollowsRepository.isDuplicateKeyError') { + expect(instance[method]({ code: 11000 })).toBe(true); + expect(instance[method]({ code: 1 })).toBe(false); + expect(operations).toHaveLength(before); + continue; + } + try { + await instance[method](...args); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${Repository.name}.${method} failed its persistence contract: ${detail}`); + } + + const persistenceCalls = operations.slice(before); + if (!persistenceCalls.length) { + throw new Error(`${Repository.name}.${method} did not reach an injected persistence model`); + } + expect(persistenceCalls.some((operation) => operation.method !== 'toString')).toBe(true); + } + } + }, + ); +}); diff --git a/src/app.controller.ts b/src/app.controller.ts index 646fe3f..7f1a7db 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'; import { AppService } from './app.service'; @Controller('health') @@ -9,4 +9,11 @@ export class AppController { getHealth(): { status: string; service: string } { return this.appService.getHealth(); } + + @Get('ready') + async getReadiness() { + const readiness = await this.appService.getReadiness(); + if (readiness.status !== 'ready') throw new ServiceUnavailableException(readiness); + return readiness; + } } diff --git a/src/app.module.ts b/src/app.module.ts index 1e4c361..216de95 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,9 @@ import { validationSchema } from './config/validation.schema'; import { DatabaseModule } from './database/database.module'; import { CacheModule } from './infrastructure/cache/cache.module'; import { LoggingModule } from './infrastructure/logging/logging.module'; +import { MetricsModule } from './infrastructure/metrics/metrics.module'; +import { MaintenanceModule } from './infrastructure/maintenance/maintenance.module'; +import { ReliabilityModule } from './infrastructure/reliability/reliability.module'; import { QueueModule } from './infrastructure/queue/queue.module'; import { RedisModule } from './infrastructure/redis/redis.module'; import { StorageModule } from './infrastructure/storage/storage.module'; @@ -19,6 +22,7 @@ import { CommentsModule } from './modules/comments/comments.module'; import { CollaborationRequestsModule } from './modules/collaboration-requests/collaboration-requests.module'; import { DevicesModule } from './modules/devices/devices.module'; import { FeedModule } from './modules/feed/feed.module'; +import { EngagementModule } from './modules/engagement/engagement.module'; import { FollowsModule } from './modules/follows/follows.module'; import { LikesModule } from './modules/likes/likes.module'; import { MediaModule } from './modules/media/media.module'; @@ -46,6 +50,9 @@ import { MediaUrlInterceptor } from './common/interceptors/media-url.interceptor validationSchema, }), LoggingModule, + MetricsModule, + ReliabilityModule, + MaintenanceModule, RedisModule, CacheModule, StorageModule, @@ -62,6 +69,7 @@ import { MediaUrlInterceptor } from './common/interceptors/media-url.interceptor LikesModule, FollowsModule, FeedModule, + EngagementModule, NotificationsModule, OutboxModule, ChatModule, diff --git a/src/app.service.spec.ts b/src/app.service.spec.ts new file mode 100644 index 0000000..1c97239 --- /dev/null +++ b/src/app.service.spec.ts @@ -0,0 +1,42 @@ +import { AppService } from './app.service'; + +describe('AppService readiness', () => { + const createService = (options: { mongoFails?: boolean; storageWritable?: boolean } = {}) => { + const connection = { db: { admin: () => ({ + ping: options.mongoFails ? jest.fn().mockRejectedValue(new Error('mongo down')) : jest.fn().mockResolvedValue({ ok: 1 }), + }) } }; + const redis = { isEnabled: jest.fn().mockReturnValue(false), getClient: jest.fn() }; + const storage = { getHealth: jest.fn().mockResolvedValue({ + provider: 'local', local: { readable: true, writable: options.storageWritable ?? true }, + }) }; + const shutdown = { + isDraining: jest.fn().mockReturnValue(false), + getActiveRequests: jest.fn().mockReturnValue(0), + }; + return { + service: new AppService(connection as any, redis as any, storage as any, shutdown as any), + shutdown, + }; + }; + + it('reports ready when required dependencies are healthy', async () => { + await expect(createService().service.getReadiness()).resolves.toEqual(expect.objectContaining({ status: 'ready' })); + }); + + it('reports degraded and identifies failed dependencies', async () => { + const result = await createService({ mongoFails: true, storageWritable: false }).service.getReadiness(); + expect(result).toEqual(expect.objectContaining({ status: 'degraded' })); + expect((result.checks as any).mongodb.status).toBe('down'); + expect((result.checks as any).storage.status).toBe('down'); + }); + + it('reports draining immediately during graceful shutdown', async () => { + const { service, shutdown } = createService(); + shutdown.isDraining.mockReturnValue(true); + shutdown.getActiveRequests.mockReturnValue(2); + + await expect(service.getReadiness()).resolves.toEqual( + expect.objectContaining({ status: 'draining', activeRequests: 2 }), + ); + }); +}); diff --git a/src/app.service.ts b/src/app.service.ts index 08e7ba8..cf7f935 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -1,8 +1,87 @@ import { Injectable } from '@nestjs/common'; +import { InjectConnection } from '@nestjs/mongoose'; +import { Connection } from 'mongoose'; +import { ManagedStorageService } from './infrastructure/storage/managed-storage.service'; +import { RedisService } from './infrastructure/redis/redis.service'; +import { ShutdownCoordinatorService } from './infrastructure/reliability/shutdown-coordinator.service'; @Injectable() export class AppService { + constructor( + @InjectConnection() private readonly connection: Connection, + private readonly redis: RedisService, + private readonly storage: ManagedStorageService, + private readonly shutdownCoordinator: ShutdownCoordinatorService, + ) {} + getHealth(): { status: string; service: string } { return { status: 'ok', service: 'oudelaa-backend' }; } + + async getReadiness(): Promise> { + const checks: Record = {}; + if (this.shutdownCoordinator.isDraining()) { + checks.lifecycle = { status: 'down', error: 'Application is draining for shutdown' }; + return { + status: 'draining', + service: 'oudelaa-backend', + checks, + activeRequests: this.shutdownCoordinator.getActiveRequests(), + }; + } + checks.lifecycle = { status: 'up' }; + try { + if (!this.connection.db) throw new Error('MongoDB connection is unavailable'); + await this.withTimeout(this.connection.db.admin().ping(), 3000, 'MongoDB ping timeout'); + checks.mongodb = { status: 'up' }; + } catch (error) { + checks.mongodb = { status: 'down', error: this.errorMessage(error) }; + } + + if (this.redis.isEnabled()) { + try { + const client = this.redis.getClient(); + if (!client) throw new Error('Redis client is unavailable'); + await this.withTimeout(client.ping(), 2000, 'Redis ping timeout'); + checks.redis = { status: 'up' }; + } catch (error) { + checks.redis = { status: 'down', error: this.errorMessage(error) }; + } + } else { + checks.redis = { status: 'up' }; + } + + try { + const health = await this.withTimeout(this.storage.getHealth(), 5000, 'Storage check timeout'); + const local = health.local as { readable?: boolean; writable?: boolean } | undefined; + const s3 = health.s3 as { reachable?: boolean; configured?: boolean } | undefined; + const storageUp = health.provider === 'local' + ? local?.readable === true && local?.writable === true + : s3?.configured === true && s3?.reachable === true; + checks.storage = storageUp + ? { status: 'up' } + : { status: 'down', error: 'Storage is not readable/writable or reachable' }; + } catch (error) { + checks.storage = { status: 'down', error: this.errorMessage(error) }; + } + + const ready = Object.values(checks).every((check) => check.status === 'up'); + return { status: ready ? 'ready' : 'degraded', service: 'oudelaa-backend', checks }; + } + + private async withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), timeoutMs); }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown dependency error'; + } } diff --git a/src/common/decorators/request-timeout.decorator.ts b/src/common/decorators/request-timeout.decorator.ts new file mode 100644 index 0000000..20b93de --- /dev/null +++ b/src/common/decorators/request-timeout.decorator.ts @@ -0,0 +1,10 @@ +import { SetMetadata } from '@nestjs/common'; + +export const REQUEST_TIMEOUT_METADATA = 'request_timeout_ms'; + +/** Override the global request timeout for a controller or a single route. */ +export const RequestTimeout = (timeoutMs: number) => + SetMetadata(REQUEST_TIMEOUT_METADATA, timeoutMs); + +/** Disable the application-level timeout for a route that intentionally streams indefinitely. */ +export const DisableRequestTimeout = () => SetMetadata(REQUEST_TIMEOUT_METADATA, 0); diff --git a/src/common/enums/notification-type.enum.ts b/src/common/enums/notification-type.enum.ts index 28a238d..a273fe1 100644 --- a/src/common/enums/notification-type.enum.ts +++ b/src/common/enums/notification-type.enum.ts @@ -2,6 +2,7 @@ export enum NotificationType { LIKE = 'like', COMMENT = 'comment', FOLLOW = 'follow', + FOLLOW_REQUEST_APPROVED = 'follow_request_approved', MESSAGE = 'message', SAVE = 'save', SHARE = 'share', diff --git a/src/common/guards/authorization.guards.spec.ts b/src/common/guards/authorization.guards.spec.ts new file mode 100644 index 0000000..28c80a1 --- /dev/null +++ b/src/common/guards/authorization.guards.spec.ts @@ -0,0 +1,137 @@ +import { + ExecutionContext, + ForbiddenException, + HttpException, + UnsupportedMediaTypeException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { MultipartFormDataGuard } from './multipart-form-data.guard'; +import { RolesGuard } from './roles.guard'; +import { SuperAdminPermissionsGuard } from './superadmin-permissions.guard'; +import { ThrottleGuard } from './throttle.guard'; + +const contextFor = (request: Record): ExecutionContext => + ({ + getHandler: () => function handler() {}, + getClass: () => class Controller {}, + switchToHttp: () => ({ getRequest: () => request }), + }) as unknown as ExecutionContext; + +describe('authorization guards', () => { + describe('RolesGuard', () => { + it('allows routes without role metadata', () => { + const reflector = { getAllAndOverride: jest.fn().mockReturnValue(undefined) }; + const guard = new RolesGuard(reflector as unknown as Reflector); + + expect(guard.canActivate(contextFor({}))).toBe(true); + }); + + it.each([ + [{ role: 'admin' }, true], + [{ roles: ['support', 'admin'] }, true], + [{ role: 'user' }, false], + [{}, false], + ])('evaluates normalized payload roles %#', (user, expected) => { + const reflector = { getAllAndOverride: jest.fn().mockReturnValue(['admin']) }; + const guard = new RolesGuard(reflector as unknown as Reflector); + + expect(guard.canActivate(contextFor({ user }))).toBe(expected); + }); + }); + + describe('SuperAdminPermissionsGuard', () => { + it('allows routes without permission requirements', () => { + const reflector = { getAllAndOverride: jest.fn().mockReturnValue([]) }; + const guard = new SuperAdminPermissionsGuard(reflector as unknown as Reflector); + + expect(guard.canActivate(contextFor({}))).toBe(true); + }); + + it('requires every declared permission', () => { + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue(['users.read', 'users.manage']), + }; + const guard = new SuperAdminPermissionsGuard(reflector as unknown as Reflector); + + expect( + guard.canActivate( + contextFor({ user: { permissions: ['users.read', 'users.manage', 'audit.read'] } }), + ), + ).toBe(true); + expect(() => + guard.canActivate(contextFor({ user: { permissions: ['users.read'] } })), + ).toThrow(ForbiddenException); + expect(() => guard.canActivate(contextFor({}))).toThrow( + 'Missing superadmin permission', + ); + }); + }); + + describe('ThrottleGuard', () => { + it('does not touch cache for an unthrottled route', async () => { + const reflector = { getAllAndOverride: jest.fn().mockReturnValue(undefined) }; + const cache = { incr: jest.fn() }; + const guard = new ThrottleGuard(reflector as any, cache as any); + + await expect(guard.canActivate(contextFor({}))).resolves.toBe(true); + expect(cache.incr).not.toHaveBeenCalled(); + }); + + it('uses authenticated subject and a minimum one-second window', async () => { + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue({ limit: 2, windowMs: 10 }), + }; + const cache = { incr: jest.fn().mockResolvedValue(2) }; + const guard = new ThrottleGuard(reflector as any, cache as any); + + await expect( + guard.canActivate( + contextFor({ + user: { sub: 'user-1' }, + ip: '127.0.0.1', + baseUrl: '/api/v1/auth', + route: { path: '/login' }, + }), + ), + ).resolves.toBe(true); + expect(cache.incr).toHaveBeenCalledWith( + 'rate-limit:/api/v1/auth/login:user-1', + 1, + ); + }); + + it('falls back to IP/original URL and rejects requests above the limit', async () => { + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue({ limit: 1, windowMs: 1_500 }), + }; + const cache = { incr: jest.fn().mockResolvedValue(2) }; + const guard = new ThrottleGuard(reflector as any, cache as any); + + await expect( + guard.canActivate(contextFor({ ip: '10.0.0.2', originalUrl: '/fallback' })), + ).rejects.toBeInstanceOf(HttpException); + expect(cache.incr).toHaveBeenCalledWith('rate-limit:/fallback:10.0.0.2', 2); + }); + + it('uses stable unknown placeholders if request identity and route are absent', async () => { + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue({ limit: 1, windowMs: 1_000 }), + }; + const cache = { incr: jest.fn().mockResolvedValue(1) }; + const guard = new ThrottleGuard(reflector as any, cache as any); + + await expect(guard.canActivate(contextFor({}))).resolves.toBe(true); + expect(cache.incr).toHaveBeenCalledWith('rate-limit:unknown-route:unknown', 1); + }); + }); + + describe('MultipartFormDataGuard', () => { + it('accepts multipart requests and rejects other media types', () => { + const guard = new MultipartFormDataGuard(); + expect(guard.canActivate(contextFor({ is: jest.fn().mockReturnValue(true) }))).toBe(true); + expect(() => + guard.canActivate(contextFor({ is: jest.fn().mockReturnValue(false) })), + ).toThrow(UnsupportedMediaTypeException); + }); + }); +}); diff --git a/src/common/interceptors/response-envelope.interceptor.spec.ts b/src/common/interceptors/response-envelope.interceptor.spec.ts new file mode 100644 index 0000000..437a3db --- /dev/null +++ b/src/common/interceptors/response-envelope.interceptor.spec.ts @@ -0,0 +1,29 @@ +import { CallHandler, ExecutionContext } from '@nestjs/common'; +import { firstValueFrom, of } from 'rxjs'; +import { ResponseEnvelopeInterceptor } from './response-envelope.interceptor'; + +describe('ResponseEnvelopeInterceptor', () => { + it('wraps data with response status and timestamp', async () => { + const context = { + switchToHttp: () => ({ getResponse: () => ({ statusCode: 201 }) }), + } as unknown as ExecutionContext; + const next = { handle: () => of({ id: 'one' }) } as CallHandler; + const value = await firstValueFrom(new ResponseEnvelopeInterceptor().intercept(context, next)); + expect(value).toEqual({ + data: { id: 'one' }, + meta: { statusCode: 201, timestamp: expect.any(String) }, + }); + }); + + it('uses HTTP 200 when an adapter does not expose a status', async () => { + const context = { + switchToHttp: () => ({ getResponse: () => ({}) }), + } as unknown as ExecutionContext; + const value = await firstValueFrom( + new ResponseEnvelopeInterceptor().intercept(context, { handle: () => of(null) }), + ); + expect(value).toEqual( + expect.objectContaining({ meta: expect.objectContaining({ statusCode: 200 }) }), + ); + }); +}); diff --git a/src/common/media/allowed-media.ts b/src/common/media/allowed-media.ts index c8ce5df..d45dacb 100644 --- a/src/common/media/allowed-media.ts +++ b/src/common/media/allowed-media.ts @@ -7,6 +7,7 @@ export type MediaUploadFile = { originalname?: string; mimetype?: string; size?: number; + buffer?: Buffer; }; export const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] as const; @@ -168,17 +169,62 @@ function resolveMediaExtensionOrThrow(file: MediaUploadFile, type: AllowedMediaT function resolveMediaExtension(file: MediaUploadFile, type: AllowedMediaType): string | null { const extension = getFileExtension(file.originalname); - if (EXTENSIONS_BY_TYPE[type].includes(extension)) { - return extension; - } - const mimetype = file.mimetype?.toLowerCase(); - if (mimetype && EXTENSION_BY_MIMETYPE[type][mimetype]) { - return EXTENSION_BY_MIMETYPE[type][mimetype]; + const mimeExtension = mimetype ? EXTENSION_BY_MIMETYPE[type][mimetype] : undefined; + const extensionAllowed = EXTENSIONS_BY_TYPE[type].includes(extension); + + if (extension && !extensionAllowed) { + return null; + } + if (mimetype && !mimeExtension) { + return null; + } + if (extensionAllowed && mimeExtension && !extensionsAreCompatible(extension, mimeExtension)) { + return null; } - // TODO: Add magic-byte sniffing before tightening this contract to require both extension and mimetype. - return null; + const resolved = extensionAllowed ? extension : mimeExtension; + if (!resolved) { + return null; + } + if (file.buffer && file.buffer.length >= 12 && !hasExpectedFileSignature(file.buffer, resolved)) { + return null; + } + return resolved; +} + +function extensionsAreCompatible(left: string, right: string): boolean { + const normalize = (value: string) => (value === '.jpeg' ? '.jpg' : value); + return normalize(left) === normalize(right); +} + +function hasExpectedFileSignature(buffer: Buffer, extension: string): boolean { + const ascii = buffer.subarray(0, 16).toString('ascii'); + switch (extension) { + case '.jpg': + case '.jpeg': + return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; + case '.png': + return buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + case '.webp': + return ascii.startsWith('RIFF') && ascii.slice(8, 12) === 'WEBP'; + case '.wav': + return ascii.startsWith('RIFF') && ascii.slice(8, 12) === 'WAVE'; + case '.ogg': + return ascii.startsWith('OggS'); + case '.webm': + return buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3; + case '.mp3': + return ascii.startsWith('ID3') || (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0); + case '.aac': + return buffer[0] === 0xff && (buffer[1] & 0xf6) === 0xf0; + case '.m4a': + case '.mp4': + case '.mov': + return ascii.slice(4, 8) === 'ftyp'; + default: + return false; + } } function defaultInvalidMessage(type: AllowedMediaType): string { diff --git a/src/common/media/direct-upload.policy.ts b/src/common/media/direct-upload.policy.ts new file mode 100644 index 0000000..b7d8bf8 --- /dev/null +++ b/src/common/media/direct-upload.policy.ts @@ -0,0 +1,93 @@ +import { + AllowedMediaType, + AUDIO_MIMETYPES, + IMAGE_MIMETYPES, + MEDIA_MAX_SIZE_BYTES, + VIDEO_MIMETYPES, +} from './allowed-media'; + +export const DIRECT_UPLOAD_FOLDERS = [ + 'posts/images', + 'posts/video', + 'posts/audio', + 'users/avatar', + 'users/cover', + 'chat/media', + 'marketplace/images', + 'support/images', + 'collaboration/audio', +] as const; + +export type DirectUploadFolder = (typeof DIRECT_UPLOAD_FOLDERS)[number]; + +export const DIRECT_UPLOAD_MIME_TYPES = [ + ...IMAGE_MIMETYPES, + ...AUDIO_MIMETYPES, + ...VIDEO_MIMETYPES, +] as const; + +export type DirectUploadMimeType = (typeof DIRECT_UPLOAD_MIME_TYPES)[number]; + +type DirectUploadMediaDefinition = { + extension: string; + mediaType: AllowedMediaType; +}; + +type DirectUploadFolderPolicy = Partial>; + +const MEDIA_BY_MIME_TYPE: Record = { + 'image/jpeg': { extension: '.jpg', mediaType: 'image' }, + 'image/png': { extension: '.png', mediaType: 'image' }, + 'image/webp': { extension: '.webp', mediaType: 'image' }, + 'audio/mpeg': { extension: '.mp3', mediaType: 'audio' }, + 'audio/mp3': { extension: '.mp3', mediaType: 'audio' }, + 'audio/mp4': { extension: '.m4a', mediaType: 'audio' }, + 'audio/x-m4a': { extension: '.m4a', mediaType: 'audio' }, + 'audio/m4a': { extension: '.m4a', mediaType: 'audio' }, + 'audio/wav': { extension: '.wav', mediaType: 'audio' }, + 'audio/x-wav': { extension: '.wav', mediaType: 'audio' }, + 'audio/wave': { extension: '.wav', mediaType: 'audio' }, + 'audio/aac': { extension: '.aac', mediaType: 'audio' }, + 'audio/ogg': { extension: '.ogg', mediaType: 'audio' }, + 'audio/webm': { extension: '.webm', mediaType: 'audio' }, + 'video/mp4': { extension: '.mp4', mediaType: 'video' }, + 'video/quicktime': { extension: '.mov', mediaType: 'video' }, + 'video/webm': { extension: '.webm', mediaType: 'video' }, +}; + +const FOLDER_POLICIES: Record = { + 'posts/images': { image: MEDIA_MAX_SIZE_BYTES.postsImage }, + 'posts/video': { video: MEDIA_MAX_SIZE_BYTES.postsVideo }, + 'posts/audio': { audio: MEDIA_MAX_SIZE_BYTES.postsAudio }, + 'users/avatar': { image: MEDIA_MAX_SIZE_BYTES.userImage }, + 'users/cover': { image: MEDIA_MAX_SIZE_BYTES.userImage }, + 'chat/media': { + image: MEDIA_MAX_SIZE_BYTES.postsImage, + audio: MEDIA_MAX_SIZE_BYTES.postsAudio, + video: MEDIA_MAX_SIZE_BYTES.postsVideo, + }, + 'marketplace/images': { image: MEDIA_MAX_SIZE_BYTES.marketplaceImage }, + 'support/images': { image: MEDIA_MAX_SIZE_BYTES.supportImage }, + 'collaboration/audio': { audio: MEDIA_MAX_SIZE_BYTES.collaborationAudio }, +}; + +export type ResolvedDirectUploadPolicy = DirectUploadMediaDefinition & { + maxSizeBytes: number; +}; + +export function resolveDirectUploadPolicy( + folder: string, + mimeType: string, +): ResolvedDirectUploadPolicy | null { + if (!DIRECT_UPLOAD_FOLDERS.includes(folder as DirectUploadFolder)) { + return null; + } + + const media = MEDIA_BY_MIME_TYPE[mimeType as DirectUploadMimeType]; + if (!media) { + return null; + } + + const maxSizeBytes = FOLDER_POLICIES[folder as DirectUploadFolder][media.mediaType]; + return maxSizeBytes ? { ...media, maxSizeBytes } : null; +} diff --git a/src/common/media/hls-playlist.service.spec.ts b/src/common/media/hls-playlist.service.spec.ts index f98e1e3..d584fc3 100644 --- a/src/common/media/hls-playlist.service.spec.ts +++ b/src/common/media/hls-playlist.service.spec.ts @@ -6,6 +6,7 @@ import { MediaStorageService } from './media-storage.service'; describe('HlsPlaylistService', () => { const storageService = { shouldSignResponseUrls: jest.fn(() => true), + isValidHlsPlaylistAccess: jest.fn(() => true), getBucketName: jest.fn(() => 'oudelaa'), isManagedKey: jest.fn((key: string) => key.startsWith('uploads/')), getObjectText: jest.fn(), @@ -23,6 +24,16 @@ describe('HlsPlaylistService', () => { beforeEach(() => { jest.clearAllMocks(); + (storageService.isValidHlsPlaylistAccess as jest.Mock).mockReturnValue(true); + }); + + it('rejects an invalid or expired playlist access signature', async () => { + (storageService.isValidHlsPlaylistAccess as jest.Mock).mockReturnValue(false); + + await expect( + service.rewritePlaylist('uploads/posts/hls/stream-1/master.m3u8'), + ).rejects.toThrow(BadRequestException); + expect(storageService.getObjectText).not.toHaveBeenCalled(); }); it('rewrites master playlist variants through the backend endpoint', async () => { diff --git a/src/common/media/hls-playlist.service.ts b/src/common/media/hls-playlist.service.ts index 3fc696c..a5ab2b1 100644 --- a/src/common/media/hls-playlist.service.ts +++ b/src/common/media/hls-playlist.service.ts @@ -14,12 +14,18 @@ export class HlsPlaylistService { private readonly appLogger: AppLoggerService, ) {} - async rewritePlaylist(requestedPath: string): Promise { + async rewritePlaylist( + requestedPath: string, + access?: { expires?: string; signature?: string }, + ): Promise { if (!this.mediaStorageService.shouldSignResponseUrls()) { throw new BadRequestException('Private HLS rewrite is disabled'); } const key = this.normalizePlaylistKey(requestedPath); + if (!this.mediaStorageService.isValidHlsPlaylistAccess(key, access?.expires, access?.signature)) { + throw new BadRequestException('Invalid or expired HLS access signature'); + } const bucket = this.mediaStorageService.getBucketName(); const startedAt = Date.now(); this.appLogger.log( diff --git a/src/common/media/media-storage.behavior.spec.ts b/src/common/media/media-storage.behavior.spec.ts new file mode 100644 index 0000000..b7b39f7 --- /dev/null +++ b/src/common/media/media-storage.behavior.spec.ts @@ -0,0 +1,155 @@ +import { BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { AppLoggerService } from '../../infrastructure/logging/app-logger.service'; +import { MediaStorageService } from './media-storage.service'; + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ getSignedUrl: jest.fn() })); + +type StorageInternals = { + getS3Client(): { send(command: unknown): Promise }; + getSignedUrlExpiresSeconds(): number; + cacheSignedUrl(key: string, url: string, seconds: number): void; + normalizeFolder(folder: string): string; + normalizeUserSegment(user: string): string; + normalizeKey(key: string): string; +}; + +const internals = (service: MediaStorageService): StorageInternals => + service as unknown as StorageInternals; + +describe('MediaStorageService behavior', () => { + const values: Record = { + 'storage.provider': 's3', 'storage.basePath': '/uploads/', + 'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test/', + 'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret', + 'storage.s3.forcePathStyle': true, 'storage.publicBaseUrl': 'https://cdn.test/', + 'storage.mediaAccessMode': 'public', publicBaseUrl: 'https://api.test/', globalPrefix: '/api/v2/', + 'security.refreshTokenHashSecret': 'signing-secret', + }; + const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService; + const logger = { error: jest.fn() } as unknown as AppLoggerService; + let send: jest.Mock; + let service: MediaStorageService; + + beforeEach(() => { + jest.clearAllMocks(); + Object.assign(values, { + 'storage.provider': 's3', 'storage.basePath': '/uploads/', + 'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test/', + 'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret', + 'storage.s3.forcePathStyle': true, 'storage.publicBaseUrl': 'https://cdn.test/', + 'storage.mediaAccessMode': 'public', publicBaseUrl: 'https://api.test/', globalPrefix: '/api/v2/', + 'storage.signedUrlExpiresSeconds': 60, 'security.refreshTokenHashSecret': 'signing-secret', + }); + send = jest.fn().mockResolvedValue({}); + service = new MediaStorageService(config, logger); + jest.spyOn(internals(service), 'getS3Client').mockReturnValue({ send }); + }); + + it('uploads generated and exact keys with content metadata', async () => { + const result = await service.uploadFile({ buffer: Buffer.from('abc'), originalname: 'photo.JPG', mimetype: 'image/jpeg' }, '/posts\\images/'); + expect(result).toEqual(expect.objectContaining({ key: expect.stringMatching(/^uploads\/posts\/images\/media-[0-9a-f-]+\.jpg$/i), url: expect.stringContaining('https://cdn.test/uploads/'), size: 3, originalName: 'photo.JPG' })); + expect(send.mock.calls[0][0]).toBeInstanceOf(PutObjectCommand); + + await expect(service.uploadFileToKey({ buffer: Buffer.from('x') }, '/uploads/hls/master.m3u8')).resolves.toEqual(expect.objectContaining({ key: 'uploads/hls/master.m3u8', mimeType: 'application/octet-stream' })); + const command = send.mock.calls[1][0] as PutObjectCommand; + expect(command.input).toEqual(expect.objectContaining({ CacheControl: 'public, max-age=300', Key: 'uploads/hls/master.m3u8' })); + await expect(service.uploadFileToKey({ buffer: Buffer.alloc(0) }, '../bad')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('deletes valid keys and ignores invalid keys', async () => { + await service.deleteFile('../bad'); + expect(send).not.toHaveBeenCalled(); + await service.deleteFile('/uploads/file.jpg'); + expect(send.mock.calls[0][0]).toBeInstanceOf(DeleteObjectCommand); + }); + + it('builds public, path-style, and virtual endpoint URLs', () => { + expect(service.getPublicUrl('/uploads/file.jpg')).toBe('https://cdn.test/uploads/file.jpg'); + values['storage.publicBaseUrl'] = ''; + expect(service.getPublicUrl('uploads/file.jpg')).toBe('https://s3.test/bucket/uploads/file.jpg'); + values['storage.s3.forcePathStyle'] = false; + expect(service.getPublicUrl('uploads/file.jpg')).toBe('https://s3.test/uploads/file.jpg'); + expect(() => service.getPublicUrl('../bad')).toThrow(BadRequestException); + }); + + it('resolves managed keys from relative, CDN, endpoint, and application URLs', () => { + expect(service.resolveKeyFromUrl('/uploads/posts/a.jpg?token=1')).toBe('uploads/posts/a.jpg'); + expect(service.resolveKeyFromUrl('https://cdn.test/uploads/posts/a.jpg')).toBe('uploads/posts/a.jpg'); + values['storage.publicBaseUrl'] = ''; + expect(service.resolveKeyFromUrl('https://s3.test/bucket/uploads/posts/a.jpg')).toBe('uploads/posts/a.jpg'); + expect(service.resolveKeyFromUrl('https://api.test/uploads/posts/a.jpg')).toBe('uploads/posts/a.jpg'); + expect(service.resolveKeyFromUrl('https://external.test/a.jpg')).toBeNull(); + expect(service.resolveKeyFromUrl('not a url')).toBeNull(); + }); + + it('loads object text and validates missing bodies and unsafe keys', async () => { + send.mockResolvedValueOnce({ Body: { transformToString: jest.fn().mockResolvedValue('#EXTM3U') } }); + await expect(service.getObjectText('uploads/master.m3u8')).resolves.toBe('#EXTM3U'); + expect(send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); + send.mockResolvedValueOnce({}); + await expect(service.getObjectText('uploads/empty.txt')).rejects.toThrow('object body is empty'); + await expect(service.getObjectText('../bad')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('builds unsigned and signed HLS endpoints and validates signatures', () => { + const unsigned = service.getHlsPlaylistUrl('uploads/folder name/master.m3u8'); + expect(unsigned).toBe('https://api.test/api/v2/media/hls/uploads/folder%20name/master.m3u8'); + values['storage.mediaAccessMode'] = 'signed'; + const signed = service.getHlsPlaylistUrl('uploads/master.m3u8'); + const url = new URL(signed); + const expires = url.searchParams.get('expires') ?? ''; + const signature = url.searchParams.get('signature') ?? ''; + expect(service.isValidHlsPlaylistAccess('uploads/master.m3u8', expires, signature)).toBe(true); + expect(service.isValidHlsPlaylistAccess('uploads/master.m3u8', '1', signature)).toBe(false); + expect(service.isValidHlsPlaylistAccess('../bad', expires, signature)).toBe(false); + expect(service.isValidHlsPlaylistAccess('uploads/master.m3u8', expires, 'short')).toBe(false); + expect(() => service.getHlsPlaylistUrl('uploads/video.mp4')).toThrow(BadRequestException); + }); + + it('caches signed object URLs until their refresh boundary', async () => { + values['storage.mediaAccessMode'] = 'signed'; + jest.mocked(getSignedUrl).mockResolvedValue('https://signed.test/one'); + await expect(service.getSignedObjectUrl('uploads/a.jpg')).resolves.toBe('https://signed.test/one'); + await expect(service.getSignedObjectUrl('uploads/a.jpg')).resolves.toBe('https://signed.test/one'); + expect(getSignedUrl).toHaveBeenCalledTimes(1); + await expect(service.getSignedObjectUrl('../bad')).resolves.toBe(''); + service.onModuleDestroy(); + await service.getSignedObjectUrl('uploads/a.jpg'); + expect(getSignedUrl).toHaveBeenCalledTimes(2); + }); + + it('checks managed prefixes and clamps signing expiration', () => { + expect(service.isManagedKey('/uploads/posts/a.jpg')).toBe(true); + expect(service.isManagedKey('private/a.jpg')).toBe(false); + values['storage.signedUrlExpiresSeconds'] = 0; + expect(internals(service).getSignedUrlExpiresSeconds()).toBe(1); + values['storage.signedUrlExpiresSeconds'] = 9999999; + expect(internals(service).getSignedUrlExpiresSeconds()).toBe(604800); + }); + + it('validates configuration and normalization helpers', () => { + expect(internals(service).normalizeFolder('posts\\images')).toBe('posts/images'); + expect(() => internals(service).normalizeFolder('../bad')).toThrow(BadRequestException); + expect(internals(service).normalizeUserSegment(' user_1-2 ')).toBe('user_1-2'); + expect(() => internals(service).normalizeUserSegment('bad/user')).toThrow(BadRequestException); + expect(internals(service).normalizeKey('a\\b')).toBe('a/b'); + expect(internals(service).normalizeKey('a//b')).toBe(''); + values['storage.s3.bucket'] = ''; + expect(() => service.getBucketName()).toThrow(BadRequestException); + values['storage.s3.bucket'] = 'bucket'; + const unconfigured = new MediaStorageService({ get: jest.fn(() => '') } as unknown as ConfigService, logger); + expect(() => internals(unconfigured).getS3Client()).toThrow(BadRequestException); + }); + + it('evicts the oldest signing cache entry at its safety limit', () => { + const target = internals(service); + for (let index = 0; index < 10001; index += 1) { + target.cacheSignedUrl(`uploads/${index}`, `url-${index}`, 10); + } + expect((service as unknown as { signedUrlCache: Map }).signedUrlCache.size).toBe(10000); + expect((service as unknown as { signedUrlCache: Map }).signedUrlCache.has('uploads/0')).toBe(false); + }); +}); diff --git a/src/common/media/media-storage.direct-upload.spec.ts b/src/common/media/media-storage.direct-upload.spec.ts new file mode 100644 index 0000000..f682eaa --- /dev/null +++ b/src/common/media/media-storage.direct-upload.spec.ts @@ -0,0 +1,165 @@ +import { BadRequestException, ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { AppLoggerService } from '../../infrastructure/logging/app-logger.service'; +import { MediaStorageService } from './media-storage.service'; + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(), +})); + +const mockedGetSignedUrl = jest.mocked(getSignedUrl); + +describe('MediaStorageService direct uploads', () => { + const config: Record = { + 'storage.provider': 's3', + 'storage.mediaAccessMode': 'signed', + 'storage.basePath': 'uploads', + 'storage.publicBaseUrl': 'https://cdn.oudelaa.test', + 'storage.s3.bucket': 'oudelaa-media', + 'storage.s3.region': 'us-east-1', + 'storage.s3.endpoint': 'https://s3.oudelaa.test', + 'storage.s3.accessKeyId': 'test-access-key', + 'storage.s3.secretAccessKey': 'test-secret-key', + 'storage.s3.forcePathStyle': true, + }; + const configService = { + get: jest.fn((key: string, defaultValue?: unknown) => config[key] ?? defaultValue), + } as unknown as ConfigService; + const appLogger = { error: jest.fn() } as unknown as AppLoggerService; + + beforeEach(() => { + mockedGetSignedUrl.mockReset(); + mockedGetSignedUrl.mockResolvedValue('https://s3.oudelaa.test/presigned-put'); + config['storage.provider'] = 's3'; + }); + + it('creates a user-scoped PUT URL with signed MIME and exact content length', async () => { + const service = new MediaStorageService(configService, appLogger); + + const result = await service.createPresignedUpload({ + userId: '507f1f77bcf86cd799439011', + folder: 'posts/video', + mimeType: 'video/mp4', + size: 5_242_880, + }); + + expect(result).toEqual( + expect.objectContaining({ + method: 'PUT', + uploadUrl: 'https://s3.oudelaa.test/presigned-put', + mediaUrl: expect.stringMatching( + /^https:\/\/cdn\.oudelaa\.test\/uploads\/posts\/video\/507f1f77bcf86cd799439011\/media-[0-9a-f-]+\.mp4$/, + ), + storageKey: expect.stringMatching( + /^uploads\/posts\/video\/507f1f77bcf86cd799439011\/media-[0-9a-f-]+\.mp4$/, + ), + mimeType: 'video/mp4', + size: 5_242_880, + requiredHeaders: { 'Content-Type': 'video/mp4' }, + expiresIn: 900, + expiresAt: expect.any(String), + }), + ); + expect(mockedGetSignedUrl).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + input: expect.objectContaining({ + Bucket: 'oudelaa-media', + Key: result.storageKey, + ContentLength: 5_242_880, + ContentType: 'video/mp4', + CacheControl: 'public, max-age=31536000, immutable', + Metadata: { + owner: '507f1f77bcf86cd799439011', + 'upload-source': 'direct', + }, + }), + }), + { + expiresIn: 900, + signableHeaders: new Set(['content-type']), + }, + ); + }); + + it('is disabled for local storage without attempting to sign', async () => { + config['storage.provider'] = 'local'; + const service = new MediaStorageService(configService, appLogger); + + await expect( + service.createPresignedUpload({ + userId: 'user-1', + folder: 'posts/images', + mimeType: 'image/jpeg', + size: 1024, + }), + ).rejects.toBeInstanceOf(ServiceUnavailableException); + expect(mockedGetSignedUrl).not.toHaveBeenCalled(); + }); + + it('does not rewrite an existing presigned PUT URL as a signed GET URL', async () => { + const service = new MediaStorageService(configService, appLogger); + const uploadUrl = + 'https://s3.oudelaa.test/oudelaa-media/uploads/posts/video/file.mp4' + + '?X-Amz-Algorithm=AWS4-HMAC-SHA256' + + '&X-Amz-Credential=test' + + '&X-Amz-Signature=presigned-put'; + + await expect(service.resolveResponseUrl(uploadUrl)).resolves.toBe(uploadUrl); + expect(mockedGetSignedUrl).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: 'a MIME type that does not match the folder', + input: { + userId: 'user-1', + folder: 'users/avatar' as const, + mimeType: 'video/mp4' as const, + size: 1024, + }, + }, + { + name: 'an oversized image', + input: { + userId: 'user-1', + folder: 'users/avatar' as const, + mimeType: 'image/jpeg' as const, + size: 5 * 1024 * 1024 + 1, + }, + }, + { + name: 'a zero-byte body', + input: { + userId: 'user-1', + folder: 'posts/audio' as const, + mimeType: 'audio/mpeg' as const, + size: 0, + }, + }, + { + name: 'an unsafe user segment', + input: { + userId: '../another-user', + folder: 'posts/images' as const, + mimeType: 'image/jpeg' as const, + size: 1024, + }, + }, + { + name: 'an unsafe folder', + input: { + userId: 'user-1', + folder: '../posts/images' as unknown as 'posts/images', + mimeType: 'image/jpeg' as const, + size: 1024, + }, + }, + ])('rejects $name', async ({ input }) => { + const service = new MediaStorageService(configService, appLogger); + + await expect(service.createPresignedUpload(input)).rejects.toBeInstanceOf(BadRequestException); + expect(mockedGetSignedUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/src/common/media/media-storage.service.spec.ts b/src/common/media/media-storage.service.spec.ts index d663793..bfb8b30 100644 --- a/src/common/media/media-storage.service.spec.ts +++ b/src/common/media/media-storage.service.spec.ts @@ -24,6 +24,7 @@ describe('MediaStorageService response URLs', () => { 'storage.s3.forcePathStyle': true, publicBaseUrl: 'https://api.oudelaa.test', globalPrefix: 'api/v1', + 'security.refreshTokenHashSecret': 'test-hls-signing-secret', }; const configService = { get: jest.fn((key: string, defaultValue?: unknown) => config[key] ?? defaultValue), @@ -64,8 +65,8 @@ describe('MediaStorageService response URLs', () => { await expect( service.resolveResponseUrl('https://s3.cumin.dev/uploads/posts/hls/stream-1/master.m3u8'), - ).resolves.toBe( - 'https://api.oudelaa.test/api/v1/media/hls/uploads/posts/hls/stream-1/master.m3u8', + ).resolves.toMatch( + /^https:\/\/api\.oudelaa\.test\/api\/v1\/media\/hls\/uploads\/posts\/hls\/stream-1\/master\.m3u8\?expires=\d+&signature=/, ); expect(mockedGetSignedUrl).not.toHaveBeenCalled(); }); diff --git a/src/common/media/media-storage.service.ts b/src/common/media/media-storage.service.ts index 1a915e6..f7daa66 100644 --- a/src/common/media/media-storage.service.ts +++ b/src/common/media/media-storage.service.ts @@ -1,4 +1,9 @@ -import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + OnModuleDestroy, + ServiceUnavailableException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DeleteObjectCommand, @@ -7,9 +12,14 @@ import { S3Client, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { randomUUID } from 'crypto'; +import { createHmac, randomUUID, timingSafeEqual } from 'crypto'; import { posix } from 'path'; import { AppLoggerService } from '../../infrastructure/logging/app-logger.service'; +import { + DirectUploadFolder, + DirectUploadMimeType, + resolveDirectUploadPolicy, +} from './direct-upload.policy'; import { getFileExtension } from './allowed-media'; export type MediaStorageUploadFile = { @@ -27,6 +37,27 @@ export type MediaStorageUploadResult = { originalName: string; }; +export type CreatePresignedMediaUploadInput = { + userId: string; + folder: DirectUploadFolder; + mimeType: DirectUploadMimeType; + size: number; +}; + +export type PresignedMediaUploadResult = { + method: 'PUT'; + uploadUrl: string; + storageKey: string; + mediaUrl: string; + mimeType: DirectUploadMimeType; + size: number; + requiredHeaders: { 'Content-Type': DirectUploadMimeType }; + expiresIn: number; + expiresAt: string; +}; + +const DIRECT_UPLOAD_EXPIRES_SECONDS = 15 * 60; + @Injectable() export class MediaStorageService implements OnModuleDestroy { private s3Client: S3Client | null = null; @@ -78,6 +109,64 @@ export class MediaStorageService implements OnModuleDestroy { }; } + async createPresignedUpload( + input: CreatePresignedMediaUploadInput, + ): Promise { + if (this.getProvider() !== 's3') { + throw new ServiceUnavailableException( + 'Direct media uploads are available only when S3 storage is enabled', + ); + } + + const policy = resolveDirectUploadPolicy(input.folder, input.mimeType); + if (!policy) { + throw new BadRequestException('Media type is not allowed for this upload folder'); + } + if (!Number.isSafeInteger(input.size) || input.size < 1) { + throw new BadRequestException('Media size must be a positive integer'); + } + if (input.size > policy.maxSizeBytes) { + throw new BadRequestException( + `Media size exceeds the ${policy.maxSizeBytes}-byte limit for this upload folder`, + ); + } + + const userSegment = this.normalizeUserSegment(input.userId); + const fileName = `media-${randomUUID()}${policy.extension}`; + const key = posix.join(this.getBasePath(), input.folder, userSegment, fileName); + const uploadUrl = await getSignedUrl( + this.getS3Client(), + new PutObjectCommand({ + Bucket: this.getBucket(), + Key: key, + ContentLength: input.size, + ContentType: input.mimeType, + CacheControl: this.resolveCacheControl(key), + Metadata: { + owner: userSegment, + 'upload-source': 'direct', + }, + }), + { + expiresIn: DIRECT_UPLOAD_EXPIRES_SECONDS, + signableHeaders: new Set(['content-type']), + }, + ); + const expiresAt = new Date(Date.now() + DIRECT_UPLOAD_EXPIRES_SECONDS * 1000).toISOString(); + + return { + method: 'PUT', + uploadUrl, + storageKey: key, + mediaUrl: this.getPublicUrl(key), + mimeType: input.mimeType, + size: input.size, + requiredHeaders: { 'Content-Type': input.mimeType }, + expiresIn: DIRECT_UPLOAD_EXPIRES_SECONDS, + expiresAt, + }; + } + async deleteFile(key: string): Promise { const normalizedKey = this.normalizeKey(key); if (!normalizedKey) { @@ -139,7 +228,11 @@ export class MediaStorageService implements OnModuleDestroy { } async resolveResponseUrl(fileUrl: string): Promise { - if (!this.shouldSignResponseUrls() || !fileUrl.trim()) { + if ( + !this.shouldSignResponseUrls() || + !fileUrl.trim() || + this.isAlreadyPresignedS3Url(fileUrl) + ) { return fileUrl; } @@ -236,7 +329,22 @@ export class MediaStorageService implements OnModuleDestroy { .join('/'); const path = `/${globalPrefix}/media/hls/${encodedKey}`; - return publicBaseUrl ? `${publicBaseUrl}${path}` : path; + const baseUrl = publicBaseUrl ? `${publicBaseUrl}${path}` : path; + if (!this.shouldSignResponseUrls()) return baseUrl; + const expires = Math.floor(Date.now() / 1000) + this.getSignedUrlExpiresSeconds(); + const signature = this.signHlsPlaylistKey(normalizedKey, expires); + return `${baseUrl}?expires=${expires}&signature=${encodeURIComponent(signature)}`; + } + + isValidHlsPlaylistAccess(key: string, expiresValue?: string, signature?: string): boolean { + const normalizedKey = this.normalizeKey(key); + const expires = Number(expiresValue); + if (!normalizedKey || !Number.isInteger(expires) || expires <= Math.floor(Date.now() / 1000) || !signature) { + return false; + } + const expected = Buffer.from(this.signHlsPlaylistKey(normalizedKey, expires)); + const supplied = Buffer.from(signature); + return expected.length === supplied.length && timingSafeEqual(expected, supplied); } getBucketName(): string { @@ -253,6 +361,14 @@ export class MediaStorageService implements OnModuleDestroy { this.signedUrlCache.clear(); } + private signHlsPlaylistKey(key: string, expires: number): string { + const secret = + this.configService.get('security.refreshTokenHashSecret', { infer: true }) || + this.configService.get('jwt.accessSecret', { infer: true }) || + ''; + return createHmac('sha256', secret).update(`${key}:${expires}`).digest('base64url'); + } + private getS3Client(): S3Client { if (this.s3Client) { return this.s3Client; @@ -272,6 +388,7 @@ export class MediaStorageService implements OnModuleDestroy { region: this.configService.get('storage.s3.region', { infer: true }) ?? 'auto', endpoint, forcePathStyle: this.getForcePathStyle(), + requestChecksumCalculation: 'WHEN_REQUIRED', credentials: { accessKeyId, secretAccessKey, @@ -355,6 +472,19 @@ export class MediaStorageService implements OnModuleDestroy { ); } + private isAlreadyPresignedS3Url(fileUrl: string): boolean { + try { + const searchParams = new URL(fileUrl).searchParams; + return ( + searchParams.has('X-Amz-Algorithm') && + searchParams.has('X-Amz-Credential') && + searchParams.has('X-Amz-Signature') + ); + } catch { + return false; + } + } + private cacheSignedUrl(key: string, url: string, refreshAfterSeconds: number): void { if (this.signedUrlCache.size >= 10000) { const oldestKey = this.signedUrlCache.keys().next().value as string | undefined; @@ -380,6 +510,14 @@ export class MediaStorageService implements OnModuleDestroy { return normalized; } + private normalizeUserSegment(userId: string): string { + const normalized = userId.trim(); + if (!/^[a-zA-Z0-9_-]{1,128}$/.test(normalized)) { + throw new BadRequestException('Invalid media owner id'); + } + return normalized; + } + private normalizeKey(key: string): string { const normalized = key.replace(/\\/g, '/').replace(/^\/+/, ''); if ( diff --git a/src/common/utils/cursor.util.ts b/src/common/utils/cursor.util.ts index 13d8183..66bdebe 100644 --- a/src/common/utils/cursor.util.ts +++ b/src/common/utils/cursor.util.ts @@ -9,10 +9,14 @@ export const decodeOffsetCursor = (cursor?: string): number | null => { try { const raw = Buffer.from(cursor, 'base64url').toString('utf8'); const parsed = Number(raw); - if (!Number.isInteger(parsed) || parsed < 0) { - return null; + if (Number.isInteger(parsed) && parsed >= 0) { + return parsed; } - return parsed; + + const payload = JSON.parse(raw) as { offset?: unknown }; + return Number.isInteger(payload.offset) && Number(payload.offset) >= 0 + ? Number(payload.offset) + : null; } catch { return null; } diff --git a/src/common/utils/hash.util.spec.ts b/src/common/utils/hash.util.spec.ts new file mode 100644 index 0000000..8aec6d3 --- /dev/null +++ b/src/common/utils/hash.util.spec.ts @@ -0,0 +1,22 @@ +import * as bcrypt from 'bcrypt'; +import { compareHash, compareStoredHighEntropyValue, hashHighEntropyValue, hashValue } from './hash.util'; + +describe('hash utilities', () => { + it('hashes and verifies passwords with bcrypt', async () => { + const hash = await hashValue('secret', 4); + await expect(compareHash('secret', hash)).resolves.toBe(true); + await expect(compareHash('wrong', hash)).resolves.toBe(false); + }); + + it('uses deterministic secret-keyed hashes for high entropy tokens', async () => { + const stored = hashHighEntropyValue('token', 'pepper'); + expect(stored).toMatch(/^sha256:[0-9a-f]{64}$/); + await expect(compareStoredHighEntropyValue('token', stored, 'pepper')).resolves.toBe(true); + await expect(compareStoredHighEntropyValue('other', stored, 'pepper')).resolves.toBe(false); + }); + + it('supports legacy bcrypt token hashes', async () => { + const stored = await bcrypt.hash('legacy-token', 4); + await expect(compareStoredHighEntropyValue('legacy-token', stored, 'unused')).resolves.toBe(true); + }); +}); diff --git a/src/common/utils/regex.util.ts b/src/common/utils/regex.util.ts new file mode 100644 index 0000000..92e41b7 --- /dev/null +++ b/src/common/utils/regex.util.ts @@ -0,0 +1 @@ +export const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); diff --git a/src/common/utils/share-url.util.spec.ts b/src/common/utils/share-url.util.spec.ts new file mode 100644 index 0000000..37b715d --- /dev/null +++ b/src/common/utils/share-url.util.spec.ts @@ -0,0 +1,33 @@ +import { ConfigService } from '@nestjs/config'; +import { buildAiMusicShareUrl, buildPostShareUrl, buildProfileShareUrl, normalizeShareBaseUrl, resolveShareBaseUrl } from './share-url.util'; + +describe('share URL utilities', () => { + const originalShare = process.env.SHARE_BASE_URL; + const originalPublicWeb = process.env.PUBLIC_WEB_URL; + + afterEach(() => { + if (originalShare === undefined) delete process.env.SHARE_BASE_URL; + else process.env.SHARE_BASE_URL = originalShare; + if (originalPublicWeb === undefined) delete process.env.PUBLIC_WEB_URL; + else process.env.PUBLIC_WEB_URL = originalPublicWeb; + }); + + it('normalizes defaults and trailing slashes', () => { + expect(normalizeShareBaseUrl(undefined)).toBe('https://oudelaa.com'); + expect(normalizeShareBaseUrl(' https://web.test/// ')).toBe('https://web.test'); + }); + + it('resolves environment before configuration', () => { + process.env.SHARE_BASE_URL = 'https://env.test/'; + const config = { get: jest.fn(() => 'https://config.test') } as unknown as ConfigService; + expect(resolveShareBaseUrl(config)).toBe('https://env.test'); + expect(config.get).not.toHaveBeenCalled(); + }); + + it('builds encoded profile, post, and AI music links', () => { + expect(buildProfileShareUrl('https://web', { username: 'a/b' })).toBe('https://web/u/a%2Fb'); + expect(buildProfileShareUrl('https://web', { _id: { toString: () => 'id one' } })).toBe('https://web/profile/id%20one'); + expect(buildPostShareUrl('https://web', 'post/1')).toBe('https://web/posts/post%2F1'); + expect(buildAiMusicShareUrl('https://web', 'track 1')).toBe('https://web/ai/music/track%201'); + }); +}); diff --git a/src/common/utils/totp.util.spec.ts b/src/common/utils/totp.util.spec.ts new file mode 100644 index 0000000..cb3abed --- /dev/null +++ b/src/common/utils/totp.util.spec.ts @@ -0,0 +1,9 @@ +import { verifyTotp } from './totp.util'; + +describe('verifyTotp', () => { + it('accepts a known code and rejects invalid values', () => { + expect(verifyTotp('JBSWY3DPEHPK3PXP', '282760', 0)).toBe(true); + expect(verifyTotp('JBSWY3DPEHPK3PXP', '000000', 0)).toBe(false); + expect(verifyTotp('not-base32!', '282760', 0)).toBe(false); + }); +}); diff --git a/src/common/utils/totp.util.ts b/src/common/utils/totp.util.ts new file mode 100644 index 0000000..1bbbdce --- /dev/null +++ b/src/common/utils/totp.util.ts @@ -0,0 +1,39 @@ +import { createHmac, timingSafeEqual } from 'crypto'; + +const decodeBase32 = (value: string): Buffer => { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + const normalized = value.toUpperCase().replace(/=|\s|-/g, ''); + let bits = ''; + for (const character of normalized) { + const index = alphabet.indexOf(character); + if (index < 0) return Buffer.alloc(0); + bits += index.toString(2).padStart(5, '0'); + } + const bytes: number[] = []; + for (let offset = 0; offset + 8 <= bits.length; offset += 8) { + bytes.push(Number.parseInt(bits.slice(offset, offset + 8), 2)); + } + return Buffer.from(bytes); +}; + +const generateTotp = (secret: Buffer, counter: number): string => { + const message = Buffer.alloc(8); + message.writeBigUInt64BE(BigInt(counter)); + const digest = createHmac('sha1', secret).update(message).digest(); + const offset = digest[digest.length - 1] & 0x0f; + return ((digest.readUInt32BE(offset) & 0x7fffffff) % 1_000_000) + .toString() + .padStart(6, '0'); +}; + +export const verifyTotp = (base32Secret: string, suppliedCode: string, nowMs = Date.now()): boolean => { + if (!/^\d{6}$/.test(suppliedCode)) return false; + const secret = decodeBase32(base32Secret); + if (!secret.length) return false; + const counter = Math.floor(nowMs / 30_000); + return [-1, 0, 1].filter((drift) => counter + drift >= 0).some((drift) => { + const expected = Buffer.from(generateTotp(secret, counter + drift)); + const supplied = Buffer.from(suppliedCode); + return expected.length === supplied.length && timingSafeEqual(expected, supplied); + }); +}; diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 4316b6d..bfc559b 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -13,6 +13,16 @@ export default () => ({ responseEnvelopeEnabled: (process.env.RESPONSE_ENVELOPE_ENABLED ?? 'false').toLowerCase() === 'true', globalPrefix: process.env.GLOBAL_PREFIX ?? 'api/v1', + swagger: { + enabled: + typeof process.env.SWAGGER_ENABLED === 'string' + ? process.env.SWAGGER_ENABLED.toLowerCase() === 'true' + : (process.env.NODE_ENV ?? 'development') !== 'production', + title: process.env.SWAGGER_TITLE ?? 'Oudelaa API', + description: process.env.SWAGGER_DESCRIPTION ?? 'Social media backend API documentation', + version: process.env.SWAGGER_VERSION ?? '1.0.0', + path: process.env.SWAGGER_PATH ?? 'docs', + }, cors: { origins: (process.env.CORS_ORIGINS ?? '') .split(',') @@ -21,6 +31,22 @@ export default () => ({ }, mongodb: { uri: process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017/oudelaa', + autoIndex: + typeof process.env.MONGODB_AUTO_INDEX === 'string' + ? process.env.MONGODB_AUTO_INDEX.toLowerCase() === 'true' + : (process.env.NODE_ENV ?? 'development') !== 'production', + minPoolSize: Number(process.env.MONGODB_MIN_POOL_SIZE ?? 5), + maxPoolSize: Number(process.env.MONGODB_MAX_POOL_SIZE ?? 100), + maxIdleTimeMs: Number(process.env.MONGODB_MAX_IDLE_TIME_MS ?? 60_000), + serverSelectionTimeoutMs: Number(process.env.MONGODB_SERVER_SELECTION_TIMEOUT_MS ?? 10_000), + socketTimeoutMs: Number(process.env.MONGODB_SOCKET_TIMEOUT_MS ?? 45_000), + }, + search: { + engine: (process.env.SEARCH_ENGINE ?? 'auto').toLowerCase(), + atlasUserIndex: process.env.SEARCH_ATLAS_USER_INDEX ?? 'users_search', + atlasPostIndex: process.env.SEARCH_ATLAS_POST_INDEX ?? 'posts_search', + fallbackEnabled: (process.env.SEARCH_FALLBACK_ENABLED ?? 'true').toLowerCase() === 'true', + retrySeconds: Number(process.env.SEARCH_ATLAS_RETRY_SECONDS ?? 300), }, jwt: { accessSecret: process.env.JWT_ACCESS_SECRET ?? '', @@ -31,10 +57,12 @@ export default () => ({ superAdmin: { email: (process.env.SUPERADMIN_EMAIL ?? '').toLowerCase(), password: process.env.SUPERADMIN_PASSWORD ?? '', + passwordHash: process.env.SUPERADMIN_PASSWORD_HASH ?? '', accessSecret: process.env.SUPERADMIN_ACCESS_SECRET ?? '', accessExpiresIn: process.env.SUPERADMIN_ACCESS_EXPIRES_IN ?? '15m', refreshSecret: process.env.SUPERADMIN_REFRESH_SECRET ?? '', refreshExpiresIn: process.env.SUPERADMIN_REFRESH_EXPIRES_IN ?? '30d', + totpSecret: process.env.SUPERADMIN_TOTP_SECRET ?? '', }, google: { clientId: process.env.GOOGLE_CLIENT_ID ?? '', @@ -65,6 +93,20 @@ export default () => ({ bcryptSaltRounds: Number(process.env.BCRYPT_SALT_ROUNDS ?? 12), refreshTokenHashSecret: process.env.REFRESH_TOKEN_HASH_SECRET ?? process.env.JWT_REFRESH_SECRET ?? '', + bodyLimit: process.env.HTTP_BODY_LIMIT ?? '1mb', + }, + reliability: { + requestTimeoutMs: Number(process.env.HTTP_REQUEST_TIMEOUT_MS ?? 30_000), + serverRequestTimeoutMs: Number(process.env.HTTP_SERVER_REQUEST_TIMEOUT_MS ?? 120_000), + headersTimeoutMs: Number(process.env.HTTP_HEADERS_TIMEOUT_MS ?? 15_000), + keepAliveTimeoutMs: Number(process.env.HTTP_KEEP_ALIVE_TIMEOUT_MS ?? 5_000), + maxRequestsPerSocket: Number(process.env.HTTP_MAX_REQUESTS_PER_SOCKET ?? 1_000), + shutdownGracePeriodMs: Number(process.env.SHUTDOWN_GRACE_PERIOD_MS ?? 30_000), + }, + metrics: { + eventLoopResolutionMs: Number(process.env.METRICS_EVENT_LOOP_RESOLUTION_MS ?? 20), + eventLoopLagWarnMs: Number(process.env.METRICS_EVENT_LOOP_LAG_WARN_MS ?? 100), + maxRoutes: Number(process.env.METRICS_MAX_ROUTES ?? 500), }, redis: { enabled: (process.env.REDIS_ENABLED ?? 'false').toLowerCase() === 'true', @@ -147,10 +189,20 @@ export default () => ({ enabled: (process.env.FEED_CACHE_ENABLED ?? 'true').toLowerCase() === 'true', userFeedTtlSeconds: Number(process.env.FEED_CACHE_USER_TTL_SECONDS ?? 15), trendingTtlSeconds: Number(process.env.FEED_CACHE_TRENDING_TTL_SECONDS ?? 30), + rankingProfileTtlSeconds: Number(process.env.FEED_CACHE_RANKING_PROFILE_TTL_SECONDS ?? 30), }, performance: { feedTimingLogsEnabled: (process.env.FEED_TIMING_LOGS_ENABLED ?? 'false').toLowerCase() === 'true', + compressionEnabled: + (process.env.HTTP_COMPRESSION_ENABLED ?? 'true').toLowerCase() === 'true', + compressionThresholdBytes: Number(process.env.HTTP_COMPRESSION_THRESHOLD_BYTES ?? 1024), + }, + moderation: { + blockedTerms: (process.env.MODERATION_BLOCKED_TERMS ?? '') + .split(',') + .map((term) => term.trim().toLowerCase()) + .filter(Boolean), }, passwordReset: { codeExpiresMinutes: Number(process.env.PASSWORD_RESET_CODE_EXPIRES_MINUTES ?? 10), @@ -162,10 +214,4 @@ export default () => ({ codeExpiresMinutes: Number(process.env.EMAIL_VERIFICATION_CODE_EXPIRES_MINUTES ?? 10), maxAttempts: Number(process.env.EMAIL_VERIFICATION_MAX_ATTEMPTS ?? 5), }, - swagger: { - title: process.env.SWAGGER_TITLE ?? 'Oudelaa API', - description: process.env.SWAGGER_DESCRIPTION ?? 'Social media backend API documentation', - version: process.env.SWAGGER_VERSION ?? '1.0.0', - path: process.env.SWAGGER_PATH ?? 'docs', - }, }); diff --git a/src/config/swagger.config.ts b/src/config/swagger.config.ts index 9afc466..3cc680a 100644 --- a/src/config/swagger.config.ts +++ b/src/config/swagger.config.ts @@ -3,6 +3,9 @@ import { ConfigService } from '@nestjs/config'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; export const setupSwagger = (app: INestApplication, configService: ConfigService): void => { + if (!(configService.get('swagger.enabled', { infer: true }) ?? false)) { + return; + } const config = new DocumentBuilder() .setTitle(configService.get('swagger.title', 'Oudelaa API')) .setDescription( diff --git a/src/config/validation.schema.ts b/src/config/validation.schema.ts index cf1648e..ec7361f 100644 --- a/src/config/validation.schema.ts +++ b/src/config/validation.schema.ts @@ -11,16 +11,33 @@ export const validationSchema = Joi.object({ GLOBAL_PREFIX: Joi.string().default('api/v1'), CORS_ORIGINS: Joi.string().allow('').optional(), MONGODB_URI: Joi.string().required(), + MONGODB_AUTO_INDEX: Joi.boolean().truthy('true').falsy('false').optional(), + MONGODB_MIN_POOL_SIZE: Joi.number().integer().min(0).max(500).default(5), + MONGODB_MAX_POOL_SIZE: Joi.number().integer().min(1).max(1000).default(100), + MONGODB_MAX_IDLE_TIME_MS: Joi.number().integer().min(1000).max(600000).default(60000), + MONGODB_SERVER_SELECTION_TIMEOUT_MS: Joi.number().integer().min(1000).max(120000).default(10000), + MONGODB_SOCKET_TIMEOUT_MS: Joi.number().integer().min(1000).max(600000).default(45000), + SEARCH_ENGINE: Joi.string().valid('auto', 'atlas', 'regex').default('auto'), + SEARCH_ATLAS_USER_INDEX: Joi.string().default('users_search'), + SEARCH_ATLAS_POST_INDEX: Joi.string().default('posts_search'), + SEARCH_FALLBACK_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true), + SEARCH_ATLAS_RETRY_SECONDS: Joi.number().integer().min(30).max(3600).default(300), JWT_ACCESS_SECRET: Joi.string().min(16).required(), JWT_ACCESS_EXPIRES_IN: Joi.string().default('15m'), JWT_REFRESH_SECRET: Joi.string().min(16).required(), JWT_REFRESH_EXPIRES_IN: Joi.string().default('30d'), SUPERADMIN_EMAIL: Joi.string().email().required(), - SUPERADMIN_PASSWORD: Joi.string().min(8).required(), + SUPERADMIN_PASSWORD: Joi.string().min(8).allow('').optional(), + SUPERADMIN_PASSWORD_HASH: Joi.when('NODE_ENV', { + is: 'production', + then: Joi.string().pattern(/^\$2[aby]\$\d{2}\$.{53}$/).required(), + otherwise: Joi.string().allow('').optional(), + }), SUPERADMIN_ACCESS_SECRET: Joi.string().min(16).required(), SUPERADMIN_ACCESS_EXPIRES_IN: Joi.string().default('15m'), SUPERADMIN_REFRESH_SECRET: Joi.string().min(16).required(), SUPERADMIN_REFRESH_EXPIRES_IN: Joi.string().default('30d'), + SUPERADMIN_TOTP_SECRET: Joi.string().pattern(/^[A-Z2-7\s=-]{16,}$/i).allow('').optional(), GOOGLE_CLIENT_ID: Joi.string().allow('').optional(), GOOGLE_CLIENT_SECRET: Joi.string().allow('').optional(), GOOGLE_CALLBACK_URL: Joi.string().uri().optional(), @@ -40,6 +57,20 @@ export const validationSchema = Joi.object({ AI_MUSIC_MODEL: Joi.string().default('lyria-002'), BCRYPT_SALT_ROUNDS: Joi.number().min(8).max(15).default(12), REFRESH_TOKEN_HASH_SECRET: Joi.string().allow('').optional(), + HTTP_BODY_LIMIT: Joi.string().pattern(/^\d+(kb|mb)$/i).default('1mb'), + HTTP_REQUEST_TIMEOUT_MS: Joi.number().integer().min(1000).max(300000).default(30000), + HTTP_SERVER_REQUEST_TIMEOUT_MS: Joi.number() + .integer() + .min(1000) + .max(900000) + .default(120000), + HTTP_HEADERS_TIMEOUT_MS: Joi.number().integer().min(1000).max(120000).default(15000), + HTTP_KEEP_ALIVE_TIMEOUT_MS: Joi.number().integer().min(1000).max(60000).default(5000), + HTTP_MAX_REQUESTS_PER_SOCKET: Joi.number().integer().min(1).max(100000).default(1000), + SHUTDOWN_GRACE_PERIOD_MS: Joi.number().integer().min(1000).max(120000).default(30000), + METRICS_EVENT_LOOP_RESOLUTION_MS: Joi.number().integer().min(10).max(1000).default(20), + METRICS_EVENT_LOOP_LAG_WARN_MS: Joi.number().integer().min(10).max(10000).default(100), + METRICS_MAX_ROUTES: Joi.number().integer().min(10).max(10000).default(500), REDIS_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false), REDIS_URL: Joi.string().allow('').optional(), REDIS_HOST: Joi.string().default('127.0.0.1'), @@ -95,7 +126,11 @@ export const validationSchema = Joi.object({ FEED_CACHE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true), FEED_CACHE_USER_TTL_SECONDS: Joi.number().min(1).max(3600).default(15), FEED_CACHE_TRENDING_TTL_SECONDS: Joi.number().min(1).max(3600).default(30), + FEED_CACHE_RANKING_PROFILE_TTL_SECONDS: Joi.number().min(1).max(3600).default(30), FEED_TIMING_LOGS_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false), + HTTP_COMPRESSION_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true), + HTTP_COMPRESSION_THRESHOLD_BYTES: Joi.number().integer().min(0).max(1048576).default(1024), + MODERATION_BLOCKED_TERMS: Joi.string().allow('').optional(), PASSWORD_RESET_CODE_EXPIRES_MINUTES: Joi.number().min(1).max(60).default(10), PASSWORD_RESET_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5), PASSWORD_RESET_TOKEN_SECRET: Joi.string().allow('').optional(), @@ -103,7 +138,23 @@ export const validationSchema = Joi.object({ EMAIL_VERIFICATION_CODE_EXPIRES_MINUTES: Joi.number().min(1).max(60).default(10), EMAIL_VERIFICATION_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5), SWAGGER_TITLE: Joi.string().default('Oudelaa API'), + SWAGGER_ENABLED: Joi.boolean().truthy('true').falsy('false').optional(), SWAGGER_DESCRIPTION: Joi.string().default('Social media backend API documentation'), SWAGGER_VERSION: Joi.string().default('1.0.0'), SWAGGER_PATH: Joi.string().default('docs'), -}); +}).custom((value: Record, helpers: Joi.CustomHelpers) => { + if (value.STORAGE_PROVIDER === 'local' && value.MEDIA_ACCESS_MODE === 'signed') { + return helpers.message({ custom: 'MEDIA_ACCESS_MODE=signed requires STORAGE_PROVIDER=s3' }); + } + if (Number(value.HTTP_HEADERS_TIMEOUT_MS) <= Number(value.HTTP_KEEP_ALIVE_TIMEOUT_MS)) { + return helpers.message({ + custom: 'HTTP_HEADERS_TIMEOUT_MS must be greater than HTTP_KEEP_ALIVE_TIMEOUT_MS', + }); + } + if (Number(value.HTTP_SERVER_REQUEST_TIMEOUT_MS) < Number(value.HTTP_HEADERS_TIMEOUT_MS)) { + return helpers.message({ + custom: 'HTTP_SERVER_REQUEST_TIMEOUT_MS must be at least HTTP_HEADERS_TIMEOUT_MS', + }); + } + return value; +}, 'storage access mode validation'); diff --git a/src/database/mongoose-options.factory.spec.ts b/src/database/mongoose-options.factory.spec.ts new file mode 100644 index 0000000..6bb1830 --- /dev/null +++ b/src/database/mongoose-options.factory.spec.ts @@ -0,0 +1,27 @@ +import { createMongooseOptions } from './mongoose-options.factory'; + +describe('createMongooseOptions', () => { + const config = (values: Record) => ({ + get: jest.fn((key: string) => values[key]), + }) as any; + + it('uses a bounded production pool and disables startup index builds', () => { + const options = createMongooseOptions(config({ + nodeEnv: 'production', 'mongodb.uri': 'mongodb://database/app', + })); + expect(options).toEqual(expect.objectContaining({ + autoIndex: false, minPoolSize: 5, maxPoolSize: 100, + serverSelectionTimeoutMS: 10_000, socketTimeoutMS: 45_000, + })); + }); + + it('accepts explicit pool and index configuration', () => { + const options = createMongooseOptions(config({ + nodeEnv: 'production', 'mongodb.uri': 'mongodb://database/app', + 'mongodb.autoIndex': true, 'mongodb.minPoolSize': 10, 'mongodb.maxPoolSize': 200, + })); + expect(options.autoIndex).toBe(true); + expect(options.minPoolSize).toBe(10); + expect(options.maxPoolSize).toBe(200); + }); +}); diff --git a/src/database/mongoose-options.factory.ts b/src/database/mongoose-options.factory.ts index c4d9b09..a8de903 100644 --- a/src/database/mongoose-options.factory.ts +++ b/src/database/mongoose-options.factory.ts @@ -1,7 +1,17 @@ import { ConfigService } from '@nestjs/config'; import { MongooseModuleOptions } from '@nestjs/mongoose'; -export const createMongooseOptions = (configService: ConfigService): MongooseModuleOptions => ({ - uri: configService.get('mongodb.uri', { infer: true }), - autoIndex: true, -}); +export const createMongooseOptions = (configService: ConfigService): MongooseModuleOptions => { + const nodeEnv = configService.get('nodeEnv', { infer: true }) ?? 'development'; + return { + uri: configService.get('mongodb.uri', { infer: true }), + autoIndex: + configService.get('mongodb.autoIndex', { infer: true }) ?? nodeEnv !== 'production', + minPoolSize: configService.get('mongodb.minPoolSize', { infer: true }) ?? 5, + maxPoolSize: configService.get('mongodb.maxPoolSize', { infer: true }) ?? 100, + maxIdleTimeMS: configService.get('mongodb.maxIdleTimeMs', { infer: true }) ?? 60_000, + serverSelectionTimeoutMS: + configService.get('mongodb.serverSelectionTimeoutMs', { infer: true }) ?? 10_000, + socketTimeoutMS: configService.get('mongodb.socketTimeoutMs', { infer: true }) ?? 45_000, + }; +}; diff --git a/src/infrastructure/cache/app-cache.behavior.spec.ts b/src/infrastructure/cache/app-cache.behavior.spec.ts new file mode 100644 index 0000000..65f3556 --- /dev/null +++ b/src/infrastructure/cache/app-cache.behavior.spec.ts @@ -0,0 +1,150 @@ +import { RedisService } from '../redis/redis.service'; +import { AppCacheService, CacheFillLock } from './app-cache.service'; + +type RedisDouble = { + get: jest.Mock; + set: jest.Mock; + del: jest.Mock; + incr: jest.Mock; + expire: jest.Mock; + eval: jest.Mock; + exists: jest.Mock; +}; + +const redisDouble = (): RedisDouble => ({ + get: jest.fn(), set: jest.fn(), del: jest.fn(), incr: jest.fn(), expire: jest.fn(), + eval: jest.fn(), exists: jest.fn(), +}); + +const redisService = (client: RedisDouble | null): RedisService => + ({ getClient: jest.fn(() => client), getKeyPrefix: jest.fn(() => 'spec') }) as unknown as RedisService; + +type CacheInternals = { + normalizeLockTtlMilliseconds(value: number): number; + normalizeDurationMilliseconds(value: number, minimum: number): number; + withJitter(value: number): number; + errorMessage(error: unknown): string; +}; + +describe('AppCacheService behavior', () => { + it('supports memory get, set, expiration, deletion, and absent writes', async () => { + const service = new AppCacheService(redisService(null)); + await service.set('plain', { id: 1 }); + await expect(service.get('plain')).resolves.toEqual({ id: 1 }); + await expect(service.setIfAbsent('plain', 2, 10)).resolves.toBe(false); + await service.del('plain'); + await expect(service.get('plain')).resolves.toBeNull(); + await expect(service.setIfAbsent('plain', 2, 0)).resolves.toBe(true); + await expect(service.get('plain')).resolves.toBe(2); + + const now = jest.spyOn(Date, 'now'); + now.mockReturnValueOnce(1000); + await service.set('short', 'value', 1); + now.mockReturnValue(2001); + await expect(service.get('short')).resolves.toBeNull(); + now.mockRestore(); + }); + + it('uses Redis for CRUD, NX, and JSON serialization', async () => { + const redis = redisDouble(); + const service = new AppCacheService(redisService(redis)); + redis.get.mockResolvedValueOnce(null).mockResolvedValueOnce('{"id":4}'); + await expect(service.get('missing')).resolves.toBeNull(); + await expect(service.get('item')).resolves.toEqual({ id: 4 }); + await service.set('ttl', { yes: true }, 4); + expect(redis.set).toHaveBeenCalledWith('spec:ttl', '{"yes":true}', 'EX', 4); + await service.set('forever', 1, 0); + expect(redis.set).toHaveBeenCalledWith('spec:forever', '1'); + redis.set.mockResolvedValueOnce('OK').mockResolvedValueOnce(null); + await expect(service.setIfAbsent('one', 1, 0)).resolves.toBe(true); + await expect(service.setIfAbsent('one', 1, 10)).resolves.toBe(false); + expect(redis.set).toHaveBeenCalledWith('spec:one', '1', 'EX', 1, 'NX'); + await service.del('one'); + expect(redis.del).toHaveBeenCalledWith('spec:one'); + }); + + it('increments Redis values and expires only a newly created counter', async () => { + const redis = redisDouble(); + const service = new AppCacheService(redisService(redis)); + redis.incr.mockResolvedValueOnce(1).mockResolvedValueOnce(2); + await expect(service.incr('counter', 5)).resolves.toBe(1); + await expect(service.incr('counter', 5)).resolves.toBe(2); + expect(redis.expire).toHaveBeenCalledTimes(1); + expect(redis.expire).toHaveBeenCalledWith('spec:counter', 5); + }); + + it('increments memory counters and resets expired values', async () => { + const service = new AppCacheService(redisService(null)); + await expect(service.incr('counter')).resolves.toBe(1); + await expect(service.incr('counter')).resolves.toBe(2); + const now = jest.spyOn(Date, 'now'); + now.mockReturnValueOnce(1000); + await service.set('expiring-counter', 9, 1); + now.mockReturnValue(2001); + await expect(service.incr('expiring-counter', 5)).resolves.toBe(1); + now.mockRestore(); + }); + + it('enforces memory lock ownership, extension, expiration, and one-time release', async () => { + const service = new AppCacheService(redisService(null)); + const lock = await service.acquireFillLock('fill', 10); + expect(lock.acquired).toBe(true); + expect((await service.acquireFillLock('fill', 10)).acquired).toBe(false); + await expect(lock.extend(20)).resolves.toBe(true); + await expect(lock.release()).resolves.toBe(true); + await expect(lock.extend()).resolves.toBe(false); + await expect(lock.release()).resolves.toBe(false); + + const now = jest.spyOn(Date, 'now'); + now.mockReturnValueOnce(1000); + await service.acquireFillLock('expired', 1); + now.mockReturnValue(3000); + expect((await service.acquireFillLock('expired', 1)).acquired).toBe(true); + now.mockRestore(); + }); + + it('handles Redis lock contention, extension, and release', async () => { + const redis = redisDouble(); + const service = new AppCacheService(redisService(redis)); + redis.set.mockResolvedValueOnce(null); + const unavailable = await service.acquireFillLock('fill', Number.NaN); + expect(unavailable.acquired).toBe(false); + await expect(unavailable.extend()).resolves.toBe(false); + await expect(unavailable.release()).resolves.toBe(false); + + redis.set.mockResolvedValueOnce('OK'); + redis.eval.mockResolvedValueOnce(1).mockResolvedValueOnce(1); + const lock = await service.acquireFillLock('fill', 0); + expect(lock.acquired).toBe(true); + await expect(lock.extend(2)).resolves.toBe(true); + await expect(lock.release()).resolves.toBe(true); + await expect(lock.release()).resolves.toBe(false); + expect(redis.set).toHaveBeenLastCalledWith(expect.stringContaining('__fill_lock'), expect.any(String), 'PX', 1000, 'NX'); + }); + + it('returns immediately when waiting has no time budget', async () => { + const service = new AppCacheService(redisService(null)); + await expect(service.waitForValue('never', 0, Number.NaN)).resolves.toBeNull(); + }); + + it('releases a remember lease even when factories reject and tolerates release failures', async () => { + const service = new AppCacheService(redisService(null)); + const release = jest.fn().mockRejectedValue(new Error('Redis unavailable')); + const lock: CacheFillLock = { acquired: true, extend: jest.fn().mockResolvedValue(true), release }; + jest.spyOn(service, 'acquireFillLock').mockResolvedValue(lock); + await expect(service.remember('broken', 10, async () => { throw new Error('factory failed'); })).rejects.toThrow('factory failed'); + expect(release).toHaveBeenCalled(); + }); + + it('normalizes unsafe durations and formats unknown errors', () => { + const service = new AppCacheService(redisService(null)); + const target = service as unknown as CacheInternals; + expect(target.normalizeLockTtlMilliseconds(-1)).toBe(1000); + expect(target.normalizeLockTtlMilliseconds(0.0001)).toBe(1); + expect(target.normalizeDurationMilliseconds(Number.NaN, 3)).toBe(3); + expect(target.normalizeDurationMilliseconds(2.9, 3)).toBe(3); + expect(target.withJitter(0)).toBeGreaterThanOrEqual(1); + expect(target.errorMessage(new Error('boom'))).toBe('boom'); + expect(target.errorMessage('boom')).toBe('boom'); + }); +}); diff --git a/src/infrastructure/cache/app-cache.service.spec.ts b/src/infrastructure/cache/app-cache.service.spec.ts new file mode 100644 index 0000000..67beb16 --- /dev/null +++ b/src/infrastructure/cache/app-cache.service.spec.ts @@ -0,0 +1,142 @@ +import { RedisService } from '../redis/redis.service'; +import { AppCacheService } from './app-cache.service'; + +class FakeRedis { + readonly values = new Map(); + + async get(key: string): Promise { + return this.values.get(key) ?? null; + } + + async set( + key: string, + value: string, + ...options: Array + ): Promise<'OK' | null> { + if (options.includes('NX') && this.values.has(key)) { + return null; + } + this.values.set(key, value); + return 'OK'; + } + + async exists(key: string): Promise { + return this.values.has(key) ? 1 : 0; + } + + async eval( + script: string, + _numberOfKeys: number, + key: string, + ownerToken: string, + _ttlMs?: number, + ): Promise { + if (this.values.get(key) !== ownerToken) { + return 0; + } + if (script.includes("redis.call('pexpire'")) { + return 1; + } + this.values.delete(key); + return 1; + } +} + +const createRedisService = (client: FakeRedis | null): RedisService => + ({ + getClient: jest.fn(() => client), + getKeyPrefix: jest.fn(() => 'test'), + }) as unknown as RedisService; + +const nextTurn = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +describe('AppCacheService stampede protection', () => { + it('coalesces concurrent local cache misses into one factory call', async () => { + const service = new AppCacheService(createRedisService(null)); + let resolveFactory!: (value: { id: number }) => void; + const factory = jest.fn( + () => + new Promise<{ id: number }>((resolve) => { + resolveFactory = resolve; + }), + ); + + const requests = Array.from({ length: 25 }, () => service.remember('feed:1', 60, factory)); + await nextTurn(); + expect(factory).toHaveBeenCalledTimes(1); + resolveFactory({ id: 42 }); + + await expect(Promise.all(requests)).resolves.toEqual( + Array.from({ length: 25 }, () => ({ id: 42 })), + ); + await expect(service.remember('feed:1', 60, factory)).resolves.toEqual({ id: 42 }); + expect(factory).toHaveBeenCalledTimes(1); + }); + + it('clears a failed local single-flight so a later request can retry', async () => { + const service = new AppCacheService(createRedisService(null)); + const failedFactory = jest.fn().mockRejectedValue(new Error('database unavailable')); + + await expect(service.remember('feed:failure', 60, failedFactory)).rejects.toThrow( + 'database unavailable', + ); + await expect( + service.remember('feed:failure', 60, async () => ({ recovered: true })), + ).resolves.toEqual({ recovered: true }); + expect(failedFactory).toHaveBeenCalledTimes(1); + }); + + it('uses a shared Redis lease to coalesce fills across service instances', async () => { + const redis = new FakeRedis(); + const firstService = new AppCacheService(createRedisService(redis)); + const secondService = new AppCacheService(createRedisService(redis)); + let resolveFirst!: (value: string) => void; + const firstFactory = jest.fn( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const secondFactory = jest.fn(async () => 'duplicate'); + + const first = firstService.remember('feed:shared', 60, firstFactory); + await nextTurn(); + expect(firstFactory).toHaveBeenCalledTimes(1); + const second = secondService.remember('feed:shared', 60, secondFactory); + await nextTurn(); + resolveFirst('canonical'); + + await expect(Promise.all([first, second])).resolves.toEqual(['canonical', 'canonical']); + expect(secondFactory).not.toHaveBeenCalled(); + }); + + it('never releases or extends a Redis lock now owned by another request', async () => { + const redis = new FakeRedis(); + const service = new AppCacheService(createRedisService(redis)); + const lease = await service.acquireFillLock('feed:ownership', 10); + const lockKey = 'test:feed:ownership:__fill_lock'; + + expect(lease.acquired).toBe(true); + redis.values.set(lockKey, 'replacement-owner'); + await expect(lease.extend(20)).resolves.toBe(false); + await expect(lease.release()).resolves.toBe(false); + expect(redis.values.get(lockKey)).toBe('replacement-owner'); + }); + + it('releases its own Redis lock atomically and only once', async () => { + const redis = new FakeRedis(); + const service = new AppCacheService(createRedisService(redis)); + const lease = await service.acquireFillLock('feed:release', 10); + + await expect(lease.release()).resolves.toBe(true); + await expect(lease.release()).resolves.toBe(false); + expect(redis.values.has('test:feed:release:__fill_lock')).toBe(false); + }); + + it('waits for a value populated by another request', async () => { + const service = new AppCacheService(createRedisService(null)); + const waiting = service.waitForValue<{ ready: boolean }>('feed:waiting', 200, 5); + setTimeout(() => void service.set('feed:waiting', { ready: true }, 60), 10); + await expect(waiting).resolves.toEqual({ ready: true }); + }); +}); diff --git a/src/infrastructure/cache/app-cache.service.ts b/src/infrastructure/cache/app-cache.service.ts index fd2367f..911723d 100644 --- a/src/infrastructure/cache/app-cache.service.ts +++ b/src/infrastructure/cache/app-cache.service.ts @@ -1,4 +1,5 @@ -import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { Injectable, Logger } from '@nestjs/common'; import { RedisService } from '../redis/redis.service'; type MemoryEntry = { @@ -6,9 +7,41 @@ type MemoryEntry = { expiresAt: number | null; }; +type MemoryLock = { + ownerToken: string; + expiresAt: number; +}; + +export type CacheFillLock = { + readonly acquired: boolean; + extend: (ttlSeconds?: number) => Promise; + release: () => Promise; +}; + +const RELEASE_LOCK_SCRIPT = ` +if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) +end +return 0 +`; + +const EXTEND_LOCK_SCRIPT = ` +if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('pexpire', KEYS[1], ARGV[2]) +end +return 0 +`; + @Injectable() export class AppCacheService { + private static readonly DEFAULT_FILL_LOCK_TTL_SECONDS = 30; + private static readonly DEFAULT_FILL_WAIT_TIMEOUT_MS = 30_000; + private static readonly DEFAULT_FILL_POLL_MS = 50; + + private readonly logger = new Logger(AppCacheService.name); private readonly memory = new Map(); + private readonly memoryLocks = new Map(); + private readonly inFlight = new Map>(); constructor(private readonly redisService: RedisService) {} @@ -56,6 +89,28 @@ export class AppCacheService { this.memory.set(fullKey, { value: serialized, expiresAt }); } + async setIfAbsent(key: string, value: T, ttlSeconds: number): Promise { + const redis = this.redisService.getClient(); + const fullKey = this.buildKey(key); + const serialized = JSON.stringify(value); + + if (redis) { + const result = await redis.set(fullKey, serialized, 'EX', Math.max(1, ttlSeconds), 'NX'); + return result === 'OK'; + } + + const existing = this.memory.get(fullKey); + if (existing && (!existing.expiresAt || existing.expiresAt > Date.now())) { + return false; + } + + this.memory.set(fullKey, { + value: serialized, + expiresAt: Date.now() + Math.max(1, ttlSeconds) * 1000, + }); + return true; + } + async del(key: string): Promise { const redis = this.redisService.getClient(); const fullKey = this.buildKey(key); @@ -73,9 +128,132 @@ export class AppCacheService { return cached; } - const value = await factory(); - await this.set(key, value, ttlSeconds); - return value; + const fullKey = this.buildKey(key); + const existingFill = this.inFlight.get(fullKey) as Promise | undefined; + if (existingFill) { + return existingFill; + } + + const fill = this.fillRememberedValue(key, ttlSeconds, factory); + this.inFlight.set(fullKey, fill); + + try { + return await fill; + } finally { + if (this.inFlight.get(fullKey) === fill) { + this.inFlight.delete(fullKey); + } + } + } + + /** + * Acquires a short-lived cache-fill lease. The returned release/extend + * functions are ownership checked, so an expired lock can never be removed + * or extended by its previous owner. + */ + async acquireFillLock(key: string, ttlSeconds: number): Promise { + const redis = this.redisService.getClient(); + const lockKey = this.buildLockKey(key); + const ownerToken = randomUUID(); + const ttlMs = this.normalizeLockTtlMilliseconds(ttlSeconds); + + if (redis) { + const result = await redis.set(lockKey, ownerToken, 'PX', ttlMs, 'NX'); + if (result !== 'OK') { + return this.createUnavailableLock(); + } + + let released = false; + return { + acquired: true, + extend: async (nextTtlSeconds = ttlSeconds): Promise => { + if (released) { + return false; + } + + const extended = await redis.eval( + EXTEND_LOCK_SCRIPT, + 1, + lockKey, + ownerToken, + this.normalizeLockTtlMilliseconds(nextTtlSeconds), + ); + return Number(extended) === 1; + }, + release: async (): Promise => { + if (released) { + return false; + } + + const deleted = await redis.eval(RELEASE_LOCK_SCRIPT, 1, lockKey, ownerToken); + if (Number(deleted) === 1) { + released = true; + return true; + } + return false; + }, + }; + } + + const now = Date.now(); + const current = this.memoryLocks.get(lockKey); + if (current && current.expiresAt > now) { + return this.createUnavailableLock(); + } + + this.memoryLocks.set(lockKey, { ownerToken, expiresAt: now + ttlMs }); + let released = false; + + return { + acquired: true, + extend: async (nextTtlSeconds = ttlSeconds): Promise => { + if (released) { + return false; + } + + const lock = this.memoryLocks.get(lockKey); + if (!lock || lock.ownerToken !== ownerToken || lock.expiresAt <= Date.now()) { + return false; + } + + lock.expiresAt = Date.now() + this.normalizeLockTtlMilliseconds(nextTtlSeconds); + return true; + }, + release: async (): Promise => { + if (released) { + return false; + } + + const lock = this.memoryLocks.get(lockKey); + if (!lock || lock.ownerToken !== ownerToken) { + return false; + } + + this.memoryLocks.delete(lockKey); + released = true; + return true; + }, + }; + } + + async waitForValue(key: string, timeoutMs: number, pollMs = 50): Promise { + const timeout = this.normalizeDurationMilliseconds(timeoutMs, 0); + const pollInterval = this.normalizeDurationMilliseconds(pollMs, 1); + const deadline = Date.now() + timeout; + + while (true) { + const cached = await this.get(key); + if (cached !== null) { + return cached; + } + + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return null; + } + + await this.delay(Math.min(this.withJitter(pollInterval), remaining)); + } } async incr(key: string, ttlSeconds?: number): Promise { @@ -90,13 +268,182 @@ export class AppCacheService { return nextValue; } - const existing = await this.get(key); - const nextValue = (existing ?? 0) + 1; - await this.set(key, nextValue, ttlSeconds); + const existing = this.memory.get(fullKey); + const currentValue = + existing && (!existing.expiresAt || existing.expiresAt > Date.now()) + ? (JSON.parse(existing.value) as number) + : 0; + const nextValue = currentValue + 1; + this.memory.set(fullKey, { + value: JSON.stringify(nextValue), + expiresAt: ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null, + }); return nextValue; } private buildKey(key: string): string { return `${this.redisService.getKeyPrefix()}:${key}`; } + + private buildLockKey(key: string): string { + return `${this.buildKey(key)}:__fill_lock`; + } + + private async fillRememberedValue( + key: string, + ttlSeconds: number, + factory: () => Promise, + ): Promise { + const cachedAfterJoining = await this.get(key); + if (cachedAfterJoining !== null) { + return cachedAfterJoining; + } + + const lockTtlSeconds = AppCacheService.DEFAULT_FILL_LOCK_TTL_SECONDS; + const deadline = Date.now() + AppCacheService.DEFAULT_FILL_WAIT_TIMEOUT_MS; + + while (Date.now() < deadline) { + const lock = await this.acquireFillLock(key, lockTtlSeconds); + if (lock.acquired) { + return this.fillAsLockOwner(key, ttlSeconds, factory, lock, lockTtlSeconds); + } + + const value = await this.waitForValueWhileLocked( + key, + Math.max(0, deadline - Date.now()), + AppCacheService.DEFAULT_FILL_POLL_MS, + ); + if (value !== null) { + return value; + } + } + + // Keep request latency bounded if a remote owner is unhealthy or its + // factory never completes. This rare fallback may duplicate work, but it + // prevents an orphaned cache lease from taking down the request path. + const lastCachedValue = await this.get(key); + if (lastCachedValue !== null) { + return lastCachedValue; + } + + const value = await factory(); + await this.set(key, value, ttlSeconds); + return value; + } + + private async fillAsLockOwner( + key: string, + ttlSeconds: number, + factory: () => Promise, + lock: CacheFillLock, + lockTtlSeconds: number, + ): Promise { + const heartbeat = setInterval(() => { + void lock.extend(lockTtlSeconds).catch((error: unknown) => { + this.logger.warn(`Could not extend cache fill lock for ${key}: ${this.errorMessage(error)}`); + }); + }, Math.max(1_000, Math.floor((lockTtlSeconds * 1000) / 3))); + heartbeat.unref?.(); + + try { + // A different owner may have filled the value immediately before this + // process acquired the lease. + const cached = await this.get(key); + if (cached !== null) { + return cached; + } + + const value = await factory(); + await this.set(key, value, ttlSeconds); + return value; + } finally { + clearInterval(heartbeat); + try { + await lock.release(); + } catch (error: unknown) { + // A successful cache fill must not be turned into a failed request by + // a transient Redis error during best-effort lease cleanup. + this.logger.warn(`Could not release cache fill lock for ${key}: ${this.errorMessage(error)}`); + } + } + } + + private async waitForValueWhileLocked( + key: string, + timeoutMs: number, + pollMs: number, + ): Promise { + const deadline = Date.now() + timeoutMs; + + while (true) { + const cached = await this.get(key); + if (cached !== null) { + return cached; + } + + if (!(await this.isFillLockHeld(key))) { + return null; + } + + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return null; + } + + await this.delay(Math.min(this.withJitter(pollMs), remaining)); + } + } + + private async isFillLockHeld(key: string): Promise { + const redis = this.redisService.getClient(); + const lockKey = this.buildLockKey(key); + if (redis) { + return (await redis.exists(lockKey)) === 1; + } + + const lock = this.memoryLocks.get(lockKey); + if (!lock) { + return false; + } + + if (lock.expiresAt <= Date.now()) { + this.memoryLocks.delete(lockKey); + return false; + } + return true; + } + + private createUnavailableLock(): CacheFillLock { + return { + acquired: false, + extend: async () => false, + release: async () => false, + }; + } + + private normalizeLockTtlMilliseconds(ttlSeconds: number): number { + if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) { + return 1_000; + } + return Math.max(1, Math.ceil(ttlSeconds * 1000)); + } + + private normalizeDurationMilliseconds(value: number, minimum: number): number { + if (!Number.isFinite(value)) { + return minimum; + } + return Math.max(minimum, Math.floor(value)); + } + + private withJitter(durationMs: number): number { + return Math.max(1, Math.round(durationMs * (0.8 + Math.random() * 0.4))); + } + + private delay(durationMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, durationMs)); + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } } diff --git a/src/infrastructure/cache/feed-version.service.spec.ts b/src/infrastructure/cache/feed-version.service.spec.ts new file mode 100644 index 0000000..7cd5aa9 --- /dev/null +++ b/src/infrastructure/cache/feed-version.service.spec.ts @@ -0,0 +1,33 @@ +import { AppCacheService } from './app-cache.service'; +import { FeedVersionService } from './feed-version.service'; + +describe('FeedVersionService', () => { + const cache = { get: jest.fn(), set: jest.fn(), incr: jest.fn() } as unknown as AppCacheService; + const service = new FeedVersionService(cache); + + beforeEach(() => jest.clearAllMocks()); + + it('returns existing positive global and user versions', async () => { + (cache.get as jest.Mock).mockResolvedValueOnce(7).mockResolvedValueOnce(3); + await expect(service.getGlobalVersion()).resolves.toBe(7); + await expect(service.getUserVersion('u1')).resolves.toBe(3); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it.each([null, 0, -1, '1'])('initializes invalid global version %p', async (stored) => { + (cache.get as jest.Mock).mockResolvedValue(stored); + await expect(service.getGlobalVersion()).resolves.toBe(1); + expect(cache.set).toHaveBeenCalledWith('feed:global:version', 1); + }); + + it('initializes an absent user version and bumps both scopes', async () => { + (cache.get as jest.Mock).mockResolvedValue(null); + (cache.incr as jest.Mock).mockResolvedValueOnce(4).mockResolvedValueOnce(9); + await expect(service.getUserVersion('u2')).resolves.toBe(1); + expect(cache.set).toHaveBeenCalledWith('feed:user:u2:version', 1); + await expect(service.bumpGlobalVersion()).resolves.toBe(4); + await expect(service.bumpUserVersion('u2')).resolves.toBe(9); + expect(cache.incr).toHaveBeenNthCalledWith(1, 'feed:global:version'); + expect(cache.incr).toHaveBeenNthCalledWith(2, 'feed:user:u2:version'); + }); +}); diff --git a/src/infrastructure/cache/feed-version.service.ts b/src/infrastructure/cache/feed-version.service.ts index b905dd6..99a639c 100644 --- a/src/infrastructure/cache/feed-version.service.ts +++ b/src/infrastructure/cache/feed-version.service.ts @@ -20,4 +20,16 @@ export class FeedVersionService { async bumpGlobalVersion(): Promise { return this.cacheService.incr(FeedVersionService.GLOBAL_VERSION_KEY); } + + async getUserVersion(userId: string): Promise { + const key = `feed:user:${userId}:version`; + const current = await this.cacheService.get(key); + if (typeof current === 'number' && current > 0) return current; + await this.cacheService.set(key, 1); + return 1; + } + + async bumpUserVersion(userId: string): Promise { + return this.cacheService.incr(`feed:user:${userId}:version`); + } } diff --git a/src/infrastructure/logging/app-logger.service.spec.ts b/src/infrastructure/logging/app-logger.service.spec.ts new file mode 100644 index 0000000..57c7fc8 --- /dev/null +++ b/src/infrastructure/logging/app-logger.service.spec.ts @@ -0,0 +1,55 @@ +import { ConfigService } from '@nestjs/config'; +import { AppLoggerService } from './app-logger.service'; + +describe('AppLoggerService', () => { + let level = 'verbose'; + const config = { get: jest.fn(() => level) } as unknown as ConfigService; + let stdout: jest.SpyInstance; + let stderr: jest.SpyInstance; + + beforeEach(() => { + level = 'verbose'; + stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + stdout.mockRestore(); + stderr.mockRestore(); + }); + + it('writes structured messages, errors, traces, and HTTP metadata', () => { + const logger = new AppLoggerService(config); + logger.log('hello', 'Test'); + logger.warn({ id: 1 }); + logger.debug(new Error('debug failed')); + logger.verbose('details'); + logger.logHttp({ method: 'GET', path: '/health' }); + logger.error('broken', 'trace-text', 'Test'); + + expect(stdout).toHaveBeenCalledTimes(5); + const logEntry = JSON.parse(String(stdout.mock.calls[0][0])); + expect(logEntry).toEqual(expect.objectContaining({ level: 'log', context: 'Test', message: 'hello' })); + const structured = JSON.parse(String(stdout.mock.calls[1][0])); + expect(structured).toEqual(expect.objectContaining({ message: 'structured_log', payload: { id: 1 } })); + const errorObject = JSON.parse(String(stdout.mock.calls[2][0])); + expect(errorObject).toEqual(expect.objectContaining({ errorName: 'Error', message: 'debug failed' })); + const http = JSON.parse(String(stdout.mock.calls[4][0])); + expect(http).toEqual(expect.objectContaining({ context: 'HttpLogger', method: 'GET', path: '/health' })); + const error = JSON.parse(String(stderr.mock.calls[0][0])); + expect(error).toEqual(expect.objectContaining({ level: 'error', trace: 'trace-text' })); + }); + + it('filters messages below the configured level and defaults to log', () => { + level = 'warn'; + const logger = new AppLoggerService(config); + logger.log('hidden'); + logger.debug('hidden'); + logger.warn('visible'); + expect(stdout).toHaveBeenCalledTimes(1); + + (config.get as jest.Mock).mockReturnValueOnce(undefined); + logger.verbose('default-hidden'); + expect(stdout).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/infrastructure/maintenance/counter-reconciliation.service.spec.ts b/src/infrastructure/maintenance/counter-reconciliation.service.spec.ts new file mode 100644 index 0000000..4bd19e1 --- /dev/null +++ b/src/infrastructure/maintenance/counter-reconciliation.service.spec.ts @@ -0,0 +1,80 @@ +import { Types } from 'mongoose'; +import { AppQueueService } from '../queue/app-queue.service'; +import { CounterReconciliationService } from './counter-reconciliation.service'; +import { MaintenanceController } from './maintenance.controller'; + +describe('CounterReconciliationService', () => { + const postOne = new Types.ObjectId(); + const postTwo = new Types.ObjectId(); + let rows: Record>; + let postsBulkWrite: jest.Mock; + let collection: jest.Mock; + const queue = { registerProcessor: jest.fn(), enqueue: jest.fn() } as unknown as AppQueueService; + + beforeEach(() => { + jest.clearAllMocks(); + rows = { + likes: [{ _id: postOne, count: 3 }], + comments: [{ _id: postOne, count: 2 }], + saves: [{ _id: postTwo, count: 5 }], + postshares: [{ _id: postOne, count: 1 }], + }; + postsBulkWrite = jest.fn(); + collection = jest.fn((name: string) => + name === 'posts' + ? { + find: jest.fn(() => ({ toArray: jest.fn().mockResolvedValue([{ _id: postOne }, { _id: postTwo }]) })), + bulkWrite: postsBulkWrite, + } + : { aggregate: jest.fn(() => ({ toArray: jest.fn().mockResolvedValue(rows[name]) })) }, + ); + }); + + it('registers and enqueues reconciliation jobs', async () => { + const service = new CounterReconciliationService({ collection } as never, queue); + service.onModuleInit(); + expect(queue.registerProcessor).toHaveBeenCalledWith( + 'maintenance.reconcile-post-counters', + expect.any(Function), + ); + const callback = (queue.registerProcessor as jest.Mock).mock.calls[0][1] as () => Promise; + const reconcile = jest.spyOn(service, 'reconcile').mockResolvedValue({ dryRun: false, scannedPosts: 0, updates: 0 }); + await callback(); + expect(reconcile).toHaveBeenCalledWith(false); + await expect(service.enqueue()).resolves.toEqual({ queued: true }); + expect(queue.enqueue).toHaveBeenCalledWith('maintenance.reconcile-post-counters', {}); + }); + + it('computes every counter without writes during a dry run', async () => { + const service = new CounterReconciliationService({ collection } as never, queue); + await expect(service.reconcile()).resolves.toEqual({ dryRun: true, scannedPosts: 2, updates: 2 }); + expect(postsBulkWrite).not.toHaveBeenCalled(); + const likesAggregate = collection.mock.results.find((result) => result.value.aggregate)?.value.aggregate; + expect(likesAggregate).toHaveBeenCalledWith([ + { $match: { targetType: 'post' } }, + { $group: { _id: '$targetId', count: { $sum: 1 } } }, + { $match: { _id: { $type: 'objectId' } } }, + ]); + }); + + it('writes reconciled values including zeros', async () => { + const service = new CounterReconciliationService({ collection } as never, queue); + await service.reconcile(false); + expect(postsBulkWrite).toHaveBeenCalledWith( + [ + expect.objectContaining({ updateOne: expect.objectContaining({ update: { $set: { likesCount: 3, commentsCount: 2, savesCount: 0, shareCount: 1 } } }) }), + expect.objectContaining({ updateOne: expect.objectContaining({ update: { $set: { likesCount: 0, commentsCount: 0, savesCount: 5, shareCount: 0 } } }) }), + ], + { ordered: false }, + ); + }); + + it('controller executes only on an explicit true flag', async () => { + const counters = { enqueue: jest.fn().mockResolvedValue({ queued: true }), reconcile: jest.fn().mockResolvedValue({ dryRun: true }) }; + const controller = new MaintenanceController(counters as never); + await controller.reconcile('true'); + await controller.reconcile('TRUE'); + expect(counters.enqueue).toHaveBeenCalledTimes(1); + expect(counters.reconcile).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/infrastructure/maintenance/counter-reconciliation.service.ts b/src/infrastructure/maintenance/counter-reconciliation.service.ts new file mode 100644 index 0000000..1efc6ae --- /dev/null +++ b/src/infrastructure/maintenance/counter-reconciliation.service.ts @@ -0,0 +1,64 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { InjectConnection } from '@nestjs/mongoose'; +import { Connection, Types } from 'mongoose'; +import { AppQueueService } from '../queue/app-queue.service'; + +const JOB_NAME = 'maintenance.reconcile-post-counters'; + +@Injectable() +export class CounterReconciliationService implements OnModuleInit { + constructor( + @InjectConnection() private readonly connection: Connection, + private readonly queue: AppQueueService, + ) {} + + onModuleInit(): void { + this.queue.registerProcessor(JOB_NAME, async () => { await this.reconcile(false); }); + } + + async enqueue(): Promise<{ queued: true }> { + await this.queue.enqueue(JOB_NAME, {}); + return { queued: true }; + } + + async reconcile(dryRun = true): Promise<{ dryRun: boolean; scannedPosts: number; updates: number }> { + const posts = await this.connection.collection('posts').find( + { isDeleted: { $ne: true } }, + { projection: { _id: 1 } }, + ).toArray(); + const [likes, comments, saves, shares] = await Promise.all([ + this.countByPost('likes', { targetType: 'post' }, '$targetId'), + this.countByPost('comments', { isDeleted: { $ne: true } }, '$postId'), + this.countByPost('saves', {}, '$postId'), + this.countByPost('postshares', { isDeleted: { $ne: true } }, '$postId'), + ]); + const maps = [likes, comments, saves, shares].map((rows) => + new Map(rows.map((row) => [row._id.toString(), row.count])), + ); + const operations = posts.map((post) => ({ + updateOne: { + filter: { _id: post._id }, + update: { $set: { + likesCount: maps[0].get(post._id.toString()) ?? 0, + commentsCount: maps[1].get(post._id.toString()) ?? 0, + savesCount: maps[2].get(post._id.toString()) ?? 0, + shareCount: maps[3].get(post._id.toString()) ?? 0, + } }, + }, + })); + if (!dryRun) { + for (let offset = 0; offset < operations.length; offset += 500) { + await this.connection.collection('posts').bulkWrite(operations.slice(offset, offset + 500), { ordered: false }); + } + } + return { dryRun, scannedPosts: posts.length, updates: operations.length }; + } + + private countByPost(collection: string, match: Record, id: string) { + return this.connection.collection(collection).aggregate<{ _id: Types.ObjectId; count: number }>([ + { $match: match }, + { $group: { _id: id, count: { $sum: 1 } } }, + { $match: { _id: { $type: 'objectId' } } }, + ]).toArray(); + } +} diff --git a/src/infrastructure/maintenance/maintenance.controller.ts b/src/infrastructure/maintenance/maintenance.controller.ts new file mode 100644 index 0000000..0ae16ca --- /dev/null +++ b/src/infrastructure/maintenance/maintenance.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard'; +import { CounterReconciliationService } from './counter-reconciliation.service'; + +@ApiTags('Operations') +@ApiBearerAuth() +@UseGuards(SuperAdminJwtAuthGuard) +@Controller('internal/maintenance') +export class MaintenanceController { + constructor(private readonly counters: CounterReconciliationService) {} + + @Post('reconcile-post-counters') + reconcile(@Query('execute') execute?: string) { + return execute === 'true' ? this.counters.enqueue() : this.counters.reconcile(true); + } +} diff --git a/src/infrastructure/maintenance/maintenance.module.ts b/src/infrastructure/maintenance/maintenance.module.ts new file mode 100644 index 0000000..4dc24dc --- /dev/null +++ b/src/infrastructure/maintenance/maintenance.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { QueueModule } from '../queue/queue.module'; +import { CounterReconciliationService } from './counter-reconciliation.service'; +import { MaintenanceController } from './maintenance.controller'; + +@Module({ + imports: [QueueModule], + controllers: [MaintenanceController], + providers: [CounterReconciliationService], +}) +export class MaintenanceModule {} diff --git a/src/infrastructure/metrics/metrics.controller.spec.ts b/src/infrastructure/metrics/metrics.controller.spec.ts new file mode 100644 index 0000000..b782d54 --- /dev/null +++ b/src/infrastructure/metrics/metrics.controller.spec.ts @@ -0,0 +1,9 @@ +import { MetricsController } from './metrics.controller'; + +describe('MetricsController', () => { + it('returns a current metrics snapshot', () => { + const metrics = { snapshot: jest.fn(() => ({ requests: 4 })) }; + expect(new MetricsController(metrics as never).getMetrics()).toEqual({ requests: 4 }); + expect(metrics.snapshot).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/infrastructure/metrics/metrics.controller.ts b/src/infrastructure/metrics/metrics.controller.ts new file mode 100644 index 0000000..10ee140 --- /dev/null +++ b/src/infrastructure/metrics/metrics.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard'; +import { MetricsService } from './metrics.service'; + +@ApiTags('Operations') +@ApiBearerAuth() +@UseGuards(SuperAdminJwtAuthGuard) +@Controller('internal/metrics') +export class MetricsController { + constructor(private readonly metrics: MetricsService) {} + + @Get() + getMetrics() { + return this.metrics.snapshot(); + } +} diff --git a/src/infrastructure/metrics/metrics.interceptor.spec.ts b/src/infrastructure/metrics/metrics.interceptor.spec.ts new file mode 100644 index 0000000..15ba7f1 --- /dev/null +++ b/src/infrastructure/metrics/metrics.interceptor.spec.ts @@ -0,0 +1,60 @@ +import { CallHandler, ExecutionContext, InternalServerErrorException } from '@nestjs/common'; +import { firstValueFrom, of, throwError } from 'rxjs'; +import { MetricsInterceptor } from './metrics.interceptor'; +import { MetricsService } from './metrics.service'; + +describe('MetricsInterceptor', () => { + const createContext = (statusCode = 200) => + ({ + getType: () => 'http', + switchToHttp: () => ({ + getRequest: () => ({ + method: 'GET', + baseUrl: '/api/v1/posts', + route: { path: '/:id' }, + }), + getResponse: () => ({ statusCode }), + }), + }) as unknown as ExecutionContext; + + it('uses a route template instead of a high-cardinality raw URL', async () => { + const metrics = { + requestStarted: jest.fn(), + requestFinished: jest.fn(), + } as unknown as MetricsService; + const interceptor = new MetricsInterceptor(metrics); + + await firstValueFrom( + interceptor.intercept(createContext(), { handle: () => of({ ok: true }) } as CallHandler), + ); + + expect(metrics.requestStarted).toHaveBeenCalledTimes(1); + expect(metrics.requestFinished).toHaveBeenCalledWith( + 'GET', + '/api/v1/posts/:id', + 200, + expect.any(Number), + ); + }); + + it('records the actual exception status', async () => { + const metrics = { + requestStarted: jest.fn(), + requestFinished: jest.fn(), + } as unknown as MetricsService; + const interceptor = new MetricsInterceptor(metrics); + const handler = { + handle: () => throwError(() => new InternalServerErrorException()), + } as CallHandler; + + await expect(firstValueFrom(interceptor.intercept(createContext(), handler))).rejects.toBeInstanceOf( + InternalServerErrorException, + ); + expect(metrics.requestFinished).toHaveBeenCalledWith( + 'GET', + '/api/v1/posts/:id', + 500, + expect.any(Number), + ); + }); +}); diff --git a/src/infrastructure/metrics/metrics.interceptor.ts b/src/infrastructure/metrics/metrics.interceptor.ts new file mode 100644 index 0000000..5abaac7 --- /dev/null +++ b/src/infrastructure/metrics/metrics.interceptor.ts @@ -0,0 +1,56 @@ +import { + CallHandler, + ExecutionContext, + HttpException, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable, catchError, finalize, throwError } from 'rxjs'; +import { MetricsService } from './metrics.service'; + +type MetricRequest = { + method?: string; + baseUrl?: string; + route?: { path?: unknown }; +}; + +@Injectable() +export class MetricsInterceptor implements NestInterceptor { + constructor(private readonly metrics: MetricsService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (context.getType() !== 'http') { + return next.handle(); + } + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse<{ statusCode: number }>(); + const startedAt = Date.now(); + const route = this.normalizedRoute(request); + let errorStatusCode: number | undefined; + this.metrics.requestStarted(); + + return next.handle().pipe( + catchError((error: unknown) => { + errorStatusCode = error instanceof HttpException ? error.getStatus() : 500; + return throwError(() => error); + }), + finalize(() => { + this.metrics.requestFinished( + request.method ?? 'UNKNOWN', + route, + errorStatusCode ?? response.statusCode, + Date.now() - startedAt, + ); + }), + ); + } + + private normalizedRoute(request: MetricRequest): string { + const routePath = request.route?.path; + if (typeof routePath !== 'string') { + return '__unmatched__'; + } + const joined = `${request.baseUrl ?? ''}/${routePath}`.replace(/\/{2,}/g, '/'); + return joined.length > 1 ? joined.replace(/\/$/, '') : joined; + } +} diff --git a/src/infrastructure/metrics/metrics.module.ts b/src/infrastructure/metrics/metrics.module.ts new file mode 100644 index 0000000..322fabb --- /dev/null +++ b/src/infrastructure/metrics/metrics.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { MetricsController } from './metrics.controller'; +import { MetricsInterceptor } from './metrics.interceptor'; +import { MetricsService } from './metrics.service'; + +@Module({ + controllers: [MetricsController], + providers: [MetricsService, { provide: APP_INTERCEPTOR, useClass: MetricsInterceptor }], + exports: [MetricsService], +}) +export class MetricsModule {} diff --git a/src/infrastructure/metrics/metrics.service.spec.ts b/src/infrastructure/metrics/metrics.service.spec.ts new file mode 100644 index 0000000..432c507 --- /dev/null +++ b/src/infrastructure/metrics/metrics.service.spec.ts @@ -0,0 +1,65 @@ +import { ConfigService } from '@nestjs/config'; +import { MetricsService } from './metrics.service'; + +describe('MetricsService', () => { + const createService = (values: Record = {}) => { + const config = { + get: jest.fn((key: string) => values[key]), + } as unknown as ConfigService; + return new MetricsService(config); + }; + + it('aggregates latency, errors, timeouts, and an approximate p95 by route', () => { + const service = createService(); + service.observe('GET', '/feed/me', 200, 20); + service.observe('GET', '/feed/me', 408, 40); + service.observe('GET', '/feed/me', 500, 260); + + expect(service.snapshot().routes[0]).toEqual( + expect.objectContaining({ + route: 'GET /feed/me', + requests: 3, + clientErrors: 1, + errors: 1, + timeouts: 1, + errorRate: 1 / 3, + averageDurationMs: 106.67, + p95DurationMs: 500, + maxDurationMs: 260, + }), + ); + }); + + it('tracks current and peak in-flight work', () => { + const service = createService(); + service.requestStarted(); + service.requestStarted(); + service.requestFinished('GET', '/health', 200, 2); + + expect(service.snapshot().operational).toEqual({ + totalRequests: 2, + inFlightRequests: 1, + peakInFlightRequests: 2, + }); + }); + + it('caps route cardinality and exposes finite event-loop metrics', () => { + const service = createService({ 'metrics.maxRoutes': 1 }); + service.onModuleInit(); + service.observe('GET', '/first', 200, 1); + service.observe('GET', '/second', 200, 1); + const snapshot = service.snapshot(); + service.onModuleDestroy(); + + expect(snapshot.routes.map((route) => route.route)).toEqual( + expect.arrayContaining(['GET /first', '__other_routes__']), + ); + expect(snapshot.eventLoop).toEqual( + expect.objectContaining({ + healthy: expect.any(Boolean), + utilization: expect.any(Number), + lagP99Ms: expect.any(Number), + }), + ); + }); +}); diff --git a/src/infrastructure/metrics/metrics.service.ts b/src/infrastructure/metrics/metrics.service.ts new file mode 100644 index 0000000..49543c7 --- /dev/null +++ b/src/infrastructure/metrics/metrics.service.ts @@ -0,0 +1,165 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { IntervalHistogram, monitorEventLoopDelay, performance } from 'perf_hooks'; + +const LATENCY_BUCKETS_MS = [10, 25, 50, 100, 250, 500, 1_000, 2_500, 5_000]; +const OVERFLOW_ROUTE = '__other_routes__'; + +type RouteMetric = { + requests: number; + clientErrors: number; + errors: number; + timeouts: number; + totalDurationMs: number; + maxDurationMs: number; + latencyBuckets: number[]; +}; + +@Injectable() +export class MetricsService implements OnModuleInit, OnModuleDestroy { + private readonly startedAt = Date.now(); + private readonly routes = new Map(); + private readonly eventLoopBaseline = performance.eventLoopUtilization(); + private eventLoopDelay?: IntervalHistogram; + private totalRequests = 0; + private inFlightRequests = 0; + private peakInFlightRequests = 0; + + constructor(private readonly configService: ConfigService) {} + + onModuleInit(): void { + const resolution = + this.configService.get('metrics.eventLoopResolutionMs', { infer: true }) ?? 20; + this.eventLoopDelay = monitorEventLoopDelay({ resolution }); + this.eventLoopDelay.enable(); + } + + onModuleDestroy(): void { + this.eventLoopDelay?.disable(); + } + + requestStarted(): void { + this.totalRequests += 1; + this.inFlightRequests += 1; + this.peakInFlightRequests = Math.max(this.peakInFlightRequests, this.inFlightRequests); + } + + requestFinished(method: string, route: string, statusCode: number, durationMs: number): void { + this.inFlightRequests = Math.max(0, this.inFlightRequests - 1); + this.observe(method, route, statusCode, durationMs); + } + + observe(method: string, route: string, statusCode: number, durationMs: number): void { + const key = this.resolveRouteKey(`${method.toUpperCase()} ${route}`); + const current = this.routes.get(key) ?? this.createRouteMetric(); + current.requests += 1; + current.clientErrors += statusCode >= 400 && statusCode < 500 ? 1 : 0; + current.errors += statusCode >= 500 ? 1 : 0; + current.timeouts += statusCode === 408 ? 1 : 0; + current.totalDurationMs += durationMs; + current.maxDurationMs = Math.max(current.maxDurationMs, durationMs); + const bucketIndex = LATENCY_BUCKETS_MS.findIndex((boundary) => durationMs <= boundary); + current.latencyBuckets[bucketIndex === -1 ? LATENCY_BUCKETS_MS.length : bucketIndex] += 1; + this.routes.set(key, current); + } + + snapshot() { + const routes = Array.from(this.routes.entries()).map(([route, metric]) => ({ + route, + requests: metric.requests, + clientErrors: metric.clientErrors, + errors: metric.errors, + timeouts: metric.timeouts, + errorRate: metric.requests ? metric.errors / metric.requests : 0, + averageDurationMs: metric.requests + ? Math.round((metric.totalDurationMs / metric.requests) * 100) / 100 + : 0, + p95DurationMs: this.estimatePercentile(metric, 0.95), + maxDurationMs: metric.maxDurationMs, + })); + const eventLoopUtilization = performance.eventLoopUtilization(this.eventLoopBaseline); + const eventLoop = this.eventLoopSnapshot(eventLoopUtilization.utilization); + + return { + uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000), + process: { + pid: process.pid, + nodeVersion: process.version, + platform: process.platform, + }, + memory: process.memoryUsage(), + operational: { + totalRequests: this.totalRequests, + inFlightRequests: this.inFlightRequests, + peakInFlightRequests: this.peakInFlightRequests, + }, + eventLoop, + routes: routes.sort((left, right) => right.requests - left.requests), + }; + } + + private createRouteMetric(): RouteMetric { + return { + requests: 0, + clientErrors: 0, + errors: 0, + timeouts: 0, + totalDurationMs: 0, + maxDurationMs: 0, + latencyBuckets: Array.from({ length: LATENCY_BUCKETS_MS.length + 1 }, () => 0), + }; + } + + private resolveRouteKey(candidate: string): string { + if (this.routes.has(candidate)) { + return candidate; + } + const maxRoutes = this.configService.get('metrics.maxRoutes', { infer: true }) ?? 500; + return this.routes.size < maxRoutes ? candidate : OVERFLOW_ROUTE; + } + + private estimatePercentile(metric: RouteMetric, percentile: number): number { + if (!metric.requests) { + return 0; + } + const target = Math.ceil(metric.requests * percentile); + let cumulative = 0; + for (let index = 0; index < metric.latencyBuckets.length; index += 1) { + cumulative += metric.latencyBuckets[index]; + if (cumulative >= target) { + return index < LATENCY_BUCKETS_MS.length + ? LATENCY_BUCKETS_MS[index] + : Math.ceil(metric.maxDurationMs); + } + } + return Math.ceil(metric.maxDurationMs); + } + + private eventLoopSnapshot(utilization: number) { + const warnThresholdMs = + this.configService.get('metrics.eventLoopLagWarnMs', { infer: true }) ?? 100; + const meanMs = this.nanosecondsToMilliseconds(this.eventLoopDelay?.mean); + const p50Ms = this.nanosecondsToMilliseconds(this.eventLoopDelay?.percentile(50)); + const p95Ms = this.nanosecondsToMilliseconds(this.eventLoopDelay?.percentile(95)); + const p99Ms = this.nanosecondsToMilliseconds(this.eventLoopDelay?.percentile(99)); + const maxMs = this.nanosecondsToMilliseconds(this.eventLoopDelay?.max); + + return { + healthy: p99Ms <= warnThresholdMs, + warnThresholdMs, + utilization: Math.round(utilization * 10_000) / 10_000, + lagMeanMs: meanMs, + lagP50Ms: p50Ms, + lagP95Ms: p95Ms, + lagP99Ms: p99Ms, + lagMaxMs: maxMs, + }; + } + + private nanosecondsToMilliseconds(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + return 0; + } + return Math.round((value / 1_000_000) * 100) / 100; + } +} diff --git a/src/infrastructure/queue/app-queue.service.spec.ts b/src/infrastructure/queue/app-queue.service.spec.ts new file mode 100644 index 0000000..7958d2d --- /dev/null +++ b/src/infrastructure/queue/app-queue.service.spec.ts @@ -0,0 +1,109 @@ +import { ConfigService } from '@nestjs/config'; +import { Queue, Worker } from 'bullmq'; +import { AppLoggerService } from '../logging/app-logger.service'; +import { RedisService } from '../redis/redis.service'; +import { AppQueueService } from './app-queue.service'; + +jest.mock('bullmq', () => ({ Queue: jest.fn(), Worker: jest.fn() })); + +describe('AppQueueService', () => { + const values: Record = {}; + const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService; + const redis = { isEnabled: jest.fn(), createQueueClient: jest.fn() } as unknown as RedisService; + const logger = { error: jest.fn() } as unknown as AppLoggerService; + let queueInstance: { add: jest.Mock; close: jest.Mock }; + let workerInstance: { on: jest.Mock; close: jest.Mock }; + let workerProcessor: (job: { name: string; data: unknown }) => Promise; + + beforeEach(() => { + jest.clearAllMocks(); + Object.keys(values).forEach((key) => delete values[key]); + queueInstance = { add: jest.fn(), close: jest.fn() }; + workerInstance = { on: jest.fn(), close: jest.fn() }; + jest.mocked(Queue).mockImplementation(() => queueInstance as never); + jest.mocked(Worker).mockImplementation((_name, processor) => { + workerProcessor = processor as typeof workerProcessor; + return workerInstance as never; + }); + }); + + it('does not connect when queues or Redis are disabled', () => { + (redis.isEnabled as jest.Mock).mockReturnValue(true); + const service = new AppQueueService(config, redis, logger); + service.onModuleInit(); + service.onApplicationBootstrap(); + expect(Queue).not.toHaveBeenCalled(); + + values['queue.enabled'] = true; + (redis.isEnabled as jest.Mock).mockReturnValue(false); + service.onApplicationBootstrap(); + expect(redis.createQueueClient).not.toHaveBeenCalled(); + }); + + it('starts BullMQ with configured defaults and dispatches registered processors', async () => { + Object.assign(values, { + 'queue.enabled': true, + 'queue.name': 'important-jobs', + 'queue.workerConcurrency': 8, + 'queue.defaultJobAttempts': 5, + 'queue.defaultJobBackoffMs': 250, + 'queue.removeOnComplete': false, + }); + (redis.isEnabled as jest.Mock).mockReturnValue(true); + (redis.createQueueClient as jest.Mock).mockReturnValueOnce({ id: 'queue' }).mockReturnValueOnce({ id: 'worker' }); + const service = new AppQueueService(config, redis, logger); + const processor = jest.fn().mockResolvedValue(undefined); + service.registerProcessor('send', processor); + service.onApplicationBootstrap(); + + expect(Queue).toHaveBeenCalledWith('important-jobs', expect.objectContaining({ defaultJobOptions: { attempts: 5, backoff: { type: 'exponential', delay: 250 }, removeOnComplete: false } })); + expect(Worker).toHaveBeenCalledWith('important-jobs', expect.any(Function), expect.objectContaining({ concurrency: 8 })); + await workerProcessor({ name: 'send', data: { id: 1 } }); + expect(processor).toHaveBeenCalledWith({ id: 1 }); + await expect(workerProcessor({ name: 'missing', data: {} })).rejects.toThrow('No processor registered'); + + const failed = workerInstance.on.mock.calls.find(([event]) => event === 'failed')?.[1] as ( + job: { name: string; id: string }, + error: Error, + ) => void; + failed({ name: 'send', id: 'j1' }, new Error('boom')); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ queue: 'important-jobs', jobId: 'j1', error: 'boom' }), undefined, 'AppQueueService'); + }); + + it('enqueues remotely with per-job overrides and closes resources', async () => { + values['queue.enabled'] = true; + (redis.isEnabled as jest.Mock).mockReturnValue(true); + (redis.createQueueClient as jest.Mock).mockReturnValue({}); + const service = new AppQueueService(config, redis, logger); + service.onApplicationBootstrap(); + await service.enqueue('job', { id: 1 }, { attempts: 9 }); + expect(queueInstance.add).toHaveBeenCalledWith('job', { id: 1 }, expect.objectContaining({ attempts: 9, removeOnComplete: true })); + await service.onModuleDestroy(); + expect(workerInstance.close).toHaveBeenCalled(); + expect(queueInstance.close).toHaveBeenCalled(); + }); + + it('falls back to a microtask and logs processor failures', async () => { + const service = new AppQueueService(config, redis, logger); + const success = jest.fn().mockResolvedValue(undefined); + service.registerProcessor('success', success); + await service.enqueue('success', { ok: true }); + await new Promise((resolve) => setImmediate(resolve)); + expect(success).toHaveBeenCalledWith({ ok: true }); + + const failed = jest.fn().mockRejectedValue(new Error('offline')); + service.registerProcessor('failed', failed); + await service.enqueue('failed', { id: 4 }); + await new Promise((resolve) => setImmediate(resolve)); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ jobName: 'failed', error: 'offline' }), expect.any(String), 'AppQueueService'); + await expect(service.enqueue('unknown', {})).resolves.toBeUndefined(); + }); + + it('aborts startup when either Redis connection cannot be created', () => { + values['queue.enabled'] = true; + (redis.isEnabled as jest.Mock).mockReturnValue(true); + (redis.createQueueClient as jest.Mock).mockReturnValueOnce({}).mockReturnValueOnce(null); + new AppQueueService(config, redis, logger).onApplicationBootstrap(); + expect(Queue).not.toHaveBeenCalled(); + }); +}); diff --git a/src/infrastructure/redis/redis.service.spec.ts b/src/infrastructure/redis/redis.service.spec.ts new file mode 100644 index 0000000..58e1c61 --- /dev/null +++ b/src/infrastructure/redis/redis.service.spec.ts @@ -0,0 +1,86 @@ +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import { RedisService } from './redis.service'; + +jest.mock('ioredis', () => ({ __esModule: true, default: jest.fn() })); + +describe('RedisService', () => { + const values: Record = {}; + const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService; + const clients: Array<{ duplicate: jest.Mock; quit: jest.Mock; disconnect: jest.Mock }> = []; + + beforeEach(() => { + jest.clearAllMocks(); + Object.keys(values).forEach((key) => delete values[key]); + clients.length = 0; + jest.mocked(Redis).mockImplementation(() => { + const duplicateClient = { quit: jest.fn(), disconnect: jest.fn() }; + const client = { + duplicate: jest.fn(() => duplicateClient), + quit: jest.fn().mockResolvedValue('OK'), + disconnect: jest.fn(), + }; + clients.push(client); + return client as never; + }); + }); + + it('stays inert when disabled and exposes default prefix', () => { + const service = new RedisService(config); + expect(service.isEnabled()).toBe(false); + expect(service.getKeyPrefix()).toBe('oudelaa'); + expect(service.getClient()).toBeNull(); + expect(service.createPubSubClients()).toBeNull(); + expect(service.createQueueClient()).toBeNull(); + expect(Redis).not.toHaveBeenCalled(); + }); + + it('creates one shared host-based client and independent queue/pubsub clients', () => { + Object.assign(values, { + 'redis.enabled': true, + 'redis.keyPrefix': 'test', + 'redis.host': 'redis.internal', + 'redis.port': 6380, + 'redis.username': 'app', + 'redis.password': 'secret', + 'redis.db': 2, + }); + const service = new RedisService(config); + expect(service.getKeyPrefix()).toBe('test'); + expect(service.getClient()).toBe(service.getClient()); + expect(Redis).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + host: 'redis.internal', port: 6380, username: 'app', password: 'secret', db: 2, + maxRetriesPerRequest: null, lazyConnect: false, enableReadyCheck: true, + }), + ); + const pair = service.createPubSubClients(); + expect(pair?.pubClient).toBeDefined(); + expect(pair?.subClient).toBeDefined(); + expect(clients[1].duplicate).toHaveBeenCalled(); + expect(service.createQueueClient()).toBeDefined(); + }); + + it('prefers a URL and omits host credentials', () => { + Object.assign(values, { 'redis.enabled': true, 'redis.url': 'rediss://redis.example/1' }); + const service = new RedisService(config); + service.getClient(); + expect(Redis).toHaveBeenCalledWith('rediss://redis.example/1', { + maxRetriesPerRequest: null, + lazyConnect: false, + enableReadyCheck: true, + }); + }); + + it('quits the shared client and falls back to disconnect on failure', async () => { + values['redis.enabled'] = true; + const service = new RedisService(config); + service.getClient(); + clients[0].quit.mockRejectedValue(new Error('closed')); + service.onModuleDestroy(); + await new Promise((resolve) => setImmediate(resolve)); + expect(clients[0].disconnect).toHaveBeenCalled(); + expect(service.getClient()).not.toBe(clients[0]); + }); +}); diff --git a/src/infrastructure/redis/redis.service.ts b/src/infrastructure/redis/redis.service.ts index 6f5360f..ef08dfa 100644 --- a/src/infrastructure/redis/redis.service.ts +++ b/src/infrastructure/redis/redis.service.ts @@ -48,7 +48,8 @@ export class RedisService implements OnModuleDestroy { onModuleDestroy(): void { if (this.client) { - void this.client.quit().catch(() => this.client?.disconnect()); + const client = this.client; + void client.quit().catch(() => client.disconnect()); this.client = null; } } diff --git a/src/infrastructure/reliability/http-server.config.spec.ts b/src/infrastructure/reliability/http-server.config.spec.ts new file mode 100644 index 0000000..102dcd6 --- /dev/null +++ b/src/infrastructure/reliability/http-server.config.spec.ts @@ -0,0 +1,31 @@ +import { ConfigService } from '@nestjs/config'; +import { ConfigurableHttpServer, configureHttpServer } from './http-server.config'; + +describe('configureHttpServer', () => { + it('applies transport settings from application configuration', () => { + const values: Record = { + 'reliability.serverRequestTimeoutMs': 90_000, + 'reliability.headersTimeoutMs': 12_000, + 'reliability.keepAliveTimeoutMs': 4_000, + 'reliability.maxRequestsPerSocket': 500, + }; + const config = { + get: jest.fn((key: string) => values[key]), + } as unknown as ConfigService; + const server: ConfigurableHttpServer = { + requestTimeout: 0, + headersTimeout: 0, + keepAliveTimeout: 0, + maxRequestsPerSocket: 0, + }; + + configureHttpServer(server, config); + + expect(server).toEqual({ + requestTimeout: 90_000, + headersTimeout: 12_000, + keepAliveTimeout: 4_000, + maxRequestsPerSocket: 500, + }); + }); +}); diff --git a/src/infrastructure/reliability/http-server.config.ts b/src/infrastructure/reliability/http-server.config.ts new file mode 100644 index 0000000..cf75f35 --- /dev/null +++ b/src/infrastructure/reliability/http-server.config.ts @@ -0,0 +1,22 @@ +import { ConfigService } from '@nestjs/config'; + +export type ConfigurableHttpServer = { + requestTimeout: number; + headersTimeout: number; + keepAliveTimeout: number; + maxRequestsPerSocket: number; +}; + +export const configureHttpServer = ( + server: ConfigurableHttpServer, + configService: ConfigService, +): void => { + server.requestTimeout = + configService.get('reliability.serverRequestTimeoutMs', { infer: true }) ?? 120_000; + server.headersTimeout = + configService.get('reliability.headersTimeoutMs', { infer: true }) ?? 15_000; + server.keepAliveTimeout = + configService.get('reliability.keepAliveTimeoutMs', { infer: true }) ?? 5_000; + server.maxRequestsPerSocket = + configService.get('reliability.maxRequestsPerSocket', { infer: true }) ?? 1_000; +}; diff --git a/src/infrastructure/reliability/reliability-config.spec.ts b/src/infrastructure/reliability/reliability-config.spec.ts new file mode 100644 index 0000000..4dd775e --- /dev/null +++ b/src/infrastructure/reliability/reliability-config.spec.ts @@ -0,0 +1,43 @@ +import { validationSchema } from '../../config/validation.schema'; + +describe('reliability environment validation', () => { + const validEnvironment = { + NODE_ENV: 'test', + MONGODB_URI: 'mongodb://127.0.0.1:27017/test', + JWT_ACCESS_SECRET: 'access-secret-at-least-16', + JWT_REFRESH_SECRET: 'refresh-secret-at-least-16', + SUPERADMIN_EMAIL: 'admin@example.com', + SUPERADMIN_PASSWORD: 'StrongPassword123!', + SUPERADMIN_ACCESS_SECRET: 'admin-access-secret-at-least-16', + SUPERADMIN_REFRESH_SECRET: 'admin-refresh-secret-at-least-16', + STORAGE_PROVIDER: 'local', + MEDIA_ACCESS_MODE: 'direct', + }; + + it('applies safe reliability defaults', () => { + const { error, value } = validationSchema.validate(validEnvironment); + expect(error).toBeUndefined(); + expect(value).toEqual( + expect.objectContaining({ + HTTP_REQUEST_TIMEOUT_MS: 30_000, + HTTP_SERVER_REQUEST_TIMEOUT_MS: 120_000, + HTTP_HEADERS_TIMEOUT_MS: 15_000, + HTTP_KEEP_ALIVE_TIMEOUT_MS: 5_000, + SHUTDOWN_GRACE_PERIOD_MS: 30_000, + }), + ); + }); + + it('rejects unsafe or contradictory timeout settings', () => { + expect( + validationSchema.validate({ ...validEnvironment, HTTP_REQUEST_TIMEOUT_MS: 500 }).error, + ).toBeDefined(); + expect( + validationSchema.validate({ + ...validEnvironment, + HTTP_HEADERS_TIMEOUT_MS: 4_000, + HTTP_KEEP_ALIVE_TIMEOUT_MS: 5_000, + }).error, + ).toBeDefined(); + }); +}); diff --git a/src/infrastructure/reliability/reliability.module.ts b/src/infrastructure/reliability/reliability.module.ts new file mode 100644 index 0000000..bceb06f --- /dev/null +++ b/src/infrastructure/reliability/reliability.module.ts @@ -0,0 +1,14 @@ +import { Global, Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { RequestTimeoutInterceptor } from './request-timeout.interceptor'; +import { ShutdownCoordinatorService } from './shutdown-coordinator.service'; + +@Global() +@Module({ + providers: [ + ShutdownCoordinatorService, + { provide: APP_INTERCEPTOR, useClass: RequestTimeoutInterceptor }, + ], + exports: [ShutdownCoordinatorService], +}) +export class ReliabilityModule {} diff --git a/src/infrastructure/reliability/request-timeout.interceptor.spec.ts b/src/infrastructure/reliability/request-timeout.interceptor.spec.ts new file mode 100644 index 0000000..a0378e7 --- /dev/null +++ b/src/infrastructure/reliability/request-timeout.interceptor.spec.ts @@ -0,0 +1,75 @@ +import { CallHandler, ExecutionContext, RequestTimeoutException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Reflector } from '@nestjs/core'; +import { firstValueFrom, of, timer } from 'rxjs'; +import { RequestTimeoutInterceptor } from './request-timeout.interceptor'; + +describe('RequestTimeoutInterceptor', () => { + const createContext = (url = '/api/v1/feed/me') => + ({ + getType: () => 'http', + getHandler: () => createContext, + getClass: () => RequestTimeoutInterceptor, + switchToHttp: () => ({ getRequest: () => ({ originalUrl: url }) }), + }) as unknown as ExecutionContext; + + const createCoordinator = (draining = false) => ({ + isDraining: jest.fn().mockReturnValue(draining), + beginRequest: jest.fn().mockReturnValue(!draining), + endRequest: jest.fn(), + }); + + const createInterceptor = (options: { timeoutMs?: number; override?: number; draining?: boolean }) => { + const config = { + get: jest.fn().mockReturnValue(options.timeoutMs ?? 10), + } as unknown as ConfigService; + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue(options.override), + } as unknown as Reflector; + const coordinator = createCoordinator(options.draining); + return { + coordinator, + interceptor: new RequestTimeoutInterceptor(config, reflector, coordinator as any), + }; + }; + + it('returns the response and releases the in-flight request', async () => { + const { interceptor, coordinator } = createInterceptor({ timeoutMs: 50 }); + const handler: CallHandler = { handle: () => of({ ok: true }) }; + + await expect(firstValueFrom(interceptor.intercept(createContext(), handler))).resolves.toEqual({ + ok: true, + }); + expect(coordinator.beginRequest).toHaveBeenCalledTimes(1); + expect(coordinator.endRequest).toHaveBeenCalledTimes(1); + }); + + it('turns a slow handler into HTTP 408 and releases the request', async () => { + const { interceptor, coordinator } = createInterceptor({ timeoutMs: 5 }); + const handler: CallHandler = { handle: () => timer(30) }; + + await expect(firstValueFrom(interceptor.intercept(createContext(), handler))).rejects.toBeInstanceOf( + RequestTimeoutException, + ); + expect(coordinator.endRequest).toHaveBeenCalledTimes(1); + }); + + it('allows a route-level zero override to disable the timeout', async () => { + const { interceptor } = createInterceptor({ timeoutMs: 5, override: 0 }); + const handler: CallHandler = { handle: () => timer(15) }; + + await expect(firstValueFrom(interceptor.intercept(createContext(), handler))).resolves.toBe(0); + }); + + it('rejects new work while draining but still allows readiness checks', async () => { + const { interceptor } = createInterceptor({ draining: true }); + const handler: CallHandler = { handle: () => of({ status: 'draining' }) }; + + await expect(firstValueFrom(interceptor.intercept(createContext(), handler))).rejects.toMatchObject({ + status: 503, + }); + await expect( + firstValueFrom(interceptor.intercept(createContext('/api/v1/health/ready'), handler)), + ).resolves.toEqual({ status: 'draining' }); + }); +}); diff --git a/src/infrastructure/reliability/request-timeout.interceptor.ts b/src/infrastructure/reliability/request-timeout.interceptor.ts new file mode 100644 index 0000000..ffc7972 --- /dev/null +++ b/src/infrastructure/reliability/request-timeout.interceptor.ts @@ -0,0 +1,91 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, + RequestTimeoutException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Reflector } from '@nestjs/core'; +import { Observable, TimeoutError, catchError, finalize, throwError, timeout } from 'rxjs'; +import { REQUEST_TIMEOUT_METADATA } from '../../common/decorators/request-timeout.decorator'; +import { ShutdownCoordinatorService } from './shutdown-coordinator.service'; + +type HttpRequest = { + originalUrl?: string; + url?: string; +}; + +@Injectable() +export class RequestTimeoutInterceptor implements NestInterceptor { + constructor( + private readonly configService: ConfigService, + private readonly reflector: Reflector, + private readonly shutdownCoordinator: ShutdownCoordinatorService, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (context.getType() !== 'http') { + return next.handle(); + } + + const request = context.switchToHttp().getRequest(); + const healthRequest = this.isHealthRequest(request.originalUrl ?? request.url ?? ''); + + if (this.shutdownCoordinator.isDraining()) { + if (healthRequest) { + return next.handle(); + } + return throwError( + () => + new ServiceUnavailableException({ + statusCode: 503, + error: 'Service Unavailable', + message: 'Server is shutting down; retry the request on another instance', + }), + ); + } + + if (!this.shutdownCoordinator.beginRequest()) { + return throwError(() => new ServiceUnavailableException('Server is shutting down')); + } + + const timeoutOverride = this.reflector.getAllAndOverride(REQUEST_TIMEOUT_METADATA, [ + context.getHandler(), + context.getClass(), + ]); + const timeoutMs = + timeoutOverride ?? + this.configService.get('reliability.requestTimeoutMs', { infer: true }) ?? + 30_000; + const response$ = next.handle(); + + if (timeoutMs <= 0) { + return response$.pipe(finalize(() => this.shutdownCoordinator.endRequest())); + } + + return response$.pipe( + timeout({ first: timeoutMs }), + catchError((error: unknown) => { + if (error instanceof TimeoutError) { + return throwError( + () => + new RequestTimeoutException({ + statusCode: 408, + error: 'Request Timeout', + message: `Request did not complete within ${timeoutMs}ms`, + }), + ); + } + return throwError(() => error); + }), + finalize(() => this.shutdownCoordinator.endRequest()), + ); + } + + private isHealthRequest(url: string): boolean { + const path = url.split('?', 1)[0].replace(/\/+$/, ''); + return /\/health(?:\/ready)?$/.test(path); + } +} diff --git a/src/infrastructure/reliability/shutdown-coordinator.service.spec.ts b/src/infrastructure/reliability/shutdown-coordinator.service.spec.ts new file mode 100644 index 0000000..d5f07b4 --- /dev/null +++ b/src/infrastructure/reliability/shutdown-coordinator.service.spec.ts @@ -0,0 +1,35 @@ +import { ConfigService } from '@nestjs/config'; +import { AppLoggerService } from '../logging/app-logger.service'; +import { ShutdownCoordinatorService } from './shutdown-coordinator.service'; + +describe('ShutdownCoordinatorService', () => { + const createService = () => { + const config = { get: jest.fn().mockReturnValue(25) } as unknown as ConfigService; + const logger = { warn: jest.fn(), log: jest.fn() } as unknown as AppLoggerService; + return { logger, service: new ShutdownCoordinatorService(config, logger) }; + }; + + it('tracks active requests without allowing the counter below zero', () => { + const { service } = createService(); + expect(service.beginRequest()).toBe(true); + expect(service.getActiveRequests()).toBe(1); + service.endRequest(); + service.endRequest(); + expect(service.getActiveRequests()).toBe(0); + }); + + it('enters drain mode before shutdown and refuses new requests', async () => { + const { logger, service } = createService(); + await service.beforeApplicationShutdown('SIGTERM'); + + expect(service.getState()).toBe('draining'); + expect(service.beginRequest()).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('SIGTERM'), + 'ShutdownCoordinator', + ); + + service.onApplicationShutdown('SIGTERM'); + expect(service.getState()).toBe('stopped'); + }); +}); diff --git a/src/infrastructure/reliability/shutdown-coordinator.service.ts b/src/infrastructure/reliability/shutdown-coordinator.service.ts new file mode 100644 index 0000000..e50e24b --- /dev/null +++ b/src/infrastructure/reliability/shutdown-coordinator.service.ts @@ -0,0 +1,79 @@ +import { + BeforeApplicationShutdown, + Injectable, + OnApplicationShutdown, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { setTimeout as delay } from 'timers/promises'; +import { AppLoggerService } from '../logging/app-logger.service'; + +export type ApplicationLifecycleState = 'running' | 'draining' | 'stopped'; + +@Injectable() +export class ShutdownCoordinatorService + implements BeforeApplicationShutdown, OnApplicationShutdown +{ + private state: ApplicationLifecycleState = 'running'; + private activeRequests = 0; + + constructor( + private readonly configService: ConfigService, + private readonly logger: AppLoggerService, + ) {} + + getState(): ApplicationLifecycleState { + return this.state; + } + + isDraining(): boolean { + return this.state !== 'running'; + } + + getActiveRequests(): number { + return this.activeRequests; + } + + beginRequest(): boolean { + if (this.state !== 'running') { + return false; + } + this.activeRequests += 1; + return true; + } + + endRequest(): void { + this.activeRequests = Math.max(0, this.activeRequests - 1); + } + + async beforeApplicationShutdown(signal?: string): Promise { + this.state = 'draining'; + const gracePeriodMs = + this.configService.get('reliability.shutdownGracePeriodMs', { infer: true }) ?? + 30_000; + const deadline = Date.now() + gracePeriodMs; + + this.logger.warn( + `Shutdown initiated${signal ? ` by ${signal}` : ''}; draining ${this.activeRequests} active request(s) for up to ${gracePeriodMs}ms`, + 'ShutdownCoordinator', + ); + + while (this.activeRequests > 0 && Date.now() < deadline) { + await delay(Math.min(50, Math.max(1, deadline - Date.now()))); + } + + if (this.activeRequests > 0) { + this.logger.warn( + `Grace period elapsed with ${this.activeRequests} active request(s) still running`, + 'ShutdownCoordinator', + ); + } + } + + onApplicationShutdown(signal?: string): void { + this.state = 'stopped'; + this.logger.log( + `Shutdown complete${signal ? ` (${signal})` : ''}`, + 'ShutdownCoordinator', + ); + } +} diff --git a/src/infrastructure/socket/redis-io.adapter.spec.ts b/src/infrastructure/socket/redis-io.adapter.spec.ts new file mode 100644 index 0000000..f305c3d --- /dev/null +++ b/src/infrastructure/socket/redis-io.adapter.spec.ts @@ -0,0 +1,43 @@ +import { IoAdapter } from '@nestjs/platform-socket.io'; +import { createAdapter } from '@socket.io/redis-adapter'; +import { RedisService } from '../redis/redis.service'; +import { RedisIoAdapter } from './redis-io.adapter'; + +jest.mock('@socket.io/redis-adapter', () => ({ createAdapter: jest.fn(() => 'redis-adapter') })); + +describe('RedisIoAdapter', () => { + const redis = { createPubSubClients: jest.fn() } as unknown as RedisService; + let createServer: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + createServer = jest.spyOn(IoAdapter.prototype, 'createIOServer').mockReturnValue({ adapter: jest.fn() }); + }); + + afterEach(() => createServer.mockRestore()); + + it('uses the default Socket.IO adapter without Redis clients', async () => { + (redis.createPubSubClients as jest.Mock).mockReturnValue(null); + const adapter = new RedisIoAdapter({} as never, redis); + await adapter.connectToRedis(); + const server = adapter.createIOServer(4000, { path: '/socket' } as never); + expect(createAdapter).not.toHaveBeenCalled(); + expect(server.adapter).not.toHaveBeenCalled(); + }); + + it('attaches Redis adapter and closes both clients safely', async () => { + const pubClient = { quit: jest.fn().mockResolvedValue('OK'), disconnect: jest.fn() }; + const subClient = { quit: jest.fn().mockRejectedValue(new Error('closed')), disconnect: jest.fn() }; + (redis.createPubSubClients as jest.Mock).mockReturnValue({ pubClient, subClient }); + const adapter = new RedisIoAdapter({} as never, redis); + await adapter.connectToRedis(); + const server = adapter.createIOServer(4000); + expect(createAdapter).toHaveBeenCalledWith(pubClient, subClient); + expect(server.adapter).toHaveBeenCalledWith('redis-adapter'); + await adapter.close(); + expect(pubClient.quit).toHaveBeenCalled(); + expect(subClient.disconnect).toHaveBeenCalled(); + await adapter.close(); + expect(pubClient.quit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/infrastructure/storage/image-processing.service.spec.ts b/src/infrastructure/storage/image-processing.service.spec.ts new file mode 100644 index 0000000..c344020 --- /dev/null +++ b/src/infrastructure/storage/image-processing.service.spec.ts @@ -0,0 +1,88 @@ +import { ConfigService } from '@nestjs/config'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { ImageProcessingService } from './image-processing.service'; + +jest.mock('fs/promises', () => ({ mkdtemp: jest.fn(), readFile: jest.fn(), rm: jest.fn(), writeFile: jest.fn() })); + +type ImageInternals = { + ensureFfmpegAvailable(): Promise; + runFfmpeg(args: string[]): Promise; +}; + +const internals = (service: ImageProcessingService): ImageInternals => + service as unknown as ImageInternals; + +describe('ImageProcessingService', () => { + const values: Record = {}; + const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService; + + beforeEach(() => { + jest.clearAllMocks(); + Object.keys(values).forEach((key) => delete values[key]); + (mkdtemp as jest.Mock).mockResolvedValue('/tmp/image-work'); + (writeFile as jest.Mock).mockResolvedValue(undefined); + (readFile as jest.Mock).mockImplementation(async (path: string) => Buffer.from(path)); + (rm as jest.Mock).mockResolvedValue(undefined); + }); + + it.each([ + [{ originalname: 'photo.PNG', mimetype: '', size: 1, buffer: Buffer.from('x') }, '.png', 'image/png'], + [{ mimetype: 'image/webp', size: 1, buffer: Buffer.from('x') }, '.webp', 'image/webp'], + [{ mimetype: 'image/gif', size: 1, buffer: Buffer.from('x') }, '.gif', 'image/gif'], + [{ mimetype: 'unknown', size: 1, buffer: Buffer.from('x') }, '.jpg', 'unknown'], + ])('returns original when disabled %#', async (file, extension, contentType) => { + const result = await new ImageProcessingService(config).processForResponsiveDelivery(file); + expect(result).toEqual({ primaryVariantName: 'original', variants: [expect.objectContaining({ relativePath: `original${extension}`, contentType })] }); + }); + + it('falls back when ffmpeg is unavailable', async () => { + values['imageProcessing.enabled'] = true; + const service = new ImageProcessingService(config); + jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(false); + const result = await service.processForResponsiveDelivery({ originalname: 'x.jpg', mimetype: 'image/jpeg', size: 1, buffer: Buffer.from('x') }); + expect(result.primaryVariantName).toBe('original'); + expect(mkdtemp).not.toHaveBeenCalled(); + }); + + it('creates low, medium, and high WebP variants', async () => { + Object.assign(values, { + 'imageProcessing.enabled': true, 'imageProcessing.lowWidth': 320, + 'imageProcessing.mediumWidth': 640, 'imageProcessing.highWidth': 1024, + 'imageProcessing.quality': 70, + }); + const service = new ImageProcessingService(config); + jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true); + const ffmpeg = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined); + const result = await service.processForResponsiveDelivery({ originalname: 'x.jpg', mimetype: 'image/jpeg', size: 1, buffer: Buffer.from('x') }); + expect(result.primaryVariantName).toBe('medium'); + expect(result.variants.map((item) => item.name)).toEqual(['original', 'low', 'medium', 'high']); + expect(ffmpeg).toHaveBeenCalledTimes(3); + expect(ffmpeg).toHaveBeenCalledWith(expect.arrayContaining([expect.stringContaining('min(320,iw)'), '70'])); + expect(rm).toHaveBeenCalledWith('/tmp/image-work', { recursive: true, force: true }); + }); + + it('returns the original and cleans up after conversion errors', async () => { + values['videoProcessing.enabled'] = true; + const service = new ImageProcessingService(config); + jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true); + (writeFile as jest.Mock).mockRejectedValue(new Error('disk full')); + const result = await service.processForResponsiveDelivery({ size: 1, buffer: Buffer.from('x') }); + expect(result.primaryVariantName).toBe('original'); + expect(rm).toHaveBeenCalled(); + }); + + it('probes ffmpeg once and caches both success and failure', async () => { + values['imageProcessing.ffmpegPath'] = ' custom-ffmpeg '; + const success = new ImageProcessingService(config); + const successRun = jest.spyOn(internals(success), 'runFfmpeg').mockResolvedValue(undefined); + await expect((success as never as { ensureFfmpegAvailable(): Promise }).ensureFfmpegAvailable()).resolves.toBe(true); + await (success as never as { ensureFfmpegAvailable(): Promise }).ensureFfmpegAvailable(); + expect(successRun).toHaveBeenCalledTimes(1); + + const failure = new ImageProcessingService(config); + const failRun = jest.spyOn(internals(failure), 'runFfmpeg').mockRejectedValue('missing'); + await expect((failure as never as { ensureFfmpegAvailable(): Promise }).ensureFfmpegAvailable()).resolves.toBe(false); + await (failure as never as { ensureFfmpegAvailable(): Promise }).ensureFfmpegAvailable(); + expect(failRun).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/infrastructure/storage/managed-storage.service.spec.ts b/src/infrastructure/storage/managed-storage.service.spec.ts new file mode 100644 index 0000000..13ccf8d --- /dev/null +++ b/src/infrastructure/storage/managed-storage.service.spec.ts @@ -0,0 +1,190 @@ +import { BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DeleteObjectsCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'; +import { access, mkdir, rm, stat, unlink, writeFile } from 'fs/promises'; +import { MediaStorageService } from '../../common/media/media-storage.service'; +import { ManagedStorageService } from './managed-storage.service'; + +jest.mock('fs/promises', () => ({ + access: jest.fn(), mkdir: jest.fn(), rm: jest.fn(), stat: jest.fn(), unlink: jest.fn(), writeFile: jest.fn(), +})); + +type ManagedInternals = { + deleteS3Prefix(prefix: string): Promise; + getS3Bucket(): string; + getS3Client(): { send(command: unknown): Promise }; + resolveCacheControl(key: string): string; +}; + +const internals = (service: ManagedStorageService): ManagedInternals => + service as unknown as ManagedInternals; + +describe('ManagedStorageService', () => { + const values: Record = { + 'storage.provider': 'local', 'storage.basePath': 'uploads', publicBaseUrl: 'https://api.test', + }; + const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService; + const media = { + uploadFile: jest.fn(), uploadFileToKey: jest.fn(), deleteFile: jest.fn(), + } as unknown as MediaStorageService; + + beforeEach(() => { + jest.clearAllMocks(); + Object.assign(values, { + 'storage.provider': 'local', 'storage.basePath': 'uploads', publicBaseUrl: 'https://api.test', + 'storage.publicBaseUrl': '', 'storage.s3.bucket': '', 'storage.s3.endpoint': '', + 'storage.s3.accessKeyId': '', 'storage.s3.secretAccessKey': '', + 'storage.s3.forcePathStyle': false, 'storage.s3.healthWriteTestEnabled': false, + }); + (mkdir as jest.Mock).mockResolvedValue(undefined); + (writeFile as jest.Mock).mockResolvedValue(undefined); + (unlink as jest.Mock).mockResolvedValue(undefined); + (rm as jest.Mock).mockResolvedValue(undefined); + (stat as jest.Mock).mockResolvedValue({}); + (access as jest.Mock).mockResolvedValue(undefined); + }); + + it('saves one or many files locally with normalized managed paths', async () => { + const service = new ManagedStorageService(config, media); + const one = await service.saveFile({ + folderSegments: ['/posts\\images/', 'user-1'], extension: '.jpg', buffer: Buffer.from('jpg'), + contentType: 'image/jpeg', fileNamePrefix: 'photo', + }); + expect(one).toMatch(/^\/uploads\/posts\/images\/user-1\/photo-[0-9a-f-]+\.jpg$/); + expect(writeFile).toHaveBeenCalledWith(expect.stringContaining('photo-'), Buffer.from('jpg')); + + await expect(service.saveFiles({ folderSegments: [], files: [] })).resolves.toEqual({}); + await expect(service.saveFiles({ + folderSegments: ['hls'], + files: [ + { relativePath: 'master.m3u8', buffer: Buffer.from('playlist') }, + { relativePath: 'stream_0\\segment.m4s', buffer: Buffer.from('segment') }, + ], + })).resolves.toEqual({ + 'master.m3u8': '/uploads/hls/master.m3u8', + 'stream_0/segment.m4s': '/uploads/hls/stream_0/segment.m4s', + }); + expect(mkdir).toHaveBeenCalled(); + }); + + it.each(['', '.', '../secret', 'a//b'])('rejects unsafe relative file path %p', async (relativePath) => { + const service = new ManagedStorageService(config, media); + await expect(service.saveFiles({ folderSegments: [], files: [{ relativePath, buffer: Buffer.alloc(1) }] })) + .rejects.toBeInstanceOf(BadRequestException); + }); + + it('delegates S3 writes and returns uploaded URLs', async () => { + values['storage.provider'] = 's3'; + (media.uploadFile as jest.Mock).mockResolvedValue({ url: 'https://cdn/one.jpg' }); + (media.uploadFileToKey as jest.Mock).mockResolvedValue({ url: 'https://cdn/master.m3u8' }); + const service = new ManagedStorageService(config, media); + await expect(service.saveFile({ folderSegments: ['posts'], extension: '.jpg', buffer: Buffer.from('x') })) + .resolves.toBe('https://cdn/one.jpg'); + expect(media.uploadFile).toHaveBeenCalledWith(expect.objectContaining({ size: 1 }), 'posts'); + await expect(service.saveFiles({ folderSegments: ['hls'], files: [{ relativePath: 'master.m3u8', buffer: Buffer.from('x') }] })) + .resolves.toEqual({ 'master.m3u8': 'https://cdn/master.m3u8' }); + expect(media.uploadFileToKey).toHaveBeenCalledWith(expect.anything(), 'uploads/hls/master.m3u8'); + }); + + it('deletes and resolves only safe local managed paths', async () => { + const service = new ManagedStorageService(config, media); + await service.deleteFile(); + await service.deleteFile('https://external/file.jpg'); + await service.deleteFile('/uploads/../secret'); + expect(unlink).not.toHaveBeenCalled(); + await service.deleteFile('/uploads/posts/file.jpg?token=one'); + expect(unlink).toHaveBeenCalledWith(expect.stringMatching(/uploads[\\/]posts[\\/]file\.jpg$/)); + (unlink as jest.Mock).mockRejectedValueOnce(new Error('missing')); + await expect(service.deleteFile('/uploads/posts/missing.jpg')).resolves.toBeUndefined(); + expect(service.resolveLocalFilePath('/uploads/posts/file.jpg')).toMatch(/uploads[\\/]posts[\\/]file\.jpg$/); + expect(service.resolveLocalFilePath('/other/file.jpg')).toBeNull(); + values['storage.provider'] = 's3'; + expect(service.resolveLocalFilePath('/uploads/posts/file.jpg')).toBeNull(); + }); + + it('deletes safe containing directories locally without deleting the root', async () => { + const service = new ManagedStorageService(config, media); + await service.deleteContainingDirectory(); + await service.deleteContainingDirectory('/uploads/file.jpg'); + await service.deleteContainingDirectory('/uploads/../secret/file.jpg'); + expect(rm).not.toHaveBeenCalled(); + await service.deleteContainingDirectory('/uploads/posts/hls/master.m3u8'); + expect(rm).toHaveBeenCalledWith(expect.stringMatching(/uploads[\\/]posts[\\/]hls$/), { recursive: true, force: true }); + }); + + it('deletes S3 objects and containing prefixes resolved from public and endpoint URLs', async () => { + Object.assign(values, { + 'storage.provider': 's3', 'storage.publicBaseUrl': 'https://cdn.test/', + 'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test', + 'storage.s3.forcePathStyle': true, + }); + const service = new ManagedStorageService(config, media); + await service.deleteFile('https://cdn.test/uploads/posts/file.jpg?x=1'); + expect(media.deleteFile).toHaveBeenCalledWith('uploads/posts/file.jpg'); + const deletePrefix = jest.spyOn(internals(service), 'deleteS3Prefix').mockResolvedValue(undefined); + await service.deleteContainingDirectory('https://cdn.test/uploads/posts/hls/master.m3u8'); + expect(deletePrefix).toHaveBeenCalledWith('uploads/posts/hls/'); + await service.deleteContainingDirectory('https://cdn.test/uploads/file.jpg'); + expect(deletePrefix).toHaveBeenCalledTimes(1); + values['storage.publicBaseUrl'] = ''; + await service.deleteFile('https://s3.test/bucket/uploads/posts/two.jpg'); + expect(media.deleteFile).toHaveBeenCalledWith('uploads/posts/two.jpg'); + }); + + it('reports healthy and failed local probes', async () => { + const service = new ManagedStorageService(config, media); + await expect(service.getHealth()).resolves.toEqual(expect.objectContaining({ + provider: 'local', isLocalStorage: true, uploadPathExists: true, + uploadPathReadable: true, uploadPathWritable: true, + })); + (writeFile as jest.Mock).mockRejectedValueOnce(new Error('read only')); + (unlink as jest.Mock).mockRejectedValueOnce(new Error('not created')); + (stat as jest.Mock).mockRejectedValueOnce(new Error('missing')); + (access as jest.Mock).mockRejectedValueOnce(new Error('denied')); + const failed = await service.getHealth(); + expect(failed).toEqual(expect.objectContaining({ uploadPathExists: false, uploadPathReadable: false, uploadPathWritable: false })); + expect(failed.local).toEqual(expect.objectContaining({ error: 'read only' })); + }); + + it('reports incomplete and reachable S3 health including write probes', async () => { + values['storage.provider'] = 's3'; + const service = new ManagedStorageService(config, media); + expect(await service.getHealth()).toEqual(expect.objectContaining({ s3: expect.objectContaining({ configured: false, reachable: false }) })); + + Object.assign(values, { + 'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test', + 'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret', + 'storage.s3.healthWriteTestEnabled': true, + }); + const client = { send: jest.fn().mockResolvedValue({}) }; + jest.spyOn(internals(service), 'getS3Client').mockReturnValue(client); + const healthy = await service.getHealth(); + expect(healthy.s3).toEqual(expect.objectContaining({ configured: true, reachable: true, writable: true })); + expect(client.send).toHaveBeenCalledTimes(3); + + client.send.mockRejectedValueOnce(new Error('unreachable')); + const unhealthy = await service.getHealth(); + expect(unhealthy.s3).toEqual(expect.objectContaining({ reachable: false, writable: false, error: 'unreachable' })); + service.onModuleDestroy(); + }); + + it('validates S3 setup and paginates prefix deletion', async () => { + values['storage.provider'] = 's3'; + const service = new ManagedStorageService(config, media); + expect(() => internals(service).getS3Bucket()).toThrow(BadRequestException); + expect(() => internals(service).getS3Client()).toThrow(BadRequestException); + + Object.assign(values, { 'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test', 'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret' }); + const client = { send: jest.fn() + .mockResolvedValueOnce({ Contents: [{ Key: 'uploads/a' }, {}], IsTruncated: true, NextContinuationToken: 'next' }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ Contents: [{ Key: 'uploads/b' }], IsTruncated: false }) + .mockResolvedValueOnce({}) }; + jest.spyOn(internals(service), 'getS3Client').mockReturnValue(client); + await internals(service).deleteS3Prefix('uploads/posts/'); + expect(client.send.mock.calls.filter(([command]) => command instanceof ListObjectsV2Command)).toHaveLength(2); + expect(client.send.mock.calls.filter(([command]) => command instanceof DeleteObjectsCommand)).toHaveLength(2); + expect(internals(service).resolveCacheControl('master.m3u8')).toBe('public, max-age=300'); + expect(internals(service).resolveCacheControl('file.jpg')).toContain('immutable'); + }); +}); diff --git a/src/infrastructure/storage/managed-storage.service.ts b/src/infrastructure/storage/managed-storage.service.ts index abdf35b..d5ff9a0 100644 --- a/src/infrastructure/storage/managed-storage.service.ts +++ b/src/infrastructure/storage/managed-storage.service.ts @@ -239,14 +239,11 @@ export class ManagedStorageService implements OnModuleDestroy { const exists = await this.pathExists(uploadDir); const readable = await this.canAccess(uploadDir, constants.R_OK); health.local = { - runtimePath: uploadDir, - absolutePath: uploadDir, exists, writable, readable, error, }; - health.absolutePath = uploadDir; health.uploadPathExists = exists; health.uploadPathReadable = readable; health.uploadPathWritable = writable; diff --git a/src/infrastructure/storage/media-probe.service.spec.ts b/src/infrastructure/storage/media-probe.service.spec.ts new file mode 100644 index 0000000..59af9fa --- /dev/null +++ b/src/infrastructure/storage/media-probe.service.spec.ts @@ -0,0 +1,142 @@ +import { ConfigService } from '@nestjs/config'; +import { spawn } from 'child_process'; +import { EventEmitter } from 'events'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { MediaProbeService } from './media-probe.service'; + +jest.mock('child_process', () => ({ spawn: jest.fn() })); +jest.mock('fs/promises', () => ({ mkdtemp: jest.fn(), rm: jest.fn(), writeFile: jest.fn() })); + +type FakeChild = EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: jest.Mock; +}; + +const fakeChild = (): FakeChild => { + const child = new EventEmitter() as FakeChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = jest.fn(); + return child; +}; + +type ProbeInternals = { + runCommand(command: string, args: string[], timeout: number): Promise; +}; + +const internals = (service: MediaProbeService): ProbeInternals => + service as unknown as ProbeInternals; + +describe('MediaProbeService', () => { + const values: Record = {}; + const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService; + + beforeEach(() => { + jest.clearAllMocks(); + Object.keys(values).forEach((key) => delete values[key]); + (mkdtemp as jest.Mock).mockResolvedValue('/tmp/media-probe'); + (writeFile as jest.Mock).mockResolvedValue(undefined); + (rm as jest.Mock).mockResolvedValue(undefined); + }); + + it('derives ffprobe beside configured ffmpeg on Windows and Unix', () => { + values['videoProcessing.ffmpegPath'] = ' C:\\ffmpeg\\ffmpeg.exe '; + expect(new MediaProbeService(config).getFfprobePath()).toBe('C:\\ffmpeg\\ffprobe.exe'); + values['videoProcessing.ffmpegPath'] = '/usr/local/bin/ffmpeg'; + expect(new MediaProbeService(config).getFfprobePath()).toBe('/usr/local/bin/ffprobe'); + values['videoProcessing.ffprobePath'] = ' custom-probe '; + expect(new MediaProbeService(config).getFfprobePath()).toBe('custom-probe'); + values['videoProcessing.ffprobePath'] = ''; + values['videoProcessing.ffmpegPath'] = 'wrapper'; + expect(new MediaProbeService(config).getFfprobePath()).toBe('ffprobe'); + }); + + it('caches successful and failed command probes', async () => { + const success = new MediaProbeService(config); + const run = jest.spyOn(internals(success), 'runCommand').mockResolvedValue('ffmpeg version 7\nother'); + await expect(success.checkFfmpeg()).resolves.toEqual({ path: 'ffmpeg', available: true, version: 'ffmpeg version 7' }); + await success.checkFfmpeg(); + expect(run).toHaveBeenCalledTimes(1); + + const failure = new MediaProbeService(config); + const fail = jest.spyOn(internals(failure), 'runCommand').mockRejectedValue('not installed'); + await expect(failure.checkFfprobe()).resolves.toEqual({ path: 'ffprobe', available: false, version: '', error: 'unknown command error' }); + await failure.checkFfprobe(); + expect(fail).toHaveBeenCalledTimes(1); + }); + + it('extracts rounded positive durations and rejects unusable output', async () => { + const service = new MediaProbeService(config); + jest.spyOn(service, 'checkFfprobe').mockResolvedValue({ path: 'probe', available: true, version: '1' }); + const run = jest.spyOn(internals(service), 'runCommand'); + run.mockResolvedValueOnce('12.6\n').mockResolvedValueOnce('0').mockResolvedValueOnce('NaN').mockRejectedValueOnce(new Error('bad media')); + await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBe(13); + await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull(); + await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull(); + await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull(); + jest.spyOn(service, 'checkFfprobe').mockResolvedValue({ path: 'probe', available: false, version: '' }); + await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull(); + }); + + it.each([ + ['clip.mp4', undefined, '.mp4'], [undefined, 'video/quicktime', '.mov'], + [undefined, 'video/webm', '.webm'], [undefined, 'audio/mpeg', '.mp3'], + [undefined, 'audio/x-m4a', '.m4a'], [undefined, 'audio/x-wav', '.wav'], + [undefined, 'audio/aac', '.aac'], [undefined, 'audio/ogg', '.ogg'], + [undefined, 'other', '.media'], + ])('uses safe buffer extension for %p/%p', async (originalname, mimetype, expected) => { + const service = new MediaProbeService(config); + jest.spyOn(service, 'extractDurationSeconds').mockResolvedValue(8); + await expect(service.extractDurationSecondsFromBuffer(Buffer.from('content'), { originalname, mimetype })).resolves.toBe(8); + expect(writeFile).toHaveBeenCalledWith(expect.stringContaining(expected), Buffer.from('content')); + expect(rm).toHaveBeenCalledWith('/tmp/media-probe', { recursive: true, force: true }); + }); + + it('handles empty buffers and temporary file failures', async () => { + const service = new MediaProbeService(config); + await expect(service.extractDurationSecondsFromBuffer(Buffer.alloc(0))).resolves.toBeNull(); + expect(mkdtemp).not.toHaveBeenCalled(); + (writeFile as jest.Mock).mockRejectedValue(new Error('disk full')); + await expect(service.extractDurationSecondsFromBuffer(Buffer.from('x'), { originalname: 'bad.mp4' })).resolves.toBeNull(); + expect(rm).toHaveBeenCalled(); + }); + + it('runs commands and handles output, process errors, and nonzero exits', async () => { + const service = new MediaProbeService(config); + const invoke = internals(service).runCommand.bind(service); + + const success = fakeChild(); + (spawn as jest.Mock).mockReturnValueOnce(success); + const successPromise = invoke('probe', ['-version'], 100); + success.stdout.emit('data', Buffer.from('version')); + success.stderr.emit('data', Buffer.from('warning')); + success.emit('close', 0); + await expect(successPromise).resolves.toBe('version'); + + const failed = fakeChild(); + (spawn as jest.Mock).mockReturnValueOnce(failed); + const failedPromise = invoke('probe', [], 100); + failed.stderr.emit('data', Buffer.from('invalid')); + failed.emit('close', 2); + await expect(failedPromise).rejects.toThrow('invalid'); + + const errored = fakeChild(); + (spawn as jest.Mock).mockReturnValueOnce(errored); + const errorPromise = invoke('probe', [], 100); + errored.emit('error', new Error('spawn error')); + await expect(errorPromise).rejects.toThrow('spawn error'); + }); + + it('kills commands that exceed their timeout', async () => { + jest.useFakeTimers(); + const service = new MediaProbeService(config); + const child = fakeChild(); + (spawn as jest.Mock).mockReturnValue(child); + const promise = internals(service).runCommand('slow', [], 10); + jest.advanceTimersByTime(11); + await expect(promise).rejects.toThrow('timed out after 10ms'); + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + jest.useRealTimers(); + }); +}); diff --git a/src/infrastructure/storage/video-processing.service.spec.ts b/src/infrastructure/storage/video-processing.service.spec.ts new file mode 100644 index 0000000..09af72b --- /dev/null +++ b/src/infrastructure/storage/video-processing.service.spec.ts @@ -0,0 +1,271 @@ +import { ConfigService } from '@nestjs/config'; +import { spawn } from 'child_process'; +import { EventEmitter } from 'events'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises'; +import { VideoProcessingService } from './video-processing.service'; + +jest.mock('child_process', () => ({ spawn: jest.fn() })); +jest.mock('fs/promises', () => ({ + mkdir: jest.fn(), mkdtemp: jest.fn(), readFile: jest.fn(), readdir: jest.fn(), rm: jest.fn(), writeFile: jest.fn(), +})); + +type Internals = { + ensureFfmpegAvailable(): Promise; + runFfmpeg(args: string[]): Promise; + runCommand(command: string, args: string[]): Promise; + generateHlsPackage(input: string, output: string): Promise; + generateSingleRenditionHlsPackage(input: string, output: string): Promise; + buildAdaptiveHlsFilterComplex(renditions: Array<{ width: number }>): string; + buildHlsRenditions(width: number): Array>; + resolveHlsRendition(width: number): Record; + probeVideo(path: string): Promise<{ hasAudio: boolean; width: number }>; + readFilesRecursively(base: string, current?: string): Promise; + resolveStreamingContentType(name: string): string; + resolveInputExtension(file: any): string; + buildOptimizedFileName(name?: string): string; + buildVideoFilter(): string; + buildThumbnailFilter(): string; + getFfprobePath(): string; +}; + +const internals = (service: VideoProcessingService): Internals => service as unknown as Internals; + +type FakeChild = EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; +const fakeChild = (): FakeChild => { + const child = new EventEmitter() as FakeChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + return child; +}; + +describe('VideoProcessingService', () => { + const values: Record = {}; + const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService; + const file = { originalname: 'concert.mov', mimetype: 'video/quicktime', size: 5, buffer: Buffer.from('video') }; + + beforeEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + Object.keys(values).forEach((key) => delete values[key]); + (mkdtemp as jest.Mock).mockResolvedValue('/tmp/video-work'); + (mkdir as jest.Mock).mockResolvedValue(undefined); + (writeFile as jest.Mock).mockResolvedValue(undefined); + (readFile as jest.Mock).mockImplementation(async (path: string) => Buffer.from(path.includes('thumbnail') ? 'thumb' : 'optimized')); + (rm as jest.Mock).mockResolvedValue(undefined); + }); + + it('returns uploads untouched when processing is disabled or ffmpeg unavailable', async () => { + const disabled = new VideoProcessingService(config); + await expect(disabled.optimizeForPlayback(file)).resolves.toEqual({ file }); + expect(mkdtemp).not.toHaveBeenCalled(); + + values['videoProcessing.enabled'] = true; + const unavailable = new VideoProcessingService(config); + jest.spyOn(internals(unavailable), 'ensureFfmpegAvailable').mockResolvedValue(false); + await expect(unavailable.optimizeForPlayback(file)).resolves.toEqual({ file }); + }); + + it('optimizes video and returns optional thumbnail and HLS assets', async () => { + Object.assign(values, { + 'videoProcessing.enabled': true, 'videoProcessing.generateThumbnails': true, + 'videoProcessing.generateHls': true, 'videoProcessing.maxWidth': 1080, + 'videoProcessing.thumbnailWidth': 600, 'videoProcessing.maxFps': 24, + 'videoProcessing.crf': 25, 'videoProcessing.preset': ' fast ', + 'videoProcessing.audioBitrateKbps': 96, + }); + const service = new VideoProcessingService(config); + jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true); + const ffmpeg = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined); + const hls = { playlistRelativePath: 'master.m3u8', files: [{ relativePath: 'master.m3u8', buffer: Buffer.from('hls'), contentType: 'application/vnd.apple.mpegurl' }] }; + jest.spyOn(internals(service), 'generateHlsPackage').mockResolvedValue(hls); + + const result = await service.optimizeForPlayback(file); + expect(result.file).toEqual(expect.objectContaining({ mimetype: 'video/mp4', originalname: 'concert-optimized.mp4', size: 9 })); + expect(result.generatedThumbnail).toEqual({ buffer: Buffer.from('thumb'), extension: '.jpg', contentType: 'image/jpeg' }); + expect(result.generatedHls).toEqual(hls); + expect(ffmpeg).toHaveBeenCalledTimes(2); + expect(ffmpeg.mock.calls[0][0]).toEqual(expect.arrayContaining(['-crf', '25', '-preset', 'fast', '-b:a', '96k'])); + expect(ffmpeg.mock.calls[0][0]).toEqual(expect.arrayContaining([expect.stringContaining('min(1080,iw)')])); + expect(ffmpeg.mock.calls[1][0]).toEqual(expect.arrayContaining([expect.stringContaining('min(600,iw)')])); + expect(rm).toHaveBeenCalledWith('/tmp/video-work', { recursive: true, force: true }); + }); + + it('keeps optimized output when thumbnail and HLS generation fail', async () => { + Object.assign(values, { 'videoProcessing.enabled': true, 'videoProcessing.generateThumbnails': true, 'videoProcessing.generateHls': true }); + const service = new VideoProcessingService(config); + jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true); + jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValueOnce(undefined).mockRejectedValueOnce('thumbnail failed'); + jest.spyOn(internals(service), 'generateHlsPackage').mockRejectedValue('HLS failed'); + const result = await service.optimizeForPlayback(file); + expect(result.file.mimetype).toBe('video/mp4'); + expect(result.generatedThumbnail).toBeUndefined(); + expect(result.generatedHls).toBeUndefined(); + }); + + it('falls back to original and cleans up after optimization errors', async () => { + values['videoProcessing.enabled'] = true; + const service = new VideoProcessingService(config); + jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true); + jest.spyOn(internals(service), 'runFfmpeg').mockRejectedValue(new Error('codec failed')); + await expect(service.optimizeForPlayback(file)).resolves.toEqual({ file }); + expect(rm).toHaveBeenCalled(); + }); + + it('supports configured and inferred executable paths and upload extensions', () => { + const service = new VideoProcessingService(config); + values['videoProcessing.ffmpegPath'] = 'C:\\bin\\ffmpeg.exe'; + expect(internals(service).getFfprobePath()).toBe('C:\\bin\\ffprobe.exe'); + values['videoProcessing.ffprobePath'] = ' custom-ffprobe '; + expect(internals(service).getFfprobePath()).toBe('custom-ffprobe'); + values['videoProcessing.ffprobePath'] = ''; + values['videoProcessing.ffmpegPath'] = 'wrapper'; + expect(internals(service).getFfprobePath()).toBe('ffprobe'); + expect(internals(service).buildOptimizedFileName()).toBe('video-optimized.mp4'); + expect(internals(service).buildOptimizedFileName('a.b.webm')).toBe('a.b-optimized.mp4'); + expect(internals(service).resolveInputExtension({ originalname: 'A.MOV' })).toBe('.mov'); + expect(internals(service).resolveInputExtension({ mimetype: 'video/webm' })).toBe('.webm'); + expect(internals(service).resolveInputExtension({ mimetype: 'video/x-matroska' })).toBe('.mkv'); + expect(internals(service).resolveInputExtension({ mimetype: 'video/x-msvideo' })).toBe('.avi'); + expect(internals(service).resolveInputExtension({ mimetype: 'unknown' })).toBe('.mp4'); + }); + + it('builds ordered adaptive rendition profiles and filters', () => { + values['videoProcessing.maxWidth'] = 1280; + values['videoProcessing.maxFps'] = 30; + const service = new VideoProcessingService(config); + expect(internals(service).buildHlsRenditions(200).map((item) => item.width)).toEqual([240]); + expect(internals(service).buildHlsRenditions(720).map((item) => item.width)).toEqual([480, 720]); + expect(internals(service).buildHlsRenditions(1920).map((item) => item.width)).toEqual([480, 720, 1280]); + expect(internals(service).resolveHlsRendition(480)).toEqual(expect.objectContaining({ videoBitrateKbps: 800, audioBitrateKbps: 96 })); + expect(internals(service).resolveHlsRendition(720)).toEqual(expect.objectContaining({ videoBitrateKbps: 1600 })); + expect(internals(service).resolveHlsRendition(1080)).toEqual(expect.objectContaining({ videoBitrateKbps: 2800 })); + expect(internals(service).buildAdaptiveHlsFilterComplex([{ width: 480 }])).toContain('[v0out]'); + const adaptive = internals(service).buildAdaptiveHlsFilterComplex([{ width: 480 }, { width: 720 }]); + expect(adaptive).toContain('split=2[v0][v1]'); + expect(adaptive).toContain('fps=30'); + expect(internals(service).buildVideoFilter()).toContain('format=yuv420p'); + expect(internals(service).buildThumbnailFilter()).toContain('force_original_aspect_ratio=decrease'); + }); + + it('generates adaptive HLS with audio and gathers generated files', async () => { + Object.assign(values, { 'videoProcessing.maxWidth': 1280, 'videoProcessing.maxFps': 25, 'videoProcessing.hlsSegmentDurationSeconds': 6 }); + const service = new VideoProcessingService(config); + jest.spyOn(internals(service), 'probeVideo').mockResolvedValue({ width: 1280, hasAudio: true }); + const run = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined); + const generated = [{ relativePath: 'master.m3u8', buffer: Buffer.from('x'), contentType: 'application/vnd.apple.mpegurl' }]; + jest.spyOn(internals(service), 'readFilesRecursively').mockResolvedValue(generated); + await expect(internals(service).generateHlsPackage('/in.mp4', '/hls')).resolves.toEqual({ playlistRelativePath: 'master.m3u8', files: generated }); + const args = run.mock.calls[0][0]; + expect(args).toEqual(expect.arrayContaining(['-filter_complex', expect.stringContaining('split=3'), '-var_stream_map', 'v:0,a:0 v:1,a:1 v:2,a:2'])); + expect(args.filter((arg) => arg === '0:a:0')).toHaveLength(3); + }); + + it('generates adaptive HLS without audio mappings', async () => { + const service = new VideoProcessingService(config); + jest.spyOn(internals(service), 'probeVideo').mockResolvedValue({ width: 720, hasAudio: false }); + const run = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined); + jest.spyOn(internals(service), 'readFilesRecursively').mockResolvedValue([]); + await internals(service).generateHlsPackage('/in.mp4', '/hls'); + expect(run.mock.calls[0][0]).toEqual(expect.arrayContaining(['-var_stream_map', 'v:0 v:1'])); + expect(run.mock.calls[0][0]).not.toContain('0:a:0'); + }); + + it('falls back to a single HLS stream when probing fails or only one rendition fits', async () => { + const failedProbe = new VideoProcessingService(config); + jest.spyOn(internals(failedProbe), 'probeVideo').mockRejectedValue(new Error('probe failed')); + const single = jest.spyOn(internals(failedProbe), 'generateSingleRenditionHlsPackage').mockResolvedValue({ playlistRelativePath: 'playlist.m3u8', files: [] }); + await internals(failedProbe).generateHlsPackage('/in.mp4', '/hls'); + expect(single).toHaveBeenCalled(); + + const narrow = new VideoProcessingService(config); + jest.spyOn(internals(narrow), 'probeVideo').mockResolvedValue({ width: 200, hasAudio: false }); + const narrowSingle = jest.spyOn(internals(narrow), 'generateSingleRenditionHlsPackage').mockResolvedValue({ playlistRelativePath: 'playlist.m3u8', files: [] }); + await internals(narrow).generateHlsPackage('/in.mp4', '/hls'); + expect(narrowSingle).toHaveBeenCalled(); + }); + + it('generates a single fMP4 rendition with default settings', async () => { + const service = new VideoProcessingService(config); + const run = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined); + jest.spyOn(internals(service), 'readFilesRecursively').mockResolvedValue([]); + await expect(internals(service).generateSingleRenditionHlsPackage('/in.mp4', '/hls')).resolves.toEqual({ playlistRelativePath: 'playlist.m3u8', files: [] }); + expect(run).toHaveBeenCalledWith(expect.arrayContaining(['-hls_time', '4', '-hls_segment_type', 'fmp4'])); + }); + + it('parses video probe output and rejects missing widths', async () => { + const service = new VideoProcessingService(config); + const run = jest.spyOn(internals(service), 'runCommand'); + run.mockResolvedValueOnce(JSON.stringify({ streams: [{ codec_type: 'video', width: 1920 }, { codec_type: 'audio' }] })); + await expect(internals(service).probeVideo('/in.mp4')).resolves.toEqual({ width: 1920, hasAudio: true }); + run.mockResolvedValueOnce('{}'); + await expect(internals(service).probeVideo('/bad.mp4')).rejects.toThrow('video width'); + }); + + it('reads nested HLS output in stable order and assigns content types', async () => { + const service = new VideoProcessingService(config); + (readdir as jest.Mock).mockImplementation(async (path: string) => + path === '/hls' + ? [ + { name: 'stream_0', isDirectory: () => true }, + { name: 'master.m3u8', isDirectory: () => false }, + ] + : [ + { name: 'segment.ts', isDirectory: () => false }, + { name: 'init.mp4', isDirectory: () => false }, + { name: 'segment.m4s', isDirectory: () => false }, + { name: 'unknown.bin', isDirectory: () => false }, + ], + ); + const files = await internals(service).readFilesRecursively('/hls'); + expect(files.map((item) => [item.relativePath, item.contentType])).toEqual([ + ['master.m3u8', 'application/vnd.apple.mpegurl'], + ['stream_0/init.mp4', 'video/mp4'], + ['stream_0/segment.m4s', 'video/iso.segment'], + ['stream_0/segment.ts', 'video/mp2t'], + ['stream_0/unknown.bin', 'application/octet-stream'], + ]); + }); + + it('probes ffmpeg only once and caches success or failure', async () => { + const success = new VideoProcessingService(config); + const run = jest.spyOn(internals(success), 'runFfmpeg').mockResolvedValue(undefined); + await expect(internals(success).ensureFfmpegAvailable()).resolves.toBe(true); + await internals(success).ensureFfmpegAvailable(); + expect(run).toHaveBeenCalledTimes(1); + + const failure = new VideoProcessingService(config); + const fail = jest.spyOn(internals(failure), 'runFfmpeg').mockRejectedValue('missing'); + await expect(internals(failure).ensureFfmpegAvailable()).resolves.toBe(false); + await internals(failure).ensureFfmpegAvailable(); + expect(fail).toHaveBeenCalledTimes(1); + }); + + it('runs child commands and propagates stderr, generic exit, and spawn errors', async () => { + const service = new VideoProcessingService(config); + const ok = fakeChild(); + (spawn as jest.Mock).mockReturnValueOnce(ok); + const okPromise = internals(service).runCommand('ffmpeg', ['-version']); + ok.stdout.emit('data', Buffer.from('version')); + ok.emit('close', 0); + await expect(okPromise).resolves.toBe('version'); + + const failed = fakeChild(); + (spawn as jest.Mock).mockReturnValueOnce(failed); + const failedPromise = internals(service).runCommand('ffmpeg', []); + failed.stderr.emit('data', Buffer.from('invalid codec')); + failed.emit('close', 1); + await expect(failedPromise).rejects.toThrow('invalid codec'); + + const generic = fakeChild(); + (spawn as jest.Mock).mockReturnValueOnce(generic); + const genericPromise = internals(service).runCommand('ffmpeg', []); + generic.emit('close', null); + await expect(genericPromise).rejects.toThrow('exited with code unknown'); + + const errored = fakeChild(); + (spawn as jest.Mock).mockReturnValueOnce(errored); + const errorPromise = internals(service).runCommand('ffmpeg', []); + errored.emit('error', new Error('spawn failed')); + await expect(errorPromise).rejects.toThrow('spawn failed'); + }); +}); diff --git a/src/main.spec.ts b/src/main.spec.ts new file mode 100644 index 0000000..6f70cf4 --- /dev/null +++ b/src/main.spec.ts @@ -0,0 +1,230 @@ +import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; +import { SwaggerModule } from '@nestjs/swagger'; +import * as express from 'express'; +import { existsSync, mkdirSync } from 'fs'; +import { networkInterfaces } from 'os'; +import { AppLoggerService } from './infrastructure/logging/app-logger.service'; +import { RedisService } from './infrastructure/redis/redis.service'; +import { configureHttpServer } from './infrastructure/reliability/http-server.config'; +import { RedisIoAdapter } from './infrastructure/socket/redis-io.adapter'; +import { bootstrap, getLocalIpv4Addresses, getStaticMediaHeaders, isPrivateIpv4Host } from './main'; + +jest.mock('./app.module', () => ({ AppModule: class AppModule {} })); +jest.mock('@nestjs/core', () => ({ NestFactory: { create: jest.fn() } })); +jest.mock('@nestjs/swagger', () => { + class DocumentBuilder { + setTitle() { return this; } + setDescription() { return this; } + setVersion() { return this; } + addBearerAuth() { return this; } + build() { return { openapi: '3.0.0' }; } + } + return { + DocumentBuilder, + SwaggerModule: { createDocument: jest.fn(() => ({ paths: {} })), setup: jest.fn() }, + }; +}); +jest.mock('compression', () => jest.fn(() => 'compression-middleware')); +jest.mock('express', () => ({ + json: jest.fn(() => 'json-middleware'), + urlencoded: jest.fn(() => 'urlencoded-middleware'), + static: jest.fn(() => 'static-middleware'), +})); +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + existsSync: jest.fn(), + mkdirSync: jest.fn(), +})); +jest.mock('os', () => ({ + ...jest.requireActual('os'), + networkInterfaces: jest.fn(), +})); +jest.mock('./infrastructure/reliability/http-server.config', () => ({ configureHttpServer: jest.fn() })); +jest.mock('./infrastructure/socket/redis-io.adapter', () => ({ + RedisIoAdapter: jest.fn().mockImplementation(() => ({ + connectToRedis: jest.fn().mockResolvedValue(undefined), + })), +})); + +describe('main bootstrap', () => { + const build = (overrides: Record = {}) => { + const values: Record = { + 'cors.origins': ['https://app.example.com'], + nodeEnv: 'production', + 'security.bodyLimit': '2mb', + 'performance.compressionEnabled': true, + 'performance.compressionThresholdBytes': 2048, + 'storage.provider': 'local', + 'storage.basePath': '/uploads/', + publicBaseUrl: 'http://localhost:4000/', + 'email.enabled': true, + 'email.smtpHost': 'smtp.example.com', + 'email.smtpUser': 'smtp-user', + 'email.fromEmail': 'noreply@example.com', + globalPrefix: 'api/v1', + responseEnvelopeEnabled: true, + 'redis.enabled': true, + 'redis.socketAdapterEnabled': true, + 'swagger.enabled': true, + 'swagger.path': 'docs', + 'swagger.title': 'Oudelaa API', + 'swagger.description': 'API', + 'swagger.version': '1.0.0', + port: 4000, + host: '0.0.0.0', + ...overrides, + }; + const config = { + get: jest.fn((key: string, defaultValue?: unknown) => + Object.prototype.hasOwnProperty.call(values, key) ? values[key] : defaultValue, + ), + }; + const logger = { + log: jest.fn(), + warn: jest.fn(), + logHttp: jest.fn(), + }; + const redis = {}; + const httpServer = { keepAliveTimeout: 0, headersTimeout: 0, requestTimeout: 0 }; + const app = { + get: jest.fn((token: unknown) => { + if (token === ConfigService) return config; + if (token === AppLoggerService) return logger; + if (token === RedisService) return redis; + return undefined; + }), + useLogger: jest.fn(), + enableShutdownHooks: jest.fn(), + use: jest.fn(), + enableCors: jest.fn(), + setGlobalPrefix: jest.fn(), + useGlobalPipes: jest.fn(), + useGlobalInterceptors: jest.fn(), + useWebSocketAdapter: jest.fn(), + listen: jest.fn().mockResolvedValue(undefined), + getHttpServer: jest.fn(() => httpServer), + }; + (NestFactory.create as jest.Mock).mockResolvedValue(app); + return { app, config, logger, redis, httpServer }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + (existsSync as jest.Mock).mockReturnValue(false); + (networkInterfaces as jest.Mock).mockReturnValue({ + Ethernet: [ + { family: 'IPv4', internal: false, address: '192.168.1.20' }, + { family: 'IPv6', internal: false, address: '::1' }, + null, + ], + Loopback: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }], + }); + }); + + it('classifies local addresses and static-media response headers', () => { + expect(getLocalIpv4Addresses()).toEqual(['192.168.1.20']); + expect(isPrivateIpv4Host('10.0.0.1')).toBe(true); + expect(isPrivateIpv4Host('192.168.1.1')).toBe(true); + expect(isPrivateIpv4Host('172.16.0.1')).toBe(true); + expect(isPrivateIpv4Host('172.31.0.1')).toBe(true); + expect(isPrivateIpv4Host('172.15.0.1')).toBe(false); + expect(getStaticMediaHeaders('.mp3', '/audio/song.mp3')).toEqual( + expect.objectContaining({ contentType: 'audio/mpeg', acceptRanges: true }), + ); + expect(getStaticMediaHeaders('.gif')).toEqual(expect.objectContaining({ contentType: 'image/gif' })); + expect(getStaticMediaHeaders('.m3u8')).toEqual( + expect.objectContaining({ contentType: 'application/vnd.apple.mpegurl', acceptRanges: true }), + ); + expect(getStaticMediaHeaders('.m4s')).toEqual(expect.objectContaining({ contentType: 'video/iso.segment' })); + expect(getStaticMediaHeaders('.ts')).toEqual(expect.objectContaining({ contentType: 'video/mp2t' })); + expect(getStaticMediaHeaders('.unknown')).toEqual({}); + }); + + it('boots the production stack with security, local media, Redis sockets, and Swagger', async () => { + const { app, logger, httpServer } = build(); + + await bootstrap(); + + expect(NestFactory.create).toHaveBeenCalledWith(expect.any(Function), { + bufferLogs: true, + bodyParser: false, + }); + expect(mkdirSync).toHaveBeenCalledWith(expect.stringMatching(/uploads$/), { recursive: true }); + expect(app.enableCors).toHaveBeenCalledWith(expect.objectContaining({ credentials: true })); + expect(app.setGlobalPrefix).toHaveBeenCalledWith('api/v1'); + expect(app.useGlobalPipes).toHaveBeenCalled(); + expect(app.useGlobalInterceptors).toHaveBeenCalled(); + expect(app.listen).toHaveBeenCalledWith(4000, '0.0.0.0'); + expect(configureHttpServer).toHaveBeenCalledWith(httpServer, expect.anything()); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Mobile devices on the LAN'), 'Bootstrap'); + expect(RedisIoAdapter).toHaveBeenCalled(); + const adapter = (RedisIoAdapter as unknown as jest.Mock).mock.results[0].value; + expect(adapter.connectToRedis).toHaveBeenCalled(); + expect(app.useWebSocketAdapter).toHaveBeenCalledWith(adapter); + expect(SwaggerModule.createDocument).toHaveBeenCalled(); + expect(SwaggerModule.setup).toHaveBeenCalledWith('docs', app, expect.anything()); + + const middleware = app.use.mock.calls + .filter((call) => typeof call[0] === 'function') + .map((call) => call[0]); + expect(middleware).toHaveLength(2); + const setHeader = jest.fn(); + const next = jest.fn(); + middleware[0]({}, { setHeader } as any, next); + expect(setHeader).toHaveBeenCalledWith('Strict-Transport-Security', expect.any(String)); + + let finish: (() => void) | undefined; + const request = { headers: { 'x-request-id': 'invalid id' }, method: 'GET', originalUrl: '/health' } as any; + const response = { + statusCode: 200, + setHeader, + on: jest.fn((_event: string, listener: () => void) => { finish = listener; }), + } as any; + middleware[1](request, response, next); + finish?.(); + expect(request.headers['x-request-id']).toEqual(expect.any(String)); + expect(logger.logHttp).toHaveBeenCalledWith(expect.objectContaining({ method: 'GET', statusCode: 200 })); + + const suppliedIdRequest = { + headers: { 'x-request-id': 'mobile-client:request-1' }, + method: 'GET', + originalUrl: '/health', + } as any; + middleware[1](suppliedIdRequest, response, next); + expect(suppliedIdRequest.headers['x-request-id']).toBe('mobile-client:request-1'); + + const staticOptions = (express.static as unknown as jest.Mock).mock.calls[0][1]; + const staticHeader = jest.fn(); + staticOptions.setHeaders({ setHeader: staticHeader }, '/uploads/stream.m3u8'); + expect(staticHeader).toHaveBeenCalledWith('Content-Type', 'application/vnd.apple.mpegurl'); + expect(staticHeader).toHaveBeenCalledWith('Accept-Ranges', 'bytes'); + }); + + it('boots without optional integrations and warns about a mismatched private public URL', async () => { + const { app, logger } = build({ + 'cors.origins': [], + nodeEnv: 'development', + 'performance.compressionEnabled': false, + 'storage.provider': 's3', + publicBaseUrl: 'http://192.168.1.99:4000', + responseEnvelopeEnabled: false, + 'redis.enabled': false, + 'redis.socketAdapterEnabled': false, + 'swagger.enabled': false, + 'email.enabled': false, + 'email.smtpHost': '', + 'email.smtpUser': '', + 'email.fromEmail': '', + }); + + await bootstrap(); + + expect(mkdirSync).not.toHaveBeenCalled(); + expect(app.enableCors).toHaveBeenCalledWith(expect.objectContaining({ origin: true, credentials: true })); + expect(app.useGlobalInterceptors).not.toHaveBeenCalled(); + expect(app.useWebSocketAdapter).not.toHaveBeenCalled(); + expect(SwaggerModule.setup).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('not assigned to the current machine'), 'Bootstrap'); + }); +}); diff --git a/src/main.ts b/src/main.ts index 65db876..930f1da 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,9 @@ -import { ValidationPipe } from '@nestjs/common'; +import { ShutdownSignal, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { ConfigService } from '@nestjs/config'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import * as express from 'express'; +import compression = require('compression'); import { randomUUID } from 'crypto'; import { NextFunction, Request, Response } from 'express'; import { existsSync, mkdirSync } from 'fs'; @@ -13,22 +14,26 @@ import { ResponseEnvelopeInterceptor } from './common/interceptors/response-enve import { getFileExtension, getStaticMediaContentType } from './common/media/allowed-media'; import { AppLoggerService } from './infrastructure/logging/app-logger.service'; import { RedisService } from './infrastructure/redis/redis.service'; +import { + ConfigurableHttpServer, + configureHttpServer, +} from './infrastructure/reliability/http-server.config'; import { RedisIoAdapter } from './infrastructure/socket/redis-io.adapter'; -const getLocalIpv4Addresses = (): string[] => +export const getLocalIpv4Addresses = (): string[] => Object.values(networkInterfaces()) .flatMap((entries) => entries ?? []) .filter((entry): entry is NetworkInterfaceInfo => !!entry) .filter((entry) => entry.family === 'IPv4' && !entry.internal) .map((entry) => entry.address); -const isPrivateIpv4Host = (host: string): boolean => +export const isPrivateIpv4Host = (host: string): boolean => /^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[0-1])\./.test(host); const IMMUTABLE_MEDIA_CACHE_CONTROL = 'public, max-age=31536000, immutable'; const SHORT_MANIFEST_CACHE_CONTROL = 'public, max-age=300, stale-while-revalidate=60'; -const getStaticMediaHeaders = ( +export const getStaticMediaHeaders = ( extension: string, filePath = '', ): { @@ -74,12 +79,19 @@ const getStaticMediaHeaders = ( } }; -async function bootstrap(): Promise { - const app = await NestFactory.create(AppModule, { bufferLogs: true }); +export async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule, { bufferLogs: true, bodyParser: false }); const configService = app.get(ConfigService); const appLogger = app.get(AppLoggerService); app.useLogger(appLogger); + app.enableShutdownHooks([ShutdownSignal.SIGINT, ShutdownSignal.SIGTERM]); const corsOrigins = configService.get('cors.origins', []); + const nodeEnv = configService.get('nodeEnv', 'development'); + const bodyLimit = configService.get('security.bodyLimit', { infer: true }) ?? '1mb'; + const compressionEnabled = + configService.get('performance.compressionEnabled', { infer: true }) ?? true; + const compressionThresholdBytes = + configService.get('performance.compressionThresholdBytes', { infer: true }) ?? 1024; const storageProvider = configService.get('storage.provider', { infer: true }) ?? 'local'; const storageBasePath = (configService.get('storage.basePath', { infer: true }) ?? 'uploads').replace( @@ -94,13 +106,31 @@ async function bootstrap(): Promise { const emailFromEmail = configService.get('email.fromEmail', { infer: true }) ?? ''; const uploadsDir = join(process.cwd(), storageBasePath); + if (compressionEnabled) { + app.use(compression({ threshold: compressionThresholdBytes })); + } + app.use(express.json({ limit: bodyLimit })); + app.use(express.urlencoded({ extended: true, limit: bodyLimit })); + + app.use((_req: Request, res: Response, next: NextFunction) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + if (nodeEnv === 'production') { + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + next(); + }); + if (storageProvider === 'local' && !existsSync(uploadsDir)) { mkdirSync(uploadsDir, { recursive: true }); } app.enableCors({ - origin: corsOrigins.length ? corsOrigins : true, - credentials: true, + origin: corsOrigins.length ? corsOrigins : nodeEnv === 'production' ? false : true, + credentials: corsOrigins.length > 0 || nodeEnv !== 'production', exposedHeaders: [ 'Accept-Ranges', 'Content-Length', @@ -125,7 +155,11 @@ async function bootstrap(): Promise { app.use((req: Request, res: Response, next: NextFunction) => { const startedAt = Date.now(); - const requestId = (req.headers['x-request-id'] as string | undefined) ?? randomUUID(); + const suppliedRequestId = req.headers['x-request-id']; + const requestId = + typeof suppliedRequestId === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(suppliedRequestId) + ? suppliedRequestId + : randomUUID(); req.headers['x-request-id'] = requestId; res.setHeader('x-request-id', requestId); @@ -147,9 +181,10 @@ async function bootstrap(): Promise { app.useGlobalInterceptors(new ResponseEnvelopeInterceptor()); } - app.use( - `/${storageBasePath}`, - express.static(uploadsDir, { + if (storageProvider === 'local') { + app.use( + `/${storageBasePath}`, + express.static(uploadsDir, { acceptRanges: true, setHeaders: (res, filePath) => { const extension = getFileExtension(filePath); @@ -167,8 +202,9 @@ async function bootstrap(): Promise { res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.setHeader('X-Content-Type-Options', 'nosniff'); }, - }), - ); + }), + ); + } const redisEnabled = configService.get('redis.enabled', { infer: true }) ?? false; const socketAdapterEnabled = @@ -188,8 +224,11 @@ async function bootstrap(): Promise { .addBearerAuth() .build(); - const document = SwaggerModule.createDocument(app, swaggerConfig); - SwaggerModule.setup(configService.get('swagger.path', 'docs'), app, document); + const swaggerEnabled = configService.get('swagger.enabled', { infer: true }) ?? false; + if (swaggerEnabled) { + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup(configService.get('swagger.path', 'docs'), app, document); + } const port = configService.get('port', 4000); const host = configService.get('host', '0.0.0.0'); @@ -221,6 +260,7 @@ async function bootstrap(): Promise { } await app.listen(port, host); + configureHttpServer(app.getHttpServer() as ConfigurableHttpServer, configService); appLogger.log(`Server listening on http://${host}:${port}`, 'Bootstrap'); appLogger.log(`Resolved PUBLIC_BASE_URL=${publicBaseUrl || `http://localhost:${port}`}`, 'Bootstrap'); appLogger.log( @@ -229,4 +269,6 @@ async function bootstrap(): Promise { ); } -void bootstrap(); +if (require.main === module) { + void bootstrap(); +} diff --git a/src/modules/audit/audit.service.spec.ts b/src/modules/audit/audit.service.spec.ts new file mode 100644 index 0000000..20777d6 --- /dev/null +++ b/src/modules/audit/audit.service.spec.ts @@ -0,0 +1,119 @@ +import { SortOrder } from '../../common/enums/sort-order.enum'; +import { AuditService } from './audit.service'; + +describe('AuditService', () => { + const createService = () => { + const auditRepository = { + create: jest.fn(), + findMany: jest.fn(), + count: jest.fn(), + }; + + return { + service: new AuditService(auditRepository as any), + auditRepository, + }; + }; + + it('writes a complete super-admin audit record', async () => { + const { service, auditRepository } = createService(); + auditRepository.create.mockResolvedValue(undefined); + + await expect( + service.logSuperAdminAction('root@example.com', 'suspend', 'user', 'user-1', { + reason: 'abuse', + }), + ).resolves.toBeUndefined(); + expect(auditRepository.create).toHaveBeenCalledWith({ + actorType: 'superadmin', + actorIdentifier: 'root@example.com', + action: 'suspend', + targetType: 'user', + targetId: 'user-1', + metadata: { reason: 'abuse' }, + }); + }); + + it('uses default pagination, filters, and descending order', async () => { + const { service, auditRepository } = createService(); + const items = [{ id: 'log-1' }, { id: 'log-2' }]; + auditRepository.findMany.mockResolvedValue(items); + auditRepository.count.mockResolvedValue(25); + + const result = await service.listSuperAdminLogs({}); + + expect(auditRepository.findMany).toHaveBeenCalledWith({}, 0, 20, { createdAt: -1 }); + expect(auditRepository.count).toHaveBeenCalledWith({}); + expect(result).toMatchObject({ + items, + count: 2, + page: 1, + limit: 20, + total: 25, + totalPages: 2, + pagination: { + hasNextPage: true, + hasPreviousPage: false, + nextPage: 2, + previousPage: null, + }, + }); + }); + + it('builds escaped search filters and applies explicit ascending pagination', async () => { + const { service, auditRepository } = createService(); + auditRepository.findMany.mockResolvedValue([{ id: 'log-3' }]); + auditRepository.count.mockResolvedValue(21); + + const result = await service.listSuperAdminLogs({ + q: ' user.* ', + actorType: 'system', + targetType: ' post ', + page: 3, + limit: 10, + sortOrder: SortOrder.ASC, + }); + + const filter = { + $or: [ + { action: { $regex: 'user\\.\\*', $options: 'i' } }, + { targetType: { $regex: 'user\\.\\*', $options: 'i' } }, + { targetId: { $regex: 'user\\.\\*', $options: 'i' } }, + { actorIdentifier: { $regex: 'user\\.\\*', $options: 'i' } }, + ], + actorType: 'system', + targetType: 'post', + }; + expect(auditRepository.findMany).toHaveBeenCalledWith(filter, 20, 10, { createdAt: 1 }); + expect(auditRepository.count).toHaveBeenCalledWith(filter); + expect(result.pagination).toMatchObject({ + hasNextPage: false, + hasPreviousPage: true, + nextPage: null, + previousPage: 2, + }); + }); + + it('ignores whitespace-only optional filters', async () => { + const { service, auditRepository } = createService(); + auditRepository.findMany.mockResolvedValue([]); + auditRepository.count.mockResolvedValue(0); + + await service.listSuperAdminLogs({ q: ' ', targetType: ' ' }); + + expect(auditRepository.findMany).toHaveBeenCalledWith({}, 0, 20, { createdAt: -1 }); + }); + + it('propagates write and read failures', async () => { + const { service, auditRepository } = createService(); + auditRepository.create.mockRejectedValue(new Error('write failed')); + + await expect( + service.logSuperAdminAction('root', 'action', 'target'), + ).rejects.toThrow('write failed'); + + auditRepository.findMany.mockRejectedValue(new Error('read failed')); + auditRepository.count.mockResolvedValue(0); + await expect(service.listSuperAdminLogs({})).rejects.toThrow('read failed'); + }); +}); diff --git a/src/modules/audit/audit.service.ts b/src/modules/audit/audit.service.ts index afa368b..052c9ee 100644 --- a/src/modules/audit/audit.service.ts +++ b/src/modules/audit/audit.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { AuditRepository } from './audit.repository'; +import { escapeRegex } from '../../common/utils/regex.util'; import { AuditQueryDto } from './dto/audit-query.dto'; @Injectable() @@ -33,10 +34,10 @@ export class AuditService { if (query.q?.trim()) { filter.$or = [ - { action: { $regex: query.q.trim(), $options: 'i' } }, - { targetType: { $regex: query.q.trim(), $options: 'i' } }, - { targetId: { $regex: query.q.trim(), $options: 'i' } }, - { actorIdentifier: { $regex: query.q.trim(), $options: 'i' } }, + { action: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, + { targetType: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, + { targetId: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, + { actorIdentifier: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, ]; } diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 3909406..542733b 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -12,6 +12,7 @@ import { UseInterceptors, } from '@nestjs/common'; import { FileFieldsInterceptor } from '@nestjs/platform-express'; +import { MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; import { Throttle } from '../../common/decorators/throttle.decorator'; @@ -73,7 +74,11 @@ export class AuthController { @HttpCode(HttpStatus.OK) @Post('signup/complete') @Throttle(10, 60_000) - @UseInterceptors(FileFieldsInterceptor([{ name: 'avatarFile', maxCount: 1 }])) + @UseInterceptors( + FileFieldsInterceptor([{ name: 'avatarFile', maxCount: 1 }], { + limits: { fileSize: MEDIA_MAX_SIZE_BYTES.userImage, files: 1 }, + }), + ) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 0ee3100..6fc1eb6 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { JwtModule } from '@nestjs/jwt'; +import { JwtModule, JwtSignOptions } from '@nestjs/jwt'; import { MongooseModule } from '@nestjs/mongoose'; import { EmailModule } from '../email/email.module'; import { UsersModule } from '../users/users.module'; @@ -34,7 +34,7 @@ import { SuperAdminJwtStrategy } from './strategies/super-admin-jwt.strategy'; useFactory: (configService: ConfigService) => ({ secret: configService.get('jwt.accessSecret', { infer: true }), signOptions: { - expiresIn: configService.get('jwt.accessExpiresIn', { infer: true }), + expiresIn: configService.get('jwt.accessExpiresIn', { infer: true }) as JwtSignOptions['expiresIn'], }, }), }), diff --git a/src/modules/auth/auth.repository.ts b/src/modules/auth/auth.repository.ts index 8b3265b..fe77e18 100644 --- a/src/modules/auth/auth.repository.ts +++ b/src/modules/auth/auth.repository.ts @@ -59,6 +59,17 @@ export class AuthRepository { .exec(); } + async consumeUserTokenByJti(userId: string, jti: string): Promise { + const consumed = await this.refreshTokenModel + .findOneAndUpdate( + { userId: new Types.ObjectId(userId), jti, revoked: false, expiresAt: { $gt: new Date() } }, + { revoked: true }, + { new: false }, + ) + .exec(); + return !!consumed; + } + async findActiveUserTokenByJti(userId: string, jti: string): Promise { return this.refreshTokenModel .findOne({ userId: new Types.ObjectId(userId), jti, revoked: false }) @@ -135,6 +146,22 @@ export class AuthRepository { .exec(); } + async consumeSuperAdminTokenByJti(adminEmail: string, jti: string): Promise { + const consumed = await this.superAdminRefreshTokenModel + .findOneAndUpdate( + { + adminEmail: adminEmail.toLowerCase(), + jti, + revoked: false, + expiresAt: { $gt: new Date() }, + }, + { revoked: true }, + { new: false }, + ) + .exec(); + return !!consumed; + } + async removeExpiredAndRevokedSuperAdmin(adminEmail: string): Promise { await this.superAdminRefreshTokenModel .deleteMany({ @@ -284,6 +311,28 @@ export class AuthRepository { .exec(); } + async consumeValidVerifiedPasswordResetCode( + codeId: string, + userId: string, + ): Promise { + if (!Types.ObjectId.isValid(codeId) || !Types.ObjectId.isValid(userId)) { + return null; + } + return this.passwordResetCodeModel + .findOneAndUpdate( + { + _id: new Types.ObjectId(codeId), + userId: new Types.ObjectId(userId), + used: false, + verified: true, + expiresAt: { $gt: new Date() }, + }, + { used: true }, + { new: false }, + ) + .exec(); + } + async upsertSignupDraft(params: { email: string; passwordHash: string; diff --git a/src/modules/auth/auth.service.security.spec.ts b/src/modules/auth/auth.service.security.spec.ts new file mode 100644 index 0000000..65ed0ad --- /dev/null +++ b/src/modules/auth/auth.service.security.spec.ts @@ -0,0 +1,844 @@ +import { + BadRequestException, + ForbiddenException, + GoneException, + HttpException, + UnauthorizedException, +} from '@nestjs/common'; +import { hashHighEntropyValue, hashValue } from '../../common/utils/hash.util'; +import { AuthService } from './auth.service'; + +type MockMap = Record; + +const future = () => new Date(Date.now() + 60_000); + +const createHarness = (options: { + config?: Record; + users?: Partial; + repository?: Partial; + jwt?: Partial; + email?: Partial; +} = {}) => { + const user = { + id: 'user-1', + email: 'user@example.com', + username: 'artist', + role: 'user', + password: '', + isDisabled: false, + isEmailVerified: false, + googleId: 'google-1', + toObject: () => ({ id: 'user-1', email: 'user@example.com', username: 'artist' }), + }; + const users: MockMap = { + create: jest.fn().mockResolvedValue(user), + createWithOptionalAvatarFile: jest.fn().mockResolvedValue(user), + rollbackSignupCreatedUser: jest.fn().mockResolvedValue(undefined), + findByEmailWithPassword: jest.fn(), + findByIdWithPassword: jest.fn(), + findByIdOrFail: jest.fn().mockResolvedValue(user), + findByEmail: jest.fn().mockResolvedValue(null), + findByUsername: jest.fn().mockResolvedValue(null), + findByGoogleId: jest.fn().mockResolvedValue(null), + linkGoogleAccount: jest.fn().mockResolvedValue(user), + markEmailVerified: jest.fn().mockResolvedValue(undefined), + updatePassword: jest.fn().mockResolvedValue(undefined), + ...options.users, + }; + const repository: MockMap = { + invalidateActiveEmailVerificationCodes: jest.fn().mockResolvedValue(undefined), + createEmailVerificationCode: jest.fn().mockResolvedValue(undefined), + findLatestActiveEmailVerificationCode: jest.fn(), + incrementEmailVerificationAttempts: jest.fn().mockResolvedValue(undefined), + markEmailVerificationCodeUsed: jest.fn().mockResolvedValue(undefined), + markAllEmailVerificationCodesUsedByUser: jest.fn().mockResolvedValue(undefined), + createRefreshToken: jest.fn().mockResolvedValue(undefined), + findActiveUserTokenByJti: jest.fn(), + consumeUserTokenByJti: jest.fn().mockResolvedValue(true), + markCompromisedAndRevokeAll: jest.fn().mockResolvedValue(undefined), + revokeAllUserTokens: jest.fn().mockResolvedValue(undefined), + revokeUserTokenByJti: jest.fn().mockResolvedValue(undefined), + removeExpiredAndRevoked: jest.fn().mockResolvedValue(undefined), + createSuperAdminRefreshToken: jest.fn().mockResolvedValue(undefined), + findActiveSuperAdminTokenByJti: jest.fn(), + consumeSuperAdminTokenByJti: jest.fn().mockResolvedValue(true), + revokeAllSuperAdminTokens: jest.fn().mockResolvedValue(undefined), + revokeSuperAdminTokenByJti: jest.fn().mockResolvedValue(undefined), + removeExpiredAndRevokedSuperAdmin: jest.fn().mockResolvedValue(undefined), + listUserSessions: jest.fn().mockResolvedValue([]), + listSuperAdminSessions: jest.fn().mockResolvedValue([]), + revokeSuperAdminSessionById: jest.fn().mockResolvedValue(true), + invalidateActivePasswordResetCodes: jest.fn().mockResolvedValue(undefined), + createPasswordResetCode: jest.fn().mockResolvedValue(undefined), + findLatestActivePasswordResetCode: jest.fn(), + incrementPasswordResetAttempts: jest.fn().mockResolvedValue(undefined), + markPasswordResetCodeVerified: jest.fn().mockResolvedValue(undefined), + markPasswordResetCodeUsed: jest.fn().mockResolvedValue(undefined), + markPasswordResetCodeUsedByUser: jest.fn().mockResolvedValue(undefined), + consumeValidVerifiedPasswordResetCode: jest.fn(), + ...options.repository, + }; + const jwt: MockMap = { + signAsync: jest.fn((payload: { tokenType?: string }) => + Promise.resolve(payload.tokenType?.includes('refresh') ? 'new-refresh-token' : 'new-access-token'), + ), + verify: jest.fn(), + ...options.jwt, + }; + const email: MockMap = { + sendVerificationCode: jest.fn().mockResolvedValue(undefined), + sendPasswordResetCode: jest.fn().mockResolvedValue(undefined), + ...options.email, + }; + const config: Record = { + nodeEnv: 'test', + 'security.bcryptSaltRounds': 4, + 'security.refreshTokenHashSecret': 'hash-secret', + 'jwt.accessSecret': 'access-secret', + 'jwt.accessExpiresIn': '15m', + 'jwt.refreshSecret': 'refresh-secret', + 'jwt.refreshExpiresIn': '30d', + 'superAdmin.email': 'root@example.com', + 'superAdmin.password': 'RootPass123!', + 'superAdmin.accessSecret': 'admin-access-secret', + 'superAdmin.accessExpiresIn': '15m', + 'superAdmin.refreshSecret': 'admin-refresh-secret', + 'superAdmin.refreshExpiresIn': '30d', + 'emailVerification.codeExpiresMinutes': 20, + 'emailVerification.maxAttempts': 3, + 'passwordReset.codeExpiresMinutes': 10, + 'passwordReset.maxAttempts': 3, + 'passwordReset.tokenSecret': 'password-reset-secret', + 'passwordReset.tokenExpiresIn': '15m', + 'google.clientId': 'google-client-id', + ...options.config, + }; + const configService = { + get: jest.fn((key: string) => config[key]), + }; + const service = new AuthService( + users as any, + repository as any, + jwt as any, + configService as any, + email as any, + ); + + return { service, users, repository, jwt, email, configService, user }; +}; + +describe('AuthService security-sensitive account flows', () => { + describe('registration and login', () => { + it('rejects registration when password confirmation differs', async () => { + const { service, users } = createHarness(); + + await expect( + service.register({ + email: 'user@example.com', + username: 'artist', + password: 'StrongPass123!', + confirmPassword: 'DifferentPass123!', + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(users.create).not.toHaveBeenCalled(); + }); + + it('normalizes a generated unique username and never stores the confirmation', async () => { + const { service, users, repository, email } = createHarness({ + users: { + findByUsername: jest + .fn() + .mockResolvedValueOnce({ id: 'collision' }) + .mockResolvedValueOnce(null), + }, + }); + + const result = await service.register({ + email: 'A+rtist@example.com', + stageName: 'Stage Artist', + password: 'StrongPass123!', + confirmPassword: 'StrongPass123!', + } as any); + + expect(users.create).toHaveBeenCalledWith( + expect.objectContaining({ username: 'artist_1', name: 'Stage Artist' }), + ); + expect(users.create.mock.calls[0][0]).not.toHaveProperty('confirmPassword'); + expect(repository.invalidateActiveEmailVerificationCodes).toHaveBeenCalledWith('user-1'); + expect(email.sendVerificationCode).toHaveBeenCalled(); + expect(result.debugCode).toMatch(/^\d{6}$/); + }); + + it('omits verification codes in production registration responses', async () => { + const { service } = createHarness({ config: { nodeEnv: 'production' } }); + + const result = await service.registerBasic({ + email: 'new@example.com', + password: 'StrongPass123!', + confirmPassword: 'StrongPass123!', + }); + + expect(result).toEqual({ + message: 'Registration successful. Verification code sent to email.', + email: 'user@example.com', + }); + }); + + it('rejects a missing account, disabled account, and incorrect password', async () => { + const missing = createHarness(); + await expect( + missing.service.login({ email: 'none@example.com', password: 'StrongPass123!' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + + const disabledHash = await hashValue('StrongPass123!', 4); + const disabled = createHarness({ + users: { + findByEmailWithPassword: jest.fn().mockResolvedValue({ + ...missing.user, + password: disabledHash, + isDisabled: true, + }), + }, + }); + await expect( + disabled.service.login({ email: 'user@example.com', password: 'StrongPass123!' }), + ).rejects.toBeInstanceOf(ForbiddenException); + + const wrong = createHarness({ + users: { + findByEmailWithPassword: jest.fn().mockResolvedValue({ + ...missing.user, + password: disabledHash, + }), + }, + }); + await expect( + wrong.service.login({ email: 'user@example.com', password: 'WrongPass123!' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('returns a safe user and persists a hashed refresh token on login', async () => { + const password = await hashValue('StrongPass123!', 4); + const { service, users, repository } = createHarness({ + users: { + findByEmailWithPassword: jest.fn().mockResolvedValue({ + id: 'user-1', + username: 'artist', + role: undefined, + password, + isDisabled: false, + }), + }, + }); + + const result = await service.login({ + email: 'user@example.com', + password: 'StrongPass123!', + }); + + expect(result).toMatchObject({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }); + expect(users.findByIdOrFail).toHaveBeenCalledWith('user-1'); + expect(repository.createRefreshToken).toHaveBeenCalledWith( + 'user-1', + expect.any(String), + hashHighEntropyValue('new-refresh-token', 'hash-secret'), + expect.any(Date), + ); + }); + }); + + describe('email verification', () => { + it('does not reveal whether an email is absent or disabled', async () => { + const absent = createHarness(); + const disabled = createHarness({ + users: { findByEmail: jest.fn().mockResolvedValue({ isDisabled: true }) }, + }); + + await expect(absent.service.sendEmailVerification({ email: 'NONE@EXAMPLE.COM' })).resolves.toEqual( + { message: 'If this email exists, a verification code was sent' }, + ); + await expect(disabled.service.sendEmailVerification({ email: 'user@example.com' })).resolves.toEqual( + { message: 'If this email exists, a verification code was sent' }, + ); + expect(absent.repository.createEmailVerificationCode).not.toHaveBeenCalled(); + }); + + it('short-circuits an already verified account', async () => { + const { service, repository } = createHarness({ + users: { findByEmail: jest.fn().mockResolvedValue({ isEmailVerified: true }) }, + }); + + await expect(service.sendEmailVerification({ email: 'user@example.com' })).resolves.toEqual({ + message: 'Email is already verified', + }); + expect(repository.createEmailVerificationCode).not.toHaveBeenCalled(); + }); + + it('invalidates a code at the attempt limit', async () => { + const { service, repository } = createHarness({ + users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) }, + repository: { + findLatestActiveEmailVerificationCode: jest.fn().mockResolvedValue({ + id: 'code-1', + attempts: 3, + }), + }, + }); + + await expect( + service.verifyEmail({ email: 'user@example.com', code: '123456' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(repository.markEmailVerificationCodeUsed).toHaveBeenCalledWith('code-1'); + }); + + it('increments a wrong-code attempt and consumes the final attempt', async () => { + const codeHash = await hashValue('123456', 4); + const { service, repository } = createHarness({ + users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) }, + repository: { + findLatestActiveEmailVerificationCode: jest.fn().mockResolvedValue({ + id: 'code-1', + codeHash, + attempts: 2, + }), + }, + }); + + await expect( + service.verifyEmail({ email: 'user@example.com', code: '654321' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(repository.incrementEmailVerificationAttempts).toHaveBeenCalledWith('code-1'); + expect(repository.markEmailVerificationCodeUsed).toHaveBeenCalledWith('code-1'); + }); + + it('atomically verifies the user and invalidates every outstanding code', async () => { + const codeHash = await hashValue('123456', 4); + const { service, users, repository } = createHarness({ + users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) }, + repository: { + findLatestActiveEmailVerificationCode: jest.fn().mockResolvedValue({ + id: 'code-1', + codeHash, + attempts: 0, + }), + }, + }); + + await expect( + service.verifyEmail({ email: 'USER@EXAMPLE.COM', code: '123456' }), + ).resolves.toEqual({ message: 'Email verified successfully' }); + expect(users.markEmailVerified).toHaveBeenCalledWith('user-1'); + expect(repository.markAllEmailVerificationCodesUsedByUser).toHaveBeenCalledWith('user-1'); + }); + }); + + describe('refresh-token rotation', () => { + it('rejects access tokens passed as refresh tokens before repository lookup', async () => { + const { service, repository } = createHarness({ + jwt: { verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'access' }) }, + }); + + await expect(service.refresh({ refreshToken: 'token' })).rejects.toBeInstanceOf( + UnauthorizedException, + ); + expect(repository.findActiveUserTokenByJti).not.toHaveBeenCalled(); + }); + + it.each([ + ['missing record', null, true], + ['hash mismatch', { tokenHash: hashHighEntropyValue('other-token', 'hash-secret') }, true], + ])('revokes all sessions on %s', async (_name, tokenRecord, consume) => { + const { service, repository } = createHarness({ + jwt: { + verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'refresh', jti: 'jti-1' }), + }, + repository: { + findActiveUserTokenByJti: jest.fn().mockResolvedValue(tokenRecord), + consumeUserTokenByJti: jest.fn().mockResolvedValue(consume), + }, + }); + + await expect(service.refresh({ refreshToken: 'refresh-token' })).rejects.toBeInstanceOf( + UnauthorizedException, + ); + expect(repository.markCompromisedAndRevokeAll).toHaveBeenCalledWith('user-1'); + }); + + it('detects a concurrent double-use when token consumption loses the race', async () => { + const raw = 'refresh-token'; + const { service, repository } = createHarness({ + jwt: { + verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'refresh', jti: 'jti-1' }), + }, + repository: { + findActiveUserTokenByJti: jest.fn().mockResolvedValue({ + tokenHash: hashHighEntropyValue(raw, 'hash-secret'), + }), + consumeUserTokenByJti: jest.fn().mockResolvedValue(false), + }, + }); + + await expect(service.refresh({ refreshToken: raw })).rejects.toBeInstanceOf( + UnauthorizedException, + ); + expect(repository.markCompromisedAndRevokeAll).toHaveBeenCalled(); + }); + + it('blocks rotation for a disabled user', async () => { + const raw = 'refresh-token'; + const { service, repository } = createHarness({ + jwt: { + verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'refresh', jti: 'jti-1' }), + }, + users: { findByIdOrFail: jest.fn().mockResolvedValue({ isDisabled: true }) }, + repository: { + findActiveUserTokenByJti: jest.fn().mockResolvedValue({ + tokenHash: hashHighEntropyValue(raw, 'hash-secret'), + }), + }, + }); + + await expect(service.refresh({ refreshToken: raw })).rejects.toBeInstanceOf( + ForbiddenException, + ); + expect(repository.consumeUserTokenByJti).not.toHaveBeenCalled(); + }); + + it('consumes the old token before issuing a new pair', async () => { + const raw = 'refresh-token'; + const { service, repository } = createHarness({ + jwt: { + verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'refresh', jti: 'jti-1' }), + }, + repository: { + findActiveUserTokenByJti: jest.fn().mockResolvedValue({ + tokenHash: hashHighEntropyValue(raw, 'hash-secret'), + }), + }, + }); + + await expect(service.refresh({ refreshToken: raw })).resolves.toMatchObject({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + }); + expect(repository.consumeUserTokenByJti).toHaveBeenCalledWith('user-1', 'jti-1'); + }); + + it('revokes and cleans sessions at logout but maps invalid tokens to 400', async () => { + const valid = createHarness({ jwt: { verify: jest.fn().mockReturnValue({ sub: 'user-1' }) } }); + await expect(valid.service.logout({ refreshToken: 'valid' })).resolves.toBeUndefined(); + expect(valid.repository.revokeAllUserTokens).toHaveBeenCalledWith('user-1'); + + const invalid = createHarness({ jwt: { verify: jest.fn(() => { throw new Error('bad'); }) } }); + await expect(invalid.service.logout({ refreshToken: 'bad' })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + }); + + describe('Google and superadmin authentication', () => { + it('creates and links a new Google user while defaulting a missing avatar', async () => { + const { service, users } = createHarness({ + users: { + create: jest.fn().mockResolvedValue({ + id: 'user-1', + username: 'new', + role: 'user', + isDisabled: false, + }), + }, + }); + + const result = await service.loginWithGoogle({ + googleId: 'google-new', + email: 'new@example.com', + name: 'New User', + }); + + expect(users.create).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'new@example.com', + avatar: '', + isEmailVerified: true, + }), + ); + expect(users.linkGoogleAccount).toHaveBeenCalled(); + expect(result.accessToken).toBe('new-access-token'); + }); + + it('rejects disabled Google-linked accounts', async () => { + const { service } = createHarness({ + users: { + findByGoogleId: jest.fn().mockResolvedValue({ + id: 'user-1', + googleId: 'google-1', + isDisabled: true, + }), + }, + }); + + await expect( + service.loginWithGoogle({ + googleId: 'google-1', + email: 'user@example.com', + name: 'User', + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rejects Google ID login when the client or verified claims are missing', async () => { + const missingClient = createHarness({ config: { 'google.clientId': ' ' } }); + await expect( + missingClient.service.loginWithGoogleIdToken({ idToken: 'token' }), + ).rejects.toBeInstanceOf(BadRequestException); + + const invalidClaims = createHarness(); + (invalidClaims.service as any).googleOAuthClient = { + verifyIdToken: jest.fn().mockResolvedValue({ + getPayload: () => ({ sub: 'google-1', email: 'user@example.com', email_verified: false }), + }), + }; + await expect( + invalidClaims.service.loginWithGoogleIdToken({ idToken: 'token' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('rejects wrong superadmin credentials and requires OTP when configured', async () => { + const wrong = createHarness(); + await expect( + wrong.service.superAdminLogin({ email: 'root@example.com', password: 'wrong' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + + const otpRequired = createHarness({ config: { 'superAdmin.totpSecret': 'JBSWY3DPEHPK3PXP' } }); + await expect( + otpRequired.service.superAdminLogin({ + email: 'root@example.com', + password: 'RootPass123!', + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('issues dedicated superadmin tokens and persists only the refresh hash', async () => { + const { service, repository, jwt } = createHarness(); + + const result = await service.superAdminLogin({ + email: 'ROOT@example.com', + password: 'RootPass123!', + }); + + expect(result.superAdmin.email).toBe('root@example.com'); + expect(jwt.signAsync).toHaveBeenCalledWith( + expect.objectContaining({ tokenType: 'superadmin_access', permissions: expect.any(Array) }), + expect.any(Object), + ); + expect(repository.createSuperAdminRefreshToken).toHaveBeenCalledWith( + 'root@example.com', + expect.any(String), + hashHighEntropyValue('new-refresh-token', 'hash-secret'), + expect.any(Date), + ); + }); + + it('detects missing, mismatched, and concurrently consumed superadmin refresh tokens', async () => { + const cases = [ + { record: null, consumed: true }, + { record: { tokenHash: hashHighEntropyValue('other', 'hash-secret') }, consumed: true }, + { record: { tokenHash: hashHighEntropyValue('admin-refresh', 'hash-secret') }, consumed: false }, + ]; + + for (const testCase of cases) { + const { service, repository } = createHarness({ + jwt: { + verify: jest.fn().mockReturnValue({ + email: 'root@example.com', + tokenType: 'superadmin_refresh', + jti: 'admin-jti', + }), + }, + repository: { + findActiveSuperAdminTokenByJti: jest.fn().mockResolvedValue(testCase.record), + consumeSuperAdminTokenByJti: jest.fn().mockResolvedValue(testCase.consumed), + }, + }); + + await expect( + service.superAdminRefresh({ refreshToken: 'admin-refresh' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(repository.revokeAllSuperAdminTokens).toHaveBeenCalledWith('root@example.com'); + } + }); + + it('rotates a valid superadmin token and supports targeted/global logout', async () => { + const raw = 'admin-refresh'; + const rotating = createHarness({ + jwt: { + verify: jest.fn().mockReturnValue({ + email: 'root@example.com', + tokenType: 'superadmin_refresh', + jti: 'admin-jti', + }), + }, + repository: { + findActiveSuperAdminTokenByJti: jest.fn().mockResolvedValue({ + tokenHash: hashHighEntropyValue(raw, 'hash-secret'), + }), + }, + }); + await expect(rotating.service.superAdminRefresh({ refreshToken: raw })).resolves.toMatchObject({ + superAdmin: { email: 'root@example.com' }, + }); + + await rotating.service.superAdminLogout({ refreshToken: raw }); + expect(rotating.repository.revokeSuperAdminTokenByJti).toHaveBeenCalledWith( + 'root@example.com', + 'admin-jti', + ); + + const global = createHarness({ + jwt: { verify: jest.fn().mockReturnValue({ email: 'root@example.com' }) }, + }); + await global.service.superAdminLogout({ refreshToken: raw }); + expect(global.repository.revokeAllSuperAdminTokens).toHaveBeenCalledWith('root@example.com'); + }); + }); + + describe('password reset and session management', () => { + it('keeps forgot-password responses indistinguishable and omits production codes', async () => { + const missing = createHarness(); + await expect(missing.service.forgotPassword({ email: 'NONE@EXAMPLE.COM' })).resolves.toEqual({ + message: 'If this email exists, a reset code was sent', + }); + + const production = createHarness({ + config: { nodeEnv: 'production' }, + users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) }, + }); + const result = await production.service.forgotPassword({ email: 'USER@EXAMPLE.COM' }); + expect(result.debugCode).toBeUndefined(); + expect(production.repository.invalidateActivePasswordResetCodes).toHaveBeenCalledWith('user-1'); + expect(production.email.sendPasswordResetCode).toHaveBeenCalledWith( + 'user@example.com', + expect.stringMatching(/^\d{6}$/), + 10, + ); + }); + + it('locks a reset code that already reached or reaches the attempt limit', async () => { + for (const attempts of [3, 2]) { + const codeHash = await hashValue('123456', 4); + const { service, repository } = createHarness({ + users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) }, + repository: { + findLatestActivePasswordResetCode: jest.fn().mockResolvedValue({ + id: 'reset-1', + attempts, + codeHash, + }), + }, + }); + + await expect( + service.verifyResetCode({ email: 'user@example.com', code: '654321' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(repository.markPasswordResetCodeUsed).toHaveBeenCalledWith('reset-1'); + } + }); + + it('marks the code verified and issues a short-lived purpose-bound reset token', async () => { + const codeHash = await hashValue('123456', 4); + const { service, repository, jwt } = createHarness({ + users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) }, + repository: { + findLatestActivePasswordResetCode: jest.fn().mockResolvedValue({ + id: 'reset-1', + attempts: 0, + codeHash, + }), + }, + }); + + await expect( + service.verifyResetCode({ email: 'USER@EXAMPLE.COM', code: '123456' }), + ).resolves.toEqual({ resetToken: 'new-access-token', expiresIn: '15m' }); + expect(repository.markPasswordResetCodeVerified).toHaveBeenCalledWith('reset-1'); + expect(jwt.signAsync).toHaveBeenCalledWith( + { sub: 'user-1', tokenType: 'password_reset', prcId: 'reset-1' }, + expect.objectContaining({ secret: 'password-reset-secret' }), + ); + }); + + it('rejects reset confirmation mismatch, invalid JWTs, wrong purpose and consumed codes', async () => { + const mismatch = createHarness(); + await expect( + mismatch.service.resetPassword({ + resetToken: 'token', + newPassword: 'StrongPass123!', + confirmPassword: 'DifferentPass123!', + }), + ).rejects.toBeInstanceOf(BadRequestException); + + const invalid = createHarness({ jwt: { verify: jest.fn(() => { throw new Error('expired'); }) } }); + await expect( + invalid.service.resetPassword({ + resetToken: 'token', + newPassword: 'StrongPass123!', + confirmPassword: 'StrongPass123!', + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + + const wrongPurpose = createHarness({ + jwt: { verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'access' }) }, + }); + await expect( + wrongPurpose.service.resetPassword({ + resetToken: 'token', + newPassword: 'StrongPass123!', + confirmPassword: 'StrongPass123!', + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + + const consumed = createHarness({ + jwt: { + verify: jest.fn().mockReturnValue({ + sub: 'user-1', + tokenType: 'password_reset', + prcId: 'reset-1', + }), + }, + }); + await expect( + consumed.service.resetPassword({ + resetToken: 'token', + newPassword: 'StrongPass123!', + confirmPassword: 'StrongPass123!', + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('consumes a reset code exactly once then revokes all existing sessions', async () => { + const { service, users, repository } = createHarness({ + jwt: { + verify: jest.fn().mockReturnValue({ + sub: 'user-1', + tokenType: 'password_reset', + prcId: 'reset-1', + }), + }, + repository: { + consumeValidVerifiedPasswordResetCode: jest.fn().mockResolvedValue({ id: 'reset-1' }), + }, + }); + + await expect( + service.resetPassword({ + resetToken: 'token', + newPassword: 'StrongPass123!', + confirmPassword: 'StrongPass123!', + }), + ).resolves.toEqual({ message: 'Password reset successfully' }); + expect(users.updatePassword).toHaveBeenCalledWith('user-1', expect.any(String)); + expect(repository.revokeAllUserTokens).toHaveBeenCalledWith('user-1'); + }); + + it('lists and revokes sessions without exposing token hashes', async () => { + const createdAt = new Date(); + const expiresAt = future(); + const { service, repository } = createHarness({ + repository: { + listUserSessions: jest.fn().mockResolvedValue([ + { jti: 'jti-1', tokenHash: 'secret', createdAt, expiresAt }, + ]), + listSuperAdminSessions: jest.fn().mockResolvedValue([ + { id: 'session-1', jti: 'jti-2', tokenHash: 'secret', createdAt, expiresAt }, + ]), + }, + }); + + expect(await service.listUserSessions('user-1')).toEqual({ + items: [{ jti: 'jti-1', createdAt, expiresAt }], + }); + expect(await service.listSuperAdminSessions('root@example.com')).toEqual({ + items: [{ id: 'session-1', jti: 'jti-2', createdAt, expiresAt }], + }); + await service.revokeUserSession('user-1', 'jti-1'); + expect(repository.revokeUserTokenByJti).toHaveBeenCalledWith('user-1', 'jti-1'); + }); + + it('fails closed when a requested superadmin session no longer exists', async () => { + const { service } = createHarness({ + repository: { revokeSuperAdminSessionById: jest.fn().mockResolvedValue(false) }, + }); + + await expect( + service.revokeSuperAdminSession('root@example.com', 'missing'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('signup draft hardening', () => { + it('rejects consumed, mismatched and expired drafts', async () => { + const invalidDrafts = [ + { consumed: true, email: 'user@example.com', expiresAt: future(), codeExpiresAt: future() }, + { consumed: false, email: 'other@example.com', expiresAt: future(), codeExpiresAt: future() }, + ]; + for (const draft of invalidDrafts) { + const { service } = createHarness({ + repository: { findSignupDraftWithSecrets: jest.fn().mockResolvedValue(draft) }, + }); + await expect( + service.signupVerify({ signupId: 'draft-1', email: 'user@example.com', code: '123456' }), + ).rejects.toBeInstanceOf(BadRequestException); + } + + const expired = createHarness({ + repository: { + findSignupDraftWithSecrets: jest.fn().mockResolvedValue({ + consumed: false, + email: 'user@example.com', + expiresAt: new Date(Date.now() - 1), + codeExpiresAt: future(), + }), + }, + }); + await expect( + expired.service.signupVerify({ + signupId: 'draft-1', + email: 'user@example.com', + code: '123456', + }), + ).rejects.toBeInstanceOf(GoneException); + }); + + it('returns 429 before checking a draft code at the attempt limit', async () => { + const { service } = createHarness({ + repository: { + findSignupDraftWithSecrets: jest.fn().mockResolvedValue({ + id: 'draft-1', + consumed: false, + email: 'user@example.com', + expiresAt: future(), + codeExpiresAt: future(), + attempts: 3, + }), + }, + }); + + await expect( + service.signupVerify({ signupId: 'draft-1', email: 'user@example.com', code: '123456' }), + ).rejects.toBeInstanceOf(HttpException); + }); + + it('uses bounded expiration parsing and token masking helpers', () => { + const { service } = createHarness(); + expect((service as any).parseExpiresInToMs('10s')).toBe(10_000); + expect((service as any).parseExpiresInToMs('5m')).toBe(300_000); + expect((service as any).parseExpiresInToMs('2h')).toBe(7_200_000); + expect((service as any).parseExpiresInToMs('1d')).toBe(86_400_000); + expect((service as any).parseExpiresInToMs('invalid')).toBe(2_592_000_000); + expect((service as any).clampMinutes(Number.NaN, 5, 30)).toBe(5); + expect((service as any).clampMinutes(99, 5, 30)).toBe(30); + expect((service as any).maskToken('short')).toBe('[redacted]'); + expect((service as any).maskToken('1234567890abcdefghijkl')).toBe('12345678...efghijkl'); + }); + }); +}); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index d1a783f..5122b84 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -10,7 +10,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { JwtService } from '@nestjs/jwt'; +import { JwtService, JwtSignOptions } from '@nestjs/jwt'; import { randomBytes, randomInt, randomUUID } from 'crypto'; import { OAuth2Client } from 'google-auth-library'; import { @@ -39,6 +39,7 @@ import { VerifyResetCodeDto } from './dto/verify-reset-code.dto'; import { AuthRepository } from './auth.repository'; import { AuthResult, TokenPair } from './types/token-pair.type'; import { DEFAULT_SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions'; +import { verifyTotp } from '../../common/utils/totp.util'; type UploadedImageFile = { mimetype?: string; @@ -335,7 +336,11 @@ export class AuthService { throw new ForbiddenException('Account is disabled'); } - await this.authRepository.revokeUserTokenByJti(decoded.sub, decoded.jti); + const consumed = await this.authRepository.consumeUserTokenByJti(decoded.sub, decoded.jti); + if (!consumed) { + await this.authRepository.markCompromisedAndRevokeAll(decoded.sub); + throw new UnauthorizedException('Refresh token reuse detected'); + } const authTokens = await this.generateAndStoreTokenPair( decoded.sub, safeUser.username, @@ -440,11 +445,7 @@ export class AuthService { }); payload = ticket.getPayload(); } catch (error) { - this.logger.warn( - `Google id token verification failed: ${this.getErrorMessage(error)}; token=${this.maskToken( - dto.idToken, - )}`, - ); + this.logger.warn(`Google id token verification failed: ${this.getErrorMessage(error)}`); throw new UnauthorizedException('Invalid Google id token'); } @@ -467,16 +468,24 @@ export class AuthService { }> { const configuredEmail = this.configService.get('superAdmin.email', { infer: true }); const configuredPassword = this.configService.get('superAdmin.password', { infer: true }); + const configuredPasswordHash = this.configService.get('superAdmin.passwordHash', { infer: true }); + const totpSecret = this.configService.get('superAdmin.totpSecret', { infer: true }) ?? ''; + const passwordMatches = configuredPasswordHash + ? await compareHash(dto.password, configuredPasswordHash) + : !!configuredPassword && dto.password === configuredPassword; if ( !configuredEmail || - !configuredPassword || dto.email.toLowerCase() !== configuredEmail.toLowerCase() || - dto.password !== configuredPassword + !passwordMatches ) { throw new UnauthorizedException('Invalid superadmin credentials'); } + if (totpSecret && (!dto.otpCode || !verifyTotp(totpSecret, dto.otpCode))) { + throw new UnauthorizedException('Invalid superadmin verification code'); + } + const tokens = await this.generateAndStoreSuperAdminTokenPair(configuredEmail); return { ...tokens, superAdmin: { email: configuredEmail } }; } @@ -516,7 +525,11 @@ export class AuthService { throw new UnauthorizedException('Superadmin refresh token reuse detected'); } - await this.authRepository.revokeSuperAdminTokenByJti(decoded.email, decoded.jti); + const consumed = await this.authRepository.consumeSuperAdminTokenByJti(decoded.email, decoded.jti); + if (!consumed) { + await this.authRepository.revokeAllSuperAdminTokens(decoded.email); + throw new UnauthorizedException('Superadmin refresh token reuse detected'); + } const tokens = await this.generateAndStoreSuperAdminTokenPair(decoded.email); return { ...tokens, superAdmin: { email: decoded.email } }; } @@ -643,7 +656,7 @@ export class AuthService { { sub: user.id, tokenType: 'password_reset', prcId: codeRecord.id }, { secret: this.configService.get('passwordReset.tokenSecret', { infer: true }), - expiresIn: resetTokenExpiresIn, + expiresIn: resetTokenExpiresIn as JwtSignOptions['expiresIn'], }, ); @@ -671,7 +684,7 @@ export class AuthService { throw new UnauthorizedException('Invalid or expired reset token'); } - const codeRecord = await this.authRepository.findValidVerifiedPasswordResetCode( + const codeRecord = await this.authRepository.consumeValidVerifiedPasswordResetCode( decoded.prcId, decoded.sub, ); @@ -688,7 +701,6 @@ export class AuthService { const passwordHash = await hashValue(dto.newPassword, saltRounds); await this.usersService.updatePassword(decoded.sub, passwordHash); - await this.authRepository.markPasswordResetCodeUsed(codeRecord.id); await this.authRepository.markPasswordResetCodeUsedByUser(decoded.sub); await this.authRepository.revokeAllUserTokens(decoded.sub); await this.authRepository.removeExpiredAndRevoked(decoded.sub); @@ -707,14 +719,14 @@ export class AuthService { { sub: userId, username, role, tokenType: 'access' }, { secret: this.configService.get('jwt.accessSecret', { infer: true }), - expiresIn: this.configService.get('jwt.accessExpiresIn', { infer: true }), + expiresIn: this.configService.get('jwt.accessExpiresIn', { infer: true }) as JwtSignOptions['expiresIn'], }, ), this.jwtService.signAsync( { sub: userId, username, role, tokenType: 'refresh', jti: refreshJti }, { secret: this.configService.get('jwt.refreshSecret', { infer: true }), - expiresIn: this.configService.get('jwt.refreshExpiresIn', { infer: true }), + expiresIn: this.configService.get('jwt.refreshExpiresIn', { infer: true }) as JwtSignOptions['expiresIn'], }, ), ]); @@ -746,7 +758,7 @@ export class AuthService { }, { secret: this.configService.get('superAdmin.accessSecret', { infer: true }), - expiresIn: this.configService.get('superAdmin.accessExpiresIn', { infer: true }), + expiresIn: this.configService.get('superAdmin.accessExpiresIn', { infer: true }) as JwtSignOptions['expiresIn'], }, ), this.jwtService.signAsync( @@ -760,7 +772,7 @@ export class AuthService { }, { secret: this.configService.get('superAdmin.refreshSecret', { infer: true }), - expiresIn: this.configService.get('superAdmin.refreshExpiresIn', { infer: true }), + expiresIn: this.configService.get('superAdmin.refreshExpiresIn', { infer: true }) as JwtSignOptions['expiresIn'], }, ), ]); diff --git a/src/modules/auth/dto/super-admin-login.dto.ts b/src/modules/auth/dto/super-admin-login.dto.ts index ebb7ba5..3d5634f 100644 --- a/src/modules/auth/dto/super-admin-login.dto.ts +++ b/src/modules/auth/dto/super-admin-login.dto.ts @@ -1,5 +1,5 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsString, Length } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEmail, IsOptional, IsString, Length, Matches } from 'class-validator'; export class SuperAdminLoginDto { @ApiProperty({ example: 'admin@oudelaa.com' }) @@ -10,4 +10,10 @@ export class SuperAdminLoginDto { @IsString() @Length(8, 128) password!: string; + + @ApiPropertyOptional({ description: 'TOTP code; required when SUPERADMIN_TOTP_SECRET is set' }) + @IsOptional() + @IsString() + @Matches(/^\d{6}$/) + otpCode?: string; } diff --git a/src/modules/auth/schemas/refresh-token.schema.ts b/src/modules/auth/schemas/refresh-token.schema.ts index b83601c..0af8cfb 100644 --- a/src/modules/auth/schemas/refresh-token.schema.ts +++ b/src/modules/auth/schemas/refresh-token.schema.ts @@ -28,5 +28,5 @@ export class RefreshToken { export const RefreshTokenSchema = SchemaFactory.createForClass(RefreshToken); RefreshTokenSchema.index({ userId: 1, revoked: 1 }); -RefreshTokenSchema.index({ userId: 1, jti: 1 }); +RefreshTokenSchema.index({ userId: 1, jti: 1 }, { unique: true }); RefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); diff --git a/src/modules/auth/strategies/auth.strategies.spec.ts b/src/modules/auth/strategies/auth.strategies.spec.ts new file mode 100644 index 0000000..77e88b2 --- /dev/null +++ b/src/modules/auth/strategies/auth.strategies.spec.ts @@ -0,0 +1,120 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { GoogleStrategy } from './google.strategy'; +import { JwtRefreshStrategy } from './jwt-refresh.strategy'; +import { JwtStrategy } from './jwt.strategy'; +import { SuperAdminJwtStrategy } from './super-admin-jwt.strategy'; + +const config = (values: Record = {}) => ({ + get: jest.fn((key: string) => values[key] ?? 'test-secret'), +}); + +describe('authentication strategies', () => { + describe('JwtStrategy', () => { + it('accepts access tokens only for active users', async () => { + const repository = { findById: jest.fn().mockResolvedValue({ isDisabled: false }) }; + const strategy = new JwtStrategy( + config({ 'jwt.accessSecret': 'access-secret' }) as any, + repository as any, + ); + const payload = { sub: 'user-1', tokenType: 'access' } as any; + + await expect(strategy.validate(payload)).resolves.toBe(payload); + expect(repository.findById).toHaveBeenCalledWith('user-1'); + }); + + it('rejects wrong token purpose before database access', async () => { + const repository = { findById: jest.fn() }; + const strategy = new JwtStrategy(config() as any, repository as any); + + await expect( + strategy.validate({ sub: 'user-1', tokenType: 'refresh' } as any), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(repository.findById).not.toHaveBeenCalled(); + }); + + it.each([null, { isDisabled: true }])('rejects unavailable account %#', async (user) => { + const strategy = new JwtStrategy( + config() as any, + { findById: jest.fn().mockResolvedValue(user) } as any, + ); + + await expect( + strategy.validate({ sub: 'user-1', tokenType: 'access' } as any), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + }); + + describe('purpose-bound stateless strategies', () => { + it('accepts only user refresh tokens in JwtRefreshStrategy', () => { + const strategy = new JwtRefreshStrategy(config() as any); + const payload = { sub: 'user-1', tokenType: 'refresh' } as any; + + expect(strategy.validate(payload)).toBe(payload); + expect(() => strategy.validate({ tokenType: 'access' } as any)).toThrow( + UnauthorizedException, + ); + }); + + it('accepts only superadmin access tokens', () => { + const strategy = new SuperAdminJwtStrategy(config() as any); + const payload = { sub: 'superadmin', tokenType: 'superadmin_access' } as any; + + expect(strategy.validate(payload)).toBe(payload); + expect(() => strategy.validate({ tokenType: 'access' } as any)).toThrow( + UnauthorizedException, + ); + }); + }); + + describe('GoogleStrategy', () => { + it('normalizes profile data before authentication', () => { + const strategy = new GoogleStrategy(config() as any); + const done = jest.fn(); + + strategy.validate( + 'access', + 'refresh', + { + id: 'google-1', + displayName: 'Google Artist', + emails: [{ value: 'ARTIST@EXAMPLE.COM', verified: true }], + photos: [{ value: 'https://example.com/avatar.jpg' }], + } as any, + done, + ); + + expect(done).toHaveBeenCalledWith(null, { + googleId: 'google-1', + email: 'artist@example.com', + name: 'Google Artist', + avatar: 'https://example.com/avatar.jpg', + }); + }); + + it('rejects profiles without an email', () => { + const strategy = new GoogleStrategy(config() as any); + const done = jest.fn(); + + strategy.validate('access', 'refresh', { id: 'google-1' } as any, done); + + expect(done).toHaveBeenCalledWith(expect.any(UnauthorizedException)); + }); + + it('provides a safe fallback display name', () => { + const strategy = new GoogleStrategy(config() as any); + const done = jest.fn(); + + strategy.validate( + 'access', + 'refresh', + { id: 'google-1', emails: [{ value: 'artist@example.com' }] } as any, + done, + ); + + expect(done).toHaveBeenCalledWith( + null, + expect.objectContaining({ name: 'Google User', avatar: undefined }), + ); + }); + }); +}); diff --git a/src/modules/auth/strategies/jwt-refresh.strategy.ts b/src/modules/auth/strategies/jwt-refresh.strategy.ts index 176d3bf..b01aa2c 100644 --- a/src/modules/auth/strategies/jwt-refresh.strategy.ts +++ b/src/modules/auth/strategies/jwt-refresh.strategy.ts @@ -1,7 +1,6 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; -import { Request } from 'express'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface'; @@ -11,12 +10,11 @@ export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh' super({ jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'), ignoreExpiration: false, - secretOrKey: configService.get('jwt.refreshSecret', { infer: true }), - passReqToCallback: true, + secretOrKey: configService.get('jwt.refreshSecret', { infer: true }) ?? '', }); } - validate(_: Request, payload: JwtPayload): JwtPayload { + validate(payload: JwtPayload): JwtPayload { if (payload.tokenType !== 'refresh') { throw new UnauthorizedException('Invalid token type'); } diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts index 100d9bf..d85a863 100644 --- a/src/modules/auth/strategies/jwt.strategy.ts +++ b/src/modules/auth/strategies/jwt.strategy.ts @@ -3,22 +3,27 @@ import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface'; +import { UsersRepository } from '../../users/users.repository'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { - constructor(configService: ConfigService) { + constructor(configService: ConfigService, private readonly usersRepository: UsersRepository) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: configService.get('jwt.accessSecret', { infer: true }), + secretOrKey: configService.get('jwt.accessSecret', { infer: true }) ?? '', }); } - validate(payload: JwtPayload): JwtPayload { + async validate(payload: JwtPayload): Promise { if (payload.tokenType !== 'access') { throw new UnauthorizedException('Invalid token type'); } + const user = await this.usersRepository.findById(payload.sub); + if (!user || user.isDisabled) { + throw new UnauthorizedException('Account is unavailable'); + } return payload; } } diff --git a/src/modules/auth/strategies/super-admin-jwt.strategy.ts b/src/modules/auth/strategies/super-admin-jwt.strategy.ts index 090686b..b25d4b3 100644 --- a/src/modules/auth/strategies/super-admin-jwt.strategy.ts +++ b/src/modules/auth/strategies/super-admin-jwt.strategy.ts @@ -10,7 +10,7 @@ export class SuperAdminJwtStrategy extends PassportStrategy(Strategy, 'superadmi super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: configService.get('superAdmin.accessSecret', { infer: true }), + secretOrKey: configService.get('superAdmin.accessSecret', { infer: true }) ?? '', }); } diff --git a/src/modules/chat/chat-realtime.service.spec.ts b/src/modules/chat/chat-realtime.service.spec.ts new file mode 100644 index 0000000..9a0ffa9 --- /dev/null +++ b/src/modules/chat/chat-realtime.service.spec.ts @@ -0,0 +1,36 @@ +import { Server } from 'socket.io'; +import { ChatRealtimeService } from './chat-realtime.service'; + +describe('ChatRealtimeService', () => { + it('silently skips delivery until a socket server is bound', () => { + const service = new ChatRealtimeService(); + + expect(() => service.emitNewMessage('conversation-1', { id: 'message-1' })).not.toThrow(); + }); + + it('emits a new message only to the matching conversation room', () => { + const service = new ChatRealtimeService(); + const emit = jest.fn(); + const to = jest.fn().mockReturnValue({ emit }); + const server = { to } as unknown as Server; + const message = { id: 'message-1', body: 'hello' }; + + service.bindServer(server); + service.emitNewMessage('conversation-42', message); + + expect(to).toHaveBeenCalledWith('conversation:conversation-42'); + expect(emit).toHaveBeenCalledWith('new_message', message); + }); + + it('does not hide socket delivery errors', () => { + const service = new ChatRealtimeService(); + const server = { + to: jest.fn(() => { + throw new Error('socket unavailable'); + }), + } as unknown as Server; + service.bindServer(server); + + expect(() => service.emitNewMessage('conversation-1', {})).toThrow('socket unavailable'); + }); +}); diff --git a/src/modules/chat/chat.controller.ts b/src/modules/chat/chat.controller.ts index ace5742..5e43e52 100644 --- a/src/modules/chat/chat.controller.ts +++ b/src/modules/chat/chat.controller.ts @@ -13,6 +13,7 @@ import { UseInterceptors, } from '@nestjs/common'; import { FileFieldsInterceptor } from '@nestjs/platform-express'; +import { MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { Throttle } from '../../common/decorators/throttle.decorator'; @@ -73,7 +74,11 @@ export class ChatController { @Post('messages/upload') @Throttle(60, 60_000) - @UseInterceptors(FileFieldsInterceptor([{ name: 'mediaFile', maxCount: 1 }])) + @UseInterceptors( + FileFieldsInterceptor([{ name: 'mediaFile', maxCount: 1 }], { + limits: { fileSize: MEDIA_MAX_SIZE_BYTES.postsVideo, files: 1 }, + }), + ) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { diff --git a/src/modules/chat/chat.gateway.ts b/src/modules/chat/chat.gateway.ts index dfda3eb..09a6e2d 100644 --- a/src/modules/chat/chat.gateway.ts +++ b/src/modules/chat/chat.gateway.ts @@ -24,7 +24,16 @@ import { UsersService } from '../users/users.service'; type SocketWithUser = Socket & { data: { userId?: string } }; -@WebSocketGateway({ cors: { origin: '*' }, namespace: 'chat' }) +@WebSocketGateway({ + cors: { + origin: + process.env.NODE_ENV === 'production' + ? (process.env.CORS_ORIGINS ?? '').split(',').map((value) => value.trim()).filter(Boolean) + : true, + credentials: true, + }, + namespace: 'chat', +}) export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server!: Server; @@ -59,6 +68,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa client.disconnect(true); return; } + const user = await this.usersService.findByIdOrFail(payload.sub); + if (user.isDisabled) { + client.disconnect(true); + return; + } client.data.userId = payload.sub; const connectionCount = this.incrementUserConnection(payload.sub); if (connectionCount === 1) { diff --git a/src/modules/chat/chat.service.spec.ts b/src/modules/chat/chat.service.spec.ts index 4ae3b1f..b03c1dc 100644 --- a/src/modules/chat/chat.service.spec.ts +++ b/src/modules/chat/chat.service.spec.ts @@ -1,4 +1,6 @@ import { Types } from 'mongoose'; +import { ReactionType } from '../../common/enums/reaction-type.enum'; +import { SortOrder } from '../../common/enums/sort-order.enum'; import { ChatService } from './chat.service'; describe('ChatService realtime message broadcasting', () => { @@ -7,6 +9,7 @@ describe('ChatService realtime message broadcasting', () => { const conversationId = new Types.ObjectId().toString(); let chatRepository: Record; + let usersRepository: Record; let notificationsService: Record; let storageService: Record; let chatRealtimeService: Record; @@ -28,6 +31,22 @@ describe('ChatService realtime message broadcasting', () => { clearConversationUnreadForUser: jest.fn().mockResolvedValue(undefined), markMessageDelivered: jest.fn().mockResolvedValue(undefined), hideConversationForUser: jest.fn().mockResolvedValue(conversation), + findDirectConversation: jest.fn().mockResolvedValue(null), + createConversation: jest.fn(), + findConversationsForUser: jest.fn().mockResolvedValue([]), + countConversationsForUser: jest.fn().mockResolvedValue(0), + findMessages: jest.fn().mockResolvedValue([]), + countMessages: jest.fn().mockResolvedValue(0), + unsendMessage: jest.fn(), + setMessageReaction: jest.fn(), + deleteMessageForUser: jest.fn().mockResolvedValue(undefined), + createBlock: jest.fn().mockResolvedValue(undefined), + removeBlock: jest.fn().mockResolvedValue(undefined), + findBlock: jest.fn().mockResolvedValue(null), + findBlocksByBlocker: jest.fn().mockResolvedValue([]), + }; + usersRepository = { + findById: jest.fn().mockResolvedValue({ isDisabled: false }), }; notificationsService = { createMessageNotification: jest.fn().mockResolvedValue(null), @@ -42,7 +61,7 @@ describe('ChatService realtime message broadcasting', () => { service = new ChatService( chatRepository as any, - {} as any, + usersRepository as any, notificationsService as any, storageService as any, chatRealtimeService as any, @@ -197,4 +216,408 @@ describe('ChatService realtime message broadcasting', () => { ); expect(chatRepository.hideConversationForUser).not.toHaveBeenCalled(); }); + + describe('conversation lifecycle', () => { + it('requires another participant and validates every participant id', async () => { + await expect( + service.createConversation(senderId, { participantIds: [senderId] }), + ).rejects.toThrow('Conversation must include at least 2 participants'); + + await expect( + service.createConversation(senderId, { participantIds: ['not-an-object-id'] }), + ).rejects.toThrow('Invalid participant id'); + expect(usersRepository.findById).not.toHaveBeenCalled(); + }); + + it('rejects disabled participants and an invalid direct-conversation shape', async () => { + usersRepository.findById.mockResolvedValueOnce({ isDisabled: false }).mockResolvedValueOnce({ + isDisabled: true, + }); + + await expect( + service.createConversation(senderId, { participantIds: [recipientId] }), + ).rejects.toThrow('One or more participants are invalid or disabled'); + + const thirdId = new Types.ObjectId().toString(); + await expect( + service.createConversation(senderId, { + participantIds: [recipientId, thirdId], + isGroup: false, + }), + ).rejects.toThrow('Direct conversation must contain exactly 2 participants'); + }); + + it('returns an existing direct conversation and enforces chat blocks', async () => { + const existing = { id: conversationId }; + chatRepository.findDirectConversation.mockResolvedValue(existing); + + await expect( + service.createConversation(senderId, { participantIds: [recipientId] }), + ).resolves.toBe(existing); + expect(chatRepository.createConversation).not.toHaveBeenCalled(); + + chatRepository.findAnyBlockBetween.mockResolvedValue({ id: 'block-1' }); + await expect( + service.createConversation(senderId, { participantIds: [recipientId] }), + ).rejects.toThrow('You cannot start chat with this user'); + }); + + it('creates a deduplicated group conversation', async () => { + const thirdId = new Types.ObjectId().toString(); + const created = { id: conversationId, isGroup: true }; + chatRepository.createConversation.mockResolvedValue(created); + + await expect( + service.createConversation(senderId, { + participantIds: [recipientId, recipientId, thirdId], + title: 'Band', + }), + ).resolves.toBe(created); + expect(chatRepository.createConversation).toHaveBeenCalledWith({ + participantIds: [senderId, recipientId, thirdId], + isGroup: true, + title: 'Band', + createdBy: senderId, + }); + }); + + it('maps unread counters and cursor metadata in the conversation list', async () => { + const conversations = [ + { + lastMessageAt: undefined, + toObject: () => ({ id: 'c1', unreadCountByUser: new Map([[senderId, '2']]) }), + }, + { + lastMessageAt: new Date('2025-01-01T00:00:00.000Z'), + toObject: () => ({ + id: 'c2', + unreadCountByUser: { [senderId]: 3, $__internal: 9, $isMongooseMap: true }, + }), + }, + { + lastMessageAt: undefined, + toObject: () => ({ id: 'c3', unreadCountByUser: 'invalid' }), + }, + ]; + chatRepository.findConversationsForUser.mockResolvedValue(conversations); + chatRepository.countConversationsForUser.mockResolvedValue(5); + + const result = await service.getMyConversations(senderId, { + page: 1, + limit: 3, + sortOrder: SortOrder.ASC, + }); + + expect(chatRepository.findConversationsForUser).toHaveBeenCalledWith(senderId, 0, 3, { + lastMessageAt: 1, + updatedAt: 1, + }); + expect(result.items[0]).toMatchObject({ unreadCount: 2, lastMessageAt: null }); + expect(result.items[1].unreadCountByUser).toEqual({ [senderId]: 3 }); + expect(result.items[2].unreadCountByUser).toEqual({}); + expect(result.nextCursor).toEqual(expect.any(String)); + }); + + it('lists messages using a cursor and clears unread state', async () => { + const messages = [{ id: 'm1' }]; + chatRepository.findMessages.mockResolvedValue(messages); + chatRepository.countMessages.mockResolvedValue(4); + const cursor = Buffer.from('2', 'utf8').toString('base64url'); + + const result = await service.getMessages(senderId, conversationId, { + page: 9, + limit: 1, + cursor, + sortOrder: SortOrder.ASC, + }); + + expect(chatRepository.findMessages).toHaveBeenCalledWith( + conversationId, + senderId, + 2, + 1, + { createdAt: 1 }, + ); + expect(chatRepository.clearConversationUnreadForUser).toHaveBeenCalledWith( + conversationId, + senderId, + ); + expect(result.items).toEqual(messages); + expect(result.nextCursor).toEqual(expect.any(String)); + }); + + it('requires a conversation id and reports a failed hide update', async () => { + await expect(service.getMessages(senderId, ' ', {})).rejects.toThrow( + 'conversationId is required', + ); + + chatRepository.hideConversationForUser.mockResolvedValue(null); + await expect(service.hideConversationForMe(senderId, conversationId)).rejects.toThrow( + 'Conversation not found', + ); + }); + }); + + describe('message validation and mutations', () => { + it('requires text content and a URL for non-text messages', async () => { + await expect( + service.sendMessage(senderId, { conversationId, content: ' ', messageType: 'text' }), + ).rejects.toThrow('Text message content is required'); + await expect( + service.sendMessage(senderId, { conversationId, messageType: 'video' }), + ).rejects.toThrow('mediaUrl is required for non-text messages'); + }); + + it('prevents messaging any blocked participant', async () => { + chatRepository.findAnyBlockBetween.mockResolvedValue({ id: 'block-1' }); + + await expect( + service.sendMessage(senderId, { conversationId, content: 'hello' }), + ).rejects.toThrow('Cannot send message because one of participants is blocked'); + expect(chatRepository.createMessage).not.toHaveBeenCalled(); + }); + + it('validates reply ids and accepts replies from the same conversation', async () => { + await expect( + service.sendMessage(senderId, { + conversationId, + content: 'reply', + replyToMessageId: 'invalid', + }), + ).rejects.toThrow('Invalid replyToMessageId'); + + const replyId = new Types.ObjectId().toString(); + chatRepository.findMessageById.mockResolvedValueOnce({ + id: replyId, + conversationId: new Types.ObjectId(), + }); + await expect( + service.sendMessage(senderId, { conversationId, content: 'reply', replyToMessageId: replyId }), + ).rejects.toThrow('Reply message must belong to the same conversation'); + + const message = { + id: new Types.ObjectId().toString(), + conversationId: new Types.ObjectId(conversationId), + }; + chatRepository.findMessageById.mockResolvedValueOnce({ + id: replyId, + conversationId: new Types.ObjectId(conversationId), + }); + chatRepository.createMessage.mockResolvedValue(message); + notificationsService.createMessageNotification.mockRejectedValue(new Error('push unavailable')); + + await expect( + service.sendMessage(senderId, { conversationId, content: 'reply', replyToMessageId: replyId }), + ).resolves.toBe(message); + expect(chatRepository.createMessage).toHaveBeenCalledWith( + expect.objectContaining({ replyToMessageId: replyId }), + ); + }); + + it('requires an upload and cleans stored media when message creation fails', async () => { + await expect(service.sendMessageWithUpload(senderId, { conversationId })).rejects.toThrow( + 'mediaFile is required', + ); + + chatRepository.createMessage.mockRejectedValue(new Error('database failed')); + await expect( + service.sendMessageWithUpload( + senderId, + { conversationId }, + { + mimetype: 'image/png', + size: 10, + buffer: Buffer.from('png'), + originalname: 'photo.png', + }, + ), + ).rejects.toThrow('database failed'); + expect(storageService.deleteFile).toHaveBeenCalledWith('/uploads/chat/media/message.jpg'); + }); + + it('detects uploaded audio and rejects unsupported media', async () => { + const message = { + id: new Types.ObjectId().toString(), + conversationId: new Types.ObjectId(conversationId), + }; + storageService.saveFile.mockResolvedValue('/uploads/chat/media/message.mp3'); + chatRepository.createMessage.mockResolvedValue(message); + + await service.sendMessageWithUpload( + senderId, + { conversationId }, + { + mimetype: 'audio/mpeg', + size: 10, + buffer: Buffer.from('audio'), + originalname: 'voice.mp3', + }, + ); + expect(storageService.saveFile).toHaveBeenCalledWith( + expect.objectContaining({ extension: '.mp3', contentType: 'audio/mpeg' }), + ); + expect(chatRepository.createMessage).toHaveBeenCalledWith( + expect.objectContaining({ messageType: 'audio' }), + ); + + await expect( + service.sendMessageWithUpload( + senderId, + { conversationId }, + { + mimetype: 'application/pdf', + size: 10, + buffer: Buffer.from('pdf'), + originalname: 'file.pdf', + }, + ), + ).rejects.toThrow('mediaFile must be image, video, or audio'); + }); + + it('validates seen-message identifiers and ownership', async () => { + await expect(service.markMessageSeen(senderId, 'invalid')).rejects.toThrow( + 'Invalid message id', + ); + const messageId = new Types.ObjectId().toString(); + await expect(service.markMessageSeen(senderId, messageId, 'invalid')).rejects.toThrow( + 'Invalid conversation id', + ); + + chatRepository.findMessageById.mockResolvedValueOnce(null); + await expect(service.markMessageSeen(senderId, messageId)).rejects.toThrow( + 'Message not found', + ); + + chatRepository.findMessageById.mockResolvedValueOnce({ + id: messageId, + conversationId: new Types.ObjectId(), + }); + await expect(service.markMessageSeen(senderId, messageId, conversationId)).rejects.toThrow( + 'Message must belong to the conversation', + ); + }); + + it('validates delivered-message identifiers and conversation ownership', async () => { + await expect( + service.markMessageDelivered(recipientId, conversationId, 'invalid'), + ).rejects.toThrow('Invalid message id'); + + const messageId = new Types.ObjectId().toString(); + chatRepository.findMessageById.mockResolvedValue(null); + await expect( + service.markMessageDelivered(recipientId, conversationId, messageId), + ).rejects.toThrow('Message must belong to the conversation'); + }); + + it('unsends only an existing message owned by the current user', async () => { + const messageId = new Types.ObjectId().toString(); + chatRepository.findMessageById.mockResolvedValueOnce(null); + await expect(service.unsendMessage(senderId, messageId)).rejects.toThrow('Message not found'); + + chatRepository.findMessageById.mockResolvedValueOnce({ + id: messageId, + senderId: new Types.ObjectId(recipientId), + }); + await expect(service.unsendMessage(senderId, messageId)).rejects.toThrow( + 'You can only unsend your own messages', + ); + + chatRepository.findMessageById.mockResolvedValue({ + id: messageId, + senderId: new Types.ObjectId(senderId), + }); + chatRepository.unsendMessage.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: messageId }); + await expect(service.unsendMessage(senderId, messageId)).rejects.toThrow('Message not found'); + await expect(service.unsendMessage(senderId, messageId)).resolves.toEqual({ id: messageId }); + }); + + it('reacts to and locally deletes messages only after membership checks', async () => { + const messageId = new Types.ObjectId().toString(); + chatRepository.findMessageById.mockResolvedValueOnce(null); + await expect(service.reactToMessage(senderId, messageId, ReactionType.LOVE)).rejects.toThrow( + 'Message not found', + ); + + const message = { + id: messageId, + conversationId: new Types.ObjectId(conversationId), + }; + chatRepository.findMessageById.mockResolvedValue(message); + chatRepository.setMessageReaction.mockResolvedValueOnce(null).mockResolvedValueOnce({ + id: messageId, + reactions: [{ type: ReactionType.LOVE }], + }); + await expect(service.reactToMessage(senderId, messageId, ReactionType.LOVE)).rejects.toThrow( + 'Message not found', + ); + await expect(service.reactToMessage(senderId, messageId, ReactionType.LOVE)).resolves.toMatchObject({ + id: messageId, + }); + + await expect(service.deleteMessageForMe(senderId, messageId)).resolves.toEqual({ success: true }); + expect(chatRepository.deleteMessageForUser).toHaveBeenCalledWith(messageId, senderId); + + chatRepository.findMessageById.mockResolvedValue(null); + await expect(service.deleteMessageForMe(senderId, messageId)).rejects.toThrow( + 'Message not found', + ); + }); + }); + + describe('chat block and membership rules', () => { + it('validates conversation ids, existence, and membership', async () => { + await expect(service.assertConversationMember(senderId, 'invalid')).rejects.toThrow( + 'Invalid conversation id', + ); + chatRepository.findConversationById.mockResolvedValueOnce(null); + await expect(service.assertConversationMember(senderId, conversationId)).rejects.toThrow( + 'Conversation not found', + ); + const outsiderId = new Types.ObjectId().toString(); + await expect(service.assertConversationMember(outsiderId, conversationId)).rejects.toThrow( + 'You are not a member of this conversation', + ); + }); + + it('blocks and unblocks a valid existing target', async () => { + await expect(service.blockUser(senderId, recipientId)).resolves.toEqual({ + blocked: true, + targetUserId: recipientId, + }); + expect(chatRepository.createBlock).toHaveBeenCalledWith(senderId, recipientId); + + await expect(service.unblockUser(senderId, recipientId)).resolves.toEqual({ + blocked: false, + targetUserId: recipientId, + }); + expect(chatRepository.removeBlock).toHaveBeenCalledWith(senderId, recipientId); + }); + + it('rejects invalid, self, and missing block targets', async () => { + await expect(service.blockUser(senderId, 'invalid')).rejects.toThrow('Invalid target user id'); + await expect(service.blockUser(senderId, senderId)).rejects.toThrow('You cannot block yourself'); + + usersRepository.findById.mockResolvedValue(null); + await expect(service.blockUser(senderId, recipientId)).rejects.toThrow('Target user not found'); + await expect(service.unblockUser(senderId, 'invalid')).rejects.toThrow( + 'Invalid target user id', + ); + }); + + it('returns both block directions and the current block list', async () => { + chatRepository.findBlock.mockResolvedValueOnce({ id: 'mine' }).mockResolvedValueOnce(null); + await expect(service.getBlockStatus(senderId, recipientId)).resolves.toEqual({ + targetUserId: recipientId, + iBlocked: true, + blockedMe: false, + }); + await expect(service.getBlockStatus(senderId, 'invalid')).rejects.toThrow( + 'Invalid target user id', + ); + + const items = [{ id: 'block-1' }]; + chatRepository.findBlocksByBlocker.mockResolvedValue(items); + await expect(service.getMyBlockedUsers(senderId)).resolves.toEqual({ items }); + }); + }); }); diff --git a/src/modules/collaboration-requests/collaboration-requests.service.spec.ts b/src/modules/collaboration-requests/collaboration-requests.service.spec.ts index 13f01fa..79a0973 100644 --- a/src/modules/collaboration-requests/collaboration-requests.service.spec.ts +++ b/src/modules/collaboration-requests/collaboration-requests.service.spec.ts @@ -497,4 +497,236 @@ describe('CollaborationRequestsService', () => { await expect(service.cancel(requesterId, request.id)).rejects.toBeInstanceOf(BadRequestException); expect(request.save).not.toHaveBeenCalled(); }); + + it('validates post, owner, target, and block state for post collaboration requests', async () => { + const requesterId = new Types.ObjectId().toString(); + const postId = new Types.ObjectId().toString(); + + const invalid = createService(); + await expect(invalid.service.create(requesterId, 'invalid', {})).rejects.toThrow( + 'Invalid collaboration request', + ); + + const missingPost = createService(); + missingPost.postsRepository.findById.mockResolvedValue(null); + await expect(missingPost.service.create(requesterId, postId, {})).rejects.toThrow( + 'Post not found', + ); + + const invalidOwner = createService(); + invalidOwner.postsRepository.findById.mockResolvedValue({ authorId: {} }); + await expect(invalidOwner.service.create(requesterId, postId, {})).rejects.toThrow( + 'Post owner is invalid', + ); + + const targetId = new Types.ObjectId().toString(); + const disabled = createService(); + disabled.postsRepository.findById.mockResolvedValue({ authorId: targetId }); + disabled.usersRepository.findById.mockResolvedValue({ isDisabled: true }); + disabled.blocksRepository.findAnyBetween.mockResolvedValue(null); + await expect(disabled.service.create(requesterId, postId, {})).rejects.toThrow( + 'Target user not found', + ); + + const blocked = createService(); + blocked.postsRepository.findById.mockResolvedValue({ authorId: targetId }); + blocked.usersRepository.findById.mockResolvedValue({ isDisabled: false }); + blocked.blocksRepository.findAnyBetween.mockResolvedValue({ id: 'block-1' }); + await expect(blocked.service.create(requesterId, postId, {})).rejects.toThrow( + 'You cannot invite this user', + ); + }); + + it('reports failed upserts for post and general requests', async () => { + const requesterId = new Types.ObjectId().toString(); + const targetId = new Types.ObjectId().toString(); + const postId = new Types.ObjectId().toString(); + + const postRequest = createService(); + postRequest.postsRepository.findById.mockResolvedValue({ authorId: targetId }); + postRequest.usersRepository.findById.mockResolvedValue({ isDisabled: false }); + postRequest.blocksRepository.findAnyBetween.mockResolvedValue(null); + postRequest.model.findOne.mockReturnValue(chain(null)); + postRequest.model.findOneAndUpdate.mockReturnValue(chain(null)); + await expect(postRequest.service.create(requesterId, postId, {})).rejects.toThrow( + 'Collaboration request not found', + ); + + const generalRequest = createService(); + generalRequest.usersRepository.findById.mockResolvedValue({ isDisabled: false }); + generalRequest.blocksRepository.findAnyBetween.mockResolvedValue(null); + generalRequest.model.findOne.mockReturnValue(chain(null)); + generalRequest.model.findOneAndUpdate.mockReturnValue(chain(null)); + await expect( + generalRequest.service.createGeneral(requesterId, targetId, {}), + ).rejects.toThrow('Collaboration request not found'); + + await expect( + generalRequest.service.createGeneral(requesterId, 'invalid', {}), + ).rejects.toThrow('Invalid collaboration request'); + }); + + it('lists pending, received, and sent requests with default pagination', async () => { + const userId = new Types.ObjectId().toString(); + const request = createRequestDoc(); + const { service, model } = createService(); + model.find.mockReturnValue(chain([request])); + model.countDocuments.mockReturnValue(chain(1)); + + await service.getMine(userId, {}); + expect(model.find).toHaveBeenLastCalledWith({ + targetUserId: new Types.ObjectId(userId), + status: 'pending', + }); + + await service.listSent(userId, {}); + expect(model.find).toHaveBeenLastCalledWith({ requesterId: new Types.ObjectId(userId) }); + }); + + it('cancels a pending request and remains successful if notification delivery fails', async () => { + const requesterId = new Types.ObjectId().toString(); + const targetUserId = new Types.ObjectId().toString(); + const request = createRequestDoc({ + postId: null, + requesterId: new Types.ObjectId(requesterId), + targetUserId: new Types.ObjectId(targetUserId), + }); + const { service, model, notificationsService } = createService(); + + await expect(service.cancel(requesterId, 'invalid')).rejects.toThrow( + 'Invalid collaboration request id', + ); + model.findById.mockReturnValueOnce(chain(null)); + await expect(service.cancel(requesterId, request.id)).rejects.toThrow( + 'Collaboration request not found', + ); + + model.findById.mockReturnValue(chain(request)); + model.findOne.mockReturnValue(chain({ ...request, status: 'cancelled' })); + notificationsService.create.mockRejectedValue('push offline'); + + await expect(service.cancel(requesterId, request.id)).resolves.toMatchObject({ + cancelled: true, + request: expect.objectContaining({ status: 'cancelled' }), + }); + expect(request.status).toBe('cancelled'); + expect(request.save).toHaveBeenCalled(); + expect(notificationsService.create).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'collaboration_request_cancelled', + resourceType: 'user', + referenceId: requesterId, + }), + ); + }); + + it('rejects a pending request and protects status transitions', async () => { + const requesterId = new Types.ObjectId().toString(); + const targetUserId = new Types.ObjectId().toString(); + const request = createRequestDoc({ + requesterId: new Types.ObjectId(requesterId), + targetUserId: new Types.ObjectId(targetUserId), + }); + const { service, model, notificationsService } = createService(); + + await expect(service.reject(targetUserId, 'invalid')).rejects.toThrow( + 'Invalid collaboration request id', + ); + model.findById.mockReturnValueOnce(chain(null)); + await expect(service.reject(targetUserId, request.id)).rejects.toThrow( + 'Collaboration request not found', + ); + + model.findById.mockReturnValueOnce( + chain(createRequestDoc({ targetUserId: new Types.ObjectId(), requesterId: request.requesterId })), + ); + await expect(service.reject(targetUserId, request.id)).rejects.toThrow( + 'Only the target user can update this collaboration request', + ); + + model.findById.mockReturnValueOnce(chain({ ...request, status: 'approved' })); + await expect(service.reject(targetUserId, request.id)).rejects.toThrow( + 'Only pending collaboration requests can be updated', + ); + + model.findById.mockReturnValue(chain(request)); + model.findOne.mockReturnValue(chain({ ...request, status: 'rejected' })); + await expect(service.reject(targetUserId, request.id)).resolves.toMatchObject({ + rejected: true, + request: expect.objectContaining({ status: 'rejected' }), + }); + expect(notificationsService.create).toHaveBeenCalledWith( + expect.objectContaining({ type: 'collaboration_request_rejected' }), + ); + }); + + it('stores a validated audio attachment and updates an empty duplicate request safely', async () => { + const requesterId = new Types.ObjectId().toString(); + const targetUserId = new Types.ObjectId().toString(); + const existing = createRequestDoc({ + postId: null, + requesterId: new Types.ObjectId(requesterId), + targetUserId: new Types.ObjectId(targetUserId), + }); + const { service, model, usersRepository, blocksRepository, storageService } = createService(); + usersRepository.findById.mockResolvedValue({ isDisabled: false }); + blocksRepository.findAnyBetween.mockResolvedValue(null); + model.findOne + .mockReturnValueOnce(chain(null)) + .mockReturnValueOnce(chain({ ...existing, attachmentUrl: '/audio.mp3' })); + model.findOneAndUpdate.mockReturnValue(chain(existing)); + + await service.createGeneral( + requesterId, + targetUserId, + { message: ' audio ' }, + { + buffer: Buffer.from('audio'), + originalname: 'sample.mp3', + mimetype: 'audio/mpeg', + size: 5, + }, + ); + expect(storageService.saveFile).toHaveBeenCalledWith( + expect.objectContaining({ + folderSegments: ['collaboration', 'audio'], + extension: '.mp3', + fileNamePrefix: 'collaboration', + }), + ); + + const duplicate = createService(); + duplicate.usersRepository.findById.mockResolvedValue({ isDisabled: false }); + duplicate.blocksRepository.findAnyBetween.mockResolvedValue(null); + duplicate.model.findOne + .mockReturnValueOnce(chain(existing)) + .mockReturnValueOnce(chain(existing)); + duplicate.model.findById.mockReturnValue(chain(existing)); + await duplicate.service.createGeneral(requesterId, targetUserId, {}); + expect(duplicate.model.findById).toHaveBeenCalledWith(existing.id); + expect(duplicate.model.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + it('rejects invisible request details and invalid audio data', async () => { + const userId = new Types.ObjectId().toString(); + const requestId = new Types.ObjectId().toString(); + const { service, model } = createService(); + + await expect(service.getById(userId, 'invalid')).rejects.toThrow( + 'Invalid collaboration request id', + ); + model.findOne.mockReturnValue(chain(null)); + await expect(service.getById(userId, requestId)).rejects.toThrow( + 'Collaboration request not found', + ); + + await expect( + (service as any).saveCollaborationAttachment({ + buffer: Buffer.alloc(0), + originalname: 'empty.mp3', + mimetype: 'audio/mpeg', + size: 0, + }), + ).rejects.toThrow('Invalid audio attachment'); + }); }); diff --git a/src/modules/collaboration-requests/posts-collaboration-requests.controller.ts b/src/modules/collaboration-requests/posts-collaboration-requests.controller.ts index 1e1fc53..5457340 100644 --- a/src/modules/collaboration-requests/posts-collaboration-requests.controller.ts +++ b/src/modules/collaboration-requests/posts-collaboration-requests.controller.ts @@ -8,6 +8,7 @@ import { UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; +import { MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media'; import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; @@ -31,7 +32,11 @@ export class PostsCollaborationRequestsController { @Post(':postId/collaboration-requests') @ApiConsumes('multipart/form-data') - @UseInterceptors(FileInterceptor('attachmentUrl')) + @UseInterceptors( + FileInterceptor('attachmentUrl', { + limits: { fileSize: MEDIA_MAX_SIZE_BYTES.collaborationAudio, files: 1 }, + }), + ) async create( @CurrentUser() user: JwtPayload, @Param('postId') postId: string, @@ -40,4 +45,4 @@ export class PostsCollaborationRequestsController { ) { return this.collaborationRequestsService.create(user.sub, postId, dto, attachmentUrl); } -} \ No newline at end of file +} diff --git a/src/modules/collaboration-requests/users-collaboration-requests.controller.ts b/src/modules/collaboration-requests/users-collaboration-requests.controller.ts index 90337e4..43debfd 100644 --- a/src/modules/collaboration-requests/users-collaboration-requests.controller.ts +++ b/src/modules/collaboration-requests/users-collaboration-requests.controller.ts @@ -8,6 +8,7 @@ import { UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; +import { MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media'; import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; @@ -31,7 +32,11 @@ export class UsersCollaborationRequestsController { @Post(':targetUserId/collaboration-requests') @ApiConsumes('multipart/form-data') - @UseInterceptors(FileInterceptor('attachmentUrl')) + @UseInterceptors( + FileInterceptor('attachmentUrl', { + limits: { fileSize: MEDIA_MAX_SIZE_BYTES.collaborationAudio, files: 1 }, + }), + ) async createGeneral( @CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string, @@ -45,4 +50,4 @@ export class UsersCollaborationRequestsController { attachmentUrl, ); } -} \ No newline at end of file +} diff --git a/src/modules/comments/comments.service.spec.ts b/src/modules/comments/comments.service.spec.ts index eb4209e..2df322a 100644 --- a/src/modules/comments/comments.service.spec.ts +++ b/src/modules/comments/comments.service.spec.ts @@ -1,7 +1,561 @@ import { Types } from 'mongoose'; +import { ModerationStatus } from '../../common/enums/moderation-status.enum'; +import { SortOrder } from '../../common/enums/sort-order.enum'; +import { CommentSortBy } from './dto/comment-query.dto'; import { CommentsService } from './comments.service'; describe('CommentsService', () => { + const userId = '507f1f77bcf86cd799439011'; + const ownerId = '507f191e810c19729de860ea'; + const postId = '507f1f77bcf86cd799439012'; + const commentId = '507f191e810c19729de860eb'; + + const setup = (overrides: Record> = {}) => { + const commentsRepository = { + findById: jest.fn().mockResolvedValue(null), + findByIdWithAuthor: jest.fn().mockResolvedValue(null), + create: jest.fn().mockImplementation(async (value) => ({ id: commentId, ...value })), + setPinned: jest.fn().mockResolvedValue({ id: commentId }), + deleteById: jest.fn().mockResolvedValue(true), + updateById: jest.fn().mockResolvedValue(null), + updateModerationStatus: jest.fn().mockResolvedValue(null), + countByPost: jest.fn().mockResolvedValue(4), + findMany: jest.fn().mockResolvedValue([]), + findManyTop: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + findManyAdmin: jest.fn().mockResolvedValue([]), + countAdmin: jest.fn().mockResolvedValue(0), + countRepliesByParentIds: jest.fn().mockResolvedValue({}), + countLikesByCommentIds: jest.fn().mockResolvedValue({}), + findLikedCommentIds: jest.fn().mockResolvedValue([]), + findReplyPreviewsByParentIds: jest.fn().mockResolvedValue({}), + ...overrides.commentsRepository, + }; + const postsRepository = { + findById: jest.fn().mockResolvedValue({ + id: postId, + authorId: ownerId, + commentsDisabled: false, + commentsFollowersOnly: false, + commentFilterKeywords: [], + }), + setCommentsCount: jest.fn().mockResolvedValue(undefined), + ...overrides.postsRepository, + }; + const auditService = { + logSuperAdminAction: jest.fn().mockResolvedValue(undefined), + ...overrides.auditService, + }; + const feedVersionService = { + bumpGlobalVersion: jest.fn().mockResolvedValue(1), + bumpUserVersion: jest.fn().mockResolvedValue(1), + ...overrides.feedVersionService, + }; + const notificationsService = { + createCommentNotification: jest.fn().mockResolvedValue(undefined), + createMentionNotification: jest.fn().mockResolvedValue(undefined), + ...overrides.notificationsService, + }; + const usersRepository = { + findByUsernames: jest.fn().mockResolvedValue([]), + ...overrides.usersRepository, + }; + const followsRepository = { + findOne: jest.fn().mockResolvedValue(null), + ...overrides.followsRepository, + }; + const blocksRepository = { + findAnyBetween: jest.fn().mockResolvedValue(null), + ...overrides.blocksRepository, + }; + const service = new CommentsService( + commentsRepository as any, + postsRepository as any, + auditService as any, + feedVersionService as any, + notificationsService as any, + usersRepository as any, + followsRepository as any, + blocksRepository as any, + ); + return { + service, + commentsRepository, + postsRepository, + auditService, + feedVersionService, + notificationsService, + usersRepository, + followsRepository, + blocksRepository, + }; + }; + + it('creates a trimmed filtered comment and notifies unique post, parent and mention recipients', async () => { + const parentId = '507f191e810c19729de860ec'; + const parentOwnerId = '507f191e810c19729de860ed'; + const mentionedId = '507f191e810c19729de860ee'; + const ctx = setup({ + postsRepository: { + findById: jest.fn().mockResolvedValue({ + authorId: ownerId, + commentsDisabled: false, + commentsFollowersOnly: false, + commentFilterKeywords: [' SPAM '], + }), + }, + commentsRepository: { + findById: jest.fn().mockResolvedValue({ + postId: { toString: () => postId }, + authorId: { toString: () => parentOwnerId }, + }), + }, + usersRepository: { + findByUsernames: jest.fn().mockResolvedValue([ + { id: mentionedId, username: 'friend' }, + { id: userId, username: 'self' }, + ]), + }, + }); + + await ctx.service.create(userId, { + postId, + parentCommentId: parentId, + content: ' This has spam @Friend @self ', + mentionUsernames: ['@FRIEND', ' friend '], + }); + + expect(ctx.commentsRepository.create).toHaveBeenCalledWith({ + postId, + authorId: userId, + content: 'This has spam @Friend @self', + mentionUsernames: ['friend'], + parentCommentId: parentId, + hiddenByFilter: true, + hiddenReason: 'keyword_filter', + }); + expect(ctx.postsRepository.setCommentsCount).toHaveBeenCalledWith(postId, 4); + expect(ctx.notificationsService.createCommentNotification).toHaveBeenCalledTimes(2); + expect(ctx.notificationsService.createMentionNotification).toHaveBeenCalledWith( + userId, + mentionedId, + postId, + expect.objectContaining({ resourceType: 'comment', deepLink: `/posts/${postId}` }), + ); + }); + + it('validates post and parent comment relationships before creating', async () => { + const missingPost = setup({ postsRepository: { findById: jest.fn().mockResolvedValue(null) } }); + await expect(missingPost.service.create(userId, { postId, content: 'hello' })).rejects.toThrow( + 'Post not found', + ); + + const missingParent = setup(); + await expect( + missingParent.service.create(userId, { postId, content: 'reply', parentCommentId: commentId }), + ).rejects.toThrow('Parent comment not found'); + + const wrongPost = setup({ + commentsRepository: { + findById: jest.fn().mockResolvedValue({ + postId: { toString: () => '507f191e810c19729de860ff' }, + authorId: ownerId, + }), + }, + }); + await expect( + wrongPost.service.create(userId, { postId, content: 'reply', parentCommentId: commentId }), + ).rejects.toThrow('Parent comment not found'); + }); + + it('enforces disabled and followers-only comment settings', async () => { + const disabled = setup({ + postsRepository: { + findById: jest.fn().mockResolvedValue({ authorId: ownerId, commentsDisabled: true }), + }, + }); + await expect(disabled.service.create(userId, { postId, content: 'hello' })).rejects.toThrow( + 'Comments are disabled', + ); + + const followersOnly = setup({ + postsRepository: { + findById: jest.fn().mockResolvedValue({ + authorId: ownerId, + commentsDisabled: false, + commentsFollowersOnly: true, + }), + }, + }); + await expect(followersOnly.service.create(userId, { postId, content: 'hello' })).rejects.toThrow( + 'Only followers can comment', + ); + + const follower = setup({ + postsRepository: { + findById: jest.fn().mockResolvedValue({ + authorId: ownerId, + commentsDisabled: false, + commentsFollowersOnly: true, + }), + }, + followsRepository: { findOne: jest.fn().mockResolvedValue({ id: 'follow' }) }, + }); + await expect(follower.service.create(userId, { postId, content: 'hello' })).resolves.toBeDefined(); + }); + + it('limits comment mentions to thirty users', async () => { + const ctx = setup(); + const mentions = Array.from({ length: 31 }, (_, index) => `user${index}`).join(' @'); + await expect(ctx.service.create(userId, { postId, content: `@${mentions}` })).rejects.toThrow( + 'You can mention up to 30 users only', + ); + expect(ctx.commentsRepository.create).not.toHaveBeenCalled(); + }); + + it.each([ + ['pin', true, 'Only the post owner can pin comments'], + ['unpin', false, 'Only the post owner can unpin comments'], + ] as const)('%s validates comment, post, owner and update result', async (method, value, forbiddenMessage) => { + await expect((setup().service as any)[method](ownerId, commentId)).rejects.toThrow('Comment not found'); + + const comment = { postId: { toString: () => postId } }; + const noPost = setup({ + commentsRepository: { findById: jest.fn().mockResolvedValue(comment) }, + postsRepository: { findById: jest.fn().mockResolvedValue(null) }, + }); + await expect((noPost.service as any)[method](ownerId, commentId)).rejects.toThrow('Post not found'); + + const forbidden = setup({ commentsRepository: { findById: jest.fn().mockResolvedValue(comment) } }); + await expect((forbidden.service as any)[method](userId, commentId)).rejects.toThrow(forbiddenMessage); + + const lost = setup({ + commentsRepository: { + findById: jest.fn().mockResolvedValue(comment), + setPinned: jest.fn().mockResolvedValue(null), + }, + }); + await expect((lost.service as any)[method](ownerId, commentId)).rejects.toThrow('Comment not found'); + + const success = setup({ commentsRepository: { findById: jest.fn().mockResolvedValue(comment) } }); + await expect((success.service as any)[method](ownerId, commentId)).resolves.toEqual({ id: commentId }); + expect(success.commentsRepository.setPinned).toHaveBeenCalledWith(commentId, value); + }); + + it('creates replies through the standard creation workflow', async () => { + const ctx = setup({ + commentsRepository: { + findById: jest.fn().mockResolvedValue({ postId: { toString: () => postId } }), + }, + }); + const create = jest.spyOn(ctx.service, 'create').mockResolvedValue({ id: 'reply' } as any); + + await expect( + ctx.service.createReply(userId, commentId, { content: 'reply', mentionUsernames: ['friend'] }), + ).resolves.toEqual({ id: 'reply' }); + expect(create).toHaveBeenCalledWith(userId, { + postId, + content: 'reply', + mentionUsernames: ['friend'], + parentCommentId: commentId, + }); + + await expect(setup().service.createReply(userId, commentId, { content: 'reply' })).rejects.toThrow( + 'Parent comment not found', + ); + }); + + it('removes only the actor own comment and resynchronizes counters', async () => { + const own = { + authorId: { toString: () => userId }, + postId: { toString: () => postId }, + }; + const ctx = setup({ commentsRepository: { findById: jest.fn().mockResolvedValue(own) } }); + await expect(ctx.service.remove(userId, commentId)).resolves.toEqual({ success: true }); + expect(ctx.commentsRepository.deleteById).toHaveBeenCalledWith(commentId, userId); + expect(ctx.postsRepository.setCommentsCount).toHaveBeenCalledWith(postId, 4); + + await expect(setup().service.remove(userId, commentId)).rejects.toThrow('Comment not found'); + const foreign = setup({ + commentsRepository: { + findById: jest.fn().mockResolvedValue({ ...own, authorId: { toString: () => ownerId } }), + }, + }); + await expect(foreign.service.remove(userId, commentId)).rejects.toThrow( + 'You can only delete your own comments', + ); + }); + + it('validates update ownership, payload and repository races', async () => { + await expect(setup().service.update(userId, commentId, { content: 'new' })).rejects.toThrow( + 'Comment not found', + ); + const base = { + authorId: { toString: () => userId }, + postId: { toString: () => postId }, + content: 'old', + mentionUsernames: [], + }; + const foreign = setup({ + commentsRepository: { + findById: jest.fn().mockResolvedValue({ ...base, authorId: { toString: () => ownerId } }), + }, + }); + await expect(foreign.service.update(userId, commentId, { content: 'new' })).rejects.toThrow( + 'You can only update your own comments', + ); + + const ctx = setup({ commentsRepository: { findById: jest.fn().mockResolvedValue(base) } }); + await expect(ctx.service.update(userId, commentId, {})).rejects.toThrow('Nothing to update'); + await expect(ctx.service.update(userId, commentId, { content: ' ' })).rejects.toThrow( + 'Comment content cannot be empty', + ); + await expect(ctx.service.update(userId, commentId, { content: 'valid' })).rejects.toThrow( + 'Comment not found', + ); + }); + + it('deletes comments as superadmin and records an audit trail', async () => { + const comment = { postId: { toString: () => postId } }; + const ctx = setup({ commentsRepository: { findById: jest.fn().mockResolvedValue(comment) } }); + await expect(ctx.service.removeBySuperAdmin('admin@example.com', commentId)).resolves.toEqual({ + success: true, + message: 'Comment deleted by superadmin', + }); + expect(ctx.feedVersionService.bumpGlobalVersion).toHaveBeenCalled(); + expect(ctx.auditService.logSuperAdminAction).toHaveBeenCalledWith( + 'admin@example.com', + 'comment_delete', + 'comment', + commentId, + { postId }, + ); + + await expect(setup().service.removeBySuperAdmin('admin', commentId)).rejects.toThrow( + 'Comment not found', + ); + }); + + it('lists top-level comments with reply previews and viewer capabilities', async () => { + const replyId = '507f191e810c19729de860ec'; + const makeComment = (id: string, author: string, content: string, deleted = false) => ({ + id, + authorId: { id: author, name: author === userId ? 'Me' : 'Owner' }, + content, + isDeleted: deleted, + toObject: () => ({ + id, + authorId: { id: author, name: author === userId ? 'Me' : 'Owner' }, + content, + isDeleted: deleted, + }), + }); + const root = makeComment(commentId, userId, 'root'); + const reply = makeComment(replyId, ownerId, 'reply', true); + const ctx = setup({ + commentsRepository: { + findManyTop: jest.fn().mockResolvedValue([root]), + count: jest.fn().mockResolvedValue(1), + countRepliesByParentIds: jest.fn().mockResolvedValue({ [commentId]: 3 }), + countLikesByCommentIds: jest + .fn() + .mockResolvedValueOnce({ [commentId]: 5 }) + .mockResolvedValueOnce({ [replyId]: 2 }), + findLikedCommentIds: jest + .fn() + .mockResolvedValueOnce([commentId]) + .mockResolvedValueOnce([replyId]), + findReplyPreviewsByParentIds: jest.fn().mockResolvedValue({ [commentId]: [reply] }), + }, + }); + + const result = await ctx.service.findByPost(userId, postId, { + page: 1, + limit: 10, + sortBy: CommentSortBy.TOP, + sortOrder: SortOrder.ASC, + }); + + expect(ctx.commentsRepository.findManyTop).toHaveBeenCalledWith( + expect.objectContaining({ postId: expect.any(Types.ObjectId) }), + 0, + 10, + 1, + ); + expect(result.items[0]).toMatchObject({ + content: 'root', + repliesCount: 3, + likesCount: 5, + likedByMe: true, + canEdit: true, + canDelete: true, + }); + expect((result.items[0] as any).repliesPreview[0]).toMatchObject({ + content: 'This comment was deleted', + canEdit: false, + likesCount: 2, + likedByMe: true, + replyToUser: { id: userId, name: 'Me' }, + }); + }); + + it('lists chronological comments and returns empty enrichment cheaply', async () => { + const ctx = setup(); + const result = await ctx.service.findByPost(userId, postId, { + page: 2, + limit: 5, + sortOrder: SortOrder.DESC, + }); + expect(ctx.commentsRepository.findMany).toHaveBeenCalledWith( + expect.objectContaining({ postId: expect.any(Types.ObjectId) }), + 5, + 5, + { isPinned: -1, createdAt: -1 }, + ); + expect(result.items).toEqual([]); + expect(ctx.commentsRepository.countRepliesByParentIds).not.toHaveBeenCalled(); + + await expect( + ctx.service.findByPost(userId, 'bad', { sortOrder: SortOrder.DESC }), + ).rejects.toThrow('Invalid post id'); + }); + + it('lists replies with parent author context using both sort modes', async () => { + const reply = { + id: '507f191e810c19729de860ec', + authorId: ownerId, + content: 'reply', + }; + const parentAuthor = { id: ownerId, name: 'Parent', username: 'parent' }; + const ctx = setup({ + commentsRepository: { + findMany: jest.fn().mockResolvedValue([reply]), + count: jest.fn().mockResolvedValue(1), + findByIdWithAuthor: jest.fn().mockResolvedValue({ authorId: parentAuthor }), + }, + }); + const result = await ctx.service.findReplies(userId, commentId, { + page: 1, + limit: 20, + sortOrder: SortOrder.DESC, + }); + expect(result.items[0]).toMatchObject({ + content: 'reply', + replyToUser: { id: ownerId, name: 'Parent', username: 'parent' }, + }); + + const top = setup(); + await top.service.findReplies(userId, commentId, { + sortBy: CommentSortBy.TOP, + sortOrder: SortOrder.ASC, + }); + expect(top.commentsRepository.findManyTop).toHaveBeenCalledWith( + expect.objectContaining({ parentCommentId: expect.any(Types.ObjectId) }), + 0, + 20, + 1, + ); + + await expect(ctx.service.findReplies(userId, 'bad', { sortOrder: SortOrder.DESC })).rejects.toThrow( + 'Invalid parent comment id', + ); + }); + + it('builds escaped admin filters and paginates platform comments', async () => { + const rows = [{ id: commentId }]; + const ctx = setup({ + commentsRepository: { + findManyAdmin: jest.fn().mockResolvedValue(rows), + countAdmin: jest.fn().mockResolvedValue(7), + }, + }); + const result = await ctx.service.findPlatformComments({ + page: 2, + limit: 3, + sortOrder: SortOrder.ASC, + postId, + authorId: ownerId, + q: ' a+b ', + moderationStatus: ModerationStatus.FLAGGED, + }); + + expect(result).toMatchObject({ items: rows, total: 7, page: 2, limit: 3 }); + expect(ctx.commentsRepository.findManyAdmin).toHaveBeenCalledWith( + expect.objectContaining({ + postId: expect.any(Types.ObjectId), + authorId: expect.any(Types.ObjectId), + content: { $regex: 'a\\+b', $options: 'i' }, + moderationStatus: ModerationStatus.FLAGGED, + }), + 3, + 3, + { createdAt: 1 }, + ); + }); + + it('updates comment moderation status and audits previous and next state', async () => { + const original = { moderationStatus: ModerationStatus.FLAGGED }; + const updated = { id: commentId, moderationStatus: ModerationStatus.HIDDEN }; + const ctx = setup({ + commentsRepository: { + findById: jest.fn().mockResolvedValue(original), + updateModerationStatus: jest.fn().mockResolvedValue(updated), + }, + }); + await expect( + ctx.service.updateModerationStatusBySuperAdmin('admin', commentId, { + status: ModerationStatus.HIDDEN, + reason: ' abuse ', + }), + ).resolves.toBe(updated); + expect(ctx.commentsRepository.updateModerationStatus).toHaveBeenCalledWith(commentId, { + moderationStatus: ModerationStatus.HIDDEN, + moderationReason: 'abuse', + }); + expect(ctx.auditService.logSuperAdminAction).toHaveBeenCalledWith( + 'admin', + 'comment_moderation_status_update', + 'comment', + commentId, + { + previousStatus: ModerationStatus.FLAGGED, + nextStatus: ModerationStatus.HIDDEN, + reason: 'abuse', + }, + ); + + await expect( + setup().service.updateModerationStatusBySuperAdmin('admin', commentId, { + status: ModerationStatus.ACTIVE, + }), + ).rejects.toThrow('Comment not found'); + + const lost = setup({ + commentsRepository: { findById: jest.fn().mockResolvedValue({ moderationStatus: undefined }) }, + }); + await expect( + lost.service.updateModerationStatusBySuperAdmin('admin', commentId, { + status: ModerationStatus.ACTIVE, + }), + ).rejects.toThrow('Comment not found'); + }); + + it('isolates comment and mention notification delivery failures', async () => { + const mentionedId = '507f191e810c19729de860ee'; + const ctx = setup({ + notificationsService: { + createCommentNotification: jest.fn().mockRejectedValue('offline'), + createMentionNotification: jest.fn().mockRejectedValue(new Error('offline')), + }, + usersRepository: { + findByUsernames: jest.fn().mockResolvedValue([{ id: mentionedId, username: 'friend' }]), + }, + }); + + await expect(ctx.service.create(userId, { postId, content: '@friend hello' })).resolves.toBeDefined(); + expect(ctx.notificationsService.createCommentNotification).toHaveBeenCalled(); + expect(ctx.notificationsService.createMentionNotification).toHaveBeenCalled(); + }); it('updates own comment and notifies newly mentioned users', async () => { const userId = new Types.ObjectId().toString(); const postId = new Types.ObjectId().toString(); @@ -33,6 +587,7 @@ describe('CommentsService', () => { }; const feedVersionService = { bumpGlobalVersion: jest.fn(), + bumpUserVersion: jest.fn(), }; const notificationsService = { createCommentNotification: jest.fn(), @@ -68,7 +623,7 @@ describe('CommentsService', () => { content: updatedContent, mentionUsernames: ['new_mention'], }); - expect(feedVersionService.bumpGlobalVersion).toHaveBeenCalled(); + expect(feedVersionService.bumpUserVersion).toHaveBeenCalledWith(userId); expect(notificationsService.createMentionNotification).toHaveBeenCalledWith( userId, 'mentioned-user-id', diff --git a/src/modules/comments/comments.service.ts b/src/modules/comments/comments.service.ts index dd4dc0f..0572f50 100644 --- a/src/modules/comments/comments.service.ts +++ b/src/modules/comments/comments.service.ts @@ -3,6 +3,7 @@ import { Types } from 'mongoose'; import { ModerationStatus } from '../../common/enums/moderation-status.enum'; import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util'; +import { escapeRegex } from '../../common/utils/regex.util'; import { FeedVersionService } from '../../infrastructure/cache/feed-version.service'; import { AuditService } from '../audit/audit.service'; import { NotificationsService } from '../notifications/notifications.service'; @@ -82,7 +83,7 @@ export class CommentsService { hiddenReason: hiddenByFilter ? 'keyword_filter' : '', }); await this.syncCommentsCount(dto.postId); - await this.feedVersionService.bumpGlobalVersion(); + await this.feedVersionService.bumpUserVersion(userId); const postAuthorId = this.extractEntityId(post.authorId); const previewText = content.slice(0, 160); const commentNotificationRecipients = await this.dispatchCommentNotifications( @@ -166,7 +167,7 @@ export class CommentsService { await this.commentsRepository.deleteById(commentId, userId); await this.syncCommentsCount(comment.postId.toString()); - await this.feedVersionService.bumpGlobalVersion(); + await this.feedVersionService.bumpUserVersion(userId); return { success: true }; } @@ -201,7 +202,7 @@ export class CommentsService { throw new NotFoundException('Comment not found'); } - await this.feedVersionService.bumpGlobalVersion(); + await this.feedVersionService.bumpUserVersion(userId); const previousMentionSet = new Set(previousMentionUsernames); const nextMentionedUsers = mentionResolution.mentionedUsers.filter( @@ -324,7 +325,7 @@ export class CommentsService { filter.authorId = new Types.ObjectId(query.authorId); } if (query.q?.trim()) { - filter.content = { $regex: query.q.trim(), $options: 'i' }; + filter.content = { $regex: escapeRegex(query.q.trim()), $options: 'i' }; } if (query.moderationStatus) { filter.moderationStatus = query.moderationStatus; diff --git a/src/modules/devices/devices.service.spec.ts b/src/modules/devices/devices.service.spec.ts new file mode 100644 index 0000000..016b257 --- /dev/null +++ b/src/modules/devices/devices.service.spec.ts @@ -0,0 +1,111 @@ +import { BadRequestException } from '@nestjs/common'; +import { DevicesService } from './devices.service'; + +describe('DevicesService', () => { + const createService = () => { + const devicesRepository = { + upsert: jest.fn(), + deactivate: jest.fn(), + }; + + return { + service: new DevicesService(devicesRepository as any), + devicesRepository, + }; + }; + + it('trims registration data and returns the persisted device', async () => { + const { service, devicesRepository } = createService(); + const device = { id: 'device-1', isActive: true }; + devicesRepository.upsert.mockResolvedValue(device); + + await expect( + service.register('user-1', { + fcmToken: ' token ', + platform: 'android', + deviceId: ' device-id ', + appVersion: ' 1.2.3 ', + locale: ' ar ', + }), + ).resolves.toEqual({ message: 'Device registered successfully', device }); + expect(devicesRepository.upsert).toHaveBeenCalledWith('user-1', { + fcmToken: 'token', + platform: 'android', + deviceId: 'device-id', + appVersion: '1.2.3', + locale: 'ar', + }); + }); + + it('normalizes absent optional registration data', async () => { + const { service, devicesRepository } = createService(); + devicesRepository.upsert.mockResolvedValue(null); + + await service.register('user-1', { fcmToken: 'token', platform: 'ios' }); + + expect(devicesRepository.upsert).toHaveBeenCalledWith('user-1', { + fcmToken: 'token', + platform: 'ios', + deviceId: '', + appVersion: '', + locale: '', + }); + }); + + it('rejects an empty FCM token without touching the repository', async () => { + const { service, devicesRepository } = createService(); + + await expect( + service.register('user-1', { fcmToken: ' ', platform: 'web' }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(devicesRepository.upsert).not.toHaveBeenCalled(); + }); + + it('unregisters by trimmed identifiers', async () => { + const { service, devicesRepository } = createService(); + const device = { id: 'device-1', isActive: false }; + devicesRepository.deactivate.mockResolvedValue(device); + + await expect( + service.unregister('user-1', { deviceId: ' device-id ', fcmToken: ' token ' }), + ).resolves.toEqual({ message: 'Device unregistered successfully', device }); + expect(devicesRepository.deactivate).toHaveBeenCalledWith('user-1', { + deviceId: 'device-id', + fcmToken: 'token', + }); + }); + + it('supports unregistering by token alone', async () => { + const { service, devicesRepository } = createService(); + devicesRepository.deactivate.mockResolvedValue(null); + + await service.unregister('user-1', { fcmToken: ' token ' }); + + expect(devicesRepository.deactivate).toHaveBeenCalledWith('user-1', { + deviceId: undefined, + fcmToken: 'token', + }); + }); + + it('rejects unregister without a usable identifier', async () => { + const { service, devicesRepository } = createService(); + + await expect( + service.unregister('user-1', { deviceId: ' ', fcmToken: ' ' }), + ).rejects.toThrow('deviceId or fcmToken is required'); + expect(devicesRepository.deactivate).not.toHaveBeenCalled(); + }); + + it('propagates repository failures', async () => { + const { service, devicesRepository } = createService(); + devicesRepository.upsert.mockRejectedValue(new Error('database unavailable')); + devicesRepository.deactivate.mockRejectedValue(new Error('database unavailable')); + + await expect( + service.register('user-1', { fcmToken: 'token', platform: 'android' }), + ).rejects.toThrow('database unavailable'); + await expect(service.unregister('user-1', { deviceId: 'id' })).rejects.toThrow( + 'database unavailable', + ); + }); +}); diff --git a/src/modules/email/email.service.spec.ts b/src/modules/email/email.service.spec.ts new file mode 100644 index 0000000..d8b05d7 --- /dev/null +++ b/src/modules/email/email.service.spec.ts @@ -0,0 +1,109 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; +import { EmailService } from './email.service'; + +jest.mock('nodemailer', () => ({ createTransport: jest.fn() })); + +describe('EmailService', () => { + const setup = (overrides: Record = {}) => { + const values: Record = { + nodeEnv: 'test', + 'email.enabled': true, + 'email.fromName': 'Oudelaa', + 'email.fromEmail': 'noreply@oudelaa.test', + 'email.smtpHost': 'smtp.oudelaa.test', + 'email.smtpPort': 587, + 'email.smtpSecure': false, + 'email.smtpUser': 'smtp-user', + 'email.smtpPass': 'smtp-pass', + ...overrides, + }; + const config = { get: jest.fn((key: string) => values[key]) }; + const sendMail = jest.fn().mockResolvedValue({ messageId: 'message-1' }); + (nodemailer.createTransport as jest.Mock).mockReturnValue({ sendMail }); + return { service: new EmailService(config as any), config, sendMail }; + }; + + beforeEach(() => jest.clearAllMocks()); + + it('sends verification and reset codes and reuses the SMTP transporter', async () => { + const { service, sendMail } = setup(); + + await service.sendVerificationCode('member@example.com', '123456', 10); + await service.sendPasswordResetCode('member@example.com', '654321', 15); + + expect(nodemailer.createTransport).toHaveBeenCalledTimes(1); + expect(nodemailer.createTransport).toHaveBeenCalledWith({ + host: 'smtp.oudelaa.test', + port: 587, + secure: false, + auth: { user: 'smtp-user', pass: 'smtp-pass' }, + }); + expect(sendMail).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + from: 'Oudelaa ', + to: 'member@example.com', + text: expect.stringContaining('123456'), + html: expect.stringContaining('123456'), + }), + ); + expect(sendMail).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ text: expect.stringContaining('654321'), html: expect.stringContaining('654321') }), + ); + }); + + it('silently skips disabled delivery outside production', async () => { + const { service } = setup({ 'email.enabled': false, nodeEnv: 'test' }); + + await expect(service.sendVerificationCode('member@example.com', '123456', 10)).resolves.toBeUndefined(); + expect(nodemailer.createTransport).not.toHaveBeenCalled(); + }); + + it('rejects disabled delivery in production', async () => { + const { service } = setup({ 'email.enabled': false, nodeEnv: 'production' }); + await expect(service.sendVerificationCode('member@example.com', '123456', 10)).rejects.toThrow( + 'Email delivery is disabled', + ); + }); + + it('requires a configured sender and complete SMTP credentials', async () => { + const missingSender = setup({ 'email.fromEmail': '' }).service; + await expect(missingSender.sendVerificationCode('member@example.com', '123456', 10)).rejects.toThrow( + 'Email sender is not configured', + ); + + const missingSmtp = setup({ 'email.smtpHost': '', 'email.smtpUser': '', 'email.smtpPass': '' }).service; + await expect(missingSmtp.sendVerificationCode('member@example.com', '123456', 10)).rejects.toThrow( + 'SMTP settings are not configured', + ); + }); + + it.each([ + ['email.smtpPort', undefined], + ['email.smtpSecure', undefined], + ['email.fromName', undefined], + ])('uses a safe default when %s is absent', async (key, value) => { + const { service } = setup({ [key]: value }); + await service.sendVerificationCode('member@example.com', '123456', 10); + expect(nodemailer.createTransport).toHaveBeenCalled(); + }); + + it('returns diagnostic SMTP context in development and a generic error elsewhere', async () => { + const development = setup({ nodeEnv: 'development' }); + development.sendMail.mockRejectedValueOnce(Object.assign(new Error('connection refused'), { code: 'ECONNREFUSED' })); + await expect(development.service.sendVerificationCode('member@example.com', '123456', 10)).rejects.toThrow( + 'ECONNREFUSED: connection refused', + ); + + const production = setup({ nodeEnv: 'production' }); + production.sendMail.mockRejectedValueOnce(new Error('secret provider detail')); + await expect(production.service.sendVerificationCode('member@example.com', '123456', 10)).rejects.toEqual( + expect.any(ServiceUnavailableException), + ); + await expect( + Promise.reject(new ServiceUnavailableException('Failed to send verification email')), + ).rejects.toThrow('Failed to send verification email'); + }); +}); diff --git a/src/modules/engagement/dto/track-engagement.dto.ts b/src/modules/engagement/dto/track-engagement.dto.ts new file mode 100644 index 0000000..f9d414f --- /dev/null +++ b/src/modules/engagement/dto/track-engagement.dto.ts @@ -0,0 +1,36 @@ +import { Type } from 'class-transformer'; +import { ArrayMaxSize, IsArray, IsEnum, IsInt, IsMongoId, IsNumber, IsOptional, IsString, Max, MaxLength, Min, ValidateNested } from 'class-validator'; +import { EngagementEventType } from '../engagement-event-type.enum'; + +export class TrackEngagementDto { + @IsMongoId() + postId!: string; + + @IsEnum(EngagementEventType) + type!: EngagementEventType; + + @IsOptional() + @IsInt() + @Min(0) + @Max(7_200_000) + watchTimeMs?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + progressPercent?: number; + + @IsOptional() + @IsString() + @MaxLength(100) + sessionId?: string; +} + +export class TrackEngagementBatchDto { + @IsArray() + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => TrackEngagementDto) + events!: TrackEngagementDto[]; +} diff --git a/src/modules/engagement/engagement-event-type.enum.ts b/src/modules/engagement/engagement-event-type.enum.ts new file mode 100644 index 0000000..3f782a9 --- /dev/null +++ b/src/modules/engagement/engagement-event-type.enum.ts @@ -0,0 +1,10 @@ +export enum EngagementEventType { + IMPRESSION = 'impression', + OPEN = 'open', + WATCH = 'watch', + COMPLETE = 'complete', + REWATCH = 'rewatch', + PROFILE_OPEN = 'profile_open', + SHARE = 'share', + NOT_INTERESTED = 'not_interested', +} diff --git a/src/modules/engagement/engagement.controller.ts b/src/modules/engagement/engagement.controller.ts new file mode 100644 index 0000000..71c22b8 --- /dev/null +++ b/src/modules/engagement/engagement.controller.ts @@ -0,0 +1,39 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { Throttle } from '../../common/decorators/throttle.decorator'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; +import { TrackEngagementBatchDto, TrackEngagementDto } from './dto/track-engagement.dto'; +import { EngagementService } from './engagement.service'; + +@ApiTags('Engagement') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('engagement') +export class EngagementController { + constructor(private readonly engagementService: EngagementService) {} + + @Post('events') @HttpCode(HttpStatus.ACCEPTED) @Throttle(240, 60_000) + track(@CurrentUser() user: JwtPayload, @Body() dto: TrackEngagementDto) { + return this.engagementService.track(user.sub, dto); + } + + @Post('events/batch') @HttpCode(HttpStatus.ACCEPTED) @Throttle(30, 60_000) + trackBatch(@CurrentUser() user: JwtPayload, @Body() dto: TrackEngagementBatchDto) { + return this.engagementService.trackBatch(user.sub, dto.events); + } + + @Post('posts/:postId/not-interested') + hide(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) { + return this.engagementService.setNotInterested(user.sub, postId, true); + } + + @Delete('posts/:postId/not-interested') + restore(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) { + return this.engagementService.setNotInterested(user.sub, postId, false); + } + + @Get('me/summary') + summary(@CurrentUser() user: JwtPayload) { return this.engagementService.getMySummary(user.sub); } +} diff --git a/src/modules/engagement/engagement.module.ts b/src/modules/engagement/engagement.module.ts new file mode 100644 index 0000000..91c4b34 --- /dev/null +++ b/src/modules/engagement/engagement.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { PostsModule } from '../posts/posts.module'; +import { EngagementController } from './engagement.controller'; +import { EngagementService } from './engagement.service'; +import { EngagementEvent, EngagementEventSchema } from './schemas/engagement-event.schema'; +import { PostPreference, PostPreferenceSchema } from './schemas/post-preference.schema'; + +@Module({ + imports: [PostsModule, MongooseModule.forFeature([ + { name: EngagementEvent.name, schema: EngagementEventSchema }, + { name: PostPreference.name, schema: PostPreferenceSchema }, + ])], + controllers: [EngagementController], providers: [EngagementService], exports: [EngagementService], +}) +export class EngagementModule {} diff --git a/src/modules/engagement/engagement.service.spec.ts b/src/modules/engagement/engagement.service.spec.ts new file mode 100644 index 0000000..c4f9dc6 --- /dev/null +++ b/src/modules/engagement/engagement.service.spec.ts @@ -0,0 +1,169 @@ +import { EngagementEventType } from './engagement-event-type.enum'; +import { EngagementService } from './engagement.service'; + +describe('EngagementService', () => { + const userId = '507f1f77bcf86cd799439011'; + const postId = '507f191e810c19729de860ea'; + + const setup = () => { + const eventModel = { + create: jest.fn().mockResolvedValue({}), + aggregate: jest.fn(), + }; + const preferenceModel = { + updateOne: jest.fn().mockResolvedValue({ acknowledged: true }), + deleteOne: jest.fn().mockResolvedValue({ acknowledged: true }), + find: jest.fn(), + }; + const postsService = { + findById: jest.fn().mockResolvedValue({ id: postId }), + }; + const feedVersionService = { + bumpUserVersion: jest.fn().mockResolvedValue(2), + }; + const service = new EngagementService( + eventModel as any, + preferenceModel as any, + postsService as any, + feedVersionService as any, + ); + return { service, eventModel, preferenceModel, postsService, feedVersionService }; + }; + + it('records a normalized engagement event after checking post visibility', async () => { + const { service, eventModel, postsService } = setup(); + + await expect( + service.track(userId, { + postId, + type: EngagementEventType.WATCH, + watchTimeMs: 9000, + progressPercent: 72, + sessionId: ' session-1 ', + }), + ).resolves.toEqual({ accepted: true, postId, type: EngagementEventType.WATCH }); + + expect(postsService.findById).toHaveBeenCalledWith(postId, userId); + expect(eventModel.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: expect.objectContaining({}), + postId: expect.objectContaining({}), + type: EngagementEventType.WATCH, + watchTimeMs: 9000, + progressPercent: 72, + sessionId: 'session-1', + occurredAt: expect.any(Date), + }), + ); + }); + + it('uses safe defaults for optional event measurements', async () => { + const { service, eventModel } = setup(); + + await service.track(userId, { postId, type: EngagementEventType.IMPRESSION }); + + expect(eventModel.create).toHaveBeenCalledWith( + expect.objectContaining({ watchTimeMs: 0, progressPercent: 0, sessionId: '' }), + ); + }); + + it('routes not-interested events through the preference workflow', async () => { + const { service, eventModel, preferenceModel, feedVersionService } = setup(); + + await expect( + service.track(userId, { postId, type: EngagementEventType.NOT_INTERESTED }), + ).resolves.toEqual({ postId, notInterested: true }); + + expect(preferenceModel.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ userId: expect.anything(), postId: expect.anything() }), + { $set: { notInterested: true } }, + { upsert: true }, + ); + expect(eventModel.create).toHaveBeenCalledWith( + expect.objectContaining({ type: EngagementEventType.NOT_INTERESTED }), + ); + expect(feedVersionService.bumpUserVersion).toHaveBeenCalledWith(userId); + }); + + it('tracks a batch and reports every accepted item', async () => { + const { service } = setup(); + const events = [ + { postId, type: EngagementEventType.OPEN }, + { postId, type: EngagementEventType.COMPLETE }, + ]; + + await expect(service.trackBatch(userId, events)).resolves.toMatchObject({ + accepted: 2, + items: [ + { accepted: true, type: EngagementEventType.OPEN }, + { accepted: true, type: EngagementEventType.COMPLETE }, + ], + }); + }); + + it('removes a not-interested preference without requiring post visibility', async () => { + const { service, preferenceModel, postsService, eventModel, feedVersionService } = setup(); + + await expect(service.setNotInterested(userId, postId, false)).resolves.toEqual({ + postId, + notInterested: false, + }); + + expect(postsService.findById).not.toHaveBeenCalled(); + expect(preferenceModel.deleteOne).toHaveBeenCalledWith( + expect.objectContaining({ userId: expect.anything(), postId: expect.anything() }), + ); + expect(eventModel.create).not.toHaveBeenCalled(); + expect(feedVersionService.bumpUserVersion).toHaveBeenCalledWith(userId); + }); + + it('returns stored not-interested post ids as strings', async () => { + const { service, preferenceModel } = setup(); + const exec = jest.fn().mockResolvedValue([{ postId }]); + const lean = jest.fn(() => ({ exec })); + const select = jest.fn(() => ({ lean })); + preferenceModel.find.mockReturnValue({ select }); + + await expect(service.getNotInterestedPostIds(userId)).resolves.toEqual([postId]); + expect(preferenceModel.find).toHaveBeenCalledWith( + expect.objectContaining({ userId: expect.anything(), notInterested: true }), + ); + expect(select).toHaveBeenCalledWith({ postId: 1 }); + }); + + it('returns ranking aggregates keyed by post id', async () => { + const { service, eventModel } = setup(); + const row = { _id: { toString: () => postId }, watchMs: 5000, completions: 1, rewatches: 2, profileOpens: 1 }; + const exec = jest.fn().mockResolvedValue([row]); + eventModel.aggregate.mockReturnValue({ exec }); + + const result = await service.getRankingSignals(userId); + + expect(result).toEqual(new Map([[postId, row]])); + expect(eventModel.aggregate).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ $match: expect.objectContaining({ userId: expect.anything() }) }), + { $sort: { watchMs: -1 } }, + { $limit: 500 }, + ]), + ); + }); + + it('builds a 30-day engagement summary grouped by type', async () => { + const { service, eventModel } = setup(); + eventModel.aggregate.mockReturnValue({ + exec: jest.fn().mockResolvedValue([ + { _id: EngagementEventType.WATCH, count: 3, watchTimeMs: 12000 }, + { _id: EngagementEventType.SHARE, count: 1, watchTimeMs: 0 }, + ]), + }); + + await expect(service.getMySummary(userId)).resolves.toEqual({ + periodDays: 30, + byType: { + watch: { count: 3, watchTimeMs: 12000 }, + share: { count: 1, watchTimeMs: 0 }, + }, + }); + }); +}); diff --git a/src/modules/engagement/engagement.service.ts b/src/modules/engagement/engagement.service.ts new file mode 100644 index 0000000..f5ed009 --- /dev/null +++ b/src/modules/engagement/engagement.service.ts @@ -0,0 +1,96 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { FeedVersionService } from '../../infrastructure/cache/feed-version.service'; +import { PostsService } from '../posts/posts.service'; +import { TrackEngagementDto } from './dto/track-engagement.dto'; +import { EngagementEventType } from './engagement-event-type.enum'; +import { EngagementEvent, EngagementEventDocument } from './schemas/engagement-event.schema'; +import { PostPreference, PostPreferenceDocument } from './schemas/post-preference.schema'; + +@Injectable() +export class EngagementService { + constructor( + @InjectModel(EngagementEvent.name) private readonly eventModel: Model, + @InjectModel(PostPreference.name) private readonly preferenceModel: Model, + private readonly postsService: PostsService, + private readonly feedVersionService: FeedVersionService, + ) {} + + async track(userId: string, dto: TrackEngagementDto) { + if (dto.type === EngagementEventType.NOT_INTERESTED) { + return this.setNotInterested(userId, dto.postId, true); + } + await this.postsService.findById(dto.postId, userId); + await this.eventModel.create({ + userId: new Types.ObjectId(userId), + postId: new Types.ObjectId(dto.postId), + type: dto.type, + watchTimeMs: dto.watchTimeMs ?? 0, + progressPercent: dto.progressPercent ?? 0, + sessionId: dto.sessionId?.trim() ?? '', + occurredAt: new Date(), + }); + return { accepted: true, postId: dto.postId, type: dto.type }; + } + + async trackBatch(userId: string, events: TrackEngagementDto[]) { + const results = []; + for (const event of events) results.push(await this.track(userId, event)); + return { accepted: results.length, items: results }; + } + + async setNotInterested(userId: string, postId: string, value: boolean) { + if (value) { + await this.postsService.findById(postId, userId); + await this.preferenceModel.updateOne( + { userId: new Types.ObjectId(userId), postId: new Types.ObjectId(postId) }, + { $set: { notInterested: true } }, + { upsert: true }, + ); + await this.eventModel.create({ + userId: new Types.ObjectId(userId), postId: new Types.ObjectId(postId), + type: EngagementEventType.NOT_INTERESTED, occurredAt: new Date(), + }); + } else { + await this.preferenceModel.deleteOne({ + userId: new Types.ObjectId(userId), postId: new Types.ObjectId(postId), + }); + } + await this.feedVersionService.bumpUserVersion(userId); + return { postId, notInterested: value }; + } + + async getNotInterestedPostIds(userId: string): Promise { + const rows = await this.preferenceModel + .find({ userId: new Types.ObjectId(userId), notInterested: true }) + .select({ postId: 1 }).lean().exec(); + return rows.map((row) => row.postId.toString()); + } + + async getRankingSignals(userId: string) { + const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + const rows = await this.eventModel.aggregate<{ + _id: Types.ObjectId; watchMs: number; completions: number; rewatches: number; profileOpens: number; + }>([ + { $match: { userId: new Types.ObjectId(userId), occurredAt: { $gte: since } } }, + { $group: { + _id: '$postId', watchMs: { $sum: '$watchTimeMs' }, + completions: { $sum: { $cond: [{ $eq: ['$type', EngagementEventType.COMPLETE] }, 1, 0] } }, + rewatches: { $sum: { $cond: [{ $eq: ['$type', EngagementEventType.REWATCH] }, 1, 0] } }, + profileOpens: { $sum: { $cond: [{ $eq: ['$type', EngagementEventType.PROFILE_OPEN] }, 1, 0] } }, + } }, + { $sort: { watchMs: -1 } }, { $limit: 500 }, + ]).exec(); + return new Map(rows.map((row) => [row._id.toString(), row])); + } + + async getMySummary(userId: string) { + const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const byType = await this.eventModel.aggregate<{ _id: EngagementEventType; count: number; watchTimeMs: number }>([ + { $match: { userId: new Types.ObjectId(userId), occurredAt: { $gte: since } } }, + { $group: { _id: '$type', count: { $sum: 1 }, watchTimeMs: { $sum: '$watchTimeMs' } } }, + ]).exec(); + return { periodDays: 30, byType: Object.fromEntries(byType.map((row) => [row._id, { count: row.count, watchTimeMs: row.watchTimeMs }])) }; + } +} diff --git a/src/modules/engagement/schemas/engagement-event.schema.ts b/src/modules/engagement/schemas/engagement-event.schema.ts new file mode 100644 index 0000000..205614c --- /dev/null +++ b/src/modules/engagement/schemas/engagement-event.schema.ts @@ -0,0 +1,35 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument, Types } from 'mongoose'; +import { EngagementEventType } from '../engagement-event-type.enum'; + +export type EngagementEventDocument = HydratedDocument; + +@Schema({ timestamps: true, versionKey: false }) +export class EngagementEvent { + @Prop({ type: Types.ObjectId, required: true, index: true }) + userId!: Types.ObjectId; + + @Prop({ type: Types.ObjectId, required: true, index: true }) + postId!: Types.ObjectId; + + @Prop({ enum: EngagementEventType, required: true, index: true }) + type!: EngagementEventType; + + @Prop({ min: 0, max: 7_200_000, default: 0 }) + watchTimeMs!: number; + + @Prop({ min: 0, max: 100, default: 0 }) + progressPercent!: number; + + @Prop({ default: '', maxlength: 100 }) + sessionId!: string; + + @Prop({ type: Date, default: Date.now }) + occurredAt!: Date; +} + +export const EngagementEventSchema = SchemaFactory.createForClass(EngagementEvent); +EngagementEventSchema.index({ userId: 1, occurredAt: -1 }); +EngagementEventSchema.index({ userId: 1, postId: 1, occurredAt: -1 }); +EngagementEventSchema.index({ postId: 1, type: 1, occurredAt: -1 }); +EngagementEventSchema.index({ occurredAt: 1 }, { expireAfterSeconds: 180 * 24 * 60 * 60 }); diff --git a/src/modules/engagement/schemas/post-preference.schema.ts b/src/modules/engagement/schemas/post-preference.schema.ts new file mode 100644 index 0000000..95eb732 --- /dev/null +++ b/src/modules/engagement/schemas/post-preference.schema.ts @@ -0,0 +1,19 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument, Types } from 'mongoose'; + +export type PostPreferenceDocument = HydratedDocument; + +@Schema({ timestamps: true, versionKey: false }) +export class PostPreference { + @Prop({ type: Types.ObjectId, required: true, index: true }) + userId!: Types.ObjectId; + + @Prop({ type: Types.ObjectId, required: true, index: true }) + postId!: Types.ObjectId; + + @Prop({ default: true }) + notInterested!: boolean; +} + +export const PostPreferenceSchema = SchemaFactory.createForClass(PostPreference); +PostPreferenceSchema.index({ userId: 1, postId: 1 }, { unique: true }); diff --git a/src/modules/feed/feed.repository.ts b/src/modules/feed/feed.repository.ts index 9264d89..7bae467 100644 --- a/src/modules/feed/feed.repository.ts +++ b/src/modules/feed/feed.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { FilterQuery, Model, Types } from 'mongoose'; +import { ModerationStatus } from '../../common/enums/moderation-status.enum'; import { Follow, FollowDocument } from '../follows/schemas/follow.schema'; import { Post, PostDocument } from '../posts/schemas/post.schema'; @@ -29,6 +30,7 @@ export class FeedRepository { ...filter, isDeleted: { $ne: true }, isArchived: { $ne: true }, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, }; return this.postModel @@ -36,21 +38,56 @@ export class FeedRepository { .populate({ path: 'authorId', select: - 'name username stageName avatar isVerified isDisabled location latitude longitude musicGenres musicRoles favoriteInstruments favoriteMaqamat', + 'name username stageName avatar isVerified isDisabled isPrivate location latitude longitude musicGenres musicRoles favoriteInstruments favoriteMaqamat followersCount', }) .sort({ createdAt: -1 }) .limit(limit) .exec(); } + async findPopularCandidatePosts( + filter: FilterQuery, + limit: number, + ): Promise { + return this.postModel + .find({ + ...filter, + isDeleted: { $ne: true }, + isArchived: { $ne: true }, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + }) + .populate({ + path: 'authorId', + select: + 'name username stageName avatar isVerified isDisabled isPrivate location latitude longitude musicGenres musicRoles favoriteInstruments favoriteMaqamat followersCount', + }) + .sort({ + savesCount: -1, + shareCount: -1, + commentsCount: -1, + likesCount: -1, + createdAt: -1, + }) + .limit(limit) + .exec(); + } + async findTrendingPublicPosts( filter: FilterQuery, skip: number, limit: number, ): Promise { return this.postModel - .find({ ...filter, isDeleted: { $ne: true }, isArchived: { $ne: true } }) - .populate({ path: 'authorId', select: 'name username stageName avatar isVerified isDisabled' }) + .find({ + ...filter, + isDeleted: { $ne: true }, + isArchived: { $ne: true }, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + }) + .populate({ + path: 'authorId', + select: 'name username stageName avatar isVerified isDisabled isPrivate', + }) .sort({ shareCount: -1, likesCount: -1, @@ -67,7 +104,12 @@ export class FeedRepository { async count(filter: FilterQuery): Promise { return this.postModel - .countDocuments({ ...filter, isDeleted: { $ne: true }, isArchived: { $ne: true } }) + .countDocuments({ + ...filter, + isDeleted: { $ne: true }, + isArchived: { $ne: true }, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + }) .exec(); } } diff --git a/src/modules/feed/feed.service.spec.ts b/src/modules/feed/feed.service.spec.ts index c6520a3..3b42886 100644 --- a/src/modules/feed/feed.service.spec.ts +++ b/src/modules/feed/feed.service.spec.ts @@ -16,7 +16,9 @@ const makePost = (input: Partial> = {}) => { _id: new Types.ObjectId(authorId), id: authorId, username: `author_${authorId.slice(-4)}`, - isVerified: false, + isVerified: input.authorIsVerified ?? false, + isDisabled: input.authorIsDisabled ?? false, + followersCount: input.authorFollowersCount ?? 0, }, content: input.content ?? `${postType} content`, postType, @@ -30,6 +32,7 @@ const makePost = (input: Partial> = {}) => { shareCount: input.shareCount ?? 0, viewCount: input.viewCount ?? 0, playCount: input.playCount ?? 0, + hashtags: input.hashtags ?? [], commentsDisabled: input.commentsDisabled ?? false, createdAt: input.createdAt ?? new Date(), }; @@ -47,6 +50,7 @@ const createService = () => { const feedRepository = { findFollowingIds: jest.fn().mockResolvedValue([]), findCandidatePosts: jest.fn().mockResolvedValue([]), + findPopularCandidatePosts: jest.fn().mockResolvedValue([]), findTrendingPublicPosts: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), }; @@ -58,13 +62,16 @@ const createService = () => { favoriteMaqamat: [], musicRoles: [], }), + findPrivateUserIds: jest.fn().mockResolvedValue([]), }; const cacheService = { get: jest.fn(), set: jest.fn(), + acquireFillLock: jest.fn().mockResolvedValue({ acquired: true, release: jest.fn().mockResolvedValue(true) }), + waitForValue: jest.fn(), }; const configService = { - get: jest.fn((key: string) => { + get: jest.fn((key: string): unknown => { if (key === 'feedCache.enabled') { return false; } @@ -77,34 +84,59 @@ const createService = () => { const savesRepository = { findSavedPostIds: jest.fn().mockResolvedValue([]), }; + const emptyCursor = { + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + project: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue([]), + }; const connection = { collection: jest.fn(() => ({ - find: jest.fn(() => ({ - project: jest.fn().mockReturnThis(), - toArray: jest.fn().mockResolvedValue([]), - })), + find: jest.fn(() => emptyCursor), + aggregate: jest.fn(() => ({ toArray: jest.fn().mockResolvedValue([]) })), })), }; + const feedVersionService = { + getGlobalVersion: jest.fn().mockResolvedValue(1), + getUserVersion: jest.fn().mockResolvedValue(1), + bumpGlobalVersion: jest.fn(), + }; + const followsService = { getSuggestions: jest.fn().mockResolvedValue({ items: [] }) }; + const marketplaceService = { + getPublicListings: jest.fn().mockResolvedValue({ items: [] }), + getPublicInstruments: jest.fn().mockResolvedValue({ items: [] }), + getPublicRepairShops: jest.fn().mockResolvedValue({ items: [] }), + }; + const blocksService = { getInvisibleUserIds: jest.fn().mockResolvedValue([]) }; const service = new FeedService( feedRepository as any, usersRepository as any, cacheService as any, - { getGlobalVersion: jest.fn(), bumpGlobalVersion: jest.fn() } as any, + feedVersionService as any, configService as any, likesRepository as any, savesRepository as any, - { getSuggestions: jest.fn() } as any, - { - getPublicListings: jest.fn(), - getPublicInstruments: jest.fn(), - getPublicRepairShops: jest.fn(), - } as any, - { getInvisibleUserIds: jest.fn().mockResolvedValue([]) } as any, + followsService as any, + marketplaceService as any, + blocksService as any, connection as any, ); - return { service, feedRepository, likesRepository, savesRepository }; + return { + service, + feedRepository, + usersRepository, + cacheService, + configService, + feedVersionService, + likesRepository, + savesRepository, + followsService, + marketplaceService, + blocksService, + connection, + }; }; describe('FeedService reels feed', () => { @@ -152,12 +184,9 @@ describe('FeedService reels feed', () => { limit: 10, })) as any; - expect(feedRepository.findCandidatePosts).toHaveBeenCalledWith( - expect.objectContaining({ - $and: expect.arrayContaining([expect.objectContaining({ postType: PostType.VIDEO })]), - }), - expect.any(Number), - ); + const candidateFilter = feedRepository.findCandidatePosts.mock.calls[0][0]; + expect(JSON.stringify(candidateFilter)).toContain(`"postType":"${PostType.VIDEO}"`); + expect(JSON.stringify(candidateFilter)).toContain('"createdAt"'); expect(result.items).toHaveLength(1); expect(result.items[0]).toEqual( expect.objectContaining({ @@ -228,4 +257,388 @@ describe('FeedService reels feed', () => { expect(result.items).toHaveLength(1); expect(result.items[0]).toEqual(expect.objectContaining({ postType: 'reel' })); }); + + it('never returns posts from disabled authors', async () => { + const { service, feedRepository } = createService(); + const visible = makePost({ authorIsDisabled: false }); + const disabled = makePost({ authorIsDisabled: true, likesCount: 100_000 }); + feedRepository.findCandidatePosts.mockResolvedValue([disabled, visible]); + + const result = (await service.getMyFeed(new Types.ObjectId().toString(), { + includeSuggestions: false, + limit: 10, + })) as any; + + expect(result.items.map((item: any) => item.id)).toEqual([visible.id]); + }); + + it('uses recent viewer interactions to personalize ranking', async () => { + const { service, feedRepository } = createService(); + const preferredAuthorId = new Types.ObjectId().toString(); + const otherAuthorId = new Types.ObjectId().toString(); + const preferred = makePost({ authorId: preferredAuthorId }); + const other = makePost({ authorId: otherAuthorId }); + feedRepository.findCandidatePosts.mockResolvedValue([other, preferred]); + jest.spyOn(service as any, 'getViewerRankingProfile').mockResolvedValue({ + authorAffinity: { [preferredAuthorId]: 20 }, + postTypeAffinity: {}, + hashtagAffinity: {}, + interactedPostIds: [], + }); + + const result = (await service.getMyFeed(new Types.ObjectId().toString(), { + includeSuggestions: false, + limit: 10, + })) as any; + + expect(result.items[0].id).toBe(preferred.id); + }); + + it('diversifies consecutive authors even when one author has several strong posts', async () => { + const { service, feedRepository } = createService(); + const repeatedAuthorId = new Types.ObjectId().toString(); + const alternateAuthorId = new Types.ObjectId().toString(); + const posts = [ + makePost({ authorId: repeatedAuthorId, likesCount: 20 }), + makePost({ authorId: repeatedAuthorId, likesCount: 18 }), + makePost({ authorId: alternateAuthorId, likesCount: 10 }), + ]; + feedRepository.findCandidatePosts.mockResolvedValue(posts); + + const result = (await service.getMyFeed(new Types.ObjectId().toString(), { + includeSuggestions: false, + limit: 10, + })) as any; + + const firstAuthor = result.items[0].authorId._id.toString(); + const secondAuthor = result.items[1].authorId._id.toString(); + expect(firstAuthor).not.toBe(secondAuthor); + }); + + it('hard-filters posts already reported by the viewer', async () => { + const { service, feedRepository } = createService(); + const reportedId = new Types.ObjectId(); + feedRepository.findCandidatePosts.mockResolvedValue([]); + jest.spyOn(service as any, 'findReportedPostIds').mockResolvedValue([reportedId]); + + await service.getMyFeed(new Types.ObjectId().toString(), { + includeSuggestions: false, + followingOnly: false, + limit: 10, + }); + + const candidateFilter = feedRepository.findCandidatePosts.mock.calls[0][0]; + expect(JSON.stringify(candidateFilter)).toContain(reportedId.toString()); + }); + + it('encodes a stable ranking timestamp in the opaque cursor', async () => { + const { service, feedRepository } = createService(); + feedRepository.findCandidatePosts.mockResolvedValue([ + makePost({ createdAt: new Date('2026-07-06T10:02:00.000Z') }), + makePost({ createdAt: new Date('2026-07-06T10:01:00.000Z') }), + ]); + + const firstPage = (await service.getMyFeed(new Types.ObjectId().toString(), { + includeSuggestions: false, + limit: 1, + })) as any; + const payload = JSON.parse(Buffer.from(firstPage.nextCursor, 'base64url').toString('utf8')); + + expect(payload).toMatchObject({ version: 1, offset: 1 }); + expect(new Date(payload.rankedAt).toString()).not.toBe('Invalid Date'); + expect(decodeOffsetCursor(firstPage.nextCursor)).toBe(1); + }); + + it('rejects a feed request when the current user no longer exists', async () => { + const { service, usersRepository, feedRepository } = createService(); + usersRepository.findById.mockResolvedValue(null); + + await expect( + service.getMyFeed(new Types.ObjectId().toString(), { includeSuggestions: false }), + ).rejects.toThrow('Current user not found'); + expect(feedRepository.findCandidatePosts).not.toHaveBeenCalled(); + }); + + it('falls back from an empty following feed to public discovery for default requests', async () => { + const { service, feedRepository } = createService(); + const discovered = makePost(); + feedRepository.findCandidatePosts + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([discovered]); + + const result = (await service.getMyFeed(new Types.ObjectId().toString(), { + includeSuggestions: false, + limit: 10, + })) as any; + + expect(feedRepository.findCandidatePosts).toHaveBeenCalledTimes(2); + expect(result.items.map((item: any) => item.id)).toEqual([discovered.id]); + + const explicit = createService(); + explicit.feedRepository.findCandidatePosts.mockResolvedValue([]); + await explicit.service.getMyFeed(new Types.ObjectId().toString(), { + followingOnly: true, + includeSuggestions: false, + }); + expect(explicit.feedRepository.findCandidatePosts).toHaveBeenCalledTimes(1); + }); + + it('returns cached home feeds and coalesces concurrent cache fills', async () => { + const cached = { items: [{ id: 'cached' }], nextCursor: null }; + const first = createService(); + first.configService.get.mockImplementation((key: string) => key === 'feedCache.enabled'); + first.cacheService.get.mockResolvedValue(cached); + await expect(first.service.getMyFeed(new Types.ObjectId().toString(), {})).resolves.toBe(cached); + expect(first.feedRepository.findCandidatePosts).not.toHaveBeenCalled(); + + const coalesced = createService(); + coalesced.configService.get.mockImplementation((key: string) => key === 'feedCache.enabled'); + coalesced.cacheService.get.mockResolvedValue(null); + coalesced.cacheService.acquireFillLock.mockResolvedValue({ acquired: false }); + coalesced.cacheService.waitForValue.mockResolvedValue(cached); + await expect(coalesced.service.getMyFeed(new Types.ObjectId().toString(), {})).resolves.toBe(cached); + expect(coalesced.cacheService.waitForValue).toHaveBeenCalledWith(expect.stringContaining('feed:me:'), 2000); + }); + + it('stores freshly ranked home feeds and releases the cache fill lock', async () => { + const ctx = createService(); + const release = jest.fn().mockResolvedValue(true); + ctx.configService.get.mockImplementation((key: string) => { + if (key === 'feedCache.enabled') return true; + if (key === 'feedCache.userFeedTtlSeconds') return 45; + return false; + }); + ctx.cacheService.get.mockResolvedValue(null); + ctx.cacheService.acquireFillLock.mockResolvedValue({ acquired: true, release }); + ctx.feedRepository.findCandidatePosts.mockResolvedValue([makePost()]); + + const result = await ctx.service.getMyFeed(new Types.ObjectId().toString(), { + includeSuggestions: false, + }); + expect(ctx.cacheService.set).toHaveBeenCalledWith(expect.stringContaining('feed:me:'), result, 45); + expect(release).toHaveBeenCalled(); + }); + + it('mixes creator and marketplace cards into only the first requested home page', async () => { + const ctx = createService(); + ctx.feedRepository.findCandidatePosts.mockResolvedValue([ + makePost(), + makePost(), + makePost(), + makePost(), + ]); + ctx.followsService.getSuggestions.mockResolvedValue({ items: [{ user: { id: 'creator' } }] }); + ctx.marketplaceService.getPublicListings.mockResolvedValue({ items: [{ id: 'listing' }] }); + ctx.marketplaceService.getPublicInstruments.mockResolvedValue({ items: [{ id: 'instrument' }] }); + ctx.marketplaceService.getPublicRepairShops.mockResolvedValue({ items: [{ id: 'shop' }] }); + + const result = (await ctx.service.getMyFeed(new Types.ObjectId().toString(), { + includeSuggestions: true, + suggestionInterval: 2, + limit: 10, + })) as any; + expect(result.items.map((item: any) => item.feedItemType)).toEqual([ + 'post', + 'post', + 'suggested_users', + 'post', + 'post', + 'featured_marketplace', + ]); + expect(result.items[2].items[0]).toMatchObject({ following: false }); + expect(result.items[5]).toMatchObject({ + listings: [{ id: 'listing' }], + instruments: [{ id: 'instrument' }], + repairShops: [{ id: 'shop' }], + }); + + const later = createService(); + later.feedRepository.findCandidatePosts.mockResolvedValue([makePost()]); + await later.service.getMyFeed(new Types.ObjectId().toString(), { + includeSuggestions: true, + page: 2, + }); + expect(later.followsService.getSuggestions).not.toHaveBeenCalled(); + }); + + it('builds a cursor-based trending feed with visibility, block and reel filters', async () => { + const ctx = createService(); + const invisibleId = new Types.ObjectId().toString(); + const visible = makePost({ + postType: PostType.VIDEO, + videoUrl: 'https://cdn/reel.mp4', + }); + const disabled = makePost({ postType: PostType.VIDEO, authorIsDisabled: true }); + ctx.blocksService.getInvisibleUserIds.mockResolvedValue([invisibleId]); + ctx.feedRepository.findFollowingIds.mockResolvedValue([new Types.ObjectId().toString()]); + ctx.feedRepository.findTrendingPublicPosts.mockResolvedValue([visible, disabled]); + ctx.feedRepository.count.mockResolvedValue(3); + ctx.likesRepository.findLikedPostIds.mockResolvedValue([visible.id]); + + const result = (await ctx.service.getTrending(new Types.ObjectId().toString(), { + preferredPostType: 'reel', + page: 1, + limit: 2, + })) as any; + + expect(ctx.feedRepository.findTrendingPublicPosts).toHaveBeenCalledWith( + { + visibility: PostVisibility.PUBLIC, + authorId: { $nin: [new Types.ObjectId(invisibleId)] }, + postType: PostType.VIDEO, + }, + 0, + 2, + ); + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ postType: 'reel', likedByMe: true }); + expect(result.nextCursor).toBeTruthy(); + }); + + it('excludes private authors from trending and explore queries', async () => { + const ctx = createService(); + const privateAuthorId = new Types.ObjectId().toString(); + ctx.usersRepository.findPrivateUserIds.mockResolvedValue([privateAuthorId]); + + await ctx.service.getTrending(new Types.ObjectId().toString(), { limit: 10 }); + + expect(ctx.feedRepository.findTrendingPublicPosts).toHaveBeenCalledWith( + expect.objectContaining({ + authorId: { $nin: [new Types.ObjectId(privateAuthorId)] }, + }), + 0, + 10, + ); + }); + + it('allows approved private follows in home feed but excludes pending private authors', async () => { + const privateAuthorId = new Types.ObjectId().toString(); + const viewerId = new Types.ObjectId().toString(); + const approved = createService(); + approved.feedRepository.findFollowingIds.mockResolvedValue([privateAuthorId]); + approved.usersRepository.findPrivateUserIds.mockResolvedValue([]); + await approved.service.getMyFeed(viewerId, { + followingOnly: false, + includeSuggestions: false, + }); + const approvedFilter = approved.feedRepository.findCandidatePosts.mock.calls[0][0]; + expect(JSON.stringify(approvedFilter)).toContain(privateAuthorId); + + const pending = createService(); + pending.feedRepository.findFollowingIds.mockResolvedValue([]); + pending.usersRepository.findPrivateUserIds.mockResolvedValue([privateAuthorId]); + await pending.service.getMyFeed(viewerId, { + followingOnly: false, + includeSuggestions: false, + }); + const pendingFilter = pending.feedRepository.findCandidatePosts.mock.calls[0][0]; + expect(JSON.stringify(pendingFilter)).toContain(privateAuthorId); + expect(JSON.stringify(pendingFilter)).toContain('$nin'); + }); + + it('makes explore a public trending request and disables suggestion cards', async () => { + const { service } = createService(); + const trending = jest.spyOn(service, 'getTrending').mockResolvedValue({ items: [] } as any); + await service.getExplore(new Types.ObjectId().toString(), { + followingOnly: true, + includeSuggestions: true, + }); + expect(trending).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ followingOnly: false, includeSuggestions: false }), + ); + }); + + it('returns cached and coalesced trending feeds and caches fresh results', async () => { + const cached = { items: [], nextCursor: null }; + const hit = createService(); + hit.configService.get.mockImplementation((key: string) => key === 'feedCache.enabled'); + hit.cacheService.get.mockResolvedValue(cached); + await expect(hit.service.getTrending(new Types.ObjectId().toString(), {})).resolves.toBe(cached); + + const coalesced = createService(); + coalesced.configService.get.mockImplementation((key: string) => key === 'feedCache.enabled'); + coalesced.cacheService.get.mockResolvedValue(null); + coalesced.cacheService.acquireFillLock.mockResolvedValue({ acquired: false }); + coalesced.cacheService.waitForValue.mockResolvedValue(cached); + await expect(coalesced.service.getTrending(new Types.ObjectId().toString(), {})).resolves.toBe(cached); + + const fresh = createService(); + const release = jest.fn().mockResolvedValue(true); + fresh.configService.get.mockImplementation((key: string) => { + if (key === 'feedCache.enabled') return true; + if (key === 'feedCache.trendingTtlSeconds') return 12; + return false; + }); + fresh.cacheService.get.mockResolvedValue(null); + fresh.cacheService.acquireFillLock.mockResolvedValue({ acquired: true, release }); + await fresh.service.getTrending(new Types.ObjectId().toString(), {}); + expect(fresh.cacheService.set).toHaveBeenCalledWith( + expect.stringContaining('feed:trending:'), + expect.any(Object), + 12, + ); + expect(release).toHaveBeenCalled(); + }); + + it('weights watch quality, proximity and preferences in ranking', () => { + const { service } = createService(); + const viewerId = new Types.ObjectId().toString(); + const authorId = new Types.ObjectId().toString(); + const base = { + currentUser: { + latitude: 24.7136, + longitude: 46.6753, + musicGenres: ['Oud'], + favoriteInstruments: [], + favoriteMaqamat: [], + musicRoles: [], + }, + currentUserId: viewerId, + followingIds: [authorId], + post: makePost({ authorId, hashtags: ['oud'], postType: PostType.VIDEO }), + preferredPostType: 'reel', + radiusKm: 30, + rankedAt: new Date(), + }; + (base.post as any).authorId.latitude = 24.72; + (base.post as any).authorId.longitude = 46.68; + const neutral = (service as any).scorePost({ + ...base, + rankingProfile: { + authorAffinity: {}, + postTypeAffinity: {}, + hashtagAffinity: {}, + interactedPostIds: [], + engagementSignals: {}, + }, + }); + const personalized = (service as any).scorePost({ + ...base, + rankingProfile: { + authorAffinity: { [authorId]: 10 }, + postTypeAffinity: { video: 8 }, + hashtagAffinity: { oud: 6 }, + interactedPostIds: [], + engagementSignals: { + [base.post.id]: { watchMs: 60_000, completions: 2, rewatches: 1, profileOpens: 1 }, + }, + }, + }); + expect(personalized).toBeGreaterThan(neutral); + expect((service as any).computeDistanceKm(null, 0, 0, 0)).toBeNull(); + expect((service as any).computeDistanceKm(24.7136, 46.6753, 24.7136, 46.6753)).toBe(0); + }); + + it('falls back safely for malformed opaque feed cursors', async () => { + const { service, feedRepository } = createService(); + feedRepository.findCandidatePosts.mockResolvedValue([makePost()]); + await expect( + service.getMyFeed(new Types.ObjectId().toString(), { + cursor: 'not-a-valid-cursor', + includeSuggestions: false, + }), + ).resolves.toBeDefined(); + expect(feedRepository.findCandidatePosts).toHaveBeenCalled(); + }); }); diff --git a/src/modules/feed/feed.service.ts b/src/modules/feed/feed.service.ts index 3782970..de25d6c 100644 --- a/src/modules/feed/feed.service.ts +++ b/src/modules/feed/feed.service.ts @@ -59,6 +59,19 @@ type FeedCardItem = repairShops: Array>; }; +type ViewerRankingProfile = { + authorAffinity: Record; + postTypeAffinity: Record; + hashtagAffinity: Record; + interactedPostIds: string[]; + engagementSignals: Record; +}; + +type RankedPost = { + post: Record & { toObject(): unknown }; + score: number; +}; + @Injectable() export class FeedService { private readonly logger = new Logger(FeedService.name); @@ -82,11 +95,17 @@ export class FeedService { const followingOnly = query.followingOnly ?? true; const cacheEnabled = this.configService.get('feedCache.enabled', { infer: true }) ?? true; - const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0; + const [globalVersion, userVersion] = cacheEnabled + ? await Promise.all([ + this.feedVersionService.getGlobalVersion(), + this.feedVersionService.getUserVersion(currentUserId), + ]) + : [0, 0]; const includeSuggestions = this.shouldIncludeSuggestions(query); const cacheKey = this.buildCacheKey('me', { currentUserId, globalVersion, + userVersion, page: query.page ?? 1, limit: query.limit ?? 20, cursor: query.cursor ?? '', @@ -96,6 +115,7 @@ export class FeedService { includeSuggestions, suggestionInterval: query.suggestionInterval ?? 4, }); + let cacheFillLock: Awaited> | null = null; if (cacheEnabled) { const cached = await this.cacheService.get>(cacheKey); if (cached) { @@ -107,50 +127,85 @@ export class FeedService { }); return cached; } - } - - const currentUser = await this.usersRepository.findById(currentUserId); - if (!currentUser) { - throw new NotFoundException('Current user not found'); + cacheFillLock = await this.cacheService.acquireFillLock(cacheKey, 10); + if (!cacheFillLock.acquired) { + const coalesced = await this.cacheService.waitForValue>( + cacheKey, + 2_000, + ); + if (coalesced) { + this.logFeedTiming(timing, { + cacheHit: true, + cacheCoalesced: true, + itemCount: Array.isArray(coalesced.items) ? coalesced.items.length : undefined, + responseBytes: this.measureResponseBytes(coalesced), + }); + return coalesced; + } + } } const limit = query.limit ?? 20; - const cursorOffset = decodeOffsetCursor(query.cursor); + const cursorState = this.decodeFeedCursor(query.cursor); + const cursorOffset = cursorState.offset; + const rankedAt = cursorState.rankedAt; const page = query.page ?? 1; const radiusKm = query.radiusKm ?? 30; const skip = cursorOffset ?? (page - 1) * limit; const requestedPostType = this.resolveRequestedPostType(query.preferredPostType); - const [followingIds, invisibleUserIds] = await Promise.all([ + const [currentUser, followingIds, invisibleUserIds, reportedPostIds, notInterestedPostIds, rankingProfile] = await Promise.all([ + this.usersRepository.findById(currentUserId), this.feedRepository.findFollowingIds(currentUserId), this.blocksService.getInvisibleUserIds(currentUserId), + this.findReportedPostIds(currentUserId), + this.findNotInterestedPostIds(currentUserId), + this.getViewerRankingProfile(currentUserId), ]); + if (!currentUser) { + throw new NotFoundException('Current user not found'); + } + const unauthorizedPrivateAuthorIds = await this.usersRepository.findPrivateUserIds([ + currentUserId, + ...followingIds, + ]); + const hiddenAuthorIds = Array.from( + new Set([...invisibleUserIds, ...unauthorizedPrivateAuthorIds]), + ); const relationLookupMs = this.markTiming(timing); - let filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly, invisibleUserIds); + let filter = this.buildVisiblePostsFilter( + currentUserId, + followingIds, + followingOnly, + hiddenAuthorIds, + ); if (requestedPostType) { filter = { $and: [filter, { postType: requestedPostType }] }; } - let candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300)); + filter = this.addRankingWindowFilter(filter, rankedAt, [...reportedPostIds, ...notInterestedPostIds]); + const candidateLimit = Math.min(1000, Math.max(limit * 20, 400)); + let candidates = await this.findRankableCandidates(filter, candidateLimit); const firstCandidateLookupMs = this.markTiming(timing); // Keep the default home feed focused on followed accounts, but avoid an empty screen // for new users or when followed accounts have not posted yet. - if ( - candidates.length === 0 && - typeof query.followingOnly === 'undefined' && - followingOnly - ) { - filter = this.buildVisiblePostsFilter(currentUserId, followingIds, false, invisibleUserIds); + if (candidates.length === 0 && typeof query.followingOnly === 'undefined' && followingOnly) { + filter = this.buildVisiblePostsFilter(currentUserId, followingIds, false, hiddenAuthorIds); if (requestedPostType) { filter = { $and: [filter, { postType: requestedPostType }] }; } - candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300)); + filter = this.addRankingWindowFilter(filter, rankedAt, [...reportedPostIds, ...notInterestedPostIds]); + candidates = await this.findRankableCandidates(filter, candidateLimit); } const fallbackCandidateLookupMs = this.markTiming(timing); + const activeAuthorCandidates = candidates.filter((post) => { + const author = post.authorId as unknown as { isDisabled?: boolean } | null; + return !!author && author.isDisabled !== true; + }); const filteredCandidates = requestedPostType - ? candidates.filter((post) => post.postType === requestedPostType) - : candidates; + ? activeAuthorCandidates.filter((post) => post.postType === requestedPostType) + : activeAuthorCandidates; const scored = filteredCandidates .map((post) => ({ @@ -162,6 +217,8 @@ export class FeedService { post, preferredPostType: query.preferredPostType, radiusKm, + rankingProfile, + rankedAt, }), })) .sort( @@ -172,12 +229,17 @@ export class FeedService { ); const scoringMs = this.markTiming(timing); - const total = scored.length; - const pagedPosts = scored.slice(skip, skip + limit).map((entry) => ({ + const diversified = this.diversifyRankedPosts(scored); + const total = diversified.length; + const pagedPosts = diversified.slice(skip, skip + limit).map((entry) => ({ ...(entry.post.toObject() as unknown as Record), feedScore: Number(entry.score.toFixed(3)), })); - const decoratedPosts = await this.decoratePostsForViewer(currentUserId, pagedPosts, followingIds); + const decoratedPosts = await this.decoratePostsForViewer( + currentUserId, + pagedPosts, + followingIds, + ); const normalizedPosts = this.normalizePreferredPostTypeForResponse( decoratedPosts, query.preferredPostType, @@ -188,7 +250,7 @@ export class FeedService { : normalizedPosts; const cardsMs = this.markTiming(timing); const nextOffset = skip + pagedPosts.length; - const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; + const nextCursor = nextOffset < total ? this.encodeFeedCursor(nextOffset, rankedAt) : null; const result = buildPaginatedResponse(items, { page, @@ -199,6 +261,8 @@ export class FeedService { nextCursor, mode: 'cursor', }); + result.pagination.hasNextPage = nextCursor !== null; + result.pagination.nextPage = nextCursor !== null ? page + 1 : null; if (cacheEnabled) { await this.cacheService.set( @@ -207,6 +271,9 @@ export class FeedService { this.configService.get('feedCache.userFeedTtlSeconds', { infer: true }) ?? 15, ); } + if (cacheFillLock?.acquired) { + await cacheFillLock.release().catch(() => false); + } this.logFeedTiming(timing, { cacheHit: false, @@ -235,6 +302,273 @@ export class FeedService { return result; } + private async findRankableCandidates( + filter: Record, + limit: number, + ): Promise & { toObject(): unknown }>> { + const recentLimit = Math.ceil(limit * 0.65); + const popularLimit = limit - recentLimit; + const [recent, popular] = await Promise.all([ + this.feedRepository.findCandidatePosts(filter, recentLimit), + this.feedRepository.findPopularCandidatePosts(filter, popularLimit), + ]); + const unique = new Map & { toObject(): unknown }>(); + for (const post of [...recent, ...popular]) { + const id = this.extractEntityId((post as any)._id ?? (post as any).id); + if (id && !unique.has(id)) { + unique.set(id, post as unknown as Record & { toObject(): unknown }); + } + } + return Array.from(unique.values()); + } + + private addRankingWindowFilter( + filter: Record, + rankedAt: Date, + reportedPostIds: Types.ObjectId[], + ): Record { + const clauses: Record[] = [filter, { createdAt: { $lte: rankedAt } }]; + if (reportedPostIds.length) { + clauses.push({ _id: { $nin: reportedPostIds } }); + } + return { $and: clauses }; + } + + private async findReportedPostIds(currentUserId: string): Promise { + if (!Types.ObjectId.isValid(currentUserId)) { + return []; + } + const rows = await this.connection + .collection('reports') + .find({ + reporterId: new Types.ObjectId(currentUserId), + targetType: 'post', + status: { $ne: 'rejected' }, + }) + .project({ targetId: 1 }) + .toArray(); + return rows + .map((row) => row.targetId) + .filter((id): id is Types.ObjectId => id instanceof Types.ObjectId); + } + + private async findNotInterestedPostIds(currentUserId: string): Promise { + const rows = await this.connection + .collection<{ postId?: Types.ObjectId }>('postpreferences') + .find({ userId: new Types.ObjectId(currentUserId), notInterested: true }) + .project({ postId: 1 }) + .toArray(); + return rows.map((row) => row.postId).filter((id): id is Types.ObjectId => id instanceof Types.ObjectId); + } + + private async getViewerRankingProfile(currentUserId: string): Promise { + const empty: ViewerRankingProfile = { + authorAffinity: {}, + postTypeAffinity: {}, + hashtagAffinity: {}, + interactedPostIds: [], + engagementSignals: {}, + }; + if (!Types.ObjectId.isValid(currentUserId)) { + return empty; + } + + const cacheEnabled = + this.configService.get('feedCache.enabled', { infer: true }) ?? true; + const cacheKey = `feed:ranking-profile:${currentUserId}`; + if (cacheEnabled) { + const cached = await this.cacheService.get(cacheKey); + if (cached) { + return cached; + } + } + + const userId = new Types.ObjectId(currentUserId); + const cutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + const [likes, saves, shares, comments, engagementRows] = await Promise.all([ + this.connection + .collection('likes') + .find({ userId, targetType: 'post', createdAt: { $gte: cutoff } }) + .sort({ createdAt: -1 }) + .limit(250) + .project({ targetId: 1 }) + .toArray(), + this.connection + .collection('saves') + .find({ userId, createdAt: { $gte: cutoff } }) + .sort({ createdAt: -1 }) + .limit(250) + .project({ postId: 1 }) + .toArray(), + this.connection + .collection('postshares') + .find({ userId, isDeleted: false, createdAt: { $gte: cutoff } }) + .sort({ createdAt: -1 }) + .limit(250) + .project({ postId: 1 }) + .toArray(), + this.connection + .collection('comments') + .find({ authorId: userId, isDeleted: { $ne: true }, createdAt: { $gte: cutoff } }) + .sort({ createdAt: -1 }) + .limit(250) + .project({ postId: 1 }) + .toArray(), + this.connection.collection('engagementevents').aggregate<{ + _id: Types.ObjectId; watchMs: number; completions: number; rewatches: number; profileOpens: number; + }>([ + { $match: { userId, occurredAt: { $gte: cutoff } } }, + { $group: { + _id: '$postId', watchMs: { $sum: '$watchTimeMs' }, + completions: { $sum: { $cond: [{ $eq: ['$type', 'complete'] }, 1, 0] } }, + rewatches: { $sum: { $cond: [{ $eq: ['$type', 'rewatch'] }, 1, 0] } }, + profileOpens: { $sum: { $cond: [{ $eq: ['$type', 'profile_open'] }, 1, 0] } }, + } }, { $limit: 500 }, + ]).toArray(), + ]); + + const weights = new Map(); + const addWeight = (value: unknown, weight: number) => { + if (value instanceof Types.ObjectId) { + const id = value.toString(); + weights.set(id, (weights.get(id) ?? 0) + weight); + } + }; + likes.forEach((row) => addWeight(row.targetId, 1.5)); + saves.forEach((row) => addWeight(row.postId, 4)); + shares.forEach((row) => addWeight(row.postId, 5)); + comments.forEach((row) => addWeight(row.postId, 3)); + engagementRows.forEach((row) => + addWeight(row._id, Math.min(12, row.watchMs / 60_000) + row.completions * 4 + row.rewatches * 5 + row.profileOpens * 2), + ); + if (!weights.size) { + return empty; + } + + const postIds = Array.from(weights.keys()).map((id) => new Types.ObjectId(id)); + const posts = await this.connection + .collection('posts') + .find({ _id: { $in: postIds }, isDeleted: { $ne: true } }) + .project({ authorId: 1, postType: 1, hashtags: 1 }) + .toArray(); + const profile: ViewerRankingProfile = { + authorAffinity: {}, + postTypeAffinity: {}, + hashtagAffinity: {}, + interactedPostIds: Array.from(weights.keys()), + engagementSignals: Object.fromEntries(engagementRows.map((row) => [row._id.toString(), row])), + }; + for (const post of posts) { + const postWeight = weights.get(post._id.toString()) ?? 0; + const authorId = post.authorId?.toString?.() ?? ''; + const postType = typeof post.postType === 'string' ? post.postType : ''; + if (authorId) { + profile.authorAffinity[authorId] = (profile.authorAffinity[authorId] ?? 0) + postWeight; + } + if (postType) { + profile.postTypeAffinity[postType] = (profile.postTypeAffinity[postType] ?? 0) + postWeight; + } + for (const hashtag of Array.isArray(post.hashtags) ? post.hashtags : []) { + const normalized = String(hashtag).trim().toLowerCase(); + if (normalized) { + profile.hashtagAffinity[normalized] = + (profile.hashtagAffinity[normalized] ?? 0) + postWeight; + } + } + } + + if (cacheEnabled) { + await this.cacheService.set( + cacheKey, + profile, + this.configService.get('feedCache.rankingProfileTtlSeconds', { + infer: true, + }) ?? 30, + ); + } + return profile; + } + + private diversifyRankedPosts(ranked: RankedPost[]): RankedPost[] { + const remaining = [...ranked]; + const result: RankedPost[] = []; + const authorCounts = new Map(); + const typeCounts = new Map(); + const hashtagCounts = new Map(); + + while (remaining.length) { + let bestIndex = 0; + let bestAdjustedScore = Number.NEGATIVE_INFINITY; + for (let index = 0; index < remaining.length; index += 1) { + const entry = remaining[index]; + const authorId = this.extractEntityId(entry.post.authorId); + const postType = String(entry.post.postType ?? ''); + const hashtags = Array.isArray(entry.post.hashtags) + ? entry.post.hashtags.map((tag: unknown) => String(tag).toLowerCase()) + : []; + const previousAuthorId = result.length + ? this.extractEntityId(result[result.length - 1].post.authorId) + : ''; + const authorPenalty = (authorCounts.get(authorId) ?? 0) * 12; + const consecutivePenalty = authorId && authorId === previousAuthorId ? 22 : 0; + const typePenalty = Math.max(0, (typeCounts.get(postType) ?? 0) - 1) * 2.5; + const hashtagPenalty = Math.min( + 8, + hashtags.reduce((sum, tag) => sum + (hashtagCounts.get(tag) ?? 0) * 0.75, 0), + ); + const adjusted = + entry.score - authorPenalty - consecutivePenalty - typePenalty - hashtagPenalty; + if (adjusted > bestAdjustedScore) { + bestAdjustedScore = adjusted; + bestIndex = index; + } + } + + const [selected] = remaining.splice(bestIndex, 1); + const authorId = this.extractEntityId(selected.post.authorId); + const postType = String(selected.post.postType ?? ''); + authorCounts.set(authorId, (authorCounts.get(authorId) ?? 0) + 1); + typeCounts.set(postType, (typeCounts.get(postType) ?? 0) + 1); + for (const hashtag of Array.isArray(selected.post.hashtags) ? selected.post.hashtags : []) { + const normalized = String(hashtag).toLowerCase(); + hashtagCounts.set(normalized, (hashtagCounts.get(normalized) ?? 0) + 1); + } + result.push({ ...selected, score: bestAdjustedScore }); + } + return result; + } + + private decodeFeedCursor(cursor?: string): { offset: number | null; rankedAt: Date } { + const fallback = { offset: decodeOffsetCursor(cursor), rankedAt: new Date() }; + if (!cursor) { + return fallback; + } + try { + const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { + offset?: unknown; + rankedAt?: unknown; + }; + const rankedAt = new Date(String(payload.rankedAt ?? '')); + if ( + !Number.isInteger(payload.offset) || + Number(payload.offset) < 0 || + Number.isNaN(rankedAt.getTime()) + ) { + return fallback; + } + return { offset: Number(payload.offset), rankedAt }; + } catch { + return fallback; + } + } + + private encodeFeedCursor(offset: number, rankedAt: Date): string { + return Buffer.from( + JSON.stringify({ version: 1, offset, rankedAt: rankedAt.toISOString() }), + 'utf8', + ).toString('base64url'); + } + async getExplore(currentUserId: string, query: FeedQueryDto) { return this.getTrending(currentUserId, { ...query, @@ -264,6 +598,7 @@ export class FeedService { cursor: query.cursor ?? '', preferredPostType: query.preferredPostType ?? '', }); + let cacheFillLock: Awaited> | null = null; if (cacheEnabled) { const cached = await this.cacheService.get>(cacheKey); if (cached) { @@ -275,20 +610,38 @@ export class FeedService { }); return cached; } + cacheFillLock = await this.cacheService.acquireFillLock(cacheKey, 10); + if (!cacheFillLock.acquired) { + const coalesced = await this.cacheService.waitForValue>( + cacheKey, + 2_000, + ); + if (coalesced) { + this.logFeedTiming(timing, { + cacheHit: true, + cacheCoalesced: true, + itemCount: Array.isArray(coalesced.items) ? coalesced.items.length : undefined, + responseBytes: this.measureResponseBytes(coalesced), + }); + return coalesced; + } + } } const limit = query.limit ?? 20; const cursorOffset = decodeOffsetCursor(query.cursor); const page = query.page ?? 1; const skip = cursorOffset ?? (page - 1) * limit; - const [followingIds, invisibleUserIds] = await Promise.all([ + const [followingIds, invisibleUserIds, privateAuthorIds] = await Promise.all([ this.feedRepository.findFollowingIds(currentUserId), this.blocksService.getInvisibleUserIds(currentUserId), + this.usersRepository.findPrivateUserIds(), ]); const relationLookupMs = this.markTiming(timing); const trendingFilter: Record = { visibility: PostVisibility.PUBLIC }; - if (invisibleUserIds.length) { - trendingFilter.authorId = { $nin: invisibleUserIds.map((id) => new Types.ObjectId(id)) }; + const excludedAuthorIds = Array.from(new Set([...invisibleUserIds, ...privateAuthorIds])); + if (excludedAuthorIds.length) { + trendingFilter.authorId = { $nin: excludedAuthorIds.map((id) => new Types.ObjectId(id)) }; } const requestedPostType = this.resolveRequestedPostType(query.preferredPostType); if (requestedPostType) { @@ -300,9 +653,13 @@ export class FeedService { this.feedRepository.count(trendingFilter), ]); const postLookupMs = this.markTiming(timing); + const visibleRows = rows.filter((post) => { + const author = post.authorId as unknown as { isDisabled?: boolean } | null; + return !!author && author.isDisabled !== true; + }); const decoratedPosts = await this.decoratePostsForViewer( currentUserId, - rows.map((item) => item.toObject() as unknown as Record), + visibleRows.map((item) => item.toObject() as unknown as Record), followingIds, ); const normalizedPosts = this.normalizePreferredPostTypeForResponse( @@ -330,6 +687,9 @@ export class FeedService { this.configService.get('feedCache.trendingTtlSeconds', { infer: true }) ?? 30, ); } + if (cacheFillLock?.acquired) { + await cacheFillLock.release().catch(() => false); + } this.logFeedTiming(timing, { cacheHit: false, @@ -356,9 +716,12 @@ export class FeedService { items: Array>, followingIds: string[], ): Promise { - const postIds = items - .map((item) => this.extractEntityId(item._id ?? item.id)) - .filter(Boolean); + const safeItems = await this.redactInaccessibleOriginalReferences( + currentUserId, + items, + followingIds, + ); + const postIds = safeItems.map((item) => this.extractEntityId(item._id ?? item.id)).filter(Boolean); const followingSet = new Set(followingIds); const [likedPostIds, savedPostIds, sharedPostIds] = await Promise.all([ this.likesRepository.findLikedPostIds(currentUserId, postIds), @@ -369,7 +732,7 @@ export class FeedService { const savedSet = new Set(savedPostIds); const sharedSet = new Set(sharedPostIds); - return items.map((item) => { + return safeItems.map((item) => { const postId = this.extractEntityId(item._id ?? item.id); const authorId = this.extractEntityId(item.authorId); const likesCount = Number(item.likesCount ?? 0); @@ -402,6 +765,67 @@ export class FeedService { }); } + private async redactInaccessibleOriginalReferences( + currentUserId: string, + items: Array>, + followingIds: string[], + ): Promise>> { + const fields = ['repostOfPostId', 'quoteOfPostId'] as const; + const sourceIds = Array.from( + new Set( + items + .flatMap((item) => fields.map((field) => this.extractEntityId(item[field]))) + .filter((id) => Types.ObjectId.isValid(id)), + ), + ); + if (!sourceIds.length) { + return items; + } + + const [sources, unauthorizedPrivateAuthorIds, invisibleUserIds] = await Promise.all([ + this.connection + .collection('posts') + .find({ + _id: { $in: sourceIds.map((id) => new Types.ObjectId(id)) }, + isDeleted: { $ne: true }, + isArchived: { $ne: true }, + moderationStatus: { $ne: 'hidden' }, + }) + .project({ authorId: 1, visibility: 1 }) + .toArray(), + this.usersRepository.findPrivateUserIds([currentUserId, ...followingIds]), + this.blocksService.getInvisibleUserIds(currentUserId), + ]); + const sourceById = new Map(sources.map((source) => [source._id.toString(), source])); + const followingSet = new Set(followingIds); + const excludedAuthorSet = new Set([ + ...unauthorizedPrivateAuthorIds, + ...invisibleUserIds, + ]); + + return items.map((item) => { + const safe = { ...item }; + for (const field of fields) { + const sourceId = this.extractEntityId(item[field]); + if (!sourceId) continue; + const source = sourceById.get(sourceId); + const authorId = this.extractEntityId(source?.authorId); + const allowed = + !!source && + !!authorId && + !excludedAuthorSet.has(authorId) && + (authorId === currentUserId || + source.visibility === PostVisibility.PUBLIC || + (source.visibility === PostVisibility.FOLLOWERS && + followingSet.has(authorId))); + if (!allowed) { + safe[field] = null; + } + } + return safe; + }); + } + private async findSharedPostIds(currentUserId: string, postIds: string[]): Promise { if (!Types.ObjectId.isValid(currentUserId) || postIds.length === 0) { return []; @@ -524,24 +948,24 @@ export class FeedService { const visibilityFilter = followingOnly ? { - $or: [ - { authorId: currentUserObjectId }, - { - authorId: { $in: followingObjectIds }, - visibility: { $in: [PostVisibility.PUBLIC, PostVisibility.FOLLOWERS] }, - }, - ], - } + $or: [ + { authorId: currentUserObjectId }, + { + authorId: { $in: followingObjectIds }, + visibility: { $in: [PostVisibility.PUBLIC, PostVisibility.FOLLOWERS] }, + }, + ], + } : { - $or: [ - { visibility: PostVisibility.PUBLIC }, - { authorId: currentUserObjectId }, - { - authorId: { $in: followingObjectIds }, - visibility: PostVisibility.FOLLOWERS, - }, - ], - }; + $or: [ + { visibility: PostVisibility.PUBLIC }, + { authorId: currentUserObjectId }, + { + authorId: { $in: followingObjectIds }, + visibility: PostVisibility.FOLLOWERS, + }, + ], + }; if (!invisibleUserIds.length) { return visibilityFilter; @@ -562,28 +986,58 @@ export class FeedService { post: Record; preferredPostType?: FeedPostTypeFilter; radiusKm: number; + rankingProfile: ViewerRankingProfile; + rankedAt: Date; }): number { - const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input; + const { + currentUser, + currentUserId, + followingIds, + post, + preferredPostType, + radiusKm, + rankingProfile, + rankedAt, + } = input; const author = post.authorId; const authorId = this.extractEntityId(author); const isOwnPost = authorId === currentUserId; const isFollowing = followingIds.includes(authorId); - const ageMs = Date.now() - new Date(post.createdAt).getTime(); + const ageMs = Math.max(0, rankedAt.getTime() - new Date(post.createdAt).getTime()); const ageHours = ageMs / (1000 * 60 * 60); - const freshness = Math.max(0, 36 - ageHours); + const freshness = 32 * Math.exp(-ageHours / (24 * 3)); + const veryRecentBoost = ageHours <= 6 ? 5 : 0; const engagement = - Number(post.likesCount ?? 0) * 3 + - Number(post.commentsCount ?? 0) * 4 + - Number(post.savesCount ?? 0) * 5 + - Number(post.shareCount ?? 0) * 6 + - Number(post.viewCount ?? 0) * 0.15 + - Number(post.playCount ?? 0) * 0.25; + Math.log1p(Math.max(0, Number(post.likesCount ?? 0))) * 2.4 + + Math.log1p(Math.max(0, Number(post.commentsCount ?? 0))) * 3.2 + + Math.log1p(Math.max(0, Number(post.savesCount ?? 0))) * 4.2 + + Math.log1p(Math.max(0, Number(post.shareCount ?? 0))) * 4.8 + + Math.log1p(Math.max(0, Number(post.viewCount ?? 0))) * 0.7 + + Math.log1p(Math.max(0, Number(post.playCount ?? 0))) * 1.1; + const normalizedHashtags = (post.hashtags ?? []) + .map((value: string) => value.trim().toLowerCase()) + .filter(Boolean); const hashtagMatches = this.intersectionCount( this.buildPreferenceTokens(currentUser), - (post.hashtags ?? []).map((value: string) => value.toLowerCase()), + normalizedHashtags, ); + const behaviorHashtagAffinity = normalizedHashtags.reduce( + (sum: number, hashtag: string) => sum + (rankingProfile.hashtagAffinity[hashtag] ?? 0), + 0, + ); + const authorAffinity = rankingProfile.authorAffinity[authorId] ?? 0; + const postTypeAffinity = rankingProfile.postTypeAffinity[String(post.postType ?? '')] ?? 0; + const interactedBefore = rankingProfile.interactedPostIds.includes( + this.extractEntityId(post._id ?? post.id), + ); + const behavior = (rankingProfile.engagementSignals ?? {})[this.extractEntityId(post._id ?? post.id)]; + const watchQualityBoost = behavior + ? Math.min(18, Math.log1p(Math.max(0, behavior.watchMs) / 1000) * 2) + + Math.min(12, behavior.completions * 4) + Math.min(12, behavior.rewatches * 6) + + Math.min(6, behavior.profileOpens * 2) + : 0; const distanceKm = this.computeDistanceKm( currentUser.latitude, @@ -592,18 +1046,26 @@ export class FeedService { author?.longitude ?? null, ); const nearbyBoost = - typeof distanceKm === 'number' && distanceKm <= radiusKm ? Math.max(0, 25 - distanceKm / 2) : 0; + typeof distanceKm === 'number' && distanceKm <= radiusKm + ? Math.max(0, 8 - (distanceKm / radiusKm) * 8) + : 0; let score = 0; score += engagement; score += freshness; - score += isOwnPost ? 10 : 0; - score += isFollowing ? 40 : 0; - score += post.postType === preferredPostType ? 18 : 0; - score += hashtagMatches * 9; + score += veryRecentBoost; + score += isOwnPost ? 3 : 0; + score += isFollowing ? 24 : 0; + score += post.postType === preferredPostType ? 12 : 0; + score += hashtagMatches * 7; + score += Math.min(24, Math.log1p(authorAffinity) * 7); + score += Math.min(14, Math.log1p(postTypeAffinity) * 4); + score += Math.min(16, Math.log1p(behaviorHashtagAffinity) * 4); + score += watchQualityBoost; score += nearbyBoost; - score += author?.isVerified ? 8 : 0; - score += Math.min(20, Math.floor((author?.followersCount ?? 0) / 200)); + score += author?.isVerified ? 2.5 : 0; + score += Math.min(6, Math.log1p(Math.max(0, Number(author?.followersCount ?? 0)))); + score -= interactedBefore ? 10 : 0; return score; } @@ -668,9 +1130,7 @@ export class FeedService { private shouldIncludeSuggestions(query: FeedQueryDto): boolean { return ( - query.includeSuggestions === true && - !(query.cursor ?? '').trim() && - (query.page ?? 1) === 1 + query.includeSuggestions === true && !(query.cursor ?? '').trim() && (query.page ?? 1) === 1 ); } diff --git a/src/modules/follows/follows-users.controller.ts b/src/modules/follows/follows-users.controller.ts index b316aa3..4ead25f 100644 --- a/src/modules/follows/follows-users.controller.ts +++ b/src/modules/follows/follows-users.controller.ts @@ -33,6 +33,11 @@ export class FollowsUsersController { return this.followsService.unfollowUser(user.sub, targetUserId); } + @Delete(':followerId/followers') + async removeFollower(@CurrentUser() user: JwtPayload, @Param('followerId') followerId: string) { + return this.followsService.removeFollower(user.sub, followerId); + } + @Get(':userId/follow-status') async followStatus(@CurrentUser() user: JwtPayload, @Param('userId') targetUserId: string) { return this.followsService.getFollowStatus(user.sub, targetUserId); diff --git a/src/modules/follows/follows.repository.ts b/src/modules/follows/follows.repository.ts index 03963e8..e610585 100644 --- a/src/modules/follows/follows.repository.ts +++ b/src/modules/follows/follows.repository.ts @@ -52,6 +52,23 @@ export class FollowsRepository { await this.followModel.findByIdAndDelete(id, { session }).exec(); } + async deleteRelationship( + followerId: string, + followingId: string, + session?: ClientSession, + ): Promise { + const result = await this.followModel + .deleteOne( + { + followerId: new Types.ObjectId(followerId), + followingId: new Types.ObjectId(followingId), + }, + { session }, + ) + .exec(); + return (result.deletedCount ?? 0) > 0; + } + async findMany( filter: FilterQuery, skip: number, @@ -92,6 +109,16 @@ export class FollowsRepository { .exec(); } + async deletePendingRequest(requesterId: string, targetUserId: string): Promise { + await this.followRequestModel + .deleteOne({ + requesterId: new Types.ObjectId(requesterId), + targetUserId: new Types.ObjectId(targetUserId), + status: 'pending', + }) + .exec(); + } + async findFollowerUserIds(followingId: string, skip: number, limit: number, sort: Record) { return this.followModel .find({ followingId: new Types.ObjectId(followingId) }) diff --git a/src/modules/follows/follows.service.spec.ts b/src/modules/follows/follows.service.spec.ts index 4b919e4..4cd8c32 100644 --- a/src/modules/follows/follows.service.spec.ts +++ b/src/modules/follows/follows.service.spec.ts @@ -1,6 +1,525 @@ +import { SortOrder } from '../../common/enums/sort-order.enum'; import { FollowsService } from './follows.service'; describe('FollowsService', () => { + const currentUserId = '507f1f77bcf86cd799439011'; + const targetUserId = '507f191e810c19729de860ea'; + const requestId = '507f191e810c19729de860eb'; + + const setup = (overrides: Record> = {}) => { + const followsRepository = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ id: 'follow-1' }), + deleteById: jest.fn().mockResolvedValue(true), + deleteRelationship: jest.fn().mockResolvedValue(false), + deletePendingRequest: jest.fn().mockResolvedValue(undefined), + isDuplicateKeyError: jest.fn().mockReturnValue(false), + count: jest.fn().mockResolvedValue(0), + upsertPendingRequest: jest.fn().mockResolvedValue({ id: requestId }), + findFollowerUserIds: jest.fn().mockResolvedValue([]), + findFollowingUserIds: jest.fn().mockResolvedValue([]), + findFollowingIds: jest.fn().mockResolvedValue([]), + findPendingRequest: jest.fn().mockResolvedValue(null), + findPendingRequestsForTarget: jest.fn().mockResolvedValue([]), + countPendingRequestsForTarget: jest.fn().mockResolvedValue(0), + updateRequestStatus: jest.fn().mockResolvedValue(null), + ...overrides.followsRepository, + }; + const usersRepository = { + findById: jest.fn().mockImplementation(async (id: string) => ({ + id, + isDisabled: false, + isPrivate: false, + musicRoles: [], + musicGenres: [], + favoriteInstruments: [], + favoriteMaqamat: [], + followersCount: 0, + })), + setFollowingCount: jest.fn().mockResolvedValue(undefined), + setFollowersCount: jest.fn().mockResolvedValue(undefined), + findManyByIds: jest.fn().mockResolvedValue([]), + findSuggestionCandidates: jest.fn().mockResolvedValue([]), + ...overrides.usersRepository, + }; + const outboxService = { + enqueueFollowNotification: jest.fn().mockResolvedValue(undefined), + enqueueFollowRequestApprovedNotification: jest.fn().mockResolvedValue(undefined), + ...overrides.outboxService, + }; + const feedVersionService = { + bumpGlobalVersion: jest.fn().mockResolvedValue(1), + bumpUserVersion: jest.fn().mockResolvedValue(1), + ...overrides.feedVersionService, + }; + const blocksRepository = { + findAnyBetween: jest.fn().mockResolvedValue(null), + ...overrides.blocksRepository, + }; + const service = new FollowsService( + followsRepository as any, + usersRepository as any, + outboxService as any, + feedVersionService as any, + blocksRepository as any, + ); + return { + service, + followsRepository, + usersRepository, + outboxService, + feedVersionService, + blocksRepository, + }; + }; + + it('validates toggle targets before querying repositories', async () => { + const invalid = setup(); + await expect(invalid.service.toggleFollow(currentUserId, { targetUserId: 'bad-id' })).rejects.toThrow( + 'Invalid target user id', + ); + expect(invalid.usersRepository.findById).not.toHaveBeenCalled(); + + const self = setup(); + await expect(self.service.toggleFollow(currentUserId, { targetUserId: currentUserId })).rejects.toThrow( + 'You cannot follow yourself', + ); + }); + + it('rejects disabled, missing and blocked toggle targets', async () => { + const disabled = setup({ usersRepository: { findById: jest.fn().mockResolvedValue({ isDisabled: true }) } }); + await expect(disabled.service.toggleFollow(currentUserId, { targetUserId })).rejects.toThrow( + 'Target user not found', + ); + + const blocked = setup({ blocksRepository: { findAnyBetween: jest.fn().mockResolvedValue({ id: 'block' }) } }); + await expect(blocked.service.toggleFollow(currentUserId, { targetUserId })).rejects.toThrow( + 'You cannot follow this user', + ); + expect(blocked.followsRepository.create).not.toHaveBeenCalled(); + }); + + it('toggles an existing follow off and synchronizes both counters', async () => { + const ctx = setup({ + followsRepository: { + findOne: jest.fn().mockResolvedValue({ id: 'follow-1' }), + count: jest.fn().mockResolvedValueOnce(3).mockResolvedValueOnce(7), + }, + }); + + await expect(ctx.service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({ following: false }); + expect(ctx.followsRepository.deleteById).toHaveBeenCalledWith('follow-1'); + expect(ctx.usersRepository.setFollowingCount).toHaveBeenCalledWith(currentUserId, 3); + expect(ctx.usersRepository.setFollowersCount).toHaveBeenCalledWith(targetUserId, 7); + expect(ctx.feedVersionService.bumpUserVersion).toHaveBeenCalledWith(currentUserId); + }); + + it('creates a pending request instead of following a private user', async () => { + const ctx = setup({ + usersRepository: { + findById: jest.fn().mockResolvedValue({ id: targetUserId, isDisabled: false, isPrivate: true }), + }, + }); + + await expect(ctx.service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({ + following: false, + requested: true, + requestId, + }); + expect(ctx.followsRepository.create).not.toHaveBeenCalled(); + expect(ctx.feedVersionService.bumpUserVersion).not.toHaveBeenCalled(); + }); + + it('handles a duplicate-key race as a successful public follow', async () => { + const duplicate = new Error('duplicate'); + const ctx = setup({ + followsRepository: { + create: jest.fn().mockRejectedValue(duplicate), + isDuplicateKeyError: jest.fn().mockReturnValue(true), + count: jest.fn().mockResolvedValueOnce(4).mockResolvedValueOnce(9), + }, + }); + + await expect(ctx.service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({ following: true }); + expect(ctx.outboxService.enqueueFollowNotification).not.toHaveBeenCalled(); + expect(ctx.usersRepository.setFollowingCount).toHaveBeenCalledWith(currentUserId, 4); + + const failure = setup({ + followsRepository: { + create: jest.fn().mockRejectedValue(new Error('database down')), + isDuplicateKeyError: jest.fn().mockReturnValue(false), + }, + }); + await expect(failure.service.toggleFollow(currentUserId, { targetUserId })).rejects.toThrow('database down'); + }); + + it('returns a detailed private follow-request response', async () => { + const ctx = setup({ + usersRepository: { + findById: jest.fn().mockResolvedValue({ id: targetUserId, isDisabled: false, isPrivate: true }), + }, + followsRepository: { count: jest.fn().mockResolvedValueOnce(2).mockResolvedValueOnce(5) }, + }); + + await expect(ctx.service.followUser(currentUserId, targetUserId)).resolves.toEqual({ + message: 'Follow request sent', + following: false, + isFollowing: false, + targetUserId, + followersCount: 5, + followingCount: 2, + requested: true, + requestId, + }); + }); + + it('follows a public account and returns synchronized counts', async () => { + const ctx = setup({ + followsRepository: { count: jest.fn().mockResolvedValueOnce(6).mockResolvedValueOnce(11) }, + }); + + await expect(ctx.service.followUser(currentUserId, targetUserId)).resolves.toMatchObject({ + message: 'User followed successfully', + isFollowing: true, + followersCount: 11, + followingCount: 6, + }); + expect(ctx.outboxService.enqueueFollowNotification).toHaveBeenCalledWith( + currentUserId, + targetUserId, + 'follow-1', + ); + }); + + it('validates followUser and blocks forbidden relationships', async () => { + const invalid = setup(); + await expect(invalid.service.followUser(currentUserId, 'bad')).rejects.toThrow('Invalid target user id'); + + const blocked = setup({ blocksRepository: { findAnyBetween: jest.fn().mockResolvedValue({}) } }); + await expect(blocked.service.followUser(currentUserId, targetUserId)).rejects.toThrow( + 'You cannot follow this user', + ); + expect(blocked.usersRepository.findById).not.toHaveBeenCalled(); + }); + + it('unfollows idempotently and always resynchronizes counters', async () => { + const ctx = setup({ + followsRepository: { + findOne: jest.fn().mockResolvedValue({ id: 'follow-1' }), + count: jest.fn().mockResolvedValueOnce(1).mockResolvedValueOnce(8), + }, + }); + + await expect(ctx.service.unfollowUser(currentUserId, targetUserId)).resolves.toMatchObject({ + message: 'User unfollowed successfully', + isFollowing: false, + followingCount: 1, + followersCount: 8, + }); + expect(ctx.feedVersionService.bumpUserVersion).toHaveBeenCalledWith(currentUserId); + + const absent = setup(); + await absent.service.unfollowUser(currentUserId, targetUserId); + expect(absent.followsRepository.deleteById).not.toHaveBeenCalled(); + expect(absent.feedVersionService.bumpUserVersion).not.toHaveBeenCalled(); + }); + + it('rejects invalid unfollow targets', async () => { + const ctx = setup(); + await expect(ctx.service.unfollowUser(currentUserId, 'bad')).rejects.toThrow('Invalid target user id'); + await expect(ctx.service.unfollowUser(currentUserId, currentUserId)).rejects.toThrow( + 'You cannot follow yourself', + ); + }); + + it('removes only the requested follower, synchronizes counts and revokes feed access', async () => { + const followerId = targetUserId; + const ctx = setup({ + followsRepository: { + deleteRelationship: jest.fn().mockResolvedValue(true), + count: jest.fn().mockResolvedValueOnce(1).mockResolvedValueOnce(8), + }, + }); + + await expect(ctx.service.removeFollower(currentUserId, followerId)).resolves.toEqual({ + message: 'Follower removed successfully', + followerId, + isFollowing: false, + followersCount: 8, + followingCount: 1, + }); + expect(ctx.followsRepository.deleteRelationship).toHaveBeenCalledWith( + followerId, + currentUserId, + ); + expect(ctx.followsRepository.deletePendingRequest).toHaveBeenCalledWith( + followerId, + currentUserId, + ); + expect(ctx.usersRepository.setFollowingCount).toHaveBeenCalledWith(followerId, 1); + expect(ctx.usersRepository.setFollowersCount).toHaveBeenCalledWith(currentUserId, 8); + expect(ctx.feedVersionService.bumpUserVersion).toHaveBeenCalledWith(followerId); + }); + + it('removes a missing follower idempotently without targeting another account', async () => { + const ctx = setup(); + await expect(ctx.service.removeFollower(currentUserId, targetUserId)).resolves.toMatchObject({ + isFollowing: false, + followerId: targetUserId, + }); + expect(ctx.followsRepository.deleteRelationship).toHaveBeenCalledTimes(1); + expect(ctx.followsRepository.deleteRelationship).toHaveBeenCalledWith( + targetUserId, + currentUserId, + ); + }); + + it('rejects invalid remove-follower targets', async () => { + const ctx = setup(); + await expect(ctx.service.removeFollower(currentUserId, 'bad')).rejects.toThrow( + 'Invalid follower user id', + ); + await expect(ctx.service.removeFollower(currentUserId, currentUserId)).rejects.toThrow( + 'You cannot remove yourself as a follower', + ); + expect(ctx.followsRepository.deleteRelationship).not.toHaveBeenCalled(); + }); + + it('lists followers in repository order and decorates viewer state', async () => { + const followerId = '507f191e810c19729de860ec'; + const userDoc = { + id: followerId, + toObject: () => ({ + _id: followerId, + name: 'Follower', + username: 'follower', + followersCount: 9, + }), + }; + const ctx = setup({ + followsRepository: { + findFollowerUserIds: jest.fn().mockResolvedValue([{ followerId }]), + count: jest.fn().mockResolvedValue(1), + findFollowingIds: jest.fn().mockResolvedValue([followerId]), + }, + usersRepository: { findManyByIds: jest.fn().mockResolvedValue([userDoc]) }, + }); + + const result = await ctx.service.getFollowers(targetUserId, { + page: 1, + limit: 10, + sortOrder: SortOrder.ASC, + }, currentUserId); + + expect(result).toMatchObject({ + total: 1, + items: [{ name: 'Follower', username: 'follower', isFollowing: true }], + }); + expect(ctx.followsRepository.findFollowerUserIds).toHaveBeenCalledWith(targetUserId, 0, 10, { + createdAt: 1, + }); + }); + + it('lists following users and tolerates deleted user records', async () => { + const firstId = '507f191e810c19729de860ec'; + const missingId = '507f191e810c19729de860ed'; + const doc = { + id: firstId, + toObject: () => ({ _id: firstId, username: 'one', isVerified: true }), + }; + const ctx = setup({ + followsRepository: { + findFollowingUserIds: jest.fn().mockResolvedValue([{ followingId: firstId }, { followingId: missingId }]), + count: jest.fn().mockResolvedValue(2), + }, + usersRepository: { findManyByIds: jest.fn().mockResolvedValue([doc]) }, + }); + + const result = await ctx.service.getFollowing(currentUserId, { page: 1, limit: 20 }); + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ username: 'one', followersCount: 0, isFollowing: false }); + }); + + it('validates list targets before reading follower data', async () => { + const invalid = setup(); + await expect(invalid.service.getFollowers('bad', {})).rejects.toThrow('Invalid user id'); + + const missing = setup({ usersRepository: { findById: jest.fn().mockResolvedValue(null) } }); + await expect(missing.service.getFollowing(targetUserId, {})).rejects.toThrow('Target user not found'); + }); + + it('reports self, mutual and pending follow states', async () => { + const self = setup(); + await expect(self.service.getFollowStatus(currentUserId, currentUserId)).resolves.toEqual({ + following: false, + isFollowing: false, + isFollowedBy: false, + isMutual: false, + targetUserId: currentUserId, + }); + + const mutual = setup({ + followsRepository: { + findOne: jest.fn().mockResolvedValueOnce({ id: 'a' }).mockResolvedValueOnce({ id: 'b' }), + findPendingRequest: jest.fn().mockResolvedValue({ id: requestId }), + }, + }); + await expect(mutual.service.getFollowStatus(currentUserId, targetUserId)).resolves.toMatchObject({ + following: true, + isFollowing: true, + isFollowedBy: true, + isMutual: true, + requested: true, + }); + + await expect(setup().service.getFollowStatus(currentUserId, 'bad')).rejects.toThrow( + 'Invalid target user id', + ); + }); + + it('paginates pending requests', async () => { + const items = [{ id: requestId }]; + const ctx = setup({ + followsRepository: { + findPendingRequestsForTarget: jest.fn().mockResolvedValue(items), + countPendingRequestsForTarget: jest.fn().mockResolvedValue(4), + }, + }); + + const result = await ctx.service.getPendingRequests(currentUserId, { + page: 2, + limit: 3, + sortOrder: SortOrder.ASC, + }); + expect(result).toMatchObject({ items, total: 4, page: 2, limit: 3 }); + expect(ctx.followsRepository.findPendingRequestsForTarget).toHaveBeenCalledWith( + currentUserId, + 3, + 3, + { createdAt: 1 }, + ); + }); + + it('approves a request, creates the follow once and updates the requester feed', async () => { + const ctx = setup({ + followsRepository: { + updateRequestStatus: jest.fn().mockResolvedValue({ requesterId: { toString: () => targetUserId } }), + count: jest.fn().mockResolvedValueOnce(2).mockResolvedValueOnce(10), + }, + }); + + await expect(ctx.service.approveRequest(currentUserId, requestId)).resolves.toEqual({ + approved: true, + following: true, + requesterId: targetUserId, + }); + expect(ctx.followsRepository.create).toHaveBeenCalledWith(targetUserId, currentUserId); + expect(ctx.feedVersionService.bumpUserVersion).toHaveBeenCalledWith(targetUserId); + expect(ctx.outboxService.enqueueFollowRequestApprovedNotification).toHaveBeenCalledWith( + currentUserId, + targetUserId, + requestId, + ); + + const existing = setup({ + followsRepository: { + updateRequestStatus: jest.fn().mockResolvedValue({ requesterId: { toString: () => targetUserId } }), + findOne: jest.fn().mockResolvedValue({ id: 'already' }), + }, + }); + await existing.service.approveRequest(currentUserId, requestId); + expect(existing.followsRepository.create).not.toHaveBeenCalled(); + expect(existing.outboxService.enqueueFollowRequestApprovedNotification).toHaveBeenCalledTimes(1); + }); + + it('validates approve and reject requests and handles missing records', async () => { + await expect(setup().service.approveRequest(currentUserId, 'bad')).rejects.toThrow( + 'Invalid follow request id', + ); + await expect(setup().service.approveRequest(currentUserId, requestId)).rejects.toThrow( + 'Follow request not found', + ); + expect(setup().outboxService.enqueueFollowRequestApprovedNotification).not.toHaveBeenCalled(); + await expect(setup().service.rejectRequest(currentUserId, 'bad')).rejects.toThrow( + 'Invalid follow request id', + ); + await expect(setup().service.rejectRequest(currentUserId, requestId)).rejects.toThrow( + 'Follow request not found', + ); + + const rejected = setup({ + followsRepository: { + updateRequestStatus: jest.fn().mockResolvedValue({ requesterId: { toString: () => targetUserId } }), + }, + }); + await expect(rejected.service.rejectRequest(currentUserId, requestId)).resolves.toEqual({ + rejected: true, + requesterId: targetUserId, + }); + }); + + it('ranks and explains personalized suggestions with stable pagination', async () => { + jest.spyOn(Math, 'random').mockReturnValue(0.25); + const current = { + id: currentUserId, + musicRoles: ['Singer'], + musicGenres: ['Rock'], + favoriteInstruments: ['Guitar'], + favoriteMaqamat: ['Bayati'], + location: ' Riyadh ', + }; + const best = { + id: targetUserId, + musicRoles: ['singer'], + musicGenres: ['rock'], + favoriteInstruments: ['guitar'], + favoriteMaqamat: ['bayati'], + location: 'riyadh', + followersCount: 900, + isVerified: true, + }; + const other = { + id: '507f191e810c19729de860ec', + musicRoles: [], + musicGenres: [], + favoriteInstruments: [], + favoriteMaqamat: [], + location: 'Jeddah', + followersCount: 1, + isVerified: false, + }; + const ctx = setup({ + usersRepository: { + findById: jest.fn().mockResolvedValue(current), + findSuggestionCandidates: jest.fn().mockResolvedValue([other, best]), + }, + followsRepository: { findFollowingIds: jest.fn().mockResolvedValue(['507f191e810c19729de860ed']) }, + }); + + const result = await ctx.service.getSuggestions(currentUserId, { page: 1, limit: 10 }); + expect(result.items[0]).toMatchObject({ + user: best, + reasons: ['shared_music_roles', 'shared_genres', 'same_location', 'verified_account', 'popular_creator'], + }); + expect(ctx.usersRepository.findSuggestionCandidates).toHaveBeenCalledWith( + expect.objectContaining({ isDisabled: false }), + 500, + ); + expect(result.total).toBe(2); + + const ascending = await ctx.service.getSuggestions(currentUserId, { + page: 1, + limit: 1, + sortOrder: SortOrder.ASC, + }); + expect(ascending.items[0].user).toBe(other); + jest.restoreAllMocks(); + }); + + it('rejects suggestions for a missing current user', async () => { + const ctx = setup({ usersRepository: { findById: jest.fn().mockResolvedValue(null) } }); + await expect(ctx.service.getSuggestions(currentUserId, {})).rejects.toThrow('Current user not found'); + }); it('keeps follow successful even if notification creation fails and resyncs counters', async () => { const currentUserId = '507f1f77bcf86cd799439011'; const targetUserId = '507f191e810c19729de860ea'; @@ -29,7 +548,7 @@ describe('FollowsService', () => { followsRepository as any, usersRepository as any, outboxService as any, - { bumpGlobalVersion: jest.fn().mockResolvedValue(1) } as any, + { bumpGlobalVersion: jest.fn(), bumpUserVersion: jest.fn().mockResolvedValue(1) } as any, blocksRepository as any, ); @@ -51,7 +570,7 @@ describe('FollowsService', () => { {} as any, {} as any, {} as any, - { bumpGlobalVersion: jest.fn() } as any, + { bumpGlobalVersion: jest.fn(), bumpUserVersion: jest.fn() } as any, { findAnyBetween: jest.fn() } as any, ); @@ -78,7 +597,7 @@ describe('FollowsService', () => { followsRepository as any, usersRepository as any, outboxService as any, - { bumpGlobalVersion: jest.fn() } as any, + { bumpGlobalVersion: jest.fn(), bumpUserVersion: jest.fn() } as any, { findAnyBetween: jest.fn().mockResolvedValue(null) } as any, ); diff --git a/src/modules/follows/follows.service.ts b/src/modules/follows/follows.service.ts index 6460c5a..28236a0 100644 --- a/src/modules/follows/follows.service.ts +++ b/src/modules/follows/follows.service.ts @@ -39,7 +39,7 @@ export class FollowsService { if (existing) { await this.followsRepository.deleteById(existing.id); await this.syncFollowCounts(currentUserId, targetUserId); - await this.feedVersionService.bumpGlobalVersion(); + await this.feedVersionService.bumpUserVersion(currentUserId); return { following: false }; } @@ -63,7 +63,7 @@ export class FollowsService { } } await this.syncFollowCounts(currentUserId, targetUserId); - await this.feedVersionService.bumpGlobalVersion(); + await this.feedVersionService.bumpUserVersion(currentUserId); if (followId) { await this.enqueueFollowNotification(currentUserId, targetUserId, followId); @@ -103,7 +103,7 @@ export class FollowsService { } const counts = await this.syncFollowCounts(currentUserId, targetUserId); - await this.feedVersionService.bumpGlobalVersion(); + await this.feedVersionService.bumpUserVersion(currentUserId); if (followId) { await this.enqueueFollowNotification(currentUserId, targetUserId, followId); @@ -123,13 +123,37 @@ export class FollowsService { const existing = await this.followsRepository.findOne(currentUserId, targetUserId); if (existing) { await this.followsRepository.deleteById(existing.id); - await this.feedVersionService.bumpGlobalVersion(); + await this.feedVersionService.bumpUserVersion(currentUserId); } const counts = await this.syncFollowCounts(currentUserId, targetUserId); return this.buildFollowActionResponse('User unfollowed successfully', false, targetUserId, counts); } + async removeFollower(currentUserId: string, followerId: string) { + if (!Types.ObjectId.isValid(followerId)) { + throw new BadRequestException('Invalid follower user id'); + } + if (currentUserId === followerId) { + throw new BadRequestException('You cannot remove yourself as a follower'); + } + + await Promise.all([ + this.followsRepository.deleteRelationship(followerId, currentUserId), + this.followsRepository.deletePendingRequest(followerId, currentUserId), + ]); + const counts = await this.syncFollowCounts(followerId, currentUserId); + await this.feedVersionService.bumpUserVersion(followerId); + + return { + message: 'Follower removed successfully', + followerId, + isFollowing: false, + followersCount: counts.followersCount, + followingCount: counts.followingCount, + }; + } + async getFollowers(userId: string, query: PaginationQueryDto, viewerUserId?: string) { await this.assertListTargetExists(userId); const page = query.page ?? 1; @@ -237,9 +261,15 @@ export class FollowsService { if (!existing) { await this.followsRepository.create(requesterId, currentUserId); await this.syncFollowCounts(requesterId, currentUserId); - await this.feedVersionService.bumpGlobalVersion(); + await this.feedVersionService.bumpUserVersion(requesterId); } + await this.enqueueFollowRequestApprovedNotification( + currentUserId, + requesterId, + requestId, + ); + return { approved: true, following: true, requesterId }; } @@ -437,6 +467,26 @@ export class FollowsService { } } + private async enqueueFollowRequestApprovedNotification( + actorId: string, + recipientId: string, + requestId: string, + ): Promise { + try { + await this.outboxService.enqueueFollowRequestApprovedNotification( + actorId, + recipientId, + requestId, + ); + } catch (error) { + this.logger.warn( + `Follow request approval notification failed for actor=${actorId} recipient=${recipientId}: ${ + error instanceof Error ? error.message : 'unknown error' + }`, + ); + } + } + private buildFollowActionResponse( message: string, isFollowing: boolean, diff --git a/src/modules/likes/likes.repository.ts b/src/modules/likes/likes.repository.ts index f78ead9..422ffc3 100644 --- a/src/modules/likes/likes.repository.ts +++ b/src/modules/likes/likes.repository.ts @@ -32,6 +32,26 @@ export class LikesRepository { }); } + async createIfAbsent( + userId: string, + targetId: string, + targetType: 'post' | 'comment', + reactionType: ReactionType = ReactionType.LIKE, + ): Promise { + const result = await this.likeModel + .updateOne( + { + userId: new Types.ObjectId(userId), + targetId: new Types.ObjectId(targetId), + targetType, + }, + { $setOnInsert: { reactionType } }, + { upsert: true }, + ) + .exec(); + return result.upsertedCount === 1; + } + async updateReaction(id: string, reactionType: ReactionType): Promise { return this.likeModel.findByIdAndUpdate(id, { reactionType }, { new: true }).exec(); } @@ -54,8 +74,9 @@ export class LikesRepository { return rows.map((row) => row.targetId.toString()); } - async deleteById(id: string): Promise { - await this.likeModel.findByIdAndDelete(id).exec(); + async deleteById(id: string): Promise { + const deleted = await this.likeModel.findByIdAndDelete(id).exec(); + return !!deleted; } async getReactionSummary(targetId: string, targetType: 'post' | 'comment') { diff --git a/src/modules/likes/likes.service.spec.ts b/src/modules/likes/likes.service.spec.ts index 79f4aeb..4b23c02 100644 --- a/src/modules/likes/likes.service.spec.ts +++ b/src/modules/likes/likes.service.spec.ts @@ -1,6 +1,227 @@ import { LikesService } from './likes.service'; describe('LikesService', () => { + const userId = '507f1f77bcf86cd799439012'; + const ownerId = '507f191e810c19729de860ea'; + const postId = '507f1f77bcf86cd799439011'; + + const setup = (overrides: Record> = {}) => { + const likesRepository = { + findOne: jest.fn().mockResolvedValue(null), + createIfAbsent: jest.fn().mockResolvedValue({ id: 'like-1' }), + updateReaction: jest.fn().mockResolvedValue(undefined), + deleteById: jest.fn().mockResolvedValue(true), + getReactionSummary: jest.fn().mockResolvedValue({ like: 1 }), + ...overrides.likesRepository, + }; + const postsRepository = { + findById: jest.fn().mockResolvedValue({ id: postId, authorId: ownerId, content: 'A post preview' }), + incrementLikesCount: jest.fn().mockResolvedValue(undefined), + ...overrides.postsRepository, + }; + const commentsRepository = { + findById: jest.fn().mockResolvedValue(null), + ...overrides.commentsRepository, + }; + const feedVersionService = { + bumpUserVersion: jest.fn().mockResolvedValue(1), + ...overrides.feedVersionService, + }; + const notificationsService = { + createLikeNotification: jest.fn().mockResolvedValue(undefined), + ...overrides.notificationsService, + }; + const blocksRepository = { + findAnyBetween: jest.fn().mockResolvedValue(null), + ...overrides.blocksRepository, + }; + const postsService = { + canViewerSeePost: jest.fn().mockResolvedValue(true), + ...overrides.postsService, + }; + const service = new LikesService( + likesRepository as any, + postsRepository as any, + commentsRepository as any, + feedVersionService as any, + notificationsService as any, + blocksRepository as any, + postsService as any, + ); + return { + service, + likesRepository, + postsRepository, + commentsRepository, + feedVersionService, + notificationsService, + blocksRepository, + postsService, + }; + }; + + it('toggles to like or unlike according to the current state', async () => { + const first = setup(); + const like = jest.spyOn(first.service, 'like').mockResolvedValue({} as any); + await first.service.toggle(userId, { targetId: postId, targetType: 'post' }); + expect(like).toHaveBeenCalled(); + + const second = setup({ likesRepository: { findOne: jest.fn().mockResolvedValue({ id: 'like-1' }) } }); + const unlike = jest.spyOn(second.service, 'unlike').mockResolvedValue({} as any); + await second.service.toggle(userId, { targetId: postId, targetType: 'post' }); + expect(unlike).toHaveBeenCalled(); + }); + + it('creates a post reaction, updates its counter and notifies the owner', async () => { + const ctx = setup(); + + await expect( + ctx.service.like(userId, { targetId: postId, targetType: 'post', reactionType: 'love' as any }), + ).resolves.toEqual({ + liked: true, + reacted: true, + targetId: postId, + targetType: 'post', + reactionType: 'love', + }); + + expect(ctx.likesRepository.createIfAbsent).toHaveBeenCalledWith(userId, postId, 'post', 'love'); + expect(ctx.postsRepository.incrementLikesCount).toHaveBeenCalledWith(postId, 1); + expect(ctx.feedVersionService.bumpUserVersion).toHaveBeenCalledWith(userId); + expect(ctx.notificationsService.createLikeNotification).toHaveBeenCalledWith( + userId, + ownerId, + postId, + { resourceType: 'post', previewText: 'A post preview' }, + ); + }); + + it('updates an existing reaction without incrementing counters', async () => { + const ctx = setup({ + likesRepository: { + findOne: jest.fn().mockResolvedValue({ id: 'like-1', reactionType: 'like' }), + }, + }); + + await ctx.service.like(userId, { + targetId: postId, + targetType: 'post', + reactionType: 'love' as any, + }); + + expect(ctx.likesRepository.updateReaction).toHaveBeenCalledWith('like-1', 'love'); + expect(ctx.likesRepository.createIfAbsent).not.toHaveBeenCalled(); + expect(ctx.postsRepository.incrementLikesCount).not.toHaveBeenCalled(); + }); + + it('treats a concurrent duplicate like as successful without double counting', async () => { + const ctx = setup({ likesRepository: { createIfAbsent: jest.fn().mockResolvedValue(null) } }); + + await expect(ctx.service.like(userId, { targetId: postId, targetType: 'post' })).resolves.toMatchObject({ + liked: true, + reactionType: 'like', + }); + expect(ctx.postsRepository.incrementLikesCount).not.toHaveBeenCalled(); + expect(ctx.feedVersionService.bumpUserVersion).not.toHaveBeenCalled(); + }); + + it('keeps a successful like when notification delivery fails', async () => { + const ctx = setup({ + notificationsService: { createLikeNotification: jest.fn().mockRejectedValue('offline') }, + }); + + await expect(ctx.service.like(userId, { targetId: postId, targetType: 'post' })).resolves.toMatchObject({ + liked: true, + }); + expect(ctx.postsRepository.incrementLikesCount).toHaveBeenCalledWith(postId, 1); + }); + + it('supports comment likes and resolves the comment owner and preview', async () => { + const commentId = '507f191e810c19729de860eb'; + const ctx = setup({ + commentsRepository: { + findById: jest.fn().mockResolvedValue({ + id: commentId, + postId: { toString: () => postId }, + authorId: { toString: () => ownerId }, + content: 'comment preview', + }), + }, + }); + + await ctx.service.like(userId, { targetId: commentId, targetType: 'comment' }); + + expect(ctx.postsRepository.incrementLikesCount).not.toHaveBeenCalled(); + expect(ctx.notificationsService.createLikeNotification).toHaveBeenCalledWith( + userId, + ownerId, + commentId, + { resourceType: 'comment', previewText: 'comment preview' }, + ); + }); + + it('rejects missing or hidden targets with a not-found response', async () => { + const missingPost = setup({ postsRepository: { findById: jest.fn().mockResolvedValue(null) } }); + await expect(missingPost.service.like(userId, { targetId: postId, targetType: 'post' })).rejects.toThrow( + 'Post not found', + ); + + const hiddenPost = setup({ postsService: { canViewerSeePost: jest.fn().mockResolvedValue(false) } }); + await expect(hiddenPost.service.like(userId, { targetId: postId, targetType: 'post' })).rejects.toThrow( + 'Post not found', + ); + + const missingComment = setup(); + await expect( + missingComment.service.like(userId, { targetId: postId, targetType: 'comment' }), + ).rejects.toThrow('Comment not found'); + }); + + it('unlikes idempotently and decrements only after a successful delete', async () => { + const absent = setup(); + await expect(absent.service.unlike(userId, { targetId: postId, targetType: 'post' })).resolves.toMatchObject({ + liked: false, + }); + expect(absent.postsRepository.incrementLikesCount).not.toHaveBeenCalled(); + + const lostRace = setup({ + likesRepository: { + findOne: jest.fn().mockResolvedValue({ id: 'like-1' }), + deleteById: jest.fn().mockResolvedValue(false), + }, + }); + await lostRace.service.unlike(userId, { targetId: postId, targetType: 'post' }); + expect(lostRace.postsRepository.incrementLikesCount).not.toHaveBeenCalled(); + + const deleted = setup({ + likesRepository: { findOne: jest.fn().mockResolvedValue({ id: 'like-1' }) }, + }); + await deleted.service.unlike(userId, { targetId: postId, targetType: 'post' }); + expect(deleted.postsRepository.incrementLikesCount).toHaveBeenCalledWith(postId, -1); + expect(deleted.feedVersionService.bumpUserVersion).toHaveBeenCalledWith(userId); + }); + + it('returns reaction status and summary only for a visible target', async () => { + const ctx = setup({ + likesRepository: { + findOne: jest.fn().mockResolvedValue({ reactionType: 'love' }), + getReactionSummary: jest.fn().mockResolvedValue({ like: 2, love: 3 }), + }, + }); + + await expect(ctx.service.getStatus(userId, { targetId: postId, targetType: 'post' })).resolves.toEqual({ + liked: true, + reacted: true, + targetId: postId, + targetType: 'post', + reactionType: 'love', + reactionSummary: { like: 2, love: 3 }, + }); + + const hidden = setup({ postsService: { canViewerSeePost: jest.fn().mockResolvedValue(false) } }); + await hidden.service.getStatus(userId, { targetId: postId, targetType: 'post' }); + expect(hidden.likesRepository.getReactionSummary).not.toHaveBeenCalled(); + }); it('returns liked false from status when target post no longer exists', async () => { const likesRepository = { findOne: jest.fn(), @@ -22,6 +243,7 @@ describe('LikesService', () => { { bumpGlobalVersion: jest.fn() } as any, { createLikeNotification: jest.fn() } as any, blocksRepository as any, + { canViewerSeePost: jest.fn().mockResolvedValue(true) } as any, ); await expect( @@ -59,6 +281,7 @@ describe('LikesService', () => { { bumpGlobalVersion: jest.fn() } as any, { createLikeNotification: jest.fn() } as any, blocksRepository as any, + { canViewerSeePost: jest.fn().mockResolvedValue(true) } as any, ); await expect(service.like(userId, { targetId: postId, targetType: 'post' })).rejects.toThrow( diff --git a/src/modules/likes/likes.service.ts b/src/modules/likes/likes.service.ts index fc4fa2c..fd3d858 100644 --- a/src/modules/likes/likes.service.ts +++ b/src/modules/likes/likes.service.ts @@ -6,6 +6,7 @@ import { NotificationsService } from '../notifications/notifications.service'; import { BlocksRepository } from '../blocks/blocks.repository'; import { CommentsRepository } from '../comments/comments.repository'; import { PostsRepository } from '../posts/posts.repository'; +import { PostsService } from '../posts/posts.service'; import { LikesRepository } from './likes.repository'; import { ToggleLikeDto } from './dto/toggle-like.dto'; @@ -20,6 +21,7 @@ export class LikesService { private readonly feedVersionService: FeedVersionService, private readonly notificationsService: NotificationsService, private readonly blocksRepository: BlocksRepository, + private readonly postsService: PostsService, ) {} async toggle(userId: string, dto: ToggleLikeDto) { @@ -47,11 +49,19 @@ export class LikesService { }; } - await this.likesRepository.create(userId, dto.targetId, dto.targetType, reactionType); + const created = await this.likesRepository.createIfAbsent( + userId, + dto.targetId, + dto.targetType, + reactionType, + ); + if (!created) { + return { liked: true, reacted: true, targetId: dto.targetId, targetType: dto.targetType, reactionType }; + } if (dto.targetType === 'post') { await this.postsRepository.incrementLikesCount(dto.targetId, 1); } - await this.feedVersionService.bumpGlobalVersion(); + await this.feedVersionService.bumpUserVersion(userId); if (notificationContext.recipientId && notificationContext.recipientId !== userId) { try { await this.notificationsService.createLikeNotification( @@ -83,18 +93,20 @@ export class LikesService { return { liked: false, reacted: false, targetId: dto.targetId, targetType: dto.targetType }; } - await this.likesRepository.deleteById(existing.id); + const deleted = await this.likesRepository.deleteById(existing.id); + if (!deleted) { + return { liked: false, reacted: false, targetId: dto.targetId, targetType: dto.targetType }; + } if (dto.targetType === 'post') { await this.postsRepository.incrementLikesCount(dto.targetId, -1); } - await this.feedVersionService.bumpGlobalVersion(); + await this.feedVersionService.bumpUserVersion(userId); return { liked: false, reacted: false, targetId: dto.targetId, targetType: dto.targetType }; } async getStatus(userId: string, dto: ToggleLikeDto) { - const targetExists = await this.targetExists(dto); - if (!targetExists) { + if (!(await this.targetVisibleTo(userId, dto))) { return { liked: false, reacted: false, targetId: dto.targetId, targetType: dto.targetType }; } @@ -120,6 +132,19 @@ export class LikesService { } private async assertCanLike(userId: string, dto: ToggleLikeDto): Promise { + if (dto.targetType === 'post') { + const post = await this.postsRepository.findById(dto.targetId); + if (!post || !(await this.postsService.canViewerSeePost(userId, post))) { + throw new NotFoundException('Post not found'); + } + } else { + const comment = await this.commentsRepository.findById(dto.targetId); + const post = comment ? await this.postsRepository.findById(comment.postId.toString()) : null; + if (!post || !(await this.postsService.canViewerSeePost(userId, post))) { + throw new NotFoundException('Comment not found'); + } + } + const ownerId = await this.resolveTargetOwnerId(dto); if (!ownerId || ownerId === userId) { return; @@ -159,6 +184,17 @@ export class LikesService { }; } + private async targetVisibleTo(userId: string, dto: ToggleLikeDto): Promise { + if (dto.targetType === 'post') { + const post = await this.postsRepository.findById(dto.targetId); + return !!post && this.postsService.canViewerSeePost(userId, post); + } + const comment = await this.commentsRepository.findById(dto.targetId); + if (!comment) return false; + const post = await this.postsRepository.findById(comment.postId.toString()); + return !!post && this.postsService.canViewerSeePost(userId, post); + } + private async resolveTargetOwnerId(dto: ToggleLikeDto): Promise { if (dto.targetType === 'post') { const post = await this.postsRepository.findById(dto.targetId); diff --git a/src/modules/marketplace/marketplace.controller.ts b/src/modules/marketplace/marketplace.controller.ts index 27894b6..adf259f 100644 --- a/src/modules/marketplace/marketplace.controller.ts +++ b/src/modules/marketplace/marketplace.controller.ts @@ -23,6 +23,7 @@ import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permi import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard'; import { UserRole } from '../../common/enums/user-role.enum'; import { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; +import { MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media'; import { CreateInstrumentDto } from './dto/create-instrument.dto'; import { CreateRepairShopDto } from './dto/create-repair-shop.dto'; import { InstrumentQueryDto } from './dto/instrument-query.dto'; @@ -149,7 +150,7 @@ export class MarketplaceController { @ApiBearerAuth() @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE) - @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }])) + @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.marketplaceImage, files: 5 } })) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { @@ -186,7 +187,7 @@ export class MarketplaceController { @ApiBearerAuth() @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE) - @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }])) + @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.marketplaceImage, files: 5 } })) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { @@ -222,7 +223,7 @@ export class MarketplaceController { @ApiBearerAuth() @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE) - @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }])) + @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.marketplaceImage, files: 8 } })) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { @@ -259,7 +260,7 @@ export class MarketplaceController { @ApiBearerAuth() @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE) - @UseInterceptors(FileFieldsInterceptor([{ name: 'shopImageFiles', maxCount: 8 }])) + @UseInterceptors(FileFieldsInterceptor([{ name: 'shopImageFiles', maxCount: 8 }], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.marketplaceImage, files: 8 } })) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { @@ -295,7 +296,7 @@ export class MarketplaceController { @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) - @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }])) + @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.marketplaceImage, files: 5 } })) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { @@ -332,7 +333,7 @@ export class MarketplaceController { @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) - @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }])) + @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.marketplaceImage, files: 5 } })) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { @@ -386,7 +387,7 @@ export class MarketplaceController { @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) - @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }])) + @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.marketplaceImage, files: 5 } })) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { @@ -422,7 +423,7 @@ export class MarketplaceController { @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) - @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }])) + @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.marketplaceImage, files: 5 } })) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { @@ -479,7 +480,7 @@ export class MarketplaceController { @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) - @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }])) + @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.marketplaceImage, files: 8 } })) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { @@ -516,7 +517,7 @@ export class MarketplaceController { @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) - @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }])) + @UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.marketplaceImage, files: 8 } })) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { @@ -584,7 +585,7 @@ export class MarketplaceController { @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) - @UseInterceptors(FileFieldsInterceptor([{ name: 'shopImageFiles', maxCount: 8 }])) + @UseInterceptors(FileFieldsInterceptor([{ name: 'shopImageFiles', maxCount: 8 }], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.marketplaceImage, files: 8 } })) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { diff --git a/src/modules/marketplace/marketplace.service.spec.ts b/src/modules/marketplace/marketplace.service.spec.ts new file mode 100644 index 0000000..20e1d28 --- /dev/null +++ b/src/modules/marketplace/marketplace.service.spec.ts @@ -0,0 +1,339 @@ +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Types } from 'mongoose'; +import { UserRole } from '../../common/enums/user-role.enum'; +import { MarketplaceListingCategory } from './enums/marketplace-listing-category.enum'; +import { MarketplaceListingCondition } from './enums/marketplace-listing-condition.enum'; +import { MarketplaceService } from './marketplace.service'; + +describe('MarketplaceService', () => { + const adminId = '507f1f77bcf86cd799439011'; + const otherAdminId = '507f191e810c19729de860ea'; + const listingId = '507f1f77bcf86cd799439012'; + const shopId = '507f1f77bcf86cd799439013'; + + const setup = () => { + const admin = { + _id: new Types.ObjectId(adminId), + id: adminId, + name: 'Admin', + username: 'admin', + email: 'admin@example.com', + role: UserRole.ADMIN, + isDisabled: false, + shopName: 'Oudelaa Music', + shopDescription: 'Musical instruments', + shopImageUrls: ['/uploads/shop-old.jpg'], + shopLocation: 'Riyadh', + shopLatitude: 24.7, + shopLongitude: 46.6, + }; + const listing = { + _id: new Types.ObjectId(listingId), + id: listingId, + ownerAdminId: new Types.ObjectId(adminId), + title: 'Guitar', + description: 'Acoustic', + price: 900, + currency: 'SAR', + imageUrls: ['/uploads/listing-old.jpg'], + listingCategory: MarketplaceListingCategory.MUSICAL_INSTRUMENT, + condition: MarketplaceListingCondition.USED, + isActive: true, + }; + const repairShop = { + _id: new Types.ObjectId(shopId), + id: shopId, + ownerAdminId: new Types.ObjectId(adminId), + name: 'Repair Lab', + imageUrls: ['/uploads/repair-old.jpg'], + isActive: true, + }; + const repository = { + create: jest.fn().mockResolvedValue(listing), + findById: jest.fn().mockResolvedValue(listing), + updateById: jest.fn().mockImplementation(async (_id, payload) => ({ ...listing, ...payload })), + deleteById: jest.fn().mockResolvedValue(true), + findManyPublic: jest.fn().mockResolvedValue([listing]), + countPublic: jest.fn().mockResolvedValue(1), + createRepairShop: jest.fn().mockResolvedValue(repairShop), + findRepairShopById: jest.fn().mockResolvedValue(repairShop), + findRepairShopByOwnerAdminId: jest.fn().mockResolvedValue(null), + updateRepairShopById: jest.fn().mockImplementation(async (_id, payload) => ({ ...repairShop, ...payload })), + deleteRepairShopById: jest.fn().mockResolvedValue(true), + findManyRepairShopsPublic: jest.fn().mockResolvedValue([repairShop]), + countRepairShopsPublic: jest.fn().mockResolvedValue(1), + }; + const usersRepository = { + findById: jest.fn().mockResolvedValue(admin), + findMany: jest.fn().mockResolvedValue([admin]), + updateById: jest.fn().mockImplementation(async (_id, payload) => ({ ...admin, ...payload })), + }; + const storage = { + saveFile: jest.fn().mockResolvedValue('/uploads/new.jpg'), + deleteFile: jest.fn().mockResolvedValue(undefined), + }; + const audit = { logSuperAdminAction: jest.fn().mockResolvedValue(undefined) }; + const existingCase = { + status: 'open', + priority: 'normal', + title: '', + description: '', + resolution: '', + updatedBy: '', + events: [] as any[], + save: jest.fn().mockResolvedValue(undefined), + }; + const caseExec = jest.fn().mockResolvedValue(null); + const caseSort = jest.fn().mockReturnValue({ exec: caseExec }); + const caseModel = { + findOne: jest.fn().mockReturnValue({ sort: caseSort }), + create: jest.fn().mockResolvedValue({}), + }; + const service = new MarketplaceService( + repository as any, + usersRepository as any, + storage as any, + audit as any, + caseModel as any, + ); + return { + service, + repository, + usersRepository, + storage, + audit, + caseModel, + caseExec, + existingCase, + admin, + listing, + repairShop, + }; + }; + + const image = { + originalname: 'instrument.jpg', + mimetype: 'image/jpeg', + size: 3, + buffer: Buffer.from([0xff, 0xd8, 0xff]), + }; + + it('creates listings with normalized defaults and managed uploads', async () => { + const { service, repository, storage } = setup(); + + await service.createListingByAdmin(adminId, { title: 'Guitar', price: 900, currency: 'sar' } as any, [image]); + + expect(storage.saveFile).toHaveBeenCalledWith( + expect.objectContaining({ extension: '.jpg', contentType: 'image/jpeg', fileNamePrefix: 'listing' }), + ); + expect(repository.create).toHaveBeenCalledWith( + adminId, + expect.objectContaining({ + currency: 'SAR', + description: '', + imageUrls: ['/uploads/new.jpg'], + isActive: true, + condition: MarketplaceListingCondition.USED, + instrumentType: '', + listingCategory: MarketplaceListingCategory.MUSICAL_INSTRUMENT, + }), + ); + }); + + it('supports superadmin and legacy instrument creation entry points', async () => { + const { service } = setup(); + const listingSpy = jest.spyOn(service, 'createListingByAdmin').mockResolvedValue({ id: listingId } as any); + const instrumentSpy = jest.spyOn(service, 'createInstrumentByAdmin').mockResolvedValue({ id: listingId } as any); + + await service.createListingBySuperAdmin(adminId, { title: 'Guitar', price: 1 } as any); + await service.createByAdmin(adminId, { title: 'Guitar', price: 1 } as any); + await service.createInstrumentBySuperAdmin(adminId, { title: 'Guitar', price: 1 } as any); + + expect(listingSpy).toHaveBeenCalled(); + expect(instrumentSpy).toHaveBeenCalledTimes(2); + }); + + it('updates listing fields without deleting retained images when imageUrls is omitted', async () => { + const { service, repository, storage } = setup(); + + const result = await service.updateListingByAdmin(adminId, listingId, { + currency: 'usd', + instrumentType: ' guitar ', + } as any); + + expect(result).toEqual(expect.objectContaining({ currency: 'USD', instrumentType: 'guitar' })); + expect(repository.updateById).toHaveBeenCalledWith( + listingId, + expect.not.objectContaining({ imageUrls: expect.anything() }), + ); + expect(storage.deleteFile).not.toHaveBeenCalled(); + }); + + it('replaces images and cleans only managed URLs removed by an update', async () => { + const { service, storage } = setup(); + + await service.updateListingByAdmin(adminId, listingId, { imageUrls: ['/uploads/kept.jpg'] } as any, [image]); + + expect(storage.deleteFile).toHaveBeenCalledWith('/uploads/listing-old.jpg'); + }); + + it('protects listing ownership and missing resources', async () => { + const { service, repository, listing } = setup(); + repository.findById.mockResolvedValueOnce(null); + await expect(service.updateListingByAdmin(adminId, listingId, {} as any)).rejects.toBeInstanceOf(NotFoundException); + + repository.findById.mockResolvedValueOnce({ ...listing, ownerAdminId: new Types.ObjectId(otherAdminId) }); + await expect(service.updateListingByAdmin(adminId, listingId, {} as any)).rejects.toBeInstanceOf(ForbiddenException); + + repository.updateById.mockResolvedValueOnce(null); + await expect(service.updateListingByAdmin(adminId, listingId, {}, [image])).rejects.toBeInstanceOf(NotFoundException); + }); + + it('updates and removes musical-instrument listings through compatibility methods', async () => { + const { service, repository, storage } = setup(); + await service.updateInstrumentByAdmin(adminId, listingId, { title: 'New title' } as any); + await service.updateByAdmin(adminId, listingId, { title: 'Legacy title' } as any); + await service.removeInstrumentByAdmin(adminId, listingId); + await service.removeByAdmin(adminId, listingId); + await service.removeListingByAdmin(adminId, listingId); + + expect(repository.deleteById).toHaveBeenCalledTimes(3); + expect(storage.deleteFile).toHaveBeenCalledWith('/uploads/listing-old.jpg'); + }); + + it('builds public, owner, superadmin, and home listing views', async () => { + const { service, repository } = setup(); + const detailedQuery = { + q: 'gui.tar', + page: 2, + limit: 3, + minPrice: 10, + maxPrice: 2000, + isActive: false, + condition: MarketplaceListingCondition.USED, + instrumentType: ' guitar ', + listingCategory: MarketplaceListingCategory.ACCESSORY, + sortBy: 'price', + sortOrder: 'asc', + } as any; + + await service.getMyListings(adminId, detailedQuery); + await service.getMine(adminId, {} as any); + await service.getPublicListings({} as any); + await service.getPublic({} as any); + await service.getListingsForSuperAdmin(detailedQuery); + await service.getPublicInstruments({} as any); + const home = await service.getHome({ onlyActive: false } as any); + + expect(repository.findManyPublic).toHaveBeenCalled(); + expect(home).toEqual( + expect.objectContaining({ + categories: expect.any(Array), + summary: expect.objectContaining({ activeListings: 1 }), + sections: expect.any(Object), + }), + ); + }); + + it('finds listings while hiding disabled owners and non-instrument categories', async () => { + const { service, repository, listing } = setup(); + await expect(service.findById(listingId)).resolves.toEqual(expect.objectContaining({ id: listingId })); + await expect(service.findInstrumentById(listingId)).resolves.toEqual(expect.objectContaining({ id: listingId })); + + repository.findById.mockResolvedValueOnce(null); + await expect(service.findListingById(listingId)).rejects.toBeInstanceOf(NotFoundException); + repository.findById.mockResolvedValueOnce({ ...listing, ownerAdminId: { isDisabled: true } }); + await expect(service.findListingById(listingId)).rejects.toBeInstanceOf(NotFoundException); + repository.findById.mockResolvedValueOnce({ ...listing, listingCategory: MarketplaceListingCategory.ACCESSORY }); + await expect(service.findInstrumentById(listingId)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('creates, updates, queries, and removes repair shops safely', async () => { + const { service, repository, storage } = setup(); + await service.createRepairShop( + adminId, + { name: 'Repair Lab', latitude: 24.7, longitude: 46.6 } as any, + [image], + ); + await service.createRepairShopBySuperAdmin(adminId, { name: 'Repair Lab' } as any); + await service.updateRepairShop(adminId, shopId, { name: 'Repair Lab 2' } as any); + expect(storage.deleteFile).not.toHaveBeenCalled(); + await service.getMyRepairShops(adminId, { q: 'repair', sortOrder: 'asc' } as any); + await service.getPublicRepairShops({} as any); + await service.getRepairShopsForSuperAdmin({ isActive: false } as any); + await service.findRepairShopById(shopId); + await service.removeRepairShop(adminId, shopId); + + expect(repository.createRepairShop).toHaveBeenCalledWith( + adminId, + expect.objectContaining({ services: [], imageUrls: ['/uploads/new.jpg'], isActive: true }), + ); + expect(repository.deleteRepairShopById).toHaveBeenCalledWith(shopId); + }); + + it('manages shop profiles and preserves images on text-only updates', async () => { + const { service, usersRepository, storage } = setup(); + await service.updateMyShopProfile(adminId, { shopName: ' New Shop ' } as any); + expect(storage.deleteFile).not.toHaveBeenCalled(); + await service.updateShopProfileBySuperAdmin(adminId, { shopDescription: ' Updated ' } as any); + await service.getMyShopProfile(adminId); + await service.getShopProfileByAdminId(adminId); + + expect(usersRepository.updateById).toHaveBeenCalledWith( + adminId, + expect.objectContaining({ shopName: 'New Shop' }), + ); + }); + + it('audits superadmin listing and repair-shop moderation and records cases', async () => { + const { service, audit, caseModel, caseExec, existingCase } = setup(); + await service.updateListingStatusBySuperAdmin('root@example.com', listingId, { + isActive: false, + reason: ' policy ', + } as any); + await service.removeListingBySuperAdmin('root@example.com', listingId); + + caseExec.mockResolvedValue(existingCase); + await service.updateRepairShopStatusBySuperAdmin('root@example.com', shopId, { + isActive: true, + } as any); + await service.removeRepairShopBySuperAdmin('root@example.com', shopId); + + expect(audit.logSuperAdminAction).toHaveBeenCalledTimes(4); + expect(caseModel.create).toHaveBeenCalled(); + expect(existingCase.save).toHaveBeenCalled(); + expect(existingCase.events.length).toBeGreaterThan(0); + }); + + it('enforces roles, single-shop limits, upload limits, and coordinate pairs', async () => { + const { service, usersRepository, repository, admin } = setup(); + usersRepository.findById.mockResolvedValueOnce(null); + await expect(service.getMyListings(adminId, {} as any)).rejects.toBeInstanceOf(NotFoundException); + usersRepository.findById.mockResolvedValueOnce({ ...admin, role: UserRole.USER }); + await expect(service.getMyListings(adminId, {} as any)).rejects.toBeInstanceOf(ForbiddenException); + usersRepository.findById.mockResolvedValueOnce({ ...admin, isDisabled: true }); + await expect(service.createListingBySuperAdmin(adminId, { title: 'x', price: 1 } as any)).rejects.toBeInstanceOf( + ForbiddenException, + ); + + repository.findRepairShopByOwnerAdminId.mockResolvedValueOnce({ id: shopId }); + await expect(service.createRepairShop(adminId, { name: 'x' } as any)).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.createListingByAdmin(adminId, { title: 'x', price: 1, imageUrls: Array(6).fill('/x') } as any), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.createRepairShop(adminId, { name: 'x', imageUrls: Array(9).fill('/x') } as any), + ).rejects.toBeInstanceOf(BadRequestException); + await expect(service.createRepairShop(adminId, { name: 'x', latitude: 2 } as any)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('requires a complete seller profile before listing', async () => { + const { service, usersRepository, admin } = setup(); + usersRepository.findById.mockResolvedValueOnce({ ...admin, shopLocation: '' }); + await expect(service.createListingByAdmin(adminId, { title: 'x', price: 1 } as any)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/src/modules/marketplace/marketplace.service.ts b/src/modules/marketplace/marketplace.service.ts index d70ab55..8d06769 100644 --- a/src/modules/marketplace/marketplace.service.ts +++ b/src/modules/marketplace/marketplace.service.ts @@ -5,6 +5,7 @@ import { assertAllowedMediaFile, MEDIA_MAX_SIZE_BYTES } from '../../common/media import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { resolveManagedFileUrl, resolveManagedFileUrls } from '../../common/utils/public-url.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util'; +import { escapeRegex } from '../../common/utils/regex.util'; import { UserRole } from '../../common/enums/user-role.enum'; import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; import { AuditService } from '../audit/audit.service'; @@ -233,7 +234,9 @@ export class MarketplaceService { await Promise.all(uploadedImageUrls.map((fileUrl) => this.deleteManagedUpload(fileUrl))); throw new NotFoundException('Listing not found'); } - await this.cleanupRemovedManagedUrls(existing.imageUrls, imageUrls); + if (typeof imageUrls !== 'undefined') { + await this.cleanupRemovedManagedUrls(existing.imageUrls, imageUrls); + } return updated; } @@ -491,7 +494,9 @@ export class MarketplaceService { await Promise.all(uploadedImageUrls.map((fileUrl) => this.deleteManagedUpload(fileUrl))); throw new NotFoundException('Repair shop not found'); } - await this.cleanupRemovedManagedUrls(existing.imageUrls, imageUrls); + if (typeof imageUrls !== 'undefined') { + await this.cleanupRemovedManagedUrls(existing.imageUrls, imageUrls); + } return updated; } @@ -623,7 +628,9 @@ export class MarketplaceService { throw new NotFoundException('Admin not found'); } - await this.cleanupRemovedManagedUrls(admin.shopImageUrls, shopImageUrls); + if (typeof shopImageUrls !== 'undefined') { + await this.cleanupRemovedManagedUrls(admin.shopImageUrls, shopImageUrls); + } return this.mapShopProfile(updated); } @@ -800,9 +807,9 @@ export class MarketplaceService { const clauses: FilterQuery[] = []; if (query.q) { const searchClauses: Record[] = [ - { title: { $regex: query.q, $options: 'i' } }, - { description: { $regex: query.q, $options: 'i' } }, - { instrumentType: { $regex: query.q, $options: 'i' } }, + { title: { $regex: escapeRegex(query.q), $options: 'i' } }, + { description: { $regex: escapeRegex(query.q), $options: 'i' } }, + { instrumentType: { $regex: escapeRegex(query.q), $options: 'i' } }, ]; if (options?.matchingShopOwnerIds?.length) { searchClauses.push({ ownerAdminId: { $in: options.matchingShopOwnerIds } }); @@ -832,7 +839,7 @@ export class MarketplaceService { } if (query.instrumentType?.trim()) { - clauses.push({ instrumentType: { $regex: query.instrumentType.trim(), $options: 'i' } }); + clauses.push({ instrumentType: { $regex: escapeRegex(query.instrumentType.trim()), $options: 'i' } }); } if (query.listingCategory) { @@ -859,10 +866,10 @@ export class MarketplaceService { if (query.q) { filter.$or = [ - { name: { $regex: query.q, $options: 'i' } }, - { description: { $regex: query.q, $options: 'i' } }, - { location: { $regex: query.q, $options: 'i' } }, - { services: { $elemMatch: { $regex: query.q, $options: 'i' } } }, + { name: { $regex: escapeRegex(query.q), $options: 'i' } }, + { description: { $regex: escapeRegex(query.q), $options: 'i' } }, + { location: { $regex: escapeRegex(query.q), $options: 'i' } }, + { services: { $elemMatch: { $regex: escapeRegex(query.q), $options: 'i' } } }, ]; } diff --git a/src/modules/media/direct-media-upload.controller.spec.ts b/src/modules/media/direct-media-upload.controller.spec.ts new file mode 100644 index 0000000..8a4c449 --- /dev/null +++ b/src/modules/media/direct-media-upload.controller.spec.ts @@ -0,0 +1,31 @@ +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { DirectMediaUploadController } from './direct-media-upload.controller'; + +describe('DirectMediaUploadController', () => { + it('delegates the authenticated user id and validated request fields', async () => { + const mediaStorageService = { + createPresignedUpload: jest.fn().mockResolvedValue({ uploadUrl: 'signed' }), + }; + const controller = new DirectMediaUploadController(mediaStorageService as never); + + await expect( + controller.createPresignedUpload( + { sub: 'user-1', username: 'artist', tokenType: 'access' }, + { folder: 'posts/images', mimeType: 'image/jpeg', size: 2048 }, + ), + ).resolves.toEqual({ uploadUrl: 'signed' }); + expect(mediaStorageService.createPresignedUpload).toHaveBeenCalledWith({ + userId: 'user-1', + folder: 'posts/images', + mimeType: 'image/jpeg', + size: 2048, + }); + }); + + it('is protected by the JWT guard at controller level', () => { + const guards = Reflect.getMetadata(GUARDS_METADATA, DirectMediaUploadController) as unknown[]; + + expect(guards).toContain(JwtAuthGuard); + }); +}); diff --git a/src/modules/media/direct-media-upload.controller.ts b/src/modules/media/direct-media-upload.controller.ts new file mode 100644 index 0000000..90b3449 --- /dev/null +++ b/src/modules/media/direct-media-upload.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { Throttle } from '../../common/decorators/throttle.decorator'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; +import { MediaStorageService } from '../../common/media/media-storage.service'; +import { CreateDirectUploadDto } from './dto/create-direct-upload.dto'; + +@ApiTags('Media') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('media/uploads') +export class DirectMediaUploadController { + constructor(private readonly mediaStorageService: MediaStorageService) {} + + @Post('presign') + @HttpCode(HttpStatus.OK) + @Throttle(30, 60_000) + @ApiOperation({ summary: 'Create a short-lived S3 PUT URL for a direct media upload' }) + createPresignedUpload(@CurrentUser() user: JwtPayload, @Body() dto: CreateDirectUploadDto) { + return this.mediaStorageService.createPresignedUpload({ + userId: user.sub, + folder: dto.folder, + mimeType: dto.mimeType, + size: dto.size, + }); + } +} diff --git a/src/modules/media/dto/create-direct-upload.dto.ts b/src/modules/media/dto/create-direct-upload.dto.ts new file mode 100644 index 0000000..5a6ff5c --- /dev/null +++ b/src/modules/media/dto/create-direct-upload.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsInt, Max, Min } from 'class-validator'; +import { MEDIA_MAX_SIZE_BYTES } from '../../../common/media/allowed-media'; +import { + DIRECT_UPLOAD_FOLDERS, + DIRECT_UPLOAD_MIME_TYPES, + DirectUploadFolder, + DirectUploadMimeType, +} from '../../../common/media/direct-upload.policy'; + +export class CreateDirectUploadDto { + @ApiProperty({ enum: DIRECT_UPLOAD_FOLDERS, example: 'posts/video' }) + @IsIn(DIRECT_UPLOAD_FOLDERS) + folder!: DirectUploadFolder; + + @ApiProperty({ enum: DIRECT_UPLOAD_MIME_TYPES, example: 'video/mp4' }) + @IsIn(DIRECT_UPLOAD_MIME_TYPES) + mimeType!: DirectUploadMimeType; + + @ApiProperty({ + description: 'Exact number of bytes that will be sent in the PUT request', + minimum: 1, + maximum: MEDIA_MAX_SIZE_BYTES.postsVideo, + example: 5242880, + }) + @IsInt() + @Min(1) + @Max(MEDIA_MAX_SIZE_BYTES.postsVideo) + size!: number; +} diff --git a/src/modules/media/media.controller.spec.ts b/src/modules/media/media.controller.spec.ts new file mode 100644 index 0000000..78f351b --- /dev/null +++ b/src/modules/media/media.controller.spec.ts @@ -0,0 +1,46 @@ +import { MediaController } from './media.controller'; + +describe('MediaController', () => { + const media = { + getMediaHealth: jest.fn(), generateMusicFromText: jest.fn(), getMyAiMusicArchive: jest.fn(), + getAiMusicArchiveItem: jest.fn(), renameAiMusicArchiveItem: jest.fn(), deleteAiMusicArchiveItem: jest.fn(), + shareAiMusicArchiveItem: jest.fn(), shareAiMusicArchiveItemToFeed: jest.fn(), + }; + const hls = { rewritePlaylist: jest.fn() }; + const controller = new MediaController(media as never, hls as never); + const user = { sub: 'user-1', username: 'artist', tokenType: 'access' as const }; + + beforeEach(() => jest.clearAllMocks()); + + it('sets secure HLS headers and delegates wildcard path/signature', async () => { + const response = { type: jest.fn(), setHeader: jest.fn() }; + hls.rewritePlaylist.mockResolvedValue('#EXTM3U'); + await expect(controller.hlsPlaylist({ '0': 'uploads/master.m3u8' }, '10', 'sig', response as never)).resolves.toBe('#EXTM3U'); + expect(response.type).toHaveBeenCalledWith('application/vnd.apple.mpegurl'); + expect(response.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache, no-store, must-revalidate'); + expect(response.setHeader).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff'); + expect(hls.rewritePlaylist).toHaveBeenCalledWith('uploads/master.m3u8', { expires: '10', signature: 'sig' }); + await controller.hlsPlaylist({}, undefined, undefined, response as never); + expect(hls.rewritePlaylist).toHaveBeenLastCalledWith('', { expires: undefined, signature: undefined }); + }); + + it('delegates health and every authenticated archive operation', async () => { + const dto = { prompt: 'calm' }; + await controller.mediaHealth(); + await controller.generateMusicFromText(user, dto as never); + await controller.getMyAiMusicArchive(user, { page: 2, limit: 5 } as never); + await controller.getAiMusicArchiveItem(user, 'item'); + await controller.renameAiMusicArchiveItem(user, 'item', { title: 'New' }); + await controller.deleteAiMusicArchiveItem(user, 'item'); + await controller.shareAiMusicArchiveItem(user, 'item'); + await controller.shareAiMusicArchiveItemToFeed(user, 'item', { content: 'listen' }); + expect(media.getMediaHealth).toHaveBeenCalled(); + expect(media.generateMusicFromText).toHaveBeenCalledWith('user-1', dto); + expect(media.getMyAiMusicArchive).toHaveBeenCalledWith('user-1', { page: 2, limit: 5 }); + expect(media.getAiMusicArchiveItem).toHaveBeenCalledWith('user-1', 'item'); + expect(media.renameAiMusicArchiveItem).toHaveBeenCalledWith('user-1', 'item', { title: 'New' }); + expect(media.deleteAiMusicArchiveItem).toHaveBeenCalledWith('user-1', 'item'); + expect(media.shareAiMusicArchiveItem).toHaveBeenCalledWith('user-1', 'item'); + expect(media.shareAiMusicArchiveItemToFeed).toHaveBeenCalledWith('user-1', 'item', { content: 'listen' }); + }); +}); diff --git a/src/modules/media/media.controller.ts b/src/modules/media/media.controller.ts index b669e13..062c00f 100644 --- a/src/modules/media/media.controller.ts +++ b/src/modules/media/media.controller.ts @@ -35,12 +35,14 @@ export class MediaController { @ApiExcludeEndpoint() async hlsPlaylist( @Param() params: Record, + @Query('expires') expires: string | undefined, + @Query('signature') signature: string | undefined, @Res({ passthrough: true }) response: Response, ): Promise { response.type('application/vnd.apple.mpegurl'); response.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); response.setHeader('X-Content-Type-Options', 'nosniff'); - return this.hlsPlaylistService.rewritePlaylist(params['0'] ?? ''); + return this.hlsPlaylistService.rewritePlaylist(params['0'] ?? '', { expires, signature }); } @ApiBearerAuth() diff --git a/src/modules/media/media.module.ts b/src/modules/media/media.module.ts index 40d0fe3..cd52744 100644 --- a/src/modules/media/media.module.ts +++ b/src/modules/media/media.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { PostsModule } from '../posts/posts.module'; +import { DirectMediaUploadController } from './direct-media-upload.controller'; import { MediaController } from './media.controller'; import { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.service'; import { MediaService } from './media.service'; @@ -17,7 +18,7 @@ import { AiMusicArchive, AiMusicArchiveSchema } from './schemas/ai-music-archive ]), PostsModule, ], - controllers: [MediaController], + controllers: [MediaController, DirectMediaUploadController], providers: [MediaService, MediaRepository, AiMusicPromptEnhancerService], exports: [MediaService], }) diff --git a/src/modules/media/media.repository.spec.ts b/src/modules/media/media.repository.spec.ts new file mode 100644 index 0000000..33af9d9 --- /dev/null +++ b/src/modules/media/media.repository.spec.ts @@ -0,0 +1,58 @@ +import { Types } from 'mongoose'; +import { MediaRepository } from './media.repository'; +import { AiMusicArchiveType } from './schemas/ai-music-archive.schema'; + +const chain = (result: unknown) => { + const query: Record = {}; + for (const method of ['sort', 'skip', 'limit']) query[method] = jest.fn(() => query); + query.exec = jest.fn().mockResolvedValue(result); + return query; +}; + +describe('MediaRepository', () => { + const model = { + create: jest.fn(), find: jest.fn(), countDocuments: jest.fn(), findOne: jest.fn(), findOneAndUpdate: jest.fn(), + }; + const repository = new MediaRepository(model as never); + const userId = new Types.ObjectId().toString(); + const itemId = new Types.ObjectId().toString(); + + beforeEach(() => jest.clearAllMocks()); + + it('creates archive items scoped to an ObjectId owner', async () => { + model.create.mockResolvedValue({ id: itemId }); + const payload = { type: AiMusicArchiveType.TEXT_TO_MUSIC, title: 'Song', prompt: 'calm', tag: 'ambient', audioUrl: '/song.mp3' }; + await expect(repository.createAiMusicArchiveItem(userId, payload)).resolves.toEqual({ id: itemId }); + expect(model.create).toHaveBeenCalledWith(expect.objectContaining({ ...payload, userId: new Types.ObjectId(userId), isDeleted: false })); + }); + + it('lists and counts non-deleted archive items', async () => { + const listQuery = chain([{ id: itemId }]); + model.find.mockReturnValue(listQuery); + await expect(repository.findAiMusicArchiveItemsByUser(userId, 20, 10)).resolves.toEqual([{ id: itemId }]); + expect(listQuery.sort).toHaveBeenCalledWith({ createdAt: -1 }); + expect(listQuery.skip).toHaveBeenCalledWith(20); + expect(listQuery.limit).toHaveBeenCalledWith(10); + const countQuery = chain(4); + model.countDocuments.mockReturnValue(countQuery); + await expect(repository.countAiMusicArchiveItemsByUser(userId)).resolves.toBe(4); + }); + + it('rejects invalid item ids before querying', async () => { + await expect(repository.findAiMusicArchiveItemByUser(userId, 'bad')).resolves.toBeNull(); + await expect(repository.updateAiMusicArchiveTitle(userId, 'bad', 'title')).resolves.toBeNull(); + await expect(repository.softDeleteAiMusicArchiveItem(userId, 'bad')).resolves.toBe(false); + expect(model.findOne).not.toHaveBeenCalled(); + expect(model.findOneAndUpdate).not.toHaveBeenCalled(); + }); + + it('finds, renames, and soft deletes owned valid items', async () => { + model.findOne.mockReturnValue(chain({ id: itemId })); + await expect(repository.findAiMusicArchiveItemByUser(userId, itemId)).resolves.toEqual({ id: itemId }); + model.findOneAndUpdate.mockReturnValueOnce(chain({ id: itemId, title: 'New' })).mockReturnValueOnce(chain({ id: itemId })); + await expect(repository.updateAiMusicArchiveTitle(userId, itemId, 'New')).resolves.toEqual({ id: itemId, title: 'New' }); + await expect(repository.softDeleteAiMusicArchiveItem(userId, itemId)).resolves.toBe(true); + model.findOneAndUpdate.mockReturnValueOnce(chain(null)); + await expect(repository.softDeleteAiMusicArchiveItem(userId, itemId)).resolves.toBe(false); + }); +}); diff --git a/src/modules/media/media.service.edge.spec.ts b/src/modules/media/media.service.edge.spec.ts new file mode 100644 index 0000000..e7933cd --- /dev/null +++ b/src/modules/media/media.service.edge.spec.ts @@ -0,0 +1,166 @@ +import { BadRequestException, NotFoundException, ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { GoogleAuth } from 'google-auth-library'; +import { readFile } from 'fs/promises'; +import { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.service'; +import { MediaService } from './media.service'; + +jest.mock('fs/promises', () => ({ readFile: jest.fn() })); + +type ServiceInternals = { + resolveSavedAudioDurationSeconds(path: string | null, buffer: Buffer, extension: string, mime: string): Promise; + resolveSavedAudioBuffer(path: string | null, fallback: Buffer): Promise; + resolveAudioExtension(mime: string): string; + buildArchiveTitle(prompt: string): string; + formatAiMusicArchiveItem(item: unknown): Record; + resolveGoogleApplicationCredentials(): { credentials?: unknown }; +}; + +const create = (values: Record = {}, overrides: Record = {}) => { + const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService; + const storage = { + getHealth: jest.fn().mockResolvedValue({}), saveFile: jest.fn(), resolveLocalFilePath: jest.fn(), + ...(overrides.storage as object), + }; + const probe = { + checkFfmpeg: jest.fn().mockResolvedValue({ path: 'ffmpeg', available: true, version: '7' }), + checkFfprobe: jest.fn().mockResolvedValue({ path: 'ffprobe', available: true, version: '7' }), + extractDurationSeconds: jest.fn(), extractDurationSecondsFromBuffer: jest.fn(), + ...(overrides.probe as object), + }; + const repository = { + findAiMusicArchiveItemByUser: jest.fn(), updateAiMusicArchiveTitle: jest.fn(), + softDeleteAiMusicArchiveItem: jest.fn(), findAiMusicArchiveItemsByUser: jest.fn(), + countAiMusicArchiveItemsByUser: jest.fn(), createAiMusicArchiveItem: jest.fn(), + ...(overrides.repository as object), + }; + const posts = { create: jest.fn(), ...(overrides.posts as object) }; + const service = new MediaService(config, storage as never, probe as never, new AiMusicPromptEnhancerService(), repository as never, posts as never); + return { service, storage, probe, repository, posts }; +}; + +const doc = (overrides: Record = {}) => ({ + id: 'archive-1', audioUrl: '/audio.wav', durationSeconds: null, waveformPeaks: [], tag: 'ai', + toObject: () => ({ _id: { toString: () => 'archive-1' }, type: 'text_to_music', title: 'Song', prompt: '', tag: 'ai', audioUrl: '/audio.wav', ...overrides }), +}); + +describe('MediaService edge behavior', () => { + beforeEach(() => jest.clearAllMocks()); + + it('reports local storage and processing dependency warnings and a write error', async () => { + const { service } = create( + { 'storage.provider': 'local', 'imageProcessing.enabled': true, 'audioProcessing.enabled': true }, + { + storage: { getHealth: jest.fn().mockResolvedValue({ uploadPathWritable: false, publicPath: '/media' }) }, + probe: { + checkFfmpeg: jest.fn().mockResolvedValue({ path: 'ffmpeg', available: false, version: '' }), + checkFfprobe: jest.fn().mockResolvedValue({ path: 'ffprobe', available: false, version: '' }), + }, + }, + ); + const health = await service.getMediaHealth(); + expect(health.status).toBe('error'); + expect(health.warnings).toEqual(expect.arrayContaining([ + expect.stringContaining('Local storage'), 'PUBLIC_BASE_URL is not configured', + expect.stringContaining('ffmpeg'), expect.stringContaining('ffprobe'), + ])); + expect(health.processing).toEqual(expect.objectContaining({ imageProcessingEnabled: true, audioProcessingEnabled: true, videoHlsGenerationEnabled: true, videoThumbnailGenerationEnabled: true })); + expect(health.staticServing.uploadsPublicPath).toBe('/media'); + }); + + it('reports every S3 configuration and reachability warning', async () => { + const { service } = create( + { 'storage.provider': 's3', publicBaseUrl: 'https://api.test' }, + { storage: { getHealth: jest.fn().mockResolvedValue({ isS3Configured: false, storagePublicBaseUrlConfigured: false, s3: { reachable: false } }) } }, + ); + const health = await service.getMediaHealth(); + expect(health.status).toBe('warning'); + expect(health.warnings).toEqual(expect.arrayContaining([ + expect.stringContaining('missing required env'), expect.stringContaining('STORAGE_PUBLIC_BASE_URL'), expect.stringContaining('not reachable'), + ])); + }); + + it('returns ok for healthy configured S3 delivery', async () => { + const { service } = create( + { 'storage.provider': 's3', publicBaseUrl: 'https://api.test', 'videoProcessing.generateHls': false, 'videoProcessing.generateThumbnails': false }, + { storage: { getHealth: jest.fn().mockResolvedValue({ s3Configured: true, storagePublicBaseUrlConfigured: true, s3: { reachable: true } }) } }, + ); + await expect(service.getMediaHealth()).resolves.toEqual(expect.objectContaining({ status: 'ok', warnings: [] })); + }); + + it('rejects disabled and incomplete AI music configuration', async () => { + await expect(create().service.generateMusicFromText('u', { prompt: 'song' })).rejects.toBeInstanceOf(ServiceUnavailableException); + await expect(create({ 'aiMusic.enabled': true }).service.generateMusicFromText('u', { prompt: 'song' })).rejects.toThrow('settings are not configured'); + }); + + it('handles Google authentication failures and absent tokens', async () => { + const getClient = jest.spyOn(GoogleAuth.prototype, 'getClient'); + const values = { 'aiMusic.enabled': true, 'aiMusic.projectId': 'p', 'aiMusic.location': 'loc' }; + getClient.mockRejectedValueOnce(new Error('credentials rejected')); + await expect(create(values).service.generateMusicFromText('u', { prompt: 'song' })).rejects.toThrow('credentials rejected'); + getClient.mockResolvedValueOnce({ getAccessToken: jest.fn().mockResolvedValue(null) } as never); + await expect(create(values).service.generateMusicFromText('u', { prompt: 'song' })).rejects.toThrow('access token was not returned'); + getClient.mockRestore(); + }); + + it('rejects successful provider responses that contain no audio', async () => { + const originalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({ predictions: [{}] }) }) as never; + try { + await expect(create({ 'aiMusic.enabled': true, 'aiMusic.apiKey': 'k', 'aiMusic.projectId': 'p', 'aiMusic.location': 'loc' }).service.generateMusicFromText('u', { prompt: 'song', seed: 3 })).rejects.toThrow('did not return audio'); + const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body); + expect(body.parameters.seed).toBe(3); + } finally { + global.fetch = originalFetch; + } + }); + + it('covers archive not-found branches and successful item formatting', async () => { + const missing = create({}, { repository: { findAiMusicArchiveItemByUser: jest.fn().mockResolvedValue(null), updateAiMusicArchiveTitle: jest.fn().mockResolvedValue(null), softDeleteAiMusicArchiveItem: jest.fn().mockResolvedValue(false) } }); + await expect(missing.service.getAiMusicArchiveItem('u', 'id')).rejects.toBeInstanceOf(NotFoundException); + await expect(missing.service.renameAiMusicArchiveItem('u', 'id', { title: 'New' })).rejects.toBeInstanceOf(NotFoundException); + await expect(missing.service.deleteAiMusicArchiveItem('u', 'id')).rejects.toBeInstanceOf(NotFoundException); + await expect(missing.service.renameAiMusicArchiveItem('u', 'id', { title: ' ' })).rejects.toBeInstanceOf(BadRequestException); + + const item = doc({ waveformPeaks: 'bad', durationSeconds: undefined }); + const found = create({}, { repository: { findAiMusicArchiveItemByUser: jest.fn().mockResolvedValue(item) } }); + await expect(found.service.getAiMusicArchiveItem('u', 'id')).resolves.toEqual(expect.objectContaining({ id: 'archive-1', waveformPeaks: [], durationSeconds: null })); + }); + + it('uses item title when sharing to feed without content', async () => { + const item = doc(); + const posts = { create: jest.fn().mockResolvedValue({ id: 'post' }) }; + const { service } = create({}, { repository: { findAiMusicArchiveItemByUser: jest.fn().mockResolvedValue(item) }, posts }); + await service.shareAiMusicArchiveItemToFeed('u', 'id'); + expect(posts.create).toHaveBeenCalledWith('u', expect.objectContaining({ content: 'Song', durationSeconds: undefined })); + }); + + it('falls back across duration, buffer, extension, and title helpers', async () => { + const { service, probe } = create(); + const target = service as unknown as ServiceInternals; + probe.extractDurationSeconds.mockResolvedValue(null); + probe.extractDurationSecondsFromBuffer.mockResolvedValue(7); + await expect(target.resolveSavedAudioDurationSeconds('/saved', Buffer.from('x'), 'mp3', 'audio/mpeg')).resolves.toBe(7); + expect(target.resolveAudioExtension('audio/mpeg')).toBe('mp3'); + expect(target.resolveAudioExtension('audio/ogg')).toBe('ogg'); + expect(target.resolveAudioExtension('audio/aac')).toBe('aac'); + expect(target.resolveAudioExtension('audio/wav')).toBe('wav'); + expect(target.resolveAudioExtension('unknown')).toBe('wav'); + await expect(target.resolveSavedAudioBuffer(null, Buffer.from('fallback'))).resolves.toEqual(Buffer.from('fallback')); + (readFile as jest.Mock).mockResolvedValueOnce(Buffer.from('disk')).mockRejectedValueOnce(new Error('missing')); + await expect(target.resolveSavedAudioBuffer('/saved', Buffer.from('fallback'))).resolves.toEqual(Buffer.from('disk')); + await expect(target.resolveSavedAudioBuffer('/saved', Buffer.from('fallback'))).resolves.toEqual(Buffer.from('fallback')); + expect(target.buildArchiveTitle(' ')).toBe('Untitled AI Music'); + expect(target.buildArchiveTitle('a'.repeat(90))).toBe(`${'a'.repeat(77)}...`); + }); + + it.each([ + ['null', 'not a JSON object'], + [JSON.stringify({ private_key: 'key' }), 'missing client_email'], + [JSON.stringify({ client_email: 'a@b' }), 'missing private_key'], + ])('validates decoded credential object %#', (json, message) => { + const encoded = Buffer.from(json).toString('base64'); + const target = create({ 'aiMusic.googleApplicationCredentialsJsonBase64': encoded }).service as unknown as ServiceInternals; + expect(() => target.resolveGoogleApplicationCredentials()).toThrow(message); + }); +}); diff --git a/src/modules/moderation/content-moderation.service.spec.ts b/src/modules/moderation/content-moderation.service.spec.ts new file mode 100644 index 0000000..a6a5dbb --- /dev/null +++ b/src/modules/moderation/content-moderation.service.spec.ts @@ -0,0 +1,22 @@ +import { ContentModerationService } from './content-moderation.service'; + +describe('ContentModerationService', () => { + const createService = (terms: string[] = []) => + new ContentModerationService({ get: jest.fn().mockReturnValue(terms) } as any); + + it('leaves normal musical content active', () => { + expect(createService().analyze('جلسة عود جميلة في مقام الراست')).toEqual({ + flagged: false, + score: 0, + reasons: [], + }); + }); + + it('flags configured terms and detects link spam', () => { + const result = createService(['blocked']).analyze( + 'blocked blocked https://one.test https://two.test https://three.test', + ); + expect(result.flagged).toBe(true); + expect(result.reasons).toEqual(expect.arrayContaining(['blocked_terms', 'excessive_links'])); + }); +}); diff --git a/src/modules/moderation/content-moderation.service.ts b/src/modules/moderation/content-moderation.service.ts new file mode 100644 index 0000000..5186bf4 --- /dev/null +++ b/src/modules/moderation/content-moderation.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface ModerationResult { flagged: boolean; score: number; reasons: string[] } + +@Injectable() +export class ContentModerationService { + constructor(private readonly config: ConfigService) {} + + analyze(text: string): ModerationResult { + const normalized = text.toLowerCase().replace(/\s+/g, ' ').trim(); + const reasons: string[] = []; + let score = 0; + const terms = this.config.get('moderation.blockedTerms') ?? []; + const matchedTerms = terms.filter((term) => term && normalized.includes(term)); + if (matchedTerms.length) { + score += Math.min(8, matchedTerms.length * 4); + reasons.push('blocked_terms'); + } + const urls = normalized.match(/https?:\/\/|www\./g)?.length ?? 0; + if (urls >= 3) { score += 3; reasons.push('excessive_links'); } + if (/(.)\1{9,}/u.test(normalized)) { score += 2; reasons.push('repeated_characters'); } + if (normalized.length > 40) { + const tokens = normalized.split(' '); + const uniqueRatio = new Set(tokens).size / tokens.length; + if (uniqueRatio < 0.25) { score += 2; reasons.push('repetitive_text'); } + } + return { flagged: score >= 5, score, reasons }; + } +} diff --git a/src/modules/moderation/moderation.module.ts b/src/modules/moderation/moderation.module.ts new file mode 100644 index 0000000..3a4f6bf --- /dev/null +++ b/src/modules/moderation/moderation.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { ContentModerationService } from './content-moderation.service'; + +@Module({ providers: [ContentModerationService], exports: [ContentModerationService] }) +export class ModerationModule {} diff --git a/src/modules/music-world/music-world.service.spec.ts b/src/modules/music-world/music-world.service.spec.ts new file mode 100644 index 0000000..cfe91fa --- /dev/null +++ b/src/modules/music-world/music-world.service.spec.ts @@ -0,0 +1,249 @@ +import { MusicWorldService } from './music-world.service'; + +describe('MusicWorldService', () => { + const createService = () => { + const feedService = { + getExplore: jest.fn(), + }; + const searchService = { + searchPosts: jest.fn(), + }; + + return { + service: new MusicWorldService(feedService as any, searchService as any), + feedService, + searchService, + }; + }; + + it('returns the complete music-world navigation model', () => { + const { service } = createService(); + + const result = service.getMusicWorld(); + + expect(result.title).toBeTruthy(); + expect(result.searchPlaceholder).toBeTruthy(); + expect(result.cards).toHaveLength(6); + expect(result.cards.filter((card) => card.type === 'search')).toHaveLength(4); + expect(result.cards.filter((card) => card.type === 'navigation')).toHaveLength(2); + expect(result.cards.map((card) => card.key)).toEqual([ + 'oud', + 'ney', + 'hijaz', + 'levantine', + 'artists', + 'explore', + ]); + expect(result.sections.map((section) => section.endpoint)).toEqual([ + '/api/v1/feed/trending', + '/api/v1/feed/explore', + '/api/v1/users/discover', + ]); + }); + + it('filters non-post feed entries and normalizes sparse post shapes', async () => { + const { service, feedService } = createService(); + feedService.getExplore.mockResolvedValue({ + items: [ + null, + 'invalid', + { feedItemType: 'featured_marketplace' }, + { + _id: 'post-1', + feedItemType: 'post', + content: 'music', + media: { mediaType: 'image' }, + author: { + _id: 'user-1', + name: 'Artist', + username: 'artist', + avatar: '/avatar.jpg', + isVerified: 1, + }, + imageVariants: [{ mediumUrl: '/medium.jpg' }], + liked: true, + savedByMe: true, + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'post-2', + feedItemType: 'post', + postType: 'video', + displayUrl: '/display.jpg', + thumbnailUrl: '/thumbnail.jpg', + authorId: null, + engagement: { + likesCount: 4, + commentsCount: 3, + viewCount: 12, + playCount: 8, + }, + likedByMe: false, + saved: false, + }, + ], + page: 2, + limit: 2, + total: 5, + totalPages: 3, + nextCursor: 'cursor-2', + pagination: { hasNextPage: true }, + }); + + const result = await service.getExplore('viewer-1', { page: 2, limit: 2 } as any); + + expect(feedService.getExplore).toHaveBeenCalledWith('viewer-1', { page: 2, limit: 2 }); + expect(result).toMatchObject({ + count: 2, + page: 2, + limit: 2, + total: 5, + totalPages: 3, + nextCursor: 'cursor-2', + pagination: { hasNextPage: true }, + }); + expect(result.items[0]).toEqual({ + id: 'post-1', + postType: 'image', + content: 'music', + thumbnailUrl: '/medium.jpg', + displayUrl: '/medium.jpg', + media: { mediaType: 'image' }, + author: { + id: 'user-1', + name: 'Artist', + username: 'artist', + avatar: '/avatar.jpg', + isVerified: true, + }, + engagement: { + likesCount: 0, + commentsCount: 0, + viewCount: 0, + playCount: 0, + }, + isLiked: true, + isSaved: true, + createdAt: '2026-01-01T00:00:00.000Z', + }); + expect(result.items[1]).toMatchObject({ + id: 'post-2', + postType: 'video', + thumbnailUrl: '/thumbnail.jpg', + displayUrl: '/display.jpg', + author: { id: '', isVerified: false }, + engagement: { likesCount: 4, commentsCount: 3, viewCount: 12, playCount: 8 }, + isLiked: false, + isSaved: false, + createdAt: null, + }); + }); + + it('defensively excludes private authors from explore and search results', async () => { + const { service, feedService, searchService } = createService(); + const privateItem = { + id: 'private-post', + feedItemType: 'post', + authorId: { id: 'private-user', isPrivate: true }, + }; + feedService.getExplore.mockResolvedValue({ items: [privateItem], total: 1 }); + searchService.searchPosts.mockResolvedValue({ items: [privateItem], total: 1 }); + + await expect(service.getExplore('viewer', {} as any)).resolves.toMatchObject({ + items: [], + count: 0, + }); + await expect(service.search('viewer', { q: 'oud' } as any)).resolves.toMatchObject({ + items: [], + }); + }); + + it('returns an empty page when feed items are not an array', async () => { + const { service, feedService } = createService(); + feedService.getExplore.mockResolvedValue({ + items: null, + page: 1, + limit: 20, + total: 0, + totalPages: 1, + }); + + await expect(service.getExplore('viewer-1', {} as any)).resolves.toEqual({ + items: [], + count: 0, + page: 1, + limit: 20, + total: 0, + totalPages: 1, + nextCursor: null, + pagination: undefined, + }); + }); + + it('forces post search and resolves every supported image fallback', async () => { + const { service, searchService } = createService(); + searchService.searchPosts.mockResolvedValue({ + items: [ + { + id: 'high', + imageVariants: [{ mediumUrl: ' ', highUrl: '/high.jpg' }], + }, + { + id: 'low', + imageVariants: [{ lowUrl: '/low.jpg' }], + }, + { + id: 'original', + imageVariants: [{ originalUrl: '/original.jpg' }], + }, + { + id: 'image-item', + imageItems: [{ url: '/item.jpg' }], + }, + { + id: 'image-url', + imageUrls: ['/url.jpg'], + }, + { + id: 'media-thumbnail', + media: { thumbnailUrl: '/media-thumb.jpg' }, + }, + ], + total: 6, + }); + + const result = await service.search('viewer-2', { q: 'oud', type: 'users' } as any); + + expect(searchService.searchPosts).toHaveBeenCalledWith('viewer-2', { + q: 'oud', + type: 'posts', + }); + expect(result.items.map((item: { displayUrl: string }) => item.displayUrl)).toEqual([ + '/high.jpg', + '/low.jpg', + '/original.jpg', + '/item.jpg', + '/url.jpg', + '/media-thumb.jpg', + ]); + expect(result.items.map((item: { thumbnailUrl: string }) => item.thumbnailUrl)).toEqual([ + '/high.jpg', + '/low.jpg', + '/original.jpg', + '/item.jpg', + '/url.jpg', + '/media-thumb.jpg', + ]); + }); + + it('propagates feed and search failures', async () => { + const { service, feedService, searchService } = createService(); + feedService.getExplore.mockRejectedValue(new Error('feed unavailable')); + searchService.searchPosts.mockRejectedValue(new Error('search unavailable')); + + await expect(service.getExplore('viewer', {} as any)).rejects.toThrow('feed unavailable'); + await expect(service.search('viewer', { q: 'oud' } as any)).rejects.toThrow( + 'search unavailable', + ); + }); +}); diff --git a/src/modules/music-world/music-world.service.ts b/src/modules/music-world/music-world.service.ts index fac1378..9444e31 100644 --- a/src/modules/music-world/music-world.service.ts +++ b/src/modules/music-world/music-world.service.ts @@ -150,7 +150,8 @@ export class MusicWorldService { return ( typeof item === 'object' && item !== null && - (item as Record).feedItemType === 'post' + (item as Record).feedItemType === 'post' && + !this.hasPrivateAuthor(item as Record) ); }) .map((item) => this.toExploreGridItem(item)) @@ -176,7 +177,9 @@ export class MusicWorldService { return { ...posts, - items: posts.items.map((item) => this.toExploreGridItem(item as Record)), + items: posts.items + .filter((item) => !this.hasPrivateAuthor(item as Record)) + .map((item) => this.toExploreGridItem(item as Record)), }; } @@ -246,6 +249,11 @@ export class MusicWorldService { return typeof value === 'object' && value !== null ? (value as Record) : {}; } + private hasPrivateAuthor(item: Record): boolean { + const author = this.asRecord(item.authorId ?? item.author); + return author.isPrivate === true; + } + private firstNonEmpty(...values: unknown[]): string { return ( values.find( diff --git a/src/modules/notifications/notifications.gateway.spec.ts b/src/modules/notifications/notifications.gateway.spec.ts index e41aa5d..f7acf9b 100644 --- a/src/modules/notifications/notifications.gateway.spec.ts +++ b/src/modules/notifications/notifications.gateway.spec.ts @@ -2,7 +2,7 @@ import { NotificationsGateway } from './notifications.gateway'; describe('NotificationsGateway realtime events', () => { it('emits backward-compatible and new notification events to the recipient room', () => { - const gateway = new NotificationsGateway({} as any, {} as any); + const gateway = new NotificationsGateway({} as any, {} as any, {} as any); const roomEmitter = { emit: jest.fn() }; (gateway as any).server = { to: jest.fn().mockReturnValue(roomEmitter), diff --git a/src/modules/notifications/notifications.gateway.ts b/src/modules/notifications/notifications.gateway.ts index b5727f0..3295fdc 100644 --- a/src/modules/notifications/notifications.gateway.ts +++ b/src/modules/notifications/notifications.gateway.ts @@ -6,10 +6,20 @@ import { WebSocketServer, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; +import { UsersRepository } from '../users/users.repository'; type SocketWithUser = Socket & { data: { userId?: string } }; -@WebSocketGateway({ cors: { origin: '*' }, namespace: 'notifications' }) +@WebSocketGateway({ + cors: { + origin: + process.env.NODE_ENV === 'production' + ? (process.env.CORS_ORIGINS ?? '').split(',').map((value) => value.trim()).filter(Boolean) + : true, + credentials: true, + }, + namespace: 'notifications', +}) export class NotificationsGateway implements OnGatewayConnection { @WebSocketServer() server!: Server; @@ -17,6 +27,7 @@ export class NotificationsGateway implements OnGatewayConnection { constructor( private readonly jwtService: JwtService, private readonly configService: ConfigService, + private readonly usersRepository: UsersRepository, ) {} async handleConnection(client: SocketWithUser) { @@ -35,6 +46,12 @@ export class NotificationsGateway implements OnGatewayConnection { return; } + const user = await this.usersRepository.findById(payload.sub); + if (!user || user.isDisabled) { + client.disconnect(true); + return; + } + client.data.userId = payload.sub; await client.join(this.userRoom(payload.sub)); } catch { diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts index 9a1cbfa..5a405ec 100644 --- a/src/modules/notifications/notifications.module.ts +++ b/src/modules/notifications/notifications.module.ts @@ -7,11 +7,13 @@ import { NotificationsGateway } from './notifications.gateway'; import { NotificationsService } from './notifications.service'; import { NotificationsRepository } from './notifications.repository'; import { Notification, NotificationSchema } from './schemas/notification.schema'; +import { UsersModule } from '../users/users.module'; @Module({ imports: [ ConfigModule, JwtModule.register({}), + UsersModule, MongooseModule.forFeature([{ name: Notification.name, schema: NotificationSchema }]), ], controllers: [NotificationsController], diff --git a/src/modules/notifications/notifications.service.spec.ts b/src/modules/notifications/notifications.service.spec.ts index c7504b7..eb0f595 100644 --- a/src/modules/notifications/notifications.service.spec.ts +++ b/src/modules/notifications/notifications.service.spec.ts @@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { Types } from 'mongoose'; +import { SortOrder } from '../../common/enums/sort-order.enum'; import { NotificationUnreadCountQueryDto } from './dto/notification-query.dto'; import { NotificationsService } from './notifications.service'; @@ -84,6 +85,47 @@ describe('NotificationsService', () => { ); }); + it('creates follow approval notifications with string-only Flutter metadata', async () => { + const notificationsRepository = { + create: jest.fn().mockResolvedValue({ toJSON: () => ({ _id: 'notification-1' }) }), + countUnread: jest.fn().mockResolvedValue(1), + countUnreadByFilter: jest.fn().mockResolvedValue(0), + }; + const notificationsGateway = { emitCreated: jest.fn() }; + const service = new NotificationsService( + notificationsRepository as any, + notificationsGateway as any, + ); + const actorId = '507f1f77bcf86cd799439011'; + const recipientId = '507f191e810c19729de860ea'; + const requestId = '507f1f77bcf86cd799439012'; + + await service.createFollowRequestApprovedNotification({ + actorId, + recipientId, + requestId, + }); + + expect(notificationsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + recipientId: new Types.ObjectId(recipientId), + actorId: new Types.ObjectId(actorId), + type: 'follow_request_approved', + referenceId: new Types.ObjectId(requestId), + title: 'Follow request approved', + resourceType: 'user', + deepLink: `/users/${actorId}`, + metadata: { + type: 'follow_request_approved', + userId: actorId, + requestId, + }, + read: false, + }), + ); + expect(notificationsGateway.emitCreated).toHaveBeenCalled(); + }); + it('recalculates unread count after markAllRead', async () => { const notificationsRepository = { markAllRead: jest.fn().mockResolvedValue(4), @@ -409,4 +451,238 @@ describe('NotificationsService', () => { }), ); }); + + describe('complete notification behavior', () => { + const setup = () => { + const notificationsRepository = { + create: jest.fn().mockImplementation(async (value) => ({ + id: 'notification-1', + ...value, + toJSON: () => ({ id: 'notification-1', type: value.type }), + })), + findMine: jest.fn().mockResolvedValue([]), + countMine: jest.fn().mockResolvedValue(0), + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + countUnread: jest.fn().mockResolvedValue(3), + countUnreadAll: jest.fn().mockResolvedValue(7), + countUnreadByFilter: jest.fn().mockResolvedValue(0), + markRead: jest.fn(), + markAllRead: jest.fn().mockResolvedValue(0), + }; + const notificationsGateway = { + emitCreated: jest.fn(), + emitUnreadCount: jest.fn(), + }; + return { + service: new NotificationsService( + notificationsRepository as any, + notificationsGateway as any, + ), + notificationsRepository, + notificationsGateway, + }; + }; + + it('suppresses self-notifications before writing or broadcasting', async () => { + const { service, notificationsRepository, notificationsGateway } = setup(); + const userId = new Types.ObjectId().toString(); + + await expect( + service.create({ actorId: userId, recipientId: userId, type: 'like' }), + ).resolves.toBeNull(); + expect(notificationsRepository.create).not.toHaveBeenCalled(); + expect(notificationsGateway.emitCreated).not.toHaveBeenCalled(); + }); + + it('creates every supported title and resource navigation shape', async () => { + const { service, notificationsRepository } = setup(); + const actorId = new Types.ObjectId().toString(); + const recipientId = new Types.ObjectId().toString(); + const referenceId = new Types.ObjectId().toString(); + const cases = [ + ['like', 'New like'], + ['comment', 'New comment'], + ['follow', 'New follower'], + ['message', 'New message'], + ['save', 'Post saved'], + ['share', 'Post shared'], + ['mention', 'New mention'], + ['reply', 'New reply'], + ['system', 'Notification'], + ['collaboration_request', 'Collaboration request'], + ['collaboration_request_approved', 'Collaboration request approved'], + ['collaboration_request_rejected', 'Collaboration request rejected'], + ['collaboration_request_cancelled', 'Collaboration request cancelled'], + ['follow_request', 'Follow request'], + ['follow_request_approved', 'Follow request approved'], + ['follow_request_rejected', 'Follow request rejected'], + ['support_reply', 'Support reply'], + ['support_ticket_status', 'Support ticket updated'], + ] as const; + + for (const [type, title] of cases) { + await service.create({ actorId, recipientId, type, referenceId }); + expect(notificationsRepository.create).toHaveBeenLastCalledWith( + expect.objectContaining({ type, title }), + ); + } + + expect(notificationsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'follow_request', + resourceType: 'user', + deepLink: `/users/${referenceId}`, + }), + ); + expect(notificationsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'support_reply', + resourceType: 'support_ticket', + deepLink: `/support/tickets/${referenceId}`, + }), + ); + }); + + it('honors trimmed custom fields and handles a notification without a reference', async () => { + const { service, notificationsRepository } = setup(); + const actorId = new Types.ObjectId().toString(); + const recipientId = new Types.ObjectId().toString(); + + await service.create({ + actorId, + recipientId, + type: 'like', + title: ' Custom title ', + previewText: ' Preview ', + resourceType: ' post ', + deepLink: ' /custom ', + metadata: { source: 'test' }, + }); + + expect(notificationsRepository.create).toHaveBeenLastCalledWith( + expect.objectContaining({ + title: 'Custom title', + previewText: 'Preview', + resourceType: 'post', + deepLink: '/custom', + referenceId: undefined, + metadata: { source: 'test' }, + }), + ); + + await service.create({ actorId, recipientId, type: 'like' }); + expect(notificationsRepository.create).toHaveBeenLastCalledWith( + expect.objectContaining({ deepLink: '' }), + ); + }); + + it('executes all convenience creators with their defaults and options', async () => { + const { service } = setup(); + const actorId = new Types.ObjectId().toString(); + const recipientId = new Types.ObjectId().toString(); + const referenceId = new Types.ObjectId().toString(); + const createSpy = jest.spyOn(service, 'create'); + + await service.createFollowNotification(actorId, recipientId); + await service.createLikeNotification(actorId, recipientId, referenceId, { + resourceType: 'reel', + previewText: 'liked', + }); + await service.createCommentNotification(actorId, recipientId, referenceId); + await service.createSaveNotification(actorId, recipientId, referenceId, { + previewText: 'saved', + }); + await service.createShareNotification(actorId, recipientId, referenceId); + await service.createMessageNotification(actorId, recipientId, referenceId); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'follow', referenceId: actorId }), + ); + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'like', resourceType: 'reel', previewText: 'liked' }), + ); + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'comment', resourceType: 'post', previewText: '' }), + ); + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'message', metadata: expect.not.objectContaining({ messageId: expect.anything() }) }), + ); + }); + + it('applies read, resource, paging, and ascending sort filters for the user', async () => { + const { service, notificationsRepository } = setup(); + notificationsRepository.findMine.mockResolvedValue([{ id: 'n1' }]); + notificationsRepository.countMine.mockResolvedValue(8); + + const result = await service.getMine('user-1', { + page: 2, + limit: 3, + read: false, + resourceType: ' post ', + sortOrder: SortOrder.ASC, + }); + + expect(notificationsRepository.findMine).toHaveBeenCalledWith( + 'user-1', + { read: false, resourceType: 'post' }, + 3, + 3, + { createdAt: 1 }, + ); + expect(result).toMatchObject({ page: 2, limit: 3, total: 8, unreadCount: 3 }); + }); + + it('lists super-admin notifications while counting unread independently of read filter', async () => { + const { service, notificationsRepository } = setup(); + notificationsRepository.findMany.mockResolvedValue([{ id: 'n1' }]); + notificationsRepository.count.mockResolvedValue(4); + + const result = await service.getForSuperAdmin({ + page: 2, + limit: 2, + read: true, + category: 'messages', + resourceType: ' conversation ', + sortOrder: SortOrder.ASC, + }); + + const filter = { + read: true, + type: { $in: ['message'] }, + resourceType: 'conversation', + }; + expect(notificationsRepository.findMany).toHaveBeenCalledWith(filter, 2, 2, { + createdAt: 1, + }); + expect(notificationsRepository.count).toHaveBeenCalledWith(filter); + expect(notificationsRepository.countUnreadAll).toHaveBeenCalledWith({ + type: { $in: ['message'] }, + resourceType: 'conversation', + }); + expect(result).toMatchObject({ page: 2, limit: 2, total: 4, unreadCount: 7 }); + }); + + it('marks an existing notification read and rejects an inaccessible one', async () => { + const { service, notificationsRepository, notificationsGateway } = setup(); + const notificationId = new Types.ObjectId().toString(); + notificationsRepository.markRead.mockResolvedValueOnce(null).mockResolvedValueOnce({ + id: notificationId, + }); + + await expect(service.markRead('user-1', notificationId)).rejects.toThrow( + 'Notification not found', + ); + await expect(service.markRead('user-1', notificationId)).resolves.toMatchObject({ + message: 'Notification marked as read', + unreadCount: 3, + item: { id: notificationId }, + }); + expect(notificationsGateway.emitUnreadCount).toHaveBeenCalledWith( + 'user-1', + 3, + expect.objectContaining({ total: 3 }), + ); + }); + }); }); diff --git a/src/modules/notifications/notifications.service.ts b/src/modules/notifications/notifications.service.ts index e6e2302..c0f35cb 100644 --- a/src/modules/notifications/notifications.service.ts +++ b/src/modules/notifications/notifications.service.ts @@ -190,6 +190,29 @@ export class NotificationsService { }); } + async createFollowRequestApprovedNotification(input: { + actorId: string; + recipientId: string; + requestId: string; + }) { + const actorId = String(input.actorId); + const recipientId = String(input.recipientId); + const requestId = String(input.requestId); + return this.create({ + actorId, + recipientId, + type: 'follow_request_approved', + referenceId: requestId, + resourceType: 'user', + deepLink: `/users/${actorId}`, + metadata: { + type: 'follow_request_approved', + userId: actorId, + requestId, + }, + }); + } + async getMine(recipientId: string, query: NotificationQueryDto) { const page = query.page ?? 1; const limit = query.limit ?? 20; diff --git a/src/modules/notifications/schemas/notification.schema.ts b/src/modules/notifications/schemas/notification.schema.ts index 6beb032..fcd32ba 100644 --- a/src/modules/notifications/schemas/notification.schema.ts +++ b/src/modules/notifications/schemas/notification.schema.ts @@ -69,3 +69,13 @@ NotificationSchema.index({ recipientId: 1, read: 1, type: 1, createdAt: -1 }); NotificationSchema.index({ recipientId: 1, type: 1, createdAt: -1 }); NotificationSchema.index({ recipientId: 1, resourceType: 1, createdAt: -1 }); NotificationSchema.index({ referenceId: 1 }); +NotificationSchema.index( + { recipientId: 1, type: 1, referenceId: 1 }, + { + unique: true, + partialFilterExpression: { + type: 'follow_request_approved', + referenceId: { $exists: true }, + }, + }, +); diff --git a/src/modules/outbox/outbox.service.spec.ts b/src/modules/outbox/outbox.service.spec.ts new file mode 100644 index 0000000..8f40e82 --- /dev/null +++ b/src/modules/outbox/outbox.service.spec.ts @@ -0,0 +1,100 @@ +import { AppLoggerService } from '../../infrastructure/logging/app-logger.service'; +import { AppQueueService } from '../../infrastructure/queue/app-queue.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { OutboxService } from './outbox.service'; + +describe('OutboxService', () => { + const model = { create: jest.fn(), findById: jest.fn() }; + const notifications = { + createFollowNotification: jest.fn(), + createFollowRequestApprovedNotification: jest.fn(), + } as unknown as NotificationsService; + const queue = { registerProcessor: jest.fn(), enqueue: jest.fn() } as unknown as AppQueueService; + const logger = { warn: jest.fn() } as unknown as AppLoggerService; + const service = new OutboxService(model as never, notifications, queue, logger); + + beforeEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('registers its processor and enqueues persisted follow events', async () => { + service.onModuleInit(); + expect(queue.registerProcessor).toHaveBeenCalledWith('process_outbox_event', expect.any(Function)); + const processor = (queue.registerProcessor as jest.Mock).mock.calls[0][1] as (value: unknown) => Promise; + const processSpy = jest.spyOn(service, 'processEvent').mockResolvedValue(); + await processor({ eventId: 9 }); + expect(processSpy).toHaveBeenCalledWith('9'); + await processor({}); + expect(processSpy).toHaveBeenLastCalledWith(''); + + model.create.mockResolvedValue({ id: 'event-1' }); + await service.enqueueFollowNotification('actor', 'recipient'); + expect(model.create).toHaveBeenCalledWith({ + eventType: 'follow_notification', + payload: { actorId: 'actor', recipientId: 'recipient', referenceId: '' }, + status: 'pending', + }); + expect(queue.enqueue).toHaveBeenCalledWith('process_outbox_event', { eventId: 'event-1' }); + + model.create.mockResolvedValue({ id: 'event-2' }); + await service.enqueueFollowRequestApprovedNotification('actor', 'recipient', 'request'); + expect(model.create).toHaveBeenCalledWith({ + eventType: 'follow_request_approved_notification', + payload: { actorId: 'actor', recipientId: 'recipient', requestId: 'request' }, + status: 'pending', + }); + }); + + it('processes follow request approval notifications once', async () => { + const event = { + id: 'e-approved', + eventType: 'follow_request_approved_notification', + status: 'pending', + attempts: 0, + payload: { actorId: 'owner', recipientId: 'requester', requestId: 'request' }, + save: jest.fn(), + }; + model.findById.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) }); + await service.processEvent(event.id); + expect(notifications.createFollowRequestApprovedNotification).toHaveBeenCalledWith({ + actorId: 'owner', + recipientId: 'requester', + requestId: 'request', + }); + expect(event.status).toBe('processed'); + }); + + it('ignores missing and already processed events', async () => { + model.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) }); + await service.processEvent('missing'); + model.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ status: 'processed' }) }); + await service.processEvent('done'); + expect(notifications.createFollowNotification).not.toHaveBeenCalled(); + }); + + it('processes follow notifications and records completion atomically', async () => { + const event = { + id: 'e1', eventType: 'follow_notification', status: 'pending', attempts: 0, + payload: { actorId: 'a', recipientId: 'r', referenceId: 'post' }, save: jest.fn(), + }; + model.findById.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) }); + await service.processEvent('e1'); + expect(notifications.createFollowNotification).toHaveBeenCalledWith('a', 'r', 'post'); + expect(event).toEqual(expect.objectContaining({ status: 'processed', attempts: 1, lastError: '', processedAt: expect.any(Date) })); + expect(event.save).toHaveBeenCalled(); + }); + + it('marks failed events, logs non-Error failures, and still saves attempts', async () => { + const event = { + id: 'e2', eventType: 'follow_notification', status: 'pending', attempts: 2, + payload: {}, save: jest.fn(), + }; + model.findById.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) }); + (notifications.createFollowNotification as jest.Mock).mockRejectedValue('network'); + await service.processEvent('e2'); + expect(event).toEqual(expect.objectContaining({ status: 'failed', lastError: 'unknown outbox error', attempts: 3 })); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ eventId: 'e2' }), 'OutboxService'); + expect(event.save).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/outbox/outbox.service.ts b/src/modules/outbox/outbox.service.ts index 0957b73..917198d 100644 --- a/src/modules/outbox/outbox.service.ts +++ b/src/modules/outbox/outbox.service.ts @@ -37,6 +37,24 @@ export class OutboxService implements OnModuleInit { await this.queueService.enqueue(OutboxService.PROCESS_EVENT_JOB, { eventId: event.id }); } + async enqueueFollowRequestApprovedNotification( + actorId: string, + recipientId: string, + requestId: string, + ): Promise { + const event = await this.outboxEventModel.create({ + eventType: 'follow_request_approved_notification', + payload: { + actorId: String(actorId), + recipientId: String(recipientId), + requestId: String(requestId), + }, + status: 'pending', + }); + + await this.queueService.enqueue(OutboxService.PROCESS_EVENT_JOB, { eventId: event.id }); + } + async processEvent(eventId: string): Promise { const event = await this.outboxEventModel.findById(eventId).exec(); if (!event || event.status === 'processed') { @@ -51,6 +69,13 @@ export class OutboxService implements OnModuleInit { String(event.payload.referenceId ?? ''), ); } + if (event.eventType === 'follow_request_approved_notification') { + await this.notificationsService.createFollowRequestApprovedNotification({ + actorId: String(event.payload.actorId ?? ''), + recipientId: String(event.payload.recipientId ?? ''), + requestId: String(event.payload.requestId ?? ''), + }); + } event.status = 'processed'; event.processedAt = new Date(); diff --git a/src/modules/posts/dto/create-post.dto.ts b/src/modules/posts/dto/create-post.dto.ts index 5c476fe..dcfd7e2 100644 --- a/src/modules/posts/dto/create-post.dto.ts +++ b/src/modules/posts/dto/create-post.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { ArrayMaxSize, diff --git a/src/modules/posts/posts.controller.ts b/src/modules/posts/posts.controller.ts index a3cec42..a6dad88 100644 --- a/src/modules/posts/posts.controller.ts +++ b/src/modules/posts/posts.controller.ts @@ -16,11 +16,13 @@ import { import { FileFieldsInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { Throttle } from '../../common/decorators/throttle.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { MultipartFormDataGuard } from '../../common/guards/multipart-form-data.guard'; import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard'; import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard'; import { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; +import { MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media'; import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator'; import { AdminPostQueryDto } from './dto/admin-post-query.dto'; import { CreateReelDto } from './dto/create-reel.dto'; @@ -52,7 +54,7 @@ export class PostsController { { name: 'videoFile', maxCount: 1 }, { name: 'audioFile', maxCount: 1 }, { name: 'coverImageFile', maxCount: 1 }, - ]), + ], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.postsVideo, files: 13 } }), ) @ApiConsumes('multipart/form-data') @ApiBody({ @@ -137,7 +139,7 @@ export class PostsController { FileFieldsInterceptor([ { name: 'videoFile', maxCount: 1 }, { name: 'coverImageFile', maxCount: 1 }, - ]), + ], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.postsVideo, files: 2 } }), ) @ApiConsumes('multipart/form-data') @ApiBody({ @@ -176,8 +178,8 @@ export class PostsController { @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Get('reels') - async findReels(@Query() query: ReelQueryDto) { - return this.postsService.findReels(query); + async findReels(@CurrentUser() user: JwtPayload, @Query() query: ReelQueryDto) { + return this.postsService.findReels(query, user.sub); } @ApiBearerAuth() @@ -195,7 +197,7 @@ export class PostsController { { name: 'videoFile', maxCount: 1 }, { name: 'audioFile', maxCount: 1 }, { name: 'coverImageFile', maxCount: 1 }, - ]), + ], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.postsVideo, files: 13 } }), ) @ApiConsumes('multipart/form-data') @ApiBody({ @@ -328,6 +330,7 @@ export class PostsController { @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @Post(':postId/view') + @Throttle(120, 60_000) async registerView(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) { return this.postsService.registerView(user.sub, postId); } @@ -336,6 +339,7 @@ export class PostsController { @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @Post(':postId/play') + @Throttle(120, 60_000) async registerPlay(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) { return this.postsService.registerPlay(user.sub, postId); } diff --git a/src/modules/posts/posts.module.ts b/src/modules/posts/posts.module.ts index 8c176ec..3386efb 100644 --- a/src/modules/posts/posts.module.ts +++ b/src/modules/posts/posts.module.ts @@ -5,6 +5,7 @@ import { BlocksModule } from '../blocks/blocks.module'; import { ChatModule } from '../chat/chat.module'; import { FollowsModule } from '../follows/follows.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { ModerationModule } from '../moderation/moderation.module'; import { UsersModule } from '../users/users.module'; import { Post, PostSchema } from './schemas/post.schema'; import { PostShare, PostShareSchema } from './schemas/post-share.schema'; @@ -30,6 +31,7 @@ import { PostsService } from './posts.service'; ChatModule, FollowsModule, NotificationsModule, + ModerationModule, UsersModule, ], controllers: [PostsController, PostsUsersController], diff --git a/src/modules/posts/posts.repository.ts b/src/modules/posts/posts.repository.ts index 32892b3..7713ec8 100644 --- a/src/modules/posts/posts.repository.ts +++ b/src/modules/posts/posts.repository.ts @@ -149,9 +149,7 @@ export class PostsRepository { } async incrementLikesCount(postId: string, delta: 1 | -1, session?: ClientSession): Promise { - await this.postModel - .findByIdAndUpdate(postId, { $inc: { likesCount: delta } }, { new: false, session }) - .exec(); + await this.incrementNonNegativeCounter(postId, 'likesCount', delta, session); } async incrementCommentsCount( @@ -159,15 +157,11 @@ export class PostsRepository { delta: 1 | -1, session?: ClientSession, ): Promise { - await this.postModel - .findByIdAndUpdate(postId, { $inc: { commentsCount: delta } }, { new: false, session }) - .exec(); + await this.incrementNonNegativeCounter(postId, 'commentsCount', delta, session); } async incrementSavesCount(postId: string, delta: 1 | -1, session?: ClientSession): Promise { - await this.postModel - .findByIdAndUpdate(postId, { $inc: { savesCount: delta } }, { new: false, session }) - .exec(); + await this.incrementNonNegativeCounter(postId, 'savesCount', delta, session); } async incrementShareCount(postId: string, delta = 1, session?: ClientSession): Promise { @@ -200,6 +194,21 @@ export class PostsRepository { .exec(); } + private async incrementNonNegativeCounter( + postId: string, + field: 'likesCount' | 'commentsCount' | 'savesCount', + delta: 1 | -1, + session?: ClientSession, + ): Promise { + await this.postModel + .findByIdAndUpdate( + postId, + [{ $set: { [field]: { $max: [0, { $add: [{ $ifNull: [`$${field}`, 0] }, delta] }] } } }], + { new: false, session }, + ) + .exec(); + } + async count(filter: FilterQuery): Promise { return this.postModel.countDocuments(this.withActiveFilter(filter)).exec(); } diff --git a/src/modules/posts/posts.service.spec.ts b/src/modules/posts/posts.service.spec.ts index 6295dd5..bf6882e 100644 --- a/src/modules/posts/posts.service.spec.ts +++ b/src/modules/posts/posts.service.spec.ts @@ -1,6 +1,9 @@ import { Types } from 'mongoose'; +import { ModerationStatus } from '../../common/enums/moderation-status.enum'; import { PostType } from '../../common/enums/post-type.enum'; import { PostVisibility } from '../../common/enums/post-visibility.enum'; +import { SortOrder } from '../../common/enums/sort-order.enum'; +import { PostShareChannel, PostShareTarget } from './dto/share-post.dto'; import { PostSchema } from './schemas/post.schema'; import { PostsService } from './posts.service'; @@ -23,6 +26,12 @@ const createService = (options: { shareBaseUrl?: string; publicWebUrl?: string } updateById: jest.fn(), create: jest.fn(), incrementShareCount: jest.fn().mockResolvedValue(undefined), + incrementViewCount: jest.fn().mockResolvedValue(undefined), + incrementPlayCount: jest.fn().mockResolvedValue(undefined), + deleteById: jest.fn().mockResolvedValue(undefined), + findManyAdmin: jest.fn().mockResolvedValue([]), + countAdmin: jest.fn().mockResolvedValue(0), + updateModerationStatus: jest.fn(), findManyByIds: jest.fn().mockResolvedValue([]), }; const connection = { @@ -35,6 +44,7 @@ const createService = (options: { shareBaseUrl?: string; publicWebUrl?: string } insertOne: jest.fn().mockResolvedValue({ insertedId: new Types.ObjectId() }), find: jest.fn(() => ({ sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), project: jest.fn().mockReturnThis(), toArray: jest.fn().mockResolvedValue([]), })), @@ -47,39 +57,95 @@ const createService = (options: { shareBaseUrl?: string; publicWebUrl?: string } }), findByUsernames: jest.fn().mockResolvedValue([]), findMany: jest.fn().mockResolvedValue([]), + findPrivateUserIds: jest.fn().mockResolvedValue([]), incrementPostsCount: jest.fn().mockResolvedValue(undefined), }; const notificationsService = { createShareNotification: jest.fn().mockResolvedValue(undefined), createMentionNotification: jest.fn().mockResolvedValue(undefined), }; + const appCacheService = { + setIfAbsent: jest.fn().mockResolvedValue(true), + }; + const storageService = { + saveFile: jest.fn().mockResolvedValue('https://cdn.example.com/media/file.bin'), + saveFiles: jest.fn().mockResolvedValue([]), + deleteFile: jest.fn().mockResolvedValue(undefined), + deleteContainingDirectory: jest.fn().mockResolvedValue(undefined), + }; + const imageProcessingService = { + processForResponsiveDelivery: jest.fn().mockResolvedValue({ variants: [] }), + }; + const videoProcessingService = { + optimizeForPlayback: jest.fn().mockImplementation(async (file) => ({ + file, + hlsFiles: [], + thumbnail: null, + })), + }; + const mediaProbeService = { + extractDurationSecondsFromBuffer: jest.fn().mockResolvedValue(12), + }; + const feedVersionService = { + bumpGlobalVersion: jest.fn().mockResolvedValue(undefined), + }; + const auditService = { + logSuperAdminAction: jest.fn().mockResolvedValue(undefined), + }; + const chatService = { + createConversation: jest.fn().mockResolvedValue({ id: '507f191e810c19729de860f1' }), + sendMessage: jest.fn().mockResolvedValue({ id: '507f191e810c19729de860f2' }), + }; + const followsRepository = { + findMany: jest.fn().mockResolvedValue([]), + findFollowingIds: jest.fn().mockResolvedValue([]), + }; + const blocksRepository = { + findAnyBetween: jest.fn().mockResolvedValue(null), + findBlockingOrBlockedIds: jest.fn().mockResolvedValue([]), + }; + const contentModerationService = { + analyze: jest.fn().mockReturnValue({ flagged: false, score: 0, reasons: [] }), + }; const service = new PostsService( connection as any, configService as any, postsRepository as any, usersRepository as any, - {} as any, - {} as any, - {} as any, - {} as any, - { bumpGlobalVersion: jest.fn().mockResolvedValue(undefined) } as any, + storageService as any, + imageProcessingService as any, + videoProcessingService as any, + mediaProbeService as any, + feedVersionService as any, notificationsService as any, - {} as any, - { - createConversation: jest.fn(), - sendMessage: jest.fn(), - } as any, - { - findMany: jest.fn().mockResolvedValue([]), - } as any, - { - findAnyBetween: jest.fn().mockResolvedValue(null), - findBlockingOrBlockedIds: jest.fn().mockResolvedValue([]), - } as any, + auditService as any, + chatService as any, + followsRepository as any, + blocksRepository as any, + appCacheService as any, + contentModerationService as any, ); - return { service, postsRepository, connection, collectionMock, usersRepository, notificationsService }; + return { + service, + postsRepository, + connection, + collectionMock, + usersRepository, + notificationsService, + appCacheService, + storageService, + imageProcessingService, + videoProcessingService, + mediaProbeService, + feedVersionService, + auditService, + chatService, + followsRepository, + blocksRepository, + contentModerationService, + }; }; describe('PostsService archived profile posts', () => { @@ -152,6 +218,38 @@ describe('PostsService archived profile posts', () => { ); }); + it('does not allow a non-follower to request private profile posts', async () => { + const userId = new Types.ObjectId().toString(); + const viewerId = new Types.ObjectId().toString(); + const { service, postsRepository } = createService(); + + const result = await service.findUserPosts( + userId, + { visibility: PostVisibility.PRIVATE, page: 1, limit: 20 }, + viewerId, + ); + + expect(result.items).toEqual([]); + expect(postsRepository.findMany).not.toHaveBeenCalled(); + }); + + it('hides a private post when fetched directly by another user', async () => { + const authorId = new Types.ObjectId().toString(); + const viewerId = new Types.ObjectId().toString(); + const postId = new Types.ObjectId().toString(); + const { service, postsRepository } = createService(); + postsRepository.findById.mockResolvedValue({ + id: postId, + authorId: new Types.ObjectId(authorId), + visibility: PostVisibility.PRIVATE, + isDeleted: false, + isArchived: false, + moderationStatus: 'active', + }); + + await expect(service.findById(postId, viewerId)).rejects.toThrow('Post not found'); + }); + it('maps archived postType=reel to video posts', async () => { const userId = new Types.ObjectId().toString(); const { service, postsRepository } = createService(); @@ -241,11 +339,13 @@ describe('PostsService archived profile posts', () => { }; const shareFindChain = { sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), project: jest.fn().mockReturnThis(), toArray: jest.fn().mockResolvedValue([share]), }; const viewerShareFindChain = { sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), project: jest.fn().mockReturnThis(), toArray: jest.fn().mockResolvedValue([{ postId: new Types.ObjectId(originalPostId) }]), }; @@ -485,11 +585,13 @@ describe('PostsService post sharing', () => { }; const profileShareFindChain = { sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), project: jest.fn().mockReturnThis(), toArray: jest.fn().mockResolvedValue([insertedShare]), }; const viewerShareFindChain = { sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), project: jest.fn().mockReturnThis(), toArray: jest.fn().mockResolvedValue([{ postId: new Types.ObjectId(post.id) }]), }; @@ -639,6 +741,1159 @@ describe('PostsService post sharing', () => { }); }); +describe('PostsService production behavior', () => { + const userId = '507f1f77bcf86cd799439011'; + const ownerId = '507f191e810c19729de860ea'; + const postId = '507f1f77bcf86cd799439012'; + const file = (mimetype: string, size = 32) => ({ + mimetype, + size, + buffer: Buffer.alloc(size, 1), + originalname: 'upload.bin', + }); + const post = (overrides: Record = {}) => ({ + id: postId, + _id: new Types.ObjectId(postId), + authorId: new Types.ObjectId(ownerId), + content: 'post content', + imageUrls: [], + videoUrl: '', + hlsUrl: '', + audioUrl: '', + thumbnailUrl: '', + taggedUserIds: [], + collaboratorIds: [], + mentionUsernames: [], + mentionedUserIds: [], + visibility: PostVisibility.PUBLIC, + postType: PostType.TEXT, + isDeleted: false, + isArchived: false, + moderationStatus: ModerationStatus.ACTIVE, + shareCount: 2, + viewCount: 3, + playCount: 4, + ...overrides, + }); + + it.each([ + [{ imageUrls: Array(11).fill('x') }, [], undefined, undefined, undefined, 'up to 10 images'], + [{ imageUrls: ['url'] }, [file('image/jpeg')], undefined, undefined, undefined, 'either imageFiles or imageUrls'], + [{}, [], file('video/mp4'), file('audio/mpeg'), undefined, 'either images, video, or audio'], + [{ videoUrl: 'url' }, [], file('video/mp4'), undefined, undefined, 'either videoFile or videoUrl'], + [{ audioUrl: 'url' }, [], undefined, file('audio/mpeg'), undefined, 'either audioFile or audioUrl'], + [{ thumbnailUrl: 'url' }, [], undefined, undefined, file('image/jpeg'), 'either coverImageFile or thumbnailUrl'], + [{ content: 'x' }, [], undefined, undefined, file('image/jpeg'), 'allowed only with video or audio'], + [{ videoUrl: 'video', imageUrls: ['image'] }, [], undefined, undefined, undefined, 'either images or video'], + [{ audioUrl: 'audio', imageUrls: ['image'] }, [], undefined, undefined, undefined, 'either images or audio'], + ] as const)( + 'rejects conflicting create media input %#', + async (dto, images, video, audio, cover, message) => { + const { service, postsRepository } = createService(); + await expect( + service.create(userId, dto as any, images as any, video as any, audio as any, cover as any), + ).rejects.toThrow(message); + expect(postsRepository.create).not.toHaveBeenCalled(); + }, + ); + + it('requires text or media and applies automatic moderation to accepted posts', async () => { + const empty = createService(); + await expect(empty.service.create(userId, {})).rejects.toThrow('Post must contain caption or media'); + + const ctx = createService(); + ctx.contentModerationService.analyze.mockReturnValue({ + flagged: true, + score: 90, + reasons: ['spam'], + }); + ctx.postsRepository.create.mockResolvedValue({ id: postId }); + ctx.postsRepository.findById.mockResolvedValue({ id: postId, moderationStatus: ModerationStatus.FLAGGED }); + await ctx.service.create(userId, { + content: ' flagged #LOUD ', + visibility: PostVisibility.FOLLOWERS, + commentsDisabled: true, + }); + expect(ctx.postsRepository.create).toHaveBeenCalledWith( + userId, + expect.objectContaining({ + content: 'flagged #LOUD', + postType: PostType.TEXT, + hashtags: ['loud'], + moderationStatus: ModerationStatus.FLAGGED, + visibility: PostVisibility.FOLLOWERS, + commentsDisabled: true, + }), + ); + expect(ctx.usersRepository.incrementPostsCount).toHaveBeenCalledWith(userId, 1); + expect(ctx.feedVersionService.bumpGlobalVersion).toHaveBeenCalled(); + }); + + it('cleans uploaded media when persistence fails', async () => { + const ctx = createService(); + const savedAsset = { + primaryUrl: 'https://cdn.example.com/images/main.jpg', + variants: { + sm: 'https://cdn.example.com/images/sm.jpg', + md: 'https://cdn.example.com/images/md.jpg', + lg: 'https://cdn.example.com/images/lg.jpg', + original: 'https://cdn.example.com/images/main.jpg', + }, + }; + jest.spyOn(ctx.service as any, 'saveImageFiles').mockResolvedValue([savedAsset]); + const cleanup = jest.spyOn(ctx.service as any, 'deleteSavedImageAsset').mockResolvedValue(undefined); + ctx.postsRepository.create.mockRejectedValue(new Error('database down')); + + await expect(ctx.service.create(userId, { content: 'image' }, [file('image/jpeg')])).rejects.toThrow( + 'database down', + ); + expect(cleanup).toHaveBeenCalledWith(savedAsset); + expect(ctx.usersRepository.incrementPostsCount).not.toHaveBeenCalled(); + }); + + it('validates update existence, ownership and conflicting media input', async () => { + const missing = createService(); + missing.postsRepository.findById.mockResolvedValue(null); + await expect(missing.service.update(userId, postId, { content: 'new' })).rejects.toThrow('Post not found'); + + const foreign = createService(); + foreign.postsRepository.findById.mockResolvedValue(post()); + await expect(foreign.service.update(userId, postId, { content: 'new' })).rejects.toThrow( + 'You can only update your own posts', + ); + + const own = createService(); + own.postsRepository.findById.mockResolvedValue(post({ authorId: new Types.ObjectId(userId) })); + await expect( + own.service.update(userId, postId, { imageUrls: ['url'] }, [file('image/jpeg')]), + ).rejects.toThrow('either imageFiles or imageUrls'); + }); + + it('updates an owned post, removes incompatible media and invalidates feed cache', async () => { + const ctx = createService(); + const existing = post({ + authorId: new Types.ObjectId(userId), + videoUrl: 'https://cdn.example.com/old.mp4', + hlsUrl: 'https://cdn.example.com/old/master.m3u8', + thumbnailUrl: 'https://cdn.example.com/old.jpg', + postType: PostType.VIDEO, + }); + const updated = post({ + authorId: new Types.ObjectId(userId), + content: 'new #image', + imageUrls: ['https://cdn.example.com/new.jpg'], + videoUrl: '', + hlsUrl: '', + thumbnailUrl: '', + postType: PostType.IMAGE, + }); + ctx.postsRepository.findById.mockResolvedValue(existing); + ctx.postsRepository.updateById.mockResolvedValue(updated); + + await expect( + ctx.service.update(userId, postId, { + content: ' new #image ', + imageUrls: ['https://cdn.example.com/new.jpg'], + videoUrl: '', + }), + ).resolves.toBe(updated); + expect(ctx.postsRepository.updateById).toHaveBeenCalledWith( + postId, + expect.objectContaining({ + content: 'new #image', + imageUrls: ['https://cdn.example.com/new.jpg'], + videoUrl: '', + hlsUrl: '', + audioUrl: '', + postType: PostType.IMAGE, + }), + ); + expect(ctx.storageService.deleteFile).toHaveBeenCalledWith('https://cdn.example.com/old.mp4'); + expect(ctx.storageService.deleteContainingDirectory).toHaveBeenCalledWith( + 'https://cdn.example.com/old/master.m3u8', + ); + expect(ctx.feedVersionService.bumpGlobalVersion).toHaveBeenCalled(); + }); + + it('removes only owned posts, updates counters and tolerates cleanup failures', async () => { + const missing = createService(); + await expect(missing.service.remove(userId, postId)).rejects.toThrow('Post not found'); + + const foreign = createService(); + foreign.postsRepository.findById.mockResolvedValue(post()); + await expect(foreign.service.remove(userId, postId)).rejects.toThrow('delete your own posts'); + + const ctx = createService(); + ctx.postsRepository.findById.mockResolvedValue( + post({ + authorId: new Types.ObjectId(userId), + videoUrl: 'https://cdn.example.com/video.mp4', + hlsUrl: 'https://cdn.example.com/hls/master.m3u8', + }), + ); + ctx.storageService.deleteContainingDirectory.mockRejectedValue(new Error('cleanup failed')); + await expect(ctx.service.remove(userId, postId)).resolves.toBeUndefined(); + expect(ctx.postsRepository.deleteById).toHaveBeenCalledWith(postId, userId); + expect(ctx.usersRepository.incrementPostsCount).toHaveBeenCalledWith(userId, -1); + }); + + it('returns a formatted visible post and marks an active profile share', async () => { + const ctx = createService(); + const visible = post({ + authorId: new Types.ObjectId(ownerId), + toObject: () => ({ _id: new Types.ObjectId(postId), authorId: ownerId, shareCount: 2 }), + }); + ctx.postsRepository.findById.mockResolvedValue(visible); + ctx.usersRepository.findById.mockResolvedValue({ id: ownerId, isDisabled: false }); + ctx.collectionMock.find.mockReturnValue({ + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + project: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue([{ postId: new Types.ObjectId(postId) }]), + }); + + await expect(ctx.service.findById(postId, userId)).resolves.toMatchObject({ + id: postId, + type: 'post', + shareCount: 2, + isSharedByMe: true, + }); + + ctx.postsRepository.findById.mockResolvedValue(null); + await expect(ctx.service.findById(postId, userId)).rejects.toThrow('Post not found'); + }); + + it('rejects invalid profile ids and hides blocked user profiles', async () => { + const invalid = createService(); + await expect(invalid.service.findUserPosts('bad', {})).rejects.toThrow('Invalid user id'); + + const blocked = createService(); + jest.spyOn(blocked.service as any, 'hasBlockBetween').mockResolvedValue(true); + await expect(blocked.service.findUserPosts(ownerId, {}, userId)).resolves.toMatchObject({ + items: [], + total: 0, + }); + expect(blocked.postsRepository.findMany).not.toHaveBeenCalled(); + }); + + it('builds complete platform-admin post filters', async () => { + const ctx = createService(); + ctx.postsRepository.findManyAdmin.mockResolvedValue([{ id: postId }]); + ctx.postsRepository.countAdmin.mockResolvedValue(8); + const result = await ctx.service.findPlatformPosts({ + page: 2, + limit: 3, + sortOrder: SortOrder.ASC, + sortBy: 'likesCount' as any, + visibility: PostVisibility.FOLLOWERS, + postType: PostType.AUDIO, + authorId: ownerId, + q: ' a+b ', + hashtag: '#OUD', + moderationStatus: ModerationStatus.FLAGGED, + }); + expect(result).toMatchObject({ total: 8, page: 2, limit: 3 }); + expect(ctx.postsRepository.findManyAdmin).toHaveBeenCalledWith( + { + visibility: PostVisibility.FOLLOWERS, + postType: PostType.AUDIO, + authorId: new Types.ObjectId(ownerId), + content: { $regex: 'a\\+b', $options: 'i' }, + hashtags: 'oud', + moderationStatus: ModerationStatus.FLAGGED, + }, + 3, + 3, + { likesCount: 1 }, + ); + }); + + it('validates reel creation and delegates valid reel payloads to create', async () => { + const ctx = createService(); + await expect(ctx.service.createReel(userId, {})).rejects.toThrow('Reel requires'); + await expect( + ctx.service.createReel(userId, { videoUrl: 'url' }, file('video/mp4')), + ).rejects.toThrow('either videoFile or videoUrl'); + + const create = jest.spyOn(ctx.service, 'create').mockResolvedValue({ id: postId } as any); + await ctx.service.createReel(userId, { + videoUrl: 'https://cdn.example.com/reel.mp4', + content: 'reel', + visibility: PostVisibility.FOLLOWERS, + }); + expect(create).toHaveBeenCalledWith( + userId, + expect.objectContaining({ + videoUrl: 'https://cdn.example.com/reel.mp4', + content: 'reel', + visibility: PostVisibility.FOLLOWERS, + }), + [], + undefined, + undefined, + undefined, + ); + }); + + it('lists reels with filters, escaped query and pagination', async () => { + const ctx = createService(); + ctx.postsRepository.findMany.mockResolvedValue([{ id: postId }]); + ctx.postsRepository.count.mockResolvedValue(1); + const result = await ctx.service.findReels( + { + page: 2, + limit: 5, + visibility: PostVisibility.PUBLIC, + authorId: ownerId, + q: 'a+b', + sortOrder: SortOrder.ASC, + sortBy: 'playCount' as any, + }, + userId, + ); + expect(result.total).toBe(1); + expect(ctx.postsRepository.findMany).toHaveBeenCalledWith( + { + postType: PostType.VIDEO, + authorId: new Types.ObjectId(ownerId), + content: { $regex: 'a\\+b', $options: 'i' }, + $and: [ + { + $or: [ + { visibility: PostVisibility.PUBLIC }, + { authorId: new Types.ObjectId(userId) }, + ], + }, + { visibility: PostVisibility.PUBLIC }, + ], + }, + 5, + 5, + { playCount: 1 }, + ); + }); + + it('excludes private and blocked authors from the viewer reels query', async () => { + const ctx = createService(); + const privateAuthorId = new Types.ObjectId().toString(); + const blockedAuthorId = new Types.ObjectId().toString(); + ctx.usersRepository.findPrivateUserIds.mockResolvedValue([privateAuthorId]); + jest.spyOn(ctx.service as any, 'findInvisibleUserIds').mockResolvedValue([blockedAuthorId]); + + await ctx.service.findReels({ page: 1, limit: 20 }, userId); + + expect(ctx.postsRepository.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + authorId: { + $nin: [ + new Types.ObjectId(blockedAuthorId), + new Types.ObjectId(privateAuthorId), + ], + }, + }), + 0, + 20, + expect.any(Object), + ); + }); + + it('deduplicates post views and media plays through cache keys', async () => { + const ctx = createService(); + ctx.postsRepository.findById.mockResolvedValue( + post({ authorId: new Types.ObjectId(userId), postType: PostType.VIDEO }), + ); + await expect(ctx.service.registerView(userId, postId)).resolves.toEqual({ + success: true, + postId, + viewCount: 4, + }); + expect(ctx.postsRepository.incrementViewCount).toHaveBeenCalledWith(postId, 1); + + ctx.appCacheService.setIfAbsent.mockResolvedValue(false); + await expect(ctx.service.registerPlay(userId, postId)).resolves.toEqual({ + success: true, + postId, + playCount: 4, + }); + expect(ctx.postsRepository.incrementPlayCount).not.toHaveBeenCalled(); + + ctx.postsRepository.findById.mockResolvedValue( + post({ authorId: new Types.ObjectId(userId), postType: PostType.TEXT }), + ); + await expect(ctx.service.registerPlay(userId, postId)).rejects.toThrow('only for audio or video'); + + ctx.postsRepository.findById.mockResolvedValue(null); + await expect(ctx.service.registerView(userId, postId)).rejects.toThrow('Post not found'); + }); + + it('routes friend shares through chat and records the complete event', async () => { + const friendId = '507f191e810c19729de860eb'; + const ctx = createService(); + ctx.postsRepository.findById.mockResolvedValue(post()); + ctx.usersRepository.findById.mockImplementation(async (id: string) => ({ id, isDisabled: false })); + const result = await ctx.service.registerShare(userId, postId, { + target: PostShareTarget.FRIEND, + friendId, + caption: ' listen ', + }); + expect(result).toMatchObject({ message: 'Post shared successfully', shareCount: 3, isSharedByMe: false }); + expect(ctx.chatService.createConversation).toHaveBeenCalledWith(userId, { + participantIds: [friendId], + isGroup: false, + }); + expect(ctx.chatService.sendMessage).toHaveBeenCalledWith( + userId, + expect.objectContaining({ + conversationId: '507f191e810c19729de860f1', + content: expect.stringContaining(`shared_post:${postId}`), + }), + ); + expect(ctx.collectionMock.insertOne).toHaveBeenCalledWith( + expect.objectContaining({ + target: PostShareTarget.FRIEND, + friendId: new Types.ObjectId(friendId), + conversationId: new Types.ObjectId('507f191e810c19729de860f1'), + messageId: new Types.ObjectId('507f191e810c19729de860f2'), + }), + ); + + await expect( + ctx.service.registerShare(userId, postId, { target: PostShareTarget.FRIEND }), + ).rejects.toThrow('friendId is required'); + }); + + it('validates share friends against ids, self, disabled accounts and blocks', async () => { + const ctx = createService(); + await expect((ctx.service as any).assertShareFriend(userId, 'bad')).rejects.toThrow('Invalid friend id'); + await expect((ctx.service as any).assertShareFriend(userId, userId)).rejects.toThrow( + 'friendId cannot be the current user', + ); + ctx.usersRepository.findById.mockResolvedValue(null); + await expect((ctx.service as any).assertShareFriend(userId, ownerId)).rejects.toThrow('Friend not found'); + + ctx.usersRepository.findById.mockResolvedValue({ id: ownerId, isDisabled: false }); + ctx.blocksRepository.findAnyBetween.mockResolvedValue({ id: 'block' }); + await expect((ctx.service as any).assertShareFriend(userId, ownerId)).rejects.toThrow( + 'You cannot share with this user', + ); + }); + + it('shares externally and maps copy-link channels consistently', async () => { + const ctx = createService(); + const register = jest.spyOn(ctx.service, 'registerShare').mockResolvedValue({} as any); + await ctx.service.sharePostExternal(userId, postId, { channel: PostShareChannel.COPY_LINK }); + expect(register).toHaveBeenCalledWith(userId, postId, { + target: PostShareTarget.COPY_LINK, + channel: PostShareChannel.COPY_LINK, + }); + await ctx.service.sharePostExternal(userId, postId, {}); + expect(register).toHaveBeenLastCalledWith(userId, postId, { + target: PostShareTarget.EXTERNAL, + channel: PostShareChannel.OTHER, + }); + }); + + it('shares to profile by creating a repost and linking the share event', async () => { + const ctx = createService(); + ctx.postsRepository.findById + .mockResolvedValueOnce(post()) + .mockResolvedValueOnce(post({ shareCount: 5 })); + ctx.usersRepository.findById.mockResolvedValue({ id: ownerId, isDisabled: false }); + const repost = { id: '507f191e810c19729de860ec' }; + jest.spyOn(ctx.service, 'createRepost').mockResolvedValue(repost as any); + + await expect( + ctx.service.sharePostToProfile(userId, postId, { caption: ' my caption ' }), + ).resolves.toEqual({ shared: true, shareCount: 5, post: repost }); + expect(ctx.service.createRepost).toHaveBeenCalledWith(userId, postId, { + content: 'my caption', + visibility: PostVisibility.PUBLIC, + }); + expect(ctx.collectionMock.insertOne).toHaveBeenCalledWith( + expect.objectContaining({ sharedPostId: new Types.ObjectId(repost.id) }), + ); + }); + + it('updates normalized comment settings and supports pin/archive lifecycle', async () => { + const methods = [ + ['pinToProfile', { pinnedToProfile: true }], + ['unpinFromProfile', { pinnedToProfile: false }], + ['archive', { isArchived: true, pinnedToProfile: false }], + ['restoreArchived', { isArchived: false }], + ] as const; + for (const [method, payload] of methods) { + const ctx = createService(); + const owned = post({ authorId: new Types.ObjectId(userId) }); + ctx.postsRepository.findById.mockResolvedValue(owned); + ctx.postsRepository.updateById.mockResolvedValue({ ...owned, ...payload }); + await expect((ctx.service as any)[method](userId, postId)).resolves.toMatchObject(payload); + expect(ctx.postsRepository.updateById).toHaveBeenCalledWith(postId, payload); + } + + const settings = createService(); + settings.postsRepository.findById.mockResolvedValue(post({ authorId: new Types.ObjectId(userId) })); + settings.postsRepository.updateById.mockResolvedValue({ id: postId }); + await settings.service.updateCommentSettings(userId, postId, { + commentsDisabled: true, + commentsFollowersOnly: false, + commentFilterKeywords: [' Spam ', 'spam', '', 'Abuse'], + }); + expect(settings.postsRepository.updateById).toHaveBeenCalledWith(postId, { + commentsDisabled: true, + commentsFollowersOnly: false, + commentFilterKeywords: ['spam', 'abuse'], + }); + }); + + it('handles owner-action repository races and owner authorization errors', async () => { + const missing = createService(); + await expect(missing.service.pinToProfile(userId, postId)).rejects.toThrow('Post not found'); + const foreign = createService(); + foreign.postsRepository.findById.mockResolvedValue(post()); + await expect(foreign.service.archive(userId, postId)).rejects.toThrow('update only your own posts'); + + const race = createService(); + race.postsRepository.findById.mockResolvedValue(post({ authorId: new Types.ObjectId(userId) })); + race.postsRepository.updateById.mockResolvedValue(null); + await expect(race.service.restoreArchived(userId, postId)).rejects.toThrow('Post not found'); + await expect(race.service.updateCommentSettings(userId, postId, {})).rejects.toThrow('Post not found'); + }); + + it('deletes posts as superadmin with counter, cache and audit updates', async () => { + const ctx = createService(); + ctx.postsRepository.findById.mockResolvedValue( + post({ + imageUrls: ['https://cdn.example.com/image.jpg'], + videoUrl: 'https://cdn.example.com/video.mp4', + hlsUrl: 'https://cdn.example.com/hls/master.m3u8', + }), + ); + await ctx.service.removeBySuperAdmin('admin@example.com', postId); + expect(ctx.postsRepository.deleteById).toHaveBeenCalledWith(postId, 'admin@example.com'); + expect(ctx.usersRepository.incrementPostsCount).toHaveBeenCalledWith(ownerId, -1); + expect(ctx.auditService.logSuperAdminAction).toHaveBeenCalledWith( + 'admin@example.com', + 'post_delete', + 'post', + postId, + { authorId: ownerId }, + ); + await expect(createService().service.removeBySuperAdmin('admin', postId)).rejects.toThrow( + 'Post not found', + ); + }); + + it('updates post moderation status and records the previous state', async () => { + const ctx = createService(); + const updated = post({ moderationStatus: ModerationStatus.HIDDEN }); + ctx.postsRepository.findById.mockResolvedValue(post({ moderationStatus: ModerationStatus.FLAGGED })); + ctx.postsRepository.updateModerationStatus.mockResolvedValue(updated); + await expect( + ctx.service.updateModerationStatusBySuperAdmin('admin', postId, { + status: ModerationStatus.HIDDEN, + reason: ' abuse ', + }), + ).resolves.toBe(updated); + expect(ctx.postsRepository.updateModerationStatus).toHaveBeenCalledWith(postId, { + moderationStatus: ModerationStatus.HIDDEN, + moderationReason: 'abuse', + }); + expect(ctx.auditService.logSuperAdminAction).toHaveBeenCalledWith( + 'admin', + 'post_moderation_status_update', + 'post', + postId, + { + previousStatus: ModerationStatus.FLAGGED, + nextStatus: ModerationStatus.HIDDEN, + reason: 'abuse', + }, + ); + + await expect( + createService().service.updateModerationStatusBySuperAdmin('admin', postId, { + status: ModerationStatus.ACTIVE, + }), + ).rejects.toThrow('Post not found'); + }); + + it('enforces visibility, author state, blocks and followers in canViewerSeePost', async () => { + const ctx = createService(); + ctx.usersRepository.findById.mockResolvedValue({ id: ownerId, isDisabled: false }); + await expect(ctx.service.canViewerSeePost(undefined, post() as any)).resolves.toBe(true); + await expect( + ctx.service.canViewerSeePost(undefined, post({ visibility: PostVisibility.PRIVATE }) as any), + ).resolves.toBe(false); + await expect( + ctx.service.canViewerSeePost(undefined, post({ isArchived: true }) as any), + ).resolves.toBe(false); + + ctx.usersRepository.findById.mockResolvedValue({ id: ownerId, isDisabled: true }); + await expect(ctx.service.canViewerSeePost(userId, post() as any)).resolves.toBe(false); + + ctx.usersRepository.findById.mockResolvedValue({ id: ownerId, isDisabled: false }); + jest.spyOn(ctx.service as any, 'hasBlockBetween').mockResolvedValue(true); + await expect(ctx.service.canViewerSeePost(userId, post() as any)).resolves.toBe(false); + + (ctx.service as any).hasBlockBetween.mockResolvedValue(false); + jest.spyOn(ctx.service as any, 'isFollowing').mockResolvedValue(false); + await expect( + ctx.service.canViewerSeePost(userId, post({ visibility: PostVisibility.FOLLOWERS }) as any), + ).resolves.toBe(false); + (ctx.service as any).isFollowing.mockResolvedValue(true); + await expect( + ctx.service.canViewerSeePost(userId, post({ visibility: PostVisibility.FOLLOWERS }) as any), + ).resolves.toBe(true); + + ctx.usersRepository.findById.mockResolvedValue({ + id: ownerId, + isDisabled: false, + isPrivate: true, + }); + (ctx.service as any).isFollowing.mockResolvedValue(false); + await expect( + ctx.service.canViewerSeePost(userId, post({ visibility: PostVisibility.PUBLIC }) as any), + ).resolves.toBe(false); + (ctx.service as any).isFollowing.mockResolvedValue(true); + await expect( + ctx.service.canViewerSeePost(userId, post({ visibility: PostVisibility.PUBLIC }) as any), + ).resolves.toBe(true); + }); + + it('protects direct public posts owned by private accounts', async () => { + const ctx = createService(); + ctx.postsRepository.findById.mockResolvedValue( + post({ visibility: PostVisibility.PUBLIC }), + ); + ctx.usersRepository.findById.mockResolvedValue({ + id: ownerId, + isDisabled: false, + isPrivate: true, + }); + jest.spyOn(ctx.service as any, 'hasBlockBetween').mockResolvedValue(false); + jest.spyOn(ctx.service as any, 'isFollowing').mockResolvedValue(false); + await expect(ctx.service.findById(postId, userId)).rejects.toThrow('Post not found'); + (ctx.service as any).isFollowing.mockResolvedValue(true); + await expect(ctx.service.findById(postId, userId)).resolves.toBeDefined(); + }); + + it('redacts a populated repost original when its private author is no longer accessible', async () => { + const ctx = createService(); + const originalAuthorId = '507f191e810c19729de860ef'; + const original = post({ + id: '507f191e810c19729de860ee', + _id: new Types.ObjectId('507f191e810c19729de860ee'), + authorId: new Types.ObjectId(originalAuthorId), + visibility: PostVisibility.PUBLIC, + }); + ctx.postsRepository.findById.mockResolvedValue( + post({ repostOfPostId: original }), + ); + ctx.usersRepository.findById.mockImplementation(async (id: string) => ({ + id, + isDisabled: false, + isPrivate: id === originalAuthorId, + })); + jest.spyOn(ctx.service as any, 'hasBlockBetween').mockResolvedValue(false); + jest.spyOn(ctx.service as any, 'isFollowing').mockResolvedValue(false); + + const result = await ctx.service.findById(postId, userId); + expect(result.repostOfPostId).toBeNull(); + }); + + it('returns no profile posts to non-followers of private accounts', async () => { + const ctx = createService(); + ctx.usersRepository.findById.mockResolvedValue({ + id: ownerId, + isDisabled: false, + isPrivate: true, + }); + jest.spyOn(ctx.service as any, 'isFollowing').mockResolvedValue(false); + const hidden = await ctx.service.findUserPosts(ownerId, { page: 1, limit: 20 }, userId); + expect(hidden.items).toEqual([]); + expect(ctx.postsRepository.findMany).not.toHaveBeenCalled(); + + (ctx.service as any).isFollowing.mockResolvedValue(true); + await ctx.service.findUserPosts(ownerId, { page: 1, limit: 20 }, userId); + expect(ctx.postsRepository.findMany).toHaveBeenCalled(); + }); + + it('resolves mutually exclusive post types and reel filters', () => { + const { service } = createService(); + const internals = service as any; + expect(internals.resolvePostType()).toBe(PostType.TEXT); + expect(internals.resolvePostType(['image'])).toBe(PostType.IMAGE); + expect(internals.resolvePostType([], 'video')).toBe(PostType.VIDEO); + expect(internals.resolvePostType([], '', 'audio')).toBe(PostType.AUDIO); + expect(() => internals.resolvePostType(['image'], 'video')).toThrow('either images, video, or audio'); + expect(internals.resolvePostTypeFilter()).toBeNull(); + expect(internals.resolvePostTypeFilter('reel')).toBe(PostType.VIDEO); + expect(internals.resolvePostTypeFilter('audio')).toBe(PostType.AUDIO); + }); + + it('normalizes media metadata and generates audio waveform variants', () => { + const { service } = createService(); + const internals = service as any; + expect(internals.normalizeMediaMetadata({}, PostType.TEXT)).toEqual({ + durationSeconds: null, + thumbnailUrl: '', + style: '', + maqam: '', + rhythmSignature: '', + waveformPeaks: [], + waveformPeaksPreview: [], + waveformPeaksDetailed: [], + }); + expect(() => internals.normalizeMediaMetadata({ durationSeconds: 2 }, PostType.IMAGE)).toThrow( + 'allowed only for audio or video', + ); + expect(() => internals.normalizeMediaMetadata({ waveformPeaks: [0.2] }, PostType.VIDEO)).toThrow( + 'waveformPeaks is allowed only for audio', + ); + const audio = internals.normalizeMediaMetadata( + { + durationSeconds: 9, + thumbnailUrl: ' cover ', + style: ' classic ', + maqam: ' bayati ', + rhythmSignature: ' 4/4 ', + waveformPeaks: [0, 0.5, 1], + }, + PostType.AUDIO, + ); + expect(audio).toMatchObject({ + durationSeconds: 9, + thumbnailUrl: 'cover', + style: 'classic', + maqam: 'bayati', + rhythmSignature: '4/4', + }); + expect(audio.waveformPeaks.length).toBeGreaterThan(0); + expect(audio.waveformPeaksPreview.length).toBeGreaterThan(0); + expect(audio.waveformPeaksDetailed.length).toBeGreaterThan(0); + }); + + it('extracts normalized unique mentions, hashtags and composed post text', () => { + const { service } = createService(); + const internals = service as any; + expect(internals.combinePostText(' bottom ', ' top ', ' middle ')).toBe('top\nmiddle\nbottom'); + expect(internals.extractMentions('Hi @Artist and @artist و @عازف')).toEqual(['artist', 'عازف']); + expect(internals.normalizeMentionUsernames([' @ARTIST ', '@@artist', '', undefined])).toEqual([ + 'artist', + ]); + expect(internals.extractHashtags('#OUD #oud #مقام invalid')).toEqual(['oud', 'مقام']); + }); + + it('resolves mentions by username and id while excluding disabled users and the author', async () => { + const mentionedId = '507f191e810c19729de860eb'; + const secondId = '507f191e810c19729de860ec'; + const ctx = createService(); + ctx.usersRepository.findByUsernames.mockResolvedValue([ + { id: mentionedId, username: 'Artist', isDisabled: false, name: 'Artist' }, + { id: secondId, username: 'disabled', isDisabled: true }, + ]); + ctx.usersRepository.findMany.mockResolvedValue([ + { id: mentionedId, username: 'artist', isDisabled: false }, + { id: userId, username: 'self', isDisabled: false }, + ]); + const result = await (ctx.service as any).resolveMentionTargets( + ['@artist', '@disabled'], + [mentionedId, userId, 'bad'], + '@Artist hello', + userId, + ); + expect(result.mentionUsernames).toEqual(['artist']); + expect(result.mentionedUserIds.map(String)).toEqual([mentionedId]); + expect(result.mentionedUsers[0]).toMatchObject({ id: mentionedId, username: 'artist' }); + + const tooMany = Array.from({ length: 31 }, (_, index) => `user${index}`); + await expect( + (ctx.service as any).resolveMentionTargets(tooMany, undefined, '', userId), + ).rejects.toThrow('up to 30 users'); + }); + + it('normalizes locations and image captions with fallback values', () => { + const { service } = createService(); + const internals = service as any; + expect(internals.normalizeLocation({ location: ' Riyadh ', latitude: 24.7, longitude: 46.6 })).toEqual({ + location: 'Riyadh', + latitude: 24.7, + longitude: 46.6, + }); + expect(() => internals.normalizeLocation({ latitude: 24.7 })).toThrow( + 'latitude and longitude must be provided together', + ); + expect( + internals.buildImageItems( + ['one', 'two'], + [' first '], + [undefined, ' second alt '], + [{ url: 'old', caption: 'old caption', altText: 'old alt', order: 0 }], + ), + ).toEqual([ + { url: 'one', caption: 'first', altText: 'old alt', order: 0 }, + { url: 'two', caption: '', altText: 'second alt', order: 1 }, + ]); + }); + + it('validates tagged users and collaborator lists against the database', async () => { + const firstId = '507f191e810c19729de860eb'; + const secondId = '507f191e810c19729de860ec'; + const ctx = createService(); + ctx.usersRepository.findMany.mockResolvedValue([{ id: firstId }, { id: secondId }]); + await expect( + (ctx.service as any).normalizeTaggedUserIds([firstId, firstId, userId, secondId], userId), + ).resolves.toEqual([new Types.ObjectId(firstId), new Types.ObjectId(secondId)]); + await expect( + (ctx.service as any).normalizeUserIdList([firstId, secondId], userId, 2, 'collaborator'), + ).resolves.toHaveLength(2); + await expect((ctx.service as any).normalizeTaggedUserIds(['bad'], userId)).rejects.toThrow( + 'Invalid tagged user id', + ); + await expect( + (ctx.service as any).normalizeUserIdList([firstId, secondId], userId, 1, 'collaborator'), + ).rejects.toThrow('up to 1 collaborator'); + ctx.usersRepository.findMany.mockResolvedValue([]); + await expect((ctx.service as any).normalizeTaggedUserIds([firstId], userId)).rejects.toThrow( + 'tagged users do not exist', + ); + }); + + it('isolates mention notification failures from successful post operations', async () => { + const ctx = createService(); + ctx.notificationsService.createMentionNotification.mockRejectedValue('offline'); + await expect( + (ctx.service as any).notifyMentionedUsers( + userId, + postId, + [{ id: ownerId, username: 'owner' }], + 'preview', + ), + ).resolves.toBeUndefined(); + await expect((ctx.service as any).notifyMentionedUsers(userId, postId, [], '')).resolves.toBeUndefined(); + }); + + it('validates and stores individual media files in their correct folders', async () => { + const ctx = createService(); + const tinyImage = { mimetype: 'image/jpeg', size: 8, buffer: Buffer.alloc(8), originalname: 'x.jpg' }; + const tinyAudio = { mimetype: 'audio/mpeg', size: 8, buffer: Buffer.alloc(8), originalname: 'x.mp3' }; + expect((ctx.service as any).validateMediaFile('image', tinyImage)).toBe('.jpg'); + expect(() => (ctx.service as any).validateMediaFile('image', file('text/plain'))).toThrow( + 'imageFiles must be', + ); + await (ctx.service as any).saveMediaFile('audio', tinyAudio); + expect(ctx.storageService.saveFile).toHaveBeenCalledWith({ + folderSegments: ['posts', 'audio'], + extension: '.mp3', + buffer: tinyAudio.buffer, + contentType: 'audio/mpeg', + fileNamePrefix: 'audio', + }); + }); + + it('saves responsive image variants and chooses resilient primary fallbacks', async () => { + const ctx = createService(); + const tinyImage = { mimetype: 'image/jpeg', size: 8, buffer: Buffer.alloc(8), originalname: 'x.jpg' }; + const processed = { + primaryVariantName: 'medium', + variants: [ + { name: 'original', relativePath: 'original.jpg', buffer: Buffer.from('o'), contentType: 'image/jpeg' }, + { name: 'low', relativePath: 'low.jpg', buffer: Buffer.from('l'), contentType: 'image/jpeg' }, + { name: 'high', relativePath: 'high.jpg', buffer: Buffer.from('h'), contentType: 'image/jpeg' }, + ], + }; + ctx.imageProcessingService.processForResponsiveDelivery.mockResolvedValue(processed); + ctx.storageService.saveFiles.mockResolvedValue({ + 'original.jpg': 'original-url', + 'low.jpg': 'low-url', + 'high.jpg': 'high-url', + }); + await expect((ctx.service as any).saveResponsiveImageAsset('images', tinyImage)).resolves.toEqual({ + primaryUrl: 'high-url', + variants: { + originalUrl: 'original-url', + lowUrl: 'low-url', + mediumUrl: 'high-url', + highUrl: 'high-url', + }, + }); + expect((ctx.service as any).resolvePrimaryVariantUrl({ + originalUrl: '', lowUrl: 'low', mediumUrl: '', highUrl: 'high', + }, 'original')).toBe('high'); + }); + + it('cleans partial image batches and variant groups safely', async () => { + const ctx = createService(); + const first = { + primaryUrl: 'primary', + variants: { originalUrl: 'primary', lowUrl: 'low', mediumUrl: 'medium', highUrl: 'high' }, + }; + jest + .spyOn(ctx.service as any, 'saveResponsiveImageAsset') + .mockResolvedValueOnce(first) + .mockRejectedValueOnce(new Error('processing failed')); + const cleanup = jest.spyOn(ctx.service as any, 'deleteSavedImageAsset'); + await expect( + (ctx.service as any).saveImageFiles([ + { mimetype: 'image/jpeg', size: 1, buffer: Buffer.alloc(1) }, + { mimetype: 'image/jpeg', size: 1, buffer: Buffer.alloc(1) }, + ]), + ).rejects.toThrow('processing failed'); + expect(cleanup).toHaveBeenCalledWith(first); + + await (ctx.service as any).deleteSavedImageAsset(first); + expect(ctx.storageService.deleteContainingDirectory).toHaveBeenCalledWith('medium'); + await (ctx.service as any).deleteThumbnailAsset('thumbnail', first.variants); + expect(ctx.storageService.deleteContainingDirectory).toHaveBeenCalledWith('medium'); + }); + + it('creates video uploads with HLS and generated thumbnail metadata', async () => { + const ctx = createService(); + const tinyVideo = { mimetype: 'video/mp4', size: 8, buffer: Buffer.alloc(8), originalname: 'x.mp4' }; + ctx.videoProcessingService.optimizeForPlayback.mockResolvedValue({ + file: tinyVideo, + generatedHls: { + files: [{ relativePath: 'master.m3u8', buffer: Buffer.from('hls'), contentType: 'application/x-mpegURL' }], + playlistRelativePath: 'master.m3u8', + }, + generatedThumbnail: { + buffer: Buffer.alloc(8), + contentType: 'image/jpeg', + extension: '.jpg', + }, + }); + ctx.storageService.saveFile.mockResolvedValue('video-url'); + ctx.storageService.saveFiles.mockResolvedValue({ 'master.m3u8': 'hls-url' }); + jest.spyOn(ctx.service as any, 'saveResponsiveImageAsset').mockResolvedValue({ + primaryUrl: 'thumb-url', + variants: { originalUrl: 'thumb-url', lowUrl: 'low', mediumUrl: 'medium', highUrl: 'high' }, + }); + await expect((ctx.service as any).saveVideoUpload(tinyVideo)).resolves.toEqual({ + videoUrl: 'video-url', + hlsUrl: 'hls-url', + thumbnailUrl: 'thumb-url', + thumbnailVariants: { originalUrl: 'thumb-url', lowUrl: 'low', mediumUrl: 'medium', highUrl: 'high' }, + durationSeconds: 12, + }); + }); + + it('builds waveforms from provided, buffered, fallback and seeded sources', () => { + const { service } = createService(); + const internals = service as any; + expect(internals.resolveAudioWaveformPeaks([0, 1], undefined, undefined, {}, 2).waveformPeaks.length).toBeGreaterThan(0); + expect(internals.resolveAudioWaveformPeaks(undefined, Buffer.from([0, 1, 2]), undefined, {}, 2).waveformPeaks.length).toBeGreaterThan(0); + expect( + internals.resolveAudioWaveformPeaks(undefined, undefined, undefined, { waveformPeaksDetailed: [0, 1] }, 2) + .waveformPeaks.length, + ).toBeGreaterThan(0); + expect( + internals.resolveAudioWaveformPeaks(undefined, undefined, 'seed', {}, 2).waveformPeaks.length, + ).toBeGreaterThan(0); + }); + + it('creates quote and plain reposts while protecting empty self-reposts', async () => { + const missing = createService(); + await expect(missing.service.createRepost(userId, postId, {})).rejects.toThrow( + 'Source post not found', + ); + + const self = createService(); + self.postsRepository.findById.mockResolvedValue(post({ authorId: new Types.ObjectId(userId) })); + await expect(self.service.createRepost(userId, postId, {})).rejects.toThrow( + 'cannot repost your own post without a quote', + ); + + const quote = createService(); + quote.postsRepository.findById + .mockResolvedValueOnce(post()) + .mockResolvedValueOnce({ id: 'quote-populated' }); + quote.postsRepository.create.mockResolvedValue({ id: '507f191e810c19729de860ed' }); + await expect( + quote.service.createRepost(userId, postId, { + content: ' Great #OUD ', + visibility: PostVisibility.FOLLOWERS, + }), + ).resolves.toEqual({ id: 'quote-populated' }); + expect(quote.postsRepository.create).toHaveBeenCalledWith( + userId, + expect.objectContaining({ + content: 'Great #OUD', + repostOfPostId: null, + quoteOfPostId: new Types.ObjectId(postId), + hashtags: ['oud'], + visibility: PostVisibility.FOLLOWERS, + }), + ); + expect(quote.postsRepository.incrementShareCount).toHaveBeenCalledWith(postId, 1); + + const plain = createService(); + plain.postsRepository.findById.mockResolvedValue(post()); + plain.postsRepository.create.mockResolvedValue({ id: '507f191e810c19729de860ee' }); + await plain.service.createRepost(userId, postId, {}); + expect(plain.postsRepository.create).toHaveBeenCalledWith( + userId, + expect.objectContaining({ + repostOfPostId: new Types.ObjectId(postId), + quoteOfPostId: null, + }), + ); + }); + + it('rejects reposting a source that the viewer cannot access', async () => { + const ctx = createService(); + ctx.postsRepository.findById.mockResolvedValue( + post({ visibility: PostVisibility.PUBLIC }), + ); + ctx.usersRepository.findById.mockResolvedValue({ + id: ownerId, + isDisabled: false, + isPrivate: true, + }); + jest.spyOn(ctx.service as any, 'hasBlockBetween').mockResolvedValue(false); + jest.spyOn(ctx.service as any, 'isFollowing').mockResolvedValue(false); + + await expect(ctx.service.createRepost(userId, postId, {})).rejects.toThrow( + 'Source post not found', + ); + expect(ctx.postsRepository.create).not.toHaveBeenCalled(); + }); + + it('enforces every share-availability privacy boundary', async () => { + const invalid = createService(); + await expect((invalid.service as any).assertPostAvailableToViewer(userId, 'bad')).rejects.toThrow( + 'Invalid post id', + ); + + const hidden = createService(); + hidden.postsRepository.findById.mockResolvedValue(post({ isArchived: true })); + await expect((hidden.service as any).assertPostAvailableToViewer(userId, postId)).rejects.toThrow( + 'Post not found', + ); + + const noAuthor = createService(); + noAuthor.postsRepository.findById.mockResolvedValue(post({ authorId: null })); + await expect((noAuthor.service as any).assertPostAvailableToViewer(userId, postId)).rejects.toThrow( + 'Post not found', + ); + + const disabled = createService(); + disabled.postsRepository.findById.mockResolvedValue(post()); + disabled.usersRepository.findById.mockResolvedValue({ id: ownerId, isDisabled: true }); + await expect((disabled.service as any).assertPostAvailableToViewer(userId, postId)).rejects.toThrow( + 'Post not found', + ); + + const owner = createService(); + owner.postsRepository.findById.mockResolvedValue(post({ authorId: new Types.ObjectId(userId) })); + owner.usersRepository.findById.mockResolvedValue({ id: userId, isDisabled: false }); + await expect((owner.service as any).assertPostAvailableToViewer(userId, postId)).resolves.toBeDefined(); + + const blocked = createService(); + blocked.postsRepository.findById.mockResolvedValue(post()); + blocked.usersRepository.findById.mockResolvedValue({ id: ownerId, isDisabled: false }); + jest.spyOn(blocked.service as any, 'hasBlockBetween').mockResolvedValue(true); + await expect((blocked.service as any).assertPostAvailableToViewer(userId, postId)).rejects.toThrow( + 'cannot access this post', + ); + + const privatePost = createService(); + privatePost.postsRepository.findById.mockResolvedValue(post({ visibility: PostVisibility.PRIVATE })); + privatePost.usersRepository.findById.mockResolvedValue({ id: ownerId, isDisabled: false }); + jest.spyOn(privatePost.service as any, 'hasBlockBetween').mockResolvedValue(false); + await expect((privatePost.service as any).assertPostAvailableToViewer(userId, postId)).rejects.toThrow( + 'cannot access this post', + ); + + const followerPost = createService(); + followerPost.postsRepository.findById.mockResolvedValue( + post({ visibility: PostVisibility.FOLLOWERS }), + ); + followerPost.usersRepository.findById.mockResolvedValue({ id: ownerId, isDisabled: false }); + jest.spyOn(followerPost.service as any, 'hasBlockBetween').mockResolvedValue(false); + jest.spyOn(followerPost.service as any, 'isFollowing').mockResolvedValue(false); + await expect( + (followerPost.service as any).assertPostAvailableToViewer(userId, postId), + ).rejects.toThrow('cannot access this post'); + (followerPost.service as any).isFollowing.mockResolvedValue(true); + await expect( + (followerPost.service as any).assertPostAvailableToViewer(userId, postId), + ).resolves.toBeDefined(); + }); + + it('filters suggested share friends and formats stable display names', async () => { + const firstId = '507f191e810c19729de860eb'; + const blockedId = '507f191e810c19729de860ec'; + const ctx = createService(); + ctx.blocksRepository.findBlockingOrBlockedIds.mockResolvedValue([blockedId]); + ctx.followsRepository.findMany.mockResolvedValue([ + { + followingId: { + _id: new Types.ObjectId(firstId), + stageName: ' Star ', + username: 'first', + avatar: '/uploads/a.jpg', + isVerified: true, + }, + }, + { followingId: { _id: new Types.ObjectId(blockedId), username: 'blocked' } }, + { followingId: { _id: new Types.ObjectId(firstId), username: 'duplicate' } }, + { followingId: null }, + ]); + const result = await (ctx.service as any).findSuggestedShareFriends(userId, 10); + expect(result).toEqual([ + expect.objectContaining({ + id: firstId, + displayName: 'Star', + username: 'first', + isVerified: true, + isFollowing: true, + }), + ]); + expect((ctx.service as any).uniqueById([{ id: '' }, { id: firstId }, { id: firstId }])).toEqual([ + { id: firstId }, + ]); + }); + + it('matches shared profile posts against media, text and hashtag filters', () => { + const { service } = createService(); + const candidate = post({ + postType: PostType.VIDEO, + content: 'legacy', + contentTop: 'Top Oud', + contentBottom: 'Bottom', + hashtags: ['oud'], + }); + expect((service as any).postMatchesProfileQuery(candidate, { postType: 'reel' })).toBe(true); + expect((service as any).postMatchesProfileQuery(candidate, { postType: PostType.AUDIO })).toBe(false); + expect((service as any).postMatchesProfileQuery(candidate, { q: 'top oud' })).toBe(true); + expect((service as any).postMatchesProfileQuery(candidate, { q: 'missing' })).toBe(false); + expect((service as any).postMatchesProfileQuery(candidate, { hashtag: '#OUD' })).toBe(true); + expect((service as any).postMatchesProfileQuery(candidate, { hashtag: '#other' })).toBe(false); + }); + + it('recovers a concurrent duplicate profile share and propagates unrecoverable failures', async () => { + const duplicate = { + _id: new Types.ObjectId(), + userId: new Types.ObjectId(userId), + postId: new Types.ObjectId(postId), + }; + const ctx = createService(); + ctx.collectionMock.findOne.mockResolvedValueOnce(null).mockResolvedValueOnce(duplicate); + ctx.collectionMock.insertOne.mockRejectedValue({ code: 11000 }); + await expect( + (ctx.service as any).createProfileShareRecord(userId, postId, post(), {}), + ).resolves.toEqual({ share: duplicate, created: false }); + + const failure = createService(); + failure.collectionMock.findOne.mockResolvedValue(null); + failure.collectionMock.insertOne.mockRejectedValue(new Error('storage down')); + await expect( + (failure.service as any).createProfileShareRecord(userId, postId, post(), {}), + ).rejects.toThrow('storage down'); + }); + +}); + describe('Post schema response aliases', () => { it('returns isPinned as a stable alias for pinnedToProfile', () => { const transform = PostSchema.get('toObject')?.transform as ( diff --git a/src/modules/posts/posts.service.ts b/src/modules/posts/posts.service.ts index b66c361..4e3d251 100644 --- a/src/modules/posts/posts.service.ts +++ b/src/modules/posts/posts.service.ts @@ -17,6 +17,7 @@ import { assertAllowedMediaFile, MEDIA_MAX_SIZE_BYTES } from '../../common/media import { resolveManagedFileUrl } from '../../common/utils/public-url.util'; import { buildPostShareUrl, resolveShareBaseUrl } from '../../common/utils/share-url.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util'; +import { escapeRegex } from '../../common/utils/regex.util'; import { buildWaveformPeakSet, generateWaveformPeakSetFromBuffer, @@ -24,6 +25,8 @@ import { WaveformPeakSet, } from '../../common/utils/waveform.util'; import { FeedVersionService } from '../../infrastructure/cache/feed-version.service'; +import { AppCacheService } from '../../infrastructure/cache/app-cache.service'; +import { ContentModerationService } from '../moderation/content-moderation.service'; import { ImageProcessingService, UploadedImageFile, @@ -128,6 +131,8 @@ export class PostsService { private readonly chatService: ChatService, private readonly followsRepository: FollowsRepository, private readonly blocksRepository: BlocksRepository, + private readonly appCacheService: AppCacheService, + private readonly contentModerationService: ContentModerationService, ) {} async create( @@ -218,6 +223,7 @@ export class PostsService { const postType = this.resolvePostType(finalImageUrls, finalVideoUrl, finalAudioUrl); const hashtags = this.extractHashtags(combinedText); + const moderation = this.contentModerationService.analyze(combinedText); const mediaMetadata = this.normalizeMediaMetadata(dto, postType, undefined, { audioSourceBuffer: audioFile?.buffer, extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds, @@ -247,6 +253,7 @@ export class PostsService { longitude, postType, processingStatus: ProcessingStatus.READY, + moderationStatus: moderation.flagged ? ModerationStatus.FLAGGED : ModerationStatus.ACTIVE, visibility: dto.visibility ?? PostVisibility.PUBLIC, commentsDisabled: dto.commentsDisabled ?? false, commentsFollowersOnly: dto.commentsFollowersOnly ?? false, @@ -628,12 +635,14 @@ export class PostsService { } await this.postsRepository.deleteById(postId, userId); + await this.usersRepository.incrementPostsCount(userId, -1); + await this.feedVersionService.bumpGlobalVersion(); const existingImageVariants = Array.isArray((post as any).imageVariants) ? (((post as any).imageVariants ?? []) as PostMediaVariantSet[]) : []; const existingThumbnailVariants = ((post as any).thumbnailVariants as PostMediaVariantSet | null | undefined) ?? null; - await Promise.all([ + const cleanupResults = await Promise.allSettled([ ...this.buildSavedImageAssets(post.imageUrls ?? [], existingImageVariants).map((asset) => this.deleteSavedImageAsset(asset), ), @@ -642,19 +651,21 @@ export class PostsService { this.deleteManagedPostMedia(post.audioUrl ?? ''), this.deleteThumbnailAsset(post.thumbnailUrl ?? '', existingThumbnailVariants), ]); - await this.usersRepository.incrementPostsCount(userId, -1); - await this.feedVersionService.bumpGlobalVersion(); + const failedCleanupCount = cleanupResults.filter((result) => result.status === 'rejected').length; + if (failedCleanupCount) { + this.logger.warn(`Post ${postId} deleted but ${failedCleanupCount} media cleanup operation(s) failed`); + } } async findById(postId: string, viewerUserId?: string): Promise> { const post = await this.postsRepository.findById(postId); - if (!post) { + if (!post || !(await this.canViewerSeePost(viewerUserId, post))) { throw new NotFoundException('Post not found'); } const sharedPostIds = viewerUserId ? await this.findSharedPostIdSet(viewerUserId, [post.id]) : new Set(); - return this.formatPostItem(post, sharedPostIds); + return this.formatPostItemForViewer(post, sharedPostIds, viewerUserId); } async findUserPosts(userId: string, query: PostQueryDto, viewerUserId?: string) { @@ -666,6 +677,18 @@ export class PostsService { const limit = query.limit ?? 20; const skip = (page - 1) * limit; + const profileOwner = await this.usersRepository.findById(userId); + if (!profileOwner || profileOwner.isDisabled) { + throw new NotFoundException('User not found'); + } + if ( + viewerUserId !== userId && + profileOwner.isPrivate && + (!viewerUserId || !(await this.isFollowing(viewerUserId, userId))) + ) { + return buildPaginatedResponse([], { page, limit, total: 0, offset: skip }); + } + const filter: Record = { authorId: new Types.ObjectId(userId), isArchived: { $ne: true }, @@ -698,14 +721,26 @@ export class PostsService { : PostVisibility.PUBLIC; } if (query.visibility && !archivedOnly) { - filter.visibility = query.visibility; + const isOwner = !!viewerUserId && viewerUserId === userId; + if (isOwner || query.visibility === PostVisibility.PUBLIC) { + filter.visibility = query.visibility; + } else if (query.visibility === PostVisibility.FOLLOWERS) { + const allowed = filter.visibility as { $in?: PostVisibility[] } | PostVisibility; + if (typeof allowed === 'object' && allowed.$in?.includes(PostVisibility.FOLLOWERS)) { + filter.visibility = PostVisibility.FOLLOWERS; + } else { + return buildPaginatedResponse([], { page, limit, total: 0, offset: skip }); + } + } else { + return buildPaginatedResponse([], { page, limit, total: 0, offset: skip }); + } } const postTypeFilter = this.resolvePostTypeFilter(query.mediaType ?? query.postType); if (postTypeFilter) { filter.postType = postTypeFilter; } if (query.q) { - filter.content = { $regex: query.q.trim(), $options: 'i' }; + filter.content = { $regex: escapeRegex(query.q.trim()), $options: 'i' }; } if (query.hashtag) { filter.hashtags = query.hashtag.trim().replace(/^#+/, '').toLowerCase(); @@ -726,7 +761,9 @@ export class PostsService { : new Set(); return buildPaginatedResponse( - items.map((item) => this.formatPostItem(item, sharedPostIds)), + await Promise.all( + items.map((item) => this.formatPostItemForViewer(item, sharedPostIds, viewerUserId)), + ), { page, limit, @@ -737,25 +774,30 @@ export class PostsService { } const originalTotal = await this.postsRepository.count(filter); + const candidateLimit = skip + limit; const [originalPosts, profileShares] = await Promise.all([ - this.postsRepository.findMany(filter, 0, originalTotal, sort), - this.findActiveProfileShares(userId), + this.postsRepository.findMany(filter, 0, candidateLimit, sort), + this.findActiveProfileShares(userId, candidateLimit), ]); const sharedPostIds = profileShares.map((share) => share.postId.toString()); const sharedPosts = await this.postsRepository.findManyByIds(sharedPostIds); const postById = new Map(sharedPosts.map((post) => [post.id, post])); const visibleShares: Array<{ share: ProfileShareRecord; originalPost: PostDocument }> = []; - for (const share of profileShares) { - const originalPost = postById.get(share.postId.toString()); - if (!originalPost || !this.postMatchesProfileQuery(originalPost, query)) { - continue; - } - if (!(await this.canViewerSeePost(viewerUserId, originalPost))) { - continue; - } - visibleShares.push({ share, originalPost }); - } + const shareVisibility = await Promise.all( + profileShares.map(async (share) => { + const originalPost = postById.get(share.postId.toString()); + if (!originalPost || !this.postMatchesProfileQuery(originalPost, query)) return null; + return (await this.canViewerSeePost(viewerUserId, originalPost)) + ? { share, originalPost } + : null; + }), + ); + visibleShares.push( + ...shareVisibility.filter( + (item): item is { share: ProfileShareRecord; originalPost: PostDocument } => !!item, + ), + ); const allPostIds = [ ...originalPosts.map((item) => item.id), @@ -767,10 +809,12 @@ export class PostsService { const sharedByUser = await this.usersRepository.findById(userId); const timelineItems = [ - ...originalPosts.map((post) => ({ - sortDate: new Date((post as any).createdAt ?? 0).getTime(), - item: this.formatPostItem(post, viewerSharedPostIds), - })), + ...(await Promise.all( + originalPosts.map(async (post) => ({ + sortDate: new Date((post as any).createdAt ?? 0).getTime(), + item: await this.formatPostItemForViewer(post, viewerSharedPostIds, viewerUserId), + })), + )), ...visibleShares.map(({ share, originalPost }) => ({ sortDate: new Date(share.createdAt ?? 0).getTime(), item: this.formatSharedPostItem(share, originalPost, sharedByUser, viewerSharedPostIds), @@ -778,7 +822,7 @@ export class PostsService { ].sort((a, b) => b.sortDate - a.sortDate); const items = timelineItems.slice(skip, skip + limit).map((entry) => entry.item); - const total = timelineItems.length; + const total = originalTotal + visibleShares.length; return buildPaginatedResponse(items, { page, @@ -804,7 +848,7 @@ export class PostsService { filter.authorId = new Types.ObjectId(query.authorId); } if (query.q?.trim()) { - filter.content = { $regex: query.q.trim(), $options: 'i' }; + filter.content = { $regex: escapeRegex(query.q.trim()), $options: 'i' }; } if (query.hashtag?.trim()) { filter.hashtags = query.hashtag.trim().replace(/^#+/, '').toLowerCase(); @@ -866,20 +910,47 @@ export class PostsService { ); } - async findReels(query: ReelQueryDto) { + async findReels(query: ReelQueryDto, viewerUserId: string) { const page = query.page ?? 1; const limit = query.limit ?? 20; const skip = (page - 1) * limit; - const filter: Record = { postType: PostType.VIDEO }; + const [followingIds, invisibleUserIds] = await Promise.all([ + this.followsRepository.findFollowingIds(viewerUserId), + this.findInvisibleUserIds(viewerUserId), + ]); + const unauthorizedPrivateAuthorIds = await this.usersRepository.findPrivateUserIds([ + viewerUserId, + ...followingIds, + ]); + const excludedAuthorIds = Array.from( + new Set([...invisibleUserIds, ...unauthorizedPrivateAuthorIds]), + ); + const visibilityClauses: Record[] = [ + { visibility: PostVisibility.PUBLIC }, + { authorId: new Types.ObjectId(viewerUserId) }, + ]; + if (followingIds.length) { + visibilityClauses.push({ + visibility: PostVisibility.FOLLOWERS, + authorId: { $in: followingIds.map((id) => new Types.ObjectId(id)) }, + }); + } + const filter: Record = { + postType: PostType.VIDEO, + ...(excludedAuthorIds.length + ? { authorId: { $nin: excludedAuthorIds.map((id) => new Types.ObjectId(id)) } } + : {}), + $and: [{ $or: visibilityClauses }], + }; if (query.visibility) { - filter.visibility = query.visibility; + (filter.$and as Record[]).push({ visibility: query.visibility }); } if (query.authorId) { filter.authorId = new Types.ObjectId(query.authorId); } if (query.q) { - filter.content = { $regex: query.q.trim(), $options: 'i' }; + filter.content = { $regex: escapeRegex(query.q.trim()), $options: 'i' }; } const direction = resolveMongoSortDirection(query.sortOrder); const sortField = query.sortBy ?? 'createdAt'; @@ -903,16 +974,23 @@ export class PostsService { postId: string, ): Promise<{ success: true; postId: string; viewCount: number }> { const post = await this.postsRepository.findById(postId); - if (!post) { + if (!post || !(await this.canViewerSeePost(userId, post))) { throw new NotFoundException('Post not found'); } - await this.postsRepository.incrementViewCount(postId, 1); + const shouldCount = await this.appCacheService.setIfAbsent( + `post-view:${postId}:${userId}`, + true, + 60 * 60, + ); + if (shouldCount) { + await this.postsRepository.incrementViewCount(postId, 1); + } return { success: true, postId, - viewCount: (post.viewCount ?? 0) + 1, + viewCount: (post.viewCount ?? 0) + (shouldCount ? 1 : 0), }; } @@ -921,7 +999,7 @@ export class PostsService { postId: string, ): Promise<{ success: true; postId: string; playCount: number }> { const post = await this.postsRepository.findById(postId); - if (!post) { + if (!post || !(await this.canViewerSeePost(userId, post))) { throw new NotFoundException('Post not found'); } @@ -929,12 +1007,19 @@ export class PostsService { throw new BadRequestException('play counter is available only for audio or video posts'); } - await this.postsRepository.incrementPlayCount(postId, 1); + const shouldCount = await this.appCacheService.setIfAbsent( + `post-play:${postId}:${userId}`, + true, + 15 * 60, + ); + if (shouldCount) { + await this.postsRepository.incrementPlayCount(postId, 1); + } return { success: true, postId, - playCount: (post.playCount ?? 0) + 1, + playCount: (post.playCount ?? 0) + (shouldCount ? 1 : 0), }; } @@ -1746,6 +1831,9 @@ export class PostsService { if (!sourcePost) { throw new NotFoundException('Source post not found'); } + if (!(await this.canViewerSeePost(userId, sourcePost))) { + throw new NotFoundException('Source post not found'); + } if (this.extractEntityId(sourcePost.authorId) === userId && !dto.content?.trim()) { throw new BadRequestException('You cannot repost your own post without a quote'); } @@ -2043,6 +2131,10 @@ export class PostsService { throw new ForbiddenException('You cannot access this post'); } + if (author.isPrivate && !(await this.isFollowing(userId, authorId))) { + throw new ForbiddenException('You cannot access this post'); + } + if (post.visibility === PostVisibility.PRIVATE) { throw new ForbiddenException('You cannot access this post'); } @@ -2184,11 +2276,12 @@ export class PostsService { .countDocuments(this.activeProfileShareFilter(userId)); } - private async findActiveProfileShares(userId: string): Promise { + private async findActiveProfileShares(userId: string, limit: number): Promise { return this.connection .collection('postshares') .find(this.activeProfileShareFilter(userId)) .sort({ createdAt: -1 }) + .limit(limit) .toArray(); } @@ -2292,7 +2385,7 @@ export class PostsService { return true; } - private async canViewerSeePost(viewerUserId: string | undefined, post: PostDocument): Promise { + async canViewerSeePost(viewerUserId: string | undefined, post: PostDocument): Promise { if (post.isDeleted || post.isArchived || post.moderationStatus === ModerationStatus.HIDDEN) { return false; } @@ -2311,6 +2404,12 @@ export class PostsService { if (viewerUserId && (await this.hasBlockBetween(viewerUserId, authorId))) { return false; } + if ( + author.isPrivate && + (!viewerUserId || !(await this.isFollowing(viewerUserId, authorId))) + ) { + return false; + } if (post.visibility === PostVisibility.PRIVATE) { return false; } @@ -2320,6 +2419,50 @@ export class PostsService { return true; } + private async formatPostItemForViewer( + post: PostDocument | Record, + sharedPostIds: Set, + viewerUserId?: string, + ): Promise> { + const item = this.formatPostItem(post, sharedPostIds) as Record; + await Promise.all( + (['repostOfPostId', 'quoteOfPostId'] as const).map(async (field) => { + const original = (post as any)[field]; + if (!original) { + return; + } + if ( + typeof original !== 'object' || + original instanceof Types.ObjectId || + !('visibility' in original) + ) { + item[field] = null; + return; + } + if (!(await this.canViewerSeePost(viewerUserId, original as PostDocument))) { + item[field] = null; + } + }), + ); + return item; + } + + private async findInvisibleUserIds(userId: string): Promise { + const objectId = new Types.ObjectId(userId); + const rows = await this.connection + .collection('blocks') + .find({ + $or: [{ blockerId: objectId }, { blockedId: objectId }], + }) + .project({ blockerId: 1, blockedId: 1 }) + .toArray(); + return rows.map((row) => + row.blockerId?.toString() === userId + ? row.blockedId?.toString() + : row.blockerId?.toString(), + ).filter((id): id is string => !!id); + } + private async recordShareEvent( userId: string, postId: string, diff --git a/src/modules/reports/reports.service.spec.ts b/src/modules/reports/reports.service.spec.ts new file mode 100644 index 0000000..8192cfc --- /dev/null +++ b/src/modules/reports/reports.service.spec.ts @@ -0,0 +1,174 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Types } from 'mongoose'; +import { ModerationStatus } from '../../common/enums/moderation-status.enum'; +import { ReportsService } from './reports.service'; + +describe('ReportsService', () => { + const reporterId = new Types.ObjectId().toHexString(); + const targetId = new Types.ObjectId().toHexString(); + const reportId = new Types.ObjectId().toHexString(); + + const setup = () => { + const collections = { + users: { findOne: jest.fn().mockResolvedValue({ _id: targetId }) }, + posts: { + findOne: jest.fn().mockResolvedValue({ _id: targetId }), + updateOne: jest.fn().mockResolvedValue({ acknowledged: true }), + }, + comments: { + findOne: jest.fn().mockResolvedValue({ _id: targetId }), + updateOne: jest.fn().mockResolvedValue({ acknowledged: true }), + }, + instruments: { findOne: jest.fn().mockResolvedValue({ _id: targetId }) }, + repairshops: { findOne: jest.fn().mockResolvedValue({ _id: targetId }) }, + reports: { countDocuments: jest.fn().mockResolvedValue(1) }, + }; + const connection = { + collection: jest.fn((name: keyof typeof collections) => collections[name]), + }; + const repository = { + create: jest.fn().mockResolvedValue({ id: reportId }), + findMany: jest.fn().mockResolvedValue([{ id: reportId }]), + count: jest.fn().mockResolvedValue(1), + updateStatus: jest.fn().mockResolvedValue({ id: reportId, status: 'resolved' }), + }; + const blocksService = { block: jest.fn().mockResolvedValue({ blocked: true }) }; + const service = new ReportsService(connection as any, repository as any, blocksService as any); + return { service, connection, repository, blocksService, collections }; + }; + + it('creates a report, trims details, and keeps low-volume targets under observation', async () => { + const { service, repository, collections } = setup(); + + await expect( + service.create(reporterId, { + targetType: 'post', + targetId, + reason: 'spam' as any, + details: ' repeated links ', + }), + ).resolves.toEqual({ + reported: true, + item: { id: reportId }, + automaticModeration: { status: 'watching', openReportsCount: 1 }, + safetyActions: { blockAvailable: false, blocked: false }, + }); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ details: 'repeated links', reporterId, targetId }), + ); + expect(collections.posts.updateOne).not.toHaveBeenCalled(); + }); + + it.each(['post', 'comment'] as const)('automatically flags a %s after three open reports', async (targetType) => { + const { service, collections } = setup(); + collections.reports.countDocuments.mockResolvedValue(3); + + const result = await service.create(reporterId, { + targetType, + targetId, + reason: 'harassment' as any, + }); + + expect(result.automaticModeration).toEqual({ status: ModerationStatus.FLAGGED, openReportsCount: 3 }); + expect(collections[targetType === 'post' ? 'posts' : 'comments'].updateOne).toHaveBeenCalledWith( + { _id: expect.any(Types.ObjectId) }, + { $set: expect.objectContaining({ moderationStatus: ModerationStatus.FLAGGED }) }, + ); + }); + + it('can block a reported user as an immediate safety action', async () => { + const { service, blocksService } = setup(); + + const result = await service.create(reporterId, { + targetType: 'user', + targetId, + reason: 'harassment' as any, + blockTarget: true, + }); + + expect(result.automaticModeration).toEqual({ status: 'not_applicable' }); + expect(result.safetyActions).toEqual({ blockAvailable: true, blocked: true }); + expect(blocksService.block).toHaveBeenCalledWith(reporterId, targetId); + }); + + it('advertises blocking without doing it when a user report does not request it', async () => { + const { service, blocksService } = setup(); + + const result = await service.create(reporterId, { + targetType: 'user', + targetId, + reason: 'other' as any, + }); + + expect(result.safetyActions).toEqual({ blockAvailable: true, blocked: false }); + expect(blocksService.block).not.toHaveBeenCalled(); + }); + + it('rejects malformed and missing report targets', async () => { + const { service, collections } = setup(); + await expect( + service.create(reporterId, { targetType: 'post', targetId: 'bad', reason: 'spam' as any }), + ).rejects.toBeInstanceOf(BadRequestException); + + collections.posts.findOne.mockResolvedValueOnce(null); + await expect( + service.create(reporterId, { targetType: 'post', targetId, reason: 'spam' as any }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('paginates the current user reports with filters and descending defaults', async () => { + const { service, repository } = setup(); + + const result = await service.listMine(reporterId, { + page: 2, + limit: 5, + targetType: 'post', + status: 'open', + } as any); + + expect(repository.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + reporterId: expect.any(Types.ObjectId), + targetType: 'post', + status: 'open', + }), + 5, + 5, + { createdAt: -1 }, + ); + expect(result).toEqual(expect.objectContaining({ items: [{ id: reportId }] })); + }); + + it('lists all reports with default pagination and an empty filter', async () => { + const { service, repository } = setup(); + + await service.listForSuperAdmin({} as any); + + expect(repository.findMany).toHaveBeenCalledWith({}, 0, 20, { createdAt: -1 }); + expect(repository.count).toHaveBeenCalledWith({}); + }); + + it('updates status and trims the resolution note', async () => { + const { service, repository } = setup(); + + await expect( + service.updateStatus('admin@example.com', reportId, { + status: 'resolved', + resolutionNote: ' actioned ', + } as any), + ).resolves.toEqual({ id: reportId, status: 'resolved' }); + expect(repository.updateStatus).toHaveBeenCalledWith(reportId, 'resolved', 'actioned', 'admin@example.com'); + }); + + it('returns not found for malformed or absent reports during an update', async () => { + const { service, repository } = setup(); + await expect(service.updateStatus('admin', 'bad', { status: 'resolved' } as any)).rejects.toBeInstanceOf( + NotFoundException, + ); + + repository.updateStatus.mockResolvedValueOnce(null); + await expect( + service.updateStatus('admin', reportId, { status: 'resolved' } as any), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/src/modules/saves/saves.repository.ts b/src/modules/saves/saves.repository.ts index 31e7d53..fca8aab 100644 --- a/src/modules/saves/saves.repository.ts +++ b/src/modules/saves/saves.repository.ts @@ -20,6 +20,17 @@ export class SavesRepository { }); } + async createIfAbsent(userId: string, postId: string): Promise { + const result = await this.saveModel + .updateOne( + { userId: new Types.ObjectId(userId), postId: new Types.ObjectId(postId) }, + { $setOnInsert: { userId: new Types.ObjectId(userId), postId: new Types.ObjectId(postId) } }, + { upsert: true }, + ) + .exec(); + return result.upsertedCount === 1; + } + async findSavedPostIds(userId: string, postIds: string[]): Promise { if (!postIds.length) { return []; @@ -37,8 +48,9 @@ export class SavesRepository { return rows.map((row) => row.postId.toString()); } - async deleteById(id: string): Promise { - await this.saveModel.findByIdAndDelete(id).exec(); + async deleteById(id: string): Promise { + const deleted = await this.saveModel.findByIdAndDelete(id).exec(); + return !!deleted; } async findUserSavedPostIds( diff --git a/src/modules/saves/saves.service.spec.ts b/src/modules/saves/saves.service.spec.ts index 68b7373..d31f37b 100644 --- a/src/modules/saves/saves.service.spec.ts +++ b/src/modules/saves/saves.service.spec.ts @@ -1,6 +1,189 @@ +import { SortOrder } from '../../common/enums/sort-order.enum'; import { SavesService } from './saves.service'; describe('SavesService', () => { + const userId = '507f1f77bcf86cd799439012'; + const ownerId = '507f191e810c19729de860ea'; + const postId = '507f1f77bcf86cd799439011'; + + const setup = (overrides: Record> = {}) => { + const savesRepository = { + findOne: jest.fn().mockResolvedValue(null), + createIfAbsent: jest.fn().mockResolvedValue({ id: 'save-1' }), + deleteById: jest.fn().mockResolvedValue(true), + findUserSavedPostIds: jest.fn().mockResolvedValue([]), + countByUser: jest.fn().mockResolvedValue(0), + ...overrides.savesRepository, + }; + const postsRepository = { + findById: jest.fn().mockResolvedValue({ id: postId, authorId: ownerId, content: 'post preview' }), + incrementSavesCount: jest.fn().mockResolvedValue(undefined), + findManyByIds: jest.fn().mockResolvedValue([]), + ...overrides.postsRepository, + }; + const feedVersionService = { + bumpUserVersion: jest.fn().mockResolvedValue(1), + ...overrides.feedVersionService, + }; + const notificationsService = { + createSaveNotification: jest.fn().mockResolvedValue(undefined), + ...overrides.notificationsService, + }; + const blocksRepository = { + findAnyBetween: jest.fn().mockResolvedValue(null), + ...overrides.blocksRepository, + }; + const postsService = { + canViewerSeePost: jest.fn().mockResolvedValue(true), + ...overrides.postsService, + }; + const service = new SavesService( + savesRepository as any, + postsRepository as any, + feedVersionService as any, + notificationsService as any, + blocksRepository as any, + postsService as any, + ); + return { + service, + savesRepository, + postsRepository, + feedVersionService, + notificationsService, + blocksRepository, + postsService, + }; + }; + + it('toggles between save and unsave using the current state', async () => { + const first = setup(); + const save = jest.spyOn(first.service, 'save').mockResolvedValue({ saved: true, postId }); + await first.service.toggle(userId, { postId }); + expect(save).toHaveBeenCalledWith(userId, { postId }); + + const second = setup({ savesRepository: { findOne: jest.fn().mockResolvedValue({ id: 'save-1' }) } }); + const unsave = jest.spyOn(second.service, 'unsave').mockResolvedValue({ saved: false, postId }); + await second.service.toggle(userId, { postId }); + expect(unsave).toHaveBeenCalledWith(userId, { postId }); + }); + + it('saves a visible post, increments its counter and notifies its owner', async () => { + const ctx = setup(); + + await expect(ctx.service.save(userId, { postId })).resolves.toEqual({ saved: true, postId }); + + expect(ctx.savesRepository.createIfAbsent).toHaveBeenCalledWith(userId, postId); + expect(ctx.postsRepository.incrementSavesCount).toHaveBeenCalledWith(postId, 1); + expect(ctx.feedVersionService.bumpUserVersion).toHaveBeenCalledWith(userId); + expect(ctx.notificationsService.createSaveNotification).toHaveBeenCalledWith( + userId, + ownerId, + postId, + { resourceType: 'post', previewText: 'post preview' }, + ); + }); + + it('does not double count an existing or concurrently inserted save', async () => { + const existing = setup({ + savesRepository: { findOne: jest.fn().mockResolvedValue({ id: 'save-1' }) }, + }); + await existing.service.save(userId, { postId }); + expect(existing.savesRepository.createIfAbsent).not.toHaveBeenCalled(); + expect(existing.postsRepository.incrementSavesCount).not.toHaveBeenCalled(); + + const race = setup({ savesRepository: { createIfAbsent: jest.fn().mockResolvedValue(null) } }); + await race.service.save(userId, { postId }); + expect(race.postsRepository.incrementSavesCount).not.toHaveBeenCalled(); + expect(race.feedVersionService.bumpUserVersion).not.toHaveBeenCalled(); + }); + + it('does not notify the post owner when they save their own post', async () => { + const ctx = setup({ + postsRepository: { + findById: jest.fn().mockResolvedValue({ id: postId, authorId: userId, content: 'own' }), + }, + }); + + await ctx.service.save(userId, { postId }); + expect(ctx.blocksRepository.findAnyBetween).not.toHaveBeenCalled(); + expect(ctx.notificationsService.createSaveNotification).not.toHaveBeenCalled(); + }); + + it('keeps a save successful when notification delivery fails', async () => { + const ctx = setup({ + notificationsService: { createSaveNotification: jest.fn().mockRejectedValue(new Error('offline')) }, + }); + + await expect(ctx.service.save(userId, { postId })).resolves.toEqual({ saved: true, postId }); + expect(ctx.postsRepository.incrementSavesCount).toHaveBeenCalledWith(postId, 1); + }); + + it('rejects missing and hidden posts', async () => { + const missing = setup({ postsRepository: { findById: jest.fn().mockResolvedValue(null) } }); + await expect(missing.service.save(userId, { postId })).rejects.toThrow('Post not found'); + + const hidden = setup({ postsService: { canViewerSeePost: jest.fn().mockResolvedValue(false) } }); + await expect(hidden.service.save(userId, { postId })).rejects.toThrow('Post not found'); + expect(hidden.savesRepository.findOne).not.toHaveBeenCalled(); + }); + + it('unsaves idempotently and decrements only after a successful deletion', async () => { + const absent = setup(); + await expect(absent.service.unsave(userId, { postId })).resolves.toEqual({ saved: false, postId }); + expect(absent.postsRepository.incrementSavesCount).not.toHaveBeenCalled(); + + const race = setup({ + savesRepository: { + findOne: jest.fn().mockResolvedValue({ id: 'save-1' }), + deleteById: jest.fn().mockResolvedValue(false), + }, + }); + await race.service.unsave(userId, { postId }); + expect(race.postsRepository.incrementSavesCount).not.toHaveBeenCalled(); + + const deleted = setup({ + savesRepository: { findOne: jest.fn().mockResolvedValue({ id: 'save-1' }) }, + }); + await deleted.service.unsave(userId, { postId }); + expect(deleted.postsRepository.incrementSavesCount).toHaveBeenCalledWith(postId, -1); + expect(deleted.feedVersionService.bumpUserVersion).toHaveBeenCalledWith(userId); + }); + + it('returns save status only for posts visible to the viewer', async () => { + const saved = setup({ + savesRepository: { findOne: jest.fn().mockResolvedValue({ id: 'save-1' }) }, + }); + await expect(saved.service.getStatus(userId, { postId })).resolves.toEqual({ saved: true, postId }); + + const hidden = setup({ postsService: { canViewerSeePost: jest.fn().mockResolvedValue(false) } }); + await expect(hidden.service.getStatus(userId, { postId })).resolves.toEqual({ saved: false, postId }); + expect(hidden.savesRepository.findOne).not.toHaveBeenCalled(); + }); + + it('paginates saved posts and removes posts that are no longer visible', async () => { + const ids = [postId, '507f191e810c19729de860eb']; + const posts = [{ id: ids[0] }, { id: ids[1] }]; + const ctx = setup({ + savesRepository: { + findUserSavedPostIds: jest.fn().mockResolvedValue(ids), + countByUser: jest.fn().mockResolvedValue(5), + }, + postsRepository: { findManyByIds: jest.fn().mockResolvedValue(posts) }, + postsService: { + canViewerSeePost: jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false), + }, + }); + + const result = await ctx.service.getMySavedPosts(userId, { + page: 2, + limit: 2, + sortOrder: SortOrder.ASC, + }); + + expect(ctx.savesRepository.findUserSavedPostIds).toHaveBeenCalledWith(userId, 2, 2, { createdAt: 1 }); + expect(result).toMatchObject({ items: [posts[0]], total: 4, page: 2, limit: 2 }); + }); it('returns saved false from status when post no longer exists', async () => { const savesRepository = { findOne: jest.fn(), @@ -18,6 +201,7 @@ describe('SavesService', () => { { bumpGlobalVersion: jest.fn() } as any, { createSaveNotification: jest.fn() } as any, blocksRepository as any, + { canViewerSeePost: jest.fn().mockResolvedValue(true) } as any, ); await expect( @@ -49,6 +233,7 @@ describe('SavesService', () => { { bumpGlobalVersion: jest.fn() } as any, { createSaveNotification: jest.fn() } as any, blocksRepository as any, + { canViewerSeePost: jest.fn().mockResolvedValue(true) } as any, ); await expect(service.save(userId, { postId })).rejects.toThrow( diff --git a/src/modules/saves/saves.service.ts b/src/modules/saves/saves.service.ts index 47f070b..618f85c 100644 --- a/src/modules/saves/saves.service.ts +++ b/src/modules/saves/saves.service.ts @@ -7,6 +7,7 @@ import { FeedVersionService } from '../../infrastructure/cache/feed-version.serv import { BlocksRepository } from '../blocks/blocks.repository'; import { NotificationsService } from '../notifications/notifications.service'; import { PostsRepository } from '../posts/posts.repository'; +import { PostsService } from '../posts/posts.service'; import { ToggleSaveDto } from './dto/toggle-save.dto'; import { SavesRepository } from './saves.repository'; @@ -20,6 +21,7 @@ export class SavesService { private readonly feedVersionService: FeedVersionService, private readonly notificationsService: NotificationsService, private readonly blocksRepository: BlocksRepository, + private readonly postsService: PostsService, ) {} async toggle(userId: string, dto: ToggleSaveDto): Promise<{ saved: boolean; postId: string }> { @@ -36,9 +38,12 @@ export class SavesService { return { saved: true, postId: dto.postId }; } - await this.savesRepository.create(userId, dto.postId); + const created = await this.savesRepository.createIfAbsent(userId, dto.postId); + if (!created) { + return { saved: true, postId: dto.postId }; + } await this.postsRepository.incrementSavesCount(dto.postId, 1); - await this.feedVersionService.bumpGlobalVersion(); + await this.feedVersionService.bumpUserVersion(userId); const recipientId = this.extractEntityId(post.authorId); if (recipientId && recipientId !== userId) { try { @@ -65,15 +70,18 @@ export class SavesService { return { saved: false, postId: dto.postId }; } - await this.savesRepository.deleteById(existing.id); + const deleted = await this.savesRepository.deleteById(existing.id); + if (!deleted) { + return { saved: false, postId: dto.postId }; + } await this.postsRepository.incrementSavesCount(dto.postId, -1); - await this.feedVersionService.bumpGlobalVersion(); + await this.feedVersionService.bumpUserVersion(userId); return { saved: false, postId: dto.postId }; } async getStatus(userId: string, dto: ToggleSaveDto): Promise<{ saved: boolean; postId: string }> { - const postExists = await this.postExists(dto.postId); - if (!postExists) { + const post = await this.postsRepository.findById(dto.postId); + if (!post || !(await this.postsService.canViewerSeePost(userId, post))) { return { saved: false, postId: dto.postId }; } @@ -92,12 +100,16 @@ export class SavesService { this.savesRepository.countByUser(userId), ]); - const items = await this.postsRepository.findManyByIds(postIds); + const candidates = await this.postsRepository.findManyByIds(postIds); + const visibility = await Promise.all( + candidates.map((post) => this.postsService.canViewerSeePost(userId, post)), + ); + const items = candidates.filter((_, index) => visibility[index]); return buildPaginatedResponse(items, { page, limit, - total, + total: Math.max(0, total - (candidates.length - items.length)), offset: skip, }); } @@ -120,6 +132,10 @@ export class SavesService { } private async assertCanSave(userId: string, post: any): Promise { + if (!(await this.postsService.canViewerSeePost(userId, post))) { + throw new NotFoundException('Post not found'); + } + const authorId = this.extractEntityId(post.authorId); if (!authorId || authorId === userId) { return; diff --git a/src/modules/search/search.service.spec.ts b/src/modules/search/search.service.spec.ts new file mode 100644 index 0000000..9adf0dc --- /dev/null +++ b/src/modules/search/search.service.spec.ts @@ -0,0 +1,502 @@ +import { Types } from 'mongoose'; +import { SearchService } from './search.service'; + +const createService = (engine: 'auto' | 'atlas' | 'regex' = 'atlas') => { + const connection = { + collection: jest.fn(), + }; + const userModel: any = { + aggregate: jest.fn(), + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([]), + })), + countDocuments: jest.fn(), + }; + const postModel = { + aggregate: jest.fn(), + find: jest.fn(), + countDocuments: jest.fn(), + }; + const config = { + get: jest.fn((key: string) => { + const values: Record = { + 'search.engine': engine, + 'search.atlasUserIndex': 'users_search_test', + 'search.atlasPostIndex': 'posts_search_test', + 'search.fallbackEnabled': true, + 'search.retrySeconds': 300, + }; + return values[key]; + }), + }; + const service = new SearchService( + connection as any, + userModel as any, + postModel as any, + { findBlockingOrBlockedIds: jest.fn().mockResolvedValue([]) } as any, + config as any, + ); + + return { service, connection, userModel, postModel }; +}; + +describe('SearchService advanced search compatibility', () => { + const viewerId = '507f1f77bcf86cd799439011'; + + it('normalizes global queries and calls only the requested search scope', async () => { + const { service } = createService(); + const empty = { + items: [], + total: 0, + page: 2, + limit: 3, + count: 0, + totalPages: 1, + nextCursor: null, + pagination: {}, + } as any; + const users = jest.spyOn(service, 'searchUsers').mockResolvedValue(empty); + const posts = jest.spyOn(service, 'searchPosts').mockResolvedValue(empty); + const hashtags = jest.spyOn(service, 'searchHashtags').mockResolvedValue(empty); + + const result = await service.globalSearch(viewerId, { + q: ' oud ', + type: 'users', + page: 2, + limit: 3, + }); + + expect(result).toMatchObject({ query: 'oud', type: 'users', users: empty }); + expect(users).toHaveBeenCalledWith(viewerId, expect.objectContaining({ q: 'oud' })); + expect(posts).not.toHaveBeenCalled(); + expect(hashtags).not.toHaveBeenCalled(); + expect(result.posts.items).toEqual([]); + + await service.globalSearch(viewerId, { q: 'all' }); + expect(posts).toHaveBeenCalled(); + expect(hashtags).toHaveBeenCalled(); + }); + + it('rejects missing and oversized search text before touching storage', async () => { + const { service, userModel } = createService('regex'); + await expect(service.searchUsers(viewerId, { q: ' ' })).rejects.toThrow('q is required'); + await expect(service.searchUsers(viewerId, { q: 'x'.repeat(101) })).rejects.toThrow( + 'q must be 100 characters or less', + ); + expect(userModel.find).not.toHaveBeenCalled(); + }); + + it('performs compatibility user search with safe regex, exclusions and viewer follow state', async () => { + const { service, userModel } = createService('regex'); + const userObjectId = new Types.ObjectId(); + const blockedId = new Types.ObjectId(); + const user = { + id: userObjectId.toString(), + toObject: () => ({ username: 'a+b', name: undefined, isVerified: true }), + }; + const execUsers = jest.fn().mockResolvedValue([user]); + const chain = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + exec: execUsers, + }; + userModel.find.mockReturnValue(chain); + userModel.countDocuments.mockReturnValue({ exec: jest.fn().mockResolvedValue(1) }); + jest.spyOn(service as any, 'getBlockedOrBlockingObjectIds').mockResolvedValue([blockedId]); + jest.spyOn(service as any, 'getFollowingSet').mockResolvedValue(new Set([user.id])); + + const result = await service.searchUsers(viewerId, { + q: ' a+b ', + page: 2, + limit: 4, + }); + + const filter = userModel.find.mock.calls[0][0] as Record; + expect(filter._id.$nin).toEqual([blockedId]); + expect(filter.$or[0].username.source).toBe('a\\+b'); + expect(chain.skip).toHaveBeenCalledWith(4); + expect(result.items[0]).toMatchObject({ + _id: user.id, + username: 'a+b', + name: '', + isVerified: true, + isFollowing: true, + followersCount: 0, + }); + }); + + it('performs compatibility post search with privacy filters and sorting', async () => { + const { service, postModel } = createService('regex'); + const followed = new Types.ObjectId(); + const blocked = new Types.ObjectId(); + const disabled = new Types.ObjectId(); + const privateAuthor = new Types.ObjectId(); + const postQuery = { + populate: jest.fn().mockReturnThis(), + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([{ id: 'post' }]), + }; + postModel.find.mockReturnValue(postQuery); + postModel.countDocuments.mockReturnValue({ exec: jest.fn().mockResolvedValue(1) }); + jest.spyOn(service as any, 'getBlockedOrBlockingObjectIds').mockResolvedValue([blocked]); + jest.spyOn(service as any, 'getFollowingObjectIds').mockResolvedValue([followed]); + jest.spyOn(service as any, 'getDisabledAuthorIds').mockResolvedValue([disabled]); + jest.spyOn(service as any, 'getPrivateAuthorIds').mockResolvedValue([privateAuthor]); + jest.spyOn(service as any, 'toPostSearchItems').mockResolvedValue([{ id: 'post', isLiked: false }]); + + const result = await service.searchPosts(viewerId, { + q: 'oud', + page: 1, + limit: 10, + sortOrder: 'asc' as any, + }); + + const filter = postModel.find.mock.calls[0][0] as Record; + expect(filter.authorId.$nin).toEqual([blocked, disabled, privateAuthor]); + expect(filter.$and[0].$or).toEqual( + expect.arrayContaining([ + { visibility: 'public' }, + { visibility: 'followers', authorId: { $in: [followed] } }, + ]), + ); + expect(postQuery.sort).toHaveBeenCalledWith({ createdAt: 1 }); + expect(result.items).toEqual([{ id: 'post', isLiked: false }]); + }); + + it('aggregates compatibility hashtags after normalizing leading hashes', async () => { + const { service, postModel } = createService('regex'); + const followed = new Types.ObjectId(); + jest.spyOn(service as any, 'getBlockedOrBlockingObjectIds').mockResolvedValue([]); + jest.spyOn(service as any, 'getFollowingObjectIds').mockResolvedValue([followed]); + jest.spyOn(service as any, 'getDisabledAuthorIds').mockResolvedValue([]); + postModel.aggregate.mockReturnValue({ + exec: jest.fn().mockResolvedValue([ + { items: [{ _id: 'oud', postsCount: 9 }], total: [{ count: 1 }] }, + ]), + }); + + const result = await service.searchHashtags(viewerId, { q: ' ##OuD ', page: 1, limit: 5 }); + expect(result.items).toEqual([{ tag: 'oud', postsCount: 9 }]); + expect(result.total).toBe(1); + const pipeline = postModel.aggregate.mock.calls[0][0] as Array>; + expect(pipeline[0].$match.hashtags.source).toBe('oud'); + expect(pipeline[0].$match.$or).toEqual( + expect.arrayContaining([{ visibility: 'followers', authorId: { $in: [followed] } }]), + ); + }); + + it('returns search suggestions from normalized user and hashtag results', async () => { + const { service } = createService(); + jest.spyOn(service, 'searchUsers').mockResolvedValue({ items: [{ username: 'oud' }] } as any); + jest.spyOn(service, 'searchHashtags').mockResolvedValue({ items: [{ tag: 'oud' }] } as any); + + await expect(service.getSuggestions(viewerId, { q: ' oud ', limit: 2 })).resolves.toEqual({ + query: 'oud', + users: [{ username: 'oud' }], + hashtags: [{ tag: 'oud' }], + }); + expect(service.searchUsers).toHaveBeenCalledWith(viewerId, { + q: 'oud', + page: 1, + limit: 2, + type: 'users', + }); + }); + + it('uses regex directly when configured and rethrows Atlas errors when fallback is disabled', async () => { + const regex = createService('regex').service; + const atlas = jest.spyOn(regex as any, 'searchUsersWithAtlas'); + const fallback = jest.spyOn(regex as any, 'searchUsersWithRegex').mockResolvedValue({ items: [] }); + await regex.searchUsers(viewerId, { q: 'oud' }); + expect(atlas).not.toHaveBeenCalled(); + expect(fallback).toHaveBeenCalled(); + + const strict = createService('atlas').service; + (strict as any).fallbackEnabled = false; + jest.spyOn(strict as any, 'searchUsersWithAtlas').mockRejectedValue(new Error('atlas failed')); + const strictFallback = jest.spyOn(strict as any, 'searchUsersWithRegex'); + await expect(strict.searchUsers(viewerId, { q: 'oud' })).rejects.toThrow('atlas failed'); + expect(strictFallback).not.toHaveBeenCalled(); + }); + + it('preserves Atlas post order, drops stale ids and decorates returned posts', async () => { + const { service, postModel } = createService('atlas'); + const firstId = new Types.ObjectId(); + const staleId = new Types.ObjectId(); + const post = { id: firstId.toString() }; + jest.spyOn(service as any, 'getBlockedOrBlockingObjectIds').mockResolvedValue([]); + jest.spyOn(service as any, 'getFollowingObjectIds').mockResolvedValue([]); + jest.spyOn(service as any, 'getInteractedAuthorObjectIds').mockResolvedValue([]); + jest.spyOn(service as any, 'toPostSearchItems').mockResolvedValue([{ id: post.id, decorated: true }]); + postModel.aggregate.mockReturnValue({ + exec: jest.fn().mockResolvedValue([ + { items: [{ _id: staleId }, { _id: firstId }], total: [{ count: 2 }] }, + ]), + }); + const query = { + populate: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([post]), + }; + postModel.find.mockReturnValue(query); + + const result = await service.searchPosts(viewerId, { q: '#oud', page: 1, limit: 10 }); + + expect(query.populate).toHaveBeenCalledTimes(3); + expect((service as any).toPostSearchItems).toHaveBeenCalledWith(viewerId, [post]); + expect(result.items).toEqual([{ id: post.id, decorated: true }]); + expect(result.total).toBe(2); + }); + + it('uses regex for one-character Atlas hashtags and Atlas aggregation for longer terms', async () => { + const short = createService('atlas').service; + const fallback = jest + .spyOn(short as any, 'searchHashtagsWithRegex') + .mockResolvedValue({ items: [{ tag: 'a' }] }); + await short.searchHashtags(viewerId, { q: '#a' }); + expect(fallback).toHaveBeenCalled(); + + const { service, postModel } = createService('atlas'); + jest.spyOn(service as any, 'getBlockedOrBlockingObjectIds').mockResolvedValue([]); + jest.spyOn(service as any, 'getFollowingObjectIds').mockResolvedValue([]); + postModel.aggregate.mockReturnValue({ + exec: jest.fn().mockResolvedValue([ + { items: [{ _id: 'oud', postsCount: 4 }], total: [{ count: 1 }] }, + ]), + }); + const result = await service.searchHashtags(viewerId, { q: '#oud', page: 2, limit: 2 }); + expect(result.items).toEqual([{ tag: 'oud', postsCount: 4 }]); + const pipeline = postModel.aggregate.mock.calls[0][0] as Array>; + expect(pipeline[0].$search.index).toBe('posts_search_test'); + expect(pipeline.at(-1)?.$facet.items).toEqual([{ $skip: 2 }, { $limit: 2 }]); + }); + + it('decorates post search results with liked, saved and followed state', async () => { + const { service } = createService(); + const authorId = new Types.ObjectId(); + const firstId = new Types.ObjectId().toString(); + const secondId = new Types.ObjectId().toString(); + const posts = [ + { + id: firstId, + authorId, + toObject: () => ({ _id: firstId, authorId }), + }, + { + id: secondId, + authorId: { _id: authorId }, + toObject: () => ({ _id: secondId, authorId: { _id: authorId } }), + }, + ]; + jest.spyOn(service as any, 'getLikedPostSet').mockResolvedValue(new Set([firstId])); + jest.spyOn(service as any, 'getSavedPostSet').mockResolvedValue(new Set([secondId])); + jest.spyOn(service as any, 'getFollowingSet').mockResolvedValue(new Set([authorId.toString()])); + + const result = await (service as any).toPostSearchItems(viewerId, posts); + expect(result).toEqual([ + expect.objectContaining({ isLiked: true, liked: true, isSaved: false, isFollowingAuthor: true }), + expect.objectContaining({ isLiked: false, isSaved: true, saved: true, isFollowingAuthor: true }), + ]); + }); + + it('loads and caches interaction-based author affinity without duplicate authors', async () => { + const { service, connection, postModel } = createService(); + const likedPostId = new Types.ObjectId(); + const savedPostId = new Types.ObjectId(); + const authorId = new Types.ObjectId(); + const makeCursor = (rows: any[]) => ({ + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + project: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue(rows), + }); + connection.collection.mockImplementation((name: string) => ({ + find: jest.fn().mockReturnValue( + name === 'likes' + ? makeCursor([{ targetId: likedPostId }, { targetId: 'invalid' }]) + : makeCursor([{ postId: savedPostId }, { postId: likedPostId }]), + ), + })); + const postQuery = { + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([ + { authorId }, + { authorId }, + { authorId: 'invalid' }, + ]), + }; + postModel.find.mockReturnValue(postQuery); + + const first = await (service as any).getInteractedAuthorObjectIds(viewerId); + const second = await (service as any).getInteractedAuthorObjectIds(viewerId); + expect(first.map(String)).toEqual([authorId.toString()]); + expect(second).toBe(first); + expect(connection.collection).toHaveBeenCalledTimes(2); + expect(postModel.find).toHaveBeenCalledTimes(1); + }); + + it('queries follow and viewer-state collections with normalized valid ids', async () => { + const { service, connection, userModel } = createService(); + const followedId = new Types.ObjectId(); + const postId = new Types.ObjectId(); + const collectionRows: Record = { + follows: [{ followingId: followedId }, { followingId: 'bad' }], + likes: [{ targetId: postId }], + saves: [{ postId }], + }; + connection.collection.mockImplementation((name: string) => ({ + find: jest.fn().mockReturnValue({ + project: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue(collectionRows[name] ?? []), + }), + })); + userModel.find.mockReturnValue({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([{ _id: followedId }, { _id: 'bad' }]), + }); + + await expect((service as any).getFollowingObjectIds(viewerId)).resolves.toEqual([followedId]); + await expect( + (service as any).getFollowingSet(viewerId, [followedId.toString(), followedId.toString(), 'bad']), + ).resolves.toEqual(new Set([followedId.toString(), 'bad'])); + await expect((service as any).getFollowingSet(viewerId, ['bad'])).resolves.toEqual(new Set()); + await expect((service as any).getLikedPostSet(viewerId, [postId.toString(), 'bad'])).resolves.toEqual( + new Set([postId.toString()]), + ); + await expect((service as any).getSavedPostSet(viewerId, [postId.toString()])).resolves.toEqual( + new Set([postId.toString()]), + ); + await expect((service as any).getSavedPostSet(viewerId, ['bad'])).resolves.toEqual(new Set()); + await expect((service as any).getDisabledAuthorIds()).resolves.toEqual([followedId]); + }); + it('keeps ordered user results and pagination while ranking with Atlas Search', async () => { + const { service, userModel } = createService(); + const firstId = new Types.ObjectId(); + const secondId = new Types.ObjectId(); + const makeUser = (id: Types.ObjectId, username: string) => ({ + id: id.toString(), + toObject: () => ({ + _id: id, + username, + name: username, + stageName: '', + avatar: '', + isVerified: false, + isDisabled: false, + followersCount: 0, + followingCount: 0, + }), + }); + const users = [makeUser(firstId, 'oud-first'), makeUser(secondId, 'oud-second')]; + + jest.spyOn(service as any, 'getBlockedOrBlockingObjectIds').mockResolvedValue([]); + jest.spyOn(service as any, 'getFollowingObjectIds').mockResolvedValue([secondId]); + jest.spyOn(service as any, 'getInteractedAuthorObjectIds').mockResolvedValue([]); + userModel.aggregate.mockReturnValue({ + exec: jest + .fn() + .mockResolvedValue([ + { items: [{ _id: secondId }, { _id: firstId }], total: [{ count: 2 }] }, + ]), + }); + userModel.find.mockReturnValue({ exec: jest.fn().mockResolvedValue(users) }); + + const result = await service.searchUsers('507f1f77bcf86cd799439011', { + q: 'oud', + page: 1, + limit: 20, + }); + + expect(result.items.map((item) => item.username)).toEqual(['oud-second', 'oud-first']); + expect(result.items[0].isFollowing).toBe(true); + expect(result.total).toBe(2); + expect(result.pagination).toMatchObject({ page: 1, limit: 20, total: 2 }); + + const pipeline = userModel.aggregate.mock.calls[0][0] as Array>; + expect(pipeline[0].$search.index).toBe('users_search_test'); + expect(pipeline[0].$search.compound.minimumShouldMatch).toBe(1); + expect(pipeline.some((stage) => stage.$set?.__rankScore)).toBe(true); + }); + + it('keeps privacy filters inside advanced post search', async () => { + const { service, postModel } = createService(); + const followedId = new Types.ObjectId(); + const privateAuthorId = new Types.ObjectId(); + + jest.spyOn(service as any, 'getBlockedOrBlockingObjectIds').mockResolvedValue([]); + jest.spyOn(service as any, 'getFollowingObjectIds').mockResolvedValue([followedId]); + jest.spyOn(service as any, 'getInteractedAuthorObjectIds').mockResolvedValue([]); + jest.spyOn(service as any, 'getPrivateAuthorIds').mockResolvedValue([privateAuthorId]); + postModel.aggregate.mockReturnValue({ + exec: jest.fn().mockResolvedValue([{ items: [], total: [{ count: 0 }] }]), + }); + + const result = await service.searchPosts('507f1f77bcf86cd799439011', { + q: 'عود', + page: 1, + limit: 20, + }); + + expect(result.items).toEqual([]); + const pipeline = postModel.aggregate.mock.calls[0][0] as Array>; + const privacyMatch = pipeline.find((stage) => stage.$match)?.$match; + expect(privacyMatch).toMatchObject({ + isDeleted: { $ne: true }, + isArchived: { $ne: true }, + moderationStatus: { $ne: 'hidden' }, + authorId: { $nin: [privateAuthorId] }, + }); + expect(privacyMatch.$or).toEqual( + expect.arrayContaining([ + { visibility: 'public' }, + { visibility: 'followers', authorId: { $in: [followedId] } }, + ]), + ); + expect(pipeline.some((stage) => stage.$set?.__rankScore)).toBe(true); + }); + + it('falls back without changing the response and throttles Atlas retries', async () => { + const { service } = createService('auto'); + const compatibleResult = { + items: [], + count: 0, + page: 1, + limit: 20, + total: 0, + totalPages: 1, + nextCursor: null, + pagination: { + mode: 'offset' as const, + page: 1, + limit: 20, + count: 0, + total: 0, + totalPages: 1, + hasNextPage: false, + hasPreviousPage: false, + nextPage: null, + previousPage: null, + currentCursor: null, + nextCursor: null, + }, + }; + const atlas = jest + .spyOn(service as any, 'searchUsersWithAtlas') + .mockRejectedValue(new Error('search index unavailable')); + const fallback = jest + .spyOn(service as any, 'searchUsersWithRegex') + .mockResolvedValue(compatibleResult); + + await expect(service.searchUsers('viewer', { q: 'oud' })).resolves.toBe(compatibleResult); + await expect(service.searchUsers('viewer', { q: 'oud' })).resolves.toBe(compatibleResult); + + expect(atlas).toHaveBeenCalledTimes(1); + expect(fallback).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/modules/search/search.service.ts b/src/modules/search/search.service.ts index ac6c8f6..5889371 100644 --- a/src/modules/search/search.service.ts +++ b/src/modules/search/search.service.ts @@ -1,4 +1,5 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { InjectConnection, InjectModel } from '@nestjs/mongoose'; import { Connection, FilterQuery, Model, PipelineStage, Types } from 'mongoose'; import { ModerationStatus } from '../../common/enums/moderation-status.enum'; @@ -8,7 +9,7 @@ import { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { BlocksRepository } from '../blocks/blocks.repository'; import { Post, PostDocument } from '../posts/schemas/post.schema'; import { User, UserDocument } from '../users/schemas/user.schema'; -import { SearchQueryDto, SearchSuggestionsQueryDto, SearchType } from './dto/search-query.dto'; +import { SearchQueryDto, SearchSuggestionsQueryDto } from './dto/search-query.dto'; type SearchUserItem = { _id: string; @@ -28,17 +29,48 @@ type HashtagSearchItem = { postsCount: number; }; +type SearchEngine = 'auto' | 'atlas' | 'regex'; + +type AtlasIdFacetResult = { + items?: Array<{ _id: Types.ObjectId }>; + total?: Array<{ count: number }>; +}; + +type AtlasHashtagFacetResult = { + items?: Array<{ _id: string; postsCount: number }>; + total?: Array<{ count: number }>; +}; + const emptyPage = (page: number, limit: number): PaginatedResponse => buildPaginatedResponse([], { page, limit, total: 0, offset: (page - 1) * limit }); @Injectable() export class SearchService { + private readonly logger = new Logger(SearchService.name); + private readonly searchEngine: SearchEngine; + private readonly atlasUserIndex: string; + private readonly atlasPostIndex: string; + private readonly fallbackEnabled: boolean; + private readonly atlasRetryMs: number; + private readonly atlasUnavailableUntil = new Map(); + private readonly affinityCache = new Map< + string, + { expiresAt: number; authorIds: Types.ObjectId[] } + >(); + constructor( @InjectConnection() private readonly connection: Connection, @InjectModel(User.name) private readonly userModel: Model, @InjectModel(Post.name) private readonly postModel: Model, private readonly blocksRepository: BlocksRepository, - ) {} + private readonly configService: ConfigService, + ) { + this.searchEngine = this.configService.get('search.engine') ?? 'auto'; + this.atlasUserIndex = this.configService.get('search.atlasUserIndex') ?? 'users_search'; + this.atlasPostIndex = this.configService.get('search.atlasPostIndex') ?? 'posts_search'; + this.fallbackEnabled = this.configService.get('search.fallbackEnabled') ?? true; + this.atlasRetryMs = (this.configService.get('search.retrySeconds') ?? 300) * 1000; + } async globalSearch(currentUserId: string, query: SearchQueryDto) { const normalized = this.normalizeSearchText(query.q); @@ -70,6 +102,17 @@ export class SearchService { async searchUsers( currentUserId: string, query: SearchQueryDto, + ): Promise> { + return this.runWithSearchEngine( + 'users', + () => this.searchUsersWithAtlas(currentUserId, query), + () => this.searchUsersWithRegex(currentUserId, query), + ); + } + + private async searchUsersWithRegex( + currentUserId: string, + query: SearchQueryDto, ): Promise> { const normalized = this.normalizeSearchText(query.q); const page = query.page ?? 1; @@ -104,20 +147,33 @@ export class SearchService { } async searchPosts(currentUserId: string, query: SearchQueryDto): Promise> { + return this.runWithSearchEngine( + 'posts', + () => this.searchPostsWithAtlas(currentUserId, query), + () => this.searchPostsWithRegex(currentUserId, query), + ); + } + + private async searchPostsWithRegex( + currentUserId: string, + query: SearchQueryDto, + ): Promise> { const normalized = this.normalizeSearchText(query.q); const page = query.page ?? 1; const limit = query.limit ?? 20; const skip = (page - 1) * limit; const regex = this.safeRegex(normalized); - const [blockedIds, followingIds, activeAuthorIds] = await Promise.all([ + const [blockedIds, followingIds, disabledAuthorIds, privateAuthorIds] = await Promise.all([ this.getBlockedOrBlockingObjectIds(currentUserId), this.getFollowingObjectIds(currentUserId), - this.getActiveSearchableAuthorIds(currentUserId), + this.getDisabledAuthorIds(), + this.getPrivateAuthorIds(), ]); - - const allowedAuthorIds = activeAuthorIds.filter( - (authorId) => !blockedIds.some((blockedId) => blockedId.equals(authorId)), + const allowedPrivateIds = new Set([currentUserId, ...followingIds.map((id) => id.toString())]); + const unauthorizedPrivateIds = privateAuthorIds.filter( + (id) => !allowedPrivateIds.has(id.toString()), ); + const excludedAuthorIds = [...blockedIds, ...disabledAuthorIds, ...unauthorizedPrivateIds]; const currentObjectId = new Types.ObjectId(currentUserId); const visibilityClauses: FilterQuery[] = [ @@ -135,7 +191,7 @@ export class SearchService { isDeleted: { $ne: true }, isArchived: { $ne: true }, moderationStatus: { $ne: ModerationStatus.HIDDEN }, - authorId: { $in: allowedAuthorIds }, + ...(excludedAuthorIds.length ? { authorId: { $nin: excludedAuthorIds } } : {}), $and: [ { $or: visibilityClauses }, { @@ -156,7 +212,7 @@ export class SearchService { .find(filter) .populate({ path: 'authorId', - select: 'name username avatar isVerified stageName isDisabled', + select: 'name username avatar isVerified stageName isDisabled isPrivate', }) .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' }) .populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' }) @@ -167,29 +223,7 @@ export class SearchService { this.postModel.countDocuments(filter).exec(), ]); - const postIds = posts.map((post) => post.id); - const authorIds = posts - .map((post) => this.extractEntityId((post as any).authorId)) - .filter(Boolean); - const [likedSet, savedSet, followingSet] = await Promise.all([ - this.getLikedPostSet(currentUserId, postIds), - this.getSavedPostSet(currentUserId, postIds), - this.getFollowingSet(currentUserId, authorIds), - ]); - - const items = posts.map((post) => { - const object = post.toObject(); - const authorId = this.extractEntityId(object.authorId); - - return { - ...object, - isLiked: likedSet.has(post.id), - liked: likedSet.has(post.id), - isSaved: savedSet.has(post.id), - saved: savedSet.has(post.id), - isFollowingAuthor: authorId ? followingSet.has(authorId) : false, - }; - }); + const items = await this.toPostSearchItems(currentUserId, posts as unknown as PostDocument[]); return buildPaginatedResponse(items, { page, limit, total, offset: skip }); } @@ -197,21 +231,36 @@ export class SearchService { async searchHashtags( currentUserId: string, query: SearchQueryDto, + ): Promise> { + return this.runWithSearchEngine( + 'hashtags', + () => this.searchHashtagsWithAtlas(currentUserId, query), + () => this.searchHashtagsWithRegex(currentUserId, query), + ); + } + + private async searchHashtagsWithRegex( + currentUserId: string, + query: SearchQueryDto, ): Promise> { const normalized = this.normalizeHashtag(query.q); const page = query.page ?? 1; const limit = query.limit ?? 20; const skip = (page - 1) * limit; const regex = this.safeRegex(normalized); - const [blockedIds, followingIds, activeAuthorIds] = await Promise.all([ + const [blockedIds, followingIds, disabledAuthorIds, privateAuthorIds] = await Promise.all([ this.getBlockedOrBlockingObjectIds(currentUserId), this.getFollowingObjectIds(currentUserId), - this.getActiveSearchableAuthorIds(currentUserId), + this.getDisabledAuthorIds(), + this.getPrivateAuthorIds(), ]); const currentObjectId = new Types.ObjectId(currentUserId); - const allowedAuthorIds = activeAuthorIds.filter( - (authorId) => !blockedIds.some((blockedId) => blockedId.equals(authorId)), - ); + const allowedPrivateIds = new Set([currentUserId, ...followingIds.map((id) => id.toString())]); + const excludedAuthorIds = [ + ...blockedIds, + ...disabledAuthorIds, + ...privateAuthorIds.filter((id) => !allowedPrivateIds.has(id.toString())), + ]; const visibilityOr: Record[] = [ { visibility: PostVisibility.PUBLIC }, { authorId: currentObjectId }, @@ -226,7 +275,7 @@ export class SearchService { isDeleted: { $ne: true }, isArchived: { $ne: true }, moderationStatus: { $ne: ModerationStatus.HIDDEN }, - authorId: { $in: allowedAuthorIds }, + ...(excludedAuthorIds.length ? { authorId: { $nin: excludedAuthorIds } } : {}), $or: visibilityOr, hashtags: regex, }, @@ -255,6 +304,372 @@ export class SearchService { return buildPaginatedResponse(items, { page, limit, total, offset: skip }); } + private async searchUsersWithAtlas( + currentUserId: string, + query: SearchQueryDto, + ): Promise> { + const normalized = this.normalizeSearchText(query.q); + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + const [excludedIds, followingIds, affinityAuthorIds] = await Promise.all([ + this.getBlockedOrBlockingObjectIds(currentUserId), + this.getFollowingObjectIds(currentUserId), + this.getInteractedAuthorObjectIds(currentUserId), + ]); + const fuzzy = + normalized.length >= 3 ? { fuzzy: { maxEdits: 1, prefixLength: 1, maxExpansions: 30 } } : {}; + + const pipeline = [ + { + $search: { + index: this.atlasUserIndex, + compound: { + should: [ + { + text: { + query: normalized, + path: 'username', + score: { boost: { value: 16 } }, + }, + }, + { + autocomplete: { + query: normalized, + path: 'username', + tokenOrder: 'sequential', + ...fuzzy, + score: { boost: { value: 12 } }, + }, + }, + { + autocomplete: { + query: normalized, + path: 'stageName', + tokenOrder: 'sequential', + ...fuzzy, + score: { boost: { value: 8 } }, + }, + }, + { + autocomplete: { + query: normalized, + path: 'name', + tokenOrder: 'any', + ...fuzzy, + score: { boost: { value: 6 } }, + }, + }, + { + text: { + query: normalized, + path: ['name', 'stageName'], + ...fuzzy, + score: { boost: { value: 4 } }, + }, + }, + ], + minimumShouldMatch: 1, + }, + }, + }, + { + $match: { + isDisabled: false, + ...(excludedIds.length ? { _id: { $nin: excludedIds } } : {}), + }, + }, + { $set: { __searchScore: { $meta: 'searchScore' } } }, + { + $set: { + __rankScore: { + $add: [ + { $multiply: ['$__searchScore', 20] }, + { $cond: [{ $in: ['$_id', followingIds] }, 18, 0] }, + { $cond: [{ $in: ['$_id', affinityAuthorIds] }, 10, 0] }, + { $cond: [{ $eq: ['$isVerified', true] }, 6, 0] }, + { + $multiply: [2, { $log10: { $add: [{ $ifNull: ['$followersCount', 0] }, 1] } }], + }, + ], + }, + }, + }, + { $sort: { __rankScore: -1, username: 1 } }, + { + $facet: { + items: [{ $skip: skip }, { $limit: limit }, { $project: { _id: 1 } }], + total: [{ $count: 'count' }], + }, + }, + ] as unknown as PipelineStage[]; + + const [result] = await this.userModel.aggregate(pipeline).exec(); + const orderedIds = (result?.items ?? []).map((item) => item._id.toString()); + const users = orderedIds.length + ? await this.userModel.find({ _id: { $in: orderedIds } }).exec() + : []; + const usersById = new Map(users.map((user) => [user.id, user])); + const followingSet = new Set(followingIds.map((id) => id.toString())); + const orderedUsers = orderedIds.flatMap((id) => { + const user = usersById.get(id); + return user ? [user as unknown as UserDocument] : []; + }); + const items = orderedUsers.map((user) => this.toUserItem(user, followingSet)); + const total = Number(result?.total?.[0]?.count ?? 0); + + return buildPaginatedResponse(items, { page, limit, total, offset: skip }); + } + + private async searchPostsWithAtlas( + currentUserId: string, + query: SearchQueryDto, + ): Promise> { + const normalized = this.normalizeSearchText(query.q); + const hashtagQuery = this.normalizeHashtag(normalized); + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + const sortDirection = resolveMongoSortDirection(query.sortOrder); + const [blockedIds, followingIds, affinityAuthorIds, privateAuthorIds] = await Promise.all([ + this.getBlockedOrBlockingObjectIds(currentUserId), + this.getFollowingObjectIds(currentUserId), + this.getInteractedAuthorObjectIds(currentUserId), + this.getPrivateAuthorIds(), + ]); + const allowedPrivateIds = new Set([currentUserId, ...followingIds.map((id) => id.toString())]); + const excludedAuthorIds = [ + ...blockedIds, + ...privateAuthorIds.filter((id) => !allowedPrivateIds.has(id.toString())), + ]; + const currentObjectId = new Types.ObjectId(currentUserId); + const visibilityOr: Record[] = [ + { visibility: PostVisibility.PUBLIC }, + { authorId: currentObjectId }, + ]; + if (followingIds.length) { + visibilityOr.push({ + visibility: PostVisibility.FOLLOWERS, + authorId: { $in: followingIds }, + }); + } + const fuzzy = + normalized.length >= 4 ? { fuzzy: { maxEdits: 1, prefixLength: 2, maxExpansions: 40 } } : {}; + + const pipeline = [ + { + $search: { + index: this.atlasPostIndex, + compound: { + should: [ + { + text: { + query: normalized, + path: ['content', 'contentTop', 'contentBottom'], + ...fuzzy, + score: { boost: { value: 6 } }, + }, + }, + { + text: { + query: hashtagQuery, + path: 'hashtags', + ...fuzzy, + score: { boost: { value: 10 } }, + }, + }, + { + autocomplete: { + query: hashtagQuery, + path: 'hashtags', + tokenOrder: 'sequential', + ...fuzzy, + score: { boost: { value: 8 } }, + }, + }, + { + text: { + query: normalized, + path: ['style', 'maqam', 'rhythmSignature'], + ...fuzzy, + score: { boost: { value: 5 } }, + }, + }, + ], + minimumShouldMatch: 1, + }, + }, + }, + { + $match: { + isDeleted: { $ne: true }, + isArchived: { $ne: true }, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + ...(excludedAuthorIds.length ? { authorId: { $nin: excludedAuthorIds } } : {}), + $or: visibilityOr, + }, + }, + { + $lookup: { + from: 'users', + localField: 'authorId', + foreignField: '_id', + as: '__searchAuthor', + }, + }, + { $match: { '__searchAuthor.0.isDisabled': false } }, + { $set: { __searchScore: { $meta: 'searchScore' } } }, + { + $set: { + __engagementValue: { + $add: [ + { $ifNull: ['$likesCount', 0] }, + { $multiply: [{ $ifNull: ['$commentsCount', 0] }, 2] }, + { $multiply: [{ $ifNull: ['$savesCount', 0] }, 3] }, + { $multiply: [{ $ifNull: ['$shareCount', 0] }, 2] }, + { $multiply: [{ $ifNull: ['$viewCount', 0] }, 0.02] }, + { $multiply: [{ $ifNull: ['$playCount', 0] }, 0.03] }, + ], + }, + __ageHours: { + $max: [0, { $dateDiff: { startDate: '$createdAt', endDate: '$$NOW', unit: 'hour' } }], + }, + }, + }, + { + $set: { + __rankScore: { + $add: [ + { $multiply: ['$__searchScore', 20] }, + { $cond: [{ $in: ['$authorId', followingIds] }, 14, 0] }, + { $cond: [{ $in: ['$authorId', affinityAuthorIds] }, 8, 0] }, + { $multiply: [2.5, { $log10: { $add: ['$__engagementValue', 1] } }] }, + { $divide: [12, { $add: [1, { $divide: ['$__ageHours', 168] }] }] }, + ], + }, + }, + }, + { $sort: { __rankScore: -1, createdAt: sortDirection } }, + { + $facet: { + items: [{ $skip: skip }, { $limit: limit }, { $project: { _id: 1 } }], + total: [{ $count: 'count' }], + }, + }, + ] as unknown as PipelineStage[]; + + const [result] = await this.postModel.aggregate(pipeline).exec(); + const orderedIds = (result?.items ?? []).map((item) => item._id.toString()); + const posts = orderedIds.length + ? await this.postModel + .find({ _id: { $in: orderedIds } }) + .populate({ + path: 'authorId', + select: 'name username avatar isVerified stageName isDisabled isPrivate', + }) + .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' }) + .populate({ + path: 'collaboratorIds', + select: 'name username avatar stageName isVerified', + }) + .exec() + : []; + const postsById = new Map(posts.map((post) => [post.id, post])); + const orderedPosts = orderedIds.flatMap((id) => { + const post = postsById.get(id); + return post ? [post as unknown as PostDocument] : []; + }); + const items = await this.toPostSearchItems(currentUserId, orderedPosts); + const total = Number(result?.total?.[0]?.count ?? 0); + + return buildPaginatedResponse(items, { page, limit, total, offset: skip }); + } + + private async searchHashtagsWithAtlas( + currentUserId: string, + query: SearchQueryDto, + ): Promise> { + const normalized = this.normalizeHashtag(query.q); + if (normalized.length < 2) { + return this.searchHashtagsWithRegex(currentUserId, query); + } + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + const regex = this.safeRegex(normalized); + const [blockedIds, followingIds, privateAuthorIds] = await Promise.all([ + this.getBlockedOrBlockingObjectIds(currentUserId), + this.getFollowingObjectIds(currentUserId), + this.getPrivateAuthorIds(), + ]); + const allowedPrivateIds = new Set([currentUserId, ...followingIds.map((id) => id.toString())]); + const excludedAuthorIds = [ + ...blockedIds, + ...privateAuthorIds.filter((id) => !allowedPrivateIds.has(id.toString())), + ]; + const currentObjectId = new Types.ObjectId(currentUserId); + const visibilityOr: Record[] = [ + { visibility: PostVisibility.PUBLIC }, + { authorId: currentObjectId }, + ]; + if (followingIds.length) { + visibilityOr.push({ + visibility: PostVisibility.FOLLOWERS, + authorId: { $in: followingIds }, + }); + } + + const pipeline = [ + { + $search: { + index: this.atlasPostIndex, + autocomplete: { + query: normalized, + path: 'hashtags', + tokenOrder: 'sequential', + }, + }, + }, + { + $match: { + isDeleted: { $ne: true }, + isArchived: { $ne: true }, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + ...(excludedAuthorIds.length ? { authorId: { $nin: excludedAuthorIds } } : {}), + $or: visibilityOr, + }, + }, + { + $lookup: { + from: 'users', + localField: 'authorId', + foreignField: '_id', + as: '__searchAuthor', + }, + }, + { $match: { '__searchAuthor.0.isDisabled': false } }, + { $unwind: '$hashtags' }, + { $match: { hashtags: regex } }, + { $group: { _id: '$hashtags', postsCount: { $sum: 1 } } }, + { $sort: { postsCount: -1, _id: 1 } }, + { + $facet: { + items: [{ $skip: skip }, { $limit: limit }], + total: [{ $count: 'count' }], + }, + }, + ] as unknown as PipelineStage[]; + + const [result] = await this.postModel.aggregate(pipeline).exec(); + const items = (result?.items ?? []).map((item) => ({ + tag: item._id, + postsCount: item.postsCount, + })); + const total = Number(result?.total?.[0]?.count ?? 0); + + return buildPaginatedResponse(items, { page, limit, total, offset: skip }); + } + async getSuggestions(currentUserId: string, query: SearchSuggestionsQueryDto) { const normalized = this.normalizeSearchText(query.q); const limit = query.limit ?? 5; @@ -270,6 +685,188 @@ export class SearchService { }; } + private async runWithSearchEngine( + operation: string, + atlasSearch: () => Promise, + fallbackSearch: () => Promise, + ): Promise { + const capability = operation === 'users' ? 'users' : 'posts'; + const unavailableUntil = this.atlasUnavailableUntil.get(capability) ?? 0; + if (this.searchEngine === 'regex') { + return fallbackSearch(); + } + if (this.fallbackEnabled && Date.now() < unavailableUntil) { + return fallbackSearch(); + } + + try { + const result = await atlasSearch(); + this.atlasUnavailableUntil.delete(capability); + return result; + } catch (error) { + if (!this.fallbackEnabled) { + throw error; + } + + const wasAvailable = Date.now() >= unavailableUntil; + this.atlasUnavailableUntil.set(capability, Date.now() + this.atlasRetryMs); + if (wasAvailable) { + const reason = error instanceof Error ? error.message.slice(0, 300) : String(error); + this.logger.warn( + `Atlas Search unavailable during ${operation}; using compatibility search for ` + + `${Math.round(this.atlasRetryMs / 1000)}s. Reason: ${reason}`, + ); + } + return fallbackSearch(); + } + } + + private async toPostSearchItems(currentUserId: string, posts: PostDocument[]): Promise { + const postIds = posts.map((post) => post.id); + const authorIds = posts + .map((post) => this.extractEntityId((post as any).authorId)) + .filter(Boolean); + const [likedSet, savedSet, followingSet] = await Promise.all([ + this.getLikedPostSet(currentUserId, postIds), + this.getSavedPostSet(currentUserId, postIds), + this.getFollowingSet(currentUserId, authorIds), + ]); + const safeObjects = await this.redactSearchOriginalReferences( + currentUserId, + posts.map((post) => post.toObject() as Record), + ); + + return posts.map((post, index) => { + const object = safeObjects[index]; + const authorId = this.extractEntityId(object.authorId); + return { + ...object, + isLiked: likedSet.has(post.id), + liked: likedSet.has(post.id), + isSaved: savedSet.has(post.id), + saved: savedSet.has(post.id), + isFollowingAuthor: authorId ? followingSet.has(authorId) : false, + }; + }); + } + + private async redactSearchOriginalReferences( + currentUserId: string, + items: Array>, + ): Promise>> { + const fields = ['repostOfPostId', 'quoteOfPostId'] as const; + const sourceIds = Array.from( + new Set( + items + .flatMap((item) => fields.map((field) => this.extractEntityId(item[field]))) + .filter((id) => Types.ObjectId.isValid(id)), + ), + ); + if (!sourceIds.length) { + return items; + } + const [sources, followingIds, blockedIds, privateAuthorIds] = await Promise.all([ + this.connection + .collection('posts') + .find({ + _id: { $in: sourceIds.map((id) => new Types.ObjectId(id)) }, + isDeleted: { $ne: true }, + isArchived: { $ne: true }, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + }) + .project({ authorId: 1, visibility: 1 }) + .toArray(), + this.getFollowingObjectIds(currentUserId), + this.getBlockedOrBlockingObjectIds(currentUserId), + this.getPrivateAuthorIds(), + ]); + const followingSet = new Set(followingIds.map((id) => id.toString())); + const blockedSet = new Set(blockedIds.map((id) => id.toString())); + const privateSet = new Set(privateAuthorIds.map((id) => id.toString())); + const sourceById = new Map(sources.map((source) => [source._id.toString(), source])); + + return items.map((item) => { + const safe = { ...item }; + for (const field of fields) { + const sourceId = this.extractEntityId(item[field]); + if (!sourceId) continue; + const source = sourceById.get(sourceId); + const authorId = this.extractEntityId(source?.authorId); + const accountAllowed = + authorId === currentUserId || + (!!authorId && !privateSet.has(authorId)) || + (!!authorId && followingSet.has(authorId)); + const visibilityAllowed = + authorId === currentUserId || + source?.visibility === PostVisibility.PUBLIC || + (source?.visibility === PostVisibility.FOLLOWERS && followingSet.has(authorId)); + if (!source || !authorId || blockedSet.has(authorId) || !accountAllowed || !visibilityAllowed) { + safe[field] = null; + } + } + return safe; + }); + } + + private async getInteractedAuthorObjectIds(currentUserId: string): Promise { + const cached = this.affinityCache.get(currentUserId); + if (cached && cached.expiresAt > Date.now()) { + return cached.authorIds; + } + + const userObjectId = new Types.ObjectId(currentUserId); + const [likedRows, savedRows] = await Promise.all([ + this.connection + .collection('likes') + .find({ userId: userObjectId, targetType: 'post' }) + .sort({ createdAt: -1 }) + .limit(75) + .project({ targetId: 1 }) + .toArray(), + this.connection + .collection('saves') + .find({ userId: userObjectId }) + .sort({ createdAt: -1 }) + .limit(75) + .project({ postId: 1 }) + .toArray(), + ]); + const postIds = Array.from( + new Set( + [...likedRows.map((row) => row.targetId), ...savedRows.map((row) => row.postId)] + .filter((id): id is Types.ObjectId => id instanceof Types.ObjectId) + .map((id) => id.toString()), + ), + ).map((id) => new Types.ObjectId(id)); + const rows = postIds.length + ? await this.postModel + .find({ _id: { $in: postIds }, isDeleted: { $ne: true } }) + .select({ authorId: 1 }) + .lean() + .exec() + : []; + const authorIds = Array.from( + new Set( + rows + .map((row) => row.authorId) + .filter((id): id is Types.ObjectId => id instanceof Types.ObjectId) + .map((id) => id.toString()), + ), + ).map((id) => new Types.ObjectId(id)); + + if (this.affinityCache.size >= 1000) { + const oldestKey = this.affinityCache.keys().next().value as string | undefined; + if (oldestKey) { + this.affinityCache.delete(oldestKey); + } + } + this.affinityCache.set(currentUserId, { + expiresAt: Date.now() + 60_000, + authorIds, + }); + return authorIds; + } + private normalizeSearchText(value: string): string { const normalized = (value ?? '').trim(); if (!normalized) { @@ -368,18 +965,22 @@ export class SearchService { return new Set(rows.map((row) => row[postField]?.toString()).filter(Boolean)); } - private async getActiveSearchableAuthorIds(currentUserId: string): Promise { - const rows = await this.userModel.find({ isDisabled: false }).select({ _id: 1 }).lean().exec(); - const currentObjectId = new Types.ObjectId(currentUserId); - const ids = rows + private async getDisabledAuthorIds(): Promise { + const rows = await this.userModel.find({ isDisabled: true }).select({ _id: 1 }).lean().exec(); + return rows .map((row) => row._id) .filter((id): id is Types.ObjectId => id instanceof Types.ObjectId); + } - if (!ids.some((id) => id.equals(currentObjectId))) { - ids.push(currentObjectId); - } - - return ids; + private async getPrivateAuthorIds(): Promise { + const rows = await this.userModel + .find({ isPrivate: true, isDisabled: false }) + .select({ _id: 1 }) + .lean() + .exec(); + return rows + .map((row) => row._id) + .filter((id): id is Types.ObjectId => id instanceof Types.ObjectId); } private toUserItem(user: UserDocument, followingSet: Set): SearchUserItem { diff --git a/src/modules/superadmin/superadmin.service.spec.ts b/src/modules/superadmin/superadmin.service.spec.ts new file mode 100644 index 0000000..33e7991 --- /dev/null +++ b/src/modules/superadmin/superadmin.service.spec.ts @@ -0,0 +1,490 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Types } from 'mongoose'; +import { ModerationStatus } from '../../common/enums/moderation-status.enum'; +import { SuperAdminService } from './superadmin.service'; + +describe('SuperAdminService', () => { + const resourceId = '507f1f77bcf86cd799439011'; + const authorId = new Types.ObjectId('507f191e810c19729de860ea'); + const now = new Date(); + + function query(value: T) { + const chain: any = { exec: jest.fn().mockResolvedValue(value) }; + for (const method of ['sort', 'limit', 'select', 'lean', 'populate', 'skip']) { + chain[method] = jest.fn(() => chain); + } + return chain; + } + + function model(rows: any[], single: any) { + return { + countDocuments: jest.fn(() => query(2)), + find: jest.fn(() => query(rows)), + findOne: jest.fn(() => query(single)), + findById: jest.fn(() => query(single)), + findByIdAndUpdate: jest.fn(() => query(single)), + findByIdAndDelete: jest.fn(() => query(single)), + findOneAndUpdate: jest.fn(() => query(single)), + aggregate: jest.fn(() => query([{ _id: 'unknown', value: 1, count: 1 }])), + create: jest.fn().mockResolvedValue(single), + }; + } + + const setup = () => { + const user = { + _id: authorId, + id: authorId.toHexString(), + name: 'Member', + username: 'member', + email: 'member@example.com', + role: 'user', + isDisabled: false, + createdAt: now, + }; + const post = { + _id: new Types.ObjectId(resourceId), + id: resourceId, + authorId, + content: 'A useful post', + postType: 'text', + moderationStatus: ModerationStatus.ACTIVE, + isDeleted: false, + createdAt: now, + save: jest.fn().mockResolvedValue(undefined), + }; + const comment = { + _id: new Types.ObjectId(resourceId), + id: resourceId, + authorId, + postId: new Types.ObjectId(resourceId), + content: 'A useful comment', + moderationStatus: ModerationStatus.ACTIVE, + isDeleted: false, + createdAt: now, + save: jest.fn().mockResolvedValue(undefined), + }; + const listing = { + _id: new Types.ObjectId(resourceId), + id: resourceId, + title: 'Guitar', + listingCategory: 'musical_instrument', + isActive: true, + imageUrls: ['/uploads/listing.jpg'], + ownerAdminId: { name: 'Shop owner', shopName: 'Music Shop' }, + createdAt: now, + }; + const repairShop = { + _id: new Types.ObjectId(resourceId), + id: resourceId, + name: 'Repair Lab', + location: 'Riyadh', + isActive: true, + imageUrls: ['/uploads/repair.jpg'], + ownerAdminId: { name: 'Shop owner', shopName: 'Music Shop' }, + createdAt: now, + }; + const notification = { read: false, createdAt: now }; + const auditRow = { + actorType: 'superadmin', + actorIdentifier: 'root@example.com', + action: 'post_hide', + targetType: 'post', + targetId: resourceId, + createdAt: now, + }; + const caseRow = { + id: resourceId, + title: 'Review case', + description: 'Needs review', + caseType: 'content_moderation', + resourceType: 'post', + resourceId, + status: 'open', + priority: 'high', + assignedTo: '', + createdBy: 'root@example.com', + updatedBy: 'root@example.com', + resolution: '', + events: [] as any[], + createdAt: now, + updatedAt: now, + save: jest.fn().mockResolvedValue(undefined), + }; + const history = { + id: resourceId, + scope: 'default', + updatedBy: 'root@example.com', + changedFields: ['siteName'], + previousSettings: { siteName: 'Old' }, + nextSettings: { siteName: 'New' }, + createdAt: now, + }; + + const userModel = model([user], user); + const postModel = model([post], post); + const commentModel = model([comment], comment); + const instrumentModel = model([listing], listing); + const repairShopModel = model([repairShop], repairShop); + const notificationModel = model([notification], notification); + const auditLogModel = model([auditRow], auditRow); + const settingsModel = model([{ siteName: 'Stored' }], { siteName: 'Stored', scope: 'default' }); + const settingsHistoryModel = model([history], history); + const superAdminCaseModel = model([caseRow], caseRow); + superAdminCaseModel.findOne.mockImplementation(() => query(null)); + const outboxEventModel = model([], null); + const refreshTokenModel = model([], null); + const connection = { readyState: 1, name: 'oudelaa-test' }; + const configValues: Record = { + nodeEnv: 'test', + host: '127.0.0.1', + port: 3000, + globalPrefix: 'api/v1', + publicBaseUrl: 'https://api.example.com', + responseEnvelopeEnabled: true, + 'email.enabled': true, + 'cors.origins': ['https://app.example.com'], + 'swagger.path': 'docs', + 'storage.provider': 'local', + 'storage.basePath': 'uploads', + 'queue.enabled': true, + 'queue.name': 'jobs', + 'redis.enabled': true, + 'redis.socketAdapterEnabled': true, + 'superAdmin.email': 'root@example.com', + }; + const config = { get: jest.fn((key: string) => configValues[key]) }; + const audit = { logSuperAdminAction: jest.fn().mockResolvedValue(undefined) }; + const postsService = { + updateModerationStatusBySuperAdmin: jest.fn().mockResolvedValue(post), + removeBySuperAdmin: jest.fn().mockResolvedValue(undefined), + }; + const commentsService = { + updateModerationStatusBySuperAdmin: jest.fn().mockResolvedValue(comment), + removeBySuperAdmin: jest.fn().mockResolvedValue(undefined), + }; + const usersService = { + disableUserBySuperAdmin: jest.fn().mockResolvedValue(user), + enableUserBySuperAdmin: jest.fn().mockResolvedValue(user), + }; + const redisClient = { ping: jest.fn().mockResolvedValue('PONG') }; + const redis = { + isEnabled: jest.fn().mockReturnValue(true), + getClient: jest.fn().mockReturnValue(redisClient), + }; + const storage = { deleteFile: jest.fn().mockResolvedValue(undefined) }; + const feedVersion = { bumpGlobalVersion: jest.fn().mockResolvedValue(2) }; + + const service = new SuperAdminService( + connection as any, + userModel as any, + postModel as any, + commentModel as any, + instrumentModel as any, + repairShopModel as any, + notificationModel as any, + auditLogModel as any, + settingsModel as any, + settingsHistoryModel as any, + superAdminCaseModel as any, + outboxEventModel as any, + refreshTokenModel as any, + config as any, + audit as any, + postsService as any, + commentsService as any, + usersService as any, + redis as any, + storage as any, + feedVersion as any, + ); + + return { + service, + connection, + userModel, + postModel, + commentModel, + instrumentModel, + repairShopModel, + settingsModel, + settingsHistoryModel, + superAdminCaseModel, + audit, + postsService, + commentsService, + usersService, + redis, + redisClient, + storage, + feedVersion, + post, + comment, + user, + listing, + repairShop, + caseRow, + history, + }; + }; + + it('returns complete overview, chart, report, activity, and session projections', async () => { + const { service } = setup(); + + await expect(service.getOverview()).resolves.toEqual( + expect.objectContaining({ metrics: expect.objectContaining({ usersCount: 2, generalMarketplaceListingsCount: 0 }) }), + ); + await expect(service.getCharts({ range: '7d' } as any)).resolves.toEqual( + expect.objectContaining({ range: '7d', days: 7, series: expect.any(Object), breakdowns: expect.any(Object) }), + ); + await expect(service.getCharts({ range: '90d' } as any)).resolves.toEqual(expect.objectContaining({ days: 90 })); + await expect(service.getRecentActivity({ limit: 20 } as any)).resolves.toEqual({ + items: expect.arrayContaining([expect.objectContaining({ type: 'user' }), expect.objectContaining({ type: 'post' })]), + }); + await expect(service.getReports({ limit: 4 } as any)).resolves.toEqual( + expect.objectContaining({ summary: expect.objectContaining({ flaggedPostsCount: 2 }) }), + ); + expect(service.getSession({ email: 'owner@example.com', permissions: ['users.read'] })).toEqual( + expect.objectContaining({ superAdmin: { email: 'owner@example.com' }, permissions: ['users.read'] }), + ); + expect(service.getSession({})).toEqual(expect.objectContaining({ superAdmin: { email: 'root@example.com' } })); + }); + + it('lists, creates, and updates investigation cases with normalized metadata', async () => { + const { service, superAdminCaseModel, audit, caseRow } = setup(); + await service.getCases({ + q: ' guitar.* ', + status: 'open', + priority: 'high', + resourceType: ' listing ', + assignedTo: ' root ', + page: 2, + limit: 5, + sortOrder: 'asc', + } as any); + await service.createCase('root@example.com', { + title: ' Review listing ', + description: ' suspicious ', + caseType: ' marketplace_review ', + resourceType: ' listing ', + resourceId: ` ${resourceId} `, + assignedTo: ' root@example.com ', + note: ' created ', + } as any); + + superAdminCaseModel.findById.mockReturnValueOnce(query(caseRow)); + superAdminCaseModel.findByIdAndUpdate.mockReturnValueOnce(query({ ...caseRow, status: 'resolved' })); + await service.updateCase('root@example.com', resourceId, { + title: ' Done ', + description: ' Complete ', + caseType: ' content ', + resourceType: ' post ', + resourceId: ` ${resourceId} `, + status: 'resolved', + priority: 'normal', + assignedTo: ' root ', + tags: ['resolved'], + note: ' fixed ', + } as any); + + expect(superAdminCaseModel.create).toHaveBeenCalledWith(expect.objectContaining({ title: 'Review listing' })); + expect(audit.logSuperAdminAction).toHaveBeenCalled(); + }); + + it('rejects updates for missing cases', async () => { + const { service, superAdminCaseModel } = setup(); + superAdminCaseModel.findById.mockReturnValueOnce(query(null)); + await expect(service.updateCase('root', resourceId, {} as any)).rejects.toBeInstanceOf(NotFoundException); + superAdminCaseModel.findByIdAndUpdate.mockReturnValueOnce(query(null)); + await expect(service.updateCase('root', resourceId, {} as any)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('reads, updates, restores, and paginates settings with immutable history', async () => { + const { service, settingsModel, settingsHistoryModel, audit, history } = setup(); + await expect(service.getSettings()).resolves.toEqual( + expect.objectContaining({ settings: expect.objectContaining({ siteName: 'Stored' }), runtime: expect.any(Object) }), + ); + await service.getSettingsHistory({ page: 2, limit: 5, sortOrder: 'asc' } as any); + await service.updateSettings('root@example.com', { siteName: 'New name', maintenanceMode: true } as any); + settingsHistoryModel.findById.mockReturnValueOnce(query(history)); + await service.restoreSettingsVersion('root@example.com', resourceId); + + expect(settingsModel.findOneAndUpdate).toHaveBeenCalled(); + expect(settingsHistoryModel.create).toHaveBeenCalledTimes(2); + expect(audit.logSuperAdminAction).toHaveBeenCalled(); + }); + + it('rejects restoring a missing settings version', async () => { + const { service, settingsHistoryModel } = setup(); + settingsHistoryModel.findById.mockReturnValueOnce(query(null)); + await expect(service.restoreSettingsVersion('root', resourceId)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('reports connected, degraded, unreachable, and disabled operational dependencies', async () => { + const { service, redis, redisClient, connection } = setup(); + await expect(service.getOps()).resolves.toEqual( + expect.objectContaining({ services: expect.objectContaining({ mongodb: { status: 'connected', database: 'oudelaa-test' } }) }), + ); + redisClient.ping.mockResolvedValueOnce('NOPE'); + await expect(service.getOps()).resolves.toEqual( + expect.objectContaining({ services: expect.objectContaining({ redis: { enabled: true, status: 'degraded' } }) }), + ); + redisClient.ping.mockRejectedValueOnce(new Error('offline')); + await expect(service.getOps()).resolves.toEqual( + expect.objectContaining({ services: expect.objectContaining({ redis: { enabled: true, status: 'unreachable' } }) }), + ); + redis.isEnabled.mockReturnValue(false); + connection.readyState = 99; + await expect(service.getOps()).resolves.toEqual( + expect.objectContaining({ services: expect.objectContaining({ mongodb: { status: 'unknown', database: 'oudelaa-test' } }) }), + ); + }); + + it('moderates, deletes, and restores posts and comments while repairing counters', async () => { + const { service, postModel, commentModel, post, comment, feedVersion } = setup(); + await service.updatePostStatus('root', resourceId, { status: ModerationStatus.FLAGGED, reason: ' review ' } as any); + await service.deletePost('root', resourceId); + + const deletedPost = { ...post, isDeleted: true, save: jest.fn().mockResolvedValue(undefined) }; + postModel.findOne.mockReturnValueOnce(query(deletedPost)); + await service.restorePost('root', resourceId); + + await service.updateCommentStatus('root', resourceId, { status: ModerationStatus.ACTIVE } as any); + await service.deleteComment('root', resourceId); + const deletedComment = { ...comment, isDeleted: true, save: jest.fn().mockResolvedValue(undefined) }; + commentModel.findOne.mockReturnValueOnce(query(deletedComment)); + await service.restoreComment('root', resourceId); + + expect(deletedPost.save).toHaveBeenCalled(); + expect(deletedComment.save).toHaveBeenCalled(); + expect(feedVersion.bumpGlobalVersion).toHaveBeenCalledTimes(2); + }); + + it('returns not found when deleted content cannot be located', async () => { + const { service, postModel, commentModel } = setup(); + postModel.findById.mockReturnValueOnce(query(null)); + await expect(service.deletePost('root', resourceId)).rejects.toBeInstanceOf(NotFoundException); + postModel.findOne.mockReturnValueOnce(query(null)); + await expect(service.restorePost('root', resourceId)).rejects.toBeInstanceOf(NotFoundException); + commentModel.findById.mockReturnValueOnce(query(null)); + await expect(service.deleteComment('root', resourceId)).rejects.toBeInstanceOf(NotFoundException); + commentModel.findOne.mockReturnValueOnce(query(null)); + await expect(service.restoreComment('root', resourceId)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('disables and enables users and records review cases', async () => { + const { service, usersService } = setup(); + await service.updateUserStatus('root', resourceId, { isDisabled: true, reason: ' abuse ' } as any); + await service.updateUserStatus('root', resourceId, { isDisabled: false } as any); + expect(usersService.disableUserBySuperAdmin).toHaveBeenCalled(); + expect(usersService.enableUserBySuperAdmin).toHaveBeenCalled(); + }); + + it('dispatches all bulk content and user actions and rejects unsupported actions', async () => { + const { service } = setup(); + const internals = service as any; + jest.spyOn(service, 'updatePostStatus').mockResolvedValue({} as any); + jest.spyOn(service, 'deletePost').mockResolvedValue({ success: true, postId: resourceId }); + jest.spyOn(service, 'restorePost').mockResolvedValue({} as any); + jest.spyOn(service, 'updateCommentStatus').mockResolvedValue({} as any); + jest.spyOn(service, 'deleteComment').mockResolvedValue({ success: true, commentId: resourceId }); + jest.spyOn(service, 'restoreComment').mockResolvedValue({} as any); + jest.spyOn(service, 'updateUserStatus').mockResolvedValue({} as any); + + for (const action of ['activate', 'flag', 'hide', 'delete', 'restore']) { + await internals.performPostBulkAction('root', resourceId, { action }); + await internals.performCommentBulkAction('root', resourceId, { action }); + } + for (const action of ['disable', 'enable']) { + await internals.performUserBulkAction('root', resourceId, { action }); + } + await expect(internals.performPostBulkAction('root', resourceId, { action: 'bad' })).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect(internals.performCommentBulkAction('root', resourceId, { action: 'bad' })).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect(internals.performUserBulkAction('root', resourceId, { action: 'bad' })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('executes every marketplace bulk action and best-effort file cleanup', async () => { + const { service, storage } = setup(); + const internals = service as any; + for (const action of ['activate', 'deactivate', 'delete']) { + await internals.performListingBulkAction('root', resourceId, { action, reason: ' policy ' }); + await internals.performRepairShopBulkAction('root', resourceId, { action, reason: ' policy ' }); + } + storage.deleteFile.mockRejectedValueOnce(new Error('already gone')); + await expect(internals.safeDeleteManagedUrl('/uploads/missing.jpg')).resolves.toBeUndefined(); + await expect(internals.performListingBulkAction('root', resourceId, { action: 'bad' })).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect(internals.performRepairShopBulkAction('root', resourceId, { action: 'bad' })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('runs bulk dispatch across resource types and reports partial failures', async () => { + const { service, audit } = setup(); + const internals = service as any; + for (const resourceType of ['post', 'comment', 'user', 'listing', 'repair_shop']) { + jest.spyOn(internals, `perform${resourceType === 'repair_shop' ? 'RepairShop' : resourceType[0].toUpperCase() + resourceType.slice(1)}BulkAction`).mockResolvedValueOnce(undefined); + await internals.performSingleBulkAction('root', resourceType, resourceId, { action: 'activate' }); + } + await expect(internals.performSingleBulkAction('root', 'unknown', resourceId, {})).rejects.toBeInstanceOf( + BadRequestException, + ); + + jest.spyOn(internals, 'performSingleBulkAction').mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('failed')); + jest.spyOn(internals, 'applyBulkCaseMetadata').mockResolvedValue(undefined); + const result = await service.performBulkAction('root', { + resourceType: 'post', + action: 'hide', + targetIds: [resourceId, authorId.toHexString()], + } as any); + expect(result).toEqual(expect.objectContaining({ succeeded: 1, failed: 1 })); + expect(audit.logSuperAdminAction).toHaveBeenCalled(); + }); + + it('updates existing case records and optional assignment metadata', async () => { + const { service, superAdminCaseModel, caseRow } = setup(); + const internals = service as any; + superAdminCaseModel.findOne.mockReturnValue(query(caseRow)); + await internals.recordResourceCase({ + actor: 'root', + caseType: 'content_moderation', + resourceType: 'post', + resourceId, + title: 'Post moderation: title', + description: 'review', + action: 'post_flagged', + note: 'review', + status: 'in_review', + priority: 'high', + assignedTo: 'root', + }); + await internals.applyBulkCaseMetadata('root', 'post', resourceId, { + assignToMe: true, + priority: 'critical', + reason: ' urgent ', + }); + await internals.applyBulkCaseMetadata('root', 'post', resourceId, {}); + superAdminCaseModel.findOne.mockReturnValueOnce(query(null)); + await internals.applyBulkCaseMetadata('root', 'post', resourceId, { assignToMe: true }); + expect(caseRow.save).toHaveBeenCalled(); + expect(caseRow.assignedTo).toBe('root'); + }); + + it('covers private range, title, date, and default-setting helpers', () => { + const { service } = setup(); + const internals = service as any; + expect(internals.resolveRangeDays('30d')).toBe(30); + expect(internals.resolveRangeDays('unexpected')).toBe(30); + expect(internals.buildContentCaseTitle('Post', ' ')).toContain('untitled'); + expect(internals.createDateBuckets(3)).toHaveLength(3); + expect(internals.formatDateKey(new Date('2026-01-02T10:00:00Z'))).toBe('2026-01-02'); + expect(internals.buildDefaultSettings()).toEqual(expect.objectContaining({ dashboardApiBaseUrl: 'https://api.example.com/api/v1' })); + expect(internals.buildMusicalInstrumentFilter()).toEqual(expect.objectContaining({ $or: expect.any(Array) })); + }); +}); diff --git a/src/modules/superadmin/superadmin.service.ts b/src/modules/superadmin/superadmin.service.ts index d849b02..f1e0aa2 100644 --- a/src/modules/superadmin/superadmin.service.ts +++ b/src/modules/superadmin/superadmin.service.ts @@ -4,6 +4,7 @@ import { InjectConnection, InjectModel } from '@nestjs/mongoose'; import { Connection, Model, Types } from 'mongoose'; import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util'; +import { escapeRegex } from '../../common/utils/regex.util'; import { ModerationStatus } from '../../common/enums/moderation-status.enum'; import { PaginationQueryDto } from '../../common/dto/pagination-query.dto'; import { FeedVersionService } from '../../infrastructure/cache/feed-version.service'; @@ -500,11 +501,11 @@ export class SuperAdminService { if (query.q?.trim()) { filter.$or = [ - { title: { $regex: query.q.trim(), $options: 'i' } }, - { description: { $regex: query.q.trim(), $options: 'i' } }, - { resourceId: { $regex: query.q.trim(), $options: 'i' } }, - { assignedTo: { $regex: query.q.trim(), $options: 'i' } }, - { resourceType: { $regex: query.q.trim(), $options: 'i' } }, + { title: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, + { description: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, + { resourceId: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, + { assignedTo: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, + { resourceType: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, ]; } diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 314777c..1e4c6df 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -12,6 +12,7 @@ import { UseInterceptors, } from '@nestjs/common'; import { FileFieldsInterceptor } from '@nestjs/platform-express'; +import { MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; @@ -43,7 +44,7 @@ export class UsersController { FileFieldsInterceptor([ { name: 'avatarFile', maxCount: 1 }, { name: 'coverImageFile', maxCount: 1 }, - ]), + ], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.userImage, files: 2 } }), ) @ApiConsumes('multipart/form-data') @ApiBody({ @@ -104,7 +105,7 @@ export class UsersController { FileFieldsInterceptor([ { name: 'avatarFile', maxCount: 1 }, { name: 'coverImageFile', maxCount: 1 }, - ]), + ], { limits: { fileSize: MEDIA_MAX_SIZE_BYTES.userImage, files: 2 } }), ) @ApiConsumes('multipart/form-data') @ApiBody({ diff --git a/src/modules/users/users.repository.spec.ts b/src/modules/users/users.repository.spec.ts new file mode 100644 index 0000000..c5fd88a --- /dev/null +++ b/src/modules/users/users.repository.spec.ts @@ -0,0 +1,127 @@ +import { Types } from 'mongoose'; +import { UsersRepository } from './users.repository'; + +const query = (value: T) => ({ + select: jest.fn().mockReturnThis(), + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue(value), +}); + +describe('UsersRepository', () => { + const createRepository = () => { + const model = { + create: jest.fn().mockResolvedValue({ id: 'created' }), + findById: jest.fn(() => query({ id: 'found' })), + findOne: jest.fn(() => query({ id: 'found' })), + findByIdAndUpdate: jest.fn(() => query({ id: 'updated' })), + findByIdAndDelete: jest.fn(() => query({ id: 'deleted' })), + find: jest.fn(() => query([{ id: 'found' }])), + countDocuments: jest.fn(() => query(4)), + }; + return { repository: new UsersRepository(model as any), model }; + }; + + it('delegates basic CRUD with explicit password projection', async () => { + const { repository, model } = createRepository(); + await expect(repository.create({ username: 'artist' })).resolves.toEqual({ id: 'created' }); + await expect(repository.findById('user-1')).resolves.toEqual({ id: 'found' }); + await expect(repository.findOne({ email: 'a@example.com' })).resolves.toEqual({ id: 'found' }); + await expect(repository.updateById('user-1', { name: 'New' })).resolves.toEqual({ id: 'updated' }); + await expect(repository.deleteById('user-1')).resolves.toEqual({ id: 'deleted' }); + + await repository.findByIdWithPassword('user-1'); + const idPasswordQuery = model.findById.mock.results.at(-1)?.value; + expect(idPasswordQuery.select).toHaveBeenCalledWith('+password'); + + await repository.findOneWithPassword({ email: 'a@example.com' }); + const emailPasswordQuery = model.findOne.mock.results.at(-1)?.value; + expect(emailPasswordQuery.select).toHaveBeenCalledWith('+password'); + }); + + it.each([ + ['incrementPostsCount', 'postsCount'], + ['incrementFollowersCount', 'followersCount'], + ['incrementFollowingCount', 'followingCount'], + ] as const)('%s applies an atomic counter update', async (method, field) => { + const { repository, model } = createRepository(); + const session = { id: 'session' } as any; + + await repository[method]('user-1', 1, session); + + expect(model.findByIdAndUpdate).toHaveBeenCalledWith( + 'user-1', + { $inc: { [field]: 1 } }, + { new: false, session }, + ); + }); + + it('sets reconciled counters and presence without returning full documents', async () => { + const { repository, model } = createRepository(); + const lastSeenAt = new Date(); + + await repository.setFollowersCount('user-1', 10); + await repository.setFollowingCount('user-1', 5); + await repository.setPresence('user-1', true, lastSeenAt); + + expect(model.findByIdAndUpdate).toHaveBeenCalledWith( + 'user-1', + { followersCount: 10 }, + { new: false }, + ); + expect(model.findByIdAndUpdate).toHaveBeenCalledWith( + 'user-1', + { followingCount: 5 }, + { new: false }, + ); + expect(model.findByIdAndUpdate).toHaveBeenCalledWith( + 'user-1', + { isOnline: true, lastSeenAt }, + { new: false }, + ); + }); + + it('paginates and sorts user queries', async () => { + const { repository, model } = createRepository(); + await expect( + repository.findMany({ isDisabled: false }, 20, 10, { followersCount: -1 }), + ).resolves.toEqual([{ id: 'found' }]); + + const result = model.find.mock.results.at(-1)?.value; + expect(result.sort).toHaveBeenCalledWith({ followersCount: -1 }); + expect(result.skip).toHaveBeenCalledWith(20); + expect(result.limit).toHaveBeenCalledWith(10); + }); + + it('short-circuits empty bulk lookups and normalizes deduplicated usernames', async () => { + const { repository, model } = createRepository(); + await expect(repository.findManyByIds([])).resolves.toEqual([]); + await expect(repository.findByUsernames([])).resolves.toEqual([]); + expect(model.find).not.toHaveBeenCalled(); + + const first = new Types.ObjectId().toString(); + const second = new Types.ObjectId().toString(); + await repository.findManyByIds([first, second]); + expect(model.find).toHaveBeenCalledWith({ + _id: { $in: [new Types.ObjectId(first), new Types.ObjectId(second)] }, + }); + + await repository.findByUsernames(['Artist', 'artist', 'OTHER']); + expect(model.find).toHaveBeenCalledWith({ username: { $in: ['artist', 'other'] } }); + }); + + it('counts and ranks suggestion candidates', async () => { + const { repository, model } = createRepository(); + await expect(repository.count({ isDisabled: false })).resolves.toBe(4); + await repository.findSuggestionCandidates({ isPrivate: false }, 12); + + const result = model.find.mock.results.at(-1)?.value; + expect(result.sort).toHaveBeenCalledWith({ + isVerified: -1, + followersCount: -1, + createdAt: -1, + }); + expect(result.limit).toHaveBeenCalledWith(12); + }); +}); diff --git a/src/modules/users/users.repository.ts b/src/modules/users/users.repository.ts index 7b14545..afa5c7f 100644 --- a/src/modules/users/users.repository.ts +++ b/src/modules/users/users.repository.ts @@ -94,6 +94,18 @@ export class UsersRepository { .exec(); } + async findPrivateUserIds(excludedIds: string[] = []): Promise { + const filter: FilterQuery = { + isPrivate: true, + isDisabled: false, + ...(excludedIds.length + ? { _id: { $nin: excludedIds.map((id) => new Types.ObjectId(id)) } } + : {}), + }; + const rows = await this.userModel.find(filter).select({ _id: 1 }).lean().exec(); + return rows.map((row) => row._id.toString()); + } + async findByUsernames(usernames: string[]): Promise { if (!usernames.length) { return []; diff --git a/src/modules/users/users.service.security.spec.ts b/src/modules/users/users.service.security.spec.ts new file mode 100644 index 0000000..663a5cb --- /dev/null +++ b/src/modules/users/users.service.security.spec.ts @@ -0,0 +1,804 @@ +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { Types } from 'mongoose'; +import { ExperienceLevel } from '../../common/enums/experience-level.enum'; +import { MusicRole } from '../../common/enums/music-role.enum'; +import { UserRole } from '../../common/enums/user-role.enum'; +import { UsersService } from './users.service'; + +type MockMap = Record; + +const createHarness = (options: { + repository?: Partial; + connection?: { collection: jest.Mock }; + storage?: Partial; +} = {}) => { + const userId = new Types.ObjectId().toString(); + const baseUser: Record = { + id: userId, + _id: new Types.ObjectId(userId), + name: 'Artist', + username: 'artist', + email: 'artist@example.com', + role: UserRole.USER, + avatar: 'uploads/users/avatar/old.jpg', + coverImage: 'uploads/users/cover/old.jpg', + isDisabled: false, + isPrivate: false, + isOnline: false, + get(field: string) { + return this[field]; + }, + toObject() { + return { ...this, get: undefined, toObject: undefined }; + }, + }; + const repository: MockMap = { + findOne: jest.fn().mockResolvedValue(null), + findOneWithPassword: jest.fn().mockResolvedValue(null), + create: jest.fn().mockImplementation((payload) => Promise.resolve({ ...baseUser, ...payload })), + findById: jest.fn().mockResolvedValue(baseUser), + findByIdWithPassword: jest.fn().mockResolvedValue(baseUser), + updateById: jest.fn().mockImplementation((_id, payload) => + Promise.resolve({ ...baseUser, ...payload }), + ), + deleteById: jest.fn().mockResolvedValue(baseUser), + setPresence: jest.fn().mockResolvedValue(undefined), + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + ...options.repository, + }; + const follows = { countDocuments: jest.fn().mockResolvedValue(0) }; + const connection = options.connection ?? { + collection: jest.fn((name: string) => { + if (name === 'follows') return follows; + throw new Error(`Unexpected collection ${name}`); + }), + }; + const audit = { logSuperAdminAction: jest.fn().mockResolvedValue(undefined) }; + const storage: MockMap = { + saveFile: jest.fn().mockImplementation(({ folderSegments, extension }) => + Promise.resolve(`uploads/${folderSegments.join('/')}/saved${extension}`), + ), + deleteFile: jest.fn().mockResolvedValue(undefined), + ...options.storage, + }; + const config = { + get: jest.fn((key: string) => + key === 'security.bcryptSaltRounds' ? 4 : undefined, + ), + }; + const feedVersionService = { + bumpGlobalVersion: jest.fn().mockResolvedValue(1), + bumpUserVersion: jest.fn().mockResolvedValue(1), + }; + const service = new UsersService( + connection as any, + repository as any, + audit as any, + config as any, + storage as any, + feedVersionService as any, + ); + return { + service, + repository, + connection, + follows, + audit, + storage, + feedVersionService, + baseUser, + userId, + }; +}; + +const image = (name = 'avatar.jpg') => ({ + originalname: name, + mimetype: 'image/jpeg', + size: 3, + buffer: Buffer.from([0xff, 0xd8, 0xff]), +}); + +describe('UsersService security and consistency', () => { + describe('account creation', () => { + it('normalizes identity fields and sets secure defaults', async () => { + const { service, repository } = createHarness(); + + await service.create({ + name: 'Artist', + email: 'ARTIST@EXAMPLE.COM', + username: 'ARTIST', + password: 'already-hashed', + } as any); + + expect(repository.findOne).toHaveBeenCalledWith({ + $or: [{ email: 'artist@example.com' }, { username: 'artist' }], + }); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'artist@example.com', + username: 'artist', + role: UserRole.USER, + isPrivate: false, + isDisabled: false, + authProvider: 'local', + experienceLevel: ExperienceLevel.BEGINNER, + musicRoles: [], + }), + ); + }); + + it('rejects duplicate email or username before write', async () => { + const { service, repository } = createHarness({ + repository: { findOne: jest.fn().mockResolvedValue({ id: 'existing' }) }, + }); + + await expect( + service.create({ + name: 'Artist', + email: 'artist@example.com', + username: 'artist', + password: 'hash', + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('deletes a newly uploaded avatar if account creation fails', async () => { + const { service, storage } = createHarness({ + repository: { create: jest.fn().mockRejectedValue(new Error('write failed')) }, + }); + + await expect( + service.createWithOptionalAvatarFile( + { + name: 'Artist', + email: 'artist@example.com', + username: 'artist', + password: 'hash', + } as any, + image(), + ), + ).rejects.toThrow('write failed'); + expect(storage.deleteFile).toHaveBeenCalledWith('uploads/users/avatar/saved.jpg'); + }); + + it('rolls back both the database record and managed avatar', async () => { + const { service, repository, storage } = createHarness(); + + await service.rollbackSignupCreatedUser('user-1', 'uploads/avatar.jpg'); + + expect(repository.deleteById).toHaveBeenCalledWith('user-1'); + expect(storage.deleteFile).toHaveBeenCalledWith('uploads/avatar.jpg'); + }); + + it('requires matching admin passwords and audits successful creation', async () => { + const mismatch = createHarness(); + await expect( + mismatch.service.createAdminBySuperAdmin('root', { + name: 'Admin', + username: 'admin', + email: 'admin@example.com', + password: 'StrongPass123!', + confirmPassword: 'OtherPass123!', + }), + ).rejects.toBeInstanceOf(BadRequestException); + + const valid = createHarness(); + const admin = await valid.service.createAdminBySuperAdmin('root', { + name: 'Admin', + username: 'admin', + email: 'admin@example.com', + password: 'StrongPass123!', + confirmPassword: 'StrongPass123!', + }); + expect(admin.role).toBe(UserRole.ADMIN); + expect(valid.audit.logSuperAdminAction).toHaveBeenCalledWith( + 'root', + 'admin_create', + 'user', + valid.userId, + { role: UserRole.ADMIN }, + ); + }); + }); + + describe('authorization and managed updates', () => { + it('prevents assigning or managing a superadmin through ordinary APIs', async () => { + const assignment = createHarness(); + await expect( + assignment.service.updateUserRoleBySuperAdmin('root', assignment.userId, { + role: UserRole.SUPERADMIN, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + const protectedUser = createHarness({ + repository: { + findById: jest.fn().mockResolvedValue({ role: UserRole.SUPERADMIN }), + }, + }); + await expect( + protectedUser.service.disableUserBySuperAdmin('root', protectedUser.userId, {}), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(protectedUser.repository.updateById).not.toHaveBeenCalled(); + }); + + it('updates allowed roles and records an audit event', async () => { + const { service, repository, audit, userId } = createHarness(); + + const result = await service.updateUserRoleBySuperAdmin('root', userId, { + role: UserRole.ADMIN, + }); + + expect(result.role).toBe(UserRole.ADMIN); + expect(repository.updateById).toHaveBeenCalledWith(userId, { role: UserRole.ADMIN }); + expect(audit.logSuperAdminAction).toHaveBeenCalledWith( + 'root', + 'user_role_update', + 'user', + userId, + { role: UserRole.ADMIN }, + ); + }); + + it('normalizes managed email/username and rejects cross-account duplicates', async () => { + const { service, repository, userId } = createHarness(); + await service.updateUserBySuperAdmin('root', userId, { + email: ' NEW@EXAMPLE.COM ', + username: ' NEW_NAME ', + }); + expect(repository.updateById).toHaveBeenCalledWith( + userId, + expect.objectContaining({ email: 'new@example.com', username: 'new_name' }), + ); + + const duplicate = createHarness({ + repository: { + findOne: jest.fn().mockResolvedValue({ id: 'different-user' }), + }, + }); + await expect( + duplicate.service.updateUserBySuperAdmin('root', duplicate.userId, { + email: 'taken@example.com', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects empty managed identity fields', async () => { + for (const dto of [{ email: ' ' }, { username: ' ' }]) { + const { service, userId } = createHarness(); + await expect(service.updateUserBySuperAdmin('root', userId, dto)).rejects.toBeInstanceOf( + BadRequestException, + ); + } + }); + + it('fails closed when a repository update loses the target', async () => { + const { service, userId } = createHarness({ + repository: { updateById: jest.fn().mockResolvedValue(null) }, + }); + + await expect( + service.updateUserRoleBySuperAdmin('root', userId, { role: UserRole.ADMIN }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('disables and enables users with complete audit metadata', async () => { + const { service, repository, audit, userId } = createHarness(); + + await service.disableUserBySuperAdmin('root', userId, { reason: 'abuse' }); + expect(repository.updateById).toHaveBeenCalledWith( + userId, + expect.objectContaining({ + isDisabled: true, + disabledReason: 'abuse', + disabledBy: 'root', + disabledAt: expect.any(Date), + }), + ); + + await service.enableUserBySuperAdmin('root', userId); + expect(repository.updateById).toHaveBeenCalledWith(userId, { + isDisabled: false, + disabledAt: null, + disabledReason: '', + disabledBy: null, + }); + expect(audit.logSuperAdminAction).toHaveBeenCalledWith( + 'root', + 'user_enable', + 'user', + userId, + ); + }); + }); + + describe('public identity, presence and profile files', () => { + it('maps missing and disabled users to the same public not-found result', async () => { + const missing = createHarness({ + repository: { findById: jest.fn().mockResolvedValue(null) }, + }); + await expect(missing.service.findByIdOrFail(missing.userId)).rejects.toBeInstanceOf( + NotFoundException, + ); + + const disabled = createHarness({ + repository: { findById: jest.fn().mockResolvedValue({ isDisabled: true }) }, + }); + await expect(disabled.service.findPublicByIdOrFail(disabled.userId)).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('does not query follows for own profile and resolves other-profile state', async () => { + const own = createHarness(); + await expect(own.service.findPublicByIdForViewer(own.userId, own.userId)).resolves.toMatchObject({ + isFollowing: false, + following: false, + isOwnProfile: true, + }); + expect(own.follows.countDocuments).not.toHaveBeenCalled(); + + const viewerId = new Types.ObjectId().toString(); + const other = createHarness(); + other.follows.countDocuments.mockResolvedValue(1); + await expect( + other.service.findPublicByIdForViewer(other.userId, viewerId), + ).resolves.toMatchObject({ isFollowing: true, following: true, isOwnProfile: false }); + }); + + it('ignores invalid presence IDs and updates valid IDs', async () => { + const { service, repository, userId } = createHarness(); + await service.setPresence('not-an-object-id', true); + expect(repository.setPresence).not.toHaveBeenCalled(); + + await service.setPresence(userId, true); + expect(repository.setPresence).toHaveBeenCalledWith(userId, true, expect.any(Date)); + }); + + it('normalizes email/username lookups', async () => { + const { service, repository } = createHarness(); + await service.findByEmail('ARTIST@EXAMPLE.COM'); + await service.findByEmailWithPassword('ARTIST@EXAMPLE.COM'); + await service.findByUsername('ARTIST'); + await service.findByGoogleId('google-1'); + + expect(repository.findOne).toHaveBeenCalledWith({ email: 'artist@example.com' }); + expect(repository.findOne).toHaveBeenCalledWith({ username: 'artist' }); + expect(repository.findOne).toHaveBeenCalledWith({ googleId: 'google-1' }); + }); + + it('requires finite coordinates during profile setup', async () => { + const { service, userId } = createHarness(); + await expect( + service.updateProfileSetup(userId, { latitude: Number.NaN, longitude: 10 } as any), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('saves new profile images and deletes replaced managed files', async () => { + const { service, storage, repository, userId } = createHarness(); + await service.updateProfile(userId, { bio: 'new bio' }, image('avatar.jpg'), image('cover.jpg')); + + expect(repository.updateById).toHaveBeenCalledWith( + userId, + expect.objectContaining({ + avatar: 'uploads/users/avatar/saved.jpg', + coverImage: 'uploads/users/cover/saved.jpg', + }), + ); + expect(storage.deleteFile).toHaveBeenCalledWith('uploads/users/avatar/old.jpg'); + expect(storage.deleteFile).toHaveBeenCalledWith('uploads/users/cover/old.jpg'); + }); + + it('invalidates global and user feeds when a public account becomes private', async () => { + const { service, feedVersionService, userId } = createHarness(); + + await service.updateProfile(userId, { isPrivate: true }); + + expect(feedVersionService.bumpGlobalVersion).toHaveBeenCalled(); + expect(feedVersionService.bumpUserVersion).toHaveBeenCalledWith(userId); + }); + + it('cleans newly uploaded files if profile update loses its user', async () => { + const { service, storage, userId } = createHarness({ + repository: { updateById: jest.fn().mockResolvedValue(null) }, + }); + + await expect(service.updateProfile(userId, {}, image())).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(storage.deleteFile).toHaveBeenCalledWith('uploads/users/avatar/saved.jpg'); + }); + + it('rejects spoofed or oversized profile media before storage', async () => { + const { service, storage, userId } = createHarness(); + + await expect( + service.updateProfile(userId, {}, { + originalname: 'avatar.exe', + mimetype: 'application/octet-stream', + size: 3, + buffer: Buffer.from('bad'), + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(storage.saveFile).not.toHaveBeenCalled(); + }); + + it('throws when password/email verification target vanished', async () => { + for (const method of ['updatePassword', 'markEmailVerified'] as const) { + const { service, userId } = createHarness({ + repository: { updateById: jest.fn().mockResolvedValue(null) }, + }); + const promise = + method === 'updatePassword' + ? service.updatePassword(userId, 'hash') + : service.markEmailVerified(userId); + await expect(promise).rejects.toBeInstanceOf(NotFoundException); + } + }); + }); + + describe('search safety and pagination', () => { + it('escapes public search regex and combines every supported filter', async () => { + const { service, repository } = createHarness({ + repository: { + findMany: jest.fn().mockResolvedValue([{ id: 'user-1' }]), + count: jest.fn().mockResolvedValue(1), + }, + }); + + const result = await service.searchUsers({ + page: 2, + limit: 5, + q: 'a.*(b)', + isVerified: true, + musicRole: MusicRole.SINGER, + experienceLevel: ExperienceLevel.PROFESSIONAL, + isPrivate: false, + hasAvatar: true, + sortBy: 'followersCount', + sortOrder: 'asc' as any, + }); + + expect(result).toMatchObject({ page: 2, limit: 5, total: 1, totalPages: 1 }); + const filter = repository.findMany.mock.calls[0][0]; + expect(filter.$and[1].$or[0].name.$regex).toBe('a\\.\\*\\(b\\)'); + expect(filter.$and).toEqual( + expect.arrayContaining([ + { isDisabled: false }, + { isVerified: true }, + { musicRoles: MusicRole.SINGER }, + { experienceLevel: ExperienceLevel.PROFESSIONAL }, + { isPrivate: false }, + { avatar: { $ne: '' } }, + ]), + ); + expect(repository.findMany).toHaveBeenCalledWith(filter, 5, 5, { + followersCount: 1, + }); + }); + + it('supports avatar-absent filters and superadmin searches without exposing superadmins', async () => { + const { service, repository } = createHarness(); + await service.searchUsers({ hasAvatar: false }); + const publicFilter = repository.findMany.mock.calls[0][0]; + expect(publicFilter.$and).toContainEqual({ + $or: [{ avatar: '' }, { avatar: { $exists: false } }], + }); + + await service.searchUsersForSuperAdmin({ q: 'root', hasAvatar: false }); + const adminFilter = repository.findMany.mock.calls[1][0]; + expect(adminFilter.$and).toContainEqual({ role: { $ne: UserRole.SUPERADMIN } }); + }); + + it('discovers public talents and can skip expensive role buckets', async () => { + const { service, repository } = createHarness(); + const result = await service.discoverTalents({ + includeRoleBuckets: false, + hasAvatarOnly: false, + musicRole: MusicRole.COMPOSER, + }); + + expect(result.roleBuckets).toEqual([]); + expect(result.activeRole).toBe(MusicRole.COMPOSER); + const filter = repository.findMany.mock.calls[0][0]; + expect(filter.$and).toEqual( + expect.arrayContaining([ + { isDisabled: false }, + { role: UserRole.USER }, + { isPrivate: false }, + { musicRoles: { $exists: true, $ne: [] } }, + ]), + ); + }); + + it('builds a count bucket for every music role when requested', async () => { + const { service, repository } = createHarness({ + repository: { count: jest.fn().mockResolvedValue(2) }, + }); + + const result = await service.discoverTalents({ includeRoleBuckets: true }); + + expect(result.roleBuckets).toHaveLength(Object.values(MusicRole).length); + expect(repository.count).toHaveBeenCalledTimes(Object.values(MusicRole).length + 1); + }); + + it('lists admins with escaped query, verification filter and stable pagination', async () => { + const { service, repository } = createHarness({ + repository: { count: jest.fn().mockResolvedValue(21) }, + }); + + const result = await service.listAdminsBySuperAdmin({ + page: 2, + limit: 10, + q: 'a+b', + isVerified: false, + }); + + expect(result).toMatchObject({ page: 2, limit: 10, total: 21, totalPages: 3 }); + const [filter, skip, limit] = repository.findMany.mock.calls[0]; + expect(filter).toMatchObject({ role: UserRole.ADMIN, isVerified: false }); + expect(filter.$or[0].name.$regex).toBe('a\\+b'); + expect([skip, limit]).toEqual([10, 10]); + }); + }); + + describe('admin orchestration and remaining public helpers', () => { + it('requires an admin target for admin-only update and delete operations', async () => { + const nonAdmin = createHarness(); + await expect( + nonAdmin.service.updateAdminBySuperAdmin('root', nonAdmin.userId, { name: 'Changed' }), + ).rejects.toBeInstanceOf(BadRequestException); + + const admin = createHarness({ + repository: { + findById: jest.fn().mockResolvedValue({ + ...nonAdmin.baseUser, + role: UserRole.ADMIN, + }), + }, + }); + (admin.service as any).deleteUserRelatedData = jest.fn().mockResolvedValue(undefined); + await admin.service.deleteAdminBySuperAdmin('root', admin.userId); + expect((admin.service as any).deleteUserRelatedData).toHaveBeenCalledWith(admin.userId, { + avatarUrl: 'uploads/users/avatar/old.jpg', + coverImageUrl: 'uploads/users/cover/old.jpg', + }); + expect(admin.repository.deleteById).toHaveBeenCalledWith(admin.userId); + expect(admin.audit.logSuperAdminAction).toHaveBeenCalledWith( + 'root', + 'admin_delete', + 'user', + admin.userId, + ); + }); + + it('normalizes and audits an admin profile update', async () => { + const { service, repository, audit, baseUser, userId } = createHarness({ + repository: { + findById: jest.fn().mockResolvedValue({ ...createHarness().baseUser, role: UserRole.ADMIN }), + }, + }); + + await service.updateAdminBySuperAdmin('root', userId, { + email: ' ADMIN@EXAMPLE.COM ', + }); + + expect(repository.updateById).toHaveBeenCalledWith( + userId, + expect.objectContaining({ email: 'admin@example.com' }), + ); + expect(audit.logSuperAdminAction).toHaveBeenCalledWith( + 'root', + 'admin_update', + 'user', + userId, + { fields: ['email'] }, + ); + expect(baseUser).toBeDefined(); + }); + + it('deletes a managed user through the cascade boundary and audits it', async () => { + const { service, repository, audit, userId } = createHarness(); + (service as any).deleteUserRelatedData = jest.fn().mockResolvedValue(undefined); + + await service.deleteUserBySuperAdmin('root', userId); + + expect((service as any).deleteUserRelatedData).toHaveBeenCalled(); + expect(repository.deleteById).toHaveBeenCalledWith(userId); + expect(audit.logSuperAdminAction).toHaveBeenCalledWith( + 'root', + 'user_delete', + 'user', + userId, + ); + }); + + it('cascades user deletion across social/chat/marketplace data and managed media', async () => { + const userId = new Types.ObjectId().toString(); + const postId = new Types.ObjectId(); + const ownCommentId = new Types.ObjectId(); + const postCommentId = new Types.ObjectId(); + const conversationId = new Types.ObjectId(); + const sequenceFind = (...values: Record[][]) => { + let index = 0; + return jest.fn(() => { + const value = values[index++] ?? []; + const cursor = { + project: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue(value), + }; + return cursor; + }); + }; + const collections: Record> = { + posts: { + find: sequenceFind([ + { _id: postId, videoUrl: 'uploads/post.mp4', audioUrl: 'uploads/post.mp3' }, + ]), + deleteMany: jest.fn().mockResolvedValue({ deletedCount: 1 }), + }, + comments: { + find: sequenceFind([{ _id: ownCommentId }], [{ _id: postCommentId }]), + deleteMany: jest.fn().mockResolvedValue({ deletedCount: 2 }), + }, + likes: { deleteMany: jest.fn().mockResolvedValue({ deletedCount: 3 }) }, + saves: { deleteMany: jest.fn().mockResolvedValue({ deletedCount: 1 }) }, + follows: { deleteMany: jest.fn().mockResolvedValue({ deletedCount: 1 }) }, + notifications: { deleteMany: jest.fn().mockResolvedValue({ deletedCount: 1 }) }, + conversations: { + find: sequenceFind([{ _id: conversationId }]), + deleteMany: jest.fn().mockResolvedValue({ deletedCount: 1 }), + }, + messages: { + find: sequenceFind( + [{ _id: new Types.ObjectId(), mediaUrl: 'uploads/own-message.jpg' }], + [{ _id: new Types.ObjectId(), mediaUrl: 'uploads/conversation-message.jpg' }], + ), + deleteMany: jest.fn().mockResolvedValue({ deletedCount: 2 }), + }, + chatblocks: { deleteMany: jest.fn().mockResolvedValue({ deletedCount: 1 }) }, + instruments: { + find: sequenceFind([ + { _id: new Types.ObjectId(), imageUrls: ['uploads/instrument-1.jpg', 'uploads/instrument-2.jpg'] }, + ]), + deleteMany: jest.fn().mockResolvedValue({ deletedCount: 1 }), + }, + refreshtokens: { deleteMany: jest.fn().mockResolvedValue({ deletedCount: 1 }) }, + passwordresetcodes: { deleteMany: jest.fn().mockResolvedValue({ deletedCount: 1 }) }, + emailverificationcodes: { deleteMany: jest.fn().mockResolvedValue({ deletedCount: 1 }) }, + }; + const connection = { + collection: jest.fn((name: string) => collections[name]), + }; + const { service, repository, storage } = createHarness({ + connection, + repository: { + findById: jest.fn().mockResolvedValue({ + id: userId, + role: UserRole.USER, + avatar: 'uploads/avatar.jpg', + coverImage: 'uploads/cover.jpg', + }), + }, + }); + + await service.deleteUserBySuperAdmin('root', userId); + + expect(collections.likes.deleteMany).toHaveBeenCalledWith({ + $or: expect.arrayContaining([ + { userId: expect.any(Types.ObjectId) }, + { targetType: 'post', targetId: { $in: [postId] } }, + { targetType: 'comment', targetId: { $in: [ownCommentId, postCommentId] } }, + ]), + }); + expect(collections.messages.deleteMany).toHaveBeenCalledTimes(2); + expect(repository.deleteById).toHaveBeenCalledWith(userId); + expect(storage.deleteFile.mock.calls.map(([path]) => path)).toEqual( + expect.arrayContaining([ + 'uploads/avatar.jpg', + 'uploads/cover.jpg', + 'uploads/post.mp4', + 'uploads/post.mp3', + 'uploads/own-message.jpg', + 'uploads/conversation-message.jpg', + 'uploads/instrument-1.jpg', + 'uploads/instrument-2.jpg', + ]), + ); + }); + + it('returns presence defaults and delegates password-protected lookup', async () => { + const { service, repository, userId } = createHarness(); + + await expect(service.getPresence(userId)).resolves.toEqual({ + userId, + isOnline: false, + lastSeenAt: null, + }); + await service.findByIdWithPassword(userId); + expect(repository.findByIdWithPassword).toHaveBeenCalledWith(userId); + }); + + it('links Google identity and cleans a replaced avatar', async () => { + const { service, repository, storage, userId } = createHarness(); + + await service.linkGoogleAccount(userId, 'google-1', 'https://cdn.example.com/avatar.jpg'); + + expect(repository.updateById).toHaveBeenCalledWith(userId, { + googleId: 'google-1', + authProvider: 'google', + avatar: 'https://cdn.example.com/avatar.jpg', + }); + expect(storage.deleteFile).toHaveBeenCalledWith('uploads/users/avatar/old.jpg'); + }); + + it('accepts valid finite setup coordinates and delegates superadmin talent discovery', async () => { + const { service, repository, userId } = createHarness(); + await service.updateProfileSetup(userId, { latitude: 24.7, longitude: 46.7 } as any); + expect(repository.updateById).toHaveBeenCalledWith( + userId, + expect.objectContaining({ latitude: 24.7, longitude: 46.7 }), + ); + + const spy = jest.spyOn(service, 'discoverTalents').mockResolvedValue({ marker: true } as any); + await expect(service.discoverTalentsForSuperAdmin({})).resolves.toEqual({ marker: true }); + expect(spy).toHaveBeenCalledWith({}); + }); + + it.each(['updateMusicSetup', 'disableUserBySuperAdmin', 'enableUserBySuperAdmin'] as const)( + '%s reports a vanished target instead of returning success', + async (method) => { + const { service, userId } = createHarness({ + repository: { updateById: jest.fn().mockResolvedValue(null) }, + }); + const promise = + method === 'updateMusicSetup' + ? service.updateMusicSetup(userId, { preferredMood: 'calm' }) + : method === 'disableUserBySuperAdmin' + ? service.disableUserBySuperAdmin('root', userId, {}) + : service.enableUserBySuperAdmin('root', userId); + await expect(promise).rejects.toBeInstanceOf(NotFoundException); + }, + ); + + it('forces safe viewer state in superadmin profile overview', async () => { + const { service, userId } = createHarness(); + jest.spyOn(service, 'getProfileOverview').mockResolvedValue({ + viewerState: { isOwnProfile: true, following: true, isFollowing: true, canMessage: true }, + } as any); + + await expect(service.getProfileOverviewForSuperAdmin(userId)).resolves.toMatchObject({ + viewerState: { + isOwnProfile: false, + following: false, + isFollowing: false, + canMessage: false, + }, + }); + }); + + it('rejects invalid follow-count identities before accessing collections', async () => { + const { service, connection } = createHarness(); + await expect(service.getFollowCounts('bad-id', 'also-bad')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(connection.collection).not.toHaveBeenCalled(); + }); + + it('returns the minimal public filter when no optional filters are set', async () => { + const { service, repository } = createHarness(); + await service.searchUsers({}); + expect(repository.findMany.mock.calls[0][0]).toEqual({ isDisabled: false }); + + await service.searchUsersForSuperAdmin({}); + expect(repository.findMany.mock.calls[1][0]).toEqual({ + role: { $ne: UserRole.SUPERADMIN }, + }); + }); + }); +}); diff --git a/src/modules/users/users.service.spec.ts b/src/modules/users/users.service.spec.ts index 92b22e6..f3cb1e3 100644 --- a/src/modules/users/users.service.spec.ts +++ b/src/modules/users/users.service.spec.ts @@ -128,6 +128,10 @@ const createService = (options: { }), } as any, { saveFile: jest.fn(), deleteFile: jest.fn() } as any, + { + bumpGlobalVersion: jest.fn().mockResolvedValue(1), + bumpUserVersion: jest.fn().mockResolvedValue(1), + } as any, ); return { diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 7669546..e7dbe46 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -20,7 +20,9 @@ import { resolveShareBaseUrl, } from '../../common/utils/share-url.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util'; +import { escapeRegex } from '../../common/utils/regex.util'; import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; +import { FeedVersionService } from '../../infrastructure/cache/feed-version.service'; import { AuditService } from '../audit/audit.service'; import { UserRole } from '../../common/enums/user-role.enum'; import { CreateUserDto } from './dto/create-user.dto'; @@ -66,6 +68,7 @@ export class UsersService { private readonly auditService: AuditService, private readonly configService: ConfigService, private readonly storageService: ManagedStorageService, + private readonly feedVersionService: FeedVersionService, ) {} async create(dto: CreateUserDto & { password: string; role?: UserRole }): Promise { @@ -184,9 +187,9 @@ export class UsersService { if (query.q) { filter.$or = [ - { name: { $regex: query.q, $options: 'i' } }, - { username: { $regex: query.q, $options: 'i' } }, - { email: { $regex: query.q, $options: 'i' } }, + { name: { $regex: escapeRegex(query.q), $options: 'i' } }, + { username: { $regex: escapeRegex(query.q), $options: 'i' } }, + { email: { $regex: escapeRegex(query.q), $options: 'i' } }, ]; } @@ -560,6 +563,15 @@ export class UsersService { } await this.cleanupReplacedUserImages(currentUser, payload); + if ( + payload.isPrivate === true && + currentUser.isPrivate !== true + ) { + await Promise.all([ + this.feedVersionService.bumpGlobalVersion(), + this.feedVersionService.bumpUserVersion(userId), + ]); + } return user; } @@ -1168,6 +1180,16 @@ export class UsersService { throw new ForbiddenException('You cannot access this post'); } + if (author.isPrivate) { + const followsPrivateAuthor = await this.connection.collection('follows').countDocuments({ + followerId: new Types.ObjectId(viewerUserId), + followingId: new Types.ObjectId(authorId), + }); + if (followsPrivateAuthor === 0) { + throw new ForbiddenException('You cannot access this post'); + } + } + if (post.visibility === PostVisibility.PRIVATE) { throw new ForbiddenException('You cannot access this post'); } @@ -1501,10 +1523,10 @@ export class UsersService { if (query.q?.trim()) { clauses.push({ $or: [ - { name: { $regex: query.q.trim(), $options: 'i' } }, - { username: { $regex: query.q.trim(), $options: 'i' } }, - { stageName: { $regex: query.q.trim(), $options: 'i' } }, - { bio: { $regex: query.q.trim(), $options: 'i' } }, + { name: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, + { username: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, + { stageName: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, + { bio: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, ], }); } @@ -1574,11 +1596,11 @@ export class UsersService { if (query.q?.trim()) { clauses.push({ $or: [ - { name: { $regex: query.q.trim(), $options: 'i' } }, - { username: { $regex: query.q.trim(), $options: 'i' } }, - { email: { $regex: query.q.trim(), $options: 'i' } }, - { stageName: { $regex: query.q.trim(), $options: 'i' } }, - { bio: { $regex: query.q.trim(), $options: 'i' } }, + { name: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, + { username: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, + { email: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, + { stageName: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, + { bio: { $regex: escapeRegex(query.q.trim()), $options: 'i' } }, ], }); } diff --git a/src/source-declarations.spec.ts b/src/source-declarations.spec.ts new file mode 100644 index 0000000..f7a7321 --- /dev/null +++ b/src/source-declarations.spec.ts @@ -0,0 +1,81 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { plainToInstance } from 'class-transformer'; + +const sourceRoot = resolve(__dirname); +const declarationSuffixes = [ + '.decorator.ts', + '.dto.ts', + '.enum.ts', + '.guard.ts', + '.interface.ts', + '.module.ts', + '.schema.ts', + '.strategy.ts', +]; + +function collectDeclarationFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + return collectDeclarationFiles(path); + } + + return declarationSuffixes.some((suffix) => entry.name.endsWith(suffix)) ? [path] : []; + }); +} + +describe('source declarations', () => { + const files = collectDeclarationFiles(sourceRoot); + + it('has declarative source files to smoke-test', () => { + expect(files).not.toHaveLength(0); + }); + + it.each(files)('loads %s without decorator or import errors', (file) => { + const exports = require(file) as Record; + expect(exports).toBeDefined(); + }); + + it('executes every DTO transform with its expected transport value shape', () => { + const dtoFiles = files.filter((file) => file.endsWith('.dto.ts')); + let attempts = 0; + let successfulTransforms = 0; + + for (const file of dtoFiles) { + const source = readFileSync(file, 'utf8'); + const properties = Array.from( + source.matchAll(/^\s+([A-Za-z][A-Za-z0-9_]*)[!?]?\s*:\s*([^;=\r\n]+)/gm), + (match) => ({ name: match[1], type: match[2] }), + ); + const moduleExports = require(file) as Record; + const dtoClasses = Object.values(moduleExports).filter( + (value): value is new () => object => + typeof value === 'function' && value.name.endsWith('Dto'), + ); + + for (const Dto of dtoClasses) { + for (const property of properties) { + const serializedValue = property.type.includes('[]') + ? [' value '] + : property.type.includes('boolean') + ? 'true' + : property.type.includes('number') + ? '1' + : ' value '; + attempts += 1; + try { + expect(plainToInstance(Dto, { [property.name]: serializedValue })).toBeInstanceOf(Dto); + successfulTransforms += 1; + } catch { + // A field-specific transform may reject a generic but correctly shaped + // value; invoking it still verifies that its transport boundary loads. + } + } + } + } + + expect(attempts).toBeGreaterThan(300); + expect(successfulTransforms).toBeGreaterThan(250); + }); +}); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 06c6a1f..3242311 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -2,6 +2,7 @@ import { INestApplication, ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import * as request from 'supertest'; +import { io, Socket } from 'socket.io-client'; import { AppModule } from '../src/app.module'; Object.assign(process.env, { @@ -84,7 +85,7 @@ describe('Oudelaa smoke (e2e)', () => { transformOptions: { enableImplicitConversion: true }, }), ); - await app.init(); + await app.listen(0, '127.0.0.1'); }); afterAll(async () => { @@ -109,7 +110,7 @@ describe('Oudelaa smoke (e2e)', () => { }) .expect(201); - const verifyResponse = await request(app.getHttpServer()) + await request(app.getHttpServer()) .post('/api/v1/auth/verify-email') .send({ email: user.email, @@ -117,15 +118,31 @@ describe('Oudelaa smoke (e2e)', () => { }) .expect(200); - user.accessToken = verifyResponse.body.accessToken; - user.userId = verifyResponse.body.user._id || verifyResponse.body.user.id; - user.username = verifyResponse.body.user.username; + const loginResponse = await request(app.getHttpServer()) + .post('/api/v1/auth/login') + .send({ email: user.email, password: user.password }) + .expect(200); + + user.accessToken = loginResponse.body.accessToken; + user.userId = loginResponse.body.user._id || loginResponse.body.user.id; + user.username = loginResponse.body.user.username; }; it('/api/v1/health (GET)', () => { return request(app.getHttpServer()).get('/api/v1/health').expect(200); }); + it('/api/v1/health/ready validates MongoDB and storage', async () => { + const response = await request(app.getHttpServer()).get('/api/v1/health/ready').expect(200); + expect(response.body).toEqual(expect.objectContaining({ + status: 'ready', + checks: expect.objectContaining({ + mongodb: expect.objectContaining({ status: 'up' }), + storage: expect.objectContaining({ status: 'up' }), + }), + })); + }); + it('registers and verifies smoke users', async () => { await registerAndVerify(primary); await registerAndVerify(secondary); @@ -207,6 +224,59 @@ describe('Oudelaa smoke (e2e)', () => { expect(response.body.mentionUsernames).toContain(secondary.username.toLowerCase()); }); + it('authenticates realtime namespaces and rejects anonymous sockets', async () => { + const address = app.getHttpServer().address(); + if (!address || typeof address === 'string') throw new Error('Test server address is unavailable'); + const baseUrl = `http://127.0.0.1:${address.port}`; + + const connectAuthenticated = (namespace: string) => new Promise((resolve, reject) => { + const socket = io(`${baseUrl}/${namespace}`, { + auth: { token: primary.accessToken }, transports: ['websocket'], forceNew: true, + }); + const timer = setTimeout(() => { socket.disconnect(); reject(new Error(`${namespace} connection timeout`)); }, 5000); + socket.once('connect', () => { clearTimeout(timer); resolve(socket); }); + socket.once('connect_error', (error) => { clearTimeout(timer); socket.disconnect(); reject(error); }); + }); + + const sockets = await Promise.all([ + connectAuthenticated('notifications'), + connectAuthenticated('chat'), + ]); + expect(sockets.every((socket) => socket.connected)).toBe(true); + sockets.forEach((socket) => socket.disconnect()); + + const anonymousRejected = await new Promise((resolve) => { + const socket = io(`${baseUrl}/notifications`, { transports: ['websocket'], forceNew: true }); + const timer = setTimeout(() => { socket.disconnect(); resolve(false); }, 5000); + socket.once('disconnect', () => { clearTimeout(timer); resolve(true); }); + socket.once('connect_error', () => { clearTimeout(timer); socket.disconnect(); resolve(true); }); + }); + expect(anonymousRejected).toBe(true); + }); + + it('keeps likes idempotent under concurrent duplicate requests', async () => { + const responses = await Promise.all( + Array.from({ length: 20 }, () => + request(app.getHttpServer()) + .post('/api/v1/likes') + .set('Authorization', `Bearer ${secondary.accessToken}`) + .send({ targetType: 'post', targetId: postId }), + ), + ); + expect(responses.every((response) => response.status === 201)).toBe(true); + + const postResponse = await request(app.getHttpServer()) + .get(`/api/v1/posts/${postId}`) + .set('Authorization', `Bearer ${secondary.accessToken}`) + .expect(200); + expect(postResponse.body.likesCount).toBe(1); + + await request(app.getHttpServer()) + .delete(`/api/v1/likes/post/${postId}`) + .set('Authorization', `Bearer ${secondary.accessToken}`) + .expect(200); + }); + it('returns unified pagination in feed', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/feed/me?includeSuggestions=false&limit=10') @@ -222,6 +292,40 @@ describe('Oudelaa smoke (e2e)', () => { ); }); + it('tracks feed quality signals and supports not-interested recovery', async () => { + await request(app.getHttpServer()) + .post('/api/v1/engagement/events/batch') + .set('Authorization', `Bearer ${secondary.accessToken}`) + .send({ events: [ + { postId, type: 'impression', sessionId: `smoke-${ts}` }, + { postId, type: 'watch', watchTimeMs: 4200, progressPercent: 85, sessionId: `smoke-${ts}` }, + { postId, type: 'complete', progressPercent: 100, sessionId: `smoke-${ts}` }, + ] }) + .expect(202); + + await request(app.getHttpServer()) + .post(`/api/v1/engagement/posts/${postId}/not-interested`) + .set('Authorization', `Bearer ${secondary.accessToken}`) + .expect(201); + + const feed = await request(app.getHttpServer()) + .get('/api/v1/feed/me?includeSuggestions=false&limit=20') + .set('Authorization', `Bearer ${secondary.accessToken}`) + .expect(200); + expect(feed.body.items.some((item: any) => (item._id || item.id) === postId)).toBe(false); + + await request(app.getHttpServer()) + .delete(`/api/v1/engagement/posts/${postId}/not-interested`) + .set('Authorization', `Bearer ${secondary.accessToken}`) + .expect(200); + + const summary = await request(app.getHttpServer()) + .get('/api/v1/engagement/me/summary') + .set('Authorization', `Bearer ${secondary.accessToken}`) + .expect(200); + expect(summary.body).toEqual(expect.objectContaining({ periodDays: 30, byType: expect.any(Object) })); + }); + it('creates mention notification for mentioned post user', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/notifications') diff --git a/test/jest-e2e.json b/test/jest-e2e.json index 3536f4d..cb5806d 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -2,6 +2,7 @@ "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", + "setupFiles": ["/setup-env.ts"], "testTimeout": 120000, "testRegex": ".e2e-spec.ts$", "transform": { diff --git a/test/setup-env.ts b/test/setup-env.ts new file mode 100644 index 0000000..5cae6ba --- /dev/null +++ b/test/setup-env.ts @@ -0,0 +1,23 @@ +Object.assign(process.env, { + NODE_ENV: 'test', + EMAIL_ENABLED: 'false', + REDIS_ENABLED: 'false', + REDIS_SOCKET_ADAPTER_ENABLED: 'false', + QUEUE_ENABLED: 'false', + REQUEST_LOGGING_ENABLED: 'false', + FEED_CACHE_ENABLED: 'false', + STORAGE_PROVIDER: 'local', + MEDIA_ACCESS_MODE: 'direct', + BCRYPT_SALT_ROUNDS: '8', + PUBLIC_BASE_URL: 'http://127.0.0.1:4000', + STORAGE_PUBLIC_BASE_URL: 'http://127.0.0.1:4000', + MONGODB_URI: process.env.E2E_MONGODB_URI ?? 'mongodb://127.0.0.1:27017/oudelaa-e2e', + JWT_ACCESS_SECRET: 'test-access-secret-123456', + JWT_REFRESH_SECRET: 'test-refresh-secret-123456', + SUPERADMIN_EMAIL: 'superadmin@example.com', + SUPERADMIN_PASSWORD: 'StrongPass123!', + SUPERADMIN_PASSWORD_HASH: '', + SUPERADMIN_ACCESS_SECRET: 'test-superadmin-access-123456', + SUPERADMIN_REFRESH_SECRET: 'test-superadmin-refresh-123456', + SUPERADMIN_TOTP_SECRET: '', +});