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
89 changes: 89 additions & 0 deletions app/api/routes-f/__tests__/idempotency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Routes-F idempotency tests.
*/
jest.mock("next/server", () => ({
NextResponse: {
json: (body: unknown, init?: ResponseInit) =>
new Response(JSON.stringify(body), {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
}),
},
}));

import { POST as itemsPost } from "../items/route";
import { __test__clearIdempotencyStore } from "@/lib/routes-f/idempotency";
import { __test__setRoutesFRecords } from "@/lib/routes-f/store";

const makePostRequest = (body: any, headersInit?: HeadersInit) => {
return new Request(`http://localhost/api/routes-f/items`, {
method: "POST",
headers: headersInit,
body: JSON.stringify(body),
});
};

describe("POST /api/routes-f/items (Idempotency)", () => {
beforeEach(() => {
__test__clearIdempotencyStore();
__test__setRoutesFRecords([]);
});

it("executes handler normally without Idempotency-Key", async () => {
const body = { title: "Test", description: "Desc" };
const res = await itemsPost(makePostRequest(body));
expect(res.status).toBe(201);
expect(res.headers.get("x-idempotency-hit")).toBeNull();

const res2 = await itemsPost(makePostRequest(body));
expect(res2.status).toBe(201);
expect(res2.headers.get("x-idempotency-hit")).toBeNull();
});

it("returns cached response for duplicate requests with same key", async () => {
const body = { title: "Test", description: "Desc" };
const key = "test-key-123";
const headers = { "idempotency-key": key };

const res1 = await itemsPost(makePostRequest(body, headers));
expect(res1.status).toBe(201);
expect(res1.headers.get("x-idempotency-hit")).toBe("false");
const data1 = await res1.json();

// Second request
const res2 = await itemsPost(makePostRequest(body, headers));
expect(res2.status).toBe(201);
expect(res2.headers.get("x-idempotency-hit")).toBe("true");
const data2 = await res2.json();

expect(data1).toEqual(data2);
});

it("allows different keys to have different results", async () => {
const body = { title: "Test", description: "Desc" };

const res1 = await itemsPost(makePostRequest(body, { "idempotency-key": "key-1" }));
expect(res1.headers.get("x-idempotency-hit")).toBe("false");

const res2 = await itemsPost(makePostRequest(body, { "idempotency-key": "key-2" }));
expect(res2.headers.get("x-idempotency-hit")).toBe("false");
});

it("handles key expiration", async () => {
jest.useFakeTimers();
const body = { title: "Test", description: "Desc" };
const key = "expiring-key";
const headers = { "idempotency-key": key };

const res1 = await itemsPost(makePostRequest(body, headers));
expect(res1.headers.get("x-idempotency-hit")).toBe("false");

// Advance time by 61 seconds
jest.advanceTimersByTime(61 * 1000);

const res2 = await itemsPost(makePostRequest(body, headers));
expect(res2.headers.get("x-idempotency-hit")).toBe("false");

jest.useRealTimers();
});
});
110 changes: 110 additions & 0 deletions app/api/routes-f/__tests__/mock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Deterministic Mock Generator Tests
*/
import { generateMockData, DeterministicGenerator } from "@/lib/routes-f/mock-generator";
import { POST } from "../mock/generate/route";

// Mock Next.js Response
jest.mock("next/server", () => ({
NextResponse: {
json: (body: unknown, init?: ResponseInit) =>
new Response(JSON.stringify(body), {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
}),
},
}));

// Mock the logging wrapper as a pass-through
jest.mock("@/lib/routes-f/logging", () => ({
withRoutesFLogging: jest.fn((req, handler) => handler(req)),
}));

function makePostRequest(body?: any): Request {
return new Request("http://localhost/api/routes-f/mock/generate", {
method: "POST",
headers: { "content-type": "application/json", "content-length": body ? JSON.stringify(body).length.toString() : "0" },
body: body ? JSON.stringify(body) : undefined,
});
}

describe("DeterministicGenerator Utility", () => {
it("generates the same sequence of numbers for the same string seed", () => {
const g1 = new DeterministicGenerator("test-seed-123");
const g2 = new DeterministicGenerator("test-seed-123");

for (let i = 0; i < 100; i++) {
expect(g1.random()).toBe(g2.random());
}
});

it("generates different sequences for different seeds", () => {
const g1 = new DeterministicGenerator("test-seed-123");
const g2 = new DeterministicGenerator("test-seed-456");

// They shouldn't match on the first draw
expect(g1.random()).not.toBe(g2.random());
});

it("generates identical mock data arrays for the same seed", () => {
const data1 = generateMockData("my-seed", 50, "financial");
const data2 = generateMockData("my-seed", 50, "financial");

expect(data1).toEqual(data2);
});

it("generates different arrays for different profiles even with same seed", () => {
const data1 = generateMockData("my-seed", 50, "financial");
const data2 = generateMockData("my-seed", 50, "social");

expect(data1).not.toEqual(data2);
});
});

