Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}): 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();
});
});
3 changes: 2 additions & 1 deletion src/modules/creators/creators.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ export const httpListCreators: AsyncController = async (req, res, next) => {
limit: validatedQuery.limit,
offset: validatedQuery.offset,
total,
})
}),
validatedQuery.search
);

attachTimestampHeader(res);
Expand Down
37 changes: 31 additions & 6 deletions src/modules/creators/creators.serializers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
>;

/**
Expand All @@ -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)
);
}

Expand Down
3 changes: 2 additions & 1 deletion src/modules/creators/creators.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ export function createEmptyCreatorListResponse(
limit: query.limit,
offset: query.offset,
total: 0,
})
}),
query.search
);
}

Expand Down
Loading