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
11 changes: 11 additions & 0 deletions src/modules/creators/creators.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down
152 changes: 152 additions & 0 deletions src/modules/creators/creators.cursor-warning.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {}, 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);
});
});
81 changes: 81 additions & 0 deletions src/modules/creators/creators.cursor-warning.utils.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 } : {}),
});
}
}
4 changes: 4 additions & 0 deletions src/modules/creators/creators.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ export const CreatorListQuerySchema = z
.optional()
.transform((val: string | undefined) => normalizeCreatorListSearchTerm(val))
),

cursor: withCreatorListQueryStringNormalization(
z.string().optional()
),
})
.strict();

Expand Down
Loading