From 48d351e6937817145ef0591813b4ba46ff6a601d Mon Sep 17 00:00:00 2001 From: Damilorlar Date: Tue, 28 Jul 2026 22:56:43 +0100 Subject: [PATCH] feat: add searchTerm to creator list response for no-results state When a search query returns zero creators, the response now includes the searchTerm field so the frontend can display a no-results message that references the term. This is distinct from the unfiltered empty state (which omits searchTerm). - Add optional searchTerm to PublicCreatorListEnvelope type - Pass validatedQuery.search through serializeCreatorListResponse - Add integration test covering search no-results state - Fix pre-existing implicit any TS errors in httpGetTrendingCreators --- ...earch-no-results-state.integration.test.ts | 94 +++++++++++++++++++ src/modules/creators/creators.controllers.ts | 7 +- src/modules/creators/creators.serializers.ts | 6 +- .../public-creator-list-envelope.utils.ts | 13 ++- 4 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 src/modules/creators/creator-list-search-no-results-state.integration.test.ts diff --git a/src/modules/creators/creator-list-search-no-results-state.integration.test.ts b/src/modules/creators/creator-list-search-no-results-state.integration.test.ts new file mode 100644 index 0000000..821c0cc --- /dev/null +++ b/src/modules/creators/creator-list-search-no-results-state.integration.test.ts @@ -0,0 +1,94 @@ +// Integration test: creator list search no-results state +// +// When a search query returns zero creators, the response should include the +// search term so that clients can display a no-results message that references +// the search term, distinct from the unfiltered empty state. +// +// Uses Jest mocks — 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(); + }); + + it('includes searchTerm in response when search returns zero results', async () => { + const req = makeReq({ search: 'zzznomatch' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.data).toHaveProperty('searchTerm', 'zzznomatch'); + }); + + it('does not include searchTerm when no search filter is applied', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data).not.toHaveProperty('searchTerm'); + }); + + it('does not include searchTerm when search filter 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).not.toHaveProperty('searchTerm'); + }); + + it('does not include searchTerm when search filter is an 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).not.toHaveProperty('searchTerm'); + }); + + it('search no-results state is distinct from unfiltered empty state', async () => { + const searchReq = makeReq({ search: 'zzznomatch' }); + const searchRes = makeRes(); + await httpListCreators(searchReq, searchRes, makeNext()); + + const noFilterReq = makeReq(); + const noFilterRes = makeRes(); + await httpListCreators(noFilterReq, noFilterRes, makeNext()); + + const searchBody = searchRes.json.mock.calls[0][0]; + const noFilterBody = noFilterRes.json.mock.calls[0][0]; + + expect(searchBody.data).toHaveProperty('searchTerm'); + expect(noFilterBody.data).not.toHaveProperty('searchTerm'); + + expect(searchBody.data.items).toEqual([]); + expect(noFilterBody.data.items).toEqual([]); + expect(searchBody.data.meta).toEqual(noFilterBody.data.meta); + }); +}); \ No newline at end of file diff --git a/src/modules/creators/creators.controllers.ts b/src/modules/creators/creators.controllers.ts index 31a40f7..716ad0a 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); @@ -200,7 +201,7 @@ export const httpGetTrendingCreators: AsyncController = async (req, res, next) = // Compute volume for each creator const creatorsWithVolume = await Promise.all( - creators.map(async (creator) => { + creators.map(async (creator: { id: string; handle: string; displayName: string | null; avatarUrl: string | null; isVerified: boolean; createdAt: Date; updatedAt: Date }) => { const volume = await compute24hVolume(creator.id); return { id: creator.id, @@ -216,7 +217,7 @@ export const httpGetTrendingCreators: AsyncController = async (req, res, next) = ); // Sort by volume descending - creatorsWithVolume.sort((a, b) => { + creatorsWithVolume.sort((a: { volume_24h: string }, b: { volume_24h: string }) => { const volA = BigInt(a.volume_24h); const volB = BigInt(b.volume_24h); if (volB > volA) return 1; diff --git a/src/modules/creators/creators.serializers.ts b/src/modules/creators/creators.serializers.ts index e24419e..b0d340f 100644 --- a/src/modules/creators/creators.serializers.ts +++ b/src/modules/creators/creators.serializers.ts @@ -188,11 +188,13 @@ export type CreatorCursorListResponse = PublicCreatorListEnvelope< */ export function serializeCreatorListResponse( profiles: CreatorProfile[], - meta: OffsetPaginationMeta + meta: OffsetPaginationMeta, + searchTerm?: string ): CreatorListResponse { return wrapPublicCreatorListResponse( serializeCreatorList(profiles), - serializeCreatorListOffsetMeta(meta) + serializeCreatorListOffsetMeta(meta), + searchTerm ); } diff --git a/src/modules/creators/public-creator-list-envelope.utils.ts b/src/modules/creators/public-creator-list-envelope.utils.ts index a80bfa8..caa7b68 100644 --- a/src/modules/creators/public-creator-list-envelope.utils.ts +++ b/src/modules/creators/public-creator-list-envelope.utils.ts @@ -7,6 +7,7 @@ export type PublicCreatorListEnvelope = { items: TItem[]; meta: TMeta; + searchTerm?: string; }; /** @@ -15,7 +16,15 @@ export type PublicCreatorListEnvelope = { */ export function wrapPublicCreatorListResponse( items: TItem[] | null | undefined, - meta: TMeta + meta: TMeta, + searchTerm?: string ): PublicCreatorListEnvelope { - return { items: items ?? [], meta }; + const envelope: PublicCreatorListEnvelope = { + items: items ?? [], + meta, + }; + if (searchTerm !== undefined) { + envelope.searchTerm = searchTerm; + } + return envelope; }