From 4b1238f7c7741691c8ee3302194aa1355501df6c Mon Sep 17 00:00:00 2001 From: dinahmaccodes Date: Fri, 29 May 2026 10:15:26 +0100 Subject: [PATCH] feat(creators): add pagination boundary warning logs for out-of-range cursors --- src/modules/creators/creators.controllers.ts | 11 ++ ...reators.cursor-warning.integration.test.ts | 152 ++++++++++++++++++ .../creators/creators.cursor-warning.utils.ts | 81 ++++++++++ src/modules/creators/creators.schemas.ts | 4 + 4 files changed, 248 insertions(+) create mode 100644 src/modules/creators/creators.cursor-warning.integration.test.ts create mode 100644 src/modules/creators/creators.cursor-warning.utils.ts diff --git a/src/modules/creators/creators.controllers.ts b/src/modules/creators/creators.controllers.ts index f04f58d..46c87e1 100644 --- a/src/modules/creators/creators.controllers.ts +++ b/src/modules/creators/creators.controllers.ts @@ -15,6 +15,7 @@ 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 { warnIfOutOfRangeCursor } from './creators.cursor-warning.utils'; import { incrementFilterParseError, type FilterParseErrorCategory, @@ -46,6 +47,16 @@ export const httpListCreators: AsyncController = async (req, res, next) => { } const validatedQuery = parsed.data; + // Check for out-of-range pagination cursor + if (validatedQuery.cursor) { + await warnIfOutOfRangeCursor({ + cursor: validatedQuery.cursor, + route: req.path, + requestId: req.requestId, + query: validatedQuery, + }); + } + // Fetch creators and total count const [creators, total] = await fetchCreatorList(validatedQuery); diff --git a/src/modules/creators/creators.cursor-warning.integration.test.ts b/src/modules/creators/creators.cursor-warning.integration.test.ts new file mode 100644 index 0000000..0645f54 --- /dev/null +++ b/src/modules/creators/creators.cursor-warning.integration.test.ts @@ -0,0 +1,152 @@ +import { httpListCreators } from './creators.controllers'; +import * as creatorsUtils from './creators.utils'; +import { logger } from '../../utils/logger.utils'; +import { prisma } from '../../utils/prisma.utils'; +import { encodeCursor } from '../../utils/cursor.utils'; +import { CreatorProfile } from '../../types/profile.types'; + +function makeReq(query: Record = {}, path = '/api/v1/creators'): any { + return { + query, + path, + requestId: 'test-request-id', + }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +describe('Pagination Boundary Warnings - Out-of-range cursors', () => { + const sampleProfile: CreatorProfile = { + id: 'creator-1', + userId: 'user-1', + handle: 'creator_1', + displayName: 'Creator One', + isVerified: true, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:00:00.000Z'), + }; + + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + jest.restoreAllMocks(); + warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + + // Mock fetchCreatorList to return a default mock result + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([[sampleProfile], 1]); + }); + + it('does not log a warning when no cursor is provided', async () => { + const req = makeReq({ limit: '10' }); + const res = makeRes(); + const next = makeNext(); + await httpListCreators(req, res, next); + + if (next.mock.calls.length > 0) { + throw next.mock.calls[0][0]; + } + + expect(warnSpy).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('does not log a warning for a valid cursor pointing to an existing creator matching query filters', async () => { + const validCursor = encodeCursor({ + id: sampleProfile.id, + createdAt: sampleProfile.createdAt.toISOString(), + }); + + // Mock prisma.creatorProfile.findFirst to return the profile (exists) + jest.spyOn(prisma.creatorProfile, 'findFirst').mockResolvedValue(sampleProfile as any); + + const req = makeReq({ cursor: validCursor }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('logs a warning for an invalid or malformed cursor string, keeping response unchanged', async () => { + const req = makeReq({ cursor: 'invalid-base64-string' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(warnSpy).toHaveBeenCalledWith( + expect.objectContaining({ + msg: 'Out-of-range pagination cursor', + route: '/api/v1/creators', + cursor: 'invalid-base64-string', + requestId: 'test-request-id', + }) + ); + // Response must remain unchanged + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('logs a warning when a valid cursor references a non-existent creator profile', async () => { + const validCursor = encodeCursor({ + id: 'non-existent-id', + createdAt: new Date('2024-01-01T00:00:00.000Z').toISOString(), + }); + + // Mock prisma.creatorProfile.findFirst to return null (does not exist) + jest.spyOn(prisma.creatorProfile, 'findFirst').mockResolvedValue(null); + + const req = makeReq({ cursor: validCursor }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(warnSpy).toHaveBeenCalledWith( + expect.objectContaining({ + msg: 'Out-of-range pagination cursor', + route: '/api/v1/creators', + cursor: validCursor, + cursorId: 'non-existent-id', + cursorCreatedAt: '2024-01-01T00:00:00.000Z', + requestId: 'test-request-id', + }) + ); + // Response must remain unchanged + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('logs a warning when a valid cursor references a profile that exists but does not match query filters', async () => { + const validCursor = encodeCursor({ + id: sampleProfile.id, + createdAt: sampleProfile.createdAt.toISOString(), + }); + + // Mock prisma.creatorProfile.findFirst to return null because filters do not match + jest.spyOn(prisma.creatorProfile, 'findFirst').mockResolvedValue(null); + + // Request verified=true, but mock findFirst returns null (meaning it does not match) + const req = makeReq({ cursor: validCursor, verified: 'true' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(warnSpy).toHaveBeenCalledWith( + expect.objectContaining({ + msg: 'Out-of-range pagination cursor', + route: '/api/v1/creators', + cursor: validCursor, + cursorId: sampleProfile.id, + cursorCreatedAt: sampleProfile.createdAt.toISOString(), + requestId: 'test-request-id', + }) + ); + // Response must remain unchanged + expect(res.status).toHaveBeenCalledWith(200); + }); +}); diff --git a/src/modules/creators/creators.cursor-warning.utils.ts b/src/modules/creators/creators.cursor-warning.utils.ts new file mode 100644 index 0000000..ceb10d8 --- /dev/null +++ b/src/modules/creators/creators.cursor-warning.utils.ts @@ -0,0 +1,81 @@ +import { decodeCreatorFeedCursor } from '../../utils/creator-feed-cursor.utils'; +import { logger } from '../../utils/logger.utils'; +import { prisma } from '../../utils/prisma.utils'; +import { buildCreatorFeedWhere } from './creator-feed-filter-combinator.utils'; + +export interface WarnIfOutOfRangeCursorParams { + cursor: string; + route: string; + requestId?: string; + query: { verified?: boolean; search?: string }; +} + +/** + * Emits a structured warning log if the client-supplied cursor is out-of-range or invalid. + * Ensures all errors are caught and handled gracefully so the API response behavior + * is never affected. + */ +export async function warnIfOutOfRangeCursor( + params: WarnIfOutOfRangeCursorParams +): Promise { + const { cursor, route, requestId, query } = params; + + try { + const decoded = decodeCreatorFeedCursor(cursor); + if (!decoded.ok) { + logger.warn({ + msg: 'Out-of-range pagination cursor', + route, + cursor, + error: decoded.error, + ...(requestId ? { requestId } : {}), + }); + return; + } + + const { id, createdAt } = decoded.payload; + const date = new Date(createdAt); + if (isNaN(date.getTime())) { + logger.warn({ + msg: 'Out-of-range pagination cursor', + route, + cursor, + error: 'Invalid date in cursor payload', + ...(requestId ? { requestId } : {}), + }); + return; + } + + const where = buildCreatorFeedWhere(query); + + // Check if the referenced creator profile exists and matches the active filters + const exists = await prisma.creatorProfile.findFirst({ + where: { + id, + createdAt: date, + ...where, + }, + }); + + if (!exists) { + logger.warn({ + msg: 'Out-of-range pagination cursor', + route, + cursor, + cursorId: id, + cursorCreatedAt: createdAt, + ...(requestId ? { requestId } : {}), + }); + } + } catch (error) { + // Catch all errors (e.g. database connection issues) to guarantee + // that client requests are never interrupted. + logger.error({ + msg: 'Error checking pagination cursor range', + route, + cursor, + error: error instanceof Error ? error.message : String(error), + ...(requestId ? { requestId } : {}), + }); + } +} diff --git a/src/modules/creators/creators.schemas.ts b/src/modules/creators/creators.schemas.ts index a4897a4..798930b 100644 --- a/src/modules/creators/creators.schemas.ts +++ b/src/modules/creators/creators.schemas.ts @@ -67,6 +67,10 @@ export const CreatorListQuerySchema = z .optional() .transform((val: string | undefined) => normalizeCreatorListSearchTerm(val)) ), + + cursor: withCreatorListQueryStringNormalization( + z.string().optional() + ), }) .strict();