diff --git a/scripts/check-no-package-lock.sh b/scripts/check-no-package-lock.sh index 6d9b8e7..6c1ad3b 100755 --- a/scripts/check-no-package-lock.sh +++ b/scripts/check-no-package-lock.sh @@ -1,3 +1,4 @@ + #!/usr/bin/env sh if git ls-files --error-unmatch package-lock.json >/dev/null 2>&1; then diff --git a/src/middlewares/trailing-slash-normalizer.middleware.test.ts b/src/middlewares/trailing-slash-normalizer.middleware.test.ts new file mode 100644 index 0000000..2624d1e --- /dev/null +++ b/src/middlewares/trailing-slash-normalizer.middleware.test.ts @@ -0,0 +1,100 @@ +// src/middlewares/trailing-slash-normalizer.middleware.test.ts +// +// Unit tests for the normalizeTrailingSlash middleware. +// Verifies that req.url is mutated correctly and that next() is always called. +// No Express app is spun up — the middleware function is exercised directly. + +import type { Request, Response, NextFunction } from 'express'; +import { normalizeTrailingSlash } from './trailing-slash-normalizer.middleware'; + +// ── Minimal mock helpers ─────────────────────────────────────────────────────── + +function makeReq(url: string): Request { + return { url } as unknown as Request; +} + +function makeRes(): Response { + return {} as Response; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('normalizeTrailingSlash middleware', () => { + let next: jest.Mock; + + beforeEach(() => { + next = jest.fn() as jest.Mock any ? [] : never>; + }); + + // ── next() is always called ────────────────────────────────────────────── + + it('always calls next()', () => { + normalizeTrailingSlash(makeReq('/some/path'), makeRes(), next); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('calls next() even when the URL is unchanged', () => { + normalizeTrailingSlash(makeReq('/'), makeRes(), next); + expect(next).toHaveBeenCalledTimes(1); + }); + + // ── Root path is preserved ─────────────────────────────────────────────── + + it('does not modify the bare root path "/"', () => { + const req = makeReq('/'); + normalizeTrailingSlash(req, makeRes(), next); + expect(req.url).toBe('/'); + }); + + it('does not modify root path with a query string "/?q=1"', () => { + const req = makeReq('/?q=1'); + normalizeTrailingSlash(req, makeRes(), next); + expect(req.url).toBe('/?q=1'); + }); + + // ── Trailing slashes are stripped ──────────────────────────────────────── + + it('strips a trailing slash from a single-segment path', () => { + const req = makeReq('/creators/'); + normalizeTrailingSlash(req, makeRes(), next); + expect(req.url).toBe('/creators'); + }); + + it('strips a trailing slash from a multi-segment path', () => { + const req = makeReq('/123/stats/'); + normalizeTrailingSlash(req, makeRes(), next); + expect(req.url).toBe('/123/stats'); + }); + + it('strips a trailing slash while preserving query string', () => { + const req = makeReq('/creators/?limit=10&offset=0'); + normalizeTrailingSlash(req, makeRes(), next); + expect(req.url).toBe('/creators?limit=10&offset=0'); + }); + + it('strips a trailing slash from a deeply nested path', () => { + const req = makeReq('/api/v1/creators/abc/profile/'); + normalizeTrailingSlash(req, makeRes(), next); + expect(req.url).toBe('/api/v1/creators/abc/profile'); + }); + + // ── Paths without trailing slashes are unchanged ────────────────────────── + + it('does not modify a path that has no trailing slash', () => { + const req = makeReq('/creators'); + normalizeTrailingSlash(req, makeRes(), next); + expect(req.url).toBe('/creators'); + }); + + it('does not modify a path with a query string but no trailing slash', () => { + const req = makeReq('/creators?limit=5'); + normalizeTrailingSlash(req, makeRes(), next); + expect(req.url).toBe('/creators?limit=5'); + }); + + it('does not modify a multi-segment path without a trailing slash', () => { + const req = makeReq('/123/stats'); + normalizeTrailingSlash(req, makeRes(), next); + expect(req.url).toBe('/123/stats'); + }); +}); diff --git a/src/middlewares/trailing-slash-normalizer.middleware.ts b/src/middlewares/trailing-slash-normalizer.middleware.ts new file mode 100644 index 0000000..ad6badc --- /dev/null +++ b/src/middlewares/trailing-slash-normalizer.middleware.ts @@ -0,0 +1,34 @@ +// src/middlewares/trailing-slash-normalizer.middleware.ts +import { RequestHandler } from 'express'; + +/** + * Middleware that normalizes trailing slashes by stripping them from the + * request URL (except for the bare root path '/'). + * + * Mutates `req.url` in-place so Express re-matches the modified path against + * the remaining route handlers in the same router chain — no redirect is + * issued, so clients never observe an extra round-trip. + * + * Query strings are preserved: `/path/?q=1` becomes `/path?q=1`. + * + * Apply this middleware to a specific router (e.g. `creatorsRouter`) to avoid + * unintended side-effects on unrelated route groups. + * + * @example + * creatorsRouter.use(normalizeTrailingSlash); + */ +export const normalizeTrailingSlash: RequestHandler = (req, _res, next) => { + const url = req.url; + + // Split path and query string so we only test the path portion. + const qIdx = url.indexOf('?'); + const pathname = qIdx === -1 ? url : url.slice(0, qIdx); + const search = qIdx === -1 ? '' : url.slice(qIdx); + + // Only strip when the path has a trailing slash AND is not the root '/'. + if (pathname !== '/' && pathname.endsWith('/')) { + req.url = pathname.slice(0, -1) + search; + } + + next(); +}; diff --git a/src/modules/creators/creator-list-page-size-boundary.integration.test.ts b/src/modules/creators/creator-list-page-size-boundary.integration.test.ts new file mode 100644 index 0000000..76a3135 --- /dev/null +++ b/src/modules/creators/creator-list-page-size-boundary.integration.test.ts @@ -0,0 +1,234 @@ +// Integration test: creator list endpoint — maximum page size boundary +// +// Verifies that: +// 1. A request at exactly MAX_PAGE_SIZE is accepted and returns a well-formed +// response with no more than MAX_PAGE_SIZE items. +// 2. A request above MAX_PAGE_SIZE is rejected with HTTP 400 before the +// service layer is reached. +// +// Uses Jest mocks with a minimal fixture set — no database required. +// Follows the same conventions as creator-feed-empty-filters.integration.test.ts +// and creator-feed-multi-filter.integration.test.ts. + +import { httpListCreators } from './creators.controllers'; +import * as creatorsUtils from './creators.utils'; +import type { CreatorProfile } from '../../types/profile.types'; +import { + MAX_PAGE_SIZE, + MIN_PAGE_SIZE, +} from '../../constants/pagination.constants'; + +// ── 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(); +} + +// ── Minimal fixture factory ─────────────────────────────────────────────────── +// +// Builds `count` distinct CreatorProfile stubs — enough for the mock to return +// a plausible MAX_PAGE_SIZE-length list without enumerating 100 hand-crafted objects. + +function makeFixtures(count: number): CreatorProfile[] { + return Array.from({ length: count }, (_, i) => ({ + id: `cuid-${i + 1}`, + userId: `user-${i + 1}`, + handle: `creator_${i + 1}`, + displayName: `Creator ${i + 1}`, + isVerified: false, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + })); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/creators — page size boundary', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── Exactly at MAX_PAGE_SIZE ─────────────────────────────────────────────── + + it('accepts limit equal to MAX_PAGE_SIZE and returns HTTP 200', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + makeFixtures(MAX_PAGE_SIZE), + MAX_PAGE_SIZE, + ]); + + const req = makeReq({ limit: String(MAX_PAGE_SIZE) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + }); + + it('passes limit=MAX_PAGE_SIZE to fetchCreatorList unmodified', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([[], 0]); + + const req = makeReq({ limit: String(MAX_PAGE_SIZE) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ limit: MAX_PAGE_SIZE }) + ); + }); + + it('response contains no more than MAX_PAGE_SIZE items when limit=MAX_PAGE_SIZE', async () => { + const fixtures = makeFixtures(MAX_PAGE_SIZE); + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + fixtures, + MAX_PAGE_SIZE, + ]); + + const req = makeReq({ limit: String(MAX_PAGE_SIZE) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items.length).toBeLessThanOrEqual(MAX_PAGE_SIZE); + }); + + it('pagination meta reflects limit=MAX_PAGE_SIZE in the response', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + makeFixtures(MAX_PAGE_SIZE), + MAX_PAGE_SIZE, + ]); + + const req = makeReq({ limit: String(MAX_PAGE_SIZE) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.limit).toBe(MAX_PAGE_SIZE); + expect(body.data.meta.total).toBe(MAX_PAGE_SIZE); + expect(body.data.meta.hasMore).toBe(false); + }); + + it('response envelope is well-formed at the limit boundary', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + makeFixtures(3), + 3, + ]); + + const req = makeReq({ limit: String(MAX_PAGE_SIZE) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body).toHaveProperty('success', true); + expect(body).toHaveProperty('data'); + expect(body.data).toHaveProperty('items'); + expect(body.data).toHaveProperty('meta'); + expect(Array.isArray(body.data.items)).toBe(true); + expect(body.data.meta).toMatchObject({ + limit: MAX_PAGE_SIZE, + offset: expect.any(Number), + total: expect.any(Number), + hasMore: expect.any(Boolean), + }); + }); + + // ── One above MAX_PAGE_SIZE ──────────────────────────────────────────────── + + it('rejects limit one above MAX_PAGE_SIZE with HTTP 400', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: String(MAX_PAGE_SIZE + 1) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + }); + + it('does not call fetchCreatorList when limit exceeds MAX_PAGE_SIZE', async () => { + const spy = jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: String(MAX_PAGE_SIZE + 1) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('response body is not a success envelope when limit exceeds MAX_PAGE_SIZE', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: String(MAX_PAGE_SIZE + 1) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + // The data field should be absent or not carry a valid items array + expect(body.data?.items).toBeUndefined(); + }); + + // ── Well-above MAX_PAGE_SIZE (sanity check) ─────────────────────────────── + + it('rejects a very large limit (e.g. 9999) with HTTP 400', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '9999' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + }); + + // ── Adjacent boundary below MAX_PAGE_SIZE ──────────────────────────────── + + it('accepts limit one below MAX_PAGE_SIZE (MAX_PAGE_SIZE - 1) with HTTP 200', async () => { + const belowMax = MAX_PAGE_SIZE - 1; + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + makeFixtures(belowMax), + belowMax, + ]); + + const req = makeReq({ limit: String(belowMax) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.meta.limit).toBe(belowMax); + }); + + // ── MIN_PAGE_SIZE boundary ──────────────────────────────────────────────── + + it('accepts limit equal to MIN_PAGE_SIZE with HTTP 200', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([ + makeFixtures(1), + 1, + ]); + + const req = makeReq({ limit: String(MIN_PAGE_SIZE) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.limit).toBe(MIN_PAGE_SIZE); + }); +}); diff --git a/src/modules/creators/creators.routes.ts b/src/modules/creators/creators.routes.ts index b3d92dd..0666bcd 100644 --- a/src/modules/creators/creators.routes.ts +++ b/src/modules/creators/creators.routes.ts @@ -4,9 +4,15 @@ import { cacheControl } from '../../middlewares/cache-control.middleware'; import { CREATOR_PUBLIC_ROUTE_CACHE_PRESETS } from '../../constants/creator-public-cache.constants'; import { CREATOR_PUBLIC_ROUTE_NAMES } from '../../constants/creator-public-routes.constants'; import { createCreatorReadMetricsMiddleware } from '../../utils/creator-read-metrics.utils'; +import { normalizeTrailingSlash } from '../../middlewares/trailing-slash-normalizer.middleware'; const creatorsRouter = Router(); +// Normalize trailing slashes for all creator routes so that, e.g., +// GET /api/v1/creators/ reaches the same handler as GET /api/v1/creators. +// Scoped to this router to avoid side-effects on other route groups. +creatorsRouter.use(normalizeTrailingSlash); + /** * GET /api/v1/creators *