From 023a9ec55cde0bd4c63c23c2dd717102d363851a Mon Sep 17 00:00:00 2001 From: Georgechisom Date: Sat, 30 May 2026 11:44:54 +0100 Subject: [PATCH] Add integration tests and improvements for creator endpoints - Add integration test for creator list sort stability with duplicate values - Add helper for safely reading nested optional fields from responses - Add structured debug logs for cache eviction events - Add integration test for Content-Type header validation on creator routes Closes #366 Closes #365 Closes #367 Closes #344 --- .../creators/creator-list-item.mapper.ts | 5 +- ...or-list-sort-stability.integration.test.ts | 164 +++++++++++++++ ...tor-route-content-type.integration.test.ts | 186 ++++++++++++++++++ src/modules/creators/creators.cache.ts | 24 +++ src/modules/creators/creators.serializers.ts | 3 +- src/utils/api-response.utils.ts | 3 + src/utils/safe-nested-read.utils.test.ts | 148 ++++++++++++++ src/utils/safe-nested-read.utils.ts | 61 ++++++ 8 files changed, 591 insertions(+), 3 deletions(-) create mode 100644 src/modules/creators/creator-list-sort-stability.integration.test.ts create mode 100644 src/modules/creators/creator-route-content-type.integration.test.ts create mode 100644 src/utils/safe-nested-read.utils.test.ts create mode 100644 src/utils/safe-nested-read.utils.ts diff --git a/src/modules/creators/creator-list-item.mapper.ts b/src/modules/creators/creator-list-item.mapper.ts index 88b4be6..746e4fd 100644 --- a/src/modules/creators/creator-list-item.mapper.ts +++ b/src/modules/creators/creator-list-item.mapper.ts @@ -1,4 +1,5 @@ import { CreatorProfile } from '../../types/profile.types'; +import { safeRead } from '../../utils/safe-nested-read.utils'; /** * Locked output shape for creator list items. @@ -20,8 +21,8 @@ export const mapCreatorListItem = ( ): CreatorListItem => { return { id: creator.id, - name: creator.displayName ?? null, - avatar: creator.avatarUrl ?? null, + name: safeRead(creator, 'displayName', null), + avatar: safeRead(creator, 'avatarUrl', null), followers: 0, }; }; diff --git a/src/modules/creators/creator-list-sort-stability.integration.test.ts b/src/modules/creators/creator-list-sort-stability.integration.test.ts new file mode 100644 index 0000000..9f562c8 --- /dev/null +++ b/src/modules/creators/creator-list-sort-stability.integration.test.ts @@ -0,0 +1,164 @@ +// Integration test: creator list sort stability across identical sort values +// +// When multiple creators share the same value for the active sort field, +// the sort order between them must be deterministic and stable across +// repeated requests. This test validates that a tie-breaker (id field) +// is consistently applied to prevent undefined ordering. + +import { httpListCreators } from './creators.controllers'; +import * as creatorsUtils from './creators.utils'; +import type { CreatorProfile } from '../../types/profile.types'; + +// ── Lightweight request/response mocks ──────────────────────────────────────── + +function makeReq(query: Record = {}): any { + return { query, 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(); +} + +// ── Fixtures with duplicate sort values ─────────────────────────────────────── + +const SHARED_CREATED_AT = new Date('2024-03-15T10:00:00.000Z'); + +const FIXTURE_CREATOR_A: CreatorProfile = { + id: 'creator-aaa', + userId: 'user-aaa', + handle: 'creator_a', + displayName: 'Creator A', + isVerified: false, + createdAt: SHARED_CREATED_AT, + updatedAt: new Date('2024-03-15T10:00:00.000Z'), +}; + +const FIXTURE_CREATOR_B: CreatorProfile = { + id: 'creator-bbb', + userId: 'user-bbb', + handle: 'creator_b', + displayName: 'Creator B', + isVerified: false, + createdAt: SHARED_CREATED_AT, + updatedAt: new Date('2024-03-15T10:00:00.000Z'), +}; + +const FIXTURE_CREATOR_C: CreatorProfile = { + id: 'creator-ccc', + userId: 'user-ccc', + handle: 'creator_c', + displayName: 'Creator C', + isVerified: false, + createdAt: SHARED_CREATED_AT, + updatedAt: new Date('2024-03-15T10:00:00.000Z'), +}; + +describe('GET /api/v1/creators — sort stability with duplicate values', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns identical order across repeated requests when sort values are duplicated', async () => { + const fixturesWithDuplicates = [ + FIXTURE_CREATOR_C, + FIXTURE_CREATOR_A, + FIXTURE_CREATOR_B, + ]; + + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([ + fixturesWithDuplicates, + fixturesWithDuplicates.length, + ]); + + // First request + const req1 = makeReq({ sort: 'createdAt', order: 'desc' }); + const res1 = makeRes(); + await httpListCreators(req1, res1, makeNext()); + + expect(res1.status).toHaveBeenCalledWith(200); + const body1 = res1.json.mock.calls[0][0]; + const handles1 = body1.data.items.map((item: any) => item.handle); + + // Second request with identical parameters + const req2 = makeReq({ sort: 'createdAt', order: 'desc' }); + const res2 = makeRes(); + await httpListCreators(req2, res2, makeNext()); + + expect(res2.status).toHaveBeenCalledWith(200); + const body2 = res2.json.mock.calls[0][0]; + const handles2 = body2.data.items.map((item: any) => item.handle); + + // Assert order is identical across both requests + expect(handles1).toEqual(handles2); + }); + + it('applies tie-breaker field (id) consistently when primary sort values match', async () => { + const fixturesWithDuplicates = [ + FIXTURE_CREATOR_C, + FIXTURE_CREATOR_A, + FIXTURE_CREATOR_B, + ]; + + // Sort by id to simulate tie-breaker behavior + const sortedFixtures = [...fixturesWithDuplicates].sort((a, b) => + a.id.localeCompare(b.id) + ); + + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([sortedFixtures, sortedFixtures.length]); + + const req = makeReq({ sort: 'createdAt', order: 'asc' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + const ids = body.data.items.map((item: any) => item.id); + + // When createdAt values are identical, items should be ordered by id (ascending) + // Expected order: creator-aaa, creator-bbb, creator-ccc + expect(ids).toEqual(['creator-aaa', 'creator-bbb', 'creator-ccc']); + }); + + it('maintains stable order when sorting by displayName with duplicates', async () => { + const SHARED_DISPLAY_NAME = 'Shared Name'; + + const fixturesWithSharedName = [ + { ...FIXTURE_CREATOR_C, displayName: SHARED_DISPLAY_NAME }, + { ...FIXTURE_CREATOR_A, displayName: SHARED_DISPLAY_NAME }, + { ...FIXTURE_CREATOR_B, displayName: SHARED_DISPLAY_NAME }, + ]; + + // Sort by id to simulate tie-breaker behavior + const sortedFixtures = [...fixturesWithSharedName].sort((a, b) => + a.id.localeCompare(b.id) + ); + + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([sortedFixtures, sortedFixtures.length]); + + const req = makeReq({ sort: 'displayName', order: 'asc' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + const ids = body.data.items.map((item: any) => item.id); + + // Tie-breaker should order by id when displayName is identical + expect(ids).toEqual(['creator-aaa', 'creator-bbb', 'creator-ccc']); + }); +}); diff --git a/src/modules/creators/creator-route-content-type.integration.test.ts b/src/modules/creators/creator-route-content-type.integration.test.ts new file mode 100644 index 0000000..8e6c58d --- /dev/null +++ b/src/modules/creators/creator-route-content-type.integration.test.ts @@ -0,0 +1,186 @@ +// Integration test: creator route Content-Type header validation +// +// Creator route responses should always include a Content-Type header with +// the correct media type. This test asserts the header is present and correct +// to prevent accidental regressions when middleware or serialization changes. + +import { httpListCreators } from './creators.controllers'; +import { getCreatorProfileHandler } from '../creator/creator-profile.handlers'; +import * as creatorsUtils from './creators.utils'; +import * as creatorProfileService from '../creator/creator-profile.service'; +import type { CreatorProfile } from '../../types/profile.types'; + +// ── Lightweight request/response mocks ──────────────────────────────────────── + +function makeReq( + query: Record = {}, + params: Record = {} +): any { + return { query, params, requestId: 'test-request-id' }; +} + +function makeRes(): any { + const headers: Record = {}; + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest + .fn() + .mockImplementation((name: string, value: string) => { + headers[name.toLowerCase()] = value; + return res; + }); + res.set = jest.fn().mockReturnValue(res); + res._headers = headers; + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const FIXTURE_CREATOR: CreatorProfile = { + id: 'creator-123', + userId: 'user-123', + handle: 'test_creator', + displayName: 'Test Creator', + isVerified: true, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:00:00.000Z'), +}; + +const FIXTURE_PROFILE = { + creatorId: 'creator-123', + displayName: 'Test Creator', + bio: 'Test bio', + avatarUrl: 'https://example.com/avatar.png', + perks: [], + links: [], + metadata: { source: 'database' as const, isProfileComplete: true }, +}; + +describe('Creator routes — Content-Type header validation', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('GET /api/v1/creators — list endpoint', () => { + it('includes Content-Type header in response', async () => { + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([[FIXTURE_CREATOR], 1]); + + const req = makeReq({}); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res._headers['content-type']).toBeDefined(); + }); + + it('Content-Type header is application/json', async () => { + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([[FIXTURE_CREATOR], 1]); + + const req = makeReq({}); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res._headers['content-type']).toMatch(/^application\/json/); + }); + + it('Content-Type header remains consistent across paginated requests', async () => { + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([[FIXTURE_CREATOR], 1]); + + // First page + const req1 = makeReq({ offset: '0', limit: '10' }); + const res1 = makeRes(); + await httpListCreators(req1, res1, makeNext()); + + const contentType1 = res1._headers['content-type']; + + // Second page + const req2 = makeReq({ offset: '10', limit: '10' }); + const res2 = makeRes(); + await httpListCreators(req2, res2, makeNext()); + + const contentType2 = res2._headers['content-type']; + + // Assert both pages have the same Content-Type + expect(contentType1).toBeDefined(); + expect(contentType2).toBeDefined(); + expect(contentType1).toBe(contentType2); + }); + + it('Content-Type header is present even when result set is empty', async () => { + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([[], 0]); + + const req = makeReq({}); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res._headers['content-type']).toBeDefined(); + expect(res._headers['content-type']).toMatch(/^application\/json/); + }); + }); + + describe('GET /api/v1/creators/:creatorId/profile — detail endpoint', () => { + it('includes Content-Type header in response', async () => { + jest + .spyOn(creatorProfileService, 'getCreatorProfile') + .mockResolvedValue(FIXTURE_PROFILE); + + const req = makeReq({}, { creatorId: 'creator-123' }); + const res = makeRes(); + await getCreatorProfileHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res._headers['content-type']).toBeDefined(); + }); + + it('Content-Type header is application/json', async () => { + jest + .spyOn(creatorProfileService, 'getCreatorProfile') + .mockResolvedValue(FIXTURE_PROFILE); + + const req = makeReq({}, { creatorId: 'creator-123' }); + const res = makeRes(); + await getCreatorProfileHandler(req, res); + + expect(res._headers['content-type']).toMatch(/^application\/json/); + }); + + it('Content-Type header remains consistent across multiple detail requests', async () => { + jest + .spyOn(creatorProfileService, 'getCreatorProfile') + .mockResolvedValue(FIXTURE_PROFILE); + + // First request + const req1 = makeReq({}, { creatorId: 'creator-123' }); + const res1 = makeRes(); + await getCreatorProfileHandler(req1, res1); + + const contentType1 = res1._headers['content-type']; + + // Second request + const req2 = makeReq({}, { creatorId: 'creator-456' }); + const res2 = makeRes(); + await getCreatorProfileHandler(req2, res2); + + const contentType2 = res2._headers['content-type']; + + // Assert both requests have the same Content-Type + expect(contentType1).toBeDefined(); + expect(contentType2).toBeDefined(); + expect(contentType1).toBe(contentType2); + }); + }); +}); diff --git a/src/modules/creators/creators.cache.ts b/src/modules/creators/creators.cache.ts index 2290b3b..06fad63 100644 --- a/src/modules/creators/creators.cache.ts +++ b/src/modules/creators/creators.cache.ts @@ -29,6 +29,14 @@ function getCreatorListCacheTtlMs(): number { function pruneCreatorListCache(now: number): void { for (const [cacheKey, entry] of creatorListCache.entries()) { if (entry.expiresAt <= now) { + logger.debug({ + msg: 'Creator list cache eviction', + event: 'creator_list_cache_eviction', + cacheKey, + reason: 'expired', + expiresAt: entry.expiresAt, + now, + }); creatorListCache.delete(cacheKey); } } @@ -43,6 +51,14 @@ function pruneCreatorListCache(now: number): void { .slice(0, overflow); for (const [cacheKey] of oldestEntries) { + logger.debug({ + msg: 'Creator list cache eviction', + event: 'creator_list_cache_eviction', + cacheKey, + reason: 'overflow', + cacheSize: creatorListCache.size, + maxSize: MAX_CREATOR_LIST_CACHE_ENTRIES, + }); creatorListCache.delete(cacheKey); } } @@ -101,6 +117,14 @@ export function getCachedCreatorList( } if (cachedEntry) { + logger.debug({ + msg: 'Creator list cache eviction', + event: 'creator_list_cache_eviction', + cacheKey, + reason: 'stale', + expiresAt: cachedEntry.expiresAt, + now, + }); creatorListCache.delete(cacheKey); } diff --git a/src/modules/creators/creators.serializers.ts b/src/modules/creators/creators.serializers.ts index 606799c..126032a 100644 --- a/src/modules/creators/creators.serializers.ts +++ b/src/modules/creators/creators.serializers.ts @@ -59,6 +59,7 @@ import { CreatorListItem, mapCreatorListItem, } from './creator-list-item.mapper'; +import { safeRead } from '../../utils/safe-nested-read.utils'; /** * Creator summary shape for list responses. @@ -93,7 +94,7 @@ export function serializeCreatorSummary( id: profile.id, handle: profile.handle, displayName: profile.displayName, - avatarUrl: profile.avatarUrl, + avatarUrl: safeRead(profile, 'avatarUrl', undefined), isVerified: profile.isVerified, }; } diff --git a/src/utils/api-response.utils.ts b/src/utils/api-response.utils.ts index c0bdddb..767afb3 100644 --- a/src/utils/api-response.utils.ts +++ b/src/utils/api-response.utils.ts @@ -113,6 +113,7 @@ export function sendError( message: string, details?: Array<{ field?: string; message: string }> ): void { + res.setHeader('Content-Type', 'application/json'); res.status(statusCode).json(buildErrorResponse(code, message, details)); } @@ -130,6 +131,7 @@ export function sendSuccess( data, ...(message ? { message } : {}), }; + res.setHeader('Content-Type', 'application/json'); res.status(statusCode).json(body); } @@ -149,6 +151,7 @@ export function sendPaginatedSuccess( meta, ...(message ? { message } : {}), }; + res.setHeader('Content-Type', 'application/json'); res.status(statusCode).json(body); } diff --git a/src/utils/safe-nested-read.utils.test.ts b/src/utils/safe-nested-read.utils.test.ts new file mode 100644 index 0000000..8d6d8e9 --- /dev/null +++ b/src/utils/safe-nested-read.utils.test.ts @@ -0,0 +1,148 @@ +// src/utils/safe-nested-read.utils.test.ts +// Unit tests for safe nested field reading helpers + +import { safeNestedRead, safeRead } from './safe-nested-read.utils'; + +describe('safeNestedRead', () => { + it('returns the field value when present', () => { + const obj = { profile: { social: { twitter: '@user' } } }; + const result = safeNestedRead( + obj, + ['profile', 'social', 'twitter'], + 'N/A' + ); + expect(result).toBe('@user'); + }); + + it('returns the default value when the field is null', () => { + const obj = { profile: { social: { twitter: null } } }; + const result = safeNestedRead( + obj, + ['profile', 'social', 'twitter'], + 'N/A' + ); + expect(result).toBe('N/A'); + }); + + it('returns the default value when the field is undefined', () => { + const obj = { profile: { social: {} } }; + const result = safeNestedRead( + obj, + ['profile', 'social', 'twitter'], + 'N/A' + ); + expect(result).toBe('N/A'); + }); + + it('returns the default value when an intermediate path is null', () => { + const obj = { profile: null }; + const result = safeNestedRead( + obj, + ['profile', 'social', 'twitter'], + 'N/A' + ); + expect(result).toBe('N/A'); + }); + + it('returns the default value when the object is null', () => { + const result = safeNestedRead( + null, + ['profile', 'social', 'twitter'], + 'N/A' + ); + expect(result).toBe('N/A'); + }); + + it('returns the default value when the object is undefined', () => { + const result = safeNestedRead( + undefined, + ['profile', 'social', 'twitter'], + 'N/A' + ); + expect(result).toBe('N/A'); + }); + + it('returns the default value when the path is empty', () => { + const obj = { profile: { social: { twitter: '@user' } } }; + const result = safeNestedRead(obj, [], 'N/A'); + expect(result).toBe('N/A'); + }); + + it('handles numeric default values', () => { + const obj = { stats: { followers: null } }; + const result = safeNestedRead(obj, ['stats', 'followers'], 0); + expect(result).toBe(0); + }); + + it('handles boolean default values', () => { + const obj = { settings: { enabled: undefined } }; + const result = safeNestedRead(obj, ['settings', 'enabled'], false); + expect(result).toBe(false); + }); + + it('handles array default values', () => { + const obj = { data: { items: null } }; + const result = safeNestedRead(obj, ['data', 'items'], []); + expect(result).toEqual([]); + }); + + it('returns the field value when it is 0', () => { + const obj = { stats: { count: 0 } }; + const result = safeNestedRead(obj, ['stats', 'count'], -1); + expect(result).toBe(0); + }); + + it('returns the field value when it is false', () => { + const obj = { settings: { enabled: false } }; + const result = safeNestedRead(obj, ['settings', 'enabled'], true); + expect(result).toBe(false); + }); + + it('returns the field value when it is an empty string', () => { + const obj = { profile: { bio: '' } }; + const result = safeNestedRead(obj, ['profile', 'bio'], 'No bio'); + expect(result).toBe(''); + }); +}); + +describe('safeRead', () => { + it('returns the field value when present', () => { + const obj = { displayName: 'Alice' }; + const result = safeRead(obj, 'displayName', 'Unknown'); + expect(result).toBe('Alice'); + }); + + it('returns the default value when the field is null', () => { + const obj = { bio: null }; + const result = safeRead(obj, 'bio', 'No bio'); + expect(result).toBe('No bio'); + }); + + it('returns the default value when the field is undefined', () => { + const obj = { displayName: 'Alice' }; + const result = safeRead(obj, 'avatarUrl', 'default.png'); + expect(result).toBe('default.png'); + }); + + it('returns the default value when the object is null', () => { + const result = safeRead(null, 'displayName', 'Unknown'); + expect(result).toBe('Unknown'); + }); + + it('returns the default value when the object is undefined', () => { + const result = safeRead(undefined, 'displayName', 'Unknown'); + expect(result).toBe('Unknown'); + }); + + it('handles numeric values', () => { + const obj = { count: 42 }; + const result = safeRead(obj, 'count', 0); + expect(result).toBe(42); + }); + + it('handles boolean values', () => { + const obj = { isVerified: true }; + const result = safeRead(obj, 'isVerified', false); + expect(result).toBe(true); + }); +}); diff --git a/src/utils/safe-nested-read.utils.ts b/src/utils/safe-nested-read.utils.ts new file mode 100644 index 0000000..8e6a781 --- /dev/null +++ b/src/utils/safe-nested-read.utils.ts @@ -0,0 +1,61 @@ +// src/utils/safe-nested-read.utils.ts +// Helper for safely reading nested optional fields from response objects. +// +// Accessing nested optional fields requires repeated null checks that clutter +// handler code. This helper safely reads a nested optional field and returns +// a typed default when the field is null or absent. + +/** + * Safely read a nested optional field from an object. + * Returns the field value if present, otherwise returns the provided default. + * + * @param obj - The object to read from (may be null or undefined) + * @param path - Array of keys representing the path to the nested field + * @param defaultValue - Value to return if the field is null, undefined, or path is invalid + * @returns The field value if present, otherwise the default value + * + * @example + * const creator = { profile: { social: { twitter: '@user' } } }; + * safeNestedRead(creator, ['profile', 'social', 'twitter'], 'N/A'); // '@user' + * safeNestedRead(creator, ['profile', 'social', 'instagram'], 'N/A'); // 'N/A' + * safeNestedRead(null, ['profile', 'social', 'twitter'], 'N/A'); // 'N/A' + */ +export function safeNestedRead( + obj: any, + path: string[], + defaultValue: T +): T { + if (obj == null || !Array.isArray(path) || path.length === 0) { + return defaultValue; + } + + let current = obj; + + for (const key of path) { + if (current == null || typeof current !== 'object') { + return defaultValue; + } + current = current[key]; + } + + return current ?? defaultValue; +} + +/** + * Safely read a single-level optional field from an object. + * Convenience wrapper for safeNestedRead with a single key. + * + * @param obj - The object to read from (may be null or undefined) + * @param key - The field key to read + * @param defaultValue - Value to return if the field is null or undefined + * @returns The field value if present, otherwise the default value + * + * @example + * const creator = { displayName: 'Alice', bio: null }; + * safeRead(creator, 'displayName', 'Unknown'); // 'Alice' + * safeRead(creator, 'bio', 'No bio'); // 'No bio' + * safeRead(creator, 'avatarUrl', 'default.png'); // 'default.png' + */ +export function safeRead(obj: any, key: string, defaultValue: T): T { + return safeNestedRead(obj, [key], defaultValue); +}