From 80c84738cc1762a885e3e09b6f169e146723e2a6 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Wed, 27 May 2026 19:00:32 +0100 Subject: [PATCH] Add structured log for unrecognized sort field in creator list request --- src/modules/creator/creator.controller.ts | 3 + src/modules/creator/creator.utils.ts | 19 +++-- ...tor-feed-empty-filters.integration.test.ts | 42 +++++++++++ src/modules/creators/creators.controllers.ts | 3 + .../creators.sort-field.utils.test.ts | 69 +++++++++++++++++++ .../creators/creators.sort-field.utils.ts | 57 +++++++++++++++ 6 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 src/modules/creators/creators.sort-field.utils.test.ts create mode 100644 src/modules/creators/creators.sort-field.utils.ts diff --git a/src/modules/creator/creator.controller.ts b/src/modules/creator/creator.controller.ts index c540c41..6039368 100644 --- a/src/modules/creator/creator.controller.ts +++ b/src/modules/creator/creator.controller.ts @@ -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 @@ -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, diff --git a/src/modules/creator/creator.utils.ts b/src/modules/creator/creator.utils.ts index 45f4bc5..a2a36e2 100644 --- a/src/modules/creator/creator.utils.ts +++ b/src/modules/creator/creator.utils.ts @@ -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; @@ -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) 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 40b4298..dc8d9e6 100644 --- a/src/modules/creators/creator-feed-empty-filters.integration.test.ts +++ b/src/modules/creators/creator-feed-empty-filters.integration.test.ts @@ -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 ──────────────────────────────────────── @@ -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); + }); +}); diff --git a/src/modules/creators/creators.controllers.ts b/src/modules/creators/creators.controllers.ts index f55ca51..f04f58d 100644 --- a/src/modules/creators/creators.controllers.ts +++ b/src/modules/creators/creators.controllers.ts @@ -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, @@ -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, diff --git a/src/modules/creators/creators.sort-field.utils.test.ts b/src/modules/creators/creators.sort-field.utils.test.ts new file mode 100644 index 0000000..d00dbcb --- /dev/null +++ b/src/modules/creators/creators.sort-field.utils.test.ts @@ -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(); + }); +}); diff --git a/src/modules/creators/creators.sort-field.utils.ts b/src/modules/creators/creators.sort-field.utils.ts new file mode 100644 index 0000000..b9a6b4c --- /dev/null +++ b/src/modules/creators/creators.sort-field.utils.ts @@ -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 | 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, + 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 } : {}), + }); +}