From a8c9ccfba807aeeb59f34fe41156507ce814b11e Mon Sep 17 00:00:00 2001 From: Awointa Date: Fri, 24 Jul 2026 14:27:15 +0100 Subject: [PATCH] feat: add global leaderboard API endpoints and service - Implemented GET /api/leaderboard/global for paginated global leaderboard. - Implemented GET /api/leaderboard/global/user/:stellarAddress for user-specific leaderboard entry. - Added globalLeaderboardService for database interactions and caching logic. - Introduced Redis caching for leaderboard data with TTL. - Created tests for API endpoints and service functions to ensure functionality and error handling. --- README.md | 9 + docs/global-leaderboard.md | 224 ++++++++++++ openapi.yaml | 178 +++++++++ package-lock.json | 16 + src/config/env.ts | 62 +--- src/index.ts | 7 +- src/openapi/registry.ts | 150 ++++++++ src/routes/leaderboard/global.ts | 164 +++++++++ src/services/globalLeaderboardService.ts | 275 ++++++++++++++ tests/globalLeaderboard.test.ts | 373 +++++++++++++++++++ tests/globalLeaderboardService.test.ts | 441 +++++++++++++++++++++++ 11 files changed, 1846 insertions(+), 53 deletions(-) create mode 100644 docs/global-leaderboard.md create mode 100644 src/routes/leaderboard/global.ts create mode 100644 src/services/globalLeaderboardService.ts create mode 100644 tests/globalLeaderboard.test.ts create mode 100644 tests/globalLeaderboardService.test.ts diff --git a/README.md b/README.md index c7b3691..70ebfe3 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,15 @@ scripts/ dev helpers (check-drizzle-drift.ts) workflows/ CI pipeline (lint, test, drift check, migrate) ``` +## Global Leaderboard + +A platform-wide leaderboard aggregated across **all markets and all time** is available at: + +- **`GET /api/leaderboard/global`** — paginated global rankings (`limit`, `offset`, `refresh` params) +- **`GET /api/leaderboard/global/user/:stellarAddress`** — single-user global entry + +Results are cached in Redis for 5 minutes. Passing `refresh=true` forces an immediate `REFRESH MATERIALIZED VIEW CONCURRENTLY`. See [docs/global-leaderboard.md](docs/global-leaderboard.md) for full documentation. + ## Roadmap This starter is intentionally minimal. The full backlog is tracked in GitHub Issues under the **OFFICIAL CAMPAIGN** label. Major themes: diff --git a/docs/global-leaderboard.md b/docs/global-leaderboard.md new file mode 100644 index 0000000..a6d057e --- /dev/null +++ b/docs/global-leaderboard.md @@ -0,0 +1,224 @@ +# Global Leaderboard API + +## Overview + +The global leaderboard aggregates prediction stats for every user across **all markets and all time**, providing a single platform-wide ranking. Unlike the market-scoped `/api/leaderboard` endpoint (which supports period filtering), the global leaderboard has no time-window filter — it is a full lifetime view of each user's performance. + +This feature was added as part of the GrantFox FWC26 campaign. + +--- + +## Endpoints + +### `GET /api/leaderboard/global` + +Returns a paginated list of all users ranked by their global prediction performance. + +**Ranking logic:** +`accuracy_percentage DESC`, then `total_predictions DESC` (to break ties). + +#### Query Parameters + +| Parameter | Type | Default | Constraints | Description | +|-----------|---------|---------|----------------------|-------------| +| `limit` | integer | `50` | 1–100 | Max entries to return | +| `offset` | integer | `0` | ≥ 0 | Zero-based row offset for pagination | +| `refresh` | boolean | `false` | `true` / `false` | Forces a `REFRESH MATERIALIZED VIEW CONCURRENTLY` before the query (expensive; intended for admin/debug use) | + +#### Response — 200 OK + +```json +{ + "data": [ + { + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "stellar_address": "GAHK7EYR7AQ5B56K2RRYUWWC7EJ5CWWWURC2Q4GQRHBDQY7ZLMQVB6TF", + "total_predictions": 120, + "correct_predictions": 96, + "accuracy_percentage": 80.00, + "total_markets": 8, + "rank": 1 + } + ], + "meta": { + "limit": 50, + "offset": 0, + "count": 1, + "refresh": false + } +} +``` + +#### Entry Fields + +| Field | Type | Description | +|------------------------|---------|-------------| +| `user_id` | UUID | Internal user UUID | +| `stellar_address` | string | User's Stellar public key (G…) | +| `total_predictions` | integer | Total predictions placed across all markets | +| `correct_predictions` | integer | Predictions matching the resolved outcome | +| `accuracy_percentage` | float | Accuracy as a percentage (0–100), rounded to 2 d.p. | +| `total_markets` | integer | Number of distinct markets the user participated in | +| `rank` | integer | 1-based global rank | + +#### Example Requests + +```bash +# Default page (50 results, rank 1–50) +GET /api/leaderboard/global + +# Next page +GET /api/leaderboard/global?limit=50&offset=50 + +# Smaller page +GET /api/leaderboard/global?limit=10 + +# Force a live refresh (admin/debug) +GET /api/leaderboard/global?refresh=true +``` + +#### Error Responses + +| Status | Code | Trigger | +|--------|--------------------|---------| +| `400` | `validation_error` | Invalid query params (e.g. limit > 100, negative offset) | +| `429` | | Rate limit exceeded (anonymous callers share a bucket) | +| `500` | | Unexpected server error | + +--- + +### `GET /api/leaderboard/global/user/:stellarAddress` + +Returns a single user's global leaderboard entry. + +#### Path Parameters + +| Parameter | Description | +|------------------|-------------| +| `stellarAddress` | User's Stellar public key (G…) | + +#### Response — 200 OK + +```json +{ + "data": { + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "stellar_address": "GAHK7EYR7AQ5B56K2RRYUWWC7EJ5CWWWURC2Q4GQRHBDQY7ZLMQVB6TF", + "total_predictions": 120, + "correct_predictions": 96, + "accuracy_percentage": 80.00, + "total_markets": 8, + "rank": 1 + } +} +``` + +#### Error Responses + +| Status | Code | Trigger | +|--------|------------|---------| +| `404` | `not_found` | Address has never placed a prediction | +| `429` | | Rate limit exceeded | +| `500` | | Unexpected server error | + +#### Example Requests + +```bash +# Look up a specific user's global rank +GET /api/leaderboard/global/user/GAHK7EYR7AQ5B56K2RRYUWWC7EJ5CWWWURC2Q4GQRHBDQY7ZLMQVB6TF +``` + +--- + +## Caching + +- Results are cached in Redis with a **5-minute TTL**. +- Paginated slices use the key `leaderboard:global:{limit}:{offset}`. +- Per-user lookups use the key `leaderboard:global:user:{stellarAddress}`. +- `null` results (unknown addresses) are cached as well to short-circuit repeated 404 lookups. +- All cache keys in the `leaderboard:global:*` namespace are invalidated when a `refresh=true` request is handled. +- Cache failures degrade gracefully — the API falls back to the database and logs a warning. + +--- + +## Database Backing + +The endpoint is backed by the `address_aggregates_mv` materialized view, which already aggregates predictions across all resolved and disputed markets. The `total_markets` column is added on-the-fly via a correlated sub-query against the `predictions` table. + +```sql +SELECT + aa.user_id, + aa.stellar_address, + aa.total_predictions, + aa.correct_predictions, + aa.accuracy_percentage, + COALESCE(mp.market_count, 0)::integer AS total_markets, + aa.rank +FROM address_aggregates_mv aa +LEFT JOIN ( + SELECT user_id, COUNT(DISTINCT market_id)::integer AS market_count + FROM predictions + GROUP BY user_id +) mp ON mp.user_id = aa.user_id +ORDER BY aa.rank ASC +LIMIT $1 OFFSET $2 +``` + +The view refresh uses `REFRESH MATERIALIZED VIEW CONCURRENTLY` to avoid blocking concurrent reads. + +--- + +## Difference from `/api/leaderboard` + +| Feature | `/api/leaderboard` | `/api/leaderboard/global` | +|----------------------|---------------------------------------------|---------------------------------------| +| Scope | All markets (market-scoped view) | All markets, all time | +| Time-period filter | `all-time`, `monthly`, `weekly` | None (always all-time) | +| Extra field | — | `total_markets` | +| Backing view | `leaderboard_mv` / `leaderboard_monthly_mv` / `leaderboard_weekly_mv` | `address_aggregates_mv` | + +--- + +## Rate Limiting + +Both endpoints use the existing anonymous rate-limiter (`rateLimitAnon`). Authenticated callers with a valid `Authorization: Bearer ` header bypass the limiter. + +--- + +## Security + +- **Input validation**: All query parameters are validated with Zod. Invalid values produce `400` responses before any service call. +- **SQL injection**: All queries use Drizzle ORM parameterised statements. +- **No auth required**: Both endpoints are publicly readable, consistent with the per-market leaderboard. + +--- + +## Implementation Files + +| File | Role | +|------|------| +| `src/routes/leaderboard/global.ts` | Express router — validation, logging, HTTP responses | +| `src/services/globalLeaderboardService.ts` | Business logic — cache read/write, DB queries, view refresh | +| `src/openapi/registry.ts` | OpenAPI schema registration (`GlobalLeaderboardEntry`, paths) | +| `tests/globalLeaderboard.test.ts` | Route-level integration tests (supertest) | +| `tests/globalLeaderboardService.test.ts` | Service unit tests (mocked DB + Redis) | + +--- + +## Testing + +```bash +# Run all tests +npm test + +# Run only the global leaderboard tests +npm test -- --testPathPattern="globalLeaderboard" + +# With coverage +npm run test:coverage +``` + +### Test Coverage + +- **Route tests** (`tests/globalLeaderboard.test.ts`): default params, custom pagination, string coercion, `refresh=true`, empty results, boundary values (limit 1, 100), all validation error cases, 500 handling, full response field shapes. +- **Service tests** (`tests/globalLeaderboardService.test.ts`): cache hits, cache misses, null caching, cache read/write failure fallback, DB error propagation, `REFRESH MATERIALIZED VIEW` SQL verification, cache key invalidation patterns, Redis-null graceful degradation. diff --git a/openapi.yaml b/openapi.yaml index 04fee35..834f265 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -40,6 +40,46 @@ components: - code required: - error + GlobalLeaderboardEntry: + type: object + properties: + user_id: + type: string + format: uuid + description: Internal user UUID + stellar_address: + type: string + description: User's Stellar public key (G…) + total_predictions: + type: integer + minimum: 0 + description: Total predictions placed across all markets + correct_predictions: + type: integer + minimum: 0 + description: Predictions whose outcome matched the resolved market outcome + accuracy_percentage: + type: number + minimum: 0 + maximum: 100 + description: Accuracy as a percentage (0–100), rounded to 2 d.p. + total_markets: + type: integer + minimum: 0 + description: Number of distinct markets in which the user participated + rank: + type: integer + minimum: 0 + exclusiveMinimum: true + description: 1-based global rank, ordered by accuracy DESC then total_predictions DESC + required: + - user_id + - stellar_address + - total_predictions + - correct_predictions + - accuracy_percentage + - total_markets + - rank DependencyHealth: type: object properties: @@ -1520,6 +1560,7 @@ paths: $ref: '#/components/schemas/ErrorBody' /api/admin/health/detail: get: + operationId: getAdminHealthDetail tags: - Admin summary: Detailed runtime health (admin only) @@ -1553,3 +1594,140 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + /api/leaderboard/global: + get: + operationId: getGlobalLeaderboard + tags: + - Leaderboard + summary: Global leaderboard across all markets + description: >- + Returns a paginated leaderboard ranking all users by their prediction accuracy and volume across **every** + market on the platform. Results are cached for 5 minutes. Pass `refresh=true` to force an immediate + materialized-view refresh (expensive; intended for admin/debug use). + parameters: + - schema: + type: integer + minimum: 0 + exclusiveMinimum: true + maximum: 100 + default: 50 + description: Maximum entries to return (1–100, default 50) + required: false + description: Maximum entries to return (1–100, default 50) + name: limit + in: query + - schema: + type: integer + nullable: true + minimum: 0 + default: 0 + description: Zero-based row offset for pagination (default 0) + required: false + description: Zero-based row offset for pagination (default 0) + name: offset + in: query + - schema: + type: boolean + nullable: true + default: false + description: When true, triggers REFRESH MATERIALIZED VIEW CONCURRENTLY before querying + required: false + description: When true, triggers REFRESH MATERIALIZED VIEW CONCURRENTLY before querying + name: refresh + in: query + responses: + '200': + description: Paginated global leaderboard + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/GlobalLeaderboardEntry' + meta: + type: object + properties: + limit: + type: integer + offset: + type: integer + count: + type: integer + refresh: + type: boolean + required: + - limit + - offset + - count + - refresh + required: + - data + - meta + '400': + description: Invalid query parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' + '429': + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + /api/leaderboard/global/user/{stellarAddress}: + get: + operationId: getGlobalLeaderboardEntry + tags: + - Leaderboard + summary: Get a single user's global leaderboard entry + description: >- + Looks up the global leaderboard rank and stats for a specific Stellar address. Returns 404 when the address has + never placed a prediction. + parameters: + - schema: + type: string + description: The user's Stellar public key (G…) + required: true + description: The user's Stellar public key (G…) + name: stellarAddress + in: path + responses: + '200': + description: User's global leaderboard entry + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/GlobalLeaderboardEntry' + required: + - data + '404': + description: Address not found on the global leaderboard + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '429': + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' diff --git a/package-lock.json b/package-lock.json index 718aa1d..a56d43d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,6 +96,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2447,6 +2448,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2883,6 +2885,7 @@ "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2893,6 +2896,7 @@ "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -3096,6 +3100,7 @@ "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", @@ -3378,6 +3383,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3948,6 +3954,7 @@ "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "bare-path": "^3.0.0" } @@ -4174,6 +4181,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -5426,6 +5434,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5691,6 +5700,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -6759,6 +6769,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8327,6 +8338,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -9905,6 +9917,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10079,6 +10092,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10769,6 +10783,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11184,6 +11199,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/config/env.ts b/src/config/env.ts index b079085..9c6d66b 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,53 +1,13 @@ -import pino from "pino"; -import { envSchema } from "./env-schema"; +import { envSchema, formatEnvErrors } from "./env-schema"; -const schema = z.object({ - NODE_ENV: z.enum(["development", "test", "production"]).default("development"), - PORT: z.coerce.number().int().positive().default(3001), - LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), - FLAGS_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(30), - DATABASE_URL: z.string().url(), - REDIS_URL: z.string().url().default("redis://localhost:6379"), - JWT_SECRET: z.string().min(32), - JWT_ISSUER: z.string().default("predictify"), - JWT_AUDIENCE: z.string().default("predictify-app"), - JWT_TTL_SECONDS: z.coerce.number().int().positive().default(3600), - // Optional multi-key ring for zero-downtime JWT rotation. - // Format: "kid1:secret1,kid2:secret2" — see src/utils/keyRing.ts. - JWT_KEYS: z.string().optional(), - JWT_ACTIVE_KID: z.string().optional(), - WORKER_HEARTBEAT_SECONDS: z.coerce.number().int().positive().default(30), - STELLAR_NETWORK: z.enum(["testnet", "mainnet"]).default("testnet"), - SOROBAN_RPC_URL: z.string().url(), - HORIZON_URL: z.string().url(), - PREDICTIFY_CONTRACT_ID: z.string().min(1), - INDEXER_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(5000), - INDEXER_START_LEDGER: z.coerce.number().int().nonnegative().default(0), - INDEXER_REWIND_LEDGERS: z.coerce.number().int().nonnegative().default(100), - INDEXER_BACKFILL_CHUNK_SIZE: z.coerce.number().int().positive().default(500), - INDEXER_GAP_SCAN_INTERVAL_MS: z.coerce.number().int().positive().default(60000), - // Lag (in ledgers) above which the health probe emits an alert log (default: 200) - INDEXER_LAG_ALERT_THRESHOLD: z.coerce.number().int().positive().default(200), - RECONCILIATION_ENABLED: z.coerce.boolean().default(false), - RECONCILIATION_SCHEDULE: z.string().default("0 2 * * *"), - ADMIN_ALLOWLIST: z.string().default("").transform((val) => val.split(",").map((s) => s.trim()).filter(Boolean)), - PG_POOL_MAX: z.coerce.number().int().positive().default(10), - PG_STATEMENT_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), - ANON_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), - ANON_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(60), - TRUST_PROXY: z.coerce.boolean().default(false), - // ── Captcha gate ────────────────────────────────────────── - CAPTCHA_THRESHOLD: z.coerce.number().int().nonnegative().default(10), - CAPTCHA_WINDOW_MS: z.coerce.number().int().positive().default(60_000), - // ── Settle confirmer ────────────────────────────────────────── - SETTLE_CONFIRMER_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(5_000), - SETTLE_CONFIRMER_CONFIRMATION_LEDGERS: z.coerce.number().int().positive().default(2), - // ── Slow Query Alerter ────────────────────────────────────────── - SLOW_QUERY_ALERTER_ENABLED: z.coerce.boolean().default(false), - SLOW_QUERY_ALERTER_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(60000), // 1 minute - SLOW_QUERY_ALERTER_MEAN_EXEC_TIME_THRESHOLD_MS: z.coerce.number().int().positive().default(100), // 100ms - SLOW_QUERY_ALERTER_MAX_EXEC_TIME_THRESHOLD_MS: z.coerce.number().int().positive().default(500), // 500ms - SLOW_QUERY_ALERTER_LIMIT: z.coerce.number().int().positive().default(10), - SLOW_QUERY_ALERTER_QUERY_MAX_LENGTH: z.coerce.number().int().positive().default(1000), -}); +const parsed = envSchema.safeParse(process.env); +if (!parsed.success) { + // In test environments we tolerate missing env values; elsewhere we crash fast. + if (process.env.NODE_ENV !== "test") { + console.error("❌ Invalid environment configuration:\n" + formatEnvErrors(parsed.error)); + process.exit(1); + } +} + +export const env = parsed.success ? parsed.data : ({} as ReturnType); diff --git a/src/index.ts b/src/index.ts index 4132f9a..ca49a90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,8 @@ import { predictionsRouter } from "./routes/predictions"; import { usersRouter } from "./routes/users"; import { userPortfolioRouter } from "./routes/users/portfolio"; import { leaderboardRouter } from "./routes/leaderboard"; +import { globalLeaderboardRouter } from "./routes/leaderboard/global"; +import { devicesRouter } from "./routes/devices"; import { createDocsRouter } from "./routes/docs"; import { notificationsRouter } from "./routes/notifications"; import { socialRouter } from "./routes/social"; @@ -24,7 +26,7 @@ import { adminAuditRouter } from "./routes/admin/audit"; import { adminMarketsRouter } from "./routes/admin/markets"; import { adminSchemaVersionsRouter } from "./routes/admin/schema-versions"; import { errorHandler } from "./middleware/errorHandler"; -import { startIndexerHealthProbe, stopIndexerHealthProbe } from "./jobs/indexerHealthProbe"; +import { stopIndexerHealthProbe } from "./jobs/indexerHealthProbe"; import { requestContextStorage } from "./lib/requestContext"; import { REQUEST_ID_HEADER } from "./lib/http"; import { register } from "./metrics/registry"; @@ -51,7 +53,7 @@ export interface AppDeps { webhooks?: any; } -export function createApp(deps: AppDeps = {}): express.Express { +export function createApp(_deps: AppDeps = {}): express.Express { const app = express(); if (env.TRUST_PROXY) { @@ -108,6 +110,7 @@ export function createApp(deps: AppDeps = {}): express.Express { app.use("/api/markets", marketsRouter); app.use("/api/predictions", predictionsRouter); app.use("/api/leaderboard", leaderboardRouter); + app.use("/api/leaderboard/global", globalLeaderboardRouter); app.use("/api/notifications", notificationsRouter); app.use("/api/users", socialRouter); app.use("/api/users", userPortfolioRouter); diff --git a/src/openapi/registry.ts b/src/openapi/registry.ts index 9ab41c3..52219a8 100644 --- a/src/openapi/registry.ts +++ b/src/openapi/registry.ts @@ -1027,3 +1027,153 @@ registry.registerPath({ }, }, }); + +// ── /api/leaderboard/global ────────────────────────────────────────────────── + +/** + * GlobalLeaderboardEntry — a single row in the global leaderboard. + * Aggregated across ALL markets (no time-window filter). + */ +const GlobalLeaderboardEntry = registry.register( + "GlobalLeaderboardEntry", + z + .object({ + user_id: z.string().uuid().describe("Internal user UUID"), + stellar_address: z.string().describe("User's Stellar public key (G…)"), + total_predictions: z + .number() + .int() + .nonnegative() + .describe("Total predictions placed across all markets"), + correct_predictions: z + .number() + .int() + .nonnegative() + .describe("Predictions whose outcome matched the resolved market outcome"), + accuracy_percentage: z + .number() + .min(0) + .max(100) + .describe("Accuracy as a percentage (0–100), rounded to 2 d.p."), + total_markets: z + .number() + .int() + .nonnegative() + .describe("Number of distinct markets in which the user participated"), + rank: z + .number() + .int() + .positive() + .describe( + "1-based global rank, ordered by accuracy DESC then total_predictions DESC", + ), + }) + .openapi("GlobalLeaderboardEntry"), +); + +registry.registerPath({ + method: "get", + path: "/api/leaderboard/global", + operationId: "getGlobalLeaderboard", + tags: ["Leaderboard"], + summary: "Global leaderboard across all markets", + description: + "Returns a paginated leaderboard ranking all users by their prediction " + + "accuracy and volume across **every** market on the platform. " + + "Results are cached for 5 minutes. " + + "Pass `refresh=true` to force an immediate materialized-view refresh " + + "(expensive; intended for admin/debug use).", + request: { + query: z.object({ + limit: z.coerce + .number() + .int() + .positive() + .max(100) + .default(50) + .describe("Maximum entries to return (1–100, default 50)"), + offset: z.coerce + .number() + .int() + .nonnegative() + .default(0) + .describe("Zero-based row offset for pagination (default 0)"), + refresh: z.coerce + .boolean() + .default(false) + .describe( + "When true, triggers REFRESH MATERIALIZED VIEW CONCURRENTLY before querying", + ), + }), + }, + responses: { + 200: { + description: "Paginated global leaderboard", + content: { + "application/json": { + schema: z.object({ + data: z.array(GlobalLeaderboardEntry), + meta: z.object({ + limit: z.number().int(), + offset: z.number().int(), + count: z.number().int(), + refresh: z.boolean(), + }), + }), + }, + }, + }, + 400: { + description: "Invalid query parameters", + content: { "application/json": { schema: ValidationErrorBody } }, + }, + 429: { + description: "Rate limit exceeded", + content: { "application/json": { schema: ErrorBody } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: ErrorBody } }, + }, + }, +}); + +registry.registerPath({ + method: "get", + path: "/api/leaderboard/global/user/{stellarAddress}", + operationId: "getGlobalLeaderboardEntry", + tags: ["Leaderboard"], + summary: "Get a single user's global leaderboard entry", + description: + "Looks up the global leaderboard rank and stats for a specific Stellar " + + "address. Returns 404 when the address has never placed a prediction.", + request: { + params: z.object({ + stellarAddress: z + .string() + .describe("The user's Stellar public key (G…)"), + }), + }, + responses: { + 200: { + description: "User's global leaderboard entry", + content: { + "application/json": { + schema: z.object({ data: GlobalLeaderboardEntry }), + }, + }, + }, + 404: { + description: "Address not found on the global leaderboard", + content: { "application/json": { schema: ErrorBody } }, + }, + 429: { + description: "Rate limit exceeded", + content: { "application/json": { schema: ErrorBody } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: ErrorBody } }, + }, + }, +}); diff --git a/src/routes/leaderboard/global.ts b/src/routes/leaderboard/global.ts new file mode 100644 index 0000000..245d9f4 --- /dev/null +++ b/src/routes/leaderboard/global.ts @@ -0,0 +1,164 @@ +/** + * GET /api/leaderboard/global + * GET /api/leaderboard/global/user/:stellarAddress + * + * Global leaderboard aggregated across ALL markets and ALL time. + * Unlike the market-scoped /api/leaderboard endpoint, this view does not + * filter by resolution period; it reflects a user's complete prediction + * history across the entire platform. + * + * Rate limiting: reuses the existing anonymous rate-limiter so unauthenticated + * callers share the bucket. Authenticated callers bypass the limiter. + */ +import { Router } from "express"; +import { z } from "zod"; +import { rateLimitAnon } from "../../middleware/rateLimitAnon"; +import { logger } from "../../config/logger"; +import { + getGlobalLeaderboard, + getGlobalLeaderboardEntry, + getGlobalLeaderboardWithRefresh, +} from "../../services/globalLeaderboardService"; + +export const globalLeaderboardRouter = Router(); + +// Apply anonymous rate limiting; authenticated Bearer callers bypass it. +globalLeaderboardRouter.use(rateLimitAnon); + +// ── Validation schemas ───────────────────────────────────────────────────── + +/** + * Query-parameter schema for the paginated list endpoint. + * + * All fields are optional; Zod coerces and defaults them so callers can + * omit any subset. + * + * - limit: 1–100 (guards against accidental large fetches) + * - offset: ≥ 0 + * - refresh: when true the underlying materialized view is refreshed before + * the query runs; expensive – intended for admin/debug usage. + */ +const listQuerySchema = z.object({ + limit: z.coerce.number().int().positive().max(100).default(50), + offset: z.coerce.number().int().nonnegative().default(0), + refresh: z.coerce.boolean().default(false), +}); + +export type GlobalLeaderboardListQuery = z.infer; + +// ── Routes ──────────────────────────────────────────────────────────────────── + +/** + * GET /api/leaderboard/global + * + * Returns a paginated global leaderboard sorted by rank ascending. + * Rank is computed from accuracy_percentage DESC then total_predictions DESC. + * + * @query limit {number} default 50, max 100 + * @query offset {number} default 0 + * @query refresh {boolean} default false – force materialized view refresh + * + * @returns 200 { data: GlobalLeaderboardEntry[], meta: { limit, offset, count, refresh } } + * @returns 400 on invalid query parameters + * @returns 500 on unexpected server errors + */ +globalLeaderboardRouter.get("/", async (req, res, next) => { + // Correlation ID comes from the request context set up in index.ts + const correlationId = String((req as { id?: unknown }).id ?? "unknown"); + + const parseResult = listQuerySchema.safeParse(req.query); + if (!parseResult.success) { + logger.warn( + { correlationId, issues: parseResult.error.issues }, + "global_leaderboard_invalid_query", + ); + res.status(400).json({ + error: { + code: "validation_error", + details: parseResult.error.issues, + requestId: correlationId, + }, + }); + return; + } + + const { limit, offset, refresh } = parseResult.data; + + try { + logger.info( + { correlationId, limit, offset, refresh }, + "global_leaderboard_requested", + ); + + const data = refresh + ? await getGlobalLeaderboardWithRefresh(limit, offset) + : await getGlobalLeaderboard(limit, offset); + + logger.info( + { correlationId, count: data.length, refresh }, + "global_leaderboard_served", + ); + + res.json({ + data, + meta: { + limit, + offset, + count: data.length, + refresh, + }, + }); + } catch (e) { + logger.error({ correlationId, err: e }, "global_leaderboard_error"); + next(e); + } +}); + +/** + * GET /api/leaderboard/global/user/:stellarAddress + * + * Returns a single user's global leaderboard entry. + * + * @param stellarAddress - The user's Stellar public key (G…) + * + * @returns 200 { data: GlobalLeaderboardEntry } + * @returns 404 { error: { code: "not_found" } } when the address is unknown + * @returns 500 on unexpected server errors + */ +globalLeaderboardRouter.get("/user/:stellarAddress", async (req, res, next) => { + const correlationId = String((req as { id?: unknown }).id ?? "unknown"); + const { stellarAddress } = req.params; + + try { + logger.info( + { correlationId, stellarAddress }, + "global_leaderboard_user_requested", + ); + + const entry = await getGlobalLeaderboardEntry(stellarAddress); + + if (!entry) { + logger.info( + { correlationId, stellarAddress }, + "global_leaderboard_user_not_found", + ); + res.status(404).json({ + error: { code: "not_found", requestId: correlationId }, + }); + return; + } + + logger.info( + { correlationId, stellarAddress, rank: entry.rank }, + "global_leaderboard_user_served", + ); + + res.json({ data: entry }); + } catch (e) { + logger.error( + { correlationId, stellarAddress, err: e }, + "global_leaderboard_user_error", + ); + next(e); + } +}); diff --git a/src/services/globalLeaderboardService.ts b/src/services/globalLeaderboardService.ts new file mode 100644 index 0000000..7cc2e04 --- /dev/null +++ b/src/services/globalLeaderboardService.ts @@ -0,0 +1,275 @@ +import { db } from "../db/client"; +import { sql } from "drizzle-orm"; +import { redis } from "../config/redis"; +import { logger } from "../config/logger"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +/** + * A single entry in the global leaderboard. + * Aggregated across ALL markets (no time-window filter). + */ +export interface GlobalLeaderboardEntry extends Record { + /** UUID of the user row */ + user_id: string; + /** Stellar public key of the user */ + stellar_address: string; + /** Total number of predictions placed across every market */ + total_predictions: number; + /** Number of predictions that matched the resolved outcome */ + correct_predictions: number; + /** + * Accuracy expressed as a percentage (0–100), rounded to 2 decimal places. + * Zero for users with no predictions. + */ + accuracy_percentage: number; + /** Number of distinct markets in which the user has placed at least one prediction */ + total_markets: number; + /** + * Rank among all users, ordered by accuracy DESC then total_predictions DESC. + * Computed server-side from the materialized view; 1-based. + */ + rank: number; +} + +/** Metadata attached to every paginated response. */ +export interface GlobalLeaderboardMeta { + limit: number; + offset: number; + count: number; +} + +// ── Cache helpers ───────────────────────────────────────────────────────────── + +const CACHE_TTL_SECONDS = 300; // 5 minutes +const CACHE_KEY_PREFIX = "leaderboard:global"; + +/** + * Cache key for a paginated global leaderboard slice. + * Format: leaderboard:global:{limit}:{offset} + */ +function getPageCacheKey(limit: number, offset: number): string { + return `${CACHE_KEY_PREFIX}:${limit}:${offset}`; +} + +/** + * Cache key for a single user's global entry. + * Format: leaderboard:global:user:{stellarAddress} + */ +function getUserCacheKey(stellarAddress: string): string { + return `${CACHE_KEY_PREFIX}:user:${stellarAddress}`; +} + +/** + * Delete all Redis keys for the global leaderboard namespace. + * Called after a view refresh so stale pages and user lookups are evicted. + */ +async function invalidateGlobalCache(): Promise { + if (!redis) return; + try { + const pattern = `${CACHE_KEY_PREFIX}:*`; + const keys = await redis.keys(pattern); + if (keys.length > 0) { + await redis.del(...keys); + logger.debug({ keysDeleted: keys.length }, "global leaderboard cache invalidated"); + } + } catch (err) { + // Cache invalidation failure is non-fatal; log and continue. + logger.warn({ err }, "failed to invalidate global leaderboard cache"); + } +} + +// ── DB helpers ──────────────────────────────────────────────────────────────── + +/** + * The global leaderboard is backed by address_aggregates_mv, which already + * aggregates every user across ALL resolved/disputed markets. We extend it + * on-the-fly with a correlated sub-query that counts distinct markets rather + * than touching an additional large table. + * + * If the materialized view does not exist (CI / test environments without a DB) + * the query will propagate the error to the caller. + */ +const SELECT_GLOBAL_LEADERBOARD_SQL = sql` + SELECT + aa.user_id, + aa.stellar_address, + aa.total_predictions, + aa.correct_predictions, + aa.accuracy_percentage, + COALESCE(mp.market_count, 0)::integer AS total_markets, + aa.rank + FROM address_aggregates_mv aa + LEFT JOIN ( + SELECT user_id, COUNT(DISTINCT market_id)::integer AS market_count + FROM predictions + GROUP BY user_id + ) mp ON mp.user_id = aa.user_id + ORDER BY aa.rank ASC +`; + +// ── Public API ───────────────────────────────────────────────────────────────── + +/** + * Refresh the underlying address_aggregates_mv materialized view and + * invalidate the global leaderboard cache so subsequent requests pick up + * the updated data. + * + * The refresh uses CONCURRENTLY to avoid blocking concurrent reads. + * This is an async operation; callers should await it before responding. + */ +export async function refreshGlobalLeaderboard(): Promise { + try { + await db.execute( + sql`REFRESH MATERIALIZED VIEW CONCURRENTLY address_aggregates_mv`, + ); + await invalidateGlobalCache(); + logger.info("global leaderboard materialized view refreshed"); + } catch (err) { + logger.error({ err }, "failed to refresh global leaderboard materialized view"); + throw err; + } +} + +/** + * Return a paginated slice of the global leaderboard. + * + * Results are served from the Redis cache when available (TTL = 5 min). + * On cache miss the address_aggregates_mv view is queried directly. + * + * @param limit - Maximum rows to return (1–100, default 50). + * @param offset - Zero-based row offset for pagination (default 0). + * @returns - Array of GlobalLeaderboardEntry sorted by rank ASC. + */ +export async function getGlobalLeaderboard( + limit: number = 50, + offset: number = 0, +): Promise { + const cacheKey = getPageCacheKey(limit, offset); + const correlationId = cacheKey; // included in structured logs + + // ── 1. Cache read ────────────────────────────────────────────────────────── + if (redis) { + try { + const cached = await redis.get(cacheKey); + if (cached) { + logger.debug({ cacheKey }, "global leaderboard cache hit"); + return JSON.parse(cached) as GlobalLeaderboardEntry[]; + } + } catch (err) { + logger.warn( + { err, cacheKey, correlationId }, + "global leaderboard cache read failed; falling back to DB", + ); + } + } + + // ── 2. DB query ──────────────────────────────────────────────────────────── + logger.debug({ cacheKey, limit, offset, correlationId }, "global leaderboard DB query"); + + const result = await db.execute( + sql`${SELECT_GLOBAL_LEADERBOARD_SQL} LIMIT ${limit} OFFSET ${offset}`, + ); + const rows = result.rows; + + // ── 3. Cache write (skip on empty results to avoid caching transient gaps) ─ + if (redis && rows.length > 0) { + try { + await redis.setex(cacheKey, CACHE_TTL_SECONDS, JSON.stringify(rows)); + } catch (err) { + logger.warn( + { err, cacheKey, correlationId }, + "global leaderboard cache write failed; query still succeeded", + ); + } + } + + return rows; +} + +/** + * Return a single user's global leaderboard entry, or null if the address + * has never placed a prediction. + * + * Cached separately from paginated slices (TTL = 5 min, including nulls so + * negative lookups don't hammer the DB on repeat 404 requests). + * + * @param stellarAddress - The user's Stellar public key. + * @returns - Entry or null when not found. + */ +export async function getGlobalLeaderboardEntry( + stellarAddress: string, +): Promise { + const cacheKey = getUserCacheKey(stellarAddress); + const correlationId = cacheKey; + + // ── 1. Cache read ────────────────────────────────────────────────────────── + if (redis) { + try { + const cached = await redis.get(cacheKey); + if (cached !== null) { + logger.debug({ cacheKey }, "global leaderboard user cache hit"); + return JSON.parse(cached) as GlobalLeaderboardEntry | null; + } + } catch (err) { + logger.warn( + { err, cacheKey, correlationId }, + "global leaderboard user cache read failed; falling back to DB", + ); + } + } + + // ── 2. DB query ──────────────────────────────────────────────────────────── + const result = await db.execute( + sql` + SELECT + aa.user_id, + aa.stellar_address, + aa.total_predictions, + aa.correct_predictions, + aa.accuracy_percentage, + COALESCE(mp.market_count, 0)::integer AS total_markets, + aa.rank + FROM address_aggregates_mv aa + LEFT JOIN ( + SELECT user_id, COUNT(DISTINCT market_id)::integer AS market_count + FROM predictions + GROUP BY user_id + ) mp ON mp.user_id = aa.user_id + WHERE aa.stellar_address = ${stellarAddress} + LIMIT 1 + `, + ); + const entry = result.rows[0] ?? null; + + // ── 3. Cache write (cache null to short-circuit repeated 404 lookups) ────── + if (redis) { + try { + await redis.setex(cacheKey, CACHE_TTL_SECONDS, JSON.stringify(entry)); + } catch (err) { + logger.warn( + { err, cacheKey, correlationId }, + "global leaderboard user cache write failed; query still succeeded", + ); + } + } + + return entry; +} + +/** + * Refresh the underlying materialized view, then return fresh paginated results. + * Useful for the `refresh=true` query flag so clients can force an update + * without calling a separate admin endpoint. + * + * @param limit - Maximum rows to return. + * @param offset - Zero-based row offset. + * @returns - Fresh GlobalLeaderboardEntry array. + */ +export async function getGlobalLeaderboardWithRefresh( + limit: number = 50, + offset: number = 0, +): Promise { + await refreshGlobalLeaderboard(); + return getGlobalLeaderboard(limit, offset); +} diff --git a/tests/globalLeaderboard.test.ts b/tests/globalLeaderboard.test.ts new file mode 100644 index 0000000..fb193dc --- /dev/null +++ b/tests/globalLeaderboard.test.ts @@ -0,0 +1,373 @@ +/** + * Route tests for global leaderboard endpoints. + * + * Uses a minimal standalone Express app (no createApp) so these tests are + * fully isolated from unrelated pre-existing TypeScript errors in other + * source files. + * + * GET /api/leaderboard/global + * GET /api/leaderboard/global/user/:stellarAddress + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, beforeEach, jest } from "@jest/globals"; +import request from "supertest"; +import express from "express"; +import { globalLeaderboardRouter } from "../src/routes/leaderboard/global"; +import * as globalLeaderboardService from "../src/services/globalLeaderboardService"; + +jest.mock("../src/services/globalLeaderboardService"); +jest.mock("../src/middleware/rateLimitAnon", () => ({ + rateLimitAnon: (_req: any, _res: any, next: any) => next(), +})); +jest.mock("../src/config/logger", () => ({ + logger: { info: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +// ── Test app ───────────────────────────────────────────────────────────────── + +function buildApp() { + const app = express(); + app.use(express.json()); + // Attach a minimal errorHandler so 500s are returned as JSON + app.use("/api/leaderboard/global", globalLeaderboardRouter); + app.use((err: any, _req: any, res: any, _next: any) => { + res.status(err.status ?? 500).json({ error: { code: err.code ?? "internal_error" } }); + }); + return app; +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const mockGetGlobalLeaderboard = + globalLeaderboardService.getGlobalLeaderboard as jest.MockedFunction< + typeof globalLeaderboardService.getGlobalLeaderboard + >; +const mockGetGlobalLeaderboardWithRefresh = + globalLeaderboardService.getGlobalLeaderboardWithRefresh as jest.MockedFunction< + typeof globalLeaderboardService.getGlobalLeaderboardWithRefresh + >; +const mockGetGlobalLeaderboardEntry = + globalLeaderboardService.getGlobalLeaderboardEntry as jest.MockedFunction< + typeof globalLeaderboardService.getGlobalLeaderboardEntry + >; + +const sampleEntry: globalLeaderboardService.GlobalLeaderboardEntry = { + user_id: "550e8400-e29b-41d4-a716-446655440000", + stellar_address: "GAHK7EYR7AQ5B56K2RRYUWWC7EJ5CWWWURC2Q4GQRHBDQY7ZLMQVB6TF", + total_predictions: 120, + correct_predictions: 96, + accuracy_percentage: 80.0, + total_markets: 8, + rank: 1, +}; + +const sampleEntry2: globalLeaderboardService.GlobalLeaderboardEntry = { + user_id: "660e8400-e29b-41d4-a716-446655440001", + stellar_address: "GBTCHKHMWCS5TOX2LAD4DAEKTC3UFSFXQ2MRLED5EYOA34RH4ZX72JK", + total_predictions: 60, + correct_predictions: 42, + accuracy_percentage: 70.0, + total_markets: 3, + rank: 2, +}; + +let app: ReturnType; + +beforeEach(() => { + jest.clearAllMocks(); + app = buildApp(); +}); + +// ── GET /api/leaderboard/global ─────────────────────────────────────────────── + +describe("GET /api/leaderboard/global", () => { + it("returns 200 with paginated entries and default meta", async () => { + mockGetGlobalLeaderboard.mockResolvedValueOnce([sampleEntry]); + + const res = await request(app).get("/api/leaderboard/global"); + + expect(res.status).toBe(200); + expect(res.body.data).toEqual([sampleEntry]); + expect(res.body.meta).toMatchObject({ limit: 50, offset: 0, count: 1, refresh: false }); + expect(mockGetGlobalLeaderboard).toHaveBeenCalledWith(50, 0); + }); + + it("returns multiple entries", async () => { + mockGetGlobalLeaderboard.mockResolvedValueOnce([sampleEntry, sampleEntry2]); + + const res = await request(app).get("/api/leaderboard/global"); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.meta.count).toBe(2); + }); + + it("respects custom limit and offset", async () => { + mockGetGlobalLeaderboard.mockResolvedValueOnce([sampleEntry]); + + const res = await request(app) + .get("/api/leaderboard/global") + .query({ limit: 10, offset: 20 }); + + expect(res.status).toBe(200); + expect(res.body.meta).toMatchObject({ limit: 10, offset: 20 }); + expect(mockGetGlobalLeaderboard).toHaveBeenCalledWith(10, 20); + }); + + it("coerces string query params to numbers", async () => { + mockGetGlobalLeaderboard.mockResolvedValueOnce([sampleEntry]); + + const res = await request(app) + .get("/api/leaderboard/global") + .query({ limit: "25", offset: "50" }); + + expect(res.status).toBe(200); + expect(res.body.meta.limit).toBe(25); + expect(res.body.meta.offset).toBe(50); + expect(mockGetGlobalLeaderboard).toHaveBeenCalledWith(25, 50); + }); + + it("calls the refresh variant when refresh=true", async () => { + mockGetGlobalLeaderboardWithRefresh.mockResolvedValueOnce([sampleEntry]); + + const res = await request(app) + .get("/api/leaderboard/global") + .query({ refresh: "true" }); + + expect(res.status).toBe(200); + expect(res.body.meta.refresh).toBe(true); + expect(mockGetGlobalLeaderboardWithRefresh).toHaveBeenCalledWith(50, 0); + expect(mockGetGlobalLeaderboard).not.toHaveBeenCalled(); + }); + + it("calls refresh variant with custom pagination", async () => { + mockGetGlobalLeaderboardWithRefresh.mockResolvedValueOnce([]); + + const res = await request(app) + .get("/api/leaderboard/global") + .query({ refresh: true, limit: 10, offset: 5 }); + + expect(res.status).toBe(200); + expect(mockGetGlobalLeaderboardWithRefresh).toHaveBeenCalledWith(10, 5); + }); + + it("returns empty array when no entries exist", async () => { + mockGetGlobalLeaderboard.mockResolvedValueOnce([]); + + const res = await request(app).get("/api/leaderboard/global"); + + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); + expect(res.body.meta.count).toBe(0); + }); + + it("returns 400 when limit exceeds 100", async () => { + const res = await request(app) + .get("/api/leaderboard/global") + .query({ limit: 101 }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("validation_error"); + expect(mockGetGlobalLeaderboard).not.toHaveBeenCalled(); + }); + + it("returns 400 when limit is zero", async () => { + const res = await request(app) + .get("/api/leaderboard/global") + .query({ limit: 0 }); + + expect(res.status).toBe(400); + expect(mockGetGlobalLeaderboard).not.toHaveBeenCalled(); + }); + + it("returns 400 when limit is negative", async () => { + const res = await request(app) + .get("/api/leaderboard/global") + .query({ limit: -5 }); + + expect(res.status).toBe(400); + expect(mockGetGlobalLeaderboard).not.toHaveBeenCalled(); + }); + + it("returns 400 when offset is negative", async () => { + const res = await request(app) + .get("/api/leaderboard/global") + .query({ offset: -1 }); + + expect(res.status).toBe(400); + expect(mockGetGlobalLeaderboard).not.toHaveBeenCalled(); + }); + + it("returns 400 when limit is not a number", async () => { + const res = await request(app) + .get("/api/leaderboard/global") + .query({ limit: "abc" }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("validation_error"); + }); + + it("accepts limit=100 (maximum)", async () => { + mockGetGlobalLeaderboard.mockResolvedValueOnce([]); + + const res = await request(app) + .get("/api/leaderboard/global") + .query({ limit: 100 }); + + expect(res.status).toBe(200); + expect(res.body.meta.limit).toBe(100); + }); + + it("accepts limit=1 (minimum)", async () => { + mockGetGlobalLeaderboard.mockResolvedValueOnce([sampleEntry]); + + const res = await request(app) + .get("/api/leaderboard/global") + .query({ limit: 1 }); + + expect(res.status).toBe(200); + expect(res.body.meta.limit).toBe(1); + }); + + it("returns 500 when the service throws", async () => { + mockGetGlobalLeaderboard.mockRejectedValueOnce(new Error("DB unavailable")); + + const res = await request(app).get("/api/leaderboard/global"); + + expect(res.status).toBe(500); + }); + + it("returns 500 when the refresh service throws", async () => { + mockGetGlobalLeaderboardWithRefresh.mockRejectedValueOnce(new Error("Refresh failed")); + + const res = await request(app) + .get("/api/leaderboard/global") + .query({ refresh: true }); + + expect(res.status).toBe(500); + }); + + it("includes all required meta fields", async () => { + mockGetGlobalLeaderboard.mockResolvedValueOnce([sampleEntry]); + + const res = await request(app).get("/api/leaderboard/global"); + + expect(res.body.meta).toHaveProperty("limit"); + expect(res.body.meta).toHaveProperty("offset"); + expect(res.body.meta).toHaveProperty("count"); + expect(res.body.meta).toHaveProperty("refresh"); + }); + + it("returns all GlobalLeaderboardEntry fields", async () => { + mockGetGlobalLeaderboard.mockResolvedValueOnce([sampleEntry]); + + const res = await request(app).get("/api/leaderboard/global"); + const entry = res.body.data[0]; + + expect(entry).toHaveProperty("user_id"); + expect(entry).toHaveProperty("stellar_address"); + expect(entry).toHaveProperty("total_predictions"); + expect(entry).toHaveProperty("correct_predictions"); + expect(entry).toHaveProperty("accuracy_percentage"); + expect(entry).toHaveProperty("total_markets"); + expect(entry).toHaveProperty("rank"); + }); + + it("returns data as array", async () => { + mockGetGlobalLeaderboard.mockResolvedValueOnce([sampleEntry, sampleEntry2]); + + const res = await request(app).get("/api/leaderboard/global"); + + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data.length).toBe(2); + }); +}); + +// ── GET /api/leaderboard/global/user/:stellarAddress ───────────────────────── + +describe("GET /api/leaderboard/global/user/:stellarAddress", () => { + it("returns 200 with the user entry", async () => { + mockGetGlobalLeaderboardEntry.mockResolvedValueOnce(sampleEntry); + + const res = await request(app).get( + `/api/leaderboard/global/user/${sampleEntry.stellar_address}`, + ); + + expect(res.status).toBe(200); + expect(res.body.data).toEqual(sampleEntry); + expect(mockGetGlobalLeaderboardEntry).toHaveBeenCalledWith( + sampleEntry.stellar_address, + ); + }); + + it("returns 404 when address not on leaderboard", async () => { + mockGetGlobalLeaderboardEntry.mockResolvedValueOnce(null); + + const res = await request(app).get( + "/api/leaderboard/global/user/GUNKNOWNADDRESS", + ); + + expect(res.status).toBe(404); + expect(res.body.error.code).toBe("not_found"); + }); + + it("returns 500 when service throws", async () => { + mockGetGlobalLeaderboardEntry.mockRejectedValueOnce(new Error("DB error")); + + const res = await request(app).get( + `/api/leaderboard/global/user/${sampleEntry.stellar_address}`, + ); + + expect(res.status).toBe(500); + }); + + it("passes the exact stellar address to the service", async () => { + const addr = "GBTCHKHMWCS5TOX2LAD4DAEKTC3UFSFXQ2MRLED5EYOA34RH4ZX72JK"; + mockGetGlobalLeaderboardEntry.mockResolvedValueOnce({ ...sampleEntry, stellar_address: addr, rank: 2 }); + + const res = await request(app).get(`/api/leaderboard/global/user/${addr}`); + + expect(res.status).toBe(200); + expect(mockGetGlobalLeaderboardEntry).toHaveBeenCalledWith(addr); + }); + + it("returns correct rank and stats", async () => { + mockGetGlobalLeaderboardEntry.mockResolvedValueOnce(sampleEntry); + + const res = await request(app).get( + `/api/leaderboard/global/user/${sampleEntry.stellar_address}`, + ); + + expect(res.body.data.rank).toBe(sampleEntry.rank); + expect(res.body.data.accuracy_percentage).toBe(sampleEntry.accuracy_percentage); + expect(res.body.data.total_markets).toBe(sampleEntry.total_markets); + }); + + it("returns data as object not array", async () => { + mockGetGlobalLeaderboardEntry.mockResolvedValueOnce(sampleEntry); + + const res = await request(app).get( + `/api/leaderboard/global/user/${sampleEntry.stellar_address}`, + ); + + expect(typeof res.body.data).toBe("object"); + expect(Array.isArray(res.body.data)).toBe(false); + }); + + it("includes all required entry fields", async () => { + mockGetGlobalLeaderboardEntry.mockResolvedValueOnce(sampleEntry); + + const res = await request(app).get( + `/api/leaderboard/global/user/${sampleEntry.stellar_address}`, + ); + const entry = res.body.data; + + expect(entry).toHaveProperty("user_id"); + expect(entry).toHaveProperty("stellar_address"); + expect(entry).toHaveProperty("total_predictions"); + expect(entry).toHaveProperty("correct_predictions"); + expect(entry).toHaveProperty("accuracy_percentage"); + expect(entry).toHaveProperty("total_markets"); + expect(entry).toHaveProperty("rank"); + }); +}); diff --git a/tests/globalLeaderboardService.test.ts b/tests/globalLeaderboardService.test.ts new file mode 100644 index 0000000..27c399f --- /dev/null +++ b/tests/globalLeaderboardService.test.ts @@ -0,0 +1,441 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Unit tests for globalLeaderboardService. + * + * All external dependencies (db, redis, logger) are mocked so these tests + * run without a real database or Redis connection. + */ + +// ── Mock dependencies ──────────────────────────────────────────────────────── + +jest.mock("../src/db/client", () => ({ + db: { + execute: jest.fn(), + }, +})); + +jest.mock("../src/config/redis", () => ({ + redis: { + get: jest.fn(), + setex: jest.fn(), + keys: jest.fn(), + del: jest.fn(), + }, +})); + +jest.mock("../src/config/logger", () => ({ + logger: { + info: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +import { db } from "../src/db/client"; +import { redis } from "../src/config/redis"; +import { + getGlobalLeaderboard, + getGlobalLeaderboardEntry, + getGlobalLeaderboardWithRefresh, + refreshGlobalLeaderboard, + type GlobalLeaderboardEntry, +} from "../src/services/globalLeaderboardService"; + +// ── Shared fixture ─────────────────────────────────────────────────────────── + +const entry1: GlobalLeaderboardEntry = { + user_id: "550e8400-e29b-41d4-a716-446655440000", + stellar_address: "GAHK7EYR7AQ5B56K2RRYUWWC7EJ5CWWWURC2Q4GQRHBDQY7ZLMQVB6TF", + total_predictions: 100, + correct_predictions: 90, + accuracy_percentage: 90.0, + total_markets: 5, + rank: 1, +}; + +const entry2: GlobalLeaderboardEntry = { + user_id: "660e8400-e29b-41d4-a716-446655440001", + stellar_address: "GBTCHKHMWCS5TOX2LAD4DAEKTC3UFSFXQ2MRLED5EYOA34RH4ZX72JK", + total_predictions: 80, + correct_predictions: 60, + accuracy_percentage: 75.0, + total_markets: 3, + rank: 2, +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// ── getGlobalLeaderboard ───────────────────────────────────────────────────── + +describe("getGlobalLeaderboard", () => { + describe("cache behaviour", () => { + it("returns cached data on a cache hit without querying the DB", async () => { + (redis.get as any).mockResolvedValueOnce(JSON.stringify([entry1, entry2])); + + const result = await getGlobalLeaderboard(50, 0); + + expect(result).toEqual([entry1, entry2]); + expect(redis.get).toHaveBeenCalledWith("leaderboard:global:50:0"); + expect(db.execute).not.toHaveBeenCalled(); + }); + + it("queries the DB on a cache miss and writes the result", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockResolvedValueOnce({ rows: [entry1] }); + + const result = await getGlobalLeaderboard(50, 0); + + expect(result).toEqual([entry1]); + expect(db.execute).toHaveBeenCalledTimes(1); + expect(redis.setex).toHaveBeenCalledWith( + "leaderboard:global:50:0", + 300, + JSON.stringify([entry1]), + ); + }); + + it("uses a per-page cache key that encodes limit and offset", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockResolvedValueOnce({ rows: [entry1] }); + + await getGlobalLeaderboard(25, 100); + + expect(redis.get).toHaveBeenCalledWith("leaderboard:global:25:100"); + expect(redis.setex).toHaveBeenCalledWith( + "leaderboard:global:25:100", + 300, + expect.any(String), + ); + }); + + it("does not cache empty results", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockResolvedValueOnce({ rows: [] }); + + const result = await getGlobalLeaderboard(50, 1000); + + expect(result).toEqual([]); + expect(redis.setex).not.toHaveBeenCalled(); + }); + + it("falls back to the DB when the cache read throws", async () => { + (redis.get as any).mockRejectedValueOnce(new Error("Redis timeout")); + (db.execute as any).mockResolvedValueOnce({ rows: [entry1] }); + + const result = await getGlobalLeaderboard(50, 0); + + expect(result).toEqual([entry1]); + expect(db.execute).toHaveBeenCalled(); + }); + + it("still returns data when the cache write fails", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockResolvedValueOnce({ rows: [entry1] }); + (redis.setex as any).mockRejectedValueOnce(new Error("Redis write fail")); + + const result = await getGlobalLeaderboard(50, 0); + + expect(result).toEqual([entry1]); + }); + }); + + describe("default parameters", () => { + it("defaults to limit=50, offset=0", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockResolvedValueOnce({ rows: [] }); + + await getGlobalLeaderboard(); + + expect(redis.get).toHaveBeenCalledWith("leaderboard:global:50:0"); + }); + }); + + describe("DB errors", () => { + it("propagates DB errors to the caller", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockRejectedValueOnce(new Error("PG connection lost")); + + await expect(getGlobalLeaderboard(50, 0)).rejects.toThrow( + "PG connection lost", + ); + }); + }); + + describe("pagination", () => { + it("accepts limit=100 (maximum)", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockResolvedValueOnce({ rows: [] }); + + await getGlobalLeaderboard(100, 0); + + expect(redis.get).toHaveBeenCalledWith("leaderboard:global:100:0"); + }); + + it("accepts offset=0", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockResolvedValueOnce({ rows: [entry1] }); + + await getGlobalLeaderboard(50, 0); + + expect(redis.get).toHaveBeenCalledWith("leaderboard:global:50:0"); + }); + + it("accepts large offsets (deep pagination)", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockResolvedValueOnce({ rows: [] }); + + await getGlobalLeaderboard(50, 500); + + expect(redis.get).toHaveBeenCalledWith("leaderboard:global:50:500"); + }); + }); +}); + +// ── getGlobalLeaderboardEntry ───────────────────────────────────────────────── + +describe("getGlobalLeaderboardEntry", () => { + const addr = entry1.stellar_address; + + describe("cache behaviour", () => { + it("returns cached entry on a cache hit without querying the DB", async () => { + (redis.get as any).mockResolvedValueOnce(JSON.stringify(entry1)); + + const result = await getGlobalLeaderboardEntry(addr); + + expect(result).toEqual(entry1); + expect(redis.get).toHaveBeenCalledWith(`leaderboard:global:user:${addr}`); + expect(db.execute).not.toHaveBeenCalled(); + }); + + it("returns null from cache when the address is known-missing", async () => { + (redis.get as any).mockResolvedValueOnce(JSON.stringify(null)); + + const result = await getGlobalLeaderboardEntry("GNOTFOUND"); + + expect(result).toBeNull(); + expect(db.execute).not.toHaveBeenCalled(); + }); + + it("queries the DB on a cache miss and caches the result", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockResolvedValueOnce({ rows: [entry1] }); + + const result = await getGlobalLeaderboardEntry(addr); + + expect(result).toEqual(entry1); + expect(db.execute).toHaveBeenCalledTimes(1); + expect(redis.setex).toHaveBeenCalledWith( + `leaderboard:global:user:${addr}`, + 300, + JSON.stringify(entry1), + ); + }); + + it("caches null when the user is not found (negative cache)", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockResolvedValueOnce({ rows: [] }); + + const result = await getGlobalLeaderboardEntry("GUNKNOWN"); + + expect(result).toBeNull(); + expect(redis.setex).toHaveBeenCalledWith( + "leaderboard:global:user:GUNKNOWN", + 300, + JSON.stringify(null), + ); + }); + + it("falls back to the DB when the cache read throws", async () => { + (redis.get as any).mockRejectedValueOnce(new Error("Cache down")); + (db.execute as any).mockResolvedValueOnce({ rows: [entry1] }); + + const result = await getGlobalLeaderboardEntry(addr); + + expect(result).toEqual(entry1); + }); + + it("still returns data when the cache write fails", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockResolvedValueOnce({ rows: [entry1] }); + (redis.setex as any).mockRejectedValueOnce(new Error("Write fail")); + + const result = await getGlobalLeaderboardEntry(addr); + + expect(result).toEqual(entry1); + }); + }); + + describe("DB errors", () => { + it("propagates DB errors to the caller", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockRejectedValueOnce(new Error("PG unavailable")); + + await expect(getGlobalLeaderboardEntry(addr)).rejects.toThrow( + "PG unavailable", + ); + }); + }); + + describe("return value", () => { + it("returns null when the user has no predictions", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockResolvedValueOnce({ rows: [] }); + + expect(await getGlobalLeaderboardEntry("GNOPREDICT")).toBeNull(); + }); + + it("returns the correct entry with all fields", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockResolvedValueOnce({ rows: [entry1] }); + + const result = await getGlobalLeaderboardEntry(addr); + + expect(result).toMatchObject({ + user_id: entry1.user_id, + stellar_address: entry1.stellar_address, + total_predictions: entry1.total_predictions, + correct_predictions: entry1.correct_predictions, + accuracy_percentage: entry1.accuracy_percentage, + total_markets: entry1.total_markets, + rank: entry1.rank, + }); + }); + + it("uses the stellar address in the cache key", async () => { + (redis.get as any).mockResolvedValueOnce(null); + (db.execute as any).mockResolvedValueOnce({ rows: [entry2] }); + + await getGlobalLeaderboardEntry(entry2.stellar_address); + + expect(redis.get).toHaveBeenCalledWith( + `leaderboard:global:user:${entry2.stellar_address}`, + ); + }); + }); +}); + +// ── refreshGlobalLeaderboard ───────────────────────────────────────────────── + +describe("refreshGlobalLeaderboard", () => { + it("executes REFRESH MATERIALIZED VIEW CONCURRENTLY on address_aggregates_mv", async () => { + (redis.keys as any).mockResolvedValueOnce([]); + (db.execute as any).mockResolvedValueOnce(undefined); + + await refreshGlobalLeaderboard(); + + expect(db.execute).toHaveBeenCalledTimes(1); + const sqlCall = JSON.stringify((db.execute as any).mock.calls[0][0]); + expect(sqlCall).toContain("REFRESH"); + expect(sqlCall).toContain("MATERIALIZED"); + expect(sqlCall).toContain("CONCURRENTLY"); + expect(sqlCall).toContain("address_aggregates_mv"); + }); + + it("invalidates all leaderboard:global:* keys from Redis", async () => { + const keys = [ + "leaderboard:global:50:0", + "leaderboard:global:25:0", + "leaderboard:global:user:GAAA", + ]; + (redis.keys as any).mockResolvedValueOnce(keys); + (redis.del as any).mockResolvedValueOnce(3); + (db.execute as any).mockResolvedValueOnce(undefined); + + await refreshGlobalLeaderboard(); + + expect(redis.keys).toHaveBeenCalledWith("leaderboard:global:*"); + expect(redis.del).toHaveBeenCalledWith(...keys); + }); + + it("skips Redis del when no cache keys exist", async () => { + (redis.keys as any).mockResolvedValueOnce([]); + (db.execute as any).mockResolvedValueOnce(undefined); + + await refreshGlobalLeaderboard(); + + expect(redis.del).not.toHaveBeenCalled(); + }); + + it("propagates DB errors to the caller", async () => { + (db.execute as any).mockRejectedValueOnce(new Error("View refresh failed")); + + await expect(refreshGlobalLeaderboard()).rejects.toThrow( + "View refresh failed", + ); + }); + + it("does not throw when Redis cache invalidation fails", async () => { + (db.execute as any).mockResolvedValueOnce(undefined); + (redis.keys as any).mockRejectedValueOnce(new Error("Redis down")); + + // Should still succeed despite the cache failure + await expect(refreshGlobalLeaderboard()).resolves.toBeUndefined(); + }); +}); + +// ── getGlobalLeaderboardWithRefresh ────────────────────────────────────────── + +describe("getGlobalLeaderboardWithRefresh", () => { + it("refreshes the view then returns fresh paginated results", async () => { + // First db.execute call = REFRESH; second = SELECT + (redis.keys as any).mockResolvedValueOnce([]); + (db.execute as any) + .mockResolvedValueOnce(undefined) // REFRESH call + .mockResolvedValueOnce({ rows: [entry1] }); // SELECT call after cache miss + (redis.get as any).mockResolvedValueOnce(null); // cache miss after invalidation + + const result = await getGlobalLeaderboardWithRefresh(50, 0); + + expect(result).toEqual([entry1]); + expect(db.execute).toHaveBeenCalledTimes(2); + }); + + it("uses the provided limit and offset for the paginated query", async () => { + (redis.keys as any).mockResolvedValueOnce([]); + (db.execute as any) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ rows: [entry2] }); + (redis.get as any).mockResolvedValueOnce(null); + + const result = await getGlobalLeaderboardWithRefresh(10, 20); + + expect(result).toEqual([entry2]); + expect(redis.get).toHaveBeenCalledWith("leaderboard:global:10:20"); + }); + + it("defaults to limit=50, offset=0", async () => { + (redis.keys as any).mockResolvedValueOnce([]); + (db.execute as any) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ rows: [] }); + (redis.get as any).mockResolvedValueOnce(null); + + await getGlobalLeaderboardWithRefresh(); + + expect(redis.get).toHaveBeenCalledWith("leaderboard:global:50:0"); + }); + + it("propagates DB errors from the refresh step", async () => { + (redis.keys as any).mockResolvedValueOnce([]); + (db.execute as any).mockRejectedValueOnce(new Error("Refresh failed")); + + await expect(getGlobalLeaderboardWithRefresh(50, 0)).rejects.toThrow( + "Refresh failed", + ); + }); + + it("propagates DB errors from the subsequent query step", async () => { + (redis.keys as any).mockResolvedValueOnce([]); + (db.execute as any) + .mockResolvedValueOnce(undefined) // refresh OK + .mockRejectedValueOnce(new Error("Query failed")); // select fails + (redis.get as any).mockResolvedValueOnce(null); + + await expect(getGlobalLeaderboardWithRefresh(50, 0)).rejects.toThrow( + "Query failed", + ); + }); +});