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
3 changes: 3 additions & 0 deletions src/modules/creator/creator.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { parseCreatorSortOptions } from './creator.utils';
import { parsePublicQuery } from '../../utils/public-query-parse.utils';
import { wrapPublicCreatorListResponse } from '../creators/public-creator-list-envelope.utils';
import { buildCreatorListRequestContext } from '../creators/creator-list-context.utils';
import { warnIfUnrecognizedCreatorListSort } from '../creators/creators.sort-field.utils';
import { normalizeCreatorListPage } from './creator-list-page.guard';

// Legacy query schema
Expand Down Expand Up @@ -72,6 +73,8 @@ export const listCreators: RequestHandler = async (req, res) => {
// Build request context
const ctx = buildCreatorListRequestContext(req);

warnIfUnrecognizedCreatorListSort(ctx.query, req.requestId);

// Parse query using legacy schema
const parsed = parsePublicQuery(
LegacyCreatorQuerySchema,
Expand Down
19 changes: 14 additions & 5 deletions src/modules/creator/creator.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import { Prisma } from '@prisma/client';
import { resolveSlugCollision } from '../../utils/slug.utils';
import { prisma } from '../../utils/prisma.utils';
import {
CREATOR_LIST_SORT_FIELDS,
CREATOR_LIST_SORT_ORDERS,
DEFAULT_CREATOR_LIST_ORDER,
DEFAULT_CREATOR_LIST_SORT,
type CreatorListSortField,
type CreatorListSortOrder,
} from '../../constants/creator-list-sort.constants';
import {
isRecognizedCreatorListSortField,
warnIfUnrecognizedCreatorListSort,
} from '../creators/creators.sort-field.utils';

export type CreatorSortField = CreatorListSortField;
export type SortOrder = CreatorListSortOrder;
Expand All @@ -25,11 +28,17 @@ export interface CreatorSortOptions {
*/
export function parseCreatorSortOptions(
sortBy?: string,
sortOrder?: string
sortOrder?: string,
requestId?: string
): CreatorSortOptions {
const field = CREATOR_LIST_SORT_FIELDS.includes(sortBy as CreatorSortField)
? (sortBy as CreatorSortField)
: DEFAULT_CREATOR_LIST_SORT;
if (sortBy !== undefined && sortBy !== '') {
warnIfUnrecognizedCreatorListSort({ sort: sortBy }, requestId);
}

const field =
sortBy && isRecognizedCreatorListSortField(sortBy)
? sortBy
: DEFAULT_CREATOR_LIST_SORT;

const order = CREATOR_LIST_SORT_ORDERS.includes(sortOrder as SortOrder)
? (sortOrder as SortOrder)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { httpListCreators } from './creators.controllers';
import * as creatorsUtils from './creators.utils';
import { logger } from '../../utils/logger.utils';

// ── Lightweight request/response mocks ────────────────────────────────────────

Expand Down Expand Up @@ -485,3 +486,44 @@ describe('GET /api/v1/creators — empty feed with filter combinations', () => {
expect(body.success).toBe(false);
});
});

describe('GET /api/v1/creators — unrecognized sort logging', () => {
let warnSpy: jest.SpyInstance;

beforeEach(() => {
jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([[], 0]);
warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
});

afterEach(() => {
jest.restoreAllMocks();
});

it('logs unrecognized sort fields at warn level without changing the 400 response', async () => {
const req = makeReq({ sort: 'invalidField' });
req.requestId = 'req-invalid-sort';
const res = makeRes();
await httpListCreators(req, res, makeNext());

expect(warnSpy).toHaveBeenCalledWith(
expect.objectContaining({
msg: 'Unrecognized creator list sort field',
sort: 'invalidField',
requestId: 'req-invalid-sort',
})
);
expect(res.status).toHaveBeenCalledWith(400);
});

it('does not log for recognized sort fields', async () => {
const req = makeReq({ sort: 'displayName' });
req.requestId = 'req-valid-sort';
const res = makeRes();
await httpListCreators(req, res, makeNext());

const sortWarnings = warnSpy.mock.calls.filter(
([payload]) => payload?.msg === 'Unrecognized creator list sort field'
);
expect(sortWarnings).toHaveLength(0);
});
});
3 changes: 3 additions & 0 deletions src/modules/creators/creators.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { attachTimestampHeader } from '../../utils/timestamp-headers.utils';
import { parsePublicQuery } from '../../utils/public-query-parse.utils';
import { buildOffsetPaginationMeta } from '../../utils/pagination.utils';
import { buildCreatorListRequestContext } from './creator-list-context.utils';
import { warnIfUnrecognizedCreatorListSort } from './creators.sort-field.utils';
import {
incrementFilterParseError,
type FilterParseErrorCategory,
Expand All @@ -29,6 +30,8 @@ export const httpListCreators: AsyncController = async (req, res, next) => {
try {
const ctx = buildCreatorListRequestContext(req);

warnIfUnrecognizedCreatorListSort(ctx.query, req.requestId);

// Validate query parameters
const parsed = parsePublicQuery(
CreatorListQuerySchema,
Expand Down
69 changes: 69 additions & 0 deletions src/modules/creators/creators.sort-field.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
getRawCreatorListSortParam,
isRecognizedCreatorListSortField,
warnIfUnrecognizedCreatorListSort,
} from './creators.sort-field.utils';
import { logger } from '../../utils/logger.utils';

jest.mock('../../utils/logger.utils', () => ({
logger: { warn: jest.fn() },
}));

const warnMock = logger.warn as jest.Mock;

beforeEach(() => {
warnMock.mockClear();
});

describe('isRecognizedCreatorListSortField()', () => {
it('accepts allowed public sort fields', () => {
expect(isRecognizedCreatorListSortField('createdAt')).toBe(true);
expect(isRecognizedCreatorListSortField('displayName')).toBe(true);
});

it('rejects values outside the allowed set', () => {
expect(isRecognizedCreatorListSortField('invalidField')).toBe(false);
});
});

describe('getRawCreatorListSortParam()', () => {
it('reads a string sort query param', () => {
expect(getRawCreatorListSortParam({ sort: 'handle' })).toBe('handle');
});

it('reads the first value when sort is an array', () => {
expect(getRawCreatorListSortParam({ sort: ['followers', 'createdAt'] })).toBe(
'followers'
);
});
});

describe('warnIfUnrecognizedCreatorListSort()', () => {
it('emits a warn log with the raw sort value and request id', () => {
warnIfUnrecognizedCreatorListSort(
{ sort: 'invalidField' },
'req-sort-123'
);

expect(warnMock).toHaveBeenCalledWith({
msg: 'Unrecognized creator list sort field',
sort: 'invalidField',
requestId: 'req-sort-123',
});
});

it('does not log for recognized sort fields', () => {
warnIfUnrecognizedCreatorListSort({ sort: 'createdAt' }, 'req-ok');
expect(warnMock).not.toHaveBeenCalled();
});

it('does not log when sort is omitted', () => {
warnIfUnrecognizedCreatorListSort({}, 'req-ok');
expect(warnMock).not.toHaveBeenCalled();
});

it('does not log for whitespace-only sort (treated as omitted)', () => {
warnIfUnrecognizedCreatorListSort({ sort: ' ' }, 'req-ok');
expect(warnMock).not.toHaveBeenCalled();
});
});
57 changes: 57 additions & 0 deletions src/modules/creators/creators.sort-field.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Request } from 'express';
import {
CREATOR_LIST_SORT_FIELDS,
type CreatorListSortField,
} from '../../constants/creator-list-sort.constants';
import { normalizeCreatorListQueryStringValue } from './creators.query-string.utils';
import { logger } from '../../utils/logger.utils';

/**
* Returns true when `value` is an allowed public creator list sort field.
*/
export function isRecognizedCreatorListSortField(
value: string
): value is CreatorListSortField {
return (CREATOR_LIST_SORT_FIELDS as readonly string[]).includes(value);
}

/**
* Reads the raw `sort` query param from an Express query object.
*/
export function getRawCreatorListSortParam(
query: Request['query'] | Record<string, unknown>
): string | undefined {
const raw = query['sort'];
if (typeof raw === 'string') {
return raw;
}
if (Array.isArray(raw) && typeof raw[0] === 'string') {
return raw[0];
}
return undefined;
}

/**
* Emits a structured warn log when the client supplied a non-empty sort field
* outside {@link CREATOR_LIST_SORT_FIELDS}. No-op for recognized or omitted values.
*/
export function warnIfUnrecognizedCreatorListSort(
query: Request['query'] | Record<string, unknown>,
requestId?: string
): void {
const rawSort = getRawCreatorListSortParam(query);
if (rawSort === undefined) {
return;
}

const normalized = normalizeCreatorListQueryStringValue(rawSort);
if (typeof normalized !== 'string' || isRecognizedCreatorListSortField(normalized)) {
return;
}

logger.warn({
msg: 'Unrecognized creator list sort field',
sort: normalized,
...(requestId ? { requestId } : {}),
});
}
Loading