Skip to content
Merged

fixes #307

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
1 change: 1 addition & 0 deletions scripts/check-no-package-lock.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

#!/usr/bin/env sh

if git ls-files --error-unmatch package-lock.json >/dev/null 2>&1; then
Expand Down
100 changes: 100 additions & 0 deletions src/middlewares/trailing-slash-normalizer.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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<void, []>;

beforeEach(() => {
next = jest.fn() as jest.Mock<void, NextFunction extends (...args: any[]) => 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');
});
});
34 changes: 34 additions & 0 deletions src/middlewares/trailing-slash-normalizer.middleware.ts
Original file line number Diff line number Diff line change
@@ -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();
};
Original file line number Diff line number Diff line change
@@ -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<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();
}

// ── 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);
});
});
6 changes: 6 additions & 0 deletions src/modules/creators/creators.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
Loading