From f9d9ceda2f2ecbfd12fb0fd857ca4af82d3f9f98 Mon Sep 17 00:00:00 2001 From: boys-cyber Date: Wed, 27 May 2026 13:34:05 +0100 Subject: [PATCH] feat: null-safe list envelope and multi-filter integration tests Closes #281 Closes #282 --- jest.config.js | 1 + jest.setup.ts | 16 + ...tor-feed-empty-filters.integration.test.ts | 62 ++- ...ator-feed-multi-filter.integration.test.ts | 379 ++++++++++++++++++ src/modules/creators/creators.serializers.ts | 23 +- .../public-creator-list-envelope.utils.ts | 5 +- 6 files changed, 474 insertions(+), 12 deletions(-) create mode 100644 jest.setup.ts create mode 100644 src/modules/creators/creator-feed-multi-filter.integration.test.ts diff --git a/jest.config.js b/jest.config.js index 86f88fb..0941da5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,4 +8,5 @@ module.exports = { transform: { ...tsJestTransformCfg, }, + setupFiles: ["./jest.setup.ts"], }; \ No newline at end of file diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..260fc70 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,16 @@ +// Test environment stub — sets all required env vars before any module loads. +// Values are non-functional placeholders sufficient for schema validation. + +process.env.MODE = 'test'; +process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'; +process.env.GMAIL_USER = 'test@example.com'; +process.env.GMAIL_APP_PASSWORD = 'test-password'; +process.env.GOOGLE_CLIENT_ID = 'test-google-client-id'; +process.env.GOOGLE_CLIENT_SECRET = 'test-google-client-secret'; +process.env.BACKEND_URL = 'http://localhost:3000'; +process.env.FRONTEND_URL = 'http://localhost:5173'; +process.env.CLOUDINARY_CLOUD_NAME = 'test-cloud'; +process.env.CLOUDINARY_API_KEY = 'test-api-key'; +process.env.CLOUDINARY_API_SECRET = 'test-api-secret'; +process.env.PAYSTACK_SECRET_KEY = 'test-paystack-secret'; +process.env.APP_SECRET = 'accesslayer_test_secret_key_32_bytes_long_xxxx'; 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 7faff39..40b4298 100644 --- a/src/modules/creators/creator-feed-empty-filters.integration.test.ts +++ b/src/modules/creators/creator-feed-empty-filters.integration.test.ts @@ -18,6 +18,7 @@ function makeRes(): 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; } @@ -127,12 +128,12 @@ describe('GET /api/v1/creators — empty feed with filter combinations', () => { const res = makeRes(); await httpListCreators(req, res, makeNext()); - expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( - expect.objectContaining({ - verified: undefined, - search: undefined, - }) - ); + // Optional filter keys are absent from the Zod output when not supplied; + // asserting absence is more accurate than asserting `undefined` equality. + const callArgs = (creatorsUtils.fetchCreatorList as jest.Mock).mock.calls[0][0]; + expect(callArgs).not.toHaveProperty('verified', true); + expect(callArgs).not.toHaveProperty('verified', false); + expect(callArgs).not.toHaveProperty('search'); const body = res.json.mock.calls[0][0]; expect(body.data.items).toHaveLength(0); @@ -392,6 +393,55 @@ describe('GET /api/v1/creators — empty feed with filter combinations', () => { } }); + // ── Null Items Normalization (issue #281) ────────────────────────────────── + + it('coerces null items from fetchCreatorList to an empty array', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([null as unknown as any[], 0]); + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(Array.isArray(body.data.items)).toBe(true); + expect(body.data.items).toHaveLength(0); + }); + + it('coerces undefined items from fetchCreatorList to an empty array', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([undefined as unknown as any[], 0]); + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(Array.isArray(body.data.items)).toBe(true); + expect(body.data.items).toHaveLength(0); + }); + + it('items is always an array regardless of filter combination when data layer returns null', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([null as unknown as any[], 0]); + const filterCombinations: Array> = [ + { verified: 'true' }, + { search: 'artist' }, + { verified: 'false', search: 'test' }, + { limit: '5', offset: '10', verified: 'true' }, + ]; + + for (const query of filterCombinations) { + const req = makeReq(query); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(Array.isArray(body.data.items)).toBe(true); + expect(body.data.items).toHaveLength(0); + + jest.clearAllMocks(); + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([null as unknown as any[], 0]); + } + }); + // ── Validation Error Handling ─────────────────────────────────────────────── it('returns 400 for invalid limit parameter', async () => { diff --git a/src/modules/creators/creator-feed-multi-filter.integration.test.ts b/src/modules/creators/creator-feed-multi-filter.integration.test.ts new file mode 100644 index 0000000..53d32cd --- /dev/null +++ b/src/modules/creators/creator-feed-multi-filter.integration.test.ts @@ -0,0 +1,379 @@ +// Integration test: creator feed with multiple category filters simultaneously +// +// Verifies that the controller correctly passes multiple filter parameters to the +// service layer and that the response envelope reflects non-empty fixture data. +// Uses Jest mocks with a real in-memory fixture set — no database required. +// +// Scope: exercises the filter combinator path end-to-end through the HTTP layer: +// query → schema validation → fetchCreatorList (mocked) → serialization → envelope + +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 }; +} + +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(); +} + +// ── Fixture creators spanning multiple filter categories ────────────────────── +// +// Each creator belongs to one or more "categories": +// • Verified status (isVerified true/false) +// • Handle/displayName searchability (contains "jazz" or "rock") +// +// Keeping fixtures deterministic and minimal so assertions remain readable. + +const FIXTURE_VERIFIED_JAZZ: CreatorProfile = { + id: 'cuid-1', + userId: 'user-1', + handle: 'alice_jazz', + displayName: 'Alice Jazz', + isVerified: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), +}; + +const FIXTURE_VERIFIED_ROCK: CreatorProfile = { + id: 'cuid-2', + userId: 'user-2', + handle: 'bob_rock', + displayName: 'Bob Rock', + isVerified: true, + createdAt: new Date('2024-01-02'), + updatedAt: new Date('2024-01-02'), +}; + +const FIXTURE_UNVERIFIED_JAZZ: CreatorProfile = { + id: 'cuid-3', + userId: 'user-3', + handle: 'carol_jazz', + displayName: 'Carol Jazz', + isVerified: false, + createdAt: new Date('2024-01-03'), + updatedAt: new Date('2024-01-03'), +}; + +const FIXTURE_UNVERIFIED_ROCK: CreatorProfile = { + id: 'cuid-4', + userId: 'user-4', + handle: 'dave_rock', + displayName: 'Dave Rock', + isVerified: false, + createdAt: new Date('2024-01-04'), + updatedAt: new Date('2024-01-04'), +}; + +// Full fixture set +const ALL_FIXTURES = [ + FIXTURE_VERIFIED_JAZZ, + FIXTURE_VERIFIED_ROCK, + FIXTURE_UNVERIFIED_JAZZ, + FIXTURE_UNVERIFIED_ROCK, +]; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/creators — multiple category filters with fixture data', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── Multi-filter: verified + search ──────────────────────────────────────── + + it('passes verified=true and search together to fetchCreatorList', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ], + 1, + ]); + + const req = makeReq({ verified: 'true', search: 'jazz' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + verified: true, + search: 'jazz', + }) + ); + }); + + it('returns only the creators the service resolved for verified=true + search=jazz', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ], + 1, + ]); + + const req = makeReq({ verified: 'true', search: 'jazz' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.items).toHaveLength(1); + expect(body.data.items[0].id).toBe(FIXTURE_VERIFIED_JAZZ.id); + expect(body.data.items[0].name).toBe(FIXTURE_VERIFIED_JAZZ.displayName); + }); + + it('returns correct pagination metadata for verified=true + search=jazz (1 result)', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ], + 1, + ]); + + const req = makeReq({ verified: 'true', search: 'jazz' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.total).toBe(1); + expect(body.data.meta.hasMore).toBe(false); + expect(body.data.meta.offset).toBe(0); + }); + + it('passes verified=false and search together to fetchCreatorList', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + [FIXTURE_UNVERIFIED_ROCK], + 1, + ]); + + const req = makeReq({ verified: 'false', search: 'rock' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + verified: false, + search: 'rock', + }) + ); + }); + + it('returns only unverified-rock creator for verified=false + search=rock', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + [FIXTURE_UNVERIFIED_ROCK], + 1, + ]); + + const req = makeReq({ verified: 'false', search: 'rock' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(1); + expect(body.data.items[0].id).toBe(FIXTURE_UNVERIFIED_ROCK.id); + }); + + // ── Multi-filter: verified + search + pagination ─────────────────────────── + + it('passes verified, search, limit, and offset together to fetchCreatorList', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ, FIXTURE_VERIFIED_ROCK], + 2, + ]); + + const req = makeReq({ verified: 'true', search: 'test', limit: '5', offset: '0' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + verified: true, + search: 'test', + limit: 5, + offset: 0, + }) + ); + }); + + it('pagination metadata reflects total from service when multiple filters applied', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ], + 10, // total = 10 (paginated result) + ]); + + const req = makeReq({ verified: 'true', limit: '1', offset: '0' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(1); + expect(body.data.meta.total).toBe(10); + expect(body.data.meta.limit).toBe(1); + expect(body.data.meta.offset).toBe(0); + expect(body.data.meta.hasMore).toBe(true); + }); + + it('hasMore is true when total exceeds limit+offset with multiple filters active', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ, FIXTURE_VERIFIED_ROCK], + 20, + ]); + + const req = makeReq({ verified: 'true', search: 'test', limit: '2', offset: '0' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.hasMore).toBe(true); + expect(body.data.meta.total).toBe(20); + }); + + it('hasMore is false when all results fit within limit+offset', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ, FIXTURE_VERIFIED_ROCK], + 2, + ]); + + const req = makeReq({ verified: 'true', search: 'test', limit: '10', offset: '0' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.hasMore).toBe(false); + expect(body.data.meta.total).toBe(2); + }); + + // ── Multi-filter: verified + sort + order ────────────────────────────────── + + it('passes verified, sort, and order together to fetchCreatorList', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ, FIXTURE_VERIFIED_ROCK], + 2, + ]); + + const req = makeReq({ verified: 'true', sort: 'displayName', order: 'asc' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + verified: true, + sort: 'displayName', + order: 'asc', + }) + ); + }); + + it('serializes multiple fixture items correctly for verified + sort combo', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ, FIXTURE_VERIFIED_ROCK], + 2, + ]); + + const req = makeReq({ verified: 'true', sort: 'displayName', order: 'asc' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(2); + const ids = body.data.items.map((item: any) => item.id); + expect(ids).toContain(FIXTURE_VERIFIED_JAZZ.id); + expect(ids).toContain(FIXTURE_VERIFIED_ROCK.id); + }); + + // ── Full fixture set: all four creators ─────────────────────────────────── + + it('returns all fixture creators with no filter applied', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + ALL_FIXTURES, + ALL_FIXTURES.length, + ]); + + const req = makeReq({}); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(4); + expect(body.data.meta.total).toBe(4); + expect(body.data.meta.hasMore).toBe(false); + }); + + it('items field is always an array for every multi-filter combo', async () => { + const filterCombinations: Record[] = [ + { verified: 'true', search: 'jazz' }, + { verified: 'false', search: 'rock' }, + { verified: 'true', sort: 'displayName', order: 'asc' }, + { verified: 'false', sort: 'createdAt', order: 'desc' }, + { search: 'jazz', limit: '2', offset: '0' }, + { verified: 'true', search: 'rock', limit: '5', offset: '0' }, + ]; + + for (const query of filterCombinations) { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ], + 1, + ]); + + const req = makeReq(query); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(Array.isArray(body.data.items)).toBe(true); + expect(body.data).toHaveProperty('meta'); + + jest.restoreAllMocks(); + } + }); + + // ── Serialized item shape ────────────────────────────────────────────────── + + it('each item in the response has the expected public shape', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ], + 1, + ]); + + const req = makeReq({ verified: 'true', search: 'jazz' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + const item = body.data.items[0]; + expect(item).toHaveProperty('id', FIXTURE_VERIFIED_JAZZ.id); + expect(item).toHaveProperty('name', FIXTURE_VERIFIED_JAZZ.displayName); + expect(item).toHaveProperty('avatar'); + expect(item).toHaveProperty('followers'); + // Internal fields must not leak through + expect(item).not.toHaveProperty('isVerified'); + expect(item).not.toHaveProperty('userId'); + expect(item).not.toHaveProperty('handle'); + }); + + it('pagination metadata has all required fields for any multi-filter combo', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + [FIXTURE_VERIFIED_JAZZ, FIXTURE_UNVERIFIED_JAZZ], + 2, + ]); + + const req = makeReq({ search: 'jazz', sort: 'createdAt', order: 'asc' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta).toMatchObject({ + total: 2, + hasMore: false, + limit: expect.any(Number), + offset: expect.any(Number), + }); + }); +}); diff --git a/src/modules/creators/creators.serializers.ts b/src/modules/creators/creators.serializers.ts index eb413ea..4bd3d19 100644 --- a/src/modules/creators/creators.serializers.ts +++ b/src/modules/creators/creators.serializers.ts @@ -48,16 +48,31 @@ export function serializeCreatorSummary( }; } +/** + * Normalizes a potentially-null or undefined profile list to a safe empty array. + * Guards serialization against edge cases where the data layer returns null. + * + * @param profiles - Raw profiles array that may be null or undefined + * @returns A guaranteed non-null array + */ +export function normalizeCreatorListItems( + profiles: CreatorProfile[] | null | undefined +): CreatorProfile[] { + return profiles ?? []; +} + /** * Serializes multiple creator profiles for list responses. + * Accepts null/undefined and coerces it to an empty array before mapping, + * so the items field in the response envelope is always an array. * - * @param profiles - Array of full creator profiles - * @returns Array of creator summaries + * @param profiles - Array of full creator profiles (null/undefined treated as empty) + * @returns Array of creator list items */ export function serializeCreatorList( - profiles: CreatorProfile[] + profiles: CreatorProfile[] | null | undefined ): CreatorListItem[] { - return profiles.map(mapCreatorListItem); + return normalizeCreatorListItems(profiles).map(mapCreatorListItem); } /** diff --git a/src/modules/creators/public-creator-list-envelope.utils.ts b/src/modules/creators/public-creator-list-envelope.utils.ts index 5eba96e..a80bfa8 100644 --- a/src/modules/creators/public-creator-list-envelope.utils.ts +++ b/src/modules/creators/public-creator-list-envelope.utils.ts @@ -11,10 +11,11 @@ export type PublicCreatorListEnvelope = { /** * Wraps list results and metadata in a single predictable object for public list routes. + * Coerces null/undefined items to an empty array so the envelope is always well-formed. */ export function wrapPublicCreatorListResponse( - items: TItem[], + items: TItem[] | null | undefined, meta: TMeta ): PublicCreatorListEnvelope { - return { items, meta }; + return { items: items ?? [], meta }; }