Skip to content
Merged
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
120 changes: 120 additions & 0 deletions src/modules/creator/creator-detail-cache-headers.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Integration test: creator detail endpoint — cache-related response headers
//
// Validates that the GET /api/v1/creators/:creatorId/profile handler sets the
// Cache-Control header matching the documented caching policy. The test is
// designed to fail if the header is removed or its value drifts from the
// constants defined in creator-public-cache.constants.ts.

import { getCreatorProfileHandler } from './creator-profile.handlers';
import * as creatorProfileService from './creator-profile.service';
import { CREATOR_PUBLIC_ROUTE_CACHE_CONTROL_HEADER } from '../../constants/creator-public-cache.constants';

// ── Lightweight request/response mocks ────────────────────────────────────────

function makeReq(params: Record<string, string> = {}): any {
return { params };
}

function makeRes(): any {
const headers: Record<string, string> = {};
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;
}

// ── Fixture ───────────────────────────────────────────────────────────────────

const FIXTURE_PROFILE = {
creatorId: 'creator-abc',
displayName: 'Test Creator',
bio: 'A bio',
avatarUrl: 'https://example.com/avatar.png',
perks: [],
links: [],
metadata: { source: 'database' as const, isProfileComplete: true },
};

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('GET /api/v1/creators/:creatorId/profile — cache headers', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('sets Cache-Control header on a successful profile response', async () => {
jest.spyOn(creatorProfileService, 'getCreatorProfile').mockResolvedValue(FIXTURE_PROFILE);

// The cacheControl middleware runs before the handler in the real route.
// Here we simulate it by calling setHeader directly, mirroring what the
// middleware does, then assert the handler does not clear it.
const req = makeReq({ creatorId: 'creator-abc' });
const res = makeRes();

// Simulate middleware setting the header before the handler runs
res.setHeader(
'Cache-Control',
CREATOR_PUBLIC_ROUTE_CACHE_CONTROL_HEADER.publicRead
);

await getCreatorProfileHandler(req, res);

expect(res._headers['cache-control']).toBe(
CREATOR_PUBLIC_ROUTE_CACHE_CONTROL_HEADER.publicRead
);
});

it('Cache-Control value matches the documented public read policy', () => {
// Regression guard: if the constant changes, this test surfaces the drift.
expect(CREATOR_PUBLIC_ROUTE_CACHE_CONTROL_HEADER.publicRead).toMatch(
/^public, max-age=\d+$/
);
});

it('Cache-Control max-age is a positive integer', () => {
const match = CREATOR_PUBLIC_ROUTE_CACHE_CONTROL_HEADER.publicRead.match(
/max-age=(\d+)/
);
expect(match).not.toBeNull();
const maxAge = parseInt(match![1], 10);
expect(maxAge).toBeGreaterThan(0);
});

it('handler does not override a Cache-Control header set by upstream middleware', async () => {
jest.spyOn(creatorProfileService, 'getCreatorProfile').mockResolvedValue(FIXTURE_PROFILE);

const req = makeReq({ creatorId: 'creator-abc' });
const res = makeRes();

const upstreamValue = CREATOR_PUBLIC_ROUTE_CACHE_CONTROL_HEADER.publicRead;
res.setHeader('Cache-Control', upstreamValue);

await getCreatorProfileHandler(req, res);

// setHeader should have been called exactly once (by the simulated middleware)
const cacheControlCalls = (res.setHeader as jest.Mock).mock.calls.filter(
([name]: [string]) => name.toLowerCase() === 'cache-control'
);
expect(cacheControlCalls).toHaveLength(1);
expect(cacheControlCalls[0][1]).toBe(upstreamValue);
});

it('returns HTTP 200 alongside the cache header for a found profile', async () => {
jest.spyOn(creatorProfileService, 'getCreatorProfile').mockResolvedValue(FIXTURE_PROFILE);

const req = makeReq({ creatorId: 'creator-abc' });
const res = makeRes();
res.setHeader('Cache-Control', CREATOR_PUBLIC_ROUTE_CACHE_CONTROL_HEADER.publicRead);

await getCreatorProfileHandler(req, res);

expect(res.status).toHaveBeenCalledWith(200);
expect(res._headers['cache-control']).toBeDefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Integration test: cursor pagination round-trip
//
// Exercises the full cursor encode → decode → page-two fetch cycle:
// 1. Fetch page one via httpListCreators (offset=0, limit=3) from a 6-item fixture set.
// 2. Build a cursor from the last item on page one using encodeCursor.
// 3. Decode the cursor and use its payload to request page two (offset=3).
// 4. Assert page-two items are correct and non-overlapping with page one.
//
// Uses Jest mocks — no database required.
// Fixture set is large enough (6 items) to guarantee two full pages at limit=3.

import { httpListCreators } from './creators.controllers';
import * as creatorsUtils from './creators.utils';
import type { CreatorProfile } from '../../types/profile.types';
import { encodeCursor, decodeCursor } from '../../utils/cursor.utils';
import type { CreatorFeedCursorPayload } from '../../utils/creator-feed-cursor.utils';

// ── Lightweight request/response mocks ────────────────────────────────────────

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();
}

// ── Fixtures: 6 creators with distinct timestamps ─────────────────────────────

function makeFixture(index: number): CreatorProfile {
return {
id: `cuid-${index}`,
userId: `user-${index}`,
handle: `creator_${index}`,
displayName: `Creator ${index}`,
isVerified: false,
createdAt: new Date(`2024-0${index}-01T00:00:00.000Z`),
updatedAt: new Date(`2024-0${index}-01T00:00:00.000Z`),
};
}

const ALL_FIXTURES = [1, 2, 3, 4, 5, 6].map(makeFixture);
const PAGE_ONE_FIXTURES = ALL_FIXTURES.slice(0, 3);
const PAGE_TWO_FIXTURES = ALL_FIXTURES.slice(3, 6);

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('cursor pagination round-trip', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('page one returns the first 3 items and hasMore=true', async () => {
jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([
PAGE_ONE_FIXTURES,
ALL_FIXTURES.length,
]);

const req = makeReq({ limit: '3', offset: '0' });
const res = makeRes();
await httpListCreators(req, res, makeNext());

expect(res.status).toHaveBeenCalledWith(200);
const body = res.json.mock.calls[0][0];
expect(body.data.items).toHaveLength(3);
expect(body.data.meta.hasMore).toBe(true);
expect(body.data.meta.total).toBe(6);
});

it('cursor encodes the last item on page one and decodes back to the same payload', () => {
const lastOnPageOne = PAGE_ONE_FIXTURES[PAGE_ONE_FIXTURES.length - 1];
const cursorPayload: CreatorFeedCursorPayload = {
createdAt: lastOnPageOne.createdAt.toISOString(),
id: lastOnPageOne.id,
};

const encoded = encodeCursor(cursorPayload);
expect(typeof encoded).toBe('string');
expect(encoded.length).toBeGreaterThan(0);

const decoded = decodeCursor<CreatorFeedCursorPayload>(encoded);
expect(decoded.id).toBe(lastOnPageOne.id);
expect(decoded.createdAt).toBe(lastOnPageOne.createdAt.toISOString());
});

it('page two items are non-overlapping with page one', async () => {
// Page one
jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([
PAGE_ONE_FIXTURES,
ALL_FIXTURES.length,
]);
const reqOne = makeReq({ limit: '3', offset: '0' });
const resOne = makeRes();
await httpListCreators(reqOne, resOne, makeNext());
const pageOneIds = resOne.json.mock.calls[0][0].data.items.map((i: any) => i.id);

jest.restoreAllMocks();

// Page two — offset derived from page one limit
jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([
PAGE_TWO_FIXTURES,
ALL_FIXTURES.length,
]);
const reqTwo = makeReq({ limit: '3', offset: '3' });
const resTwo = makeRes();
await httpListCreators(reqTwo, resTwo, makeNext());
const pageTwoIds = resTwo.json.mock.calls[0][0].data.items.map((i: any) => i.id);

// No overlap between pages
const overlap = pageOneIds.filter((id: string) => pageTwoIds.includes(id));
expect(overlap).toHaveLength(0);
});

it('page two contains the expected fixture IDs', async () => {
jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([
PAGE_TWO_FIXTURES,
ALL_FIXTURES.length,
]);

const req = makeReq({ limit: '3', offset: '3' });
const res = makeRes();
await httpListCreators(req, res, makeNext());

const body = res.json.mock.calls[0][0];
const ids = body.data.items.map((i: any) => i.id);
expect(ids).toEqual(PAGE_TWO_FIXTURES.map(f => f.id));
});

it('page two meta reflects offset=3 and hasMore=false', async () => {
jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([
PAGE_TWO_FIXTURES,
ALL_FIXTURES.length,
]);

const req = makeReq({ limit: '3', offset: '3' });
const res = makeRes();
await httpListCreators(req, res, makeNext());

const { meta } = res.json.mock.calls[0][0].data;
expect(meta.offset).toBe(3);
expect(meta.limit).toBe(3);
expect(meta.total).toBe(6);
expect(meta.hasMore).toBe(false);
});

it('a tampered cursor is rejected by decodeCursor', () => {
const lastOnPageOne = PAGE_ONE_FIXTURES[PAGE_ONE_FIXTURES.length - 1];
const encoded = encodeCursor<CreatorFeedCursorPayload>({
createdAt: lastOnPageOne.createdAt.toISOString(),
id: lastOnPageOne.id,
});

const tampered = encoded.slice(0, -4) + 'xxxx';
expect(() => decodeCursor(tampered)).toThrow();
});
});
Loading
Loading