describe("POST /api/routes-f/mock/generate", () => {
it("returns 400 if count exceeds maximum", async () => {
const res = await POST(makePostRequest({ count: 1000 }));
expect(res.status).toBe(400);

const data = await res.json();
expect(data.error).toMatch(/count must be between/);
});

it("returns 400 if count is negative", async () => {
const res = await POST(makePostRequest({ count: -5 }));
expect(res.status).toBe(400);
});

it("returns 400 if count is not an integer", async () => {
const res = await POST(makePostRequest({ count: 5.5 }));
expect(res.status).toBe(400);
});

it("returns default 10 records if no body is provided", async () => {
const res = await POST(new Request("http://localhost/api", { method: "POST" }));
expect(res.status).toBe(200);

const data = await res.json();
expect(data.metadata.count).toBe(10);
expect(data.metadata.seed).toBeDefined();
expect(data.data.length).toBe(10);
});

it("returns deterministic payloads when seeded", async () => {
const seed = "fixed-seed-789";
const count = 5;

const res1 = await POST(makePostRequest({ seed, count }));
const res2 = await POST(makePostRequest({ seed, count }));

const data1 = await res1.json();
const data2 = await res2.json();

expect(data1.metadata.seed).toBe(seed);
expect(data2.metadata.seed).toBe(seed);

// We stringify and compare to ignore the generatedAt timestamp in metadata
// Data arrays should be strictly identical
expect(data1.data).toEqual(data2.data);
});
});
119 changes: 119 additions & 0 deletions app/api/routes-f/__tests__/payload-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Payload guard utility tests
*/
jest.mock("next/server", () => ({
NextResponse: {
json: (body: unknown, init?: ResponseInit) =>
new Response(JSON.stringify(body), {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
}),
},
}));

import { withPayloadGuard, getPayloadLimitBytes } from "@/lib/routes-f/payload-guard";

describe("payload-guard getPayloadLimitBytes", () => {
const originalEnv = process.env;

beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
});

afterAll(() => {
process.env = originalEnv;
});

it("returns default 50KB when no env and no options", () => {
expect(getPayloadLimitBytes()).toBe(50 * 1024);
});

it("respects ROUTES_F_MAX_PAYLOAD_BYTES env var", () => {
process.env.ROUTES_F_MAX_PAYLOAD_BYTES = "1024";
expect(getPayloadLimitBytes()).toBe(1024);
});

it("falls back to default if env var is invalid", () => {
process.env.ROUTES_F_MAX_PAYLOAD_BYTES = "abc";
expect(getPayloadLimitBytes()).toBe(50 * 1024);

process.env.ROUTES_F_MAX_PAYLOAD_BYTES = "-10";
expect(getPayloadLimitBytes()).toBe(50 * 1024);
});

it("overrides env var when maxBytes option is provided", () => {
process.env.ROUTES_F_MAX_PAYLOAD_BYTES = "1024";
expect(getPayloadLimitBytes({ maxBytes: 2048 })).toBe(2048);
});
});

describe("withPayloadGuard middleware", () => {
const mockHandler = jest.fn().mockImplementation((req) => new Response("OK", { status: 200 }));

beforeEach(() => {
mockHandler.mockClear();
});

it("allows requests under the limit", async () => {
const req = new Request("http://localhost/api", {
method: "POST",
headers: { "content-length": "100" },
body: "test",
});

const res = await withPayloadGuard(req, mockHandler, { maxBytes: 200 });
expect(res.status).toBe(200);
expect(mockHandler).toHaveBeenCalledTimes(1);
});

it("rejects requests exactly equal to limit", async () => {
// Actually, it usually allows exact limits (`> limitBytes`), so let's verify that.
const req = new Request("http://localhost/api", {
method: "POST",
headers: { "content-length": "200" },
body: "test",
});

const res = await withPayloadGuard(req, mockHandler, { maxBytes: 200 });
expect(res.status).toBe(200);
expect(mockHandler).toHaveBeenCalledTimes(1);
});

it("rejects requests strictly over the limit with 413", async () => {
const req = new Request("http://localhost/api", {
method: "POST",
headers: { "content-length": "201" },
body: "test test test test test",
});

const res = await withPayloadGuard(req, mockHandler, { maxBytes: 200 });
expect(res.status).toBe(413);

const data = await res.json();
expect(data.error).toBe("Payload too large");
expect(mockHandler).not.toHaveBeenCalled();
});

it("allows requests missing content-length header", async () => {
const req = new Request("http://localhost/api", {
method: "GET", // GET usually has no content-length
});

const res = await withPayloadGuard(req, mockHandler, { maxBytes: 200 });
expect(res.status).toBe(200);
expect(mockHandler).toHaveBeenCalledTimes(1);
});

it("allows requests with unparsable content-length header", async () => {
const req = new Request("http://localhost/api", {
method: "POST",
headers: { "content-length": "chunked" },
body: "test",
});

const res = await withPayloadGuard(req, mockHandler, { maxBytes: 200 });
expect(res.status).toBe(200);
expect(mockHandler).toHaveBeenCalledTimes(1);
});
});
Loading
Loading