Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions docs/api/creator-list-query-precedence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Creator List Query Parameter Precedence

`GET /api/v1/creators` accepts several query parameters for pagination, sorting,
and filtering. When parameters overlap or conflict, the rules below determine
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`) |

## Precedence rules

### `sort` and `order` are always applied together

`order` has no effect unless a `sort` field is present. When `sort` is omitted,
the default field (`createdAt`) and the default direction (`desc`) are used.
If `sort` is supplied without `order`, `order` defaults to `desc`.

```
sort=displayName → sort by displayName desc (order defaults)
sort=displayName&order=asc → sort by displayName asc
order=asc → sort by createdAt asc (sort defaults)
```

### Cursor-based navigation overrides `offset`

When a `cursor` value is present (future feature, type-checked at the schema
level), it takes precedence over `offset`. The `limit` value continues to
control page size regardless of which pagination mode is active.

While the endpoint currently uses offset pagination, the schema reserves the
`cursor` field for forward compatibility. Supplying both `cursor` and `offset`
is not recommended; `cursor` will win when both are non-empty.

### `search` and `verified` are independent filters — both are applied

`search` and `verified` narrow the result set independently. When both are
present the response contains only creators that match **both** conditions.
Neither parameter takes precedence over the other; they are ANDed together in
the database query.

```
verified=true&search=jazz → creators who are verified AND whose name/handle
contains "jazz"
```

### `search` is applied before sorting

Sorting is applied to the filtered result set. Specifying `search=jazz` with
`sort=displayName` returns matching creators sorted alphabetically — not all
creators sorted alphabetically narrowed to those matching "jazz".

### `verified` is applied before `search`

There is no practical difference in the result when both are present (AND
semantics), but internally `verified` is resolved first in the filter
combinator. The ordering is an implementation detail and may not be relied upon
for correctness.

### `limit` and `offset` operate on the fully-filtered, sorted result set

`limit` and `offset` are applied **after** all filters and sorting. Setting
`offset=40&limit=20` skips the first 40 matching creators and returns the next
20, not 20 creators from position 40 in the unfiltered list.

### Unrecognized parameters are rejected

The query schema uses `strict()` mode. Any parameter not listed in the table
above causes a `400 Bad Request` with a structured error body listing the
unknown keys. Unknown parameters are never silently ignored.

### Repeated parameters use the first value

When the same parameter appears more than once in the query string
(e.g. `sort=createdAt&sort=displayName`), only the first occurrence is used.
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` |

## Related files

- [`src/modules/creators/creators.schemas.ts`](../../src/modules/creators/creators.schemas.ts) — Zod validation schema with defaults
- [`src/modules/creators/creators.filter.ts`](../../src/modules/creators/creators.filter.ts) — Filter parsing and unknown-key rejection
- [`src/modules/creators/creator-feed-filter-combinator.utils.ts`](../../src/modules/creators/creator-feed-filter-combinator.utils.ts) — Prisma `where` clause builder
- [`src/constants/creator-list-sort.constants.ts`](../../src/constants/creator-list-sort.constants.ts) — Allowed sort fields and defaults
- [`src/modules/creators/creators.routes.ts`](../../src/modules/creators/creators.routes.ts) — Route handler wiring
45 changes: 19 additions & 26 deletions src/modules/creators/creators-cache-key.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
* filter and pagination inputs to ensure cache invalidation works correctly.
*/
import { CreatorListQueryType } from './creators.schemas';
import { buildCanonicalParamString } from '../../utils/cache-key-params.utils';

/**
* Builds a cache key for the creator feed endpoint.
*
* The key includes all query parameters to ensure that different
* filter/pagination combinations have separate cache entries.
* Parameters are sorted into a canonical order via `buildCanonicalParamString`
* so that two requests with identical params in different orders always map to
* the same cache entry.
*
* @param query - The parsed creator feed query parameters
* @returns A deterministic cache key string
Expand All @@ -26,34 +28,25 @@ import { CreatorListQueryType } from './creators.schemas';
* search: 'example',
* include: ['stats']
* });
* // Returns: "creators:limit:20:offset:0:sort:createdAt:order:desc:verified:true:search:example:include:stats"
* // Returns: "creators:include:stats:limit:20:offset:0:order:desc:search:example:sort:createdAt:verified:true"
* ```
*/
export function buildCreatorFeedCacheKey(query: CreatorListQueryType): string {
const parts: string[] = ['creators'];
const params: Record<string, string | number | boolean | undefined> = {
limit: query.limit,
offset: query.offset,
sort: query.sort,
order: query.order,
verified: query.verified,
search: query.search !== '' ? query.search : undefined,
include:
query.include !== undefined && query.include.length > 0
? query.include.join(',')
: undefined,
};

// Add pagination parameters
parts.push(`limit:${query.limit}`);
parts.push(`offset:${query.offset}`);

// Add sorting parameters
parts.push(`sort:${query.sort}`);
parts.push(`order:${query.order}`);

// Add filter parameters if present
if (query.verified !== undefined) {
parts.push(`verified:${query.verified}`);
}

if (query.search !== undefined && query.search !== '') {
parts.push(`search:${query.search}`);
}

if (query.include !== undefined && query.include.length > 0) {
parts.push(`include:${query.include.join(',')}`);
}

return parts.join(':');
const canonical = buildCanonicalParamString(params);
return canonical ? `creators:${canonical}` : 'creators';
}

/**
Expand Down
167 changes: 167 additions & 0 deletions src/modules/health/health.controllers.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Integration tests for health controllers under simulated dependency failures.
// 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,
},
appConfig: {
allowedOrigins: [],
},
}));

jest.mock('../../utils/prisma.utils', () => ({
prisma: {
$queryRaw: jest.fn(),
},
}));

jest.mock('../../utils/indexer-cursor-staleness.utils', () => ({
checkIndexerCursorStalenessFromStore: jest.fn().mockResolvedValue(undefined),
}));

import { Request, Response } from 'express';
import { healthCheck, readinessCheck } from './health.controllers';
import { prisma } from '../../utils/prisma.utils';

const queryRawMock = prisma.$queryRaw as unknown as jest.Mock;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

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;
};
return res;
}

function mockRequest(): Request {
return {} as Request;
}

// ---------------------------------------------------------------------------
// readinessCheck — database failure
// ---------------------------------------------------------------------------

describe('readinessCheck() — simulated database failure', () => {
beforeEach(() => {
queryRawMock.mockReset();
});

it('returns 503 when the database is unreachable', async () => {
queryRawMock.mockRejectedValue(new Error('connection refused'));

const res = mockResponse();
await readinessCheck(mockRequest(), res);

expect(res.statusCode).toBe(503);
});

it('sets ready:false when a dependency check fails', async () => {
queryRawMock.mockRejectedValue(new Error('timeout'));

const res = mockResponse();
await readinessCheck(mockRequest(), res);

expect(res.body.ready).toBe(false);
});

it('response body conforms to the readiness schema even on failure', async () => {
queryRawMock.mockRejectedValue(new Error('ECONNREFUSED'));

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);
});

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 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'));

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('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);

expect(res.body.latencyMs).toBeGreaterThanOrEqual(0);
});
});

// ---------------------------------------------------------------------------
// healthCheck (detailed) — database failure in production
// ---------------------------------------------------------------------------

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');
});
});
Loading
Loading