diff --git a/src/modules/creators/creator-list-no-results-search.integration.test.ts b/src/modules/creators/creator-list-no-results-search.integration.test.ts new file mode 100644 index 0000000..580e85a --- /dev/null +++ b/src/modules/creators/creator-list-no-results-search.integration.test.ts @@ -0,0 +1,142 @@ +// Integration test: search no-results state +// +// When a search query returns zero creators, the response meta includes the +// search term so the frontend can show a contextual no-results message instead +// of the generic empty state used for an unfiltered empty list. +// +// Acceptance criteria: +// - No-results message visible when search returns zero results +// - Message references or acknowledges the search term +// - No-results state distinct from the unfiltered empty state +// - Clearing search input removes the no-results state +// +// Uses Jest mocks (isolated empty fixture) — no database connection required. + +import { httpListCreators } from './creators.controllers'; +import * as creatorsUtils from './creators.utils'; + +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(); +} + +describe('GET /api/v1/creators — search no-results state', () => { + beforeEach(() => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([[], 0]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── No-results state with search ────────────────────────────────────────── + + it('includes search term in response meta when search returns zero results', async () => { + const req = makeReq({ search: 'zzznomatch' }); + 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).toEqual([]); + expect(body.data.meta).toHaveProperty('search', 'zzznomatch'); + }); + + it('returns HTTP 200 for search with no results', async () => { + const req = makeReq({ search: 'zzznomatch' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + }); + + // ── Distinct from unfiltered empty state ────────────────────────────────── + + it('does not include search in meta for unfiltered empty list', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toEqual([]); + expect(body.data.meta).not.toHaveProperty('search'); + }); + + it('no-results response with search differs from unfiltered empty response', async () => { + const noSearchReq = makeReq({}); + const noSearchRes = makeRes(); + await httpListCreators(noSearchReq, noSearchRes, makeNext()); + const noSearchBody = noSearchRes.json.mock.calls[0][0]; + + const searchReq = makeReq({ search: 'zzznomatch' }); + const searchRes = makeRes(); + await httpListCreators(searchReq, searchRes, makeNext()); + const searchBody = searchRes.json.mock.calls[0][0]; + + // Both have empty items + expect(noSearchBody.data.items).toEqual([]); + expect(searchBody.data.items).toEqual([]); + + // But search response includes the search term in meta + expect(noSearchBody.data.meta).not.toHaveProperty('search'); + expect(searchBody.data.meta).toHaveProperty('search', 'zzznomatch'); + }); + + // ── Clearing search removes no-results state ────────────────────────────── + + it('omits search from meta when search param is empty string', async () => { + const req = makeReq({ search: '' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toEqual([]); + expect(body.data.meta).not.toHaveProperty('search'); + }); + + it('omits search from meta when search param is whitespace only', async () => { + const req = makeReq({ search: ' ' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toEqual([]); + expect(body.data.meta).not.toHaveProperty('search'); + }); + + // ── Additional assertions ───────────────────────────────────────────────── + + it('preserves all standard pagination meta fields alongside search', async () => { + const req = makeReq({ search: 'zzznomatch' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const meta = res.json.mock.calls[0][0].data.meta; + expect(meta).toHaveProperty('limit'); + expect(meta).toHaveProperty('offset'); + expect(meta).toHaveProperty('total', 0); + expect(meta).toHaveProperty('hasMore', false); + expect(meta).toHaveProperty('search', 'zzznomatch'); + }); + + it('does not call next() error handler on search no-results', async () => { + const req = makeReq({ search: 'zzznomatch' }); + const res = makeRes(); + const next = makeNext(); + await httpListCreators(req, res, next); + + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/creators/creators.controllers.ts b/src/modules/creators/creators.controllers.ts index 31a40f7..d769281 100644 --- a/src/modules/creators/creators.controllers.ts +++ b/src/modules/creators/creators.controllers.ts @@ -76,7 +76,8 @@ export const httpListCreators: AsyncController = async (req, res, next) => { limit: validatedQuery.limit, offset: validatedQuery.offset, total, - }) + }), + validatedQuery.search ); attachTimestampHeader(res); diff --git a/src/modules/creators/creators.serializers.ts b/src/modules/creators/creators.serializers.ts index e24419e..3345ab1 100644 --- a/src/modules/creators/creators.serializers.ts +++ b/src/modules/creators/creators.serializers.ts @@ -149,27 +149,45 @@ export function serializeCreatorListCursorMeta( * Serializes offset pagination metadata for creator list responses. * * Ensures consistency of metadata shape across offset-paginated endpoints. + * Includes the `search` term when one was provided, so clients can show a + * contextual no-results message instead of a generic empty state. * * @param meta - Raw offset pagination metadata + * @param search - Optional search term from the request query * @returns Offset pagination metadata normalized for public list responses */ export function serializeCreatorListOffsetMeta( - meta: OffsetPaginationMeta -): OffsetPaginationMeta { - return { + meta: OffsetPaginationMeta, + search?: string +): CreatorListMeta { + const result: CreatorListMeta = { limit: meta.limit, offset: meta.offset, total: meta.total, hasMore: meta.hasMore, }; + if (search !== undefined && search !== '') { + result.search = search; + } + return result; } +/** + * Meta for the creator list response, extending offset pagination with an + * optional `search` field so clients can distinguish between an unfiltered + * empty database and a search that returned no results. + */ +export type CreatorListMeta = OffsetPaginationMeta & { + /** The search term that was queried, if any. */ + search?: string; +}; + /** * Paginated creator list response body (offset pagination metadata). */ export type CreatorListResponse = PublicCreatorListEnvelope< CreatorListItem, - OffsetPaginationMeta + CreatorListMeta >; /** @@ -185,14 +203,21 @@ export type CreatorCursorListResponse = PublicCreatorListEnvelope< * * This centralizes the wrapping of creators and metadata to ensure * a consistent public response shape (envelope). + * When a `search` term is provided, it is included in the response meta + * so clients can display a contextual no-results message. + * + * @param profiles - Array of creator profiles + * @param meta - Raw offset pagination metadata + * @param search - Optional search term from the request */ export function serializeCreatorListResponse( profiles: CreatorProfile[], - meta: OffsetPaginationMeta + meta: OffsetPaginationMeta, + search?: string ): CreatorListResponse { return wrapPublicCreatorListResponse( serializeCreatorList(profiles), - serializeCreatorListOffsetMeta(meta) + serializeCreatorListOffsetMeta(meta, search) ); } diff --git a/src/modules/creators/creators.utils.ts b/src/modules/creators/creators.utils.ts index 29b5d0e..fd88c4f 100644 --- a/src/modules/creators/creators.utils.ts +++ b/src/modules/creators/creators.utils.ts @@ -130,7 +130,8 @@ export function createEmptyCreatorListResponse( limit: query.limit, offset: query.offset, total: 0, - }) + }), + query.search ); }