From 73fa2c85457f81c906ceec1342a6f00479fd7874 Mon Sep 17 00:00:00 2001 From: dubemoyibe-star Date: Tue, 21 Jul 2026 12:26:08 +0100 Subject: [PATCH 1/3] feat: add structured error logging for webhook retry exhaustion --- .github/prompts/autonomy-version.json | 2 +- .github/prompts/autonomy.manifest.json | 14 +- .kilo/kilo.json | 4 +- CONTRIBUTING.md | 58 +- docs/TEMPLATE_SCAFFOLDING.md | 36 +- docs/api-inventory.md | 10 +- docs/api-timeouts.md | 18 +- docs/api/creator-list-query-precedence.md | 42 +- docs/architecture/adding-new-creator-route.md | 211 +-- docs/architecture/ownership-read-model.md | 16 +- docs/error-response.md | 159 ++- docs/indexer/ARCHITECTURE.md | 35 +- docs/indexer/PRICE_ALERTS.md | 3 + docs/indexer/RECOVERY.md | 55 +- docs/rate-limiting.md | 37 +- docs/stellar-signature-format.md | 131 +- jest.config.js | 22 +- src/__mocks__/chalk.ts | 11 +- .../integration/creator-holders-404.test.ts | 34 +- ...tor-holders-pagination.integration.test.ts | 145 +- .../integration/creator-holders-sort.test.ts | 180 +-- .../creator-list-concurrent.test.ts | 172 +-- ...or-list-limit-boundary.integration.test.ts | 148 +- .../integration/creator-list-load.test.ts | 30 +- .../creator-list-response-shape.test.ts | 236 ++-- src/config.schema.ts | 14 +- .../activity-feed-cache.constants.ts | 9 +- .../creator-detail-include.constants.ts | 35 +- .../creator-list-projection.constants.ts | 34 +- .../creator-public-query.constants.ts | 8 +- .../public-endpoint-cache.constants.ts | 1 - src/jobs/ownership-snapshot-cleanup.job.ts | 3 +- src/middlewares/admin-guard.middleware.ts | 42 +- .../body-parse-error.middleware.test.ts | 315 +++-- .../creator-param.middleware.test.ts | 254 ++-- src/middlewares/creator-param.middleware.ts | 46 +- .../deprecation.middleware.test.ts | 27 +- src/middlewares/error.middleware.ts | 5 +- src/middlewares/request-context.middleware.ts | 22 +- .../request-entry-logger.middleware.test.ts | 202 +-- .../request-entry-logger.middleware.ts | 38 +- .../schema-version.middleware.test.ts | 26 +- src/middlewares/schema-version.middleware.ts | 5 +- .../stellar-signature.middleware.test.ts | 302 ++-- .../stellar-signature.middleware.ts | 224 +-- ...ailing-slash-normalizer.middleware.test.ts | 5 +- .../wallet-ownership.middleware.test.ts | 28 +- .../wallet-ownership.middleware.ts | 2 +- .../activity/activity-cache-key.utils.ts | 44 +- .../activity-feed-empty.integration.test.ts | 240 ++-- src/modules/activity/activity.controllers.ts | 51 +- src/modules/activity/activity.routes.ts | 8 +- src/modules/activity/activity.schemas.ts | 76 +- src/modules/activity/activity.service.test.ts | 44 +- src/modules/activity/activity.service.ts | 36 +- src/modules/admin/admin.controllers.test.ts | 159 +-- src/modules/admin/admin.controllers.ts | 182 +-- src/modules/admin/admin.routes.test.ts | 104 +- src/modules/admin/admin.routes.ts | 5 +- .../alert-invalid-address.integration.test.ts | 90 +- .../alert-price-crossing.integration.test.ts | 384 +++--- .../alert-price-movement.integration.test.ts | 393 +++--- .../alerts/__tests__/alert.service.test.ts | 268 ++-- src/modules/alerts/alert.controllers.ts | 183 +-- src/modules/alerts/alert.router.ts | 6 +- src/modules/alerts/alert.schemas.ts | 42 +- src/modules/alerts/alert.service.ts | 122 +- src/modules/config/config.controllers.ts | 6 +- .../creator-bio-sanitize.utils.test.ts | 50 +- .../creator/creator-bio-sanitize.utils.ts | 25 +- ...r-detail-cache-headers.integration.test.ts | 37 +- .../creator/creator-not-found.utils.test.ts | 52 +- .../creator/creator-not-found.utils.ts | 2 +- ...ator-profile-protected.integration.test.ts | 12 +- .../creator-social-link-url.utils.test.ts | 44 +- .../creator/creator-social-link-url.utils.ts | 52 +- src/modules/creator/creator.controller.ts | 89 +- src/modules/creator/creator.utils.test.ts | 2 +- src/modules/creator/creator.utils.ts | 4 +- ...-detail-price-snapshot.integration.test.ts | 22 +- ...feed-cursor-pagination.integration.test.ts | 55 +- ...ator-feed-default-sort.integration.test.ts | 1 - ...tor-feed-empty-filters.integration.test.ts | 19 +- .../creator-feed-explain-helpers.test.ts | 8 +- ...eator-feed-filter-combinator.utils.test.ts | 104 +- .../creator-feed-filter-combinator.utils.ts | 74 +- ...ator-feed-multi-filter.integration.test.ts | 149 +- ...eator-feed-newest-sort.integration.test.ts | 35 +- ...tor-holders-held-since.integration.test.ts | 260 ++-- .../creators/creator-holders-zero-log.test.ts | 122 +- .../creators/creator-holders.controller.ts | 19 +- .../creator-holders.integration.test.ts | 449 +++--- .../creators/creator-holders.schemas.ts | 51 +- .../creators/creator-holders.service.ts | 97 +- .../creator-list-field-picker.utils.test.ts | 15 +- .../creator-list-field-picker.utils.ts | 9 +- ...ist-filter-total-count.integration.test.ts | 73 +- .../creators/creator-list-item.mapper.ts | 4 +- ...ist-page-size-boundary.integration.test.ts | 42 +- ...r-list-partial-results.integration.test.ts | 2 +- ...ator-list-price-filter.integration.test.ts | 316 ++--- ...t-price-filtered-total.integration.test.ts | 174 +-- .../creators/creator-list-query-log.test.ts | 5 +- ...r-list-verified-filter.integration.test.ts | 4 +- .../creator-registration-fixtures.utils.ts | 5 +- ...ute-method-not-allowed.integration.test.ts | 32 +- .../creators/creators-cache-key.utils.ts | 6 +- ...reators.cursor-warning.integration.test.ts | 13 +- src/modules/creators/creators.filter.test.ts | 132 +- src/modules/creators/creators.filter.ts | 89 +- src/modules/creators/creators.handle.ts | 14 +- src/modules/creators/creators.limit.utils.ts | 16 +- src/modules/creators/creators.schemas.ts | 180 +-- .../creators/creators.search-term.utils.ts | 1 - .../creators/creators.slow-query-plan.test.ts | 11 +- .../creators.sort-field.utils.test.ts | 6 +- src/modules/creators/creators.stats.ts | 47 +- src/modules/creators/creators.utils.ts | 19 +- .../health.controllers.integration.test.ts | 252 ++-- src/modules/health/health.controllers.test.ts | 8 +- src/modules/health/health.controllers.ts | 383 +++--- .../health/health.response-schema.test.ts | 1065 +++++++------- .../health.response-time.integration.test.ts | 70 +- src/modules/health/health.routes.ts | 10 +- src/modules/index.ts | 2 +- .../ledger-gap-detection.integration.test.ts | 150 +- .../indexer/ledger-gap-detection.service.ts | 152 +- .../price-snapshot.integration.test.ts | 162 +-- src/modules/indexer/price-snapshot.service.ts | 146 +- src/modules/ledger/ledger.controllers.test.ts | 131 +- src/modules/ledger/ledger.controllers.ts | 65 +- src/modules/ledger/ledger.routes.ts | 2 +- src/modules/ledger/ledger.schema.ts | 22 +- .../metrics/metrics.controllers.test.ts | 6 +- src/modules/metrics/metrics.controllers.ts | 20 +- .../ownership/ownership-update-log.test.ts | 214 +-- .../ownership/ownership.controllers.ts | 57 +- src/modules/ownership/ownership.routes.ts | 2 +- src/modules/ownership/ownership.schemas.ts | 36 +- .../ownership/ownership.service.test.ts | 12 +- src/modules/ownership/ownership.service.ts | 97 +- src/modules/ownership/ownership.utils.test.ts | 62 +- src/modules/ownership/ownership.utils.ts | 14 +- .../wallet/__tests__/wallet.utils.test.ts | 143 +- src/modules/wallet/wallet.schemas.ts | 13 +- src/modules/wallet/wallet.utils.ts | 8 +- ...vity-cursor-pagination.integration.test.ts | 187 +-- ...ctivity-reverse-chrono.integration.test.ts | 271 ++-- ...-holdings-default-sort.integration.test.ts | 265 ++-- .../wallet-holdings.integration.test.ts | 226 +-- ...ctivity-creator-filter.integration.test.ts | 341 ++--- ...ivity-historical-price.integration.test.ts | 162 ++- ...tivity-invalid-address.integration.test.ts | 165 +-- .../wallets/wallet-activity.controllers.ts | 130 +- .../wallet-activity.integration.test.ts | 490 ++++--- .../wallets/wallet-activity.schemas.ts | 61 +- ...allet-activity.service.integration.test.ts | 239 ++-- .../wallets/wallet-activity.service.ts | 153 ++- ...et-holdings-pagination.integration.test.ts | 165 +-- ...oldings-price-snapshot.integration.test.ts | 305 ++-- ...-holdings-zero-balance.integration.test.ts | 143 +- .../wallets/wallet-holdings.controllers.ts | 96 +- .../wallet-holdings.integration.test.ts | 361 ++--- .../wallets/wallet-holdings.schemas.ts | 51 +- ...allet-holdings.service.integration.test.ts | 149 +- .../wallets/wallet-holdings.service.ts | 128 +- src/modules/wallets/wallets.routes.ts | 6 +- src/modules/webhook/webhook.controllers.ts | 155 ++- .../webhook/webhook.integration.test.ts | 440 +++--- src/modules/webhook/webhook.schemas.ts | 28 +- src/modules/webhook/webhook.service.ts | 24 +- .../webhook-event-filter.integration.test.ts | 313 +++-- .../webhooks/webhook-payload.utils.test.ts | 117 +- src/modules/webhooks/webhook-payload.utils.ts | 18 +- .../webhooks/webhook-signature.middleware.ts | 197 +-- .../webhooks/webhook.controllers.test.ts | 220 +-- src/modules/webhooks/webhook.controllers.ts | 134 +- .../webhooks/webhook.integration.test.ts | 1221 +++++++++-------- src/modules/webhooks/webhook.router.ts | 24 +- src/modules/webhooks/webhook.schemas.ts | 8 +- src/modules/webhooks/webhook.service.test.ts | 550 ++++---- src/modules/webhooks/webhook.service.ts | 344 ++--- src/modules/webhooks/webhook.types.ts | 48 +- src/types/profile.types.ts | 5 +- src/types/wallet.types.ts | 12 +- src/utils/als.utils.ts | 6 +- src/utils/audit.utils.ts | 46 +- src/utils/bigint-serializer.utils.test.ts | 31 +- src/utils/cache-key-params.utils.test.ts | 127 +- src/utils/cache-key-params.utils.ts | 15 +- src/utils/client-ip.utils.test.ts | 149 +- src/utils/client-ip.utils.ts | 42 +- src/utils/comma-query.utils.ts | 28 +- src/utils/config-mask.utils.test.ts | 166 +-- src/utils/config-mask.utils.ts | 65 +- src/utils/creator-batch.utils.test.ts | 63 +- src/utils/creator-batch.utils.ts | 4 +- src/utils/creator-etag.utils.test.ts | 5 +- src/utils/creator-etag.utils.ts | 9 +- src/utils/creator-feed-cursor.utils.ts | 66 +- src/utils/creator-id.utils.ts | 30 +- src/utils/creator-public-query.util.ts | 12 +- src/utils/creator-read-metrics.utils.test.ts | 19 +- src/utils/creator-read-metrics.utils.ts | 40 +- src/utils/cursor-debug.utils.test.ts | 90 +- src/utils/cursor-debug.utils.ts | 48 +- src/utils/cursor.utils.ts | 22 +- src/utils/env-boolean.utils.test.ts | 132 +- src/utils/env-boolean.utils.ts | 30 +- src/utils/filter-parse-metrics.utils.ts | 49 +- src/utils/filter-whitelist.utils.ts | 22 +- src/utils/hash-request-body.utils.ts | 41 +- src/utils/held-since.utils.test.ts | 125 +- src/utils/held-since.utils.ts | 34 +- src/utils/indexer-dedupe.utils.test.ts | 78 +- src/utils/indexer-dedupe.utils.ts | 36 +- src/utils/indexer-dlq.utils.ts | 40 +- .../indexer-event-processor.utils.test.ts | 140 +- src/utils/indexer-event-processor.utils.ts | 52 +- .../indexer-flags-startup-check.utils.test.ts | 16 +- .../indexer-flags-startup-check.utils.ts | 12 +- .../indexer-retry-exhaustion.utils.test.ts | 22 +- src/utils/indexer-retry-exhaustion.utils.ts | 17 +- .../indexer-trade-event-logger.utils.test.ts | 176 +-- src/utils/indexer-trade-event-logger.utils.ts | 40 +- src/utils/iso-timestamp.utils.test.ts | 10 +- src/utils/migration-checksum.utils.ts | 39 +- src/utils/monotonic-clock.utils.test.ts | 15 +- src/utils/ownershipConsistency.ts | 5 +- src/utils/pagination-guard.utils.ts | 9 +- .../pagination-link-headers.utils.test.ts | 6 +- src/utils/pagination-link-headers.utils.ts | 18 +- src/utils/pagination.utils.ts | 12 +- src/utils/parseBoolean.utils.ts | 72 +- src/utils/price.utils.ts | 8 +- src/utils/prisma.utils.ts | 249 ++-- src/utils/public-headers.utils.ts | 15 +- src/utils/query-normalization-debug.utils.ts | 39 +- src/utils/query-param.utils.test.ts | 182 +-- src/utils/query-param.utils.ts | 73 +- src/utils/query-plan.utils.test.ts | 49 +- src/utils/query-plan.utils.ts | 7 +- src/utils/query.utils.ts | 33 +- src/utils/querySignature.test.ts | 44 +- src/utils/querySignature.ts | 14 +- src/utils/queue-metrics.utils.ts | 32 +- src/utils/rate-limit-response.utils.ts | 28 +- src/utils/request-context-hash.utils.ts | 12 +- src/utils/route-error.utils.ts | 4 +- src/utils/rpc-timeout.utils.test.ts | 13 +- src/utils/slug.utils.ts | 5 +- src/utils/sort-tiebreaker.utils.ts | 44 +- .../stellar-trade-event-parser.utils.test.ts | 190 +-- src/utils/stellar-trade-event-parser.utils.ts | 160 ++- src/utils/test/api-response.utils.test.ts | 60 +- src/utils/test/comma-query.utils.test.ts | 132 +- src/utils/test/creator-id.utils.test.ts | 116 +- src/utils/test/cursor.utils.test.ts | 53 +- .../test/hash-request-body.utils.test.ts | 196 +-- .../indexer-cursor-staleness.utils.test.ts | 8 +- src/utils/test/indexer-dlq.utils.test.ts | 95 +- .../test/migration-checksum.utils.test.ts | 38 +- src/utils/test/pagination.utils.test.ts | 108 +- src/utils/test/parseBoolean.utils.test.ts | 258 ++-- .../test/protected-route-request.utils.ts | 10 +- .../query-normalization-debug.utils.test.ts | 30 +- .../test/request-context-hash.utils.test.ts | 252 ++-- src/utils/test/slug.utils.test.ts | 10 +- src/utils/test/sort-tiebreaker.utils.test.ts | 170 +-- src/utils/timestamp-headers.utils.ts | 5 +- src/utils/validation-error.utils.test.ts | 106 +- src/utils/validation-error.utils.ts | 30 +- src/utils/webhook-mask.utils.test.ts | 52 +- src/utils/webhook-mask.utils.ts | 12 +- 274 files changed, 13639 insertions(+), 11838 deletions(-) diff --git a/.github/prompts/autonomy-version.json b/.github/prompts/autonomy-version.json index 39afc29..8c77795 100644 --- a/.github/prompts/autonomy-version.json +++ b/.github/prompts/autonomy-version.json @@ -1,3 +1,3 @@ { - "version": "2025.11" + "version": "2025.11" } diff --git a/.github/prompts/autonomy.manifest.json b/.github/prompts/autonomy.manifest.json index 25f7f88..7bc1466 100644 --- a/.github/prompts/autonomy.manifest.json +++ b/.github/prompts/autonomy.manifest.json @@ -1,8 +1,8 @@ { - "version": "2025.11", - "consent": { - "phrase": "", - "expiresMinutes": 0 - }, - "actions": [] -} \ No newline at end of file + "version": "2025.11", + "consent": { + "phrase": "", + "expiresMinutes": 0 + }, + "actions": [] +} diff --git a/.kilo/kilo.json b/.kilo/kilo.json index d3e1b2d..28c5939 100644 --- a/.kilo/kilo.json +++ b/.kilo/kilo.json @@ -1,3 +1,3 @@ { - "snapshot": false -} \ No newline at end of file + "snapshot": false +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b2c663..9c48121 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,9 +58,11 @@ Run `pnpm exec prisma generate` again whenever Prisma schema changes. When adding new endpoints, you must include an integration test that exercises the full request lifecycle against a database. ### Folder Structure and Naming + Integration tests belong in the `src/__tests__/integration/` directory (for cross-module tests) or adjacent to the controller they test (e.g., `src/modules/creators/creators.integration.test.ts`). They must be suffixed with `.test.ts` or `.integration.test.ts`. ### Seeding the Database + Use Prisma to seed test fixtures in a `beforeAll` block, and ensure you clean them up in an `afterAll` block to maintain a pristine test environment. Do not rely on external seed scripts for unit or integration tests. ### Minimal Worked Example @@ -71,38 +73,40 @@ import app from '../../app'; import { prisma } from '../../utils/prisma.utils'; describe('GET /api/v1/example', () => { - beforeAll(async () => { - // 1. Seed database with test fixtures - await prisma.user.create({ - data: { - id: 'test-user', - email: 'test@example.com', - passwordHash: 'hash', - firstName: 'Test', - lastName: 'User' - } - }); - }); - - afterAll(async () => { - // 2. Clean up fixtures - await prisma.user.delete({ where: { id: 'test-user' } }); - await prisma.$disconnect(); - }); - - it('returns 200 and data for an existing record', async () => { - // 3. Execute the request - const res = await supertest(app).get('/api/v1/example/test-user'); - - // 4. Assert response - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - }); + beforeAll(async () => { + // 1. Seed database with test fixtures + await prisma.user.create({ + data: { + id: 'test-user', + email: 'test@example.com', + passwordHash: 'hash', + firstName: 'Test', + lastName: 'User', + }, + }); + }); + + afterAll(async () => { + // 2. Clean up fixtures + await prisma.user.delete({ where: { id: 'test-user' } }); + await prisma.$disconnect(); + }); + + it('returns 200 and data for an existing record', async () => { + // 3. Execute the request + const res = await supertest(app).get('/api/v1/example/test-user'); + + // 4. Assert response + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); }); ``` ### Running Integration Tests Locally + To run only the integration tests, you can use jest with a path or name filter: + ```bash pnpm test -- src/__tests__/integration # or run a specific file diff --git a/docs/TEMPLATE_SCAFFOLDING.md b/docs/TEMPLATE_SCAFFOLDING.md index 03890fe..e3959cd 100644 --- a/docs/TEMPLATE_SCAFFOLDING.md +++ b/docs/TEMPLATE_SCAFFOLDING.md @@ -10,27 +10,27 @@ They are **not product logic**. They are development accelerators that should be ## Reusable template files -| File | Purpose | Adapt or keep as-is? | -|------|---------|----------------------| -| `src/template/render-starter-email.ts` | Generic HTML email wrapper for transactional emails | Adapt when Access Layer needs branded email layouts | -| `src/utils/mail.utils.ts` | Gmail-based email transport with starter templates | Keep structure, update templates as product flows are added | -| `src/config.ts` | Zod-validated environment config with common variables | Extend with new env vars as needed | -| `src/middlewares/error.middleware.ts` | Global error handler covering Zod, Prisma, JWT, and syntax errors | Keep as-is, extend for new error types | -| `src/middlewares/rate.middleware.ts` | Rate limiting with dev/prod defaults | Keep as-is, tune limits for production | -| `src/middlewares/cors.middleware.ts` | CORS setup using allowed origins from config | Keep as-is | -| `src/utils/prisma.utils.ts` | Prisma singleton with dev-friendly logging | Keep as-is | -| `src/utils/logger.utils.ts` | Pino logger and HTTP status constants | Keep as-is | -| `src/tspec.config.ts` | OpenAPI doc generation config | Keep, update title and description as API grows | -| `src/types/profile.types.ts` | Domain types and a `STARTER_ACCOUNT_SCHEMA` reference string | Adapt types as data model evolves; schema string is a reference, not a migration | +| File | Purpose | Adapt or keep as-is? | +| -------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `src/template/render-starter-email.ts` | Generic HTML email wrapper for transactional emails | Adapt when Access Layer needs branded email layouts | +| `src/utils/mail.utils.ts` | Gmail-based email transport with starter templates | Keep structure, update templates as product flows are added | +| `src/config.ts` | Zod-validated environment config with common variables | Extend with new env vars as needed | +| `src/middlewares/error.middleware.ts` | Global error handler covering Zod, Prisma, JWT, and syntax errors | Keep as-is, extend for new error types | +| `src/middlewares/rate.middleware.ts` | Rate limiting with dev/prod defaults | Keep as-is, tune limits for production | +| `src/middlewares/cors.middleware.ts` | CORS setup using allowed origins from config | Keep as-is | +| `src/utils/prisma.utils.ts` | Prisma singleton with dev-friendly logging | Keep as-is | +| `src/utils/logger.utils.ts` | Pino logger and HTTP status constants | Keep as-is | +| `src/tspec.config.ts` | OpenAPI doc generation config | Keep, update title and description as API grows | +| `src/types/profile.types.ts` | Domain types and a `STARTER_ACCOUNT_SCHEMA` reference string | Adapt types as data model evolves; schema string is a reference, not a migration | ## Local-only and example files -| File | Purpose | Notes | -|------|---------|-------| -| `.env.example` | Safe placeholder environment variables | Copy to `.env` locally. Never commit a real `.env` file | -| `docker-compose.yml` | Local PostgreSQL container | Local development only. Not used in production or CI | -| `nodemon.json` | Auto-reload config for `pnpm dev` | Local development convenience | -| `.husky/` | Git hooks for lint-staged | Runs locally on commit, enforced by `prepare` script | +| File | Purpose | Notes | +| -------------------- | -------------------------------------- | ------------------------------------------------------- | +| `.env.example` | Safe placeholder environment variables | Copy to `.env` locally. Never commit a real `.env` file | +| `docker-compose.yml` | Local PostgreSQL container | Local development only. Not used in production or CI | +| `nodemon.json` | Auto-reload config for `pnpm dev` | Local development convenience | +| `.husky/` | Git hooks for lint-staged | Runs locally on commit, enforced by `prepare` script | ## Files that are not scaffolding diff --git a/docs/api-inventory.md b/docs/api-inventory.md index ea823d8..2393799 100644 --- a/docs/api-inventory.md +++ b/docs/api-inventory.md @@ -48,11 +48,11 @@ Manage trade webhooks for creator profiles. - Trade webhooks reference (payload shape, retry behavior, delivery guarantees): [`docs/webhooks.md`](./webhooks.md). -| Method | Path | Description | -| :------- | :---------------------------------- | :---------------------------------------------------- | -| `POST` | `/creators/:id/webhooks` | Register a new webhook for trade events. | -| `GET` | `/creators/:id/webhooks` | List all registered webhooks for the creator. | -| `DELETE` | `/creators/:id/webhooks/:webhookId` | Delete a registered webhook. | +| Method | Path | Description | +| :------- | :---------------------------------- | :-------------------------------------------- | +| `POST` | `/creators/:id/webhooks` | Register a new webhook for trade events. | +| `GET` | `/creators/:id/webhooks` | List all registered webhooks for the creator. | +| `DELETE` | `/creators/:id/webhooks/:webhookId` | Delete a registered webhook. | ## Activity Module diff --git a/docs/api-timeouts.md b/docs/api-timeouts.md index c35e088..f9bf910 100644 --- a/docs/api-timeouts.md +++ b/docs/api-timeouts.md @@ -5,12 +5,12 @@ All values are in milliseconds unless noted. ## Defaults -| Config key | Default | Source | Description | -|---|---|---|---| -| `RPC_TIMEOUT_MS` (code constant) | `5000` | `src/utils/rpc-timeout.utils.ts` | Max wait for outbound RPC calls. Per-call override via `withRpcTimeout` third arg. | -| `BACKGROUND_JOB_LOCK_TTL_MS` | `300000` | `src/config.ts`, `.env` | How long a background job holds its distributed lock before it is considered stale. | -| `SHUTDOWN_TIMEOUT_MS` (code constant) | `30000` | `src/server.ts` | Hard deadline for graceful shutdown before `process.exit(1)` is forced. | -| `DRAIN_WINDOW_MS` (code constant) | `5000` | `src/server.ts` | Extra drain period after the HTTP server closes to finish in-flight requests. | +| Config key | Default | Source | Description | +| ------------------------------------- | -------- | -------------------------------- | ----------------------------------------------------------------------------------- | +| `RPC_TIMEOUT_MS` (code constant) | `5000` | `src/utils/rpc-timeout.utils.ts` | Max wait for outbound RPC calls. Per-call override via `withRpcTimeout` third arg. | +| `BACKGROUND_JOB_LOCK_TTL_MS` | `300000` | `src/config.ts`, `.env` | How long a background job holds its distributed lock before it is considered stale. | +| `SHUTDOWN_TIMEOUT_MS` (code constant) | `30000` | `src/server.ts` | Hard deadline for graceful shutdown before `process.exit(1)` is forced. | +| `DRAIN_WINDOW_MS` (code constant) | `5000` | `src/server.ts` | Extra drain period after the HTTP server closes to finish in-flight requests. | ## Override examples @@ -40,7 +40,11 @@ import { withRpcTimeout } from '../utils/rpc-timeout.utils'; const data = await withRpcTimeout('fetchUser', () => api.getUser(id)); // explicit override for a known-slow operation -const report = await withRpcTimeout('generateReport', () => api.report(), 15_000); +const report = await withRpcTimeout( + 'generateReport', + () => api.report(), + 15_000 +); ``` To change the process-wide defaults, edit the constants directly in their source files and redeploy. diff --git a/docs/api/creator-list-query-precedence.md b/docs/api/creator-list-query-precedence.md index 5649268..b5e596c 100644 --- a/docs/api/creator-list-query-precedence.md +++ b/docs/api/creator-list-query-precedence.md @@ -6,15 +6,15 @@ which value takes effect. ## Parameter reference -| Parameter | Type | Default | Notes | -| :--------- | :---------------- | :----------- | :------------------------------------------------------- | -| `limit` | integer (1–100) | `20` | Number of results per page | -| `offset` | integer (≥ 0) | `0` | Number of results to skip | -| `sort` | enum | `createdAt` | Field used to order results | -| `order` | `asc` \| `desc` | `desc` | Direction applied to the `sort` field | -| `verified` | boolean | _(absent)_ | Filter by creator verification status | -| `search` | string | _(absent)_ | Full-text filter applied to display name and handle | -| `include` | comma-separated | _(absent)_ | Extra data to embed in each result (e.g. `stats`) | +| Parameter | Type | Default | Notes | +| :--------- | :-------------- | :---------- | :-------------------------------------------------- | +| `limit` | integer (1–100) | `20` | Number of results per page | +| `offset` | integer (≥ 0) | `0` | Number of results to skip | +| `sort` | enum | `createdAt` | Field used to order results | +| `order` | `asc` \| `desc` | `desc` | Direction applied to the `sort` field | +| `verified` | boolean | _(absent)_ | Filter by creator verification status | +| `search` | string | _(absent)_ | Full-text filter applied to display name and handle | +| `include` | comma-separated | _(absent)_ | Extra data to embed in each result (e.g. `stats`) | ## Precedence rules @@ -85,18 +85,18 @@ This is consistent with how Express parses repeated scalar query params. ## Behaviour summary table -| Supplied params | Effective behaviour | -| :------------------------------------- | :------------------------------------------------ | -| _(no params)_ | `createdAt desc`, page 1 (limit 20, offset 0) | -| `sort=displayName` | `displayName desc` | -| `order=asc` | `createdAt asc` | -| `sort=displayName&order=asc` | `displayName asc` | -| `verified=true` | verified creators only, `createdAt desc` | -| `search=jazz` | creators matching "jazz", `createdAt desc` | -| `verified=true&search=jazz` | verified creators matching "jazz", `createdAt desc` | -| `verified=true&sort=displayName` | verified creators sorted `displayName desc` | -| `limit=10&offset=20` | page 3 at 10-per-page | -| `unknownParam=x` | `400 Bad Request` | +| Supplied params | Effective behaviour | +| :------------------------------- | :-------------------------------------------------- | +| _(no params)_ | `createdAt desc`, page 1 (limit 20, offset 0) | +| `sort=displayName` | `displayName desc` | +| `order=asc` | `createdAt asc` | +| `sort=displayName&order=asc` | `displayName asc` | +| `verified=true` | verified creators only, `createdAt desc` | +| `search=jazz` | creators matching "jazz", `createdAt desc` | +| `verified=true&search=jazz` | verified creators matching "jazz", `createdAt desc` | +| `verified=true&sort=displayName` | verified creators sorted `displayName desc` | +| `limit=10&offset=20` | page 3 at 10-per-page | +| `unknownParam=x` | `400 Bad Request` | ## Related files diff --git a/docs/architecture/adding-new-creator-route.md b/docs/architecture/adding-new-creator-route.md index fcc6c28..4331a35 100644 --- a/docs/architecture/adding-new-creator-route.md +++ b/docs/architecture/adding-new-creator-route.md @@ -64,40 +64,44 @@ Define your query parameter validation schema in `src/modules/creators/creators. ```typescript import { z } from 'zod'; import { safeIntParam, safeBooleanQueryParam } from '../../utils/query.utils'; -import { MIN_PAGE_SIZE, MAX_PAGE_SIZE } from '../../constants/pagination.constants'; +import { + MIN_PAGE_SIZE, + MAX_PAGE_SIZE, +} from '../../constants/pagination.constants'; import { PUBLIC_OFFSET_PAGINATION_DEFAULTS } from '../../utils/public-list-query-defaults'; /** * Validation schema for your new route query parameters. */ export const YourNewRouteQuerySchema = z - .object({ - // Pagination parameters (if needed) - limit: safeIntParam({ - defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, - min: MIN_PAGE_SIZE, - max: MAX_PAGE_SIZE, - label: 'Limit', - }), - offset: safeIntParam({ - defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, - min: 0, - max: Number.MAX_SAFE_INTEGER, - label: 'Offset', - }), - - // Your custom query parameters - customParam: z.string().optional(), - anotherParam: safeBooleanQueryParam({ - paramName: 'anotherParam', - }), - }) - .strict(); + .object({ + // Pagination parameters (if needed) + limit: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, + min: MIN_PAGE_SIZE, + max: MAX_PAGE_SIZE, + label: 'Limit', + }), + offset: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, + min: 0, + max: Number.MAX_SAFE_INTEGER, + label: 'Offset', + }), + + // Your custom query parameters + customParam: z.string().optional(), + anotherParam: safeBooleanQueryParam({ + paramName: 'anotherParam', + }), + }) + .strict(); export type YourNewRouteQueryType = z.infer; ``` **Key points:** + - Use `.strict()` to reject unknown query parameters - Use helper functions like `safeIntParam` and `safeBooleanQueryParam` for type-safe parsing - Provide default values where appropriate @@ -110,7 +114,10 @@ Create your controller handler in `src/modules/creators/creators.controllers.ts` ```typescript import { AsyncController } from '../../types/auth.types'; import { YourNewRouteQuerySchema } from './creators.schemas'; -import { sendSuccess, sendValidationError } from '../../utils/api-response.utils'; +import { + sendSuccess, + sendValidationError, +} from '../../utils/api-response.utils'; import { attachTimestampHeader } from '../../utils/timestamp-headers.utils'; import { parsePublicQuery } from '../../utils/public-query-parse.utils'; @@ -123,16 +130,18 @@ import { parsePublicQuery } from '../../utils/public-query-parse.utils'; export const httpYourNewRoute: AsyncController = async (req, res, next) => { try { // Validate query parameters - const parsed = parsePublicQuery( - YourNewRouteQuerySchema, - req.query, - { debugContext: 'your-new-route-query' } - ); - + const parsed = parsePublicQuery(YourNewRouteQuerySchema, req.query, { + debugContext: 'your-new-route-query', + }); + if (!parsed.ok) { - return sendValidationError(res, 'Invalid query parameters', parsed.details); + return sendValidationError( + res, + 'Invalid query parameters', + parsed.details + ); } - + const validatedQuery = parsed.data; // Your business logic here @@ -148,6 +157,7 @@ export const httpYourNewRoute: AsyncController = async (req, res, next) => { ``` **Key points:** + - Use `AsyncController` type for async handlers - Always use `parsePublicQuery` for validation - Use `sendValidationError` for validation errors @@ -161,7 +171,11 @@ Add your route to `src/modules/creators/creators.routes.ts`: ```typescript import { Router } from 'express'; -import { httpListCreators, httpGetCreatorStats, httpYourNewRoute } from './creators.controllers'; +import { + httpListCreators, + httpGetCreatorStats, + httpYourNewRoute, +} from './creators.controllers'; import { cacheControl } from '../../middlewares/cache-control.middleware'; import { CREATOR_PUBLIC_ROUTE_CACHE_PRESETS } from '../../constants/creator-public-cache.constants'; import { CREATOR_PUBLIC_ROUTE_NAMES } from '../../constants/creator-public-routes.constants'; @@ -178,7 +192,11 @@ const creatorsRouter = Router(); creatorsRouter.get( '/your-new-route', createCreatorReadMetricsMiddleware('your-route'), - cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.YOUR_NEW_ROUTE]), + cacheControl( + CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[ + CREATOR_PUBLIC_ROUTE_NAMES.YOUR_NEW_ROUTE + ] + ), httpYourNewRoute ); @@ -186,6 +204,7 @@ export default creatorsRouter; ``` **Key points:** + - Add JSDoc comments describing the route - Apply middleware in order: metrics → cache → handler - Use the route name constant for cache configuration @@ -196,6 +215,7 @@ export default creatorsRouter; If your route requires additional middleware: **For authentication/authorization:** + ```typescript import { requireCreatorProfileOwnership } from '../../middlewares/wallet-ownership.middleware'; @@ -207,6 +227,7 @@ router.put( ``` **For other custom middleware:** + - Create middleware in `src/middlewares/` or module-specific middleware files - Follow the Express middleware signature: `(req, res, next) => void` @@ -303,6 +324,7 @@ describe('GET /api/v1/creators/your-new-route', () => { ``` **Key points:** + - Test both success and error cases - Test validation edge cases - Use lightweight mocks for req/res @@ -327,6 +349,7 @@ npm test -- --watch ### 9. Update Documentation If your route adds significant functionality, consider updating: + - Module README (`src/modules/creators/README.md`) - API inventory (`docs/api-inventory.md`) - Any relevant architecture documentation @@ -338,6 +361,7 @@ Here's a complete minimal example for a new read-only route `GET /api/v1/creator ### 1. Add Route Name Constant `src/constants/creator-public-routes.constants.ts`: + ```typescript export const CREATOR_PUBLIC_ROUTE_NAMES = { LIST: 'creators:list', @@ -351,6 +375,7 @@ export const CREATOR_PUBLIC_ROUTE_NAMES = { ### 2. Add Cache Configuration `src/constants/creator-public-cache.constants.ts`: + ```typescript export const CREATOR_PUBLIC_ROUTE_CACHE_PRESETS = { [CREATOR_PUBLIC_ROUTE_NAMES.LIST]: { @@ -379,37 +404,47 @@ export const CREATOR_PUBLIC_ROUTE_CACHE_PRESETS = { ### 3. Add Schema `src/modules/creators/creators.schemas.ts`: + ```typescript export const FeaturedCreatorsQuerySchema = z - .object({ - limit: safeIntParam({ - defaultValue: 10, - min: 1, - max: 50, - label: 'Limit', - }), - }) - .strict(); - -export type FeaturedCreatorsQueryType = z.infer; + .object({ + limit: safeIntParam({ + defaultValue: 10, + min: 1, + max: 50, + label: 'Limit', + }), + }) + .strict(); + +export type FeaturedCreatorsQueryType = z.infer< + typeof FeaturedCreatorsQuerySchema +>; ``` ### 4. Add Controller `src/modules/creators/creators.controllers.ts`: + ```typescript -export const httpGetFeaturedCreators: AsyncController = async (req, res, next) => { +export const httpGetFeaturedCreators: AsyncController = async ( + req, + res, + next +) => { try { - const parsed = parsePublicQuery( - FeaturedCreatorsQuerySchema, - req.query, - { debugContext: 'featured-creators-query' } - ); - + const parsed = parsePublicQuery(FeaturedCreatorsQuerySchema, req.query, { + debugContext: 'featured-creators-query', + }); + if (!parsed.ok) { - return sendValidationError(res, 'Invalid query parameters', parsed.details); + return sendValidationError( + res, + 'Invalid query parameters', + parsed.details + ); } - + const { limit } = parsed.data; // Placeholder: fetch featured creators from database @@ -426,8 +461,13 @@ export const httpGetFeaturedCreators: AsyncController = async (req, res, next) = ### 5. Register Route `src/modules/creators/creators.routes.ts`: + ```typescript -import { httpListCreators, httpGetCreatorStats, httpGetFeaturedCreators } from './creators.controllers'; +import { + httpListCreators, + httpGetCreatorStats, + httpGetFeaturedCreators, +} from './creators.controllers'; /** * GET /api/v1/creators/featured @@ -438,7 +478,9 @@ import { httpListCreators, httpGetCreatorStats, httpGetFeaturedCreators } from ' creatorsRouter.get( '/featured', createCreatorReadMetricsMiddleware('featured'), - cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.FEATURED]), + cacheControl( + CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.FEATURED] + ), httpGetFeaturedCreators ); ``` @@ -446,6 +488,7 @@ creatorsRouter.get( ### 6. Add Tests `src/modules/creators/featured-creators.integration.test.ts`: + ```typescript import { httpGetFeaturedCreators } from './creators.controllers'; @@ -508,23 +551,28 @@ Before submitting your PR, ensure: For paginated endpoints, use the standard pagination schema: ```typescript -import { MIN_PAGE_SIZE, MAX_PAGE_SIZE } from '../../constants/pagination.constants'; +import { + MIN_PAGE_SIZE, + MAX_PAGE_SIZE, +} from '../../constants/pagination.constants'; import { PUBLIC_OFFSET_PAGINATION_DEFAULTS } from '../../utils/public-list-query-defaults'; -export const PaginatedQuerySchema = z.object({ - limit: safeIntParam({ - defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, - min: MIN_PAGE_SIZE, - max: MAX_PAGE_SIZE, - label: 'Limit', - }), - offset: safeIntParam({ - defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, - min: 0, - max: Number.MAX_SAFE_INTEGER, - label: 'Offset', - }), -}).strict(); +export const PaginatedQuerySchema = z + .object({ + limit: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, + min: MIN_PAGE_SIZE, + max: MAX_PAGE_SIZE, + label: 'Limit', + }), + offset: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, + min: 0, + max: Number.MAX_SAFE_INTEGER, + label: 'Offset', + }), + }) + .strict(); ``` ### Filtering @@ -535,13 +583,15 @@ For filtered endpoints, use boolean and include query helpers: import { creatorListSortDirectionQueryParam } from './creators.sort-direction.parse'; import { creatorListIncludeQueryParam } from './creators.include.parse'; -export const FilteredQuerySchema = z.object({ - verified: safeBooleanQueryParam({ paramName: 'verified' }), - search: z.string().optional(), - sort: z.enum(['createdAt', 'name']).optional(), - order: creatorListSortDirectionQueryParam(), - include: creatorListIncludeQueryParam(), -}).strict(); +export const FilteredQuerySchema = z + .object({ + verified: safeBooleanQueryParam({ paramName: 'verified' }), + search: z.string().optional(), + sort: z.enum(['createdAt', 'name']).optional(), + order: creatorListSortDirectionQueryParam(), + include: creatorListIncludeQueryParam(), + }) + .strict(); ``` ### Path Parameters @@ -555,7 +605,10 @@ export const httpGetCreatorById: AsyncController = async (req, res, next) => { if (!creatorId || typeof creatorId !== 'string') { return sendValidationError(res, 'Invalid creator ID', [ - { field: 'creatorId', message: 'Creator ID must be a valid string' }, + { + field: 'creatorId', + message: 'Creator ID must be a valid string', + }, ]); } diff --git a/docs/architecture/ownership-read-model.md b/docs/architecture/ownership-read-model.md index eb7ff9f..d797ae9 100644 --- a/docs/architecture/ownership-read-model.md +++ b/docs/architecture/ownership-read-model.md @@ -6,14 +6,14 @@ The ownership read model is the authoritative source of truth for wallet-level k The ownership read model is stored in the `KeyOwnership` table. -| Field | Type | Description | -|----------------|------------|-----------------------------------------------------------------------------| -| `id` | `String` | Unique record identifier (cuid). | -| `ownerAddress` | `String` | Stellar wallet address of the key holder. | -| `creatorId` | `String` | ID of the creator whose keys are held. | -| `balance` | `Decimal` | Number of keys currently held. Defaults to `0`. Never goes below `0`. | -| `createdAt` | `DateTime` | Timestamp when this ownership record was first created. | -| `updatedAt` | `DateTime` | Timestamp of the most recent balance update (auto-managed by Prisma). | +| Field | Type | Description | +| -------------- | ---------- | --------------------------------------------------------------------- | +| `id` | `String` | Unique record identifier (cuid). | +| `ownerAddress` | `String` | Stellar wallet address of the key holder. | +| `creatorId` | `String` | ID of the creator whose keys are held. | +| `balance` | `Decimal` | Number of keys currently held. Defaults to `0`. Never goes below `0`. | +| `createdAt` | `DateTime` | Timestamp when this ownership record was first created. | +| `updatedAt` | `DateTime` | Timestamp of the most recent balance update (auto-managed by Prisma). | **Uniqueness constraint:** `(ownerAddress, creatorId)` — one record per wallet per creator. diff --git a/docs/error-response.md b/docs/error-response.md index 62ac026..05486ee 100644 --- a/docs/error-response.md +++ b/docs/error-response.md @@ -6,14 +6,14 @@ All error responses from the API follow a consistent structure to enable predict Every error response is a JSON object with the following fields: -| Field | Type | Required? | Description | -|-------|------|-----------|-------------| -| `success` | `boolean` | **Always** | Always `false` for error responses. | -| `requestId` | `string` | Optional | Unique identifier for the request, present when available from the async-local-storage context (omitted outside request lifecycle, e.g., in tests). | -| `error` | `object` | **Always** | Contains error details. | -| `error.code` | `string` | **Always** | Machine-readable error code (see [Error Code Registry](./ERROR_CODE_REGISTRY.md)). | -| `error.message` | `string` | **Always** | Human-readable error message. | -| `error.details` | `Array<{ field?: string; message: string }>` | Optional | Additional validation details (present only for validation errors, e.g., 400 responses from Zod schema validation). Each object may include a `field` indicating which field failed validation and a `message` describing the validation error. | +| Field | Type | Required? | Description | +| --------------- | -------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `success` | `boolean` | **Always** | Always `false` for error responses. | +| `requestId` | `string` | Optional | Unique identifier for the request, present when available from the async-local-storage context (omitted outside request lifecycle, e.g., in tests). | +| `error` | `object` | **Always** | Contains error details. | +| `error.code` | `string` | **Always** | Machine-readable error code (see [Error Code Registry](./ERROR_CODE_REGISTRY.md)). | +| `error.message` | `string` | **Always** | Human-readable error message. | +| `error.details` | `Array<{ field?: string; message: string }>` | Optional | Additional validation details (present only for validation errors, e.g., 400 responses from Zod schema validation). Each object may include a `field` indicating which field failed validation and a `message` describing the validation error. | ## Guarantees @@ -26,130 +26,143 @@ Every error response is a JSON object with the following fields: ## Example Error Responses ### 400 Bad Request – Validation Error + ```json { - "success": false, - "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", - "error": { - "code": "VALIDATION_ERROR", - "message": "Validation failed", - "details": [ - { "field": "email", "message": "Invalid email format" }, - { "field": "password", "message": "Password must be at least 8 characters" } - ] - } + "success": false, + "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", + "error": { + "code": "VALIDATION_ERROR", + "message": "Validation failed", + "details": [ + { "field": "email", "message": "Invalid email format" }, + { + "field": "password", + "message": "Password must be at least 8 characters" + } + ] + } } ``` ### 400 Bad Request – Generic (e.g., invalid JSON) + ```json { - "success": false, - "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", - "error": { - "code": "BAD_REQUEST", - "message": "Invalid JSON format" - } + "success": false, + "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", + "error": { + "code": "BAD_REQUEST", + "message": "Invalid JSON format" + } } ``` ### 401 Unauthorized + ```json { - "success": false, - "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", - "error": { - "code": "UNAUTHORIZED", - "message": "Invalid or expired token" - } + "success": false, + "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid or expired token" + } } ``` ### 403 Forbidden + ```json { - "success": false, - "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", - "error": { - "code": "FORBIDDEN", - "message": "Access forbidden" - } + "success": false, + "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", + "error": { + "code": "FORBIDDEN", + "message": "Access forbidden" + } } ``` ### 404 Not Found + ```json { - "success": false, - "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", - "error": { - "code": "NOT_FOUND", - "message": "User not found" - } + "success": false, + "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", + "error": { + "code": "NOT_FOUND", + "message": "User not found" + } } ``` ### 409 Conflict + ```json { - "success": false, - "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", - "error": { - "code": "CONFLICT", - "message": "Record already exists (unique constraint violation)" - } + "success": false, + "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", + "error": { + "code": "CONFLICT", + "message": "Record already exists (unique constraint violation)" + } } ``` ### 413 Payload Too Large + ```json { - "success": false, - "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", - "error": { - "code": "BAD_REQUEST", - "message": "Request payload too large" - } + "success": false, + "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", + "error": { + "code": "BAD_REQUEST", + "message": "Request payload too large" + } } ``` ### 429 Rate Limited + ```json { - "success": false, - "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", - "error": { - "code": "RATE_LIMIT", - "message": "Too many requests, please try again later" - } + "success": false, + "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", + "error": { + "code": "RATE_LIMIT", + "message": "Too many requests, please try again later" + } } ``` ### 500 Internal Server Error + ```json { - "success": false, - "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", - "error": { - "code": "INTERNAL_ERROR", - "message": "An unexpected error occurred" - } + "success": false, + "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" + } } ``` ### 503 Service Temporarily Disabled + ```json { - "success": false, - "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", - "error": { - "code": "INTERNAL_ERROR", - "message": "This endpoint is temporarily disabled" - } + "success": false, + "requestId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", + "error": { + "code": "INTERNAL_ERROR", + "message": "This endpoint is temporarily disabled" + } } ``` ## Notes - In development mode (`envConfig.MODE === 'development'`), certain error responses may include an additional `error` field at the top level containing the original error message for debugging (e.g., Prisma errors). This field is **not** part of the documented contract and should not be relied upon by clients. -- The `requestId` correlates with server logs, enabling traceability across services when request IDs are propagated. \ No newline at end of file +- The `requestId` correlates with server logs, enabling traceability across services when request IDs are propagated. diff --git a/docs/indexer/ARCHITECTURE.md b/docs/indexer/ARCHITECTURE.md index 535fa97..e5d5dd6 100644 --- a/docs/indexer/ARCHITECTURE.md +++ b/docs/indexer/ARCHITECTURE.md @@ -3,6 +3,7 @@ End-to-end architecture of the indexer pipeline: how on-chain Stellar events travel from the external polling worker through to all three database destinations on this server. See also: + - [Indexer Contributor Expectations](./CONTRIBUTOR_EXPECTATIONS.md) - [Chain Event Processing](./EVENT_PROCESSING.md) — deduplication and idempotency detail - [Retry and Backoff](./RETRY_BACKOFF.md) — retry strategy and exhaustion behaviour @@ -46,10 +47,10 @@ The external worker delivers events to this server as `ChainEvent[]`, defined in ```typescript interface ChainEvent { - txHash: string; // Transaction hash — unique across the chain - eventIndex: number; // Index of the event within the transaction - ledger?: number; // Optional ledger sequence number - [key: string]: any; // Additional event-specific fields + txHash: string; // Transaction hash — unique across the chain + eventIndex: number; // Index of the event within the transaction + ledger?: number; // Optional ledger sequence number + [key: string]: any; // Additional event-specific fields } ``` @@ -67,24 +68,24 @@ Raw Stellar events are cast to `IndexerChainEvent` (defined in `src/utils/indexe ```typescript interface IndexerChainEvent extends ChainEvent { - eventType: string; // e.g. 'KEY_BOUGHT', 'KEY_SOLD', 'CREATOR_REGISTERED' + eventType: string; // e.g. 'KEY_BOUGHT', 'KEY_SOLD', 'CREATOR_REGISTERED' } ``` Extraction of typed fields from raw Soroban contract data happens **in the external worker** before events reach this server. By the time events enter `processIndexerChainEvents`, they already carry the following fields: -| Field | Type | Description | -| :----------- | :------- | :---------------------------------------------------- | -| `txHash` | string | Stable dedup key and log correlation ID prefix | -| `eventIndex` | number | Per-transaction index; combined with `txHash` for ID | -| `ledger` | number | Stellar ledger sequence number | -| `eventType` | string | `KEY_BOUGHT` or `KEY_SOLD` for trade events | -| `creatorId` | string | Which creator's key was traded | -| `actor` | string | Buyer or seller wallet address | -| `amount` | number | Number of keys traded | -| `price` | bigint | Price in stroops | -| `feePaid` | bigint | Protocol fees in stroops | -| `tradeAt` | Date | ISO timestamp of the transaction | +| Field | Type | Description | +| :----------- | :----- | :--------------------------------------------------- | +| `txHash` | string | Stable dedup key and log correlation ID prefix | +| `eventIndex` | number | Per-transaction index; combined with `txHash` for ID | +| `ledger` | number | Stellar ledger sequence number | +| `eventType` | string | `KEY_BOUGHT` or `KEY_SOLD` for trade events | +| `creatorId` | string | Which creator's key was traded | +| `actor` | string | Buyer or seller wallet address | +| `amount` | number | Number of keys traded | +| `price` | bigint | Price in stroops | +| `feePaid` | bigint | Protocol fees in stroops | +| `tradeAt` | Date | ISO timestamp of the transaction | `processIndexerChainEvents(events, handler)` in `src/utils/indexer-event-processor.utils.ts` wraps the processing loop: it first calls `dedupeChainEvents` internally (removing duplicates by `txHash:eventIndex`), then runs the provided `handler` for each unique event. Each event emits exactly one structured `info` log (`type: 'indexer_event_processed'`) with `elapsedMs` measured from a monotonic clock. diff --git a/docs/indexer/PRICE_ALERTS.md b/docs/indexer/PRICE_ALERTS.md index 10c24dc..9444a49 100644 --- a/docs/indexer/PRICE_ALERTS.md +++ b/docs/indexer/PRICE_ALERTS.md @@ -13,12 +13,14 @@ The price alert evaluation process follows a strict sequence: ## Synchronous vs. Queued Execution The alert check process is **queued (asynchronous)**. + - It is intentionally decoupled from the main trade indexing transaction. This ensures that the evaluation of potentially thousands of alerts does not block or degrade the performance of core trade event ingestion. - Once the trade is written and the price snapshot updated, a background job is enqueued to handle the alert processing. ## Multi-Alert Evaluation To optimize performance, multiple alerts for the same creator are evaluated in a **single pass**. + - When the alert worker picks up the job, it retrieves all active price alerts configured across all users for that specific creator. - The newly updated price snapshot is evaluated against all threshold conditions in memory simultaneously. - Triggered alerts are batched together and dispatched to the notification service in bulk, minimizing database queries and network overhead. @@ -26,6 +28,7 @@ To optimize performance, multiple alerts for the same creator are evaluated in a ## Failure Handling Because the alert check is asynchronous, **a failure in the alert evaluation process does not impact or roll back the trade event.** + - If a trade event is successfully written but the subsequent alert check fails (e.g., due to a temporary worker crash or notification service downtime), the trade remains permanently indexed. - The failed alert check job is governed by a retry policy. It will be re-attempted automatically. - If the job exhausts its maximum retry attempts, it is routed to the Dead-Letter Queue (DLQ) for manual inspection, guaranteeing that the failure is recorded without compromising trade integrity. diff --git a/docs/indexer/RECOVERY.md b/docs/indexer/RECOVERY.md index 4bfd562..9d4ecdd 100644 --- a/docs/indexer/RECOVERY.md +++ b/docs/indexer/RECOVERY.md @@ -25,11 +25,11 @@ A structured `warn` log is emitted when `gapSize > 10` (~50 seconds at 5 s/ledge ```json { - "event": "ledger_gap_detected", - "lastProcessed": 12340, - "networkHead": 12400, - "gapSize": 60, - "gapRange": { "start": 12341, "end": 12400 } + "event": "ledger_gap_detected", + "lastProcessed": 12340, + "networkHead": 12400, + "gapSize": 60, + "gapRange": { "start": 12341, "end": 12400 } } ``` @@ -51,11 +51,11 @@ POST /api/v1/admin/indexer/replay **Body:** -| Field | Type | Required | Description | -| ------------ | ------- | -------- | ----------- | -| `startLedger` | number | yes | First ledger sequence to replay (must be >= 1) | -| `endLedger` | number | no | Last ledger to replay (must be >= startLedger). Omit to replay through the current network head. | -| `dryRun` | boolean | no | Validate input and acquire lock without processing events (default: `false`). | +| Field | Type | Required | Description | +| ------------- | ------- | -------- | ------------------------------------------------------------------------------------------------ | +| `startLedger` | number | yes | First ledger sequence to replay (must be >= 1) | +| `endLedger` | number | no | Last ledger to replay (must be >= startLedger). Omit to replay through the current network head. | +| `dryRun` | boolean | no | Validate input and acquire lock without processing events (default: `false`). | ### Behaviour @@ -73,17 +73,17 @@ POST /api/v1/admin/indexer/replay ```json // HTTP 409 { - "success": false, - "error": { - "code": "CONFLICT", - "message": "Indexer replay job is already running", - "details": [ - { - "field": "indexerReplayLock", - "message": "Lock is held by admin-abc until 2026-07-19T14:00:00Z" - } - ] - } + "success": false, + "error": { + "code": "CONFLICT", + "message": "Indexer replay job is already running", + "details": [ + { + "field": "indexerReplayLock", + "message": "Lock is held by admin-abc until 2026-07-19T14:00:00Z" + } + ] + } } ``` @@ -95,18 +95,19 @@ Set `dryRun: true` to validate the request, check the lock, and emit the audit e If a ledger gap is **not** replayed after an outage, the following data becomes permanently inaccurate: -| Area | Impact | -| ---- | ------ | -| **Price snapshots** | `currentPrice` and `price24hAgo` in `creator_price_snapshots` reflect pre-outage prices. Trades that occurred during the gap are missing. The 24-hour rotation logic (`price24hAgo` promoted from `currentPrice`) will calculate against the wrong baseline. | -| **Ownership balances** | `keyOwnership` rows show incorrect `balance` values. Every buy/sell during the gap is absent, so holder balances are off by the sum of missed deltas. | -| **Activity feed** | No `KEY_BOUGHT` / `KEY_SOLD` records for trades during the gap. The feed is permanently incomplete. | -| **Webhooks** | Registered callback URLs never receive delivery for missed trade events. Creators relying on webhook-driven workflows will not be notified. | +| Area | Impact | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Price snapshots** | `currentPrice` and `price24hAgo` in `creator_price_snapshots` reflect pre-outage prices. Trades that occurred during the gap are missing. The 24-hour rotation logic (`price24hAgo` promoted from `currentPrice`) will calculate against the wrong baseline. | +| **Ownership balances** | `keyOwnership` rows show incorrect `balance` values. Every buy/sell during the gap is absent, so holder balances are off by the sum of missed deltas. | +| **Activity feed** | No `KEY_BOUGHT` / `KEY_SOLD` records for trades during the gap. The feed is permanently incomplete. | +| **Webhooks** | Registered callback URLs never receive delivery for missed trade events. Creators relying on webhook-driven workflows will not be notified. | Recovery requires a manual replay (`POST /api/v1/admin/indexer/replay`) with the correct `startLedger` through `endLedger` range. --- See also: + - [Indexer Architecture](./ARCHITECTURE.md) — overview of the polling and write pipeline - [Event Processing](./EVENT_PROCESSING.md) — deduplication and idempotency guarantees - [Retry and Backoff](./RETRY_BACKOFF.md) — retry strategy on transient failures diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md index 226d9d7..f253180 100644 --- a/docs/rate-limiting.md +++ b/docs/rate-limiting.md @@ -17,12 +17,13 @@ Rate limiting is implemented using the `express-rate-limit` middleware, which re The default rate limit configuration uses a sliding/fixed window rate limiter based on the client IP address. -| Environment | Limit (Requests) | Window Size | Units | Description | -| :--- | :--- | :--- | :--- | :--- | -| **Production** | `1,000` | `15` | minutes | Standard rate limit for API consumers | -| **Development** / **Test** | `10,000` | `15` | minutes | Relaxed rate limit to facilitate local testing and manual integration tests | +| Environment | Limit (Requests) | Window Size | Units | Description | +| :------------------------- | :--------------- | :---------- | :------ | :-------------------------------------------------------------------------- | +| **Production** | `1,000` | `15` | minutes | Standard rate limit for API consumers | +| **Development** / **Test** | `10,000` | `15` | minutes | Relaxed rate limit to facilitate local testing and manual integration tests | The window duration is fixed in code: + - **Default Window:** `15` minutes (`RATE_LIMIT_WINDOW_MS` = `15 * 60 * 1000` milliseconds). - **Production Limit:** `1,000` requests per window. - **Development/Test Limit:** `10,000` requests per window. @@ -33,11 +34,11 @@ The window duration is fixed in code: Every successful and failed API response includes standard HTTP headers defined by the IETF draft RFC for rate limiting. These headers allow client applications to dynamically adjust their request rates. -| Header Name | Type | Description | -| :--- | :--- | :--- | -| `RateLimit-Limit` | Integer | The maximum number of allowed requests in the window. | -| `RateLimit-Remaining` | Integer | The number of remaining requests allowed in the current window. | -| `RateLimit-Reset` | Integer | The time at which the rate limit resets (in Unix epoch seconds). | +| Header Name | Type | Description | +| :-------------------- | :------ | :--------------------------------------------------------------- | +| `RateLimit-Limit` | Integer | The maximum number of allowed requests in the window. | +| `RateLimit-Remaining` | Integer | The number of remaining requests allowed in the current window. | +| `RateLimit-Reset` | Integer | The time at which the rate limit resets (in Unix epoch seconds). | > [!NOTE] > The legacy headers (e.g. `X-RateLimit-*`) are explicitly disabled (`legacyHeaders: false`) to comply with modern API standards. @@ -52,7 +53,7 @@ When a client exceeds the allowed request threshold, the server rejects the requ - **HTTP Status Code:** `429 Too Many Requests` - **Response Headers:** - - `Retry-After`: The duration in seconds that the client must wait before making another request (e.g., `900` seconds if a client is blocked at the start of a window). + - `Retry-After`: The duration in seconds that the client must wait before making another request (e.g., `900` seconds if a client is blocked at the start of a window). ### Response Body @@ -60,10 +61,10 @@ The response returns a JSON object following the API error pattern: ```json { - "type": "RATE_LIMIT_EXCEEDED", - "message": "Too many requests, please try again later.", - "retryAfterSeconds": 900, - "timestamp": "2026-05-29T20:00:00.000Z" + "type": "RATE_LIMIT_EXCEEDED", + "message": "Too many requests, please try again later.", + "retryAfterSeconds": 900, + "timestamp": "2026-05-29T20:00:00.000Z" } ``` @@ -76,7 +77,7 @@ The response returns a JSON object following the API error pattern: ## Custom Per-Endpoint Thresholds -Currently, **all endpoints share the global rate limit**, and there are no custom per-endpoint thresholds configured. +Currently, **all endpoints share the global rate limit**, and there are no custom per-endpoint thresholds configured. If you are developing new endpoints that are computationally expensive or susceptible to abuse (e.g., wallet authentication, payment initiation, email dispatch, heavy database query searches), you should define custom limits. @@ -118,7 +119,11 @@ import { expensiveEndpointLimit } from './expensive.middleware'; const router = Router(); // Mount custom rate limiter middleware before the controller -router.post('/process-transaction', expensiveEndpointLimit, processTransactionController); +router.post( + '/process-transaction', + expensiveEndpointLimit, + processTransactionController +); export default router; ``` diff --git a/docs/stellar-signature-format.md b/docs/stellar-signature-format.md index 3989570..b709873 100644 --- a/docs/stellar-signature-format.md +++ b/docs/stellar-signature-format.md @@ -22,8 +22,8 @@ Serialize your request body to JSON (no extra whitespace, consistent ordering): ```javascript const body = { - displayName: "Alice Creator", - bio: "Building the future on Stellar" + displayName: 'Alice Creator', + bio: 'Building the future on Stellar', }; const bodyJson = JSON.stringify(body); ``` @@ -64,16 +64,19 @@ const signatureBase64 = signature.toString('base64'); ### 5. Send the Request ```javascript -const response = await fetch('https://api.accesslayer.org/api/v1/creators/:creatorId/profile', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'x-wallet-address': keypair.publicKey(), - 'x-wallet-signature': signatureBase64, - 'x-timestamp': timestamp, - }, - body: bodyJson, -}); +const response = await fetch( + 'https://api.accesslayer.org/api/v1/creators/:creatorId/profile', + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'x-wallet-address': keypair.publicKey(), + 'x-wallet-signature': signatureBase64, + 'x-timestamp': timestamp, + }, + body: bodyJson, + } +); ``` ## Complete Example @@ -84,41 +87,41 @@ const crypto = require('crypto'); const fetch = require('node-fetch'); async function signedRequest(creatorId, body, secretKey) { - const keypair = Keypair.fromSecret(secretKey); - const timestamp = Date.now().toString(); - const bodyJson = JSON.stringify(body); - - // Build canonical message - const payload = bodyJson + timestamp; - const message = crypto.createHash('sha256').update(payload, 'utf8').digest(); - - // Sign - const signature = keypair.sign(message); - const signatureBase64 = signature.toString('base64'); - - // Send - const response = await fetch( - `https://api.accesslayer.org/api/v1/creators/${creatorId}/profile`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'x-wallet-address': keypair.publicKey(), - 'x-wallet-signature': signatureBase64, - 'x-timestamp': timestamp, - }, - body: bodyJson, - } - ); - - return response.json(); + const keypair = Keypair.fromSecret(secretKey); + const timestamp = Date.now().toString(); + const bodyJson = JSON.stringify(body); + + // Build canonical message + const payload = bodyJson + timestamp; + const message = crypto.createHash('sha256').update(payload, 'utf8').digest(); + + // Sign + const signature = keypair.sign(message); + const signatureBase64 = signature.toString('base64'); + + // Send + const response = await fetch( + `https://api.accesslayer.org/api/v1/creators/${creatorId}/profile`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'x-wallet-address': keypair.publicKey(), + 'x-wallet-signature': signatureBase64, + 'x-timestamp': timestamp, + }, + body: bodyJson, + } + ); + + return response.json(); } // Usage const result = await signedRequest( - 'clxxx123', - { displayName: 'Alice', bio: 'Creator on Stellar' }, - 'SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' + 'clxxx123', + { displayName: 'Alice', bio: 'Creator on Stellar' }, + 'SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' ); ``` @@ -128,11 +131,11 @@ const result = await signedRequest( ```json { - "success": false, - "error": { - "code": "UNAUTHORIZED", - "message": "Missing required signature headers: x-wallet-address, x-wallet-signature, x-timestamp" - } + "success": false, + "error": { + "code": "UNAUTHORIZED", + "message": "Missing required signature headers: x-wallet-address, x-wallet-signature, x-timestamp" + } } ``` @@ -140,11 +143,11 @@ const result = await signedRequest( ```json { - "success": false, - "error": { - "code": "UNAUTHORIZED", - "message": "Signature timestamp is invalid or expired (must be within 5 minutes)" - } + "success": false, + "error": { + "code": "UNAUTHORIZED", + "message": "Signature timestamp is invalid or expired (must be within 5 minutes)" + } } ``` @@ -152,11 +155,11 @@ const result = await signedRequest( ```json { - "success": false, - "error": { - "code": "FORBIDDEN", - "message": "Invalid signature — the request body was tampered with or signed by the wrong key" - } + "success": false, + "error": { + "code": "FORBIDDEN", + "message": "Invalid signature — the request body was tampered with or signed by the wrong key" + } } ``` @@ -164,11 +167,11 @@ const result = await signedRequest( ```json { - "success": false, - "error": { - "code": "BAD_REQUEST", - "message": "Invalid Stellar wallet address format" - } + "success": false, + "error": { + "code": "BAD_REQUEST", + "message": "Invalid Stellar wallet address format" + } } ``` @@ -184,7 +187,7 @@ const result = await signedRequest( The following routes require Stellar signature verification: - `PUT /api/v1/creators/:creatorId/profile` — Update creator profile -- *(Future routes will be added here as they are protected)* +- _(Future routes will be added here as they are protected)_ ## Testing diff --git a/jest.config.js b/jest.config.js index 31c41b8..bfdfb30 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,16 +1,16 @@ -const { createDefaultPreset } = require("ts-jest"); +const { createDefaultPreset } = require('ts-jest'); const tsJestTransformCfg = createDefaultPreset().transform; /** @type {import("jest").Config} **/ module.exports = { - testEnvironment: "node", - transform: { - ...tsJestTransformCfg, - }, - moduleNameMapper: { - "^chalk$": "/src/__mocks__/chalk.ts", - }, - roots: ["/src"], - setupFiles: ["./jest.setup.ts"], -}; \ No newline at end of file + testEnvironment: 'node', + transform: { + ...tsJestTransformCfg, + }, + moduleNameMapper: { + '^chalk$': '/src/__mocks__/chalk.ts', + }, + roots: ['/src'], + setupFiles: ['./jest.setup.ts'], +}; diff --git a/src/__mocks__/chalk.ts b/src/__mocks__/chalk.ts index d748f95..5e5a5f3 100644 --- a/src/__mocks__/chalk.ts +++ b/src/__mocks__/chalk.ts @@ -1,12 +1,9 @@ -const chalk = new Proxy( - (s: string) => s, - { - get(_target, prop) { +const chalk = new Proxy((s: string) => s, { + get(_target, prop) { if (prop === 'default' || prop === 'chalk') return chalk; if (typeof prop === 'string') return chalk; return chalk; - }, - } -); + }, +}); export default chalk; diff --git a/src/__tests__/integration/creator-holders-404.test.ts b/src/__tests__/integration/creator-holders-404.test.ts index 8320428..b2a5092 100644 --- a/src/__tests__/integration/creator-holders-404.test.ts +++ b/src/__tests__/integration/creator-holders-404.test.ts @@ -2,20 +2,22 @@ import supertest from 'supertest'; import app from '../../app'; describe('GET /api/v1/creators/:id/holders 404', () => { - it('returns 404 with a clear error body for non-existent creator', async () => { - // A random CUID that does not exist in the database - const nonexistentId = 'nonexistent-creator-123'; - - const res = await supertest(app).get(`/api/v1/creators/${nonexistentId}/holders`); - - expect(res.status).toBe(404); - - expect(res.body).toEqual({ - success: false, - error: { - code: 'NOT_FOUND', - message: 'Creator not found', - } - }); - }); + it('returns 404 with a clear error body for non-existent creator', async () => { + // A random CUID that does not exist in the database + const nonexistentId = 'nonexistent-creator-123'; + + const res = await supertest(app).get( + `/api/v1/creators/${nonexistentId}/holders` + ); + + expect(res.status).toBe(404); + + expect(res.body).toEqual({ + success: false, + error: { + code: 'NOT_FOUND', + message: 'Creator not found', + }, + }); + }); }); diff --git a/src/__tests__/integration/creator-holders-pagination.integration.test.ts b/src/__tests__/integration/creator-holders-pagination.integration.test.ts index c67dcf2..61d790f 100644 --- a/src/__tests__/integration/creator-holders-pagination.integration.test.ts +++ b/src/__tests__/integration/creator-holders-pagination.integration.test.ts @@ -16,7 +16,7 @@ describe('GET /api/v1/creators/:id/holders pagination', () => { passwordHash: 'dummy-hash', firstName: 'Holder', lastName: 'PagTest', - } + }, }); const creator = await prisma.creatorProfile.create({ @@ -24,7 +24,7 @@ describe('GET /api/v1/creators/:id/holders pagination', () => { userId: user.id, handle: 'holder-pag-creator', displayName: 'Holder Pag Creator', - } + }, }); creatorId = creator.id; @@ -33,84 +33,95 @@ describe('GET /api/v1/creators/:id/holders pagination', () => { ownerAddress: `0xholder${String(i).padStart(6, '0')}`, creatorId: creator.id, balance: TOTAL_HOLDERS - i, - createdAt: new Date(`2024-01-${String((i % 28) + 1).padStart(2, '0')}T00:00:00.000Z`), - })) + createdAt: new Date( + `2024-01-${String((i % 28) + 1).padStart(2, '0')}T00:00:00.000Z` + ), + })), }); }); afterAll(async () => { await prisma.keyOwnership.deleteMany({ - where: { creatorId } + where: { creatorId }, }); await prisma.creatorProfile.delete({ - where: { id: creatorId } + where: { id: creatorId }, }); await prisma.user.delete({ - where: { id: 'holder-pag-test-user' } + where: { id: 'holder-pag-test-user' }, }); await prisma.$disconnect(); }); it('paginates correctly across multiple pages', async () => { - const allPageItems: string[][] = []; - let offset = 0; - let hasMore = true; - - while (hasMore) { - const res = await supertest(app) - .get(`/api/v1/creators/${creatorId}/holders?limit=${PAGE_SIZE}&offset=${offset}`); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - - const items = res.body.data.items as Array<{ wallet_address: string }>; - const meta = res.body.data.meta; - - allPageItems.push(items.map(i => i.wallet_address)); - hasMore = meta.hasMore; - offset += items.length; - } - - const seenAcrossAllPages = allPageItems.flat(); - - const pageOneIds = allPageItems[0]; - const pageTwoIds = allPageItems[1]; - const pageThreeIds = allPageItems[2] ?? []; - - const overlapOneTwo = pageOneIds.filter((id: string) => pageTwoIds.includes(id)); - const overlapTwoThree = pageTwoIds.filter((id: string) => pageThreeIds.includes(id)); - const overlapOneThree = pageOneIds.filter((id: string) => pageThreeIds.includes(id)); - - expect(overlapOneTwo).toHaveLength(0); - expect(overlapTwoThree).toHaveLength(0); - expect(overlapOneThree).toHaveLength(0); - - expect(seenAcrossAllPages).toHaveLength(TOTAL_HOLDERS); - expect(new Set(seenAcrossAllPages).size).toBe(TOTAL_HOLDERS); - }); + const allPageItems: string[][] = []; + let offset = 0; + let hasMore = true; + + while (hasMore) { + const res = await supertest(app).get( + `/api/v1/creators/${creatorId}/holders?limit=${PAGE_SIZE}&offset=${offset}` + ); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const items = res.body.data.items as Array<{ wallet_address: string }>; + const meta = res.body.data.meta; + + allPageItems.push(items.map(i => i.wallet_address)); + hasMore = meta.hasMore; + offset += items.length; + } + + const seenAcrossAllPages = allPageItems.flat(); + + const pageOneIds = allPageItems[0]; + const pageTwoIds = allPageItems[1]; + const pageThreeIds = allPageItems[2] ?? []; + + const overlapOneTwo = pageOneIds.filter((id: string) => + pageTwoIds.includes(id) + ); + const overlapTwoThree = pageTwoIds.filter((id: string) => + pageThreeIds.includes(id) + ); + const overlapOneThree = pageOneIds.filter((id: string) => + pageThreeIds.includes(id) + ); + + expect(overlapOneTwo).toHaveLength(0); + expect(overlapTwoThree).toHaveLength(0); + expect(overlapOneThree).toHaveLength(0); + + expect(seenAcrossAllPages).toHaveLength(TOTAL_HOLDERS); + expect(new Set(seenAcrossAllPages).size).toBe(TOTAL_HOLDERS); + }); it('final page returns hasMore=false', async () => { - const pages: number[] = []; - let offset = 0; - let hasMore = true; - - while (hasMore) { - const res = await supertest(app) - .get(`/api/v1/creators/${creatorId}/holders?limit=${PAGE_SIZE}&offset=${offset}`); - - expect(res.status).toBe(200); - const meta = res.body.data.meta; - hasMore = meta.hasMore; - offset += PAGE_SIZE; - pages.push(offset - 1); - } - - const finalPage = pages.length - 1; - const lastOffset = finalPage * PAGE_SIZE; - - const res = await supertest(app) - .get(`/api/v1/creators/${creatorId}/holders?limit=${PAGE_SIZE}&offset=${lastOffset}`); - - expect(res.body.data.meta.hasMore).toBe(false); - }); -}); \ No newline at end of file + const pages: number[] = []; + let offset = 0; + let hasMore = true; + + while (hasMore) { + const res = await supertest(app).get( + `/api/v1/creators/${creatorId}/holders?limit=${PAGE_SIZE}&offset=${offset}` + ); + + expect(res.status).toBe(200); + const meta = res.body.data.meta; + hasMore = meta.hasMore; + offset += PAGE_SIZE; + pages.push(offset - 1); + } + + const finalPage = pages.length - 1; + const lastOffset = finalPage * PAGE_SIZE; + + const res = await supertest(app).get( + `/api/v1/creators/${creatorId}/holders?limit=${PAGE_SIZE}&offset=${lastOffset}` + ); + + expect(res.body.data.meta.hasMore).toBe(false); + }); +}); diff --git a/src/__tests__/integration/creator-holders-sort.test.ts b/src/__tests__/integration/creator-holders-sort.test.ts index 96eab9c..3a9f195 100644 --- a/src/__tests__/integration/creator-holders-sort.test.ts +++ b/src/__tests__/integration/creator-holders-sort.test.ts @@ -3,100 +3,104 @@ import app from '../../app'; import { prisma } from '../../utils/prisma.utils'; describe('GET /api/v1/creators/:id/holders sorting', () => { - let creatorId: string; + let creatorId: string; - beforeAll(async () => { - // Seed a creator - const user = await prisma.user.create({ - data: { - id: 'holder-sort-test-user', - email: 'holder-sort-test@example.com', - passwordHash: 'dummy-hash', - firstName: 'Holder', - lastName: 'SortTest', - } - }); + beforeAll(async () => { + // Seed a creator + const user = await prisma.user.create({ + data: { + id: 'holder-sort-test-user', + email: 'holder-sort-test@example.com', + passwordHash: 'dummy-hash', + firstName: 'Holder', + lastName: 'SortTest', + }, + }); - const creator = await prisma.creatorProfile.create({ - data: { - userId: user.id, - handle: 'holder-sort-creator', - displayName: 'Holder Sort Creator', - } - }); - creatorId = creator.id; + const creator = await prisma.creatorProfile.create({ + data: { + userId: user.id, + handle: 'holder-sort-creator', + displayName: 'Holder Sort Creator', + }, + }); + creatorId = creator.id; - // Seed 3 holders with different held_since (createdAt) and key_balance - await prisma.keyOwnership.createMany({ - data: [ - { - ownerAddress: '0xabc1', - creatorId: creator.id, - balance: 10, - createdAt: new Date('2024-01-01T00:00:00.000Z'), // Earliest buyer, smallest balance - }, - { - ownerAddress: '0xabc2', - creatorId: creator.id, - balance: 30, - createdAt: new Date('2024-02-01T00:00:00.000Z'), // Middle buyer, largest balance - }, - { - ownerAddress: '0xabc3', - creatorId: creator.id, - balance: 20, - createdAt: new Date('2024-03-01T00:00:00.000Z'), // Latest buyer, middle balance - }, - ] - }); - }); + // Seed 3 holders with different held_since (createdAt) and key_balance + await prisma.keyOwnership.createMany({ + data: [ + { + ownerAddress: '0xabc1', + creatorId: creator.id, + balance: 10, + createdAt: new Date('2024-01-01T00:00:00.000Z'), // Earliest buyer, smallest balance + }, + { + ownerAddress: '0xabc2', + creatorId: creator.id, + balance: 30, + createdAt: new Date('2024-02-01T00:00:00.000Z'), // Middle buyer, largest balance + }, + { + ownerAddress: '0xabc3', + creatorId: creator.id, + balance: 20, + createdAt: new Date('2024-03-01T00:00:00.000Z'), // Latest buyer, middle balance + }, + ], + }); + }); - afterAll(async () => { - await prisma.keyOwnership.deleteMany({ - where: { creatorId } - }); - await prisma.creatorProfile.delete({ - where: { id: creatorId } - }); - await prisma.user.delete({ - where: { id: 'holder-sort-test-user' } - }); - await prisma.$disconnect(); - }); + afterAll(async () => { + await prisma.keyOwnership.deleteMany({ + where: { creatorId }, + }); + await prisma.creatorProfile.delete({ + where: { id: creatorId }, + }); + await prisma.user.delete({ + where: { id: 'holder-sort-test-user' }, + }); + await prisma.$disconnect(); + }); - it('returns holders sorted by balance desc by default', async () => { - const res = await supertest(app).get(`/api/v1/creators/${creatorId}/holders`); - expect(res.status).toBe(200); + it('returns holders sorted by balance desc by default', async () => { + const res = await supertest(app).get( + `/api/v1/creators/${creatorId}/holders` + ); + expect(res.status).toBe(200); - const items = res.body.data.items; - expect(items).toHaveLength(3); - - // Largest balance first: 30 (0xabc2), 20 (0xabc3), 10 (0xabc1) - expect(items[0].wallet_address).toBe('0xabc2'); - expect(items[0].key_balance).toBe(30); - - expect(items[1].wallet_address).toBe('0xabc3'); - expect(items[1].key_balance).toBe(20); - - expect(items[2].wallet_address).toBe('0xabc1'); - expect(items[2].key_balance).toBe(10); - }); + const items = res.body.data.items; + expect(items).toHaveLength(3); - it('returns earliest buyer first when sort=held_since is passed', async () => { - const res = await supertest(app).get(`/api/v1/creators/${creatorId}/holders?sort=held_since`); - expect(res.status).toBe(200); + // Largest balance first: 30 (0xabc2), 20 (0xabc3), 10 (0xabc1) + expect(items[0].wallet_address).toBe('0xabc2'); + expect(items[0].key_balance).toBe(30); - const items = res.body.data.items; - expect(items).toHaveLength(3); - - // Earliest buyer first: 2024-01-01 (0xabc1), 2024-02-01 (0xabc2), 2024-03-01 (0xabc3) - expect(items[0].wallet_address).toBe('0xabc1'); - expect(items[0].held_since).toBe('2024-01-01T00:00:00.000Z'); - - expect(items[1].wallet_address).toBe('0xabc2'); - expect(items[1].held_since).toBe('2024-02-01T00:00:00.000Z'); - - expect(items[2].wallet_address).toBe('0xabc3'); - expect(items[2].held_since).toBe('2024-03-01T00:00:00.000Z'); - }); + expect(items[1].wallet_address).toBe('0xabc3'); + expect(items[1].key_balance).toBe(20); + + expect(items[2].wallet_address).toBe('0xabc1'); + expect(items[2].key_balance).toBe(10); + }); + + it('returns earliest buyer first when sort=held_since is passed', async () => { + const res = await supertest(app).get( + `/api/v1/creators/${creatorId}/holders?sort=held_since` + ); + expect(res.status).toBe(200); + + const items = res.body.data.items; + expect(items).toHaveLength(3); + + // Earliest buyer first: 2024-01-01 (0xabc1), 2024-02-01 (0xabc2), 2024-03-01 (0xabc3) + expect(items[0].wallet_address).toBe('0xabc1'); + expect(items[0].held_since).toBe('2024-01-01T00:00:00.000Z'); + + expect(items[1].wallet_address).toBe('0xabc2'); + expect(items[1].held_since).toBe('2024-02-01T00:00:00.000Z'); + + expect(items[2].wallet_address).toBe('0xabc3'); + expect(items[2].held_since).toBe('2024-03-01T00:00:00.000Z'); + }); }); diff --git a/src/__tests__/integration/creator-list-concurrent.test.ts b/src/__tests__/integration/creator-list-concurrent.test.ts index db537c2..f0ea8f1 100644 --- a/src/__tests__/integration/creator-list-concurrent.test.ts +++ b/src/__tests__/integration/creator-list-concurrent.test.ts @@ -6,94 +6,98 @@ const FIXTURE_SIZE = 50; const CONCURRENT_REQUESTS = 3; interface PaginationMeta { - limit: number; - offset: number; - total: number; - hasMore: boolean; + limit: number; + offset: number; + total: number; + hasMore: boolean; } interface ResponseEnvelope { - success: boolean; - data: { - items: unknown[]; - meta: PaginationMeta; - }; + success: boolean; + data: { + items: unknown[]; + meta: PaginationMeta; + }; } describe('GET /api/v1/creators — concurrent requests return consistent results', () => { - beforeAll(async () => { - const usersToCreate = Array.from({ length: FIXTURE_SIZE }).map((_, i) => ({ - id: `concurrent-test-user-${i}`, - email: `concurrent-test-user-${i}@example.com`, - passwordHash: 'dummy-hash', - firstName: 'Concurrent', - lastName: `TestUser ${i}`, - })); - - await prisma.user.createMany({ - data: usersToCreate, - skipDuplicates: true, - }); - - const creatorsToCreate = Array.from({ length: FIXTURE_SIZE }).map((_, i) => ({ - userId: `concurrent-test-user-${i}`, - handle: `concurrent-test-creator-${i}`, - displayName: `Concurrent Test Creator ${i}`, - })); - - await prisma.creatorProfile.createMany({ - data: creatorsToCreate, - skipDuplicates: true, - }); - }); - - afterAll(async () => { - await prisma.creatorProfile.deleteMany({ - where: { handle: { startsWith: 'concurrent-test-creator-' } }, - }); - - await prisma.user.deleteMany({ - where: { id: { startsWith: 'concurrent-test-user-' } }, - }); - - await prisma.$disconnect(); - }); - - it('returns identical item sets and metadata across simultaneous requests', async () => { - const requests = Array.from({ length: CONCURRENT_REQUESTS }, () => - supertest(app).get('/api/v1/creators?limit=20') - ); - - const responses = await Promise.all(requests); - - for (const res of responses) { - expect(res.status).toBe(200); - } - - const bodies = responses.map((r) => r.body as ResponseEnvelope); - - for (let i = 1; i < bodies.length; i++) { - expect(bodies[i].success).toBe(bodies[0].success); - expect(bodies[i].data.items).toEqual(bodies[0].data.items); - expect(bodies[i].data.meta).toEqual(bodies[0].data.meta); - } - }); - - it('returns the same total count across all concurrent requests', async () => { - const requests = Array.from({ length: CONCURRENT_REQUESTS }, () => - supertest(app).get('/api/v1/creators?limit=10') - ); - - const responses = await Promise.all(requests); - - for (const res of responses) { - expect(res.status).toBe(200); - } - - const bodies = responses.map((r) => r.body as ResponseEnvelope); - - for (const body of bodies) { - expect(body.data.meta.total).toBe(FIXTURE_SIZE); - } - }); + beforeAll(async () => { + const usersToCreate = Array.from({ length: FIXTURE_SIZE }).map( + (_, i) => ({ + id: `concurrent-test-user-${i}`, + email: `concurrent-test-user-${i}@example.com`, + passwordHash: 'dummy-hash', + firstName: 'Concurrent', + lastName: `TestUser ${i}`, + }) + ); + + await prisma.user.createMany({ + data: usersToCreate, + skipDuplicates: true, + }); + + const creatorsToCreate = Array.from({ length: FIXTURE_SIZE }).map( + (_, i) => ({ + userId: `concurrent-test-user-${i}`, + handle: `concurrent-test-creator-${i}`, + displayName: `Concurrent Test Creator ${i}`, + }) + ); + + await prisma.creatorProfile.createMany({ + data: creatorsToCreate, + skipDuplicates: true, + }); + }); + + afterAll(async () => { + await prisma.creatorProfile.deleteMany({ + where: { handle: { startsWith: 'concurrent-test-creator-' } }, + }); + + await prisma.user.deleteMany({ + where: { id: { startsWith: 'concurrent-test-user-' } }, + }); + + await prisma.$disconnect(); + }); + + it('returns identical item sets and metadata across simultaneous requests', async () => { + const requests = Array.from({ length: CONCURRENT_REQUESTS }, () => + supertest(app).get('/api/v1/creators?limit=20') + ); + + const responses = await Promise.all(requests); + + for (const res of responses) { + expect(res.status).toBe(200); + } + + const bodies = responses.map(r => r.body as ResponseEnvelope); + + for (let i = 1; i < bodies.length; i++) { + expect(bodies[i].success).toBe(bodies[0].success); + expect(bodies[i].data.items).toEqual(bodies[0].data.items); + expect(bodies[i].data.meta).toEqual(bodies[0].data.meta); + } + }); + + it('returns the same total count across all concurrent requests', async () => { + const requests = Array.from({ length: CONCURRENT_REQUESTS }, () => + supertest(app).get('/api/v1/creators?limit=10') + ); + + const responses = await Promise.all(requests); + + for (const res of responses) { + expect(res.status).toBe(200); + } + + const bodies = responses.map(r => r.body as ResponseEnvelope); + + for (const body of bodies) { + expect(body.data.meta.total).toBe(FIXTURE_SIZE); + } + }); }); diff --git a/src/__tests__/integration/creator-list-limit-boundary.integration.test.ts b/src/__tests__/integration/creator-list-limit-boundary.integration.test.ts index 0ab400a..8569c7c 100644 --- a/src/__tests__/integration/creator-list-limit-boundary.integration.test.ts +++ b/src/__tests__/integration/creator-list-limit-boundary.integration.test.ts @@ -6,82 +6,94 @@ const FIXTURE_SIZE = 105; // More than MAX_PAGE_SIZE (100) const MAX_PAGE_SIZE = 100; describe('GET /api/v1/creators — limit boundary integration', () => { - beforeAll(async () => { - // Seed test users - const usersToCreate = Array.from({ length: FIXTURE_SIZE }).map((_, i) => ({ - id: `limit-test-user-${i}`, - email: `limit-test-user-${i}@example.com`, - passwordHash: 'dummy-hash', - firstName: 'Limit', - lastName: `TestUser ${i}`, - })); + beforeAll(async () => { + // Seed test users + const usersToCreate = Array.from({ length: FIXTURE_SIZE }).map( + (_, i) => ({ + id: `limit-test-user-${i}`, + email: `limit-test-user-${i}@example.com`, + passwordHash: 'dummy-hash', + firstName: 'Limit', + lastName: `TestUser ${i}`, + }) + ); - await prisma.user.createMany({ - data: usersToCreate, - skipDuplicates: true, - }); + await prisma.user.createMany({ + data: usersToCreate, + skipDuplicates: true, + }); - // Seed test creators - const creatorsToCreate = Array.from({ length: FIXTURE_SIZE }).map((_, i) => ({ - userId: `limit-test-user-${i}`, - handle: `limit-test-creator-${i}`, - displayName: `Limit Test Creator ${i}`, - })); + // Seed test creators + const creatorsToCreate = Array.from({ length: FIXTURE_SIZE }).map( + (_, i) => ({ + userId: `limit-test-user-${i}`, + handle: `limit-test-creator-${i}`, + displayName: `Limit Test Creator ${i}`, + }) + ); - await prisma.creatorProfile.createMany({ - data: creatorsToCreate, - skipDuplicates: true, - }); - }); + await prisma.creatorProfile.createMany({ + data: creatorsToCreate, + skipDuplicates: true, + }); + }); - afterAll(async () => { - // Cleanup - await prisma.creatorProfile.deleteMany({ - where: { handle: { startsWith: 'limit-test-creator-' } }, - }); + afterAll(async () => { + // Cleanup + await prisma.creatorProfile.deleteMany({ + where: { handle: { startsWith: 'limit-test-creator-' } }, + }); - await prisma.user.deleteMany({ - where: { id: { startsWith: 'limit-test-user-' } }, - }); + await prisma.user.deleteMany({ + where: { id: { startsWith: 'limit-test-user-' } }, + }); - await prisma.$disconnect(); - }); + await prisma.$disconnect(); + }); - it('accepts limit equal to MAX_PAGE_SIZE and returns HTTP 200', async () => { - const res = await supertest(app).get(`/api/v1/creators?limit=${MAX_PAGE_SIZE}`); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.data.items).toHaveLength(MAX_PAGE_SIZE); - expect(res.body.data.meta.limit).toBe(MAX_PAGE_SIZE); - }); + it('accepts limit equal to MAX_PAGE_SIZE and returns HTTP 200', async () => { + const res = await supertest(app).get( + `/api/v1/creators?limit=${MAX_PAGE_SIZE}` + ); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.items).toHaveLength(MAX_PAGE_SIZE); + expect(res.body.data.meta.limit).toBe(MAX_PAGE_SIZE); + }); - it('rejects limit one above MAX_PAGE_SIZE with HTTP 400', async () => { - const res = await supertest(app).get(`/api/v1/creators?limit=${MAX_PAGE_SIZE + 1}`); - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - expect(res.body.error.code).toBe('VALIDATION_ERROR'); - expect(res.body.error.details).toBeDefined(); - - // Assert the details explicitly name the 'limit' field and message - const limitError = res.body.error.details.find((d: any) => d.field === 'limit'); - expect(limitError).toBeDefined(); - expect(limitError.message).toContain('Limit must be an integer between'); - }); + it('rejects limit one above MAX_PAGE_SIZE with HTTP 400', async () => { + const res = await supertest(app).get( + `/api/v1/creators?limit=${MAX_PAGE_SIZE + 1}` + ); + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + expect(res.body.error.details).toBeDefined(); - it('rejects a very large limit (e.g. 9999) with HTTP 400', async () => { - const res = await supertest(app).get('/api/v1/creators?limit=9999'); - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - expect(res.body.error.code).toBe('VALIDATION_ERROR'); - const limitError = res.body.error.details.find((d: any) => d.field === 'limit'); - expect(limitError).toBeDefined(); - }); + // Assert the details explicitly name the 'limit' field and message + const limitError = res.body.error.details.find( + (d: any) => d.field === 'limit' + ); + expect(limitError).toBeDefined(); + expect(limitError.message).toContain('Limit must be an integer between'); + }); - it('asserts the returned count never exceeds the maximum page size when using default limit', async () => { - // When no limit param is provided, it should default to a size <= MAX_PAGE_SIZE (e.g. 20) - const res = await supertest(app).get('/api/v1/creators'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.data.items.length).toBeLessThanOrEqual(MAX_PAGE_SIZE); - }); + it('rejects a very large limit (e.g. 9999) with HTTP 400', async () => { + const res = await supertest(app).get('/api/v1/creators?limit=9999'); + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + const limitError = res.body.error.details.find( + (d: any) => d.field === 'limit' + ); + expect(limitError).toBeDefined(); + }); + + it('asserts the returned count never exceeds the maximum page size when using default limit', async () => { + // When no limit param is provided, it should default to a size <= MAX_PAGE_SIZE (e.g. 20) + const res = await supertest(app).get('/api/v1/creators'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.items.length).toBeLessThanOrEqual(MAX_PAGE_SIZE); + }); }); diff --git a/src/__tests__/integration/creator-list-load.test.ts b/src/__tests__/integration/creator-list-load.test.ts index d3bafaf..af34e80 100644 --- a/src/__tests__/integration/creator-list-load.test.ts +++ b/src/__tests__/integration/creator-list-load.test.ts @@ -6,18 +6,20 @@ const CREATOR_FIXTURE_SIZE = 500; const RESPONSE_TIME_BUDGET_MS = 2000; describe('Creator List Load Test', () => { - // NOTE: This test should be run in isolation using --runInBand to avoid interference + // NOTE: This test should be run in isolation using --runInBand to avoid interference // from other tests that might be hitting the database simultaneously. beforeAll(async () => { // 1. Seed Users - const usersToCreate = Array.from({ length: CREATOR_FIXTURE_SIZE }).map((_, i) => ({ - id: `load-test-user-${i}`, - email: `load-test-user-${i}@example.com`, - passwordHash: 'dummy-hash', - firstName: 'Load', - lastName: `TestUser ${i}`, - })); + const usersToCreate = Array.from({ length: CREATOR_FIXTURE_SIZE }).map( + (_, i) => ({ + id: `load-test-user-${i}`, + email: `load-test-user-${i}@example.com`, + passwordHash: 'dummy-hash', + firstName: 'Load', + lastName: `TestUser ${i}`, + }) + ); await prisma.user.createMany({ data: usersToCreate, @@ -25,11 +27,13 @@ describe('Creator List Load Test', () => { }); // 2. Seed CreatorProfiles - const creatorsToCreate = Array.from({ length: CREATOR_FIXTURE_SIZE }).map((_, i) => ({ - userId: `load-test-user-${i}`, - handle: `load-test-creator-${i}`, - displayName: `Load Test Creator ${i}`, - })); + const creatorsToCreate = Array.from({ length: CREATOR_FIXTURE_SIZE }).map( + (_, i) => ({ + userId: `load-test-user-${i}`, + handle: `load-test-creator-${i}`, + displayName: `Load Test Creator ${i}`, + }) + ); await prisma.creatorProfile.createMany({ data: creatorsToCreate, diff --git a/src/__tests__/integration/creator-list-response-shape.test.ts b/src/__tests__/integration/creator-list-response-shape.test.ts index f6fd2ee..eef2b55 100644 --- a/src/__tests__/integration/creator-list-response-shape.test.ts +++ b/src/__tests__/integration/creator-list-response-shape.test.ts @@ -6,126 +6,130 @@ const FIXTURE_SIZE = 150; const MAX_PAGE_SIZE = 100; interface PaginationMeta { - limit: number; - offset: number; - total: number; - hasMore: boolean; + limit: number; + offset: number; + total: number; + hasMore: boolean; } interface ResponseEnvelope { - success: boolean; - data: { - items: unknown[]; - meta: PaginationMeta; - }; + success: boolean; + data: { + items: unknown[]; + meta: PaginationMeta; + }; } describe('GET /api/v1/creators — response shape consistency across page sizes', () => { - beforeAll(async () => { - const usersToCreate = Array.from({ length: FIXTURE_SIZE }).map((_, i) => ({ - id: `shape-test-user-${i}`, - email: `shape-test-user-${i}@example.com`, - passwordHash: 'dummy-hash', - firstName: 'Shape', - lastName: `TestUser ${i}`, - })); - - await prisma.user.createMany({ - data: usersToCreate, - skipDuplicates: true, - }); - - const creatorsToCreate = Array.from({ length: FIXTURE_SIZE }).map((_, i) => ({ - userId: `shape-test-user-${i}`, - handle: `shape-test-creator-${i}`, - displayName: `Shape Test Creator ${i}`, - })); - - await prisma.creatorProfile.createMany({ - data: creatorsToCreate, - skipDuplicates: true, - }); - }); - - afterAll(async () => { - await prisma.creatorProfile.deleteMany({ - where: { handle: { startsWith: 'shape-test-creator-' } }, - }); - - await prisma.user.deleteMany({ - where: { id: { startsWith: 'shape-test-user-' } }, - }); - - await prisma.$disconnect(); - }); - - async function fetchPage(limit: number): Promise { - const res = await supertest(app).get(`/api/v1/creators?limit=${limit}`); - expect(res.status).toBe(200); - return res.body as ResponseEnvelope; - } - - it('returns the same top-level response envelope shape for all page sizes', async () => { - const [page1, page10, page100] = await Promise.all([ - fetchPage(1), - fetchPage(10), - fetchPage(MAX_PAGE_SIZE), - ]); - - const topLevelKeys = ['data', 'success']; - expect(Object.keys(page1).sort()).toEqual(topLevelKeys); - expect(Object.keys(page10).sort()).toEqual(topLevelKeys); - expect(Object.keys(page100).sort()).toEqual(topLevelKeys); - - const dataKeys = ['items', 'meta']; - expect(Object.keys(page1.data).sort()).toEqual(dataKeys); - expect(Object.keys(page10.data).sort()).toEqual(dataKeys); - expect(Object.keys(page100.data).sort()).toEqual(dataKeys); - - const metaKeys = ['hasMore', 'limit', 'offset', 'total']; - expect(Object.keys(page1.data.meta).sort()).toEqual(metaKeys); - expect(Object.keys(page10.data.meta).sort()).toEqual(metaKeys); - expect(Object.keys(page100.data.meta).sort()).toEqual(metaKeys); - }); - - it('returns items arrays whose lengths match the requested page size', async () => { - const [page1, page10, page100] = await Promise.all([ - fetchPage(1), - fetchPage(10), - fetchPage(MAX_PAGE_SIZE), - ]); - - expect(page1.data.items).toHaveLength(1); - expect(page10.data.items).toHaveLength(10); - expect(page100.data.items).toHaveLength(MAX_PAGE_SIZE); - }); - - it('returns pagination metadata that reflects the requested page size', async () => { - const [page1, page10, page100] = await Promise.all([ - fetchPage(1), - fetchPage(10), - fetchPage(MAX_PAGE_SIZE), - ]); - - expect(page1.data.meta).toMatchObject({ - limit: 1, - offset: 0, - total: FIXTURE_SIZE, - hasMore: true, - }); - - expect(page10.data.meta).toMatchObject({ - limit: 10, - offset: 0, - total: FIXTURE_SIZE, - hasMore: true, - }); - - expect(page100.data.meta).toMatchObject({ - limit: MAX_PAGE_SIZE, - offset: 0, - total: FIXTURE_SIZE, - hasMore: true, - }); - }); + beforeAll(async () => { + const usersToCreate = Array.from({ length: FIXTURE_SIZE }).map( + (_, i) => ({ + id: `shape-test-user-${i}`, + email: `shape-test-user-${i}@example.com`, + passwordHash: 'dummy-hash', + firstName: 'Shape', + lastName: `TestUser ${i}`, + }) + ); + + await prisma.user.createMany({ + data: usersToCreate, + skipDuplicates: true, + }); + + const creatorsToCreate = Array.from({ length: FIXTURE_SIZE }).map( + (_, i) => ({ + userId: `shape-test-user-${i}`, + handle: `shape-test-creator-${i}`, + displayName: `Shape Test Creator ${i}`, + }) + ); + + await prisma.creatorProfile.createMany({ + data: creatorsToCreate, + skipDuplicates: true, + }); + }); + + afterAll(async () => { + await prisma.creatorProfile.deleteMany({ + where: { handle: { startsWith: 'shape-test-creator-' } }, + }); + + await prisma.user.deleteMany({ + where: { id: { startsWith: 'shape-test-user-' } }, + }); + + await prisma.$disconnect(); + }); + + async function fetchPage(limit: number): Promise { + const res = await supertest(app).get(`/api/v1/creators?limit=${limit}`); + expect(res.status).toBe(200); + return res.body as ResponseEnvelope; + } + + it('returns the same top-level response envelope shape for all page sizes', async () => { + const [page1, page10, page100] = await Promise.all([ + fetchPage(1), + fetchPage(10), + fetchPage(MAX_PAGE_SIZE), + ]); + + const topLevelKeys = ['data', 'success']; + expect(Object.keys(page1).sort()).toEqual(topLevelKeys); + expect(Object.keys(page10).sort()).toEqual(topLevelKeys); + expect(Object.keys(page100).sort()).toEqual(topLevelKeys); + + const dataKeys = ['items', 'meta']; + expect(Object.keys(page1.data).sort()).toEqual(dataKeys); + expect(Object.keys(page10.data).sort()).toEqual(dataKeys); + expect(Object.keys(page100.data).sort()).toEqual(dataKeys); + + const metaKeys = ['hasMore', 'limit', 'offset', 'total']; + expect(Object.keys(page1.data.meta).sort()).toEqual(metaKeys); + expect(Object.keys(page10.data.meta).sort()).toEqual(metaKeys); + expect(Object.keys(page100.data.meta).sort()).toEqual(metaKeys); + }); + + it('returns items arrays whose lengths match the requested page size', async () => { + const [page1, page10, page100] = await Promise.all([ + fetchPage(1), + fetchPage(10), + fetchPage(MAX_PAGE_SIZE), + ]); + + expect(page1.data.items).toHaveLength(1); + expect(page10.data.items).toHaveLength(10); + expect(page100.data.items).toHaveLength(MAX_PAGE_SIZE); + }); + + it('returns pagination metadata that reflects the requested page size', async () => { + const [page1, page10, page100] = await Promise.all([ + fetchPage(1), + fetchPage(10), + fetchPage(MAX_PAGE_SIZE), + ]); + + expect(page1.data.meta).toMatchObject({ + limit: 1, + offset: 0, + total: FIXTURE_SIZE, + hasMore: true, + }); + + expect(page10.data.meta).toMatchObject({ + limit: 10, + offset: 0, + total: FIXTURE_SIZE, + hasMore: true, + }); + + expect(page100.data.meta).toMatchObject({ + limit: MAX_PAGE_SIZE, + offset: 0, + total: FIXTURE_SIZE, + hasMore: true, + }); + }); }); diff --git a/src/config.schema.ts b/src/config.schema.ts index 90f7358..24ff999 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -110,15 +110,19 @@ export const envSchema = z .int() .positive() .default(300000), - INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: z.coerce - .number() - .positive() - .default(300000), + INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: z.coerce + .number() + .positive() + .default(300000), // Webhook settings WEBHOOK_MAX_PER_CREATOR: z.coerce.number().int().positive().default(5), WEBHOOK_RETRY_MAX_ATTEMPTS: z.coerce.number().int().positive().default(3), - WEBHOOK_RETRY_BASE_DELAY_MS: z.coerce.number().int().positive().default(1000), + WEBHOOK_RETRY_BASE_DELAY_MS: z.coerce + .number() + .int() + .positive() + .default(1000), // Indexer feature flags ENABLE_INDEXER_DEDUPE: booleanCoerce.default(true), diff --git a/src/constants/activity-feed-cache.constants.ts b/src/constants/activity-feed-cache.constants.ts index ab1dba8..030ccd6 100644 --- a/src/constants/activity-feed-cache.constants.ts +++ b/src/constants/activity-feed-cache.constants.ts @@ -13,15 +13,16 @@ import { PUBLIC_ENDPOINT_CACHE_SECONDS } from './public-endpoint-cache.constants * to reduce database load. A 2-minute cache provides a balance between * freshness and performance. */ -export const ACTIVITY_FEED_CACHE_MAX_AGE_SECONDS = PUBLIC_ENDPOINT_CACHE_SECONDS.short; +export const ACTIVITY_FEED_CACHE_MAX_AGE_SECONDS = + PUBLIC_ENDPOINT_CACHE_SECONDS.short; /** * Cache control preset for the activity feed endpoint. */ export const ACTIVITY_FEED_CACHE_PRESET = { - maxAge: ACTIVITY_FEED_CACHE_MAX_AGE_SECONDS, - type: 'public' as const, - staleIfError: 86400, // 24 hours + maxAge: ACTIVITY_FEED_CACHE_MAX_AGE_SECONDS, + type: 'public' as const, + staleIfError: 86400, // 24 hours } as const; /** diff --git a/src/constants/creator-detail-include.constants.ts b/src/constants/creator-detail-include.constants.ts index 9acf20f..5c782ba 100644 --- a/src/constants/creator-detail-include.constants.ts +++ b/src/constants/creator-detail-include.constants.ts @@ -8,22 +8,23 @@ * stay in sync without duplicating field lists. */ export const CREATOR_DETAIL_DEFAULT_SELECT = { - id: true, - handle: true, - displayName: true, - bio: true, - avatarUrl: true, - perks: true, - isVerified: true, - createdAt: true, - updatedAt: true, - priceSnapshot: { - select: { - currentPrice: true, - price24hAgo: true, - lastTradeAt: true, - }, - }, + id: true, + handle: true, + displayName: true, + bio: true, + avatarUrl: true, + perks: true, + isVerified: true, + createdAt: true, + updatedAt: true, + priceSnapshot: { + select: { + currentPrice: true, + price24hAgo: true, + lastTradeAt: true, + }, + }, } as const; -export type CreatorDetailSelectKeys = keyof typeof CREATOR_DETAIL_DEFAULT_SELECT; +export type CreatorDetailSelectKeys = + keyof typeof CREATOR_DETAIL_DEFAULT_SELECT; diff --git a/src/constants/creator-list-projection.constants.ts b/src/constants/creator-list-projection.constants.ts index b7eddfc..a4e65dd 100644 --- a/src/constants/creator-list-projection.constants.ts +++ b/src/constants/creator-list-projection.constants.ts @@ -4,13 +4,13 @@ /** * Prisma select fields returned for every creator list read. - * + * * This projection includes only the minimal fields needed for list responses, * reducing payload size and improving query performance. - * + * * Keeping this centralized ensures route, service, and test layers * stay in sync without duplicating field lists. - * + * * Fields included: * - id: Unique identifier for the creator * - handle: Creator's unique handle/username @@ -22,20 +22,20 @@ * - priceSnapshot: Nested price read model (currentPrice, price24hAgo, lastTradeAt) */ export const CREATOR_LIST_DEFAULT_SELECT = { - id: true, - handle: true, - displayName: true, - avatarUrl: true, - isVerified: true, - createdAt: true, - updatedAt: true, - priceSnapshot: { - select: { - currentPrice: true, - price24hAgo: true, - lastTradeAt: true, - }, - }, + id: true, + handle: true, + displayName: true, + avatarUrl: true, + isVerified: true, + createdAt: true, + updatedAt: true, + priceSnapshot: { + select: { + currentPrice: true, + price24hAgo: true, + lastTradeAt: true, + }, + }, } as const; export type CreatorListSelectKeys = keyof typeof CREATOR_LIST_DEFAULT_SELECT; diff --git a/src/constants/creator-public-query.constants.ts b/src/constants/creator-public-query.constants.ts index 729a858..f4df382 100644 --- a/src/constants/creator-public-query.constants.ts +++ b/src/constants/creator-public-query.constants.ts @@ -1,8 +1,8 @@ export const CREATOR_PUBLIC_QUERY_KEYS = { - CREATOR_ID: "creatorId", - CREATOR_ADDRESS: "creatorAddress", - USERNAME: "username", + CREATOR_ID: 'creatorId', + CREATOR_ADDRESS: 'creatorAddress', + USERNAME: 'username', } as const; export type CreatorPublicQueryKey = - (typeof CREATOR_PUBLIC_QUERY_KEYS)[keyof typeof CREATOR_PUBLIC_QUERY_KEYS]; \ No newline at end of file + (typeof CREATOR_PUBLIC_QUERY_KEYS)[keyof typeof CREATOR_PUBLIC_QUERY_KEYS]; diff --git a/src/constants/public-endpoint-cache.constants.ts b/src/constants/public-endpoint-cache.constants.ts index b7fd969..ed6153a 100644 --- a/src/constants/public-endpoint-cache.constants.ts +++ b/src/constants/public-endpoint-cache.constants.ts @@ -23,4 +23,3 @@ export const PUBLIC_ENDPOINT_CACHE_PRESETS = { type: 'public' as const, }, } as const; - diff --git a/src/jobs/ownership-snapshot-cleanup.job.ts b/src/jobs/ownership-snapshot-cleanup.job.ts index bb6eb44..af48abb 100644 --- a/src/jobs/ownership-snapshot-cleanup.job.ts +++ b/src/jobs/ownership-snapshot-cleanup.job.ts @@ -47,7 +47,8 @@ async function tableExists( export async function cleanupExpiredOwnershipSnapshots( deps?: Partial ): Promise { - const queryRawUnsafe = deps?.queryRawUnsafe ?? prisma.$queryRawUnsafe.bind(prisma); + const queryRawUnsafe = + deps?.queryRawUnsafe ?? prisma.$queryRawUnsafe.bind(prisma); const executeRawUnsafe = deps?.executeRawUnsafe ?? prisma.$executeRawUnsafe.bind(prisma); const now = deps?.now ?? (() => new Date()); diff --git a/src/middlewares/admin-guard.middleware.ts b/src/middlewares/admin-guard.middleware.ts index 8bde41a..f6ea8ab 100644 --- a/src/middlewares/admin-guard.middleware.ts +++ b/src/middlewares/admin-guard.middleware.ts @@ -1,27 +1,31 @@ import { Request, Response, NextFunction } from 'express'; export interface AdminRequest extends Request { - adminId?: string; + adminId?: string; } -export function adminGuard(req: AdminRequest, res: Response, next: NextFunction): void { - const adminIdHeader = req.headers['x-admin-id']; - const adminId = - typeof adminIdHeader === 'string' - ? adminIdHeader - : Array.isArray(adminIdHeader) - ? adminIdHeader[0] - : undefined; +export function adminGuard( + req: AdminRequest, + res: Response, + next: NextFunction +): void { + const adminIdHeader = req.headers['x-admin-id']; + const adminId = + typeof adminIdHeader === 'string' + ? adminIdHeader + : Array.isArray(adminIdHeader) + ? adminIdHeader[0] + : undefined; - if (!adminId) { - res.status(403).json({ - type: 'FORBIDDEN', - message: 'Admin authorization required.', - timestamp: new Date().toISOString(), - }); - return; - } + if (!adminId) { + res.status(403).json({ + type: 'FORBIDDEN', + message: 'Admin authorization required.', + timestamp: new Date().toISOString(), + }); + return; + } - req.adminId = adminId; - next(); + req.adminId = adminId; + next(); } diff --git a/src/middlewares/body-parse-error.middleware.test.ts b/src/middlewares/body-parse-error.middleware.test.ts index 9cd7b32..368edc4 100644 --- a/src/middlewares/body-parse-error.middleware.test.ts +++ b/src/middlewares/body-parse-error.middleware.test.ts @@ -3,167 +3,212 @@ import { bodyParseErrorMiddleware } from './body-parse-error.middleware'; import { logger } from '../utils/logger.utils'; jest.mock('../utils/logger.utils', () => ({ - logger: { error: jest.fn() }, + logger: { error: jest.fn() }, })); jest.mock('../utils/client-ip.utils', () => ({ - getClientIp: jest.fn(() => '10.0.0.1'), + getClientIp: jest.fn(() => '10.0.0.1'), })); function makeReq(method: string, url = '/api/v1/resource'): Request { - return { - method, - originalUrl: url, - url, - requestId: 'req-test-id', - socket: { remoteAddress: '127.0.0.1' }, - headers: {}, - } as unknown as Request; + return { + method, + originalUrl: url, + url, + requestId: 'req-test-id', + socket: { remoteAddress: '127.0.0.1' }, + headers: {}, + } as unknown as Request; } function makeRes(): Partial { - const res: Partial = {}; - res.status = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - return res; + const res: Partial = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; } function makeSyntaxError(): SyntaxError & { body: string } { - const err = new SyntaxError('Unexpected token } in JSON') as SyntaxError & { body: string }; - err.body = 'raw body'; - return err; + const err = new SyntaxError('Unexpected token } in JSON') as SyntaxError & { + body: string; + }; + err.body = 'raw body'; + return err; } function makeEntityTooLargeError() { - return Object.assign(new Error('request entity too large'), { - type: 'entity.too.large', - status: 413, - limit: 10 * 1024 * 1024, - }); + return Object.assign(new Error('request entity too large'), { + type: 'entity.too.large', + status: 413, + limit: 10 * 1024 * 1024, + }); } describe('bodyParseErrorMiddleware', () => { - let next: jest.Mock; - - beforeEach(() => { - next = jest.fn(); - jest.clearAllMocks(); - }); - - describe('malformed JSON on mutation methods', () => { - const MUTATION_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']; - - it.each(MUTATION_METHODS)( - 'logs error and returns 400 for %s with invalid JSON', - (method) => { - const err = makeSyntaxError(); - const req = makeReq(method); - const res = makeRes(); - - bodyParseErrorMiddleware(err, req, res as Response, next as NextFunction); - - expect(logger.error).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'body_parse_failure', - method, - path: '/api/v1/resource', - requestId: 'req-test-id', - errorType: 'invalid_json', - }) - ); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ success: false, message: 'Invalid JSON in request body' }) - ); - expect(next).not.toHaveBeenCalled(); - } - ); - - it('does not include raw body in the log', () => { - const err = makeSyntaxError(); - const req = makeReq('POST'); - const res = makeRes(); - - bodyParseErrorMiddleware(err, req, res as Response, next as NextFunction); - - const logCall = (logger.error as jest.Mock).mock.calls[0][0]; - expect(logCall).not.toHaveProperty('body'); - expect(logCall).not.toHaveProperty('rawBody'); - }); - - it('logs and returns 413 for entity.too.large on mutation', () => { - const err = makeEntityTooLargeError(); - const req = makeReq('POST'); - const res = makeRes(); - - bodyParseErrorMiddleware(err, req, res as Response, next as NextFunction); - - expect(logger.error).toHaveBeenCalledWith( - expect.objectContaining({ errorType: 'entity.too.large' }) + let next: jest.Mock; + + beforeEach(() => { + next = jest.fn(); + jest.clearAllMocks(); + }); + + describe('malformed JSON on mutation methods', () => { + const MUTATION_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']; + + it.each(MUTATION_METHODS)( + 'logs error and returns 400 for %s with invalid JSON', + method => { + const err = makeSyntaxError(); + const req = makeReq(method); + const res = makeRes(); + + bodyParseErrorMiddleware( + err, + req, + res as Response, + next as NextFunction + ); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'body_parse_failure', + method, + path: '/api/v1/resource', + requestId: 'req-test-id', + errorType: 'invalid_json', + }) + ); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: 'Invalid JSON in request body', + }) + ); + expect(next).not.toHaveBeenCalled(); + } ); - expect(res.status).toHaveBeenCalledWith(413); - }); - }); - describe('non-mutation methods', () => { - it('calls next() for GET with a SyntaxError (not a mutation)', () => { - const err = makeSyntaxError(); - const req = makeReq('GET'); - const res = makeRes(); - - bodyParseErrorMiddleware(err, req, res as Response, next as NextFunction); - - expect(next).toHaveBeenCalledWith(err); - expect(logger.error).not.toHaveBeenCalled(); - }); - - it('calls next() for HEAD with a SyntaxError', () => { - const err = makeSyntaxError(); - const req = makeReq('HEAD'); - const res = makeRes(); - - bodyParseErrorMiddleware(err, req, res as Response, next as NextFunction); - - expect(next).toHaveBeenCalledWith(err); - }); - }); - - describe('non-parse errors', () => { - it('calls next() for a generic Error on a mutation method', () => { - const err = new Error('Something else entirely'); - const req = makeReq('POST'); - const res = makeRes(); - - bodyParseErrorMiddleware(err, req, res as Response, next as NextFunction); + it('does not include raw body in the log', () => { + const err = makeSyntaxError(); + const req = makeReq('POST'); + const res = makeRes(); + + bodyParseErrorMiddleware( + err, + req, + res as Response, + next as NextFunction + ); + + const logCall = (logger.error as jest.Mock).mock.calls[0][0]; + expect(logCall).not.toHaveProperty('body'); + expect(logCall).not.toHaveProperty('rawBody'); + }); - expect(next).toHaveBeenCalledWith(err); - expect(logger.error).not.toHaveBeenCalled(); - }); + it('logs and returns 413 for entity.too.large on mutation', () => { + const err = makeEntityTooLargeError(); + const req = makeReq('POST'); + const res = makeRes(); + + bodyParseErrorMiddleware( + err, + req, + res as Response, + next as NextFunction + ); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ errorType: 'entity.too.large' }) + ); + expect(res.status).toHaveBeenCalledWith(413); + }); + }); + + describe('non-mutation methods', () => { + it('calls next() for GET with a SyntaxError (not a mutation)', () => { + const err = makeSyntaxError(); + const req = makeReq('GET'); + const res = makeRes(); + + bodyParseErrorMiddleware( + err, + req, + res as Response, + next as NextFunction + ); + + expect(next).toHaveBeenCalledWith(err); + expect(logger.error).not.toHaveBeenCalled(); + }); - it('calls next() for an ApiError on a mutation method', () => { - const err = Object.assign(new Error('Not found'), { statusCode: 404 }); - const req = makeReq('PUT'); - const res = makeRes(); + it('calls next() for HEAD with a SyntaxError', () => { + const err = makeSyntaxError(); + const req = makeReq('HEAD'); + const res = makeRes(); - bodyParseErrorMiddleware(err, req, res as Response, next as NextFunction); + bodyParseErrorMiddleware( + err, + req, + res as Response, + next as NextFunction + ); - expect(next).toHaveBeenCalledWith(err); - }); - }); + expect(next).toHaveBeenCalledWith(err); + }); + }); + + describe('non-parse errors', () => { + it('calls next() for a generic Error on a mutation method', () => { + const err = new Error('Something else entirely'); + const req = makeReq('POST'); + const res = makeRes(); + + bodyParseErrorMiddleware( + err, + req, + res as Response, + next as NextFunction + ); + + expect(next).toHaveBeenCalledWith(err); + expect(logger.error).not.toHaveBeenCalled(); + }); - describe('client response behaviour unchanged', () => { - it('returns 400 JSON with success:false and does not call next', () => { - const err = makeSyntaxError(); - const req = makeReq('POST'); - const res = makeRes(); + it('calls next() for an ApiError on a mutation method', () => { + const err = Object.assign(new Error('Not found'), { statusCode: 404 }); + const req = makeReq('PUT'); + const res = makeRes(); - bodyParseErrorMiddleware(err, req, res as Response, next as NextFunction); + bodyParseErrorMiddleware( + err, + req, + res as Response, + next as NextFunction + ); - expect(res.status).toHaveBeenCalledWith(400); - expect((res.json as jest.Mock).mock.calls[0][0]).toMatchObject({ - success: false, + expect(next).toHaveBeenCalledWith(err); + }); + }); + + describe('client response behaviour unchanged', () => { + it('returns 400 JSON with success:false and does not call next', () => { + const err = makeSyntaxError(); + const req = makeReq('POST'); + const res = makeRes(); + + bodyParseErrorMiddleware( + err, + req, + res as Response, + next as NextFunction + ); + + expect(res.status).toHaveBeenCalledWith(400); + expect((res.json as jest.Mock).mock.calls[0][0]).toMatchObject({ + success: false, + }); + expect(next).not.toHaveBeenCalled(); }); - expect(next).not.toHaveBeenCalled(); - }); - }); + }); }); diff --git a/src/middlewares/creator-param.middleware.test.ts b/src/middlewares/creator-param.middleware.test.ts index 8e583e5..efb2a88 100644 --- a/src/middlewares/creator-param.middleware.test.ts +++ b/src/middlewares/creator-param.middleware.test.ts @@ -3,149 +3,149 @@ import { Request, Response, NextFunction } from 'express'; import { validateCreatorParam } from './creator-param.middleware'; function makeReq(params: Record = {}): Request { - return { params } as unknown as Request; + return { params } as unknown as Request; } function makeRes(): Partial { - const res: Partial = {}; - res.status = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - res.setHeader = jest.fn().mockReturnValue(res); - return res; + const res: Partial = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + return res; } function makeNext(): jest.Mock { - return jest.fn(); + return jest.fn(); } describe('validateCreatorParam middleware', () => { - describe('valid params — calls next()', () => { - it('passes a standard alphanumeric handle', () => { - const next = makeNext(); - validateCreatorParam('creatorId')( - makeReq({ creatorId: 'alice123' }), - makeRes() as Response, - next as NextFunction - ); - expect(next).toHaveBeenCalledWith(); - expect(next).toHaveBeenCalledTimes(1); - }); + describe('valid params — calls next()', () => { + it('passes a standard alphanumeric handle', () => { + const next = makeNext(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: 'alice123' }), + makeRes() as Response, + next as NextFunction + ); + expect(next).toHaveBeenCalledWith(); + expect(next).toHaveBeenCalledTimes(1); + }); - it('passes a UUID-style id param', () => { - const next = makeNext(); - validateCreatorParam('id')( - makeReq({ id: 'abc123-def456' }), - makeRes() as Response, - next as NextFunction - ); - expect(next).toHaveBeenCalledWith(); - }); + it('passes a UUID-style id param', () => { + const next = makeNext(); + validateCreatorParam('id')( + makeReq({ id: 'abc123-def456' }), + makeRes() as Response, + next as NextFunction + ); + expect(next).toHaveBeenCalledWith(); + }); - it('passes a handle with underscores and hyphens', () => { - const next = makeNext(); - validateCreatorParam('creatorId')( - makeReq({ creatorId: 'jazz_king-99' }), - makeRes() as Response, - next as NextFunction - ); - expect(next).toHaveBeenCalledWith(); - }); + it('passes a handle with underscores and hyphens', () => { + const next = makeNext(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: 'jazz_king-99' }), + makeRes() as Response, + next as NextFunction + ); + expect(next).toHaveBeenCalledWith(); + }); - it('passes a single character param', () => { - const next = makeNext(); - validateCreatorParam('creatorId')( - makeReq({ creatorId: 'a' }), - makeRes() as Response, - next as NextFunction - ); - expect(next).toHaveBeenCalledWith(); - }); - }); + it('passes a single character param', () => { + const next = makeNext(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: 'a' }), + makeRes() as Response, + next as NextFunction + ); + expect(next).toHaveBeenCalledWith(); + }); + }); - describe('invalid params — returns 400 and does not call next()', () => { - it('rejects a missing param key', () => { - const next = makeNext(); - const res = makeRes(); - validateCreatorParam('creatorId')( - makeReq({}), - res as Response, - next as NextFunction - ); - expect(next).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(400); - }); + describe('invalid params — returns 400 and does not call next()', () => { + it('rejects a missing param key', () => { + const next = makeNext(); + const res = makeRes(); + validateCreatorParam('creatorId')( + makeReq({}), + res as Response, + next as NextFunction + ); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); - it('rejects a param with special characters', () => { - const next = makeNext(); - const res = makeRes(); - validateCreatorParam('creatorId')( - makeReq({ creatorId: 'bad param!' }), - res as Response, - next as NextFunction - ); - expect(next).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(400); - }); + it('rejects a param with special characters', () => { + const next = makeNext(); + const res = makeRes(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: 'bad param!' }), + res as Response, + next as NextFunction + ); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); - it('rejects a param exceeding 128 characters', () => { - const next = makeNext(); - const res = makeRes(); - validateCreatorParam('creatorId')( - makeReq({ creatorId: 'a'.repeat(129) }), - res as Response, - next as NextFunction - ); - expect(next).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(400); - }); + it('rejects a param exceeding 128 characters', () => { + const next = makeNext(); + const res = makeRes(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: 'a'.repeat(129) }), + res as Response, + next as NextFunction + ); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); - it('rejects a param with path traversal characters', () => { - const next = makeNext(); - const res = makeRes(); - validateCreatorParam('creatorId')( - makeReq({ creatorId: '../admin' }), - res as Response, - next as NextFunction - ); - expect(next).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(400); - }); + it('rejects a param with path traversal characters', () => { + const next = makeNext(); + const res = makeRes(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: '../admin' }), + res as Response, + next as NextFunction + ); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); - it('rejects a param with spaces', () => { - const next = makeNext(); - const res = makeRes(); - validateCreatorParam('creatorId')( - makeReq({ creatorId: 'hello world' }), - res as Response, - next as NextFunction - ); - expect(next).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(400); - }); + it('rejects a param with spaces', () => { + const next = makeNext(); + const res = makeRes(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: 'hello world' }), + res as Response, + next as NextFunction + ); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); - it('error response includes the param field name', () => { - const next = makeNext(); - const res = makeRes(); - validateCreatorParam('creatorId')( - makeReq({ creatorId: 'bad!' }), - res as Response, - next as NextFunction - ); - const body = (res.json as jest.Mock).mock.calls[0][0]; - expect(body.error.details[0].field).toBe('creatorId'); - }); + it('error response includes the param field name', () => { + const next = makeNext(); + const res = makeRes(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: 'bad!' }), + res as Response, + next as NextFunction + ); + const body = (res.json as jest.Mock).mock.calls[0][0]; + expect(body.error.details[0].field).toBe('creatorId'); + }); - it('works with a different param name (id)', () => { - const next = makeNext(); - const res = makeRes(); - validateCreatorParam('id')( - makeReq({ id: 'bad@id' }), - res as Response, - next as NextFunction - ); - expect(next).not.toHaveBeenCalled(); - const body = (res.json as jest.Mock).mock.calls[0][0]; - expect(body.error.details[0].field).toBe('id'); - }); - }); -}); \ No newline at end of file + it('works with a different param name (id)', () => { + const next = makeNext(); + const res = makeRes(); + validateCreatorParam('id')( + makeReq({ id: 'bad@id' }), + res as Response, + next as NextFunction + ); + expect(next).not.toHaveBeenCalled(); + const body = (res.json as jest.Mock).mock.calls[0][0]; + expect(body.error.details[0].field).toBe('id'); + }); + }); +}); diff --git a/src/middlewares/creator-param.middleware.ts b/src/middlewares/creator-param.middleware.ts index 0f9fe1d..22c3a60 100644 --- a/src/middlewares/creator-param.middleware.ts +++ b/src/middlewares/creator-param.middleware.ts @@ -24,29 +24,29 @@ const CREATOR_PARAM_PATTERN = /^[a-zA-Z0-9_-]{1,128}$/; * router.get('/:id/stats', validateCreatorParam('id'), handler); */ export function validateCreatorParam(paramName: string) { - return (req: Request, res: Response, next: NextFunction): void => { - const value = req.params[paramName]; + return (req: Request, res: Response, next: NextFunction): void => { + const value = req.params[paramName]; - if (!value || typeof value !== 'string') { - sendValidationError(res, 'Invalid creator parameter', [ - { - field: paramName, - message: `Route parameter '${paramName}' is required`, - }, - ]); - return; - } + if (!value || typeof value !== 'string') { + sendValidationError(res, 'Invalid creator parameter', [ + { + field: paramName, + message: `Route parameter '${paramName}' is required`, + }, + ]); + return; + } - if (!CREATOR_PARAM_PATTERN.test(value)) { - sendValidationError(res, 'Invalid creator parameter', [ - { - field: paramName, - message: `Route parameter '${paramName}' contains invalid characters or exceeds maximum length`, - }, - ]); - return; - } + if (!CREATOR_PARAM_PATTERN.test(value)) { + sendValidationError(res, 'Invalid creator parameter', [ + { + field: paramName, + message: `Route parameter '${paramName}' contains invalid characters or exceeds maximum length`, + }, + ]); + return; + } - next(); - }; -} \ No newline at end of file + next(); + }; +} diff --git a/src/middlewares/deprecation.middleware.test.ts b/src/middlewares/deprecation.middleware.test.ts index ba99140..12388e4 100644 --- a/src/middlewares/deprecation.middleware.test.ts +++ b/src/middlewares/deprecation.middleware.test.ts @@ -22,8 +22,14 @@ function run() { { const res = mockRes(); let called = false; - const next: NextFunction = () => { called = true; }; - deprecate({ deprecatedSince: '2026-01-01T00:00:00Z' })(mockReq(), res, next); + const next: NextFunction = () => { + called = true; + }; + deprecate({ deprecatedSince: '2026-01-01T00:00:00Z' })( + mockReq(), + res, + next + ); assert.equal(res.headers['Deprecation'], '2026-01-01T00:00:00Z'); assert.ok(called, 'next() should be called'); } @@ -41,7 +47,11 @@ function run() { // omits Sunset header when not provided { const res = mockRes(); - deprecate({ deprecatedSince: '2026-01-01T00:00:00Z' })(mockReq(), res, () => {}); + deprecate({ deprecatedSince: '2026-01-01T00:00:00Z' })( + mockReq(), + res, + () => {} + ); assert.ok(!('Sunset' in res.headers), 'Sunset should not be set'); } @@ -52,13 +62,20 @@ function run() { deprecatedSince: '2026-01-01T00:00:00Z', link: '/api/v2/creators', })(mockReq(), res, () => {}); - assert.equal(res.headers['Link'], '; rel="successor-version"'); + assert.equal( + res.headers['Link'], + '; rel="successor-version"' + ); } // omits Link header when not provided { const res = mockRes(); - deprecate({ deprecatedSince: '2026-01-01T00:00:00Z' })(mockReq(), res, () => {}); + deprecate({ deprecatedSince: '2026-01-01T00:00:00Z' })( + mockReq(), + res, + () => {} + ); assert.ok(!('Link' in res.headers), 'Link should not be set'); } diff --git a/src/middlewares/error.middleware.ts b/src/middlewares/error.middleware.ts index ca8f53f..e254f19 100644 --- a/src/middlewares/error.middleware.ts +++ b/src/middlewares/error.middleware.ts @@ -9,7 +9,10 @@ import { RpcTimeoutError } from '../utils/rpc-timeout.utils'; import { mapUnknownRouteError } from '../utils/route-error.utils'; import { buildErrorContext } from '../utils/error-context.utils'; import { sanitizeLogFieldValue } from '../utils/log-field-sanitizer.utils'; -import { buildErrorResponse, zodIssuesToDetails } from '../utils/api-response.utils'; +import { + buildErrorResponse, + zodIssuesToDetails, +} from '../utils/api-response.utils'; export class ApiError extends Error { statusCode: number; diff --git a/src/middlewares/request-context.middleware.ts b/src/middlewares/request-context.middleware.ts index 4de5e65..8ef3f89 100644 --- a/src/middlewares/request-context.middleware.ts +++ b/src/middlewares/request-context.middleware.ts @@ -2,17 +2,17 @@ import { Request, Response, NextFunction } from 'express'; import { requestContextStorage } from '../utils/als.utils'; export const requestContextMiddleware = ( - req: Request, - _res: Response, - next: NextFunction + req: Request, + _res: Response, + next: NextFunction ): void => { - const context = { - path: req.originalUrl || req.url, - method: req.method, - requestId: req.requestId as string | undefined, - }; + const context = { + path: req.originalUrl || req.url, + method: req.method, + requestId: req.requestId as string | undefined, + }; - requestContextStorage.run(context, () => { - next(); - }); + requestContextStorage.run(context, () => { + next(); + }); }; diff --git a/src/middlewares/request-entry-logger.middleware.test.ts b/src/middlewares/request-entry-logger.middleware.test.ts index 7a40ec0..f392db7 100644 --- a/src/middlewares/request-entry-logger.middleware.test.ts +++ b/src/middlewares/request-entry-logger.middleware.test.ts @@ -2,115 +2,135 @@ import { requestEntryLoggerMiddleware } from './request-entry-logger.middleware' import { logger } from '../utils/logger.utils'; jest.mock('../utils/logger.utils', () => ({ - logger: { info: jest.fn() }, + logger: { info: jest.fn() }, })); function makeReq(overrides: Record = {}): any { - return { - method: 'GET', - path: '/api/v1/health', - requestId: 'test-request-id', - ip: '203.0.113.42', - headers: { 'user-agent': 'jest-test-agent' }, - socket: { remoteAddress: '203.0.113.42' }, - ...overrides, - }; + return { + method: 'GET', + path: '/api/v1/health', + requestId: 'test-request-id', + ip: '203.0.113.42', + headers: { 'user-agent': 'jest-test-agent' }, + socket: { remoteAddress: '203.0.113.42' }, + ...overrides, + }; } function makeRes(): any { - return {}; + return {}; } describe('requestEntryLoggerMiddleware', () => { - beforeEach(() => jest.clearAllMocks()); + beforeEach(() => jest.clearAllMocks()); - it('emits a log before calling next', () => { - const next = jest.fn(); - requestEntryLoggerMiddleware(makeReq(), makeRes(), next); - expect((logger.info as jest.Mock).mock.calls.length).toBe(1); - expect(next).toHaveBeenCalled(); - const callOrder = (logger.info as jest.Mock).mock.invocationCallOrder[0]; - const nextOrder = (next as jest.Mock).mock.invocationCallOrder[0]; - expect(callOrder).toBeLessThan(nextOrder); - }); + it('emits a log before calling next', () => { + const next = jest.fn(); + requestEntryLoggerMiddleware(makeReq(), makeRes(), next); + expect((logger.info as jest.Mock).mock.calls.length).toBe(1); + expect(next).toHaveBeenCalled(); + const callOrder = (logger.info as jest.Mock).mock.invocationCallOrder[0]; + const nextOrder = (next as jest.Mock).mock.invocationCallOrder[0]; + expect(callOrder).toBeLessThan(nextOrder); + }); - it('includes all five required fields', () => { - requestEntryLoggerMiddleware(makeReq(), makeRes(), jest.fn()); - const log = (logger.info as jest.Mock).mock.calls[0][0]; - expect(log).toHaveProperty('request_id'); - expect(log).toHaveProperty('method'); - expect(log).toHaveProperty('path'); - expect(log).toHaveProperty('ip'); - expect(log).toHaveProperty('user_agent'); - }); + it('includes all five required fields', () => { + requestEntryLoggerMiddleware(makeReq(), makeRes(), jest.fn()); + const log = (logger.info as jest.Mock).mock.calls[0][0]; + expect(log).toHaveProperty('request_id'); + expect(log).toHaveProperty('method'); + expect(log).toHaveProperty('path'); + expect(log).toHaveProperty('ip'); + expect(log).toHaveProperty('user_agent'); + }); - it('uses req.requestId as request_id when present', () => { - requestEntryLoggerMiddleware(makeReq({ requestId: 'my-trace-id' }), makeRes(), jest.fn()); - const log = (logger.info as jest.Mock).mock.calls[0][0]; - expect(log.request_id).toBe('my-trace-id'); - }); + it('uses req.requestId as request_id when present', () => { + requestEntryLoggerMiddleware( + makeReq({ requestId: 'my-trace-id' }), + makeRes(), + jest.fn() + ); + const log = (logger.info as jest.Mock).mock.calls[0][0]; + expect(log.request_id).toBe('my-trace-id'); + }); - it('generates a UUID when requestId is absent', () => { - const req = makeReq(); - delete req.requestId; - requestEntryLoggerMiddleware(req, makeRes(), jest.fn()); - const log = (logger.info as jest.Mock).mock.calls[0][0]; - expect(log.request_id).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - ); - }); + it('generates a UUID when requestId is absent', () => { + const req = makeReq(); + delete req.requestId; + requestEntryLoggerMiddleware(req, makeRes(), jest.fn()); + const log = (logger.info as jest.Mock).mock.calls[0][0]; + expect(log.request_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); - it('masks IP to /24 subnet (zeroes last octet)', () => { - requestEntryLoggerMiddleware(makeReq({ ip: '203.0.113.42' }), makeRes(), jest.fn()); - const log = (logger.info as jest.Mock).mock.calls[0][0]; - expect(log.ip).toBe('203.0.113.0'); - }); + it('masks IP to /24 subnet (zeroes last octet)', () => { + requestEntryLoggerMiddleware( + makeReq({ ip: '203.0.113.42' }), + makeRes(), + jest.fn() + ); + const log = (logger.info as jest.Mock).mock.calls[0][0]; + expect(log.ip).toBe('203.0.113.0'); + }); - it('masks different /24 subnets correctly', () => { - requestEntryLoggerMiddleware(makeReq({ ip: '192.168.1.99' }), makeRes(), jest.fn()); - const log = (logger.info as jest.Mock).mock.calls[0][0]; - expect(log.ip).toBe('192.168.1.0'); - }); + it('masks different /24 subnets correctly', () => { + requestEntryLoggerMiddleware( + makeReq({ ip: '192.168.1.99' }), + makeRes(), + jest.fn() + ); + const log = (logger.info as jest.Mock).mock.calls[0][0]; + expect(log.ip).toBe('192.168.1.0'); + }); - it('does not include request body', () => { - requestEntryLoggerMiddleware( - makeReq({ body: { password: 'secret' } }), - makeRes(), - jest.fn() - ); - const log = (logger.info as jest.Mock).mock.calls[0][0]; - expect(log).not.toHaveProperty('body'); - }); + it('does not include request body', () => { + requestEntryLoggerMiddleware( + makeReq({ body: { password: 'secret' } }), + makeRes(), + jest.fn() + ); + const log = (logger.info as jest.Mock).mock.calls[0][0]; + expect(log).not.toHaveProperty('body'); + }); - it('does not include query string', () => { - requestEntryLoggerMiddleware( - makeReq({ query: { secret_token: 'abc' } }), - makeRes(), - jest.fn() - ); - const log = (logger.info as jest.Mock).mock.calls[0][0]; - expect(log).not.toHaveProperty('query'); - }); + it('does not include query string', () => { + requestEntryLoggerMiddleware( + makeReq({ query: { secret_token: 'abc' } }), + makeRes(), + jest.fn() + ); + const log = (logger.info as jest.Mock).mock.calls[0][0]; + expect(log).not.toHaveProperty('query'); + }); - it('logs the correct method', () => { - requestEntryLoggerMiddleware(makeReq({ method: 'POST' }), makeRes(), jest.fn()); - const log = (logger.info as jest.Mock).mock.calls[0][0]; - expect(log.method).toBe('POST'); - }); + it('logs the correct method', () => { + requestEntryLoggerMiddleware( + makeReq({ method: 'POST' }), + makeRes(), + jest.fn() + ); + const log = (logger.info as jest.Mock).mock.calls[0][0]; + expect(log.method).toBe('POST'); + }); - it('logs the correct path', () => { - requestEntryLoggerMiddleware(makeReq({ path: '/api/v1/creators' }), makeRes(), jest.fn()); - const log = (logger.info as jest.Mock).mock.calls[0][0]; - expect(log.path).toBe('/api/v1/creators'); - }); + it('logs the correct path', () => { + requestEntryLoggerMiddleware( + makeReq({ path: '/api/v1/creators' }), + makeRes(), + jest.fn() + ); + const log = (logger.info as jest.Mock).mock.calls[0][0]; + expect(log.path).toBe('/api/v1/creators'); + }); - it('logs the user-agent header', () => { - requestEntryLoggerMiddleware( - makeReq({ headers: { 'user-agent': 'Mozilla/5.0' } }), - makeRes(), - jest.fn() - ); - const log = (logger.info as jest.Mock).mock.calls[0][0]; - expect(log.user_agent).toBe('Mozilla/5.0'); - }); + it('logs the user-agent header', () => { + requestEntryLoggerMiddleware( + makeReq({ headers: { 'user-agent': 'Mozilla/5.0' } }), + makeRes(), + jest.fn() + ); + const log = (logger.info as jest.Mock).mock.calls[0][0]; + expect(log.user_agent).toBe('Mozilla/5.0'); + }); }); diff --git a/src/middlewares/request-entry-logger.middleware.ts b/src/middlewares/request-entry-logger.middleware.ts index 72ee8f4..765713b 100644 --- a/src/middlewares/request-entry-logger.middleware.ts +++ b/src/middlewares/request-entry-logger.middleware.ts @@ -3,29 +3,29 @@ import crypto from 'crypto'; import { logger } from '../utils/logger.utils'; function maskIpToSubnet24(ip: string | undefined): string | undefined { - if (!ip) return undefined; - const parts = ip.split('.'); - if (parts.length === 4) { - return `${parts[0]}.${parts[1]}.${parts[2]}.0`; - } - return ip; + if (!ip) return undefined; + const parts = ip.split('.'); + if (parts.length === 4) { + return `${parts[0]}.${parts[1]}.${parts[2]}.0`; + } + return ip; } export const requestEntryLoggerMiddleware = ( - req: Request, - _res: Response, - next: NextFunction + req: Request, + _res: Response, + next: NextFunction ): void => { - const requestId = (req as any).requestId ?? crypto.randomUUID(); + const requestId = (req as any).requestId ?? crypto.randomUUID(); - logger.info({ - type: 'request_entry', - request_id: requestId, - method: req.method, - path: req.path, - ip: maskIpToSubnet24((req as any).ip ?? req.socket?.remoteAddress), - user_agent: req.headers['user-agent'], - }); + logger.info({ + type: 'request_entry', + request_id: requestId, + method: req.method, + path: req.path, + ip: maskIpToSubnet24((req as any).ip ?? req.socket?.remoteAddress), + user_agent: req.headers['user-agent'], + }); - next(); + next(); }; diff --git a/src/middlewares/schema-version.middleware.test.ts b/src/middlewares/schema-version.middleware.test.ts index a036fea..34adfde 100644 --- a/src/middlewares/schema-version.middleware.test.ts +++ b/src/middlewares/schema-version.middleware.test.ts @@ -1,7 +1,10 @@ import { strict as assert } from 'assert'; import { schemaVersionMiddleware } from './schema-version.middleware'; import type { Request, Response, NextFunction } from 'express'; -import { REQUEST_SCHEMA_VERSION, SCHEMA_VERSION_HEADER } from '../constants/schema.constants'; +import { + REQUEST_SCHEMA_VERSION, + SCHEMA_VERSION_HEADER, +} from '../constants/schema.constants'; import { envConfig } from '../config'; // Minimal mock helpers @@ -27,16 +30,16 @@ function run() { const next: NextFunction = () => { called = true; }; - + // Ensure it's enabled for the test const originalValue = envConfig.ENABLE_SCHEMA_VERSION_HEADER; (envConfig as any).ENABLE_SCHEMA_VERSION_HEADER = true; - + schemaVersionMiddleware(mockReq(), res, next); - + assert.equal(res.headers[SCHEMA_VERSION_HEADER], REQUEST_SCHEMA_VERSION); assert.ok(called, 'next() should be called'); - + // Restore (envConfig as any).ENABLE_SCHEMA_VERSION_HEADER = originalValue; } @@ -48,16 +51,19 @@ function run() { const next: NextFunction = () => { called = true; }; - + // Ensure it's disabled for the test const originalValue = envConfig.ENABLE_SCHEMA_VERSION_HEADER; (envConfig as any).ENABLE_SCHEMA_VERSION_HEADER = false; - + schemaVersionMiddleware(mockReq(), res, next); - - assert.ok(!(SCHEMA_VERSION_HEADER in res.headers), 'Header should not be set'); + + assert.ok( + !(SCHEMA_VERSION_HEADER in res.headers), + 'Header should not be set' + ); assert.ok(called, 'next() should be called'); - + // Restore (envConfig as any).ENABLE_SCHEMA_VERSION_HEADER = originalValue; } diff --git a/src/middlewares/schema-version.middleware.ts b/src/middlewares/schema-version.middleware.ts index 64b63f4..77537c3 100644 --- a/src/middlewares/schema-version.middleware.ts +++ b/src/middlewares/schema-version.middleware.ts @@ -1,7 +1,10 @@ // src/middlewares/schema-version.middleware.ts import { Request, Response, NextFunction } from 'express'; import { envConfig } from '../config'; -import { REQUEST_SCHEMA_VERSION, SCHEMA_VERSION_HEADER } from '../constants/schema.constants'; +import { + REQUEST_SCHEMA_VERSION, + SCHEMA_VERSION_HEADER, +} from '../constants/schema.constants'; /** * Middleware that adds a schema version header to the response. diff --git a/src/middlewares/stellar-signature.middleware.test.ts b/src/middlewares/stellar-signature.middleware.test.ts index 66f0325..189ad2c 100644 --- a/src/middlewares/stellar-signature.middleware.test.ts +++ b/src/middlewares/stellar-signature.middleware.test.ts @@ -11,158 +11,158 @@ const USER_ID = 'sig-test-user-1'; const HANDLE = 'sig-test-creator-1'; function buildCanonicalMessage(body: unknown, timestamp: string): Buffer { - const bodyJson = JSON.stringify(body); - const payload = `${bodyJson}${timestamp}`; - return createHash('sha256').update(payload, 'utf8').digest(); + const bodyJson = JSON.stringify(body); + const payload = `${bodyJson}${timestamp}`; + return createHash('sha256').update(payload, 'utf8').digest(); } describe('#418 Stellar signature verification middleware', () => { - let creatorId: string; - let keypair: Keypair; - - beforeAll(async () => { - keypair = Keypair.random(); - - const user = await prisma.user.upsert({ - where: { id: USER_ID }, - create: { - id: USER_ID, - email: 'sig-test@example.test', - passwordHash: 'dummy-hash', - firstName: 'Sig', - lastName: 'Test', - }, - update: {}, - }); - - await prisma.stellarWallet.upsert({ - where: { userId: user.id }, - create: { - userId: user.id, - address: keypair.publicKey(), - }, - update: { - address: keypair.publicKey(), - }, - }); - - const creator = await prisma.creatorProfile.upsert({ - where: { userId: user.id }, - create: { - userId: user.id, - handle: HANDLE, - displayName: 'Sig Test Creator', - }, - update: {}, - }); - - creatorId = creator.id; - }); - - afterAll(async () => { - await prisma.creatorProfile.deleteMany({ where: { handle: HANDLE } }); - await prisma.stellarWallet.deleteMany({ where: { userId: USER_ID } }); - await prisma.user.deleteMany({ where: { id: USER_ID } }); - await prisma.$disconnect(); - }); - - it('rejects request with missing signature headers', async () => { - const res = await supertest(app) - .put(`/api/v1/creators/${creatorId}/profile`) - .send({ displayName: 'Test' }); - - expect(res.status).toBe(401); - expect(res.body.success).toBe(false); - expect(res.body.error.code).toBe('UNAUTHORIZED'); - }); - - it('rejects request with expired timestamp', async () => { - const body = { displayName: 'Test' }; - const timestamp = (Date.now() - 10 * 60 * 1000).toString(); // 10 min ago - const message = buildCanonicalMessage(body, timestamp); - const signature = keypair.sign(message).toString('base64'); - - const res = await supertest(app) - .put(`/api/v1/creators/${creatorId}/profile`) - .set('x-wallet-address', keypair.publicKey()) - .set('x-wallet-signature', signature) - .set('x-timestamp', timestamp) - .send(body); - - expect(res.status).toBe(401); - expect(res.body.error.message).toContain('expired'); - }); - - it('rejects request with tampered body', async () => { - const originalBody = { displayName: 'Original' }; - const timestamp = Date.now().toString(); - const message = buildCanonicalMessage(originalBody, timestamp); - const signature = keypair.sign(message).toString('base64'); - - // Send different body than what was signed - const tamperedBody = { displayName: 'Tampered' }; - - const res = await supertest(app) - .put(`/api/v1/creators/${creatorId}/profile`) - .set('x-wallet-address', keypair.publicKey()) - .set('x-wallet-signature', signature) - .set('x-timestamp', timestamp) - .send(tamperedBody); - - expect(res.status).toBe(403); - expect(res.body.error.code).toBe('FORBIDDEN'); - expect(res.body.error.message).toContain('tampered'); - }); - - it('rejects request signed with wrong keypair', async () => { - const wrongKeypair = Keypair.random(); - const body = { displayName: 'Test' }; - const timestamp = Date.now().toString(); - const message = buildCanonicalMessage(body, timestamp); - const signature = wrongKeypair.sign(message).toString('base64'); - - const res = await supertest(app) - .put(`/api/v1/creators/${creatorId}/profile`) - .set('x-wallet-address', keypair.publicKey()) // Correct address - .set('x-wallet-signature', signature) // But wrong signature - .set('x-timestamp', timestamp) - .send(body); - - expect(res.status).toBe(403); - expect(res.body.error.code).toBe('FORBIDDEN'); - }); - - it('accepts valid signature and updates profile', async () => { - const body = { displayName: 'Valid Update' }; - const timestamp = Date.now().toString(); - const message = buildCanonicalMessage(body, timestamp); - const signature = keypair.sign(message).toString('base64'); - - const res = await supertest(app) - .put(`/api/v1/creators/${creatorId}/profile`) - .set('x-wallet-address', keypair.publicKey()) - .set('x-wallet-signature', signature) - .set('x-timestamp', timestamp) - .send(body); - - expect(res.status).toBe(202); - expect(res.body.success).toBe(true); - }); - - it('rejects invalid Stellar address format', async () => { - const body = { displayName: 'Test' }; - const timestamp = Date.now().toString(); - const message = buildCanonicalMessage(body, timestamp); - const signature = keypair.sign(message).toString('base64'); - - const res = await supertest(app) - .put(`/api/v1/creators/${creatorId}/profile`) - .set('x-wallet-address', 'INVALID_ADDRESS') - .set('x-wallet-signature', signature) - .set('x-timestamp', timestamp) - .send(body); - - expect(res.status).toBe(400); - expect(res.body.error.code).toBe('BAD_REQUEST'); - }); + let creatorId: string; + let keypair: Keypair; + + beforeAll(async () => { + keypair = Keypair.random(); + + const user = await prisma.user.upsert({ + where: { id: USER_ID }, + create: { + id: USER_ID, + email: 'sig-test@example.test', + passwordHash: 'dummy-hash', + firstName: 'Sig', + lastName: 'Test', + }, + update: {}, + }); + + await prisma.stellarWallet.upsert({ + where: { userId: user.id }, + create: { + userId: user.id, + address: keypair.publicKey(), + }, + update: { + address: keypair.publicKey(), + }, + }); + + const creator = await prisma.creatorProfile.upsert({ + where: { userId: user.id }, + create: { + userId: user.id, + handle: HANDLE, + displayName: 'Sig Test Creator', + }, + update: {}, + }); + + creatorId = creator.id; + }); + + afterAll(async () => { + await prisma.creatorProfile.deleteMany({ where: { handle: HANDLE } }); + await prisma.stellarWallet.deleteMany({ where: { userId: USER_ID } }); + await prisma.user.deleteMany({ where: { id: USER_ID } }); + await prisma.$disconnect(); + }); + + it('rejects request with missing signature headers', async () => { + const res = await supertest(app) + .put(`/api/v1/creators/${creatorId}/profile`) + .send({ displayName: 'Test' }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('UNAUTHORIZED'); + }); + + it('rejects request with expired timestamp', async () => { + const body = { displayName: 'Test' }; + const timestamp = (Date.now() - 10 * 60 * 1000).toString(); // 10 min ago + const message = buildCanonicalMessage(body, timestamp); + const signature = keypair.sign(message).toString('base64'); + + const res = await supertest(app) + .put(`/api/v1/creators/${creatorId}/profile`) + .set('x-wallet-address', keypair.publicKey()) + .set('x-wallet-signature', signature) + .set('x-timestamp', timestamp) + .send(body); + + expect(res.status).toBe(401); + expect(res.body.error.message).toContain('expired'); + }); + + it('rejects request with tampered body', async () => { + const originalBody = { displayName: 'Original' }; + const timestamp = Date.now().toString(); + const message = buildCanonicalMessage(originalBody, timestamp); + const signature = keypair.sign(message).toString('base64'); + + // Send different body than what was signed + const tamperedBody = { displayName: 'Tampered' }; + + const res = await supertest(app) + .put(`/api/v1/creators/${creatorId}/profile`) + .set('x-wallet-address', keypair.publicKey()) + .set('x-wallet-signature', signature) + .set('x-timestamp', timestamp) + .send(tamperedBody); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('FORBIDDEN'); + expect(res.body.error.message).toContain('tampered'); + }); + + it('rejects request signed with wrong keypair', async () => { + const wrongKeypair = Keypair.random(); + const body = { displayName: 'Test' }; + const timestamp = Date.now().toString(); + const message = buildCanonicalMessage(body, timestamp); + const signature = wrongKeypair.sign(message).toString('base64'); + + const res = await supertest(app) + .put(`/api/v1/creators/${creatorId}/profile`) + .set('x-wallet-address', keypair.publicKey()) // Correct address + .set('x-wallet-signature', signature) // But wrong signature + .set('x-timestamp', timestamp) + .send(body); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('FORBIDDEN'); + }); + + it('accepts valid signature and updates profile', async () => { + const body = { displayName: 'Valid Update' }; + const timestamp = Date.now().toString(); + const message = buildCanonicalMessage(body, timestamp); + const signature = keypair.sign(message).toString('base64'); + + const res = await supertest(app) + .put(`/api/v1/creators/${creatorId}/profile`) + .set('x-wallet-address', keypair.publicKey()) + .set('x-wallet-signature', signature) + .set('x-timestamp', timestamp) + .send(body); + + expect(res.status).toBe(202); + expect(res.body.success).toBe(true); + }); + + it('rejects invalid Stellar address format', async () => { + const body = { displayName: 'Test' }; + const timestamp = Date.now().toString(); + const message = buildCanonicalMessage(body, timestamp); + const signature = keypair.sign(message).toString('base64'); + + const res = await supertest(app) + .put(`/api/v1/creators/${creatorId}/profile`) + .set('x-wallet-address', 'INVALID_ADDRESS') + .set('x-wallet-signature', signature) + .set('x-timestamp', timestamp) + .send(body); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('BAD_REQUEST'); + }); }); diff --git a/src/middlewares/stellar-signature.middleware.ts b/src/middlewares/stellar-signature.middleware.ts index 106eb18..2edfad4 100644 --- a/src/middlewares/stellar-signature.middleware.ts +++ b/src/middlewares/stellar-signature.middleware.ts @@ -11,150 +11,150 @@ import { ErrorCode } from '../constants/error.constants'; import { logger } from '../utils/logger.utils'; export interface StellarSignedRequest extends Request { - walletAddress?: string; - signatureVerified?: boolean; + walletAddress?: string; + signatureVerified?: boolean; } const SIGNATURE_TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; // 5 minutes function readHeader(req: Request, name: string): string | undefined { - const raw = req.headers[name]; - if (Array.isArray(raw)) return raw[0]?.trim() || undefined; - return typeof raw === 'string' ? raw.trim() || undefined : undefined; + const raw = req.headers[name]; + if (Array.isArray(raw)) return raw[0]?.trim() || undefined; + return typeof raw === 'string' ? raw.trim() || undefined : undefined; } /** * Builds the canonical message that the client must sign. - * + * * Format: SHA256(body_json + timestamp) - * + * * This prevents replay attacks (timestamp) and body tampering (body hash). */ function buildCanonicalMessage(body: unknown, timestamp: string): Buffer { - const bodyJson = JSON.stringify(body); - const payload = `${bodyJson}${timestamp}`; - return createHash('sha256').update(payload, 'utf8').digest(); + const bodyJson = JSON.stringify(body); + const payload = `${bodyJson}${timestamp}`; + return createHash('sha256').update(payload, 'utf8').digest(); } /** * Middleware that verifies Stellar Ed25519 signatures for mutating routes. - * + * * Required headers: * - x-wallet-address: Stellar public key (56 chars, starts with G) * - x-wallet-signature: Base64-encoded Ed25519 signature * - x-timestamp: Unix timestamp in milliseconds (must be within 5 min) - * + * * The signature must be computed over SHA256(body_json + timestamp). - * + * * On success, attaches `walletAddress` and `signatureVerified=true` to the request. * On failure, returns 401 (missing/expired) or 403 (invalid signature). */ export function requireStellarSignature() { - return async ( - req: StellarSignedRequest, - res: Response, - next: NextFunction - ): Promise => { - const address = readHeader(req, 'x-wallet-address'); - const signature = readHeader(req, 'x-wallet-signature'); - const timestamp = readHeader(req, 'x-timestamp'); + return async ( + req: StellarSignedRequest, + res: Response, + next: NextFunction + ): Promise => { + const address = readHeader(req, 'x-wallet-address'); + const signature = readHeader(req, 'x-wallet-signature'); + const timestamp = readHeader(req, 'x-timestamp'); - if (!address || !signature || !timestamp) { - sendError( - res, - 401, - ErrorCode.UNAUTHORIZED, - 'Missing required signature headers: x-wallet-address, x-wallet-signature, x-timestamp' - ); - return; - } - - // Validate wallet address format - const addressValidation = StellarAddressSchema.safeParse(address); - if (!addressValidation.success) { - sendError( - res, - 400, - ErrorCode.BAD_REQUEST, - 'Invalid Stellar wallet address format', - addressValidation.error.issues.map((issue) => ({ - field: 'x-wallet-address', - message: issue.message, - })) - ); - return; - } + if (!address || !signature || !timestamp) { + sendError( + res, + 401, + ErrorCode.UNAUTHORIZED, + 'Missing required signature headers: x-wallet-address, x-wallet-signature, x-timestamp' + ); + return; + } - // Validate timestamp freshness - const ts = parseInt(timestamp, 10); - if (isNaN(ts)) { - sendError( - res, - 400, - ErrorCode.BAD_REQUEST, - 'Invalid timestamp format — must be Unix milliseconds' - ); - return; - } + // Validate wallet address format + const addressValidation = StellarAddressSchema.safeParse(address); + if (!addressValidation.success) { + sendError( + res, + 400, + ErrorCode.BAD_REQUEST, + 'Invalid Stellar wallet address format', + addressValidation.error.issues.map(issue => ({ + field: 'x-wallet-address', + message: issue.message, + })) + ); + return; + } - const now = Date.now(); - const age = now - ts; - if (age < 0 || age > SIGNATURE_TIMESTAMP_TOLERANCE_MS) { - sendError( - res, - 401, - ErrorCode.UNAUTHORIZED, - 'Signature timestamp is invalid or expired (must be within 5 minutes)' - ); - return; - } + // Validate timestamp freshness + const ts = parseInt(timestamp, 10); + if (isNaN(ts)) { + sendError( + res, + 400, + ErrorCode.BAD_REQUEST, + 'Invalid timestamp format — must be Unix milliseconds' + ); + return; + } - // Build canonical message and verify signature - try { - const message = buildCanonicalMessage(req.body, timestamp); - const signatureBuffer = Buffer.from(signature, 'base64'); - - if (signatureBuffer.length !== 64) { - sendError( - res, - 400, - ErrorCode.BAD_REQUEST, - 'Invalid signature format — Ed25519 signatures must be 64 bytes' - ); - return; + const now = Date.now(); + const age = now - ts; + if (age < 0 || age > SIGNATURE_TIMESTAMP_TOLERANCE_MS) { + sendError( + res, + 401, + ErrorCode.UNAUTHORIZED, + 'Signature timestamp is invalid or expired (must be within 5 minutes)' + ); + return; } - const keypair = Keypair.fromPublicKey(address); - const verified = keypair.verify(message, signatureBuffer); + // Build canonical message and verify signature + try { + const message = buildCanonicalMessage(req.body, timestamp); + const signatureBuffer = Buffer.from(signature, 'base64'); - if (!verified) { - logger.warn( - { address, endpoint: req.path, requestId: req.requestId }, - 'Stellar signature verification failed — signature mismatch' - ); - sendError( - res, - 403, - ErrorCode.FORBIDDEN, - 'Invalid signature — the request body was tampered with or signed by the wrong key' - ); - return; - } + if (signatureBuffer.length !== 64) { + sendError( + res, + 400, + ErrorCode.BAD_REQUEST, + 'Invalid signature format — Ed25519 signatures must be 64 bytes' + ); + return; + } + + const keypair = Keypair.fromPublicKey(address); + const verified = keypair.verify(message, signatureBuffer); - req.walletAddress = address; - req.signatureVerified = true; - next(); - } catch (error) { - logger.error( - { error, address, endpoint: req.path, requestId: req.requestId }, - 'Stellar signature verification exception' - ); - sendError( - res, - 500, - ErrorCode.INTERNAL_ERROR, - 'Signature verification failed due to an internal error' - ); - } - }; + if (!verified) { + logger.warn( + { address, endpoint: req.path, requestId: req.requestId }, + 'Stellar signature verification failed — signature mismatch' + ); + sendError( + res, + 403, + ErrorCode.FORBIDDEN, + 'Invalid signature — the request body was tampered with or signed by the wrong key' + ); + return; + } + + req.walletAddress = address; + req.signatureVerified = true; + next(); + } catch (error) { + logger.error( + { error, address, endpoint: req.path, requestId: req.requestId }, + 'Stellar signature verification exception' + ); + sendError( + res, + 500, + ErrorCode.INTERNAL_ERROR, + 'Signature verification failed due to an internal error' + ); + } + }; } diff --git a/src/middlewares/trailing-slash-normalizer.middleware.test.ts b/src/middlewares/trailing-slash-normalizer.middleware.test.ts index 2624d1e..505cae6 100644 --- a/src/middlewares/trailing-slash-normalizer.middleware.test.ts +++ b/src/middlewares/trailing-slash-normalizer.middleware.test.ts @@ -23,7 +23,10 @@ describe('normalizeTrailingSlash middleware', () => { let next: jest.Mock; beforeEach(() => { - next = jest.fn() as jest.Mock any ? [] : never>; + next = jest.fn() as jest.Mock< + void, + NextFunction extends (...args: any[]) => any ? [] : never + >; }); // ── next() is always called ────────────────────────────────────────────── diff --git a/src/middlewares/wallet-ownership.middleware.test.ts b/src/middlewares/wallet-ownership.middleware.test.ts index a1dcb48..70869bc 100644 --- a/src/middlewares/wallet-ownership.middleware.test.ts +++ b/src/middlewares/wallet-ownership.middleware.test.ts @@ -66,8 +66,7 @@ describe('requireCreatorProfileOwnership', () => { it('returns 400 when the wallet address has invalid characters', async () => { const req = buildReq({ - address: - 'G!!!!!AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + address: 'G!!!!!AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', creatorId: 'alice', }); const res = buildRes(); @@ -96,7 +95,10 @@ describe('requireCreatorProfileOwnership', () => { status: 'wallet_not_found', address: VALID_STELLAR_ADDRESS, }); - const req = buildReq({ address: VALID_STELLAR_ADDRESS, creatorId: 'alice' }); + const req = buildReq({ + address: VALID_STELLAR_ADDRESS, + creatorId: 'alice', + }); const res = buildRes(); const next = jest.fn(); @@ -113,7 +115,10 @@ describe('requireCreatorProfileOwnership', () => { address: VALID_STELLAR_ADDRESS, ownerUserId: 'someone-else', }); - const req = buildReq({ address: VALID_STELLAR_ADDRESS, creatorId: 'alice' }); + const req = buildReq({ + address: VALID_STELLAR_ADDRESS, + creatorId: 'alice', + }); const res = buildRes(); const next = jest.fn(); @@ -128,7 +133,10 @@ describe('requireCreatorProfileOwnership', () => { status: 'granted', ownerUserId: 'user-1', }); - const req = buildReq({ address: VALID_STELLAR_ADDRESS, creatorId: 'alice' }); + const req = buildReq({ + address: VALID_STELLAR_ADDRESS, + creatorId: 'alice', + }); const res = buildRes(); const next = jest.fn(); @@ -150,7 +158,10 @@ describe('requireCreatorProfileOwnership', () => { ownerUserId: 'user-1', }); const req = buildReq({ - address: [VALID_STELLAR_ADDRESS, 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'], + address: [ + VALID_STELLAR_ADDRESS, + 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + ], creatorId: 'alice', }); const res = buildRes(); @@ -179,7 +190,10 @@ describe('requireCreatorProfileOwnership', () => { it('returns 500 when the helper throws with a valid address', async () => { mockedCheck.mockRejectedValue(new Error('db down')); - const req = buildReq({ address: VALID_STELLAR_ADDRESS, creatorId: 'alice' }); + const req = buildReq({ + address: VALID_STELLAR_ADDRESS, + creatorId: 'alice', + }); const res = buildRes(); const next = jest.fn(); const errorSpy = jest diff --git a/src/middlewares/wallet-ownership.middleware.ts b/src/middlewares/wallet-ownership.middleware.ts index 9ab60d2..ac73f0e 100644 --- a/src/middlewares/wallet-ownership.middleware.ts +++ b/src/middlewares/wallet-ownership.middleware.ts @@ -63,7 +63,7 @@ export function requireCreatorProfileOwnership( 400, ErrorCode.BAD_REQUEST, 'Invalid wallet address format. Stellar address must be 56 characters, start with G, and use Base32 characters.', - addressValidation.error.issues.map((issue) => ({ + addressValidation.error.issues.map(issue => ({ field: 'x-wallet-address', message: issue.message, })) diff --git a/src/modules/activity/activity-cache-key.utils.ts b/src/modules/activity/activity-cache-key.utils.ts index 54300c7..bc0e1f5 100644 --- a/src/modules/activity/activity-cache-key.utils.ts +++ b/src/modules/activity/activity-cache-key.utils.ts @@ -28,26 +28,26 @@ import { ActivityQueryType } from './activity.schemas'; * ``` */ export function buildActivityFeedCacheKey(query: ActivityQueryType): string { - const parts: string[] = ['activity']; + const parts: string[] = ['activity']; - // Add pagination parameters - parts.push(`limit:${query.limit}`); - parts.push(`offset:${query.offset}`); + // Add pagination parameters + parts.push(`limit:${query.limit}`); + parts.push(`offset:${query.offset}`); - // Add filter parameters if present - if (query.creatorId) { - parts.push(`creatorId:${query.creatorId}`); - } + // Add filter parameters if present + if (query.creatorId) { + parts.push(`creatorId:${query.creatorId}`); + } - if (query.actor) { - parts.push(`actor:${query.actor}`); - } + if (query.actor) { + parts.push(`actor:${query.actor}`); + } - if (query.type) { - parts.push(`type:${query.type}`); - } + if (query.type) { + parts.push(`type:${query.type}`); + } - return parts.join(':'); + return parts.join(':'); } /** @@ -68,10 +68,10 @@ export function buildActivityFeedCacheKey(query: ActivityQueryType): string { * the affected creator or actor. */ export const ACTIVITY_FEED_CACHE_INVALIDATION_TOUCHPOINTS = { - CREATOR_REGISTERED: 'creator:registered', - KEY_BOUGHT: 'key:bought', - KEY_SOLD: 'key:sold', - PROFILE_UPDATED: 'profile:updated', + CREATOR_REGISTERED: 'creator:registered', + KEY_BOUGHT: 'key:bought', + KEY_SOLD: 'key:sold', + PROFILE_UPDATED: 'profile:updated', } as const; /** @@ -90,7 +90,7 @@ export const ACTIVITY_FEED_CACHE_INVALIDATION_TOUCHPOINTS = { * ``` */ export function buildActivityFeedInvalidationKeys(creatorId: string): string[] { - // Invalidate all activity feed entries for this creator - // regardless of pagination or other filters - return [`activity:*:creatorId:${creatorId}:*`]; + // Invalidate all activity feed entries for this creator + // regardless of pagination or other filters + return [`activity:*:creatorId:${creatorId}:*`]; } diff --git a/src/modules/activity/activity-feed-empty.integration.test.ts b/src/modules/activity/activity-feed-empty.integration.test.ts index f893d1f..cdbcd51 100644 --- a/src/modules/activity/activity-feed-empty.integration.test.ts +++ b/src/modules/activity/activity-feed-empty.integration.test.ts @@ -10,134 +10,136 @@ import * as activityService from './activity.service'; // ── Lightweight request/response mocks ──────────────────────────────────────── function makeReq(query: Record = {}): any { - return { query }; + return { query }; } function makeRes(): any { - const res: any = {}; - res.status = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - return res; + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; } function makeNext(): jest.Mock { - return jest.fn(); + return jest.fn(); } // ── Tests ───────────────────────────────────────────────────────────────────── describe('GET /activity — empty feed integration', () => { - beforeEach(() => { - jest.spyOn(activityService, 'fetchActivityFeed').mockResolvedValue([[], 0]); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('calls fetchActivityFeed with default limit and offset', async () => { - const req = makeReq(); - const res = makeRes(); - await httpGetActivityFeed(req, res, makeNext()); - - expect(activityService.fetchActivityFeed).toHaveBeenCalledWith( - expect.objectContaining({ limit: expect.any(Number), offset: 0 }), - ); - }); - - it('responds with status 200', async () => { - const req = makeReq(); - const res = makeRes(); - await httpGetActivityFeed(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(200); - }); - - it('response envelope contains items array', async () => { - const req = makeReq(); - const res = makeRes(); - await httpGetActivityFeed(req, res, makeNext()); - - const body = res.json.mock.calls[0][0]; - expect(body).toHaveProperty('data'); - expect(Array.isArray(body.data.items)).toBe(true); - expect(body.data.items).toHaveLength(0); - }); - - it('response envelope contains meta object', async () => { - const req = makeReq(); - const res = makeRes(); - await httpGetActivityFeed(req, res, makeNext()); - - const body = res.json.mock.calls[0][0]; - expect(body.data).toHaveProperty('meta'); - expect(body.data.meta).toMatchObject({ - total: 0, - hasMore: false, - }); - }); - - it('meta.total is 0 when feed is empty', async () => { - const req = makeReq(); - const res = makeRes(); - await httpGetActivityFeed(req, res, makeNext()); - - const body = res.json.mock.calls[0][0]; - expect(body.data.meta.total).toBe(0); - }); - - it('meta.hasMore is false when feed is empty', async () => { - const req = makeReq(); - const res = makeRes(); - await httpGetActivityFeed(req, res, makeNext()); - - const body = res.json.mock.calls[0][0]; - expect(body.data.meta.hasMore).toBe(false); - }); - - it('meta.offset reflects the requested offset', async () => { - const req = makeReq({ offset: '10' }); - const res = makeRes(); - await httpGetActivityFeed(req, res, makeNext()); - - const body = res.json.mock.calls[0][0]; - expect(body.data.meta.offset).toBe(10); - }); - - it('meta.limit reflects the requested limit', async () => { - const req = makeReq({ limit: '5' }); - const res = makeRes(); - await httpGetActivityFeed(req, res, makeNext()); - - const body = res.json.mock.calls[0][0]; - expect(body.data.meta.limit).toBe(5); - }); - - it('filters by creatorId when provided', async () => { - const req = makeReq({ creatorId: 'creator-xyz' }); - const res = makeRes(); - await httpGetActivityFeed(req, res, makeNext()); - - expect(activityService.fetchActivityFeed).toHaveBeenCalledWith( - expect.objectContaining({ creatorId: 'creator-xyz' }), - ); - const body = res.json.mock.calls[0][0]; - expect(body.data.items).toHaveLength(0); - }); - - it('returns 400 for invalid query params', async () => { - const req = makeReq({ limit: 'not-a-number' }); - const res = makeRes(); - const next = makeNext(); - await httpGetActivityFeed(req, res, next); - - // Either sendValidationError sets 400 or next is called with an error - const statusArg = res.status.mock.calls[0]?.[0]; - if (statusArg !== undefined) { - expect(statusArg).toBe(400); - } else { - // schema coercion may handle this gracefully; either is acceptable - expect(res.json).toHaveBeenCalled(); - } - }); + beforeEach(() => { + jest + .spyOn(activityService, 'fetchActivityFeed') + .mockResolvedValue([[], 0]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('calls fetchActivityFeed with default limit and offset', async () => { + const req = makeReq(); + const res = makeRes(); + await httpGetActivityFeed(req, res, makeNext()); + + expect(activityService.fetchActivityFeed).toHaveBeenCalledWith( + expect.objectContaining({ limit: expect.any(Number), offset: 0 }) + ); + }); + + it('responds with status 200', async () => { + const req = makeReq(); + const res = makeRes(); + await httpGetActivityFeed(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('response envelope contains items array', async () => { + const req = makeReq(); + const res = makeRes(); + await httpGetActivityFeed(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body).toHaveProperty('data'); + expect(Array.isArray(body.data.items)).toBe(true); + expect(body.data.items).toHaveLength(0); + }); + + it('response envelope contains meta object', async () => { + const req = makeReq(); + const res = makeRes(); + await httpGetActivityFeed(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data).toHaveProperty('meta'); + expect(body.data.meta).toMatchObject({ + total: 0, + hasMore: false, + }); + }); + + it('meta.total is 0 when feed is empty', async () => { + const req = makeReq(); + const res = makeRes(); + await httpGetActivityFeed(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.total).toBe(0); + }); + + it('meta.hasMore is false when feed is empty', async () => { + const req = makeReq(); + const res = makeRes(); + await httpGetActivityFeed(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.hasMore).toBe(false); + }); + + it('meta.offset reflects the requested offset', async () => { + const req = makeReq({ offset: '10' }); + const res = makeRes(); + await httpGetActivityFeed(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.offset).toBe(10); + }); + + it('meta.limit reflects the requested limit', async () => { + const req = makeReq({ limit: '5' }); + const res = makeRes(); + await httpGetActivityFeed(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.limit).toBe(5); + }); + + it('filters by creatorId when provided', async () => { + const req = makeReq({ creatorId: 'creator-xyz' }); + const res = makeRes(); + await httpGetActivityFeed(req, res, makeNext()); + + expect(activityService.fetchActivityFeed).toHaveBeenCalledWith( + expect.objectContaining({ creatorId: 'creator-xyz' }) + ); + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(0); + }); + + it('returns 400 for invalid query params', async () => { + const req = makeReq({ limit: 'not-a-number' }); + const res = makeRes(); + const next = makeNext(); + await httpGetActivityFeed(req, res, next); + + // Either sendValidationError sets 400 or next is called with an error + const statusArg = res.status.mock.calls[0]?.[0]; + if (statusArg !== undefined) { + expect(statusArg).toBe(400); + } else { + // schema coercion may handle this gracefully; either is acceptable + expect(res.json).toHaveBeenCalled(); + } + }); }); diff --git a/src/modules/activity/activity.controllers.ts b/src/modules/activity/activity.controllers.ts index 4b70e1b..69e840b 100644 --- a/src/modules/activity/activity.controllers.ts +++ b/src/modules/activity/activity.controllers.ts @@ -1,32 +1,39 @@ import { AsyncController } from '../../types/auth.types'; import { ActivityQuerySchema } from './activity.schemas'; import { fetchActivityFeed } from './activity.service'; -import { sendSuccess, sendValidationError } from '../../utils/api-response.utils'; +import { + sendSuccess, + sendValidationError, +} from '../../utils/api-response.utils'; import { buildOffsetPaginationMeta } from '../../utils/pagination.utils'; export const httpGetActivityFeed: AsyncController = async (req, res, next) => { - try { - const parsed = ActivityQuerySchema.safeParse(req.query); - if (!parsed.success) { - return sendValidationError(res, 'Invalid query parameters', parsed.error.issues.map(issue => ({ - field: issue.path.join('.'), - message: issue.message, - }))); - } + try { + const parsed = ActivityQuerySchema.safeParse(req.query); + if (!parsed.success) { + return sendValidationError( + res, + 'Invalid query parameters', + parsed.error.issues.map(issue => ({ + field: issue.path.join('.'), + message: issue.message, + })) + ); + } - const [items, total] = await fetchActivityFeed(parsed.data); + const [items, total] = await fetchActivityFeed(parsed.data); - const response = { - items, - meta: buildOffsetPaginationMeta({ - limit: parsed.data.limit, - offset: parsed.data.offset, - total, - }), - }; + const response = { + items, + meta: buildOffsetPaginationMeta({ + limit: parsed.data.limit, + offset: parsed.data.offset, + total, + }), + }; - sendSuccess(res, response); - } catch (error) { - next(error); - } + sendSuccess(res, response); + } catch (error) { + next(error); + } }; diff --git a/src/modules/activity/activity.routes.ts b/src/modules/activity/activity.routes.ts index 8c3be47..f5d1576 100644 --- a/src/modules/activity/activity.routes.ts +++ b/src/modules/activity/activity.routes.ts @@ -7,10 +7,14 @@ const activityRouter = Router(); /** * GET /api/v1/activity - * + * * Public activity feed with optional filtering by creator, actor, or type. * Cached for 2 minutes to reduce database load while maintaining reasonable freshness. */ -activityRouter.get('/', cacheControl(ACTIVITY_FEED_CACHE_PRESET), httpGetActivityFeed); +activityRouter.get( + '/', + cacheControl(ACTIVITY_FEED_CACHE_PRESET), + httpGetActivityFeed +); export default activityRouter; diff --git a/src/modules/activity/activity.schemas.ts b/src/modules/activity/activity.schemas.ts index bd0231c..0cce30e 100644 --- a/src/modules/activity/activity.schemas.ts +++ b/src/modules/activity/activity.schemas.ts @@ -1,46 +1,58 @@ import { z } from 'zod'; import { safeIntParam } from '../../utils/query.utils'; import { PUBLIC_OFFSET_PAGINATION_DEFAULTS } from '../../utils/public-list-query-defaults'; -import { MIN_PAGE_SIZE, MAX_PAGE_SIZE } from '../../constants/pagination.constants'; +import { + MIN_PAGE_SIZE, + MAX_PAGE_SIZE, +} from '../../constants/pagination.constants'; -export const ActivityQuerySchema = z.object({ - limit: safeIntParam({ - defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, - min: MIN_PAGE_SIZE, - max: MAX_PAGE_SIZE, - label: 'Limit', - }), - offset: safeIntParam({ - defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, - min: 0, - max: Number.MAX_SAFE_INTEGER, - label: 'Offset', - }), - creatorId: z.string().optional(), - actor: z.string().optional(), - type: z.enum(['CREATOR_REGISTERED', 'KEY_BOUGHT', 'KEY_SOLD', 'PROFILE_UPDATED']).optional(), -}).strict(); +export const ActivityQuerySchema = z + .object({ + limit: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, + min: MIN_PAGE_SIZE, + max: MAX_PAGE_SIZE, + label: 'Limit', + }), + offset: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, + min: 0, + max: Number.MAX_SAFE_INTEGER, + label: 'Offset', + }), + creatorId: z.string().optional(), + actor: z.string().optional(), + type: z + .enum([ + 'CREATOR_REGISTERED', + 'KEY_BOUGHT', + 'KEY_SOLD', + 'PROFILE_UPDATED', + ]) + .optional(), + }) + .strict(); export type ActivityQueryType = z.infer; export const ActivityItemSchema = z.object({ - id: z.string(), - type: z.string(), - actor: z.string(), - creatorId: z.string().nullable(), - target: z.string().nullable(), - payload: z.any(), - createdAt: z.date(), + id: z.string(), + type: z.string(), + actor: z.string(), + creatorId: z.string().nullable(), + target: z.string().nullable(), + payload: z.any(), + createdAt: z.date(), }); export const ActivityFeedResponseSchema = z.object({ - items: z.array(ActivityItemSchema), - meta: z.object({ - limit: z.number(), - offset: z.number(), - total: z.number(), - hasMore: z.boolean(), - }), + items: z.array(ActivityItemSchema), + meta: z.object({ + limit: z.number(), + offset: z.number(), + total: z.number(), + hasMore: z.boolean(), + }), }); export type ActivityFeedResponse = z.infer; diff --git a/src/modules/activity/activity.service.test.ts b/src/modules/activity/activity.service.test.ts index d5627a8..42a1f1d 100644 --- a/src/modules/activity/activity.service.test.ts +++ b/src/modules/activity/activity.service.test.ts @@ -1,27 +1,31 @@ import { fetchActivityFeed } from './activity.service'; describe('Activity Service', () => { - beforeAll(async () => { - // Clean up and seed minimal test data if needed - // In a real environment, we'd use a test database - }); + beforeAll(async () => { + // Clean up and seed minimal test data if needed + // In a real environment, we'd use a test database + }); - it('should return empty list when no activity exists', async () => { - const [items] = await fetchActivityFeed({ limit: 10, offset: 0 }); - expect(Array.isArray(items)).toBe(true); - // expect(total).toBe(0); // Depends on DB state - }); + it('should return empty list when no activity exists', async () => { + const [items] = await fetchActivityFeed({ limit: 10, offset: 0 }); + expect(Array.isArray(items)).toBe(true); + // expect(total).toBe(0); // Depends on DB state + }); - it('should filter by creatorId', async () => { - const [items] = await fetchActivityFeed({ limit: 10, offset: 0, creatorId: 'non-existent' }); - expect(items.length).toBe(0); - }); + it('should filter by creatorId', async () => { + const [items] = await fetchActivityFeed({ + limit: 10, + offset: 0, + creatorId: 'non-existent', + }); + expect(items.length).toBe(0); + }); - it('should handle pagination', async () => { - const [items1] = await fetchActivityFeed({ limit: 1, offset: 0 }); - const [items2] = await fetchActivityFeed({ limit: 1, offset: 1 }); - if (items1.length > 0 && items2.length > 0) { - expect(items1[0].id).not.toBe(items2[0].id); - } - }); + it('should handle pagination', async () => { + const [items1] = await fetchActivityFeed({ limit: 1, offset: 0 }); + const [items2] = await fetchActivityFeed({ limit: 1, offset: 1 }); + if (items1.length > 0 && items2.length > 0) { + expect(items1[0].id).not.toBe(items2[0].id); + } + }); }); diff --git a/src/modules/activity/activity.service.ts b/src/modules/activity/activity.service.ts index ce818cf..69f4946 100644 --- a/src/modules/activity/activity.service.ts +++ b/src/modules/activity/activity.service.ts @@ -1,27 +1,29 @@ import { prisma } from '../../utils/prisma.utils'; import { ActivityQueryType } from './activity.schemas'; -type Activity = NonNullable>>; +type Activity = NonNullable< + Awaited> +>; export async function fetchActivityFeed( - query: ActivityQueryType + query: ActivityQueryType ): Promise<[Activity[], number]> { - const { limit, offset, creatorId, actor, type } = query; + const { limit, offset, creatorId, actor, type } = query; - const where: any = {}; - if (creatorId) where.creatorId = creatorId; - if (actor) where.actor = actor; - if (type) where.type = type; + const where: any = {}; + if (creatorId) where.creatorId = creatorId; + if (actor) where.actor = actor; + if (type) where.type = type; - const [items, total] = await Promise.all([ - prisma.activity.findMany({ - where, - orderBy: { createdAt: 'desc' }, - skip: offset, - take: limit, - }), - prisma.activity.count({ where }), - ]); + const [items, total] = await Promise.all([ + prisma.activity.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: offset, + take: limit, + }), + prisma.activity.count({ where }), + ]); - return [items, total]; + return [items, total]; } diff --git a/src/modules/admin/admin.controllers.test.ts b/src/modules/admin/admin.controllers.test.ts index f22af3a..28ebe9d 100644 --- a/src/modules/admin/admin.controllers.test.ts +++ b/src/modules/admin/admin.controllers.test.ts @@ -4,101 +4,106 @@ import { AdminRequest } from '../../middlewares/admin-guard.middleware'; import { Response } from 'express'; jest.mock('../../utils/background-job-lock.utils', () => ({ - acquireJobLock: jest.fn(() => ({ - acquired: true, - expiresAt: '2026-01-01T00:00:00.000Z', - })), + acquireJobLock: jest.fn(() => ({ + acquired: true, + expiresAt: '2026-01-01T00:00:00.000Z', + })), })); jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - creatorProfile: { - findUnique: jest.fn(), - update: jest.fn(), - }, - }, + prisma: { + creatorProfile: { + findUnique: jest.fn(), + update: jest.fn(), + }, + }, })); jest.mock('../../utils/audit.utils', () => ({ - emitAuditEvent: jest.fn(), + emitAuditEvent: jest.fn(), })); describe('httpReplayIndexerEvents', () => { - const next = jest.fn(); + const next = jest.fn(); - const createRes = (): Response => - ({ - status: jest.fn().mockReturnThis(), - json: jest.fn(), - }) as unknown as Response; + const createRes = (): Response => + ({ + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }) as unknown as Response; - beforeEach(() => { - jest.clearAllMocks(); - }); + beforeEach(() => { + jest.clearAllMocks(); + }); - it('returns validation error when dryRun is not a boolean', async () => { - const req = { - body: { startLedger: 10, dryRun: 'true' }, - adminId: 'admin-1', - } as unknown as AdminRequest; - const res = createRes(); + it('returns validation error when dryRun is not a boolean', async () => { + const req = { + body: { startLedger: 10, dryRun: 'true' }, + adminId: 'admin-1', + } as unknown as AdminRequest; + const res = createRes(); - await httpReplayIndexerEvents(req, res, next); + await httpReplayIndexerEvents(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, - error: expect.objectContaining({ - message: 'Invalid request body', - details: expect.arrayContaining([expect.objectContaining({ field: 'dryRun' })]), - }), - }) - ); - expect(emitAuditEvent).not.toHaveBeenCalled(); - }); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + message: 'Invalid request body', + details: expect.arrayContaining([ + expect.objectContaining({ field: 'dryRun' }), + ]), + }), + }) + ); + expect(emitAuditEvent).not.toHaveBeenCalled(); + }); - it('does not emit audit event when dryRun=true', async () => { - const req = { - body: { startLedger: 20, dryRun: true }, - adminId: 'admin-2', - } as unknown as AdminRequest; - const res = createRes(); + it('does not emit audit event when dryRun=true', async () => { + const req = { + body: { startLedger: 20, dryRun: true }, + adminId: 'admin-2', + } as unknown as AdminRequest; + const res = createRes(); - await httpReplayIndexerEvents(req, res, next); + await httpReplayIndexerEvents(req, res, next); - expect(emitAuditEvent).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: true, - data: expect.objectContaining({ - type: 'INDEXER_REPLAY_INITIATED', - startLedger: 20, - dryRun: true, - initiatedBy: 'admin-2', - }), - }) - ); - }); + expect(emitAuditEvent).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + type: 'INDEXER_REPLAY_INITIATED', + startLedger: 20, + dryRun: true, + initiatedBy: 'admin-2', + }), + }) + ); + }); - it('emits audit event when dryRun=false', async () => { - const req = { - body: { startLedger: 30, dryRun: false }, - adminId: 'admin-3', - } as unknown as AdminRequest; - const res = createRes(); + it('emits audit event when dryRun=false', async () => { + const req = { + body: { startLedger: 30, dryRun: false }, + adminId: 'admin-3', + } as unknown as AdminRequest; + const res = createRes(); - await httpReplayIndexerEvents(req, res, next); + await httpReplayIndexerEvents(req, res, next); - expect(emitAuditEvent).toHaveBeenCalledWith( - expect.objectContaining({ - actor: 'admin-3', - action: 'replay_indexer_events', - targetId: '30', - metadata: expect.objectContaining({ startLedger: 30, dryRun: false }), - }) - ); - expect(res.status).toHaveBeenCalledWith(200); - }); + expect(emitAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + actor: 'admin-3', + action: 'replay_indexer_events', + targetId: '30', + metadata: expect.objectContaining({ + startLedger: 30, + dryRun: false, + }), + }) + ); + expect(res.status).toHaveBeenCalledWith(200); + }); }); diff --git a/src/modules/admin/admin.controllers.ts b/src/modules/admin/admin.controllers.ts index 2aaa021..55a8b86 100644 --- a/src/modules/admin/admin.controllers.ts +++ b/src/modules/admin/admin.controllers.ts @@ -99,92 +99,106 @@ export const httpUpdateCreatorMetadata: AsyncController = async ( } }; -export const httpReplayIndexerEvents: AsyncController = async (req: AdminRequest, res: Response, next) => { - try { - const { startLedger, endLedger, dryRun = false } = req.body as { - startLedger?: number; - endLedger?: number; - dryRun?: boolean; - }; - const adminId = req.adminId; - const lockName = 'indexer-replay'; - const lockOwner = adminId || 'unknown'; - - if (typeof startLedger !== 'number' || startLedger < 1) { - return sendValidationError(res, 'Invalid request body', [ - { field: 'startLedger', message: 'startLedger must be a positive integer' }, - ]); - } - - if (endLedger !== undefined && (typeof endLedger !== 'number' || endLedger < startLedger)) { - return sendValidationError(res, 'Invalid request body', [ - { field: 'endLedger', message: 'endLedger must be >= startLedger' }, - ]); - } - - if (typeof dryRun !== 'boolean') { - return sendValidationError(res, 'Invalid request body', [ - { field: 'dryRun', message: 'dryRun must be a boolean' }, - ]); - } - - const lock = acquireJobLock({ - name: lockName, - owner: lockOwner, - }); - - if (!lock.acquired) { - return res.status(409).json({ - success: false, - error: { - code: ErrorCode.CONFLICT, - message: 'Indexer replay job is already running', - details: [ +export const httpReplayIndexerEvents: AsyncController = async ( + req: AdminRequest, + res: Response, + next +) => { + try { + const { + startLedger, + endLedger, + dryRun = false, + } = req.body as { + startLedger?: number; + endLedger?: number; + dryRun?: boolean; + }; + const adminId = req.adminId; + const lockName = 'indexer-replay'; + const lockOwner = adminId || 'unknown'; + + if (typeof startLedger !== 'number' || startLedger < 1) { + return sendValidationError(res, 'Invalid request body', [ { - field: 'indexerReplayLock', - message: `Lock is held by ${lock.holder || 'another worker'} until ${lock.expiresAt || 'unknown time'}`, + field: 'startLedger', + message: 'startLedger must be a positive integer', }, - ], - }, - }); - } - - const replayInitiated = { - type: 'INDEXER_REPLAY_INITIATED', - startLedger, - endLedger: endLedger || null, - dryRun, - initiatedBy: adminId, - lock: { - name: lockName, - expiresAt: lock.expiresAt, - }, - timestamp: new Date().toISOString(), - }; - - logger.info( - { - lockName, - lockOwner, - lockExpiresAt: lock.expiresAt, - startLedger, - endLedger: endLedger || null, - }, - 'Acquired background job lock for indexer replay' - ); - - if (!dryRun) { - await emitAuditEvent({ - actor: adminId || 'unknown', - action: 'replay_indexer_events', - target: 'IndexerQueue', - targetId: String(startLedger), - metadata: { startLedger, endLedger: endLedger || null, dryRun }, + ]); + } + + if ( + endLedger !== undefined && + (typeof endLedger !== 'number' || endLedger < startLedger) + ) { + return sendValidationError(res, 'Invalid request body', [ + { field: 'endLedger', message: 'endLedger must be >= startLedger' }, + ]); + } + + if (typeof dryRun !== 'boolean') { + return sendValidationError(res, 'Invalid request body', [ + { field: 'dryRun', message: 'dryRun must be a boolean' }, + ]); + } + + const lock = acquireJobLock({ + name: lockName, + owner: lockOwner, }); - } - sendSuccess(res, replayInitiated); - } catch (error) { - next(error); - } + if (!lock.acquired) { + return res.status(409).json({ + success: false, + error: { + code: ErrorCode.CONFLICT, + message: 'Indexer replay job is already running', + details: [ + { + field: 'indexerReplayLock', + message: `Lock is held by ${lock.holder || 'another worker'} until ${lock.expiresAt || 'unknown time'}`, + }, + ], + }, + }); + } + + const replayInitiated = { + type: 'INDEXER_REPLAY_INITIATED', + startLedger, + endLedger: endLedger || null, + dryRun, + initiatedBy: adminId, + lock: { + name: lockName, + expiresAt: lock.expiresAt, + }, + timestamp: new Date().toISOString(), + }; + + logger.info( + { + lockName, + lockOwner, + lockExpiresAt: lock.expiresAt, + startLedger, + endLedger: endLedger || null, + }, + 'Acquired background job lock for indexer replay' + ); + + if (!dryRun) { + await emitAuditEvent({ + actor: adminId || 'unknown', + action: 'replay_indexer_events', + target: 'IndexerQueue', + targetId: String(startLedger), + metadata: { startLedger, endLedger: endLedger || null, dryRun }, + }); + } + + sendSuccess(res, replayInitiated); + } catch (error) { + next(error); + } }; diff --git a/src/modules/admin/admin.routes.test.ts b/src/modules/admin/admin.routes.test.ts index 323e4c1..3f58212 100644 --- a/src/modules/admin/admin.routes.test.ts +++ b/src/modules/admin/admin.routes.test.ts @@ -2,56 +2,56 @@ import { adminGuard } from '../../middlewares/admin-guard.middleware'; import { Request, Response, NextFunction } from 'express'; describe('adminGuard middleware', () => { - let mockReq: Partial; - let mockRes: Partial; - let mockNext: NextFunction; - - beforeEach(() => { - mockReq = { headers: {} }; - mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() }; - mockNext = jest.fn(); - }); - - it('should call next when valid admin ID is provided', () => { - mockReq.headers = { 'x-admin-id': 'admin-123' }; - - adminGuard(mockReq as any, mockRes as Response, mockNext); - - expect(mockNext).toHaveBeenCalled(); - expect(mockRes.status).not.toHaveBeenCalled(); - }); - - it('should return 403 when admin ID is missing', () => { - mockReq.headers = {}; - - adminGuard(mockReq as any, mockRes as Response, mockNext); - - expect(mockRes.status).toHaveBeenCalledWith(403); - expect(mockRes.json).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'FORBIDDEN', - message: 'Admin authorization required.', - }) - ); - expect(mockNext).not.toHaveBeenCalled(); - }); - - it('should extract admin ID from array header', () => { - mockReq.headers = { 'x-admin-id': ['admin-456'] }; - - adminGuard(mockReq as any, mockRes as Response, mockNext); - - expect(mockNext).toHaveBeenCalled(); - expect((mockReq as any).adminId).toBe('admin-456'); - }); - - it('should return 403 with timestamp when authorization fails', () => { - adminGuard(mockReq as any, mockRes as Response, mockNext); - - expect(mockRes.json).toHaveBeenCalledWith( - expect.objectContaining({ - timestamp: expect.any(String), - }) - ); - }); + let mockReq: Partial; + let mockRes: Partial; + let mockNext: NextFunction; + + beforeEach(() => { + mockReq = { headers: {} }; + mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + mockNext = jest.fn(); + }); + + it('should call next when valid admin ID is provided', () => { + mockReq.headers = { 'x-admin-id': 'admin-123' }; + + adminGuard(mockReq as any, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it('should return 403 when admin ID is missing', () => { + mockReq.headers = {}; + + adminGuard(mockReq as any, mockRes as Response, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'FORBIDDEN', + message: 'Admin authorization required.', + }) + ); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should extract admin ID from array header', () => { + mockReq.headers = { 'x-admin-id': ['admin-456'] }; + + adminGuard(mockReq as any, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect((mockReq as any).adminId).toBe('admin-456'); + }); + + it('should return 403 with timestamp when authorization fails', () => { + adminGuard(mockReq as any, mockRes as Response, mockNext); + + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + timestamp: expect.any(String), + }) + ); + }); }); diff --git a/src/modules/admin/admin.routes.ts b/src/modules/admin/admin.routes.ts index 690ce82..0f2b01d 100644 --- a/src/modules/admin/admin.routes.ts +++ b/src/modules/admin/admin.routes.ts @@ -1,5 +1,8 @@ import { Router } from 'express'; -import { httpUpdateCreatorMetadata, httpReplayIndexerEvents } from './admin.controllers'; +import { + httpUpdateCreatorMetadata, + httpReplayIndexerEvents, +} from './admin.controllers'; import { adminGuard } from '../../middlewares/admin-guard.middleware'; const adminRouter = Router(); diff --git a/src/modules/alerts/__tests__/alert-invalid-address.integration.test.ts b/src/modules/alerts/__tests__/alert-invalid-address.integration.test.ts index 0174489..d103660 100644 --- a/src/modules/alerts/__tests__/alert-invalid-address.integration.test.ts +++ b/src/modules/alerts/__tests__/alert-invalid-address.integration.test.ts @@ -8,71 +8,75 @@ import { httpCreateAlert } from '../alert.controllers'; import * as alertService from '../alert.service'; jest.mock('../../../utils/prisma.utils', () => ({ - prisma: { - priceAlert: { - create: jest.fn(), - }, - }, + prisma: { + priceAlert: { + create: jest.fn(), + }, + }, })); function makeRes(): any { - const res: any = {}; - res.status = jest.fn().mockReturnValue(res); - res.setHeader = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - return res; + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; } function makeNext(): jest.Mock { - return jest.fn(); + return jest.fn(); } function makeReq(body: Record): any { - return { body }; + return { body }; } const VALID_PAYLOAD = { - creator_id: 'creator-1', - wallet_address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - target_price: 100, - direction: 'above', - callback_url: 'https://example.com/cb', + creator_id: 'creator-1', + wallet_address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + target_price: 100, + direction: 'above', + callback_url: 'https://example.com/cb', }; describe('POST /alerts — invalid Stellar wallet address', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); + afterEach(() => { + jest.restoreAllMocks(); + }); - it('returns 400 for a malformed wallet address', async () => { - const req = makeReq({ ...VALID_PAYLOAD, wallet_address: 'not-a-stellar-address' }); - const res = makeRes(); + it('returns 400 for a malformed wallet address', async () => { + const req = makeReq({ + ...VALID_PAYLOAD, + wallet_address: 'not-a-stellar-address', + }); + const res = makeRes(); - await httpCreateAlert(req, res, makeNext()); + await httpCreateAlert(req, res, makeNext()); - expect(res.status).toHaveBeenCalledWith(400); - }); + expect(res.status).toHaveBeenCalledWith(400); + }); - it('error body identifies the wallet_address field', async () => { - const req = makeReq({ ...VALID_PAYLOAD, wallet_address: 'BADINPUT' }); - const res = makeRes(); + it('error body identifies the wallet_address field', async () => { + const req = makeReq({ ...VALID_PAYLOAD, wallet_address: 'BADINPUT' }); + const res = makeRes(); - await httpCreateAlert(req, res, makeNext()); + await httpCreateAlert(req, res, makeNext()); - const body = res.json.mock.calls[0][0]; - expect(body.success).toBe(false); - const details: Array<{ field: string; message: string }> = body.error.details ?? []; - const fieldNames = details.map((d) => d.field); - expect(fieldNames).toContain('wallet_address'); - }); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + const details: Array<{ field: string; message: string }> = + body.error.details ?? []; + const fieldNames = details.map(d => d.field); + expect(fieldNames).toContain('wallet_address'); + }); - it('does not create an alert record after failed validation', async () => { - const createSpy = jest.spyOn(alertService, 'createAlert'); - const req = makeReq({ ...VALID_PAYLOAD, wallet_address: 'invalid' }); - const res = makeRes(); + it('does not create an alert record after failed validation', async () => { + const createSpy = jest.spyOn(alertService, 'createAlert'); + const req = makeReq({ ...VALID_PAYLOAD, wallet_address: 'invalid' }); + const res = makeRes(); - await httpCreateAlert(req, res, makeNext()); + await httpCreateAlert(req, res, makeNext()); - expect(createSpy).not.toHaveBeenCalled(); - }); + expect(createSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/modules/alerts/__tests__/alert-price-crossing.integration.test.ts b/src/modules/alerts/__tests__/alert-price-crossing.integration.test.ts index 20e4f72..6460104 100644 --- a/src/modules/alerts/__tests__/alert-price-crossing.integration.test.ts +++ b/src/modules/alerts/__tests__/alert-price-crossing.integration.test.ts @@ -9,209 +9,219 @@ import { evaluatePriceAlertsForMovement } from '../alert.service'; import { prisma } from '../../../utils/prisma.utils'; jest.mock('../../../utils/prisma.utils', () => ({ - prisma: { - priceAlert: { - findMany: jest.fn(), - update: jest.fn(), - }, - }, + prisma: { + priceAlert: { + findMany: jest.fn(), + update: jest.fn(), + }, + }, })); const mockPrisma = prisma as unknown as { - priceAlert: { - findMany: jest.Mock; - update: jest.Mock; - }; + priceAlert: { + findMany: jest.Mock; + update: jest.Mock; + }; }; const CREATOR_ID = 'creator-alert-test'; -const WALLET_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const WALLET_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const CALLBACK_URL = 'https://hooks.example.com/price-alert'; const BASE_ALERT = { - id: 'alert-fire-test', - creatorId: CREATOR_ID, - walletAddress: WALLET_ADDRESS, - targetPrice: 100, - direction: 'above' as const, - callbackUrl: CALLBACK_URL, - isActive: true, - triggeredAt: null, - createdAt: new Date('2026-06-01T00:00:00Z'), + id: 'alert-fire-test', + creatorId: CREATOR_ID, + walletAddress: WALLET_ADDRESS, + targetPrice: 100, + direction: 'above' as const, + callbackUrl: CALLBACK_URL, + isActive: true, + triggeredAt: null, + createdAt: new Date('2026-06-01T00:00:00Z'), }; beforeEach(() => { - jest.clearAllMocks(); - global.fetch = jest.fn().mockResolvedValue({ ok: true }); + jest.clearAllMocks(); + global.fetch = jest.fn().mockResolvedValue({ ok: true }); }); describe('price alert fires when price crosses threshold (#464)', () => { - it('calls the callback URL when price crosses above the target', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 110, - }); - - expect(global.fetch).toHaveBeenCalledTimes(1); - expect(global.fetch).toHaveBeenCalledWith( - CALLBACK_URL, - expect.objectContaining({ method: 'POST' }) - ); - }); - - it('sends the correct payload to the callback', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 110, - }); - - const callArgs = (global.fetch as jest.Mock).mock.calls[0]; - const body = JSON.parse(callArgs[1].body); - - expect(body.creator_id).toBe(CREATOR_ID); - expect(body.target_price).toBe(100); - expect(body.current_price).toBe(110); - expect(body.direction).toBe('above'); - expect(body.event_type).toBe('price_alert'); - }); - - it('calls the callback when price crosses below the target', async () => { - const belowAlert = { ...BASE_ALERT, id: 'alert-below', direction: 'below' as const, targetPrice: 80 }; - mockPrisma.priceAlert.findMany.mockResolvedValue([belowAlert]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 70, - }); - - expect(global.fetch).toHaveBeenCalledTimes(1); - const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body); - expect(body.direction).toBe('below'); - expect(body.current_price).toBe(70); - }); - - it('does not call the callback when price does not cross the threshold', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 95, - }); - - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('does not call the callback when price crosses in the wrong direction', async () => { - // alert is 'above' but price dropped - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 110, - currentPrice: 90, - }); - - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('includes alert_id in the callback payload', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 110, - }); - - const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body); - expect(body.alert_id).toBe(BASE_ALERT.id); - }); + it('calls the callback URL when price crosses above the target', async () => { + mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); + mockPrisma.priceAlert.update.mockResolvedValue({}); + + await evaluatePriceAlertsForMovement({ + creatorId: CREATOR_ID, + previousPrice: 90, + currentPrice: 110, + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + CALLBACK_URL, + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('sends the correct payload to the callback', async () => { + mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); + mockPrisma.priceAlert.update.mockResolvedValue({}); + + await evaluatePriceAlertsForMovement({ + creatorId: CREATOR_ID, + previousPrice: 90, + currentPrice: 110, + }); + + const callArgs = (global.fetch as jest.Mock).mock.calls[0]; + const body = JSON.parse(callArgs[1].body); + + expect(body.creator_id).toBe(CREATOR_ID); + expect(body.target_price).toBe(100); + expect(body.current_price).toBe(110); + expect(body.direction).toBe('above'); + expect(body.event_type).toBe('price_alert'); + }); + + it('calls the callback when price crosses below the target', async () => { + const belowAlert = { + ...BASE_ALERT, + id: 'alert-below', + direction: 'below' as const, + targetPrice: 80, + }; + mockPrisma.priceAlert.findMany.mockResolvedValue([belowAlert]); + mockPrisma.priceAlert.update.mockResolvedValue({}); + + await evaluatePriceAlertsForMovement({ + creatorId: CREATOR_ID, + previousPrice: 90, + currentPrice: 70, + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const body = JSON.parse( + (global.fetch as jest.Mock).mock.calls[0][1].body + ); + expect(body.direction).toBe('below'); + expect(body.current_price).toBe(70); + }); + + it('does not call the callback when price does not cross the threshold', async () => { + mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); + + await evaluatePriceAlertsForMovement({ + creatorId: CREATOR_ID, + previousPrice: 90, + currentPrice: 95, + }); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('does not call the callback when price crosses in the wrong direction', async () => { + // alert is 'above' but price dropped + mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); + + await evaluatePriceAlertsForMovement({ + creatorId: CREATOR_ID, + previousPrice: 110, + currentPrice: 90, + }); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('includes alert_id in the callback payload', async () => { + mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); + mockPrisma.priceAlert.update.mockResolvedValue({}); + + await evaluatePriceAlertsForMovement({ + creatorId: CREATOR_ID, + previousPrice: 90, + currentPrice: 110, + }); + + const body = JSON.parse( + (global.fetch as jest.Mock).mock.calls[0][1].body + ); + expect(body.alert_id).toBe(BASE_ALERT.id); + }); }); describe('price alert deleted after successful webhook delivery (#472)', () => { - it('marks the alert inactive after successful delivery', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 110, - }); - - expect(mockPrisma.priceAlert.update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: BASE_ALERT.id }, - data: expect.objectContaining({ isActive: false }), - }) - ); - }); - - it('sets triggeredAt after successful delivery', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 110, - }); - - const updateCall = mockPrisma.priceAlert.update.mock.calls[0][0]; - expect(updateCall.data.triggeredAt).toBeInstanceOf(Date); - }); - - it('does not re-trigger when the alert is already inactive (second crossing)', async () => { - // Second call: alert is not returned because isActive: false filters it out - mockPrisma.priceAlert.findMany.mockResolvedValue([]); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 120, - }); - - expect(global.fetch).not.toHaveBeenCalled(); - expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); - }); - - it('does not mark alert inactive when the callback fails', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - (global.fetch as jest.Mock).mockResolvedValue({ ok: false, status: 500 }); - - await expect( - evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 110, - }) - ).rejects.toThrow(); - - expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); - }); - - it('update is called exactly once per alert per movement', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 110, - }); - - expect(mockPrisma.priceAlert.update).toHaveBeenCalledTimes(1); - }); + it('marks the alert inactive after successful delivery', async () => { + mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); + mockPrisma.priceAlert.update.mockResolvedValue({}); + + await evaluatePriceAlertsForMovement({ + creatorId: CREATOR_ID, + previousPrice: 90, + currentPrice: 110, + }); + + expect(mockPrisma.priceAlert.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: BASE_ALERT.id }, + data: expect.objectContaining({ isActive: false }), + }) + ); + }); + + it('sets triggeredAt after successful delivery', async () => { + mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); + mockPrisma.priceAlert.update.mockResolvedValue({}); + + await evaluatePriceAlertsForMovement({ + creatorId: CREATOR_ID, + previousPrice: 90, + currentPrice: 110, + }); + + const updateCall = mockPrisma.priceAlert.update.mock.calls[0][0]; + expect(updateCall.data.triggeredAt).toBeInstanceOf(Date); + }); + + it('does not re-trigger when the alert is already inactive (second crossing)', async () => { + // Second call: alert is not returned because isActive: false filters it out + mockPrisma.priceAlert.findMany.mockResolvedValue([]); + + await evaluatePriceAlertsForMovement({ + creatorId: CREATOR_ID, + previousPrice: 90, + currentPrice: 120, + }); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); + }); + + it('does not mark alert inactive when the callback fails', async () => { + mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); + (global.fetch as jest.Mock).mockResolvedValue({ ok: false, status: 500 }); + + await expect( + evaluatePriceAlertsForMovement({ + creatorId: CREATOR_ID, + previousPrice: 90, + currentPrice: 110, + }) + ).rejects.toThrow(); + + expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); + }); + + it('update is called exactly once per alert per movement', async () => { + mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); + mockPrisma.priceAlert.update.mockResolvedValue({}); + + await evaluatePriceAlertsForMovement({ + creatorId: CREATOR_ID, + previousPrice: 90, + currentPrice: 110, + }); + + expect(mockPrisma.priceAlert.update).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/modules/alerts/__tests__/alert-price-movement.integration.test.ts b/src/modules/alerts/__tests__/alert-price-movement.integration.test.ts index 8967ed5..0699354 100644 --- a/src/modules/alerts/__tests__/alert-price-movement.integration.test.ts +++ b/src/modules/alerts/__tests__/alert-price-movement.integration.test.ts @@ -4,217 +4,226 @@ import { envConfig } from '../../../config'; import { logger } from '../../../utils/logger.utils'; jest.mock('../../../utils/prisma.utils', () => ({ - prisma: { - priceAlert: { - findMany: jest.fn(), - update: jest.fn(), - }, - }, + prisma: { + priceAlert: { + findMany: jest.fn(), + update: jest.fn(), + }, + }, })); jest.mock('../../../utils/logger.utils', () => ({ - logger: { - warn: jest.fn(), - error: jest.fn(), - }, + logger: { + warn: jest.fn(), + error: jest.fn(), + }, })); const mockPrisma = prisma as unknown as { - priceAlert: { - findMany: jest.Mock; - update: jest.Mock; - }; + priceAlert: { + findMany: jest.Mock; + update: jest.Mock; + }; }; const mockLogger = logger as unknown as { - warn: jest.Mock; - error: jest.Mock; + warn: jest.Mock; + error: jest.Mock; }; const BASE_ALERT = { - id: 'alert-1', - creatorId: 'creator-1', - walletAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - targetPrice: 100, - callbackUrl: 'https://example.com/price-alert', - isActive: true, - triggeredAt: null, - createdAt: new Date('2026-06-01T00:00:00Z'), + id: 'alert-1', + creatorId: 'creator-1', + walletAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + targetPrice: 100, + callbackUrl: 'https://example.com/price-alert', + isActive: true, + triggeredAt: null, + createdAt: new Date('2026-06-01T00:00:00Z'), }; describe('price alert movement integration', () => { - beforeEach(() => { - jest.clearAllMocks(); - global.fetch = jest.fn().mockResolvedValue({ ok: true }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('does not fire an above alert when the price drops', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([ - { - ...BASE_ALERT, - id: 'above-alert', - direction: 'above', - targetPrice: 120, - }, - ]); - - await evaluatePriceAlertsForMovement({ + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = jest.fn().mockResolvedValue({ ok: true }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not fire an above alert when the price drops', async () => { + mockPrisma.priceAlert.findMany.mockResolvedValue([ + { + ...BASE_ALERT, + id: 'above-alert', + direction: 'above', + targetPrice: 120, + }, + ]); + + await evaluatePriceAlertsForMovement({ + creatorId: 'creator-1', + previousPrice: 100, + currentPrice: 90, + }); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); + expect(mockPrisma.priceAlert.findMany).toHaveBeenCalledWith({ + where: { creatorId: 'creator-1', - previousPrice: 100, - currentPrice: 90, - }); - - expect(global.fetch).not.toHaveBeenCalled(); - expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); - expect(mockPrisma.priceAlert.findMany).toHaveBeenCalledWith({ - where: { - creatorId: 'creator-1', - isActive: true, - triggeredAt: null, - }, - }); - }); - - it('does not fire a below alert when the price rises', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([ - { - ...BASE_ALERT, - id: 'below-alert', - direction: 'below', - targetPrice: 80, - }, - ]); - - await evaluatePriceAlertsForMovement({ + isActive: true, + triggeredAt: null, + }, + }); + }); + + it('does not fire a below alert when the price rises', async () => { + mockPrisma.priceAlert.findMany.mockResolvedValue([ + { + ...BASE_ALERT, + id: 'below-alert', + direction: 'below', + targetPrice: 80, + }, + ]); + + await evaluatePriceAlertsForMovement({ + creatorId: 'creator-1', + previousPrice: 100, + currentPrice: 110, + }); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); + }); + + it('logs a structured warning with masked URL after a failed delivery attempt', async () => { + mockPrisma.priceAlert.findMany.mockResolvedValue([ + { + ...BASE_ALERT, + id: 'above-alert', + direction: 'above', + targetPrice: 100, + callbackUrl: + 'https://hooks.example.com/secret/path?token=sensitive', + }, + ]); + mockPrisma.priceAlert.update.mockResolvedValue({}); + (global.fetch as jest.Mock) + .mockRejectedValueOnce(new Error('Network failure')) + .mockResolvedValueOnce({ ok: true }); + + await evaluatePriceAlertsForMovement({ + creatorId: 'creator-1', + previousPrice: 90, + currentPrice: 110, + }); + + expect(mockLogger.warn).toHaveBeenCalledWith( + { + alert_id: 'above-alert', + retry_count: 1, + error_code: 'Error', + failure_reason: 'Network failure', + masked_url: 'https://hooks.example.com', + }, + 'Price alert webhook delivery failed' + ); + expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain( + '/secret/path' + ); + expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain( + 'sensitive' + ); + expect(mockLogger.error).not.toHaveBeenCalled(); + expect(mockPrisma.priceAlert.update).toHaveBeenCalledWith({ + where: { id: 'above-alert' }, + data: { + isActive: false, + triggeredAt: expect.any(Date), + }, + }); + }); + + it('logs a structured final error with masked URL when retries are exhausted', async () => { + mockPrisma.priceAlert.findMany.mockResolvedValue([ + { + ...BASE_ALERT, + id: 'below-alert', + direction: 'below', + targetPrice: 80, + callbackUrl: 'https://hooks.example.com/private/price-alert', + }, + ]); + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 503, + }); + + await expect( + evaluatePriceAlertsForMovement({ creatorId: 'creator-1', previousPrice: 100, - currentPrice: 110, - }); - - expect(global.fetch).not.toHaveBeenCalled(); - expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); - }); - - it('logs a structured warning with masked URL after a failed delivery attempt', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([ - { - ...BASE_ALERT, - id: 'above-alert', - direction: 'above', - targetPrice: 100, - callbackUrl: 'https://hooks.example.com/secret/path?token=sensitive', - }, - ]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - (global.fetch as jest.Mock) - .mockRejectedValueOnce(new Error('Network failure')) - .mockResolvedValueOnce({ ok: true }); - - await evaluatePriceAlertsForMovement({ - creatorId: 'creator-1', - previousPrice: 90, - currentPrice: 110, - }); - - expect(mockLogger.warn).toHaveBeenCalledWith( - { - alert_id: 'above-alert', - retry_count: 1, - error_code: 'Error', - failure_reason: 'Network failure', - masked_url: 'https://hooks.example.com', - }, - 'Price alert webhook delivery failed' - ); - expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain('/secret/path'); - expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain('sensitive'); - expect(mockLogger.error).not.toHaveBeenCalled(); - expect(mockPrisma.priceAlert.update).toHaveBeenCalledWith({ - where: { id: 'above-alert' }, - data: { - isActive: false, - triggeredAt: expect.any(Date), - }, - }); - }); - - it('logs a structured final error with masked URL when retries are exhausted', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([ - { - ...BASE_ALERT, - id: 'below-alert', - direction: 'below', - targetPrice: 80, - callbackUrl: 'https://hooks.example.com/private/price-alert', - }, - ]); - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 503, - }); - - await expect( - evaluatePriceAlertsForMovement({ - creatorId: 'creator-1', - previousPrice: 100, - currentPrice: 70, - }) - ).rejects.toThrow('HTTP_503'); - - expect(global.fetch).toHaveBeenCalledTimes(envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS); - expect(mockLogger.error).toHaveBeenCalledWith( - { - alert_id: 'below-alert', - retry_count: envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS, - error_code: 'HTTP_503', - failure_reason: 'HTTP_503', - masked_url: 'https://hooks.example.com', - final: true, - }, - 'Price alert webhook delivery exhausted retries' - ); - expect(JSON.stringify(mockLogger.error.mock.calls)).not.toContain('/private/price-alert'); - expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); - }); - - it('logs a structured error with all fields when the database query fails', async () => { - const dbError = new Error('connection refused'); - mockPrisma.priceAlert.findMany.mockRejectedValue(dbError); - - await expect( - evaluatePriceAlertsForMovement({ - creatorId: 'creator-1', - previousPrice: 90, - currentPrice: 110, - ledger_sequence: 42, - }) - ).rejects.toThrow('connection refused'); - - expect(mockLogger.error).toHaveBeenCalledWith( - { - creator_id: 'creator-1', - ledger_sequence: 42, - new_price: 110, - error_message: 'connection refused', - failed_at: expect.any(String), - }, - 'Price alert threshold check failed' - ); - }); - - it('does not log alert threshold failure when the check succeeds', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([]); - - await evaluatePriceAlertsForMovement({ + currentPrice: 70, + }) + ).rejects.toThrow('HTTP_503'); + + expect(global.fetch).toHaveBeenCalledTimes( + envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS + ); + expect(mockLogger.error).toHaveBeenCalledWith( + { + alert_id: 'below-alert', + retry_count: envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS, + error_code: 'HTTP_503', + failure_reason: 'HTTP_503', + masked_url: 'https://hooks.example.com', + final: true, + }, + 'Price alert webhook delivery exhausted retries' + ); + expect(JSON.stringify(mockLogger.error.mock.calls)).not.toContain( + '/private/price-alert' + ); + expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); + }); + + it('logs a structured error with all fields when the database query fails', async () => { + const dbError = new Error('connection refused'); + mockPrisma.priceAlert.findMany.mockRejectedValue(dbError); + + await expect( + evaluatePriceAlertsForMovement({ creatorId: 'creator-1', previousPrice: 90, currentPrice: 110, - }); - - expect(mockLogger.error).not.toHaveBeenCalled(); - }); + ledger_sequence: 42, + }) + ).rejects.toThrow('connection refused'); + + expect(mockLogger.error).toHaveBeenCalledWith( + { + creator_id: 'creator-1', + ledger_sequence: 42, + new_price: 110, + error_message: 'connection refused', + failed_at: expect.any(String), + }, + 'Price alert threshold check failed' + ); + }); + + it('does not log alert threshold failure when the check succeeds', async () => { + mockPrisma.priceAlert.findMany.mockResolvedValue([]); + + await evaluatePriceAlertsForMovement({ + creatorId: 'creator-1', + previousPrice: 90, + currentPrice: 110, + }); + + expect(mockLogger.error).not.toHaveBeenCalled(); + }); }); diff --git a/src/modules/alerts/__tests__/alert.service.test.ts b/src/modules/alerts/__tests__/alert.service.test.ts index f9b8592..77456f9 100644 --- a/src/modules/alerts/__tests__/alert.service.test.ts +++ b/src/modules/alerts/__tests__/alert.service.test.ts @@ -8,156 +8,172 @@ import { prisma } from '../../../utils/prisma.utils'; import { logger } from '../../../utils/logger.utils'; jest.mock('../../../utils/prisma.utils', () => ({ - prisma: { - priceAlert: { - create: jest.fn(), - findMany: jest.fn(), - findFirst: jest.fn(), - delete: jest.fn(), - }, - }, + prisma: { + priceAlert: { + create: jest.fn(), + findMany: jest.fn(), + findFirst: jest.fn(), + delete: jest.fn(), + }, + }, })); jest.mock('../../../utils/logger.utils', () => ({ - logger: { info: jest.fn() }, + logger: { info: jest.fn() }, })); const mockedPrisma = prisma as jest.Mocked; -const VALID_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const VALID_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const BASE_INPUT = { - creator_id: 'creator-1', - wallet_address: VALID_ADDRESS, - target_price: 100, - direction: 'above' as const, - callback_url: 'https://example.com/callback', + creator_id: 'creator-1', + wallet_address: VALID_ADDRESS, + target_price: 100, + direction: 'above' as const, + callback_url: 'https://example.com/callback', }; const DB_ALERT = { - id: 'alert-1', - creatorId: 'creator-1', - walletAddress: VALID_ADDRESS, - targetPrice: 100, - direction: 'above', - callbackUrl: 'https://example.com/callback', - isActive: true, - triggeredAt: null, - createdAt: new Date('2026-01-01T00:00:00Z'), + id: 'alert-1', + creatorId: 'creator-1', + walletAddress: VALID_ADDRESS, + targetPrice: 100, + direction: 'above', + callbackUrl: 'https://example.com/callback', + isActive: true, + triggeredAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), }; describe('createAlert', () => { - afterEach(() => jest.clearAllMocks()); - - it('calls prisma.priceAlert.create with correct data', async () => { - (mockedPrisma.priceAlert.create as jest.Mock).mockResolvedValue(DB_ALERT); - - const result = await createAlert(BASE_INPUT); - - expect(mockedPrisma.priceAlert.create).toHaveBeenCalledWith({ - data: { - creatorId: 'creator-1', - walletAddress: VALID_ADDRESS, - targetPrice: 100, - direction: 'above', - callbackUrl: 'https://example.com/callback', - }, - }); - expect(result).toEqual(DB_ALERT); - - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ - alert_id: DB_ALERT.id, - creator_id: DB_ALERT.creatorId, - direction: DB_ALERT.direction, - target_price: DB_ALERT.targetPrice, - registered_at: DB_ALERT.createdAt, - wallet_address: 'GAAA***AAAA', - }), - 'Price alert registered' - ); - expect((logger.info as jest.Mock).mock.calls[0][0]).not.toHaveProperty('callback_url'); - }); - - it('creates a below-direction alert', async () => { - const input = { ...BASE_INPUT, direction: 'below' as const, target_price: 50 }; - (mockedPrisma.priceAlert.create as jest.Mock).mockResolvedValue({ - ...DB_ALERT, - direction: 'below', - targetPrice: 50, - }); - - const result = await createAlert(input); - expect(result.direction).toBe('below'); - }); + afterEach(() => jest.clearAllMocks()); + + it('calls prisma.priceAlert.create with correct data', async () => { + (mockedPrisma.priceAlert.create as jest.Mock).mockResolvedValue(DB_ALERT); + + const result = await createAlert(BASE_INPUT); + + expect(mockedPrisma.priceAlert.create).toHaveBeenCalledWith({ + data: { + creatorId: 'creator-1', + walletAddress: VALID_ADDRESS, + targetPrice: 100, + direction: 'above', + callbackUrl: 'https://example.com/callback', + }, + }); + expect(result).toEqual(DB_ALERT); + + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + alert_id: DB_ALERT.id, + creator_id: DB_ALERT.creatorId, + direction: DB_ALERT.direction, + target_price: DB_ALERT.targetPrice, + registered_at: DB_ALERT.createdAt, + wallet_address: 'GAAA***AAAA', + }), + 'Price alert registered' + ); + expect((logger.info as jest.Mock).mock.calls[0][0]).not.toHaveProperty( + 'callback_url' + ); + }); + + it('creates a below-direction alert', async () => { + const input = { + ...BASE_INPUT, + direction: 'below' as const, + target_price: 50, + }; + (mockedPrisma.priceAlert.create as jest.Mock).mockResolvedValue({ + ...DB_ALERT, + direction: 'below', + targetPrice: 50, + }); + + const result = await createAlert(input); + expect(result.direction).toBe('below'); + }); }); describe('listAlerts', () => { - afterEach(() => jest.clearAllMocks()); + afterEach(() => jest.clearAllMocks()); - it('returns active alerts for a wallet address', async () => { - (mockedPrisma.priceAlert.findMany as jest.Mock).mockResolvedValue([DB_ALERT]); + it('returns active alerts for a wallet address', async () => { + (mockedPrisma.priceAlert.findMany as jest.Mock).mockResolvedValue([ + DB_ALERT, + ]); - const result = await listAlerts(VALID_ADDRESS); + const result = await listAlerts(VALID_ADDRESS); - expect(mockedPrisma.priceAlert.findMany).toHaveBeenCalledWith({ - where: { walletAddress: VALID_ADDRESS, isActive: true }, - orderBy: { createdAt: 'desc' }, - }); - expect(result).toHaveLength(1); - expect(result[0].id).toBe('alert-1'); - }); + expect(mockedPrisma.priceAlert.findMany).toHaveBeenCalledWith({ + where: { walletAddress: VALID_ADDRESS, isActive: true }, + orderBy: { createdAt: 'desc' }, + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('alert-1'); + }); - it('returns empty array when no alerts exist', async () => { - (mockedPrisma.priceAlert.findMany as jest.Mock).mockResolvedValue([]); + it('returns empty array when no alerts exist', async () => { + (mockedPrisma.priceAlert.findMany as jest.Mock).mockResolvedValue([]); - const result = await listAlerts(VALID_ADDRESS); - expect(result).toEqual([]); - }); + const result = await listAlerts(VALID_ADDRESS); + expect(result).toEqual([]); + }); }); describe('deleteAlert', () => { - afterEach(() => jest.clearAllMocks()); - - it('deletes the alert and returns its id when found', async () => { - (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(DB_ALERT); - (mockedPrisma.priceAlert.delete as jest.Mock).mockResolvedValue(DB_ALERT); - - const result = await deleteAlert('alert-1', VALID_ADDRESS); - - expect(mockedPrisma.priceAlert.findFirst).toHaveBeenCalledWith({ - where: { id: 'alert-1', walletAddress: VALID_ADDRESS }, - }); - expect(mockedPrisma.priceAlert.delete).toHaveBeenCalledWith({ - where: { id: 'alert-1' }, - }); - expect(result).toEqual({ id: 'alert-1' }); - - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ - alert_id: DB_ALERT.id, - creator_id: DB_ALERT.creatorId, - cancelled_at: expect.any(Date), - wallet_address: 'GAAA***AAAA', - }), - 'Price alert cancelled' - ); - expect((logger.info as jest.Mock).mock.calls[0][0]).not.toHaveProperty('callback_url'); - }); - - it('returns null when the alert is not found', async () => { - (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null); - - const result = await deleteAlert('nonexistent', VALID_ADDRESS); - - expect(result).toBeNull(); - expect(mockedPrisma.priceAlert.delete).not.toHaveBeenCalled(); - }); - - it('does not delete an alert belonging to a different wallet address', async () => { - (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null); - - const result = await deleteAlert('alert-1', 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'); - expect(result).toBeNull(); - }); + afterEach(() => jest.clearAllMocks()); + + it('deletes the alert and returns its id when found', async () => { + (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue( + DB_ALERT + ); + (mockedPrisma.priceAlert.delete as jest.Mock).mockResolvedValue(DB_ALERT); + + const result = await deleteAlert('alert-1', VALID_ADDRESS); + + expect(mockedPrisma.priceAlert.findFirst).toHaveBeenCalledWith({ + where: { id: 'alert-1', walletAddress: VALID_ADDRESS }, + }); + expect(mockedPrisma.priceAlert.delete).toHaveBeenCalledWith({ + where: { id: 'alert-1' }, + }); + expect(result).toEqual({ id: 'alert-1' }); + + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + alert_id: DB_ALERT.id, + creator_id: DB_ALERT.creatorId, + cancelled_at: expect.any(Date), + wallet_address: 'GAAA***AAAA', + }), + 'Price alert cancelled' + ); + expect((logger.info as jest.Mock).mock.calls[0][0]).not.toHaveProperty( + 'callback_url' + ); + }); + + it('returns null when the alert is not found', async () => { + (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null); + + const result = await deleteAlert('nonexistent', VALID_ADDRESS); + + expect(result).toBeNull(); + expect(mockedPrisma.priceAlert.delete).not.toHaveBeenCalled(); + }); + + it('does not delete an alert belonging to a different wallet address', async () => { + (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null); + + const result = await deleteAlert( + 'alert-1', + 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + ); + expect(result).toBeNull(); + }); }); diff --git a/src/modules/alerts/alert.controllers.ts b/src/modules/alerts/alert.controllers.ts index 91c559d..d646377 100644 --- a/src/modules/alerts/alert.controllers.ts +++ b/src/modules/alerts/alert.controllers.ts @@ -1,15 +1,15 @@ import { Request, Response, NextFunction } from 'express'; import { - CreateAlertSchema, - ListAlertsQuerySchema, - AlertParamsSchema, - DeleteAlertBodySchema, + CreateAlertSchema, + ListAlertsQuerySchema, + AlertParamsSchema, + DeleteAlertBodySchema, } from './alert.schemas'; import { createAlert, listAlerts, deleteAlert } from './alert.service'; import { - sendSuccess, - sendValidationError, - sendNotFound, + sendSuccess, + sendValidationError, + sendNotFound, } from '../../utils/api-response.utils'; /** @@ -17,29 +17,31 @@ import { * Register a new price alert. */ export async function httpCreateAlert( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { - try { - const parsed = CreateAlertSchema.safeParse(req.body); - if (!parsed.success) { - sendValidationError( - res, - 'Invalid alert input', - parsed.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({ - field: issue.path.join('.'), - message: issue.message, - })) - ); - return; - } + try { + const parsed = CreateAlertSchema.safeParse(req.body); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid alert input', + parsed.error.issues.map( + (issue: { path: (string | number)[]; message: string }) => ({ + field: issue.path.join('.'), + message: issue.message, + }) + ) + ); + return; + } - const alert = await createAlert(parsed.data); - sendSuccess(res, alert, 201); - } catch (error) { - next(error); - } + const alert = await createAlert(parsed.data); + sendSuccess(res, alert, 201); + } catch (error) { + next(error); + } } /** @@ -47,29 +49,31 @@ export async function httpCreateAlert( * List all active price alerts for a wallet address. */ export async function httpListAlerts( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { - try { - const parsed = ListAlertsQuerySchema.safeParse(req.query); - if (!parsed.success) { - sendValidationError( - res, - 'Invalid query parameters', - parsed.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({ - field: issue.path.join('.'), - message: issue.message, - })) - ); - return; - } + try { + const parsed = ListAlertsQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid query parameters', + parsed.error.issues.map( + (issue: { path: (string | number)[]; message: string }) => ({ + field: issue.path.join('.'), + message: issue.message, + }) + ) + ); + return; + } - const alerts = await listAlerts(parsed.data.wallet_address); - sendSuccess(res, { items: alerts, total: alerts.length }); - } catch (error) { - next(error); - } + const alerts = await listAlerts(parsed.data.wallet_address); + sendSuccess(res, { items: alerts, total: alerts.length }); + } catch (error) { + next(error); + } } /** @@ -77,46 +81,53 @@ export async function httpListAlerts( * Delete a price alert by id, scoped to the wallet address in the request body. */ export async function httpDeleteAlert( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { - try { - const parsedParams = AlertParamsSchema.safeParse(req.params); - if (!parsedParams.success) { - sendValidationError( - res, - 'Invalid alert id', - parsedParams.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({ - field: issue.path.join('.'), - message: issue.message, - })) - ); - return; - } + try { + const parsedParams = AlertParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + sendValidationError( + res, + 'Invalid alert id', + parsedParams.error.issues.map( + (issue: { path: (string | number)[]; message: string }) => ({ + field: issue.path.join('.'), + message: issue.message, + }) + ) + ); + return; + } - const parsedBody = DeleteAlertBodySchema.safeParse(req.body); - if (!parsedBody.success) { - sendValidationError( - res, - 'Invalid request body', - parsedBody.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({ - field: issue.path.join('.'), - message: issue.message, - })) - ); - return; - } + const parsedBody = DeleteAlertBodySchema.safeParse(req.body); + if (!parsedBody.success) { + sendValidationError( + res, + 'Invalid request body', + parsedBody.error.issues.map( + (issue: { path: (string | number)[]; message: string }) => ({ + field: issue.path.join('.'), + message: issue.message, + }) + ) + ); + return; + } - const result = await deleteAlert(parsedParams.data.id, parsedBody.data.wallet_address); + const result = await deleteAlert( + parsedParams.data.id, + parsedBody.data.wallet_address + ); - if (!result) { - sendNotFound(res, 'Alert'); - return; - } + if (!result) { + sendNotFound(res, 'Alert'); + return; + } - sendSuccess(res, result); - } catch (error) { - next(error); - } + sendSuccess(res, result); + } catch (error) { + next(error); + } } diff --git a/src/modules/alerts/alert.router.ts b/src/modules/alerts/alert.router.ts index 90aaf75..b99ef3c 100644 --- a/src/modules/alerts/alert.router.ts +++ b/src/modules/alerts/alert.router.ts @@ -1,5 +1,9 @@ import { Router } from 'express'; -import { httpCreateAlert, httpListAlerts, httpDeleteAlert } from './alert.controllers'; +import { + httpCreateAlert, + httpListAlerts, + httpDeleteAlert, +} from './alert.controllers'; const alertsRouter = Router(); diff --git a/src/modules/alerts/alert.schemas.ts b/src/modules/alerts/alert.schemas.ts index 0f7f2f1..3512099 100644 --- a/src/modules/alerts/alert.schemas.ts +++ b/src/modules/alerts/alert.schemas.ts @@ -2,37 +2,43 @@ import { z } from 'zod'; import { isValidStellarAddress } from '../wallet/wallet.utils'; export const CreateAlertSchema = z.object({ - creator_id: z.string().min(1, 'creator_id is required'), - wallet_address: z - .string() - .refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address' }), - target_price: z - .number({ invalid_type_error: 'target_price must be a number' }) - .positive('target_price must be positive'), - direction: z.enum(['above', 'below'], { - errorMap: () => ({ message: "direction must be 'above' or 'below'" }), - }), - callback_url: z.string().url('callback_url must be a valid URL'), + creator_id: z.string().min(1, 'creator_id is required'), + wallet_address: z + .string() + .refine(isValidStellarAddress, { + message: 'Invalid Stellar wallet address', + }), + target_price: z + .number({ invalid_type_error: 'target_price must be a number' }) + .positive('target_price must be positive'), + direction: z.enum(['above', 'below'], { + errorMap: () => ({ message: "direction must be 'above' or 'below'" }), + }), + callback_url: z.string().url('callback_url must be a valid URL'), }); export type CreateAlertInput = z.infer; export const ListAlertsQuerySchema = z.object({ - wallet_address: z - .string() - .refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address' }), + wallet_address: z + .string() + .refine(isValidStellarAddress, { + message: 'Invalid Stellar wallet address', + }), }); export type ListAlertsQueryType = z.infer; export const AlertParamsSchema = z.object({ - id: z.string().min(1, 'Alert id is required'), + id: z.string().min(1, 'Alert id is required'), }); export const DeleteAlertBodySchema = z.object({ - wallet_address: z - .string() - .refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address' }), + wallet_address: z + .string() + .refine(isValidStellarAddress, { + message: 'Invalid Stellar wallet address', + }), }); export type DeleteAlertBodyType = z.infer; diff --git a/src/modules/alerts/alert.service.ts b/src/modules/alerts/alert.service.ts index 50e13fd..38ff0e6 100644 --- a/src/modules/alerts/alert.service.ts +++ b/src/modules/alerts/alert.service.ts @@ -4,10 +4,10 @@ import { logger } from '../../utils/logger.utils'; import { CreateAlertInput } from './alert.schemas'; export type PriceMovement = { - creatorId: string; - previousPrice: number | string; - currentPrice: number | string; - ledger_sequence?: number; + creatorId: string; + previousPrice: number | string; + currentPrice: number | string; + ledger_sequence?: number; }; /** @@ -184,62 +184,62 @@ async function deliverPriceAlertWebhook( export async function evaluatePriceAlertsForMovement( movement: PriceMovement ): Promise { - try { - const previousPrice = toNumber(movement.previousPrice); - const currentPrice = toNumber(movement.currentPrice); - - const alerts = await prisma.priceAlert.findMany({ - where: { - creatorId: movement.creatorId, - isActive: true, - triggeredAt: null, - }, - }); - - for (const alert of alerts) { - const targetPrice = toNumber(alert.targetPrice); - const crossedAbove = - alert.direction === 'above' && - previousPrice < targetPrice && - currentPrice >= targetPrice; - const crossedBelow = - alert.direction === 'below' && - previousPrice > targetPrice && - currentPrice <= targetPrice; - - if (!crossedAbove && !crossedBelow) { - continue; - } - - await deliverPriceAlertWebhook(alert, { - event_type: 'price_alert', - alert_id: alert.id, - creator_id: alert.creatorId, - wallet_address: alert.walletAddress, - target_price: targetPrice, - current_price: currentPrice, - direction: alert.direction, - }); - - await prisma.priceAlert.update({ - where: { id: alert.id }, - data: { - isActive: false, - triggeredAt: new Date(), - }, - }); - } - } catch (err) { - logger.error( - { - creator_id: movement.creatorId, - ledger_sequence: movement.ledger_sequence, - new_price: movement.currentPrice, - error_message: err instanceof Error ? err.message : 'Unknown error', - failed_at: new Date().toISOString(), + try { + const previousPrice = toNumber(movement.previousPrice); + const currentPrice = toNumber(movement.currentPrice); + + const alerts = await prisma.priceAlert.findMany({ + where: { + creatorId: movement.creatorId, + isActive: true, + triggeredAt: null, + }, + }); + + for (const alert of alerts) { + const targetPrice = toNumber(alert.targetPrice); + const crossedAbove = + alert.direction === 'above' && + previousPrice < targetPrice && + currentPrice >= targetPrice; + const crossedBelow = + alert.direction === 'below' && + previousPrice > targetPrice && + currentPrice <= targetPrice; + + if (!crossedAbove && !crossedBelow) { + continue; + } + + await deliverPriceAlertWebhook(alert, { + event_type: 'price_alert', + alert_id: alert.id, + creator_id: alert.creatorId, + wallet_address: alert.walletAddress, + target_price: targetPrice, + current_price: currentPrice, + direction: alert.direction, + }); + + await prisma.priceAlert.update({ + where: { id: alert.id }, + data: { + isActive: false, + triggeredAt: new Date(), }, - 'Price alert threshold check failed' - ); - throw err; - } + }); + } + } catch (err) { + logger.error( + { + creator_id: movement.creatorId, + ledger_sequence: movement.ledger_sequence, + new_price: movement.currentPrice, + error_message: err instanceof Error ? err.message : 'Unknown error', + failed_at: new Date().toISOString(), + }, + 'Price alert threshold check failed' + ); + throw err; + } } diff --git a/src/modules/config/config.controllers.ts b/src/modules/config/config.controllers.ts index f4bc2a0..1c65d24 100644 --- a/src/modules/config/config.controllers.ts +++ b/src/modules/config/config.controllers.ts @@ -32,10 +32,7 @@ interface ProtocolConfig { }; } -export const httpGetProtocolConfig = ( - _req: Request, - res: Response -): void => { +export const httpGetProtocolConfig = (_req: Request, res: Response): void => { const config: ProtocolConfig = { environment: envConfig.MODE, apiVersion: 'v1', @@ -58,4 +55,3 @@ export const httpGetProtocolConfig = ( data: config, }); }; - diff --git a/src/modules/creator/creator-bio-sanitize.utils.test.ts b/src/modules/creator/creator-bio-sanitize.utils.test.ts index 2bfcde7..17e27c0 100644 --- a/src/modules/creator/creator-bio-sanitize.utils.test.ts +++ b/src/modules/creator/creator-bio-sanitize.utils.test.ts @@ -1,33 +1,33 @@ import { sanitizeBio } from './creator-bio-sanitize.utils'; describe('sanitizeBio', () => { - it('strips HTML tags from the bio', () => { - expect(sanitizeBio('Hello world!

How are you?

')).toBe( - 'Hello world! How are you?' - ); - }); + it('strips HTML tags from the bio', () => { + expect(sanitizeBio('Hello world!

How are you?

')).toBe( + 'Hello world! How are you?' + ); + }); - it('removes invisible Unicode characters', () => { - expect( - sanitizeBio('Hello\u200Bworld\u200D!\uFEFFHow\u200Care you?') - ).toBe('Helloworld!Howare you?'); - }); + it('removes invisible Unicode characters', () => { + expect( + sanitizeBio('Hello\u200Bworld\u200D!\uFEFFHow\u200Care you?') + ).toBe('Helloworld!Howare you?'); + }); - it('normalizes whitespace to single spaces', () => { - expect(sanitizeBio('Hello world ! How are you?')).toBe( - 'Hello world ! How are you?' - ); - }); + it('normalizes whitespace to single spaces', () => { + expect(sanitizeBio('Hello world ! How are you?')).toBe( + 'Hello world ! How are you?' + ); + }); - it('trims leading and trailing whitespace', () => { - expect(sanitizeBio(' Hello world! ')).toBe('Hello world!'); - }); + it('trims leading and trailing whitespace', () => { + expect(sanitizeBio(' Hello world! ')).toBe('Hello world!'); + }); - it('handles all sanitization requirements together', () => { - expect( - sanitizeBio( - '
Hello\u200B world! \uFEFF How are you?
' - ) - ).toBe('Hello world! How are you?'); - }); + it('handles all sanitization requirements together', () => { + expect( + sanitizeBio( + '
Hello\u200B world! \uFEFF How are you?
' + ) + ).toBe('Hello world! How are you?'); + }); }); diff --git a/src/modules/creator/creator-bio-sanitize.utils.ts b/src/modules/creator/creator-bio-sanitize.utils.ts index c540ab8..b7d077c 100644 --- a/src/modules/creator/creator-bio-sanitize.utils.ts +++ b/src/modules/creator/creator-bio-sanitize.utils.ts @@ -1,17 +1,20 @@ export function sanitizeBio(bio: string): string { - let sanitized = bio; + let sanitized = bio; - // Strip HTML tags - sanitized = sanitized.replace(/<[^>]*>/g, ''); + // Strip HTML tags + sanitized = sanitized.replace(/<[^>]*>/g, ''); - // Remove invisible Unicode characters (zero-width spaces, control characters, etc.) - // This includes: - // - Control characters (C0 and C1) except for space (0x20), tab (0x09), line feed (0x0A), carriage return (0x0D) - // - Zero-width spaces, joiners, non-joiners, etc. - sanitized = sanitized.replace(/[\u0000-\u0008\u000B-\u000C\u000E-\u001F\u007F-\u009F\u200B-\u200D\u2060\uFEFF]/g, ''); + // Remove invisible Unicode characters (zero-width spaces, control characters, etc.) + // This includes: + // - Control characters (C0 and C1) except for space (0x20), tab (0x09), line feed (0x0A), carriage return (0x0D) + // - Zero-width spaces, joiners, non-joiners, etc. + sanitized = sanitized.replace( + /[\u0000-\u0008\u000B-\u000C\u000E-\u001F\u007F-\u009F\u200B-\u200D\u2060\uFEFF]/g, + '' + ); - // Normalize whitespace - sanitized = sanitized.replace(/\s+/g, ' ').trim(); + // Normalize whitespace + sanitized = sanitized.replace(/\s+/g, ' ').trim(); - return sanitized; + return sanitized; } diff --git a/src/modules/creator/creator-detail-cache-headers.integration.test.ts b/src/modules/creator/creator-detail-cache-headers.integration.test.ts index b47a5b8..a4e59f2 100644 --- a/src/modules/creator/creator-detail-cache-headers.integration.test.ts +++ b/src/modules/creator/creator-detail-cache-headers.integration.test.ts @@ -20,10 +20,12 @@ function makeRes(): any { const res: any = {}; res.status = jest.fn().mockReturnValue(res); res.json = jest.fn().mockReturnValue(res); - res.setHeader = jest.fn().mockImplementation((name: string, value: string) => { - headers[name.toLowerCase()] = value; - return res; - }); + res.setHeader = jest + .fn() + .mockImplementation((name: string, value: string) => { + headers[name.toLowerCase()] = value; + return res; + }); res.set = jest.fn().mockImplementation((name: string, value: string) => { headers[name.toLowerCase()] = value; return res; @@ -57,7 +59,9 @@ describe('GET /api/v1/creators/:creatorId/profile — cache headers', () => { }); it('sets Cache-Control header on a successful profile response', async () => { - jest.spyOn(creatorProfileService, 'getCreatorProfile').mockResolvedValue(FIXTURE_PROFILE); + jest + .spyOn(creatorProfileService, 'getCreatorProfile') + .mockResolvedValue(FIXTURE_PROFILE); // The cacheControl middleware runs before the handler in the real route. // Here we simulate it by calling setHeader directly, mirroring what the @@ -86,21 +90,25 @@ describe('GET /api/v1/creators/:creatorId/profile — cache headers', () => { }); it('Cache-Control max-age is a positive integer', () => { - const match = CREATOR_PUBLIC_ROUTE_CACHE_CONTROL_HEADER.publicRead.match( - /max-age=(\d+)/ - ); + const match = + CREATOR_PUBLIC_ROUTE_CACHE_CONTROL_HEADER.publicRead.match( + /max-age=(\d+)/ + ); expect(match).not.toBeNull(); const maxAge = parseInt(match![1], 10); expect(maxAge).toBeGreaterThan(0); }); it('handler does not override a Cache-Control header set by upstream middleware', async () => { - jest.spyOn(creatorProfileService, 'getCreatorProfile').mockResolvedValue(FIXTURE_PROFILE); + jest + .spyOn(creatorProfileService, 'getCreatorProfile') + .mockResolvedValue(FIXTURE_PROFILE); const req = makeReq({ creatorId: 'creator-abc' }); const res = makeRes(); - const upstreamValue = CREATOR_PUBLIC_ROUTE_CACHE_CONTROL_HEADER.publicRead; + const upstreamValue = + CREATOR_PUBLIC_ROUTE_CACHE_CONTROL_HEADER.publicRead; res.setHeader('Cache-Control', upstreamValue); await getCreatorProfileHandler(req, res); @@ -114,11 +122,16 @@ describe('GET /api/v1/creators/:creatorId/profile — cache headers', () => { }); it('returns HTTP 200 alongside the cache header and response timestamp for a found profile', async () => { - jest.spyOn(creatorProfileService, 'getCreatorProfile').mockResolvedValue(FIXTURE_PROFILE); + jest + .spyOn(creatorProfileService, 'getCreatorProfile') + .mockResolvedValue(FIXTURE_PROFILE); const req = makeReq({ creatorId: 'creator-abc' }); const res = makeRes(); - res.setHeader('Cache-Control', CREATOR_PUBLIC_ROUTE_CACHE_CONTROL_HEADER.publicRead); + res.setHeader( + 'Cache-Control', + CREATOR_PUBLIC_ROUTE_CACHE_CONTROL_HEADER.publicRead + ); await getCreatorProfileHandler(req, res); diff --git a/src/modules/creator/creator-not-found.utils.test.ts b/src/modules/creator/creator-not-found.utils.test.ts index 24bc098..f2265c2 100644 --- a/src/modules/creator/creator-not-found.utils.test.ts +++ b/src/modules/creator/creator-not-found.utils.test.ts @@ -4,39 +4,39 @@ import { sendError } from '../../utils/api-response.utils'; import { ErrorCode } from '../../constants/error.constants'; jest.mock('../../utils/api-response.utils', () => ({ - sendError: jest.fn(), + sendError: jest.fn(), })); function makeRes(): any { - return { status: jest.fn().mockReturnThis(), json: jest.fn() }; + return { status: jest.fn().mockReturnThis(), json: jest.fn() }; } describe('sendCreatorNotFound', () => { - beforeEach(() => { - jest.resetAllMocks(); - }); + beforeEach(() => { + jest.resetAllMocks(); + }); - it('calls sendError with status 404', () => { - const res = makeRes(); - sendCreatorNotFound(res); - expect(sendError).toHaveBeenCalledWith( - res, - 404, - ErrorCode.NOT_FOUND, - 'Creator not found' - ); - }); + it('calls sendError with status 404', () => { + const res = makeRes(); + sendCreatorNotFound(res); + expect(sendError).toHaveBeenCalledWith( + res, + 404, + ErrorCode.NOT_FOUND, + 'Creator not found' + ); + }); - it('uses the NOT_FOUND error code from shared constants', () => { - const res = makeRes(); - sendCreatorNotFound(res); - const [, , code] = (sendError as jest.Mock).mock.calls[0]; - expect(code).toBe(ErrorCode.NOT_FOUND); - }); + it('uses the NOT_FOUND error code from shared constants', () => { + const res = makeRes(); + sendCreatorNotFound(res); + const [, , code] = (sendError as jest.Mock).mock.calls[0]; + expect(code).toBe(ErrorCode.NOT_FOUND); + }); - it('returns void', () => { - const res = makeRes(); - const result = sendCreatorNotFound(res); - expect(result).toBeUndefined(); - }); + it('returns void', () => { + const res = makeRes(); + const result = sendCreatorNotFound(res); + expect(result).toBeUndefined(); + }); }); diff --git a/src/modules/creator/creator-not-found.utils.ts b/src/modules/creator/creator-not-found.utils.ts index e458c7b..b21eeaf 100644 --- a/src/modules/creator/creator-not-found.utils.ts +++ b/src/modules/creator/creator-not-found.utils.ts @@ -20,5 +20,5 @@ import { ErrorCode } from '../../constants/error.constants'; * } */ export function sendCreatorNotFound(res: Response): void { - sendError(res, 404, ErrorCode.NOT_FOUND, 'Creator not found'); + sendError(res, 404, ErrorCode.NOT_FOUND, 'Creator not found'); } diff --git a/src/modules/creator/creator-profile-protected.integration.test.ts b/src/modules/creator/creator-profile-protected.integration.test.ts index 0ebaae3..7153551 100644 --- a/src/modules/creator/creator-profile-protected.integration.test.ts +++ b/src/modules/creator/creator-profile-protected.integration.test.ts @@ -46,11 +46,13 @@ jest.mock('../../utils/wallet-ownership.utils', () => ({ jest.mock('./creator-profile.service', () => ({ getCreatorProfile: jest.fn(), - upsertCreatorProfile: jest.fn(async (creatorId: string, payload: unknown) => ({ - creatorId, - acceptedProfile: payload, - metadata: { source: 'database', persisted: true }, - })), + upsertCreatorProfile: jest.fn( + async (creatorId: string, payload: unknown) => ({ + creatorId, + acceptedProfile: payload, + metadata: { source: 'database', persisted: true }, + }) + ), })); import supertest from 'supertest'; diff --git a/src/modules/creator/creator-social-link-url.utils.test.ts b/src/modules/creator/creator-social-link-url.utils.test.ts index 7abbcf4..30d7216 100644 --- a/src/modules/creator/creator-social-link-url.utils.test.ts +++ b/src/modules/creator/creator-social-link-url.utils.test.ts @@ -1,29 +1,29 @@ import { normalizeSocialLinkUrl } from './creator-social-link-url.utils'; describe('normalizeSocialLinkUrl', () => { - it('removes trailing slashes from the path', () => { - expect(normalizeSocialLinkUrl('https://example.com/alice/')).toBe( - 'https://example.com/alice' - ); - }); + it('removes trailing slashes from the path', () => { + expect(normalizeSocialLinkUrl('https://example.com/alice/')).toBe( + 'https://example.com/alice' + ); + }); - it('lowercases the host component', () => { - expect(normalizeSocialLinkUrl('https://Twitter.com/Alice')).toBe( - 'https://twitter.com/Alice' - ); - }); + it('lowercases the host component', () => { + expect(normalizeSocialLinkUrl('https://Twitter.com/Alice')).toBe( + 'https://twitter.com/Alice' + ); + }); - it('strips tracking query parameters', () => { - expect( - normalizeSocialLinkUrl( - 'https://example.com/alice?utm_source=twitter&utm_medium=social&page=1' - ) - ).toBe('https://example.com/alice?page=1'); - }); + it('strips tracking query parameters', () => { + expect( + normalizeSocialLinkUrl( + 'https://example.com/alice?utm_source=twitter&utm_medium=social&page=1' + ) + ).toBe('https://example.com/alice?page=1'); + }); - it('preserves the root path slash', () => { - expect(normalizeSocialLinkUrl('https://Example.com/')).toBe( - 'https://example.com/' - ); - }); + it('preserves the root path slash', () => { + expect(normalizeSocialLinkUrl('https://Example.com/')).toBe( + 'https://example.com/' + ); + }); }); diff --git a/src/modules/creator/creator-social-link-url.utils.ts b/src/modules/creator/creator-social-link-url.utils.ts index 5dac015..07ba9b5 100644 --- a/src/modules/creator/creator-social-link-url.utils.ts +++ b/src/modules/creator/creator-social-link-url.utils.ts @@ -3,19 +3,19 @@ * before storing social profile URLs. */ const TRACKING_QUERY_PARAMS = new Set([ - 'fbclid', - 'gclid', - 'mc_cid', - 'mc_eid', - 'msclkid', - 'ref', - 'ref_src', - 'ref_url', - 'utm_campaign', - 'utm_content', - 'utm_medium', - 'utm_source', - 'utm_term', + 'fbclid', + 'gclid', + 'mc_cid', + 'mc_eid', + 'msclkid', + 'ref', + 'ref_src', + 'ref_url', + 'utm_campaign', + 'utm_content', + 'utm_medium', + 'utm_source', + 'utm_term', ]); /** @@ -26,22 +26,22 @@ const TRACKING_QUERY_PARAMS = new Set([ * - Strips common tracking query parameters */ export function normalizeSocialLinkUrl(raw: string): string { - const trimmed = raw.trim(); - const parsed = new URL(trimmed); + const trimmed = raw.trim(); + const parsed = new URL(trimmed); - parsed.hostname = parsed.hostname.toLowerCase(); + parsed.hostname = parsed.hostname.toLowerCase(); - for (const param of [...parsed.searchParams.keys()]) { - if (TRACKING_QUERY_PARAMS.has(param.toLowerCase())) { - parsed.searchParams.delete(param); - } - } + for (const param of [...parsed.searchParams.keys()]) { + if (TRACKING_QUERY_PARAMS.has(param.toLowerCase())) { + parsed.searchParams.delete(param); + } + } - let normalized = parsed.toString(); + let normalized = parsed.toString(); - if (parsed.pathname !== '/' && normalized.endsWith('/')) { - normalized = normalized.replace(/\/+$/, ''); - } + if (parsed.pathname !== '/' && normalized.endsWith('/')) { + normalized = normalized.replace(/\/+$/, ''); + } - return normalized; + return normalized; } diff --git a/src/modules/creator/creator.controller.ts b/src/modules/creator/creator.controller.ts index 56efaa9..a377883 100644 --- a/src/modules/creator/creator.controller.ts +++ b/src/modules/creator/creator.controller.ts @@ -3,10 +3,10 @@ import { RequestHandler } from 'express'; // API response helpers import { - sendSuccess, - sendError, - sendValidationError, - ErrorCode, + sendSuccess, + sendError, + sendValidationError, + ErrorCode, } from '../../utils/api-response.utils'; import { attachTimestampHeader } from '../../utils/timestamp-headers.utils'; import { getPaginatedCreators } from './creator.service'; @@ -23,50 +23,52 @@ import { logger } from '../../utils/logger.utils'; import { LegacyCreatorQuerySchema } from '../creators/creators.schemas'; const ALLOWED_CREATOR_SELECT_FIELDS = [ - 'id', - 'handle', - 'displayName', - 'bio', - 'avatarUrl', - 'bannerUrl', - 'isVerified', - 'keysSupply', - 'floorPrice', - 'createdAt', - 'updatedAt', + 'id', + 'handle', + 'displayName', + 'bio', + 'avatarUrl', + 'bannerUrl', + 'isVerified', + 'keysSupply', + 'floorPrice', + 'createdAt', + 'updatedAt', ] as const; type AllowedCreatorSelectField = (typeof ALLOWED_CREATOR_SELECT_FIELDS)[number]; function parseSelectFields(raw: unknown): string[] { - if (typeof raw !== 'string' || !raw.trim()) { - return []; - } - - return raw - .split(',') - .map((field) => field.trim()) - .filter(Boolean); + if (typeof raw !== 'string' || !raw.trim()) { + return []; + } + + return raw + .split(',') + .map(field => field.trim()) + .filter(Boolean); } function getInvalidSelectFields(fields: string[]): string[] { - return fields.filter( - (field) => - !ALLOWED_CREATOR_SELECT_FIELDS.includes(field as AllowedCreatorSelectField) - ); + return fields.filter( + field => + !ALLOWED_CREATOR_SELECT_FIELDS.includes( + field as AllowedCreatorSelectField + ) + ); } function pickFields>( - item: T, - fields: string[] + item: T, + fields: string[] ): Partial { - if (!fields.length) { - return item; - } + if (!fields.length) { + return item; + } - return Object.fromEntries( - Object.entries(item).filter(([key]) => fields.includes(key)) - ) as Partial; + return Object.fromEntries( + Object.entries(item).filter(([key]) => fields.includes(key)) + ) as Partial; } // Typed Express handler @@ -80,11 +82,9 @@ export const listCreators: RequestHandler = async (req, res) => { warnIfUnrecognizedCreatorListSort(ctx.query, req.requestId); // Parse query using legacy schema - const parsed = parsePublicQuery( - LegacyCreatorQuerySchema, - ctx.query, - { debugContext: 'legacy-creator-list-query' } - ); + const parsed = parsePublicQuery(LegacyCreatorQuerySchema, ctx.query, { + debugContext: 'legacy-creator-list-query', + }); if (!parsed.ok) { return sendValidationError( @@ -125,7 +125,7 @@ export const listCreators: RequestHandler = async (req, res) => { const response = wrapPublicCreatorListResponse(creators, meta); attachTimestampHeader(res); const filteredItems = Array.isArray(response.items) - ? response.items.map((item) => + ? response.items.map(item => pickFields(item as Record, selectedFields) ) : response.items; @@ -149,6 +149,11 @@ export const listCreators: RequestHandler = async (req, res) => { }, 'Error listing creators' ); - return sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Failed to retrieve creators'); + return sendError( + res, + 500, + ErrorCode.INTERNAL_ERROR, + 'Failed to retrieve creators' + ); } }; diff --git a/src/modules/creator/creator.utils.test.ts b/src/modules/creator/creator.utils.test.ts index 851a03c..563c24b 100644 --- a/src/modules/creator/creator.utils.test.ts +++ b/src/modules/creator/creator.utils.test.ts @@ -45,4 +45,4 @@ describe('handleCreatorParamNotFound', () => { expect(result.id).toBe('creator-123'); expect(result.handle).toBe('alice'); }); -}); \ No newline at end of file +}); diff --git a/src/modules/creator/creator.utils.ts b/src/modules/creator/creator.utils.ts index 0eeca44..7ff65aa 100644 --- a/src/modules/creator/creator.utils.ts +++ b/src/modules/creator/creator.utils.ts @@ -69,7 +69,7 @@ export function toPrismaOrderBy( export async function resolveCreatorSlugCollision( displayName: string ): Promise { - return resolveSlugCollision(displayName, async (handle) => { + return resolveSlugCollision(displayName, async handle => { const existing = await prisma.creatorProfile.findUnique({ where: { handle }, select: { id: true }, @@ -110,4 +110,4 @@ export function handleCreatorParamNotFound( return false as const; } return true as const; -} \ No newline at end of file +} diff --git a/src/modules/creators/creator-detail-price-snapshot.integration.test.ts b/src/modules/creators/creator-detail-price-snapshot.integration.test.ts index f65b59c..f3b53f1 100644 --- a/src/modules/creators/creator-detail-price-snapshot.integration.test.ts +++ b/src/modules/creators/creator-detail-price-snapshot.integration.test.ts @@ -43,7 +43,9 @@ describe('#504 creator detail endpoint — current_price from price snapshot', ( }); it('creator detail returns null current_price before any snapshot exists', async () => { - const res = await supertest(app).get(`/api/v1/creators/${creatorId}/profile`); + const res = await supertest(app).get( + `/api/v1/creators/${creatorId}/profile` + ); expect(res.status).toBe(200); expect(res.body.data.currentPrice).toBeNull(); expect(res.body.data.priceChange24h).toBeNull(); @@ -57,7 +59,9 @@ describe('#504 creator detail endpoint — current_price from price snapshot', ( tradeAt: new Date(), }); - const res = await supertest(app).get(`/api/v1/creators/${creatorId}/profile`); + const res = await supertest(app).get( + `/api/v1/creators/${creatorId}/profile` + ); expect(res.status).toBe(200); expect(res.body.data.currentPrice).toBe('1500000'); }); @@ -70,7 +74,9 @@ describe('#504 creator detail endpoint — current_price from price snapshot', ( tradeAt: new Date(), }); - const beforeRes = await supertest(app).get(`/api/v1/creators/${creatorId}/profile`); + const beforeRes = await supertest(app).get( + `/api/v1/creators/${creatorId}/profile` + ); expect(beforeRes.status).toBe(200); expect(beforeRes.body.data.currentPrice).toBe('2000000'); @@ -81,10 +87,14 @@ describe('#504 creator detail endpoint — current_price from price snapshot', ( tradeAt: new Date(), }); - const afterRes = await supertest(app).get(`/api/v1/creators/${creatorId}/profile`); + const afterRes = await supertest(app).get( + `/api/v1/creators/${creatorId}/profile` + ); expect(afterRes.status).toBe(200); expect(afterRes.body.data.currentPrice).toBe('3750000'); - expect(afterRes.body.data.currentPrice).not.toBe(beforeRes.body.data.currentPrice); + expect(afterRes.body.data.currentPrice).not.toBe( + beforeRes.body.data.currentPrice + ); }); it('creator list includes current_price matching snapshot value', async () => { @@ -124,4 +134,4 @@ describe('#504 creator detail endpoint — current_price from price snapshot', ( expect(afterItem.currentPrice).toBe('750000'); expect(afterItem.currentPrice).not.toBe(beforeItem.currentPrice); }); -}); \ No newline at end of file +}); diff --git a/src/modules/creators/creator-feed-cursor-pagination.integration.test.ts b/src/modules/creators/creator-feed-cursor-pagination.integration.test.ts index 55301f3..bb554a6 100644 --- a/src/modules/creators/creator-feed-cursor-pagination.integration.test.ts +++ b/src/modules/creators/creator-feed-cursor-pagination.integration.test.ts @@ -60,10 +60,9 @@ describe('cursor pagination round-trip', () => { }); it('page one returns the first 3 items and hasMore=true', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - PAGE_ONE_FIXTURES, - ALL_FIXTURES.length, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([PAGE_ONE_FIXTURES, ALL_FIXTURES.length]); const req = makeReq({ limit: '3', offset: '0' }); const res = makeRes(); @@ -97,10 +96,9 @@ describe('cursor pagination round-trip', () => { }); it('page two items are non-overlapping with page one', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - PAGE_ONE_FIXTURES, - ALL_FIXTURES.length, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([PAGE_ONE_FIXTURES, ALL_FIXTURES.length]); const reqOne = makeReq({ limit: '3', offset: '0' }); const resOne = makeRes(); @@ -112,10 +110,9 @@ describe('cursor pagination round-trip', () => { jest.restoreAllMocks(); - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - PAGE_TWO_FIXTURES, - ALL_FIXTURES.length, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([PAGE_TWO_FIXTURES, ALL_FIXTURES.length]); const reqTwo = makeReq({ limit: '3', offset: '3' }); const resTwo = makeRes(); @@ -125,16 +122,17 @@ describe('cursor pagination round-trip', () => { (i: any) => i.id ); - const overlap = pageOneIds.filter((id: string) => pageTwoIds.includes(id)); + const overlap = pageOneIds.filter((id: string) => + pageTwoIds.includes(id) + ); expect(overlap).toHaveLength(0); }); it('page two contains the expected fixture IDs', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - PAGE_TWO_FIXTURES, - ALL_FIXTURES.length, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([PAGE_TWO_FIXTURES, ALL_FIXTURES.length]); const req = makeReq({ limit: '3', offset: '3' }); const res = makeRes(); @@ -147,10 +145,9 @@ describe('cursor pagination round-trip', () => { }); it('page two meta reflects offset=3 and hasMore=false', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - PAGE_TWO_FIXTURES, - ALL_FIXTURES.length, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([PAGE_TWO_FIXTURES, ALL_FIXTURES.length]); const req = makeReq({ limit: '3', offset: '3' }); const res = makeRes(); @@ -165,7 +162,8 @@ describe('cursor pagination round-trip', () => { }); it('returns next available creators when the item at the cursor position was deleted', async () => { - const deletedCursorCreator = PAGE_ONE_FIXTURES[PAGE_ONE_FIXTURES.length - 1]; + const deletedCursorCreator = + PAGE_ONE_FIXTURES[PAGE_ONE_FIXTURES.length - 1]; const encoded = encodeCursor({ createdAt: deletedCursorCreator.createdAt.toISOString(), @@ -175,12 +173,13 @@ describe('cursor pagination round-trip', () => { const decoded = decodeCursor(encoded); expect(decoded.id).toBe(deletedCursorCreator.id); - expect(decoded.createdAt).toBe(deletedCursorCreator.createdAt.toISOString()); + expect(decoded.createdAt).toBe( + deletedCursorCreator.createdAt.toISOString() + ); - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - PAGE_TWO_FIXTURES, - ALL_FIXTURES.length - 1, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([PAGE_TWO_FIXTURES, ALL_FIXTURES.length - 1]); const req = makeReq({ limit: '3', offset: '3' }); const res = makeRes(); @@ -211,4 +210,4 @@ describe('cursor pagination round-trip', () => { expect(() => decodeCursor(tampered)).toThrow(); }); -}); \ No newline at end of file +}); diff --git a/src/modules/creators/creator-feed-default-sort.integration.test.ts b/src/modules/creators/creator-feed-default-sort.integration.test.ts index 84f822d..cf88892 100644 --- a/src/modules/creators/creator-feed-default-sort.integration.test.ts +++ b/src/modules/creators/creator-feed-default-sort.integration.test.ts @@ -103,4 +103,3 @@ describe('GET /api/v1/creators — default sort when sort is omitted', () => { ); }); }); - diff --git a/src/modules/creators/creator-feed-empty-filters.integration.test.ts b/src/modules/creators/creator-feed-empty-filters.integration.test.ts index 60a93fc..e15bde5 100644 --- a/src/modules/creators/creator-feed-empty-filters.integration.test.ts +++ b/src/modules/creators/creator-feed-empty-filters.integration.test.ts @@ -120,7 +120,11 @@ describe('GET /api/v1/creators — empty feed with filter combinations', () => { include: ' ', }); const explicitDefaultsRes = makeRes(); - await httpListCreators(explicitDefaultsReq, explicitDefaultsRes, makeNext()); + await httpListCreators( + explicitDefaultsReq, + explicitDefaultsRes, + makeNext() + ); expect(creatorsUtils.fetchCreatorList).toHaveBeenNthCalledWith( 1, @@ -139,8 +143,8 @@ describe('GET /api/v1/creators — empty feed with filter combinations', () => { }) ); - const explicitCallArgs = (creatorsUtils.fetchCreatorList as jest.Mock).mock - .calls[1][0]; + const explicitCallArgs = (creatorsUtils.fetchCreatorList as jest.Mock) + .mock.calls[1][0]; expect(explicitCallArgs.search).toBeUndefined(); expect(explicitCallArgs.include).toBeUndefined(); @@ -155,7 +159,8 @@ describe('GET /api/v1/creators — empty feed with filter combinations', () => { const res = makeRes(); await httpListCreators(req, res, makeNext()); - const callArgs = (creatorsUtils.fetchCreatorList as jest.Mock).mock.calls[0][0]; + const callArgs = (creatorsUtils.fetchCreatorList as jest.Mock).mock + .calls[0][0]; expect(callArgs).not.toHaveProperty('verified', true); expect(callArgs).not.toHaveProperty('verified', false); @@ -443,7 +448,9 @@ describe('GET /api/v1/creators — empty feed with filter combinations', () => { expect(body.data.meta).toHaveProperty('hasMore', false); jest.clearAllMocks(); - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([[], 0]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([[], 0]); } }); @@ -595,4 +602,4 @@ describe('GET /api/v1/creators — unrecognized sort logging', () => { expect(sortWarnings).toHaveLength(0); }); -}); \ No newline at end of file +}); diff --git a/src/modules/creators/creator-feed-explain-helpers.test.ts b/src/modules/creators/creator-feed-explain-helpers.test.ts index 24e7545..361c420 100644 --- a/src/modules/creators/creator-feed-explain-helpers.test.ts +++ b/src/modules/creators/creator-feed-explain-helpers.test.ts @@ -54,11 +54,15 @@ describe('buildCreatorFeedExplainParams()', () => { }); it('returns [true] for isVerified=true', () => { - expect(buildCreatorFeedExplainParams({ isVerified: true })).toEqual([true]); + expect(buildCreatorFeedExplainParams({ isVerified: true })).toEqual([ + true, + ]); }); it('returns [false] for isVerified=false', () => { - expect(buildCreatorFeedExplainParams({ isVerified: false })).toEqual([false]); + expect(buildCreatorFeedExplainParams({ isVerified: false })).toEqual([ + false, + ]); }); it('returns two ILIKE params wrapping the search term', () => { diff --git a/src/modules/creators/creator-feed-filter-combinator.utils.test.ts b/src/modules/creators/creator-feed-filter-combinator.utils.test.ts index a6a8d52..4ecf056 100644 --- a/src/modules/creators/creator-feed-filter-combinator.utils.test.ts +++ b/src/modules/creators/creator-feed-filter-combinator.utils.test.ts @@ -1,54 +1,58 @@ import { buildCreatorFeedWhere } from './creator-feed-filter-combinator.utils'; describe('buildCreatorFeedWhere()', () => { - it('returns an empty object when no filters are supplied', () => { - expect(buildCreatorFeedWhere({})).toEqual({}); - }); - - it('sets isVerified when verified is true', () => { - expect(buildCreatorFeedWhere({ verified: true })).toEqual({ isVerified: true }); - }); - - it('sets isVerified when verified is false', () => { - expect(buildCreatorFeedWhere({ verified: false })).toEqual({ isVerified: false }); - }); - - it('omits isVerified when verified is undefined', () => { - const where = buildCreatorFeedWhere({ search: 'jazz' }); - expect('isVerified' in where).toBe(false); - }); - - it('sets OR search clause for handle and displayName', () => { - const where = buildCreatorFeedWhere({ search: 'jazz' }); - expect(where.OR).toEqual([ - { handle: { contains: 'jazz', mode: 'insensitive' } }, - { displayName: { contains: 'jazz', mode: 'insensitive' } }, - ]); - }); - - it('normalizes whitespace in the search term', () => { - const where = buildCreatorFeedWhere({ search: ' jazz musician ' }); - expect(where.OR?.[0].handle?.contains).toBe('jazz musician'); - }); - - it('omits OR clause when search is whitespace-only', () => { - const where = buildCreatorFeedWhere({ search: ' ' }); - expect('OR' in where).toBe(false); - }); - - it('omits OR clause when search is undefined', () => { - const where = buildCreatorFeedWhere({ verified: true }); - expect('OR' in where).toBe(false); - }); - - it('combines verified and search filters', () => { - const where = buildCreatorFeedWhere({ verified: false, search: 'alice' }); - expect(where).toEqual({ - isVerified: false, - OR: [ - { handle: { contains: 'alice', mode: 'insensitive' } }, - { displayName: { contains: 'alice', mode: 'insensitive' } }, - ], - }); - }); + it('returns an empty object when no filters are supplied', () => { + expect(buildCreatorFeedWhere({})).toEqual({}); + }); + + it('sets isVerified when verified is true', () => { + expect(buildCreatorFeedWhere({ verified: true })).toEqual({ + isVerified: true, + }); + }); + + it('sets isVerified when verified is false', () => { + expect(buildCreatorFeedWhere({ verified: false })).toEqual({ + isVerified: false, + }); + }); + + it('omits isVerified when verified is undefined', () => { + const where = buildCreatorFeedWhere({ search: 'jazz' }); + expect('isVerified' in where).toBe(false); + }); + + it('sets OR search clause for handle and displayName', () => { + const where = buildCreatorFeedWhere({ search: 'jazz' }); + expect(where.OR).toEqual([ + { handle: { contains: 'jazz', mode: 'insensitive' } }, + { displayName: { contains: 'jazz', mode: 'insensitive' } }, + ]); + }); + + it('normalizes whitespace in the search term', () => { + const where = buildCreatorFeedWhere({ search: ' jazz musician ' }); + expect(where.OR?.[0].handle?.contains).toBe('jazz musician'); + }); + + it('omits OR clause when search is whitespace-only', () => { + const where = buildCreatorFeedWhere({ search: ' ' }); + expect('OR' in where).toBe(false); + }); + + it('omits OR clause when search is undefined', () => { + const where = buildCreatorFeedWhere({ verified: true }); + expect('OR' in where).toBe(false); + }); + + it('combines verified and search filters', () => { + const where = buildCreatorFeedWhere({ verified: false, search: 'alice' }); + expect(where).toEqual({ + isVerified: false, + OR: [ + { handle: { contains: 'alice', mode: 'insensitive' } }, + { displayName: { contains: 'alice', mode: 'insensitive' } }, + ], + }); + }); }); diff --git a/src/modules/creators/creator-feed-filter-combinator.utils.ts b/src/modules/creators/creator-feed-filter-combinator.utils.ts index b2558c3..447d41f 100644 --- a/src/modules/creators/creator-feed-filter-combinator.utils.ts +++ b/src/modules/creators/creator-feed-filter-combinator.utils.ts @@ -5,17 +5,17 @@ import { CreatorFilterInput } from './creators.filter'; import { normalizeCreatorListSearchTerm } from './creators.search-term.utils'; export type CreatorFeedWhere = { - isVerified?: boolean; - OR?: Array<{ - handle?: { contains: string; mode: 'insensitive' }; - displayName?: { contains: string; mode: 'insensitive' }; - }>; - priceSnapshot?: { - currentPrice?: { - gte?: bigint; - lte?: bigint; - }; - }; + isVerified?: boolean; + OR?: Array<{ + handle?: { contains: string; mode: 'insensitive' }; + displayName?: { contains: string; mode: 'insensitive' }; + }>; + priceSnapshot?: { + currentPrice?: { + gte?: bigint; + lte?: bigint; + }; + }; }; /** @@ -39,35 +39,37 @@ export type CreatorFeedWhere = { * const where = buildCreatorFeedWhere({}); * // => {} */ -export function buildCreatorFeedWhere(filters: CreatorFilterInput): CreatorFeedWhere { - const where: CreatorFeedWhere = {}; +export function buildCreatorFeedWhere( + filters: CreatorFilterInput +): CreatorFeedWhere { + const where: CreatorFeedWhere = {}; - if (filters.verified !== undefined) { - where.isVerified = filters.verified; - } + if (filters.verified !== undefined) { + where.isVerified = filters.verified; + } - const normalizedSearch = normalizeCreatorListSearchTerm(filters.search); - if (normalizedSearch) { - where.OR = [ - { handle: { contains: normalizedSearch, mode: 'insensitive' } }, - { displayName: { contains: normalizedSearch, mode: 'insensitive' } }, - ]; - } + const normalizedSearch = normalizeCreatorListSearchTerm(filters.search); + if (normalizedSearch) { + where.OR = [ + { handle: { contains: normalizedSearch, mode: 'insensitive' } }, + { displayName: { contains: normalizedSearch, mode: 'insensitive' } }, + ]; + } - // Price range filtering - if (filters.minPrice !== undefined || filters.maxPrice !== undefined) { - where.priceSnapshot = { - currentPrice: {}, - }; + // Price range filtering + if (filters.minPrice !== undefined || filters.maxPrice !== undefined) { + where.priceSnapshot = { + currentPrice: {}, + }; - if (filters.minPrice !== undefined) { - where.priceSnapshot.currentPrice!.gte = filters.minPrice; - } + if (filters.minPrice !== undefined) { + where.priceSnapshot.currentPrice!.gte = filters.minPrice; + } - if (filters.maxPrice !== undefined) { - where.priceSnapshot.currentPrice!.lte = filters.maxPrice; - } - } + if (filters.maxPrice !== undefined) { + where.priceSnapshot.currentPrice!.lte = filters.maxPrice; + } + } - return where; + return where; } diff --git a/src/modules/creators/creator-feed-multi-filter.integration.test.ts b/src/modules/creators/creator-feed-multi-filter.integration.test.ts index 53d32cd..49ea335 100644 --- a/src/modules/creators/creator-feed-multi-filter.integration.test.ts +++ b/src/modules/creators/creator-feed-multi-filter.integration.test.ts @@ -96,10 +96,9 @@ describe('GET /api/v1/creators — multiple category filters with fixture data', // ── Multi-filter: verified + search ──────────────────────────────────────── it('passes verified=true and search together to fetchCreatorList', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - [FIXTURE_VERIFIED_JAZZ], - 1, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([[FIXTURE_VERIFIED_JAZZ], 1]); const req = makeReq({ verified: 'true', search: 'jazz' }); const res = makeRes(); @@ -114,10 +113,9 @@ describe('GET /api/v1/creators — multiple category filters with fixture data', }); it('returns only the creators the service resolved for verified=true + search=jazz', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - [FIXTURE_VERIFIED_JAZZ], - 1, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([[FIXTURE_VERIFIED_JAZZ], 1]); const req = makeReq({ verified: 'true', search: 'jazz' }); const res = makeRes(); @@ -131,10 +129,9 @@ describe('GET /api/v1/creators — multiple category filters with fixture data', }); it('returns correct pagination metadata for verified=true + search=jazz (1 result)', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - [FIXTURE_VERIFIED_JAZZ], - 1, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([[FIXTURE_VERIFIED_JAZZ], 1]); const req = makeReq({ verified: 'true', search: 'jazz' }); const res = makeRes(); @@ -147,10 +144,9 @@ describe('GET /api/v1/creators — multiple category filters with fixture data', }); it('passes verified=false and search together to fetchCreatorList', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - [FIXTURE_UNVERIFIED_ROCK], - 1, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([[FIXTURE_UNVERIFIED_ROCK], 1]); const req = makeReq({ verified: 'false', search: 'rock' }); const res = makeRes(); @@ -165,10 +161,9 @@ describe('GET /api/v1/creators — multiple category filters with fixture data', }); it('returns only unverified-rock creator for verified=false + search=rock', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - [FIXTURE_UNVERIFIED_ROCK], - 1, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([[FIXTURE_UNVERIFIED_ROCK], 1]); const req = makeReq({ verified: 'false', search: 'rock' }); const res = makeRes(); @@ -182,12 +177,19 @@ describe('GET /api/v1/creators — multiple category filters with fixture data', // ── Multi-filter: verified + search + pagination ─────────────────────────── it('passes verified, search, limit, and offset together to fetchCreatorList', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - [FIXTURE_VERIFIED_JAZZ, FIXTURE_VERIFIED_ROCK], - 2, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ, FIXTURE_VERIFIED_ROCK], + 2, + ]); - const req = makeReq({ verified: 'true', search: 'test', limit: '5', offset: '0' }); + const req = makeReq({ + verified: 'true', + search: 'test', + limit: '5', + offset: '0', + }); const res = makeRes(); await httpListCreators(req, res, makeNext()); @@ -220,12 +222,19 @@ describe('GET /api/v1/creators — multiple category filters with fixture data', }); it('hasMore is true when total exceeds limit+offset with multiple filters active', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - [FIXTURE_VERIFIED_JAZZ, FIXTURE_VERIFIED_ROCK], - 20, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ, FIXTURE_VERIFIED_ROCK], + 20, + ]); - const req = makeReq({ verified: 'true', search: 'test', limit: '2', offset: '0' }); + const req = makeReq({ + verified: 'true', + search: 'test', + limit: '2', + offset: '0', + }); const res = makeRes(); await httpListCreators(req, res, makeNext()); @@ -235,12 +244,19 @@ describe('GET /api/v1/creators — multiple category filters with fixture data', }); it('hasMore is false when all results fit within limit+offset', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - [FIXTURE_VERIFIED_JAZZ, FIXTURE_VERIFIED_ROCK], - 2, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ, FIXTURE_VERIFIED_ROCK], + 2, + ]); - const req = makeReq({ verified: 'true', search: 'test', limit: '10', offset: '0' }); + const req = makeReq({ + verified: 'true', + search: 'test', + limit: '10', + offset: '0', + }); const res = makeRes(); await httpListCreators(req, res, makeNext()); @@ -252,12 +268,18 @@ describe('GET /api/v1/creators — multiple category filters with fixture data', // ── Multi-filter: verified + sort + order ────────────────────────────────── it('passes verified, sort, and order together to fetchCreatorList', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - [FIXTURE_VERIFIED_JAZZ, FIXTURE_VERIFIED_ROCK], - 2, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ, FIXTURE_VERIFIED_ROCK], + 2, + ]); - const req = makeReq({ verified: 'true', sort: 'displayName', order: 'asc' }); + const req = makeReq({ + verified: 'true', + sort: 'displayName', + order: 'asc', + }); const res = makeRes(); await httpListCreators(req, res, makeNext()); @@ -271,12 +293,18 @@ describe('GET /api/v1/creators — multiple category filters with fixture data', }); it('serializes multiple fixture items correctly for verified + sort combo', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - [FIXTURE_VERIFIED_JAZZ, FIXTURE_VERIFIED_ROCK], - 2, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ, FIXTURE_VERIFIED_ROCK], + 2, + ]); - const req = makeReq({ verified: 'true', sort: 'displayName', order: 'asc' }); + const req = makeReq({ + verified: 'true', + sort: 'displayName', + order: 'asc', + }); const res = makeRes(); await httpListCreators(req, res, makeNext()); @@ -290,10 +318,9 @@ describe('GET /api/v1/creators — multiple category filters with fixture data', // ── Full fixture set: all four creators ─────────────────────────────────── it('returns all fixture creators with no filter applied', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - ALL_FIXTURES, - ALL_FIXTURES.length, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([ALL_FIXTURES, ALL_FIXTURES.length]); const req = makeReq({}); const res = makeRes(); @@ -316,10 +343,9 @@ describe('GET /api/v1/creators — multiple category filters with fixture data', ]; for (const query of filterCombinations) { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - [FIXTURE_VERIFIED_JAZZ], - 1, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([[FIXTURE_VERIFIED_JAZZ], 1]); const req = makeReq(query); const res = makeRes(); @@ -337,10 +363,9 @@ describe('GET /api/v1/creators — multiple category filters with fixture data', // ── Serialized item shape ────────────────────────────────────────────────── it('each item in the response has the expected public shape', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - [FIXTURE_VERIFIED_JAZZ], - 1, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([[FIXTURE_VERIFIED_JAZZ], 1]); const req = makeReq({ verified: 'true', search: 'jazz' }); const res = makeRes(); @@ -359,10 +384,12 @@ describe('GET /api/v1/creators — multiple category filters with fixture data', }); it('pagination metadata has all required fields for any multi-filter combo', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - [FIXTURE_VERIFIED_JAZZ, FIXTURE_UNVERIFIED_JAZZ], - 2, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ, FIXTURE_UNVERIFIED_JAZZ], + 2, + ]); const req = makeReq({ search: 'jazz', sort: 'createdAt', order: 'asc' }); const res = makeRes(); diff --git a/src/modules/creators/creator-feed-newest-sort.integration.test.ts b/src/modules/creators/creator-feed-newest-sort.integration.test.ts index c54d2ff..16ead3f 100644 --- a/src/modules/creators/creator-feed-newest-sort.integration.test.ts +++ b/src/modules/creators/creator-feed-newest-sort.integration.test.ts @@ -45,10 +45,9 @@ describe('GET /api/v1/creators — sort=createdAt&order=desc (newest registered) }); it('passes sort=createdAt and order=desc to fetchCreatorList', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - FIXTURES_DESCENDING, - FIXTURES_DESCENDING.length, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([FIXTURES_DESCENDING, FIXTURES_DESCENDING.length]); const req = makeReq({ sort: 'createdAt', order: 'desc' }); const res = makeRes(); @@ -60,10 +59,9 @@ describe('GET /api/v1/creators — sort=createdAt&order=desc (newest registered) }); it('returns items in strict descending registration order', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - FIXTURES_DESCENDING, - FIXTURES_DESCENDING.length, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([FIXTURES_DESCENDING, FIXTURES_DESCENDING.length]); const req = makeReq({ sort: 'createdAt', order: 'desc' }); const res = makeRes(); @@ -80,10 +78,9 @@ describe('GET /api/v1/creators — sort=createdAt&order=desc (newest registered) it('fails if items are returned in ascending order instead of descending', async () => { // Mock returns ascending order — the test must detect the wrong ordering. - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - FIXTURES_ASCENDING, - FIXTURES_ASCENDING.length, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([FIXTURES_ASCENDING, FIXTURES_ASCENDING.length]); const req = makeReq({ sort: 'createdAt', order: 'desc' }); const res = makeRes(); @@ -96,10 +93,9 @@ describe('GET /api/v1/creators — sort=createdAt&order=desc (newest registered) }); it('each consecutive pair satisfies descending registration order', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - FIXTURES_DESCENDING, - FIXTURES_DESCENDING.length, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([FIXTURES_DESCENDING, FIXTURES_DESCENDING.length]); const req = makeReq({ sort: 'createdAt', order: 'desc' }); const res = makeRes(); @@ -122,10 +118,9 @@ describe('GET /api/v1/creators — sort=createdAt&order=desc (newest registered) }); it('pagination meta reflects the full fixture count', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - FIXTURES_DESCENDING, - FIXTURES_DESCENDING.length, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([FIXTURES_DESCENDING, FIXTURES_DESCENDING.length]); const req = makeReq({ sort: 'createdAt', order: 'desc' }); const res = makeRes(); diff --git a/src/modules/creators/creator-holders-held-since.integration.test.ts b/src/modules/creators/creator-holders-held-since.integration.test.ts index a898f59..e41bd4c 100644 --- a/src/modules/creators/creator-holders-held-since.integration.test.ts +++ b/src/modules/creators/creator-holders-held-since.integration.test.ts @@ -11,18 +11,23 @@ import { httpGetCreatorHolders } from './creator-holders.controller'; import * as holdersService from './creator-holders.service'; import type { HolderRecord } from './creator-holders.service'; -function makeReq(params: Record = {}, query: Record = {}): any { - return { params, query }; +function makeReq( + params: Record = {}, + query: Record = {} +): any { + return { params, query }; } function makeRes(): any { - const res: any = {}; - res.status = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - res.setHeader = jest.fn().mockReturnValue(res); - res.set = jest.fn().mockReturnValue(res); - return res; + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + return res; +} +function makeNext() { + return jest.fn(); } -function makeNext() { return jest.fn(); } const CREATOR = { id: 'creator-abc', handle: 'alice' }; @@ -32,127 +37,134 @@ const TS_WALLET_B = new Date('2024-02-20T12:30:00.000Z'); const TS_WALLET_C = new Date('2024-03-10T16:45:00.000Z'); const HOLDER_A: HolderRecord = { - wallet_address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1', - key_balance: 30, - held_since: TS_WALLET_A, + wallet_address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1', + key_balance: 30, + held_since: TS_WALLET_A, }; const HOLDER_B: HolderRecord = { - wallet_address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB2', - key_balance: 20, - held_since: TS_WALLET_B, + wallet_address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB2', + key_balance: 20, + held_since: TS_WALLET_B, }; // Wallet C bought twice — held_since is still its FIRST buy (TS_WALLET_C) const HOLDER_C: HolderRecord = { - wallet_address: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC3', - key_balance: 15, // bought 10 then 5 more - held_since: TS_WALLET_C, // createdAt from first buy — never updated on re-buy + wallet_address: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC3', + key_balance: 15, // bought 10 then 5 more + held_since: TS_WALLET_C, // createdAt from first buy — never updated on re-buy }; describe('GET /creators/:id/holders — held_since per wallet (#493)', () => { - beforeEach(() => { - jest.spyOn(holdersService, 'findCreatorByIdOrHandle').mockResolvedValue(CREATOR); - jest.restoreAllMocks(); - jest.spyOn(holdersService, 'findCreatorByIdOrHandle').mockResolvedValue(CREATOR); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('each holder held_since matches their first-buy timestamp', async () => { - jest - .spyOn(holdersService, 'fetchCreatorHolders') - .mockResolvedValue([[HOLDER_A, HOLDER_B, HOLDER_C], 3]); - - const req = makeReq({ id: CREATOR.id }); - const res = makeRes(); - await httpGetCreatorHolders(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(200); - const items: HolderRecord[] = res.json.mock.calls[0][0].data.items; - expect(items).toHaveLength(3); - - expect(items[0].held_since).toEqual(TS_WALLET_A); - expect(items[1].held_since).toEqual(TS_WALLET_B); - expect(items[2].held_since).toEqual(TS_WALLET_C); - }); - - it('wallet that bought twice shows the earliest buy timestamp (not most recent)', async () => { - // Even though wallet C bought twice, held_since = TS_WALLET_C (first buy) - // The second buy only changes balance, not createdAt in KeyOwnership - jest - .spyOn(holdersService, 'fetchCreatorHolders') - .mockResolvedValue([[HOLDER_C], 1]); - - const req = makeReq({ id: CREATOR.id }); - const res = makeRes(); - await httpGetCreatorHolders(req, res, makeNext()); - - const [item] = res.json.mock.calls[0][0].data.items as HolderRecord[]; - expect(item.held_since).toEqual(TS_WALLET_C); - expect(item.key_balance).toBe(15); // reflects total of both buys - // Confirm held_since is the first-buy timestamp, not the second-buy time - expect(item.held_since.getTime()).toBe(TS_WALLET_C.getTime()); - expect(item.held_since.getTime()).toBeLessThan(Date.now()); - }); - - it('held_since values are in ISO 8601 format when serialised to JSON', async () => { - jest - .spyOn(holdersService, 'fetchCreatorHolders') - .mockResolvedValue([[HOLDER_A, HOLDER_B], 2]); - - const req = makeReq({ id: CREATOR.id }); - const res = makeRes(); - await httpGetCreatorHolders(req, res, makeNext()); - - const items: HolderRecord[] = res.json.mock.calls[0][0].data.items; - for (const item of items) { - const iso = (item.held_since instanceof Date - ? item.held_since.toISOString() - : String(item.held_since)); - expect(iso).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); - } - }); - - it('three wallets with different first-buy timestamps each show their own held_since', async () => { - jest - .spyOn(holdersService, 'fetchCreatorHolders') - .mockResolvedValue([[HOLDER_A, HOLDER_B, HOLDER_C], 3]); - - const req = makeReq({ id: CREATOR.id }, { sort: 'held_since' }); - const res = makeRes(); - await httpGetCreatorHolders(req, res, makeNext()); - - const items: HolderRecord[] = res.json.mock.calls[0][0].data.items; - const heldSinceTimes = items.map((i) => - i.held_since instanceof Date ? i.held_since.getTime() : 0, - ); - // All timestamps are distinct — no two wallets share a held_since - const unique = new Set(heldSinceTimes); - expect(unique.size).toBe(3); - }); - - it('fetchCreatorHolders is called with sort=key_balance when no sort param', async () => { - jest - .spyOn(holdersService, 'fetchCreatorHolders') - .mockResolvedValue([[HOLDER_A], 1]); - - const req = makeReq({ id: CREATOR.id }); - await httpGetCreatorHolders(req, makeRes(), makeNext()); - - const [, query] = (holdersService.fetchCreatorHolders as jest.Mock).mock.calls[0]; - expect(query.sort).toBe('key_balance'); - }); - - it('fetchCreatorHolders is called with sort=held_since when requested', async () => { - jest - .spyOn(holdersService, 'fetchCreatorHolders') - .mockResolvedValue([[HOLDER_A, HOLDER_B, HOLDER_C], 3]); - - const req = makeReq({ id: CREATOR.id }, { sort: 'held_since' }); - await httpGetCreatorHolders(req, makeRes(), makeNext()); - - const [, query] = (holdersService.fetchCreatorHolders as jest.Mock).mock.calls[0]; - expect(query.sort).toBe('held_since'); - }); + beforeEach(() => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR); + jest.restoreAllMocks(); + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('each holder held_since matches their first-buy timestamp', async () => { + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([[HOLDER_A, HOLDER_B, HOLDER_C], 3]); + + const req = makeReq({ id: CREATOR.id }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const items: HolderRecord[] = res.json.mock.calls[0][0].data.items; + expect(items).toHaveLength(3); + + expect(items[0].held_since).toEqual(TS_WALLET_A); + expect(items[1].held_since).toEqual(TS_WALLET_B); + expect(items[2].held_since).toEqual(TS_WALLET_C); + }); + + it('wallet that bought twice shows the earliest buy timestamp (not most recent)', async () => { + // Even though wallet C bought twice, held_since = TS_WALLET_C (first buy) + // The second buy only changes balance, not createdAt in KeyOwnership + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([[HOLDER_C], 1]); + + const req = makeReq({ id: CREATOR.id }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + const [item] = res.json.mock.calls[0][0].data.items as HolderRecord[]; + expect(item.held_since).toEqual(TS_WALLET_C); + expect(item.key_balance).toBe(15); // reflects total of both buys + // Confirm held_since is the first-buy timestamp, not the second-buy time + expect(item.held_since.getTime()).toBe(TS_WALLET_C.getTime()); + expect(item.held_since.getTime()).toBeLessThan(Date.now()); + }); + + it('held_since values are in ISO 8601 format when serialised to JSON', async () => { + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([[HOLDER_A, HOLDER_B], 2]); + + const req = makeReq({ id: CREATOR.id }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + const items: HolderRecord[] = res.json.mock.calls[0][0].data.items; + for (const item of items) { + const iso = + item.held_since instanceof Date + ? item.held_since.toISOString() + : String(item.held_since); + expect(iso).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + } + }); + + it('three wallets with different first-buy timestamps each show their own held_since', async () => { + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([[HOLDER_A, HOLDER_B, HOLDER_C], 3]); + + const req = makeReq({ id: CREATOR.id }, { sort: 'held_since' }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + const items: HolderRecord[] = res.json.mock.calls[0][0].data.items; + const heldSinceTimes = items.map(i => + i.held_since instanceof Date ? i.held_since.getTime() : 0 + ); + // All timestamps are distinct — no two wallets share a held_since + const unique = new Set(heldSinceTimes); + expect(unique.size).toBe(3); + }); + + it('fetchCreatorHolders is called with sort=key_balance when no sort param', async () => { + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([[HOLDER_A], 1]); + + const req = makeReq({ id: CREATOR.id }); + await httpGetCreatorHolders(req, makeRes(), makeNext()); + + const [, query] = (holdersService.fetchCreatorHolders as jest.Mock).mock + .calls[0]; + expect(query.sort).toBe('key_balance'); + }); + + it('fetchCreatorHolders is called with sort=held_since when requested', async () => { + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([[HOLDER_A, HOLDER_B, HOLDER_C], 3]); + + const req = makeReq({ id: CREATOR.id }, { sort: 'held_since' }); + await httpGetCreatorHolders(req, makeRes(), makeNext()); + + const [, query] = (holdersService.fetchCreatorHolders as jest.Mock).mock + .calls[0]; + expect(query.sort).toBe('held_since'); + }); }); diff --git a/src/modules/creators/creator-holders-zero-log.test.ts b/src/modules/creators/creator-holders-zero-log.test.ts index dc21145..53ee1f0 100644 --- a/src/modules/creators/creator-holders-zero-log.test.ts +++ b/src/modules/creators/creator-holders-zero-log.test.ts @@ -9,95 +9,95 @@ import { prisma } from '../../utils/prisma.utils'; import { logger } from '../../utils/logger.utils'; jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - keyOwnership: { - findMany: jest.fn(), - count: jest.fn(), - }, - creatorProfile: { - findMany: jest.fn(), - }, - }, + prisma: { + keyOwnership: { + findMany: jest.fn(), + count: jest.fn(), + }, + creatorProfile: { + findMany: jest.fn(), + }, + }, })); jest.mock('../../utils/logger.utils', () => ({ - logger: { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, })); const mockPrisma = prisma as unknown as { - keyOwnership: { - findMany: jest.Mock; - count: jest.Mock; - }; + keyOwnership: { + findMany: jest.Mock; + count: jest.Mock; + }; }; const mockLogger = logger as unknown as { - debug: jest.Mock; + debug: jest.Mock; }; const CREATOR_ID = 'creator-cuid-abc123'; const DEFAULT_QUERY = { limit: 20, offset: 0, sort: 'key_balance' as const }; const HOLDER_ROW = { - ownerAddress: 'GWALLETAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - balance: BigInt(10), - createdAt: new Date('2025-01-01T00:00:00Z'), + ownerAddress: 'GWALLETAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + balance: BigInt(10), + createdAt: new Date('2025-01-01T00:00:00Z'), }; describe('fetchCreatorHolders – zero-holder debug log (#443)', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); + beforeEach(() => { + jest.clearAllMocks(); + }); - // Test 1 – logger.debug is called when holder count is 0 - it('calls logger.debug when the holders array is empty', async () => { - mockPrisma.keyOwnership.findMany.mockResolvedValue([]); - mockPrisma.keyOwnership.count.mockResolvedValue(0); + // Test 1 – logger.debug is called when holder count is 0 + it('calls logger.debug when the holders array is empty', async () => { + mockPrisma.keyOwnership.findMany.mockResolvedValue([]); + mockPrisma.keyOwnership.count.mockResolvedValue(0); - await fetchCreatorHolders(CREATOR_ID, DEFAULT_QUERY); + await fetchCreatorHolders(CREATOR_ID, DEFAULT_QUERY); - expect(mockLogger.debug).toHaveBeenCalledTimes(1); - }); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + }); - // Test 2 – log object includes creator_id, holder_count: 0, query_duration_ms - it('log object includes creator_id, holder_count 0, and query_duration_ms', async () => { - mockPrisma.keyOwnership.findMany.mockResolvedValue([]); - mockPrisma.keyOwnership.count.mockResolvedValue(0); + // Test 2 – log object includes creator_id, holder_count: 0, query_duration_ms + it('log object includes creator_id, holder_count 0, and query_duration_ms', async () => { + mockPrisma.keyOwnership.findMany.mockResolvedValue([]); + mockPrisma.keyOwnership.count.mockResolvedValue(0); - await fetchCreatorHolders(CREATOR_ID, DEFAULT_QUERY); + await fetchCreatorHolders(CREATOR_ID, DEFAULT_QUERY); - const [logObj, message] = mockLogger.debug.mock.calls[0]; - expect(logObj).toMatchObject({ - creator_id: CREATOR_ID, - holder_count: 0, - }); - expect(typeof logObj.query_duration_ms).toBe('number'); - expect(message).toBe('Creator holders query returned zero results'); - }); + const [logObj, message] = mockLogger.debug.mock.calls[0]; + expect(logObj).toMatchObject({ + creator_id: CREATOR_ID, + holder_count: 0, + }); + expect(typeof logObj.query_duration_ms).toBe('number'); + expect(message).toBe('Creator holders query returned zero results'); + }); - // Test 3 – logger.debug is NOT called when holders are present - it('does NOT call logger.debug when holders are returned', async () => { - mockPrisma.keyOwnership.findMany.mockResolvedValue([HOLDER_ROW]); - mockPrisma.keyOwnership.count.mockResolvedValue(1); + // Test 3 – logger.debug is NOT called when holders are present + it('does NOT call logger.debug when holders are returned', async () => { + mockPrisma.keyOwnership.findMany.mockResolvedValue([HOLDER_ROW]); + mockPrisma.keyOwnership.count.mockResolvedValue(1); - await fetchCreatorHolders(CREATOR_ID, DEFAULT_QUERY); + await fetchCreatorHolders(CREATOR_ID, DEFAULT_QUERY); - expect(mockLogger.debug).not.toHaveBeenCalled(); - }); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); - // Test 4 – query_duration_ms is a non-negative number - it('query_duration_ms is a non-negative number', async () => { - mockPrisma.keyOwnership.findMany.mockResolvedValue([]); - mockPrisma.keyOwnership.count.mockResolvedValue(0); + // Test 4 – query_duration_ms is a non-negative number + it('query_duration_ms is a non-negative number', async () => { + mockPrisma.keyOwnership.findMany.mockResolvedValue([]); + mockPrisma.keyOwnership.count.mockResolvedValue(0); - await fetchCreatorHolders(CREATOR_ID, DEFAULT_QUERY); + await fetchCreatorHolders(CREATOR_ID, DEFAULT_QUERY); - const [logObj] = mockLogger.debug.mock.calls[0]; - expect(logObj.query_duration_ms).toBeGreaterThanOrEqual(0); - }); + const [logObj] = mockLogger.debug.mock.calls[0]; + expect(logObj.query_duration_ms).toBeGreaterThanOrEqual(0); + }); }); diff --git a/src/modules/creators/creator-holders.controller.ts b/src/modules/creators/creator-holders.controller.ts index 03983bb..4b709cd 100644 --- a/src/modules/creators/creator-holders.controller.ts +++ b/src/modules/creators/creator-holders.controller.ts @@ -25,7 +25,11 @@ import { parseCreatorId } from '../../utils/creator-id.utils'; * - Default sort: largest key_balance first. * - Optional ?sort=held_since returns earliest buyers first. */ -export const httpGetCreatorHolders: AsyncController = async (req, res, next) => { +export const httpGetCreatorHolders: AsyncController = async ( + req, + res, + next +) => { try { const rawId = req.params.id; const creatorId = parseCreatorId(Array.isArray(rawId) ? rawId[0] : rawId); @@ -35,13 +39,20 @@ export const httpGetCreatorHolders: AsyncController = async (req, res, next) => }); if (!parsed.ok) { - return sendValidationError(res, 'Invalid query parameters', parsed.details); + return sendValidationError( + res, + 'Invalid query parameters', + parsed.details + ); } const creator = await findCreatorByIdOrHandle(String(creatorId)); if (!handleCreatorParamNotFound(res, creator)) return; - const [holders, total] = await fetchCreatorHolders(creator.id, parsed.data); + const [holders, total] = await fetchCreatorHolders( + creator.id, + parsed.data + ); const meta = buildOffsetPaginationMeta({ limit: parsed.data.limit, @@ -54,4 +65,4 @@ export const httpGetCreatorHolders: AsyncController = async (req, res, next) => } catch (error) { next(error); } -}; \ No newline at end of file +}; diff --git a/src/modules/creators/creator-holders.integration.test.ts b/src/modules/creators/creator-holders.integration.test.ts index 7a40fa9..9c0648d 100644 --- a/src/modules/creators/creator-holders.integration.test.ts +++ b/src/modules/creators/creator-holders.integration.test.ts @@ -16,23 +16,23 @@ import type { HolderRecord } from './creator-holders.service'; // ── Lightweight request/response mocks ──────────────────────────────────────── function makeReq( - params: Record = {}, - query: Record = {}, + params: Record = {}, + query: Record = {} ): any { - return { params, query }; + return { params, query }; } function makeRes(): any { - const res: any = {}; - res.status = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - res.setHeader = jest.fn().mockReturnValue(res); - res.set = jest.fn().mockReturnValue(res); - return res; + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + return res; } function makeNext(): jest.Mock { - return jest.fn(); + return jest.fn(); } // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -40,15 +40,15 @@ function makeNext(): jest.Mock { const CREATOR_STUB = { id: 'creator-cuid-1', handle: 'alice' }; function makeHolder( - index: number, - overrides: Partial = {}, + index: number, + overrides: Partial = {} ): HolderRecord { - return { - wallet_address: `GWALLETADDRESS${String(index).padStart(46, '0')}`, - key_balance: (4 - index) * 10, // 30, 20, 10 for indices 1, 2, 3 - held_since: new Date(`2024-0${index}-01T00:00:00.000Z`), - ...overrides, - }; + return { + wallet_address: `GWALLETADDRESS${String(index).padStart(46, '0')}`, + key_balance: (4 - index) * 10, // 30, 20, 10 for indices 1, 2, 3 + held_since: new Date(`2024-0${index}-01T00:00:00.000Z`), + ...overrides, + }; } const HOLDER_A = makeHolder(1); // balance: 30, earliest buyer @@ -60,209 +60,214 @@ const ALL_HOLDERS = [HOLDER_A, HOLDER_B, HOLDER_C]; // ── Tests ───────────────────────────────────────────────────────────────────── describe('GET /creators/:id/holders', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── Creator not found ────────────────────────────────────────────────────── - // ── Creator not found ────────────────────────────────────────────────────── - - it('returns 404 when the creator does not exist', async () => { - jest - .spyOn(holdersService, 'findCreatorByIdOrHandle') - .mockResolvedValue(null); - - const req = makeReq({ id: 'nonexistent-creator' }); - const res = makeRes(); - await httpGetCreatorHolders(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(404); - - const body = res.json.mock.calls[0][0]; - expect(body.success).toBe(false); - expect(body.error.code).toBe('NOT_FOUND'); - }); - - // ── Creator exists but no holders ───────────────────────────────────────── - - it('returns empty items list (not 404) when creator exists but has no holders', async () => { - jest - .spyOn(holdersService, 'findCreatorByIdOrHandle') - .mockResolvedValue(CREATOR_STUB); - jest - .spyOn(holdersService, 'fetchCreatorHolders') - .mockResolvedValue([[], 0]); - - const req = makeReq({ id: CREATOR_STUB.handle }); - const res = makeRes(); - await httpGetCreatorHolders(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(200); - - const body = res.json.mock.calls[0][0]; - expect(body.success).toBe(true); - expect(body.data.items).toEqual([]); - expect(body.data.meta.total).toBe(0); - expect(body.data.meta.hasMore).toBe(false); - }); - - // ── Default sort: key_balance desc ──────────────────────────────────────── - - it('returns holders sorted by key_balance desc by default', async () => { - jest - .spyOn(holdersService, 'findCreatorByIdOrHandle') - .mockResolvedValue(CREATOR_STUB); - jest - .spyOn(holdersService, 'fetchCreatorHolders') - .mockResolvedValue([ALL_HOLDERS, ALL_HOLDERS.length]); - - const req = makeReq({ id: CREATOR_STUB.id }); - const res = makeRes(); - await httpGetCreatorHolders(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(200); - - const body = res.json.mock.calls[0][0]; - const items = body.data.items as HolderRecord[]; - - expect(items).toHaveLength(3); - expect(items[0].wallet_address).toBe(HOLDER_A.wallet_address); - expect(items[0].key_balance).toBe(HOLDER_A.key_balance); // 30 - expect(items[2].key_balance).toBe(HOLDER_C.key_balance); // 10 - - // Verify fetchCreatorHolders was called with sort=key_balance - const [, query] = (holdersService.fetchCreatorHolders as jest.Mock).mock.calls[0]; - expect(query.sort).toBe('key_balance'); - }); - - // ── Sort by held_since ───────────────────────────────────────────────────── - - it('returns holders sorted by held_since asc when ?sort=held_since', async () => { - jest - .spyOn(holdersService, 'findCreatorByIdOrHandle') - .mockResolvedValue(CREATOR_STUB); - jest - .spyOn(holdersService, 'fetchCreatorHolders') - .mockResolvedValue([ALL_HOLDERS, ALL_HOLDERS.length]); - - const req = makeReq({ id: CREATOR_STUB.id }, { sort: 'held_since' }); - const res = makeRes(); - await httpGetCreatorHolders(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(200); - - const body = res.json.mock.calls[0][0]; - expect(body.success).toBe(true); - - // Verify fetchCreatorHolders was called with sort=held_since - const [, query] = (holdersService.fetchCreatorHolders as jest.Mock).mock.calls[0]; - expect(query.sort).toBe('held_since'); - }); - - // ── Pagination ───────────────────────────────────────────────────────────── - - it('returns correct pagination meta for a partial page', async () => { - const TOTAL = 10; - const PAGE_ITEMS = ALL_HOLDERS; // 3 of 10 - - jest - .spyOn(holdersService, 'findCreatorByIdOrHandle') - .mockResolvedValue(CREATOR_STUB); - jest - .spyOn(holdersService, 'fetchCreatorHolders') - .mockResolvedValue([PAGE_ITEMS, TOTAL]); - - const req = makeReq({ id: CREATOR_STUB.id }, { limit: '3', offset: '0' }); - const res = makeRes(); - await httpGetCreatorHolders(req, res, makeNext()); - - const body = res.json.mock.calls[0][0]; - const { meta } = body.data; - - expect(meta.limit).toBe(3); - expect(meta.offset).toBe(0); - expect(meta.total).toBe(TOTAL); - expect(meta.hasMore).toBe(true); - }); - - it('returns hasMore=false on the last page', async () => { - jest - .spyOn(holdersService, 'findCreatorByIdOrHandle') - .mockResolvedValue(CREATOR_STUB); - jest - .spyOn(holdersService, 'fetchCreatorHolders') - .mockResolvedValue([ALL_HOLDERS, ALL_HOLDERS.length]); - - const req = makeReq({ id: CREATOR_STUB.id }, { limit: '20', offset: '0' }); - const res = makeRes(); - await httpGetCreatorHolders(req, res, makeNext()); - - const { meta } = res.json.mock.calls[0][0].data; - expect(meta.hasMore).toBe(false); - expect(meta.total).toBe(3); - }); - - // ── Response shape ───────────────────────────────────────────────────────── - - it('each holder item has wallet_address, key_balance, and held_since', async () => { - jest - .spyOn(holdersService, 'findCreatorByIdOrHandle') - .mockResolvedValue(CREATOR_STUB); - jest - .spyOn(holdersService, 'fetchCreatorHolders') - .mockResolvedValue([[HOLDER_A], 1]); - - const req = makeReq({ id: CREATOR_STUB.id }); - const res = makeRes(); - await httpGetCreatorHolders(req, res, makeNext()); - - const [item] = res.json.mock.calls[0][0].data.items as HolderRecord[]; - expect(item).toHaveProperty('wallet_address', HOLDER_A.wallet_address); - expect(item).toHaveProperty('key_balance', HOLDER_A.key_balance); - expect(item).toHaveProperty('held_since', HOLDER_A.held_since); - }); - - // ── Validation ───────────────────────────────────────────────────────────── - - it('returns 400 for an unknown query param (strict schema)', async () => { - jest - .spyOn(holdersService, 'findCreatorByIdOrHandle') - .mockResolvedValue(CREATOR_STUB); - - const req = makeReq({ id: CREATOR_STUB.id }, { unknown_param: 'oops' }); - const res = makeRes(); - await httpGetCreatorHolders(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(400); - const body = res.json.mock.calls[0][0]; - expect(body.success).toBe(false); - }); - - it('returns 400 for an invalid sort value', async () => { - jest - .spyOn(holdersService, 'findCreatorByIdOrHandle') - .mockResolvedValue(CREATOR_STUB); - - const req = makeReq({ id: CREATOR_STUB.id }, { sort: 'invalid_sort' }); - const res = makeRes(); - await httpGetCreatorHolders(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(400); - }); - - // ── Error propagation ────────────────────────────────────────────────────── - - it('calls next(error) when the service throws', async () => { - const dbError = new Error('DB connection failed'); - jest - .spyOn(holdersService, 'findCreatorByIdOrHandle') - .mockRejectedValue(dbError); - - const req = makeReq({ id: CREATOR_STUB.id }); - const res = makeRes(); - const next = makeNext(); - await httpGetCreatorHolders(req, res, next); - - expect(next).toHaveBeenCalledWith(dbError); - expect(res.json).not.toHaveBeenCalled(); - }); + it('returns 404 when the creator does not exist', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(null); + + const req = makeReq({ id: 'nonexistent-creator' }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(404); + + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + expect(body.error.code).toBe('NOT_FOUND'); + }); + + // ── Creator exists but no holders ───────────────────────────────────────── + + it('returns empty items list (not 404) when creator exists but has no holders', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([[], 0]); + + const req = makeReq({ id: CREATOR_STUB.handle }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.items).toEqual([]); + expect(body.data.meta.total).toBe(0); + expect(body.data.meta.hasMore).toBe(false); + }); + + // ── Default sort: key_balance desc ──────────────────────────────────────── + + it('returns holders sorted by key_balance desc by default', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([ALL_HOLDERS, ALL_HOLDERS.length]); + + const req = makeReq({ id: CREATOR_STUB.id }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + + const body = res.json.mock.calls[0][0]; + const items = body.data.items as HolderRecord[]; + + expect(items).toHaveLength(3); + expect(items[0].wallet_address).toBe(HOLDER_A.wallet_address); + expect(items[0].key_balance).toBe(HOLDER_A.key_balance); // 30 + expect(items[2].key_balance).toBe(HOLDER_C.key_balance); // 10 + + // Verify fetchCreatorHolders was called with sort=key_balance + const [, query] = (holdersService.fetchCreatorHolders as jest.Mock).mock + .calls[0]; + expect(query.sort).toBe('key_balance'); + }); + + // ── Sort by held_since ───────────────────────────────────────────────────── + + it('returns holders sorted by held_since asc when ?sort=held_since', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([ALL_HOLDERS, ALL_HOLDERS.length]); + + const req = makeReq({ id: CREATOR_STUB.id }, { sort: 'held_since' }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + + // Verify fetchCreatorHolders was called with sort=held_since + const [, query] = (holdersService.fetchCreatorHolders as jest.Mock).mock + .calls[0]; + expect(query.sort).toBe('held_since'); + }); + + // ── Pagination ───────────────────────────────────────────────────────────── + + it('returns correct pagination meta for a partial page', async () => { + const TOTAL = 10; + const PAGE_ITEMS = ALL_HOLDERS; // 3 of 10 + + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([PAGE_ITEMS, TOTAL]); + + const req = makeReq({ id: CREATOR_STUB.id }, { limit: '3', offset: '0' }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + const { meta } = body.data; + + expect(meta.limit).toBe(3); + expect(meta.offset).toBe(0); + expect(meta.total).toBe(TOTAL); + expect(meta.hasMore).toBe(true); + }); + + it('returns hasMore=false on the last page', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([ALL_HOLDERS, ALL_HOLDERS.length]); + + const req = makeReq( + { id: CREATOR_STUB.id }, + { limit: '20', offset: '0' } + ); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + const { meta } = res.json.mock.calls[0][0].data; + expect(meta.hasMore).toBe(false); + expect(meta.total).toBe(3); + }); + + // ── Response shape ───────────────────────────────────────────────────────── + + it('each holder item has wallet_address, key_balance, and held_since', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + jest + .spyOn(holdersService, 'fetchCreatorHolders') + .mockResolvedValue([[HOLDER_A], 1]); + + const req = makeReq({ id: CREATOR_STUB.id }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + const [item] = res.json.mock.calls[0][0].data.items as HolderRecord[]; + expect(item).toHaveProperty('wallet_address', HOLDER_A.wallet_address); + expect(item).toHaveProperty('key_balance', HOLDER_A.key_balance); + expect(item).toHaveProperty('held_since', HOLDER_A.held_since); + }); + + // ── Validation ───────────────────────────────────────────────────────────── + + it('returns 400 for an unknown query param (strict schema)', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + + const req = makeReq({ id: CREATOR_STUB.id }, { unknown_param: 'oops' }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + }); + + it('returns 400 for an invalid sort value', async () => { + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockResolvedValue(CREATOR_STUB); + + const req = makeReq({ id: CREATOR_STUB.id }, { sort: 'invalid_sort' }); + const res = makeRes(); + await httpGetCreatorHolders(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + // ── Error propagation ────────────────────────────────────────────────────── + + it('calls next(error) when the service throws', async () => { + const dbError = new Error('DB connection failed'); + jest + .spyOn(holdersService, 'findCreatorByIdOrHandle') + .mockRejectedValue(dbError); + + const req = makeReq({ id: CREATOR_STUB.id }); + const res = makeRes(); + const next = makeNext(); + await httpGetCreatorHolders(req, res, next); + + expect(next).toHaveBeenCalledWith(dbError); + expect(res.json).not.toHaveBeenCalled(); + }); }); diff --git a/src/modules/creators/creator-holders.schemas.ts b/src/modules/creators/creator-holders.schemas.ts index 72aaaa0..785a5b3 100644 --- a/src/modules/creators/creator-holders.schemas.ts +++ b/src/modules/creators/creator-holders.schemas.ts @@ -1,6 +1,9 @@ import { z } from 'zod'; import { safeIntParam } from '../../utils/query.utils'; -import { MIN_PAGE_SIZE, MAX_PAGE_SIZE } from '../../constants/pagination.constants'; +import { + MIN_PAGE_SIZE, + MAX_PAGE_SIZE, +} from '../../constants/pagination.constants'; import { PUBLIC_OFFSET_PAGINATION_DEFAULTS } from '../../utils/public-list-query-defaults'; /** @@ -8,31 +11,35 @@ import { PUBLIC_OFFSET_PAGINATION_DEFAULTS } from '../../utils/public-list-query * - key_balance: sort by number of keys held (default, largest first) * - held_since: sort by when the wallet first bought a key (earliest first) */ -export const CREATOR_HOLDER_SORT_FIELDS = ['key_balance', 'held_since'] as const; -export type CreatorHolderSortField = (typeof CREATOR_HOLDER_SORT_FIELDS)[number]; +export const CREATOR_HOLDER_SORT_FIELDS = [ + 'key_balance', + 'held_since', +] as const; +export type CreatorHolderSortField = + (typeof CREATOR_HOLDER_SORT_FIELDS)[number]; /** * Validation schema for GET /creators/:id/holders query parameters. */ export const CreatorHoldersQuerySchema = z - .object({ - limit: safeIntParam({ - defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, - min: MIN_PAGE_SIZE, - max: MAX_PAGE_SIZE, - label: 'Limit', - }), - offset: safeIntParam({ - defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, - min: 0, - max: Number.MAX_SAFE_INTEGER, - label: 'Offset', - }), - sort: z - .enum(CREATOR_HOLDER_SORT_FIELDS) - .optional() - .default('key_balance'), - }) - .strict(); + .object({ + limit: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, + min: MIN_PAGE_SIZE, + max: MAX_PAGE_SIZE, + label: 'Limit', + }), + offset: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, + min: 0, + max: Number.MAX_SAFE_INTEGER, + label: 'Offset', + }), + sort: z + .enum(CREATOR_HOLDER_SORT_FIELDS) + .optional() + .default('key_balance'), + }) + .strict(); export type CreatorHoldersQueryType = z.infer; diff --git a/src/modules/creators/creator-holders.service.ts b/src/modules/creators/creator-holders.service.ts index 1dbe9d2..8f52ecd 100644 --- a/src/modules/creators/creator-holders.service.ts +++ b/src/modules/creators/creator-holders.service.ts @@ -7,9 +7,9 @@ import { CreatorHoldersQueryType } from './creator-holders.schemas'; * Public-facing holder record returned by the holders endpoint. */ export interface HolderRecord { - wallet_address: string; - key_balance: number; - held_since: Date; + wallet_address: string; + key_balance: number; + held_since: Date; } /** @@ -17,14 +17,14 @@ export interface HolderRecord { * Returns null if no creator matches either field. */ export async function findCreatorByIdOrHandle( - idOrHandle: string, + idOrHandle: string ): Promise<{ id: string; handle: string } | null> { - return prisma.creatorProfile.findFirst({ - where: { - OR: [{ id: idOrHandle }, { handle: idOrHandle }], - }, - select: { id: true, handle: true }, - }); + return prisma.creatorProfile.findFirst({ + where: { + OR: [{ id: idOrHandle }, { handle: idOrHandle }], + }, + select: { id: true, handle: true }, + }); } /** @@ -41,47 +41,52 @@ export async function findCreatorByIdOrHandle( * @returns Tuple of [holder records, total count] */ export async function fetchCreatorHolders( - creatorId: string, - query: CreatorHoldersQueryType, + creatorId: string, + query: CreatorHoldersQueryType ): Promise<[HolderRecord[], number]> { - const { limit, offset, sort } = query; - const startMs = Date.now(); + const { limit, offset, sort } = query; + const startMs = Date.now(); - const where: Prisma.KeyOwnershipWhereInput = { - creatorId, - balance: { gt: 0 }, - }; + const where: Prisma.KeyOwnershipWhereInput = { + creatorId, + balance: { gt: 0 }, + }; - const orderBy: Prisma.KeyOwnershipOrderByWithRelationInput = - sort === 'held_since' - ? { createdAt: 'asc' } - : { balance: 'desc' }; + const orderBy: Prisma.KeyOwnershipOrderByWithRelationInput = + sort === 'held_since' ? { createdAt: 'asc' } : { balance: 'desc' }; - const [rows, total] = await Promise.all([ - prisma.keyOwnership.findMany({ - where, - orderBy, - skip: offset, - take: limit, - select: { - ownerAddress: true, - balance: true, - createdAt: true, - }, - }), - prisma.keyOwnership.count({ where }), - ]); + const [rows, total] = await Promise.all([ + prisma.keyOwnership.findMany({ + where, + orderBy, + skip: offset, + take: limit, + select: { + ownerAddress: true, + balance: true, + createdAt: true, + }, + }), + prisma.keyOwnership.count({ where }), + ]); - const holders: HolderRecord[] = rows.map((row) => ({ - wallet_address: row.ownerAddress, - key_balance: Number(row.balance), - held_since: row.createdAt, - })); + const holders: HolderRecord[] = rows.map(row => ({ + wallet_address: row.ownerAddress, + key_balance: Number(row.balance), + held_since: row.createdAt, + })); - if (holders.length === 0) { - const durationMs = Date.now() - startMs; - logger.debug({ creator_id: creatorId, holder_count: 0, query_duration_ms: durationMs }, 'Creator holders query returned zero results'); - } + if (holders.length === 0) { + const durationMs = Date.now() - startMs; + logger.debug( + { + creator_id: creatorId, + holder_count: 0, + query_duration_ms: durationMs, + }, + 'Creator holders query returned zero results' + ); + } - return [holders, total]; + return [holders, total]; } diff --git a/src/modules/creators/creator-list-field-picker.utils.test.ts b/src/modules/creators/creator-list-field-picker.utils.test.ts index d49283d..a032699 100644 --- a/src/modules/creators/creator-list-field-picker.utils.test.ts +++ b/src/modules/creators/creator-list-field-picker.utils.test.ts @@ -57,7 +57,9 @@ describe('pickCreatorListFields()', () => { for (const field of CREATOR_LIST_PUBLIC_FIELDS) { expect(picked).toHaveProperty(field); } - expect(Object.keys(picked)).toHaveLength(CREATOR_LIST_PUBLIC_FIELDS.length); + expect(Object.keys(picked)).toHaveLength( + CREATOR_LIST_PUBLIC_FIELDS.length + ); }); it('returns only the requested subset', () => { @@ -75,14 +77,21 @@ describe('pickCreatorListFields()', () => { price24hAgo: null, priceChange24h: null, }; - const picked = pickCreatorListFields(nullItem, ['currentPrice', 'price24hAgo', 'priceChange24h']); + const picked = pickCreatorListFields(nullItem, [ + 'currentPrice', + 'price24hAgo', + 'priceChange24h', + ]); expect(picked.currentPrice).toBeNull(); expect(picked.price24hAgo).toBeNull(); expect(picked.priceChange24h).toBeNull(); }); it('preserves exact field values', () => { - const picked = pickCreatorListFields(sampleItem, ['followers', 'priceChange24h']); + const picked = pickCreatorListFields(sampleItem, [ + 'followers', + 'priceChange24h', + ]); expect(picked.followers).toBe(42); expect(picked.priceChange24h).toBe(11.11); }); diff --git a/src/modules/creators/creator-list-field-picker.utils.ts b/src/modules/creators/creator-list-field-picker.utils.ts index ff1994c..c74f240 100644 --- a/src/modules/creators/creator-list-field-picker.utils.ts +++ b/src/modules/creators/creator-list-field-picker.utils.ts @@ -12,12 +12,15 @@ export const CREATOR_LIST_PUBLIC_FIELDS = [ 'priceChange24h', ] as const; -export type CreatorListPublicField = (typeof CREATOR_LIST_PUBLIC_FIELDS)[number]; +export type CreatorListPublicField = + (typeof CREATOR_LIST_PUBLIC_FIELDS)[number]; /** * Returns true if `field` is a recognised creator list public field. */ -export function isCreatorListPublicField(field: string): field is CreatorListPublicField { +export function isCreatorListPublicField( + field: string +): field is CreatorListPublicField { return (CREATOR_LIST_PUBLIC_FIELDS as readonly string[]).includes(field); } @@ -38,7 +41,7 @@ export function isCreatorListPublicField(field: string): field is CreatorListPub */ export function pickCreatorListFields( item: CreatorListItem, - fields: readonly CreatorListPublicField[] = CREATOR_LIST_PUBLIC_FIELDS, + fields: readonly CreatorListPublicField[] = CREATOR_LIST_PUBLIC_FIELDS ): Pick { const result = {} as Pick; for (const field of fields) { diff --git a/src/modules/creators/creator-list-filter-total-count.integration.test.ts b/src/modules/creators/creator-list-filter-total-count.integration.test.ts index 4343e0d..8a9cf8f 100644 --- a/src/modules/creators/creator-list-filter-total-count.integration.test.ts +++ b/src/modules/creators/creator-list-filter-total-count.integration.test.ts @@ -25,7 +25,7 @@ function makeRes(): any { } function makeNext(): jest.Mock { - return jest.fn((err) => { + return jest.fn(err => { if (err) { console.error('NEXT CALLED WITH ERROR:', err); } @@ -35,14 +35,59 @@ function makeNext(): jest.Mock { // ── Mock Fixture Data with known split ──────────────────────────────────────── const mockMatchingCreators = [ - { id: '1', displayName: 'Gamer One', handle: 'gamer1', category: 'gaming', avatarUrl: 'http://avatar1.png', isVerified: true, createdAt: new Date(), updatedAt: new Date() }, - { id: '2', displayName: 'Gamer Two', handle: 'gamer2', category: 'gaming', avatarUrl: 'http://avatar2.png', isVerified: true, createdAt: new Date(), updatedAt: new Date() }, - { id: '3', displayName: 'Gamer Three', handle: 'gamer3', category: 'gaming', avatarUrl: 'http://avatar3.png', isVerified: true, createdAt: new Date(), updatedAt: new Date() }, + { + id: '1', + displayName: 'Gamer One', + handle: 'gamer1', + category: 'gaming', + avatarUrl: 'http://avatar1.png', + isVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: '2', + displayName: 'Gamer Two', + handle: 'gamer2', + category: 'gaming', + avatarUrl: 'http://avatar2.png', + isVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: '3', + displayName: 'Gamer Three', + handle: 'gamer3', + category: 'gaming', + avatarUrl: 'http://avatar3.png', + isVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }, ] as unknown as CreatorProfile[]; const mockNonMatchingCreators = [ - { id: '4', displayName: 'Artist One', handle: 'artist1', category: 'art', avatarUrl: 'http://avatar4.png', isVerified: false, createdAt: new Date(), updatedAt: new Date() }, - { id: '5', displayName: 'Musician One', handle: 'musician1', category: 'music', avatarUrl: 'http://avatar5.png', isVerified: false, createdAt: new Date(), updatedAt: new Date() }, + { + id: '4', + displayName: 'Artist One', + handle: 'artist1', + category: 'art', + avatarUrl: 'http://avatar4.png', + isVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: '5', + displayName: 'Musician One', + handle: 'musician1', + category: 'music', + avatarUrl: 'http://avatar5.png', + isVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }, ] as unknown as CreatorProfile[]; const mockAllCreators = [...mockMatchingCreators, ...mockNonMatchingCreators]; @@ -52,12 +97,14 @@ const mockAllCreators = [...mockMatchingCreators, ...mockNonMatchingCreators]; describe('GET /api/v1/creators — total count accuracy after filter', () => { beforeEach(() => { // Mock fetchCreatorList implementation to return different totals depending on filter - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockImplementation(async (query: any) => { - if (query.category === 'gaming') { - return [mockMatchingCreators, mockMatchingCreators.length]; // 3 creators - } - return [mockAllCreators, mockAllCreators.length]; // 5 creators - }); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockImplementation(async (query: any) => { + if (query.category === 'gaming') { + return [mockMatchingCreators, mockMatchingCreators.length]; // 3 creators + } + return [mockAllCreators, mockAllCreators.length]; // 5 creators + }); }); afterEach(() => { @@ -104,7 +151,7 @@ describe('GET /api/v1/creators — total count accuracy after filter', () => { await httpListCreators(req, res, makeNext()); const body = res.json.mock.calls[0][0]; - + // Explicit assertion: the test will fail if body.data.meta.total is the unfiltered count (5) // because we expect it to be exactly 3. expect(body.data.meta.total).toBe(3); diff --git a/src/modules/creators/creator-list-item.mapper.ts b/src/modules/creators/creator-list-item.mapper.ts index bfb2cd6..68f8c7b 100644 --- a/src/modules/creators/creator-list-item.mapper.ts +++ b/src/modules/creators/creator-list-item.mapper.ts @@ -48,7 +48,9 @@ function logIfFieldTypeMismatch( const expectedType = CREATOR_LIST_FIELD_EXPECTED_TYPES[fieldName]; const typeMatches = - expectedType === 'Date' ? value instanceof Date : typeof value === expectedType; + expectedType === 'Date' + ? value instanceof Date + : typeof value === expectedType; if (!typeMatches) { logger.error({ diff --git a/src/modules/creators/creator-list-page-size-boundary.integration.test.ts b/src/modules/creators/creator-list-page-size-boundary.integration.test.ts index 76a3135..dca0530 100644 --- a/src/modules/creators/creator-list-page-size-boundary.integration.test.ts +++ b/src/modules/creators/creator-list-page-size-boundary.integration.test.ts @@ -64,10 +64,9 @@ describe('GET /api/v1/creators — page size boundary', () => { // ── Exactly at MAX_PAGE_SIZE ─────────────────────────────────────────────── it('accepts limit equal to MAX_PAGE_SIZE and returns HTTP 200', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - makeFixtures(MAX_PAGE_SIZE), - MAX_PAGE_SIZE, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([makeFixtures(MAX_PAGE_SIZE), MAX_PAGE_SIZE]); const req = makeReq({ limit: String(MAX_PAGE_SIZE) }); const res = makeRes(); @@ -92,10 +91,9 @@ describe('GET /api/v1/creators — page size boundary', () => { it('response contains no more than MAX_PAGE_SIZE items when limit=MAX_PAGE_SIZE', async () => { const fixtures = makeFixtures(MAX_PAGE_SIZE); - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - fixtures, - MAX_PAGE_SIZE, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([fixtures, MAX_PAGE_SIZE]); const req = makeReq({ limit: String(MAX_PAGE_SIZE) }); const res = makeRes(); @@ -106,10 +104,9 @@ describe('GET /api/v1/creators — page size boundary', () => { }); it('pagination meta reflects limit=MAX_PAGE_SIZE in the response', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - makeFixtures(MAX_PAGE_SIZE), - MAX_PAGE_SIZE, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([makeFixtures(MAX_PAGE_SIZE), MAX_PAGE_SIZE]); const req = makeReq({ limit: String(MAX_PAGE_SIZE) }); const res = makeRes(); @@ -122,10 +119,9 @@ describe('GET /api/v1/creators — page size boundary', () => { }); it('response envelope is well-formed at the limit boundary', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - makeFixtures(3), - 3, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([makeFixtures(3), 3]); const req = makeReq({ limit: String(MAX_PAGE_SIZE) }); const res = makeRes(); @@ -200,10 +196,9 @@ describe('GET /api/v1/creators — page size boundary', () => { it('accepts limit one below MAX_PAGE_SIZE (MAX_PAGE_SIZE - 1) with HTTP 200', async () => { const belowMax = MAX_PAGE_SIZE - 1; - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - makeFixtures(belowMax), - belowMax, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([makeFixtures(belowMax), belowMax]); const req = makeReq({ limit: String(belowMax) }); const res = makeRes(); @@ -218,10 +213,9 @@ describe('GET /api/v1/creators — page size boundary', () => { // ── MIN_PAGE_SIZE boundary ──────────────────────────────────────────────── it('accepts limit equal to MIN_PAGE_SIZE with HTTP 200', async () => { - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ - makeFixtures(1), - 1, - ]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([makeFixtures(1), 1]); const req = makeReq({ limit: String(MIN_PAGE_SIZE) }); const res = makeRes(); diff --git a/src/modules/creators/creator-list-partial-results.integration.test.ts b/src/modules/creators/creator-list-partial-results.integration.test.ts index a54f403..f95ee02 100644 --- a/src/modules/creators/creator-list-partial-results.integration.test.ts +++ b/src/modules/creators/creator-list-partial-results.integration.test.ts @@ -68,7 +68,7 @@ describe('GET /api/v1/creators — partial database results', () => { const body = res.json.mock.calls[0][0]; const meta = body.data.meta; - + expect(meta.limit).toBe(20); expect(meta.total).toBe(50); // hasMore = (offset + limit) < total => (0 + 20) < 50 => true diff --git a/src/modules/creators/creator-list-price-filter.integration.test.ts b/src/modules/creators/creator-list-price-filter.integration.test.ts index a0853ab..a336653 100644 --- a/src/modules/creators/creator-list-price-filter.integration.test.ts +++ b/src/modules/creators/creator-list-price-filter.integration.test.ts @@ -5,164 +5,172 @@ import supertest from 'supertest'; import app from '../../app'; import { prisma } from '../../utils/prisma.utils'; -const USER_IDS = ['price-filter-user-1', 'price-filter-user-2', 'price-filter-user-3']; -const HANDLES = ['price-filter-creator-1', 'price-filter-creator-2', 'price-filter-creator-3']; +const USER_IDS = [ + 'price-filter-user-1', + 'price-filter-user-2', + 'price-filter-user-3', +]; +const HANDLES = [ + 'price-filter-creator-1', + 'price-filter-creator-2', + 'price-filter-creator-3', +]; describe('#419 min_price and max_price filtering', () => { - let creatorIds: string[]; - - beforeAll(async () => { - creatorIds = []; - - for (let i = 0; i < 3; i++) { - await prisma.user.upsert({ - where: { id: USER_IDS[i] }, - create: { - id: USER_IDS[i], - email: `price-filter-${i}@example.test`, - passwordHash: 'dummy-hash', - firstName: 'Price', - lastName: `Filter ${i}`, - }, - update: {}, + let creatorIds: string[]; + + beforeAll(async () => { + creatorIds = []; + + for (let i = 0; i < 3; i++) { + await prisma.user.upsert({ + where: { id: USER_IDS[i] }, + create: { + id: USER_IDS[i], + email: `price-filter-${i}@example.test`, + passwordHash: 'dummy-hash', + firstName: 'Price', + lastName: `Filter ${i}`, + }, + update: {}, + }); + + const creator = await prisma.creatorProfile.upsert({ + where: { userId: USER_IDS[i] }, + create: { + userId: USER_IDS[i], + handle: HANDLES[i], + displayName: `Creator ${i}`, + }, + update: {}, + }); + + creatorIds.push(creator.id); + } + + // Seed price snapshots: 1M, 3M, 5M stroops + const prices = [1_000_000n, 3_000_000n, 5_000_000n]; + for (let i = 0; i < 3; i++) { + await prisma.creatorPriceSnapshot.upsert({ + where: { creatorId: creatorIds[i] }, + create: { + creatorId: creatorIds[i], + currentPrice: prices[i], + price24hAgo: prices[i], + lastTradeAt: new Date(), + }, + update: { + currentPrice: prices[i], + price24hAgo: prices[i], + lastTradeAt: new Date(), + }, + }); + } + }); + + afterAll(async () => { + await prisma.creatorPriceSnapshot.deleteMany({ + where: { creatorId: { in: creatorIds } }, }); - - const creator = await prisma.creatorProfile.upsert({ - where: { userId: USER_IDS[i] }, - create: { - userId: USER_IDS[i], - handle: HANDLES[i], - displayName: `Creator ${i}`, - }, - update: {}, + await prisma.creatorProfile.deleteMany({ + where: { handle: { in: HANDLES } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: USER_IDS } }, + }); + await prisma.$disconnect(); + }); + + it('minPrice alone filters out creators below the value', async () => { + const res = await supertest(app).get('/api/v1/creators?minPrice=2000000'); + expect(res.status).toBe(200); + + const ids = (res.body.data.items as any[]) + .filter((c: any) => creatorIds.includes(c.id)) + .map((c: any) => c.id); + + // Only creators with price >= 2M (creators 1 and 2) + expect(ids).toContain(creatorIds[1]); // 3M + expect(ids).toContain(creatorIds[2]); // 5M + expect(ids).not.toContain(creatorIds[0]); // 1M + }); + + it('maxPrice alone filters out creators above the value', async () => { + const res = await supertest(app).get('/api/v1/creators?maxPrice=4000000'); + expect(res.status).toBe(200); + + const ids = (res.body.data.items as any[]) + .filter((c: any) => creatorIds.includes(c.id)) + .map((c: any) => c.id); + + // Only creators with price <= 4M (creators 0 and 1) + expect(ids).toContain(creatorIds[0]); // 1M + expect(ids).toContain(creatorIds[1]); // 3M + expect(ids).not.toContain(creatorIds[2]); // 5M + }); + + it('both params together return only creators within range (inclusive)', async () => { + const res = await supertest(app).get( + '/api/v1/creators?minPrice=2000000&maxPrice=4000000' + ); + expect(res.status).toBe(200); + + const ids = (res.body.data.items as any[]) + .filter((c: any) => creatorIds.includes(c.id)) + .map((c: any) => c.id); + + // Only creator 1 (3M) is in range [2M, 4M] + expect(ids).toContain(creatorIds[1]); + expect(ids).not.toContain(creatorIds[0]); + expect(ids).not.toContain(creatorIds[2]); + }); + + it('returns 400 when minPrice > maxPrice', async () => { + const res = await supertest(app).get( + '/api/v1/creators?minPrice=5000000&maxPrice=1000000' + ); + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + expect(res.body.error.message).toBe('Invalid query parameters'); + expect(res.body.error.details[0].field).toBe('minPrice'); + }); + + it('combines correctly with sort and pagination', async () => { + const res = await supertest(app).get( + '/api/v1/creators?minPrice=1000000&maxPrice=5000000&limit=10&offset=0&sort=createdAt&order=desc' + ); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.meta.limit).toBe(10); + expect(res.body.data.meta.offset).toBe(0); + }); + + it('combines correctly with verified filter', async () => { + // Mark creator 1 as verified + await prisma.creatorProfile.update({ + where: { id: creatorIds[1] }, + data: { isVerified: true }, }); - creatorIds.push(creator.id); - } - - // Seed price snapshots: 1M, 3M, 5M stroops - const prices = [1_000_000n, 3_000_000n, 5_000_000n]; - for (let i = 0; i < 3; i++) { - await prisma.creatorPriceSnapshot.upsert({ - where: { creatorId: creatorIds[i] }, - create: { - creatorId: creatorIds[i], - currentPrice: prices[i], - price24hAgo: prices[i], - lastTradeAt: new Date(), - }, - update: { - currentPrice: prices[i], - price24hAgo: prices[i], - lastTradeAt: new Date(), - }, + const res = await supertest(app).get( + '/api/v1/creators?minPrice=1000000&maxPrice=5000000&verified=true' + ); + expect(res.status).toBe(200); + + const ids = (res.body.data.items as any[]) + .filter((c: any) => creatorIds.includes(c.id)) + .map((c: any) => c.id); + + // Only verified creator 1 within price range + expect(ids).toContain(creatorIds[1]); + expect(ids).not.toContain(creatorIds[0]); + expect(ids).not.toContain(creatorIds[2]); + + // Cleanup + await prisma.creatorProfile.update({ + where: { id: creatorIds[1] }, + data: { isVerified: false }, }); - } - }); - - afterAll(async () => { - await prisma.creatorPriceSnapshot.deleteMany({ - where: { creatorId: { in: creatorIds } }, - }); - await prisma.creatorProfile.deleteMany({ - where: { handle: { in: HANDLES } }, - }); - await prisma.user.deleteMany({ - where: { id: { in: USER_IDS } }, - }); - await prisma.$disconnect(); - }); - - it('minPrice alone filters out creators below the value', async () => { - const res = await supertest(app).get('/api/v1/creators?minPrice=2000000'); - expect(res.status).toBe(200); - - const ids = (res.body.data.items as any[]) - .filter((c: any) => creatorIds.includes(c.id)) - .map((c: any) => c.id); - - // Only creators with price >= 2M (creators 1 and 2) - expect(ids).toContain(creatorIds[1]); // 3M - expect(ids).toContain(creatorIds[2]); // 5M - expect(ids).not.toContain(creatorIds[0]); // 1M - }); - - it('maxPrice alone filters out creators above the value', async () => { - const res = await supertest(app).get('/api/v1/creators?maxPrice=4000000'); - expect(res.status).toBe(200); - - const ids = (res.body.data.items as any[]) - .filter((c: any) => creatorIds.includes(c.id)) - .map((c: any) => c.id); - - // Only creators with price <= 4M (creators 0 and 1) - expect(ids).toContain(creatorIds[0]); // 1M - expect(ids).toContain(creatorIds[1]); // 3M - expect(ids).not.toContain(creatorIds[2]); // 5M - }); - - it('both params together return only creators within range (inclusive)', async () => { - const res = await supertest(app).get( - '/api/v1/creators?minPrice=2000000&maxPrice=4000000' - ); - expect(res.status).toBe(200); - - const ids = (res.body.data.items as any[]) - .filter((c: any) => creatorIds.includes(c.id)) - .map((c: any) => c.id); - - // Only creator 1 (3M) is in range [2M, 4M] - expect(ids).toContain(creatorIds[1]); - expect(ids).not.toContain(creatorIds[0]); - expect(ids).not.toContain(creatorIds[2]); - }); - - it('returns 400 when minPrice > maxPrice', async () => { - const res = await supertest(app).get( - '/api/v1/creators?minPrice=5000000&maxPrice=1000000' - ); - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - expect(res.body.error.code).toBe('VALIDATION_ERROR'); - expect(res.body.error.message).toBe('Invalid query parameters'); - expect(res.body.error.details[0].field).toBe('minPrice'); - }); - - it('combines correctly with sort and pagination', async () => { - const res = await supertest(app).get( - '/api/v1/creators?minPrice=1000000&maxPrice=5000000&limit=10&offset=0&sort=createdAt&order=desc' - ); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.data.meta.limit).toBe(10); - expect(res.body.data.meta.offset).toBe(0); - }); - - it('combines correctly with verified filter', async () => { - // Mark creator 1 as verified - await prisma.creatorProfile.update({ - where: { id: creatorIds[1] }, - data: { isVerified: true }, - }); - - const res = await supertest(app).get( - '/api/v1/creators?minPrice=1000000&maxPrice=5000000&verified=true' - ); - expect(res.status).toBe(200); - - const ids = (res.body.data.items as any[]) - .filter((c: any) => creatorIds.includes(c.id)) - .map((c: any) => c.id); - - // Only verified creator 1 within price range - expect(ids).toContain(creatorIds[1]); - expect(ids).not.toContain(creatorIds[0]); - expect(ids).not.toContain(creatorIds[2]); - - // Cleanup - await prisma.creatorProfile.update({ - where: { id: creatorIds[1] }, - data: { isVerified: false }, - }); - }); + }); }); diff --git a/src/modules/creators/creator-list-price-filtered-total.integration.test.ts b/src/modules/creators/creator-list-price-filtered-total.integration.test.ts index 7e25b21..38bdb3a 100644 --- a/src/modules/creators/creator-list-price-filtered-total.integration.test.ts +++ b/src/modules/creators/creator-list-price-filtered-total.integration.test.ts @@ -5,104 +5,112 @@ import app from '../../app'; import { prisma } from '../../utils/prisma.utils'; const USER_IDS = [ - 'filtered-total-user-1', - 'filtered-total-user-2', - 'filtered-total-user-3', - 'filtered-total-user-4', - 'filtered-total-user-5', + 'filtered-total-user-1', + 'filtered-total-user-2', + 'filtered-total-user-3', + 'filtered-total-user-4', + 'filtered-total-user-5', ]; const HANDLES = [ - 'filtered-total-creator-1', - 'filtered-total-creator-2', - 'filtered-total-creator-3', - 'filtered-total-creator-4', - 'filtered-total-creator-5', + 'filtered-total-creator-1', + 'filtered-total-creator-2', + 'filtered-total-creator-3', + 'filtered-total-creator-4', + 'filtered-total-creator-5', ]; describe('GET /api/v1/creators — filtered total count with price range', () => { - let creatorIds: string[] = []; + let creatorIds: string[] = []; - beforeAll(async () => { - // Ensure database is completely clean of any conflicting data - await prisma.keyOwnership.deleteMany({}); - await prisma.creatorPriceSnapshot.deleteMany({}); - await prisma.creatorProfile.deleteMany({}); - await prisma.user.deleteMany({}); + beforeAll(async () => { + // Ensure database is completely clean of any conflicting data + await prisma.keyOwnership.deleteMany({}); + await prisma.creatorPriceSnapshot.deleteMany({}); + await prisma.creatorProfile.deleteMany({}); + await prisma.user.deleteMany({}); - creatorIds = []; + creatorIds = []; - // Seed exactly 5 users and creators - for (let i = 0; i < 5; i++) { - await prisma.user.create({ - data: { - id: USER_IDS[i], - email: `filtered-total-${i}@example.test`, - passwordHash: 'dummy-hash', - firstName: 'Filtered', - lastName: `Total ${i}`, - }, - }); + // Seed exactly 5 users and creators + for (let i = 0; i < 5; i++) { + await prisma.user.create({ + data: { + id: USER_IDS[i], + email: `filtered-total-${i}@example.test`, + passwordHash: 'dummy-hash', + firstName: 'Filtered', + lastName: `Total ${i}`, + }, + }); - const creator = await prisma.creatorProfile.create({ - data: { - userId: USER_IDS[i], - handle: HANDLES[i], - displayName: `Creator ${i}`, - }, - }); + const creator = await prisma.creatorProfile.create({ + data: { + userId: USER_IDS[i], + handle: HANDLES[i], + displayName: `Creator ${i}`, + }, + }); - creatorIds.push(creator.id); - } + creatorIds.push(creator.id); + } - // Seed exactly 5 creators with varied prices: 1M, 2M, 3M, 4M, 5M stroops - const prices = [1_000_000n, 2_000_000n, 3_000_000n, 4_000_000n, 5_000_000n]; - for (let i = 0; i < 5; i++) { - await prisma.creatorPriceSnapshot.create({ - data: { - creatorId: creatorIds[i], - currentPrice: prices[i], - price24hAgo: prices[i], - lastTradeAt: new Date(), - }, - }); - } - }); + // Seed exactly 5 creators with varied prices: 1M, 2M, 3M, 4M, 5M stroops + const prices = [ + 1_000_000n, + 2_000_000n, + 3_000_000n, + 4_000_000n, + 5_000_000n, + ]; + for (let i = 0; i < 5; i++) { + await prisma.creatorPriceSnapshot.create({ + data: { + creatorId: creatorIds[i], + currentPrice: prices[i], + price24hAgo: prices[i], + lastTradeAt: new Date(), + }, + }); + } + }); - afterAll(async () => { - // Teardown - await prisma.creatorPriceSnapshot.deleteMany({ - where: { creatorId: { in: creatorIds } }, - }); - await prisma.creatorProfile.deleteMany({ - where: { id: { in: creatorIds } }, - }); - await prisma.user.deleteMany({ - where: { id: { in: USER_IDS } }, - }); - await prisma.$disconnect(); - }); + afterAll(async () => { + // Teardown + await prisma.creatorPriceSnapshot.deleteMany({ + where: { creatorId: { in: creatorIds } }, + }); + await prisma.creatorProfile.deleteMany({ + where: { id: { in: creatorIds } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: USER_IDS } }, + }); + await prisma.$disconnect(); + }); - it('should verify that meta.total reflects the filtered creator count and matches the response data length when a price range is applied', async () => { - // Apply a price range filter matching exactly three creators: [2M, 4M] (prices 2M, 3M, 4M) - const res = await supertest(app).get('/api/v1/creators?minPrice=2000000&maxPrice=4000000'); - expect(res.status).toBe(200); + it('should verify that meta.total reflects the filtered creator count and matches the response data length when a price range is applied', async () => { + // Apply a price range filter matching exactly three creators: [2M, 4M] (prices 2M, 3M, 4M) + const res = await supertest(app).get( + '/api/v1/creators?minPrice=2000000&maxPrice=4000000' + ); + expect(res.status).toBe(200); - const response = { - data: res.body.data.items, - meta: res.body.data.meta, - }; - const { meta } = response; + const response = { + data: res.body.data.items, + meta: res.body.data.meta, + }; + const { meta } = response; - // Assert requirements: - // - meta.total === 3 - // - response.data.length === 3 - // - response.data.length === response.meta.total - expect(meta.total).toBe(3); - expect(response.data.length).toBe(3); - expect(response.data.length).toBe(response.meta.total); + // Assert requirements: + // - meta.total === 3 + // - response.data.length === 3 + // - response.data.length === response.meta.total + expect(meta.total).toBe(3); + expect(response.data.length).toBe(3); + expect(response.data.length).toBe(response.meta.total); - // Verify that meta.total is the filtered count, not the total creator count in the database (5) - expect(meta.total).not.toBe(5); - }); + // Verify that meta.total is the filtered count, not the total creator count in the database (5) + expect(meta.total).not.toBe(5); + }); }); diff --git a/src/modules/creators/creator-list-query-log.test.ts b/src/modules/creators/creator-list-query-log.test.ts index a83926b..d399209 100644 --- a/src/modules/creators/creator-list-query-log.test.ts +++ b/src/modules/creators/creator-list-query-log.test.ts @@ -100,7 +100,10 @@ describe('fetchCreatorList – structured debug log (#550)', () => { // ── result_count ────────────────────────────────────────────────────────────── it('log object contains result_count equal to the number of returned creators', async () => { - mockPrisma.creatorProfile.findMany.mockResolvedValue([CREATOR_ROW, CREATOR_ROW]); + mockPrisma.creatorProfile.findMany.mockResolvedValue([ + CREATOR_ROW, + CREATOR_ROW, + ]); mockPrisma.creatorProfile.count.mockResolvedValue(2); await fetchCreatorList(BASE_QUERY as any); diff --git a/src/modules/creators/creator-list-verified-filter.integration.test.ts b/src/modules/creators/creator-list-verified-filter.integration.test.ts index 2cc89fb..ac0c511 100644 --- a/src/modules/creators/creator-list-verified-filter.integration.test.ts +++ b/src/modules/creators/creator-list-verified-filter.integration.test.ts @@ -96,7 +96,9 @@ describe('GET /api/v1/creators — verified status filter', () => { }); it('returns only unverified creators when filtered by verified=false', async () => { - const unverifiedCreators = ALL_FIXTURES.filter(c => c.isVerified === false); + const unverifiedCreators = ALL_FIXTURES.filter( + c => c.isVerified === false + ); jest .spyOn(creatorsUtils, 'fetchCreatorList') .mockResolvedValue([unverifiedCreators, unverifiedCreators.length]); diff --git a/src/modules/creators/creator-registration-fixtures.utils.ts b/src/modules/creators/creator-registration-fixtures.utils.ts index 092bb66..120fa1a 100644 --- a/src/modules/creators/creator-registration-fixtures.utils.ts +++ b/src/modules/creators/creator-registration-fixtures.utils.ts @@ -11,7 +11,10 @@ export function buildCreatorRegistrationFixtures( startTimestamp: string | Date, stepDays = 1 ): CreatorProfile[] { - const start = typeof startTimestamp === 'string' ? new Date(startTimestamp) : startTimestamp; + const start = + typeof startTimestamp === 'string' + ? new Date(startTimestamp) + : startTimestamp; const stepMs = stepDays * 24 * 60 * 60 * 1000; return Array.from({ length: count }, (_, index) => { diff --git a/src/modules/creators/creator-route-method-not-allowed.integration.test.ts b/src/modules/creators/creator-route-method-not-allowed.integration.test.ts index 7020d37..580c217 100644 --- a/src/modules/creators/creator-route-method-not-allowed.integration.test.ts +++ b/src/modules/creators/creator-route-method-not-allowed.integration.test.ts @@ -5,15 +5,15 @@ // Mock chalk first to avoid ESM issues jest.mock('chalk', () => ({ - red: (text: string) => text, - green: (text: string) => text, - magenta: (text: string) => text, - cyan: (text: string) => text, + red: (text: string) => text, + green: (text: string) => text, + magenta: (text: string) => text, + cyan: (text: string) => text, })); // Mock tspec to prevent hanging jest.mock('tspec', () => ({ - TspecDocsMiddleware: jest.fn().mockResolvedValue([]), + TspecDocsMiddleware: jest.fn().mockResolvedValue([]), })); import supertest from 'supertest'; @@ -22,15 +22,15 @@ import app from '../../app'; // Mock all the things so we don't need a real database jest.mock('../../utils/prisma.utils'); jest.mock('../../utils/logger.utils', () => ({ - logger: { - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - } + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }, })); jest.mock('../../config', () => ({ - envConfig: { MODE: 'test', PORT: 3000, ENABLE_REQUEST_LOGGING: false }, - appConfig: { allowedOrigins: [] } + envConfig: { MODE: 'test', PORT: 3000, ENABLE_REQUEST_LOGGING: false }, + appConfig: { allowedOrigins: [] }, })); describe('Creator routes — Method Not Allowed (405) validation', () => { @@ -48,12 +48,16 @@ describe('Creator routes — Method Not Allowed (405) validation', () => { describe('POST /api/v1/creators/test-id/stats (unsupported method)', () => { it('returns 405 status code', async () => { - const res = await supertest(app).post('/api/v1/creators/test-id/stats'); + const res = await supertest(app).post( + '/api/v1/creators/test-id/stats' + ); expect(res.status).toBe(405); }); it('includes Allow header with GET', async () => { - const res = await supertest(app).post('/api/v1/creators/test-id/stats'); + const res = await supertest(app).post( + '/api/v1/creators/test-id/stats' + ); expect(res.headers.allow).toBe('GET'); }); }); diff --git a/src/modules/creators/creators-cache-key.utils.ts b/src/modules/creators/creators-cache-key.utils.ts index 1b764e7..c33130f 100644 --- a/src/modules/creators/creators-cache-key.utils.ts +++ b/src/modules/creators/creators-cache-key.utils.ts @@ -47,8 +47,10 @@ export function buildCreatorFeedCacheKey(query: CreatorListQueryType): string { query.include !== undefined && query.include.length > 0 ? query.include.join(',') : undefined, - minPrice: query.minPrice !== undefined ? query.minPrice.toString() : undefined, - maxPrice: query.maxPrice !== undefined ? query.maxPrice.toString() : undefined, + minPrice: + query.minPrice !== undefined ? query.minPrice.toString() : undefined, + maxPrice: + query.maxPrice !== undefined ? query.maxPrice.toString() : undefined, }; const canonical = buildCanonicalParamString(params); diff --git a/src/modules/creators/creators.cursor-warning.integration.test.ts b/src/modules/creators/creators.cursor-warning.integration.test.ts index 0645f54..10de148 100644 --- a/src/modules/creators/creators.cursor-warning.integration.test.ts +++ b/src/modules/creators/creators.cursor-warning.integration.test.ts @@ -5,7 +5,10 @@ import { prisma } from '../../utils/prisma.utils'; import { encodeCursor } from '../../utils/cursor.utils'; import { CreatorProfile } from '../../types/profile.types'; -function makeReq(query: Record = {}, path = '/api/v1/creators'): any { +function makeReq( + query: Record = {}, + path = '/api/v1/creators' +): any { return { query, path, @@ -44,7 +47,9 @@ describe('Pagination Boundary Warnings - Out-of-range cursors', () => { warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); // Mock fetchCreatorList to return a default mock result - jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([[sampleProfile], 1]); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([[sampleProfile], 1]); }); it('does not log a warning when no cursor is provided', async () => { @@ -68,7 +73,9 @@ describe('Pagination Boundary Warnings - Out-of-range cursors', () => { }); // Mock prisma.creatorProfile.findFirst to return the profile (exists) - jest.spyOn(prisma.creatorProfile, 'findFirst').mockResolvedValue(sampleProfile as any); + jest + .spyOn(prisma.creatorProfile, 'findFirst') + .mockResolvedValue(sampleProfile as any); const req = makeReq({ cursor: validCursor }); const res = makeRes(); diff --git a/src/modules/creators/creators.filter.test.ts b/src/modules/creators/creators.filter.test.ts index 5f9909f..2ebe6dd 100644 --- a/src/modules/creators/creators.filter.test.ts +++ b/src/modules/creators/creators.filter.test.ts @@ -7,63 +7,81 @@ import { strict as assert } from 'assert'; import { parseCreatorFilters } from './creators.filter'; function run() { - // --- search: whitespace normalization --- - - // leading/trailing whitespace is trimmed - assert.deepEqual(parseCreatorFilters({ search: ' jazz ' }), { search: 'jazz' }); - - // internal whitespace collapses to a single space - assert.deepEqual(parseCreatorFilters({ search: 'jazz musician' }), { search: 'jazz musician' }); - - // tabs and newlines are treated as whitespace - assert.deepEqual(parseCreatorFilters({ search: '\t jazz \n' }), { search: 'jazz' }); - - // whitespace-only input is dropped (no search key in result) - assert.deepEqual(parseCreatorFilters({ search: ' ' }), {}); - - // empty string is dropped - assert.deepEqual(parseCreatorFilters({ search: '' }), {}); - - // undefined search is omitted - assert.deepEqual(parseCreatorFilters({}), {}); - - // normal search passes through unchanged - assert.deepEqual(parseCreatorFilters({ search: 'alice' }), { search: 'alice' }); - - // --- verified: coercion --- - - assert.deepEqual(parseCreatorFilters({ verified: 'true' }), { verified: true }); - assert.deepEqual(parseCreatorFilters({ verified: 'false' }), { verified: false }); - assert.deepEqual(parseCreatorFilters({ verified: '1' }), { verified: true }); - assert.deepEqual(parseCreatorFilters({ verified: '0' }), { verified: false }); - assert.deepEqual(parseCreatorFilters({ verified: true }), { verified: true }); - assert.deepEqual(parseCreatorFilters({ verified: false }), { verified: false }); - - assert.throws( - () => parseCreatorFilters({ verified: 'yes' }), - /Accepted values: "true", "false", "1", "0"\./ - ); - - // --- combined --- - - assert.deepEqual( - parseCreatorFilters({ verified: 'true', search: ' bob ' }), - { verified: true, search: 'bob' } - ); - - // --- unknown keys are rejected --- - - assert.throws( - () => parseCreatorFilters({ unknown: 'value' }), - /Unsupported creator filter key\(s\): unknown/ - ); - - assert.throws( - () => parseCreatorFilters({ verified: 'true', extra: '1' }), - /Unsupported creator filter key\(s\): extra/ - ); - - console.log('creators.filter whitespace normalization tests passed'); + // --- search: whitespace normalization --- + + // leading/trailing whitespace is trimmed + assert.deepEqual(parseCreatorFilters({ search: ' jazz ' }), { + search: 'jazz', + }); + + // internal whitespace collapses to a single space + assert.deepEqual(parseCreatorFilters({ search: 'jazz musician' }), { + search: 'jazz musician', + }); + + // tabs and newlines are treated as whitespace + assert.deepEqual(parseCreatorFilters({ search: '\t jazz \n' }), { + search: 'jazz', + }); + + // whitespace-only input is dropped (no search key in result) + assert.deepEqual(parseCreatorFilters({ search: ' ' }), {}); + + // empty string is dropped + assert.deepEqual(parseCreatorFilters({ search: '' }), {}); + + // undefined search is omitted + assert.deepEqual(parseCreatorFilters({}), {}); + + // normal search passes through unchanged + assert.deepEqual(parseCreatorFilters({ search: 'alice' }), { + search: 'alice', + }); + + // --- verified: coercion --- + + assert.deepEqual(parseCreatorFilters({ verified: 'true' }), { + verified: true, + }); + assert.deepEqual(parseCreatorFilters({ verified: 'false' }), { + verified: false, + }); + assert.deepEqual(parseCreatorFilters({ verified: '1' }), { verified: true }); + assert.deepEqual(parseCreatorFilters({ verified: '0' }), { + verified: false, + }); + assert.deepEqual(parseCreatorFilters({ verified: true }), { + verified: true, + }); + assert.deepEqual(parseCreatorFilters({ verified: false }), { + verified: false, + }); + + assert.throws( + () => parseCreatorFilters({ verified: 'yes' }), + /Accepted values: "true", "false", "1", "0"\./ + ); + + // --- combined --- + + assert.deepEqual( + parseCreatorFilters({ verified: 'true', search: ' bob ' }), + { verified: true, search: 'bob' } + ); + + // --- unknown keys are rejected --- + + assert.throws( + () => parseCreatorFilters({ unknown: 'value' }), + /Unsupported creator filter key\(s\): unknown/ + ); + + assert.throws( + () => parseCreatorFilters({ verified: 'true', extra: '1' }), + /Unsupported creator filter key\(s\): extra/ + ); + + console.log('creators.filter whitespace normalization tests passed'); } run(); diff --git a/src/modules/creators/creators.filter.ts b/src/modules/creators/creators.filter.ts index 341019e..e1d2e97 100644 --- a/src/modules/creators/creators.filter.ts +++ b/src/modules/creators/creators.filter.ts @@ -7,7 +7,12 @@ import { parseBoolean } from '../../utils/parseBoolean.utils'; /** * Supported filter keys for creator list requests. */ -export const SUPPORTED_CREATOR_FILTERS = ['verified', 'search', 'minPrice', 'maxPrice'] as const; +export const SUPPORTED_CREATOR_FILTERS = [ + 'verified', + 'search', + 'minPrice', + 'maxPrice', +] as const; export type CreatorFilterKey = (typeof SUPPORTED_CREATOR_FILTERS)[number]; @@ -15,10 +20,10 @@ export type CreatorFilterKey = (typeof SUPPORTED_CREATOR_FILTERS)[number]; * Parsed creator filter input ready for use in list queries. */ export interface CreatorFilterInput { - verified?: boolean; - search?: string; - minPrice?: bigint; - maxPrice?: bigint; + verified?: boolean; + search?: string; + minPrice?: bigint; + maxPrice?: bigint; } /** @@ -47,45 +52,53 @@ export interface CreatorFilterInput { * // throws Error: Unsupported creator filter key(s): unknown */ export function parseCreatorFilters( - raw: Record + raw: Record ): CreatorFilterInput { - rejectUnknownKeys(SUPPORTED_CREATOR_FILTERS, raw, 'creator filter'); + rejectUnknownKeys(SUPPORTED_CREATOR_FILTERS, raw, 'creator filter'); - const result: CreatorFilterInput = {}; + const result: CreatorFilterInput = {}; - if (raw.verified !== undefined) { - const verified = parseBoolean( - 'verified', - raw.verified as string | string[] | boolean | null | undefined - ); + if (raw.verified !== undefined) { + const verified = parseBoolean( + 'verified', + raw.verified as string | string[] | boolean | null | undefined + ); - if (verified !== null) { - result.verified = verified; - } - } + if (verified !== null) { + result.verified = verified; + } + } - if (typeof raw.search === 'string') { - const normalized = raw.search.trim().replace(/\s+/g, ' '); - if (normalized.length > 0) { - result.search = normalized; - } - } + if (typeof raw.search === 'string') { + const normalized = raw.search.trim().replace(/\s+/g, ' '); + if (normalized.length > 0) { + result.search = normalized; + } + } - if (raw.minPrice !== undefined && raw.minPrice !== null && raw.minPrice !== '') { - const minPriceStr = String(raw.minPrice); - const minPriceNum = parseInt(minPriceStr, 10); - if (!isNaN(minPriceNum) && minPriceNum >= 0) { - result.minPrice = BigInt(minPriceNum); - } - } + if ( + raw.minPrice !== undefined && + raw.minPrice !== null && + raw.minPrice !== '' + ) { + const minPriceStr = String(raw.minPrice); + const minPriceNum = parseInt(minPriceStr, 10); + if (!isNaN(minPriceNum) && minPriceNum >= 0) { + result.minPrice = BigInt(minPriceNum); + } + } - if (raw.maxPrice !== undefined && raw.maxPrice !== null && raw.maxPrice !== '') { - const maxPriceStr = String(raw.maxPrice); - const maxPriceNum = parseInt(maxPriceStr, 10); - if (!isNaN(maxPriceNum) && maxPriceNum >= 0) { - result.maxPrice = BigInt(maxPriceNum); - } - } + if ( + raw.maxPrice !== undefined && + raw.maxPrice !== null && + raw.maxPrice !== '' + ) { + const maxPriceStr = String(raw.maxPrice); + const maxPriceNum = parseInt(maxPriceStr, 10); + if (!isNaN(maxPriceNum) && maxPriceNum >= 0) { + result.maxPrice = BigInt(maxPriceNum); + } + } - return result; + return result; } diff --git a/src/modules/creators/creators.handle.ts b/src/modules/creators/creators.handle.ts index b901c95..3187ea6 100644 --- a/src/modules/creators/creators.handle.ts +++ b/src/modules/creators/creators.handle.ts @@ -21,11 +21,11 @@ * normalizeCreatorHandle('Jazz__King') // 'jazz__king' → 'jazz_king' */ export function normalizeCreatorHandle(handle: string): string { - return handle - .trim() - .toLowerCase() - .replace(/\s+/g, '_') // internal spaces → underscore - .replace(/[^a-z0-9_-]/g, '') // strip unsupported characters - .replace(/-{2,}/g, '-') // collapse consecutive hyphens - .replace(/_{2,}/g, '_'); // collapse consecutive underscores + return handle + .trim() + .toLowerCase() + .replace(/\s+/g, '_') // internal spaces → underscore + .replace(/[^a-z0-9_-]/g, '') // strip unsupported characters + .replace(/-{2,}/g, '-') // collapse consecutive hyphens + .replace(/_{2,}/g, '_'); // collapse consecutive underscores } diff --git a/src/modules/creators/creators.limit.utils.ts b/src/modules/creators/creators.limit.utils.ts index bdb2421..53828e4 100644 --- a/src/modules/creators/creators.limit.utils.ts +++ b/src/modules/creators/creators.limit.utils.ts @@ -1,4 +1,8 @@ -import { CREATOR_FEED_DEFAULT_PAGE_SIZE, CREATOR_FEED_MAX_PAGE_SIZE, CREATOR_FEED_MIN_PAGE_SIZE } from '../../constants/creator-feed-pagination.constants'; +import { + CREATOR_FEED_DEFAULT_PAGE_SIZE, + CREATOR_FEED_MAX_PAGE_SIZE, + CREATOR_FEED_MIN_PAGE_SIZE, +} from '../../constants/creator-feed-pagination.constants'; /** * Resolve list limit for public creator list endpoints. @@ -7,9 +11,9 @@ import { CREATOR_FEED_DEFAULT_PAGE_SIZE, CREATOR_FEED_MAX_PAGE_SIZE, CREATOR_FEE * omitted, and clamps the value to the allowed [min, max] range. */ export function resolveCreatorListLimit(pageSize?: number): number { - if (pageSize === undefined) return CREATOR_FEED_DEFAULT_PAGE_SIZE; - return Math.min( - CREATOR_FEED_MAX_PAGE_SIZE, - Math.max(CREATOR_FEED_MIN_PAGE_SIZE, pageSize), - ); + if (pageSize === undefined) return CREATOR_FEED_DEFAULT_PAGE_SIZE; + return Math.min( + CREATOR_FEED_MAX_PAGE_SIZE, + Math.max(CREATOR_FEED_MIN_PAGE_SIZE, pageSize) + ); } diff --git a/src/modules/creators/creators.schemas.ts b/src/modules/creators/creators.schemas.ts index 215a144..054cde2 100644 --- a/src/modules/creators/creators.schemas.ts +++ b/src/modules/creators/creators.schemas.ts @@ -4,14 +4,14 @@ import { creatorListIncludeQueryParam } from './creators.include.parse'; import { withCreatorListQueryStringNormalization } from './creators.query-string.utils'; import { safeBooleanQueryParam, safeIntParam } from '../../utils/query.utils'; import { - MIN_PAGE_SIZE, - MAX_PAGE_SIZE, + MIN_PAGE_SIZE, + MAX_PAGE_SIZE, } from '../../constants/pagination.constants'; import { PUBLIC_OFFSET_PAGINATION_DEFAULTS } from '../../utils/public-list-query-defaults'; import { - CREATOR_LIST_SORT_FIELDS, - DEFAULT_CREATOR_LIST_SORT, + CREATOR_LIST_SORT_FIELDS, + DEFAULT_CREATOR_LIST_SORT, } from '../../constants/creator-list-sort.constants'; import { resolveCreatorListLimit } from './creators.limit.utils'; import { normalizeCreatorListSearchTerm } from './creators.search-term.utils'; @@ -26,78 +26,78 @@ import { normalizeCreatorListSearchTerm } from './creators.search-term.utils'; * GET /api/v1/creators?limit=20&offset=0&sort=createdAt&order=desc&verified=true */ export const CreatorListQuerySchema = z - .object({ - // Pagination - limit: safeIntParam({ - defaultValue: - resolveCreatorListLimit() ?? - PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, - min: MIN_PAGE_SIZE, - max: MAX_PAGE_SIZE, - label: 'Limit', - }), - - offset: safeIntParam({ - defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, - min: 0, - max: Number.MAX_SAFE_INTEGER, - label: 'Offset', - }), - - // Sorting - sort: withCreatorListQueryStringNormalization( - z - .enum(CREATOR_LIST_SORT_FIELDS) - .optional() - .default(DEFAULT_CREATOR_LIST_SORT) - ), - - order: creatorListSortDirectionQueryParam(), - - include: creatorListIncludeQueryParam(), - - // Filters - verified: safeBooleanQueryParam({ - paramName: 'verified', - }), - - search: withCreatorListQueryStringNormalization( - z - .string() - .optional() - .transform((val: string | undefined) => normalizeCreatorListSearchTerm(val)) - ), - - // Price range filters - minPrice: z - .string() - .regex(/^\d+$/, 'minPrice must be a positive integer') - .transform((val) => BigInt(val)) - .optional(), - - maxPrice: z - .string() - .regex(/^\d+$/, 'maxPrice must be a positive integer') - .transform((val) => BigInt(val)) - .optional(), - - cursor: withCreatorListQueryStringNormalization( - z.string().optional() - ), - }) - .strict() - .refine( - (data) => { - if (data.minPrice !== undefined && data.maxPrice !== undefined) { - return data.minPrice <= data.maxPrice; + .object({ + // Pagination + limit: safeIntParam({ + defaultValue: + resolveCreatorListLimit() ?? + PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, + min: MIN_PAGE_SIZE, + max: MAX_PAGE_SIZE, + label: 'Limit', + }), + + offset: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, + min: 0, + max: Number.MAX_SAFE_INTEGER, + label: 'Offset', + }), + + // Sorting + sort: withCreatorListQueryStringNormalization( + z + .enum(CREATOR_LIST_SORT_FIELDS) + .optional() + .default(DEFAULT_CREATOR_LIST_SORT) + ), + + order: creatorListSortDirectionQueryParam(), + + include: creatorListIncludeQueryParam(), + + // Filters + verified: safeBooleanQueryParam({ + paramName: 'verified', + }), + + search: withCreatorListQueryStringNormalization( + z + .string() + .optional() + .transform((val: string | undefined) => + normalizeCreatorListSearchTerm(val) + ) + ), + + // Price range filters + minPrice: z + .string() + .regex(/^\d+$/, 'minPrice must be a positive integer') + .transform(val => BigInt(val)) + .optional(), + + maxPrice: z + .string() + .regex(/^\d+$/, 'maxPrice must be a positive integer') + .transform(val => BigInt(val)) + .optional(), + + cursor: withCreatorListQueryStringNormalization(z.string().optional()), + }) + .strict() + .refine( + data => { + if (data.minPrice !== undefined && data.maxPrice !== undefined) { + return data.minPrice <= data.maxPrice; + } + return true; + }, + { + message: 'minPrice cannot be greater than maxPrice', + path: ['minPrice'], } - return true; - }, - { - message: 'minPrice cannot be greater than maxPrice', - path: ['minPrice'], - } - ); + ); // Export as LegacyCreatorQuerySchema for backward compatibility export const LegacyCreatorQuerySchema = CreatorListQuerySchema; @@ -108,21 +108,25 @@ export type CreatorListQueryType = z.infer; * Validation schema for individual creator perks. */ export const CreatorPerkSchema = z.object({ - id: z.string().cuid().optional().or(z.string().uuid()), - title: z.string().min(1, 'Title is required').max(100), - description: z.string().min(1, 'Description is required').max(500), - icon: z.string().optional(), + id: z.string().cuid().optional().or(z.string().uuid()), + title: z.string().min(1, 'Title is required').max(100), + description: z.string().min(1, 'Description is required').max(500), + icon: z.string().optional(), }); /** * Validation schema for updating a creator profile. */ -export const UpdateCreatorProfileSchema = z.object({ - displayName: z.string().min(1).max(100).optional(), - bio: z.string().max(1000).optional(), - avatarUrl: z.string().url().optional().or(z.literal('')), - perkSummary: z.string().max(200).optional(), - perks: z.array(CreatorPerkSchema).optional(), -}).strict(); - -export type UpdateCreatorProfileType = z.infer; +export const UpdateCreatorProfileSchema = z + .object({ + displayName: z.string().min(1).max(100).optional(), + bio: z.string().max(1000).optional(), + avatarUrl: z.string().url().optional().or(z.literal('')), + perkSummary: z.string().max(200).optional(), + perks: z.array(CreatorPerkSchema).optional(), + }) + .strict(); + +export type UpdateCreatorProfileType = z.infer< + typeof UpdateCreatorProfileSchema +>; diff --git a/src/modules/creators/creators.search-term.utils.ts b/src/modules/creators/creators.search-term.utils.ts index 0c0f34f..6b9ffbb 100644 --- a/src/modules/creators/creators.search-term.utils.ts +++ b/src/modules/creators/creators.search-term.utils.ts @@ -17,4 +17,3 @@ export function normalizeCreatorListSearchTerm( const normalized = term.trim().replace(/\s+/g, ' '); return normalized === '' ? undefined : normalized; } - diff --git a/src/modules/creators/creators.slow-query-plan.test.ts b/src/modules/creators/creators.slow-query-plan.test.ts index 073229a..ba30e94 100644 --- a/src/modules/creators/creators.slow-query-plan.test.ts +++ b/src/modules/creators/creators.slow-query-plan.test.ts @@ -69,7 +69,8 @@ describe('fetchCreatorList — slow query plan logging', () => { afterEach(() => { (envConfig as any).MODE = originalMode; - (envConfig as any).CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS = originalThreshold; + (envConfig as any).CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS = + originalThreshold; jest.restoreAllMocks(); }); @@ -89,7 +90,9 @@ describe('fetchCreatorList — slow query plan logging', () => { }); it('includes queryPlan in the warn log when the plan is captured', async () => { - const fakePlan = [{ 'Node Type': 'Seq Scan', 'Relation Name': 'CreatorProfile' }]; + const fakePlan = [ + { 'Node Type': 'Seq Scan', 'Relation Name': 'CreatorProfile' }, + ]; captureQueryPlanSpy.mockResolvedValue(fakePlan); await fetchCreatorList(makeQuery()); @@ -115,7 +118,9 @@ describe('fetchCreatorList — slow query plan logging', () => { captureQueryPlanSpy.mockRejectedValue(new Error('db error')); // fetchCreatorList should not throw even if plan capture rejects - await expect(fetchCreatorList(makeQuery())).rejects.toThrow('db error'); + await expect(fetchCreatorList(makeQuery())).rejects.toThrow( + 'db error' + ); // The warn is emitted before the await resolves in this edge case, // but the real guard is inside captureQueryPlan itself (it never throws). // This test documents that captureQueryPlan is expected to swallow errors. diff --git a/src/modules/creators/creators.sort-field.utils.test.ts b/src/modules/creators/creators.sort-field.utils.test.ts index d00dbcb..5954c26 100644 --- a/src/modules/creators/creators.sort-field.utils.test.ts +++ b/src/modules/creators/creators.sort-field.utils.test.ts @@ -32,9 +32,9 @@ describe('getRawCreatorListSortParam()', () => { }); it('reads the first value when sort is an array', () => { - expect(getRawCreatorListSortParam({ sort: ['followers', 'createdAt'] })).toBe( - 'followers' - ); + expect( + getRawCreatorListSortParam({ sort: ['followers', 'createdAt'] }) + ).toBe('followers'); }); }); diff --git a/src/modules/creators/creators.stats.ts b/src/modules/creators/creators.stats.ts index e5292b5..97eaa79 100644 --- a/src/modules/creators/creators.stats.ts +++ b/src/modules/creators/creators.stats.ts @@ -8,10 +8,10 @@ import { CreatorMetrics } from '../../types/profile.types'; * Single source of truth for what gets included in public stats responses. */ export const CREATOR_STATS_FIELDS = [ - 'holderCount', - 'totalSupply', - 'totalVolume', - 'lastActivityAt', + 'holderCount', + 'totalSupply', + 'totalVolume', + 'lastActivityAt', ] as const; export type CreatorStatsField = (typeof CREATOR_STATS_FIELDS)[number]; @@ -23,10 +23,10 @@ export type CreatorStatsField = (typeof CREATOR_STATS_FIELDS)[number]; * Avoids leaking internal or sensitive metric fields. */ export interface PublicCreatorStats { - holderCount: number; - totalSupply: number; - totalVolume: number; - lastActivityAt?: Date; + holderCount: number; + totalSupply: number; + totalVolume: number; + lastActivityAt?: Date; } /** @@ -38,10 +38,10 @@ export interface PublicCreatorStats { * while enforcing that all public fields map to valid CreatorMetrics keys. */ const CREATOR_STATS_FIELD_MAP = { - holderCount: 'holderCount', - totalSupply: 'totalSupply', - totalVolume: 'totalVolume', - lastActivityAt: 'lastActivityAt', + holderCount: 'holderCount', + totalSupply: 'totalSupply', + totalVolume: 'totalVolume', + lastActivityAt: 'lastActivityAt', } as const satisfies Record; /** @@ -58,17 +58,16 @@ const CREATOR_STATS_FIELD_MAP = { * // => { holderCount: 10, totalSupply: 100, totalVolume: 500 } */ export function mapPublicCreatorStats( - metrics: CreatorMetrics + metrics: CreatorMetrics ): PublicCreatorStats { - return { - holderCount: metrics[CREATOR_STATS_FIELD_MAP.holderCount], - totalSupply: metrics[CREATOR_STATS_FIELD_MAP.totalSupply], - totalVolume: metrics[CREATOR_STATS_FIELD_MAP.totalVolume], - ...(metrics[CREATOR_STATS_FIELD_MAP.lastActivityAt] !== undefined - ? { - lastActivityAt: - metrics[CREATOR_STATS_FIELD_MAP.lastActivityAt], - } - : {}), - }; + return { + holderCount: metrics[CREATOR_STATS_FIELD_MAP.holderCount], + totalSupply: metrics[CREATOR_STATS_FIELD_MAP.totalSupply], + totalVolume: metrics[CREATOR_STATS_FIELD_MAP.totalVolume], + ...(metrics[CREATOR_STATS_FIELD_MAP.lastActivityAt] !== undefined + ? { + lastActivityAt: metrics[CREATOR_STATS_FIELD_MAP.lastActivityAt], + } + : {}), + }; } diff --git a/src/modules/creators/creators.utils.ts b/src/modules/creators/creators.utils.ts index ad40d1b..29b5d0e 100644 --- a/src/modules/creators/creators.utils.ts +++ b/src/modules/creators/creators.utils.ts @@ -9,7 +9,10 @@ import { import { buildOffsetPaginationMeta } from '../../utils/pagination.utils'; import { logger } from '../../utils/logger.utils'; import { envConfig } from '../../config'; -import { buildCreatorFeedWhere, CreatorFeedWhere } from './creator-feed-filter-combinator.utils'; +import { + buildCreatorFeedWhere, + CreatorFeedWhere, +} from './creator-feed-filter-combinator.utils'; import { CREATOR_LIST_DEFAULT_SELECT } from '../../constants/creator-list-projection.constants'; import { getCachedCreatorList, setCachedCreatorList } from './creators.cache'; import { captureQueryPlan } from '../../utils/query-plan.utils'; @@ -28,9 +31,15 @@ export async function fetchCreatorList( return [cached.creators, cached.total]; } - const { limit, offset, sort, order, verified, search, minPrice, maxPrice } = query; + const { limit, offset, sort, order, verified, search, minPrice, maxPrice } = + query; - const where = buildCreatorFeedWhere({ verified, search, minPrice, maxPrice }); + const where = buildCreatorFeedWhere({ + verified, + search, + minPrice, + maxPrice, + }); const orderBy = mapCreatorListSort(sort, order); // Fetch creators and total count in parallel @@ -162,7 +171,9 @@ export function buildCreatorFeedExplainSql(where: CreatorFeedWhere): string { * @param where - The Prisma where clause produced by `buildCreatorFeedWhere`. * @returns An array of values in the same order as the SQL placeholders. */ -export function buildCreatorFeedExplainParams(where: CreatorFeedWhere): unknown[] { +export function buildCreatorFeedExplainParams( + where: CreatorFeedWhere +): unknown[] { const params: unknown[] = []; if (where.isVerified !== undefined) { diff --git a/src/modules/health/health.controllers.integration.test.ts b/src/modules/health/health.controllers.integration.test.ts index baeea69..96cdeaf 100644 --- a/src/modules/health/health.controllers.integration.test.ts +++ b/src/modules/health/health.controllers.integration.test.ts @@ -2,25 +2,25 @@ // All external dependencies (Prisma, config) are mocked so no real DB is needed. jest.mock('../../config', () => ({ - envConfig: { - MODE: 'production', - PORT: 3000, - INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: 300000, - DB_QUERY_TIMEOUT_MS: 5000, - }, - appConfig: { - allowedOrigins: [], - }, + envConfig: { + MODE: 'production', + PORT: 3000, + INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: 300000, + DB_QUERY_TIMEOUT_MS: 5000, + }, + appConfig: { + allowedOrigins: [], + }, })); jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - $queryRaw: jest.fn(), - }, + prisma: { + $queryRaw: jest.fn(), + }, })); jest.mock('../../utils/indexer-cursor-staleness.utils', () => ({ - checkIndexerCursorStalenessFromStore: jest.fn().mockResolvedValue(undefined), + checkIndexerCursorStalenessFromStore: jest.fn().mockResolvedValue(undefined), })); import { Request, Response } from 'express'; @@ -34,21 +34,21 @@ const queryRawMock = prisma.$queryRaw as unknown as jest.Mock; // --------------------------------------------------------------------------- function mockResponse(): Response & { statusCode: number; body: any } { - const res = { statusCode: 0, body: undefined as any } as any; - res.status = (code: number) => { - res.statusCode = code; - return res; - }; - res.json = (payload: any) => { - res.body = payload; - return res; - }; - res.setHeader = () => res; - return res; + const res = { statusCode: 0, body: undefined as any } as any; + res.status = (code: number) => { + res.statusCode = code; + return res; + }; + res.json = (payload: any) => { + res.body = payload; + return res; + }; + res.setHeader = () => res; + return res; } function mockRequest(): Request { - return {} as Request; + return {} as Request; } // --------------------------------------------------------------------------- @@ -56,73 +56,73 @@ function mockRequest(): Request { // --------------------------------------------------------------------------- describe('readinessCheck() — simulated database failure', () => { - beforeEach(() => { - queryRawMock.mockReset(); - }); + beforeEach(() => { + queryRawMock.mockReset(); + }); - it('returns 503 when the database is unreachable', async () => { - queryRawMock.mockRejectedValue(new Error('connection refused')); + it('returns 503 when the database is unreachable', async () => { + queryRawMock.mockRejectedValue(new Error('connection refused')); - const res = mockResponse(); - await readinessCheck(mockRequest(), res); + const res = mockResponse(); + await readinessCheck(mockRequest(), res); - expect(res.statusCode).toBe(503); - }); + expect(res.statusCode).toBe(503); + }); - it('sets ready:false when a dependency check fails', async () => { - queryRawMock.mockRejectedValue(new Error('timeout')); + it('sets ready:false when a dependency check fails', async () => { + queryRawMock.mockRejectedValue(new Error('timeout')); - const res = mockResponse(); - await readinessCheck(mockRequest(), res); + const res = mockResponse(); + await readinessCheck(mockRequest(), res); - expect(res.body.ready).toBe(false); - }); + expect(res.body.ready).toBe(false); + }); - it('response body conforms to the readiness schema even on failure', async () => { - queryRawMock.mockRejectedValue(new Error('ECONNREFUSED')); + it('response body conforms to the readiness schema even on failure', async () => { + queryRawMock.mockRejectedValue(new Error('ECONNREFUSED')); - const res = mockResponse(); - await readinessCheck(mockRequest(), res); + const res = mockResponse(); + await readinessCheck(mockRequest(), res); - expect(res.body).toHaveProperty('ready', false); - expect(res.body).toHaveProperty('timestamp'); - expect(typeof res.body.timestamp).toBe('string'); - expect(res.body).toHaveProperty('latencyMs'); - expect(typeof res.body.latencyMs).toBe('number'); - expect(Array.isArray(res.body.checks)).toBe(true); - }); + expect(res.body).toHaveProperty('ready', false); + expect(res.body).toHaveProperty('timestamp'); + expect(typeof res.body.timestamp).toBe('string'); + expect(res.body).toHaveProperty('latencyMs'); + expect(typeof res.body.latencyMs).toBe('number'); + expect(Array.isArray(res.body.checks)).toBe(true); + }); - it('reports the database check as failed in the checks array', async () => { - queryRawMock.mockRejectedValue(new Error('connection refused')); + it('reports the database check as failed in the checks array', async () => { + queryRawMock.mockRejectedValue(new Error('connection refused')); - const res = mockResponse(); - await readinessCheck(mockRequest(), res); + const res = mockResponse(); + await readinessCheck(mockRequest(), res); - const dbCheck = res.body.checks.find((c: any) => c.name === 'database'); - expect(dbCheck).toBeDefined(); - expect(dbCheck.status).toBe('fail'); - expect(typeof dbCheck.error).toBe('string'); - }); + const dbCheck = res.body.checks.find((c: any) => c.name === 'database'); + expect(dbCheck).toBeDefined(); + expect(dbCheck.status).toBe('fail'); + expect(typeof dbCheck.error).toBe('string'); + }); - it('still passes the cache check when only the database fails', async () => { - queryRawMock.mockRejectedValue(new Error('db down')); + it('still passes the cache check when only the database fails', async () => { + queryRawMock.mockRejectedValue(new Error('db down')); - const res = mockResponse(); - await readinessCheck(mockRequest(), res); + const res = mockResponse(); + await readinessCheck(mockRequest(), res); - const cacheCheck = res.body.checks.find((c: any) => c.name === 'cache'); - expect(cacheCheck).toBeDefined(); - expect(cacheCheck.status).toBe('ok'); - }); + const cacheCheck = res.body.checks.find((c: any) => c.name === 'cache'); + expect(cacheCheck).toBeDefined(); + expect(cacheCheck.status).toBe('ok'); + }); - it('includes a non-zero latencyMs even when the database check fails', async () => { - queryRawMock.mockRejectedValue(new Error('db down')); + it('includes a non-zero latencyMs even when the database check fails', async () => { + queryRawMock.mockRejectedValue(new Error('db down')); - const res = mockResponse(); - await readinessCheck(mockRequest(), res); + const res = mockResponse(); + await readinessCheck(mockRequest(), res); - expect(res.body.latencyMs).toBeGreaterThanOrEqual(0); - }); + expect(res.body.latencyMs).toBeGreaterThanOrEqual(0); + }); }); // --------------------------------------------------------------------------- @@ -130,55 +130,57 @@ describe('readinessCheck() — simulated database failure', () => { // --------------------------------------------------------------------------- describe('healthCheck() — simulated database failure in production mode', () => { - beforeEach(() => { - queryRawMock.mockReset(); - }); - - it('returns 503 when the database is disconnected in production', async () => { - queryRawMock.mockRejectedValue(new Error('connection refused')); - - const res = mockResponse(); - await healthCheck(mockRequest(), res); - - expect(res.statusCode).toBe(503); - }); - - it('response body conforms to the health schema even when DB is down', async () => { - queryRawMock.mockRejectedValue(new Error('ECONNREFUSED')); - - const res = mockResponse(); - await healthCheck(mockRequest(), res); - - expect(res.body).toHaveProperty('success'); - expect(res.body).toHaveProperty('message'); - expect(res.body).toHaveProperty('timestamp'); - expect(res.body).toHaveProperty('database'); - expect(res.body.database.status).toBe('disconnected'); - }); - - it('marks the Database service as unhealthy in the services array', async () => { - queryRawMock.mockRejectedValue(new Error('db down')); - - const res = mockResponse(); - await healthCheck(mockRequest(), res); - - const dbService = res.body.services?.find((s: any) => s.name === 'Database'); - expect(dbService).toBeDefined(); - expect(dbService.status).toBe('unhealthy'); - }); - - it('includes public-safe timeout metadata in detailed health output', async () => { - queryRawMock.mockResolvedValue([{ '?column?': 1 }]); - - const res = mockResponse(); - await healthCheck(mockRequest(), res); - - expect(res.body).toHaveProperty('timeouts'); - expect(res.body.timeouts).toEqual({ - database_timeout_ms: 5000, - cache_timeout_ms: 300000, - }); - expect(typeof res.body.timeouts.database_timeout_ms).toBe('number'); - expect(typeof res.body.timeouts.cache_timeout_ms).toBe('number'); - }); + beforeEach(() => { + queryRawMock.mockReset(); + }); + + it('returns 503 when the database is disconnected in production', async () => { + queryRawMock.mockRejectedValue(new Error('connection refused')); + + const res = mockResponse(); + await healthCheck(mockRequest(), res); + + expect(res.statusCode).toBe(503); + }); + + it('response body conforms to the health schema even when DB is down', async () => { + queryRawMock.mockRejectedValue(new Error('ECONNREFUSED')); + + const res = mockResponse(); + await healthCheck(mockRequest(), res); + + expect(res.body).toHaveProperty('success'); + expect(res.body).toHaveProperty('message'); + expect(res.body).toHaveProperty('timestamp'); + expect(res.body).toHaveProperty('database'); + expect(res.body.database.status).toBe('disconnected'); + }); + + it('marks the Database service as unhealthy in the services array', async () => { + queryRawMock.mockRejectedValue(new Error('db down')); + + const res = mockResponse(); + await healthCheck(mockRequest(), res); + + const dbService = res.body.services?.find( + (s: any) => s.name === 'Database' + ); + expect(dbService).toBeDefined(); + expect(dbService.status).toBe('unhealthy'); + }); + + it('includes public-safe timeout metadata in detailed health output', async () => { + queryRawMock.mockResolvedValue([{ '?column?': 1 }]); + + const res = mockResponse(); + await healthCheck(mockRequest(), res); + + expect(res.body).toHaveProperty('timeouts'); + expect(res.body.timeouts).toEqual({ + database_timeout_ms: 5000, + cache_timeout_ms: 300000, + }); + expect(typeof res.body.timeouts.database_timeout_ms).toBe('number'); + expect(typeof res.body.timeouts.cache_timeout_ms).toBe('number'); + }); }); diff --git a/src/modules/health/health.controllers.test.ts b/src/modules/health/health.controllers.test.ts index 94b1214..e5f901c 100644 --- a/src/modules/health/health.controllers.test.ts +++ b/src/modules/health/health.controllers.test.ts @@ -170,7 +170,9 @@ describe('Indexer Heartbeat Controllers', () => { await recordIndexerHeartbeat(req, res); - expect(checkCursorStalenessMock).toHaveBeenCalledWith({ job: 'indexer' }); + expect(checkCursorStalenessMock).toHaveBeenCalledWith({ + job: 'indexer', + }); }); }); }); @@ -210,7 +212,9 @@ describe('Readiness Controller', () => { await readinessCheck({} as Request, res); - const dbCheck = res.body.checks.find((c: any) => c.name === 'database'); + const dbCheck = res.body.checks.find( + (c: any) => c.name === 'database' + ); expect(dbCheck.status).toBe('ok'); expect(typeof dbCheck.latencyMs).toBe('number'); expect(typeof res.body.latencyMs).toBe('number'); diff --git a/src/modules/health/health.controllers.ts b/src/modules/health/health.controllers.ts index f1de289..387ba59 100644 --- a/src/modules/health/health.controllers.ts +++ b/src/modules/health/health.controllers.ts @@ -9,166 +9,169 @@ import { PUBLIC_ENDPOINT_CACHE_SECONDS } from '../../constants/public-endpoint-c const SYNC_LAG_DEGRADATION_THRESHOLD = 100; interface ChainSyncStatus { - status: 'degraded' | 'in-sync'; - latestIndexedLedger: number; - observedHeadLedger: number; - syncLagLedgers: number; + status: 'degraded' | 'in-sync'; + latestIndexedLedger: number; + observedHeadLedger: number; + syncLagLedgers: number; } async function getChainSyncStatus(): Promise { - try { - const latestIndexedLedger = 12345; - const observedHeadLedger = 12400; - const syncLagLedgers = observedHeadLedger - latestIndexedLedger; - const isDegraded = syncLagLedgers > SYNC_LAG_DEGRADATION_THRESHOLD; - - return { - status: isDegraded ? 'degraded' : 'in-sync', - latestIndexedLedger, - observedHeadLedger, - syncLagLedgers, - }; - } catch (_error) { - return null; - } + try { + const latestIndexedLedger = 12345; + const observedHeadLedger = 12400; + const syncLagLedgers = observedHeadLedger - latestIndexedLedger; + const isDegraded = syncLagLedgers > SYNC_LAG_DEGRADATION_THRESHOLD; + + return { + status: isDegraded ? 'degraded' : 'in-sync', + latestIndexedLedger, + observedHeadLedger, + syncLagLedgers, + }; + } catch (_error) { + return null; + } } type CheckStatus = 'ok' | 'fail'; interface ReadinessCheck { - name: string; - status: CheckStatus; - latencyMs?: number; - error?: string; + name: string; + status: CheckStatus; + latencyMs?: number; + error?: string; } interface HealthTimeouts { - database_timeout_ms: number; - cache_timeout_ms: number; + database_timeout_ms: number; + cache_timeout_ms: number; } interface HealthStatus { - success: boolean; - message: string; - timestamp: string; - version: string; - environment: string; - uptime: number; - memory: { - used: number; - total: number; - }; - system: { - platform: string; - nodeVersion: string; - }; - timeouts: HealthTimeouts; - database?: { - status: 'connected' | 'disconnected'; - responseTime?: number; - }; - syncing?: { - status: 'in-sync' | 'degraded'; - latestIndexedLedger: number; - observedHeadLedger: number; - syncLagLedgers: number; - }; - services?: { - name: string; - status: 'healthy' | 'unhealthy'; - }[]; + success: boolean; + message: string; + timestamp: string; + version: string; + environment: string; + uptime: number; + memory: { + used: number; + total: number; + }; + system: { + platform: string; + nodeVersion: string; + }; + timeouts: HealthTimeouts; + database?: { + status: 'connected' | 'disconnected'; + responseTime?: number; + }; + syncing?: { + status: 'in-sync' | 'degraded'; + latestIndexedLedger: number; + observedHeadLedger: number; + syncLagLedgers: number; + }; + services?: { + name: string; + status: 'healthy' | 'unhealthy'; + }[]; } export const healthCheck = async (_: Request, res: Response): Promise => { - const startTime = Date.now(); - - try { - // Check database connectivity - let dbStatus: HealthStatus['database'] = { - status: 'disconnected', - }; - - try { - await prisma.$queryRaw`SELECT 1`; - const dbResponseTime = Date.now() - startTime; - dbStatus = { - status: 'connected', - responseTime: dbResponseTime, - }; - } catch (dbError) { - console.error('Database health check failed:', dbError); - dbStatus = { - status: 'disconnected', + const startTime = Date.now(); + + try { + // Check database connectivity + let dbStatus: HealthStatus['database'] = { + status: 'disconnected', }; - } - const syncStatus = await getChainSyncStatus(); + try { + await prisma.$queryRaw`SELECT 1`; + const dbResponseTime = Date.now() - startTime; + dbStatus = { + status: 'connected', + responseTime: dbResponseTime, + }; + } catch (dbError) { + console.error('Database health check failed:', dbError); + dbStatus = { + status: 'disconnected', + }; + } + + const syncStatus = await getChainSyncStatus(); + + const healthTimeouts: HealthTimeouts = { + database_timeout_ms: envConfig.DB_QUERY_TIMEOUT_MS, + cache_timeout_ms: PUBLIC_ENDPOINT_CACHE_SECONDS.short * 1000, + }; - const healthTimeouts: HealthTimeouts = { - database_timeout_ms: envConfig.DB_QUERY_TIMEOUT_MS, - cache_timeout_ms: PUBLIC_ENDPOINT_CACHE_SECONDS.short * 1000, - }; + const healthData: HealthStatus = { + success: true, + message: 'Access Layer server is running', + timestamp: new Date().toISOString(), + version: '1.0.0', + environment: envConfig.MODE || 'development', + uptime: process.uptime(), + memory: { + used: + Math.round( + (process.memoryUsage().heapUsed / 1024 / 1024) * 100 + ) / 100, + total: + Math.round( + (process.memoryUsage().heapTotal / 1024 / 1024) * 100 + ) / 100, + }, + system: { + platform: process.platform, + nodeVersion: process.version, + }, + timeouts: healthTimeouts, + database: dbStatus, + syncing: syncStatus || undefined, + services: [ + { + name: 'API Server', + status: 'healthy', + }, + { + name: 'Database', + status: + dbStatus.status === 'connected' ? 'healthy' : 'unhealthy', + }, + { + name: 'Chain Sync', + status: + syncStatus?.status === 'degraded' ? 'unhealthy' : 'healthy', + }, + ], + }; - const healthData: HealthStatus = { - success: true, - message: 'Access Layer server is running', - timestamp: new Date().toISOString(), - version: '1.0.0', - environment: envConfig.MODE || 'development', - uptime: process.uptime(), - memory: { - used: - Math.round((process.memoryUsage().heapUsed / 1024 / 1024) * 100) / - 100, - total: - Math.round((process.memoryUsage().heapTotal / 1024 / 1024) * 100) / - 100, - }, - system: { - platform: process.platform, - nodeVersion: process.version, - }, - timeouts: healthTimeouts, - database: dbStatus, - syncing: syncStatus || undefined, - services: [ - { - name: 'API Server', - status: 'healthy', - }, - { - name: 'Database', - status: dbStatus.status === 'connected' ? 'healthy' : 'unhealthy', - }, - { - name: 'Chain Sync', - status: syncStatus?.status === 'degraded' ? 'unhealthy' : 'healthy', - }, - ], - }; - - // Return 503 if database is disconnected in production - const overallHealthy = - dbStatus.status === 'connected' || - envConfig.MODE !== 'production'; - - res.status(overallHealthy ? 200 : 503).json(healthData); - } catch (error) { - console.error('Health check failed:', error); - res.status(500).json({ - success: false, - message: 'Health check failed', - error: error instanceof Error ? error.message : 'Unknown error', - }); - } + // Return 503 if database is disconnected in production + const overallHealthy = + dbStatus.status === 'connected' || envConfig.MODE !== 'production'; + + res.status(overallHealthy ? 200 : 503).json(healthData); + } catch (error) { + console.error('Health check failed:', error); + res.status(500).json({ + success: false, + message: 'Health check failed', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } }; export const simpleHealthCheck = (_: Request, res: Response): void => { - res.status(200).json({ - success: true, - message: 'OK', - timestamp: new Date().toISOString(), - }); + res.status(200).json({ + success: true, + message: 'OK', + timestamp: new Date().toISOString(), + }); }; /** @@ -177,9 +180,9 @@ export const simpleHealthCheck = (_: Request, res: Response): void => { * Responds with 503 when the heartbeat is stale (degraded). */ export const indexerHeartbeatCheck = (_: Request, res: Response): void => { - const state = indexerHeartbeat.getStatus(); - const statusCode = state.status === 'degraded' ? 503 : 200; - sendSuccess(res, state, statusCode); + const state = indexerHeartbeat.getStatus(); + const statusCode = state.status === 'degraded' ? 503 : 200; + sendSuccess(res, state, statusCode); }; /** @@ -187,54 +190,62 @@ export const indexerHeartbeatCheck = (_: Request, res: Response): void => { * Called by the indexer worker to record a successful run. */ export const recordIndexerHeartbeat = async ( - _: Request, - res: Response + _: Request, + res: Response ): Promise => { - const timestamp = indexerHeartbeat.recordHeartbeat(); - await checkIndexerCursorStalenessFromStore({ job: 'indexer' }); - sendSuccess( - res, - { recorded: true, timestamp: timestamp.toISOString() }, - 200, - 'Heartbeat recorded' - ); + const timestamp = indexerHeartbeat.recordHeartbeat(); + await checkIndexerCursorStalenessFromStore({ job: 'indexer' }); + sendSuccess( + res, + { recorded: true, timestamp: timestamp.toISOString() }, + 200, + 'Heartbeat recorded' + ); }; -export const readinessCheck = async (_: Request, res: Response): Promise => { - const checks: ReadinessCheck[] = []; - const overallStart = Date.now(); - - // DB check - const dbStart = Date.now(); - try { - await prisma.$queryRaw`SELECT 1`; - checks.push({ name: 'database', status: 'ok', latencyMs: Date.now() - dbStart }); - } catch (err) { - checks.push({ - name: 'database', - status: 'fail', - error: err instanceof Error ? err.message : 'Unknown error', - }); - } - - // Cache config check — verifies the HTTP cache layer is configured - try { - const configured = typeof PUBLIC_ENDPOINT_CACHE_SECONDS.short === 'number'; - if (!configured) throw new Error('Cache config unavailable'); - checks.push({ name: 'cache', status: 'ok' }); - } catch (err) { - checks.push({ - name: 'cache', - status: 'fail', - error: err instanceof Error ? err.message : 'Unknown error', - }); - } - - const ready = checks.every(c => c.status === 'ok'); - res.status(ready ? 200 : 503).json({ - ready, - timestamp: new Date().toISOString(), - latencyMs: Date.now() - overallStart, - checks, - }); +export const readinessCheck = async ( + _: Request, + res: Response +): Promise => { + const checks: ReadinessCheck[] = []; + const overallStart = Date.now(); + + // DB check + const dbStart = Date.now(); + try { + await prisma.$queryRaw`SELECT 1`; + checks.push({ + name: 'database', + status: 'ok', + latencyMs: Date.now() - dbStart, + }); + } catch (err) { + checks.push({ + name: 'database', + status: 'fail', + error: err instanceof Error ? err.message : 'Unknown error', + }); + } + + // Cache config check — verifies the HTTP cache layer is configured + try { + const configured = + typeof PUBLIC_ENDPOINT_CACHE_SECONDS.short === 'number'; + if (!configured) throw new Error('Cache config unavailable'); + checks.push({ name: 'cache', status: 'ok' }); + } catch (err) { + checks.push({ + name: 'cache', + status: 'fail', + error: err instanceof Error ? err.message : 'Unknown error', + }); + } + + const ready = checks.every(c => c.status === 'ok'); + res.status(ready ? 200 : 503).json({ + ready, + timestamp: new Date().toISOString(), + latencyMs: Date.now() - overallStart, + checks, + }); }; diff --git a/src/modules/health/health.response-schema.test.ts b/src/modules/health/health.response-schema.test.ts index a5e927a..8cf9bad 100644 --- a/src/modules/health/health.response-schema.test.ts +++ b/src/modules/health/health.response-schema.test.ts @@ -5,34 +5,34 @@ // These tests act as a living contract between the code and the documentation. jest.mock('../../config', () => ({ - envConfig: { - MODE: 'test', - PORT: 3000, - INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: 300000, - DB_QUERY_TIMEOUT_MS: 5000, - }, - appConfig: { - allowedOrigins: [], - }, + envConfig: { + MODE: 'test', + PORT: 3000, + INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: 300000, + DB_QUERY_TIMEOUT_MS: 5000, + }, + appConfig: { + allowedOrigins: [], + }, })); jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - $queryRaw: jest.fn(), - }, + prisma: { + $queryRaw: jest.fn(), + }, })); jest.mock('../../utils/indexer-cursor-staleness.utils', () => ({ - checkIndexerCursorStalenessFromStore: jest.fn().mockResolvedValue(undefined), + checkIndexerCursorStalenessFromStore: jest.fn().mockResolvedValue(undefined), })); import { Request, Response } from 'express'; import { - healthCheck, - indexerHeartbeatCheck, - readinessCheck, - recordIndexerHeartbeat, - simpleHealthCheck, + healthCheck, + indexerHeartbeatCheck, + readinessCheck, + recordIndexerHeartbeat, + simpleHealthCheck, } from './health.controllers'; import { indexerHeartbeat } from '../../utils/heartbeat.service'; import { prisma } from '../../utils/prisma.utils'; @@ -44,21 +44,21 @@ const queryRawMock = prisma.$queryRaw as unknown as jest.Mock; // --------------------------------------------------------------------------- function mockResponse(): Response & { statusCode: number; body: any } { - const res = { statusCode: 0, body: undefined as any } as any; - res.status = (code: number) => { - res.statusCode = code; - return res; - }; - res.json = (payload: any) => { - res.body = payload; - return res; - }; - res.setHeader = () => res; - return res; + const res = { statusCode: 0, body: undefined as any } as any; + res.status = (code: number) => { + res.statusCode = code; + return res; + }; + res.json = (payload: any) => { + res.body = payload; + return res; + }; + res.setHeader = () => res; + return res; } function mockRequest(): Request { - return {} as Request; + return {} as Request; } // --------------------------------------------------------------------------- @@ -66,36 +66,40 @@ function mockRequest(): Request { // --------------------------------------------------------------------------- describe('GET /health — liveness response schema', () => { - it('always returns HTTP 200', () => { - const res = mockResponse(); - simpleHealthCheck(mockRequest(), res); - expect(res.statusCode).toBe(200); - }); - - it('has success field always set to true', () => { - const res = mockResponse(); - simpleHealthCheck(mockRequest(), res); - expect(res.body.success).toBe(true); - }); - - it('has message field set to "OK"', () => { - const res = mockResponse(); - simpleHealthCheck(mockRequest(), res); - expect(res.body.message).toBe('OK'); - }); - - it('has timestamp field as a valid ISO-8601 string', () => { - const res = mockResponse(); - simpleHealthCheck(mockRequest(), res); - expect(typeof res.body.timestamp).toBe('string'); - expect(new Date(res.body.timestamp).toISOString()).toBe(res.body.timestamp); - }); - - it('response contains exactly the documented fields', () => { - const res = mockResponse(); - simpleHealthCheck(mockRequest(), res); - expect(Object.keys(res.body).sort()).toEqual(['message', 'success', 'timestamp'].sort()); - }); + it('always returns HTTP 200', () => { + const res = mockResponse(); + simpleHealthCheck(mockRequest(), res); + expect(res.statusCode).toBe(200); + }); + + it('has success field always set to true', () => { + const res = mockResponse(); + simpleHealthCheck(mockRequest(), res); + expect(res.body.success).toBe(true); + }); + + it('has message field set to "OK"', () => { + const res = mockResponse(); + simpleHealthCheck(mockRequest(), res); + expect(res.body.message).toBe('OK'); + }); + + it('has timestamp field as a valid ISO-8601 string', () => { + const res = mockResponse(); + simpleHealthCheck(mockRequest(), res); + expect(typeof res.body.timestamp).toBe('string'); + expect(new Date(res.body.timestamp).toISOString()).toBe( + res.body.timestamp + ); + }); + + it('response contains exactly the documented fields', () => { + const res = mockResponse(); + simpleHealthCheck(mockRequest(), res); + expect(Object.keys(res.body).sort()).toEqual( + ['message', 'success', 'timestamp'].sort() + ); + }); }); // --------------------------------------------------------------------------- @@ -103,347 +107,364 @@ describe('GET /health — liveness response schema', () => { // --------------------------------------------------------------------------- describe('GET /health/ready — readiness response schema (all checks pass)', () => { - beforeEach(() => { - queryRawMock.mockResolvedValue([{ '?column?': 1 }]); - }); - - afterEach(() => { - queryRawMock.mockReset(); - }); - - it('returns HTTP 200 when all checks pass', async () => { - const res = mockResponse(); - await readinessCheck(mockRequest(), res); - expect(res.statusCode).toBe(200); - }); - - it('has ready field set to true', async () => { - const res = mockResponse(); - await readinessCheck(mockRequest(), res); - expect(res.body.ready).toBe(true); - }); - - it('has timestamp as a valid ISO-8601 string', async () => { - const res = mockResponse(); - await readinessCheck(mockRequest(), res); - expect(typeof res.body.timestamp).toBe('string'); - expect(new Date(res.body.timestamp).toISOString()).toBe(res.body.timestamp); - }); - - it('has latencyMs as a non-negative number', async () => { - const res = mockResponse(); - await readinessCheck(mockRequest(), res); - expect(typeof res.body.latencyMs).toBe('number'); - expect(res.body.latencyMs).toBeGreaterThanOrEqual(0); - }); - - it('has checks as an array', async () => { - const res = mockResponse(); - await readinessCheck(mockRequest(), res); - expect(Array.isArray(res.body.checks)).toBe(true); - }); - - it('includes a "database" check with status "ok"', async () => { - const res = mockResponse(); - await readinessCheck(mockRequest(), res); - const dbCheck = res.body.checks.find((c: any) => c.name === 'database'); - expect(dbCheck).toBeDefined(); - expect(dbCheck.status).toBe('ok'); - }); - - it('includes latencyMs on the database check when it passes', async () => { - const res = mockResponse(); - await readinessCheck(mockRequest(), res); - const dbCheck = res.body.checks.find((c: any) => c.name === 'database'); - expect(typeof dbCheck.latencyMs).toBe('number'); - expect(dbCheck.latencyMs).toBeGreaterThanOrEqual(0); - }); - - it('includes a "cache" check with status "ok"', async () => { - const res = mockResponse(); - await readinessCheck(mockRequest(), res); - const cacheCheck = res.body.checks.find((c: any) => c.name === 'cache'); - expect(cacheCheck).toBeDefined(); - expect(cacheCheck.status).toBe('ok'); - }); - - it('check status values are only "ok" or "fail"', async () => { - const res = mockResponse(); - await readinessCheck(mockRequest(), res); - for (const check of res.body.checks) { - expect(['ok', 'fail']).toContain(check.status); - } - }); -}); + beforeEach(() => { + queryRawMock.mockResolvedValue([{ '?column?': 1 }]); + }); -// --------------------------------------------------------------------------- -// GET /api/v1/health/ready — readiness: database failure path -// --------------------------------------------------------------------------- + afterEach(() => { + queryRawMock.mockReset(); + }); -describe('GET /health/ready — readiness response schema (database failure)', () => { - beforeEach(() => { - queryRawMock.mockRejectedValue(new Error('connection refused')); - }); - - afterEach(() => { - queryRawMock.mockReset(); - }); - - it('returns HTTP 503 when a check fails', async () => { - const res = mockResponse(); - await readinessCheck(mockRequest(), res); - expect(res.statusCode).toBe(503); - }); - - it('sets ready to false — the sole non-200 trigger', async () => { - const res = mockResponse(); - await readinessCheck(mockRequest(), res); - expect(res.body.ready).toBe(false); - }); - - it('includes error string on the failed check — no stack trace or hostnames', async () => { - const res = mockResponse(); - await readinessCheck(mockRequest(), res); - const dbCheck = res.body.checks.find((c: any) => c.name === 'database'); - expect(dbCheck.status).toBe('fail'); - expect(typeof dbCheck.error).toBe('string'); - expect(dbCheck.error.length).toBeGreaterThan(0); - }); - - it('does not include latencyMs on the failed database check', async () => { - const res = mockResponse(); - await readinessCheck(mockRequest(), res); - const dbCheck = res.body.checks.find((c: any) => c.name === 'database'); - expect(dbCheck.latencyMs).toBeUndefined(); - }); - - it('still includes latencyMs at the top level even when a check fails', async () => { - const res = mockResponse(); - await readinessCheck(mockRequest(), res); - expect(typeof res.body.latencyMs).toBe('number'); - expect(res.body.latencyMs).toBeGreaterThanOrEqual(0); - }); - - it('cache check still passes independently of the database check', async () => { - const res = mockResponse(); - await readinessCheck(mockRequest(), res); - const cacheCheck = res.body.checks.find((c: any) => c.name === 'cache'); - expect(cacheCheck).toBeDefined(); - expect(cacheCheck.status).toBe('ok'); - }); -}); + it('returns HTTP 200 when all checks pass', async () => { + const res = mockResponse(); + await readinessCheck(mockRequest(), res); + expect(res.statusCode).toBe(200); + }); -// --------------------------------------------------------------------------- -// GET /api/v1/health/detailed — diagnostics: healthy path -// --------------------------------------------------------------------------- + it('has ready field set to true', async () => { + const res = mockResponse(); + await readinessCheck(mockRequest(), res); + expect(res.body.ready).toBe(true); + }); -describe('GET /health/detailed — diagnostics response schema (DB connected)', () => { - beforeEach(() => { - queryRawMock.mockResolvedValue([{ '?column?': 1 }]); - }); - - afterEach(() => { - queryRawMock.mockReset(); - }); - - it('returns HTTP 200', async () => { - const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(res.statusCode).toBe(200); - }); - - it('has success set to true', async () => { - const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(res.body.success).toBe(true); - }); - - it('has message as a non-empty string', async () => { - const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(typeof res.body.message).toBe('string'); - expect(res.body.message.length).toBeGreaterThan(0); - }); - - it('has timestamp as a valid ISO-8601 string', async () => { - const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(typeof res.body.timestamp).toBe('string'); - expect(new Date(res.body.timestamp).toISOString()).toBe(res.body.timestamp); - }); - - it('has version as a non-empty string', async () => { - const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(typeof res.body.version).toBe('string'); - expect(res.body.version.length).toBeGreaterThan(0); - }); - - it('has environment as a non-empty string', async () => { - const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(typeof res.body.environment).toBe('string'); - expect(res.body.environment.length).toBeGreaterThan(0); - }); - - it('has uptime as a non-negative number (seconds)', async () => { - const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(typeof res.body.uptime).toBe('number'); - expect(res.body.uptime).toBeGreaterThanOrEqual(0); - }); - - describe('memory object', () => { - it('has memory.used as a non-negative number (megabytes)', async () => { + it('has timestamp as a valid ISO-8601 string', async () => { const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(typeof res.body.memory.used).toBe('number'); - expect(res.body.memory.used).toBeGreaterThanOrEqual(0); - }); + await readinessCheck(mockRequest(), res); + expect(typeof res.body.timestamp).toBe('string'); + expect(new Date(res.body.timestamp).toISOString()).toBe( + res.body.timestamp + ); + }); - it('has memory.total as a positive number (megabytes)', async () => { + it('has latencyMs as a non-negative number', async () => { const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(typeof res.body.memory.total).toBe('number'); - expect(res.body.memory.total).toBeGreaterThan(0); - }); + await readinessCheck(mockRequest(), res); + expect(typeof res.body.latencyMs).toBe('number'); + expect(res.body.latencyMs).toBeGreaterThanOrEqual(0); + }); - it('has memory.used <= memory.total', async () => { + it('has checks as an array', async () => { const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(res.body.memory.used).toBeLessThanOrEqual(res.body.memory.total); - }); - }); + await readinessCheck(mockRequest(), res); + expect(Array.isArray(res.body.checks)).toBe(true); + }); - describe('system object', () => { - it('has system.platform as a non-empty string', async () => { + it('includes a "database" check with status "ok"', async () => { const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(typeof res.body.system.platform).toBe('string'); - expect(res.body.system.platform.length).toBeGreaterThan(0); - }); + await readinessCheck(mockRequest(), res); + const dbCheck = res.body.checks.find((c: any) => c.name === 'database'); + expect(dbCheck).toBeDefined(); + expect(dbCheck.status).toBe('ok'); + }); - it('has system.nodeVersion as a string starting with "v"', async () => { + it('includes latencyMs on the database check when it passes', async () => { const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(typeof res.body.system.nodeVersion).toBe('string'); - expect(res.body.system.nodeVersion).toMatch(/^v\d+/); - }); - }); + await readinessCheck(mockRequest(), res); + const dbCheck = res.body.checks.find((c: any) => c.name === 'database'); + expect(typeof dbCheck.latencyMs).toBe('number'); + expect(dbCheck.latencyMs).toBeGreaterThanOrEqual(0); + }); - describe('timeouts object', () => { - it('has timeouts.database_timeout_ms as a positive number', async () => { + it('includes a "cache" check with status "ok"', async () => { const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(typeof res.body.timeouts.database_timeout_ms).toBe('number'); - expect(res.body.timeouts.database_timeout_ms).toBeGreaterThan(0); - }); + await readinessCheck(mockRequest(), res); + const cacheCheck = res.body.checks.find((c: any) => c.name === 'cache'); + expect(cacheCheck).toBeDefined(); + expect(cacheCheck.status).toBe('ok'); + }); - it('has timeouts.cache_timeout_ms as a positive number', async () => { + it('check status values are only "ok" or "fail"', async () => { const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(typeof res.body.timeouts.cache_timeout_ms).toBe('number'); - expect(res.body.timeouts.cache_timeout_ms).toBeGreaterThan(0); - }); - }); + await readinessCheck(mockRequest(), res); + for (const check of res.body.checks) { + expect(['ok', 'fail']).toContain(check.status); + } + }); +}); + +// --------------------------------------------------------------------------- +// GET /api/v1/health/ready — readiness: database failure path +// --------------------------------------------------------------------------- + +describe('GET /health/ready — readiness response schema (database failure)', () => { + beforeEach(() => { + queryRawMock.mockRejectedValue(new Error('connection refused')); + }); - describe('database object', () => { - it('has database.status set to "connected" when DB responds', async () => { + afterEach(() => { + queryRawMock.mockReset(); + }); + + it('returns HTTP 503 when a check fails', async () => { const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(res.body.database.status).toBe('connected'); - }); + await readinessCheck(mockRequest(), res); + expect(res.statusCode).toBe(503); + }); - it('database.status is only "connected" or "disconnected"', async () => { + it('sets ready to false — the sole non-200 trigger', async () => { const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(['connected', 'disconnected']).toContain(res.body.database.status); - }); + await readinessCheck(mockRequest(), res); + expect(res.body.ready).toBe(false); + }); - it('has database.responseTime as a non-negative number when connected', async () => { + it('includes error string on the failed check — no stack trace or hostnames', async () => { const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(typeof res.body.database.responseTime).toBe('number'); - expect(res.body.database.responseTime).toBeGreaterThanOrEqual(0); - }); - }); + await readinessCheck(mockRequest(), res); + const dbCheck = res.body.checks.find((c: any) => c.name === 'database'); + expect(dbCheck.status).toBe('fail'); + expect(typeof dbCheck.error).toBe('string'); + expect(dbCheck.error.length).toBeGreaterThan(0); + }); + + it('does not include latencyMs on the failed database check', async () => { + const res = mockResponse(); + await readinessCheck(mockRequest(), res); + const dbCheck = res.body.checks.find((c: any) => c.name === 'database'); + expect(dbCheck.latencyMs).toBeUndefined(); + }); - describe('syncing object', () => { - it('has syncing.status as either "in-sync" or "degraded"', async () => { + it('still includes latencyMs at the top level even when a check fails', async () => { const res = mockResponse(); - await healthCheck(mockRequest(), res); - if (res.body.syncing !== undefined) { - expect(['in-sync', 'degraded']).toContain(res.body.syncing.status); - } - }); + await readinessCheck(mockRequest(), res); + expect(typeof res.body.latencyMs).toBe('number'); + expect(res.body.latencyMs).toBeGreaterThanOrEqual(0); + }); - it('has syncing.latestIndexedLedger as a non-negative integer when present', async () => { + it('cache check still passes independently of the database check', async () => { const res = mockResponse(); - await healthCheck(mockRequest(), res); - if (res.body.syncing !== undefined) { - expect(typeof res.body.syncing.latestIndexedLedger).toBe('number'); - expect(res.body.syncing.latestIndexedLedger).toBeGreaterThanOrEqual(0); - } - }); + await readinessCheck(mockRequest(), res); + const cacheCheck = res.body.checks.find((c: any) => c.name === 'cache'); + expect(cacheCheck).toBeDefined(); + expect(cacheCheck.status).toBe('ok'); + }); +}); - it('has syncing.observedHeadLedger as a non-negative integer when present', async () => { +// --------------------------------------------------------------------------- +// GET /api/v1/health/detailed — diagnostics: healthy path +// --------------------------------------------------------------------------- + +describe('GET /health/detailed — diagnostics response schema (DB connected)', () => { + beforeEach(() => { + queryRawMock.mockResolvedValue([{ '?column?': 1 }]); + }); + + afterEach(() => { + queryRawMock.mockReset(); + }); + + it('returns HTTP 200', async () => { const res = mockResponse(); await healthCheck(mockRequest(), res); - if (res.body.syncing !== undefined) { - expect(typeof res.body.syncing.observedHeadLedger).toBe('number'); - expect(res.body.syncing.observedHeadLedger).toBeGreaterThanOrEqual(0); - } - }); + expect(res.statusCode).toBe(200); + }); - it('has syncing.syncLagLedgers equal to observedHead - latestIndexed', async () => { + it('has success set to true', async () => { const res = mockResponse(); await healthCheck(mockRequest(), res); - if (res.body.syncing !== undefined) { - expect(res.body.syncing.syncLagLedgers).toBe( - res.body.syncing.observedHeadLedger - res.body.syncing.latestIndexedLedger - ); - } - }); - }); + expect(res.body.success).toBe(true); + }); - describe('services array', () => { - it('has services as an array', async () => { + it('has message as a non-empty string', async () => { const res = mockResponse(); await healthCheck(mockRequest(), res); - expect(Array.isArray(res.body.services)).toBe(true); - }); + expect(typeof res.body.message).toBe('string'); + expect(res.body.message.length).toBeGreaterThan(0); + }); - it('includes "API Server", "Database", and "Chain Sync" entries', async () => { + it('has timestamp as a valid ISO-8601 string', async () => { const res = mockResponse(); await healthCheck(mockRequest(), res); - const names = res.body.services.map((s: any) => s.name); - expect(names).toContain('API Server'); - expect(names).toContain('Database'); - expect(names).toContain('Chain Sync'); - }); + expect(typeof res.body.timestamp).toBe('string'); + expect(new Date(res.body.timestamp).toISOString()).toBe( + res.body.timestamp + ); + }); - it('each service status is only "healthy" or "unhealthy"', async () => { + it('has version as a non-empty string', async () => { const res = mockResponse(); await healthCheck(mockRequest(), res); - for (const svc of res.body.services) { - expect(['healthy', 'unhealthy']).toContain(svc.status); - } - }); + expect(typeof res.body.version).toBe('string'); + expect(res.body.version.length).toBeGreaterThan(0); + }); - it('"API Server" is always healthy', async () => { + it('has environment as a non-empty string', async () => { const res = mockResponse(); await healthCheck(mockRequest(), res); - const apiServer = res.body.services.find((s: any) => s.name === 'API Server'); - expect(apiServer.status).toBe('healthy'); - }); + expect(typeof res.body.environment).toBe('string'); + expect(res.body.environment.length).toBeGreaterThan(0); + }); - it('"Database" service is "healthy" when database is connected', async () => { + it('has uptime as a non-negative number (seconds)', async () => { const res = mockResponse(); await healthCheck(mockRequest(), res); - const dbService = res.body.services.find((s: any) => s.name === 'Database'); - expect(dbService.status).toBe('healthy'); - }); - }); + expect(typeof res.body.uptime).toBe('number'); + expect(res.body.uptime).toBeGreaterThanOrEqual(0); + }); + + describe('memory object', () => { + it('has memory.used as a non-negative number (megabytes)', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + expect(typeof res.body.memory.used).toBe('number'); + expect(res.body.memory.used).toBeGreaterThanOrEqual(0); + }); + + it('has memory.total as a positive number (megabytes)', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + expect(typeof res.body.memory.total).toBe('number'); + expect(res.body.memory.total).toBeGreaterThan(0); + }); + + it('has memory.used <= memory.total', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + expect(res.body.memory.used).toBeLessThanOrEqual( + res.body.memory.total + ); + }); + }); + + describe('system object', () => { + it('has system.platform as a non-empty string', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + expect(typeof res.body.system.platform).toBe('string'); + expect(res.body.system.platform.length).toBeGreaterThan(0); + }); + + it('has system.nodeVersion as a string starting with "v"', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + expect(typeof res.body.system.nodeVersion).toBe('string'); + expect(res.body.system.nodeVersion).toMatch(/^v\d+/); + }); + }); + + describe('timeouts object', () => { + it('has timeouts.database_timeout_ms as a positive number', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + expect(typeof res.body.timeouts.database_timeout_ms).toBe('number'); + expect(res.body.timeouts.database_timeout_ms).toBeGreaterThan(0); + }); + + it('has timeouts.cache_timeout_ms as a positive number', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + expect(typeof res.body.timeouts.cache_timeout_ms).toBe('number'); + expect(res.body.timeouts.cache_timeout_ms).toBeGreaterThan(0); + }); + }); + + describe('database object', () => { + it('has database.status set to "connected" when DB responds', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + expect(res.body.database.status).toBe('connected'); + }); + + it('database.status is only "connected" or "disconnected"', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + expect(['connected', 'disconnected']).toContain( + res.body.database.status + ); + }); + + it('has database.responseTime as a non-negative number when connected', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + expect(typeof res.body.database.responseTime).toBe('number'); + expect(res.body.database.responseTime).toBeGreaterThanOrEqual(0); + }); + }); + + describe('syncing object', () => { + it('has syncing.status as either "in-sync" or "degraded"', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + if (res.body.syncing !== undefined) { + expect(['in-sync', 'degraded']).toContain(res.body.syncing.status); + } + }); + + it('has syncing.latestIndexedLedger as a non-negative integer when present', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + if (res.body.syncing !== undefined) { + expect(typeof res.body.syncing.latestIndexedLedger).toBe('number'); + expect(res.body.syncing.latestIndexedLedger).toBeGreaterThanOrEqual( + 0 + ); + } + }); + + it('has syncing.observedHeadLedger as a non-negative integer when present', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + if (res.body.syncing !== undefined) { + expect(typeof res.body.syncing.observedHeadLedger).toBe('number'); + expect(res.body.syncing.observedHeadLedger).toBeGreaterThanOrEqual( + 0 + ); + } + }); + + it('has syncing.syncLagLedgers equal to observedHead - latestIndexed', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + if (res.body.syncing !== undefined) { + expect(res.body.syncing.syncLagLedgers).toBe( + res.body.syncing.observedHeadLedger - + res.body.syncing.latestIndexedLedger + ); + } + }); + }); + + describe('services array', () => { + it('has services as an array', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + expect(Array.isArray(res.body.services)).toBe(true); + }); + + it('includes "API Server", "Database", and "Chain Sync" entries', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + const names = res.body.services.map((s: any) => s.name); + expect(names).toContain('API Server'); + expect(names).toContain('Database'); + expect(names).toContain('Chain Sync'); + }); + + it('each service status is only "healthy" or "unhealthy"', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + for (const svc of res.body.services) { + expect(['healthy', 'unhealthy']).toContain(svc.status); + } + }); + + it('"API Server" is always healthy', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + const apiServer = res.body.services.find( + (s: any) => s.name === 'API Server' + ); + expect(apiServer.status).toBe('healthy'); + }); + + it('"Database" service is "healthy" when database is connected', async () => { + const res = mockResponse(); + await healthCheck(mockRequest(), res); + const dbService = res.body.services.find( + (s: any) => s.name === 'Database' + ); + expect(dbService.status).toBe('healthy'); + }); + }); }); // --------------------------------------------------------------------------- @@ -451,201 +472,209 @@ describe('GET /health/detailed — diagnostics response schema (DB connected)', // --------------------------------------------------------------------------- describe('GET /health/detailed — diagnostics response schema (DB disconnected, non-production)', () => { - beforeEach(() => { - queryRawMock.mockRejectedValue(new Error('connection refused')); - }); - - afterEach(() => { - queryRawMock.mockReset(); - }); - - it('returns HTTP 200 in non-production even when DB is disconnected', async () => { - const res = mockResponse(); - await healthCheck(mockRequest(), res); - // MODE is "test" in this suite's mock — non-production returns 200 - expect(res.statusCode).toBe(200); - }); - - it('has database.status set to "disconnected"', async () => { - const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(res.body.database.status).toBe('disconnected'); - }); - - it('does not include database.responseTime when disconnected', async () => { - const res = mockResponse(); - await healthCheck(mockRequest(), res); - expect(res.body.database.responseTime).toBeUndefined(); - }); - - it('"Database" service is "unhealthy" when database is disconnected', async () => { - const res = mockResponse(); - await healthCheck(mockRequest(), res); - const dbService = res.body.services.find((s: any) => s.name === 'Database'); - expect(dbService.status).toBe('unhealthy'); - }); -}); + beforeEach(() => { + queryRawMock.mockRejectedValue(new Error('connection refused')); + }); -// --------------------------------------------------------------------------- -// GET /api/v1/health/indexer — worker heartbeat: all three status values -// --------------------------------------------------------------------------- + afterEach(() => { + queryRawMock.mockReset(); + }); -describe('GET /health/indexer — indexer heartbeat response schema', () => { - beforeEach(() => { - indexerHeartbeat.reset(); - }); - - it('has success set to true in all states', () => { - const res = mockResponse(); - indexerHeartbeatCheck(mockRequest(), res); - expect(res.body.success).toBe(true); - }); - - it('has data.service set to "indexer"', () => { - const res = mockResponse(); - indexerHeartbeatCheck(mockRequest(), res); - expect(res.body.data.service).toBe('indexer'); - }); - - it('data.status is only "healthy", "degraded", or "unknown"', () => { - const res = mockResponse(); - indexerHeartbeatCheck(mockRequest(), res); - expect(['healthy', 'degraded', 'unknown']).toContain(res.body.data.status); - }); - - describe('"unknown" state — no heartbeat ever recorded', () => { - it('returns HTTP 200', () => { + it('returns HTTP 200 in non-production even when DB is disconnected', async () => { const res = mockResponse(); - indexerHeartbeatCheck(mockRequest(), res); + await healthCheck(mockRequest(), res); + // MODE is "test" in this suite's mock — non-production returns 200 expect(res.statusCode).toBe(200); - }); + }); - it('has data.status set to "unknown"', () => { + it('has database.status set to "disconnected"', async () => { const res = mockResponse(); - indexerHeartbeatCheck(mockRequest(), res); - expect(res.body.data.status).toBe('unknown'); - }); + await healthCheck(mockRequest(), res); + expect(res.body.database.status).toBe('disconnected'); + }); - it('has data.lastSuccessfulRun as null', () => { + it('does not include database.responseTime when disconnected', async () => { const res = mockResponse(); - indexerHeartbeatCheck(mockRequest(), res); - expect(res.body.data.lastSuccessfulRun).toBeNull(); - }); + await healthCheck(mockRequest(), res); + expect(res.body.database.responseTime).toBeUndefined(); + }); - it('has data.staleSinceMs as null', () => { + it('"Database" service is "unhealthy" when database is disconnected', async () => { const res = mockResponse(); - indexerHeartbeatCheck(mockRequest(), res); - expect(res.body.data.staleSinceMs).toBeNull(); - }); - }); + await healthCheck(mockRequest(), res); + const dbService = res.body.services.find( + (s: any) => s.name === 'Database' + ); + expect(dbService.status).toBe('unhealthy'); + }); +}); - describe('"healthy" state — recent heartbeat recorded', () => { - beforeEach(() => { - indexerHeartbeat.recordHeartbeat(); - }); +// --------------------------------------------------------------------------- +// GET /api/v1/health/indexer — worker heartbeat: all three status values +// --------------------------------------------------------------------------- + +describe('GET /health/indexer — indexer heartbeat response schema', () => { + beforeEach(() => { + indexerHeartbeat.reset(); + }); - it('returns HTTP 200', () => { + it('has success set to true in all states', () => { const res = mockResponse(); indexerHeartbeatCheck(mockRequest(), res); - expect(res.statusCode).toBe(200); - }); + expect(res.body.success).toBe(true); + }); - it('has data.status set to "healthy"', () => { + it('has data.service set to "indexer"', () => { const res = mockResponse(); indexerHeartbeatCheck(mockRequest(), res); - expect(res.body.data.status).toBe('healthy'); - }); + expect(res.body.data.service).toBe('indexer'); + }); - it('has data.lastSuccessfulRun as a valid ISO-8601 string', () => { + it('data.status is only "healthy", "degraded", or "unknown"', () => { const res = mockResponse(); indexerHeartbeatCheck(mockRequest(), res); - expect(typeof res.body.data.lastSuccessfulRun).toBe('string'); - expect(new Date(res.body.data.lastSuccessfulRun).toISOString()).toBe( - res.body.data.lastSuccessfulRun + expect(['healthy', 'degraded', 'unknown']).toContain( + res.body.data.status ); - }); + }); + + describe('"unknown" state — no heartbeat ever recorded', () => { + it('returns HTTP 200', () => { + const res = mockResponse(); + indexerHeartbeatCheck(mockRequest(), res); + expect(res.statusCode).toBe(200); + }); + + it('has data.status set to "unknown"', () => { + const res = mockResponse(); + indexerHeartbeatCheck(mockRequest(), res); + expect(res.body.data.status).toBe('unknown'); + }); + + it('has data.lastSuccessfulRun as null', () => { + const res = mockResponse(); + indexerHeartbeatCheck(mockRequest(), res); + expect(res.body.data.lastSuccessfulRun).toBeNull(); + }); + + it('has data.staleSinceMs as null', () => { + const res = mockResponse(); + indexerHeartbeatCheck(mockRequest(), res); + expect(res.body.data.staleSinceMs).toBeNull(); + }); + }); + + describe('"healthy" state — recent heartbeat recorded', () => { + beforeEach(() => { + indexerHeartbeat.recordHeartbeat(); + }); + + it('returns HTTP 200', () => { + const res = mockResponse(); + indexerHeartbeatCheck(mockRequest(), res); + expect(res.statusCode).toBe(200); + }); + + it('has data.status set to "healthy"', () => { + const res = mockResponse(); + indexerHeartbeatCheck(mockRequest(), res); + expect(res.body.data.status).toBe('healthy'); + }); + + it('has data.lastSuccessfulRun as a valid ISO-8601 string', () => { + const res = mockResponse(); + indexerHeartbeatCheck(mockRequest(), res); + expect(typeof res.body.data.lastSuccessfulRun).toBe('string'); + expect(new Date(res.body.data.lastSuccessfulRun).toISOString()).toBe( + res.body.data.lastSuccessfulRun + ); + }); + + it('has data.staleSinceMs as null when healthy', () => { + const res = mockResponse(); + indexerHeartbeatCheck(mockRequest(), res); + expect(res.body.data.staleSinceMs).toBeNull(); + }); + }); + + describe('"degraded" state — stale heartbeat', () => { + beforeEach(() => { + indexerHeartbeat.recordHeartbeat(); + const longAgo = new Date(Date.now() - 10 * 60 * 1000); // 10 min ago + ( + indexerHeartbeat as unknown as { lastSuccessfulRun: Date } + ).lastSuccessfulRun = longAgo; + }); + + it('returns HTTP 503 — sole non-200 trigger for this endpoint', () => { + const res = mockResponse(); + indexerHeartbeatCheck(mockRequest(), res); + expect(res.statusCode).toBe(503); + }); + + it('has data.status set to "degraded"', () => { + const res = mockResponse(); + indexerHeartbeatCheck(mockRequest(), res); + expect(res.body.data.status).toBe('degraded'); + }); + + it('has data.lastSuccessfulRun as a valid ISO-8601 string', () => { + const res = mockResponse(); + indexerHeartbeatCheck(mockRequest(), res); + expect(typeof res.body.data.lastSuccessfulRun).toBe('string'); + expect(new Date(res.body.data.lastSuccessfulRun).toISOString()).toBe( + res.body.data.lastSuccessfulRun + ); + }); + + it('has data.staleSinceMs as a positive number when degraded', () => { + const res = mockResponse(); + indexerHeartbeatCheck(mockRequest(), res); + expect(typeof res.body.data.staleSinceMs).toBe('number'); + expect(res.body.data.staleSinceMs).toBeGreaterThan(0); + }); + }); +}); - it('has data.staleSinceMs as null when healthy', () => { - const res = mockResponse(); - indexerHeartbeatCheck(mockRequest(), res); - expect(res.body.data.staleSinceMs).toBeNull(); - }); - }); +// --------------------------------------------------------------------------- +// POST /api/v1/health/indexer/heartbeat — record worker run +// --------------------------------------------------------------------------- - describe('"degraded" state — stale heartbeat', () => { - beforeEach(() => { - indexerHeartbeat.recordHeartbeat(); - const longAgo = new Date(Date.now() - 10 * 60 * 1000); // 10 min ago - (indexerHeartbeat as unknown as { lastSuccessfulRun: Date }).lastSuccessfulRun = longAgo; - }); +describe('POST /health/indexer/heartbeat — response schema', () => { + beforeEach(() => { + indexerHeartbeat.reset(); + }); - it('returns HTTP 503 — sole non-200 trigger for this endpoint', () => { + it('returns HTTP 200', async () => { const res = mockResponse(); - indexerHeartbeatCheck(mockRequest(), res); - expect(res.statusCode).toBe(503); - }); + await recordIndexerHeartbeat(mockRequest(), res); + expect(res.statusCode).toBe(200); + }); - it('has data.status set to "degraded"', () => { + it('has success set to true', async () => { const res = mockResponse(); - indexerHeartbeatCheck(mockRequest(), res); - expect(res.body.data.status).toBe('degraded'); - }); + await recordIndexerHeartbeat(mockRequest(), res); + expect(res.body.success).toBe(true); + }); - it('has data.lastSuccessfulRun as a valid ISO-8601 string', () => { + it('has data.recorded set to true', async () => { const res = mockResponse(); - indexerHeartbeatCheck(mockRequest(), res); - expect(typeof res.body.data.lastSuccessfulRun).toBe('string'); - expect(new Date(res.body.data.lastSuccessfulRun).toISOString()).toBe( - res.body.data.lastSuccessfulRun - ); - }); + await recordIndexerHeartbeat(mockRequest(), res); + expect(res.body.data.recorded).toBe(true); + }); - it('has data.staleSinceMs as a positive number when degraded', () => { + it('has data.timestamp as a valid ISO-8601 string', async () => { const res = mockResponse(); - indexerHeartbeatCheck(mockRequest(), res); - expect(typeof res.body.data.staleSinceMs).toBe('number'); - expect(res.body.data.staleSinceMs).toBeGreaterThan(0); - }); - }); -}); - -// --------------------------------------------------------------------------- -// POST /api/v1/health/indexer/heartbeat — record worker run -// --------------------------------------------------------------------------- + await recordIndexerHeartbeat(mockRequest(), res); + expect(typeof res.body.data.timestamp).toBe('string'); + expect(new Date(res.body.data.timestamp).toISOString()).toBe( + res.body.data.timestamp + ); + }); -describe('POST /health/indexer/heartbeat — response schema', () => { - beforeEach(() => { - indexerHeartbeat.reset(); - }); - - it('returns HTTP 200', async () => { - const res = mockResponse(); - await recordIndexerHeartbeat(mockRequest(), res); - expect(res.statusCode).toBe(200); - }); - - it('has success set to true', async () => { - const res = mockResponse(); - await recordIndexerHeartbeat(mockRequest(), res); - expect(res.body.success).toBe(true); - }); - - it('has data.recorded set to true', async () => { - const res = mockResponse(); - await recordIndexerHeartbeat(mockRequest(), res); - expect(res.body.data.recorded).toBe(true); - }); - - it('has data.timestamp as a valid ISO-8601 string', async () => { - const res = mockResponse(); - await recordIndexerHeartbeat(mockRequest(), res); - expect(typeof res.body.data.timestamp).toBe('string'); - expect(new Date(res.body.data.timestamp).toISOString()).toBe(res.body.data.timestamp); - }); - - it('has message set to "Heartbeat recorded"', async () => { - const res = mockResponse(); - await recordIndexerHeartbeat(mockRequest(), res); - expect(res.body.message).toBe('Heartbeat recorded'); - }); + it('has message set to "Heartbeat recorded"', async () => { + const res = mockResponse(); + await recordIndexerHeartbeat(mockRequest(), res); + expect(res.body.message).toBe('Heartbeat recorded'); + }); }); diff --git a/src/modules/health/health.response-time.integration.test.ts b/src/modules/health/health.response-time.integration.test.ts index 28033fb..1960e02 100644 --- a/src/modules/health/health.response-time.integration.test.ts +++ b/src/modules/health/health.response-time.integration.test.ts @@ -3,20 +3,20 @@ // pnpm exec jest src/modules/health/health.response-time.integration.test.ts jest.mock('../../config', () => ({ - envConfig: { - MODE: 'test', - PORT: 3000, - INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: 300000, - }, - appConfig: { - allowedOrigins: [], - }, + envConfig: { + MODE: 'test', + PORT: 3000, + INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: 300000, + }, + appConfig: { + allowedOrigins: [], + }, })); jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - $queryRaw: jest.fn(), - }, + prisma: { + $queryRaw: jest.fn(), + }, })); import { Request, Response } from 'express'; @@ -25,34 +25,34 @@ import { HEALTH_LIVENESS_MAX_LATENCY_MS } from '../../constants/health.constants import { elapsedMs, startTimer } from '../../utils/monotonic-clock.utils'; function mockResponse(): Response & { statusCode: number; body: unknown } { - const res = { statusCode: 0, body: undefined as unknown } as Response & { - statusCode: number; - body: unknown; - }; - res.status = (code: number) => { - res.statusCode = code; - return res; - }; - res.json = (payload: unknown) => { - res.body = payload; - return res; - }; - return res; + const res = { statusCode: 0, body: undefined as unknown } as Response & { + statusCode: number; + body: unknown; + }; + res.status = (code: number) => { + res.statusCode = code; + return res; + }; + res.json = (payload: unknown) => { + res.body = payload; + return res; + }; + return res; } describe('simpleHealthCheck() — response time budget', () => { - it(`responds within ${HEALTH_LIVENESS_MAX_LATENCY_MS}ms`, () => { - const timer = startTimer(); - const res = mockResponse(); + it(`responds within ${HEALTH_LIVENESS_MAX_LATENCY_MS}ms`, () => { + const timer = startTimer(); + const res = mockResponse(); - simpleHealthCheck({} as Request, res); + simpleHealthCheck({} as Request, res); - const durationMs = elapsedMs(timer); + const durationMs = elapsedMs(timer); - expect(res.statusCode).toBe(200); - expect(res.body).toEqual( - expect.objectContaining({ success: true, message: 'OK' }) - ); - expect(durationMs).toBeLessThanOrEqual(HEALTH_LIVENESS_MAX_LATENCY_MS); - }); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ success: true, message: 'OK' }) + ); + expect(durationMs).toBeLessThanOrEqual(HEALTH_LIVENESS_MAX_LATENCY_MS); + }); }); diff --git a/src/modules/health/health.routes.ts b/src/modules/health/health.routes.ts index 7066a1a..15dd20e 100644 --- a/src/modules/health/health.routes.ts +++ b/src/modules/health/health.routes.ts @@ -1,10 +1,10 @@ import { Router } from 'express'; import { - healthCheck, - readinessCheck, - simpleHealthCheck, - indexerHeartbeatCheck, - recordIndexerHeartbeat, + healthCheck, + readinessCheck, + simpleHealthCheck, + indexerHeartbeatCheck, + recordIndexerHeartbeat, } from './health.controllers'; const router = Router(); diff --git a/src/modules/index.ts b/src/modules/index.ts index ad62c69..fa5cbc9 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -30,4 +30,4 @@ router.use(CREATORS_BASE, webhookRouter); router.use('/wallets', walletsRouter); router.use('/alerts', alertsRouter); -export default router; \ No newline at end of file +export default router; diff --git a/src/modules/indexer/ledger-gap-detection.integration.test.ts b/src/modules/indexer/ledger-gap-detection.integration.test.ts index b4adde0..f244e3b 100644 --- a/src/modules/indexer/ledger-gap-detection.integration.test.ts +++ b/src/modules/indexer/ledger-gap-detection.integration.test.ts @@ -3,94 +3,94 @@ import { prisma } from '../../utils/prisma.utils'; import { - detectLedgerGap, - updateIndexedLedger, + detectLedgerGap, + updateIndexedLedger, } from './ledger-gap-detection.service'; import { upsertPriceSnapshot } from './price-snapshot.service'; describe('#420 ledger gap detection and replay', () => { - beforeAll(async () => { - // Seed a baseline ledger - await updateIndexedLedger(100, '100-000'); - }); + beforeAll(async () => { + // Seed a baseline ledger + await updateIndexedLedger(100, '100-000'); + }); - afterAll(async () => { - await prisma.indexedLedger.deleteMany({}); - await prisma.$disconnect(); - }); + afterAll(async () => { + await prisma.indexedLedger.deleteMany({}); + await prisma.$disconnect(); + }); - it('detects no gap when ledger is up to date', async () => { - await updateIndexedLedger(12_390, '12390-000'); - const gap = await detectLedgerGap(); - expect(gap.detected).toBe(false); - expect(gap.gapSize).toBeLessThanOrEqual(10); - }); + it('detects no gap when ledger is up to date', async () => { + await updateIndexedLedger(12_390, '12390-000'); + const gap = await detectLedgerGap(); + expect(gap.detected).toBe(false); + expect(gap.gapSize).toBeLessThanOrEqual(10); + }); - it('detects gap when ledger is behind', async () => { - await updateIndexedLedger(100, '100-000'); - const gap = await detectLedgerGap(); - expect(gap.detected).toBe(true); - expect(gap.gapSize).toBeGreaterThan(10); - expect(gap.gapRange).not.toBeNull(); - expect(gap.gapRange!.start).toBe(101); - }); + it('detects gap when ledger is behind', async () => { + await updateIndexedLedger(100, '100-000'); + const gap = await detectLedgerGap(); + expect(gap.detected).toBe(true); + expect(gap.gapSize).toBeGreaterThan(10); + expect(gap.gapRange).not.toBeNull(); + expect(gap.gapRange!.start).toBe(101); + }); - it('gap detection runs on startup and logs warning', async () => { - await updateIndexedLedger(50, '50-000'); - const gap = await detectLedgerGap(); - // Just verify structure, logging is tested via manual inspection - expect(gap).toHaveProperty('detected'); - expect(gap).toHaveProperty('gapSize'); - expect(gap).toHaveProperty('gapRange'); - }); + it('gap detection runs on startup and logs warning', async () => { + await updateIndexedLedger(50, '50-000'); + const gap = await detectLedgerGap(); + // Just verify structure, logging is tested via manual inspection + expect(gap).toHaveProperty('detected'); + expect(gap).toHaveProperty('gapSize'); + expect(gap).toHaveProperty('gapRange'); + }); - it('replay is idempotent — reprocessing an event does not create duplicates', async () => { - // Create a test creator - const user = await prisma.user.create({ - data: { - id: 'gap-test-user-1', - email: 'gap-test@example.test', - passwordHash: 'dummy-hash', - firstName: 'Gap', - lastName: 'Test', - }, - }); + it('replay is idempotent — reprocessing an event does not create duplicates', async () => { + // Create a test creator + const user = await prisma.user.create({ + data: { + id: 'gap-test-user-1', + email: 'gap-test@example.test', + passwordHash: 'dummy-hash', + firstName: 'Gap', + lastName: 'Test', + }, + }); - const creator = await prisma.creatorProfile.create({ - data: { - userId: user.id, - handle: 'gap-test-creator', - displayName: 'Gap Test Creator', - }, - }); + const creator = await prisma.creatorProfile.create({ + data: { + userId: user.id, + handle: 'gap-test-creator', + displayName: 'Gap Test Creator', + }, + }); - // Simulate a trade event twice (idempotency test) - const tradeAt = new Date('2026-01-01T00:00:00Z'); - await upsertPriceSnapshot({ - creatorId: creator.id, - price: BigInt(2_000_000), - tradeAt, - }); + // Simulate a trade event twice (idempotency test) + const tradeAt = new Date('2026-01-01T00:00:00Z'); + await upsertPriceSnapshot({ + creatorId: creator.id, + price: BigInt(2_000_000), + tradeAt, + }); - await upsertPriceSnapshot({ - creatorId: creator.id, - price: BigInt(2_000_000), - tradeAt, - }); + await upsertPriceSnapshot({ + creatorId: creator.id, + price: BigInt(2_000_000), + tradeAt, + }); - // Verify only one snapshot exists - const snapshots = await prisma.creatorPriceSnapshot.findMany({ - where: { creatorId: creator.id }, - }); + // Verify only one snapshot exists + const snapshots = await prisma.creatorPriceSnapshot.findMany({ + where: { creatorId: creator.id }, + }); - expect(snapshots).toHaveLength(1); - expect(snapshots[0].currentPrice.toString()).toBe('2000000'); + expect(snapshots).toHaveLength(1); + expect(snapshots[0].currentPrice.toString()).toBe('2000000'); - // Cleanup - await prisma.creatorPriceSnapshot.deleteMany({ - where: { creatorId: creator.id }, - }); - await prisma.creatorProfile.deleteMany({ where: { id: creator.id } }); - await prisma.user.deleteMany({ where: { id: user.id } }); - }); + // Cleanup + await prisma.creatorPriceSnapshot.deleteMany({ + where: { creatorId: creator.id }, + }); + await prisma.creatorProfile.deleteMany({ where: { id: creator.id } }); + await prisma.user.deleteMany({ where: { id: user.id } }); + }); }); diff --git a/src/modules/indexer/ledger-gap-detection.service.ts b/src/modules/indexer/ledger-gap-detection.service.ts index 3ab6d67..b10b88e 100644 --- a/src/modules/indexer/ledger-gap-detection.service.ts +++ b/src/modules/indexer/ledger-gap-detection.service.ts @@ -5,11 +5,11 @@ import { prisma } from '../../utils/prisma.utils'; import { logger } from '../../utils/logger.utils'; export interface LedgerGap { - detected: boolean; - lastProcessed: number; - currentNetworkHead: number; - gapSize: number; - gapRange: { start: number; end: number } | null; + detected: boolean; + lastProcessed: number; + currentNetworkHead: number; + gapSize: number; + gapRange: { start: number; end: number } | null; } /** @@ -17,10 +17,10 @@ export interface LedgerGap { * In production, this would call the Stellar Horizon/Soroban RPC API. */ async function fetchStellarNetworkHead(): Promise { - // TODO: Replace with actual Stellar RPC call - // const response = await fetch(`${envConfig.STELLAR_SOROBAN_RPC_URL}/...`); - // return response.latestLedger; - return 12_400; // Mock value for dev + // TODO: Replace with actual Stellar RPC call + // const response = await fetch(`${envConfig.STELLAR_SOROBAN_RPC_URL}/...`); + // return response.latestLedger; + return 12_400; // Mock value for dev } /** @@ -33,65 +33,65 @@ async function fetchStellarNetworkHead(): Promise { * @returns Gap detection result with range details. */ export async function detectLedgerGap(): Promise { - try { - const indexedLedger = await prisma.indexedLedger.findFirst({ - orderBy: { updatedAt: 'desc' }, - }); + try { + const indexedLedger = await prisma.indexedLedger.findFirst({ + orderBy: { updatedAt: 'desc' }, + }); - if (!indexedLedger) { - logger.warn('No indexed ledger record found — indexer has never run'); - return { - detected: false, - lastProcessed: 0, - currentNetworkHead: 0, - gapSize: 0, - gapRange: null, - }; - } + if (!indexedLedger) { + logger.warn('No indexed ledger record found — indexer has never run'); + return { + detected: false, + lastProcessed: 0, + currentNetworkHead: 0, + gapSize: 0, + gapRange: null, + }; + } - const networkHead = await fetchStellarNetworkHead(); - const gapSize = networkHead - indexedLedger.ledger; + const networkHead = await fetchStellarNetworkHead(); + const gapSize = networkHead - indexedLedger.ledger; - if (gapSize > 10) { - // Gap threshold: 10 ledgers (~50 seconds at 5s/ledger) - logger.warn( - { - event: 'ledger_gap_detected', - lastProcessed: indexedLedger.ledger, - networkHead, - gapSize, - gapRange: { - start: indexedLedger.ledger + 1, - end: networkHead, - }, - updatedAt: indexedLedger.updatedAt.toISOString(), - }, - `Ledger gap detected: ${gapSize} ledgers behind (${indexedLedger.ledger} → ${networkHead})` - ); + if (gapSize > 10) { + // Gap threshold: 10 ledgers (~50 seconds at 5s/ledger) + logger.warn( + { + event: 'ledger_gap_detected', + lastProcessed: indexedLedger.ledger, + networkHead, + gapSize, + gapRange: { + start: indexedLedger.ledger + 1, + end: networkHead, + }, + updatedAt: indexedLedger.updatedAt.toISOString(), + }, + `Ledger gap detected: ${gapSize} ledgers behind (${indexedLedger.ledger} → ${networkHead})` + ); + + return { + detected: true, + lastProcessed: indexedLedger.ledger, + currentNetworkHead: networkHead, + gapSize, + gapRange: { + start: indexedLedger.ledger + 1, + end: networkHead, + }, + }; + } return { - detected: true, - lastProcessed: indexedLedger.ledger, - currentNetworkHead: networkHead, - gapSize, - gapRange: { - start: indexedLedger.ledger + 1, - end: networkHead, - }, + detected: false, + lastProcessed: indexedLedger.ledger, + currentNetworkHead: networkHead, + gapSize, + gapRange: null, }; - } - - return { - detected: false, - lastProcessed: indexedLedger.ledger, - currentNetworkHead: networkHead, - gapSize, - gapRange: null, - }; - } catch (err) { - logger.error({ err }, 'Failed to detect ledger gap'); - throw err; - } + } catch (err) { + logger.error({ err }, 'Failed to detect ledger gap'); + throw err; + } } /** @@ -102,19 +102,19 @@ export async function detectLedgerGap(): Promise { * @param cursor - The opaque cursor for resumption. */ export async function updateIndexedLedger( - ledger: number, - cursor: string + ledger: number, + cursor: string ): Promise { - await prisma.indexedLedger.upsert({ - where: { id: 1 }, - create: { - id: 1, - ledger, - cursor, - }, - update: { - ledger, - cursor, - }, - }); + await prisma.indexedLedger.upsert({ + where: { id: 1 }, + create: { + id: 1, + ledger, + cursor, + }, + update: { + ledger, + cursor, + }, + }); } diff --git a/src/modules/indexer/price-snapshot.integration.test.ts b/src/modules/indexer/price-snapshot.integration.test.ts index 42320c4..58cbbea 100644 --- a/src/modules/indexer/price-snapshot.integration.test.ts +++ b/src/modules/indexer/price-snapshot.integration.test.ts @@ -10,93 +10,101 @@ const USER_ID = 'price-snap-test-user-1'; const HANDLE = 'price-snap-test-creator-1'; describe('#417 price snapshot — fields update after a simulated trade', () => { - let creatorId: string; + let creatorId: string; - beforeAll(async () => { - await prisma.user.upsert({ - where: { id: USER_ID }, - create: { - id: USER_ID, - email: 'price-snap-test@example.test', - passwordHash: 'dummy-hash', - firstName: 'Price', - lastName: 'Snap', - }, - update: {}, - }); + beforeAll(async () => { + await prisma.user.upsert({ + where: { id: USER_ID }, + create: { + id: USER_ID, + email: 'price-snap-test@example.test', + passwordHash: 'dummy-hash', + firstName: 'Price', + lastName: 'Snap', + }, + update: {}, + }); - const creator = await prisma.creatorProfile.upsert({ - where: { userId: USER_ID }, - create: { - userId: USER_ID, - handle: HANDLE, - displayName: 'Price Snap Creator', - }, - update: {}, - }); + const creator = await prisma.creatorProfile.upsert({ + where: { userId: USER_ID }, + create: { + userId: USER_ID, + handle: HANDLE, + displayName: 'Price Snap Creator', + }, + update: {}, + }); - creatorId = creator.id; - }); + creatorId = creator.id; + }); - afterAll(async () => { - await prisma.creatorPriceSnapshot.deleteMany({ where: { creatorId } }); - await prisma.creatorProfile.deleteMany({ where: { handle: HANDLE } }); - await prisma.user.deleteMany({ where: { id: USER_ID } }); - await prisma.$disconnect(); - }); + afterAll(async () => { + await prisma.creatorPriceSnapshot.deleteMany({ where: { creatorId } }); + await prisma.creatorProfile.deleteMany({ where: { handle: HANDLE } }); + await prisma.user.deleteMany({ where: { id: USER_ID } }); + await prisma.$disconnect(); + }); - it('creator list returns null price before any trade', async () => { - const res = await supertest(app).get('/api/v1/creators'); - expect(res.status).toBe(200); - const item = (res.body.data.items as any[]).find( - (c: any) => c.id === creatorId - ); - expect(item).toBeDefined(); - expect(item.currentPrice).toBeNull(); - expect(item.priceChange24h).toBeNull(); - }); + it('creator list returns null price before any trade', async () => { + const res = await supertest(app).get('/api/v1/creators'); + expect(res.status).toBe(200); + const item = (res.body.data.items as any[]).find( + (c: any) => c.id === creatorId + ); + expect(item).toBeDefined(); + expect(item.currentPrice).toBeNull(); + expect(item.priceChange24h).toBeNull(); + }); - it('creator detail returns null price before any trade', async () => { - const res = await supertest(app).get( - `/api/v1/creators/${creatorId}/profile` - ); - expect(res.status).toBe(200); - expect(res.body.data.currentPrice).toBeNull(); - expect(res.body.data.priceChange24h).toBeNull(); - }); + it('creator detail returns null price before any trade', async () => { + const res = await supertest(app).get( + `/api/v1/creators/${creatorId}/profile` + ); + expect(res.status).toBe(200); + expect(res.body.data.currentPrice).toBeNull(); + expect(res.body.data.priceChange24h).toBeNull(); + }); - it('price fields update after a simulated trade event', async () => { - await upsertPriceSnapshot({ - creatorId, - price: BigInt(2_000_000), - tradeAt: new Date(), - }); + it('price fields update after a simulated trade event', async () => { + await upsertPriceSnapshot({ + creatorId, + price: BigInt(2_000_000), + tradeAt: new Date(), + }); - const listRes = await supertest(app).get('/api/v1/creators'); - expect(listRes.status).toBe(200); - const item = (listRes.body.data.items as any[]).find( - (c: any) => c.id === creatorId - ); - expect(item).toBeDefined(); - expect(item.currentPrice).toBe('2000000'); + const listRes = await supertest(app).get('/api/v1/creators'); + expect(listRes.status).toBe(200); + const item = (listRes.body.data.items as any[]).find( + (c: any) => c.id === creatorId + ); + expect(item).toBeDefined(); + expect(item.currentPrice).toBe('2000000'); - const detailRes = await supertest(app).get( - `/api/v1/creators/${creatorId}/profile` - ); - expect(detailRes.status).toBe(200); - expect(detailRes.body.data.currentPrice).toBe('2000000'); - }); + const detailRes = await supertest(app).get( + `/api/v1/creators/${creatorId}/profile` + ); + expect(detailRes.status).toBe(200); + expect(detailRes.body.data.currentPrice).toBe('2000000'); + }); - it('is idempotent — replaying the same event does not change the snapshot', async () => { - const tradeAt = new Date('2026-01-01T00:00:00Z'); + it('is idempotent — replaying the same event does not change the snapshot', async () => { + const tradeAt = new Date('2026-01-01T00:00:00Z'); - await upsertPriceSnapshot({ creatorId, price: BigInt(5_000_000), tradeAt }); - await upsertPriceSnapshot({ creatorId, price: BigInt(5_000_000), tradeAt }); + await upsertPriceSnapshot({ + creatorId, + price: BigInt(5_000_000), + tradeAt, + }); + await upsertPriceSnapshot({ + creatorId, + price: BigInt(5_000_000), + tradeAt, + }); - const snap = await prisma.creatorPriceSnapshot.findUnique({ - where: { creatorId }, - }); - // Should still be 5_000_000 from the first call, not doubled or errored - expect(snap?.currentPrice.toString()).toBe('5000000'); - }); + const snap = await prisma.creatorPriceSnapshot.findUnique({ + where: { creatorId }, + }); + // Should still be 5_000_000 from the first call, not doubled or errored + expect(snap?.currentPrice.toString()).toBe('5000000'); + }); }); diff --git a/src/modules/indexer/price-snapshot.service.ts b/src/modules/indexer/price-snapshot.service.ts index 826c13a..84ae5ca 100644 --- a/src/modules/indexer/price-snapshot.service.ts +++ b/src/modules/indexer/price-snapshot.service.ts @@ -6,11 +6,11 @@ import { prisma } from '../../utils/prisma.utils'; import { logger } from '../../utils/logger.utils'; export interface TradeEventPayload { - creatorId: string; - /** Trade price in stroops */ - price: bigint; - /** ISO timestamp of the trade */ - tradeAt: Date; + creatorId: string; + /** Trade price in stroops */ + price: bigint; + /** ISO timestamp of the trade */ + tradeAt: Date; } /** @@ -22,76 +22,80 @@ export interface TradeEventPayload { * * Idempotent: re-processing the same event produces the same state. */ -export async function upsertPriceSnapshot(event: TradeEventPayload): Promise { - const { creatorId, price, tradeAt } = event; +export async function upsertPriceSnapshot( + event: TradeEventPayload +): Promise { + const { creatorId, price, tradeAt } = event; - try { - const existing = await prisma.creatorPriceSnapshot.findUnique({ - where: { creatorId }, - }); - - if (!existing) { - // First ever trade — seed both price fields with current price. - await prisma.creatorPriceSnapshot.create({ - data: { - creatorId, - currentPrice: price, - price24hAgo: price, - lastTradeAt: tradeAt, - }, + try { + const existing = await prisma.creatorPriceSnapshot.findUnique({ + where: { creatorId }, }); - logger.debug( - { - creator_id: creatorId, - new_price: price.toString(), - previous_price: null, - ledger_sequence: null, - written_at: tradeAt.toISOString(), - }, - 'price-snapshot: written (first trade)' - ); - return; - } - // Idempotency: skip if this event is older than the last recorded trade. - if (existing.lastTradeAt && tradeAt <= existing.lastTradeAt) { - logger.debug( - { creatorId, tradeAt, lastTradeAt: existing.lastTradeAt }, - 'price-snapshot: skipping stale event (idempotency guard)' - ); - return; - } + if (!existing) { + // First ever trade — seed both price fields with current price. + await prisma.creatorPriceSnapshot.create({ + data: { + creatorId, + currentPrice: price, + price24hAgo: price, + lastTradeAt: tradeAt, + }, + }); + logger.debug( + { + creator_id: creatorId, + new_price: price.toString(), + previous_price: null, + ledger_sequence: null, + written_at: tradeAt.toISOString(), + }, + 'price-snapshot: written (first trade)' + ); + return; + } - // Skip write when price is unchanged. - if (existing.currentPrice.toString() === price.toString()) { - return; - } + // Idempotency: skip if this event is older than the last recorded trade. + if (existing.lastTradeAt && tradeAt <= existing.lastTradeAt) { + logger.debug( + { creatorId, tradeAt, lastTradeAt: existing.lastTradeAt }, + 'price-snapshot: skipping stale event (idempotency guard)' + ); + return; + } - // Promote currentPrice → price24hAgo when the snapshot is older than 24 h. - const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); - const shouldRotate24h = - existing.lastTradeAt && existing.lastTradeAt < twentyFourHoursAgo; + // Skip write when price is unchanged. + if (existing.currentPrice.toString() === price.toString()) { + return; + } - await prisma.creatorPriceSnapshot.update({ - where: { creatorId }, - data: { - currentPrice: price, - price24hAgo: shouldRotate24h ? existing.currentPrice : existing.price24hAgo, - lastTradeAt: tradeAt, - }, - }); - logger.debug( - { - creator_id: creatorId, - new_price: price.toString(), - previous_price: existing.currentPrice.toString(), - ledger_sequence: null, - written_at: tradeAt.toISOString(), - }, - 'price-snapshot: written' - ); - } catch (err) { - logger.error({ err, creatorId }, 'price-snapshot: failed to upsert'); - throw err; - } + // Promote currentPrice → price24hAgo when the snapshot is older than 24 h. + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const shouldRotate24h = + existing.lastTradeAt && existing.lastTradeAt < twentyFourHoursAgo; + + await prisma.creatorPriceSnapshot.update({ + where: { creatorId }, + data: { + currentPrice: price, + price24hAgo: shouldRotate24h + ? existing.currentPrice + : existing.price24hAgo, + lastTradeAt: tradeAt, + }, + }); + logger.debug( + { + creator_id: creatorId, + new_price: price.toString(), + previous_price: existing.currentPrice.toString(), + ledger_sequence: null, + written_at: tradeAt.toISOString(), + }, + 'price-snapshot: written' + ); + } catch (err) { + logger.error({ err, creatorId }, 'price-snapshot: failed to upsert'); + throw err; + } } diff --git a/src/modules/ledger/ledger.controllers.test.ts b/src/modules/ledger/ledger.controllers.test.ts index eb73dcb..7c0b993 100644 --- a/src/modules/ledger/ledger.controllers.test.ts +++ b/src/modules/ledger/ledger.controllers.test.ts @@ -5,80 +5,97 @@ import { sendSuccess } from '../../utils/api-response.utils'; // Mock prisma and api-response utils jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - indexedLedger: { - findFirst: jest.fn(), - }, - }, + prisma: { + indexedLedger: { + findFirst: jest.fn(), + }, + }, })); jest.mock('../../utils/api-response.utils', () => ({ - sendSuccess: jest.fn(), + sendSuccess: jest.fn(), })); jest.mock('../../utils/timestamp-headers.utils', () => ({ - attachTimestampHeader: jest.fn(), + attachTimestampHeader: jest.fn(), })); describe('Ledger Controller', () => { - let mockRequest: Partial; - let mockResponse: Partial; - let jsonFn: jest.Mock; - let statusFn: jest.Mock; + let mockRequest: Partial; + let mockResponse: Partial; + let jsonFn: jest.Mock; + let statusFn: jest.Mock; - beforeEach(() => { - jsonFn = jest.fn(); - statusFn = jest.fn().mockReturnValue({ json: jsonFn }); - mockRequest = {}; - mockResponse = { - status: statusFn, - json: jsonFn, - set: jest.fn(), - }; - jest.clearAllMocks(); - }); + beforeEach(() => { + jsonFn = jest.fn(); + statusFn = jest.fn().mockReturnValue({ json: jsonFn }); + mockRequest = {}; + mockResponse = { + status: statusFn, + json: jsonFn, + set: jest.fn(), + }; + jest.clearAllMocks(); + }); - it('returns 200 with empty data when no ledger is found', async () => { - (prisma.indexedLedger.findFirst as jest.Mock).mockResolvedValue(null); + it('returns 200 with empty data when no ledger is found', async () => { + (prisma.indexedLedger.findFirst as jest.Mock).mockResolvedValue(null); - await httpGetLedgerStatus(mockRequest as Request, mockResponse as Response); + await httpGetLedgerStatus( + mockRequest as Request, + mockResponse as Response + ); - expect(statusFn).toHaveBeenCalledWith(200); - expect(jsonFn).toHaveBeenCalledWith(expect.objectContaining({ - success: true, - data: expect.objectContaining({ - ledger: 0, - message: 'No ledgers indexed yet', - }), - })); - }); + expect(statusFn).toHaveBeenCalledWith(200); + expect(jsonFn).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + ledger: 0, + message: 'No ledgers indexed yet', + }), + }) + ); + }); - it('returns 200 with ledger data when a record exists', async () => { - const mockLedger = { - ledger: 12345, - cursor: 'abc-123', - updatedAt: new Date('2024-01-01T00:00:00Z'), - }; - (prisma.indexedLedger.findFirst as jest.Mock).mockResolvedValue(mockLedger); + it('returns 200 with ledger data when a record exists', async () => { + const mockLedger = { + ledger: 12345, + cursor: 'abc-123', + updatedAt: new Date('2024-01-01T00:00:00Z'), + }; + (prisma.indexedLedger.findFirst as jest.Mock).mockResolvedValue( + mockLedger + ); - await httpGetLedgerStatus(mockRequest as Request, mockResponse as Response); + await httpGetLedgerStatus( + mockRequest as Request, + mockResponse as Response + ); - expect(sendSuccess).toHaveBeenCalledWith(mockResponse, { - ledger: mockLedger.ledger, - cursor: mockLedger.cursor, - updatedAt: mockLedger.updatedAt.toISOString(), - }); - }); + expect(sendSuccess).toHaveBeenCalledWith(mockResponse, { + ledger: mockLedger.ledger, + cursor: mockLedger.cursor, + updatedAt: mockLedger.updatedAt.toISOString(), + }); + }); - it('returns 500 when database query fails', async () => { - (prisma.indexedLedger.findFirst as jest.Mock).mockRejectedValue(new Error('DB Error')); + it('returns 500 when database query fails', async () => { + (prisma.indexedLedger.findFirst as jest.Mock).mockRejectedValue( + new Error('DB Error') + ); - await httpGetLedgerStatus(mockRequest as Request, mockResponse as Response); + await httpGetLedgerStatus( + mockRequest as Request, + mockResponse as Response + ); - expect(statusFn).toHaveBeenCalledWith(500); - expect(jsonFn).toHaveBeenCalledWith(expect.objectContaining({ - success: false, - message: 'Failed to fetch ledger status', - })); - }); + expect(statusFn).toHaveBeenCalledWith(500); + expect(jsonFn).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: 'Failed to fetch ledger status', + }) + ); + }); }); diff --git a/src/modules/ledger/ledger.controllers.ts b/src/modules/ledger/ledger.controllers.ts index 0f69c31..5152c35 100644 --- a/src/modules/ledger/ledger.controllers.ts +++ b/src/modules/ledger/ledger.controllers.ts @@ -5,40 +5,43 @@ import { attachTimestampHeader } from '../../utils/timestamp-headers.utils'; /** * Controller for GET /api/v1/ledger/status - * + * * Returns the latest indexed ledger, cursor, and sync timestamp. * Used by clients to verify sync state and by ops for monitoring. */ -export const httpGetLedgerStatus = async (_req: Request, res: Response): Promise => { - try { - const status = await prisma.indexedLedger.findFirst({ - orderBy: { updatedAt: 'desc' }, - }); - - if (!status) { - res.status(200).json({ - success: true, - data: { - ledger: 0, - cursor: '0', - updatedAt: null, - message: 'No ledgers indexed yet', - }, +export const httpGetLedgerStatus = async ( + _req: Request, + res: Response +): Promise => { + try { + const status = await prisma.indexedLedger.findFirst({ + orderBy: { updatedAt: 'desc' }, }); - return; - } - attachTimestampHeader(res); - sendSuccess(res, { - ledger: status.ledger, - cursor: status.cursor, - updatedAt: status.updatedAt.toISOString(), - }); - } catch (error) { - console.error('Failed to fetch ledger status:', error); - res.status(500).json({ - success: false, - message: 'Failed to fetch ledger status', - }); - } + if (!status) { + res.status(200).json({ + success: true, + data: { + ledger: 0, + cursor: '0', + updatedAt: null, + message: 'No ledgers indexed yet', + }, + }); + return; + } + + attachTimestampHeader(res); + sendSuccess(res, { + ledger: status.ledger, + cursor: status.cursor, + updatedAt: status.updatedAt.toISOString(), + }); + } catch (error) { + console.error('Failed to fetch ledger status:', error); + res.status(500).json({ + success: false, + message: 'Failed to fetch ledger status', + }); + } }; diff --git a/src/modules/ledger/ledger.routes.ts b/src/modules/ledger/ledger.routes.ts index af2203a..bdac1c5 100644 --- a/src/modules/ledger/ledger.routes.ts +++ b/src/modules/ledger/ledger.routes.ts @@ -5,7 +5,7 @@ const ledgerRouter = Router(); /** * GET /api/v1/ledger/status - * + * * Public read-only endpoint for ledger sync state. */ ledgerRouter.get('/status', httpGetLedgerStatus); diff --git a/src/modules/ledger/ledger.schema.ts b/src/modules/ledger/ledger.schema.ts index 83a9662..6ca1d97 100644 --- a/src/modules/ledger/ledger.schema.ts +++ b/src/modules/ledger/ledger.schema.ts @@ -2,15 +2,15 @@ * Response shape for the ledger status endpoint. */ export interface LedgerStatusResponse { - /** Whether the request was successful */ - success: boolean; - /** Ledger status data */ - data: { - /** Latest indexed ledger number */ - ledger: number; - /** Opaque cursor for the latest ledger */ - cursor: string; - /** ISO timestamp of the last successful sync */ - updatedAt: string | null; - }; + /** Whether the request was successful */ + success: boolean; + /** Ledger status data */ + data: { + /** Latest indexed ledger number */ + ledger: number; + /** Opaque cursor for the latest ledger */ + cursor: string; + /** ISO timestamp of the last successful sync */ + updatedAt: string | null; + }; } diff --git a/src/modules/metrics/metrics.controllers.test.ts b/src/modules/metrics/metrics.controllers.test.ts index f870724..17298c5 100644 --- a/src/modules/metrics/metrics.controllers.test.ts +++ b/src/modules/metrics/metrics.controllers.test.ts @@ -1,5 +1,7 @@ jest.mock('../../utils/queue-metrics.utils', () => ({ - getQueueDepths: jest.fn(() => [{ queue: 'ingest', state: 'pending', depth: 3 }]), + getQueueDepths: jest.fn(() => [ + { queue: 'ingest', state: 'pending', depth: 3 }, + ]), })); jest.mock('../../utils/creator-read-metrics.utils', () => ({ @@ -78,4 +80,4 @@ describe('metrics.controllers', () => { }) ); }); -}); \ No newline at end of file +}); diff --git a/src/modules/metrics/metrics.controllers.ts b/src/modules/metrics/metrics.controllers.ts index 43883de..f9b7c02 100644 --- a/src/modules/metrics/metrics.controllers.ts +++ b/src/modules/metrics/metrics.controllers.ts @@ -3,18 +3,18 @@ import { getQueueDepths } from '../../utils/queue-metrics.utils'; import { getCreatorReadMetrics } from '../../utils/creator-read-metrics.utils'; export const queueMetrics = (_: Request, res: Response): void => { - const queues = getQueueDepths(); - res.status(200).json({ - timestamp: new Date().toISOString(), - queues, - }); + const queues = getQueueDepths(); + res.status(200).json({ + timestamp: new Date().toISOString(), + queues, + }); }; export const creatorReadMetrics = (_: Request, res: Response): void => { - const creatorReads = getCreatorReadMetrics(); + const creatorReads = getCreatorReadMetrics(); - res.status(200).json({ - timestamp: new Date().toISOString(), - creatorReads, - }); + res.status(200).json({ + timestamp: new Date().toISOString(), + creatorReads, + }); }; diff --git a/src/modules/ownership/ownership-update-log.test.ts b/src/modules/ownership/ownership-update-log.test.ts index cfc264c..82156b0 100644 --- a/src/modules/ownership/ownership-update-log.test.ts +++ b/src/modules/ownership/ownership-update-log.test.ts @@ -3,110 +3,134 @@ import { logger } from '../../utils/logger.utils'; import { prisma } from '../../utils/prisma.utils'; jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - keyOwnership: { - findFirst: jest.fn(), - upsert: jest.fn(), - }, - }, + prisma: { + keyOwnership: { + findFirst: jest.fn(), + upsert: jest.fn(), + }, + }, })); jest.mock('../../utils/logger.utils', () => ({ - logger: { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, - HTTP_STATUS: {}, + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + HTTP_STATUS: {}, })); const mockPrisma = prisma as jest.Mocked; function mockOwnership(balance: number) { - return { - id: 'own-1', - ownerAddress: 'GTEST1234GTEST1234GTEST1234GTEST1234GTEST1234GTEST12345', - creatorId: 'creator-1', - balance: BigInt(balance), - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-02'), - }; + return { + id: 'own-1', + ownerAddress: 'GTEST1234GTEST1234GTEST1234GTEST1234GTEST1234GTEST12345', + creatorId: 'creator-1', + balance: BigInt(balance), + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-02'), + }; } describe('updateOwnership — structured debug log', () => { - const ownerAddress = 'GTEST1234GTEST1234GTEST1234GTEST1234GTEST1234GTEST12345'; - const creatorId = 'creator-1'; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('emits debug log with all six required fields after successful update', async () => { - (mockPrisma.keyOwnership.findFirst as jest.Mock).mockResolvedValue(mockOwnership(10)); - (mockPrisma.keyOwnership.upsert as jest.Mock).mockResolvedValue(mockOwnership(15)); - - await updateOwnership(ownerAddress, creatorId, 5, { - event_type: 'buy', - ledger_sequence: 42, - }); - - expect(logger.debug).toHaveBeenCalledTimes(1); - const [logFields, _msg] = (logger.debug as jest.Mock).mock.calls[0]; - - expect(logFields).toMatchObject({ - creator_id: creatorId, - previous_balance: 10, - new_balance: 15, - event_type: 'buy', - ledger_sequence: 42, - }); - expect(typeof logFields.wallet_address).toBe('string'); - expect(logFields.wallet_address).not.toBe(ownerAddress); // masked - expect(logFields.wallet_address).toContain('GTES'); // first 4 - expect(logFields.wallet_address).toContain('2345'); // last 4 - }); - - it('masks wallet address to first 4 and last 4 characters', async () => { - (mockPrisma.keyOwnership.findFirst as jest.Mock).mockResolvedValue(null); - (mockPrisma.keyOwnership.upsert as jest.Mock).mockResolvedValue(mockOwnership(5)); - - await updateOwnership(ownerAddress, creatorId, 5); - - const [logFields] = (logger.debug as jest.Mock).mock.calls[0]; - // masked address should not be the full address - expect(logFields.wallet_address.length).toBeLessThan(ownerAddress.length); - expect(logFields.wallet_address.startsWith(ownerAddress.slice(0, 4))).toBe(true); - expect(logFields.wallet_address.endsWith(ownerAddress.slice(-4))).toBe(true); - }); - - it('sets previous_balance to 0 when record is new (create path)', async () => { - (mockPrisma.keyOwnership.findFirst as jest.Mock).mockResolvedValue(null); - (mockPrisma.keyOwnership.upsert as jest.Mock).mockResolvedValue(mockOwnership(10)); - - await updateOwnership(ownerAddress, creatorId, 10, { event_type: 'buy' }); - - const [logFields] = (logger.debug as jest.Mock).mock.calls[0]; - expect(logFields.previous_balance).toBe(0); - expect(logFields.new_balance).toBe(10); - }); - - it('correctly logs sell event type', async () => { - (mockPrisma.keyOwnership.findFirst as jest.Mock).mockResolvedValue(mockOwnership(20)); - (mockPrisma.keyOwnership.upsert as jest.Mock).mockResolvedValue(mockOwnership(15)); - - await updateOwnership(ownerAddress, creatorId, -5, { event_type: 'sell', ledger_sequence: 100 }); - - const [logFields] = (logger.debug as jest.Mock).mock.calls[0]; - expect(logFields.event_type).toBe('sell'); - expect(logFields.ledger_sequence).toBe(100); - }); - - it('does not emit log when upsert throws', async () => { - (mockPrisma.keyOwnership.findFirst as jest.Mock).mockResolvedValue(null); - (mockPrisma.keyOwnership.upsert as jest.Mock).mockRejectedValue(new Error('DB error')); - - await expect(updateOwnership(ownerAddress, creatorId, 5)).rejects.toThrow('DB error'); - expect(logger.debug).not.toHaveBeenCalled(); - }); + const ownerAddress = + 'GTEST1234GTEST1234GTEST1234GTEST1234GTEST1234GTEST12345'; + const creatorId = 'creator-1'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('emits debug log with all six required fields after successful update', async () => { + (mockPrisma.keyOwnership.findFirst as jest.Mock).mockResolvedValue( + mockOwnership(10) + ); + (mockPrisma.keyOwnership.upsert as jest.Mock).mockResolvedValue( + mockOwnership(15) + ); + + await updateOwnership(ownerAddress, creatorId, 5, { + event_type: 'buy', + ledger_sequence: 42, + }); + + expect(logger.debug).toHaveBeenCalledTimes(1); + const [logFields, _msg] = (logger.debug as jest.Mock).mock.calls[0]; + + expect(logFields).toMatchObject({ + creator_id: creatorId, + previous_balance: 10, + new_balance: 15, + event_type: 'buy', + ledger_sequence: 42, + }); + expect(typeof logFields.wallet_address).toBe('string'); + expect(logFields.wallet_address).not.toBe(ownerAddress); // masked + expect(logFields.wallet_address).toContain('GTES'); // first 4 + expect(logFields.wallet_address).toContain('2345'); // last 4 + }); + + it('masks wallet address to first 4 and last 4 characters', async () => { + (mockPrisma.keyOwnership.findFirst as jest.Mock).mockResolvedValue(null); + (mockPrisma.keyOwnership.upsert as jest.Mock).mockResolvedValue( + mockOwnership(5) + ); + + await updateOwnership(ownerAddress, creatorId, 5); + + const [logFields] = (logger.debug as jest.Mock).mock.calls[0]; + // masked address should not be the full address + expect(logFields.wallet_address.length).toBeLessThan(ownerAddress.length); + expect( + logFields.wallet_address.startsWith(ownerAddress.slice(0, 4)) + ).toBe(true); + expect(logFields.wallet_address.endsWith(ownerAddress.slice(-4))).toBe( + true + ); + }); + + it('sets previous_balance to 0 when record is new (create path)', async () => { + (mockPrisma.keyOwnership.findFirst as jest.Mock).mockResolvedValue(null); + (mockPrisma.keyOwnership.upsert as jest.Mock).mockResolvedValue( + mockOwnership(10) + ); + + await updateOwnership(ownerAddress, creatorId, 10, { event_type: 'buy' }); + + const [logFields] = (logger.debug as jest.Mock).mock.calls[0]; + expect(logFields.previous_balance).toBe(0); + expect(logFields.new_balance).toBe(10); + }); + + it('correctly logs sell event type', async () => { + (mockPrisma.keyOwnership.findFirst as jest.Mock).mockResolvedValue( + mockOwnership(20) + ); + (mockPrisma.keyOwnership.upsert as jest.Mock).mockResolvedValue( + mockOwnership(15) + ); + + await updateOwnership(ownerAddress, creatorId, -5, { + event_type: 'sell', + ledger_sequence: 100, + }); + + const [logFields] = (logger.debug as jest.Mock).mock.calls[0]; + expect(logFields.event_type).toBe('sell'); + expect(logFields.ledger_sequence).toBe(100); + }); + + it('does not emit log when upsert throws', async () => { + (mockPrisma.keyOwnership.findFirst as jest.Mock).mockResolvedValue(null); + (mockPrisma.keyOwnership.upsert as jest.Mock).mockRejectedValue( + new Error('DB error') + ); + + await expect(updateOwnership(ownerAddress, creatorId, 5)).rejects.toThrow( + 'DB error' + ); + expect(logger.debug).not.toHaveBeenCalled(); + }); }); diff --git a/src/modules/ownership/ownership.controllers.ts b/src/modules/ownership/ownership.controllers.ts index 8d397c5..cbc7102 100644 --- a/src/modules/ownership/ownership.controllers.ts +++ b/src/modules/ownership/ownership.controllers.ts @@ -2,32 +2,39 @@ import { AsyncController } from '../../types/auth.types'; import { OwnershipQuerySchema } from './ownership.schemas'; import { fetchOwnership } from './ownership.service'; import { calculateTotalPortfolioValue } from './ownership.utils'; -import { sendSuccess, sendValidationError } from '../../utils/api-response.utils'; +import { + sendSuccess, + sendValidationError, +} from '../../utils/api-response.utils'; export const httpGetOwnership: AsyncController = async (req, res, next) => { - try { - const parsed = OwnershipQuerySchema.safeParse(req.query); - if (!parsed.success) { - return sendValidationError(res, 'Invalid query parameters', parsed.error.issues.map(issue => ({ - field: issue.path.join('.'), - message: issue.message, - }))); - } + try { + const parsed = OwnershipQuerySchema.safeParse(req.query); + if (!parsed.success) { + return sendValidationError( + res, + 'Invalid query parameters', + parsed.error.issues.map(issue => ({ + field: issue.path.join('.'), + message: issue.message, + })) + ); + } - const records = await fetchOwnership(parsed.data); - const holdings = records.map(record => ({ - id: record.id, - ownerAddress: record.ownerAddress, - creatorId: record.creatorId, - balance: record.balance.toString(), - currentPrice: '0', - updatedAt: record.updatedAt, - })); - sendSuccess(res, { - holdings, - total_portfolio_value: calculateTotalPortfolioValue(holdings), - }); - } catch (error) { - next(error); - } + const records = await fetchOwnership(parsed.data); + const holdings = records.map(record => ({ + id: record.id, + ownerAddress: record.ownerAddress, + creatorId: record.creatorId, + balance: record.balance.toString(), + currentPrice: '0', + updatedAt: record.updatedAt, + })); + sendSuccess(res, { + holdings, + total_portfolio_value: calculateTotalPortfolioValue(holdings), + }); + } catch (error) { + next(error); + } }; diff --git a/src/modules/ownership/ownership.routes.ts b/src/modules/ownership/ownership.routes.ts index 9a30347..594656d 100644 --- a/src/modules/ownership/ownership.routes.ts +++ b/src/modules/ownership/ownership.routes.ts @@ -5,7 +5,7 @@ const ownershipRouter = Router(); /** * GET /api/v1/ownership - * + * * Lookup key ownership by owner address or creator ID. */ ownershipRouter.get('/', httpGetOwnership); diff --git a/src/modules/ownership/ownership.schemas.ts b/src/modules/ownership/ownership.schemas.ts index 26ff998..6635788 100644 --- a/src/modules/ownership/ownership.schemas.ts +++ b/src/modules/ownership/ownership.schemas.ts @@ -1,32 +1,34 @@ import { z } from 'zod'; -export const OwnershipQuerySchema = z.object({ - ownerAddress: z.string().optional(), - creatorId: z.string().optional(), -}).strict(); +export const OwnershipQuerySchema = z + .object({ + ownerAddress: z.string().optional(), + creatorId: z.string().optional(), + }) + .strict(); export type OwnershipQueryType = z.infer; export const OwnershipItemSchema = z.object({ - id: z.string(), - ownerAddress: z.string(), - creatorId: z.string(), - balance: z.string(), - updatedAt: z.date(), + id: z.string(), + ownerAddress: z.string(), + creatorId: z.string(), + balance: z.string(), + updatedAt: z.date(), }); export const HoldingItemSchema = z.object({ - id: z.string(), - ownerAddress: z.string(), - creatorId: z.string(), - balance: z.string(), - currentPrice: z.string(), - updatedAt: z.date(), + id: z.string(), + ownerAddress: z.string(), + creatorId: z.string(), + balance: z.string(), + currentPrice: z.string(), + updatedAt: z.date(), }); export const HoldingsResponseSchema = z.object({ - holdings: z.array(HoldingItemSchema), - total_portfolio_value: z.string(), + holdings: z.array(HoldingItemSchema), + total_portfolio_value: z.string(), }); export const OwnershipResponseSchema = z.array(OwnershipItemSchema); diff --git a/src/modules/ownership/ownership.service.test.ts b/src/modules/ownership/ownership.service.test.ts index e82d623..70b0031 100644 --- a/src/modules/ownership/ownership.service.test.ts +++ b/src/modules/ownership/ownership.service.test.ts @@ -1,10 +1,10 @@ import { fetchOwnership, updateOwnership } from './ownership.service'; describe('Ownership Service', () => { - it('should return ownership record after update', async () => { - // Note: This relies on real DB or mock - // For now we just verify it exists as a function - expect(typeof fetchOwnership).toBe('function'); - expect(typeof updateOwnership).toBe('function'); - }); + it('should return ownership record after update', async () => { + // Note: This relies on real DB or mock + // For now we just verify it exists as a function + expect(typeof fetchOwnership).toBe('function'); + expect(typeof updateOwnership).toBe('function'); + }); }); diff --git a/src/modules/ownership/ownership.service.ts b/src/modules/ownership/ownership.service.ts index 86863d5..65cb7d4 100644 --- a/src/modules/ownership/ownership.service.ts +++ b/src/modules/ownership/ownership.service.ts @@ -2,70 +2,75 @@ import { prisma } from '../../utils/prisma.utils'; import { logger } from '../../utils/logger.utils'; import { OwnershipQueryType } from './ownership.schemas'; -type KeyOwnership = NonNullable>>; +type KeyOwnership = NonNullable< + Awaited> +>; function maskAddress(address: string): string { - if (address.length <= 8) return address; - return `${address.slice(0, 4)}…${address.slice(-4)}`; + if (address.length <= 8) return address; + return `${address.slice(0, 4)}…${address.slice(-4)}`; } export async function fetchOwnership( - query: OwnershipQueryType + query: OwnershipQueryType ): Promise { - const { ownerAddress, creatorId } = query; + const { ownerAddress, creatorId } = query; - const where: any = {}; - if (ownerAddress) where.ownerAddress = ownerAddress; - if (creatorId) where.creatorId = creatorId; + const where: any = {}; + if (ownerAddress) where.ownerAddress = ownerAddress; + if (creatorId) where.creatorId = creatorId; - return prisma.keyOwnership.findMany({ - where, - orderBy: { updatedAt: 'desc' }, - }); + return prisma.keyOwnership.findMany({ + where, + orderBy: { updatedAt: 'desc' }, + }); } export interface OwnershipUpdateContext { - event_type?: 'buy' | 'sell'; - ledger_sequence?: number; + event_type?: 'buy' | 'sell'; + ledger_sequence?: number; } export async function updateOwnership( - ownerAddress: string, - creatorId: string, - balanceChange: number, - ctx: OwnershipUpdateContext = {}, + ownerAddress: string, + creatorId: string, + balanceChange: number, + ctx: OwnershipUpdateContext = {} ): Promise { - const existing = await prisma.keyOwnership.findFirst({ - where: { ownerAddress, creatorId }, - select: { balance: true }, - }); - const previousBalance = existing ? Number(existing.balance) : 0; + const existing = await prisma.keyOwnership.findFirst({ + where: { ownerAddress, creatorId }, + select: { balance: true }, + }); + const previousBalance = existing ? Number(existing.balance) : 0; - const result = await prisma.keyOwnership.upsert({ - where: { - ownerAddress_creatorId: { - ownerAddress, - creatorId, - }, - }, - update: { - balance: { increment: balanceChange }, - }, - create: { + const result = await prisma.keyOwnership.upsert({ + where: { + ownerAddress_creatorId: { ownerAddress, creatorId, - balance: balanceChange, - }, - }); + }, + }, + update: { + balance: { increment: balanceChange }, + }, + create: { + ownerAddress, + creatorId, + balance: balanceChange, + }, + }); - logger.debug({ - creator_id: creatorId, - wallet_address: maskAddress(ownerAddress), - previous_balance: previousBalance, - new_balance: Number(result.balance), - event_type: ctx.event_type, - ledger_sequence: ctx.ledger_sequence, - }, 'Ownership read model updated'); + logger.debug( + { + creator_id: creatorId, + wallet_address: maskAddress(ownerAddress), + previous_balance: previousBalance, + new_balance: Number(result.balance), + event_type: ctx.event_type, + ledger_sequence: ctx.ledger_sequence, + }, + 'Ownership read model updated' + ); - return result; + return result; } diff --git a/src/modules/ownership/ownership.utils.test.ts b/src/modules/ownership/ownership.utils.test.ts index fa64b24..695bbde 100644 --- a/src/modules/ownership/ownership.utils.test.ts +++ b/src/modules/ownership/ownership.utils.test.ts @@ -1,39 +1,39 @@ import { calculateTotalPortfolioValue, HoldingEntry } from './ownership.utils'; describe('calculateTotalPortfolioValue', () => { - it('returns correct sum for a single holding', () => { - const entries: HoldingEntry[] = [ - { balance: '100', currentPrice: '5.50' }, - ]; - expect(calculateTotalPortfolioValue(entries)).toBe('550'); - }); + it('returns correct sum for a single holding', () => { + const entries: HoldingEntry[] = [ + { balance: '100', currentPrice: '5.50' }, + ]; + expect(calculateTotalPortfolioValue(entries)).toBe('550'); + }); - it('returns correct sum for multiple holdings', () => { - const entries: HoldingEntry[] = [ - { balance: '100', currentPrice: '5.50' }, - { balance: '200', currentPrice: '10.00' }, - { balance: '50', currentPrice: '2.00' }, - ]; - expect(calculateTotalPortfolioValue(entries)).toBe('2650'); - }); + it('returns correct sum for multiple holdings', () => { + const entries: HoldingEntry[] = [ + { balance: '100', currentPrice: '5.50' }, + { balance: '200', currentPrice: '10.00' }, + { balance: '50', currentPrice: '2.00' }, + ]; + expect(calculateTotalPortfolioValue(entries)).toBe('2650'); + }); - it('returns zero when all prices are zero', () => { - const entries: HoldingEntry[] = [ - { balance: '100', currentPrice: '0' }, - { balance: '200', currentPrice: '0' }, - ]; - expect(calculateTotalPortfolioValue(entries)).toBe('0'); - }); + it('returns zero when all prices are zero', () => { + const entries: HoldingEntry[] = [ + { balance: '100', currentPrice: '0' }, + { balance: '200', currentPrice: '0' }, + ]; + expect(calculateTotalPortfolioValue(entries)).toBe('0'); + }); - it('returns zero when all balances are zero', () => { - const entries: HoldingEntry[] = [ - { balance: '0', currentPrice: '5.50' }, - { balance: '0', currentPrice: '10.00' }, - ]; - expect(calculateTotalPortfolioValue(entries)).toBe('0'); - }); + it('returns zero when all balances are zero', () => { + const entries: HoldingEntry[] = [ + { balance: '0', currentPrice: '5.50' }, + { balance: '0', currentPrice: '10.00' }, + ]; + expect(calculateTotalPortfolioValue(entries)).toBe('0'); + }); - it('returns zero for an empty array', () => { - expect(calculateTotalPortfolioValue([])).toBe('0'); - }); + it('returns zero for an empty array', () => { + expect(calculateTotalPortfolioValue([])).toBe('0'); + }); }); diff --git a/src/modules/ownership/ownership.utils.ts b/src/modules/ownership/ownership.utils.ts index 587c5f3..71b2aa5 100644 --- a/src/modules/ownership/ownership.utils.ts +++ b/src/modules/ownership/ownership.utils.ts @@ -1,12 +1,12 @@ export interface HoldingEntry { - balance: string; - currentPrice: string; + balance: string; + currentPrice: string; } export function calculateTotalPortfolioValue(entries: HoldingEntry[]): string { - const total = entries.reduce( - (sum, entry) => sum + Number(entry.balance) * Number(entry.currentPrice), - 0, - ); - return String(total); + const total = entries.reduce( + (sum, entry) => sum + Number(entry.balance) * Number(entry.currentPrice), + 0 + ); + return String(total); } diff --git a/src/modules/wallet/__tests__/wallet.utils.test.ts b/src/modules/wallet/__tests__/wallet.utils.test.ts index 900e7f1..7e586aa 100644 --- a/src/modules/wallet/__tests__/wallet.utils.test.ts +++ b/src/modules/wallet/__tests__/wallet.utils.test.ts @@ -3,79 +3,82 @@ import { isValidStellarAddress, StellarAddressSchema } from '../wallet.utils'; // 56-character valid Stellar G address (all uppercase base32 chars A-Z, 2-7) -const VALID_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const VALID_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; describe('isValidStellarAddress', () => { - it('returns true for a valid Stellar G... address', () => { - expect(isValidStellarAddress(VALID_ADDRESS)).toBe(true); - }); - - it('returns true for an address using digits 2-7', () => { - // Replace some chars with valid Base32 digits - const addr = 'G' + '2'.repeat(55); - expect(isValidStellarAddress(addr)).toBe(true); - }); - - it('returns false when the address does not start with G', () => { - const addr = 'A' + 'A'.repeat(55); - expect(isValidStellarAddress(addr)).toBe(false); - }); - - it('returns false when the address is too short (55 chars)', () => { - const addr = 'G' + 'A'.repeat(54); - expect(isValidStellarAddress(addr)).toBe(false); - }); - - it('returns false when the address is too long (57 chars)', () => { - const addr = 'G' + 'A'.repeat(56); - expect(isValidStellarAddress(addr)).toBe(false); - }); - - it('returns false when the address contains invalid characters (lowercase)', () => { - const addr = 'G' + 'a'.repeat(55); - expect(isValidStellarAddress(addr)).toBe(false); - }); - - it('returns false when the address contains invalid digits (0, 1, 8, 9)', () => { - const addr = 'G' + '0'.repeat(55); - expect(isValidStellarAddress(addr)).toBe(false); - }); - - it('returns false for an empty string', () => { - expect(isValidStellarAddress('')).toBe(false); - }); - - it('returns false for a random non-address string', () => { - expect(isValidStellarAddress('not-a-stellar-address')).toBe(false); - }); + it('returns true for a valid Stellar G... address', () => { + expect(isValidStellarAddress(VALID_ADDRESS)).toBe(true); + }); + + it('returns true for an address using digits 2-7', () => { + // Replace some chars with valid Base32 digits + const addr = 'G' + '2'.repeat(55); + expect(isValidStellarAddress(addr)).toBe(true); + }); + + it('returns false when the address does not start with G', () => { + const addr = 'A' + 'A'.repeat(55); + expect(isValidStellarAddress(addr)).toBe(false); + }); + + it('returns false when the address is too short (55 chars)', () => { + const addr = 'G' + 'A'.repeat(54); + expect(isValidStellarAddress(addr)).toBe(false); + }); + + it('returns false when the address is too long (57 chars)', () => { + const addr = 'G' + 'A'.repeat(56); + expect(isValidStellarAddress(addr)).toBe(false); + }); + + it('returns false when the address contains invalid characters (lowercase)', () => { + const addr = 'G' + 'a'.repeat(55); + expect(isValidStellarAddress(addr)).toBe(false); + }); + + it('returns false when the address contains invalid digits (0, 1, 8, 9)', () => { + const addr = 'G' + '0'.repeat(55); + expect(isValidStellarAddress(addr)).toBe(false); + }); + + it('returns false for an empty string', () => { + expect(isValidStellarAddress('')).toBe(false); + }); + + it('returns false for a random non-address string', () => { + expect(isValidStellarAddress('not-a-stellar-address')).toBe(false); + }); }); describe('StellarAddressSchema', () => { - it('passes for a valid Stellar address', () => { - const result = StellarAddressSchema.safeParse(VALID_ADDRESS); - expect(result.success).toBe(true); - }); - - it('fails with the correct message for a wrong first character', () => { - const result = StellarAddressSchema.safeParse('A' + 'A'.repeat(55)); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues[0].message).toBe('Invalid Stellar wallet address'); - } - }); - - it('fails for wrong length', () => { - const result = StellarAddressSchema.safeParse('G' + 'A'.repeat(54)); - expect(result.success).toBe(false); - }); - - it('fails for invalid characters', () => { - const result = StellarAddressSchema.safeParse('G' + '!'.repeat(55)); - expect(result.success).toBe(false); - }); - - it('fails for an empty string', () => { - const result = StellarAddressSchema.safeParse(''); - expect(result.success).toBe(false); - }); + it('passes for a valid Stellar address', () => { + const result = StellarAddressSchema.safeParse(VALID_ADDRESS); + expect(result.success).toBe(true); + }); + + it('fails with the correct message for a wrong first character', () => { + const result = StellarAddressSchema.safeParse('A' + 'A'.repeat(55)); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe( + 'Invalid Stellar wallet address' + ); + } + }); + + it('fails for wrong length', () => { + const result = StellarAddressSchema.safeParse('G' + 'A'.repeat(54)); + expect(result.success).toBe(false); + }); + + it('fails for invalid characters', () => { + const result = StellarAddressSchema.safeParse('G' + '!'.repeat(55)); + expect(result.success).toBe(false); + }); + + it('fails for an empty string', () => { + const result = StellarAddressSchema.safeParse(''); + expect(result.success).toBe(false); + }); }); diff --git a/src/modules/wallet/wallet.schemas.ts b/src/modules/wallet/wallet.schemas.ts index ee9fc92..717f2b7 100644 --- a/src/modules/wallet/wallet.schemas.ts +++ b/src/modules/wallet/wallet.schemas.ts @@ -7,13 +7,16 @@ import { z } from 'zod'; * 3. Must follow Base32 encoding (A-Z, 2-7). */ export const StellarAddressSchema = z - .string() - .length(56, 'Stellar address must be exactly 56 characters long') - .regex(/^G[A-Z2-7]{55}$/, 'Invalid Stellar public key format (must start with G and use Base32 characters)'); + .string() + .length(56, 'Stellar address must be exactly 56 characters long') + .regex( + /^G[A-Z2-7]{55}$/, + 'Invalid Stellar public key format (must start with G and use Base32 characters)' + ); export const MapUserToWalletSchema = z.object({ - userId: z.string().cuid('Invalid user ID format (CUID expected)'), - address: StellarAddressSchema, + userId: z.string().cuid('Invalid user ID format (CUID expected)'), + address: StellarAddressSchema, }); export type MapUserToWalletType = z.infer; diff --git a/src/modules/wallet/wallet.utils.ts b/src/modules/wallet/wallet.utils.ts index 64ad883..951e3f9 100644 --- a/src/modules/wallet/wallet.utils.ts +++ b/src/modules/wallet/wallet.utils.ts @@ -9,15 +9,17 @@ import { logger } from '../../utils/logger.utils'; * the Base32 character set (A-Z, 2-7). */ export function isValidStellarAddress(address: string): boolean { - return typeof address === 'string' && /^G[A-Z2-7]{55}$/.test(address); + return typeof address === 'string' && /^G[A-Z2-7]{55}$/.test(address); } /** * Zod schema that validates a Stellar wallet address using isValidStellarAddress. */ export const StellarAddressSchema = z - .string() - .refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address' }); + .string() + .refine(isValidStellarAddress, { + message: 'Invalid Stellar wallet address', + }); /** * Service boundary for Stellar wallet identity mapping. diff --git a/src/modules/wallets/__tests__/wallet-activity-cursor-pagination.integration.test.ts b/src/modules/wallets/__tests__/wallet-activity-cursor-pagination.integration.test.ts index 0e219e1..dacfb0a 100644 --- a/src/modules/wallets/__tests__/wallet-activity-cursor-pagination.integration.test.ts +++ b/src/modules/wallets/__tests__/wallet-activity-cursor-pagination.integration.test.ts @@ -3,35 +3,39 @@ import * as walletActivityService from '../wallet-activity.service'; import { encodeCursor } from '../../../utils/cursor.utils'; import type { ActivityFeedCursorPayload } from '../wallet-activity.service'; -function makeReq(params: Record = {}, query: Record = {}): any { - return { params, query }; +function makeReq( + params: Record = {}, + query: Record = {} +): any { + return { params, query }; } function makeRes(): any { - const res: any = {}; - res.status = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - return res; + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; } function makeNext(): jest.Mock { - return jest.fn(); + return jest.fn(); } -const WALLET_ADDRESS = 'GBRST3QZ5XQQ74345MTHXMY3R745B6N5J2S7K6D6NCT7YIHMHQ45X2WZ'; +const WALLET_ADDRESS = + 'GBRST3QZ5XQQ74345MTHXMY3R745B6N5J2S7K6D6NCT7YIHMHQ45X2WZ'; function makeFixture(index: number) { - return { - id: `activity-${index}`, - type: 'buy' as const, - creator_id: `creator-${index}`, - creator_handle: `handle-${index}`, - amount: index * 100, - price_at_trade: index * 10, - fee_paid: index, - ledger_sequence: 1000 + index, - timestamp: new Date(`2024-0${index}-01T00:00:00.000Z`), - }; + return { + id: `activity-${index}`, + type: 'buy' as const, + creator_id: `creator-${index}`, + creator_handle: `handle-${index}`, + amount: index * 100, + price_at_trade: index * 10, + fee_paid: index, + ledger_sequence: 1000 + index, + timestamp: new Date(`2024-0${index}-01T00:00:00.000Z`), + }; } const ALL_FIXTURES = [6, 5, 4, 3, 2, 1].map(makeFixture); @@ -39,71 +43,90 @@ const PAGE_ONE_FIXTURES = ALL_FIXTURES.slice(0, 3); const PAGE_TWO_FIXTURES = ALL_FIXTURES.slice(3, 6); describe('wallet activity feed cursor-based pagination integration', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('returns a valid next cursor on the first page', async () => { - const lastOnPageOne = PAGE_ONE_FIXTURES[PAGE_ONE_FIXTURES.length - 1]; - const nextCursorStr = encodeCursor({ id: lastOnPageOne.id }); - - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([ + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns a valid next cursor on the first page', async () => { + const lastOnPageOne = PAGE_ONE_FIXTURES[PAGE_ONE_FIXTURES.length - 1]; + const nextCursorStr = encodeCursor({ + id: lastOnPageOne.id, + }); + + jest + .spyOn(walletActivityService, 'fetchWalletActivity') + .mockResolvedValue([ PAGE_ONE_FIXTURES, ALL_FIXTURES.length, - nextCursorStr - ]); - - const req = makeReq({ address: WALLET_ADDRESS }, { limit: '3', offset: '0' }); - const res = makeRes(); - await httpGetWalletActivity(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(200); - - const body = res.json.mock.calls[0][0]; - expect(body.data.items).toHaveLength(3); - expect(body.data.meta.nextCursor).toBe(nextCursorStr); - expect(body.data.meta.hasMore).toBe(true); - }); - - it('fetches the second page using the cursor and confirms no duplicates', async () => { - const lastOnPageOne = PAGE_ONE_FIXTURES[PAGE_ONE_FIXTURES.length - 1]; - const nextCursorStr = encodeCursor({ id: lastOnPageOne.id }); - - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([ + nextCursorStr, + ]); + + const req = makeReq( + { address: WALLET_ADDRESS }, + { limit: '3', offset: '0' } + ); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(3); + expect(body.data.meta.nextCursor).toBe(nextCursorStr); + expect(body.data.meta.hasMore).toBe(true); + }); + + it('fetches the second page using the cursor and confirms no duplicates', async () => { + const lastOnPageOne = PAGE_ONE_FIXTURES[PAGE_ONE_FIXTURES.length - 1]; + const nextCursorStr = encodeCursor({ + id: lastOnPageOne.id, + }); + + jest + .spyOn(walletActivityService, 'fetchWalletActivity') + .mockResolvedValue([ PAGE_ONE_FIXTURES, ALL_FIXTURES.length, - nextCursorStr - ]); - - const reqOne = makeReq({ address: WALLET_ADDRESS }, { limit: '3', offset: '0' }); - const resOne = makeRes(); - await httpGetWalletActivity(reqOne, resOne, makeNext()); - const pageOneIds = resOne.json.mock.calls[0][0].data.items.map((i: any) => i.id); - - jest.restoreAllMocks(); - - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([ - PAGE_TWO_FIXTURES, - ALL_FIXTURES.length, - null - ]); - - const reqTwo = makeReq({ address: WALLET_ADDRESS }, { limit: '3', cursor: nextCursorStr }); - const resTwo = makeRes(); - await httpGetWalletActivity(reqTwo, resTwo, makeNext()); - - const bodyTwo = resTwo.json.mock.calls[0][0]; - const pageTwoIds = bodyTwo.data.items.map((i: any) => i.id); - - expect(bodyTwo.data.items).toHaveLength(3); - expect(bodyTwo.data.meta.nextCursor).toBeNull(); - expect(bodyTwo.data.meta.hasMore).toBe(false); - - const overlap = pageOneIds.filter((id: string) => pageTwoIds.includes(id)); - expect(overlap).toHaveLength(0); - - const allExpectedIds = ALL_FIXTURES.map(f => f.id); - const combinedIds = [...pageOneIds, ...pageTwoIds]; - expect(combinedIds).toEqual(allExpectedIds); - }); + nextCursorStr, + ]); + + const reqOne = makeReq( + { address: WALLET_ADDRESS }, + { limit: '3', offset: '0' } + ); + const resOne = makeRes(); + await httpGetWalletActivity(reqOne, resOne, makeNext()); + const pageOneIds = resOne.json.mock.calls[0][0].data.items.map( + (i: any) => i.id + ); + + jest.restoreAllMocks(); + + jest + .spyOn(walletActivityService, 'fetchWalletActivity') + .mockResolvedValue([PAGE_TWO_FIXTURES, ALL_FIXTURES.length, null]); + + const reqTwo = makeReq( + { address: WALLET_ADDRESS }, + { limit: '3', cursor: nextCursorStr } + ); + const resTwo = makeRes(); + await httpGetWalletActivity(reqTwo, resTwo, makeNext()); + + const bodyTwo = resTwo.json.mock.calls[0][0]; + const pageTwoIds = bodyTwo.data.items.map((i: any) => i.id); + + expect(bodyTwo.data.items).toHaveLength(3); + expect(bodyTwo.data.meta.nextCursor).toBeNull(); + expect(bodyTwo.data.meta.hasMore).toBe(false); + + const overlap = pageOneIds.filter((id: string) => + pageTwoIds.includes(id) + ); + expect(overlap).toHaveLength(0); + + const allExpectedIds = ALL_FIXTURES.map(f => f.id); + const combinedIds = [...pageOneIds, ...pageTwoIds]; + expect(combinedIds).toEqual(allExpectedIds); + }); }); diff --git a/src/modules/wallets/__tests__/wallet-activity-reverse-chrono.integration.test.ts b/src/modules/wallets/__tests__/wallet-activity-reverse-chrono.integration.test.ts index 7f11d48..c9d868f 100644 --- a/src/modules/wallets/__tests__/wallet-activity-reverse-chrono.integration.test.ts +++ b/src/modules/wallets/__tests__/wallet-activity-reverse-chrono.integration.test.ts @@ -9,149 +9,168 @@ import { fetchWalletActivity } from '../wallet-activity.service'; import { prisma } from '../../../utils/prisma.utils'; jest.mock('../../../utils/prisma.utils', () => ({ - prisma: { - activity: { - findMany: jest.fn(), - count: jest.fn(), - }, - creatorProfile: { - findMany: jest.fn(), - }, - }, + prisma: { + activity: { + findMany: jest.fn(), + count: jest.fn(), + }, + creatorProfile: { + findMany: jest.fn(), + }, + }, })); const mockPrisma = prisma as unknown as { - activity: { - findMany: jest.Mock; - count: jest.Mock; - }; - creatorProfile: { - findMany: jest.Mock; - }; + activity: { + findMany: jest.Mock; + count: jest.Mock; + }; + creatorProfile: { + findMany: jest.Mock; + }; }; -const WALLET_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const WALLET_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; // Three activities with timestamps: 2026-06-01 (newest), 2026-03-15, 2026-01-10 (oldest). // Listed here in the order Prisma would return them when orderBy: { createdAt: 'desc' } // is applied — newest first. const activitiesNewestFirst = [ - { - type: 'KEY_BOUGHT', - actor: WALLET_ADDRESS, - creatorId: 'creator-alpha', - payload: { amount: '3', price_at_trade: '12', fee_paid: '0.12', ledger_sequence: 103 }, - createdAt: new Date('2026-06-01T00:00:00Z'), - }, - { - type: 'KEY_SOLD', - actor: WALLET_ADDRESS, - creatorId: 'creator-beta', - payload: { amount: '2', price_at_trade: '10', fee_paid: '0.10', ledger_sequence: 102 }, - createdAt: new Date('2026-03-15T00:00:00Z'), - }, - { - type: 'KEY_BOUGHT', - actor: WALLET_ADDRESS, - creatorId: 'creator-alpha', - payload: { amount: '1', price_at_trade: '8', fee_paid: '0.08', ledger_sequence: 101 }, - createdAt: new Date('2026-01-10T00:00:00Z'), - }, + { + type: 'KEY_BOUGHT', + actor: WALLET_ADDRESS, + creatorId: 'creator-alpha', + payload: { + amount: '3', + price_at_trade: '12', + fee_paid: '0.12', + ledger_sequence: 103, + }, + createdAt: new Date('2026-06-01T00:00:00Z'), + }, + { + type: 'KEY_SOLD', + actor: WALLET_ADDRESS, + creatorId: 'creator-beta', + payload: { + amount: '2', + price_at_trade: '10', + fee_paid: '0.10', + ledger_sequence: 102, + }, + createdAt: new Date('2026-03-15T00:00:00Z'), + }, + { + type: 'KEY_BOUGHT', + actor: WALLET_ADDRESS, + creatorId: 'creator-alpha', + payload: { + amount: '1', + price_at_trade: '8', + fee_paid: '0.08', + ledger_sequence: 101, + }, + createdAt: new Date('2026-01-10T00:00:00Z'), + }, ]; // Same three records in a scrambled (insertion-order) sequence. const activitiesScrambled = [ - activitiesNewestFirst[1], // 2026-03-15 - activitiesNewestFirst[2], // 2026-01-10 - activitiesNewestFirst[0], // 2026-06-01 + activitiesNewestFirst[1], // 2026-03-15 + activitiesNewestFirst[2], // 2026-01-10 + activitiesNewestFirst[0], // 2026-06-01 ]; const DEFAULT_QUERY = { limit: 20, offset: 0 }; describe('fetchWalletActivity – reverse chronological ordering (#445)', () => { - beforeEach(() => { - jest.clearAllMocks(); - - mockPrisma.creatorProfile.findMany.mockResolvedValue([ - { id: 'creator-alpha', handle: 'alpha' }, - { id: 'creator-beta', handle: 'beta' }, - ]); - mockPrisma.activity.count.mockResolvedValue(activitiesNewestFirst.length); - }); - - // Test 1 – service passes orderBy: { createdAt: 'desc' } to Prisma - it('calls prisma.activity.findMany with orderBy: { createdAt: "desc" }', async () => { - mockPrisma.activity.findMany.mockResolvedValue(activitiesNewestFirst); - - await fetchWalletActivity(WALLET_ADDRESS, DEFAULT_QUERY); - - expect(mockPrisma.activity.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - orderBy: { createdAt: 'desc' }, - }), - ); - }); - - // Test 2 – service does NOT reorder rows; the order it returns matches - // what Prisma hands back (Prisma is the sole owner of sorting). - it('returns items in the same order Prisma provides (service does not reorder)', async () => { - // Prisma returns newest-first (as it would with orderBy: desc). - mockPrisma.activity.findMany.mockResolvedValue(activitiesNewestFirst); - - const [items] = await fetchWalletActivity(WALLET_ADDRESS, DEFAULT_QUERY); - - // The service must not sort; it must preserve Prisma's order. - expect(items[0].timestamp).toEqual(activitiesNewestFirst[0].createdAt); - expect(items[1].timestamp).toEqual(activitiesNewestFirst[1].createdAt); - expect(items[2].timestamp).toEqual(activitiesNewestFirst[2].createdAt); - }); - - // Test 3 – when Prisma returns rows newest-first the items are newest-first - it('returns items newest-first when prisma returns them in descending order', async () => { - mockPrisma.activity.findMany.mockResolvedValue(activitiesNewestFirst); - - const [items] = await fetchWalletActivity(WALLET_ADDRESS, DEFAULT_QUERY); - - expect(items).toHaveLength(3); - // Each timestamp must be >= the one that follows. - for (let i = 0; i < items.length - 1; i++) { - expect(items[i].timestamp.getTime()).toBeGreaterThanOrEqual( - items[i + 1].timestamp.getTime(), - ); - } - }); - - // Test 4 – explicit timestamp ordering for the three specific dates - it('events with timestamps 2026-06-01 > 2026-03-15 > 2026-01-10 appear in that order', async () => { - mockPrisma.activity.findMany.mockResolvedValue(activitiesNewestFirst); - - const [items] = await fetchWalletActivity(WALLET_ADDRESS, DEFAULT_QUERY); - - expect(items[0].timestamp).toEqual(new Date('2026-06-01T00:00:00Z')); - expect(items[1].timestamp).toEqual(new Date('2026-03-15T00:00:00Z')); - expect(items[2].timestamp).toEqual(new Date('2026-01-10T00:00:00Z')); - }); - - // Test 5 – zero activity returns empty array - it('returns an empty items array when there are no activities', async () => { - mockPrisma.activity.findMany.mockResolvedValue([]); - mockPrisma.activity.count.mockResolvedValue(0); - - const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, DEFAULT_QUERY); - - expect(items).toEqual([]); - expect(total).toBe(0); - }); - - // Bonus: even when Prisma hands back rows in scrambled order the service - // passes through that order unchanged (it trusts Prisma to sort). - it('does not independently sort: preserves scrambled Prisma output as-is', async () => { - mockPrisma.activity.findMany.mockResolvedValue(activitiesScrambled); - - const [items] = await fetchWalletActivity(WALLET_ADDRESS, DEFAULT_QUERY); - - expect(items[0].timestamp).toEqual(activitiesScrambled[0].createdAt); // 2026-03-15 - expect(items[1].timestamp).toEqual(activitiesScrambled[1].createdAt); // 2026-01-10 - expect(items[2].timestamp).toEqual(activitiesScrambled[2].createdAt); // 2026-06-01 - }); + beforeEach(() => { + jest.clearAllMocks(); + + mockPrisma.creatorProfile.findMany.mockResolvedValue([ + { id: 'creator-alpha', handle: 'alpha' }, + { id: 'creator-beta', handle: 'beta' }, + ]); + mockPrisma.activity.count.mockResolvedValue(activitiesNewestFirst.length); + }); + + // Test 1 – service passes orderBy: { createdAt: 'desc' } to Prisma + it('calls prisma.activity.findMany with orderBy: { createdAt: "desc" }', async () => { + mockPrisma.activity.findMany.mockResolvedValue(activitiesNewestFirst); + + await fetchWalletActivity(WALLET_ADDRESS, DEFAULT_QUERY); + + expect(mockPrisma.activity.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { createdAt: 'desc' }, + }) + ); + }); + + // Test 2 – service does NOT reorder rows; the order it returns matches + // what Prisma hands back (Prisma is the sole owner of sorting). + it('returns items in the same order Prisma provides (service does not reorder)', async () => { + // Prisma returns newest-first (as it would with orderBy: desc). + mockPrisma.activity.findMany.mockResolvedValue(activitiesNewestFirst); + + const [items] = await fetchWalletActivity(WALLET_ADDRESS, DEFAULT_QUERY); + + // The service must not sort; it must preserve Prisma's order. + expect(items[0].timestamp).toEqual(activitiesNewestFirst[0].createdAt); + expect(items[1].timestamp).toEqual(activitiesNewestFirst[1].createdAt); + expect(items[2].timestamp).toEqual(activitiesNewestFirst[2].createdAt); + }); + + // Test 3 – when Prisma returns rows newest-first the items are newest-first + it('returns items newest-first when prisma returns them in descending order', async () => { + mockPrisma.activity.findMany.mockResolvedValue(activitiesNewestFirst); + + const [items] = await fetchWalletActivity(WALLET_ADDRESS, DEFAULT_QUERY); + + expect(items).toHaveLength(3); + // Each timestamp must be >= the one that follows. + for (let i = 0; i < items.length - 1; i++) { + expect(items[i].timestamp.getTime()).toBeGreaterThanOrEqual( + items[i + 1].timestamp.getTime() + ); + } + }); + + // Test 4 – explicit timestamp ordering for the three specific dates + it('events with timestamps 2026-06-01 > 2026-03-15 > 2026-01-10 appear in that order', async () => { + mockPrisma.activity.findMany.mockResolvedValue(activitiesNewestFirst); + + const [items] = await fetchWalletActivity(WALLET_ADDRESS, DEFAULT_QUERY); + + expect(items[0].timestamp).toEqual(new Date('2026-06-01T00:00:00Z')); + expect(items[1].timestamp).toEqual(new Date('2026-03-15T00:00:00Z')); + expect(items[2].timestamp).toEqual(new Date('2026-01-10T00:00:00Z')); + }); + + // Test 5 – zero activity returns empty array + it('returns an empty items array when there are no activities', async () => { + mockPrisma.activity.findMany.mockResolvedValue([]); + mockPrisma.activity.count.mockResolvedValue(0); + + const [items, total] = await fetchWalletActivity( + WALLET_ADDRESS, + DEFAULT_QUERY + ); + + expect(items).toEqual([]); + expect(total).toBe(0); + }); + + // Bonus: even when Prisma hands back rows in scrambled order the service + // passes through that order unchanged (it trusts Prisma to sort). + it('does not independently sort: preserves scrambled Prisma output as-is', async () => { + mockPrisma.activity.findMany.mockResolvedValue(activitiesScrambled); + + const [items] = await fetchWalletActivity(WALLET_ADDRESS, DEFAULT_QUERY); + + expect(items[0].timestamp).toEqual(activitiesScrambled[0].createdAt); // 2026-03-15 + expect(items[1].timestamp).toEqual(activitiesScrambled[1].createdAt); // 2026-01-10 + expect(items[2].timestamp).toEqual(activitiesScrambled[2].createdAt); // 2026-06-01 + }); }); diff --git a/src/modules/wallets/__tests__/wallet-holdings-default-sort.integration.test.ts b/src/modules/wallets/__tests__/wallet-holdings-default-sort.integration.test.ts index b449351..69c5a03 100644 --- a/src/modules/wallets/__tests__/wallet-holdings-default-sort.integration.test.ts +++ b/src/modules/wallets/__tests__/wallet-holdings-default-sort.integration.test.ts @@ -5,137 +5,140 @@ import app from '../../../app'; import { prisma } from '../../../utils/prisma.utils'; describe('GET /api/v1/wallets/:address/holdings - default sort order', () => { - const WALLET_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; - - const USER_IDS = [ - 'wallet-sort-user-1', - 'wallet-sort-user-2', - 'wallet-sort-user-3', - ]; - - const CREATOR_IDS = [ - 'wallet-sort-creator-1', - 'wallet-sort-creator-2', - 'wallet-sort-creator-3', - ]; - - beforeAll(async () => { - // Clean up database tables to avoid tests leaking into each other - await prisma.keyOwnership.deleteMany({}); - await prisma.creatorPriceSnapshot.deleteMany({}); - await prisma.creatorProfile.deleteMany({}); - await prisma.user.deleteMany({}); - - // Create 3 users for the creators - for (let i = 0; i < 3; i++) { - await prisma.user.create({ - data: { - id: USER_IDS[i], - email: `wallet-sort-user-${i}@example.test`, - passwordHash: 'dummy-hash', - firstName: 'Wallet', - lastName: `Sort ${i}`, - }, + const WALLET_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + + const USER_IDS = [ + 'wallet-sort-user-1', + 'wallet-sort-user-2', + 'wallet-sort-user-3', + ]; + + const CREATOR_IDS = [ + 'wallet-sort-creator-1', + 'wallet-sort-creator-2', + 'wallet-sort-creator-3', + ]; + + beforeAll(async () => { + // Clean up database tables to avoid tests leaking into each other + await prisma.keyOwnership.deleteMany({}); + await prisma.creatorPriceSnapshot.deleteMany({}); + await prisma.creatorProfile.deleteMany({}); + await prisma.user.deleteMany({}); + + // Create 3 users for the creators + for (let i = 0; i < 3; i++) { + await prisma.user.create({ + data: { + id: USER_IDS[i], + email: `wallet-sort-user-${i}@example.test`, + passwordHash: 'dummy-hash', + firstName: 'Wallet', + lastName: `Sort ${i}`, + }, + }); + } + + // Create 3 creators + for (let i = 0; i < 3; i++) { + await prisma.creatorProfile.create({ + data: { + id: CREATOR_IDS[i], + userId: USER_IDS[i], + handle: `wallet_sort_creator_${i}`, + displayName: `Wallet Sort Creator ${i}`, + }, + }); + } + + // Create price snapshots with different currentPrice (BigInt) + // Creator 1: price = 100 + // Creator 2: price = 50 + // Creator 3: price = 50 + await prisma.creatorPriceSnapshot.create({ + data: { + creatorId: CREATOR_IDS[0], + currentPrice: 100n, + }, }); - } - - // Create 3 creators - for (let i = 0; i < 3; i++) { - await prisma.creatorProfile.create({ - data: { - id: CREATOR_IDS[i], - userId: USER_IDS[i], - handle: `wallet_sort_creator_${i}`, - displayName: `Wallet Sort Creator ${i}`, - }, + await prisma.creatorPriceSnapshot.create({ + data: { + creatorId: CREATOR_IDS[1], + currentPrice: 50n, + }, }); - } - - // Create price snapshots with different currentPrice (BigInt) - // Creator 1: price = 100 - // Creator 2: price = 50 - // Creator 3: price = 50 - await prisma.creatorPriceSnapshot.create({ - data: { - creatorId: CREATOR_IDS[0], - currentPrice: 100n, - }, - }); - await prisma.creatorPriceSnapshot.create({ - data: { - creatorId: CREATOR_IDS[1], - currentPrice: 50n, - }, - }); - await prisma.creatorPriceSnapshot.create({ - data: { - creatorId: CREATOR_IDS[2], - currentPrice: 50n, - }, - }); - - // Create key ownership records - // Creator 1: balance = 3 -> total value = 300 - // Creator 2: balance = 3 -> total value = 150 - // Creator 3: balance = 1 -> total value = 50 - // To ensure the test does not pass by accident due to creation/insert order, - // we insert Creator 2, then Creator 3, then Creator 1. - await prisma.keyOwnership.create({ - data: { - ownerAddress: WALLET_ADDRESS, - creatorId: CREATOR_IDS[1], - balance: 3.0, - }, - }); - await prisma.keyOwnership.create({ - data: { - ownerAddress: WALLET_ADDRESS, - creatorId: CREATOR_IDS[2], - balance: 1.0, - }, - }); - await prisma.keyOwnership.create({ - data: { - ownerAddress: WALLET_ADDRESS, - creatorId: CREATOR_IDS[0], - balance: 3.0, - }, - }); - }); - - afterAll(async () => { - // Clean up seeded database tables - await prisma.keyOwnership.deleteMany({}); - await prisma.creatorPriceSnapshot.deleteMany({}); - await prisma.creatorProfile.deleteMany({}); - await prisma.user.deleteMany({}); - }); - - it('should return holdings ordered by total value descending by default', async () => { - const res = await supertest(app).get(`/api/v1/wallets/${WALLET_ADDRESS}/holdings`); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - - const { items, total } = res.body.data; - expect(items).toHaveLength(3); - expect(total).toBe(3); - - // Verify ordering by total holding value (balance * price) descending: - // 1. Creator 1: total_value = 300 - // 2. Creator 2: total_value = 150 - // 3. Creator 3: total_value = 50 - expect(items[0].creator_id).toBe(CREATOR_IDS[0]); - expect(items[0].total_value).toBe('300'); - - expect(items[1].creator_id).toBe(CREATOR_IDS[1]); - expect(items[1].total_value).toBe('150'); - - expect(items[2].creator_id).toBe(CREATOR_IDS[2]); - expect(items[2].total_value).toBe('50'); - - // Confirm that the first has the highest and the last has the lowest - const totalValues = items.map((item: any) => Number(item.total_value)); - expect(totalValues[0]).toBeGreaterThan(totalValues[1]); - expect(totalValues[1]).toBeGreaterThan(totalValues[2]); - }); + await prisma.creatorPriceSnapshot.create({ + data: { + creatorId: CREATOR_IDS[2], + currentPrice: 50n, + }, + }); + + // Create key ownership records + // Creator 1: balance = 3 -> total value = 300 + // Creator 2: balance = 3 -> total value = 150 + // Creator 3: balance = 1 -> total value = 50 + // To ensure the test does not pass by accident due to creation/insert order, + // we insert Creator 2, then Creator 3, then Creator 1. + await prisma.keyOwnership.create({ + data: { + ownerAddress: WALLET_ADDRESS, + creatorId: CREATOR_IDS[1], + balance: 3.0, + }, + }); + await prisma.keyOwnership.create({ + data: { + ownerAddress: WALLET_ADDRESS, + creatorId: CREATOR_IDS[2], + balance: 1.0, + }, + }); + await prisma.keyOwnership.create({ + data: { + ownerAddress: WALLET_ADDRESS, + creatorId: CREATOR_IDS[0], + balance: 3.0, + }, + }); + }); + + afterAll(async () => { + // Clean up seeded database tables + await prisma.keyOwnership.deleteMany({}); + await prisma.creatorPriceSnapshot.deleteMany({}); + await prisma.creatorProfile.deleteMany({}); + await prisma.user.deleteMany({}); + }); + + it('should return holdings ordered by total value descending by default', async () => { + const res = await supertest(app).get( + `/api/v1/wallets/${WALLET_ADDRESS}/holdings` + ); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const { items, total } = res.body.data; + expect(items).toHaveLength(3); + expect(total).toBe(3); + + // Verify ordering by total holding value (balance * price) descending: + // 1. Creator 1: total_value = 300 + // 2. Creator 2: total_value = 150 + // 3. Creator 3: total_value = 50 + expect(items[0].creator_id).toBe(CREATOR_IDS[0]); + expect(items[0].total_value).toBe('300'); + + expect(items[1].creator_id).toBe(CREATOR_IDS[1]); + expect(items[1].total_value).toBe('150'); + + expect(items[2].creator_id).toBe(CREATOR_IDS[2]); + expect(items[2].total_value).toBe('50'); + + // Confirm that the first has the highest and the last has the lowest + const totalValues = items.map((item: any) => Number(item.total_value)); + expect(totalValues[0]).toBeGreaterThan(totalValues[1]); + expect(totalValues[1]).toBeGreaterThan(totalValues[2]); + }); }); diff --git a/src/modules/wallets/__tests__/wallet-holdings.integration.test.ts b/src/modules/wallets/__tests__/wallet-holdings.integration.test.ts index 13c4dc5..51b3e90 100644 --- a/src/modules/wallets/__tests__/wallet-holdings.integration.test.ts +++ b/src/modules/wallets/__tests__/wallet-holdings.integration.test.ts @@ -10,125 +10,145 @@ import { HoldingEntry } from '../wallet-holdings.schemas'; // ── Helpers ─────────────────────────────────────────────────────────────────── -const VALID_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const VALID_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const MALFORMED_ADDRESS = 'not-a-stellar-address'; -function makeReq(params: Record = {}, query: Record = {}): any { - return { params, query }; +function makeReq( + params: Record = {}, + query: Record = {} +): any { + return { params, query }; } function makeRes(): any { - const res: any = {}; - res.status = jest.fn().mockReturnValue(res); - res.setHeader = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - return res; + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; } function makeNext(): jest.Mock { - return jest.fn(); + return jest.fn(); } function makeHolding(overrides: Partial = {}): HoldingEntry { - return { - creator_id: 'creator-1', - creator_handle: 'alice', - key_count: '5', - current_price: '100', - total_value: null, - ...overrides, - }; + return { + creator_id: 'creator-1', + creator_handle: 'alice', + key_count: '5', + current_price: '100', + total_value: null, + ...overrides, + }; } // ── Tests ───────────────────────────────────────────────────────────────────── describe('GET /wallets/:address/holdings', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('returns 200 with items and meta for a wallet with holdings', async () => { - const holdings: HoldingEntry[] = [ - makeHolding({ creator_id: 'creator-1', creator_handle: 'alice', key_count: '5' }), - makeHolding({ creator_id: 'creator-2', creator_handle: 'bob', key_count: '3' }), - ]; - jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([holdings, 2]); - - const req = makeReq({ address: VALID_ADDRESS }); - const res = makeRes(); - await httpGetWalletHoldings(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(200); - const body = res.json.mock.calls[0][0]; - expect(body.success).toBe(true); - expect(body.data.items).toHaveLength(2); - expect(body.data.meta.total).toBe(2); - }); - - it('each holding includes required fields', async () => { - const holding = makeHolding({ - creator_id: 'creator-1', - creator_handle: 'alice', - key_count: '10', - current_price: '200', - total_value: null, - }); - jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([[holding], 1]); - - const req = makeReq({ address: VALID_ADDRESS }); - const res = makeRes(); - await httpGetWalletHoldings(req, res, makeNext()); - - const item = res.json.mock.calls[0][0].data.items[0]; - expect(item).toMatchObject({ + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns 200 with items and meta for a wallet with holdings', async () => { + const holdings: HoldingEntry[] = [ + makeHolding({ creator_id: 'creator-1', creator_handle: 'alice', - key_count: '10', - current_price: '200', - }); - }); - - it('returns 200 with empty items for a wallet with no holdings', async () => { - jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([[], 0]); - - const req = makeReq({ address: VALID_ADDRESS }); - const res = makeRes(); - await httpGetWalletHoldings(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(200); - const body = res.json.mock.calls[0][0]; - expect(body.data.items).toEqual([]); - expect(body.data.meta.total).toBe(0); - }); - - it('returns 400 for a malformed Stellar address', async () => { - const req = makeReq({ address: MALFORMED_ADDRESS }); - const res = makeRes(); - await httpGetWalletHoldings(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(400); - const body = res.json.mock.calls[0][0]; - expect(body.success).toBe(false); - expect(body.error.code).toBe('VALIDATION_ERROR'); - }); - - it('returns 400 for an address starting with wrong character', async () => { - const req = makeReq({ address: 'A' + 'A'.repeat(55) }); - const res = makeRes(); - await httpGetWalletHoldings(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(400); - }); - - it('forwards service errors to next()', async () => { - const err = new Error('db down'); - jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockRejectedValue(err); - - const req = makeReq({ address: VALID_ADDRESS }); - const res = makeRes(); - const next = makeNext(); - await httpGetWalletHoldings(req, res, next); - - expect(next).toHaveBeenCalledWith(err); - }); + key_count: '5', + }), + makeHolding({ + creator_id: 'creator-2', + creator_handle: 'bob', + key_count: '3', + }), + ]; + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([holdings, 2]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.items).toHaveLength(2); + expect(body.data.meta.total).toBe(2); + }); + + it('each holding includes required fields', async () => { + const holding = makeHolding({ + creator_id: 'creator-1', + creator_handle: 'alice', + key_count: '10', + current_price: '200', + total_value: null, + }); + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([[holding], 1]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const item = res.json.mock.calls[0][0].data.items[0]; + expect(item).toMatchObject({ + creator_id: 'creator-1', + creator_handle: 'alice', + key_count: '10', + current_price: '200', + }); + }); + + it('returns 200 with empty items for a wallet with no holdings', async () => { + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([[], 0]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toEqual([]); + expect(body.data.meta.total).toBe(0); + }); + + it('returns 400 for a malformed Stellar address', async () => { + const req = makeReq({ address: MALFORMED_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 400 for an address starting with wrong character', async () => { + const req = makeReq({ address: 'A' + 'A'.repeat(55) }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('forwards service errors to next()', async () => { + const err = new Error('db down'); + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockRejectedValue(err); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + const next = makeNext(); + await httpGetWalletHoldings(req, res, next); + + expect(next).toHaveBeenCalledWith(err); + }); }); diff --git a/src/modules/wallets/wallet-activity-creator-filter.integration.test.ts b/src/modules/wallets/wallet-activity-creator-filter.integration.test.ts index 526de67..0610db7 100644 --- a/src/modules/wallets/wallet-activity-creator-filter.integration.test.ts +++ b/src/modules/wallets/wallet-activity-creator-filter.integration.test.ts @@ -8,184 +8,205 @@ import { fetchWalletActivity } from './wallet-activity.service'; import { prisma } from '../../utils/prisma.utils'; jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - activity: { - findMany: jest.fn(), - count: jest.fn(), - }, - creatorProfile: { - findMany: jest.fn(), - }, - }, + prisma: { + activity: { + findMany: jest.fn(), + count: jest.fn(), + }, + creatorProfile: { + findMany: jest.fn(), + }, + }, })); const mockPrisma = prisma as unknown as { - activity: { findMany: jest.Mock; count: jest.Mock }; - creatorProfile: { findMany: jest.Mock }; + activity: { findMany: jest.Mock; count: jest.Mock }; + creatorProfile: { findMany: jest.Mock }; }; -const WALLET_ADDRESS = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA'; +const WALLET_ADDRESS = + 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA'; const CREATOR_A = 'creator-filter-a'; const CREATOR_B = 'creator-filter-b'; const seededActivities = [ - { - type: 'KEY_BOUGHT', - actor: WALLET_ADDRESS, - creatorId: CREATOR_A, - payload: { amount: '5', price_at_trade: '20', fee_paid: '0.5', ledger_sequence: 201 }, - createdAt: new Date('2026-06-10T00:00:00Z'), - }, - { - type: 'KEY_SOLD', - actor: WALLET_ADDRESS, - creatorId: CREATOR_A, - payload: { amount: '2', price_at_trade: '25', fee_paid: '0.4', ledger_sequence: 202 }, - createdAt: new Date('2026-06-11T00:00:00Z'), - }, - { - type: 'KEY_BOUGHT', - actor: WALLET_ADDRESS, - creatorId: CREATOR_B, - payload: { amount: '3', price_at_trade: '15', fee_paid: '0.3', ledger_sequence: 203 }, - createdAt: new Date('2026-06-12T00:00:00Z'), - }, - { - type: 'KEY_SOLD', - actor: WALLET_ADDRESS, - creatorId: CREATOR_B, - payload: { amount: '1', price_at_trade: '18', fee_paid: '0.2', ledger_sequence: 204 }, - createdAt: new Date('2026-06-13T00:00:00Z'), - }, + { + type: 'KEY_BOUGHT', + actor: WALLET_ADDRESS, + creatorId: CREATOR_A, + payload: { + amount: '5', + price_at_trade: '20', + fee_paid: '0.5', + ledger_sequence: 201, + }, + createdAt: new Date('2026-06-10T00:00:00Z'), + }, + { + type: 'KEY_SOLD', + actor: WALLET_ADDRESS, + creatorId: CREATOR_A, + payload: { + amount: '2', + price_at_trade: '25', + fee_paid: '0.4', + ledger_sequence: 202, + }, + createdAt: new Date('2026-06-11T00:00:00Z'), + }, + { + type: 'KEY_BOUGHT', + actor: WALLET_ADDRESS, + creatorId: CREATOR_B, + payload: { + amount: '3', + price_at_trade: '15', + fee_paid: '0.3', + ledger_sequence: 203, + }, + createdAt: new Date('2026-06-12T00:00:00Z'), + }, + { + type: 'KEY_SOLD', + actor: WALLET_ADDRESS, + creatorId: CREATOR_B, + payload: { + amount: '1', + price_at_trade: '18', + fee_paid: '0.2', + ledger_sequence: 204, + }, + createdAt: new Date('2026-06-13T00:00:00Z'), + }, ]; function applyWhere(where: { - actor: string; - creatorId?: string; - type?: string | { in: string[] }; + actor: string; + creatorId?: string; + type?: string | { in: string[] }; }) { - return seededActivities.filter((a) => { - if (a.actor !== where.actor) return false; - if (where.creatorId && a.creatorId !== where.creatorId) return false; - if (where.type) { - if (typeof where.type === 'string') { - if (a.type !== where.type) return false; - } else if (where.type.in && !where.type.in.includes(a.type)) { - return false; + return seededActivities.filter(a => { + if (a.actor !== where.actor) return false; + if (where.creatorId && a.creatorId !== where.creatorId) return false; + if (where.type) { + if (typeof where.type === 'string') { + if (a.type !== where.type) return false; + } else if (where.type.in && !where.type.in.includes(a.type)) { + return false; + } } - } - return true; - }); + return true; + }); } beforeEach(() => { - jest.clearAllMocks(); - mockPrisma.activity.findMany.mockImplementation(({ where }) => - Promise.resolve(applyWhere(where)) - ); - mockPrisma.activity.count.mockImplementation(({ where }) => - Promise.resolve(applyWhere(where).length) - ); - mockPrisma.creatorProfile.findMany.mockResolvedValue([ - { id: CREATOR_A, handle: 'handle-a' }, - { id: CREATOR_B, handle: 'handle-b' }, - ]); + jest.clearAllMocks(); + mockPrisma.activity.findMany.mockImplementation(({ where }) => + Promise.resolve(applyWhere(where)) + ); + mockPrisma.activity.count.mockImplementation(({ where }) => + Promise.resolve(applyWhere(where).length) + ); + mockPrisma.creatorProfile.findMany.mockResolvedValue([ + { id: CREATOR_A, handle: 'handle-a' }, + { id: CREATOR_B, handle: 'handle-b' }, + ]); }); describe('fetchWalletActivity — creator_id filter (#476)', () => { - it('returns only trades for creator A when creator_id=CREATOR_A', async () => { - const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { - creator_id: CREATOR_A, - limit: 20, - offset: 0, - }); - - expect(total).toBe(2); - expect(items).toHaveLength(2); - expect(items.every((i) => i.creator_id === CREATOR_A)).toBe(true); - }); - - it('excludes all trades from creator B when filtering by creator A', async () => { - const [items] = await fetchWalletActivity(WALLET_ADDRESS, { - creator_id: CREATOR_A, - limit: 20, - offset: 0, - }); - - expect(items.some((i) => i.creator_id === CREATOR_B)).toBe(false); - }); - - it('returns only trades for creator B when creator_id=CREATOR_B', async () => { - const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { - creator_id: CREATOR_B, - limit: 20, - offset: 0, - }); - - expect(total).toBe(2); - expect(items).toHaveLength(2); - expect(items.every((i) => i.creator_id === CREATOR_B)).toBe(true); - }); - - it('excludes all trades from creator A when filtering by creator B', async () => { - const [items] = await fetchWalletActivity(WALLET_ADDRESS, { - creator_id: CREATOR_B, - limit: 20, - offset: 0, - }); - - expect(items.some((i) => i.creator_id === CREATOR_A)).toBe(false); - }); - - it('combines creator_id and type=buy filters correctly', async () => { - const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { - creator_id: CREATOR_A, - type: 'buy', - limit: 20, - offset: 0, - }); - - expect(total).toBe(1); - expect(items).toHaveLength(1); - expect(items[0].creator_id).toBe(CREATOR_A); - expect(items[0].type).toBe('buy'); - }); - - it('combines creator_id and type=sell filters correctly', async () => { - const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { - creator_id: CREATOR_B, - type: 'sell', - limit: 20, - offset: 0, - }); - - expect(total).toBe(1); - expect(items).toHaveLength(1); - expect(items[0].creator_id).toBe(CREATOR_B); - expect(items[0].type).toBe('sell'); - }); - - it('passes creatorId in the where clause when creator_id is provided', async () => { - await fetchWalletActivity(WALLET_ADDRESS, { - creator_id: CREATOR_A, - limit: 20, - offset: 0, - }); - - expect(mockPrisma.activity.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ creatorId: CREATOR_A }), - }) - ); - }); - - it('returns all trades when no creator_id filter is applied', async () => { - const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { - limit: 20, - offset: 0, - }); - - expect(total).toBe(4); - expect(items).toHaveLength(4); - }); + it('returns only trades for creator A when creator_id=CREATOR_A', async () => { + const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { + creator_id: CREATOR_A, + limit: 20, + offset: 0, + }); + + expect(total).toBe(2); + expect(items).toHaveLength(2); + expect(items.every(i => i.creator_id === CREATOR_A)).toBe(true); + }); + + it('excludes all trades from creator B when filtering by creator A', async () => { + const [items] = await fetchWalletActivity(WALLET_ADDRESS, { + creator_id: CREATOR_A, + limit: 20, + offset: 0, + }); + + expect(items.some(i => i.creator_id === CREATOR_B)).toBe(false); + }); + + it('returns only trades for creator B when creator_id=CREATOR_B', async () => { + const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { + creator_id: CREATOR_B, + limit: 20, + offset: 0, + }); + + expect(total).toBe(2); + expect(items).toHaveLength(2); + expect(items.every(i => i.creator_id === CREATOR_B)).toBe(true); + }); + + it('excludes all trades from creator A when filtering by creator B', async () => { + const [items] = await fetchWalletActivity(WALLET_ADDRESS, { + creator_id: CREATOR_B, + limit: 20, + offset: 0, + }); + + expect(items.some(i => i.creator_id === CREATOR_A)).toBe(false); + }); + + it('combines creator_id and type=buy filters correctly', async () => { + const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { + creator_id: CREATOR_A, + type: 'buy', + limit: 20, + offset: 0, + }); + + expect(total).toBe(1); + expect(items).toHaveLength(1); + expect(items[0].creator_id).toBe(CREATOR_A); + expect(items[0].type).toBe('buy'); + }); + + it('combines creator_id and type=sell filters correctly', async () => { + const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { + creator_id: CREATOR_B, + type: 'sell', + limit: 20, + offset: 0, + }); + + expect(total).toBe(1); + expect(items).toHaveLength(1); + expect(items[0].creator_id).toBe(CREATOR_B); + expect(items[0].type).toBe('sell'); + }); + + it('passes creatorId in the where clause when creator_id is provided', async () => { + await fetchWalletActivity(WALLET_ADDRESS, { + creator_id: CREATOR_A, + limit: 20, + offset: 0, + }); + + expect(mockPrisma.activity.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ creatorId: CREATOR_A }), + }) + ); + }); + + it('returns all trades when no creator_id filter is applied', async () => { + const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + }); + + expect(total).toBe(4); + expect(items).toHaveLength(4); + }); }); diff --git a/src/modules/wallets/wallet-activity-historical-price.integration.test.ts b/src/modules/wallets/wallet-activity-historical-price.integration.test.ts index 534b967..e7b45fd 100644 --- a/src/modules/wallets/wallet-activity-historical-price.integration.test.ts +++ b/src/modules/wallets/wallet-activity-historical-price.integration.test.ts @@ -9,23 +9,24 @@ import { fetchWalletActivity } from './wallet-activity.service'; import { prisma } from '../../utils/prisma.utils'; jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - activity: { - findMany: jest.fn(), - count: jest.fn(), - }, - creatorProfile: { - findMany: jest.fn(), - }, - }, + prisma: { + activity: { + findMany: jest.fn(), + count: jest.fn(), + }, + creatorProfile: { + findMany: jest.fn(), + }, + }, })); const mockPrisma = prisma as unknown as { - activity: { findMany: jest.Mock; count: jest.Mock }; - creatorProfile: { findMany: jest.Mock }; + activity: { findMany: jest.Mock; count: jest.Mock }; + creatorProfile: { findMany: jest.Mock }; }; -const WALLET_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const WALLET_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const CREATOR_ID = 'creator-hist-price-1'; // Trade 1: price was 200 at trade time @@ -36,66 +37,91 @@ const TRADE_2_PRICE = '350'; const CURRENT_SNAPSHOT_PRICE = '500'; const trade1 = { - type: 'KEY_BOUGHT', - actor: WALLET_ADDRESS, - creatorId: CREATOR_ID, - payload: { amount: '5', price_at_trade: TRADE_1_PRICE, fee_paid: '1', ledger_sequence: 1001 }, - createdAt: new Date('2026-01-10T00:00:00Z'), + type: 'KEY_BOUGHT', + actor: WALLET_ADDRESS, + creatorId: CREATOR_ID, + payload: { + amount: '5', + price_at_trade: TRADE_1_PRICE, + fee_paid: '1', + ledger_sequence: 1001, + }, + createdAt: new Date('2026-01-10T00:00:00Z'), }; const trade2 = { - type: 'KEY_BOUGHT', - actor: WALLET_ADDRESS, - creatorId: CREATOR_ID, - payload: { amount: '3', price_at_trade: TRADE_2_PRICE, fee_paid: '1', ledger_sequence: 1002 }, - createdAt: new Date('2026-03-15T00:00:00Z'), + type: 'KEY_BOUGHT', + actor: WALLET_ADDRESS, + creatorId: CREATOR_ID, + payload: { + amount: '3', + price_at_trade: TRADE_2_PRICE, + fee_paid: '1', + ledger_sequence: 1002, + }, + createdAt: new Date('2026-03-15T00:00:00Z'), }; describe('Wallet activity feed — historical price preservation', () => { - beforeEach(() => { - jest.clearAllMocks(); - - mockPrisma.activity.findMany.mockResolvedValue([trade2, trade1]); // ordered newest first - mockPrisma.activity.count.mockResolvedValue(2); - mockPrisma.creatorProfile.findMany.mockResolvedValue([ - { id: CREATOR_ID, handle: 'hist-creator' }, - ]); - }); - - it('first trade event shows the price at the time of that trade', async () => { - const [items] = await fetchWalletActivity(WALLET_ADDRESS, { limit: 20, offset: 0 }); - - // items[0] is trade2 (newest first) - expect(items[0].price_at_trade).toBe(TRADE_2_PRICE); - }); - - it('second trade event shows a different price matching its own trade time', async () => { - const [items] = await fetchWalletActivity(WALLET_ADDRESS, { limit: 20, offset: 0 }); - - // items[1] is trade1 (older) - expect(items[1].price_at_trade).toBe(TRADE_1_PRICE); - expect(items[1].price_at_trade).not.toBe(items[0].price_at_trade); - }); - - it('neither event shows the current snapshot price', async () => { - const [items] = await fetchWalletActivity(WALLET_ADDRESS, { limit: 20, offset: 0 }); - - for (const item of items) { - expect(item.price_at_trade).not.toBe(CURRENT_SNAPSHOT_PRICE); - } - }); - - it('both events belong to the same creator', async () => { - const [items] = await fetchWalletActivity(WALLET_ADDRESS, { limit: 20, offset: 0 }); - - expect(items[0].creator_id).toBe(CREATOR_ID); - expect(items[1].creator_id).toBe(CREATOR_ID); - }); - - it('returns exactly two trade events', async () => { - const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { limit: 20, offset: 0 }); - - expect(items).toHaveLength(2); - expect(total).toBe(2); - }); + beforeEach(() => { + jest.clearAllMocks(); + + mockPrisma.activity.findMany.mockResolvedValue([trade2, trade1]); // ordered newest first + mockPrisma.activity.count.mockResolvedValue(2); + mockPrisma.creatorProfile.findMany.mockResolvedValue([ + { id: CREATOR_ID, handle: 'hist-creator' }, + ]); + }); + + it('first trade event shows the price at the time of that trade', async () => { + const [items] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + }); + + // items[0] is trade2 (newest first) + expect(items[0].price_at_trade).toBe(TRADE_2_PRICE); + }); + + it('second trade event shows a different price matching its own trade time', async () => { + const [items] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + }); + + // items[1] is trade1 (older) + expect(items[1].price_at_trade).toBe(TRADE_1_PRICE); + expect(items[1].price_at_trade).not.toBe(items[0].price_at_trade); + }); + + it('neither event shows the current snapshot price', async () => { + const [items] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + }); + + for (const item of items) { + expect(item.price_at_trade).not.toBe(CURRENT_SNAPSHOT_PRICE); + } + }); + + it('both events belong to the same creator', async () => { + const [items] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + }); + + expect(items[0].creator_id).toBe(CREATOR_ID); + expect(items[1].creator_id).toBe(CREATOR_ID); + }); + + it('returns exactly two trade events', async () => { + const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + }); + + expect(items).toHaveLength(2); + expect(total).toBe(2); + }); }); diff --git a/src/modules/wallets/wallet-activity-invalid-address.integration.test.ts b/src/modules/wallets/wallet-activity-invalid-address.integration.test.ts index 80620ee..e36b1a7 100644 --- a/src/modules/wallets/wallet-activity-invalid-address.integration.test.ts +++ b/src/modules/wallets/wallet-activity-invalid-address.integration.test.ts @@ -2,95 +2,96 @@ import request from 'supertest'; import { prisma } from '../../utils/prisma.utils'; jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - activity: { - findMany: jest.fn(), - count: jest.fn(), - }, - creatorProfile: { - findMany: jest.fn(), - }, - }, + prisma: { + activity: { + findMany: jest.fn(), + count: jest.fn(), + }, + creatorProfile: { + findMany: jest.fn(), + }, + }, })); import app from '../../app'; const mockPrisma = prisma as unknown as { - activity: { findMany: jest.Mock; count: jest.Mock }; - creatorProfile: { findMany: jest.Mock }; + activity: { findMany: jest.Mock; count: jest.Mock }; + creatorProfile: { findMany: jest.Mock }; }; -const VALID_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; +const VALID_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; describe('GET /api/v1/wallets/:address/activity - Malformed Stellar Address', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should return 400 for address with wrong prefix', async () => { - const response = await request(app) - .get( - '/api/v1/wallets/XBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/activity' - ) - .expect(400); - - expect(response.body.error).toBeDefined(); - expect(response.body.error.details).toBeDefined(); - expect( - response.body.error.details.some((d: any) => d.field === 'address') - ).toBeTruthy(); - }); - - it('should return 400 for too-short address', async () => { - const response = await request(app) - .get('/api/v1/wallets/GASHORT/activity') - .expect(400); - - expect(response.body.error).toBeDefined(); - expect(response.body.error.details).toBeDefined(); - expect( - response.body.error.details.some((d: any) => d.field === 'address') - ).toBeTruthy(); - }); - - it('should return 400 for address with invalid characters', async () => { - const response = await request(app) - .get( - '/api/v1/wallets/GA!!!INVALID!!!CHARACTERS!!!HERE!!!AAAAAAAAAAAAAAAAA/activity' - ) - .expect(400); - - expect(response.body.error).toBeDefined(); - expect(response.body.error.details).toBeDefined(); - expect( - response.body.error.details.some((d: any) => d.field === 'address') - ).toBeTruthy(); - }); - - it('should return 400 for completely invalid address format', async () => { - const response = await request(app) - .get('/api/v1/wallets/not-a-stellar-address/activity') - .expect(400); - - expect(response.body.error).toBeDefined(); - expect(response.body.error.details).toBeDefined(); - expect( - response.body.error.details.some((d: any) => d.field === 'address') - ).toBeTruthy(); - }); - - it('should return 200 with empty data array for a valid Stellar address with no trade history', async () => { - mockPrisma.activity.findMany.mockResolvedValue([]); - mockPrisma.activity.count.mockResolvedValue(0); - mockPrisma.creatorProfile.findMany.mockResolvedValue([]); - - const response = await request(app) - .get(`/api/v1/wallets/${VALID_ADDRESS}/activity`) - .expect(200); - - expect(response.body.success).toBe(true); - expect(response.body.data.items).toEqual([]); - expect(response.body.data.meta.total).toBe(0); - expect(response.body.data.meta.hasMore).toBe(false); - }); + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return 400 for address with wrong prefix', async () => { + const response = await request(app) + .get( + '/api/v1/wallets/XBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/activity' + ) + .expect(400); + + expect(response.body.error).toBeDefined(); + expect(response.body.error.details).toBeDefined(); + expect( + response.body.error.details.some((d: any) => d.field === 'address') + ).toBeTruthy(); + }); + + it('should return 400 for too-short address', async () => { + const response = await request(app) + .get('/api/v1/wallets/GASHORT/activity') + .expect(400); + + expect(response.body.error).toBeDefined(); + expect(response.body.error.details).toBeDefined(); + expect( + response.body.error.details.some((d: any) => d.field === 'address') + ).toBeTruthy(); + }); + + it('should return 400 for address with invalid characters', async () => { + const response = await request(app) + .get( + '/api/v1/wallets/GA!!!INVALID!!!CHARACTERS!!!HERE!!!AAAAAAAAAAAAAAAAA/activity' + ) + .expect(400); + + expect(response.body.error).toBeDefined(); + expect(response.body.error.details).toBeDefined(); + expect( + response.body.error.details.some((d: any) => d.field === 'address') + ).toBeTruthy(); + }); + + it('should return 400 for completely invalid address format', async () => { + const response = await request(app) + .get('/api/v1/wallets/not-a-stellar-address/activity') + .expect(400); + + expect(response.body.error).toBeDefined(); + expect(response.body.error.details).toBeDefined(); + expect( + response.body.error.details.some((d: any) => d.field === 'address') + ).toBeTruthy(); + }); + + it('should return 200 with empty data array for a valid Stellar address with no trade history', async () => { + mockPrisma.activity.findMany.mockResolvedValue([]); + mockPrisma.activity.count.mockResolvedValue(0); + mockPrisma.creatorProfile.findMany.mockResolvedValue([]); + + const response = await request(app) + .get(`/api/v1/wallets/${VALID_ADDRESS}/activity`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.items).toEqual([]); + expect(response.body.data.meta.total).toBe(0); + expect(response.body.data.meta.hasMore).toBe(false); + }); }); diff --git a/src/modules/wallets/wallet-activity.controllers.ts b/src/modules/wallets/wallet-activity.controllers.ts index ceb3496..7e0d21e 100644 --- a/src/modules/wallets/wallet-activity.controllers.ts +++ b/src/modules/wallets/wallet-activity.controllers.ts @@ -1,77 +1,91 @@ import { Request, Response, NextFunction } from 'express'; -import { WalletActivityParamsSchema, WalletActivityQuerySchema } from './wallet-activity.schemas'; +import { + WalletActivityParamsSchema, + WalletActivityQuerySchema, +} from './wallet-activity.schemas'; import { fetchWalletActivity } from './wallet-activity.service'; -import { sendSuccess, sendValidationError } from '../../utils/api-response.utils'; +import { + sendSuccess, + sendValidationError, +} from '../../utils/api-response.utils'; import { buildOffsetPaginationMeta } from '../../utils/pagination.utils'; import { logger } from '../../utils/logger.utils'; export async function httpGetWalletActivity( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { - try { - const parsedParams = WalletActivityParamsSchema.safeParse(req.params); - if (!parsedParams.success) { - sendValidationError( - res, - 'Invalid wallet address', - parsedParams.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({ - field: `address`, - message: issue.message, - })) - ); - return; - } + try { + const parsedParams = WalletActivityParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + sendValidationError( + res, + 'Invalid wallet address', + parsedParams.error.issues.map( + (issue: { path: (string | number)[]; message: string }) => ({ + field: `address`, + message: issue.message, + }) + ) + ); + return; + } - const parsedQuery = WalletActivityQuerySchema.safeParse(req.query); - if (!parsedQuery.success) { - sendValidationError( - res, - 'Invalid query parameters', - parsedQuery.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({ - field: issue.path.join('.'), - message: issue.message, - })) - ); - return; - } + const parsedQuery = WalletActivityQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + sendValidationError( + res, + 'Invalid query parameters', + parsedQuery.error.issues.map( + (issue: { path: (string | number)[]; message: string }) => ({ + field: issue.path.join('.'), + message: issue.message, + }) + ) + ); + return; + } - const t0 = performance.now(); - const [items, total, nextCursor] = await fetchWalletActivity( - parsedParams.data.address, - parsedQuery.data - ); - const duration = performance.now() - t0; + const t0 = performance.now(); + const [items, total, nextCursor] = await fetchWalletActivity( + parsedParams.data.address, + parsedQuery.data + ); + const duration = performance.now() - t0; - const filters_applied = []; - if (parsedQuery.data.type) filters_applied.push('type'); - if (parsedQuery.data.creator_id) filters_applied.push('creator_id'); + const filters_applied = []; + if (parsedQuery.data.type) filters_applied.push('type'); + if (parsedQuery.data.creator_id) filters_applied.push('creator_id'); - const address = parsedParams.data.address; - const maskedAddress = address.length >= 8 + const address = parsedParams.data.address; + const maskedAddress = + address.length >= 8 ? `${address.slice(0, 4)}...${address.slice(-4)}` : address; - logger.debug({ + logger.debug( + { wallet_address: maskedAddress, result_count: items.length, query_duration_ms: Math.round(duration), - filters_applied - }, 'Wallet activity feed query'); + filters_applied, + }, + 'Wallet activity feed query' + ); - sendSuccess(res, { - items, - meta: { - ...buildOffsetPaginationMeta({ - limit: parsedQuery.data.limit, - offset: parsedQuery.data.offset, - total, - }), - nextCursor, - }, - }); - } catch (error) { - next(error); - } + sendSuccess(res, { + items, + meta: { + ...buildOffsetPaginationMeta({ + limit: parsedQuery.data.limit, + offset: parsedQuery.data.offset, + total, + }), + nextCursor, + }, + }); + } catch (error) { + next(error); + } } diff --git a/src/modules/wallets/wallet-activity.integration.test.ts b/src/modules/wallets/wallet-activity.integration.test.ts index d17c540..828e252 100644 --- a/src/modules/wallets/wallet-activity.integration.test.ts +++ b/src/modules/wallets/wallet-activity.integration.test.ts @@ -10,240 +10,294 @@ import { WalletActivityItem } from './wallet-activity.schemas'; // ── Helpers ─────────────────────────────────────────────────────────────────── -const VALID_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const VALID_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const MALFORMED_ADDRESS = 'not-a-stellar-address'; -function makeReq(params: Record = {}, query: Record = {}): any { - return { params, query }; +function makeReq( + params: Record = {}, + query: Record = {} +): any { + return { params, query }; } function makeRes(): any { - const res: any = {}; - res.status = jest.fn().mockReturnValue(res); - res.setHeader = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - return res; + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; } function makeNext(): jest.Mock { - return jest.fn(); + return jest.fn(); } -function makeActivity(overrides: Partial = {}): WalletActivityItem { - return { - id: 'activity-1', - type: 'buy', - creator_id: 'creator-1', - creator_handle: 'alice', - amount: '10', - price_at_trade: '50', - fee_paid: '1', - ledger_sequence: 100, - timestamp: new Date('2026-01-01T00:00:00Z'), - ...overrides, - }; +function makeActivity( + overrides: Partial = {} +): WalletActivityItem { + return { + id: 'activity-1', + type: 'buy', + creator_id: 'creator-1', + creator_handle: 'alice', + amount: '10', + price_at_trade: '50', + fee_paid: '1', + ledger_sequence: 100, + timestamp: new Date('2026-01-01T00:00:00Z'), + ...overrides, + }; } // ── Tests ───────────────────────────────────────────────────────────────────── describe('GET /wallets/:address/activity', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - // ── Happy path: mixed history ───────────────────────────────────────────── - - it('returns 200 with items and meta for a wallet with mixed trade history', async () => { - const activities: WalletActivityItem[] = [ - makeActivity({ type: 'buy', creator_id: 'creator-1', creator_handle: 'alice' }), - makeActivity({ type: 'sell', creator_id: 'creator-2', creator_handle: 'bob' }), - ]; - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([activities, 2, null]); - - const req = makeReq({ address: VALID_ADDRESS }); - const res = makeRes(); - await httpGetWalletActivity(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(200); - const body = res.json.mock.calls[0][0]; - expect(body.success).toBe(true); - expect(body.data.items).toHaveLength(2); - expect(body.data.meta.total).toBe(2); - }); - - it('each item includes required trade fields', async () => { - const activity = makeActivity({ - type: 'buy', - creator_id: 'creator-1', - creator_handle: 'alice', - amount: '5', - price_at_trade: '100', - fee_paid: '2', - ledger_sequence: 42, - timestamp: new Date('2026-03-01T00:00:00Z'), - }); - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[activity], 1, null]); - - const req = makeReq({ address: VALID_ADDRESS }); - const res = makeRes(); - await httpGetWalletActivity(req, res, makeNext()); - - const item = res.json.mock.calls[0][0].data.items[0]; - expect(item).toMatchObject({ + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── Happy path: mixed history ───────────────────────────────────────────── + + it('returns 200 with items and meta for a wallet with mixed trade history', async () => { + const activities: WalletActivityItem[] = [ + makeActivity({ type: 'buy', creator_id: 'creator-1', creator_handle: 'alice', - amount: '5', - price_at_trade: '100', - fee_paid: '2', - ledger_sequence: 42, - }); - expect(item.timestamp).toBeDefined(); - }); - - // ── Empty wallet ────────────────────────────────────────────────────────── - - it('returns 200 with empty items array (not 404) for a wallet with no activity', async () => { - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0, null]); - - const req = makeReq({ address: VALID_ADDRESS }); - const res = makeRes(); - await httpGetWalletActivity(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(200); - const body = res.json.mock.calls[0][0]; - expect(body.data.items).toEqual([]); - expect(body.data.meta.total).toBe(0); - expect(body.data.meta.hasMore).toBe(false); - }); - - // ── type filter ─────────────────────────────────────────────────────────── - - it('passes type=buy filter to the service', async () => { - const spy = jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0, null]); - - const req = makeReq({ address: VALID_ADDRESS }, { type: 'buy' }); - const res = makeRes(); - await httpGetWalletActivity(req, res, makeNext()); - - expect(spy).toHaveBeenCalledWith( - VALID_ADDRESS, - expect.objectContaining({ type: 'buy' }) - ); - }); - - it('passes type=sell filter to the service', async () => { - const spy = jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0, null]); - - const req = makeReq({ address: VALID_ADDRESS }, { type: 'sell' }); - const res = makeRes(); - await httpGetWalletActivity(req, res, makeNext()); - - expect(spy).toHaveBeenCalledWith( - VALID_ADDRESS, - expect.objectContaining({ type: 'sell' }) - ); - }); - - it('returns only buy events when type=buy', async () => { - const buys: WalletActivityItem[] = [makeActivity({ type: 'buy' })]; - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([buys, 1, null]); - - const req = makeReq({ address: VALID_ADDRESS }, { type: 'buy' }); - const res = makeRes(); - await httpGetWalletActivity(req, res, makeNext()); - - const body = res.json.mock.calls[0][0]; - expect(body.data.items.every((i: WalletActivityItem) => i.type === 'buy')).toBe(true); - }); - - // ── creator_id filter ───────────────────────────────────────────────────── - - it('passes creator_id filter to the service', async () => { - const spy = jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0, null]); - - const req = makeReq({ address: VALID_ADDRESS }, { creator_id: 'creator-abc' }); - const res = makeRes(); - await httpGetWalletActivity(req, res, makeNext()); - - expect(spy).toHaveBeenCalledWith( - VALID_ADDRESS, - expect.objectContaining({ creator_id: 'creator-abc' }) - ); - }); - - it('returns only trades for the specified creator when creator_id is set', async () => { - const items: WalletActivityItem[] = [ - makeActivity({ creator_id: 'creator-abc', creator_handle: 'target' }), - ]; - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([items, 1, null]); - - const req = makeReq({ address: VALID_ADDRESS }, { creator_id: 'creator-abc' }); - const res = makeRes(); - await httpGetWalletActivity(req, res, makeNext()); - - const body = res.json.mock.calls[0][0]; - expect(body.data.items.every((i: WalletActivityItem) => i.creator_id === 'creator-abc')).toBe(true); - }); - - // ── Malformed address → 400 ─────────────────────────────────────────────── - - it('returns 400 for a malformed Stellar address', async () => { - const req = makeReq({ address: MALFORMED_ADDRESS }); - const res = makeRes(); - await httpGetWalletActivity(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(400); - const body = res.json.mock.calls[0][0]; - expect(body.success).toBe(false); - expect(body.error.code).toBe('VALIDATION_ERROR'); - }); - - it('returns 400 for an invalid type filter value', async () => { - const req = makeReq({ address: VALID_ADDRESS }, { type: 'transfer' }); - const res = makeRes(); - await httpGetWalletActivity(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(400); - }); - - // ── Pagination ──────────────────────────────────────────────────────────── - - it('meta reflects limit and offset correctly', async () => { - const items = Array.from({ length: 5 }, () => makeActivity()); - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([items, 50, null]); - - const req = makeReq({ address: VALID_ADDRESS }, { limit: '5', offset: '10' }); - const res = makeRes(); - await httpGetWalletActivity(req, res, makeNext()); - - const meta = res.json.mock.calls[0][0].data.meta; - expect(meta.limit).toBe(5); - expect(meta.offset).toBe(10); - expect(meta.total).toBe(50); - expect(meta.hasMore).toBe(true); - }); - - it('hasMore is false when all items fit in one page', async () => { - const items = [makeActivity()]; - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([items, 1, null]); - - const req = makeReq({ address: VALID_ADDRESS }, { limit: '20', offset: '0' }); - const res = makeRes(); - await httpGetWalletActivity(req, res, makeNext()); - - expect(res.json.mock.calls[0][0].data.meta.hasMore).toBe(false); - }); - - it('forwards service errors to next()', async () => { - const err = new Error('db down'); - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockRejectedValue(err); - - const req = makeReq({ address: VALID_ADDRESS }); - const res = makeRes(); - const next = makeNext(); - await httpGetWalletActivity(req, res, next); - - expect(next).toHaveBeenCalledWith(err); - }); + }), + makeActivity({ + type: 'sell', + creator_id: 'creator-2', + creator_handle: 'bob', + }), + ]; + jest + .spyOn(walletActivityService, 'fetchWalletActivity') + .mockResolvedValue([activities, 2, null]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.items).toHaveLength(2); + expect(body.data.meta.total).toBe(2); + }); + + it('each item includes required trade fields', async () => { + const activity = makeActivity({ + type: 'buy', + creator_id: 'creator-1', + creator_handle: 'alice', + amount: '5', + price_at_trade: '100', + fee_paid: '2', + ledger_sequence: 42, + timestamp: new Date('2026-03-01T00:00:00Z'), + }); + jest + .spyOn(walletActivityService, 'fetchWalletActivity') + .mockResolvedValue([[activity], 1, null]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + const item = res.json.mock.calls[0][0].data.items[0]; + expect(item).toMatchObject({ + type: 'buy', + creator_id: 'creator-1', + creator_handle: 'alice', + amount: '5', + price_at_trade: '100', + fee_paid: '2', + ledger_sequence: 42, + }); + expect(item.timestamp).toBeDefined(); + }); + + // ── Empty wallet ────────────────────────────────────────────────────────── + + it('returns 200 with empty items array (not 404) for a wallet with no activity', async () => { + jest + .spyOn(walletActivityService, 'fetchWalletActivity') + .mockResolvedValue([[], 0, null]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toEqual([]); + expect(body.data.meta.total).toBe(0); + expect(body.data.meta.hasMore).toBe(false); + }); + + // ── type filter ─────────────────────────────────────────────────────────── + + it('passes type=buy filter to the service', async () => { + const spy = jest + .spyOn(walletActivityService, 'fetchWalletActivity') + .mockResolvedValue([[], 0, null]); + + const req = makeReq({ address: VALID_ADDRESS }, { type: 'buy' }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(spy).toHaveBeenCalledWith( + VALID_ADDRESS, + expect.objectContaining({ type: 'buy' }) + ); + }); + + it('passes type=sell filter to the service', async () => { + const spy = jest + .spyOn(walletActivityService, 'fetchWalletActivity') + .mockResolvedValue([[], 0, null]); + + const req = makeReq({ address: VALID_ADDRESS }, { type: 'sell' }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(spy).toHaveBeenCalledWith( + VALID_ADDRESS, + expect.objectContaining({ type: 'sell' }) + ); + }); + + it('returns only buy events when type=buy', async () => { + const buys: WalletActivityItem[] = [makeActivity({ type: 'buy' })]; + jest + .spyOn(walletActivityService, 'fetchWalletActivity') + .mockResolvedValue([buys, 1, null]); + + const req = makeReq({ address: VALID_ADDRESS }, { type: 'buy' }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect( + body.data.items.every((i: WalletActivityItem) => i.type === 'buy') + ).toBe(true); + }); + + // ── creator_id filter ───────────────────────────────────────────────────── + + it('passes creator_id filter to the service', async () => { + const spy = jest + .spyOn(walletActivityService, 'fetchWalletActivity') + .mockResolvedValue([[], 0, null]); + + const req = makeReq( + { address: VALID_ADDRESS }, + { creator_id: 'creator-abc' } + ); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(spy).toHaveBeenCalledWith( + VALID_ADDRESS, + expect.objectContaining({ creator_id: 'creator-abc' }) + ); + }); + + it('returns only trades for the specified creator when creator_id is set', async () => { + const items: WalletActivityItem[] = [ + makeActivity({ creator_id: 'creator-abc', creator_handle: 'target' }), + ]; + jest + .spyOn(walletActivityService, 'fetchWalletActivity') + .mockResolvedValue([items, 1, null]); + + const req = makeReq( + { address: VALID_ADDRESS }, + { creator_id: 'creator-abc' } + ); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect( + body.data.items.every( + (i: WalletActivityItem) => i.creator_id === 'creator-abc' + ) + ).toBe(true); + }); + + // ── Malformed address → 400 ─────────────────────────────────────────────── + + it('returns 400 for a malformed Stellar address', async () => { + const req = makeReq({ address: MALFORMED_ADDRESS }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 400 for an invalid type filter value', async () => { + const req = makeReq({ address: VALID_ADDRESS }, { type: 'transfer' }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + // ── Pagination ──────────────────────────────────────────────────────────── + + it('meta reflects limit and offset correctly', async () => { + const items = Array.from({ length: 5 }, () => makeActivity()); + jest + .spyOn(walletActivityService, 'fetchWalletActivity') + .mockResolvedValue([items, 50, null]); + + const req = makeReq( + { address: VALID_ADDRESS }, + { limit: '5', offset: '10' } + ); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + const meta = res.json.mock.calls[0][0].data.meta; + expect(meta.limit).toBe(5); + expect(meta.offset).toBe(10); + expect(meta.total).toBe(50); + expect(meta.hasMore).toBe(true); + }); + + it('hasMore is false when all items fit in one page', async () => { + const items = [makeActivity()]; + jest + .spyOn(walletActivityService, 'fetchWalletActivity') + .mockResolvedValue([items, 1, null]); + + const req = makeReq( + { address: VALID_ADDRESS }, + { limit: '20', offset: '0' } + ); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(res.json.mock.calls[0][0].data.meta.hasMore).toBe(false); + }); + + it('forwards service errors to next()', async () => { + const err = new Error('db down'); + jest + .spyOn(walletActivityService, 'fetchWalletActivity') + .mockRejectedValue(err); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + const next = makeNext(); + await httpGetWalletActivity(req, res, next); + + expect(next).toHaveBeenCalledWith(err); + }); }); diff --git a/src/modules/wallets/wallet-activity.schemas.ts b/src/modules/wallets/wallet-activity.schemas.ts index a6af6b0..dbff3a3 100644 --- a/src/modules/wallets/wallet-activity.schemas.ts +++ b/src/modules/wallets/wallet-activity.schemas.ts @@ -2,42 +2,47 @@ import { z } from 'zod'; import { StellarAddressSchema } from '../wallet/wallet.schemas'; import { safeIntParam } from '../../utils/query.utils'; import { PUBLIC_OFFSET_PAGINATION_DEFAULTS } from '../../utils/public-list-query-defaults'; -import { MIN_PAGE_SIZE, MAX_PAGE_SIZE } from '../../constants/pagination.constants'; +import { + MIN_PAGE_SIZE, + MAX_PAGE_SIZE, +} from '../../constants/pagination.constants'; export const WalletActivityParamsSchema = z.object({ - address: StellarAddressSchema, + address: StellarAddressSchema, }); -export const WalletActivityQuerySchema = z.object({ - limit: safeIntParam({ - defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, - min: MIN_PAGE_SIZE, - max: MAX_PAGE_SIZE, - label: 'Limit', - }), - offset: safeIntParam({ - defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, - min: 0, - max: Number.MAX_SAFE_INTEGER, - label: 'Offset', - }), - type: z.enum(['buy', 'sell']).optional(), - creator_id: z.string().optional(), - cursor: z.string().optional(), -}).strict(); +export const WalletActivityQuerySchema = z + .object({ + limit: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, + min: MIN_PAGE_SIZE, + max: MAX_PAGE_SIZE, + label: 'Limit', + }), + offset: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, + min: 0, + max: Number.MAX_SAFE_INTEGER, + label: 'Offset', + }), + type: z.enum(['buy', 'sell']).optional(), + creator_id: z.string().optional(), + cursor: z.string().optional(), + }) + .strict(); export type WalletActivityQueryType = z.infer; export const WalletActivityItemSchema = z.object({ - id: z.string(), - type: z.enum(['buy', 'sell']), - creator_id: z.string(), - creator_handle: z.string().nullable(), - amount: z.any(), - price_at_trade: z.any(), - fee_paid: z.any(), - ledger_sequence: z.number().nullable(), - timestamp: z.date(), + id: z.string(), + type: z.enum(['buy', 'sell']), + creator_id: z.string(), + creator_handle: z.string().nullable(), + amount: z.any(), + price_at_trade: z.any(), + fee_paid: z.any(), + ledger_sequence: z.number().nullable(), + timestamp: z.date(), }); export type WalletActivityItem = z.infer; diff --git a/src/modules/wallets/wallet-activity.service.integration.test.ts b/src/modules/wallets/wallet-activity.service.integration.test.ts index a582df4..0b01699 100644 --- a/src/modules/wallets/wallet-activity.service.integration.test.ts +++ b/src/modules/wallets/wallet-activity.service.integration.test.ts @@ -2,133 +2,158 @@ import { fetchWalletActivity } from './wallet-activity.service'; import { prisma } from '../../utils/prisma.utils'; jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - activity: { - findMany: jest.fn(), - count: jest.fn(), - }, - creatorProfile: { - findMany: jest.fn(), - }, - }, + prisma: { + activity: { + findMany: jest.fn(), + count: jest.fn(), + }, + creatorProfile: { + findMany: jest.fn(), + }, + }, })); const mockPrisma = prisma as unknown as { - activity: { - findMany: jest.Mock; - count: jest.Mock; - }; - creatorProfile: { - findMany: jest.Mock; - }; + activity: { + findMany: jest.Mock; + count: jest.Mock; + }; + creatorProfile: { + findMany: jest.Mock; + }; }; -const WALLET_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const WALLET_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const mixedActivities = [ - { - type: 'KEY_BOUGHT', - actor: WALLET_ADDRESS, - creatorId: 'creator-alpha', - payload: { amount: '2', price_at_trade: '10', fee_paid: '0.1', ledger_sequence: 101 }, - createdAt: new Date('2026-06-01T00:00:00Z'), - }, - { - type: 'KEY_SOLD', - actor: WALLET_ADDRESS, - creatorId: 'creator-beta', - payload: { amount: '1', price_at_trade: '8', fee_paid: '0.08', ledger_sequence: 102 }, - createdAt: new Date('2026-06-02T00:00:00Z'), - }, - { - type: 'KEY_BOUGHT', - actor: WALLET_ADDRESS, - creatorId: 'creator-beta', - payload: { amount: '3', price_at_trade: '12', fee_paid: '0.12', ledger_sequence: 103 }, - createdAt: new Date('2026-06-03T00:00:00Z'), - }, + { + type: 'KEY_BOUGHT', + actor: WALLET_ADDRESS, + creatorId: 'creator-alpha', + payload: { + amount: '2', + price_at_trade: '10', + fee_paid: '0.1', + ledger_sequence: 101, + }, + createdAt: new Date('2026-06-01T00:00:00Z'), + }, + { + type: 'KEY_SOLD', + actor: WALLET_ADDRESS, + creatorId: 'creator-beta', + payload: { + amount: '1', + price_at_trade: '8', + fee_paid: '0.08', + ledger_sequence: 102, + }, + createdAt: new Date('2026-06-02T00:00:00Z'), + }, + { + type: 'KEY_BOUGHT', + actor: WALLET_ADDRESS, + creatorId: 'creator-beta', + payload: { + amount: '3', + price_at_trade: '12', + fee_paid: '0.12', + ledger_sequence: 103, + }, + createdAt: new Date('2026-06-03T00:00:00Z'), + }, ]; function matchingActivities(where: { type?: string | { in: string[] } }) { - if (where.type === 'KEY_BOUGHT') { - return mixedActivities.filter((activity) => activity.type === 'KEY_BOUGHT'); - } + if (where.type === 'KEY_BOUGHT') { + return mixedActivities.filter(activity => activity.type === 'KEY_BOUGHT'); + } - if (where.type === 'KEY_SOLD') { - return mixedActivities.filter((activity) => activity.type === 'KEY_SOLD'); - } + if (where.type === 'KEY_SOLD') { + return mixedActivities.filter(activity => activity.type === 'KEY_SOLD'); + } - return mixedActivities; + return mixedActivities; } describe('fetchWalletActivity type filter integration', () => { - beforeEach(() => { - jest.clearAllMocks(); + beforeEach(() => { + jest.clearAllMocks(); - mockPrisma.activity.findMany.mockImplementation(({ where }) => - Promise.resolve(matchingActivities(where)) - ); - mockPrisma.activity.count.mockImplementation(({ where }) => - Promise.resolve(matchingActivities(where).length) - ); - mockPrisma.creatorProfile.findMany.mockResolvedValue([ - { id: 'creator-alpha', handle: 'alpha' }, - { id: 'creator-beta', handle: 'beta' }, - ]); - }); + mockPrisma.activity.findMany.mockImplementation(({ where }) => + Promise.resolve(matchingActivities(where)) + ); + mockPrisma.activity.count.mockImplementation(({ where }) => + Promise.resolve(matchingActivities(where).length) + ); + mockPrisma.creatorProfile.findMany.mockResolvedValue([ + { id: 'creator-alpha', handle: 'alpha' }, + { id: 'creator-beta', handle: 'beta' }, + ]); + }); - it('returns only buy events when type=buy', async () => { - const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { - type: 'buy', - limit: 20, - offset: 0, - }); + it('returns only buy events when type=buy', async () => { + const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { + type: 'buy', + limit: 20, + offset: 0, + }); - expect(total).toBe(2); - expect(items).toHaveLength(2); - expect(items.map((item) => item.type)).toEqual(['buy', 'buy']); - expect(items.map((item) => item.creator_id)).toEqual(['creator-alpha', 'creator-beta']); - expect(mockPrisma.activity.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ actor: WALLET_ADDRESS, type: 'KEY_BOUGHT' }), - }) - ); - }); + expect(total).toBe(2); + expect(items).toHaveLength(2); + expect(items.map(item => item.type)).toEqual(['buy', 'buy']); + expect(items.map(item => item.creator_id)).toEqual([ + 'creator-alpha', + 'creator-beta', + ]); + expect(mockPrisma.activity.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + actor: WALLET_ADDRESS, + type: 'KEY_BOUGHT', + }), + }) + ); + }); - it('returns only sell events when type=sell', async () => { - const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { - type: 'sell', - limit: 20, - offset: 0, - }); + it('returns only sell events when type=sell', async () => { + const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { + type: 'sell', + limit: 20, + offset: 0, + }); - expect(total).toBe(1); - expect(items).toHaveLength(1); - expect(items.every((item) => item.type === 'sell')).toBe(true); - expect(items[0].creator_id).toBe('creator-beta'); - expect(mockPrisma.activity.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ actor: WALLET_ADDRESS, type: 'KEY_SOLD' }), - }) - ); - }); + expect(total).toBe(1); + expect(items).toHaveLength(1); + expect(items.every(item => item.type === 'sell')).toBe(true); + expect(items[0].creator_id).toBe('creator-beta'); + expect(mockPrisma.activity.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + actor: WALLET_ADDRESS, + type: 'KEY_SOLD', + }), + }) + ); + }); - it('returns the full mixed history when type is omitted', async () => { - const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { - limit: 20, - offset: 0, - }); + it('returns the full mixed history when type is omitted', async () => { + const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + }); - expect(total).toBe(3); - expect(items).toHaveLength(3); - expect(items.map((item) => item.type)).toEqual(['buy', 'sell', 'buy']); - expect(mockPrisma.activity.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - actor: WALLET_ADDRESS, - type: { in: ['KEY_BOUGHT', 'KEY_SOLD'] }, - }), - }) - ); - }); + expect(total).toBe(3); + expect(items).toHaveLength(3); + expect(items.map(item => item.type)).toEqual(['buy', 'sell', 'buy']); + expect(mockPrisma.activity.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + actor: WALLET_ADDRESS, + type: { in: ['KEY_BOUGHT', 'KEY_SOLD'] }, + }), + }) + ); + }); }); diff --git a/src/modules/wallets/wallet-activity.service.ts b/src/modules/wallets/wallet-activity.service.ts index 06285f3..4500be4 100644 --- a/src/modules/wallets/wallet-activity.service.ts +++ b/src/modules/wallets/wallet-activity.service.ts @@ -1,5 +1,8 @@ import { prisma } from '../../utils/prisma.utils'; -import { WalletActivityItem, WalletActivityQueryType } from './wallet-activity.schemas'; +import { + WalletActivityItem, + WalletActivityQueryType, +} from './wallet-activity.schemas'; /** * Fetches the paginated trade activity (KEY_BOUGHT / KEY_SOLD) for a single @@ -15,88 +18,110 @@ import { decodeCursor, encodeCursor } from '../../utils/cursor.utils'; * Shape of decoded activity cursor */ export interface ActivityFeedCursorPayload { - id: string; + id: string; } export async function fetchWalletActivity( - address: string, - query: WalletActivityQueryType + address: string, + query: WalletActivityQueryType ): Promise<[WalletActivityItem[], number, string | null]> { - const { limit, offset, type, creator_id, cursor } = query; + const { limit, offset, type, creator_id, cursor } = query; - // Map the public-facing type param to the internal ActivityType enum values. - const typeFilter = - type === 'buy' ? 'KEY_BOUGHT' : - type === 'sell' ? 'KEY_SOLD' : - undefined; + // Map the public-facing type param to the internal ActivityType enum values. + const typeFilter = + type === 'buy' ? 'KEY_BOUGHT' : type === 'sell' ? 'KEY_SOLD' : undefined; - const where: any = { - actor: address, - type: typeFilter - ? typeFilter - : { in: ['KEY_BOUGHT', 'KEY_SOLD'] }, - }; + const where: any = { + actor: address, + type: typeFilter ? typeFilter : { in: ['KEY_BOUGHT', 'KEY_SOLD'] }, + }; - if (creator_id) { - where.creatorId = creator_id; - } + if (creator_id) { + where.creatorId = creator_id; + } - let prismaCursor: { id: string } | undefined; - if (cursor) { - try { - const decoded = decodeCursor(cursor); - if (decoded && decoded.id) { - prismaCursor = { id: decoded.id }; - } - } catch (_e) { - // Ignore tampered cursor and fall back - } - } + let prismaCursor: { id: string } | undefined; + if (cursor) { + try { + const decoded = decodeCursor(cursor); + if (decoded && decoded.id) { + prismaCursor = { id: decoded.id }; + } + } catch (_e) { + // Ignore tampered cursor and fall back + } + } - const [rows, total] = await Promise.all([ - prisma.activity.findMany({ - where, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - skip: prismaCursor ? 1 : offset, - take: limit, - ...(prismaCursor ? { cursor: prismaCursor } : {}), - }), - prisma.activity.count({ where }), - ]); + const [rows, total] = await Promise.all([ + prisma.activity.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: prismaCursor ? 1 : offset, + take: limit, + ...(prismaCursor ? { cursor: prismaCursor } : {}), + }), + prisma.activity.count({ where }), + ]); - if (rows.length === 0) { - return [[], total, null]; - } + if (rows.length === 0) { + return [[], total, null]; + } - // Resolve creator handles in a single batched query. - const creatorIds = [...new Set(rows.map((r: { creatorId: string | null }) => r.creatorId).filter(Boolean))] as string[]; - const creatorProfiles = await prisma.creatorProfile.findMany({ - where: { id: { in: creatorIds } }, - select: { id: true, handle: true }, - }); - const handleMap = new Map(creatorProfiles.map((c: { id: string; handle: string }) => [c.id, c.handle])); + // Resolve creator handles in a single batched query. + const creatorIds = [ + ...new Set( + rows + .map((r: { creatorId: string | null }) => r.creatorId) + .filter(Boolean) + ), + ] as string[]; + const creatorProfiles = await prisma.creatorProfile.findMany({ + where: { id: { in: creatorIds } }, + select: { id: true, handle: true }, + }); + const handleMap = new Map( + creatorProfiles.map((c: { id: string; handle: string }) => [ + c.id, + c.handle, + ]) + ); - const items: WalletActivityItem[] = rows.map((row: { id: string; type: string; creatorId: string | null; payload: unknown; createdAt: Date }) => { - const payload = (row.payload ?? {}) as Record; - return { + const items: WalletActivityItem[] = rows.map( + (row: { + id: string; + type: string; + creatorId: string | null; + payload: unknown; + createdAt: Date; + }) => { + const payload = (row.payload ?? {}) as Record; + return { id: row.id, type: row.type === 'KEY_BOUGHT' ? 'buy' : 'sell', creator_id: row.creatorId ?? '', - creator_handle: row.creatorId ? (handleMap.get(row.creatorId) ?? null) : null, + creator_handle: row.creatorId + ? (handleMap.get(row.creatorId) ?? null) + : null, amount: payload.amount ?? null, price_at_trade: payload.price_at_trade ?? null, fee_paid: payload.fee_paid ?? null, - ledger_sequence: payload.ledger_sequence != null ? Number(payload.ledger_sequence) : null, + ledger_sequence: + payload.ledger_sequence != null + ? Number(payload.ledger_sequence) + : null, timestamp: row.createdAt, - }; - }); + }; + } + ); - const hasMore = prismaCursor ? items.length === limit : offset + limit < total; - let nextCursor: string | null = null; - if (hasMore && items.length > 0) { - const lastItem = items[items.length - 1]; - nextCursor = encodeCursor({ id: lastItem.id }); - } + const hasMore = prismaCursor + ? items.length === limit + : offset + limit < total; + let nextCursor: string | null = null; + if (hasMore && items.length > 0) { + const lastItem = items[items.length - 1]; + nextCursor = encodeCursor({ id: lastItem.id }); + } - return [items, total, nextCursor]; + return [items, total, nextCursor]; } diff --git a/src/modules/wallets/wallet-holdings-pagination.integration.test.ts b/src/modules/wallets/wallet-holdings-pagination.integration.test.ts index 6bbb884..6b2c953 100644 --- a/src/modules/wallets/wallet-holdings-pagination.integration.test.ts +++ b/src/modules/wallets/wallet-holdings-pagination.integration.test.ts @@ -4,99 +4,104 @@ import { prisma } from '../../utils/prisma.utils'; const PAGE_SIZE = 20; const TOTAL_HOLDINGS = 50; -const TEST_WALLET_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const TEST_WALLET_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; describe('GET /api/v1/wallets/:address/holdings pagination', () => { - let creatorIds: string[] = []; + let creatorIds: string[] = []; - beforeAll(async () => { - // Create user - const user = await prisma.user.create({ - data: { - id: 'wallet-holdings-pag-test-user', - email: 'wallet-holdings-pag@example.com', - passwordHash: 'dummy-hash', - firstName: 'Wallet', - lastName: 'HoldingsPagTest', - }, - }); + beforeAll(async () => { + // Create user + const user = await prisma.user.create({ + data: { + id: 'wallet-holdings-pag-test-user', + email: 'wallet-holdings-pag@example.com', + passwordHash: 'dummy-hash', + firstName: 'Wallet', + lastName: 'HoldingsPagTest', + }, + }); - // Create creators - const creators = await Promise.all( - Array.from({ length: TOTAL_HOLDINGS }).map((_, i) => - prisma.creatorProfile.create({ - data: { - userId: user.id, - handle: `creator-${i}`, - displayName: `Creator ${i}`, - }, - }) - ) - ); - creatorIds = creators.map((c) => c.id); + // Create creators + const creators = await Promise.all( + Array.from({ length: TOTAL_HOLDINGS }).map((_, i) => + prisma.creatorProfile.create({ + data: { + userId: user.id, + handle: `creator-${i}`, + displayName: `Creator ${i}`, + }, + }) + ) + ); + creatorIds = creators.map(c => c.id); - // Create key ownerships for the test wallet - await prisma.keyOwnership.createMany({ - data: creatorIds.map((creatorId, i) => ({ - ownerAddress: TEST_WALLET_ADDRESS, - creatorId: creatorId, - balance: TOTAL_HOLDINGS - i, - createdAt: new Date(`2026-06-${String((i % 28) + 1).padStart(2, '0')}T00:00:00.000Z`), - })), - }); - }); + // Create key ownerships for the test wallet + await prisma.keyOwnership.createMany({ + data: creatorIds.map((creatorId, i) => ({ + ownerAddress: TEST_WALLET_ADDRESS, + creatorId: creatorId, + balance: TOTAL_HOLDINGS - i, + createdAt: new Date( + `2026-06-${String((i % 28) + 1).padStart(2, '0')}T00:00:00.000Z` + ), + })), + }); + }); - afterAll(async () => { - // Cleanup - await prisma.keyOwnership.deleteMany({ - where: { ownerAddress: TEST_WALLET_ADDRESS }, - }); - await prisma.creatorProfile.deleteMany({ - where: { id: { in: creatorIds } }, - }); - await prisma.user.delete({ - where: { id: 'wallet-holdings-pag-test-user' }, - }); - await prisma.$disconnect(); - }); + afterAll(async () => { + // Cleanup + await prisma.keyOwnership.deleteMany({ + where: { ownerAddress: TEST_WALLET_ADDRESS }, + }); + await prisma.creatorProfile.deleteMany({ + where: { id: { in: creatorIds } }, + }); + await prisma.user.delete({ + where: { id: 'wallet-holdings-pag-test-user' }, + }); + await prisma.$disconnect(); + }); - it('paginates correctly across multiple pages with no duplicates and all items present', async () => { - const allPageItems: string[][] = []; - let offset = 0; - let hasMore = true; + it('paginates correctly across multiple pages with no duplicates and all items present', async () => { + const allPageItems: string[][] = []; + let offset = 0; + let hasMore = true; - while (hasMore) { - const res = await supertest(app) - .get(`/api/v1/wallets/${TEST_WALLET_ADDRESS}/holdings?limit=${PAGE_SIZE}&offset=${offset}`); + while (hasMore) { + const res = await supertest(app).get( + `/api/v1/wallets/${TEST_WALLET_ADDRESS}/holdings?limit=${PAGE_SIZE}&offset=${offset}` + ); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); - const items = res.body.data.items; - const meta = res.body.data.meta; + const items = res.body.data.items; + const meta = res.body.data.meta; - allPageItems.push(items.map((item: any) => item.creator_id)); - hasMore = meta.hasMore; - offset += items.length; - } + allPageItems.push(items.map((item: any) => item.creator_id)); + hasMore = meta.hasMore; + offset += items.length; + } - const seenAcrossAllPages = allPageItems.flat(); - expect(new Set(seenAcrossAllPages).size).toBe(TOTAL_HOLDINGS); - expect(seenAcrossAllPages.length).toBe(TOTAL_HOLDINGS); + const seenAcrossAllPages = allPageItems.flat(); + expect(new Set(seenAcrossAllPages).size).toBe(TOTAL_HOLDINGS); + expect(seenAcrossAllPages.length).toBe(TOTAL_HOLDINGS); - // Check no duplicates between pages - const page1 = allPageItems[0]; - const page2 = allPageItems[1]; - const overlap = page1.filter((id) => page2.includes(id)); - expect(overlap.length).toBe(0); - }); + // Check no duplicates between pages + const page1 = allPageItems[0]; + const page2 = allPageItems[1]; + const overlap = page1.filter(id => page2.includes(id)); + expect(overlap.length).toBe(0); + }); - it('final page returns hasMore: false', async () => { - const res = await supertest(app) - .get(`/api/v1/wallets/${TEST_WALLET_ADDRESS}/holdings?limit=${PAGE_SIZE}&offset=${40}`); + it('final page returns hasMore: false', async () => { + const res = await supertest(app).get( + `/api/v1/wallets/${TEST_WALLET_ADDRESS}/holdings?limit=${PAGE_SIZE}&offset=${40}` + ); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.data.meta.hasMore).toBe(false); - }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.meta.hasMore).toBe(false); + }); }); diff --git a/src/modules/wallets/wallet-holdings-price-snapshot.integration.test.ts b/src/modules/wallets/wallet-holdings-price-snapshot.integration.test.ts index 59ac607..ec542b7 100644 --- a/src/modules/wallets/wallet-holdings-price-snapshot.integration.test.ts +++ b/src/modules/wallets/wallet-holdings-price-snapshot.integration.test.ts @@ -9,164 +9,165 @@ import { fetchWalletHoldings } from './wallet-holdings.service'; import { prisma } from '../../utils/prisma.utils'; jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - keyOwnership: { - findMany: jest.fn(), - }, - creatorProfile: { - findMany: jest.fn(), - }, - creatorPriceSnapshot: { - findMany: jest.fn(), - }, - }, + prisma: { + keyOwnership: { + findMany: jest.fn(), + }, + creatorProfile: { + findMany: jest.fn(), + }, + creatorPriceSnapshot: { + findMany: jest.fn(), + }, + }, })); const mockPrisma = prisma as unknown as { - keyOwnership: { findMany: jest.Mock }; - creatorProfile: { findMany: jest.Mock }; - creatorPriceSnapshot: { findMany: jest.Mock }; + keyOwnership: { findMany: jest.Mock }; + creatorProfile: { findMany: jest.Mock }; + creatorPriceSnapshot: { findMany: jest.Mock }; }; -const WALLET_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const WALLET_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const CREATOR_ID = 'creator-snap-test-1'; describe('Holdings total_value recalculated after price snapshot update', () => { - beforeEach(() => { - jest.clearAllMocks(); - - mockPrisma.keyOwnership.findMany.mockResolvedValue([ - { - ownerAddress: WALLET_ADDRESS, - creatorId: CREATOR_ID, - balance: '5', - createdAt: new Date('2026-01-01T00:00:00Z'), - }, - ]); - mockPrisma.creatorProfile.findMany.mockResolvedValue([ - { id: CREATOR_ID, handle: 'snap-creator' }, - ]); - }); - - it('total_value reflects the initial price snapshot', async () => { - mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ - { creatorId: CREATOR_ID, currentPrice: BigInt(1000) }, - ]); - - const [items, total] = await fetchWalletHoldings(WALLET_ADDRESS); - - expect(total).toBe(1); - expect(items[0].current_price).toBe('1000'); - expect(items[0].total_value).toBe('5000'); - }); - - it('total_value and current_price update after price snapshot changes', async () => { - mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ - { creatorId: CREATOR_ID, currentPrice: BigInt(1000) }, - ]); - const [initialItems] = await fetchWalletHoldings(WALLET_ADDRESS); - const initialCurrentPrice = initialItems[0].current_price; - const initialTotalValue = initialItems[0].total_value; - - expect(initialCurrentPrice).toBe('1000'); - expect(initialTotalValue).toBe('5000'); - - mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ - { creatorId: CREATOR_ID, currentPrice: BigInt(2500) }, - ]); - const [updatedItems] = await fetchWalletHoldings(WALLET_ADDRESS); - - expect(updatedItems[0].current_price).toBe('2500'); - expect(updatedItems[0].total_value).toBe('12500'); - expect(updatedItems[0].current_price).not.toBe(initialCurrentPrice); - expect(updatedItems[0].total_value).not.toBe(initialTotalValue); - }); - - it('current_price and total_value are null when no snapshot exists for the creator', async () => { - mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([]); - - const [items] = await fetchWalletHoldings(WALLET_ADDRESS); - - expect(items[0].current_price).toBeNull(); - expect(items[0].total_value).toBeNull(); - }); - - it('total_value is computed per-holding when wallet has multiple holdings', async () => { - mockPrisma.keyOwnership.findMany.mockResolvedValue([ - { - ownerAddress: WALLET_ADDRESS, - creatorId: 'creator-snap-a', - balance: '3', - createdAt: new Date('2026-01-01T00:00:00Z'), - }, - { - ownerAddress: WALLET_ADDRESS, - creatorId: 'creator-snap-b', - balance: '2', - createdAt: new Date('2026-01-02T00:00:00Z'), - }, - ]); - mockPrisma.creatorProfile.findMany.mockResolvedValue([ - { id: 'creator-snap-a', handle: 'snap-a' }, - { id: 'creator-snap-b', handle: 'snap-b' }, - ]); - mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ - { creatorId: 'creator-snap-a', currentPrice: BigInt(100) }, - { creatorId: 'creator-snap-b', currentPrice: BigInt(200) }, - ]); - - const [items, total] = await fetchWalletHoldings(WALLET_ADDRESS); - - expect(total).toBe(2); - expect(items[0].current_price).toBe('200'); - expect(items[0].total_value).toBe('400'); - expect(items[1].current_price).toBe('100'); - expect(items[1].total_value).toBe('300'); - }); - - it('total_value for each holding updates independently when snapshot changes', async () => { - mockPrisma.keyOwnership.findMany.mockResolvedValue([ - { - ownerAddress: WALLET_ADDRESS, - creatorId: 'creator-snap-a', - balance: '3', - createdAt: new Date('2026-01-01T00:00:00Z'), - }, - { - ownerAddress: WALLET_ADDRESS, - creatorId: 'creator-snap-b', - balance: '2', - createdAt: new Date('2026-01-02T00:00:00Z'), - }, - ]); - mockPrisma.creatorProfile.findMany.mockResolvedValue([ - { id: 'creator-snap-a', handle: 'snap-a' }, - { id: 'creator-snap-b', handle: 'snap-b' }, - ]); - - mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ - { creatorId: 'creator-snap-a', currentPrice: BigInt(100) }, - { creatorId: 'creator-snap-b', currentPrice: BigInt(200) }, - ]); - const [before] = await fetchWalletHoldings(WALLET_ADDRESS); - - mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ - { creatorId: 'creator-snap-a', currentPrice: BigInt(150) }, - { creatorId: 'creator-snap-b', currentPrice: BigInt(200) }, - ]); - const [after] = await fetchWalletHoldings(WALLET_ADDRESS); - - expect(before[1].total_value).toBe('300'); - expect(after[0].total_value).toBe('450'); - expect(before[0].total_value).toBe(after[1].total_value); - }); - - it('returns empty items for a wallet with no holdings', async () => { - mockPrisma.keyOwnership.findMany.mockResolvedValue([]); - - const [items, total] = await fetchWalletHoldings(WALLET_ADDRESS); - - expect(total).toBe(0); - expect(items).toEqual([]); - }); + beforeEach(() => { + jest.clearAllMocks(); + + mockPrisma.keyOwnership.findMany.mockResolvedValue([ + { + ownerAddress: WALLET_ADDRESS, + creatorId: CREATOR_ID, + balance: '5', + createdAt: new Date('2026-01-01T00:00:00Z'), + }, + ]); + mockPrisma.creatorProfile.findMany.mockResolvedValue([ + { id: CREATOR_ID, handle: 'snap-creator' }, + ]); + }); + + it('total_value reflects the initial price snapshot', async () => { + mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ + { creatorId: CREATOR_ID, currentPrice: BigInt(1000) }, + ]); + + const [items, total] = await fetchWalletHoldings(WALLET_ADDRESS); + + expect(total).toBe(1); + expect(items[0].current_price).toBe('1000'); + expect(items[0].total_value).toBe('5000'); + }); + + it('total_value and current_price update after price snapshot changes', async () => { + mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ + { creatorId: CREATOR_ID, currentPrice: BigInt(1000) }, + ]); + const [initialItems] = await fetchWalletHoldings(WALLET_ADDRESS); + const initialCurrentPrice = initialItems[0].current_price; + const initialTotalValue = initialItems[0].total_value; + + expect(initialCurrentPrice).toBe('1000'); + expect(initialTotalValue).toBe('5000'); + + mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ + { creatorId: CREATOR_ID, currentPrice: BigInt(2500) }, + ]); + const [updatedItems] = await fetchWalletHoldings(WALLET_ADDRESS); + + expect(updatedItems[0].current_price).toBe('2500'); + expect(updatedItems[0].total_value).toBe('12500'); + expect(updatedItems[0].current_price).not.toBe(initialCurrentPrice); + expect(updatedItems[0].total_value).not.toBe(initialTotalValue); + }); + + it('current_price and total_value are null when no snapshot exists for the creator', async () => { + mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([]); + + const [items] = await fetchWalletHoldings(WALLET_ADDRESS); + + expect(items[0].current_price).toBeNull(); + expect(items[0].total_value).toBeNull(); + }); + + it('total_value is computed per-holding when wallet has multiple holdings', async () => { + mockPrisma.keyOwnership.findMany.mockResolvedValue([ + { + ownerAddress: WALLET_ADDRESS, + creatorId: 'creator-snap-a', + balance: '3', + createdAt: new Date('2026-01-01T00:00:00Z'), + }, + { + ownerAddress: WALLET_ADDRESS, + creatorId: 'creator-snap-b', + balance: '2', + createdAt: new Date('2026-01-02T00:00:00Z'), + }, + ]); + mockPrisma.creatorProfile.findMany.mockResolvedValue([ + { id: 'creator-snap-a', handle: 'snap-a' }, + { id: 'creator-snap-b', handle: 'snap-b' }, + ]); + mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ + { creatorId: 'creator-snap-a', currentPrice: BigInt(100) }, + { creatorId: 'creator-snap-b', currentPrice: BigInt(200) }, + ]); + + const [items, total] = await fetchWalletHoldings(WALLET_ADDRESS); + + expect(total).toBe(2); + expect(items[0].current_price).toBe('200'); + expect(items[0].total_value).toBe('400'); + expect(items[1].current_price).toBe('100'); + expect(items[1].total_value).toBe('300'); + }); + + it('total_value for each holding updates independently when snapshot changes', async () => { + mockPrisma.keyOwnership.findMany.mockResolvedValue([ + { + ownerAddress: WALLET_ADDRESS, + creatorId: 'creator-snap-a', + balance: '3', + createdAt: new Date('2026-01-01T00:00:00Z'), + }, + { + ownerAddress: WALLET_ADDRESS, + creatorId: 'creator-snap-b', + balance: '2', + createdAt: new Date('2026-01-02T00:00:00Z'), + }, + ]); + mockPrisma.creatorProfile.findMany.mockResolvedValue([ + { id: 'creator-snap-a', handle: 'snap-a' }, + { id: 'creator-snap-b', handle: 'snap-b' }, + ]); + + mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ + { creatorId: 'creator-snap-a', currentPrice: BigInt(100) }, + { creatorId: 'creator-snap-b', currentPrice: BigInt(200) }, + ]); + const [before] = await fetchWalletHoldings(WALLET_ADDRESS); + + mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ + { creatorId: 'creator-snap-a', currentPrice: BigInt(150) }, + { creatorId: 'creator-snap-b', currentPrice: BigInt(200) }, + ]); + const [after] = await fetchWalletHoldings(WALLET_ADDRESS); + + expect(before[1].total_value).toBe('300'); + expect(after[0].total_value).toBe('450'); + expect(before[0].total_value).toBe(after[1].total_value); + }); + + it('returns empty items for a wallet with no holdings', async () => { + mockPrisma.keyOwnership.findMany.mockResolvedValue([]); + + const [items, total] = await fetchWalletHoldings(WALLET_ADDRESS); + + expect(total).toBe(0); + expect(items).toEqual([]); + }); }); diff --git a/src/modules/wallets/wallet-holdings-zero-balance.integration.test.ts b/src/modules/wallets/wallet-holdings-zero-balance.integration.test.ts index f761634..1abb6a3 100644 --- a/src/modules/wallets/wallet-holdings-zero-balance.integration.test.ts +++ b/src/modules/wallets/wallet-holdings-zero-balance.integration.test.ts @@ -8,84 +8,85 @@ import { fetchWalletHoldings } from './wallet-holdings.service'; import { prisma } from '../../utils/prisma.utils'; jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - keyOwnership: { - findMany: jest.fn(), - }, - creatorProfile: { - findMany: jest.fn(), - }, - creatorPriceSnapshot: { - findMany: jest.fn(), - }, - }, + prisma: { + keyOwnership: { + findMany: jest.fn(), + }, + creatorProfile: { + findMany: jest.fn(), + }, + creatorPriceSnapshot: { + findMany: jest.fn(), + }, + }, })); const mockPrisma = prisma as unknown as { - keyOwnership: { findMany: jest.Mock }; - creatorProfile: { findMany: jest.Mock }; - creatorPriceSnapshot: { findMany: jest.Mock }; + keyOwnership: { findMany: jest.Mock }; + creatorProfile: { findMany: jest.Mock }; + creatorPriceSnapshot: { findMany: jest.Mock }; }; -const WALLET_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const WALLET_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const CREATOR_WITH_BALANCE = 'creator-positive-balance'; const CREATOR_ZERO_BALANCE = 'creator-zero-balance'; describe('GET /wallets/:address/holdings — zero-balance exclusion', () => { - beforeEach(() => { - jest.clearAllMocks(); - - // The service filters balance > 0 at the DB layer, so the mock returns - // only the positive-balance row (simulating what prisma would return - // with the `balance: { gt: 0 }` where clause). - mockPrisma.keyOwnership.findMany.mockResolvedValue([ - { - ownerAddress: WALLET_ADDRESS, - creatorId: CREATOR_WITH_BALANCE, - balance: '3', - createdAt: new Date('2026-01-01T00:00:00Z'), - }, - ]); - - mockPrisma.creatorProfile.findMany.mockResolvedValue([ - { id: CREATOR_WITH_BALANCE, handle: 'active-creator' }, - ]); - - mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ - { creatorId: CREATOR_WITH_BALANCE, currentPrice: BigInt(500) }, - ]); - }); - - it('excludes zero-balance creator from response', async () => { - const [items] = await fetchWalletHoldings(WALLET_ADDRESS); - - const returnedIds = items.map((item) => item.creator_id); - expect(returnedIds).not.toContain(CREATOR_ZERO_BALANCE); - }); - - it('includes positive-balance creator with correct balance', async () => { - const [items] = await fetchWalletHoldings(WALLET_ADDRESS); - - expect(items[0].creator_id).toBe(CREATOR_WITH_BALANCE); - expect(items[0].key_count).toBe('3'); - }); - - it('response length matches only non-zero entries', async () => { - const [items, total] = await fetchWalletHoldings(WALLET_ADDRESS); - - expect(items).toHaveLength(1); - expect(total).toBe(1); - }); - - it('service queries DB with balance > 0 filter', async () => { - await fetchWalletHoldings(WALLET_ADDRESS); - - expect(mockPrisma.keyOwnership.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - balance: { gt: 0 }, - }), - }) - ); - }); + beforeEach(() => { + jest.clearAllMocks(); + + // The service filters balance > 0 at the DB layer, so the mock returns + // only the positive-balance row (simulating what prisma would return + // with the `balance: { gt: 0 }` where clause). + mockPrisma.keyOwnership.findMany.mockResolvedValue([ + { + ownerAddress: WALLET_ADDRESS, + creatorId: CREATOR_WITH_BALANCE, + balance: '3', + createdAt: new Date('2026-01-01T00:00:00Z'), + }, + ]); + + mockPrisma.creatorProfile.findMany.mockResolvedValue([ + { id: CREATOR_WITH_BALANCE, handle: 'active-creator' }, + ]); + + mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ + { creatorId: CREATOR_WITH_BALANCE, currentPrice: BigInt(500) }, + ]); + }); + + it('excludes zero-balance creator from response', async () => { + const [items] = await fetchWalletHoldings(WALLET_ADDRESS); + + const returnedIds = items.map(item => item.creator_id); + expect(returnedIds).not.toContain(CREATOR_ZERO_BALANCE); + }); + + it('includes positive-balance creator with correct balance', async () => { + const [items] = await fetchWalletHoldings(WALLET_ADDRESS); + + expect(items[0].creator_id).toBe(CREATOR_WITH_BALANCE); + expect(items[0].key_count).toBe('3'); + }); + + it('response length matches only non-zero entries', async () => { + const [items, total] = await fetchWalletHoldings(WALLET_ADDRESS); + + expect(items).toHaveLength(1); + expect(total).toBe(1); + }); + + it('service queries DB with balance > 0 filter', async () => { + await fetchWalletHoldings(WALLET_ADDRESS); + + expect(mockPrisma.keyOwnership.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + balance: { gt: 0 }, + }), + }) + ); + }); }); diff --git a/src/modules/wallets/wallet-holdings.controllers.ts b/src/modules/wallets/wallet-holdings.controllers.ts index e43e095..9f600a3 100644 --- a/src/modules/wallets/wallet-holdings.controllers.ts +++ b/src/modules/wallets/wallet-holdings.controllers.ts @@ -1,54 +1,64 @@ import { Request, Response, NextFunction } from 'express'; -import { WalletHoldingsParamsSchema, WalletHoldingsQuerySchema } from './wallet-holdings.schemas'; +import { + WalletHoldingsParamsSchema, + WalletHoldingsQuerySchema, +} from './wallet-holdings.schemas'; import { fetchWalletHoldings } from './wallet-holdings.service'; -import { sendSuccess, sendValidationError } from '../../utils/api-response.utils'; +import { + sendSuccess, + sendValidationError, +} from '../../utils/api-response.utils'; import { buildOffsetPaginationMeta } from '../../utils/pagination.utils'; export async function httpGetWalletHoldings( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { - try { - const parsedParams = WalletHoldingsParamsSchema.safeParse(req.params); - if (!parsedParams.success) { - sendValidationError( - res, - 'Invalid wallet address', - parsedParams.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({ - field: 'address', - message: issue.message, - })) - ); - return; - } + try { + const parsedParams = WalletHoldingsParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + sendValidationError( + res, + 'Invalid wallet address', + parsedParams.error.issues.map( + (issue: { path: (string | number)[]; message: string }) => ({ + field: 'address', + message: issue.message, + }) + ) + ); + return; + } - const parsedQuery = WalletHoldingsQuerySchema.safeParse(req.query); - if (!parsedQuery.success) { - sendValidationError( - res, - 'Invalid query parameters', - parsedQuery.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({ - field: issue.path.join('.'), - message: issue.message, - })) - ); - return; - } + const parsedQuery = WalletHoldingsQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + sendValidationError( + res, + 'Invalid query parameters', + parsedQuery.error.issues.map( + (issue: { path: (string | number)[]; message: string }) => ({ + field: issue.path.join('.'), + message: issue.message, + }) + ) + ); + return; + } - const [items, total] = await fetchWalletHoldings( - parsedParams.data.address, - parsedQuery.data - ); + const [items, total] = await fetchWalletHoldings( + parsedParams.data.address, + parsedQuery.data + ); - const meta = buildOffsetPaginationMeta({ - limit: parsedQuery.data.limit, - offset: parsedQuery.data.offset, - total, - }); + const meta = buildOffsetPaginationMeta({ + limit: parsedQuery.data.limit, + offset: parsedQuery.data.offset, + total, + }); - sendSuccess(res, { items, meta }); - } catch (error) { - next(error); - } + sendSuccess(res, { items, meta }); + } catch (error) { + next(error); + } } diff --git a/src/modules/wallets/wallet-holdings.integration.test.ts b/src/modules/wallets/wallet-holdings.integration.test.ts index c28af11..f11702d 100644 --- a/src/modules/wallets/wallet-holdings.integration.test.ts +++ b/src/modules/wallets/wallet-holdings.integration.test.ts @@ -10,188 +10,221 @@ import { HoldingEntry } from './wallet-holdings.schemas'; // ── Helpers ─────────────────────────────────────────────────────────────────── -const VALID_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const VALID_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; function makeReq(params: Record = {}): any { - return { params }; + return { params }; } function makeRes(): any { - const res: any = {}; - res.status = jest.fn().mockReturnValue(res); - res.setHeader = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - return res; + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; } function makeNext(): jest.Mock { - return jest.fn(); + return jest.fn(); } function assertInvalidAddress(res: any, serviceSpy: jest.SpyInstance): void { - expect(res.status).toHaveBeenCalledWith(400); - const body = res.json.mock.calls[0][0]; - expect(body.success).toBe(false); - expect(body.error.code).toBe('VALIDATION_ERROR'); - const details: Array<{ field?: string; message: string }> = body.error.details ?? []; - expect(details.some(d => d.field === 'address')).toBe(true); - expect(serviceSpy).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + expect(body.error.code).toBe('VALIDATION_ERROR'); + const details: Array<{ field?: string; message: string }> = + body.error.details ?? []; + expect(details.some(d => d.field === 'address')).toBe(true); + expect(serviceSpy).not.toHaveBeenCalled(); } function makeHolding(overrides: Partial = {}): HoldingEntry { - return { - creator_id: 'creator-1', - creator_handle: null, - key_count: '10', - current_price: '0', - total_value: null, - ...overrides, - }; + return { + creator_id: 'creator-1', + creator_handle: null, + key_count: '10', + current_price: '0', + total_value: null, + ...overrides, + }; } // ── Tests ───────────────────────────────────────────────────────────────────── describe('GET /wallets/:address/holdings', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - // ── Malformed address: too short ────────────────────────────────────────── - - it('returns 400, identifies address field, and skips DB for an address that is too short', async () => { - const serviceSpy = jest.spyOn(walletHoldingsService, 'fetchWalletHoldings'); - - const req = makeReq({ address: 'GSHORT' }); - const res = makeRes(); - await httpGetWalletHoldings(req, res, makeNext()); - - assertInvalidAddress(res, serviceSpy); - }); - - // ── Malformed address: wrong prefix ─────────────────────────────────────── - - it('returns 400, identifies address field, and skips DB for an address with the wrong prefix', async () => { - // Valid length and Base32 chars but starts with 'A' instead of 'G' - const serviceSpy = jest.spyOn(walletHoldingsService, 'fetchWalletHoldings'); - - const req = makeReq({ address: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }); - const res = makeRes(); - await httpGetWalletHoldings(req, res, makeNext()); - - assertInvalidAddress(res, serviceSpy); - }); - - // ── Malformed address: invalid characters ───────────────────────────────── - - it('returns 400, identifies address field, and skips DB for an address with invalid characters', async () => { - // Stellar Base32 allows only A-Z and 2-7; digit '0' is illegal - const serviceSpy = jest.spyOn(walletHoldingsService, 'fetchWalletHoldings'); - - const req = makeReq({ address: 'G000000000000000000000000000000000000000000000000000000' }); - const res = makeRes(); - await httpGetWalletHoldings(req, res, makeNext()); - - assertInvalidAddress(res, serviceSpy); - }); - - // ── Valid address: service is called ────────────────────────────────────── - - it('calls the service and returns 200 for a valid Stellar address', async () => { - jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([[], 0]); - - const req = makeReq({ address: VALID_ADDRESS }); - const res = makeRes(); - await httpGetWalletHoldings(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(200); - const body = res.json.mock.calls[0][0]; - expect(body.success).toBe(true); - expect(body.data.items).toEqual([]); - expect(body.data.total).toBe(0); - }); - - // ── Empty wallet ────────────────────────────────────────────────────────── - - it('returns 200 with empty items array for a wallet with no holdings', async () => { - jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([[], 0]); - - const req = makeReq({ address: VALID_ADDRESS }); - const res = makeRes(); - await httpGetWalletHoldings(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(200); - const body = res.json.mock.calls[0][0]; - expect(body.success).toBe(true); - expect(body.data.items).toEqual([]); - expect(body.data.total).toBe(0); - }); - - // ── Wallet with holdings ────────────────────────────────────────────────── - - it('returns 200 with populated items for a wallet with positions', async () => { - const items: HoldingEntry[] = [ - makeHolding({ creator_id: 'creator-1', key_count: '5', current_price: '100' }), - makeHolding({ creator_id: 'creator-2', key_count: '3', current_price: '50' }), - ]; - jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([items, 2]); - - const req = makeReq({ address: VALID_ADDRESS }); - const res = makeRes(); - await httpGetWalletHoldings(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(200); - const body = res.json.mock.calls[0][0]; - expect(body.success).toBe(true); - expect(body.data.items).toHaveLength(2); - expect(body.data.total).toBe(2); - }); - - it('each holding item includes required fields', async () => { - const holding = makeHolding({ - creator_id: 'creator-xyz', - creator_handle: 'creator-handle', - key_count: '7', + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── Malformed address: too short ────────────────────────────────────────── + + it('returns 400, identifies address field, and skips DB for an address that is too short', async () => { + const serviceSpy = jest.spyOn( + walletHoldingsService, + 'fetchWalletHoldings' + ); + + const req = makeReq({ address: 'GSHORT' }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + assertInvalidAddress(res, serviceSpy); + }); + + // ── Malformed address: wrong prefix ─────────────────────────────────────── + + it('returns 400, identifies address field, and skips DB for an address with the wrong prefix', async () => { + // Valid length and Base32 chars but starts with 'A' instead of 'G' + const serviceSpy = jest.spyOn( + walletHoldingsService, + 'fetchWalletHoldings' + ); + + const req = makeReq({ + address: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + assertInvalidAddress(res, serviceSpy); + }); + + // ── Malformed address: invalid characters ───────────────────────────────── + + it('returns 400, identifies address field, and skips DB for an address with invalid characters', async () => { + // Stellar Base32 allows only A-Z and 2-7; digit '0' is illegal + const serviceSpy = jest.spyOn( + walletHoldingsService, + 'fetchWalletHoldings' + ); + + const req = makeReq({ + address: 'G000000000000000000000000000000000000000000000000000000', + }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + assertInvalidAddress(res, serviceSpy); + }); + + // ── Valid address: service is called ────────────────────────────────────── + + it('calls the service and returns 200 for a valid Stellar address', async () => { + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([[], 0]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.items).toEqual([]); + expect(body.data.total).toBe(0); + }); + + // ── Empty wallet ────────────────────────────────────────────────────────── + + it('returns 200 with empty items array for a wallet with no holdings', async () => { + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([[], 0]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.items).toEqual([]); + expect(body.data.total).toBe(0); + }); + + // ── Wallet with holdings ────────────────────────────────────────────────── + + it('returns 200 with populated items for a wallet with positions', async () => { + const items: HoldingEntry[] = [ + makeHolding({ + creator_id: 'creator-1', + key_count: '5', current_price: '100', - total_value: '700', - }); - jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([[holding], 1]); - - const req = makeReq({ address: VALID_ADDRESS }); - const res = makeRes(); - await httpGetWalletHoldings(req, res, makeNext()); - - const item = res.json.mock.calls[0][0].data.items[0]; - expect(item).toMatchObject({ - creator_id: 'creator-xyz', - creator_handle: 'creator-handle', - key_count: '7', - current_price: '100', - }); - }); - - it('passes the address to the service', async () => { - const spy = jest - .spyOn(walletHoldingsService, 'fetchWalletHoldings') - .mockResolvedValue([[], 0]); - - const req = makeReq({ address: VALID_ADDRESS }); - const res = makeRes(); - await httpGetWalletHoldings(req, res, makeNext()); - - expect(spy).toHaveBeenCalledWith(VALID_ADDRESS); - }); - - // ── Error forwarding ────────────────────────────────────────────────────── - - it('forwards service errors to next()', async () => { - const err = new Error('db down'); - jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockRejectedValue(err); - - const req = makeReq({ address: VALID_ADDRESS }); - const res = makeRes(); - const next = makeNext(); - await httpGetWalletHoldings(req, res, next); - - expect(next).toHaveBeenCalledWith(err); - }); + }), + makeHolding({ + creator_id: 'creator-2', + key_count: '3', + current_price: '50', + }), + ]; + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([items, 2]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.items).toHaveLength(2); + expect(body.data.total).toBe(2); + }); + + it('each holding item includes required fields', async () => { + const holding = makeHolding({ + creator_id: 'creator-xyz', + creator_handle: 'creator-handle', + key_count: '7', + current_price: '100', + total_value: '700', + }); + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([[holding], 1]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const item = res.json.mock.calls[0][0].data.items[0]; + expect(item).toMatchObject({ + creator_id: 'creator-xyz', + creator_handle: 'creator-handle', + key_count: '7', + current_price: '100', + }); + }); + + it('passes the address to the service', async () => { + const spy = jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([[], 0]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(spy).toHaveBeenCalledWith(VALID_ADDRESS); + }); + + // ── Error forwarding ────────────────────────────────────────────────────── + + it('forwards service errors to next()', async () => { + const err = new Error('db down'); + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockRejectedValue(err); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + const next = makeNext(); + await httpGetWalletHoldings(req, res, next); + + expect(next).toHaveBeenCalledWith(err); + }); }); diff --git a/src/modules/wallets/wallet-holdings.schemas.ts b/src/modules/wallets/wallet-holdings.schemas.ts index 5303185..aca3add 100644 --- a/src/modules/wallets/wallet-holdings.schemas.ts +++ b/src/modules/wallets/wallet-holdings.schemas.ts @@ -1,39 +1,44 @@ import { z } from 'zod'; import { StellarAddressSchema } from '../wallet/wallet.schemas'; import { safeIntParam } from '../../utils/query.utils'; -import { MIN_PAGE_SIZE, MAX_PAGE_SIZE } from '../../constants/pagination.constants'; +import { + MIN_PAGE_SIZE, + MAX_PAGE_SIZE, +} from '../../constants/pagination.constants'; import { PUBLIC_OFFSET_PAGINATION_DEFAULTS } from '../../utils/public-list-query-defaults'; export const WalletHoldingsParamsSchema = z.object({ - address: StellarAddressSchema, + address: StellarAddressSchema, }); export const WalletHoldingsQuerySchema = z - .object({ - limit: safeIntParam({ - defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, - min: MIN_PAGE_SIZE, - max: MAX_PAGE_SIZE, - label: 'Limit', - }), - offset: safeIntParam({ - defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, - min: 0, - max: Number.MAX_SAFE_INTEGER, - label: 'Offset', - }), - }) - .strict(); + .object({ + limit: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, + min: MIN_PAGE_SIZE, + max: MAX_PAGE_SIZE, + label: 'Limit', + }), + offset: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, + min: 0, + max: Number.MAX_SAFE_INTEGER, + label: 'Offset', + }), + }) + .strict(); -export type WalletHoldingsParamsType = z.infer; +export type WalletHoldingsParamsType = z.infer< + typeof WalletHoldingsParamsSchema +>; export type WalletHoldingsQueryType = z.infer; export const HoldingEntrySchema = z.object({ - creator_id: z.string(), - creator_handle: z.string().nullable(), - key_count: z.any(), - current_price: z.any().nullable(), - total_value: z.any().nullable(), + creator_id: z.string(), + creator_handle: z.string().nullable(), + key_count: z.any(), + current_price: z.any().nullable(), + total_value: z.any().nullable(), }); export type HoldingEntry = z.infer; diff --git a/src/modules/wallets/wallet-holdings.service.integration.test.ts b/src/modules/wallets/wallet-holdings.service.integration.test.ts index 867cbb6..c976292 100644 --- a/src/modules/wallets/wallet-holdings.service.integration.test.ts +++ b/src/modules/wallets/wallet-holdings.service.integration.test.ts @@ -2,88 +2,89 @@ import { fetchWalletHoldings } from './wallet-holdings.service'; import { prisma } from '../../utils/prisma.utils'; jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - keyOwnership: { - findMany: jest.fn(), - }, - creatorProfile: { - findMany: jest.fn(), - }, - creatorPriceSnapshot: { - findMany: jest.fn(), - }, - }, + prisma: { + keyOwnership: { + findMany: jest.fn(), + }, + creatorProfile: { + findMany: jest.fn(), + }, + creatorPriceSnapshot: { + findMany: jest.fn(), + }, + }, })); const mockPrisma = prisma as unknown as { - keyOwnership: { - findMany: jest.Mock; - }; - creatorProfile: { - findMany: jest.Mock; - }; - creatorPriceSnapshot: { - findMany: jest.Mock; - }; + keyOwnership: { + findMany: jest.Mock; + }; + creatorProfile: { + findMany: jest.Mock; + }; + creatorPriceSnapshot: { + findMany: jest.Mock; + }; }; -const WALLET_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const WALLET_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; describe('fetchWalletHoldings ownership read model integration', () => { - beforeEach(() => { - jest.clearAllMocks(); + beforeEach(() => { + jest.clearAllMocks(); - mockPrisma.keyOwnership.findMany.mockResolvedValue([ - { - ownerAddress: WALLET_ADDRESS, - creatorId: 'creator-alpha', - balance: '5', - createdAt: new Date('2026-06-02T00:00:00Z'), - }, - { - ownerAddress: WALLET_ADDRESS, - creatorId: 'creator-beta', - balance: '2.5', - createdAt: new Date('2026-06-01T00:00:00Z'), - }, - ]); - mockPrisma.creatorProfile.findMany.mockResolvedValue([ - { id: 'creator-alpha', handle: 'alpha' }, - { id: 'creator-beta', handle: 'beta' }, - ]); - mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ - { creatorId: 'creator-alpha', currentPrice: BigInt(10) }, - { creatorId: 'creator-beta', currentPrice: BigInt(4) }, - ]); - }); + mockPrisma.keyOwnership.findMany.mockResolvedValue([ + { + ownerAddress: WALLET_ADDRESS, + creatorId: 'creator-alpha', + balance: '5', + createdAt: new Date('2026-06-02T00:00:00Z'), + }, + { + ownerAddress: WALLET_ADDRESS, + creatorId: 'creator-beta', + balance: '2.5', + createdAt: new Date('2026-06-01T00:00:00Z'), + }, + ]); + mockPrisma.creatorProfile.findMany.mockResolvedValue([ + { id: 'creator-alpha', handle: 'alpha' }, + { id: 'creator-beta', handle: 'beta' }, + ]); + mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([ + { creatorId: 'creator-alpha', currentPrice: BigInt(10) }, + { creatorId: 'creator-beta', currentPrice: BigInt(4) }, + ]); + }); - it('returns correct key balances and excludes zero-balance ownership rows', async () => { - const [items, total] = await fetchWalletHoldings(WALLET_ADDRESS); + it('returns correct key balances and excludes zero-balance ownership rows', async () => { + const [items, total] = await fetchWalletHoldings(WALLET_ADDRESS); - expect(mockPrisma.keyOwnership.findMany).toHaveBeenCalledWith({ - where: { - ownerAddress: WALLET_ADDRESS, - balance: { gt: 0 }, - }, - orderBy: { createdAt: 'desc' }, - }); - expect(total).toBe(2); - expect(items).toEqual([ - { - creator_id: 'creator-alpha', - creator_handle: 'alpha', - key_count: '5', - current_price: '10', - total_value: '50', - }, - { - creator_id: 'creator-beta', - creator_handle: 'beta', - key_count: '2.5', - current_price: '4', - total_value: '10', - }, - ]); - expect(items.map((item) => item.creator_id)).not.toContain('creator-zero'); - }); + expect(mockPrisma.keyOwnership.findMany).toHaveBeenCalledWith({ + where: { + ownerAddress: WALLET_ADDRESS, + balance: { gt: 0 }, + }, + orderBy: { createdAt: 'desc' }, + }); + expect(total).toBe(2); + expect(items).toEqual([ + { + creator_id: 'creator-alpha', + creator_handle: 'alpha', + key_count: '5', + current_price: '10', + total_value: '50', + }, + { + creator_id: 'creator-beta', + creator_handle: 'beta', + key_count: '2.5', + current_price: '4', + total_value: '10', + }, + ]); + expect(items.map(item => item.creator_id)).not.toContain('creator-zero'); + }); }); diff --git a/src/modules/wallets/wallet-holdings.service.ts b/src/modules/wallets/wallet-holdings.service.ts index 2c4e13a..05eedcd 100644 --- a/src/modules/wallets/wallet-holdings.service.ts +++ b/src/modules/wallets/wallet-holdings.service.ts @@ -1,6 +1,9 @@ import { prisma } from '../../utils/prisma.utils'; import { isValidStellarAddress } from '../wallet/wallet.utils'; -import { HoldingEntry, WalletHoldingsQueryType } from './wallet-holdings.schemas'; +import { + HoldingEntry, + WalletHoldingsQueryType, +} from './wallet-holdings.schemas'; /** * Fetches all creator key holdings for a given Stellar wallet address. @@ -14,80 +17,87 @@ import { HoldingEntry, WalletHoldingsQueryType } from './wallet-holdings.schemas * - total_value (null — not calculated server-side; consumers derive it from key_count * current_price) */ export async function fetchWalletHoldings( - address: string, - query?: WalletHoldingsQueryType + address: string, + query?: WalletHoldingsQueryType ): Promise<[HoldingEntry[], number]> { - if (!isValidStellarAddress(address)) { - const err = Object.assign( - new Error('Invalid Stellar wallet address'), - { statusCode: 400, code: 'VALIDATION_ERROR' } - ); - throw err; - } + if (!isValidStellarAddress(address)) { + const err = Object.assign(new Error('Invalid Stellar wallet address'), { + statusCode: 400, + code: 'VALIDATION_ERROR', + }); + throw err; + } - const rows = await prisma.keyOwnership.findMany({ - where: { - ownerAddress: address, - balance: { gt: 0 }, - }, - orderBy: { createdAt: 'desc' }, - }); + const rows = await prisma.keyOwnership.findMany({ + where: { + ownerAddress: address, + balance: { gt: 0 }, + }, + orderBy: { createdAt: 'desc' }, + }); - const total = rows.length; + const total = rows.length; - if (total === 0) { - return [[], 0]; - } + if (total === 0) { + return [[], 0]; + } - const creatorIds = [...new Set(rows.map((r: { creatorId: string }) => r.creatorId))]; + const creatorIds = [ + ...new Set(rows.map((r: { creatorId: string }) => r.creatorId)), + ]; - // Resolve creator handles in one batched query - const creatorProfiles = await prisma.creatorProfile.findMany({ - where: { id: { in: creatorIds } }, - select: { id: true, handle: true }, - }); - const handleMap = new Map( - creatorProfiles.map((c: { id: string; handle: string }) => [c.id, c.handle]) - ); + // Resolve creator handles in one batched query + const creatorProfiles = await prisma.creatorProfile.findMany({ + where: { id: { in: creatorIds } }, + select: { id: true, handle: true }, + }); + const handleMap = new Map( + creatorProfiles.map((c: { id: string; handle: string }) => [ + c.id, + c.handle, + ]) + ); - // Resolve latest price per creator from the price snapshot read model - const priceSnapshots = await prisma.creatorPriceSnapshot.findMany({ - where: { creatorId: { in: creatorIds } }, - select: { creatorId: true, currentPrice: true }, - }); + // Resolve latest price per creator from the price snapshot read model + const priceSnapshots = await prisma.creatorPriceSnapshot.findMany({ + where: { creatorId: { in: creatorIds } }, + select: { creatorId: true, currentPrice: true }, + }); - const priceMap = new Map(); - for (const snap of priceSnapshots) { - priceMap.set(snap.creatorId as string, snap.currentPrice as bigint); - } + const priceMap = new Map(); + for (const snap of priceSnapshots) { + priceMap.set(snap.creatorId as string, snap.currentPrice as bigint); + } - const items: HoldingEntry[] = rows.map((row: { creatorId: string; balance: unknown }) => { - const rawPrice = priceMap.get(row.creatorId) ?? null; - const currentPrice = rawPrice !== null ? rawPrice.toString() : null; - const totalValue = + const items: HoldingEntry[] = rows.map( + (row: { creatorId: string; balance: unknown }) => { + const rawPrice = priceMap.get(row.creatorId) ?? null; + const currentPrice = rawPrice !== null ? rawPrice.toString() : null; + const totalValue = rawPrice !== null && row.balance !== null - ? (Number(row.balance) * Number(rawPrice)).toString() - : null; - return { + ? (Number(row.balance) * Number(rawPrice)).toString() + : null; + return { creator_id: row.creatorId, creator_handle: handleMap.get(row.creatorId) ?? null, key_count: row.balance, current_price: currentPrice, total_value: totalValue, - }; - }); + }; + } + ); - // Default sort order is by total holding value descending. - items.sort((a, b) => { - const valA = a.total_value !== null ? Number(a.total_value) : 0; - const valB = b.total_value !== null ? Number(b.total_value) : 0; - return valB - valA; - }); + // Default sort order is by total holding value descending. + items.sort((a, b) => { + const valA = a.total_value !== null ? Number(a.total_value) : 0; + const valB = b.total_value !== null ? Number(b.total_value) : 0; + return valB - valA; + }); - // Apply pagination - const limit = query?.limit ?? 20; - const offset = query?.offset ?? 0; - const paginatedItems = items.slice(offset, offset + limit); + // Apply pagination + const limit = query?.limit ?? 20; + const offset = query?.offset ?? 0; + const paginatedItems = items.slice(offset, offset + limit); - return [paginatedItems, total]; + return [paginatedItems, total]; } diff --git a/src/modules/wallets/wallets.routes.ts b/src/modules/wallets/wallets.routes.ts index c663075..0495aae 100644 --- a/src/modules/wallets/wallets.routes.ts +++ b/src/modules/wallets/wallets.routes.ts @@ -14,9 +14,9 @@ const walletsRouter = Router(); * and `creator_id` filters. */ walletsRouter.get( - '/:address/activity', - cacheControl(ACTIVITY_FEED_CACHE_PRESET), - httpGetWalletActivity + '/:address/activity', + cacheControl(ACTIVITY_FEED_CACHE_PRESET), + httpGetWalletActivity ); /** diff --git a/src/modules/webhook/webhook.controllers.ts b/src/modules/webhook/webhook.controllers.ts index 4b3b4a0..5d0485e 100644 --- a/src/modules/webhook/webhook.controllers.ts +++ b/src/modules/webhook/webhook.controllers.ts @@ -1,90 +1,99 @@ import { AsyncController } from '../../types/auth.types'; import { RegisterWebhookSchema, SimulateTradeSchema } from './webhook.schemas'; -import { upsertWebhookSubscription, findMatchingSubscriptions } from './webhook.service'; -import { sendSuccess, sendValidationError } from '../../utils/api-response.utils'; +import { + upsertWebhookSubscription, + findMatchingSubscriptions, +} from './webhook.service'; +import { + sendSuccess, + sendValidationError, +} from '../../utils/api-response.utils'; import { prisma } from '../../utils/prisma.utils'; import { logger } from '../../utils/logger.utils'; export const httpRegisterWebhook: AsyncController = async (req, res, next) => { - try { - const parsed = RegisterWebhookSchema.safeParse(req.body); - if (!parsed.success) { - return sendValidationError( - res, - 'Invalid webhook registration payload', - parsed.error.issues.map(issue => ({ - field: issue.path.join('.'), - message: issue.message, - })) - ); - } + try { + const parsed = RegisterWebhookSchema.safeParse(req.body); + if (!parsed.success) { + return sendValidationError( + res, + 'Invalid webhook registration payload', + parsed.error.issues.map(issue => ({ + field: issue.path.join('.'), + message: issue.message, + })) + ); + } - const subscription = await upsertWebhookSubscription( - parsed.data.url, - parsed.data.events - ); + const subscription = await upsertWebhookSubscription( + parsed.data.url, + parsed.data.events + ); - sendSuccess(res, subscription); - } catch (error) { - next(error); - } + sendSuccess(res, subscription); + } catch (error) { + next(error); + } }; export const httpSimulateTrade: AsyncController = async (req, res, next) => { - try { - const parsed = SimulateTradeSchema.safeParse(req.body); - if (!parsed.success) { - return sendValidationError( - res, - 'Invalid trade simulation payload', - parsed.error.issues.map(issue => ({ - field: issue.path.join('.'), - message: issue.message, - })) - ); - } + try { + const parsed = SimulateTradeSchema.safeParse(req.body); + if (!parsed.success) { + return sendValidationError( + res, + 'Invalid trade simulation payload', + parsed.error.issues.map(issue => ({ + field: issue.path.join('.'), + message: issue.message, + })) + ); + } - const { type, amount, price, creatorId, actor } = parsed.data; + const { type, amount, price, creatorId, actor } = parsed.data; - // 1. Create corresponding Activity record - const activityType = type === 'buy' ? 'KEY_BOUGHT' : 'KEY_SOLD'; - const activity = await prisma.activity.create({ - data: { - type: activityType as any, - actor, - creatorId, - payload: { amount, price }, - }, - }); + // 1. Create corresponding Activity record + const activityType = type === 'buy' ? 'KEY_BOUGHT' : 'KEY_SOLD'; + const activity = await prisma.activity.create({ + data: { + type: activityType as any, + actor, + creatorId, + payload: { amount, price }, + }, + }); - // 2. Query subscriptions subscribed to this type - const subscriptions = await findMatchingSubscriptions(type); + // 2. Query subscriptions subscribed to this type + const subscriptions = await findMatchingSubscriptions(type); - // 3. Deliver webhook payloads - await Promise.all( - subscriptions.map(async (sub: any) => { - try { - await fetch(sub.url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - event_type: type, - activity, - }), - }); - } catch (err: any) { - logger.error( - { err: err.message, url: sub.url }, - 'Failed to deliver webhook' - ); - } - }) - ); + // 3. Deliver webhook payloads + await Promise.all( + subscriptions.map(async (sub: any) => { + try { + await fetch(sub.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + event_type: type, + activity, + }), + }); + } catch (err: any) { + logger.error( + { err: err.message, url: sub.url }, + 'Failed to deliver webhook' + ); + } + }) + ); - sendSuccess(res, { activity, deliveredTo: subscriptions.map((s: any) => s.url) }); - } catch (error) { - next(error); - } + sendSuccess(res, { + activity, + deliveredTo: subscriptions.map((s: any) => s.url), + }); + } catch (error) { + next(error); + } }; diff --git a/src/modules/webhook/webhook.integration.test.ts b/src/modules/webhook/webhook.integration.test.ts index 0493c3d..a1dda3f 100644 --- a/src/modules/webhook/webhook.integration.test.ts +++ b/src/modules/webhook/webhook.integration.test.ts @@ -4,249 +4,251 @@ import { prisma } from '../../utils/prisma.utils'; // Mock the prisma client to avoid needing a live DB connection jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - activity: { - create: jest.fn(), - }, - webhookSubscription: { - upsert: jest.fn(), - findMany: jest.fn(), - }, - }, + prisma: { + activity: { + create: jest.fn(), + }, + webhookSubscription: { + upsert: jest.fn(), + findMany: jest.fn(), + }, + }, })); const activityCreateMock = prisma.activity.create as jest.Mock; -const webhookUpsertMock = (prisma as any).webhookSubscription.upsert as jest.Mock; -const webhookFindManyMock = (prisma as any).webhookSubscription.findMany as jest.Mock; +const webhookUpsertMock = (prisma as any).webhookSubscription + .upsert as jest.Mock; +const webhookFindManyMock = (prisma as any).webhookSubscription + .findMany as jest.Mock; // Helper to mock Express req, res, next function makeReq(body: any = {}): any { - return { body }; + return { body }; } function makeRes(): any { - const res: any = {}; - res.status = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - return res; + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; } function makeNext() { - return jest.fn(); + return jest.fn(); } describe('Webhook Integration Tests', () => { - let mockServer: http.Server; - let mockServerUrl: string; - let receivedPayloads: any[] = []; - - beforeAll((done) => { - // Start a mock HTTP server to receive webhook payloads - mockServer = http.createServer((req, res) => { - let body = ''; - req.on('data', chunk => { - body += chunk; - }); - req.on('end', () => { - try { - receivedPayloads.push(JSON.parse(body)); - } catch (_e) { - // Ignore non-json bodies - } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true })); - }); - }); - - mockServer.listen(0, '127.0.0.1', () => { - const address = mockServer.address() as any; - mockServerUrl = `http://127.0.0.1:${address.port}/webhook`; - done(); - }); - }); - - afterAll((done) => { - mockServer.close(done); - }); - - beforeEach(() => { - receivedPayloads = []; - jest.clearAllMocks(); - }); - - it('should register a webhook successfully', async () => { - const req = makeReq({ - url: mockServerUrl, - events: ['buy', 'sell'], - }); - const res = makeRes(); - const next = makeNext(); - - const mockSub = { + let mockServer: http.Server; + let mockServerUrl: string; + let receivedPayloads: any[] = []; + + beforeAll(done => { + // Start a mock HTTP server to receive webhook payloads + mockServer = http.createServer((req, res) => { + let body = ''; + req.on('data', chunk => { + body += chunk; + }); + req.on('end', () => { + try { + receivedPayloads.push(JSON.parse(body)); + } catch (_e) { + // Ignore non-json bodies + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + + mockServer.listen(0, '127.0.0.1', () => { + const address = mockServer.address() as any; + mockServerUrl = `http://127.0.0.1:${address.port}/webhook`; + done(); + }); + }); + + afterAll(done => { + mockServer.close(done); + }); + + beforeEach(() => { + receivedPayloads = []; + jest.clearAllMocks(); + }); + + it('should register a webhook successfully', async () => { + const req = makeReq({ + url: mockServerUrl, + events: ['buy', 'sell'], + }); + const res = makeRes(); + const next = makeNext(); + + const mockSub = { + id: 'sub-123', + url: mockServerUrl, + events: ['buy', 'sell'], + createdAt: new Date(), + updatedAt: new Date(), + }; + webhookUpsertMock.mockResolvedValue(mockSub); + + await httpRegisterWebhook(req, res, next); + + expect(res.status).not.toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + url: mockServerUrl, + events: ['buy', 'sell'], + }), + }) + ); + expect(webhookUpsertMock).toHaveBeenCalledWith({ + where: { url: mockServerUrl }, + create: { url: mockServerUrl, events: ['buy', 'sell'] }, + update: { events: ['buy', 'sell'] }, + }); + }); + + it('should deliver webhook with event_type: buy when a buy trade is simulated', async () => { + const req = makeReq({ + type: 'buy', + amount: 5, + price: 15.5, + creatorId: 'creator-abc', + actor: 'user-xyz', + }); + const res = makeRes(); + const next = makeNext(); + + const mockActivity = { + id: 'activity-buy-1', + type: 'KEY_BOUGHT', + actor: 'user-xyz', + creatorId: 'creator-abc', + payload: { amount: 5, price: 15.5 }, + createdAt: new Date(), + }; + + const mockSubscriptions = [ + { id: 'sub-123', url: mockServerUrl, events: ['buy', 'sell'], createdAt: new Date(), updatedAt: new Date(), - }; - webhookUpsertMock.mockResolvedValue(mockSub); - - await httpRegisterWebhook(req, res, next); - - expect(res.status).not.toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: true, - data: expect.objectContaining({ - url: mockServerUrl, - events: ['buy', 'sell'], - }), - }) - ); - expect(webhookUpsertMock).toHaveBeenCalledWith({ - where: { url: mockServerUrl }, - create: { url: mockServerUrl, events: ['buy', 'sell'] }, - update: { events: ['buy', 'sell'] }, - }); - }); - - it('should deliver webhook with event_type: buy when a buy trade is simulated', async () => { - const req = makeReq({ - type: 'buy', - amount: 5, - price: 15.5, - creatorId: 'creator-abc', - actor: 'user-xyz', - }); - const res = makeRes(); - const next = makeNext(); - - const mockActivity = { + }, + ]; + + activityCreateMock.mockResolvedValue(mockActivity); + webhookFindManyMock.mockResolvedValue(mockSubscriptions); + + await httpSimulateTrade(req, res, next); + + expect(res.status).not.toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + activity: expect.objectContaining({ id: 'activity-buy-1' }), + }), + }) + ); + + // Verify webhook HTTP delivery + expect(receivedPayloads).toHaveLength(1); + expect(receivedPayloads[0]).toEqual({ + event_type: 'buy', + activity: expect.objectContaining({ id: 'activity-buy-1', type: 'KEY_BOUGHT', - actor: 'user-xyz', - creatorId: 'creator-abc', - payload: { amount: 5, price: 15.5 }, - createdAt: new Date(), - }; - - const mockSubscriptions = [ - { - id: 'sub-123', - url: mockServerUrl, - events: ['buy', 'sell'], - createdAt: new Date(), - updatedAt: new Date(), - }, - ]; - - activityCreateMock.mockResolvedValue(mockActivity); - webhookFindManyMock.mockResolvedValue(mockSubscriptions); - - await httpSimulateTrade(req, res, next); - - expect(res.status).not.toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: true, - data: expect.objectContaining({ - activity: expect.objectContaining({ id: 'activity-buy-1' }), - }), - }) - ); - - // Verify webhook HTTP delivery - expect(receivedPayloads).toHaveLength(1); - expect(receivedPayloads[0]).toEqual({ - event_type: 'buy', - activity: expect.objectContaining({ - id: 'activity-buy-1', - type: 'KEY_BOUGHT', - }), - }); - }); - - it('should deliver webhook with event_type: sell when a sell trade is simulated', async () => { - const req = makeReq({ - type: 'sell', - amount: 2, - price: 8.0, - creatorId: 'creator-abc', - actor: 'user-xyz', - }); - const res = makeRes(); - const next = makeNext(); - - const mockActivity = { - id: 'activity-sell-1', - type: 'KEY_SOLD', - actor: 'user-xyz', - creatorId: 'creator-abc', - payload: { amount: 2, price: 8.0 }, - createdAt: new Date(), - }; - - const mockSubscriptions = [ - { - id: 'sub-123', - url: mockServerUrl, - events: ['buy', 'sell'], - createdAt: new Date(), - updatedAt: new Date(), - }, - ]; - - activityCreateMock.mockResolvedValue(mockActivity); - webhookFindManyMock.mockResolvedValue(mockSubscriptions); - - await httpSimulateTrade(req, res, next); - - expect(res.status).not.toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: true, - data: expect.objectContaining({ - activity: expect.objectContaining({ id: 'activity-sell-1' }), - }), - }) - ); - - // Verify webhook HTTP delivery - expect(receivedPayloads).toHaveLength(1); - expect(receivedPayloads[0]).toEqual({ - event_type: 'sell', - activity: expect.objectContaining({ - id: 'activity-sell-1', - type: 'KEY_SOLD', - }), - }); - }); - - it('should not deliver webhook if subscription does not match event_type', async () => { - const req = makeReq({ - type: 'sell', - amount: 1, - price: 10.0, - creatorId: 'creator-abc', - actor: 'user-xyz', - }); - const res = makeRes(); - const next = makeNext(); - - activityCreateMock.mockResolvedValue({ - id: 'activity-sell-2', - type: 'KEY_SOLD', - actor: 'user-xyz', - creatorId: 'creator-abc', - payload: { amount: 1, price: 10.0 }, + }), + }); + }); + + it('should deliver webhook with event_type: sell when a sell trade is simulated', async () => { + const req = makeReq({ + type: 'sell', + amount: 2, + price: 8.0, + creatorId: 'creator-abc', + actor: 'user-xyz', + }); + const res = makeRes(); + const next = makeNext(); + + const mockActivity = { + id: 'activity-sell-1', + type: 'KEY_SOLD', + actor: 'user-xyz', + creatorId: 'creator-abc', + payload: { amount: 2, price: 8.0 }, + createdAt: new Date(), + }; + + const mockSubscriptions = [ + { + id: 'sub-123', + url: mockServerUrl, + events: ['buy', 'sell'], createdAt: new Date(), - }); + updatedAt: new Date(), + }, + ]; - // Mock findMany returning empty array (no subscriptions for sell event) - webhookFindManyMock.mockResolvedValue([]); + activityCreateMock.mockResolvedValue(mockActivity); + webhookFindManyMock.mockResolvedValue(mockSubscriptions); - await httpSimulateTrade(req, res, next); + await httpSimulateTrade(req, res, next); - expect(receivedPayloads).toHaveLength(0); - }); + expect(res.status).not.toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + activity: expect.objectContaining({ id: 'activity-sell-1' }), + }), + }) + ); + + // Verify webhook HTTP delivery + expect(receivedPayloads).toHaveLength(1); + expect(receivedPayloads[0]).toEqual({ + event_type: 'sell', + activity: expect.objectContaining({ + id: 'activity-sell-1', + type: 'KEY_SOLD', + }), + }); + }); + + it('should not deliver webhook if subscription does not match event_type', async () => { + const req = makeReq({ + type: 'sell', + amount: 1, + price: 10.0, + creatorId: 'creator-abc', + actor: 'user-xyz', + }); + const res = makeRes(); + const next = makeNext(); + + activityCreateMock.mockResolvedValue({ + id: 'activity-sell-2', + type: 'KEY_SOLD', + actor: 'user-xyz', + creatorId: 'creator-abc', + payload: { amount: 1, price: 10.0 }, + createdAt: new Date(), + }); + + // Mock findMany returning empty array (no subscriptions for sell event) + webhookFindManyMock.mockResolvedValue([]); + + await httpSimulateTrade(req, res, next); + + expect(receivedPayloads).toHaveLength(0); + }); }); diff --git a/src/modules/webhook/webhook.schemas.ts b/src/modules/webhook/webhook.schemas.ts index 32fdcc4..a824f16 100644 --- a/src/modules/webhook/webhook.schemas.ts +++ b/src/modules/webhook/webhook.schemas.ts @@ -1,18 +1,24 @@ import { z } from 'zod'; -export const RegisterWebhookSchema = z.object({ - url: z.string().url('Invalid webhook URL'), - events: z.array(z.enum(['buy', 'sell'])).min(1, 'At least one event is required'), -}).strict(); +export const RegisterWebhookSchema = z + .object({ + url: z.string().url('Invalid webhook URL'), + events: z + .array(z.enum(['buy', 'sell'])) + .min(1, 'At least one event is required'), + }) + .strict(); export type RegisterWebhookInput = z.infer; -export const SimulateTradeSchema = z.object({ - type: z.enum(['buy', 'sell']), - amount: z.coerce.number().positive(), - price: z.coerce.number().positive(), - creatorId: z.string().min(1, 'creatorId is required'), - actor: z.string().min(1, 'actor is required'), -}).strict(); +export const SimulateTradeSchema = z + .object({ + type: z.enum(['buy', 'sell']), + amount: z.coerce.number().positive(), + price: z.coerce.number().positive(), + creatorId: z.string().min(1, 'creatorId is required'), + actor: z.string().min(1, 'actor is required'), + }) + .strict(); export type SimulateTradeInput = z.infer; diff --git a/src/modules/webhook/webhook.service.ts b/src/modules/webhook/webhook.service.ts index cfacd80..89f0245 100644 --- a/src/modules/webhook/webhook.service.ts +++ b/src/modules/webhook/webhook.service.ts @@ -1,19 +1,19 @@ import { prisma } from '../../utils/prisma.utils'; export async function upsertWebhookSubscription(url: string, events: string[]) { - return (prisma as any).webhookSubscription.upsert({ - where: { url }, - create: { url, events }, - update: { events }, - }); + return (prisma as any).webhookSubscription.upsert({ + where: { url }, + create: { url, events }, + update: { events }, + }); } export async function findMatchingSubscriptions(eventType: 'buy' | 'sell') { - return (prisma as any).webhookSubscription.findMany({ - where: { - events: { - has: eventType, - }, - }, - }); + return (prisma as any).webhookSubscription.findMany({ + where: { + events: { + has: eventType, + }, + }, + }); } diff --git a/src/modules/webhooks/webhook-event-filter.integration.test.ts b/src/modules/webhooks/webhook-event-filter.integration.test.ts index 19d5c94..44479a8 100644 --- a/src/modules/webhooks/webhook-event-filter.integration.test.ts +++ b/src/modules/webhooks/webhook-event-filter.integration.test.ts @@ -15,164 +15,187 @@ const testUserId = 'webhook-filter-test-user'; const creatorId = 'webhook-filter-test-creator'; const BASE_TRADE = { - creatorId, - buyerOrSellerAddress: 'GDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDH', - amount: '10', - price: '5.0', - feePaid: '0.25', - timestamp: new Date().toISOString(), + creatorId, + buyerOrSellerAddress: + 'GDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDH', + amount: '10', + price: '5.0', + feePaid: '0.25', + timestamp: new Date().toISOString(), }; beforeAll(async () => { - await prisma.user.create({ - data: { - id: testUserId, - email: 'webhook-filter-test@example.com', - passwordHash: 'dummy-hash', - firstName: 'Filter', - lastName: 'Test', - }, - }); - await prisma.stellarWallet.create({ - data: { address: walletAddress, userId: testUserId }, - }); - await prisma.creatorProfile.create({ - data: { - id: creatorId, - userId: testUserId, - handle: 'filter-test-creator', - displayName: 'Filter Test Creator', - }, - }); + await prisma.user.create({ + data: { + id: testUserId, + email: 'webhook-filter-test@example.com', + passwordHash: 'dummy-hash', + firstName: 'Filter', + lastName: 'Test', + }, + }); + await prisma.stellarWallet.create({ + data: { address: walletAddress, userId: testUserId }, + }); + await prisma.creatorProfile.create({ + data: { + id: creatorId, + userId: testUserId, + handle: 'filter-test-creator', + displayName: 'Filter Test Creator', + }, + }); }); afterAll(async () => { - await prisma.webhookEvent.deleteMany({ where: { webhook: { creatorId } } }); - await prisma.webhook.deleteMany({ where: { creatorId } }); - await prisma.creatorProfile.deleteMany({ where: { id: creatorId } }); - await prisma.stellarWallet.deleteMany({ where: { userId: testUserId } }); - await prisma.user.deleteMany({ where: { id: testUserId } }); + await prisma.webhookEvent.deleteMany({ where: { webhook: { creatorId } } }); + await prisma.webhook.deleteMany({ where: { creatorId } }); + await prisma.creatorProfile.deleteMany({ where: { id: creatorId } }); + await prisma.stellarWallet.deleteMany({ where: { userId: testUserId } }); + await prisma.user.deleteMany({ where: { id: testUserId } }); }); describe('Webhook event type filter — buy-only webhook', () => { - let buyOnlyWebhookId: string; - - beforeEach(async () => { - const wh = await prisma.webhook.create({ - data: { - creatorId, - callbackUrl: 'https://example.com/callback', - events: { set: ['BUY'] }, - }, - }); - buyOnlyWebhookId = wh.id; - }); - - afterEach(async () => { - await prisma.webhookEvent.deleteMany({ where: { webhookId: buyOnlyWebhookId } }); - await prisma.webhook.delete({ where: { id: buyOnlyWebhookId } }); - }); - - it('does NOT create a WebhookEvent record when a sell event occurs (buy-only filter)', async () => { - const { dispatchWebhookEvent } = await import('./webhook.service'); - - await dispatchWebhookEvent({ ...BASE_TRADE, type: 'sell' }); - - const events = await prisma.webhookEvent.findMany({ - where: { webhookId: buyOnlyWebhookId, eventType: 'SELL' }, - }); - expect(events).toHaveLength(0); - }); - - it('DOES create a WebhookEvent record when a buy event occurs (buy-only filter)', async () => { - const { dispatchWebhookEvent } = await import('./webhook.service'); - - await dispatchWebhookEvent({ ...BASE_TRADE, type: 'buy' }); - - const events = await prisma.webhookEvent.findMany({ - where: { webhookId: buyOnlyWebhookId, eventType: 'BUY' }, - }); - expect(events.length).toBeGreaterThan(0); - expect(events[0].eventType).toBe('BUY'); - }); + let buyOnlyWebhookId: string; + + beforeEach(async () => { + const wh = await prisma.webhook.create({ + data: { + creatorId, + callbackUrl: 'https://example.com/callback', + events: { set: ['BUY'] }, + }, + }); + buyOnlyWebhookId = wh.id; + }); + + afterEach(async () => { + await prisma.webhookEvent.deleteMany({ + where: { webhookId: buyOnlyWebhookId }, + }); + await prisma.webhook.delete({ where: { id: buyOnlyWebhookId } }); + }); + + it('does NOT create a WebhookEvent record when a sell event occurs (buy-only filter)', async () => { + const { dispatchWebhookEvent } = await import('./webhook.service'); + + await dispatchWebhookEvent({ ...BASE_TRADE, type: 'sell' }); + + const events = await prisma.webhookEvent.findMany({ + where: { webhookId: buyOnlyWebhookId, eventType: 'SELL' }, + }); + expect(events).toHaveLength(0); + }); + + it('DOES create a WebhookEvent record when a buy event occurs (buy-only filter)', async () => { + const { dispatchWebhookEvent } = await import('./webhook.service'); + + await dispatchWebhookEvent({ ...BASE_TRADE, type: 'buy' }); + + const events = await prisma.webhookEvent.findMany({ + where: { webhookId: buyOnlyWebhookId, eventType: 'BUY' }, + }); + expect(events.length).toBeGreaterThan(0); + expect(events[0].eventType).toBe('BUY'); + }); }); describe('Webhook event type filter — sell-only webhook', () => { - let sellOnlyWebhookId: string; - - beforeEach(async () => { - const wh = await prisma.webhook.create({ - data: { - creatorId, - callbackUrl: 'https://example.com/callback', - events: { set: ['SELL'] }, - }, - }); - sellOnlyWebhookId = wh.id; - }); - - afterEach(async () => { - await prisma.webhookEvent.deleteMany({ where: { webhookId: sellOnlyWebhookId } }); - await prisma.webhook.delete({ where: { id: sellOnlyWebhookId } }); - }); - - it('does NOT create a WebhookEvent record when a buy event occurs (sell-only filter)', async () => { - const { dispatchWebhookEvent } = await import('./webhook.service'); - - await dispatchWebhookEvent({ ...BASE_TRADE, type: 'buy' }); - - const events = await prisma.webhookEvent.findMany({ - where: { webhookId: sellOnlyWebhookId, eventType: 'BUY' }, - }); - expect(events).toHaveLength(0); - }); - - it('DOES create a WebhookEvent record when a sell event occurs (sell-only filter)', async () => { - const { dispatchWebhookEvent } = await import('./webhook.service'); - - await dispatchWebhookEvent({ ...BASE_TRADE, type: 'sell' }); - - const events = await prisma.webhookEvent.findMany({ - where: { webhookId: sellOnlyWebhookId, eventType: 'SELL' }, - }); - expect(events.length).toBeGreaterThan(0); - expect(events[0].eventType).toBe('SELL'); - }); + let sellOnlyWebhookId: string; + + beforeEach(async () => { + const wh = await prisma.webhook.create({ + data: { + creatorId, + callbackUrl: 'https://example.com/callback', + events: { set: ['SELL'] }, + }, + }); + sellOnlyWebhookId = wh.id; + }); + + afterEach(async () => { + await prisma.webhookEvent.deleteMany({ + where: { webhookId: sellOnlyWebhookId }, + }); + await prisma.webhook.delete({ where: { id: sellOnlyWebhookId } }); + }); + + it('does NOT create a WebhookEvent record when a buy event occurs (sell-only filter)', async () => { + const { dispatchWebhookEvent } = await import('./webhook.service'); + + await dispatchWebhookEvent({ ...BASE_TRADE, type: 'buy' }); + + const events = await prisma.webhookEvent.findMany({ + where: { webhookId: sellOnlyWebhookId, eventType: 'BUY' }, + }); + expect(events).toHaveLength(0); + }); + + it('DOES create a WebhookEvent record when a sell event occurs (sell-only filter)', async () => { + const { dispatchWebhookEvent } = await import('./webhook.service'); + + await dispatchWebhookEvent({ ...BASE_TRADE, type: 'sell' }); + + const events = await prisma.webhookEvent.findMany({ + where: { webhookId: sellOnlyWebhookId, eventType: 'SELL' }, + }); + expect(events.length).toBeGreaterThan(0); + expect(events[0].eventType).toBe('SELL'); + }); }); describe('Webhook event type filter — cross-isolation', () => { - it('buy-only webhook and sell-only webhook co-exist — each receives only their event', async () => { - const buyWh = await prisma.webhook.create({ - data: { creatorId, callbackUrl: 'https://example.com/buy', events: { set: ['BUY'] } }, - }); - const sellWh = await prisma.webhook.create({ - data: { creatorId, callbackUrl: 'https://example.com/sell', events: { set: ['SELL'] } }, - }); - - const { dispatchWebhookEvent } = await import('./webhook.service'); - - // Fire a buy - await dispatchWebhookEvent({ ...BASE_TRADE, type: 'buy' }); - - const buyEvents = await prisma.webhookEvent.findMany({ where: { webhookId: buyWh.id } }); - const sellEventsOnBuyFire = await prisma.webhookEvent.findMany({ where: { webhookId: sellWh.id } }); - - expect(buyEvents.length).toBeGreaterThan(0); - expect(sellEventsOnBuyFire).toHaveLength(0); - - // Fire a sell - await dispatchWebhookEvent({ ...BASE_TRADE, type: 'sell' }); - - const sellEvents = await prisma.webhookEvent.findMany({ where: { webhookId: sellWh.id } }); - const buyEventsOnSellFire = await prisma.webhookEvent.findMany({ - where: { webhookId: buyWh.id, eventType: 'SELL' }, - }); - - expect(sellEvents.length).toBeGreaterThan(0); - expect(buyEventsOnSellFire).toHaveLength(0); - - // cleanup - await prisma.webhookEvent.deleteMany({ where: { webhookId: { in: [buyWh.id, sellWh.id] } } }); - await prisma.webhook.deleteMany({ where: { id: { in: [buyWh.id, sellWh.id] } } }); - }); + it('buy-only webhook and sell-only webhook co-exist — each receives only their event', async () => { + const buyWh = await prisma.webhook.create({ + data: { + creatorId, + callbackUrl: 'https://example.com/buy', + events: { set: ['BUY'] }, + }, + }); + const sellWh = await prisma.webhook.create({ + data: { + creatorId, + callbackUrl: 'https://example.com/sell', + events: { set: ['SELL'] }, + }, + }); + + const { dispatchWebhookEvent } = await import('./webhook.service'); + + // Fire a buy + await dispatchWebhookEvent({ ...BASE_TRADE, type: 'buy' }); + + const buyEvents = await prisma.webhookEvent.findMany({ + where: { webhookId: buyWh.id }, + }); + const sellEventsOnBuyFire = await prisma.webhookEvent.findMany({ + where: { webhookId: sellWh.id }, + }); + + expect(buyEvents.length).toBeGreaterThan(0); + expect(sellEventsOnBuyFire).toHaveLength(0); + + // Fire a sell + await dispatchWebhookEvent({ ...BASE_TRADE, type: 'sell' }); + + const sellEvents = await prisma.webhookEvent.findMany({ + where: { webhookId: sellWh.id }, + }); + const buyEventsOnSellFire = await prisma.webhookEvent.findMany({ + where: { webhookId: buyWh.id, eventType: 'SELL' }, + }); + + expect(sellEvents.length).toBeGreaterThan(0); + expect(buyEventsOnSellFire).toHaveLength(0); + + // cleanup + await prisma.webhookEvent.deleteMany({ + where: { webhookId: { in: [buyWh.id, sellWh.id] } }, + }); + await prisma.webhook.deleteMany({ + where: { id: { in: [buyWh.id, sellWh.id] } }, + }); + }); }); diff --git a/src/modules/webhooks/webhook-payload.utils.test.ts b/src/modules/webhooks/webhook-payload.utils.test.ts index 0e96c48..51a0523 100644 --- a/src/modules/webhooks/webhook-payload.utils.test.ts +++ b/src/modules/webhooks/webhook-payload.utils.test.ts @@ -4,71 +4,76 @@ import type { TradeEvent, WebhookEventPayload } from './webhook.types'; // ── Helpers ─────────────────────────────────────────────────────────────────── function makeTradeEvent(overrides: Partial = {}): TradeEvent { - return { - type: 'buy', - creatorId: 'creator-1', - buyerOrSellerAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - amount: '10', - price: '50', - feePaid: '1', - timestamp: '2026-01-01T00:00:00.000Z', - ...overrides, - }; + return { + type: 'buy', + creatorId: 'creator-1', + buyerOrSellerAddress: + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + amount: '10', + price: '50', + feePaid: '1', + timestamp: '2026-01-01T00:00:00.000Z', + ...overrides, + }; } // ── Tests ───────────────────────────────────────────────────────────────────── describe('buildWebhookPayload', () => { - it('maps all fields correctly for a buy event', () => { - const event = makeTradeEvent({ type: 'buy' }); - const payload = buildWebhookPayload(event); + it('maps all fields correctly for a buy event', () => { + const event = makeTradeEvent({ type: 'buy' }); + const payload = buildWebhookPayload(event); - expect(payload).toEqual({ - event_type: 'buy', - creator_id: 'creator-1', - buyer_or_seller_address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - amount: '10', - price: '50', - fee_paid: '1', - timestamp: '2026-01-01T00:00:00.000Z', - }); - }); + expect(payload).toEqual({ + event_type: 'buy', + creator_id: 'creator-1', + buyer_or_seller_address: + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + amount: '10', + price: '50', + fee_paid: '1', + timestamp: '2026-01-01T00:00:00.000Z', + }); + }); - it('sets event_type to sell for a sell event and maps fields correctly', () => { - const event = makeTradeEvent({ - type: 'sell', - creatorId: 'creator-2', - buyerOrSellerAddress: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', - amount: '5', - price: '200', - feePaid: '2', - timestamp: '2026-06-15T12:00:00.000Z', - }); - const payload = buildWebhookPayload(event); + it('sets event_type to sell for a sell event and maps fields correctly', () => { + const event = makeTradeEvent({ + type: 'sell', + creatorId: 'creator-2', + buyerOrSellerAddress: + 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + amount: '5', + price: '200', + feePaid: '2', + timestamp: '2026-06-15T12:00:00.000Z', + }); + const payload = buildWebhookPayload(event); - expect(payload.event_type).toBe('sell'); - expect(payload.creator_id).toBe('creator-2'); - expect(payload.buyer_or_seller_address).toBe('GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'); - expect(payload.amount).toBe('5'); - expect(payload.price).toBe('200'); - expect(payload.fee_paid).toBe('2'); - expect(payload.timestamp).toBe('2026-06-15T12:00:00.000Z'); - }); + expect(payload.event_type).toBe('sell'); + expect(payload.creator_id).toBe('creator-2'); + expect(payload.buyer_or_seller_address).toBe( + 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + ); + expect(payload.amount).toBe('5'); + expect(payload.price).toBe('200'); + expect(payload.fee_paid).toBe('2'); + expect(payload.timestamp).toBe('2026-06-15T12:00:00.000Z'); + }); - it('produces no extra keys beyond the WebhookEventPayload contract', () => { - const event = makeTradeEvent(); - const payload = buildWebhookPayload(event); + it('produces no extra keys beyond the WebhookEventPayload contract', () => { + const event = makeTradeEvent(); + const payload = buildWebhookPayload(event); - const expectedKeys: Array = [ - 'event_type', - 'creator_id', - 'buyer_or_seller_address', - 'amount', - 'price', - 'fee_paid', - 'timestamp', - ]; + const expectedKeys: Array = [ + 'event_type', + 'creator_id', + 'buyer_or_seller_address', + 'amount', + 'price', + 'fee_paid', + 'timestamp', + ]; - expect(Object.keys(payload).sort()).toEqual(expectedKeys.sort()); - }); + expect(Object.keys(payload).sort()).toEqual(expectedKeys.sort()); + }); }); diff --git a/src/modules/webhooks/webhook-payload.utils.ts b/src/modules/webhooks/webhook-payload.utils.ts index 920986c..9e5cdc0 100644 --- a/src/modules/webhooks/webhook-payload.utils.ts +++ b/src/modules/webhooks/webhook-payload.utils.ts @@ -1,13 +1,13 @@ import type { TradeEvent, WebhookEventPayload } from './webhook.types'; export function buildWebhookPayload(event: TradeEvent): WebhookEventPayload { - return { - event_type: event.type, - creator_id: event.creatorId, - buyer_or_seller_address: event.buyerOrSellerAddress, - amount: event.amount, - price: event.price, - fee_paid: event.feePaid, - timestamp: event.timestamp, - }; + return { + event_type: event.type, + creator_id: event.creatorId, + buyer_or_seller_address: event.buyerOrSellerAddress, + amount: event.amount, + price: event.price, + fee_paid: event.feePaid, + timestamp: event.timestamp, + }; } diff --git a/src/modules/webhooks/webhook-signature.middleware.ts b/src/modules/webhooks/webhook-signature.middleware.ts index 5155494..acc3646 100644 --- a/src/modules/webhooks/webhook-signature.middleware.ts +++ b/src/modules/webhooks/webhook-signature.middleware.ts @@ -9,117 +9,132 @@ import { createHash } from 'crypto'; import { parseCreatorId } from '../../utils/creator-id.utils'; export interface WalletSignedRequest extends Request { - walletAddress?: string; - creatorId?: string; + walletAddress?: string; + creatorId?: string; } const SIGNATURE_TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; function readHeader(req: Request, name: string): string | undefined { - const raw = req.headers[name]; - if (Array.isArray(raw)) return raw[0]?.trim() || undefined; - return typeof raw === 'string' ? raw.trim() || undefined : undefined; + const raw = req.headers[name]; + if (Array.isArray(raw)) return raw[0]?.trim() || undefined; + return typeof raw === 'string' ? raw.trim() || undefined : undefined; } function buildMessage( - method: string, - path: string, - creatorId: string, - timestamp: string + method: string, + path: string, + creatorId: string, + timestamp: string ): Buffer { - const payload = `${method.toUpperCase()}:${path}:${creatorId}:${timestamp}`; - return createHash('sha256').update(payload, 'utf8').digest(); + const payload = `${method.toUpperCase()}:${path}:${creatorId}:${timestamp}`; + return createHash('sha256').update(payload, 'utf8').digest(); } export function requireWalletSignature() { - return async ( - req: WalletSignedRequest, - res: Response, - next: NextFunction - ): Promise => { - const address = readHeader(req, 'x-wallet-address'); - const signature = readHeader(req, 'x-signature'); - const timestamp = readHeader(req, 'x-timestamp'); + return async ( + req: WalletSignedRequest, + res: Response, + next: NextFunction + ): Promise => { + const address = readHeader(req, 'x-wallet-address'); + const signature = readHeader(req, 'x-signature'); + const timestamp = readHeader(req, 'x-timestamp'); - if (!address || !signature || !timestamp) { - sendError( - res, - 401, - ErrorCode.UNAUTHORIZED, - 'Missing required headers: x-wallet-address, x-signature, x-timestamp' - ); - return; - } + if (!address || !signature || !timestamp) { + sendError( + res, + 401, + ErrorCode.UNAUTHORIZED, + 'Missing required headers: x-wallet-address, x-signature, x-timestamp' + ); + return; + } - const addressValidation = StellarAddressSchema.safeParse(address); - if (!addressValidation.success) { - sendError( - res, - 400, - ErrorCode.BAD_REQUEST, - 'Invalid wallet address format' - ); - return; - } + const addressValidation = StellarAddressSchema.safeParse(address); + if (!addressValidation.success) { + sendError( + res, + 400, + ErrorCode.BAD_REQUEST, + 'Invalid wallet address format' + ); + return; + } - const ts = parseInt(timestamp, 10); - if (isNaN(ts) || Date.now() - ts > SIGNATURE_TIMESTAMP_TOLERANCE_MS) { - sendError( - res, - 401, - ErrorCode.UNAUTHORIZED, - 'Signature timestamp is invalid or expired' - ); - return; - } + const ts = parseInt(timestamp, 10); + if (isNaN(ts) || Date.now() - ts > SIGNATURE_TIMESTAMP_TOLERANCE_MS) { + sendError( + res, + 401, + ErrorCode.UNAUTHORIZED, + 'Signature timestamp is invalid or expired' + ); + return; + } - let creatorId: string; - try { - const rawId = req.params.id; - creatorId = String(parseCreatorId(Array.isArray(rawId) ? rawId[0] : rawId)); - } catch { - sendError(res, 400, ErrorCode.BAD_REQUEST, 'Creator ID must be a positive integer'); - return; - } + let creatorId: string; + try { + const rawId = req.params.id; + creatorId = String( + parseCreatorId(Array.isArray(rawId) ? rawId[0] : rawId) + ); + } catch { + sendError( + res, + 400, + ErrorCode.BAD_REQUEST, + 'Creator ID must be a positive integer' + ); + return; + } - try { - const creatorProfile = await prisma.creatorProfile.findUnique({ - where: { id: creatorId }, - select: { id: true }, - }); + try { + const creatorProfile = await prisma.creatorProfile.findUnique({ + where: { id: creatorId }, + select: { id: true }, + }); - if (!creatorProfile) { - sendError(res, 404, ErrorCode.NOT_FOUND, 'Creator not found'); - return; - } + if (!creatorProfile) { + sendError(res, 404, ErrorCode.NOT_FOUND, 'Creator not found'); + return; + } - const message = buildMessage(req.method, req.originalUrl, creatorId, timestamp); - const signatureBuffer = Buffer.from(signature, 'base64'); - const keypair = Keypair.fromPublicKey(address); - const verified = keypair.verify(message, signatureBuffer); + const message = buildMessage( + req.method, + req.originalUrl, + creatorId, + timestamp + ); + const signatureBuffer = Buffer.from(signature, 'base64'); + const keypair = Keypair.fromPublicKey(address); + const verified = keypair.verify(message, signatureBuffer); - if (!verified) { - sendError( - res, - 403, - ErrorCode.FORBIDDEN, - 'Invalid signature — wallet does not own this creator' - ); - return; + if (!verified) { + sendError( + res, + 403, + ErrorCode.FORBIDDEN, + 'Invalid signature — wallet does not own this creator' + ); + return; + } + } catch (error) { + logger.error( + { error, address, creatorId }, + 'Signature verification failed' + ); + sendError( + res, + 403, + ErrorCode.FORBIDDEN, + 'Signature verification failed' + ); + return; } - } catch (error) { - logger.error({ error, address, creatorId }, 'Signature verification failed'); - sendError( - res, - 403, - ErrorCode.FORBIDDEN, - 'Signature verification failed' - ); - return; - } - req.walletAddress = address; - req.creatorId = creatorId; - next(); - }; + req.walletAddress = address; + req.creatorId = creatorId; + next(); + }; } diff --git a/src/modules/webhooks/webhook.controllers.test.ts b/src/modules/webhooks/webhook.controllers.test.ts index 2424cd0..d6edde8 100644 --- a/src/modules/webhooks/webhook.controllers.test.ts +++ b/src/modules/webhooks/webhook.controllers.test.ts @@ -1,8 +1,8 @@ import type { Response } from 'express'; import { - registerWebhookHandler, - listWebhooksHandler, - deleteWebhookHandler, + registerWebhookHandler, + listWebhooksHandler, + deleteWebhookHandler, } from './webhook.controllers'; import * as webhookService from './webhook.service'; @@ -10,126 +10,140 @@ jest.mock('./webhook.service'); const mockService = webhookService as jest.Mocked; -function createMockSignedRequest(creatorId = 'creator-1', params: Record = {}) { - return { - body: {}, - params: { id: creatorId, ...params }, - creatorId, - method: 'POST', - originalUrl: `/api/v1/creators/${creatorId}/webhooks`, - } as any; +function createMockSignedRequest( + creatorId = 'creator-1', + params: Record = {} +) { + return { + body: {}, + params: { id: creatorId, ...params }, + creatorId, + method: 'POST', + originalUrl: `/api/v1/creators/${creatorId}/webhooks`, + } as any; } function createMockResponse() { - const res: Partial = {}; - res.status = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - res.setHeader = jest.fn().mockReturnValue(res); - res.end = jest.fn().mockReturnValue(res); - return res as Response; + const res: Partial = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.end = jest.fn().mockReturnValue(res); + return res as Response; } beforeEach(() => { - jest.clearAllMocks(); + jest.clearAllMocks(); }); describe('registerWebhookHandler', () => { - it('returns 400 for invalid body', async () => { - const req = createMockSignedRequest(); - req.body = { callback_url: 'not-a-url', events: [] }; - const res = createMockResponse(); - - await registerWebhookHandler(req, res); - - expect(res.status).toHaveBeenCalledWith(400); - }); - - it('returns 201 on success', async () => { - mockService.createWebhook.mockResolvedValue({ - id: 'wh-1', - creatorId: 'creator-1', - callbackUrl: 'https://example.com/hook', - events: ['buy', 'sell'], - isActive: true, - isFailing: false, - createdAt: new Date(), - updatedAt: new Date(), - }); - - const req = createMockSignedRequest(); - req.body = { callback_url: 'https://example.com/hook', events: ['buy', 'sell'] }; - const res = createMockResponse(); - - await registerWebhookHandler(req, res); - - expect(res.status).toHaveBeenCalledWith(201); - expect(mockService.createWebhook).toHaveBeenCalledWith('creator-1', { - callbackUrl: 'https://example.com/hook', - events: ['buy', 'sell'], - }); - }); - - it('returns 409 when max webhooks reached', async () => { - mockService.createWebhook.mockRejectedValue( - Object.assign(new Error('Max webhooks reached'), { statusCode: 409, code: 'MAX_WEBHOOKS_REACHED' }) - ); - - const req = createMockSignedRequest(); - req.body = { callback_url: 'https://example.com/hook', events: ['buy'] }; - const res = createMockResponse(); - - await registerWebhookHandler(req, res); - - expect(res.status).toHaveBeenCalledWith(409); - }); + it('returns 400 for invalid body', async () => { + const req = createMockSignedRequest(); + req.body = { callback_url: 'not-a-url', events: [] }; + const res = createMockResponse(); + + await registerWebhookHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 201 on success', async () => { + mockService.createWebhook.mockResolvedValue({ + id: 'wh-1', + creatorId: 'creator-1', + callbackUrl: 'https://example.com/hook', + events: ['buy', 'sell'], + isActive: true, + isFailing: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const req = createMockSignedRequest(); + req.body = { + callback_url: 'https://example.com/hook', + events: ['buy', 'sell'], + }; + const res = createMockResponse(); + + await registerWebhookHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(201); + expect(mockService.createWebhook).toHaveBeenCalledWith('creator-1', { + callbackUrl: 'https://example.com/hook', + events: ['buy', 'sell'], + }); + }); + + it('returns 409 when max webhooks reached', async () => { + mockService.createWebhook.mockRejectedValue( + Object.assign(new Error('Max webhooks reached'), { + statusCode: 409, + code: 'MAX_WEBHOOKS_REACHED', + }) + ); + + const req = createMockSignedRequest(); + req.body = { callback_url: 'https://example.com/hook', events: ['buy'] }; + const res = createMockResponse(); + + await registerWebhookHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(409); + }); }); describe('listWebhooksHandler', () => { - it('returns 200 with webhooks list', async () => { - mockService.listWebhooks.mockResolvedValue([ - { - id: 'wh-1', - creatorId: 'creator-1', - callbackUrl: 'https://example.com/hook', - events: ['buy'], - isActive: true, - isFailing: false, - createdAt: new Date(), - updatedAt: new Date(), - }, - ]); - - const req = createMockSignedRequest(); - const res = createMockResponse(); - - await listWebhooksHandler(req, res); - - expect(res.status).toHaveBeenCalledWith(200); - expect(mockService.listWebhooks).toHaveBeenCalledWith('creator-1'); - }); + it('returns 200 with webhooks list', async () => { + mockService.listWebhooks.mockResolvedValue([ + { + id: 'wh-1', + creatorId: 'creator-1', + callbackUrl: 'https://example.com/hook', + events: ['buy'], + isActive: true, + isFailing: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + const req = createMockSignedRequest(); + const res = createMockResponse(); + + await listWebhooksHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(mockService.listWebhooks).toHaveBeenCalledWith('creator-1'); + }); }); describe('deleteWebhookHandler', () => { - it('returns 404 for non-existent webhook', async () => { - mockService.deleteWebhook.mockResolvedValue(null); + it('returns 404 for non-existent webhook', async () => { + mockService.deleteWebhook.mockResolvedValue(null); - const req = createMockSignedRequest('creator-1', { webhookId: 'wh-nonexistent' }); - const res = createMockResponse(); + const req = createMockSignedRequest('creator-1', { + webhookId: 'wh-nonexistent', + }); + const res = createMockResponse(); - await deleteWebhookHandler(req, res); + await deleteWebhookHandler(req, res); - expect(res.status).toHaveBeenCalledWith(404); - }); + expect(res.status).toHaveBeenCalledWith(404); + }); - it('returns 204 on successful deletion', async () => { - mockService.deleteWebhook.mockResolvedValue({ id: 'wh-1' }); + it('returns 204 on successful deletion', async () => { + mockService.deleteWebhook.mockResolvedValue({ id: 'wh-1' }); - const req = createMockSignedRequest('creator-1', { webhookId: 'wh-1' }); - const res = createMockResponse(); + const req = createMockSignedRequest('creator-1', { webhookId: 'wh-1' }); + const res = createMockResponse(); - await deleteWebhookHandler(req, res); + await deleteWebhookHandler(req, res); - expect(res.status).toHaveBeenCalledWith(204); - expect(mockService.deleteWebhook).toHaveBeenCalledWith('wh-1', 'creator-1'); - }); + expect(res.status).toHaveBeenCalledWith(204); + expect(mockService.deleteWebhook).toHaveBeenCalledWith( + 'wh-1', + 'creator-1' + ); + }); }); diff --git a/src/modules/webhooks/webhook.controllers.ts b/src/modules/webhooks/webhook.controllers.ts index fee1547..f323ddc 100644 --- a/src/modules/webhooks/webhook.controllers.ts +++ b/src/modules/webhooks/webhook.controllers.ts @@ -1,9 +1,9 @@ import type { Response } from 'express'; import { - sendSuccess, - sendError, - sendValidationError, - sendNotFound, + sendSuccess, + sendError, + sendValidationError, + sendNotFound, } from '../../utils/api-response.utils'; import { ErrorCode } from '../../constants/error.constants'; import { CreateWebhookSchema } from './webhook.schemas'; @@ -11,80 +11,84 @@ import * as webhookService from './webhook.service'; import type { WalletSignedRequest } from './webhook-signature.middleware'; export async function registerWebhookHandler( - req: WalletSignedRequest, - res: Response + req: WalletSignedRequest, + res: Response ) { - const parseResult = CreateWebhookSchema.safeParse(req.body); - if (!parseResult.success) { - sendValidationError( - res, - 'Invalid webhook registration data', - parseResult.error.issues.map((issue) => ({ - field: issue.path.join('.'), - message: issue.message, - })) - ); - return; - } + const parseResult = CreateWebhookSchema.safeParse(req.body); + if (!parseResult.success) { + sendValidationError( + res, + 'Invalid webhook registration data', + parseResult.error.issues.map(issue => ({ + field: issue.path.join('.'), + message: issue.message, + })) + ); + return; + } - try { - const result = await webhookService.createWebhook( - req.creatorId!, - { - callbackUrl: parseResult.data.callback_url, - events: parseResult.data.events, + try { + const result = await webhookService.createWebhook(req.creatorId!, { + callbackUrl: parseResult.data.callback_url, + events: parseResult.data.events, + }); + sendSuccess(res, result, 201, 'Webhook registered successfully'); + } catch (error) { + if (error instanceof Error && 'statusCode' in error) { + sendError( + res, + (error as any).statusCode, + (error as any).code, + error.message + ); + return; } - ); - sendSuccess(res, result, 201, 'Webhook registered successfully'); - } catch (error) { - if (error instanceof Error && 'statusCode' in error) { sendError( - res, - (error as any).statusCode, - (error as any).code, - error.message + res, + 500, + ErrorCode.INTERNAL_ERROR, + 'Failed to register webhook' ); - return; - } - sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Failed to register webhook'); - } + } } export async function listWebhooksHandler( - req: WalletSignedRequest, - res: Response + req: WalletSignedRequest, + res: Response ) { - try { - const webhooks = await webhookService.listWebhooks(req.creatorId!); - sendSuccess(res, webhooks); - } catch { - sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Failed to list webhooks'); - } + try { + const webhooks = await webhookService.listWebhooks(req.creatorId!); + sendSuccess(res, webhooks); + } catch { + sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Failed to list webhooks'); + } } export async function deleteWebhookHandler( - req: WalletSignedRequest, - res: Response + req: WalletSignedRequest, + res: Response ) { - const rawWebhookId = req.params.webhookId; - const webhookId = Array.isArray(rawWebhookId) ? rawWebhookId[0] : rawWebhookId; - - if (!webhookId) { - sendError(res, 400, ErrorCode.BAD_REQUEST, 'Missing webhook ID in path'); - return; - } + const rawWebhookId = req.params.webhookId; + const webhookId = Array.isArray(rawWebhookId) + ? rawWebhookId[0] + : rawWebhookId; - try { - const result = await webhookService.deleteWebhook( - webhookId, - req.creatorId! - ); - if (!result) { - sendNotFound(res, 'Webhook'); + if (!webhookId) { + sendError(res, 400, ErrorCode.BAD_REQUEST, 'Missing webhook ID in path'); return; - } - res.status(204).end(); - } catch { - sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Failed to delete webhook'); - } + } + + try { + const result = await webhookService.deleteWebhook( + webhookId, + req.creatorId! + ); + if (!result) { + sendNotFound(res, 'Webhook'); + return; + } + res.status(204).end(); + } catch { + sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Failed to delete webhook'); + } } diff --git a/src/modules/webhooks/webhook.integration.test.ts b/src/modules/webhooks/webhook.integration.test.ts index 8cdd785..121e179 100644 --- a/src/modules/webhooks/webhook.integration.test.ts +++ b/src/modules/webhooks/webhook.integration.test.ts @@ -21,615 +21,734 @@ const walletAddressB = keypairB.publicKey(); const userIdB = 'webhook-isolation-user-b'; const creatorIdB = 'webhook-isolation-creator-b'; -function signMessageFor(keypair: Keypair, method: string, path: string, cId: string, timestamp: string): string { - const payload = `${method.toUpperCase()}:${path}:${cId}:${timestamp}`; - const hash = createHash('sha256').update(payload, 'utf8').digest(); - return keypair.sign(hash).toString('base64'); +function signMessageFor( + keypair: Keypair, + method: string, + path: string, + cId: string, + timestamp: string +): string { + const payload = `${method.toUpperCase()}:${path}:${cId}:${timestamp}`; + const hash = createHash('sha256').update(payload, 'utf8').digest(); + return keypair.sign(hash).toString('base64'); } -function authHeadersFor(kp: Keypair, method: string, path: string, cId: string) { - const timestamp = Date.now().toString(); - const signature = signMessageFor(kp, method, path, cId, timestamp); - return { - 'x-wallet-address': kp.publicKey(), - 'x-signature': signature, - 'x-timestamp': timestamp, - }; +function authHeadersFor( + kp: Keypair, + method: string, + path: string, + cId: string +) { + const timestamp = Date.now().toString(); + const signature = signMessageFor(kp, method, path, cId, timestamp); + return { + 'x-wallet-address': kp.publicKey(), + 'x-signature': signature, + 'x-timestamp': timestamp, + }; } -function signMessage(method: string, path: string, creatorId: string, timestamp: string): string { - const payload = `${method.toUpperCase()}:${path}:${creatorId}:${timestamp}`; - const hash = createHash('sha256').update(payload, 'utf8').digest(); - return keypair.sign(hash).toString('base64'); +function signMessage( + method: string, + path: string, + creatorId: string, + timestamp: string +): string { + const payload = `${method.toUpperCase()}:${path}:${creatorId}:${timestamp}`; + const hash = createHash('sha256').update(payload, 'utf8').digest(); + return keypair.sign(hash).toString('base64'); } function authHeaders(method: string, path: string, cId: string) { - const timestamp = Date.now().toString(); - const signature = signMessage(method, path, cId, timestamp); - return { - 'x-wallet-address': walletAddress, - 'x-signature': signature, - 'x-timestamp': timestamp, - }; + const timestamp = Date.now().toString(); + const signature = signMessage(method, path, cId, timestamp); + return { + 'x-wallet-address': walletAddress, + 'x-signature': signature, + 'x-timestamp': timestamp, + }; } beforeAll(async () => { - await prisma.user.create({ - data: { - id: testUserId, - email: 'webhook-test@example.com', - passwordHash: 'dummy-hash', - firstName: 'Webhook', - lastName: 'Test', - }, - }); - - await prisma.stellarWallet.create({ - data: { - address: walletAddress, - userId: testUserId, - }, - }); - - await prisma.creatorProfile.create({ - data: { - id: creatorId, - userId: testUserId, - handle: 'webhook-test-creator', - displayName: 'Webhook Test Creator', - }, - }); + await prisma.user.create({ + data: { + id: testUserId, + email: 'webhook-test@example.com', + passwordHash: 'dummy-hash', + firstName: 'Webhook', + lastName: 'Test', + }, + }); + + await prisma.stellarWallet.create({ + data: { + address: walletAddress, + userId: testUserId, + }, + }); + + await prisma.creatorProfile.create({ + data: { + id: creatorId, + userId: testUserId, + handle: 'webhook-test-creator', + displayName: 'Webhook Test Creator', + }, + }); }); afterAll(async () => { - await prisma.webhookEvent.deleteMany({ - where: { webhook: { creatorId } }, - }); - await prisma.webhook.deleteMany({ where: { creatorId } }); - await prisma.creatorProfile.delete({ where: { id: creatorId } }).catch(() => {}); - await prisma.stellarWallet.delete({ where: { address: walletAddress } }).catch(() => {}); - await prisma.user.delete({ where: { id: testUserId } }).catch(() => {}); - await prisma.$disconnect(); + await prisma.webhookEvent.deleteMany({ + where: { webhook: { creatorId } }, + }); + await prisma.webhook.deleteMany({ where: { creatorId } }); + await prisma.creatorProfile + .delete({ where: { id: creatorId } }) + .catch(() => {}); + await prisma.stellarWallet + .delete({ where: { address: walletAddress } }) + .catch(() => {}); + await prisma.user.delete({ where: { id: testUserId } }).catch(() => {}); + await prisma.$disconnect(); }); describe('POST /api/v1/creators/:id/webhooks', () => { - const basePath = `/api/v1/creators/${creatorId}/webhooks`; - - it('registers a webhook with valid signature and data', async () => { - const res = await supertest(app) - .post(basePath) - .set(authHeaders('POST', basePath, creatorId)) - .send({ callback_url: 'https://example.com/hook', events: ['buy', 'sell'] }); - - expect(res.status).toBe(201); - expect(res.body.success).toBe(true); - expect(res.body.data.callbackUrl).toBe('https://example.com/hook'); - expect(res.body.data.events).toEqual(['buy', 'sell']); - }); - - it('returns 401 when signature headers missing', async () => { - const res = await supertest(app) - .post(basePath) - .send({ callback_url: 'https://example.com/hook', events: ['buy'] }); - - expect(res.status).toBe(401); - }); - - it('returns 400 on invalid body', async () => { - const res = await supertest(app) - .post(basePath) - .set(authHeaders('POST', basePath, creatorId)) - .send({ callback_url: 'not-a-url', events: ['invalid'] }); - - expect(res.status).toBe(400); - }); - - it('returns 422 when max webhooks reached', async () => { - const existingCount = await prisma.webhook.count({ - where: { creatorId, isActive: true }, - }); - - const remaining = envConfig.WEBHOOK_MAX_PER_CREATOR - existingCount; - for (let i = 0; i < remaining; i++) { + const basePath = `/api/v1/creators/${creatorId}/webhooks`; + + it('registers a webhook with valid signature and data', async () => { const res = await supertest(app) - .post(basePath) - .set(authHeaders('POST', basePath, creatorId)) - .send({ callback_url: `https://example.com/hook-${i}`, events: ['buy'] }); + .post(basePath) + .set(authHeaders('POST', basePath, creatorId)) + .send({ + callback_url: 'https://example.com/hook', + events: ['buy', 'sell'], + }); + expect(res.status).toBe(201); - } - - const countAtLimit = await prisma.webhook.count({ - where: { creatorId, isActive: true }, - }); - expect(countAtLimit).toBe(envConfig.WEBHOOK_MAX_PER_CREATOR); - - const res = await supertest(app) - .post(basePath) - .set(authHeaders('POST', basePath, creatorId)) - .send({ callback_url: 'https://example.com/too-many', events: ['buy'] }); - - expect(res.status).toBe(422); - expect(res.body.error.code).toBe('MAX_WEBHOOKS_REACHED'); - expect(res.body.error.message).toMatch(/maximum/i); - - const countAfter = await prisma.webhook.count({ - where: { creatorId, isActive: true }, - }); - expect(countAfter).toBe(envConfig.WEBHOOK_MAX_PER_CREATOR); - }); -}); + expect(res.body.success).toBe(true); + expect(res.body.data.callbackUrl).toBe('https://example.com/hook'); + expect(res.body.data.events).toEqual(['buy', 'sell']); + }); -describe('GET /api/v1/creators/:id/webhooks', () => { - const basePath = `/api/v1/creators/${creatorId}/webhooks`; - - it('lists webhooks for the creator', async () => { - const res = await supertest(app) - .get(basePath) - .set(authHeaders('GET', basePath, creatorId)); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(Array.isArray(res.body.data)).toBe(true); - expect(res.body.data.length).toBeGreaterThan(0); - }); - - it('returns 401 without auth', async () => { - const res = await supertest(app).get(basePath); - expect(res.status).toBe(401); - }); -}); + it('returns 401 when signature headers missing', async () => { + const res = await supertest(app) + .post(basePath) + .send({ callback_url: 'https://example.com/hook', events: ['buy'] }); -describe('DELETE /api/v1/creators/:id/webhooks/:webhookId', () => { - it('deletes a webhook', async () => { - const listRes = await supertest(app) - .get(`/api/v1/creators/${creatorId}/webhooks`) - .set(authHeaders('GET', `/api/v1/creators/${creatorId}/webhooks`, creatorId)); + expect(res.status).toBe(401); + }); - const webhookId = listRes.body.data[0].id; + it('returns 400 on invalid body', async () => { + const res = await supertest(app) + .post(basePath) + .set(authHeaders('POST', basePath, creatorId)) + .send({ callback_url: 'not-a-url', events: ['invalid'] }); - const deleteRes = await supertest(app) - .delete(`/api/v1/creators/${creatorId}/webhooks/${webhookId}`) - .set(authHeaders('DELETE', `/api/v1/creators/${creatorId}/webhooks/${webhookId}`, creatorId)); + expect(res.status).toBe(400); + }); - expect(deleteRes.status).toBe(204); + it('returns 422 when max webhooks reached', async () => { + const existingCount = await prisma.webhook.count({ + where: { creatorId, isActive: true }, + }); - const verifyRes = await supertest(app) - .get(`/api/v1/creators/${creatorId}/webhooks`) - .set(authHeaders('GET', `/api/v1/creators/${creatorId}/webhooks`, creatorId)); + const remaining = envConfig.WEBHOOK_MAX_PER_CREATOR - existingCount; + for (let i = 0; i < remaining; i++) { + const res = await supertest(app) + .post(basePath) + .set(authHeaders('POST', basePath, creatorId)) + .send({ + callback_url: `https://example.com/hook-${i}`, + events: ['buy'], + }); + expect(res.status).toBe(201); + } - const ids = verifyRes.body.data.map((w: any) => w.id); - expect(ids).not.toContain(webhookId); - }); + const countAtLimit = await prisma.webhook.count({ + where: { creatorId, isActive: true }, + }); + expect(countAtLimit).toBe(envConfig.WEBHOOK_MAX_PER_CREATOR); - it('returns 404 for non-existent webhook', async () => { - const res = await supertest(app) - .delete(`/api/v1/creators/${creatorId}/webhooks/non-existent-id`) - .set(authHeaders('DELETE', `/api/v1/creators/${creatorId}/webhooks/non-existent-id`, creatorId)); + const res = await supertest(app) + .post(basePath) + .set(authHeaders('POST', basePath, creatorId)) + .send({ + callback_url: 'https://example.com/too-many', + events: ['buy'], + }); + + expect(res.status).toBe(422); + expect(res.body.error.code).toBe('MAX_WEBHOOKS_REACHED'); + expect(res.body.error.message).toMatch(/maximum/i); + + const countAfter = await prisma.webhook.count({ + where: { creatorId, isActive: true }, + }); + expect(countAfter).toBe(envConfig.WEBHOOK_MAX_PER_CREATOR); + }); +}); - expect(res.status).toBe(404); - }); +describe('GET /api/v1/creators/:id/webhooks', () => { + const basePath = `/api/v1/creators/${creatorId}/webhooks`; - it('stops future deliveries when a webhook is deleted (#506)', async () => { - // Register a webhook and confirm it exists - const webhook = await prisma.webhook.create({ - data: { - id: 'webhook-deletion-test-506', - creatorId, - callbackUrl: 'https://example.com/deleted-hook', - events: { set: ['BUY', 'SELL'] }, - }, - }); - - // Delete the webhook and assert the response is 204 - const deleteRes = await supertest(app) - .delete(`/api/v1/creators/${creatorId}/webhooks/${webhook.id}`) - .set(authHeaders('DELETE', `/api/v1/creators/${creatorId}/webhooks/${webhook.id}`, creatorId)); - - expect(deleteRes.status).toBe(204); - - // Webhook record no longer exists after deletion - const deletedWebhook = await prisma.webhook.findUnique({ where: { id: webhook.id } }); - expect(deletedWebhook).toBeNull(); - - // Simulate a trade event after deletion - const { dispatchWebhookEvent } = await import('./webhook.service'); - await dispatchWebhookEvent({ - type: 'buy', - creatorId, - buyerOrSellerAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', - amount: '100', - price: '10.5', - feePaid: '0.5', - timestamp: new Date().toISOString(), - }); - - // Assert no delivery attempt was made for the deleted webhook - const events = await prisma.webhookEvent.findMany({ - where: { webhookId: webhook.id }, - }); - - expect(events.length).toBe(0); - }); + it('lists webhooks for the creator', async () => { + const res = await supertest(app) + .get(basePath) + .set(authHeaders('GET', basePath, creatorId)); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data.length).toBeGreaterThan(0); + }); + + it('returns 401 without auth', async () => { + const res = await supertest(app).get(basePath); + expect(res.status).toBe(401); + }); +}); + +describe('DELETE /api/v1/creators/:id/webhooks/:webhookId', () => { + it('deletes a webhook', async () => { + const listRes = await supertest(app) + .get(`/api/v1/creators/${creatorId}/webhooks`) + .set( + authHeaders( + 'GET', + `/api/v1/creators/${creatorId}/webhooks`, + creatorId + ) + ); + + const webhookId = listRes.body.data[0].id; + + const deleteRes = await supertest(app) + .delete(`/api/v1/creators/${creatorId}/webhooks/${webhookId}`) + .set( + authHeaders( + 'DELETE', + `/api/v1/creators/${creatorId}/webhooks/${webhookId}`, + creatorId + ) + ); + + expect(deleteRes.status).toBe(204); + + const verifyRes = await supertest(app) + .get(`/api/v1/creators/${creatorId}/webhooks`) + .set( + authHeaders( + 'GET', + `/api/v1/creators/${creatorId}/webhooks`, + creatorId + ) + ); + + const ids = verifyRes.body.data.map((w: any) => w.id); + expect(ids).not.toContain(webhookId); + }); + + it('returns 404 for non-existent webhook', async () => { + const res = await supertest(app) + .delete(`/api/v1/creators/${creatorId}/webhooks/non-existent-id`) + .set( + authHeaders( + 'DELETE', + `/api/v1/creators/${creatorId}/webhooks/non-existent-id`, + creatorId + ) + ); + + expect(res.status).toBe(404); + }); + + it('stops future deliveries when a webhook is deleted (#506)', async () => { + // Register a webhook and confirm it exists + const webhook = await prisma.webhook.create({ + data: { + id: 'webhook-deletion-test-506', + creatorId, + callbackUrl: 'https://example.com/deleted-hook', + events: { set: ['BUY', 'SELL'] }, + }, + }); + + // Delete the webhook and assert the response is 204 + const deleteRes = await supertest(app) + .delete(`/api/v1/creators/${creatorId}/webhooks/${webhook.id}`) + .set( + authHeaders( + 'DELETE', + `/api/v1/creators/${creatorId}/webhooks/${webhook.id}`, + creatorId + ) + ); + + expect(deleteRes.status).toBe(204); + + // Webhook record no longer exists after deletion + const deletedWebhook = await prisma.webhook.findUnique({ + where: { id: webhook.id }, + }); + expect(deletedWebhook).toBeNull(); + + // Simulate a trade event after deletion + const { dispatchWebhookEvent } = await import('./webhook.service'); + await dispatchWebhookEvent({ + type: 'buy', + creatorId, + buyerOrSellerAddress: + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + amount: '100', + price: '10.5', + feePaid: '0.5', + timestamp: new Date().toISOString(), + }); + + // Assert no delivery attempt was made for the deleted webhook + const events = await prisma.webhookEvent.findMany({ + where: { webhookId: webhook.id }, + }); + + expect(events.length).toBe(0); + }); }); describe('webhook dispatch', () => { - let webhookId: string; + let webhookId: string; + + beforeAll(async () => { + const webhook = await prisma.webhook.create({ + data: { + id: 'webhook-dispatch-test', + creatorId, + callbackUrl: 'https://httpbin.org/post', + events: { set: ['BUY', 'SELL'] }, + }, + }); + webhookId = webhook.id; + }); + + afterAll(async () => { + await prisma.webhookEvent.deleteMany({ where: { webhookId } }); + await prisma.webhook.delete({ where: { id: webhookId } }).catch(() => {}); + }); + + it('dispatches a buy event and creates a WebhookEvent record', async () => { + const { dispatchWebhookEvent } = await import('./webhook.service'); + + await dispatchWebhookEvent({ + type: 'buy', + creatorId, + buyerOrSellerAddress: + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + amount: '100', + price: '10.5', + feePaid: '0.5', + timestamp: new Date().toISOString(), + }); - beforeAll(async () => { - const webhook = await prisma.webhook.create({ - data: { - id: 'webhook-dispatch-test', - creatorId, - callbackUrl: 'https://httpbin.org/post', - events: { set: ['BUY', 'SELL'] }, - }, - }); - webhookId = webhook.id; - }); - - afterAll(async () => { - await prisma.webhookEvent.deleteMany({ where: { webhookId } }); - await prisma.webhook.delete({ where: { id: webhookId } }).catch(() => {}); - }); - - it('dispatches a buy event and creates a WebhookEvent record', async () => { - const { dispatchWebhookEvent } = await import('./webhook.service'); - - await dispatchWebhookEvent({ - type: 'buy', - creatorId, - buyerOrSellerAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', - amount: '100', - price: '10.5', - feePaid: '0.5', - timestamp: new Date().toISOString(), - }); - - const events = await prisma.webhookEvent.findMany({ - where: { webhookId, eventType: 'BUY' }, - orderBy: { createdAt: 'desc' }, - }); - - expect(events.length).toBeGreaterThan(0); - }); - - it('dispatches a sell event and creates a WebhookEvent record', async () => { - const { dispatchWebhookEvent } = await import('./webhook.service'); - - await dispatchWebhookEvent({ - type: 'sell', - creatorId, - buyerOrSellerAddress: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWBH', - amount: '50', - price: '20.0', - feePaid: '1.0', - timestamp: new Date().toISOString(), - }); - - const events = await prisma.webhookEvent.findMany({ - where: { webhookId, eventType: 'SELL' }, - orderBy: { createdAt: 'desc' }, - }); - - expect(events.length).toBeGreaterThan(0); - }); - - it('respects event type filter — buy-only webhook does not receive sell events', async () => { - const buyOnlyWebhook = await prisma.webhook.create({ - data: { - id: 'webhook-filter-buy', - creatorId, - callbackUrl: 'https://httpbin.org/post', - events: { set: ['BUY'] }, - }, - }); + const events = await prisma.webhookEvent.findMany({ + where: { webhookId, eventType: 'BUY' }, + orderBy: { createdAt: 'desc' }, + }); - const { dispatchWebhookEvent } = await import('./webhook.service'); + expect(events.length).toBeGreaterThan(0); + }); + + it('dispatches a sell event and creates a WebhookEvent record', async () => { + const { dispatchWebhookEvent } = await import('./webhook.service'); + + await dispatchWebhookEvent({ + type: 'sell', + creatorId, + buyerOrSellerAddress: + 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWBH', + amount: '50', + price: '20.0', + feePaid: '1.0', + timestamp: new Date().toISOString(), + }); - await dispatchWebhookEvent({ - type: 'sell', - creatorId, - buyerOrSellerAddress: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCF', - amount: '25', - price: '30.0', - feePaid: '0.75', - timestamp: new Date().toISOString(), - }); + const events = await prisma.webhookEvent.findMany({ + where: { webhookId, eventType: 'SELL' }, + orderBy: { createdAt: 'desc' }, + }); - const events = await prisma.webhookEvent.findMany({ - where: { webhookId: buyOnlyWebhook.id, eventType: 'SELL' }, - }); + expect(events.length).toBeGreaterThan(0); + }); + + it('respects event type filter — buy-only webhook does not receive sell events', async () => { + const buyOnlyWebhook = await prisma.webhook.create({ + data: { + id: 'webhook-filter-buy', + creatorId, + callbackUrl: 'https://httpbin.org/post', + events: { set: ['BUY'] }, + }, + }); - expect(events.length).toBe(0); + const { dispatchWebhookEvent } = await import('./webhook.service'); + + await dispatchWebhookEvent({ + type: 'sell', + creatorId, + buyerOrSellerAddress: + 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCF', + amount: '25', + price: '30.0', + feePaid: '0.75', + timestamp: new Date().toISOString(), + }); - await prisma.webhookEvent.deleteMany({ where: { webhookId: buyOnlyWebhook.id } }); - await prisma.webhook.delete({ where: { id: buyOnlyWebhook.id } }); - }); + const events = await prisma.webhookEvent.findMany({ + where: { webhookId: buyOnlyWebhook.id, eventType: 'SELL' }, + }); - it('retries delivery on failure and flags webhook as failing after exhaustion', async () => { - const failingWebhook = await prisma.webhook.create({ - data: { - id: 'webhook-retry-test', - creatorId, - callbackUrl: 'https://nonexistent.example.com/fail', - events: { set: ['BUY'] }, - }, - }); + expect(events.length).toBe(0); - const { dispatchWebhookEvent } = await import('./webhook.service'); + await prisma.webhookEvent.deleteMany({ + where: { webhookId: buyOnlyWebhook.id }, + }); + await prisma.webhook.delete({ where: { id: buyOnlyWebhook.id } }); + }); + + it('retries delivery on failure and flags webhook as failing after exhaustion', async () => { + const failingWebhook = await prisma.webhook.create({ + data: { + id: 'webhook-retry-test', + creatorId, + callbackUrl: 'https://nonexistent.example.com/fail', + events: { set: ['BUY'] }, + }, + }); - await dispatchWebhookEvent({ - type: 'buy', - creatorId, - buyerOrSellerAddress: 'GDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDH', - amount: '10', - price: '5.0', - feePaid: '0.25', - timestamp: new Date().toISOString(), - }); + const { dispatchWebhookEvent } = await import('./webhook.service'); + + await dispatchWebhookEvent({ + type: 'buy', + creatorId, + buyerOrSellerAddress: + 'GDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDH', + amount: '10', + price: '5.0', + feePaid: '0.25', + timestamp: new Date().toISOString(), + }); - await new Promise((resolve) => setTimeout(resolve, 15000)); + await new Promise(resolve => setTimeout(resolve, 15000)); - const updated = await prisma.webhook.findUnique({ - where: { id: failingWebhook.id }, - select: { isFailing: true }, - }); + const updated = await prisma.webhook.findUnique({ + where: { id: failingWebhook.id }, + select: { isFailing: true }, + }); - expect(updated?.isFailing).toBe(true); + expect(updated?.isFailing).toBe(true); - const events = await prisma.webhookEvent.findMany({ - where: { webhookId: failingWebhook.id }, - }); + const events = await prisma.webhookEvent.findMany({ + where: { webhookId: failingWebhook.id }, + }); - expect(events.length).toBeGreaterThan(0); - expect(events[0].status).toBe('FAILED'); - expect(events[0].retryCount).toBe(envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS); + expect(events.length).toBeGreaterThan(0); + expect(events[0].status).toBe('FAILED'); + expect(events[0].retryCount).toBe(envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS); - await prisma.webhookEvent.deleteMany({ where: { webhookId: failingWebhook.id } }); - await prisma.webhook.delete({ where: { id: failingWebhook.id } }); - }, 30000); + await prisma.webhookEvent.deleteMany({ + where: { webhookId: failingWebhook.id }, + }); + await prisma.webhook.delete({ where: { id: failingWebhook.id } }); + }, 30000); }); describe('GET /api/v1/creators/:id/webhooks — isolation between creators (#474)', () => { - beforeAll(async () => { - for (const [userId, email, walletAddr, cId, handle, displayName] of [ - [userIdA, 'webhook-isolation-a@example.com', walletAddressA, creatorIdA, 'isolation-creator-a', 'Isolation Creator A'], - [userIdB, 'webhook-isolation-b@example.com', walletAddressB, creatorIdB, 'isolation-creator-b', 'Isolation Creator B'], - ] as const) { - await prisma.user.create({ - data: { id: userId, email, passwordHash: 'dummy-hash', firstName: 'Isolation', lastName: 'Test' }, - }); - await prisma.stellarWallet.create({ data: { address: walletAddr, userId } }); - await prisma.creatorProfile.create({ data: { id: cId, userId, handle, displayName } }); - } - - const pathA = `/api/v1/creators/${creatorIdA}/webhooks`; - const pathB = `/api/v1/creators/${creatorIdB}/webhooks`; - - await supertest(app) - .post(pathA) - .set(authHeadersFor(keypairA, 'POST', pathA, creatorIdA)) - .send({ callback_url: 'https://example.com/hook-a1', events: ['buy'] }); - - await supertest(app) - .post(pathA) - .set(authHeadersFor(keypairA, 'POST', pathA, creatorIdA)) - .send({ callback_url: 'https://example.com/hook-a2', events: ['sell'] }); - - await supertest(app) - .post(pathB) - .set(authHeadersFor(keypairB, 'POST', pathB, creatorIdB)) - .send({ callback_url: 'https://example.com/hook-b1', events: ['buy', 'sell'] }); - }); - - afterAll(async () => { - await prisma.webhook.deleteMany({ where: { creatorId: creatorIdA } }); - await prisma.webhook.deleteMany({ where: { creatorId: creatorIdB } }); - for (const [cId, walletAddr, userId] of [ - [creatorIdA, walletAddressA, userIdA], - [creatorIdB, walletAddressB, userIdB], - ] as const) { - await prisma.creatorProfile.delete({ where: { id: cId } }).catch(() => {}); - await prisma.stellarWallet.delete({ where: { address: walletAddr } }).catch(() => {}); - await prisma.user.delete({ where: { id: userId } }).catch(() => {}); - } - }); - - it("creator A's webhook list contains no webhooks from creator B", async () => { - const pathA = `/api/v1/creators/${creatorIdA}/webhooks`; - const res = await supertest(app) - .get(pathA) - .set(authHeadersFor(keypairA, 'GET', pathA, creatorIdA)); - - expect(res.status).toBe(200); - const webhooks = res.body.data as Array<{ creatorId?: string; callbackUrl: string }>; - expect(webhooks.every((w) => !w.callbackUrl.includes('hook-b'))).toBe(true); - }); - - it("creator B's webhook list contains no webhooks from creator A", async () => { - const pathB = `/api/v1/creators/${creatorIdB}/webhooks`; - const res = await supertest(app) - .get(pathB) - .set(authHeadersFor(keypairB, 'GET', pathB, creatorIdB)); - - expect(res.status).toBe(200); - const webhooks = res.body.data as Array<{ callbackUrl: string }>; - expect(webhooks.every((w) => !w.callbackUrl.includes('hook-a'))).toBe(true); - }); - - it("count for creator A matches the number of webhooks registered for A", async () => { - const pathA = `/api/v1/creators/${creatorIdA}/webhooks`; - const res = await supertest(app) - .get(pathA) - .set(authHeadersFor(keypairA, 'GET', pathA, creatorIdA)); - - expect(res.status).toBe(200); - expect(res.body.data).toHaveLength(2); - }); - - it("count for creator B matches the number of webhooks registered for B", async () => { - const pathB = `/api/v1/creators/${creatorIdB}/webhooks`; - const res = await supertest(app) - .get(pathB) - .set(authHeadersFor(keypairB, 'GET', pathB, creatorIdB)); - - expect(res.status).toBe(200); - expect(res.body.data).toHaveLength(1); - }); + beforeAll(async () => { + for (const [userId, email, walletAddr, cId, handle, displayName] of [ + [ + userIdA, + 'webhook-isolation-a@example.com', + walletAddressA, + creatorIdA, + 'isolation-creator-a', + 'Isolation Creator A', + ], + [ + userIdB, + 'webhook-isolation-b@example.com', + walletAddressB, + creatorIdB, + 'isolation-creator-b', + 'Isolation Creator B', + ], + ] as const) { + await prisma.user.create({ + data: { + id: userId, + email, + passwordHash: 'dummy-hash', + firstName: 'Isolation', + lastName: 'Test', + }, + }); + await prisma.stellarWallet.create({ + data: { address: walletAddr, userId }, + }); + await prisma.creatorProfile.create({ + data: { id: cId, userId, handle, displayName }, + }); + } + + const pathA = `/api/v1/creators/${creatorIdA}/webhooks`; + const pathB = `/api/v1/creators/${creatorIdB}/webhooks`; + + await supertest(app) + .post(pathA) + .set(authHeadersFor(keypairA, 'POST', pathA, creatorIdA)) + .send({ + callback_url: 'https://example.com/hook-a1', + events: ['buy'], + }); + + await supertest(app) + .post(pathA) + .set(authHeadersFor(keypairA, 'POST', pathA, creatorIdA)) + .send({ + callback_url: 'https://example.com/hook-a2', + events: ['sell'], + }); + + await supertest(app) + .post(pathB) + .set(authHeadersFor(keypairB, 'POST', pathB, creatorIdB)) + .send({ + callback_url: 'https://example.com/hook-b1', + events: ['buy', 'sell'], + }); + }); + + afterAll(async () => { + await prisma.webhook.deleteMany({ where: { creatorId: creatorIdA } }); + await prisma.webhook.deleteMany({ where: { creatorId: creatorIdB } }); + for (const [cId, walletAddr, userId] of [ + [creatorIdA, walletAddressA, userIdA], + [creatorIdB, walletAddressB, userIdB], + ] as const) { + await prisma.creatorProfile + .delete({ where: { id: cId } }) + .catch(() => {}); + await prisma.stellarWallet + .delete({ where: { address: walletAddr } }) + .catch(() => {}); + await prisma.user.delete({ where: { id: userId } }).catch(() => {}); + } + }); + + it("creator A's webhook list contains no webhooks from creator B", async () => { + const pathA = `/api/v1/creators/${creatorIdA}/webhooks`; + const res = await supertest(app) + .get(pathA) + .set(authHeadersFor(keypairA, 'GET', pathA, creatorIdA)); + + expect(res.status).toBe(200); + const webhooks = res.body.data as Array<{ + creatorId?: string; + callbackUrl: string; + }>; + expect(webhooks.every(w => !w.callbackUrl.includes('hook-b'))).toBe(true); + }); + + it("creator B's webhook list contains no webhooks from creator A", async () => { + const pathB = `/api/v1/creators/${creatorIdB}/webhooks`; + const res = await supertest(app) + .get(pathB) + .set(authHeadersFor(keypairB, 'GET', pathB, creatorIdB)); + + expect(res.status).toBe(200); + const webhooks = res.body.data as Array<{ callbackUrl: string }>; + expect(webhooks.every(w => !w.callbackUrl.includes('hook-a'))).toBe(true); + }); + + it('count for creator A matches the number of webhooks registered for A', async () => { + const pathA = `/api/v1/creators/${creatorIdA}/webhooks`; + const res = await supertest(app) + .get(pathA) + .set(authHeadersFor(keypairA, 'GET', pathA, creatorIdA)); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + }); + + it('count for creator B matches the number of webhooks registered for B', async () => { + const pathB = `/api/v1/creators/${creatorIdB}/webhooks`; + const res = await supertest(app) + .get(pathB) + .set(authHeadersFor(keypairB, 'GET', pathB, creatorIdB)); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + }); }); describe('GET /api/v1/creators/:id/webhooks — empty list for creator with no webhooks (#551)', () => { - // Use a dedicated keypair + creator that has no webhooks registered. - // The creator ID is a positive-integer-format string to satisfy - // parseCreatorId in webhook-signature.middleware.ts. - const emptyKeypair = Keypair.random(); - const emptyWalletAddress = emptyKeypair.publicKey(); - const emptyUserId = 'webhook-empty-user-551'; - const emptyCreatorId = '551001'; - const emptyHandle = 'webhook-empty-creator-551'; - const emptyListPath = `/api/v1/creators/${emptyCreatorId}/webhooks`; - - beforeAll(async () => { - await prisma.user.create({ - data: { - id: emptyUserId, - email: 'webhook-empty-551@example.com', - passwordHash: 'dummy-hash', - firstName: 'Empty', - lastName: 'Webhooks', - }, - }); + // Use a dedicated keypair + creator that has no webhooks registered. + // The creator ID is a positive-integer-format string to satisfy + // parseCreatorId in webhook-signature.middleware.ts. + const emptyKeypair = Keypair.random(); + const emptyWalletAddress = emptyKeypair.publicKey(); + const emptyUserId = 'webhook-empty-user-551'; + const emptyCreatorId = '551001'; + const emptyHandle = 'webhook-empty-creator-551'; + const emptyListPath = `/api/v1/creators/${emptyCreatorId}/webhooks`; + + beforeAll(async () => { + await prisma.user.create({ + data: { + id: emptyUserId, + email: 'webhook-empty-551@example.com', + passwordHash: 'dummy-hash', + firstName: 'Empty', + lastName: 'Webhooks', + }, + }); - await prisma.stellarWallet.create({ - data: { - address: emptyWalletAddress, - userId: emptyUserId, - }, - }); + await prisma.stellarWallet.create({ + data: { + address: emptyWalletAddress, + userId: emptyUserId, + }, + }); - await prisma.creatorProfile.create({ - data: { - id: emptyCreatorId, - userId: emptyUserId, - handle: emptyHandle, - displayName: 'Empty Webhooks Creator', - }, - }); - }); - - afterAll(async () => { - await prisma.webhook.deleteMany({ where: { creatorId: emptyCreatorId } }); - await prisma.creatorProfile.delete({ where: { id: emptyCreatorId } }).catch(() => {}); - await prisma.stellarWallet.delete({ where: { address: emptyWalletAddress } }).catch(() => {}); - await prisma.user.delete({ where: { id: emptyUserId } }).catch(() => {}); - }); - - it('returns 200 with an empty array when the creator has not registered any webhooks (#551)', async () => { - // Sanity-check: ensure no webhooks are persisted in the DB for this creator. - const dbCount = await prisma.webhook.count({ - where: { creatorId: emptyCreatorId }, - }); - expect(dbCount).toBe(0); - - const res = await supertest(app) - .get(emptyListPath) - .set(authHeadersFor(emptyKeypair, 'GET', emptyListPath, emptyCreatorId)); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(Array.isArray(res.body.data)).toBe(true); - expect(res.body.data).toEqual([]); - }); + await prisma.creatorProfile.create({ + data: { + id: emptyCreatorId, + userId: emptyUserId, + handle: emptyHandle, + displayName: 'Empty Webhooks Creator', + }, + }); + }); + + afterAll(async () => { + await prisma.webhook.deleteMany({ where: { creatorId: emptyCreatorId } }); + await prisma.creatorProfile + .delete({ where: { id: emptyCreatorId } }) + .catch(() => {}); + await prisma.stellarWallet + .delete({ where: { address: emptyWalletAddress } }) + .catch(() => {}); + await prisma.user.delete({ where: { id: emptyUserId } }).catch(() => {}); + }); + + it('returns 200 with an empty array when the creator has not registered any webhooks (#551)', async () => { + // Sanity-check: ensure no webhooks are persisted in the DB for this creator. + const dbCount = await prisma.webhook.count({ + where: { creatorId: emptyCreatorId }, + }); + expect(dbCount).toBe(0); + + const res = await supertest(app) + .get(emptyListPath) + .set( + authHeadersFor(emptyKeypair, 'GET', emptyListPath, emptyCreatorId) + ); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data).toEqual([]); + }); }); describe('webhook retry on 500 response (#578)', () => { - let mockServer: http.Server; - let mockServerUrl: string; - let requestTimestamps: number[]; - let secondRequestResolve: () => void; - - beforeAll((done) => { - let requestCount = 0; - requestTimestamps = []; - - mockServer = http.createServer((_req, res) => { - requestTimestamps.push(Date.now()); - requestCount++; - - if (requestCount === 1) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Internal Server Error' })); - } else { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true })); - secondRequestResolve(); - } - }); + let mockServer: http.Server; + let mockServerUrl: string; + let requestTimestamps: number[]; + let secondRequestResolve: () => void; + + beforeAll(done => { + let requestCount = 0; + requestTimestamps = []; + + mockServer = http.createServer((_req, res) => { + requestTimestamps.push(Date.now()); + requestCount++; + + if (requestCount === 1) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal Server Error' })); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + secondRequestResolve(); + } + }); - mockServer.listen(0, '127.0.0.1', () => { - const address = mockServer.address() as any; - mockServerUrl = `http://127.0.0.1:${address.port}/webhook`; - done(); - }); - }); + mockServer.listen(0, '127.0.0.1', () => { + const address = mockServer.address() as any; + mockServerUrl = `http://127.0.0.1:${address.port}/webhook`; + done(); + }); + }); - afterAll((done) => { - mockServer.close(done); - }); + afterAll(done => { + mockServer.close(done); + }); - it('retries delivery after a 500 response and succeeds on the second attempt', async () => { - const secondRequestPromise = new Promise((resolve) => { - secondRequestResolve = resolve; - }); + it('retries delivery after a 500 response and succeeds on the second attempt', async () => { + const secondRequestPromise = new Promise(resolve => { + secondRequestResolve = resolve; + }); - const webhook = await prisma.webhook.create({ - data: { - id: 'webhook-retry-500-test', - creatorId, - callbackUrl: mockServerUrl, - events: { set: ['BUY'] }, - }, - }); - - const { dispatchWebhookEvent } = await import('./webhook.service'); - - await dispatchWebhookEvent({ - type: 'buy', - creatorId, - buyerOrSellerAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', - amount: '100', - price: '10.5', - feePaid: '0.5', - timestamp: new Date().toISOString(), - }); - - // Wait for the retry (second request after backoff) - await secondRequestPromise; - - // Allow time for DB writes after the second response - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Assert callback was called twice - expect(requestTimestamps.length).toBe(2); - - // Assert second call happened after the configured backoff delay - const backoffDelay = requestTimestamps[1] - requestTimestamps[0]; - expect(backoffDelay).toBeGreaterThanOrEqual(2000); - - // Assert webhook event was delivered successfully after retry - const events = await prisma.webhookEvent.findMany({ - where: { webhookId: webhook.id }, - }); - expect(events.length).toBeGreaterThan(0); - expect(events[0].status).toBe('DELIVERED'); - expect(events[0].retryCount).toBe(2); - - // Assert webhook is NOT marked as failing after successful retry - const updatedWebhook = await prisma.webhook.findUnique({ - where: { id: webhook.id }, - }); - expect(updatedWebhook?.isFailing).toBe(false); - - await prisma.webhookEvent.deleteMany({ where: { webhookId: webhook.id } }); - await prisma.webhook.delete({ where: { id: webhook.id } }); - }); + const webhook = await prisma.webhook.create({ + data: { + id: 'webhook-retry-500-test', + creatorId, + callbackUrl: mockServerUrl, + events: { set: ['BUY'] }, + }, + }); + + const { dispatchWebhookEvent } = await import('./webhook.service'); + + await dispatchWebhookEvent({ + type: 'buy', + creatorId, + buyerOrSellerAddress: + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + amount: '100', + price: '10.5', + feePaid: '0.5', + timestamp: new Date().toISOString(), + }); + + // Wait for the retry (second request after backoff) + await secondRequestPromise; + + // Allow time for DB writes after the second response + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Assert callback was called twice + expect(requestTimestamps.length).toBe(2); + + // Assert second call happened after the configured backoff delay + const backoffDelay = requestTimestamps[1] - requestTimestamps[0]; + expect(backoffDelay).toBeGreaterThanOrEqual(2000); + + // Assert webhook event was delivered successfully after retry + const events = await prisma.webhookEvent.findMany({ + where: { webhookId: webhook.id }, + }); + expect(events.length).toBeGreaterThan(0); + expect(events[0].status).toBe('DELIVERED'); + expect(events[0].retryCount).toBe(2); + + // Assert webhook is NOT marked as failing after successful retry + const updatedWebhook = await prisma.webhook.findUnique({ + where: { id: webhook.id }, + }); + expect(updatedWebhook?.isFailing).toBe(false); + + await prisma.webhookEvent.deleteMany({ + where: { webhookId: webhook.id }, + }); + await prisma.webhook.delete({ where: { id: webhook.id } }); + }); }); diff --git a/src/modules/webhooks/webhook.router.ts b/src/modules/webhooks/webhook.router.ts index 9d14e21..729c2f1 100644 --- a/src/modules/webhooks/webhook.router.ts +++ b/src/modules/webhooks/webhook.router.ts @@ -1,29 +1,21 @@ import { Router } from 'express'; import { requireWalletSignature } from './webhook-signature.middleware'; import { - registerWebhookHandler, - listWebhooksHandler, - deleteWebhookHandler, + registerWebhookHandler, + listWebhooksHandler, + deleteWebhookHandler, } from './webhook.controllers'; const router = Router(); -router.post( - '/:id/webhooks', - requireWalletSignature(), - registerWebhookHandler -); +router.post('/:id/webhooks', requireWalletSignature(), registerWebhookHandler); -router.get( - '/:id/webhooks', - requireWalletSignature(), - listWebhooksHandler -); +router.get('/:id/webhooks', requireWalletSignature(), listWebhooksHandler); router.delete( - '/:id/webhooks/:webhookId', - requireWalletSignature(), - deleteWebhookHandler + '/:id/webhooks/:webhookId', + requireWalletSignature(), + deleteWebhookHandler ); export default router; diff --git a/src/modules/webhooks/webhook.schemas.ts b/src/modules/webhooks/webhook.schemas.ts index f3a4454..c761ed9 100644 --- a/src/modules/webhooks/webhook.schemas.ts +++ b/src/modules/webhooks/webhook.schemas.ts @@ -3,10 +3,10 @@ import { z } from 'zod'; export const WebhookEventEnum = z.enum(['buy', 'sell']); export const CreateWebhookSchema = z.object({ - callback_url: z.string().url('callback_url must be a valid URL'), - events: z - .array(WebhookEventEnum, { required_error: 'events is required' }) - .min(1, 'At least one event type is required'), + callback_url: z.string().url('callback_url must be a valid URL'), + events: z + .array(WebhookEventEnum, { required_error: 'events is required' }) + .min(1, 'At least one event type is required'), }); export type CreateWebhookType = z.infer; diff --git a/src/modules/webhooks/webhook.service.test.ts b/src/modules/webhooks/webhook.service.test.ts index 0d64a0b..983cfc0 100644 --- a/src/modules/webhooks/webhook.service.test.ts +++ b/src/modules/webhooks/webhook.service.test.ts @@ -4,297 +4,305 @@ import * as webhookService from './webhook.service'; import { logger } from '../../utils/logger.utils'; jest.mock('../../utils/prisma.utils', () => ({ - prisma: { - webhook: { - count: jest.fn(), - create: jest.fn(), - findMany: jest.fn(), - findFirst: jest.fn(), - findUnique: jest.fn(), - delete: jest.fn(), - update: jest.fn(), - }, - webhookEvent: { - create: jest.fn(), - findMany: jest.fn(), - updateMany: jest.fn(), - }, - }, + prisma: { + webhook: { + count: jest.fn(), + create: jest.fn(), + findMany: jest.fn(), + findFirst: jest.fn(), + findUnique: jest.fn(), + delete: jest.fn(), + update: jest.fn(), + }, + webhookEvent: { + create: jest.fn(), + findMany: jest.fn(), + updateMany: jest.fn(), + }, + }, })); jest.mock('../../utils/logger.utils', () => ({ - logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, })); const mockPrisma = prisma as unknown as { - webhook: { - count: jest.Mock; - create: jest.Mock; - findMany: jest.Mock; - findFirst: jest.Mock; - findUnique: jest.Mock; - delete: jest.Mock; - update: jest.Mock; - }; - webhookEvent: { - create: jest.Mock; - findMany: jest.Mock; - updateMany: jest.Mock; - }; + webhook: { + count: jest.Mock; + create: jest.Mock; + findMany: jest.Mock; + findFirst: jest.Mock; + findUnique: jest.Mock; + delete: jest.Mock; + update: jest.Mock; + }; + webhookEvent: { + create: jest.Mock; + findMany: jest.Mock; + updateMany: jest.Mock; + }; }; beforeEach(() => { - jest.clearAllMocks(); + jest.clearAllMocks(); }); describe('createWebhook', () => { - it('creates a webhook when under the max limit', async () => { - mockPrisma.webhook.count.mockResolvedValue(0); - mockPrisma.webhook.create.mockResolvedValue({ - id: 'wh-1', - creatorId: 'creator-1', - callbackUrl: 'https://example.com/hook', - events: ['BUY', 'SELL'], - isActive: true, - isFailing: false, - createdAt: new Date(), - updatedAt: new Date(), - }); - - const result = await webhookService.createWebhook('creator-1', { - callbackUrl: 'https://example.com/hook', - events: ['buy', 'sell'], - }); - - expect(result.events).toEqual(['buy', 'sell']); - expect(result.callbackUrl).toBe('https://example.com/hook'); - expect(mockPrisma.webhook.count).toHaveBeenCalledWith({ - where: { creatorId: 'creator-1', isActive: true }, - }); - }); - - it('rejects creation when max webhooks reached', async () => { - mockPrisma.webhook.count.mockResolvedValue(envConfig.WEBHOOK_MAX_PER_CREATOR); - - await expect( - webhookService.createWebhook('creator-1', { - callbackUrl: 'https://example.com/hook', - events: ['buy'], - }) - ).rejects.toMatchObject({ - statusCode: 409, - code: 'MAX_WEBHOOKS_REACHED', - }); - }); + it('creates a webhook when under the max limit', async () => { + mockPrisma.webhook.count.mockResolvedValue(0); + mockPrisma.webhook.create.mockResolvedValue({ + id: 'wh-1', + creatorId: 'creator-1', + callbackUrl: 'https://example.com/hook', + events: ['BUY', 'SELL'], + isActive: true, + isFailing: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await webhookService.createWebhook('creator-1', { + callbackUrl: 'https://example.com/hook', + events: ['buy', 'sell'], + }); + + expect(result.events).toEqual(['buy', 'sell']); + expect(result.callbackUrl).toBe('https://example.com/hook'); + expect(mockPrisma.webhook.count).toHaveBeenCalledWith({ + where: { creatorId: 'creator-1', isActive: true }, + }); + }); + + it('rejects creation when max webhooks reached', async () => { + mockPrisma.webhook.count.mockResolvedValue( + envConfig.WEBHOOK_MAX_PER_CREATOR + ); + + await expect( + webhookService.createWebhook('creator-1', { + callbackUrl: 'https://example.com/hook', + events: ['buy'], + }) + ).rejects.toMatchObject({ + statusCode: 422, + code: 'MAX_WEBHOOKS_REACHED', + }); + }); }); describe('listWebhooks', () => { - it('returns denormalized event names', async () => { - mockPrisma.webhook.findMany.mockResolvedValue([ - { - id: 'wh-1', - creatorId: 'creator-1', - callbackUrl: 'https://example.com/hook', - events: ['BUY'], - isActive: true, - isFailing: false, - createdAt: new Date(), - updatedAt: new Date(), - }, - ]); - - const result = await webhookService.listWebhooks('creator-1'); - expect(result[0].events).toEqual(['buy']); - }); + it('returns denormalized event names', async () => { + mockPrisma.webhook.findMany.mockResolvedValue([ + { + id: 'wh-1', + creatorId: 'creator-1', + callbackUrl: 'https://example.com/hook', + events: ['BUY'], + isActive: true, + isFailing: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + const result = await webhookService.listWebhooks('creator-1'); + expect(result[0].events).toEqual(['buy']); + }); }); describe('deleteWebhook', () => { - it('deletes a webhook owned by the creator', async () => { - mockPrisma.webhook.findFirst.mockResolvedValue({ - id: 'wh-1', - creatorId: 'creator-1', - }); - mockPrisma.webhook.delete.mockResolvedValue({ id: 'wh-1' }); - - const result = await webhookService.deleteWebhook('wh-1', 'creator-1'); - expect(result).toEqual({ id: 'wh-1' }); - expect(mockPrisma.webhook.delete).toHaveBeenCalledWith({ - where: { id: 'wh-1' }, - }); - }); - - it('returns null for non-existent webhook', async () => { - mockPrisma.webhook.findFirst.mockResolvedValue(null); - - const result = await webhookService.deleteWebhook('wh-1', 'creator-1'); - expect(result).toBeNull(); - }); + it('deletes a webhook owned by the creator', async () => { + mockPrisma.webhook.findFirst.mockResolvedValue({ + id: 'wh-1', + creatorId: 'creator-1', + }); + mockPrisma.webhook.delete.mockResolvedValue({ id: 'wh-1' }); + + const result = await webhookService.deleteWebhook('wh-1', 'creator-1'); + expect(result).toEqual({ id: 'wh-1' }); + expect(mockPrisma.webhook.delete).toHaveBeenCalledWith({ + where: { id: 'wh-1' }, + }); + }); + + it('returns null for non-existent webhook', async () => { + mockPrisma.webhook.findFirst.mockResolvedValue(null); + + const result = await webhookService.deleteWebhook('wh-1', 'creator-1'); + expect(result).toBeNull(); + }); }); describe('dispatchWebhookEvent', () => { - beforeEach(() => { - jest.useFakeTimers(); - global.fetch = jest.fn(); - }); - - afterEach(() => { - jest.useRealTimers(); - jest.restoreAllMocks(); - }); - - it('does nothing when no matching webhooks', async () => { - mockPrisma.webhook.findMany.mockResolvedValue([]); - - await webhookService.dispatchWebhookEvent({ - type: 'buy', - creatorId: 'creator-1', - buyerOrSellerAddress: 'G...', - amount: '100', - price: '10', - feePaid: '0.5', - timestamp: new Date().toISOString(), - }); - - expect(mockPrisma.webhook.findMany).toHaveBeenCalled(); - expect(mockPrisma.webhookEvent.create).not.toHaveBeenCalled(); - }); - - it('creates WebhookEvent and dispatches for matching webhooks', async () => { - mockPrisma.webhook.findMany.mockResolvedValue([ - { - id: 'wh-1', - creatorId: 'creator-1', - callbackUrl: 'https://example.com/hook', - events: ['BUY'], - isActive: true, - isFailing: false, - }, - ]); - mockPrisma.webhookEvent.create.mockResolvedValue({ id: 'we-1' }); - mockPrisma.webhookEvent.updateMany.mockResolvedValue({ count: 1 }); - - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - }); - (global.fetch as jest.Mock) = mockFetch; - - await webhookService.dispatchWebhookEvent({ - type: 'buy', - creatorId: 'creator-1', - buyerOrSellerAddress: 'G...', - amount: '100', - price: '10', - feePaid: '0.5', - timestamp: new Date().toISOString(), - }); - - expect(mockPrisma.webhookEvent.create).toHaveBeenCalled(); - expect(mockFetch).toHaveBeenCalled(); - }); - - it('respects event type filter — buy webhook does not fire for sell events', async () => { - mockPrisma.webhook.findMany.mockImplementation( - (args: { where: { events: { has: string } } }) => { - if (args?.where?.events?.has === 'BUY') { - return Promise.resolve([ - { - id: 'wh-buy', - creatorId: 'creator-1', - callbackUrl: 'https://example.com/hook-buy', - events: ['BUY'], - isActive: true, - isFailing: false, - }, - ]); - } - return Promise.resolve([]); + beforeEach(() => { + jest.useFakeTimers(); + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('does nothing when no matching webhooks', async () => { + mockPrisma.webhook.findMany.mockResolvedValue([]); + + await webhookService.dispatchWebhookEvent({ + type: 'buy', + creatorId: 'creator-1', + buyerOrSellerAddress: 'G...', + amount: '100', + price: '10', + feePaid: '0.5', + timestamp: new Date().toISOString(), + }); + + expect(mockPrisma.webhook.findMany).toHaveBeenCalled(); + expect(mockPrisma.webhookEvent.create).not.toHaveBeenCalled(); + }); + + it('creates WebhookEvent and dispatches for matching webhooks', async () => { + mockPrisma.webhook.findMany.mockResolvedValue([ + { + id: 'wh-1', + creatorId: 'creator-1', + callbackUrl: 'https://example.com/hook', + events: ['BUY'], + isActive: true, + isFailing: false, + }, + ]); + mockPrisma.webhookEvent.create.mockResolvedValue({ id: 'we-1' }); + mockPrisma.webhookEvent.updateMany.mockResolvedValue({ count: 1 }); + + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + }); + (global.fetch as jest.Mock) = mockFetch; + + await webhookService.dispatchWebhookEvent({ + type: 'buy', + creatorId: 'creator-1', + buyerOrSellerAddress: 'G...', + amount: '100', + price: '10', + feePaid: '0.5', + timestamp: new Date().toISOString(), + }); + + expect(mockPrisma.webhookEvent.create).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalled(); + }); + + it('respects event type filter — buy webhook does not fire for sell events', async () => { + mockPrisma.webhook.findMany.mockImplementation( + (args: { where: { events: { has: string } } }) => { + if (args?.where?.events?.has === 'BUY') { + return Promise.resolve([ + { + id: 'wh-buy', + creatorId: 'creator-1', + callbackUrl: 'https://example.com/hook-buy', + events: ['BUY'], + isActive: true, + isFailing: false, + }, + ]); + } + return Promise.resolve([]); + } + ); + + await webhookService.dispatchWebhookEvent({ + type: 'sell', + creatorId: 'creator-1', + buyerOrSellerAddress: 'G...', + amount: '50', + price: '20', + feePaid: '1.0', + timestamp: new Date().toISOString(), + }); + + expect(mockPrisma.webhook.findMany).toHaveBeenCalled(); + expect(mockPrisma.webhookEvent.create).not.toHaveBeenCalled(); + }); + + it('retries delivery up to max attempts and flags webhook as failing', async () => { + mockPrisma.webhook.findMany.mockResolvedValue([ + { + id: 'wh-1', + creatorId: 'creator-1', + callbackUrl: 'https://nonexistent.example.com/fail', + events: ['SELL'], + isActive: true, + isFailing: false, + }, + ]); + mockPrisma.webhookEvent.create.mockResolvedValue({ id: 'we-1' }); + mockPrisma.webhookEvent.updateMany.mockResolvedValue({ count: 1 }); + mockPrisma.webhook.update.mockResolvedValue({}); + mockPrisma.webhook.findUnique.mockResolvedValue({ + callbackUrl: 'https://nonexistent.example.com/fail', + }); + + const mockFetch = jest.fn().mockRejectedValue(new Error('Network error')); + (global.fetch as jest.Mock) = mockFetch; + + const dispatchPromise = webhookService.dispatchWebhookEvent({ + type: 'sell', + creatorId: 'creator-1', + buyerOrSellerAddress: 'G...', + amount: '10', + price: '5', + feePaid: '0.25', + timestamp: new Date().toISOString(), + }); + + for (let i = 0; i < envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS; i++) { + await jest.advanceTimersByTimeAsync(Math.pow(2, i + 1) * 1000); } - ); - - await webhookService.dispatchWebhookEvent({ - type: 'sell', - creatorId: 'creator-1', - buyerOrSellerAddress: 'G...', - amount: '50', - price: '20', - feePaid: '1.0', - timestamp: new Date().toISOString(), - }); - - expect(mockPrisma.webhook.findMany).toHaveBeenCalled(); - expect(mockPrisma.webhookEvent.create).not.toHaveBeenCalled(); - }); - - it('retries delivery up to max attempts and flags webhook as failing', async () => { - mockPrisma.webhook.findMany.mockResolvedValue([ - { - id: 'wh-1', - creatorId: 'creator-1', - callbackUrl: 'https://nonexistent.example.com/fail', - events: ['SELL'], - isActive: true, - isFailing: false, - }, - ]); - mockPrisma.webhookEvent.create.mockResolvedValue({ id: 'we-1' }); - mockPrisma.webhookEvent.updateMany.mockResolvedValue({ count: 1 }); - mockPrisma.webhook.update.mockResolvedValue({}); - mockPrisma.webhook.findUnique.mockResolvedValue({ - callbackUrl: 'https://nonexistent.example.com/fail', - }); - - const mockFetch = jest.fn().mockRejectedValue(new Error('Network error')); - (global.fetch as jest.Mock) = mockFetch; - - const dispatchPromise = webhookService.dispatchWebhookEvent({ - type: 'sell', - creatorId: 'creator-1', - buyerOrSellerAddress: 'G...', - amount: '10', - price: '5', - feePaid: '0.25', - timestamp: new Date().toISOString(), - }); - - for (let i = 0; i < envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS; i++) { - await jest.advanceTimersByTimeAsync(Math.pow(2, i + 1) * 1000); - } - - await dispatchPromise; - - expect(mockFetch).toHaveBeenCalledTimes(envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS); - expect(mockPrisma.webhook.update).toHaveBeenCalledWith({ - where: { id: 'wh-1' }, - data: { isFailing: true }, - }); - expect(mockPrisma.webhookEvent.updateMany).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ status: 'FAILED' }), - }) - ); - - expect(logger.warn).toHaveBeenCalledTimes(envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS - 1); - expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ - webhook_id: 'wh-1', - creator_id: 'creator-1', - attempt_number: 2, - backoff_delay_ms: 2000, - last_error_code: 'Network error', - }), - 'Webhook delivery failed, retrying' - ); - expect(logger.error).toHaveBeenCalledTimes(1); - expect(logger.error).toHaveBeenCalledWith( - expect.objectContaining({ - webhook_id: 'wh-1', - creator_id: 'creator-1', - attempt_number: envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS, - last_error_code: 'Network error', - }), - 'Webhook delivery exhausted all retries, flagged as failing' - ); - }); + + await dispatchPromise; + + expect(mockFetch).toHaveBeenCalledTimes( + envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS + ); + expect(mockPrisma.webhook.update).toHaveBeenCalledWith({ + where: { id: 'wh-1' }, + data: { isFailing: true }, + }); + expect(mockPrisma.webhookEvent.updateMany).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'FAILED' }), + }) + ); + + expect(logger.warn).toHaveBeenCalledTimes( + envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS - 1 + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + webhook_id: 'wh-1', + creator_id: 'creator-1', + attempt_number: 2, + backoff_delay_ms: 2000, + last_error_code: 'Network error', + }), + 'Webhook delivery failed, retrying' + ); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + webhook_id: 'wh-1', + creator_id: 'creator-1', + event_type: 'sell', + total_attempts: envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS, + last_error_code: 'Network error', + flagged_at: expect.any(String), + }), + 'Webhook delivery exhausted all retries, flagged as failing' + ); + }); }); diff --git a/src/modules/webhooks/webhook.service.ts b/src/modules/webhooks/webhook.service.ts index 2573582..63445f5 100644 --- a/src/modules/webhooks/webhook.service.ts +++ b/src/modules/webhooks/webhook.service.ts @@ -3,206 +3,224 @@ import { logger } from '../../utils/logger.utils'; import { envConfig } from '../../config'; import { maskWebhookUrl } from '../../utils/webhook-mask.utils'; import { buildWebhookPayload } from './webhook-payload.utils'; -import type { CreateWebhookInput, TradeEvent, WebhookEventPayload, WebhookEventName } from './webhook.types'; +import type { + CreateWebhookInput, + TradeEvent, + WebhookEventPayload, + WebhookEventName, +} from './webhook.types'; function normalizeEvents(events: string[]): ('BUY' | 'SELL')[] { - return events.map((e) => (e === 'buy' ? 'BUY' : 'SELL')); + return events.map(e => (e === 'buy' ? 'BUY' : 'SELL')); } function denormalizeEvents(events: ('BUY' | 'SELL')[]): WebhookEventName[] { - return events.map((e) => (e === 'BUY' ? 'buy' : 'sell')); + return events.map(e => (e === 'BUY' ? 'buy' : 'sell')); } export async function createWebhook( - creatorId: string, - input: CreateWebhookInput + creatorId: string, + input: CreateWebhookInput ) { - const count = await prisma.webhook.count({ - where: { creatorId, isActive: true }, - }); - - if (count >= envConfig.WEBHOOK_MAX_PER_CREATOR) { - throw Object.assign( - new Error( - `Maximum of ${envConfig.WEBHOOK_MAX_PER_CREATOR} active webhooks per creator reached` - ), - { statusCode: 422, code: 'MAX_WEBHOOKS_REACHED' } - ); - } - - const webhook = await prisma.webhook.create({ - data: { - creatorId, - callbackUrl: input.callbackUrl, - events: { - set: normalizeEvents(input.events), + const count = await prisma.webhook.count({ + where: { creatorId, isActive: true }, + }); + + if (count >= envConfig.WEBHOOK_MAX_PER_CREATOR) { + throw Object.assign( + new Error( + `Maximum of ${envConfig.WEBHOOK_MAX_PER_CREATOR} active webhooks per creator reached` + ), + { statusCode: 422, code: 'MAX_WEBHOOKS_REACHED' } + ); + } + + const webhook = await prisma.webhook.create({ + data: { + creatorId, + callbackUrl: input.callbackUrl, + events: { + set: normalizeEvents(input.events), + }, }, - }, - }); - - logger.info( - { creator_id: creatorId, webhook_id: webhook.id, event_types: input.events, registered_at: webhook.createdAt.toISOString() }, - 'Webhook registered' - ); - - return { - ...webhook, - events: denormalizeEvents(webhook.events as ('BUY' | 'SELL')[]), - }; + }); + + logger.info( + { + creator_id: creatorId, + webhook_id: webhook.id, + event_types: input.events, + registered_at: webhook.createdAt.toISOString(), + }, + 'Webhook registered' + ); + + return { + ...webhook, + events: denormalizeEvents(webhook.events as ('BUY' | 'SELL')[]), + }; } export async function listWebhooks(creatorId: string) { - const webhooks = await prisma.webhook.findMany({ - where: { creatorId }, - orderBy: { createdAt: 'desc' }, - }); - - return webhooks.map((w) => ({ - ...w, - events: denormalizeEvents(w.events as ('BUY' | 'SELL')[]), - })); + const webhooks = await prisma.webhook.findMany({ + where: { creatorId }, + orderBy: { createdAt: 'desc' }, + }); + + return webhooks.map((w: { events: string[] }) => ({ + ...w, + events: denormalizeEvents(w.events as ('BUY' | 'SELL')[]), + })); } export async function deleteWebhook(webhookId: string, creatorId: string) { - const webhook = await prisma.webhook.findFirst({ - where: { id: webhookId, creatorId }, - }); + const webhook = await prisma.webhook.findFirst({ + where: { id: webhookId, creatorId }, + }); - if (!webhook) { - return null; - } + if (!webhook) { + return null; + } - await prisma.webhook.delete({ where: { id: webhookId } }); + await prisma.webhook.delete({ where: { id: webhookId } }); - logger.info( - { creator_id: creatorId, webhook_id: webhookId, deleted_at: new Date().toISOString() }, - 'Webhook deleted' - ); + logger.info( + { + creator_id: creatorId, + webhook_id: webhookId, + deleted_at: new Date().toISOString(), + }, + 'Webhook deleted' + ); - return { id: webhookId }; + return { id: webhookId }; } export async function dispatchWebhookEvent(tradeEvent: TradeEvent) { - const eventName: 'BUY' | 'SELL' = - tradeEvent.type === 'buy' ? 'BUY' : 'SELL'; - - const webhooks = await prisma.webhook.findMany({ - where: { - creatorId: tradeEvent.creatorId, - isActive: true, - isFailing: false, - events: { has: eventName }, - }, - }); + const eventName: 'BUY' | 'SELL' = tradeEvent.type === 'buy' ? 'BUY' : 'SELL'; + + const webhooks = await prisma.webhook.findMany({ + where: { + creatorId: tradeEvent.creatorId, + isActive: true, + isFailing: false, + events: { has: eventName }, + }, + }); - if (webhooks.length === 0) return; + if (webhooks.length === 0) return; - for (const webhook of webhooks) { - const payload: WebhookEventPayload = buildWebhookPayload(tradeEvent); + for (const webhook of webhooks) { + const payload: WebhookEventPayload = buildWebhookPayload(tradeEvent); - await prisma.webhookEvent.create({ - data: { - webhookId: webhook.id, - eventType: eventName, - payload: payload as unknown as Record, - status: 'PENDING', - }, - }); + await prisma.webhookEvent.create({ + data: { + webhookId: webhook.id, + eventType: eventName, + payload: payload as unknown as Record, + status: 'PENDING', + }, + }); - attemptDelivery(webhook.id, webhook.callbackUrl, payload).catch((err) => { - logger.error({ webhookId: webhook.id, error: err.message }, 'Webhook delivery failed'); - }); - } + attemptDelivery(webhook.id, webhook.callbackUrl, payload).catch(err => { + logger.error( + { webhookId: webhook.id, error: err.message }, + 'Webhook delivery failed' + ); + }); + } } async function attemptDelivery( - webhookId: string, - callbackUrl: string, - payload: WebhookEventPayload, - attempt = 1 + webhookId: string, + callbackUrl: string, + payload: WebhookEventPayload, + attempt = 1 ): Promise { - const maxAttempts = envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS; + const maxAttempts = envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS; + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + const startTime = Date.now(); + + const response = await fetch(callbackUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }); - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); - const startTime = Date.now(); + const endTime = Date.now(); + const responseTimeMs = endTime - startTime; + clearTimeout(timeout); + + if (response.ok) { + await prisma.webhookEvent.updateMany({ + where: { webhookId, status: 'PENDING' }, + data: { status: 'DELIVERED', retryCount: attempt }, + }); + + logger.info( + { + webhook_id: webhookId, + creator_id: payload.creator_id, + event_type: payload.event_type, + response_status: response.status, + response_time_ms: responseTimeMs, + delivered_at: new Date().toISOString(), + }, + 'Webhook delivery succeeded' + ); + return; + } + + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } catch (error) { + const errMsg = error instanceof Error ? error.message : 'Unknown error'; - const response = await fetch(callbackUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: controller.signal, - }); + await prisma.webhookEvent.updateMany({ + where: { webhookId, status: 'PENDING' }, + data: { retryCount: attempt, lastError: errMsg }, + }); - const endTime = Date.now(); - const responseTimeMs = endTime - startTime; - clearTimeout(timeout); + if (attempt < maxAttempts) { + const delay = Math.pow(2, attempt) * 1000; + logger.warn( + { + webhook_id: webhookId, + creator_id: payload.creator_id, + callback_url: maskWebhookUrl(callbackUrl), + attempt_number: attempt + 1, + backoff_delay_ms: delay, + last_error_code: errMsg, + }, + 'Webhook delivery failed, retrying' + ); + await new Promise(resolve => setTimeout(resolve, delay)); + return attemptDelivery(webhookId, callbackUrl, payload, attempt + 1); + } + + await prisma.webhook.update({ + where: { id: webhookId }, + data: { isFailing: true }, + }); - if (response.ok) { await prisma.webhookEvent.updateMany({ - where: { webhookId, status: 'PENDING' }, - data: { status: 'DELIVERED', retryCount: attempt }, + where: { webhookId, status: 'PENDING' }, + data: { status: 'FAILED', retryCount: attempt }, }); - logger.info( - { - webhook_id: webhookId, - creator_id: payload.creator_id, - event_type: payload.event_type, - response_status: response.status, - response_time_ms: responseTimeMs, - delivered_at: new Date().toISOString(), - }, - 'Webhook delivery succeeded' + logger.error( + { + webhook_id: webhookId, + creator_id: payload.creator_id, + event_type: payload.event_type, + total_attempts: attempt, + last_error_code: errMsg, + flagged_at: new Date().toISOString(), + }, + 'Webhook delivery exhausted all retries, flagged as failing' ); - return; - } - - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } catch (error) { - const errMsg = error instanceof Error ? error.message : 'Unknown error'; - - await prisma.webhookEvent.updateMany({ - where: { webhookId, status: 'PENDING' }, - data: { retryCount: attempt, lastError: errMsg }, - }); - - if (attempt < maxAttempts) { - const delay = Math.pow(2, attempt) * 1000; - logger.warn( - { - webhook_id: webhookId, - creator_id: payload.creator_id, - callback_url: maskWebhookUrl(callbackUrl), - attempt_number: attempt + 1, - backoff_delay_ms: delay, - last_error_code: errMsg, - }, - 'Webhook delivery failed, retrying' - ); - await new Promise((resolve) => setTimeout(resolve, delay)); - return attemptDelivery(webhookId, callbackUrl, payload, attempt + 1); - } - - await prisma.webhook.update({ - where: { id: webhookId }, - data: { isFailing: true }, - }); - - await prisma.webhookEvent.updateMany({ - where: { webhookId, status: 'PENDING' }, - data: { status: 'FAILED', retryCount: attempt }, - }); - - logger.error( - { - webhook_id: webhookId, - creator_id: payload.creator_id, - attempt_number: attempt, - last_error_code: errMsg, - }, - 'Webhook delivery exhausted all retries, flagged as failing' - ); - } + } } diff --git a/src/modules/webhooks/webhook.types.ts b/src/modules/webhooks/webhook.types.ts index 847ffde..b6146a9 100644 --- a/src/modules/webhooks/webhook.types.ts +++ b/src/modules/webhooks/webhook.types.ts @@ -1,37 +1,37 @@ export type WebhookEventName = 'buy' | 'sell'; export interface CreateWebhookInput { - callbackUrl: string; - events: WebhookEventName[]; + callbackUrl: string; + events: WebhookEventName[]; } export interface WebhookResponse { - id: string; - creatorId: string; - callbackUrl: string; - events: WebhookEventName[]; - isActive: boolean; - isFailing: boolean; - createdAt: Date; - updatedAt: Date; + id: string; + creatorId: string; + callbackUrl: string; + events: WebhookEventName[]; + isActive: boolean; + isFailing: boolean; + createdAt: Date; + updatedAt: Date; } export interface WebhookEventPayload { - event_type: WebhookEventName; - creator_id: string; - buyer_or_seller_address: string; - amount: string; - price: string; - fee_paid: string; - timestamp: string; + event_type: WebhookEventName; + creator_id: string; + buyer_or_seller_address: string; + amount: string; + price: string; + fee_paid: string; + timestamp: string; } export interface TradeEvent { - type: WebhookEventName; - creatorId: string; - buyerOrSellerAddress: string; - amount: string; - price: string; - feePaid: string; - timestamp: string; + type: WebhookEventName; + creatorId: string; + buyerOrSellerAddress: string; + amount: string; + price: string; + feePaid: string; + timestamp: string; } diff --git a/src/types/profile.types.ts b/src/types/profile.types.ts index aaac7da..735f550 100644 --- a/src/types/profile.types.ts +++ b/src/types/profile.types.ts @@ -191,7 +191,10 @@ export function getUserCapabilities(user: User): UserCapabilities { } export function getUserDisplayName(user: User): string { - const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); + const name = [user.firstName, user.lastName] + .filter(Boolean) + .join(' ') + .trim(); return name || user.email; } diff --git a/src/types/wallet.types.ts b/src/types/wallet.types.ts index 0f1d488..446ca0a 100644 --- a/src/types/wallet.types.ts +++ b/src/types/wallet.types.ts @@ -9,15 +9,17 @@ export type StellarWalletIdentity = StellarWallet; * Represent a Stellar wallet identity with associated user profile. */ export type StellarWalletWithUser = StellarWallet & { - user: User; + user: User; }; /** * Interface for the wallet service boundary. */ export interface IStellarWalletService { - upsertStellarWallet(userId: string, address: string): Promise; - getStellarWalletByUserId(userId: string): Promise; - getUserByStellarAddress(address: string): Promise; - isStellarAddressRegistered(address: string): Promise; + upsertStellarWallet(userId: string, address: string): Promise; + getStellarWalletByUserId(userId: string): Promise; + getUserByStellarAddress( + address: string + ): Promise; + isStellarAddressRegistered(address: string): Promise; } diff --git a/src/utils/als.utils.ts b/src/utils/als.utils.ts index 44408e2..190fa4a 100644 --- a/src/utils/als.utils.ts +++ b/src/utils/als.utils.ts @@ -1,9 +1,9 @@ import { AsyncLocalStorage } from 'async_hooks'; export interface RequestContext { - path: string; - method: string; - requestId?: string; + path: string; + method: string; + requestId?: string; } export const requestContextStorage = new AsyncLocalStorage(); diff --git a/src/utils/audit.utils.ts b/src/utils/audit.utils.ts index ef4b023..08c2d73 100644 --- a/src/utils/audit.utils.ts +++ b/src/utils/audit.utils.ts @@ -1,30 +1,32 @@ import { prisma } from './prisma.utils'; export interface AuditEventPayload { - actor: string; - action: string; - target: string; - targetId: string; - metadata?: Record; + actor: string; + action: string; + target: string; + targetId: string; + metadata?: Record; } -export async function emitAuditEvent(payload: AuditEventPayload): Promise { - try { - const data: Record = { - actor: payload.actor, - action: payload.action, - target: payload.target, - targetId: payload.targetId, - }; +export async function emitAuditEvent( + payload: AuditEventPayload +): Promise { + try { + const data: Record = { + actor: payload.actor, + action: payload.action, + target: payload.target, + targetId: payload.targetId, + }; - if (payload.metadata) { - data.metadata = payload.metadata as Record; - } + if (payload.metadata) { + data.metadata = payload.metadata as Record; + } - await prisma.auditEvent.create({ - data: data as Parameters[0]['data'], - }); - } catch (error) { - console.error('Failed to emit audit event:', error); - } + await prisma.auditEvent.create({ + data: data as Parameters[0]['data'], + }); + } catch (error) { + console.error('Failed to emit audit event:', error); + } } diff --git a/src/utils/bigint-serializer.utils.test.ts b/src/utils/bigint-serializer.utils.test.ts index 712ef63..1ec894f 100644 --- a/src/utils/bigint-serializer.utils.test.ts +++ b/src/utils/bigint-serializer.utils.test.ts @@ -1,5 +1,10 @@ import { strict as assert } from 'assert'; -import { bigIntReplacer, safeJsonStringify, sanitizeBigInts, serializeBigInt } from './bigint-serializer.utils'; +import { + bigIntReplacer, + safeJsonStringify, + sanitizeBigInts, + serializeBigInt, +} from './bigint-serializer.utils'; function run() { // bigIntReplacer converts BigInt to string @@ -26,8 +31,16 @@ function run() { assert.equal(sanitizeBigInts(5n), '5'); // sanitizeBigInts – nested object - const sanitized = sanitizeBigInts({ id: 1n, nested: { amount: 500n }, label: 'ok' }); - assert.deepEqual(sanitized, { id: '1', nested: { amount: '500' }, label: 'ok' }); + const sanitized = sanitizeBigInts({ + id: 1n, + nested: { amount: 500n }, + label: 'ok', + }); + assert.deepEqual(sanitized, { + id: '1', + nested: { amount: '500' }, + label: 'ok', + }); // sanitizeBigInts – array assert.deepEqual(sanitizeBigInts([1n, 2n, 3n]), ['1', '2', '3']); @@ -40,8 +53,16 @@ function run() { assert.equal(serializeBigInt(9007199254740993n), '9007199254740993'); // serializeBigInt – nested BigInt in object converts correctly - const serializedObj = serializeBigInt({ id: 1n, nested: { amount: 500n }, label: 'ok' }); - assert.deepEqual(serializedObj, { id: '1', nested: { amount: '500' }, label: 'ok' }); + const serializedObj = serializeBigInt({ + id: 1n, + nested: { amount: 500n }, + label: 'ok', + }); + assert.deepEqual(serializedObj, { + id: '1', + nested: { amount: '500' }, + label: 'ok', + }); // serializeBigInt – BigInt inside an array converts correctly assert.deepEqual(serializeBigInt([1n, 2n, 3n]), ['1', '2', '3']); diff --git a/src/utils/cache-key-params.utils.test.ts b/src/utils/cache-key-params.utils.test.ts index 33995ec..e8bd039 100644 --- a/src/utils/cache-key-params.utils.test.ts +++ b/src/utils/cache-key-params.utils.test.ts @@ -1,74 +1,81 @@ import { buildCanonicalParamString } from './cache-key-params.utils'; describe('buildCanonicalParamString()', () => { - it('produces identical output regardless of input key order', () => { - const setA = buildCanonicalParamString({ - order: 'desc', - limit: 20, - sort: 'createdAt', - offset: 0, - }); + it('produces identical output regardless of input key order', () => { + const setA = buildCanonicalParamString({ + order: 'desc', + limit: 20, + sort: 'createdAt', + offset: 0, + }); - const setB = buildCanonicalParamString({ - limit: 20, - offset: 0, - sort: 'createdAt', - order: 'desc', - }); + const setB = buildCanonicalParamString({ + limit: 20, + offset: 0, + sort: 'createdAt', + order: 'desc', + }); - expect(setA).toBe(setB); - }); + expect(setA).toBe(setB); + }); - it('sorts keys lexicographically', () => { - const result = buildCanonicalParamString({ z: 'last', a: 'first', m: 'mid' }); - expect(result).toBe('a:first:m:mid:z:last'); - }); + it('sorts keys lexicographically', () => { + const result = buildCanonicalParamString({ + z: 'last', + a: 'first', + m: 'mid', + }); + expect(result).toBe('a:first:m:mid:z:last'); + }); - it('omits undefined values', () => { - const result = buildCanonicalParamString({ - limit: 20, - search: undefined, - verified: undefined, - }); - expect(result).toBe('limit:20'); - }); + it('omits undefined values', () => { + const result = buildCanonicalParamString({ + limit: 20, + search: undefined, + verified: undefined, + }); + expect(result).toBe('limit:20'); + }); - it('includes boolean values', () => { - const result = buildCanonicalParamString({ verified: true, limit: 10 }); - expect(result).toBe('limit:10:verified:true'); - }); + it('includes boolean values', () => { + const result = buildCanonicalParamString({ verified: true, limit: 10 }); + expect(result).toBe('limit:10:verified:true'); + }); - it('returns an empty string when all values are undefined', () => { - const result = buildCanonicalParamString({ search: undefined, verified: undefined }); - expect(result).toBe(''); - }); + it('returns an empty string when all values are undefined', () => { + const result = buildCanonicalParamString({ + search: undefined, + verified: undefined, + }); + expect(result).toBe(''); + }); - it('returns an empty string for an empty params object', () => { - expect(buildCanonicalParamString({})).toBe(''); - }); + it('returns an empty string for an empty params object', () => { + expect(buildCanonicalParamString({})).toBe(''); + }); - it('two equivalent creator feed param sets produce the same cache key fragment', () => { - const paramsA = buildCanonicalParamString({ - limit: 20, - offset: 0, - sort: 'createdAt', - order: 'desc', - verified: true, - search: 'example', - }); + it('two equivalent creator feed param sets produce the same cache key fragment', () => { + const paramsA = buildCanonicalParamString({ + limit: 20, + offset: 0, + sort: 'createdAt', + order: 'desc', + verified: true, + search: 'example', + }); - const paramsB = buildCanonicalParamString({ - search: 'example', - verified: true, - order: 'desc', - sort: 'createdAt', - offset: 0, - limit: 20, - }); + const paramsB = buildCanonicalParamString({ + search: 'example', + verified: true, + order: 'desc', + sort: 'createdAt', + offset: 0, + limit: 20, + }); - expect(paramsA).toBe(paramsB); - expect(paramsA).toBe( - 'limit:20:offset:0:order:desc:search:example:sort:createdAt:verified:true', - ); - }); + expect(paramsA).toBe(paramsB); + expect(paramsA).toBe( + 'limit:20:offset:0:order:desc:search:example:sort:createdAt:verified:true' + ); + }); }); diff --git a/src/utils/cache-key-params.utils.ts b/src/utils/cache-key-params.utils.ts index 4c12ef2..d925af0 100644 --- a/src/utils/cache-key-params.utils.ts +++ b/src/utils/cache-key-params.utils.ts @@ -15,11 +15,14 @@ * // => "limit:20:order:desc:sort:createdAt" */ export function buildCanonicalParamString( - params: Record, + params: Record ): string { - return Object.entries(params) - .filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, value]) => `${key}:${value}`) - .join(':'); + return Object.entries(params) + .filter( + (entry): entry is [string, string | number | boolean] => + entry[1] !== undefined + ) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}:${value}`) + .join(':'); } diff --git a/src/utils/client-ip.utils.test.ts b/src/utils/client-ip.utils.test.ts index b761268..a472a3e 100644 --- a/src/utils/client-ip.utils.test.ts +++ b/src/utils/client-ip.utils.test.ts @@ -1,96 +1,93 @@ import { getClientIp } from './client-ip.utils'; import { Request } from 'express'; -function makeReq( - socketIp: string, - forwardedFor?: string -): Request { - return { - socket: { remoteAddress: socketIp }, - headers: forwardedFor ? { 'x-forwarded-for': forwardedFor } : {}, - } as unknown as Request; +function makeReq(socketIp: string, forwardedFor?: string): Request { + return { + socket: { remoteAddress: socketIp }, + headers: forwardedFor ? { 'x-forwarded-for': forwardedFor } : {}, + } as unknown as Request; } describe('getClientIp', () => { - describe('when socket is a trusted proxy', () => { - it('returns the first X-Forwarded-For IP', () => { - const req = makeReq('127.0.0.1', '203.0.113.5, 10.0.0.1'); - expect(getClientIp(req)).toBe('203.0.113.5'); - }); + describe('when socket is a trusted proxy', () => { + it('returns the first X-Forwarded-For IP', () => { + const req = makeReq('127.0.0.1', '203.0.113.5, 10.0.0.1'); + expect(getClientIp(req)).toBe('203.0.113.5'); + }); - it('handles a single IP in X-Forwarded-For', () => { - const req = makeReq('10.0.0.2', '198.51.100.7'); - expect(getClientIp(req)).toBe('198.51.100.7'); - }); + it('handles a single IP in X-Forwarded-For', () => { + const req = makeReq('10.0.0.2', '198.51.100.7'); + expect(getClientIp(req)).toBe('198.51.100.7'); + }); - it('trims whitespace from the extracted IP', () => { - const req = makeReq('192.168.1.1', ' 1.2.3.4 , 10.0.0.5'); - expect(getClientIp(req)).toBe('1.2.3.4'); - }); + it('trims whitespace from the extracted IP', () => { + const req = makeReq('192.168.1.1', ' 1.2.3.4 , 10.0.0.5'); + expect(getClientIp(req)).toBe('1.2.3.4'); + }); - it('falls back to socket address when X-Forwarded-For is absent', () => { - const req = makeReq('10.10.10.1'); - expect(getClientIp(req)).toBe('10.10.10.1'); - }); + it('falls back to socket address when X-Forwarded-For is absent', () => { + const req = makeReq('10.10.10.1'); + expect(getClientIp(req)).toBe('10.10.10.1'); + }); - it('falls back to socket address when X-Forwarded-For is empty string', () => { - const req = makeReq('172.16.0.1', ''); - expect(getClientIp(req)).toBe('172.16.0.1'); - }); + it('falls back to socket address when X-Forwarded-For is empty string', () => { + const req = makeReq('172.16.0.1', ''); + expect(getClientIp(req)).toBe('172.16.0.1'); + }); - it('handles array-valued X-Forwarded-For header', () => { - const req = { - socket: { remoteAddress: '127.0.0.1' }, - headers: { 'x-forwarded-for': ['9.9.9.9, 10.0.0.1', '8.8.8.8'] }, - } as unknown as Request; - expect(getClientIp(req)).toBe('9.9.9.9'); - }); + it('handles array-valued X-Forwarded-For header', () => { + const req = { + socket: { remoteAddress: '127.0.0.1' }, + headers: { 'x-forwarded-for': ['9.9.9.9, 10.0.0.1', '8.8.8.8'] }, + } as unknown as Request; + expect(getClientIp(req)).toBe('9.9.9.9'); + }); - it('accepts private 172.16.x.x as a trusted proxy', () => { - const req = makeReq('172.16.5.10', '55.55.55.55'); - expect(getClientIp(req)).toBe('55.55.55.55'); - }); + it('accepts private 172.16.x.x as a trusted proxy', () => { + const req = makeReq('172.16.5.10', '55.55.55.55'); + expect(getClientIp(req)).toBe('55.55.55.55'); + }); - it('accepts IPv6 loopback ::1 as trusted proxy', () => { - const req = makeReq('::1', '203.0.113.99'); - expect(getClientIp(req)).toBe('203.0.113.99'); - }); - }); + it('accepts IPv6 loopback ::1 as trusted proxy', () => { + const req = makeReq('::1', '203.0.113.99'); + expect(getClientIp(req)).toBe('203.0.113.99'); + }); + }); - describe('when socket is NOT a trusted proxy', () => { - it('ignores X-Forwarded-For and returns the socket address', () => { - const req = makeReq('203.0.113.1', '1.2.3.4'); - expect(getClientIp(req)).toBe('203.0.113.1'); - }); + describe('when socket is NOT a trusted proxy', () => { + it('ignores X-Forwarded-For and returns the socket address', () => { + const req = makeReq('203.0.113.1', '1.2.3.4'); + expect(getClientIp(req)).toBe('203.0.113.1'); + }); - it('returns socket address even when no forwarded header is present', () => { - const req = makeReq('8.8.8.8'); - expect(getClientIp(req)).toBe('8.8.8.8'); - }); - }); + it('returns socket address even when no forwarded header is present', () => { + const req = makeReq('8.8.8.8'); + expect(getClientIp(req)).toBe('8.8.8.8'); + }); + }); - describe('custom trusted predicate', () => { - it('uses the override predicate instead of the default CIDR check', () => { - const req = makeReq('8.8.8.8', '203.0.113.42'); - // Treat all IPs as trusted via override - expect(getClientIp(req, () => true)).toBe('203.0.113.42'); - }); + describe('custom trusted predicate', () => { + it('uses the override predicate instead of the default CIDR check', () => { + const req = makeReq('8.8.8.8', '203.0.113.42'); + // Treat all IPs as trusted via override + expect(getClientIp(req, () => true)).toBe('203.0.113.42'); + }); - it('rejects all proxies when override always returns false', () => { - const req = makeReq('127.0.0.1', '203.0.113.42'); - expect(getClientIp(req, () => false)).toBe('127.0.0.1'); - }); - }); + it('rejects all proxies when override always returns false', () => { + const req = makeReq('127.0.0.1', '203.0.113.42'); + expect(getClientIp(req, () => false)).toBe('127.0.0.1'); + }); + }); - describe('edge cases', () => { - it('returns undefined when socket address is missing', () => { - const req = { socket: {}, headers: {} } as unknown as Request; - expect(getClientIp(req)).toBeUndefined(); - }); + describe('edge cases', () => { + it('returns undefined when socket address is missing', () => { + const req = { socket: {}, headers: {} } as unknown as Request; + expect(getClientIp(req)).toBeUndefined(); + }); - it('returns undefined when socket itself is undefined', () => { - const req = { headers: {} } as unknown as Request; - expect(getClientIp(req)).toBeUndefined(); - }); - }); + it('returns undefined when socket itself is undefined', () => { + const req = { headers: {} } as unknown as Request; + expect(getClientIp(req)).toBeUndefined(); + }); + }); }); diff --git a/src/utils/client-ip.utils.ts b/src/utils/client-ip.utils.ts index 34ab670..73e27e0 100644 --- a/src/utils/client-ip.utils.ts +++ b/src/utils/client-ip.utils.ts @@ -7,13 +7,13 @@ import { Request } from 'express'; * is ignored. */ const TRUSTED_PROXY_CIDRS = [ - { prefix: '127.', bits: 8 }, // 127.0.0.0/8 — loopback - { prefix: '10.', bits: 8 }, // 10.0.0.0/8 — private class A - { prefix: '172.16.', bits: 12 }, // 172.16.0.0/12 — private class B - { prefix: '192.168.', bits: 16 }, // 192.168.0.0/16 — private class C - { prefix: '::1', bits: 128 }, // IPv6 loopback - { prefix: 'fc00:', bits: 7 }, // IPv6 unique-local - { prefix: 'fd', bits: 8 }, // IPv6 unique-local (fd00::/8) + { prefix: '127.', bits: 8 }, // 127.0.0.0/8 — loopback + { prefix: '10.', bits: 8 }, // 10.0.0.0/8 — private class A + { prefix: '172.16.', bits: 12 }, // 172.16.0.0/12 — private class B + { prefix: '192.168.', bits: 16 }, // 192.168.0.0/16 — private class C + { prefix: '::1', bits: 128 }, // IPv6 loopback + { prefix: 'fc00:', bits: 7 }, // IPv6 unique-local + { prefix: 'fd', bits: 8 }, // IPv6 unique-local (fd00::/8) ]; /** @@ -24,9 +24,7 @@ const TRUSTED_PROXY_CIDRS = [ * deployment uses a known set of proxy IPs). */ function isFromTrustedProxy(ip: string): boolean { - return TRUSTED_PROXY_CIDRS.some(({ prefix }) => - ip.startsWith(prefix) - ); + return TRUSTED_PROXY_CIDRS.some(({ prefix }) => ip.startsWith(prefix)); } /** @@ -45,19 +43,19 @@ function isFromTrustedProxy(ip: string): boolean { * Defaults to the private/loopback CIDR check above. */ export function getClientIp( - req: Request, - trusted: (ip: string) => boolean = isFromTrustedProxy + req: Request, + trusted: (ip: string) => boolean = isFromTrustedProxy ): string | undefined { - const socketIp = req.socket?.remoteAddress ?? ''; + const socketIp = req.socket?.remoteAddress ?? ''; - if (trusted(socketIp)) { - const forwarded = req.headers['x-forwarded-for']; - if (forwarded) { - const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded; - const firstIp = raw.split(',')[0].trim(); - if (firstIp) return firstIp; - } - } + if (trusted(socketIp)) { + const forwarded = req.headers['x-forwarded-for']; + if (forwarded) { + const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded; + const firstIp = raw.split(',')[0].trim(); + if (firstIp) return firstIp; + } + } - return socketIp || undefined; + return socketIp || undefined; } diff --git a/src/utils/comma-query.utils.ts b/src/utils/comma-query.utils.ts index d69d98c..86069df 100644 --- a/src/utils/comma-query.utils.ts +++ b/src/utils/comma-query.utils.ts @@ -29,23 +29,23 @@ * parseCommaQuery(undefined) // [] */ export function parseCommaQuery( - raw: string | string[] | undefined | null + raw: string | string[] | undefined | null ): string[] { - if (raw == null) return []; + if (raw == null) return []; - const joined = Array.isArray(raw) ? raw.join(',') : raw; + const joined = Array.isArray(raw) ? raw.join(',') : raw; - const seen = new Set(); - const result: string[] = []; + const seen = new Set(); + const result: string[] = []; - for (const token of joined.split(',')) { - const trimmed = token.trim(); - if (trimmed === '') continue; - if (!seen.has(trimmed)) { - seen.add(trimmed); - result.push(trimmed); - } - } + for (const token of joined.split(',')) { + const trimmed = token.trim(); + if (trimmed === '') continue; + if (!seen.has(trimmed)) { + seen.add(trimmed); + result.push(trimmed); + } + } - return result; + return result; } diff --git a/src/utils/config-mask.utils.test.ts b/src/utils/config-mask.utils.test.ts index 593fa7c..3ce4d11 100644 --- a/src/utils/config-mask.utils.test.ts +++ b/src/utils/config-mask.utils.test.ts @@ -1,96 +1,96 @@ jest.mock('../config', () => ({ - envConfig: { - PORT: 3000, - MODE: 'test', - DATABASE_URL: 'postgresql://user:supersecret@localhost:5432/testdb', - GMAIL_USER: 'test@example.com', - GMAIL_APP_PASSWORD: 'my-app-password', - GOOGLE_CLIENT_ID: 'test-client-id', - GOOGLE_CLIENT_SECRET: 'test-client-secret', - BACKEND_URL: 'http://localhost:3000', - FRONTEND_URL: 'http://localhost:5173', - CLOUDINARY_CLOUD_NAME: 'test-cloud', - CLOUDINARY_API_KEY: 'test-api-key', - CLOUDINARY_API_SECRET: 'test-api-secret', - PAYSTACK_SECRET_KEY: 'sk_test_123456789', - PAYSTACK_PUBLIC_KEY: 'pk_test_123456789', - APP_SECRET: 'accesslayer_test_secret_key_32_bytes_long_xxxx', - STELLAR_NETWORK: 'testnet', - STELLAR_HORIZON_URL: 'https://horizon-testnet.stellar.org', - STELLAR_SOROBAN_RPC_URL: 'https://soroban-testnet.stellar.org', - ENABLE_RESPONSE_TIMING: true, - API_VERSION: '1.0.0', - ENABLE_API_VERSION_HEADER: true, - ENABLE_SCHEMA_VERSION_HEADER: true, - ENABLE_REQUEST_LOGGING: true, - DB_QUERY_TIMEOUT_MS: 5000, - INDEXER_JITTER_FACTOR: 0.1, - BACKGROUND_JOB_LOCK_TTL_MS: 300000, - SLOW_QUERY_THRESHOLD_MS: 500, - CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS: 500, - INDEXER_CURSOR_STALE_AGE_WARNING_MS: 300000, - INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: 300000, - ENABLE_INDEXER_DEDUPE: true, - ENABLE_INDEXER_DLQ: true, - ENABLE_INDEXER_CURSOR_STALENESS_WARNING: true, - OWNERSHIP_SNAPSHOT_TABLE_NAME: 'creator_ownership_snapshots', - OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN: true, - OWNERSHIP_SNAPSHOT_RETENTION_DAYS: 30, - OWNERSHIP_SNAPSHOT_CLEANUP_ENABLED: false, - OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES: 60, - }, + envConfig: { + PORT: 3000, + MODE: 'test', + DATABASE_URL: 'postgresql://user:supersecret@localhost:5432/testdb', + GMAIL_USER: 'test@example.com', + GMAIL_APP_PASSWORD: 'my-app-password', + GOOGLE_CLIENT_ID: 'test-client-id', + GOOGLE_CLIENT_SECRET: 'test-client-secret', + BACKEND_URL: 'http://localhost:3000', + FRONTEND_URL: 'http://localhost:5173', + CLOUDINARY_CLOUD_NAME: 'test-cloud', + CLOUDINARY_API_KEY: 'test-api-key', + CLOUDINARY_API_SECRET: 'test-api-secret', + PAYSTACK_SECRET_KEY: 'sk_test_123456789', + PAYSTACK_PUBLIC_KEY: 'pk_test_123456789', + APP_SECRET: 'accesslayer_test_secret_key_32_bytes_long_xxxx', + STELLAR_NETWORK: 'testnet', + STELLAR_HORIZON_URL: 'https://horizon-testnet.stellar.org', + STELLAR_SOROBAN_RPC_URL: 'https://soroban-testnet.stellar.org', + ENABLE_RESPONSE_TIMING: true, + API_VERSION: '1.0.0', + ENABLE_API_VERSION_HEADER: true, + ENABLE_SCHEMA_VERSION_HEADER: true, + ENABLE_REQUEST_LOGGING: true, + DB_QUERY_TIMEOUT_MS: 5000, + INDEXER_JITTER_FACTOR: 0.1, + BACKGROUND_JOB_LOCK_TTL_MS: 300000, + SLOW_QUERY_THRESHOLD_MS: 500, + CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS: 500, + INDEXER_CURSOR_STALE_AGE_WARNING_MS: 300000, + INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: 300000, + ENABLE_INDEXER_DEDUPE: true, + ENABLE_INDEXER_DLQ: true, + ENABLE_INDEXER_CURSOR_STALENESS_WARNING: true, + OWNERSHIP_SNAPSHOT_TABLE_NAME: 'creator_ownership_snapshots', + OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN: true, + OWNERSHIP_SNAPSHOT_RETENTION_DAYS: 30, + OWNERSHIP_SNAPSHOT_CLEANUP_ENABLED: false, + OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES: 60, + }, })); import { maskSensitiveConfigValues } from './config-mask.utils'; describe('maskSensitiveConfigValues', () => { - it('redacts values for keys matching SECRET pattern', () => { - const masked = maskSensitiveConfigValues(); - expect(masked.GOOGLE_CLIENT_SECRET).toBe('test***cret'); - expect(masked.CLOUDINARY_API_SECRET).toBe('test***cret'); - expect(masked.PAYSTACK_SECRET_KEY).toBe('sk_t***6789'); - expect(masked.APP_SECRET).toBe('acce***xxxx'); - }); + it('redacts values for keys matching SECRET pattern', () => { + const masked = maskSensitiveConfigValues(); + expect(masked.GOOGLE_CLIENT_SECRET).toBe('test***cret'); + expect(masked.CLOUDINARY_API_SECRET).toBe('test***cret'); + expect(masked.PAYSTACK_SECRET_KEY).toBe('sk_t***6789'); + expect(masked.APP_SECRET).toBe('acce***xxxx'); + }); - it('redacts values for keys matching KEY pattern', () => { - const masked = maskSensitiveConfigValues(); - expect(masked.CLOUDINARY_API_KEY).toBe('test***-key'); - expect(masked.PAYSTACK_PUBLIC_KEY).toBe('pk_t***6789'); - }); + it('redacts values for keys matching KEY pattern', () => { + const masked = maskSensitiveConfigValues(); + expect(masked.CLOUDINARY_API_KEY).toBe('test***-key'); + expect(masked.PAYSTACK_PUBLIC_KEY).toBe('pk_t***6789'); + }); - it('redacts values for keys matching PASSWORD pattern', () => { - const masked = maskSensitiveConfigValues(); - expect(masked.GMAIL_APP_PASSWORD).toBe('my-a***word'); - }); + it('redacts values for keys matching PASSWORD pattern', () => { + const masked = maskSensitiveConfigValues(); + expect(masked.GMAIL_APP_PASSWORD).toBe('my-a***word'); + }); - it('redacts the password portion of DATABASE_URL', () => { - const masked = maskSensitiveConfigValues(); - expect(masked.DATABASE_URL).toBe( - 'postgresql://***:***@localhost:5432/testdb' - ); - }); + it('redacts the password portion of DATABASE_URL', () => { + const masked = maskSensitiveConfigValues(); + expect(masked.DATABASE_URL).toBe( + 'postgresql://***:***@localhost:5432/testdb' + ); + }); - it('passes through non-sensitive values as-is', () => { - const masked = maskSensitiveConfigValues(); - expect(masked.PORT).toBe(3000); - expect(masked.MODE).toBe('test'); - expect(masked.BACKEND_URL).toBe('http://localhost:3000'); - expect(masked.FRONTEND_URL).toBe('http://localhost:5173'); - expect(masked.STELLAR_NETWORK).toBe('testnet'); - expect(masked.API_VERSION).toBe('1.0.0'); - }); + it('passes through non-sensitive values as-is', () => { + const masked = maskSensitiveConfigValues(); + expect(masked.PORT).toBe(3000); + expect(masked.MODE).toBe('test'); + expect(masked.BACKEND_URL).toBe('http://localhost:3000'); + expect(masked.FRONTEND_URL).toBe('http://localhost:5173'); + expect(masked.STELLAR_NETWORK).toBe('testnet'); + expect(masked.API_VERSION).toBe('1.0.0'); + }); - it('preserves boolean values for non-sensitive keys', () => { - const masked = maskSensitiveConfigValues(); - expect(masked.ENABLE_RESPONSE_TIMING).toBe(true); - expect(masked.ENABLE_INDEXER_DEDUPE).toBe(true); - expect(masked.OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN).toBe(true); - }); + it('preserves boolean values for non-sensitive keys', () => { + const masked = maskSensitiveConfigValues(); + expect(masked.ENABLE_RESPONSE_TIMING).toBe(true); + expect(masked.ENABLE_INDEXER_DEDUPE).toBe(true); + expect(masked.OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN).toBe(true); + }); - it('preserves numeric values for non-sensitive keys', () => { - const masked = maskSensitiveConfigValues(); - expect(masked.PORT).toBe(3000); - expect(masked.DB_QUERY_TIMEOUT_MS).toBe(5000); - expect(masked.SLOW_QUERY_THRESHOLD_MS).toBe(500); - }); + it('preserves numeric values for non-sensitive keys', () => { + const masked = maskSensitiveConfigValues(); + expect(masked.PORT).toBe(3000); + expect(masked.DB_QUERY_TIMEOUT_MS).toBe(5000); + expect(masked.SLOW_QUERY_THRESHOLD_MS).toBe(500); + }); }); diff --git a/src/utils/config-mask.utils.ts b/src/utils/config-mask.utils.ts index 6d3fa05..ba40e2b 100644 --- a/src/utils/config-mask.utils.ts +++ b/src/utils/config-mask.utils.ts @@ -16,48 +16,43 @@ import { envConfig } from '../config'; * - `TOKEN` — reserved for future token-based config values * - `DATABASE_URL` — contains embedded credentials (user:password@host) */ -const SENSITIVE_KEY_PATTERNS = [ - /SECRET/i, - /KEY/i, - /PASSWORD/i, - /TOKEN/i, -]; +const SENSITIVE_KEY_PATTERNS = [/SECRET/i, /KEY/i, /PASSWORD/i, /TOKEN/i]; const SENSITIVE_EXACT_KEYS = ['DATABASE_URL']; function isKeySensitive(key: string): boolean { - if (SENSITIVE_EXACT_KEYS.includes(key)) return true; - return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key)); + if (SENSITIVE_EXACT_KEYS.includes(key)) return true; + return SENSITIVE_KEY_PATTERNS.some(pattern => pattern.test(key)); } function maskDatabaseUrl(url: string): string { - try { - const parsed = new URL(url); - if (parsed.password) { - parsed.password = '***'; - } - if (parsed.username) { - parsed.username = parsed.username ? '***' : ''; - } - return parsed.toString(); - } catch { - return '***'; - } + try { + const parsed = new URL(url); + if (parsed.password) { + parsed.password = '***'; + } + if (parsed.username) { + parsed.username = parsed.username ? '***' : ''; + } + return parsed.toString(); + } catch { + return '***'; + } } function maskValue(key: string, value: unknown): unknown { - if (!isKeySensitive(key)) return value; + if (!isKeySensitive(key)) return value; - if (typeof value === 'string') { - if (key === 'DATABASE_URL') { - return maskDatabaseUrl(value); - } - if (value.length > 8) { - return value.slice(0, 4) + '***' + value.slice(-4); - } - } + if (typeof value === 'string') { + if (key === 'DATABASE_URL') { + return maskDatabaseUrl(value); + } + if (value.length > 8) { + return value.slice(0, 4) + '***' + value.slice(-4); + } + } - return '***'; + return '***'; } /** @@ -74,11 +69,11 @@ function maskValue(key: string, value: unknown): unknown { * logger.info(maskSensitiveConfigValues(), 'Startup configuration summary'); */ export function maskSensitiveConfigValues(): Record { - const masked: Record = {}; + const masked: Record = {}; - for (const [key, value] of Object.entries(envConfig)) { - masked[key] = maskValue(key, value); - } + for (const [key, value] of Object.entries(envConfig)) { + masked[key] = maskValue(key, value); + } - return masked; + return masked; } diff --git a/src/utils/creator-batch.utils.test.ts b/src/utils/creator-batch.utils.test.ts index 04a1e6c..6b6e884 100644 --- a/src/utils/creator-batch.utils.test.ts +++ b/src/utils/creator-batch.utils.test.ts @@ -1,4 +1,7 @@ -import { fetchCreatorProfilesByIds, CREATOR_PROFILE_SELECT } from './creator-batch.utils'; +import { + fetchCreatorProfilesByIds, + CREATOR_PROFILE_SELECT, +} from './creator-batch.utils'; import { prisma } from './prisma.utils'; jest.mock('./prisma.utils', () => ({ @@ -24,9 +27,42 @@ describe('fetchCreatorProfilesByIds', () => { // return records in a different order to ensure we reorder them findMany.mockResolvedValueOnce([ - { id: 'a', userId: 'u-a', handle: 'a', displayName: 'A', bio: null, avatarUrl: null, perkSummary: null, isVerified: false, createdAt, updatedAt }, - { id: 'b', userId: 'u-b', handle: 'b', displayName: 'B', bio: null, avatarUrl: null, perkSummary: null, isVerified: true, createdAt, updatedAt }, - { id: 'c', userId: 'u-c', handle: 'c', displayName: 'C', bio: null, avatarUrl: null, perkSummary: null, isVerified: false, createdAt, updatedAt }, + { + id: 'a', + userId: 'u-a', + handle: 'a', + displayName: 'A', + bio: null, + avatarUrl: null, + perkSummary: null, + isVerified: false, + createdAt, + updatedAt, + }, + { + id: 'b', + userId: 'u-b', + handle: 'b', + displayName: 'B', + bio: null, + avatarUrl: null, + perkSummary: null, + isVerified: true, + createdAt, + updatedAt, + }, + { + id: 'c', + userId: 'u-c', + handle: 'c', + displayName: 'C', + bio: null, + avatarUrl: null, + perkSummary: null, + isVerified: false, + createdAt, + updatedAt, + }, ]); const result = await fetchCreatorProfilesByIds(ids); @@ -37,16 +73,29 @@ describe('fetchCreatorProfilesByIds', () => { select: CREATOR_PROFILE_SELECT, }); - expect(result.map((r) => (r ? r.id : null))).toEqual(ids); + expect(result.map(r => (r ? r.id : null))).toEqual(ids); }); it('returns nulls for missing records preserving order', async () => { const ids = ['x', 'y']; - findMany.mockResolvedValueOnce([{ id: 'y', userId: 'u-y', handle: 'y', displayName: 'Y', bio: null, avatarUrl: null, perkSummary: null, isVerified: false, createdAt: new Date(), updatedAt: new Date() }]); + findMany.mockResolvedValueOnce([ + { + id: 'y', + userId: 'u-y', + handle: 'y', + displayName: 'Y', + bio: null, + avatarUrl: null, + perkSummary: null, + isVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); const result = await fetchCreatorProfilesByIds(ids); expect(findMany).toHaveBeenCalledTimes(1); - expect(result.map((r) => (r ? r.id : null))).toEqual([null, 'y']); + expect(result.map(r => (r ? r.id : null))).toEqual([null, 'y']); }); }); diff --git a/src/utils/creator-batch.utils.ts b/src/utils/creator-batch.utils.ts index cc81a00..38b78c5 100644 --- a/src/utils/creator-batch.utils.ts +++ b/src/utils/creator-batch.utils.ts @@ -31,9 +31,9 @@ export async function fetchCreatorProfilesByIds( }); const map = new Map(); - records.forEach((r) => map.set(r.id, r as CreatorProfile)); + records.forEach(r => map.set(r.id, r as CreatorProfile)); - return ids.map((id) => map.get(id) ?? null); + return ids.map(id => map.get(id) ?? null); } export { CREATOR_PROFILE_SELECT }; diff --git a/src/utils/creator-etag.utils.test.ts b/src/utils/creator-etag.utils.test.ts index 44a3654..4388590 100644 --- a/src/utils/creator-etag.utils.test.ts +++ b/src/utils/creator-etag.utils.test.ts @@ -5,8 +5,7 @@ import { CREATOR_ETAG_HEADER, } from './creator-etag.utils'; -const mockRes = () => - ({ set: jest.fn() }) as unknown as Response; +const mockRes = () => ({ set: jest.fn() }) as unknown as Response; describe('computeCreatorETag()', () => { it('returns a double-quoted 64-char hex string', () => { @@ -40,7 +39,7 @@ describe('attachCreatorETagHeader()', () => { attachCreatorETagHeader(res, { id: '1' }); expect(res.set).toHaveBeenCalledWith( CREATOR_ETAG_HEADER, - expect.stringMatching(/^"[0-9a-f]{64}"$/), + expect.stringMatching(/^"[0-9a-f]{64}"$/) ); }); diff --git a/src/utils/creator-etag.utils.ts b/src/utils/creator-etag.utils.ts index acac4a3..33374f3 100644 --- a/src/utils/creator-etag.utils.ts +++ b/src/utils/creator-etag.utils.ts @@ -13,9 +13,7 @@ export const CREATOR_ETAG_HEADER = 'ETag'; * @returns A quoted strong ETag string, e.g. `"abc123..."` */ export function computeCreatorETag(data: unknown): string { - const hash = createHash('sha256') - .update(JSON.stringify(data)) - .digest('hex'); + const hash = createHash('sha256').update(JSON.stringify(data)).digest('hex'); return `"${hash}"`; } @@ -37,7 +35,10 @@ export function computeCreatorETag(data: unknown): string { * // Pre-computed: * attachCreatorETagHeader(res, '"abc123"'); */ -export function attachCreatorETagHeader(res: Response, etagOrData: string | unknown): void { +export function attachCreatorETagHeader( + res: Response, + etagOrData: string | unknown +): void { const etag = typeof etagOrData === 'string' && /^"[^"]*"$/.test(etagOrData) ? etagOrData diff --git a/src/utils/creator-feed-cursor.utils.ts b/src/utils/creator-feed-cursor.utils.ts index c651e49..4fdfc71 100644 --- a/src/utils/creator-feed-cursor.utils.ts +++ b/src/utils/creator-feed-cursor.utils.ts @@ -9,15 +9,15 @@ import { decodeCursor, CursorChecksumError } from './cursor.utils'; * Shape of a decoded creator feed cursor payload. */ export interface CreatorFeedCursorPayload { - /** ISO timestamp used as the pagination anchor */ - createdAt: string; - /** Tiebreaker ID for stable ordering */ - id: string; + /** ISO timestamp used as the pagination anchor */ + createdAt: string; + /** Tiebreaker ID for stable ordering */ + id: string; } export type CreatorFeedCursorResult = - | { ok: true; payload: CreatorFeedCursorPayload } - | { ok: false; error: string }; + | { ok: true; payload: CreatorFeedCursorPayload } + | { ok: false; error: string }; /** * Decode and validate a creator feed cursor string. @@ -33,31 +33,31 @@ export type CreatorFeedCursorResult = * if (!result.ok) return sendValidationError(res, result.error); */ export function decodeCreatorFeedCursor(raw: unknown): CreatorFeedCursorResult { - if (raw === undefined || raw === null || raw === '') { - return { ok: false, error: 'Cursor is required' }; - } - - if (typeof raw !== 'string') { - return { ok: false, error: 'Cursor must be a string' }; - } - - let payload: CreatorFeedCursorPayload; - try { - payload = decodeCursor(raw); - } catch (err) { - const message = - err instanceof CursorChecksumError ? err.message : 'Invalid cursor'; - return { ok: false, error: message }; - } - - if ( - typeof payload.createdAt !== 'string' || - typeof payload.id !== 'string' || - !payload.createdAt || - !payload.id - ) { - return { ok: false, error: 'Cursor payload is missing required fields' }; - } - - return { ok: true, payload }; + if (raw === undefined || raw === null || raw === '') { + return { ok: false, error: 'Cursor is required' }; + } + + if (typeof raw !== 'string') { + return { ok: false, error: 'Cursor must be a string' }; + } + + let payload: CreatorFeedCursorPayload; + try { + payload = decodeCursor(raw); + } catch (err) { + const message = + err instanceof CursorChecksumError ? err.message : 'Invalid cursor'; + return { ok: false, error: message }; + } + + if ( + typeof payload.createdAt !== 'string' || + typeof payload.id !== 'string' || + !payload.createdAt || + !payload.id + ) { + return { ok: false, error: 'Cursor payload is missing required fields' }; + } + + return { ok: true, payload }; } diff --git a/src/utils/creator-id.utils.ts b/src/utils/creator-id.utils.ts index f0172f3..d8bee4c 100644 --- a/src/utils/creator-id.utils.ts +++ b/src/utils/creator-id.utils.ts @@ -3,23 +3,23 @@ import { validationError } from '../middlewares/error.middleware'; const POSITIVE_INTEGER_RE = /^\d+$/; export function parseCreatorId(raw: string): number { - if (!raw || typeof raw !== 'string') { - throw validationError('Creator ID is required'); - } + if (!raw || typeof raw !== 'string') { + throw validationError('Creator ID is required'); + } - const trimmed = raw.trim(); - if (!trimmed) { - throw validationError('Creator ID is required'); - } + const trimmed = raw.trim(); + if (!trimmed) { + throw validationError('Creator ID is required'); + } - if (!POSITIVE_INTEGER_RE.test(trimmed)) { - throw validationError('Creator ID must be a positive integer'); - } + if (!POSITIVE_INTEGER_RE.test(trimmed)) { + throw validationError('Creator ID must be a positive integer'); + } - const parsed = Number.parseInt(trimmed, 10); - if (parsed <= 0 || !Number.isFinite(parsed)) { - throw validationError('Creator ID must be a positive integer'); - } + const parsed = Number.parseInt(trimmed, 10); + if (parsed <= 0 || !Number.isFinite(parsed)) { + throw validationError('Creator ID must be a positive integer'); + } - return parsed; + return parsed; } diff --git a/src/utils/creator-public-query.util.ts b/src/utils/creator-public-query.util.ts index 8bca958..11d6f41 100644 --- a/src/utils/creator-public-query.util.ts +++ b/src/utils/creator-public-query.util.ts @@ -1,9 +1,9 @@ -import { CREATOR_PUBLIC_QUERY_KEYS } from "../constants/creator-public-query.constants"; +import { CREATOR_PUBLIC_QUERY_KEYS } from '../constants/creator-public-query.constants'; export const parseCreatorPublicQuery = (query: Record) => { - return { - creatorId: query[CREATOR_PUBLIC_QUERY_KEYS.CREATOR_ID], - creatorAddress: query[CREATOR_PUBLIC_QUERY_KEYS.CREATOR_ADDRESS], - username: query[CREATOR_PUBLIC_QUERY_KEYS.USERNAME], - }; + return { + creatorId: query[CREATOR_PUBLIC_QUERY_KEYS.CREATOR_ID], + creatorAddress: query[CREATOR_PUBLIC_QUERY_KEYS.CREATOR_ADDRESS], + username: query[CREATOR_PUBLIC_QUERY_KEYS.USERNAME], + }; }; diff --git a/src/utils/creator-read-metrics.utils.test.ts b/src/utils/creator-read-metrics.utils.test.ts index 561b0e3..89e44d1 100644 --- a/src/utils/creator-read-metrics.utils.test.ts +++ b/src/utils/creator-read-metrics.utils.test.ts @@ -21,11 +21,18 @@ describe('creator-read-metrics.utils', () => { expect(metrics.counters.totalServerErrors).toBe(1); expect(metrics.counters.byEndpoint.list.requests).toBe(1); expect(metrics.counters.byEndpoint.detail.serverErrors).toBe(1); - expect(metrics.counters.byEndpoint.list.histogram.buckets[0].count).toBe(0); - expect(metrics.counters.byEndpoint.list.histogram.buckets[1].count).toBe(1); + expect(metrics.counters.byEndpoint.list.histogram.buckets[0].count).toBe( + 0 + ); + expect(metrics.counters.byEndpoint.list.histogram.buckets[1].count).toBe( + 1 + ); const lastBucketIndex = metrics.counters.byEndpoint.detail.histogram.buckets.length - 1; - expect(metrics.counters.byEndpoint.detail.histogram.buckets[lastBucketIndex].count).toBe(1); + expect( + metrics.counters.byEndpoint.detail.histogram.buckets[lastBucketIndex] + .count + ).toBe(1); }); it('records timing when the response finishes', () => { @@ -55,6 +62,8 @@ describe('creator-read-metrics.utils', () => { expect(metrics.counters.byEndpoint.list.requests).toBe(1); expect(metrics.counters.byEndpoint.list.success).toBe(1); expect(metrics.counters.byEndpoint.list.histogram.count).toBe(1); - expect(metrics.counters.byEndpoint.list.histogram.sumMs).toBeGreaterThan(0); + expect(metrics.counters.byEndpoint.list.histogram.sumMs).toBeGreaterThan( + 0 + ); }); -}); \ No newline at end of file +}); diff --git a/src/utils/creator-read-metrics.utils.ts b/src/utils/creator-read-metrics.utils.ts index 694a342..35d3d27 100644 --- a/src/utils/creator-read-metrics.utils.ts +++ b/src/utils/creator-read-metrics.utils.ts @@ -2,7 +2,9 @@ import { RequestHandler } from 'express'; export type CreatorReadEndpoint = 'list' | 'detail' | 'holders'; -const DURATION_BUCKETS_MS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000] as const; +const DURATION_BUCKETS_MS = [ + 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, +] as const; export interface CreatorReadHistogramBucket { leMs: number | null; @@ -67,7 +69,9 @@ const registry: Record = { holders: createEndpointState(), }; -function classifyStatus(statusCode: number): 'success' | 'clientErrors' | 'serverErrors' { +function classifyStatus( + statusCode: number +): 'success' | 'clientErrors' | 'serverErrors' { if (statusCode >= 500) { return 'serverErrors'; } @@ -80,7 +84,9 @@ function classifyStatus(statusCode: number): 'success' | 'clientErrors' | 'serve } function getHistogramBucketIndex(durationMs: number): number { - const bucketIndex = DURATION_BUCKETS_MS.findIndex(limit => durationMs <= limit); + const bucketIndex = DURATION_BUCKETS_MS.findIndex( + limit => durationMs <= limit + ); return bucketIndex === -1 ? DURATION_BUCKETS_MS.length : bucketIndex; } @@ -98,7 +104,11 @@ export function recordCreatorReadMetric( entry.histogram.sumMs += durationMs; const bucketIndex = getHistogramBucketIndex(durationMs); - for (let index = bucketIndex; index < entry.histogram.buckets.length; index += 1) { + for ( + let index = bucketIndex; + index < entry.histogram.buckets.length; + index += 1 + ) { entry.histogram.buckets[index].count += 1; } } @@ -136,10 +146,22 @@ function snapshotEndpoint(entry: EndpointState): CreatorReadEndpointSnapshot { export function getCreatorReadMetrics(): CreatorReadMetricsSnapshot { return { counters: { - totalRequests: registry.list.requests + registry.detail.requests + registry.holders.requests, - totalSuccess: registry.list.success + registry.detail.success + registry.holders.success, - totalClientErrors: registry.list.clientErrors + registry.detail.clientErrors + registry.holders.clientErrors, - totalServerErrors: registry.list.serverErrors + registry.detail.serverErrors + registry.holders.serverErrors, + totalRequests: + registry.list.requests + + registry.detail.requests + + registry.holders.requests, + totalSuccess: + registry.list.success + + registry.detail.success + + registry.holders.success, + totalClientErrors: + registry.list.clientErrors + + registry.detail.clientErrors + + registry.holders.clientErrors, + totalServerErrors: + registry.list.serverErrors + + registry.detail.serverErrors + + registry.holders.serverErrors, byEndpoint: { list: snapshotEndpoint(registry.list), detail: snapshotEndpoint(registry.detail), @@ -153,4 +175,4 @@ export function resetCreatorReadMetrics(): void { registry.list = createEndpointState(); registry.detail = createEndpointState(); registry.holders = createEndpointState(); -} \ No newline at end of file +} diff --git a/src/utils/cursor-debug.utils.test.ts b/src/utils/cursor-debug.utils.test.ts index f3f54c0..b442605 100644 --- a/src/utils/cursor-debug.utils.test.ts +++ b/src/utils/cursor-debug.utils.test.ts @@ -1,11 +1,11 @@ jest.mock('./cursor.utils', () => ({ - decodeCursor: jest.fn(), - CursorChecksumError: class CursorChecksumError extends Error { - constructor(msg = 'Invalid cursor') { - super(msg); - this.name = 'CursorChecksumError'; - } - }, + decodeCursor: jest.fn(), + CursorChecksumError: class CursorChecksumError extends Error { + constructor(msg = 'Invalid cursor') { + super(msg); + this.name = 'CursorChecksumError'; + } + }, })); import { formatCursorForDebug } from './cursor-debug.utils'; @@ -14,57 +14,63 @@ import { decodeCursor, CursorChecksumError } from './cursor.utils'; const decodeMock = decodeCursor as jest.MockedFunction; beforeEach(() => { - decodeMock.mockReset(); + decodeMock.mockReset(); }); describe('formatCursorForDebug()', () => { - it('returns a string-keyed map for a valid cursor', () => { - decodeMock.mockReturnValue({ createdAt: '2024-01-01T00:00:00.000Z', id: 'abc123' }); + it('returns a string-keyed map for a valid cursor', () => { + decodeMock.mockReturnValue({ + createdAt: '2024-01-01T00:00:00.000Z', + id: 'abc123', + }); - const result = formatCursorForDebug('valid-cursor'); + const result = formatCursorForDebug('valid-cursor'); - expect(result).toEqual({ createdAt: '2024-01-01T00:00:00.000Z', id: 'abc123' }); - }); + expect(result).toEqual({ + createdAt: '2024-01-01T00:00:00.000Z', + id: 'abc123', + }); + }); - it('converts non-string values to strings', () => { - decodeMock.mockReturnValue({ page: 3, active: true } as any); + it('converts non-string values to strings', () => { + decodeMock.mockReturnValue({ page: 3, active: true } as any); - const result = formatCursorForDebug('valid-cursor'); + const result = formatCursorForDebug('valid-cursor'); - expect(result).toEqual({ page: '3', active: 'true' }); - }); + expect(result).toEqual({ page: '3', active: 'true' }); + }); - it('returns null for a tampered cursor (CursorChecksumError)', () => { - decodeMock.mockImplementation(() => { - throw new CursorChecksumError('Cursor checksum mismatch'); - }); + it('returns null for a tampered cursor (CursorChecksumError)', () => { + decodeMock.mockImplementation(() => { + throw new CursorChecksumError('Cursor checksum mismatch'); + }); - const result = formatCursorForDebug('tampered-cursor'); + const result = formatCursorForDebug('tampered-cursor'); - expect(result).toBeNull(); - }); + expect(result).toBeNull(); + }); - it('returns null for an empty string', () => { - expect(formatCursorForDebug('')).toBeNull(); - }); + it('returns null for an empty string', () => { + expect(formatCursorForDebug('')).toBeNull(); + }); - it('returns null for a non-string input', () => { - expect(formatCursorForDebug(null)).toBeNull(); - expect(formatCursorForDebug(undefined)).toBeNull(); - expect(formatCursorForDebug(42)).toBeNull(); - }); + it('returns null for a non-string input', () => { + expect(formatCursorForDebug(null)).toBeNull(); + expect(formatCursorForDebug(undefined)).toBeNull(); + expect(formatCursorForDebug(42)).toBeNull(); + }); - it('returns null when decoded payload is not a plain object', () => { - decodeMock.mockReturnValue(['not', 'an', 'object'] as any); + it('returns null when decoded payload is not a plain object', () => { + decodeMock.mockReturnValue(['not', 'an', 'object'] as any); - expect(formatCursorForDebug('array-cursor')).toBeNull(); - }); + expect(formatCursorForDebug('array-cursor')).toBeNull(); + }); - it('coerces null payload values to empty string', () => { - decodeMock.mockReturnValue({ id: 'abc', extra: null } as any); + it('coerces null payload values to empty string', () => { + decodeMock.mockReturnValue({ id: 'abc', extra: null } as any); - const result = formatCursorForDebug('cursor'); + const result = formatCursorForDebug('cursor'); - expect(result).toEqual({ id: 'abc', extra: '' }); - }); + expect(result).toEqual({ id: 'abc', extra: '' }); + }); }); diff --git a/src/utils/cursor-debug.utils.ts b/src/utils/cursor-debug.utils.ts index 8084960..c8158e3 100644 --- a/src/utils/cursor-debug.utils.ts +++ b/src/utils/cursor-debug.utils.ts @@ -15,28 +15,34 @@ import { decodeCursor, CursorChecksumError } from './cursor.utils'; * @example * logger.debug({ cursor: formatCursorForDebug(raw), msg: 'Processing cursor' }); */ -export function formatCursorForDebug(raw: unknown): Record | null { - if (typeof raw !== 'string' || raw === '') { - return null; - } +export function formatCursorForDebug( + raw: unknown +): Record | null { + if (typeof raw !== 'string' || raw === '') { + return null; + } - try { - const payload = decodeCursor>(raw); + try { + const payload = decodeCursor>(raw); - if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { - return null; - } + if ( + payload === null || + typeof payload !== 'object' || + Array.isArray(payload) + ) { + return null; + } - return Object.fromEntries( - Object.entries(payload).map(([key, value]) => [ - key, - value === null || value === undefined ? '' : String(value), - ]), - ); - } catch (err) { - if (err instanceof CursorChecksumError) { - return null; - } - return null; - } + return Object.fromEntries( + Object.entries(payload).map(([key, value]) => [ + key, + value === null || value === undefined ? '' : String(value), + ]) + ); + } catch (err) { + if (err instanceof CursorChecksumError) { + return null; + } + return null; + } } diff --git a/src/utils/cursor.utils.ts b/src/utils/cursor.utils.ts index 78647bc..1c365a6 100644 --- a/src/utils/cursor.utils.ts +++ b/src/utils/cursor.utils.ts @@ -34,7 +34,7 @@ export function encodeCursor(payload: T): string { const payloadStr = JSON.stringify(payload); const base64Payload = Buffer.from(payloadStr).toString('base64url'); const checksum = generateCursorChecksum(base64Payload); - + return `${base64Payload}.${checksum}`; } @@ -52,34 +52,38 @@ export function decodeCursor(cursor: string): T { const parts = cursor.split('.'); if (parts.length > 2) { - throw new CursorChecksumError('Invalid cursor format. Expected base64payload.checksum'); + throw new CursorChecksumError( + 'Invalid cursor format. Expected base64payload.checksum' + ); } const [base64Payload, providedChecksum] = parts; - + if (!base64Payload) { throw new CursorChecksumError('Cursor payload cannot be empty'); } - + // Backward compatibility: allow parsing without checksum if no dot was present if (providedChecksum !== undefined) { if (!providedChecksum) { - throw new CursorChecksumError('Cursor checksum cannot be empty if signature separator is present'); + throw new CursorChecksumError( + 'Cursor checksum cannot be empty if signature separator is present' + ); } const expectedChecksum = generateCursorChecksum(base64Payload); - + // Use timing-safe equal to prevent timing attacks comparing checksums let expectedBuffer: Buffer; let providedBuffer: Buffer; - + try { expectedBuffer = Buffer.from(expectedChecksum, 'hex'); providedBuffer = Buffer.from(providedChecksum, 'hex'); } catch { throw new CursorChecksumError('Invalid checksum format'); } - + if ( expectedBuffer.length !== providedBuffer.length || !crypto.timingSafeEqual(expectedBuffer, providedBuffer) @@ -94,7 +98,7 @@ export function decodeCursor(cursor: string): T { } catch { throw new CursorChecksumError('Failed to decode base64 payload'); } - + try { return JSON.parse(payloadStr) as T; } catch { diff --git a/src/utils/env-boolean.utils.test.ts b/src/utils/env-boolean.utils.test.ts index 532e735..9c41482 100644 --- a/src/utils/env-boolean.utils.test.ts +++ b/src/utils/env-boolean.utils.test.ts @@ -1,82 +1,72 @@ import { normalizeEnvBoolean, EnvBooleanParseError } from './env-boolean.utils'; describe('normalizeEnvBoolean', () => { - describe('true variants', () => { - it.each([ - ['true'], - ['True'], - ['TRUE'], - ['1'], - ['yes'], - ['Yes'], - ['YES'], - ])('returns true for "%s"', (value) => { - expect(normalizeEnvBoolean('MY_FLAG', value)).toBe(true); - }); + describe('true variants', () => { + it.each([['true'], ['True'], ['TRUE'], ['1'], ['yes'], ['Yes'], ['YES']])( + 'returns true for "%s"', + value => { + expect(normalizeEnvBoolean('MY_FLAG', value)).toBe(true); + } + ); - it('trims surrounding whitespace before parsing', () => { - expect(normalizeEnvBoolean('MY_FLAG', ' true ')).toBe(true); - }); - }); + it('trims surrounding whitespace before parsing', () => { + expect(normalizeEnvBoolean('MY_FLAG', ' true ')).toBe(true); + }); + }); - describe('false variants', () => { - it.each([ - ['false'], - ['False'], - ['FALSE'], - ['0'], - ['no'], - ['No'], - ['NO'], - ])('returns false for "%s"', (value) => { - expect(normalizeEnvBoolean('MY_FLAG', value)).toBe(false); - }); + describe('false variants', () => { + it.each([['false'], ['False'], ['FALSE'], ['0'], ['no'], ['No'], ['NO']])( + 'returns false for "%s"', + value => { + expect(normalizeEnvBoolean('MY_FLAG', value)).toBe(false); + } + ); - it('trims surrounding whitespace before parsing', () => { - expect(normalizeEnvBoolean('MY_FLAG', ' false ')).toBe(false); - }); - }); + it('trims surrounding whitespace before parsing', () => { + expect(normalizeEnvBoolean('MY_FLAG', ' false ')).toBe(false); + }); + }); - describe('unrecognized values', () => { - it.each([ - ['maybe'], - ['2'], - ['on'], - ['off'], - ['enabled'], - ['disabled'], - [''], - ['null'], - ['undefined'], - ])('throws EnvBooleanParseError for "%s"', (value) => { - expect(() => normalizeEnvBoolean('MY_FLAG', value)).toThrow( - EnvBooleanParseError - ); - }); + describe('unrecognized values', () => { + it.each([ + ['maybe'], + ['2'], + ['on'], + ['off'], + ['enabled'], + ['disabled'], + [''], + ['null'], + ['undefined'], + ])('throws EnvBooleanParseError for "%s"', value => { + expect(() => normalizeEnvBoolean('MY_FLAG', value)).toThrow( + EnvBooleanParseError + ); + }); - it('includes the env var name in the error message', () => { - expect(() => normalizeEnvBoolean('FEATURE_FLAG', 'maybe')).toThrow( - /FEATURE_FLAG/ - ); - }); + it('includes the env var name in the error message', () => { + expect(() => normalizeEnvBoolean('FEATURE_FLAG', 'maybe')).toThrow( + /FEATURE_FLAG/ + ); + }); - it('includes the raw value in the error message', () => { - expect(() => normalizeEnvBoolean('FEATURE_FLAG', 'banana')).toThrow( - /banana/ - ); - }); + it('includes the raw value in the error message', () => { + expect(() => normalizeEnvBoolean('FEATURE_FLAG', 'banana')).toThrow( + /banana/ + ); + }); - it('exposes varName and rawValue on the thrown error', () => { - let caught: unknown; - try { - normalizeEnvBoolean('MY_VAR', 'oops'); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(EnvBooleanParseError); - const error = caught as EnvBooleanParseError; - expect(error.varName).toBe('MY_VAR'); - expect(error.rawValue).toBe('oops'); - }); - }); + it('exposes varName and rawValue on the thrown error', () => { + let caught: unknown; + try { + normalizeEnvBoolean('MY_VAR', 'oops'); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(EnvBooleanParseError); + const error = caught as EnvBooleanParseError; + expect(error.varName).toBe('MY_VAR'); + expect(error.rawValue).toBe('oops'); + }); + }); }); diff --git a/src/utils/env-boolean.utils.ts b/src/utils/env-boolean.utils.ts index c0debbb..45e8999 100644 --- a/src/utils/env-boolean.utils.ts +++ b/src/utils/env-boolean.utils.ts @@ -14,18 +14,18 @@ const TRUE_VALUES = new Set(['true', '1', 'yes']); const FALSE_VALUES = new Set(['false', '0', 'no']); export class EnvBooleanParseError extends Error { - public readonly varName: string; - public readonly rawValue: string; + public readonly varName: string; + public readonly rawValue: string; - constructor(varName: string, rawValue: string) { - super( - `Cannot parse env var "${varName}" as boolean: received "${rawValue}". ` + - `Accepted values: "true", "false", "1", "0", "yes", "no".` - ); - this.name = 'EnvBooleanParseError'; - this.varName = varName; - this.rawValue = rawValue; - } + constructor(varName: string, rawValue: string) { + super( + `Cannot parse env var "${varName}" as boolean: received "${rawValue}". ` + + `Accepted values: "true", "false", "1", "0", "yes", "no".` + ); + this.name = 'EnvBooleanParseError'; + this.varName = varName; + this.rawValue = rawValue; + } } /** @@ -37,10 +37,10 @@ export class EnvBooleanParseError extends Error { * @throws {EnvBooleanParseError} when the value is not a recognized boolean string */ export function normalizeEnvBoolean(varName: string, value: string): boolean { - const normalized = value.trim().toLowerCase(); + const normalized = value.trim().toLowerCase(); - if (TRUE_VALUES.has(normalized)) return true; - if (FALSE_VALUES.has(normalized)) return false; + if (TRUE_VALUES.has(normalized)) return true; + if (FALSE_VALUES.has(normalized)) return false; - throw new EnvBooleanParseError(varName, value); + throw new EnvBooleanParseError(varName, value); } diff --git a/src/utils/filter-parse-metrics.utils.ts b/src/utils/filter-parse-metrics.utils.ts index dc5a2d3..dc4251f 100644 --- a/src/utils/filter-parse-metrics.utils.ts +++ b/src/utils/filter-parse-metrics.utils.ts @@ -5,19 +5,22 @@ * monitor malformed or malicious query patterns without external dependencies. */ -export type FilterParseErrorCategory = 'unknown_key' | 'invalid_value' | 'schema_error'; +export type FilterParseErrorCategory = + | 'unknown_key' + | 'invalid_value' + | 'schema_error'; export interface FilterParseErrorEntry { - route: string; - category: FilterParseErrorCategory; - count: number; - lastOccurred: string; + route: string; + category: FilterParseErrorCategory; + count: number; + lastOccurred: string; } const counters = new Map(); function key(route: string, category: FilterParseErrorCategory): string { - return `${route}:${category}`; + return `${route}:${category}`; } /** @@ -27,22 +30,22 @@ function key(route: string, category: FilterParseErrorCategory): string { * @param category - The type of parse error */ export function incrementFilterParseError( - route: string, - category: FilterParseErrorCategory + route: string, + category: FilterParseErrorCategory ): void { - const k = key(route, category); - const existing = counters.get(k); - if (existing) { - existing.count += 1; - existing.lastOccurred = new Date().toISOString(); - } else { - counters.set(k, { - route, - category, - count: 1, - lastOccurred: new Date().toISOString(), - }); - } + const k = key(route, category); + const existing = counters.get(k); + if (existing) { + existing.count += 1; + existing.lastOccurred = new Date().toISOString(); + } else { + counters.set(k, { + route, + category, + count: 1, + lastOccurred: new Date().toISOString(), + }); + } } /** @@ -51,7 +54,7 @@ export function incrementFilterParseError( * @returns Array of error entries with route, category, count, and last occurrence */ export function getFilterParseErrors(): FilterParseErrorEntry[] { - return Array.from(counters.values()); + return Array.from(counters.values()); } /** @@ -59,5 +62,5 @@ export function getFilterParseErrors(): FilterParseErrorEntry[] { * Primarily for testing. */ export function resetFilterParseMetrics(): void { - counters.clear(); + counters.clear(); } diff --git a/src/utils/filter-whitelist.utils.ts b/src/utils/filter-whitelist.utils.ts index 3fa4240..44ca543 100644 --- a/src/utils/filter-whitelist.utils.ts +++ b/src/utils/filter-whitelist.utils.ts @@ -16,16 +16,18 @@ * // throws Error: Unsupported filter key(s): foo */ export function rejectUnknownKeys( - allowedKeys: T, - raw: Record, - label?: string + allowedKeys: T, + raw: Record, + label?: string ): void { - const unsupported = Object.keys(raw).filter( - key => !(allowedKeys as readonly string[]).includes(key) - ); + const unsupported = Object.keys(raw).filter( + key => !(allowedKeys as readonly string[]).includes(key) + ); - if (unsupported.length > 0) { - const prefix = label ? `Unsupported ${label} key(s)` : 'Unsupported filter key(s)'; - throw new Error(`${prefix}: ${unsupported.join(', ')}`); - } + if (unsupported.length > 0) { + const prefix = label + ? `Unsupported ${label} key(s)` + : 'Unsupported filter key(s)'; + throw new Error(`${prefix}: ${unsupported.join(', ')}`); + } } diff --git a/src/utils/hash-request-body.utils.ts b/src/utils/hash-request-body.utils.ts index 7df0f93..2ad416c 100644 --- a/src/utils/hash-request-body.utils.ts +++ b/src/utils/hash-request-body.utils.ts @@ -1,27 +1,28 @@ import crypto from 'crypto'; function stableStringify(value: unknown): string { - if (value === null) return 'null'; - if (value === undefined) return 'undefined'; - if (typeof value === 'string') return JSON.stringify(value); - if (typeof value === 'number') return JSON.stringify(value); - if (typeof value === 'boolean') return JSON.stringify(value); - if (typeof value === 'bigint') return JSON.stringify(value.toString()); - if (Array.isArray(value)) { - const items = value.map(item => stableStringify(item)); - return `[${items.join(',')}]`; - } - if (typeof value === 'object') { - const keys = Object.keys(value).sort(); - const entries = keys.map( - key => `${JSON.stringify(key)}:${stableStringify((value as Record)[key])}`, - ); - return `{${entries.join(',')}}`; - } - return JSON.stringify(value); + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number') return JSON.stringify(value); + if (typeof value === 'boolean') return JSON.stringify(value); + if (typeof value === 'bigint') return JSON.stringify(value.toString()); + if (Array.isArray(value)) { + const items = value.map(item => stableStringify(item)); + return `[${items.join(',')}]`; + } + if (typeof value === 'object') { + const keys = Object.keys(value).sort(); + const entries = keys.map( + key => + `${JSON.stringify(key)}:${stableStringify((value as Record)[key])}` + ); + return `{${entries.join(',')}}`; + } + return JSON.stringify(value); } export function hashRequestBody(body: unknown): string { - const normalized = stableStringify(body); - return crypto.createHash('sha256').update(normalized, 'utf8').digest('hex'); + const normalized = stableStringify(body); + return crypto.createHash('sha256').update(normalized, 'utf8').digest('hex'); } diff --git a/src/utils/held-since.utils.test.ts b/src/utils/held-since.utils.test.ts index 654d59f..c97494c 100644 --- a/src/utils/held-since.utils.test.ts +++ b/src/utils/held-since.utils.test.ts @@ -3,71 +3,88 @@ import { getHeldSince, TradeEvent } from './held-since.utils'; const WALLET = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const CREATOR = 'creator-abc'; -function makeBuy(timestamp: Date, wallet = WALLET, creator = CREATOR): TradeEvent { - return { walletAddress: wallet, creatorId: creator, type: 'buy', timestamp }; +function makeBuy( + timestamp: Date, + wallet = WALLET, + creator = CREATOR +): TradeEvent { + return { walletAddress: wallet, creatorId: creator, type: 'buy', timestamp }; } -function makeSell(timestamp: Date, wallet = WALLET, creator = CREATOR): TradeEvent { - return { walletAddress: wallet, creatorId: creator, type: 'sell', timestamp }; +function makeSell( + timestamp: Date, + wallet = WALLET, + creator = CREATOR +): TradeEvent { + return { + walletAddress: wallet, + creatorId: creator, + type: 'sell', + timestamp, + }; } describe('getHeldSince', () => { - it('returns null when the event list is empty', () => { - expect(getHeldSince(WALLET, CREATOR, [])).toBeNull(); - }); + it('returns null when the event list is empty', () => { + expect(getHeldSince(WALLET, CREATOR, [])).toBeNull(); + }); - it('returns null when there are no buy events for the wallet/creator pair', () => { - const events: TradeEvent[] = [makeSell(new Date('2026-01-01'))]; - expect(getHeldSince(WALLET, CREATOR, events)).toBeNull(); - }); + it('returns null when there are no buy events for the wallet/creator pair', () => { + const events: TradeEvent[] = [makeSell(new Date('2026-01-01'))]; + expect(getHeldSince(WALLET, CREATOR, events)).toBeNull(); + }); - it('returns the timestamp of a single buy event', () => { - const ts = new Date('2026-03-15T10:00:00Z'); - expect(getHeldSince(WALLET, CREATOR, [makeBuy(ts)])).toEqual(ts); - }); + it('returns the timestamp of a single buy event', () => { + const ts = new Date('2026-03-15T10:00:00Z'); + expect(getHeldSince(WALLET, CREATOR, [makeBuy(ts)])).toEqual(ts); + }); - it('returns the earliest timestamp when multiple buy events exist', () => { - const early = new Date('2026-01-01T00:00:00Z'); - const late = new Date('2026-06-01T00:00:00Z'); - const events = [makeBuy(late), makeBuy(early)]; - expect(getHeldSince(WALLET, CREATOR, events)).toEqual(early); - }); + it('returns the earliest timestamp when multiple buy events exist', () => { + const early = new Date('2026-01-01T00:00:00Z'); + const late = new Date('2026-06-01T00:00:00Z'); + const events = [makeBuy(late), makeBuy(early)]; + expect(getHeldSince(WALLET, CREATOR, events)).toEqual(early); + }); - it('ignores sell events when finding the earliest buy', () => { - const sellTs = new Date('2025-12-01T00:00:00Z'); - const buyTs = new Date('2026-02-01T00:00:00Z'); - const events = [makeSell(sellTs), makeBuy(buyTs)]; - expect(getHeldSince(WALLET, CREATOR, events)).toEqual(buyTs); - }); + it('ignores sell events when finding the earliest buy', () => { + const sellTs = new Date('2025-12-01T00:00:00Z'); + const buyTs = new Date('2026-02-01T00:00:00Z'); + const events = [makeSell(sellTs), makeBuy(buyTs)]; + expect(getHeldSince(WALLET, CREATOR, events)).toEqual(buyTs); + }); - it('filters by walletAddress — ignores buys from other wallets', () => { - const otherWallet = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA'; - const events = [makeBuy(new Date('2026-01-01'), otherWallet)]; - expect(getHeldSince(WALLET, CREATOR, events)).toBeNull(); - }); + it('filters by walletAddress — ignores buys from other wallets', () => { + const otherWallet = + 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA'; + const events = [makeBuy(new Date('2026-01-01'), otherWallet)]; + expect(getHeldSince(WALLET, CREATOR, events)).toBeNull(); + }); - it('filters by creatorId — ignores buys for other creators', () => { - const events = [makeBuy(new Date('2026-01-01'), WALLET, 'creator-other')]; - expect(getHeldSince(WALLET, CREATOR, events)).toBeNull(); - }); + it('filters by creatorId — ignores buys for other creators', () => { + const events = [makeBuy(new Date('2026-01-01'), WALLET, 'creator-other')]; + expect(getHeldSince(WALLET, CREATOR, events)).toBeNull(); + }); - it('returns the correct earliest among many buys for the same wallet/creator', () => { - const ts1 = new Date('2026-03-01T00:00:00Z'); - const ts2 = new Date('2026-01-01T00:00:00Z'); - const ts3 = new Date('2026-05-01T00:00:00Z'); - const events = [makeBuy(ts1), makeBuy(ts2), makeBuy(ts3)]; - expect(getHeldSince(WALLET, CREATOR, events)).toEqual(ts2); - }); + it('returns the correct earliest among many buys for the same wallet/creator', () => { + const ts1 = new Date('2026-03-01T00:00:00Z'); + const ts2 = new Date('2026-01-01T00:00:00Z'); + const ts3 = new Date('2026-05-01T00:00:00Z'); + const events = [makeBuy(ts1), makeBuy(ts2), makeBuy(ts3)]; + expect(getHeldSince(WALLET, CREATOR, events)).toEqual(ts2); + }); - it('handles mixed wallets and creators — returns earliest for the matching pair only', () => { - const matchTs = new Date('2026-06-01T00:00:00Z'); - const otherWalletTs = new Date('2026-01-01T00:00:00Z'); - const otherCreatorTs = new Date('2026-02-01T00:00:00Z'); - const events = [ - makeBuy(matchTs), - makeBuy(otherWalletTs, 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCD'), - makeBuy(otherCreatorTs, WALLET, 'creator-other'), - ]; - expect(getHeldSince(WALLET, CREATOR, events)).toEqual(matchTs); - }); + it('handles mixed wallets and creators — returns earliest for the matching pair only', () => { + const matchTs = new Date('2026-06-01T00:00:00Z'); + const otherWalletTs = new Date('2026-01-01T00:00:00Z'); + const otherCreatorTs = new Date('2026-02-01T00:00:00Z'); + const events = [ + makeBuy(matchTs), + makeBuy( + otherWalletTs, + 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCD' + ), + makeBuy(otherCreatorTs, WALLET, 'creator-other'), + ]; + expect(getHeldSince(WALLET, CREATOR, events)).toEqual(matchTs); + }); }); diff --git a/src/utils/held-since.utils.ts b/src/utils/held-since.utils.ts index 30f5f80..b9dafac 100644 --- a/src/utils/held-since.utils.ts +++ b/src/utils/held-since.utils.ts @@ -1,8 +1,8 @@ export interface TradeEvent { - walletAddress: string; - creatorId: string; - type: 'buy' | 'sell'; - timestamp: Date; + walletAddress: string; + creatorId: string; + type: 'buy' | 'sell'; + timestamp: Date; } /** @@ -10,20 +10,20 @@ export interface TradeEvent { * creator, or null if no matching buy event exists. */ export function getHeldSince( - walletAddress: string, - creatorId: string, - tradeEvents: TradeEvent[] + walletAddress: string, + creatorId: string, + tradeEvents: TradeEvent[] ): Date | null { - const buyTimestamps = tradeEvents - .filter( - (e) => - e.type === 'buy' && - e.walletAddress === walletAddress && - e.creatorId === creatorId - ) - .map((e) => e.timestamp.getTime()); + const buyTimestamps = tradeEvents + .filter( + e => + e.type === 'buy' && + e.walletAddress === walletAddress && + e.creatorId === creatorId + ) + .map(e => e.timestamp.getTime()); - if (buyTimestamps.length === 0) return null; + if (buyTimestamps.length === 0) return null; - return new Date(Math.min(...buyTimestamps)); + return new Date(Math.min(...buyTimestamps)); } diff --git a/src/utils/indexer-dedupe.utils.test.ts b/src/utils/indexer-dedupe.utils.test.ts index 05ab689..e40dea1 100644 --- a/src/utils/indexer-dedupe.utils.test.ts +++ b/src/utils/indexer-dedupe.utils.test.ts @@ -2,50 +2,50 @@ import { strict as assert } from 'assert'; import { dedupeChainEvents, ChainEvent } from './indexer-dedupe.utils'; function run() { - console.log('Running indexer-dedupe.utils tests...'); + console.log('Running indexer-dedupe.utils tests...'); - // Case 1: Unique events are all kept - { - const events: ChainEvent[] = [ - { txHash: '0x1', eventIndex: 0 }, - { txHash: '0x1', eventIndex: 1 }, - { txHash: '0x2', eventIndex: 0 }, - ]; - const deduped = dedupeChainEvents(events); - assert.equal(deduped.length, 3, 'Should keep all unique events'); - } + // Case 1: Unique events are all kept + { + const events: ChainEvent[] = [ + { txHash: '0x1', eventIndex: 0 }, + { txHash: '0x1', eventIndex: 1 }, + { txHash: '0x2', eventIndex: 0 }, + ]; + const deduped = dedupeChainEvents(events); + assert.equal(deduped.length, 3, 'Should keep all unique events'); + } - // Case 2: Duplicate events are removed - { - const events: ChainEvent[] = [ - { txHash: '0x1', eventIndex: 0 }, - { txHash: '0x1', eventIndex: 0 }, // Duplicate - { txHash: '0x2', eventIndex: 0 }, - ]; - const deduped = dedupeChainEvents(events); - assert.equal(deduped.length, 2, 'Should remove duplicate events'); - assert.equal(deduped[0].txHash, '0x1'); - assert.equal(deduped[1].txHash, '0x2'); - } + // Case 2: Duplicate events are removed + { + const events: ChainEvent[] = [ + { txHash: '0x1', eventIndex: 0 }, + { txHash: '0x1', eventIndex: 0 }, // Duplicate + { txHash: '0x2', eventIndex: 0 }, + ]; + const deduped = dedupeChainEvents(events); + assert.equal(deduped.length, 2, 'Should remove duplicate events'); + assert.equal(deduped[0].txHash, '0x1'); + assert.equal(deduped[1].txHash, '0x2'); + } - // Case 3: Empty list - { - const deduped = dedupeChainEvents([]); - assert.equal(deduped.length, 0, 'Should handle empty list'); - } + // Case 3: Empty list + { + const deduped = dedupeChainEvents([]); + assert.equal(deduped.length, 0, 'Should handle empty list'); + } - // Case 4: Events with extra data are preserved - { - const events = [ - { txHash: '0x1', eventIndex: 0, data: 'foo' }, - { txHash: '0x1', eventIndex: 0, data: 'bar' }, // Duplicate txHash/index but different data - ]; - const deduped = dedupeChainEvents(events); - assert.equal(deduped.length, 1, 'Should dedupe regardless of extra data'); - assert.equal(deduped[0].data, 'foo', 'Should keep the first occurrence'); - } + // Case 4: Events with extra data are preserved + { + const events = [ + { txHash: '0x1', eventIndex: 0, data: 'foo' }, + { txHash: '0x1', eventIndex: 0, data: 'bar' }, // Duplicate txHash/index but different data + ]; + const deduped = dedupeChainEvents(events); + assert.equal(deduped.length, 1, 'Should dedupe regardless of extra data'); + assert.equal(deduped[0].data, 'foo', 'Should keep the first occurrence'); + } - console.log('indexer-dedupe.utils tests passed'); + console.log('indexer-dedupe.utils tests passed'); } run(); diff --git a/src/utils/indexer-dedupe.utils.ts b/src/utils/indexer-dedupe.utils.ts index d79df19..b40fb55 100644 --- a/src/utils/indexer-dedupe.utils.ts +++ b/src/utils/indexer-dedupe.utils.ts @@ -2,32 +2,32 @@ * Interface representing a minimal chain event for deduplication. */ export interface ChainEvent { - /** Transaction hash (unique across the chain) */ - txHash: string; - /** Index of the event within the transaction */ - eventIndex: number; - /** Optional ledger/block number */ - ledger?: number; - [key: string]: any; + /** Transaction hash (unique across the chain) */ + txHash: string; + /** Index of the event within the transaction */ + eventIndex: number; + /** Optional ledger/block number */ + ledger?: number; + [key: string]: any; } /** * Dedupes a list of chain events based on transaction hash and event index. - * + * * This ensures that if the same event is received multiple times in a batch * (e.g. due to overlapping ingestion windows), it is only processed once. - * + * * @param events - The list of events to dedupe. * @returns A new array containing only unique events. */ export function dedupeChainEvents(events: T[]): T[] { - const seen = new Set(); - return events.filter((event) => { - const key = `${event.txHash}:${event.eventIndex}`; - if (seen.has(key)) { - return false; - } - seen.add(key); - return true; - }); + const seen = new Set(); + return events.filter(event => { + const key = `${event.txHash}:${event.eventIndex}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); } diff --git a/src/utils/indexer-dlq.utils.ts b/src/utils/indexer-dlq.utils.ts index 3d17f1d..9dd2da2 100644 --- a/src/utils/indexer-dlq.utils.ts +++ b/src/utils/indexer-dlq.utils.ts @@ -2,44 +2,44 @@ import { prisma } from './prisma.utils'; import { setQueueDepth } from './queue-metrics.utils'; export interface MoveToDLQParams { - jobType: string; - payload: any; - retryCount: number; - failureReason: string; - errorDetails?: string; + jobType: string; + payload: any; + retryCount: number; + failureReason: string; + errorDetails?: string; } /** * Moves a failed indexing job to the Dead-Letter Queue (DLQ). - * + * * Call this when a job has exhausted its retry attempts or encountered * a terminal error that requires manual intervention. */ export async function moveToDLQ(params: MoveToDLQParams) { - return await prisma.indexerDLQ.create({ - data: { - jobType: params.jobType, - payload: params.payload, - retryCount: params.retryCount, - failureReason: params.failureReason, - errorDetails: params.errorDetails, - }, - }); + return await prisma.indexerDLQ.create({ + data: { + jobType: params.jobType, + payload: params.payload, + retryCount: params.retryCount, + failureReason: params.failureReason, + errorDetails: params.errorDetails, + }, + }); } /** * Retrieves the current depth of the DLQ for a specific job type. */ export async function getDLQDepth(jobType?: string) { - return await prisma.indexerDLQ.count({ - where: jobType ? { jobType } : undefined, - }); + return await prisma.indexerDLQ.count({ + where: jobType ? { jobType } : undefined, + }); } /** * Syncs the current DLQ depth from the database to the in-process metrics registry. */ export async function syncDLQMetrics() { - const depth = await getDLQDepth(); - setQueueDepth('indexer', 'dlq', depth); + const depth = await getDLQDepth(); + setQueueDepth('indexer', 'dlq', depth); } diff --git a/src/utils/indexer-event-processor.utils.test.ts b/src/utils/indexer-event-processor.utils.test.ts index 94ae3be..5a34ff5 100644 --- a/src/utils/indexer-event-processor.utils.test.ts +++ b/src/utils/indexer-event-processor.utils.test.ts @@ -1,87 +1,99 @@ import { logger } from './logger.utils'; import { - getChainEventId, - processIndexerChainEvent, - processIndexerChainEvents, - IndexerChainEvent, + getChainEventId, + processIndexerChainEvent, + processIndexerChainEvents, + IndexerChainEvent, } from './indexer-event-processor.utils'; jest.mock('./logger.utils', () => ({ - logger: { - info: jest.fn(), - }, + logger: { + info: jest.fn(), + }, })); const infoMock = logger.info as jest.Mock; -function makeEvent(overrides: Partial = {}): IndexerChainEvent { - return { - txHash: '0xabc123', - eventIndex: 0, - eventType: 'CREATOR_REGISTERED', - ...overrides, - }; +function makeEvent( + overrides: Partial = {} +): IndexerChainEvent { + return { + txHash: '0xabc123', + eventIndex: 0, + eventType: 'CREATOR_REGISTERED', + ...overrides, + }; } describe('indexer-event-processor.utils', () => { - beforeEach(() => { - infoMock.mockClear(); - }); + beforeEach(() => { + infoMock.mockClear(); + }); - describe('getChainEventId', () => { - it('combines txHash and eventIndex', () => { - expect(getChainEventId(makeEvent({ txHash: '0xdead', eventIndex: 3 }))).toBe( - '0xdead:3' - ); - }); - }); + describe('getChainEventId', () => { + it('combines txHash and eventIndex', () => { + expect( + getChainEventId(makeEvent({ txHash: '0xdead', eventIndex: 3 })) + ).toBe('0xdead:3'); + }); + }); - describe('processIndexerChainEvent', () => { - it('emits one structured log after the handler completes', async () => { - const event = makeEvent(); - const handler = jest.fn().mockResolvedValue(undefined); + describe('processIndexerChainEvent', () => { + it('emits one structured log after the handler completes', async () => { + const event = makeEvent(); + const handler = jest.fn().mockResolvedValue(undefined); - await processIndexerChainEvent(event, handler); + await processIndexerChainEvent(event, handler); - expect(handler).toHaveBeenCalledWith(event); - expect(infoMock).toHaveBeenCalledTimes(1); - expect(infoMock).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'indexer_event_processed', - eventType: 'CREATOR_REGISTERED', - eventId: '0xabc123:0', - txHash: '0xabc123', - eventIndex: 0, - elapsedMs: expect.any(Number), - }), - 'Indexer chain event processed' - ); - }); + expect(handler).toHaveBeenCalledWith(event); + expect(infoMock).toHaveBeenCalledTimes(1); + expect(infoMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'indexer_event_processed', + eventType: 'CREATOR_REGISTERED', + eventId: '0xabc123:0', + txHash: '0xabc123', + eventIndex: 0, + elapsedMs: expect.any(Number), + }), + 'Indexer chain event processed' + ); + }); - it('does not emit a log when the handler throws', async () => { - const handler = jest.fn().mockRejectedValue(new Error('handler failed')); + it('does not emit a log when the handler throws', async () => { + const handler = jest + .fn() + .mockRejectedValue(new Error('handler failed')); - await expect( - processIndexerChainEvent(makeEvent(), handler) - ).rejects.toThrow('handler failed'); + await expect( + processIndexerChainEvent(makeEvent(), handler) + ).rejects.toThrow('handler failed'); - expect(infoMock).not.toHaveBeenCalled(); - }); - }); + expect(infoMock).not.toHaveBeenCalled(); + }); + }); - describe('processIndexerChainEvents', () => { - it('dedupes events and logs once per unique event', async () => { - const events: IndexerChainEvent[] = [ - makeEvent({ txHash: '0x1', eventIndex: 0, eventType: 'KEY_BOUGHT' }), - makeEvent({ txHash: '0x1', eventIndex: 0, eventType: 'KEY_BOUGHT' }), - makeEvent({ txHash: '0x1', eventIndex: 1, eventType: 'KEY_SOLD' }), - ]; - const handler = jest.fn().mockResolvedValue(undefined); + describe('processIndexerChainEvents', () => { + it('dedupes events and logs once per unique event', async () => { + const events: IndexerChainEvent[] = [ + makeEvent({ + txHash: '0x1', + eventIndex: 0, + eventType: 'KEY_BOUGHT', + }), + makeEvent({ + txHash: '0x1', + eventIndex: 0, + eventType: 'KEY_BOUGHT', + }), + makeEvent({ txHash: '0x1', eventIndex: 1, eventType: 'KEY_SOLD' }), + ]; + const handler = jest.fn().mockResolvedValue(undefined); - await processIndexerChainEvents(events, handler); + await processIndexerChainEvents(events, handler); - expect(handler).toHaveBeenCalledTimes(2); - expect(infoMock).toHaveBeenCalledTimes(2); - }); - }); + expect(handler).toHaveBeenCalledTimes(2); + expect(infoMock).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/src/utils/indexer-event-processor.utils.ts b/src/utils/indexer-event-processor.utils.ts index 125810b..8c87ede 100644 --- a/src/utils/indexer-event-processor.utils.ts +++ b/src/utils/indexer-event-processor.utils.ts @@ -6,15 +6,15 @@ import { elapsedMs, startTimer } from './monotonic-clock.utils'; * Minimal chain event shape required for indexer processing and logging. */ export interface IndexerChainEvent extends ChainEvent { - /** Domain event type (e.g. CREATOR_REGISTERED, KEY_BOUGHT). */ - eventType: string; + /** Domain event type (e.g. CREATOR_REGISTERED, KEY_BOUGHT). */ + eventType: string; } /** * Stable identifier for a chain event, used for deduplication and log correlation. */ export function getChainEventId(event: ChainEvent): string { - return `${event.txHash}:${event.eventIndex}`; + return `${event.txHash}:${event.eventIndex}`; } /** @@ -25,26 +25,26 @@ export function getChainEventId(event: ChainEvent): string { * monotonic clock. */ export async function processIndexerChainEvent( - event: T, - handler: (event: T) => Promise + event: T, + handler: (event: T) => Promise ): Promise { - const timer = startTimer(); - const eventId = getChainEventId(event); + const timer = startTimer(); + const eventId = getChainEventId(event); - await handler(event); + await handler(event); - logger.info( - { - type: 'indexer_event_processed', - eventType: event.eventType, - eventId, - txHash: event.txHash, - eventIndex: event.eventIndex, - ledger: event.ledger, - elapsedMs: elapsedMs(timer), - }, - 'Indexer chain event processed' - ); + logger.info( + { + type: 'indexer_event_processed', + eventType: event.eventType, + eventId, + txHash: event.txHash, + eventIndex: event.eventIndex, + ledger: event.ledger, + elapsedMs: elapsedMs(timer), + }, + 'Indexer chain event processed' + ); } /** @@ -53,12 +53,12 @@ export async function processIndexerChainEvent( * Each event emits one structured log entry via {@link processIndexerChainEvent}. */ export async function processIndexerChainEvents( - events: T[], - handler: (event: T) => Promise + events: T[], + handler: (event: T) => Promise ): Promise { - const uniqueEvents = dedupeChainEvents(events); + const uniqueEvents = dedupeChainEvents(events); - for (const event of uniqueEvents) { - await processIndexerChainEvent(event, handler); - } + for (const event of uniqueEvents) { + await processIndexerChainEvent(event, handler); + } } diff --git a/src/utils/indexer-flags-startup-check.utils.test.ts b/src/utils/indexer-flags-startup-check.utils.test.ts index 238bec3..b656b7f 100644 --- a/src/utils/indexer-flags-startup-check.utils.test.ts +++ b/src/utils/indexer-flags-startup-check.utils.test.ts @@ -14,7 +14,9 @@ import { validateIndexerFeatureFlags, } from './indexer-flags-startup-check.utils'; -function makeConfig(overrides: Partial = {}): IndexerFlagsConfig { +function makeConfig( + overrides: Partial = {} +): IndexerFlagsConfig { return { ENABLE_INDEXER_DEDUPE: true, ENABLE_INDEXER_DLQ: true, @@ -32,7 +34,9 @@ describe('validateIndexerFeatureFlags()', () => { it('rejects a jitter factor below zero', () => { try { - validateIndexerFeatureFlags(makeConfig({ INDEXER_JITTER_FACTOR: -0.5 })); + validateIndexerFeatureFlags( + makeConfig({ INDEXER_JITTER_FACTOR: -0.5 }) + ); fail('expected to throw'); } catch (err) { expect(err).toBeInstanceOf(IndexerFlagsConfigError); @@ -119,15 +123,15 @@ describe('validateIndexerFeatureFlags()', () => { fail('expected to throw'); } catch (err) { expect(err).toBeInstanceOf(IndexerFlagsConfigError); - expect((err as IndexerFlagsConfigError).issues.length).toBeGreaterThanOrEqual(3); + expect( + (err as IndexerFlagsConfigError).issues.length + ).toBeGreaterThanOrEqual(3); } }); it('error message lists every issue with bullet prefixes', () => { try { - validateIndexerFeatureFlags( - makeConfig({ INDEXER_JITTER_FACTOR: 9 }) - ); + validateIndexerFeatureFlags(makeConfig({ INDEXER_JITTER_FACTOR: 9 })); fail('expected to throw'); } catch (err) { expect((err as Error).message).toMatch( diff --git a/src/utils/indexer-flags-startup-check.utils.ts b/src/utils/indexer-flags-startup-check.utils.ts index e601eec..d11772e 100644 --- a/src/utils/indexer-flags-startup-check.utils.ts +++ b/src/utils/indexer-flags-startup-check.utils.ts @@ -35,9 +35,7 @@ export class IndexerFlagsConfigError extends Error { * issues — operators see every problem at once instead of fixing them one * boot at a time. */ -export function validateIndexerFeatureFlags( - config: IndexerFlagsConfig -): void { +export function validateIndexerFeatureFlags(config: IndexerFlagsConfig): void { const issues: string[] = []; if ( @@ -68,10 +66,7 @@ export function validateIndexerFeatureFlags( ); } - if ( - !config.ENABLE_INDEXER_DEDUPE && - config.ENABLE_INDEXER_DLQ - ) { + if (!config.ENABLE_INDEXER_DEDUPE && config.ENABLE_INDEXER_DLQ) { issues.push( 'ENABLE_INDEXER_DLQ is on but ENABLE_INDEXER_DEDUPE is off — the DLQ relies on dedupe to identify duplicate failures, enable both or neither' ); @@ -105,8 +100,7 @@ export function runIndexerFeatureFlagsStartupCheck(): void { enableDlq: envConfig.ENABLE_INDEXER_DLQ, enableStalenessWarning: envConfig.ENABLE_INDEXER_CURSOR_STALENESS_WARNING, - cursorStaleAgeWarningMs: - envConfig.INDEXER_CURSOR_STALE_AGE_WARNING_MS, + cursorStaleAgeWarningMs: envConfig.INDEXER_CURSOR_STALE_AGE_WARNING_MS, jitterFactor: envConfig.INDEXER_JITTER_FACTOR, }, 'Indexer feature flags validated' diff --git a/src/utils/indexer-retry-exhaustion.utils.test.ts b/src/utils/indexer-retry-exhaustion.utils.test.ts index 7f24a5b..82905ed 100644 --- a/src/utils/indexer-retry-exhaustion.utils.test.ts +++ b/src/utils/indexer-retry-exhaustion.utils.test.ts @@ -12,14 +12,18 @@ describe('logIndexerRetryExhaustion()', () => { }); it('logs an error with required context fields', () => { - logIndexerRetryExhaustion({ jobId: 'job-1', jobType: 'sync', retryCount: 5 }); + logIndexerRetryExhaustion({ + jobId: 'job-1', + jobType: 'sync', + retryCount: 5, + }); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ msg: 'Indexer job retry limit exhausted', jobId: 'job-1', jobType: 'sync', retryCount: 5, - }), + }) ); }); @@ -31,12 +35,16 @@ describe('logIndexerRetryExhaustion()', () => { lastError: 'Connection timeout', }); expect(logger.error).toHaveBeenCalledWith( - expect.objectContaining({ lastError: 'Connection timeout' }), + expect.objectContaining({ lastError: 'Connection timeout' }) ); }); it('omits lastError when not provided', () => { - logIndexerRetryExhaustion({ jobId: 'job-3', jobType: 'ingest', retryCount: 3 }); + logIndexerRetryExhaustion({ + jobId: 'job-3', + jobType: 'ingest', + retryCount: 3, + }); const call = (logger.error as jest.Mock).mock.calls[0][0]; expect(call).not.toHaveProperty('lastError'); }); @@ -75,7 +83,11 @@ describe('logIndexerRetryExhaustion()', () => { }); it('omits payload when not provided', () => { - logIndexerRetryExhaustion({ jobId: 'job-6', jobType: 'sync', retryCount: 1 }); + logIndexerRetryExhaustion({ + jobId: 'job-6', + jobType: 'sync', + retryCount: 1, + }); const call = (logger.error as jest.Mock).mock.calls[0][0]; expect(call).not.toHaveProperty('payload'); }); diff --git a/src/utils/indexer-retry-exhaustion.utils.ts b/src/utils/indexer-retry-exhaustion.utils.ts index ca2b34c..6a3586f 100644 --- a/src/utils/indexer-retry-exhaustion.utils.ts +++ b/src/utils/indexer-retry-exhaustion.utils.ts @@ -1,4 +1,7 @@ -import { sanitizeLogFieldValue, sanitizeLogObject } from './log-field-sanitizer.utils'; +import { + sanitizeLogFieldValue, + sanitizeLogObject, +} from './log-field-sanitizer.utils'; import { logger } from './logger.utils'; export interface IndexerRetryExhaustionContext { @@ -18,13 +21,19 @@ export interface IndexerRetryExhaustionContext { * * @param ctx - Job identity and context metadata */ -export function logIndexerRetryExhaustion(ctx: IndexerRetryExhaustionContext): void { +export function logIndexerRetryExhaustion( + ctx: IndexerRetryExhaustionContext +): void { logger.error({ msg: 'Indexer job retry limit exhausted', jobId: sanitizeLogFieldValue(ctx.jobId), jobType: sanitizeLogFieldValue(ctx.jobType), retryCount: ctx.retryCount, - ...(ctx.lastError !== undefined && { lastError: sanitizeLogFieldValue(ctx.lastError) }), - ...(ctx.payload !== undefined && { payload: sanitizeLogObject(ctx.payload) }), + ...(ctx.lastError !== undefined && { + lastError: sanitizeLogFieldValue(ctx.lastError), + }), + ...(ctx.payload !== undefined && { + payload: sanitizeLogObject(ctx.payload), + }), }); } diff --git a/src/utils/indexer-trade-event-logger.utils.test.ts b/src/utils/indexer-trade-event-logger.utils.test.ts index 2f01649..69497ed 100644 --- a/src/utils/indexer-trade-event-logger.utils.test.ts +++ b/src/utils/indexer-trade-event-logger.utils.test.ts @@ -1,95 +1,105 @@ -import { logIndexerTradeEvent, IndexerTradeEventLogFields } from './indexer-trade-event-logger.utils'; +import { + logIndexerTradeEvent, + IndexerTradeEventLogFields, +} from './indexer-trade-event-logger.utils'; import { logger } from './logger.utils'; jest.mock('./logger.utils', () => ({ - logger: { info: jest.fn() }, + logger: { info: jest.fn() }, })); const infoMock = logger.info as jest.Mock; const BASE_FIELDS: IndexerTradeEventLogFields = { - event_type: 'buy', - creator_id: 'creator-xyz', - ledger_sequence: 12345, - actor_address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - amount: '5', - processed_at: new Date('2026-06-26T10:00:00Z'), + event_type: 'buy', + creator_id: 'creator-xyz', + ledger_sequence: 12345, + actor_address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + amount: '5', + processed_at: new Date('2026-06-26T10:00:00Z'), }; describe('logIndexerTradeEvent', () => { - beforeEach(() => infoMock.mockClear()); - - it('emits exactly one structured info log', () => { - logIndexerTradeEvent(BASE_FIELDS); - expect(infoMock).toHaveBeenCalledTimes(1); - }); - - it('includes all required fields in the log object', () => { - logIndexerTradeEvent(BASE_FIELDS); - const [logObj] = infoMock.mock.calls[0]; - expect(logObj).toHaveProperty('event_type'); - expect(logObj).toHaveProperty('creator_id'); - expect(logObj).toHaveProperty('ledger_sequence'); - expect(logObj).toHaveProperty('actor_address'); - expect(logObj).toHaveProperty('amount'); - expect(logObj).toHaveProperty('processed_at'); - }); - - it('logs event_type correctly for a buy event', () => { - logIndexerTradeEvent({ ...BASE_FIELDS, event_type: 'buy' }); - expect(infoMock.mock.calls[0][0].event_type).toBe('buy'); - }); - - it('logs event_type correctly for a sell event', () => { - logIndexerTradeEvent({ ...BASE_FIELDS, event_type: 'sell' }); - expect(infoMock.mock.calls[0][0].event_type).toBe('sell'); - }); - - it('logs creator_id correctly', () => { - logIndexerTradeEvent({ ...BASE_FIELDS, creator_id: 'creator-abc' }); - expect(infoMock.mock.calls[0][0].creator_id).toBe('creator-abc'); - }); - - it('logs ledger_sequence correctly', () => { - logIndexerTradeEvent({ ...BASE_FIELDS, ledger_sequence: 99999 }); - expect(infoMock.mock.calls[0][0].ledger_sequence).toBe(99999); - }); - - it('masks actor_address to first 4 and last 4 characters', () => { - logIndexerTradeEvent(BASE_FIELDS); - const addr = infoMock.mock.calls[0][0].actor_address as string; - expect(addr).toMatch(/^GAAA\.\.\.AAAA$/); - }); - - it('masks short addresses without truncating', () => { - logIndexerTradeEvent({ ...BASE_FIELDS, actor_address: 'ABCDEFGH' }); - expect(infoMock.mock.calls[0][0].actor_address).toBe('ABCDEFGH'); - }); - - it('masks a different address correctly (first 4 + last 4)', () => { - logIndexerTradeEvent({ ...BASE_FIELDS, actor_address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA' }); - const addr = infoMock.mock.calls[0][0].actor_address as string; - expect(addr.startsWith('GBBB')).toBe(true); - expect(addr.endsWith('BBBA')).toBe(true); - expect(addr).toContain('...'); - }); - - it('does not log the full actor_address in clear text', () => { - const fullAddress = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; - logIndexerTradeEvent({ ...BASE_FIELDS, actor_address: fullAddress }); - const addr = infoMock.mock.calls[0][0].actor_address as string; - expect(addr).not.toBe(fullAddress); - expect(addr.length).toBeLessThan(fullAddress.length); - }); - - it('logs processed_at as an ISO string', () => { - const ts = new Date('2026-06-26T10:00:00Z'); - logIndexerTradeEvent({ ...BASE_FIELDS, processed_at: ts }); - expect(infoMock.mock.calls[0][0].processed_at).toBe('2026-06-26T10:00:00.000Z'); - }); - - it('logs amount correctly', () => { - logIndexerTradeEvent({ ...BASE_FIELDS, amount: '42' }); - expect(infoMock.mock.calls[0][0].amount).toBe('42'); - }); + beforeEach(() => infoMock.mockClear()); + + it('emits exactly one structured info log', () => { + logIndexerTradeEvent(BASE_FIELDS); + expect(infoMock).toHaveBeenCalledTimes(1); + }); + + it('includes all required fields in the log object', () => { + logIndexerTradeEvent(BASE_FIELDS); + const [logObj] = infoMock.mock.calls[0]; + expect(logObj).toHaveProperty('event_type'); + expect(logObj).toHaveProperty('creator_id'); + expect(logObj).toHaveProperty('ledger_sequence'); + expect(logObj).toHaveProperty('actor_address'); + expect(logObj).toHaveProperty('amount'); + expect(logObj).toHaveProperty('processed_at'); + }); + + it('logs event_type correctly for a buy event', () => { + logIndexerTradeEvent({ ...BASE_FIELDS, event_type: 'buy' }); + expect(infoMock.mock.calls[0][0].event_type).toBe('buy'); + }); + + it('logs event_type correctly for a sell event', () => { + logIndexerTradeEvent({ ...BASE_FIELDS, event_type: 'sell' }); + expect(infoMock.mock.calls[0][0].event_type).toBe('sell'); + }); + + it('logs creator_id correctly', () => { + logIndexerTradeEvent({ ...BASE_FIELDS, creator_id: 'creator-abc' }); + expect(infoMock.mock.calls[0][0].creator_id).toBe('creator-abc'); + }); + + it('logs ledger_sequence correctly', () => { + logIndexerTradeEvent({ ...BASE_FIELDS, ledger_sequence: 99999 }); + expect(infoMock.mock.calls[0][0].ledger_sequence).toBe(99999); + }); + + it('masks actor_address to first 4 and last 4 characters', () => { + logIndexerTradeEvent(BASE_FIELDS); + const addr = infoMock.mock.calls[0][0].actor_address as string; + expect(addr).toMatch(/^GAAA\.\.\.AAAA$/); + }); + + it('masks short addresses without truncating', () => { + logIndexerTradeEvent({ ...BASE_FIELDS, actor_address: 'ABCDEFGH' }); + expect(infoMock.mock.calls[0][0].actor_address).toBe('ABCDEFGH'); + }); + + it('masks a different address correctly (first 4 + last 4)', () => { + logIndexerTradeEvent({ + ...BASE_FIELDS, + actor_address: + 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA', + }); + const addr = infoMock.mock.calls[0][0].actor_address as string; + expect(addr.startsWith('GBBB')).toBe(true); + expect(addr.endsWith('BBBA')).toBe(true); + expect(addr).toContain('...'); + }); + + it('does not log the full actor_address in clear text', () => { + const fullAddress = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + logIndexerTradeEvent({ ...BASE_FIELDS, actor_address: fullAddress }); + const addr = infoMock.mock.calls[0][0].actor_address as string; + expect(addr).not.toBe(fullAddress); + expect(addr.length).toBeLessThan(fullAddress.length); + }); + + it('logs processed_at as an ISO string', () => { + const ts = new Date('2026-06-26T10:00:00Z'); + logIndexerTradeEvent({ ...BASE_FIELDS, processed_at: ts }); + expect(infoMock.mock.calls[0][0].processed_at).toBe( + '2026-06-26T10:00:00.000Z' + ); + }); + + it('logs amount correctly', () => { + logIndexerTradeEvent({ ...BASE_FIELDS, amount: '42' }); + expect(infoMock.mock.calls[0][0].amount).toBe('42'); + }); }); diff --git a/src/utils/indexer-trade-event-logger.utils.ts b/src/utils/indexer-trade-event-logger.utils.ts index 288eee3..8d9ad19 100644 --- a/src/utils/indexer-trade-event-logger.utils.ts +++ b/src/utils/indexer-trade-event-logger.utils.ts @@ -1,17 +1,17 @@ import { logger } from './logger.utils'; export interface IndexerTradeEventLogFields { - event_type: 'buy' | 'sell'; - creator_id: string; - ledger_sequence: number; - actor_address: string; - amount: string; - processed_at: Date; + event_type: 'buy' | 'sell'; + creator_id: string; + ledger_sequence: number; + actor_address: string; + amount: string; + processed_at: Date; } function maskActorAddress(address: string): string { - if (address.length <= 8) return address; - return `${address.slice(0, 4)}...${address.slice(-4)}`; + if (address.length <= 8) return address; + return `${address.slice(0, 4)}...${address.slice(-4)}`; } /** @@ -19,16 +19,16 @@ function maskActorAddress(address: string): string { * the database. Must only be called after the write succeeds — not on failure. */ export function logIndexerTradeEvent(fields: IndexerTradeEventLogFields): void { - logger.info( - { - type: 'indexer_trade_processed', - event_type: fields.event_type, - creator_id: fields.creator_id, - ledger_sequence: fields.ledger_sequence, - actor_address: maskActorAddress(fields.actor_address), - amount: fields.amount, - processed_at: fields.processed_at.toISOString(), - }, - 'Indexer trade event processed' - ); + logger.info( + { + type: 'indexer_trade_processed', + event_type: fields.event_type, + creator_id: fields.creator_id, + ledger_sequence: fields.ledger_sequence, + actor_address: maskActorAddress(fields.actor_address), + amount: fields.amount, + processed_at: fields.processed_at.toISOString(), + }, + 'Indexer trade event processed' + ); } diff --git a/src/utils/iso-timestamp.utils.test.ts b/src/utils/iso-timestamp.utils.test.ts index d094aee..04512af 100644 --- a/src/utils/iso-timestamp.utils.test.ts +++ b/src/utils/iso-timestamp.utils.test.ts @@ -1,4 +1,8 @@ -import { formatIsoTimestamp, parseISOTimestamp, InvalidTimestamp } from './iso-timestamp.utils'; +import { + formatIsoTimestamp, + parseISOTimestamp, + InvalidTimestamp, +} from './iso-timestamp.utils'; describe('formatIsoTimestamp()', () => { it('formats supported timestamp inputs with one ISO 8601 UTC representation', () => { @@ -66,7 +70,9 @@ describe('parseISOTimestamp()', () => { // Acceptance criterion: Unix timestamp string throws InvalidTimestamp it('throws InvalidTimestamp for a Unix timestamp string', () => { - expect(() => parseISOTimestamp('1704153845000')).toThrow(InvalidTimestamp); + expect(() => parseISOTimestamp('1704153845000')).toThrow( + InvalidTimestamp + ); }); it('throws InvalidTimestamp for a bare date string (no time component)', () => { diff --git a/src/utils/migration-checksum.utils.ts b/src/utils/migration-checksum.utils.ts index 180bc6f..33ae209 100644 --- a/src/utils/migration-checksum.utils.ts +++ b/src/utils/migration-checksum.utils.ts @@ -6,37 +6,50 @@ import { logger } from './logger.utils'; /** * Verifies that the local migration files match the checksums recorded in the database. - * + * * This helps detect "migration drift" or accidental modifications to historical * migration files which could lead to inconsistent environments. - * + * * @throws {Error} If a checksum mismatch is detected or if migrations are missing. */ export async function verifyMigrationChecksums(): Promise { - const migrationsPath = path.resolve(process.cwd(), 'prisma', 'schema', 'migrations'); + const migrationsPath = path.resolve( + process.cwd(), + 'prisma', + 'schema', + 'migrations' + ); if (!fs.existsSync(migrationsPath)) { - logger.warn('Migrations directory not found, skipping checksum verification.'); + logger.warn( + 'Migrations directory not found, skipping checksum verification.' + ); return; } // 1. Check if the migrations table exists try { - await prisma.$queryRawUnsafe(`SELECT 1 FROM "_prisma_migrations" LIMIT 1`); + await prisma.$queryRawUnsafe( + `SELECT 1 FROM "_prisma_migrations" LIMIT 1` + ); } catch (_error) { - logger.info('Prisma migrations table not found. Skipping verification (fresh database).'); + logger.info( + 'Prisma migrations table not found. Skipping verification (fresh database).' + ); return; } // 2. Read local migrations const entries = fs.readdirSync(migrationsPath, { withFileTypes: true }); const migrationDirs = entries - .filter((entry) => entry.isDirectory() && /^\d+_/.test(entry.name)) - .map((entry) => entry.name) + .filter(entry => entry.isDirectory() && /^\d+_/.test(entry.name)) + .map(entry => entry.name) .sort(); if (migrationDirs.length === 0) { - logger.warn('No migration directories found in prisma/schema/migrations.'); + logger.warn( + 'No migration directories found in prisma/schema/migrations.' + ); return; } @@ -53,7 +66,7 @@ export async function verifyMigrationChecksums(): Promise { // 4. Compare for (const migrationName of migrationDirs) { const dbChecksum = dbChecksumMap.get(migrationName); - + // If the migration hasn't been applied yet, we skip it (it's a new migration) if (!dbChecksum) { continue; @@ -65,7 +78,7 @@ export async function verifyMigrationChecksums(): Promise { } const sqlContent = fs.readFileSync(sqlPath, 'utf8'); - + // Prisma normalizes line endings to LF before hashing const normalizedContent = sqlContent.replace(/\r\n/g, '\n'); const localChecksum = crypto @@ -88,5 +101,7 @@ To fix this: } } - logger.info(`Verified checksums for ${dbChecksumMap.size} migrations. All match.`); + logger.info( + `Verified checksums for ${dbChecksumMap.size} migrations. All match.` + ); } diff --git a/src/utils/monotonic-clock.utils.test.ts b/src/utils/monotonic-clock.utils.test.ts index 48b50e6..adc2fc5 100644 --- a/src/utils/monotonic-clock.utils.test.ts +++ b/src/utils/monotonic-clock.utils.test.ts @@ -1,5 +1,9 @@ import { strict as assert } from 'assert'; -import { startTimer, elapsedMs, elapsedMsFormatted } from './monotonic-clock.utils'; +import { + startTimer, + elapsedMs, + elapsedMsFormatted, +} from './monotonic-clock.utils'; async function run() { // elapsedMs returns a non-negative number @@ -10,7 +14,7 @@ async function run() { // elapsedMs grows over time const t2 = startTimer(); - await new Promise((r) => setTimeout(r, 20)); + await new Promise(r => setTimeout(r, 20)); const elapsed = elapsedMs(t2); assert.ok(elapsed >= 15, `expected >= 15ms, got ${elapsed}ms`); @@ -19,12 +23,15 @@ async function run() { const formatted = elapsedMsFormatted(t3); assert.ok(typeof formatted === 'string', 'should return a string'); assert.ok(formatted.endsWith('ms'), 'should end with "ms"'); - assert.ok(/^\d+\.\d{3}ms$/.test(formatted), `unexpected format: ${formatted}`); + assert.ok( + /^\d+\.\d{3}ms$/.test(formatted), + `unexpected format: ${formatted}` + ); console.log('monotonic-clock.utils tests passed'); } -run().catch((err) => { +run().catch(err => { console.error(err); process.exit(1); }); diff --git a/src/utils/ownershipConsistency.ts b/src/utils/ownershipConsistency.ts index 12659de..7764f39 100644 --- a/src/utils/ownershipConsistency.ts +++ b/src/utils/ownershipConsistency.ts @@ -13,7 +13,10 @@ export function checkOwnershipReadModelConsistency({ detectedAt, logError, }: OwnershipConsistencyCheckParams): boolean { - const computedHolderSum = holderBalances.reduce((sum, balance) => sum + balance, 0); + const computedHolderSum = holderBalances.reduce( + (sum, balance) => sum + balance, + 0 + ); const discrepancy = computedHolderSum - storedSupply; if (discrepancy === 0) { diff --git a/src/utils/pagination-guard.utils.ts b/src/utils/pagination-guard.utils.ts index 140e02b..0237fd7 100644 --- a/src/utils/pagination-guard.utils.ts +++ b/src/utils/pagination-guard.utils.ts @@ -1,4 +1,7 @@ -import { MAX_PAGE_SIZE, MIN_PAGE_SIZE } from '../constants/pagination.constants'; +import { + MAX_PAGE_SIZE, + MIN_PAGE_SIZE, +} from '../constants/pagination.constants'; /** * Error thrown when page size exceeds the maximum allowed limit. @@ -15,7 +18,9 @@ export class PageSizeExceededError extends Error { */ export class PageSizeTooSmallError extends Error { constructor(limit: number, minLimit: number = MIN_PAGE_SIZE) { - super(`Page size limit (${limit}) is below minimum allowed (${minLimit})`); + super( + `Page size limit (${limit}) is below minimum allowed (${minLimit})` + ); this.name = 'PageSizeTooSmallError'; } } diff --git a/src/utils/pagination-link-headers.utils.test.ts b/src/utils/pagination-link-headers.utils.test.ts index 4863c59..ccba46d 100644 --- a/src/utils/pagination-link-headers.utils.test.ts +++ b/src/utils/pagination-link-headers.utils.test.ts @@ -90,7 +90,9 @@ describe('buildPaginationLinkHeader()', () => { total: 20, }); const nextLink = result?.split(',')[0] ?? ''; - const params = new URLSearchParams(nextLink.match(/\?([^>]+)/)?.[1] ?? ''); + const params = new URLSearchParams( + nextLink.match(/\?([^>]+)/)?.[1] ?? '' + ); expect(params.getAll('offset')).toHaveLength(1); expect(params.getAll('limit')).toHaveLength(1); }); @@ -119,7 +121,7 @@ describe('attachPaginationLinkHeaders()', () => { }); expect(res.set).toHaveBeenCalledWith( PAGINATION_LINK_HEADER, - expect.stringContaining('rel="next"'), + expect.stringContaining('rel="next"') ); }); diff --git a/src/utils/pagination-link-headers.utils.ts b/src/utils/pagination-link-headers.utils.ts index 562dae7..c7c8f4c 100644 --- a/src/utils/pagination-link-headers.utils.ts +++ b/src/utils/pagination-link-headers.utils.ts @@ -14,7 +14,7 @@ function buildOffsetUrl( baseUrl: string, query: Record, offset: number, - limit: number, + limit: number ): string { const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { @@ -43,14 +43,21 @@ function buildOffsetUrl( * buildPaginationLinkHeader({ baseUrl: 'https://api.example.com/creators', query: {}, offset: 10, limit: 10, total: 25 }) * // => '; rel="next", ; rel="prev"' */ -export function buildPaginationLinkHeader(params: PaginationLinkParams): string | null { +export function buildPaginationLinkHeader( + params: PaginationLinkParams +): string | null { const { baseUrl, query, offset, limit, total } = params; const safeOffset = Math.max(0, offset); const safeLimit = Math.max(1, limit); const links: string[] = []; if (safeOffset + safeLimit < total) { - const nextUrl = buildOffsetUrl(baseUrl, query, safeOffset + safeLimit, safeLimit); + const nextUrl = buildOffsetUrl( + baseUrl, + query, + safeOffset + safeLimit, + safeLimit + ); links.push(`<${nextUrl}>; rel="next"`); } @@ -73,7 +80,10 @@ export function buildPaginationLinkHeader(params: PaginationLinkParams): string * @param res - Express response object * @param params - Pagination context */ -export function attachPaginationLinkHeaders(res: Response, params: PaginationLinkParams): void { +export function attachPaginationLinkHeaders( + res: Response, + params: PaginationLinkParams +): void { const header = buildPaginationLinkHeader(params); if (header !== null) { res.set(PAGINATION_LINK_HEADER, header); diff --git a/src/utils/pagination.utils.ts b/src/utils/pagination.utils.ts index 9345495..1c964b1 100644 --- a/src/utils/pagination.utils.ts +++ b/src/utils/pagination.utils.ts @@ -80,7 +80,7 @@ export type CursorPaginationResult = { /** * Helper for cursor-based pagination. * Appends cursor filtering and limit to a query function. - * + * * @param query A function that accepts pagination args and executes the DB query * @param options Pagination options containing cursor and limit * @param getCursor Optional function to extract the cursor from the last item. Defaults to extracting the `id` property. @@ -92,22 +92,22 @@ export async function paginateQuery( ): Promise> { const take = limit + 1; const args: { take: number; skip?: number; cursor?: any } = { take }; - + if (cursor) { args.cursor = cursor; args.skip = 1; } - + const results = await query(args); - + const hasMore = results.length > limit; const data = hasMore ? results.slice(0, limit) : results; - + let nextCursor = undefined; if (data.length > 0 && hasMore) { const lastItem = data[data.length - 1]; nextCursor = getCursor ? getCursor(lastItem) : (lastItem as any).id; } - + return { data, nextCursor, hasMore }; } diff --git a/src/utils/parseBoolean.utils.ts b/src/utils/parseBoolean.utils.ts index d921722..b285fb7 100644 --- a/src/utils/parseBoolean.utils.ts +++ b/src/utils/parseBoolean.utils.ts @@ -14,18 +14,18 @@ const TRUE_VALUES = new Set(['true', '1']); const FALSE_VALUES = new Set(['false', '0']); export class ParseBooleanError extends Error { - public readonly rawValue: string; - public readonly paramName: string; + public readonly rawValue: string; + public readonly paramName: string; - constructor(paramName: string, rawValue: string) { - super( - `Invalid boolean value for query parameter "${paramName}": received "${rawValue}". ` + - `Accepted values: "true", "false", "1", "0".` - ); - this.name = 'ParseBooleanError'; - this.rawValue = rawValue; - this.paramName = paramName; - } + constructor(paramName: string, rawValue: string) { + super( + `Invalid boolean value for query parameter "${paramName}": received "${rawValue}". ` + + `Accepted values: "true", "false", "1", "0".` + ); + this.name = 'ParseBooleanError'; + this.rawValue = rawValue; + this.paramName = paramName; + } } /** @@ -37,33 +37,33 @@ export class ParseBooleanError extends Error { * @throws {ParseBooleanError} when the value is present but unrecognized */ export function parseBoolean( - paramName: string, - raw: string | string[] | boolean | null | undefined + paramName: string, + raw: string | string[] | boolean | null | undefined ): boolean | null { - if (raw === undefined || raw === null) { - return null; - } + if (raw === undefined || raw === null) { + return null; + } - if (typeof raw === 'boolean') { - return raw; - } + if (typeof raw === 'boolean') { + return raw; + } - const rawValue = Array.isArray(raw) ? raw[0] : raw; - if (typeof rawValue !== 'string') { - throw new ParseBooleanError(paramName, String(rawValue)); - } + const rawValue = Array.isArray(raw) ? raw[0] : raw; + if (typeof rawValue !== 'string') { + throw new ParseBooleanError(paramName, String(rawValue)); + } - const value = rawValue.trim().toLowerCase(); + const value = rawValue.trim().toLowerCase(); - if (TRUE_VALUES.has(value)) { - return true; - } + if (TRUE_VALUES.has(value)) { + return true; + } - if (FALSE_VALUES.has(value)) { - return false; - } + if (FALSE_VALUES.has(value)) { + return false; + } - throw new ParseBooleanError(paramName, rawValue); + throw new ParseBooleanError(paramName, rawValue); } /** @@ -71,10 +71,10 @@ export function parseBoolean( * parameter is absent instead of null. */ export function parseBooleanWithDefault( - paramName: string, - raw: string | string[] | boolean | null | undefined, - defaultValue: boolean + paramName: string, + raw: string | string[] | boolean | null | undefined, + defaultValue: boolean ): boolean { - const result = parseBoolean(paramName, raw); - return result === null ? defaultValue : result; + const result = parseBoolean(paramName, raw); + return result === null ? defaultValue : result; } diff --git a/src/utils/price.utils.ts b/src/utils/price.utils.ts index 249329d..884f60e 100644 --- a/src/utils/price.utils.ts +++ b/src/utils/price.utils.ts @@ -1,4 +1,7 @@ -export function compute24hPriceChange(current: bigint, price24hAgo: bigint): number { +export function compute24hPriceChange( + current: bigint, + price24hAgo: bigint +): number { if (price24hAgo === 0n) { return 0; } @@ -7,7 +10,8 @@ export function compute24hPriceChange(current: bigint, price24hAgo: bigint): num const sign = change < 0n ? -1n : 1n; const absoluteChange = change < 0n ? -change : change; const absoluteBase = price24hAgo < 0n ? -price24hAgo : price24hAgo; - const roundedCents = (absoluteChange * 10000n + absoluteBase / 2n) / absoluteBase; + const roundedCents = + (absoluteChange * 10000n + absoluteBase / 2n) / absoluteBase; const percentage = Number(sign * roundedCents) / 100; return Number(percentage.toFixed(2)); diff --git a/src/utils/prisma.utils.ts b/src/utils/prisma.utils.ts index e9d098b..1e7eec9 100644 --- a/src/utils/prisma.utils.ts +++ b/src/utils/prisma.utils.ts @@ -6,7 +6,7 @@ import { logger } from './logger.utils'; // Use global variable to prevent multiple instances in development declare global { - var prisma: any | undefined; + var prisma: any | undefined; } /** @@ -14,23 +14,23 @@ declare global { * resulting structure identifies the query pattern without exposing any values. */ function normalizeArgsForFingerprint(value: unknown, depth = 0): unknown { - if (depth > 8) return '?'; - if (value === null || value === undefined) return value; - if (Array.isArray(value)) { - return value.map((item) => normalizeArgsForFingerprint(item, depth + 1)); - } - if (typeof value === 'object') { - const sorted = Object.keys(value as object).sort(); - const result: Record = {}; - for (const key of sorted) { - result[key] = normalizeArgsForFingerprint( - (value as Record)[key], - depth + 1 - ); - } - return result; - } - return '?'; + if (depth > 8) return '?'; + if (value === null || value === undefined) return value; + if (Array.isArray(value)) { + return value.map(item => normalizeArgsForFingerprint(item, depth + 1)); + } + if (typeof value === 'object') { + const sorted = Object.keys(value as object).sort(); + const result: Record = {}; + for (const key of sorted) { + result[key] = normalizeArgsForFingerprint( + (value as Record)[key], + depth + 1 + ); + } + return result; + } + return '?'; } /** @@ -38,27 +38,25 @@ function normalizeArgsForFingerprint(value: unknown, depth = 0): unknown { * operation, and arg structure) without including any parameter values. */ function buildQueryFingerprint( - model: string | undefined, - operation: string, - args: unknown + model: string | undefined, + operation: string, + args: unknown ): string { - const normalized = { - model: model ?? 'unknown', - operation, - args: normalizeArgsForFingerprint(args), - }; - return createHash('sha256') - .update(JSON.stringify(normalized)) - .digest('hex') - .slice(0, 16); + const normalized = { + model: model ?? 'unknown', + operation, + args: normalizeArgsForFingerprint(args), + }; + return createHash('sha256') + .update(JSON.stringify(normalized)) + .digest('hex') + .slice(0, 16); } const basePrisma = new PrismaClient({ - log: - envConfig.MODE === 'development' - ? ['query', 'error', 'warn'] - : ['error'], - datasourceUrl: envConfig.DATABASE_URL, + log: + envConfig.MODE === 'development' ? ['query', 'error', 'warn'] : ['error'], + datasourceUrl: envConfig.DATABASE_URL, }); // Track connection pool metrics and log when wait thresholds are exceeded @@ -68,101 +66,110 @@ const queryStartTimes = new Map(); // Extend Prisma with query timeout, slow-query detection, and pool wait tracking export const prisma = basePrisma.$extends({ - query: { - $allOperations({ operation, model, args, query }) { - const timeoutMs = envConfig.DB_QUERY_TIMEOUT_MS; - const slowThresholdMs = envConfig.SLOW_QUERY_THRESHOLD_MS; - const poolWarnThreshold = envConfig.DB_POOL_WAIT_WARN_MS; - const poolErrorThreshold = envConfig.DB_POOL_WAIT_ERROR_MS; - const context = requestContextStorage.getStore(); + query: { + $allOperations({ operation, model, args, query }) { + const timeoutMs = envConfig.DB_QUERY_TIMEOUT_MS; + const slowThresholdMs = envConfig.SLOW_QUERY_THRESHOLD_MS; + const poolWarnThreshold = envConfig.DB_POOL_WAIT_WARN_MS; + const poolErrorThreshold = envConfig.DB_POOL_WAIT_ERROR_MS; + const context = requestContextStorage.getStore(); - const queryId = Symbol(); - const waitStart = Date.now(); - activeQueries++; + const queryId = Symbol(); + const waitStart = Date.now(); + activeQueries++; - let timeoutId: NodeJS.Timeout; - let timedOut = false; + let timeoutId: NodeJS.Timeout; + let timedOut = false; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - timedOut = true; - const logContext = { - type: 'database_timeout', - operation, - model, - timeoutMs, - path: context?.path, - method: context?.method, - requestId: context?.requestId, - }; - logger.error(logContext, `Database query timed out after ${timeoutMs}ms`); - reject(new Error(`Database query timed out after ${timeoutMs}ms`)); - }, timeoutMs); - }); + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + timedOut = true; + const logContext = { + type: 'database_timeout', + operation, + model, + timeoutMs, + path: context?.path, + method: context?.method, + requestId: context?.requestId, + }; + logger.error( + logContext, + `Database query timed out after ${timeoutMs}ms` + ); + reject( + new Error(`Database query timed out after ${timeoutMs}ms`) + ); + }, timeoutMs); + }); - const start = Date.now(); - const queryPromise = query(args).finally(() => { - clearTimeout(timeoutId); - const waitTime = Date.now() - waitStart; - activeQueries--; - queryStartTimes.delete(queryId); + const start = Date.now(); + const queryPromise = query(args).finally(() => { + clearTimeout(timeoutId); + const waitTime = Date.now() - waitStart; + activeQueries--; + queryStartTimes.delete(queryId); - // Log if wait time exceeds thresholds - if (waitTime > poolErrorThreshold) { - logger.error( - { - type: 'database_pool_wait_exceeded', - waitTimeMs: waitTime, - poolSize: 10, // Default Prisma pool size - queueDepth: activeQueries, - endpoint: context?.path, - operation, - model, - requestId: context?.requestId, - }, - `Database connection pool wait time exceeded ${poolErrorThreshold}ms` - ); - } else if (waitTime > poolWarnThreshold) { - logger.warn( - { - type: 'database_pool_wait_exceeded', - waitTimeMs: waitTime, - poolSize: 10, // Default Prisma pool size - queueDepth: activeQueries, - endpoint: context?.path, - operation, - model, - requestId: context?.requestId, - }, - `Database connection pool wait time exceeded ${poolWarnThreshold}ms` - ); - } + // Log if wait time exceeds thresholds + if (waitTime > poolErrorThreshold) { + logger.error( + { + type: 'database_pool_wait_exceeded', + waitTimeMs: waitTime, + poolSize: 10, // Default Prisma pool size + queueDepth: activeQueries, + endpoint: context?.path, + operation, + model, + requestId: context?.requestId, + }, + `Database connection pool wait time exceeded ${poolErrorThreshold}ms` + ); + } else if (waitTime > poolWarnThreshold) { + logger.warn( + { + type: 'database_pool_wait_exceeded', + waitTimeMs: waitTime, + poolSize: 10, // Default Prisma pool size + queueDepth: activeQueries, + endpoint: context?.path, + operation, + model, + requestId: context?.requestId, + }, + `Database connection pool wait time exceeded ${poolWarnThreshold}ms` + ); + } - if (!timedOut) { - const elapsedMs = Date.now() - start; - if (elapsedMs > slowThresholdMs) { - logger.warn( - { - type: 'slow_query', - model, - operation, - fingerprint: buildQueryFingerprint(model, operation, args), - elapsedMs, - thresholdMs: slowThresholdMs, - requestId: context?.requestId, - }, - 'Slow database query detected' - ); - } - } - }); + if (!timedOut) { + const elapsedMs = Date.now() - start; + if (elapsedMs > slowThresholdMs) { + logger.warn( + { + type: 'slow_query', + model, + operation, + fingerprint: buildQueryFingerprint( + model, + operation, + args + ), + elapsedMs, + thresholdMs: slowThresholdMs, + requestId: context?.requestId, + }, + 'Slow database query detected' + ); + } + } + }); - return Promise.race([queryPromise, timeoutPromise]); - }, - }, + return Promise.race([queryPromise, timeoutPromise]); + }, + }, }); // Prevent multiple instances in development environment if (envConfig.MODE !== 'production') { - global.prisma = prisma; + global.prisma = prisma; } diff --git a/src/utils/public-headers.utils.ts b/src/utils/public-headers.utils.ts index 742e75c..d41f96f 100644 --- a/src/utils/public-headers.utils.ts +++ b/src/utils/public-headers.utils.ts @@ -1,11 +1,18 @@ import { Request, Response, NextFunction } from 'express'; -import { cacheControl, CachePresets } from '../middlewares/cache-control.middleware'; +import { + cacheControl, + CachePresets, +} from '../middlewares/cache-control.middleware'; /** * Sets common headers for public, read-only endpoints. * Applies a short public cache and marks the response as non-credentialed. */ -export function setPublicHeaders(req: Request, res: Response, next: NextFunction): void { - res.setHeader('X-Content-Type-Options', 'nosniff'); - cacheControl(CachePresets.publicShort)(req, res, next); +export function setPublicHeaders( + req: Request, + res: Response, + next: NextFunction +): void { + res.setHeader('X-Content-Type-Options', 'nosniff'); + cacheControl(CachePresets.publicShort)(req, res, next); } diff --git a/src/utils/query-normalization-debug.utils.ts b/src/utils/query-normalization-debug.utils.ts index 3d26be1..74555a3 100644 --- a/src/utils/query-normalization-debug.utils.ts +++ b/src/utils/query-normalization-debug.utils.ts @@ -30,12 +30,14 @@ const SANITIZED_VALUE = '[REDACTED]'; */ function isSensitiveField(fieldName: string): boolean { const lowerField = fieldName.toLowerCase(); - return SENSITIVE_FIELD_PATTERNS.some(pattern => lowerField.includes(pattern)); + return SENSITIVE_FIELD_PATTERNS.some(pattern => + lowerField.includes(pattern) + ); } /** * Recursively sanitize an object by replacing sensitive field values. - * + * * @param obj - Object to sanitize * @returns Sanitized copy of the object */ @@ -85,15 +87,15 @@ export interface QueryNormalizationSnapshot { /** * Emit a debug snapshot of query normalization results. - * + * * This helper is only active when the logger level is set to 'debug' or lower. * It sanitizes sensitive fields before logging to prevent data leakage. - * + * * Use this to diagnose query parsing issues, understand normalization behavior, * or validate that query transformations are working as expected. - * + * * @param snapshot - Query normalization snapshot data - * + * * @example * // In a query parser * const parsed = parsePublicQuery(schema, rawQuery); @@ -115,8 +117,14 @@ export function emitQueryNormalizationDebug( // Build query signature if raw query is an object let querySignature: string | undefined; - if (typeof snapshot.raw === 'object' && snapshot.raw !== null && !Array.isArray(snapshot.raw)) { - querySignature = buildQuerySignature(snapshot.raw as Record); + if ( + typeof snapshot.raw === 'object' && + snapshot.raw !== null && + !Array.isArray(snapshot.raw) + ) { + querySignature = buildQuerySignature( + snapshot.raw as Record + ); } const sanitizedSnapshot: QueryNormalizationSnapshot = { @@ -137,16 +145,16 @@ export function emitQueryNormalizationDebug( /** * Create a query normalization debug emitter with a fixed context label. - * + * * This is useful for creating a reusable debug helper for a specific endpoint * or query type without repeating the context label. - * + * * @param context - Context label for all snapshots from this emitter * @returns Function that emits debug snapshots with the fixed context - * + * * @example * const debugCreatorQuery = createQueryDebugEmitter('creator-list'); - * + * * // Later in code * debugCreatorQuery({ * raw: req.query, @@ -155,7 +163,12 @@ export function emitQueryNormalizationDebug( * }); */ export function createQueryDebugEmitter(context: string) { - return (snapshot: Omit) => { + return ( + snapshot: Omit< + QueryNormalizationSnapshot, + 'timestamp' | 'context' | 'querySignature' + > + ) => { emitQueryNormalizationDebug({ ...snapshot, context }); }; } diff --git a/src/utils/query-param.utils.test.ts b/src/utils/query-param.utils.test.ts index a339102..bc1d5f4 100644 --- a/src/utils/query-param.utils.test.ts +++ b/src/utils/query-param.utils.test.ts @@ -1,103 +1,113 @@ import { readQueryParam } from './query-param.utils'; describe('readQueryParam — string type', () => { - it('returns the string value when present', () => { - expect(readQueryParam({ q: 'hello' }, 'q', 'string', 'default')).toBe('hello'); - }); - - it('returns defaultValue when param is missing', () => { - expect(readQueryParam({}, 'q', 'string', 'default')).toBe('default'); - }); - - it('returns defaultValue when param is empty string', () => { - expect(readQueryParam({ q: '' }, 'q', 'string', 'default')).toBe('default'); - }); - - it('returns defaultValue when param is null', () => { - expect(readQueryParam({ q: null }, 'q', 'string', 'fallback')).toBe('fallback'); - }); - - it('uses first element of array value', () => { - expect(readQueryParam({ q: ['first', 'second'] }, 'q', 'string', 'x')).toBe('first'); - }); - - it('returns undefined defaultValue when absent and default is undefined', () => { - expect(readQueryParam({}, 'q', 'string', undefined)).toBeUndefined(); - }); + it('returns the string value when present', () => { + expect(readQueryParam({ q: 'hello' }, 'q', 'string', 'default')).toBe( + 'hello' + ); + }); + + it('returns defaultValue when param is missing', () => { + expect(readQueryParam({}, 'q', 'string', 'default')).toBe('default'); + }); + + it('returns defaultValue when param is empty string', () => { + expect(readQueryParam({ q: '' }, 'q', 'string', 'default')).toBe( + 'default' + ); + }); + + it('returns defaultValue when param is null', () => { + expect(readQueryParam({ q: null }, 'q', 'string', 'fallback')).toBe( + 'fallback' + ); + }); + + it('uses first element of array value', () => { + expect( + readQueryParam({ q: ['first', 'second'] }, 'q', 'string', 'x') + ).toBe('first'); + }); + + it('returns undefined defaultValue when absent and default is undefined', () => { + expect(readQueryParam({}, 'q', 'string', undefined)).toBeUndefined(); + }); }); describe('readQueryParam — number type', () => { - it('returns parsed integer when param is a valid non-negative number string', () => { - expect(readQueryParam({ n: '42' }, 'n', 'number', 0)).toBe(42); - }); + it('returns parsed integer when param is a valid non-negative number string', () => { + expect(readQueryParam({ n: '42' }, 'n', 'number', 0)).toBe(42); + }); - it('returns 0 as a valid value', () => { - expect(readQueryParam({ n: '0' }, 'n', 'number', 99)).toBe(0); - }); + it('returns 0 as a valid value', () => { + expect(readQueryParam({ n: '0' }, 'n', 'number', 99)).toBe(0); + }); - it('returns defaultValue when param is missing', () => { - expect(readQueryParam({}, 'n', 'number', 10)).toBe(10); - }); + it('returns defaultValue when param is missing', () => { + expect(readQueryParam({}, 'n', 'number', 10)).toBe(10); + }); - it('returns defaultValue when param is NaN', () => { - expect(readQueryParam({ n: 'abc' }, 'n', 'number', 5)).toBe(5); - }); + it('returns defaultValue when param is NaN', () => { + expect(readQueryParam({ n: 'abc' }, 'n', 'number', 5)).toBe(5); + }); - it('returns defaultValue when param is negative', () => { - expect(readQueryParam({ n: '-1' }, 'n', 'number', 7)).toBe(7); - }); + it('returns defaultValue when param is negative', () => { + expect(readQueryParam({ n: '-1' }, 'n', 'number', 7)).toBe(7); + }); - it('returns defaultValue when param is a float string (not an integer)', () => { - expect(readQueryParam({ n: '3.14' }, 'n', 'number', 0)).toBe(3); - }); + it('returns defaultValue when param is a float string (not an integer)', () => { + expect(readQueryParam({ n: '3.14' }, 'n', 'number', 0)).toBe(3); + }); - it('returns defaultValue when param is empty string', () => { - expect(readQueryParam({ n: '' }, 'n', 'number', 99)).toBe(99); - }); + it('returns defaultValue when param is empty string', () => { + expect(readQueryParam({ n: '' }, 'n', 'number', 99)).toBe(99); + }); - it('uses first element of array value', () => { - expect(readQueryParam({ n: ['7', '8'] }, 'n', 'number', 0)).toBe(7); - }); + it('uses first element of array value', () => { + expect(readQueryParam({ n: ['7', '8'] }, 'n', 'number', 0)).toBe(7); + }); }); describe('readQueryParam — boolean type', () => { - it('returns true for "true"', () => { - expect(readQueryParam({ b: 'true' }, 'b', 'boolean', false)).toBe(true); - }); - - it('returns false for "false"', () => { - expect(readQueryParam({ b: 'false' }, 'b', 'boolean', true)).toBe(false); - }); - - it('is case-insensitive — accepts "True"', () => { - expect(readQueryParam({ b: 'True' }, 'b', 'boolean', false)).toBe(true); - }); - - it('is case-insensitive — accepts "FALSE"', () => { - expect(readQueryParam({ b: 'FALSE' }, 'b', 'boolean', true)).toBe(false); - }); - - it('returns defaultValue when param is missing', () => { - expect(readQueryParam({}, 'b', 'boolean', true)).toBe(true); - }); - - it('returns defaultValue for invalid string "yes"', () => { - expect(readQueryParam({ b: 'yes' }, 'b', 'boolean', false)).toBe(false); - }); - - it('returns defaultValue for invalid string "1"', () => { - expect(readQueryParam({ b: '1' }, 'b', 'boolean', false)).toBe(false); - }); - - it('returns defaultValue for invalid string "0"', () => { - expect(readQueryParam({ b: '0' }, 'b', 'boolean', true)).toBe(true); - }); - - it('returns defaultValue when param is empty string', () => { - expect(readQueryParam({ b: '' }, 'b', 'boolean', true)).toBe(true); - }); - - it('uses first element of array value', () => { - expect(readQueryParam({ b: ['true', 'false'] }, 'b', 'boolean', false)).toBe(true); - }); + it('returns true for "true"', () => { + expect(readQueryParam({ b: 'true' }, 'b', 'boolean', false)).toBe(true); + }); + + it('returns false for "false"', () => { + expect(readQueryParam({ b: 'false' }, 'b', 'boolean', true)).toBe(false); + }); + + it('is case-insensitive — accepts "True"', () => { + expect(readQueryParam({ b: 'True' }, 'b', 'boolean', false)).toBe(true); + }); + + it('is case-insensitive — accepts "FALSE"', () => { + expect(readQueryParam({ b: 'FALSE' }, 'b', 'boolean', true)).toBe(false); + }); + + it('returns defaultValue when param is missing', () => { + expect(readQueryParam({}, 'b', 'boolean', true)).toBe(true); + }); + + it('returns defaultValue for invalid string "yes"', () => { + expect(readQueryParam({ b: 'yes' }, 'b', 'boolean', false)).toBe(false); + }); + + it('returns defaultValue for invalid string "1"', () => { + expect(readQueryParam({ b: '1' }, 'b', 'boolean', false)).toBe(false); + }); + + it('returns defaultValue for invalid string "0"', () => { + expect(readQueryParam({ b: '0' }, 'b', 'boolean', true)).toBe(true); + }); + + it('returns defaultValue when param is empty string', () => { + expect(readQueryParam({ b: '' }, 'b', 'boolean', true)).toBe(true); + }); + + it('uses first element of array value', () => { + expect( + readQueryParam({ b: ['true', 'false'] }, 'b', 'boolean', false) + ).toBe(true); + }); }); diff --git a/src/utils/query-param.utils.ts b/src/utils/query-param.utils.ts index 8d3c2d5..0d7fa6a 100644 --- a/src/utils/query-param.utils.ts +++ b/src/utils/query-param.utils.ts @@ -1,16 +1,16 @@ type QueryParamType = 'string' | 'number' | 'boolean'; type QueryParamReturn = D extends undefined - ? T extends 'string' - ? string | undefined - : T extends 'number' - ? number | undefined - : boolean | undefined - : T extends 'string' - ? string - : T extends 'number' - ? number - : boolean; + ? T extends 'string' + ? string | undefined + : T extends 'number' + ? number | undefined + : boolean | undefined + : T extends 'string' + ? string + : T extends 'number' + ? number + : boolean; /** * Safely reads a typed value from an Express query-param map. @@ -20,35 +20,38 @@ type QueryParamReturn = D extends undefined * - 'boolean': accepts only 'true'/'false' strings (case-insensitive); * returns defaultValue for anything else or when missing. */ -export function readQueryParam | undefined>( - params: Record, - key: string, - type: T, - defaultValue: D +export function readQueryParam< + T extends QueryParamType, + D extends QueryParamReturn | undefined, +>( + params: Record, + key: string, + type: T, + defaultValue: D ): QueryParamReturn { - const raw = params[key]; + const raw = params[key]; - if (raw === undefined || raw === null || raw === '') { - return defaultValue as QueryParamReturn; - } + if (raw === undefined || raw === null || raw === '') { + return defaultValue as QueryParamReturn; + } - const value = Array.isArray(raw) ? raw[0] : String(raw); + const value = Array.isArray(raw) ? raw[0] : String(raw); - if (type === 'string') { - return value as QueryParamReturn; - } + if (type === 'string') { + return value as QueryParamReturn; + } - if (type === 'number') { - const parsed = Number.parseInt(value, 10); - if (Number.isNaN(parsed) || parsed < 0) { - return defaultValue as QueryParamReturn; - } - return parsed as QueryParamReturn; - } + if (type === 'number') { + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed) || parsed < 0) { + return defaultValue as QueryParamReturn; + } + return parsed as QueryParamReturn; + } - // boolean - const lower = value.toLowerCase(); - if (lower === 'true') return true as QueryParamReturn; - if (lower === 'false') return false as QueryParamReturn; - return defaultValue as QueryParamReturn; + // boolean + const lower = value.toLowerCase(); + if (lower === 'true') return true as QueryParamReturn; + if (lower === 'false') return false as QueryParamReturn; + return defaultValue as QueryParamReturn; } diff --git a/src/utils/query-plan.utils.test.ts b/src/utils/query-plan.utils.test.ts index 7c8781c..83b123c 100644 --- a/src/utils/query-plan.utils.test.ts +++ b/src/utils/query-plan.utils.test.ts @@ -22,7 +22,9 @@ describe('captureQueryPlan()', () => { it('returns null in production without touching the database', async () => { (envConfig as any).MODE = 'production'; - const result = await captureQueryPlan('SELECT * FROM "CreatorProfile"'); + const result = await captureQueryPlan( + 'SELECT * FROM "CreatorProfile"' + ); expect(result).toBeNull(); expect(queryRawUnsafeMock).not.toHaveBeenCalled(); @@ -31,7 +33,9 @@ describe('captureQueryPlan()', () => { it('returns null in test mode without touching the database', async () => { (envConfig as any).MODE = 'test'; - const result = await captureQueryPlan('SELECT * FROM "CreatorProfile"'); + const result = await captureQueryPlan( + 'SELECT * FROM "CreatorProfile"' + ); expect(result).toBeNull(); expect(queryRawUnsafeMock).not.toHaveBeenCalled(); @@ -44,8 +48,13 @@ describe('captureQueryPlan()', () => { }); it('prepends EXPLAIN (FORMAT JSON) to the provided SQL', async () => { - const planNode = { 'Node Type': 'Seq Scan', 'Relation Name': 'CreatorProfile' }; - queryRawUnsafeMock.mockResolvedValueOnce([{ 'QUERY PLAN': [planNode] }]); + const planNode = { + 'Node Type': 'Seq Scan', + 'Relation Name': 'CreatorProfile', + }; + queryRawUnsafeMock.mockResolvedValueOnce([ + { 'QUERY PLAN': [planNode] }, + ]); await captureQueryPlan('SELECT * FROM "CreatorProfile"'); @@ -57,7 +66,10 @@ describe('captureQueryPlan()', () => { it('forwards bound parameters to $queryRawUnsafe', async () => { queryRawUnsafeMock.mockResolvedValueOnce([{ 'QUERY PLAN': [] }]); - await captureQueryPlan('SELECT * FROM "CreatorProfile" WHERE "isVerified" = $1', [true]); + await captureQueryPlan( + 'SELECT * FROM "CreatorProfile" WHERE "isVerified" = $1', + [true] + ); expect(queryRawUnsafeMock).toHaveBeenCalledWith( 'EXPLAIN (FORMAT JSON) SELECT * FROM "CreatorProfile" WHERE "isVerified" = $1', @@ -66,10 +78,17 @@ describe('captureQueryPlan()', () => { }); it('returns the QUERY PLAN array from the first result row', async () => { - const planNode = { 'Node Type': 'Index Scan', 'Index Name': 'idx_creator_verified' }; - queryRawUnsafeMock.mockResolvedValueOnce([{ 'QUERY PLAN': [planNode] }]); - - const result = await captureQueryPlan('SELECT * FROM "CreatorProfile"'); + const planNode = { + 'Node Type': 'Index Scan', + 'Index Name': 'idx_creator_verified', + }; + queryRawUnsafeMock.mockResolvedValueOnce([ + { 'QUERY PLAN': [planNode] }, + ]); + + const result = await captureQueryPlan( + 'SELECT * FROM "CreatorProfile"' + ); expect(result).toEqual([planNode]); }); @@ -77,7 +96,9 @@ describe('captureQueryPlan()', () => { it('returns null when the database throws instead of propagating the error', async () => { queryRawUnsafeMock.mockRejectedValueOnce(new Error('syntax error')); - const result = await captureQueryPlan('SELECT * FROM "CreatorProfile"'); + const result = await captureQueryPlan( + 'SELECT * FROM "CreatorProfile"' + ); expect(result).toBeNull(); }); @@ -85,7 +106,9 @@ describe('captureQueryPlan()', () => { it('returns null when the result row has no QUERY PLAN key', async () => { queryRawUnsafeMock.mockResolvedValueOnce([{}]); - const result = await captureQueryPlan('SELECT * FROM "CreatorProfile"'); + const result = await captureQueryPlan( + 'SELECT * FROM "CreatorProfile"' + ); expect(result).toBeNull(); }); @@ -93,7 +116,9 @@ describe('captureQueryPlan()', () => { it('returns null when the result set is empty', async () => { queryRawUnsafeMock.mockResolvedValueOnce([]); - const result = await captureQueryPlan('SELECT * FROM "CreatorProfile"'); + const result = await captureQueryPlan( + 'SELECT * FROM "CreatorProfile"' + ); expect(result).toBeNull(); }); diff --git a/src/utils/query-plan.utils.ts b/src/utils/query-plan.utils.ts index c645d27..c7b4a81 100644 --- a/src/utils/query-plan.utils.ts +++ b/src/utils/query-plan.utils.ts @@ -32,10 +32,9 @@ export async function captureQueryPlan( try { // EXPLAIN does not execute the query, so it is safe to run inline. const explainSql = `EXPLAIN (FORMAT JSON) ${sql}`; - const rows = await prisma.$queryRawUnsafe>( - explainSql, - ...params - ); + const rows = await prisma.$queryRawUnsafe< + Array<{ 'QUERY PLAN': QueryPlanNode[] }> + >(explainSql, ...params); return rows[0]?.['QUERY PLAN'] ?? null; } catch (err) { // Never let plan capture crash the request path. diff --git a/src/utils/query.utils.ts b/src/utils/query.utils.ts index 0abef0a..4ad0b6a 100644 --- a/src/utils/query.utils.ts +++ b/src/utils/query.utils.ts @@ -79,20 +79,23 @@ export function safeBooleanQueryParam(options: { }) { const { paramName, defaultValue } = options; - return z.any().optional().transform((raw, ctx) => { - try { - const parsed = parseBoolean(paramName, raw); - return parsed === null ? defaultValue : parsed; - } catch (error) { - if (error instanceof ParseBooleanError) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: error.message, - }); - return z.NEVER; - } + return z + .any() + .optional() + .transform((raw, ctx) => { + try { + const parsed = parseBoolean(paramName, raw); + return parsed === null ? defaultValue : parsed; + } catch (error) { + if (error instanceof ParseBooleanError) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: error.message, + }); + return z.NEVER; + } - throw error; - } - }); + throw error; + } + }); } diff --git a/src/utils/querySignature.test.ts b/src/utils/querySignature.test.ts index f365582..4f156bb 100644 --- a/src/utils/querySignature.test.ts +++ b/src/utils/querySignature.test.ts @@ -7,7 +7,11 @@ async function run() { const query2 = { c: 3, b: 2, a: 1 }; const sig1 = buildQuerySignature(query1); const sig2 = buildQuerySignature(query2); - assert.equal(sig1, sig2, 'Signatures should be identical for same params in different order'); + assert.equal( + sig1, + sig2, + 'Signatures should be identical for same params in different order' + ); // Test sensitive fields are excluded const queryWithSensitive = { @@ -15,43 +19,61 @@ async function run() { search: 'test', token: 'secret123', password: 'pass', - apiKey: 'key123' + apiKey: 'key123', }; const querySafe = { limit: 10, - search: 'test' + search: 'test', }; const sigSensitive = buildQuerySignature(queryWithSensitive); const sigSafe = buildQuerySignature(querySafe); - assert.equal(sigSensitive, sigSafe, 'Sensitive fields should be excluded from signature'); + assert.equal( + sigSensitive, + sigSafe, + 'Sensitive fields should be excluded from signature' + ); // Test empty query returns consistent signature const emptySig1 = buildQuerySignature({}); const emptySig2 = buildQuerySignature({}); - assert.equal(emptySig1, emptySig2, 'Empty query should return consistent signature'); - assert.ok(typeof emptySig1 === 'string' && emptySig1.length === 64, 'Should return 64-character hex string'); + assert.equal( + emptySig1, + emptySig2, + 'Empty query should return consistent signature' + ); + assert.ok( + typeof emptySig1 === 'string' && emptySig1.length === 64, + 'Should return 64-character hex string' + ); // Test different queries produce different signatures const queryA = { limit: 5 }; const queryB = { limit: 10 }; const sigA = buildQuerySignature(queryA); const sigB = buildQuerySignature(queryB); - assert.notEqual(sigA, sigB, 'Different queries should produce different signatures'); + assert.notEqual( + sigA, + sigB, + 'Different queries should produce different signatures' + ); // Test complex values are handled const complexQuery = { arr: [1, 2, 3], obj: { nested: 'value' }, bool: true, - num: 42 + num: 42, }; const complexSig = buildQuerySignature(complexQuery); - assert.ok(typeof complexSig === 'string' && complexSig.length === 64, 'Complex values should be handled'); + assert.ok( + typeof complexSig === 'string' && complexSig.length === 64, + 'Complex values should be handled' + ); console.log('querySignature.utils tests passed'); } -run().catch((err) => { +run().catch(err => { console.error(err); process.exit(1); -}); \ No newline at end of file +}); diff --git a/src/utils/querySignature.ts b/src/utils/querySignature.ts index d0a679c..558ac86 100644 --- a/src/utils/querySignature.ts +++ b/src/utils/querySignature.ts @@ -24,7 +24,9 @@ const SENSITIVE_FIELD_PATTERNS = [ */ function isSensitiveField(fieldName: string): boolean { const lowerField = fieldName.toLowerCase(); - return SENSITIVE_FIELD_PATTERNS.some(pattern => lowerField.includes(pattern)); + return SENSITIVE_FIELD_PATTERNS.some(pattern => + lowerField.includes(pattern) + ); } /** @@ -49,14 +51,18 @@ function isSensitiveField(fieldName: string): boolean { */ export function buildQuerySignature(query: Record): string { // Filter out sensitive fields - const safeEntries = Object.entries(query).filter(([key]) => !isSensitiveField(key)); + const safeEntries = Object.entries(query).filter( + ([key]) => !isSensitiveField(key) + ); // Sort keys alphabetically for deterministic ordering safeEntries.sort(([a], [b]) => a.localeCompare(b)); // Create a normalized string representation - const normalized = safeEntries.map(([key, value]) => `${key}:${JSON.stringify(value)}`).join('|'); + const normalized = safeEntries + .map(([key, value]) => `${key}:${JSON.stringify(value)}`) + .join('|'); // Generate SHA-256 hash return createHash('sha256').update(normalized).digest('hex'); -} \ No newline at end of file +} diff --git a/src/utils/queue-metrics.utils.ts b/src/utils/queue-metrics.utils.ts index ae55097..a2508ee 100644 --- a/src/utils/queue-metrics.utils.ts +++ b/src/utils/queue-metrics.utils.ts @@ -15,31 +15,35 @@ export type QueueState = 'pending' | 'processing' | 'failed' | 'dlq'; export interface QueueDepthEntry { - queue: string; - state: QueueState; - depth: number; - updatedAt: string; + queue: string; + state: QueueState; + depth: number; + updatedAt: string; } const registry = new Map(); function key(queue: string, state: QueueState): string { - return `${queue}:${state}`; + return `${queue}:${state}`; } -export function setQueueDepth(queue: string, state: QueueState, depth: number): void { - registry.set(key(queue, state), { - queue, - state, - depth, - updatedAt: new Date().toISOString(), - }); +export function setQueueDepth( + queue: string, + state: QueueState, + depth: number +): void { + registry.set(key(queue, state), { + queue, + state, + depth, + updatedAt: new Date().toISOString(), + }); } export function getQueueDepths(): QueueDepthEntry[] { - return Array.from(registry.values()); + return Array.from(registry.values()); } export function resetQueueMetrics(): void { - registry.clear(); + registry.clear(); } diff --git a/src/utils/rate-limit-response.utils.ts b/src/utils/rate-limit-response.utils.ts index b20ac49..e5561ab 100644 --- a/src/utils/rate-limit-response.utils.ts +++ b/src/utils/rate-limit-response.utils.ts @@ -1,24 +1,22 @@ import { Response } from 'express'; export interface RateLimitResponse { - type: 'RATE_LIMIT_EXCEEDED'; - message: string; - retryAfterSeconds: number; - timestamp: string; + type: 'RATE_LIMIT_EXCEEDED'; + message: string; + retryAfterSeconds: number; + timestamp: string; } export function sendRateLimitError( - res: Response, - retryAfterSeconds: number = 900 + res: Response, + retryAfterSeconds: number = 900 ): void { - const response: RateLimitResponse = { - type: 'RATE_LIMIT_EXCEEDED', - message: 'Too many requests, please try again later.', - retryAfterSeconds, - timestamp: new Date().toISOString(), - }; + const response: RateLimitResponse = { + type: 'RATE_LIMIT_EXCEEDED', + message: 'Too many requests, please try again later.', + retryAfterSeconds, + timestamp: new Date().toISOString(), + }; - res.status(429) - .set('Retry-After', String(retryAfterSeconds)) - .json(response); + res.status(429).set('Retry-After', String(retryAfterSeconds)).json(response); } diff --git a/src/utils/request-context-hash.utils.ts b/src/utils/request-context-hash.utils.ts index 3796946..89f8941 100644 --- a/src/utils/request-context-hash.utils.ts +++ b/src/utils/request-context-hash.utils.ts @@ -29,10 +29,14 @@ import { Request } from 'express'; * computeRequestContextHash(req) // e.g. "3a9f1c2b4e7d" */ export function computeRequestContextHash(req: Request): string { - const path = (req.path || '/').split('?')[0]; - const contentType = (req.headers['content-type'] ?? '').split(';')[0].trim(); + const path = (req.path || '/').split('?')[0]; + const contentType = (req.headers['content-type'] ?? '').split(';')[0].trim(); - const payload = [req.method.toUpperCase(), path, contentType].join('\x00'); + const payload = [req.method.toUpperCase(), path, contentType].join('\x00'); - return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 12); + return crypto + .createHash('sha256') + .update(payload) + .digest('hex') + .slice(0, 12); } diff --git a/src/utils/route-error.utils.ts b/src/utils/route-error.utils.ts index 48cb4d8..4de173f 100644 --- a/src/utils/route-error.utils.ts +++ b/src/utils/route-error.utils.ts @@ -52,8 +52,8 @@ export function mapUnknownRouteError( typeof fromErr?.statusCode === 'number' ? fromErr.statusCode : typeof fromErr?.status === 'number' - ? fromErr.status - : 500; + ? fromErr.status + : 500; const message = includeDebug ? fromErr?.message || 'Something went wrong' diff --git a/src/utils/rpc-timeout.utils.test.ts b/src/utils/rpc-timeout.utils.test.ts index 12e9442..32b5211 100644 --- a/src/utils/rpc-timeout.utils.test.ts +++ b/src/utils/rpc-timeout.utils.test.ts @@ -1,5 +1,9 @@ import { strict as assert } from 'assert'; -import { withRpcTimeout, RpcTimeoutError, DEFAULT_RPC_TIMEOUT_MS } from './rpc-timeout.utils'; +import { + withRpcTimeout, + RpcTimeoutError, + DEFAULT_RPC_TIMEOUT_MS, +} from './rpc-timeout.utils'; async function run() { // resolves before timeout @@ -11,7 +15,7 @@ async function run() { () => withRpcTimeout( 'slow', - () => new Promise((resolve) => setTimeout(resolve, 200)), + () => new Promise(resolve => setTimeout(resolve, 200)), 50 ), (err: unknown) => { @@ -26,7 +30,8 @@ async function run() { // propagates non-timeout rejections unchanged await assert.rejects( - () => withRpcTimeout('fail', () => Promise.reject(new Error('boom')), 100), + () => + withRpcTimeout('fail', () => Promise.reject(new Error('boom')), 100), (err: unknown) => { assert.ok(err instanceof Error); assert.equal((err as Error).message, 'boom'); @@ -40,7 +45,7 @@ async function run() { console.log('rpc-timeout.utils tests passed'); } -run().catch((err) => { +run().catch(err => { console.error(err); process.exit(1); }); diff --git a/src/utils/slug.utils.ts b/src/utils/slug.utils.ts index 935db75..28a3ff9 100644 --- a/src/utils/slug.utils.ts +++ b/src/utils/slug.utils.ts @@ -37,10 +37,7 @@ export function generateSlug(input: string): string { * @example * generateSlugWithSuffix("John Doe", 2) // "john-doe-2" */ -export function generateSlugWithSuffix( - input: string, - suffix: number -): string { +export function generateSlugWithSuffix(input: string, suffix: number): string { const base = generateSlug(input); return suffix > 0 ? `${base}-${suffix}` : base; } diff --git a/src/utils/sort-tiebreaker.utils.ts b/src/utils/sort-tiebreaker.utils.ts index 2a9f41b..ade1652 100644 --- a/src/utils/sort-tiebreaker.utils.ts +++ b/src/utils/sort-tiebreaker.utils.ts @@ -9,9 +9,9 @@ * Returns negative, zero, or positive — compatible with Array.prototype.sort. */ function compareStrings(a: string, b: string): number { - if (a < b) return -1; - if (a > b) return 1; - return 0; + if (a < b) return -1; + if (a > b) return 1; + return 0; } /** @@ -31,26 +31,26 @@ function compareStrings(a: string, b: string): number { * // Items with equal followers are ordered by id ascending — stable across pages. */ export function withTieBreaker( - primaryKey: keyof T, - direction: 'asc' | 'desc' = 'asc', + primaryKey: keyof T, + direction: 'asc' | 'desc' = 'asc' ): (a: T, b: T) => number { - return (a: T, b: T): number => { - const av = a[primaryKey]; - const bv = b[primaryKey]; + return (a: T, b: T): number => { + const av = a[primaryKey]; + const bv = b[primaryKey]; - let primary: number; + let primary: number; - if (typeof av === 'number' && typeof bv === 'number') { - primary = av - bv; - } else { - primary = compareStrings(String(av ?? ''), String(bv ?? '')); - } + if (typeof av === 'number' && typeof bv === 'number') { + primary = av - bv; + } else { + primary = compareStrings(String(av ?? ''), String(bv ?? '')); + } - if (direction === 'desc') primary = -primary; + if (direction === 'desc') primary = -primary; - // Tie-break on `id` (always ascending) for deterministic order - return primary !== 0 ? primary : compareStrings(a.id, b.id); - }; + // Tie-break on `id` (always ascending) for deterministic order + return primary !== 0 ? primary : compareStrings(a.id, b.id); + }; } /** @@ -62,9 +62,9 @@ export function withTieBreaker( * @param direction - Sort direction for the primary key */ export function stableSortCreators( - items: T[], - key: keyof T, - direction: 'asc' | 'desc' = 'asc', + items: T[], + key: keyof T, + direction: 'asc' | 'desc' = 'asc' ): T[] { - return [...items].sort(withTieBreaker(key, direction)); + return [...items].sort(withTieBreaker(key, direction)); } diff --git a/src/utils/stellar-trade-event-parser.utils.test.ts b/src/utils/stellar-trade-event-parser.utils.test.ts index ce6eb3a..175671d 100644 --- a/src/utils/stellar-trade-event-parser.utils.test.ts +++ b/src/utils/stellar-trade-event-parser.utils.test.ts @@ -1,116 +1,132 @@ import { - parseStellarTradeEvent, - RawSorobanEvent, - TradeEvent, - ParseError, + parseStellarTradeEvent, + RawSorobanEvent, + TradeEvent, + ParseError, } from './stellar-trade-event-parser.utils'; function makeRawEvent( - overrides: Partial = {} + overrides: Partial = {} ): RawSorobanEvent { - return { - ledger: 12345, - ledgerClosedAt: '2026-06-27T12:00:00.000Z', - topic: ['KEY_BOUGHT', 'creator-abc', 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'], - data: JSON.stringify({ amount: '100', price: '50', fee: '1' }), - ...overrides, - }; + return { + ledger: 12345, + ledgerClosedAt: '2026-06-27T12:00:00.000Z', + topic: [ + 'KEY_BOUGHT', + 'creator-abc', + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ], + data: JSON.stringify({ amount: '100', price: '50', fee: '1' }), + ...overrides, + }; } describe('parseStellarTradeEvent', () => { - describe('valid events', () => { - it('maps a valid buy event to the correct schema fields', () => { - const raw = makeRawEvent({ - topic: ['KEY_BOUGHT', 'creator-abc', 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA'], - data: JSON.stringify({ amount: '250', price: '75', fee: '2.5' }), + describe('valid events', () => { + it('maps a valid buy event to the correct schema fields', () => { + const raw = makeRawEvent({ + topic: [ + 'KEY_BOUGHT', + 'creator-abc', + 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA', + ], + data: JSON.stringify({ amount: '250', price: '75', fee: '2.5' }), + }); + + const result = parseStellarTradeEvent(raw); + + expect(result).toEqual({ + event_type: 'buy', + creator_id: 'creator-abc', + actor_address: + 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA', + amount: '250', + price: '75', + fee: '2.5', + ledger_sequence: 12345, + timestamp: '2026-06-27T12:00:00.000Z', + }); }); - const result = parseStellarTradeEvent(raw); - - expect(result).toEqual({ - event_type: 'buy', - creator_id: 'creator-abc', - actor_address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA', - amount: '250', - price: '75', - fee: '2.5', - ledger_sequence: 12345, - timestamp: '2026-06-27T12:00:00.000Z', - }); - }); - - it('maps a valid sell event to the correct schema fields', () => { - const raw = makeRawEvent({ - topic: ['KEY_SOLD', 'creator-xyz', 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCA'], - data: JSON.stringify({ amount: '50', price: '30', fee: '0.5' }), - ledger: 99999, - ledgerClosedAt: '2026-06-28T08:00:00.000Z', + it('maps a valid sell event to the correct schema fields', () => { + const raw = makeRawEvent({ + topic: [ + 'KEY_SOLD', + 'creator-xyz', + 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCA', + ], + data: JSON.stringify({ amount: '50', price: '30', fee: '0.5' }), + ledger: 99999, + ledgerClosedAt: '2026-06-28T08:00:00.000Z', + }); + + const result = parseStellarTradeEvent(raw); + + expect(result).toEqual({ + event_type: 'sell', + creator_id: 'creator-xyz', + actor_address: + 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCA', + amount: '50', + price: '30', + fee: '0.5', + ledger_sequence: 99999, + timestamp: '2026-06-28T08:00:00.000Z', + }); }); + }); - const result = parseStellarTradeEvent(raw); - - expect(result).toEqual({ - event_type: 'sell', - creator_id: 'creator-xyz', - actor_address: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCA', - amount: '50', - price: '30', - fee: '0.5', - ledger_sequence: 99999, - timestamp: '2026-06-28T08:00:00.000Z', - }); - }); - }); + describe('error handling', () => { + it('throws ParseError when a required topic field is missing', () => { + const raw = makeRawEvent({ topic: [] }); - describe('error handling', () => { - it('throws ParseError when a required topic field is missing', () => { - const raw = makeRawEvent({ topic: [] }); + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); + }); - expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); - }); + it('throws ParseError when topic has fewer than 3 segments', () => { + const raw = makeRawEvent({ topic: ['KEY_BOUGHT', 'creator-abc'] }); - it('throws ParseError when topic has fewer than 3 segments', () => { - const raw = makeRawEvent({ topic: ['KEY_BOUGHT', 'creator-abc'] }); + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); + }); - expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); - }); + it('throws ParseError for an unknown event type', () => { + const raw = makeRawEvent({ + topic: ['UNKNOWN_EVENT', 'creator-abc', 'GABCDEFGH'], + }); - it('throws ParseError for an unknown event type', () => { - const raw = makeRawEvent({ topic: ['UNKNOWN_EVENT', 'creator-abc', 'GABCDEFGH'] }); + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); + }); - expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); - }); + it('throws ParseError when an empty string is used for creator_id', () => { + const raw = makeRawEvent({ topic: ['KEY_BOUGHT', '', 'GABCDEFGH'] }); - it('throws ParseError when an empty string is used for creator_id', () => { - const raw = makeRawEvent({ topic: ['KEY_BOUGHT', '', 'GABCDEFGH'] }); + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); + }); - expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); - }); + it('throws ParseError when a required field in data is missing', () => { + const raw = makeRawEvent({ + data: JSON.stringify({ amount: '100', price: '50' }), + }); - it('throws ParseError when a required field in data is missing', () => { - const raw = makeRawEvent({ - data: JSON.stringify({ amount: '100', price: '50' }), + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); }); - expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); - }); - - it('throws ParseError when data is not valid JSON', () => { - const raw = makeRawEvent({ data: 'not-json' }); + it('throws ParseError when data is not valid JSON', () => { + const raw = makeRawEvent({ data: 'not-json' }); - expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); - }); + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); + }); - it('throws ParseError when ledger is not a number', () => { - const raw = makeRawEvent({ ledger: NaN }); + it('throws ParseError when ledger is not a number', () => { + const raw = makeRawEvent({ ledger: NaN }); - expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); - }); + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); + }); - it('throws ParseError when ledgerClosedAt is empty', () => { - const raw = makeRawEvent({ ledgerClosedAt: '' }); + it('throws ParseError when ledgerClosedAt is empty', () => { + const raw = makeRawEvent({ ledgerClosedAt: '' }); - expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); - }); - }); + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); + }); + }); }); diff --git a/src/utils/stellar-trade-event-parser.utils.ts b/src/utils/stellar-trade-event-parser.utils.ts index 50fd3d3..a37e682 100644 --- a/src/utils/stellar-trade-event-parser.utils.ts +++ b/src/utils/stellar-trade-event-parser.utils.ts @@ -7,64 +7,64 @@ */ export class ParseError extends Error { - name = 'ParseError'; + name = 'ParseError'; - constructor(message: string) { - super(message); - } + constructor(message: string) { + super(message); + } } export interface RawSorobanEvent { - /** Ledger sequence number */ - ledger: number; - /** ISO timestamp of the ledger close */ - ledgerClosedAt: string; - /** Decoded event topic segments: [event_symbol, creator_id, actor_address] */ - topic: string[]; - /** Decoded event data as a JSON string containing { amount, price, fee } */ - data: string; + /** Ledger sequence number */ + ledger: number; + /** ISO timestamp of the ledger close */ + ledgerClosedAt: string; + /** Decoded event topic segments: [event_symbol, creator_id, actor_address] */ + topic: string[]; + /** Decoded event data as a JSON string containing { amount, price, fee } */ + data: string; } export interface TradeEvent { - event_type: 'buy' | 'sell'; - creator_id: string; - actor_address: string; - amount: string; - price: string; - fee: string; - ledger_sequence: number; - timestamp: string; + event_type: 'buy' | 'sell'; + creator_id: string; + actor_address: string; + amount: string; + price: string; + fee: string; + ledger_sequence: number; + timestamp: string; } const EVENT_TYPE_MAP: Record = { - KEY_BOUGHT: 'buy', - KEY_SOLD: 'sell', + KEY_BOUGHT: 'buy', + KEY_SOLD: 'sell', }; function requireString( - value: unknown, - fieldName: string, - context: string + value: unknown, + fieldName: string, + context: string ): string { - if (typeof value !== 'string' || value.length === 0) { - throw new ParseError( - `Missing or invalid required field "${fieldName}" in ${context}` - ); - } - return value; + if (typeof value !== 'string' || value.length === 0) { + throw new ParseError( + `Missing or invalid required field "${fieldName}" in ${context}` + ); + } + return value; } function requireNumber( - value: unknown, - fieldName: string, - context: string + value: unknown, + fieldName: string, + context: string ): number { - if (typeof value !== 'number' || isNaN(value)) { - throw new ParseError( - `Missing or invalid required field "${fieldName}" in ${context}` - ); - } - return value; + if (typeof value !== 'number' || isNaN(value)) { + throw new ParseError( + `Missing or invalid required field "${fieldName}" in ${context}` + ); + } + return value; } /** @@ -75,53 +75,49 @@ function requireNumber( * @throws {ParseError} when any required field is missing or invalid */ export function parseStellarTradeEvent(rawEvent: RawSorobanEvent): TradeEvent { - const { ledger, ledgerClosedAt, topic, data } = rawEvent; + const { ledger, ledgerClosedAt, topic, data } = rawEvent; - const rawType = requireString(topic?.[0], 'topic[0] (event_type)', 'topic'); - const event_type = EVENT_TYPE_MAP[rawType]; - if (!event_type) { - throw new ParseError( - `Unknown event type "${rawType}" — expected KEY_BOUGHT or KEY_SOLD` - ); - } + const rawType = requireString(topic?.[0], 'topic[0] (event_type)', 'topic'); + const event_type = EVENT_TYPE_MAP[rawType]; + if (!event_type) { + throw new ParseError( + `Unknown event type "${rawType}" — expected KEY_BOUGHT or KEY_SOLD` + ); + } - const creator_id = requireString(topic[1], 'topic[1] (creator_id)', 'topic'); - const actor_address = requireString( - topic[2], - 'topic[2] (actor_address)', - 'topic' - ); + const creator_id = requireString(topic[1], 'topic[1] (creator_id)', 'topic'); + const actor_address = requireString( + topic[2], + 'topic[2] (actor_address)', + 'topic' + ); - const ledger_sequence = requireNumber(ledger, 'ledger', 'root'); - const timestamp = requireString( - ledgerClosedAt, - 'ledgerClosedAt', - 'root' - ); + const ledger_sequence = requireNumber(ledger, 'ledger', 'root'); + const timestamp = requireString(ledgerClosedAt, 'ledgerClosedAt', 'root'); - let parsedData: Record; - try { - parsedData = JSON.parse(data); - } catch { - throw new ParseError('data must be a valid JSON string'); - } + let parsedData: Record; + try { + parsedData = JSON.parse(data); + } catch { + throw new ParseError('data must be a valid JSON string'); + } - if (typeof parsedData !== 'object' || parsedData === null) { - throw new ParseError('data must be a JSON object'); - } + if (typeof parsedData !== 'object' || parsedData === null) { + throw new ParseError('data must be a JSON object'); + } - const amount = requireString(parsedData.amount, 'amount', 'data'); - const price = requireString(parsedData.price, 'price', 'data'); - const fee = requireString(parsedData.fee, 'fee', 'data'); + const amount = requireString(parsedData.amount, 'amount', 'data'); + const price = requireString(parsedData.price, 'price', 'data'); + const fee = requireString(parsedData.fee, 'fee', 'data'); - return { - event_type, - creator_id, - actor_address, - amount, - price, - fee, - ledger_sequence, - timestamp, - }; + return { + event_type, + creator_id, + actor_address, + amount, + price, + fee, + ledger_sequence, + timestamp, + }; } diff --git a/src/utils/test/api-response.utils.test.ts b/src/utils/test/api-response.utils.test.ts index 27b6405..3c9bb3f 100644 --- a/src/utils/test/api-response.utils.test.ts +++ b/src/utils/test/api-response.utils.test.ts @@ -86,7 +86,10 @@ describe('api-response.utils', () => { describe('buildErrorResponse', () => { it('returns a well-formed error body without requestId when no ALS context is active', () => { - const body = buildErrorResponse(ErrorCode.NOT_FOUND, 'Resource not found'); + const body = buildErrorResponse( + ErrorCode.NOT_FOUND, + 'Resource not found' + ); expect(body).toEqual({ success: false, error: { code: ErrorCode.NOT_FOUND, message: 'Resource not found' }, @@ -107,23 +110,28 @@ describe('buildErrorResponse', () => { it('omits requestId when ALS context has no requestId', () => { let body: ReturnType | undefined; - requestContextStorage.run( - { path: '/test', method: 'GET' }, - () => { - body = buildErrorResponse(ErrorCode.INTERNAL_ERROR, 'Oops'); - } - ); + requestContextStorage.run({ path: '/test', method: 'GET' }, () => { + body = buildErrorResponse(ErrorCode.INTERNAL_ERROR, 'Oops'); + }); expect(body!).not.toHaveProperty('requestId'); }); it('includes details when provided', () => { const details = [{ field: 'email', message: 'Required' }]; - const body = buildErrorResponse(ErrorCode.VALIDATION_ERROR, 'Invalid', details); + const body = buildErrorResponse( + ErrorCode.VALIDATION_ERROR, + 'Invalid', + details + ); expect(body.error.details).toEqual(details); }); it('omits details key when details array is empty', () => { - const body = buildErrorResponse(ErrorCode.VALIDATION_ERROR, 'Invalid', []); + const body = buildErrorResponse( + ErrorCode.VALIDATION_ERROR, + 'Invalid', + [] + ); expect(body.error).not.toHaveProperty('details'); }); @@ -136,7 +144,11 @@ describe('buildErrorResponse', () => { let body: ReturnType | undefined; requestContextStorage.run( - { path: '/api/v1/creators', method: 'GET', requestId: expectedRequestId }, + { + path: '/api/v1/creators', + method: 'GET', + requestId: expectedRequestId, + }, () => { capturedRequestId = requestContextStorage.getStore()?.requestId; body = buildErrorResponse(ErrorCode.INTERNAL_ERROR, 'Server error'); @@ -152,23 +164,37 @@ describe('buildErrorResponse', () => { describe('zodIssuesToDetails', () => { it('maps a single issue to a details entry', () => { const result = zodIssuesToDetails([ - { path: ['email'], message: 'Invalid email', code: 'invalid_string' } as any, + { + path: ['email'], + message: 'Invalid email', + code: 'invalid_string', + } as any, ]); expect(result).toEqual([{ field: 'email', message: 'Invalid email' }]); }); it('joins nested paths with a dot', () => { const result = zodIssuesToDetails([ - { path: ['address', 'city'], message: 'Required', code: 'invalid_type' } as any, + { + path: ['address', 'city'], + message: 'Required', + code: 'invalid_type', + } as any, ]); expect(result).toEqual([{ field: 'address.city', message: 'Required' }]); }); it('produces an empty string field for root-level issues', () => { const result = zodIssuesToDetails([ - { path: [], message: 'Input must be an object', code: 'invalid_type' } as any, + { + path: [], + message: 'Input must be an object', + code: 'invalid_type', + } as any, + ]); + expect(result).toEqual([ + { field: '', message: 'Input must be an object' }, ]); - expect(result).toEqual([{ field: '', message: 'Input must be an object' }]); }); it('returns an empty array for an empty issues list', () => { @@ -178,7 +204,11 @@ describe('zodIssuesToDetails', () => { it('maps multiple issues preserving order', () => { const result = zodIssuesToDetails([ { path: ['name'], message: 'Required', code: 'invalid_type' } as any, - { path: ['age'], message: 'Must be a number', code: 'invalid_type' } as any, + { + path: ['age'], + message: 'Must be a number', + code: 'invalid_type', + } as any, ]); expect(result).toEqual([ { field: 'name', message: 'Required' }, diff --git a/src/utils/test/comma-query.utils.test.ts b/src/utils/test/comma-query.utils.test.ts index 9b3adbf..bb0b22a 100644 --- a/src/utils/test/comma-query.utils.test.ts +++ b/src/utils/test/comma-query.utils.test.ts @@ -1,93 +1,97 @@ import { parseCommaQuery } from '../comma-query.utils'; describe('parseCommaQuery()', () => { - // ── Null / undefined ────────────────────────────────────────────────────── + // ── Null / undefined ────────────────────────────────────────────────────── - it('returns [] for undefined', () => { - expect(parseCommaQuery(undefined)).toEqual([]); - }); + it('returns [] for undefined', () => { + expect(parseCommaQuery(undefined)).toEqual([]); + }); - it('returns [] for null', () => { - expect(parseCommaQuery(null)).toEqual([]); - }); + it('returns [] for null', () => { + expect(parseCommaQuery(null)).toEqual([]); + }); - // ── Empty / whitespace-only input ───────────────────────────────────────── + // ── Empty / whitespace-only input ───────────────────────────────────────── - it('returns [] for empty string', () => { - expect(parseCommaQuery('')).toEqual([]); - }); + it('returns [] for empty string', () => { + expect(parseCommaQuery('')).toEqual([]); + }); - it('returns [] for whitespace-only string', () => { - expect(parseCommaQuery(' ')).toEqual([]); - }); + it('returns [] for whitespace-only string', () => { + expect(parseCommaQuery(' ')).toEqual([]); + }); - it('returns [] for commas-only string', () => { - expect(parseCommaQuery(',,,,')).toEqual([]); - }); + it('returns [] for commas-only string', () => { + expect(parseCommaQuery(',,,,')).toEqual([]); + }); - // ── Single value ────────────────────────────────────────────────────────── + // ── Single value ────────────────────────────────────────────────────────── - it('returns single-element array for a plain string', () => { - expect(parseCommaQuery('alpha')).toEqual(['alpha']); - }); + it('returns single-element array for a plain string', () => { + expect(parseCommaQuery('alpha')).toEqual(['alpha']); + }); - it('trims a single token', () => { - expect(parseCommaQuery(' alpha ')).toEqual(['alpha']); - }); + it('trims a single token', () => { + expect(parseCommaQuery(' alpha ')).toEqual(['alpha']); + }); - // ── Multiple values ─────────────────────────────────────────────────────── + // ── Multiple values ─────────────────────────────────────────────────────── - it('splits on commas', () => { - expect(parseCommaQuery('a,b,c')).toEqual(['a', 'b', 'c']); - }); + it('splits on commas', () => { + expect(parseCommaQuery('a,b,c')).toEqual(['a', 'b', 'c']); + }); - it('trims each token', () => { - expect(parseCommaQuery('a, b , c')).toEqual(['a', 'b', 'c']); - }); + it('trims each token', () => { + expect(parseCommaQuery('a, b , c')).toEqual(['a', 'b', 'c']); + }); - it('drops empty tokens between commas', () => { - expect(parseCommaQuery('a,,b')).toEqual(['a', 'b']); - }); + it('drops empty tokens between commas', () => { + expect(parseCommaQuery('a,,b')).toEqual(['a', 'b']); + }); - it('drops whitespace-only tokens', () => { - expect(parseCommaQuery('a, , b')).toEqual(['a', 'b']); - }); + it('drops whitespace-only tokens', () => { + expect(parseCommaQuery('a, , b')).toEqual(['a', 'b']); + }); - // ── Deduplication ───────────────────────────────────────────────────────── + // ── Deduplication ───────────────────────────────────────────────────────── - it('deduplicates identical tokens', () => { - expect(parseCommaQuery('a,b,a')).toEqual(['a', 'b']); - }); + it('deduplicates identical tokens', () => { + expect(parseCommaQuery('a,b,a')).toEqual(['a', 'b']); + }); - it('preserves first-occurrence order during deduplication', () => { - expect(parseCommaQuery('c,a,b,a,c')).toEqual(['c', 'a', 'b']); - }); + it('preserves first-occurrence order during deduplication', () => { + expect(parseCommaQuery('c,a,b,a,c')).toEqual(['c', 'a', 'b']); + }); - it('deduplication is case-sensitive', () => { - expect(parseCommaQuery('A,a,A')).toEqual(['A', 'a']); - }); + it('deduplication is case-sensitive', () => { + expect(parseCommaQuery('A,a,A')).toEqual(['A', 'a']); + }); - // ── Array input ─────────────────────────────────────────────────────────── + // ── Array input ─────────────────────────────────────────────────────────── - it('handles string[] by flattening', () => { - expect(parseCommaQuery(['a,b', 'c'])).toEqual(['a', 'b', 'c']); - }); + it('handles string[] by flattening', () => { + expect(parseCommaQuery(['a,b', 'c'])).toEqual(['a', 'b', 'c']); + }); - it('deduplicates across array elements', () => { - expect(parseCommaQuery(['a,b', 'b,c'])).toEqual(['a', 'b', 'c']); - }); + it('deduplicates across array elements', () => { + expect(parseCommaQuery(['a,b', 'b,c'])).toEqual(['a', 'b', 'c']); + }); - it('trims tokens coming from array input', () => { - expect(parseCommaQuery([' a , b ', ' c '])).toEqual(['a', 'b', 'c']); - }); + it('trims tokens coming from array input', () => { + expect(parseCommaQuery([' a , b ', ' c '])).toEqual(['a', 'b', 'c']); + }); - it('returns [] for array of empty strings', () => { - expect(parseCommaQuery(['', '', ''])).toEqual([]); - }); + it('returns [] for array of empty strings', () => { + expect(parseCommaQuery(['', '', ''])).toEqual([]); + }); - // ── Real-world query-string shapes ─────────────────────────────────────── + // ── Real-world query-string shapes ─────────────────────────────────────── - it('handles typical multi-tag filter string', () => { - expect(parseCommaQuery('music, sports , art,music')).toEqual(['music', 'sports', 'art']); - }); + it('handles typical multi-tag filter string', () => { + expect(parseCommaQuery('music, sports , art,music')).toEqual([ + 'music', + 'sports', + 'art', + ]); + }); }); diff --git a/src/utils/test/creator-id.utils.test.ts b/src/utils/test/creator-id.utils.test.ts index bb510eb..2d83c66 100644 --- a/src/utils/test/creator-id.utils.test.ts +++ b/src/utils/test/creator-id.utils.test.ts @@ -2,57 +2,67 @@ import { parseCreatorId } from '../creator-id.utils'; import { ApiError } from '../../middlewares/error.middleware'; describe('parseCreatorId', () => { - it('parses a valid positive integer string', () => { - expect(parseCreatorId('42')).toBe(42); - expect(parseCreatorId('1')).toBe(1); - expect(parseCreatorId('999999')).toBe(999999); - }); - - it('parses a trimmed integer string', () => { - expect(parseCreatorId(' 42 ')).toBe(42); - }); - - it('throws a 400 error for a float string', () => { - expect(() => parseCreatorId('3.14')).toThrow(ApiError); - expect(() => parseCreatorId('3.14')).toThrow('Creator ID must be a positive integer'); - }); - - it('throws a 400 error for a negative number', () => { - expect(() => parseCreatorId('-5')).toThrow(ApiError); - expect(() => parseCreatorId('-5')).toThrow('Creator ID must be a positive integer'); - }); - - it('throws a 400 error for a non-numeric string', () => { - expect(() => parseCreatorId('abc')).toThrow(ApiError); - expect(() => parseCreatorId('abc')).toThrow('Creator ID must be a positive integer'); - }); - - it('throws a 400 error for an empty string', () => { - expect(() => parseCreatorId('')).toThrow(ApiError); - expect(() => parseCreatorId('')).toThrow('Creator ID is required'); - }); - - it('throws a 400 error for a whitespace-only string', () => { - expect(() => parseCreatorId(' ')).toThrow(ApiError); - expect(() => parseCreatorId(' ')).toThrow('Creator ID is required'); - }); - - it('throws a 400 error for a string with trailing non-digits', () => { - expect(() => parseCreatorId('42abc')).toThrow(ApiError); - expect(() => parseCreatorId('42abc')).toThrow('Creator ID must be a positive integer'); - }); - - it('throws a 400 error for zero', () => { - expect(() => parseCreatorId('0')).toThrow(ApiError); - expect(() => parseCreatorId('0')).toThrow('Creator ID must be a positive integer'); - }); - - it('sets statusCode 400 on the thrown error', () => { - try { - parseCreatorId('not-a-number'); - } catch (error) { - expect(error).toBeInstanceOf(ApiError); - expect((error as ApiError).statusCode).toBe(400); - } - }); + it('parses a valid positive integer string', () => { + expect(parseCreatorId('42')).toBe(42); + expect(parseCreatorId('1')).toBe(1); + expect(parseCreatorId('999999')).toBe(999999); + }); + + it('parses a trimmed integer string', () => { + expect(parseCreatorId(' 42 ')).toBe(42); + }); + + it('throws a 400 error for a float string', () => { + expect(() => parseCreatorId('3.14')).toThrow(ApiError); + expect(() => parseCreatorId('3.14')).toThrow( + 'Creator ID must be a positive integer' + ); + }); + + it('throws a 400 error for a negative number', () => { + expect(() => parseCreatorId('-5')).toThrow(ApiError); + expect(() => parseCreatorId('-5')).toThrow( + 'Creator ID must be a positive integer' + ); + }); + + it('throws a 400 error for a non-numeric string', () => { + expect(() => parseCreatorId('abc')).toThrow(ApiError); + expect(() => parseCreatorId('abc')).toThrow( + 'Creator ID must be a positive integer' + ); + }); + + it('throws a 400 error for an empty string', () => { + expect(() => parseCreatorId('')).toThrow(ApiError); + expect(() => parseCreatorId('')).toThrow('Creator ID is required'); + }); + + it('throws a 400 error for a whitespace-only string', () => { + expect(() => parseCreatorId(' ')).toThrow(ApiError); + expect(() => parseCreatorId(' ')).toThrow('Creator ID is required'); + }); + + it('throws a 400 error for a string with trailing non-digits', () => { + expect(() => parseCreatorId('42abc')).toThrow(ApiError); + expect(() => parseCreatorId('42abc')).toThrow( + 'Creator ID must be a positive integer' + ); + }); + + it('throws a 400 error for zero', () => { + expect(() => parseCreatorId('0')).toThrow(ApiError); + expect(() => parseCreatorId('0')).toThrow( + 'Creator ID must be a positive integer' + ); + }); + + it('sets statusCode 400 on the thrown error', () => { + try { + parseCreatorId('not-a-number'); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).statusCode).toBe(400); + } + }); }); diff --git a/src/utils/test/cursor.utils.test.ts b/src/utils/test/cursor.utils.test.ts index 5f0c64c..aef276d 100644 --- a/src/utils/test/cursor.utils.test.ts +++ b/src/utils/test/cursor.utils.test.ts @@ -12,22 +12,25 @@ import { } from '../cursor.utils'; describe('Cursor Utils', () => { - const samplePayload = { id: 'user_123', createdAt: '2023-01-01T00:00:00.000Z' }; + const samplePayload = { + id: 'user_123', + createdAt: '2023-01-01T00:00:00.000Z', + }; describe('generateCursorChecksum', () => { it('should return a deterministic 64-character hex string for the same input', () => { const checksum1 = generateCursorChecksum('test_payload'); const checksum2 = generateCursorChecksum('test_payload'); - + expect(checksum1).toBe(checksum2); expect(checksum1).toHaveLength(64); expect(/^[0-9a-f]{64}$/.test(checksum1)).toBe(true); }); - + it('should return different checksums for different inputs', () => { const checksum1 = generateCursorChecksum('test_payload_1'); const checksum2 = generateCursorChecksum('test_payload_2'); - + expect(checksum1).not.toBe(checksum2); }); }); @@ -58,7 +61,9 @@ describe('Cursor Utils', () => { }); it('should correctly decode a valid legacy cursor without a checksum', () => { - const legacyCursor = Buffer.from(JSON.stringify(samplePayload)).toString('base64url'); + const legacyCursor = Buffer.from( + JSON.stringify(samplePayload) + ).toString('base64url'); const decoded = decodeCursor(legacyCursor); expect(decoded).toEqual(samplePayload); }); @@ -66,20 +71,27 @@ describe('Cursor Utils', () => { it('should throw CursorChecksumError when checksum is tampered', () => { const cursor = encodeCursor(samplePayload); const [payload, checksum] = cursor.split('.'); - const tamperedChecksum = checksum.substring(0, 63) + (checksum.endsWith('a') ? 'b' : 'a'); + const tamperedChecksum = + checksum.substring(0, 63) + (checksum.endsWith('a') ? 'b' : 'a'); const tamperedCursor = `${payload}.${tamperedChecksum}`; - - expect(() => decodeCursor(tamperedCursor)).toThrow(CursorChecksumError); + + expect(() => decodeCursor(tamperedCursor)).toThrow( + CursorChecksumError + ); }); it('should throw CursorChecksumError when payload is tampered', () => { const cursor = encodeCursor(samplePayload); const [payload, checksum] = cursor.split('.'); // Change base64 by modifying a character - const tamperedPayload = payload.substring(0, payload.length - 1) + (payload.endsWith('a') ? 'b' : 'a'); + const tamperedPayload = + payload.substring(0, payload.length - 1) + + (payload.endsWith('a') ? 'b' : 'a'); const tamperedCursor = `${tamperedPayload}.${checksum}`; - - expect(() => decodeCursor(tamperedCursor)).toThrow(CursorChecksumError); + + expect(() => decodeCursor(tamperedCursor)).toThrow( + CursorChecksumError + ); }); it('should throw CursorChecksumError for empty string inputs', () => { @@ -88,19 +100,18 @@ describe('Cursor Utils', () => { expect(() => decodeCursor('payload.')).toThrow(CursorChecksumError); expect(() => decodeCursor('.checksum')).toThrow(CursorChecksumError); }); - + it('should throw CursorChecksumError for invalid target typed primitives', () => { - - expect(() => decodeCursor(123 as any)).toThrow(CursorChecksumError); + expect(() => decodeCursor(123 as any)).toThrow(CursorChecksumError); }); - + it('should throw CursorChecksumError for malformed JSON payload', () => { - const badJsonStr = 'bad_json_string'; - const payload = Buffer.from(badJsonStr).toString('base64url'); - const checksum = generateCursorChecksum(payload); - const cursor = `${payload}.${checksum}`; - - expect(() => decodeCursor(cursor)).toThrow(CursorChecksumError); + const badJsonStr = 'bad_json_string'; + const payload = Buffer.from(badJsonStr).toString('base64url'); + const checksum = generateCursorChecksum(payload); + const cursor = `${payload}.${checksum}`; + + expect(() => decodeCursor(cursor)).toThrow(CursorChecksumError); }); }); }); diff --git a/src/utils/test/hash-request-body.utils.test.ts b/src/utils/test/hash-request-body.utils.test.ts index 730924f..9f568c2 100644 --- a/src/utils/test/hash-request-body.utils.test.ts +++ b/src/utils/test/hash-request-body.utils.test.ts @@ -1,102 +1,102 @@ import { hashRequestBody } from '../hash-request-body.utils'; describe('hashRequestBody()', () => { - // ── Output format ────────────────────────────────────────────────────────── - - it('returns a 64-character hex string', () => { - const hash = hashRequestBody({}); - expect(hash).toMatch(/^[0-9a-f]{64}$/); - }); - - // ── Determinism ──────────────────────────────────────────────────────────── - - it('returns the same hash for identical objects', () => { - const body = { name: 'alert', channel: 'email' }; - expect(hashRequestBody(body)).toBe(hashRequestBody(body)); - }); - - it('produces the same hash for objects with keys in different insertion order', () => { - const a: Record = { name: 'alert', channel: 'email' }; - const b: Record = { channel: 'email', name: 'alert' }; - expect(hashRequestBody(a)).toBe(hashRequestBody(b)); - }); - - // ── Sensitivity to values ────────────────────────────────────────────────── - - it('produces different hashes for different objects', () => { - const a = { name: 'alert', channel: 'email' }; - const b = { name: 'webhook', channel: 'slack' }; - expect(hashRequestBody(a)).not.toBe(hashRequestBody(b)); - }); - - it('produces different hashes when a single field value changes', () => { - const a = { name: 'alert', channel: 'email' }; - const b = { name: 'alert', channel: 'sms' }; - expect(hashRequestBody(a)).not.toBe(hashRequestBody(b)); - }); - - // ── Empty object ─────────────────────────────────────────────────────────── - - it('produces a stable hash for an empty object', () => { - const first = hashRequestBody({}); - const second = hashRequestBody({}); - expect(first).toBe(second); - expect(first).toMatch(/^[0-9a-f]{64}$/); - }); - - // ── Primitive and edge-case inputs ───────────────────────────────────────── - - it('hashes null consistently', () => { - expect(hashRequestBody(null)).toBe(hashRequestBody(null)); - }); - - it('hashes undefined consistently', () => { - expect(hashRequestBody(undefined)).toBe(hashRequestBody(undefined)); - }); - - it('hashes strings consistently', () => { - expect(hashRequestBody('hello')).toBe(hashRequestBody('hello')); - }); - - it('produces different hashes for different strings', () => { - expect(hashRequestBody('hello')).not.toBe(hashRequestBody('world')); - }); - - it('hashes numbers consistently', () => { - expect(hashRequestBody(42)).toBe(hashRequestBody(42)); - }); - - it('produces different hashes for different numbers', () => { - expect(hashRequestBody(1)).not.toBe(hashRequestBody(2)); - }); - - it('hashes booleans consistently', () => { - expect(hashRequestBody(true)).toBe(hashRequestBody(true)); - expect(hashRequestBody(false)).toBe(hashRequestBody(false)); - }); - - it('hashes arrays consistently', () => { - expect(hashRequestBody([1, 2, 3])).toBe(hashRequestBody([1, 2, 3])); - }); - - it('produces different hashes for different arrays', () => { - expect(hashRequestBody([1, 2, 3])).not.toBe(hashRequestBody([1, 2, 4])); - }); - - it('produces the same hash for objects with undefined values', () => { - const a: Record = { a: 1, b: undefined }; - const b: Record = { b: undefined, a: 1 }; - expect(hashRequestBody(a)).toBe(hashRequestBody(b)); - }); - - it('hashes nested objects consistently', () => { - const body = { alert: { name: 'test', settings: { retries: 3 } } }; - expect(hashRequestBody(body)).toBe(hashRequestBody(body)); - }); - - it('produces different hashes for different nested objects', () => { - const a = { alert: { name: 'test', retries: 3 } }; - const b = { alert: { name: 'test', retries: 5 } }; - expect(hashRequestBody(a)).not.toBe(hashRequestBody(b)); - }); + // ── Output format ────────────────────────────────────────────────────────── + + it('returns a 64-character hex string', () => { + const hash = hashRequestBody({}); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + // ── Determinism ──────────────────────────────────────────────────────────── + + it('returns the same hash for identical objects', () => { + const body = { name: 'alert', channel: 'email' }; + expect(hashRequestBody(body)).toBe(hashRequestBody(body)); + }); + + it('produces the same hash for objects with keys in different insertion order', () => { + const a: Record = { name: 'alert', channel: 'email' }; + const b: Record = { channel: 'email', name: 'alert' }; + expect(hashRequestBody(a)).toBe(hashRequestBody(b)); + }); + + // ── Sensitivity to values ────────────────────────────────────────────────── + + it('produces different hashes for different objects', () => { + const a = { name: 'alert', channel: 'email' }; + const b = { name: 'webhook', channel: 'slack' }; + expect(hashRequestBody(a)).not.toBe(hashRequestBody(b)); + }); + + it('produces different hashes when a single field value changes', () => { + const a = { name: 'alert', channel: 'email' }; + const b = { name: 'alert', channel: 'sms' }; + expect(hashRequestBody(a)).not.toBe(hashRequestBody(b)); + }); + + // ── Empty object ─────────────────────────────────────────────────────────── + + it('produces a stable hash for an empty object', () => { + const first = hashRequestBody({}); + const second = hashRequestBody({}); + expect(first).toBe(second); + expect(first).toMatch(/^[0-9a-f]{64}$/); + }); + + // ── Primitive and edge-case inputs ───────────────────────────────────────── + + it('hashes null consistently', () => { + expect(hashRequestBody(null)).toBe(hashRequestBody(null)); + }); + + it('hashes undefined consistently', () => { + expect(hashRequestBody(undefined)).toBe(hashRequestBody(undefined)); + }); + + it('hashes strings consistently', () => { + expect(hashRequestBody('hello')).toBe(hashRequestBody('hello')); + }); + + it('produces different hashes for different strings', () => { + expect(hashRequestBody('hello')).not.toBe(hashRequestBody('world')); + }); + + it('hashes numbers consistently', () => { + expect(hashRequestBody(42)).toBe(hashRequestBody(42)); + }); + + it('produces different hashes for different numbers', () => { + expect(hashRequestBody(1)).not.toBe(hashRequestBody(2)); + }); + + it('hashes booleans consistently', () => { + expect(hashRequestBody(true)).toBe(hashRequestBody(true)); + expect(hashRequestBody(false)).toBe(hashRequestBody(false)); + }); + + it('hashes arrays consistently', () => { + expect(hashRequestBody([1, 2, 3])).toBe(hashRequestBody([1, 2, 3])); + }); + + it('produces different hashes for different arrays', () => { + expect(hashRequestBody([1, 2, 3])).not.toBe(hashRequestBody([1, 2, 4])); + }); + + it('produces the same hash for objects with undefined values', () => { + const a: Record = { a: 1, b: undefined }; + const b: Record = { b: undefined, a: 1 }; + expect(hashRequestBody(a)).toBe(hashRequestBody(b)); + }); + + it('hashes nested objects consistently', () => { + const body = { alert: { name: 'test', settings: { retries: 3 } } }; + expect(hashRequestBody(body)).toBe(hashRequestBody(body)); + }); + + it('produces different hashes for different nested objects', () => { + const a = { alert: { name: 'test', retries: 3 } }; + const b = { alert: { name: 'test', retries: 5 } }; + expect(hashRequestBody(a)).not.toBe(hashRequestBody(b)); + }); }); diff --git a/src/utils/test/indexer-cursor-staleness.utils.test.ts b/src/utils/test/indexer-cursor-staleness.utils.test.ts index 2152a19..627b8fb 100644 --- a/src/utils/test/indexer-cursor-staleness.utils.test.ts +++ b/src/utils/test/indexer-cursor-staleness.utils.test.ts @@ -31,7 +31,9 @@ const findFirstMock = prisma.indexedLedger.findFirst as jest.Mock; beforeEach(() => { warnMock.mockClear(); findFirstMock.mockReset(); - (envConfig as { ENABLE_INDEXER_CURSOR_STALENESS_WARNING: boolean }).ENABLE_INDEXER_CURSOR_STALENESS_WARNING = true; + ( + envConfig as { ENABLE_INDEXER_CURSOR_STALENESS_WARNING: boolean } + ).ENABLE_INDEXER_CURSOR_STALENESS_WARNING = true; }); describe('warnIfIndexerCursorStale()', () => { @@ -84,7 +86,9 @@ describe('warnIfIndexerCursorStale()', () => { }); it('does not emit a warning when ENABLE_INDEXER_CURSOR_STALENESS_WARNING is false', () => { - (envConfig as { ENABLE_INDEXER_CURSOR_STALENESS_WARNING: boolean }).ENABLE_INDEXER_CURSOR_STALENESS_WARNING = false; + ( + envConfig as { ENABLE_INDEXER_CURSOR_STALENESS_WARNING: boolean } + ).ENABLE_INDEXER_CURSOR_STALENESS_WARNING = false; const sixMinutesAgo = new Date(Date.now() - 360_000); warnIfIndexerCursorStale(sixMinutesAgo, 300_000); expect(warnMock).not.toHaveBeenCalled(); diff --git a/src/utils/test/indexer-dlq.utils.test.ts b/src/utils/test/indexer-dlq.utils.test.ts index 96e4926..47b30c6 100644 --- a/src/utils/test/indexer-dlq.utils.test.ts +++ b/src/utils/test/indexer-dlq.utils.test.ts @@ -3,71 +3,74 @@ import { prisma } from '../prisma.utils'; import { setQueueDepth } from '../queue-metrics.utils'; jest.mock('../prisma.utils', () => ({ - prisma: { - indexerDLQ: { - create: jest.fn(), - count: jest.fn(), - }, - }, + prisma: { + indexerDLQ: { + create: jest.fn(), + count: jest.fn(), + }, + }, })); jest.mock('../queue-metrics.utils', () => ({ - setQueueDepth: jest.fn(), + setQueueDepth: jest.fn(), })); describe('indexer-dlq.utils', () => { - describe('moveToDLQ', () => { - it('should call prisma.indexerDLQ.create with correct data', async () => { - const params = { - jobType: 'TEST_JOB', - payload: { foo: 'bar' }, - retryCount: 3, - failureReason: 'Test failure', - errorDetails: 'Stack trace', - }; + describe('moveToDLQ', () => { + it('should call prisma.indexerDLQ.create with correct data', async () => { + const params = { + jobType: 'TEST_JOB', + payload: { foo: 'bar' }, + retryCount: 3, + failureReason: 'Test failure', + errorDetails: 'Stack trace', + }; - (prisma.indexerDLQ.create as jest.Mock).mockResolvedValue({ id: '1', ...params }); + (prisma.indexerDLQ.create as jest.Mock).mockResolvedValue({ + id: '1', + ...params, + }); - await moveToDLQ(params); + await moveToDLQ(params); - expect(prisma.indexerDLQ.create).toHaveBeenCalledWith({ - data: params, + expect(prisma.indexerDLQ.create).toHaveBeenCalledWith({ + data: params, + }); }); - }); - }); + }); - describe('getDLQDepth', () => { - it('should call prisma.indexerDLQ.count without where if jobType is not provided', async () => { - (prisma.indexerDLQ.count as jest.Mock).mockResolvedValue(5); + describe('getDLQDepth', () => { + it('should call prisma.indexerDLQ.count without where if jobType is not provided', async () => { + (prisma.indexerDLQ.count as jest.Mock).mockResolvedValue(5); - const depth = await getDLQDepth(); + const depth = await getDLQDepth(); - expect(depth).toBe(5); - expect(prisma.indexerDLQ.count).toHaveBeenCalledWith({ - where: undefined, + expect(depth).toBe(5); + expect(prisma.indexerDLQ.count).toHaveBeenCalledWith({ + where: undefined, + }); }); - }); - it('should call prisma.indexerDLQ.count with where if jobType is provided', async () => { - (prisma.indexerDLQ.count as jest.Mock).mockResolvedValue(2); + it('should call prisma.indexerDLQ.count with where if jobType is provided', async () => { + (prisma.indexerDLQ.count as jest.Mock).mockResolvedValue(2); - const depth = await getDLQDepth('TEST_JOB'); + const depth = await getDLQDepth('TEST_JOB'); - expect(depth).toBe(2); - expect(prisma.indexerDLQ.count).toHaveBeenCalledWith({ - where: { jobType: 'TEST_JOB' }, + expect(depth).toBe(2); + expect(prisma.indexerDLQ.count).toHaveBeenCalledWith({ + where: { jobType: 'TEST_JOB' }, + }); }); - }); - }); + }); - describe('syncDLQMetrics', () => { - it('should call getDLQDepth and then setQueueDepth', async () => { - (prisma.indexerDLQ.count as jest.Mock).mockResolvedValue(10); + describe('syncDLQMetrics', () => { + it('should call getDLQDepth and then setQueueDepth', async () => { + (prisma.indexerDLQ.count as jest.Mock).mockResolvedValue(10); - await syncDLQMetrics(); + await syncDLQMetrics(); - expect(prisma.indexerDLQ.count).toHaveBeenCalled(); - expect(setQueueDepth).toHaveBeenCalledWith('indexer', 'dlq', 10); - }); - }); + expect(prisma.indexerDLQ.count).toHaveBeenCalled(); + expect(setQueueDepth).toHaveBeenCalledWith('indexer', 'dlq', 10); + }); + }); }); diff --git a/src/utils/test/migration-checksum.utils.test.ts b/src/utils/test/migration-checksum.utils.test.ts index 5c40e77..cb67ffd 100644 --- a/src/utils/test/migration-checksum.utils.test.ts +++ b/src/utils/test/migration-checksum.utils.test.ts @@ -29,45 +29,59 @@ describe('migration-checksum.utils', () => { it('should skip if migrations directory does not exist', async () => { mockFs.existsSync.mockReturnValue(false); await verifyMigrationChecksums(); - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('not found')); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('not found') + ); }); it('should skip if migrations table does not exist', async () => { - mockPrisma.$queryRawUnsafe.mockRejectedValueOnce(new Error('Table not found')); + mockPrisma.$queryRawUnsafe.mockRejectedValueOnce( + new Error('Table not found') + ); await verifyMigrationChecksums(); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('table not found')); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('table not found') + ); }); it('should pass if checksums match', async () => { mockPrisma.$queryRawUnsafe .mockResolvedValueOnce([{}]) // Table check .mockResolvedValueOnce([ - { migration_name: '20240101_init', checksum: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' } // SHA256 of empty string (normalized) + { + migration_name: '20240101_init', + checksum: + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + }, // SHA256 of empty string (normalized) ]); mockFs.readdirSync.mockReturnValue([ - { name: '20240101_init', isDirectory: () => true } + { name: '20240101_init', isDirectory: () => true }, ] as any); mockFs.readFileSync.mockReturnValue(''); await verifyMigrationChecksums(); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Verified checksums')); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Verified checksums') + ); }); it('should throw error if checksum mismatch detected', async () => { mockPrisma.$queryRawUnsafe .mockResolvedValueOnce([{}]) // Table check .mockResolvedValueOnce([ - { migration_name: '20240101_init', checksum: 'wrong-checksum' } + { migration_name: '20240101_init', checksum: 'wrong-checksum' }, ]); mockFs.readdirSync.mockReturnValue([ - { name: '20240101_init', isDirectory: () => true } + { name: '20240101_init', isDirectory: () => true }, ] as any); mockFs.readFileSync.mockReturnValue('some content'); - await expect(verifyMigrationChecksums()).rejects.toThrow('Migration checksum mismatch'); + await expect(verifyMigrationChecksums()).rejects.toThrow( + 'Migration checksum mismatch' + ); expect(logger.error).toHaveBeenCalled(); }); @@ -77,12 +91,14 @@ describe('migration-checksum.utils', () => { .mockResolvedValueOnce([]); // No migrations in DB mockFs.readdirSync.mockReturnValue([ - { name: '20240101_init', isDirectory: () => true } + { name: '20240101_init', isDirectory: () => true }, ] as any); mockFs.readFileSync.mockReturnValue('new content'); await verifyMigrationChecksums(); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Verified checksums for 0 migrations')); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Verified checksums for 0 migrations') + ); }); }); diff --git a/src/utils/test/pagination.utils.test.ts b/src/utils/test/pagination.utils.test.ts index 3984fc1..5dd1fc0 100644 --- a/src/utils/test/pagination.utils.test.ts +++ b/src/utils/test/pagination.utils.test.ts @@ -1,56 +1,64 @@ import { paginateQuery } from '../pagination.utils'; describe('paginateQuery', () => { - const mockData = [ - { id: 1, name: 'Alice' }, - { id: 2, name: 'Bob' }, - { id: 3, name: 'Charlie' }, - { id: 4, name: 'David' }, - { id: 5, name: 'Eve' } - ]; - - const mockQueryFn = jest.fn().mockImplementation(async ({ take, skip, cursor }) => { - let startIndex = 0; - if (cursor) { + const mockData = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + { id: 4, name: 'David' }, + { id: 5, name: 'Eve' }, + ]; + + const mockQueryFn = jest + .fn() + .mockImplementation(async ({ take, skip, cursor }) => { + let startIndex = 0; + if (cursor) { startIndex = mockData.findIndex(item => item.id === cursor); if (startIndex === -1) return []; - } - if (skip) startIndex += skip; - - return mockData.slice(startIndex, startIndex + take); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('returns first page with no cursor', async () => { - const result = await paginateQuery(mockQueryFn, { limit: 2 }); - - expect(mockQueryFn).toHaveBeenCalledWith({ take: 3 }); - expect(result.data).toHaveLength(2); - expect(result.data).toEqual([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]); - expect(result.hasMore).toBe(true); - expect(result.nextCursor).toBe(2); - }); - - it('returns subsequent page with cursor', async () => { - const result = await paginateQuery(mockQueryFn, { cursor: 2, limit: 2 }); - - expect(mockQueryFn).toHaveBeenCalledWith({ take: 3, skip: 1, cursor: 2 }); - expect(result.data).toHaveLength(2); - expect(result.data).toEqual([{ id: 3, name: 'Charlie' }, { id: 4, name: 'David' }]); - expect(result.hasMore).toBe(true); - expect(result.nextCursor).toBe(4); - }); - - it('returns last page where hasMore is false', async () => { - const result = await paginateQuery(mockQueryFn, { cursor: 4, limit: 2 }); - - expect(mockQueryFn).toHaveBeenCalledWith({ take: 3, skip: 1, cursor: 4 }); - expect(result.data).toHaveLength(1); - expect(result.data).toEqual([{ id: 5, name: 'Eve' }]); - expect(result.hasMore).toBe(false); - expect(result.nextCursor).toBeUndefined(); - }); + } + if (skip) startIndex += skip; + + return mockData.slice(startIndex, startIndex + take); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns first page with no cursor', async () => { + const result = await paginateQuery(mockQueryFn, { limit: 2 }); + + expect(mockQueryFn).toHaveBeenCalledWith({ take: 3 }); + expect(result.data).toHaveLength(2); + expect(result.data).toEqual([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]); + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBe(2); + }); + + it('returns subsequent page with cursor', async () => { + const result = await paginateQuery(mockQueryFn, { cursor: 2, limit: 2 }); + + expect(mockQueryFn).toHaveBeenCalledWith({ take: 3, skip: 1, cursor: 2 }); + expect(result.data).toHaveLength(2); + expect(result.data).toEqual([ + { id: 3, name: 'Charlie' }, + { id: 4, name: 'David' }, + ]); + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBe(4); + }); + + it('returns last page where hasMore is false', async () => { + const result = await paginateQuery(mockQueryFn, { cursor: 4, limit: 2 }); + + expect(mockQueryFn).toHaveBeenCalledWith({ take: 3, skip: 1, cursor: 4 }); + expect(result.data).toHaveLength(1); + expect(result.data).toEqual([{ id: 5, name: 'Eve' }]); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeUndefined(); + }); }); diff --git a/src/utils/test/parseBoolean.utils.test.ts b/src/utils/test/parseBoolean.utils.test.ts index fe8e2c8..216c878 100644 --- a/src/utils/test/parseBoolean.utils.test.ts +++ b/src/utils/test/parseBoolean.utils.test.ts @@ -1,152 +1,154 @@ import { - parseBoolean, - parseBooleanWithDefault, - ParseBooleanError, + parseBoolean, + parseBooleanWithDefault, + ParseBooleanError, } from '../parseBoolean.utils'; describe('parseBoolean()', () => { - describe('true variants', () => { - const trueInputs = ['true', '1']; + describe('true variants', () => { + const trueInputs = ['true', '1']; - trueInputs.forEach((input) => { - it(`returns true for "${input}"`, () => { - expect(parseBoolean('isVerified', input)).toBe(true); + trueInputs.forEach(input => { + it(`returns true for "${input}"`, () => { + expect(parseBoolean('isVerified', input)).toBe(true); + }); + + it(`returns true for uppercase "${input.toUpperCase()}"`, () => { + expect(parseBoolean('isVerified', input.toUpperCase())).toBe(true); + }); }); + }); + + describe('false variants', () => { + const falseInputs = ['false', '0']; + + falseInputs.forEach(input => { + it(`returns false for "${input}"`, () => { + expect(parseBoolean('isVerified', input)).toBe(false); + }); - it(`returns true for uppercase "${input.toUpperCase()}"`, () => { - expect(parseBoolean('isVerified', input.toUpperCase())).toBe(true); + it(`returns false for uppercase "${input.toUpperCase()}"`, () => { + expect(parseBoolean('isVerified', input.toUpperCase())).toBe(false); + }); }); - }); - }); + }); - describe('false variants', () => { - const falseInputs = ['false', '0']; + describe('absent parameter', () => { + it('returns null when value is undefined', () => { + expect(parseBoolean('isVerified', undefined)).toBeNull(); + }); - falseInputs.forEach((input) => { - it(`returns false for "${input}"`, () => { - expect(parseBoolean('isVerified', input)).toBe(false); + it('returns null when value is null', () => { + expect(parseBoolean('isVerified', null)).toBeNull(); }); + }); - it(`returns false for uppercase "${input.toUpperCase()}"`, () => { - expect(parseBoolean('isVerified', input.toUpperCase())).toBe(false); + describe('non-string boolean input', () => { + it('passes through boolean values', () => { + expect(parseBoolean('isVerified', true)).toBe(true); + expect(parseBoolean('isVerified', false)).toBe(false); }); - }); - }); - - describe('absent parameter', () => { - it('returns null when value is undefined', () => { - expect(parseBoolean('isVerified', undefined)).toBeNull(); - }); - - it('returns null when value is null', () => { - expect(parseBoolean('isVerified', null)).toBeNull(); - }); - }); - - describe('non-string boolean input', () => { - it('passes through boolean values', () => { - expect(parseBoolean('isVerified', true)).toBe(true); - expect(parseBoolean('isVerified', false)).toBe(false); - }); - }); - - describe('whitespace trimming', () => { - it('trims leading/trailing whitespace before parsing', () => { - expect(parseBoolean('isVerified', ' true ')).toBe(true); - expect(parseBoolean('isVerified', ' false ')).toBe(false); - }); - }); - - describe('array input', () => { - it('uses the first element of an array', () => { - expect(parseBoolean('isVerified', ['true', 'false'])).toBe(true); - expect(parseBoolean('isVerified', ['false', 'true'])).toBe(false); - }); - }); - - describe('invalid values', () => { - const invalidInputs = [ - 'maybe', - '2', - '-1', - 'yes', - 'no', - 'on', - 'off', - 't', - 'f', - '', - ' ', - ]; - - invalidInputs.forEach((input) => { - it(`throws ParseBooleanError for "${input}"`, () => { - expect(() => parseBoolean('isVerified', input)).toThrow( - ParseBooleanError - ); + }); + + describe('whitespace trimming', () => { + it('trims leading/trailing whitespace before parsing', () => { + expect(parseBoolean('isVerified', ' true ')).toBe(true); + expect(parseBoolean('isVerified', ' false ')).toBe(false); }); - }); + }); - it('includes the param name in the error message', () => { - expect(() => parseBoolean('isActive', 'maybe')).toThrow(/isActive/); - }); + describe('array input', () => { + it('uses the first element of an array', () => { + expect(parseBoolean('isVerified', ['true', 'false'])).toBe(true); + expect(parseBoolean('isVerified', ['false', 'true'])).toBe(false); + }); + }); + + describe('invalid values', () => { + const invalidInputs = [ + 'maybe', + '2', + '-1', + 'yes', + 'no', + 'on', + 'off', + 't', + 'f', + '', + ' ', + ]; + + invalidInputs.forEach(input => { + it(`throws ParseBooleanError for "${input}"`, () => { + expect(() => parseBoolean('isVerified', input)).toThrow( + ParseBooleanError + ); + }); + }); - it('includes the raw value in the error message', () => { - expect(() => parseBoolean('isActive', 'maybe')).toThrow(/maybe/); - }); + it('includes the param name in the error message', () => { + expect(() => parseBoolean('isActive', 'maybe')).toThrow(/isActive/); + }); - it('includes accepted values hint in the error message', () => { - expect(() => parseBoolean('isActive', 'bad')).toThrow( - /Accepted values: "true", "false", "1", "0"\./ - ); - }); - - it('sets rawValue on the error instance', () => { - try { - parseBoolean('isActive', 'bad'); - } catch (e) { - expect(e).toBeInstanceOf(ParseBooleanError); - expect((e as ParseBooleanError).rawValue).toBe('bad'); - } - }); - - it('sets paramName on the error instance', () => { - try { - parseBoolean('isActive', 'bad'); - } catch (e) { - expect(e).toBeInstanceOf(ParseBooleanError); - expect((e as ParseBooleanError).paramName).toBe('isActive'); - } - }); - }); + it('includes the raw value in the error message', () => { + expect(() => parseBoolean('isActive', 'maybe')).toThrow(/maybe/); + }); + + it('includes accepted values hint in the error message', () => { + expect(() => parseBoolean('isActive', 'bad')).toThrow( + /Accepted values: "true", "false", "1", "0"\./ + ); + }); + + it('sets rawValue on the error instance', () => { + try { + parseBoolean('isActive', 'bad'); + } catch (e) { + expect(e).toBeInstanceOf(ParseBooleanError); + expect((e as ParseBooleanError).rawValue).toBe('bad'); + } + }); + + it('sets paramName on the error instance', () => { + try { + parseBoolean('isActive', 'bad'); + } catch (e) { + expect(e).toBeInstanceOf(ParseBooleanError); + expect((e as ParseBooleanError).paramName).toBe('isActive'); + } + }); + }); }); describe('parseBooleanWithDefault()', () => { - it('returns the parsed value when present', () => { - expect(parseBooleanWithDefault('isVerified', 'true', false)).toBe(true); - expect(parseBooleanWithDefault('isVerified', 'false', true)).toBe(false); - }); - - it('returns the default when param is undefined', () => { - expect(parseBooleanWithDefault('isVerified', undefined, false)).toBe(false); - expect(parseBooleanWithDefault('isVerified', undefined, true)).toBe(true); - }); - - it('still throws ParseBooleanError for invalid values', () => { - expect(() => - parseBooleanWithDefault('isVerified', 'maybe', false) - ).toThrow(ParseBooleanError); - }); + it('returns the parsed value when present', () => { + expect(parseBooleanWithDefault('isVerified', 'true', false)).toBe(true); + expect(parseBooleanWithDefault('isVerified', 'false', true)).toBe(false); + }); + + it('returns the default when param is undefined', () => { + expect(parseBooleanWithDefault('isVerified', undefined, false)).toBe( + false + ); + expect(parseBooleanWithDefault('isVerified', undefined, true)).toBe(true); + }); + + it('still throws ParseBooleanError for invalid values', () => { + expect(() => + parseBooleanWithDefault('isVerified', 'maybe', false) + ).toThrow(ParseBooleanError); + }); }); describe('ParseBooleanError', () => { - it('is an instance of Error', () => { - const err = new ParseBooleanError('field', 'bad'); - expect(err).toBeInstanceOf(Error); - }); - - it('has name ParseBooleanError', () => { - const err = new ParseBooleanError('field', 'bad'); - expect(err.name).toBe('ParseBooleanError'); - }); + it('is an instance of Error', () => { + const err = new ParseBooleanError('field', 'bad'); + expect(err).toBeInstanceOf(Error); + }); + + it('has name ParseBooleanError', () => { + const err = new ParseBooleanError('field', 'bad'); + expect(err.name).toBe('ParseBooleanError'); + }); }); diff --git a/src/utils/test/protected-route-request.utils.ts b/src/utils/test/protected-route-request.utils.ts index 3ca99c4..727358d 100644 --- a/src/utils/test/protected-route-request.utils.ts +++ b/src/utils/test/protected-route-request.utils.ts @@ -9,7 +9,8 @@ const DEFAULT_PROTECTED_ROUTE_HEADERS: Record< string > = { 'x-admin-id': 'admin-test-1', - 'x-wallet-address': 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'x-wallet-address': + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', }; /** @@ -19,13 +20,16 @@ const DEFAULT_PROTECTED_ROUTE_HEADERS: Record< * for a specific key. */ export function withProtectedRouteHeaders< - T extends { set(name: string, value: string): T; set(headers: Record): T } + T extends { + set(name: string, value: string): T; + set(headers: Record): T; + }, >(request: T, overrides: ProtectedRouteHeaderOverrides = {}): T { const headers: Partial> = { ...DEFAULT_PROTECTED_ROUTE_HEADERS, }; - (Object.keys(overrides) as ProtectedRouteHeaderName[]).forEach((name) => { + (Object.keys(overrides) as ProtectedRouteHeaderName[]).forEach(name => { const value = overrides[name]; if (value === undefined || value === null) { delete headers[name]; diff --git a/src/utils/test/query-normalization-debug.utils.test.ts b/src/utils/test/query-normalization-debug.utils.test.ts index e83dad2..0fb868f 100644 --- a/src/utils/test/query-normalization-debug.utils.test.ts +++ b/src/utils/test/query-normalization-debug.utils.test.ts @@ -233,7 +233,11 @@ function run() { assertEqual(snapshot.valid, false, 'Should mark as invalid'); assertOk(snapshot.errors, 'Should include errors'); assertEqual(snapshot.errors.length, 1, 'Should have one error'); - assertEqual(snapshot.errors[0].field, 'limit', 'Should include error field'); + assertEqual( + snapshot.errors[0].field, + 'limit', + 'Should include error field' + ); assertEqual( snapshot.errors[0].message, 'Expected number, received string', @@ -330,7 +334,11 @@ function run() { undefined, 'Undefined should be preserved' ); - assertEqual(snapshot.raw.field3, 'value', 'String value should be preserved'); + assertEqual( + snapshot.raw.field3, + 'value', + 'String value should be preserved' + ); restoreLogger(); } @@ -346,8 +354,16 @@ function run() { const snapshot = debugCalls[0].queryNormalization; assertOk(snapshot.querySignature, 'Should include querySignature'); - assertEqual(typeof snapshot.querySignature, 'string', 'Signature should be a string'); - assertEqual(snapshot.querySignature.length, 64, 'Signature should be 64 characters (SHA-256 hex)'); + assertEqual( + typeof snapshot.querySignature, + 'string', + 'Signature should be a string' + ); + assertEqual( + snapshot.querySignature.length, + 64, + 'Signature should be 64 characters (SHA-256 hex)' + ); restoreLogger(); } @@ -362,7 +378,11 @@ function run() { }); const snapshot = debugCalls[0].queryNormalization; - assertEqual(snapshot.querySignature, undefined, 'Should not include signature for non-object raw query'); + assertEqual( + snapshot.querySignature, + undefined, + 'Should not include signature for non-object raw query' + ); restoreLogger(); } diff --git a/src/utils/test/request-context-hash.utils.test.ts b/src/utils/test/request-context-hash.utils.test.ts index 99c4bdd..4f52574 100644 --- a/src/utils/test/request-context-hash.utils.test.ts +++ b/src/utils/test/request-context-hash.utils.test.ts @@ -1,105 +1,161 @@ import { Request } from 'express'; import { computeRequestContextHash } from '../request-context-hash.utils'; -function makeReq(overrides: Partial<{ method: string; path: string; headers: Record }>): Request { - return { - method: 'GET', - path: '/', - headers: {}, - ...overrides, - } as unknown as Request; +function makeReq( + overrides: Partial<{ + method: string; + path: string; + headers: Record; + }> +): Request { + return { + method: 'GET', + path: '/', + headers: {}, + ...overrides, + } as unknown as Request; } describe('computeRequestContextHash()', () => { - // ── Output format ────────────────────────────────────────────────────────── - - it('returns a 12-character hex string', () => { - const hash = computeRequestContextHash(makeReq({})); - expect(hash).toMatch(/^[0-9a-f]{12}$/); - }); - - // ── Determinism ──────────────────────────────────────────────────────────── - - it('returns the same hash for identical inputs', () => { - const req = makeReq({ method: 'GET', path: '/api/creators' }); - expect(computeRequestContextHash(req)).toBe(computeRequestContextHash(req)); - }); - - it('produces a stable hash across separate calls with matching fields', () => { - const a = makeReq({ method: 'POST', path: '/api/auth/login', headers: { 'content-type': 'application/json' } }); - const b = makeReq({ method: 'POST', path: '/api/auth/login', headers: { 'content-type': 'application/json' } }); - expect(computeRequestContextHash(a)).toBe(computeRequestContextHash(b)); - }); - - // ── Sensitivity to field values ──────────────────────────────────────────── - - it('produces different hashes for different HTTP methods', () => { - const get = makeReq({ method: 'GET', path: '/api/creators' }); - const post = makeReq({ method: 'POST', path: '/api/creators' }); - expect(computeRequestContextHash(get)).not.toBe(computeRequestContextHash(post)); - }); - - it('produces different hashes for different paths', () => { - const a = makeReq({ path: '/api/creators' }); - const b = makeReq({ path: '/api/wallet' }); - expect(computeRequestContextHash(a)).not.toBe(computeRequestContextHash(b)); - }); - - it('produces different hashes for different content-type headers', () => { - const json = makeReq({ method: 'POST', path: '/upload', headers: { 'content-type': 'application/json' } }); - const form = makeReq({ method: 'POST', path: '/upload', headers: { 'content-type': 'multipart/form-data' } }); - expect(computeRequestContextHash(json)).not.toBe(computeRequestContextHash(form)); - }); - - // ── Query-string isolation ───────────────────────────────────────────────── - - it('ignores query strings in the path', () => { - const withQuery = makeReq({ path: '/api/creators?page=1&limit=20' }); - const withoutQuery = makeReq({ path: '/api/creators' }); - expect(computeRequestContextHash(withQuery)).toBe(computeRequestContextHash(withoutQuery)); - }); - - it('ignores different query strings for the same path', () => { - const a = makeReq({ path: '/api/creators?q=music' }); - const b = makeReq({ path: '/api/creators?q=sports&page=2' }); - expect(computeRequestContextHash(a)).toBe(computeRequestContextHash(b)); - }); - - // ── Sensitive-header isolation ───────────────────────────────────────────── - - it('produces the same hash regardless of Authorization header value', () => { - const noAuth = makeReq({ path: '/api/creators', headers: {} }); - const withAuth = makeReq({ path: '/api/creators', headers: { authorization: 'Bearer secret-token' } }); - expect(computeRequestContextHash(noAuth)).toBe(computeRequestContextHash(withAuth)); - }); - - it('produces the same hash regardless of Cookie header value', () => { - const noCookie = makeReq({ path: '/api/creators', headers: {} }); - const withCookie = makeReq({ path: '/api/creators', headers: { cookie: 'session=abc123' } }); - expect(computeRequestContextHash(noCookie)).toBe(computeRequestContextHash(withCookie)); - }); - - // ── Content-type charset suffix stripping ───────────────────────────────── - - it('treats content-type with and without charset as the same', () => { - const plain = makeReq({ method: 'POST', path: '/api/auth', headers: { 'content-type': 'application/json' } }); - const withCharset = makeReq({ method: 'POST', path: '/api/auth', headers: { 'content-type': 'application/json; charset=utf-8' } }); - expect(computeRequestContextHash(plain)).toBe(computeRequestContextHash(withCharset)); - }); - - // ── Missing / empty path ─────────────────────────────────────────────────── - - it('falls back to "/" when path is empty', () => { - const emptyPath = makeReq({ path: '' }); - const rootPath = makeReq({ path: '/' }); - expect(computeRequestContextHash(emptyPath)).toBe(computeRequestContextHash(rootPath)); - }); - - // ── Method normalisation ─────────────────────────────────────────────────── - - it('is case-insensitive for method (get vs GET)', () => { - const upper = makeReq({ method: 'GET', path: '/api/creators' }); - const lower = makeReq({ method: 'get', path: '/api/creators' }); - expect(computeRequestContextHash(upper)).toBe(computeRequestContextHash(lower)); - }); + // ── Output format ────────────────────────────────────────────────────────── + + it('returns a 12-character hex string', () => { + const hash = computeRequestContextHash(makeReq({})); + expect(hash).toMatch(/^[0-9a-f]{12}$/); + }); + + // ── Determinism ──────────────────────────────────────────────────────────── + + it('returns the same hash for identical inputs', () => { + const req = makeReq({ method: 'GET', path: '/api/creators' }); + expect(computeRequestContextHash(req)).toBe( + computeRequestContextHash(req) + ); + }); + + it('produces a stable hash across separate calls with matching fields', () => { + const a = makeReq({ + method: 'POST', + path: '/api/auth/login', + headers: { 'content-type': 'application/json' }, + }); + const b = makeReq({ + method: 'POST', + path: '/api/auth/login', + headers: { 'content-type': 'application/json' }, + }); + expect(computeRequestContextHash(a)).toBe(computeRequestContextHash(b)); + }); + + // ── Sensitivity to field values ──────────────────────────────────────────── + + it('produces different hashes for different HTTP methods', () => { + const get = makeReq({ method: 'GET', path: '/api/creators' }); + const post = makeReq({ method: 'POST', path: '/api/creators' }); + expect(computeRequestContextHash(get)).not.toBe( + computeRequestContextHash(post) + ); + }); + + it('produces different hashes for different paths', () => { + const a = makeReq({ path: '/api/creators' }); + const b = makeReq({ path: '/api/wallet' }); + expect(computeRequestContextHash(a)).not.toBe( + computeRequestContextHash(b) + ); + }); + + it('produces different hashes for different content-type headers', () => { + const json = makeReq({ + method: 'POST', + path: '/upload', + headers: { 'content-type': 'application/json' }, + }); + const form = makeReq({ + method: 'POST', + path: '/upload', + headers: { 'content-type': 'multipart/form-data' }, + }); + expect(computeRequestContextHash(json)).not.toBe( + computeRequestContextHash(form) + ); + }); + + // ── Query-string isolation ───────────────────────────────────────────────── + + it('ignores query strings in the path', () => { + const withQuery = makeReq({ path: '/api/creators?page=1&limit=20' }); + const withoutQuery = makeReq({ path: '/api/creators' }); + expect(computeRequestContextHash(withQuery)).toBe( + computeRequestContextHash(withoutQuery) + ); + }); + + it('ignores different query strings for the same path', () => { + const a = makeReq({ path: '/api/creators?q=music' }); + const b = makeReq({ path: '/api/creators?q=sports&page=2' }); + expect(computeRequestContextHash(a)).toBe(computeRequestContextHash(b)); + }); + + // ── Sensitive-header isolation ───────────────────────────────────────────── + + it('produces the same hash regardless of Authorization header value', () => { + const noAuth = makeReq({ path: '/api/creators', headers: {} }); + const withAuth = makeReq({ + path: '/api/creators', + headers: { authorization: 'Bearer secret-token' }, + }); + expect(computeRequestContextHash(noAuth)).toBe( + computeRequestContextHash(withAuth) + ); + }); + + it('produces the same hash regardless of Cookie header value', () => { + const noCookie = makeReq({ path: '/api/creators', headers: {} }); + const withCookie = makeReq({ + path: '/api/creators', + headers: { cookie: 'session=abc123' }, + }); + expect(computeRequestContextHash(noCookie)).toBe( + computeRequestContextHash(withCookie) + ); + }); + + // ── Content-type charset suffix stripping ───────────────────────────────── + + it('treats content-type with and without charset as the same', () => { + const plain = makeReq({ + method: 'POST', + path: '/api/auth', + headers: { 'content-type': 'application/json' }, + }); + const withCharset = makeReq({ + method: 'POST', + path: '/api/auth', + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); + expect(computeRequestContextHash(plain)).toBe( + computeRequestContextHash(withCharset) + ); + }); + + // ── Missing / empty path ─────────────────────────────────────────────────── + + it('falls back to "/" when path is empty', () => { + const emptyPath = makeReq({ path: '' }); + const rootPath = makeReq({ path: '/' }); + expect(computeRequestContextHash(emptyPath)).toBe( + computeRequestContextHash(rootPath) + ); + }); + + // ── Method normalisation ─────────────────────────────────────────────────── + + it('is case-insensitive for method (get vs GET)', () => { + const upper = makeReq({ method: 'GET', path: '/api/creators' }); + const lower = makeReq({ method: 'get', path: '/api/creators' }); + expect(computeRequestContextHash(upper)).toBe( + computeRequestContextHash(lower) + ); + }); }); diff --git a/src/utils/test/slug.utils.test.ts b/src/utils/test/slug.utils.test.ts index 0b6c1eb..19b35e7 100644 --- a/src/utils/test/slug.utils.test.ts +++ b/src/utils/test/slug.utils.test.ts @@ -1,4 +1,8 @@ -import { generateSlug, generateSlugWithSuffix, resolveSlugCollision } from '../slug.utils'; +import { + generateSlug, + generateSlugWithSuffix, + resolveSlugCollision, +} from '../slug.utils'; describe('slug.utils', () => { describe('generateSlug', () => { @@ -65,7 +69,9 @@ describe('slug.utils', () => { it('should throw error if limit is reached', async () => { const isUnique = jest.fn().mockResolvedValue(false); - await expect(resolveSlugCollision('John Doe', isUnique)).rejects.toThrow( + await expect( + resolveSlugCollision('John Doe', isUnique) + ).rejects.toThrow( 'Failed to resolve slug collision for "John Doe" after 1000 attempts.' ); }); diff --git a/src/utils/test/sort-tiebreaker.utils.test.ts b/src/utils/test/sort-tiebreaker.utils.test.ts index 584abcb..069b54d 100644 --- a/src/utils/test/sort-tiebreaker.utils.test.ts +++ b/src/utils/test/sort-tiebreaker.utils.test.ts @@ -3,106 +3,106 @@ import { withTieBreaker, stableSortCreators } from '../sort-tiebreaker.utils'; type Creator = { id: string; name: string; followers: number }; function makeCreator(id: string, name: string, followers: number): Creator { - return { id, name, followers }; + return { id, name, followers }; } describe('withTieBreaker()', () => { - const cmp = withTieBreaker('followers', 'asc'); + const cmp = withTieBreaker('followers', 'asc'); - it('orders lower followers before higher (asc)', () => { - const a = makeCreator('1', 'Alice', 10); - const b = makeCreator('2', 'Bob', 20); - expect(cmp(a, b)).toBeLessThan(0); - expect(cmp(b, a)).toBeGreaterThan(0); - }); + it('orders lower followers before higher (asc)', () => { + const a = makeCreator('1', 'Alice', 10); + const b = makeCreator('2', 'Bob', 20); + expect(cmp(a, b)).toBeLessThan(0); + expect(cmp(b, a)).toBeGreaterThan(0); + }); - it('orders higher followers before lower (desc)', () => { - const cmpDesc = withTieBreaker('followers', 'desc'); - const a = makeCreator('1', 'Alice', 30); - const b = makeCreator('2', 'Bob', 10); - expect(cmpDesc(a, b)).toBeLessThan(0); - expect(cmpDesc(b, a)).toBeGreaterThan(0); - }); + it('orders higher followers before lower (desc)', () => { + const cmpDesc = withTieBreaker('followers', 'desc'); + const a = makeCreator('1', 'Alice', 30); + const b = makeCreator('2', 'Bob', 10); + expect(cmpDesc(a, b)).toBeLessThan(0); + expect(cmpDesc(b, a)).toBeGreaterThan(0); + }); - it('breaks tie by id ascending when primary values are equal', () => { - const a = makeCreator('aaa', 'Alice', 50); - const b = makeCreator('bbb', 'Bob', 50); - expect(cmp(a, b)).toBeLessThan(0); // 'aaa' < 'bbb' - expect(cmp(b, a)).toBeGreaterThan(0); - }); + it('breaks tie by id ascending when primary values are equal', () => { + const a = makeCreator('aaa', 'Alice', 50); + const b = makeCreator('bbb', 'Bob', 50); + expect(cmp(a, b)).toBeLessThan(0); // 'aaa' < 'bbb' + expect(cmp(b, a)).toBeGreaterThan(0); + }); - it('returns 0 only when both primary key and id are equal', () => { - const a = makeCreator('same', 'Alice', 50); - const b = makeCreator('same', 'Bob', 50); - expect(cmp(a, b)).toBe(0); - }); + it('returns 0 only when both primary key and id are equal', () => { + const a = makeCreator('same', 'Alice', 50); + const b = makeCreator('same', 'Bob', 50); + expect(cmp(a, b)).toBe(0); + }); - it('tie-break direction is always ascending regardless of primary direction', () => { - const cmpDesc = withTieBreaker('followers', 'desc'); - const a = makeCreator('aaa', 'Alice', 50); - const b = makeCreator('bbb', 'Bob', 50); - // Equal followers → tie-break by id ascending: 'aaa' < 'bbb' - expect(cmpDesc(a, b)).toBeLessThan(0); - expect(cmpDesc(b, a)).toBeGreaterThan(0); - }); + it('tie-break direction is always ascending regardless of primary direction', () => { + const cmpDesc = withTieBreaker('followers', 'desc'); + const a = makeCreator('aaa', 'Alice', 50); + const b = makeCreator('bbb', 'Bob', 50); + // Equal followers → tie-break by id ascending: 'aaa' < 'bbb' + expect(cmpDesc(a, b)).toBeLessThan(0); + expect(cmpDesc(b, a)).toBeGreaterThan(0); + }); - it('works with string primary key', () => { - const cmpName = withTieBreaker('name', 'asc'); - const a = makeCreator('2', 'Alice', 10); - const b = makeCreator('1', 'Bob', 20); - expect(cmpName(a, b)).toBeLessThan(0); // 'Alice' < 'Bob' - }); + it('works with string primary key', () => { + const cmpName = withTieBreaker('name', 'asc'); + const a = makeCreator('2', 'Alice', 10); + const b = makeCreator('1', 'Bob', 20); + expect(cmpName(a, b)).toBeLessThan(0); // 'Alice' < 'Bob' + }); }); describe('stableSortCreators()', () => { - it('does not mutate the original array', () => { - const items = [makeCreator('b', 'B', 10), makeCreator('a', 'A', 10)]; - const sorted = stableSortCreators(items, 'id'); - expect(sorted).not.toBe(items); - expect(items[0].id).toBe('b'); // original unchanged - }); + it('does not mutate the original array', () => { + const items = [makeCreator('b', 'B', 10), makeCreator('a', 'A', 10)]; + const sorted = stableSortCreators(items, 'id'); + expect(sorted).not.toBe(items); + expect(items[0].id).toBe('b'); // original unchanged + }); - it('sorts by followers descending with id tie-breaker', () => { - const items = [ - makeCreator('c', 'C', 30), - makeCreator('a', 'A', 50), - makeCreator('b', 'B', 50), - ]; - const sorted = stableSortCreators(items, 'followers', 'desc'); - expect(sorted[0].id).toBe('a'); // 50 followers, id 'a' < 'b' - expect(sorted[1].id).toBe('b'); // 50 followers, id 'b' - expect(sorted[2].id).toBe('c'); // 30 followers - }); + it('sorts by followers descending with id tie-breaker', () => { + const items = [ + makeCreator('c', 'C', 30), + makeCreator('a', 'A', 50), + makeCreator('b', 'B', 50), + ]; + const sorted = stableSortCreators(items, 'followers', 'desc'); + expect(sorted[0].id).toBe('a'); // 50 followers, id 'a' < 'b' + expect(sorted[1].id).toBe('b'); // 50 followers, id 'b' + expect(sorted[2].id).toBe('c'); // 30 followers + }); - it('sorts by name ascending with id tie-breaker', () => { - const items = [ - makeCreator('z', 'Beta', 5), - makeCreator('a', 'Alpha', 5), - makeCreator('m', 'Alpha', 10), - ]; - const sorted = stableSortCreators(items, 'name', 'asc'); - expect(sorted[0].id).toBe('a'); // Alpha, id 'a' - expect(sorted[1].id).toBe('m'); // Alpha, id 'm' - expect(sorted[2].id).toBe('z'); // Beta - }); + it('sorts by name ascending with id tie-breaker', () => { + const items = [ + makeCreator('z', 'Beta', 5), + makeCreator('a', 'Alpha', 5), + makeCreator('m', 'Alpha', 10), + ]; + const sorted = stableSortCreators(items, 'name', 'asc'); + expect(sorted[0].id).toBe('a'); // Alpha, id 'a' + expect(sorted[1].id).toBe('m'); // Alpha, id 'm' + expect(sorted[2].id).toBe('z'); // Beta + }); - it('produces deterministic output on repeated calls', () => { - const items = [ - makeCreator('c', 'C', 50), - makeCreator('a', 'A', 50), - makeCreator('b', 'B', 50), - ]; - const s1 = stableSortCreators(items, 'followers', 'desc'); - const s2 = stableSortCreators(items, 'followers', 'desc'); - expect(s1.map((i) => i.id)).toEqual(s2.map((i) => i.id)); - }); + it('produces deterministic output on repeated calls', () => { + const items = [ + makeCreator('c', 'C', 50), + makeCreator('a', 'A', 50), + makeCreator('b', 'B', 50), + ]; + const s1 = stableSortCreators(items, 'followers', 'desc'); + const s2 = stableSortCreators(items, 'followers', 'desc'); + expect(s1.map(i => i.id)).toEqual(s2.map(i => i.id)); + }); - it('returns empty array for empty input', () => { - expect(stableSortCreators([], 'followers')).toEqual([]); - }); + it('returns empty array for empty input', () => { + expect(stableSortCreators([], 'followers')).toEqual([]); + }); - it('returns single-element array unchanged', () => { - const item = makeCreator('x', 'X', 99); - expect(stableSortCreators([item], 'followers')).toEqual([item]); - }); + it('returns single-element array unchanged', () => { + const item = makeCreator('x', 'X', 99); + expect(stableSortCreators([item], 'followers')).toEqual([item]); + }); }); diff --git a/src/utils/timestamp-headers.utils.ts b/src/utils/timestamp-headers.utils.ts index 7368fec..af8199c 100644 --- a/src/utils/timestamp-headers.utils.ts +++ b/src/utils/timestamp-headers.utils.ts @@ -34,6 +34,9 @@ export function formatTimestampHeader(date: Date = new Date()): string { * attachTimestampHeader(res); * sendSuccess(res, data); */ -export function attachTimestampHeader(res: Response, date: Date = new Date()): void { +export function attachTimestampHeader( + res: Response, + date: Date = new Date() +): void { res.set(RESPONSE_TIMESTAMP_HEADER, formatTimestampHeader(date)); } diff --git a/src/utils/validation-error.utils.test.ts b/src/utils/validation-error.utils.test.ts index 86732a0..66d0bde 100644 --- a/src/utils/validation-error.utils.test.ts +++ b/src/utils/validation-error.utils.test.ts @@ -5,49 +5,65 @@ import { buildValidationError } from './validation-error.utils'; describe('buildValidationError', () => { - it('returns correct shape with all three fields', () => { - const result = buildValidationError('wallet_address', 'Invalid Stellar address', 'INVALID_ADDRESS'); - - expect(result).toEqual({ - error: { - code: 'INVALID_ADDRESS', - field: 'wallet_address', - message: 'Invalid Stellar address', - }, - }); - }); - - it('returns correct shape for a required field error', () => { - const result = buildValidationError('creator_id', 'creator_id is required', 'REQUIRED'); - - expect(result.error.field).toBe('creator_id'); - expect(result.error.message).toBe('creator_id is required'); - expect(result.error.code).toBe('REQUIRED'); - }); - - it('returns correct shape for a range violation', () => { - const result = buildValidationError('target_price', 'target_price must be positive', 'OUT_OF_RANGE'); - - expect(result.error.field).toBe('target_price'); - expect(result.error.message).toBe('target_price must be positive'); - expect(result.error.code).toBe('OUT_OF_RANGE'); - }); - - it('error object contains exactly the three expected keys', () => { - const result = buildValidationError('email', 'Invalid email format', 'INVALID_FORMAT'); - - expect(Object.keys(result.error)).toEqual(['code', 'field', 'message']); - }); - - it('preserves arbitrary field names and messages without mutation', () => { - const field = 'callback_url'; - const message = 'callback_url must be a valid URL'; - const code = 'INVALID_URL'; - - const result = buildValidationError(field, message, code); - - expect(result.error.field).toBe(field); - expect(result.error.message).toBe(message); - expect(result.error.code).toBe(code); - }); + it('returns correct shape with all three fields', () => { + const result = buildValidationError( + 'wallet_address', + 'Invalid Stellar address', + 'INVALID_ADDRESS' + ); + + expect(result).toEqual({ + error: { + code: 'INVALID_ADDRESS', + field: 'wallet_address', + message: 'Invalid Stellar address', + }, + }); + }); + + it('returns correct shape for a required field error', () => { + const result = buildValidationError( + 'creator_id', + 'creator_id is required', + 'REQUIRED' + ); + + expect(result.error.field).toBe('creator_id'); + expect(result.error.message).toBe('creator_id is required'); + expect(result.error.code).toBe('REQUIRED'); + }); + + it('returns correct shape for a range violation', () => { + const result = buildValidationError( + 'target_price', + 'target_price must be positive', + 'OUT_OF_RANGE' + ); + + expect(result.error.field).toBe('target_price'); + expect(result.error.message).toBe('target_price must be positive'); + expect(result.error.code).toBe('OUT_OF_RANGE'); + }); + + it('error object contains exactly the three expected keys', () => { + const result = buildValidationError( + 'email', + 'Invalid email format', + 'INVALID_FORMAT' + ); + + expect(Object.keys(result.error)).toEqual(['code', 'field', 'message']); + }); + + it('preserves arbitrary field names and messages without mutation', () => { + const field = 'callback_url'; + const message = 'callback_url must be a valid URL'; + const code = 'INVALID_URL'; + + const result = buildValidationError(field, message, code); + + expect(result.error.field).toBe(field); + expect(result.error.message).toBe(message); + expect(result.error.code).toBe(code); + }); }); diff --git a/src/utils/validation-error.utils.ts b/src/utils/validation-error.utils.ts index cf0b674..92ed4e2 100644 --- a/src/utils/validation-error.utils.ts +++ b/src/utils/validation-error.utils.ts @@ -8,23 +8,23 @@ * res.status(422).json(buildValidationError('wallet_address', 'Invalid Stellar address', 'INVALID_ADDRESS')); */ export interface ValidationErrorResponse { - error: { - code: string; - field: string; - message: string; - }; + error: { + code: string; + field: string; + message: string; + }; } export function buildValidationError( - field: string, - message: string, - code: string + field: string, + message: string, + code: string ): ValidationErrorResponse { - return { - error: { - code, - field, - message, - }, - }; + return { + error: { + code, + field, + message, + }, + }; } diff --git a/src/utils/webhook-mask.utils.test.ts b/src/utils/webhook-mask.utils.test.ts index ad40494..82ef18c 100644 --- a/src/utils/webhook-mask.utils.test.ts +++ b/src/utils/webhook-mask.utils.test.ts @@ -1,31 +1,41 @@ import { maskWebhookUrl } from './webhook-mask.utils'; describe('maskWebhookUrl', () => { - it('strips query string containing a secret', () => { - expect(maskWebhookUrl('https://api.example.com/hook?token=secret')).toBe('https://api.example.com'); - }); + it('strips query string containing a secret', () => { + expect(maskWebhookUrl('https://api.example.com/hook?token=secret')).toBe( + 'https://api.example.com' + ); + }); - it('strips path segments', () => { - expect(maskWebhookUrl('https://hooks.slack.com/services/T123/B456/xyzxyz')).toBe('https://hooks.slack.com'); - }); + it('strips path segments', () => { + expect( + maskWebhookUrl('https://hooks.slack.com/services/T123/B456/xyzxyz') + ).toBe('https://hooks.slack.com'); + }); - it('returns the URL as-is when there is no path or query string', () => { - expect(maskWebhookUrl('http://localhost:3000')).toBe('http://localhost:3000'); - }); + it('returns the URL as-is when there is no path or query string', () => { + expect(maskWebhookUrl('http://localhost:3000')).toBe( + 'http://localhost:3000' + ); + }); - it('returns [invalid url] for a non-URL string', () => { - expect(maskWebhookUrl('not a url')).toBe('[invalid url]'); - }); + it('returns [invalid url] for a non-URL string', () => { + expect(maskWebhookUrl('not a url')).toBe('[invalid url]'); + }); - it('returns [invalid url] for an empty string', () => { - expect(maskWebhookUrl('')).toBe('[invalid url]'); - }); + it('returns [invalid url] for an empty string', () => { + expect(maskWebhookUrl('')).toBe('[invalid url]'); + }); - it('strips path and preserves non-standard port', () => { - expect(maskWebhookUrl('https://internal.corp:8443/webhooks/receiver?auth=abc')).toBe('https://internal.corp:8443'); - }); + it('strips path and preserves non-standard port', () => { + expect( + maskWebhookUrl('https://internal.corp:8443/webhooks/receiver?auth=abc') + ).toBe('https://internal.corp:8443'); + }); - it('strips fragment identifiers', () => { - expect(maskWebhookUrl('https://example.com/path#section')).toBe('https://example.com'); - }); + it('strips fragment identifiers', () => { + expect(maskWebhookUrl('https://example.com/path#section')).toBe( + 'https://example.com' + ); + }); }); diff --git a/src/utils/webhook-mask.utils.ts b/src/utils/webhook-mask.utils.ts index f9fe4c7..64da3c0 100644 --- a/src/utils/webhook-mask.utils.ts +++ b/src/utils/webhook-mask.utils.ts @@ -15,10 +15,10 @@ * // → '[invalid url]' */ export function maskWebhookUrl(url: string): string { - try { - const parsed = new URL(url); - return parsed.origin; - } catch { - return '[invalid url]'; - } + try { + const parsed = new URL(url); + return parsed.origin; + } catch { + return '[invalid url]'; + } } From 6f9a785c7da6df3e24126567bbd3263fa2049bbc Mon Sep 17 00:00:00 2001 From: dubemoyibe-star Date: Tue, 21 Jul 2026 12:33:56 +0100 Subject: [PATCH 2/3] Fixed CI --- .kilo/kilo.json | 1 + src/modules/webhooks/webhook.service.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.kilo/kilo.json b/.kilo/kilo.json index 28c5939..183ebeb 100644 --- a/.kilo/kilo.json +++ b/.kilo/kilo.json @@ -1,3 +1,4 @@ { + "$schema": "https://app.kilo.ai/config.json", "snapshot": false } diff --git a/src/modules/webhooks/webhook.service.ts b/src/modules/webhooks/webhook.service.ts index 63445f5..e8af826 100644 --- a/src/modules/webhooks/webhook.service.ts +++ b/src/modules/webhooks/webhook.service.ts @@ -8,6 +8,7 @@ import type { TradeEvent, WebhookEventPayload, WebhookEventName, + WebhookResponse, } from './webhook.types'; function normalizeEvents(events: string[]): ('BUY' | 'SELL')[] { @@ -61,7 +62,7 @@ export async function createWebhook( }; } -export async function listWebhooks(creatorId: string) { +export async function listWebhooks(creatorId: string): Promise { const webhooks = await prisma.webhook.findMany({ where: { creatorId }, orderBy: { createdAt: 'desc' }, From b3ff10f9f144d778a4723ab44cf6fca914bf0f6e Mon Sep 17 00:00:00 2001 From: dubemoyibe-star Date: Tue, 21 Jul 2026 12:47:06 +0100 Subject: [PATCH 3/3] Fixed failing webhook service --- src/modules/webhooks/webhook.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/webhooks/webhook.service.ts b/src/modules/webhooks/webhook.service.ts index e8af826..f4576fe 100644 --- a/src/modules/webhooks/webhook.service.ts +++ b/src/modules/webhooks/webhook.service.ts @@ -68,7 +68,7 @@ export async function listWebhooks(creatorId: string): Promise ({ + return webhooks.map((w) => ({ ...w, events: denormalizeEvents(w.events as ('BUY' | 'SELL')[]), }));