From 4b0afaf997a9b768fbd898913780c3f40c9eca99 Mon Sep 17 00:00:00 2001 From: Jonathan Baldie Date: Mon, 28 Sep 2026 10:49:49 +0100 Subject: [PATCH] feat: type HTTP queue payloads as JSON values (#142) --- main.ts | 7 +-- src/handler.ts | 24 +++++----- src/payload.ts | 42 ++++++++++++------ tests/e2e_test.ts | 3 +- tests/handler_test.ts | 88 +++++++++++++++++++++++-------------- tests/manager_test.ts | 3 +- tests/payload_test.ts | 16 +++---- tests/payload_types_test.ts | 29 ++++++++++++ tests/persist_test.ts | 3 +- tests/rate_limiter_test.ts | 3 +- 10 files changed, 146 insertions(+), 72 deletions(-) create mode 100644 tests/payload_types_test.ts diff --git a/main.ts b/main.ts index 4088e5b..b258096 100644 --- a/main.ts +++ b/main.ts @@ -2,6 +2,7 @@ import * as Persistency from "./src/persist.ts"; import QueueManager from "./src/manager.ts"; import { createHandler } from "./src/handler.ts"; import { parseConfig } from "./src/config.ts"; +import type { Payload } from "./src/payload.ts"; const LOG_ENCODER = new TextEncoder(); @@ -35,13 +36,13 @@ const CONFIG = parseConfig(readEnv(), Deno.args); // Set up our persistency manager const PERSIST_ENGINE = CONFIG.persistEnabled - ? new Persistency.FileStore - : new Persistency.MemoryStore; + ? new Persistency.FileStore() + : new Persistency.MemoryStore(); PERSIST_ENGINE.dir(CONFIG.persistDir); // Set up the manager, which will handle our queues for us -const MANAGER = new QueueManager(PERSIST_ENGINE, CONFIG.queueDepthLimit, CONFIG.queueCountLimit, CONFIG.persistEnabled); +const MANAGER = new QueueManager(PERSIST_ENGINE, CONFIG.queueDepthLimit, CONFIG.queueCountLimit, CONFIG.persistEnabled); // Load up any existing queue data, if we're persisting if (PERSIST_ENGINE instanceof Persistency.FileStore) { diff --git a/src/handler.ts b/src/handler.ts index 95a76e8..b593e7d 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -1,12 +1,16 @@ import QueueManager, { QueueNameTooLongError } from "./manager.ts"; import { RateLimiter } from "./rate_limiter.ts"; import { withAuth, withRateLimit } from "./middleware.ts"; -import { RouteHandler, Router } from "./router.ts"; +import { Router } from "./router.ts"; import * as Payload from "./payload.ts"; +type JsonPayload = Payload.Payload; +type RouteHandler = Parameters[1]; +type RouteMatch = Parameters[1]; + const LOG_ENCODER = Reflect.construct(TextEncoder, []); -function extractQueueName(match: Parameters[1]): { name: string } | { error: Response } { +function extractQueueName(match: RouteMatch): { name: string } | { error: Response } { const raw = match.pathname.groups.queue; if (raw === undefined) { return { error: new Response("Invalid queue name", { status: 400 }) }; @@ -21,7 +25,7 @@ function extractQueueName(match: Parameters[1]): { name: string } } } -function enqueueHandler(mgr: QueueManager): RouteHandler { +function enqueueHandler(mgr: QueueManager): RouteHandler { return async (request, match) => { const queueResult = extractQueueName(match); if ("error" in queueResult) { @@ -33,7 +37,7 @@ function enqueueHandler(mgr: QueueManager): RouteHandler { if (contentLength && parseInt(contentLength) > Payload.DEFAULT_MAX_PAYLOAD_SIZE) { return new Response("Payload too large", { status: 413 }); } - const payload = await Payload.readAndValidatePayload( + const payload = await Payload.readAndValidatePayload( request.body, Payload.DEFAULT_MAX_PAYLOAD_SIZE, ); @@ -71,7 +75,7 @@ function queueNameErrorResponse(error: unknown): Response { throw error; } -function itemResponse(item: unknown): Response { +function itemResponse(item: JsonPayload | undefined): Response { if (item === undefined) { return new Response(null, { status: 204 }); } @@ -80,7 +84,7 @@ function itemResponse(item: unknown): Response { }); } -function dequeueHandler(mgr: QueueManager): RouteHandler { +function dequeueHandler(mgr: QueueManager): RouteHandler { return (request, match) => { void request; const queueResult = extractQueueName(match); @@ -98,7 +102,7 @@ function dequeueHandler(mgr: QueueManager): RouteHandler { }; } -function peekHandler(mgr: QueueManager): RouteHandler { +function peekHandler(mgr: QueueManager): RouteHandler { return (request, match) => { void request; const queueResult = extractQueueName(match); @@ -113,7 +117,7 @@ function peekHandler(mgr: QueueManager): RouteHandler { }; } -function lengthHandler(mgr: QueueManager): RouteHandler { +function lengthHandler(mgr: QueueManager): RouteHandler { return (request, match) => { void request; const queueResult = extractQueueName(match); @@ -129,7 +133,7 @@ function lengthHandler(mgr: QueueManager): RouteHandler { }; } -function registerRoutes(router: Router, mgr: QueueManager): void { +function registerRoutes(router: Router, mgr: QueueManager): void { router.get("/health{/}?", () => { return new Response(JSON.stringify({ status: "ok" }), { status: 200, @@ -153,7 +157,7 @@ function writeLog(destination: { writeSync(data: Uint8Array): number }, message: } export function createHandler( - mgr: QueueManager, + mgr: QueueManager, apiToken: string, rateLimitRequests?: number, ) { diff --git a/src/payload.ts b/src/payload.ts index b3033b4..8eee8e3 100644 --- a/src/payload.ts +++ b/src/payload.ts @@ -1,3 +1,13 @@ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export type Payload = Exclude; + export class InvalidPayloadError extends Error { constructor(message: string = "Invalid JSON") { super(message); @@ -101,11 +111,12 @@ function decodePayloadBody(body: string | Uint8Array): string { } } -function parseAndValidateJson(text: string): unknown { +function parseAndValidateJson(text: string): JsonValue { try { - const json = JSON.parse(text); + const json: unknown = JSON.parse(text); validateJsonSource(text); - return json; + // JSON.parse only creates JSON values; source validation also rules out non-finite numbers. + return json as JsonValue; } catch (error) { if (error instanceof RangeError || error instanceof JsonNestingTooDeepError) { throw new JsonNestingTooDeepError(); @@ -117,32 +128,36 @@ function parseAndValidateJson(text: string): unknown { } } -function extractPayloadValue(json: unknown): T { - if (json === null || typeof json !== "object") { +function isJsonObject(value: JsonValue): value is { [key: string]: JsonValue } { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function extractPayloadValue(json: JsonValue): Payload { + if (!isJsonObject(json)) { throw new InvalidPayloadError("Missing payload key"); } if (!("payload" in json)) { throw new InvalidPayloadError("Missing payload key"); } - const payload = (json as Record).payload; + const payload = json.payload; if (payload === null) { throw new InvalidPayloadError("Null payload not allowed"); } - return payload as T; + return payload; } -export function parsePayloadBody(body: string | Uint8Array): T { +export function parsePayloadBody(body: string | Uint8Array): Payload { const text = decodePayloadBody(body); const json = parseAndValidateJson(text); - return extractPayloadValue(json); + return extractPayloadValue(json); } -export async function readAndValidatePayload( +export async function readAndValidatePayload( stream: ReadableStream | null, maxBytes: number = DEFAULT_MAX_PAYLOAD_SIZE, -): Promise { +): Promise { if (stream === null) { - return parsePayloadBody(""); + return parsePayloadBody(""); } const reader = stream.getReader(); @@ -182,6 +197,5 @@ export async function readAndValidatePayload( body.set(chunk, offset); offset += chunk.byteLength; } - return parsePayloadBody(body); + return parsePayloadBody(body); } - diff --git a/tests/e2e_test.ts b/tests/e2e_test.ts index 1b752c9..62359fc 100644 --- a/tests/e2e_test.ts +++ b/tests/e2e_test.ts @@ -4,13 +4,14 @@ import { createHandler } from "../src/handler.ts"; import * as Persistency from "../src/persist.ts"; import { RateLimiter } from "../src/rate_limiter.ts"; import { parseConfig, ConfigError } from "../src/config.ts"; +import type { Payload } from "../src/payload.ts"; // Shared helpers const API_TOKEN = "test-token"; const authHeaders = { "Authorization": `Bearer ${API_TOKEN}` }; function makeHandler(token = API_TOKEN, rateLimit = 100) { - const mgr = new QueueManager(new Persistency.MemoryStore()); + const mgr = new QueueManager(new Persistency.MemoryStore()); return createHandler(mgr, token, rateLimit); } diff --git a/tests/handler_test.ts b/tests/handler_test.ts index 0997245..a6063f8 100644 --- a/tests/handler_test.ts +++ b/tests/handler_test.ts @@ -4,13 +4,14 @@ import { createHandler } from "../src/handler.ts"; import * as Persistency from "../src/persist.ts"; import { RateLimiter } from "../src/rate_limiter.ts"; import { parseConfig, ConfigError } from "../src/config.ts"; +import type { Payload } from "../src/payload.ts"; // Shared helpers const API_TOKEN = "test-token"; const authHeaders = { "Authorization": `Bearer ${API_TOKEN}` }; function makeHandler(queueDepthLimit?: number, queueCountLimit?: number, rateLimitRequests = 100, token = API_TOKEN) { - const mgr = new QueueManager(new Persistency.MemoryStore(), queueDepthLimit, queueCountLimit); + const mgr = new QueueManager(new Persistency.MemoryStore(), queueDepthLimit, queueCountLimit); return createHandler(mgr, token, rateLimitRequests); } const handler = makeHandler(); @@ -778,7 +779,7 @@ Deno.test("POST to length returns 405", async () => { // /queues endpoint: GET returns list of queue names Deno.test("GET /queues returns empty array when no queues", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const request = new Request("http://localhost:3000/queues", { method: "GET", @@ -791,7 +792,7 @@ Deno.test("GET /queues returns empty array when no queues", async () => { }); Deno.test("GET /queues returns queue names", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); mgr.enqueue("queue1", "item1"); mgr.enqueue("queue2", "item2"); const handler = createHandler(mgr, API_TOKEN); @@ -806,7 +807,7 @@ Deno.test("GET /queues returns queue names", async () => { }); Deno.test("POST /queues returns 405", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const request = new Request("http://localhost:3000/queues", { method: "POST", @@ -818,7 +819,7 @@ Deno.test("POST /queues returns 405", async () => { }); Deno.test("GET /queues requires bearer token", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const request = new Request("http://localhost:3000/queues", { method: "GET", @@ -828,7 +829,7 @@ Deno.test("GET /queues requires bearer token", async () => { }); Deno.test("GET /queues returns sorted queue names", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); mgr.enqueue("zebra", "item1"); mgr.enqueue("alpha", "item2"); mgr.enqueue("mango", "item3"); @@ -844,7 +845,7 @@ Deno.test("GET /queues returns sorted queue names", async () => { }); Deno.test("GET /queues does not include cleaned-up empty queues", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); mgr.enqueue("persistent", "item1"); mgr.enqueue("transient", "item2"); mgr.dequeue("transient"); // queue becomes empty and gets cleaned up @@ -1019,7 +1020,7 @@ Deno.test("GET peek returns 204 when queue is empty", async () => { }); Deno.test("dequeue returns application/json for object payload", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const enqueueReq = new Request("http://localhost:3000/enqueue/jsonqueue", { @@ -1041,7 +1042,7 @@ Deno.test("dequeue returns application/json for object payload", async () => { }); Deno.test("dequeue returns application/json for array payload", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const enqueueReq = new Request("http://localhost:3000/enqueue/jsonqueue", { @@ -1062,8 +1063,29 @@ Deno.test("dequeue returns application/json for array payload", async () => { assertEquals(body, [1, 2, 3]); }); +Deno.test("dequeue preserves null nested inside JSON payloads", async () => { + const mgr = new QueueManager(new Persistency.MemoryStore()); + const handler = createHandler(mgr, API_TOKEN); + const payload = { object: null, array: [null] }; + + const enqueueResponse = await handler(new Request("http://localhost:3000/enqueue/jsonqueue", { + method: "POST", + body: JSON.stringify({ payload }), + headers: authHeaders, + })); + assertEquals(enqueueResponse.status, 200); + + const response = await handler(new Request("http://localhost:3000/dequeue/jsonqueue", { + method: "GET", + headers: authHeaders, + })); + assertEquals(response.status, 200); + assertEquals(response.headers.get("content-type"), "application/json"); + assertEquals(await response.json(), payload); +}); + Deno.test("dequeue returns application/json for number payload", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const enqueueReq = new Request("http://localhost:3000/enqueue/jsonqueue", { @@ -1084,7 +1106,7 @@ Deno.test("dequeue returns application/json for number payload", async () => { }); Deno.test("API: rejects non-finite numeric payloads instead of corrupting them", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const enqueueResponse = await handler(new Request("http://localhost:3000/enqueue/numeric-queue", { @@ -1103,7 +1125,7 @@ Deno.test("API: rejects non-finite numeric payloads instead of corrupting them", }); Deno.test("API: rejects unsafe integer payloads instead of rounding them", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const enqueueResponse = await handler(new Request("http://localhost:3000/enqueue/unsafe-integer-queue", { @@ -1122,7 +1144,7 @@ Deno.test("API: rejects unsafe integer payloads instead of rounding them", async }); Deno.test("API: accepts exactly representable large integer payloads", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const enqueueResponse = await handler(new Request("http://localhost:3000/enqueue/exact-large-integer-queue", { @@ -1150,7 +1172,7 @@ Deno.test("API: preserves equivalent JSON numeric spellings", async () => { { name: "negative-exponent", source: "-1.2300e+2", value: -123 }, { name: "zero-exponent", source: "0e400", value: 0 }, ]; - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); for (const testCase of cases) { @@ -1171,7 +1193,7 @@ Deno.test("API: preserves equivalent JSON numeric spellings", async () => { }); Deno.test("API: rejects underflowing numeric payloads instead of changing them to zero", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const enqueueResponse = await handler(new Request("http://localhost:3000/enqueue/underflow-queue", { @@ -1190,7 +1212,7 @@ Deno.test("API: rejects underflowing numeric payloads instead of changing them t }); Deno.test("API: rejects rounded decimal payloads instead of changing their precision", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const enqueueResponse = await handler(new Request("http://localhost:3000/enqueue/decimal-precision-queue", { @@ -1209,7 +1231,7 @@ Deno.test("API: rejects rounded decimal payloads instead of changing their preci }); Deno.test("API: rejects invalid UTF-8 in JSON string payloads without enqueueing", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const invalidUtf8Body = Uint8Array.of( ...new TextEncoder().encode('{"payload":"caf'), @@ -1233,7 +1255,7 @@ Deno.test("API: rejects invalid UTF-8 in JSON string payloads without enqueueing }); Deno.test("API: round-trips valid UTF-8 in JSON string payloads", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const enqueueResponse = await handler(new Request("http://localhost:3000/enqueue/valid-utf8", { method: "POST", @@ -1251,7 +1273,7 @@ Deno.test("API: round-trips valid UTF-8 in JSON string payloads", async () => { }); Deno.test("dequeue returns application/json for boolean payload", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const enqueueReq = new Request("http://localhost:3000/enqueue/jsonqueue", { @@ -1274,7 +1296,7 @@ Deno.test("dequeue returns application/json for boolean payload", async () => { // queue-nyc: Missing payload key returns 400 Deno.test("missing payload key returns 400", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const request = new Request("http://localhost:3000/enqueue/testqueue", { method: "POST", @@ -1288,7 +1310,7 @@ Deno.test("missing payload key returns 400", async () => { // queue-w37: Null payload is rejected with 400 Deno.test("null payload returns 400", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const request = new Request("http://localhost:3000/enqueue/testqueue", { method: "POST", @@ -1302,7 +1324,7 @@ Deno.test("null payload returns 400", async () => { // queue-nyc: Valid payload returns 200 Deno.test("valid payload key returns 200", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const request = new Request("http://localhost:3000/enqueue/testqueue", { method: "POST", @@ -1316,7 +1338,7 @@ Deno.test("valid payload key returns 200", async () => { // queue-o3b: Health check works even when rate limit is exhausted Deno.test("health check exempt from rate limiting", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN, 1); // 1 request per minute // Exhaust rate limit with a non-health request @@ -1337,7 +1359,7 @@ Deno.test("health check exempt from rate limiting", async () => { // queue-zla: Body within limit is accepted Deno.test("body within limit is accepted", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const request = new Request("http://localhost:3000/enqueue/testqueue", { method: "POST", @@ -1351,7 +1373,7 @@ Deno.test("body within limit is accepted", async () => { // queue-zla: Body exceeding limit is rejected without Content-Length header Deno.test("body exceeding limit rejected without Content-Length", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const bigBody = '{"payload": "' + "x".repeat(1024 * 1024) + '"}'; const request = new Request("http://localhost:3000/enqueue/testqueue", { @@ -1366,7 +1388,7 @@ Deno.test("body exceeding limit rejected without Content-Length", async () => { // queue-zla: Body exceeding limit rejected with fake Content-Length Deno.test("body exceeding limit rejected with fake Content-Length", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const bigBody = '{"payload": "' + "x".repeat(1024 * 1024) + '"}'; const request = new Request("http://localhost:3000/enqueue/testqueue", { @@ -1383,7 +1405,7 @@ Deno.test("body exceeding limit rejected with fake Content-Length", async () => // The byte limit must be measured in bytes, not in JS string code units. Deno.test("oversized multi-byte UTF-8 body without content-length returns 413", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); // U+4E00 is one UTF-16 code unit but three UTF-8 bytes. // 400000 chars -> ~1.2 MiB of wire bytes, < 1 MiB of code units. @@ -1410,7 +1432,7 @@ Deno.test("oversized multi-byte UTF-8 body without content-length returns 413", // queue-02t: Body read error returns 413 not 400 Deno.test("body read error returns 413 not 400", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const bodyStream = new ReadableStream({ start(controller) { @@ -1427,7 +1449,7 @@ Deno.test("body read error returns 413 not 400", async () => { }); Deno.test("dequeue returns application/json for string payload", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); const enqueueReq = new Request("http://localhost:3000/enqueue/jsonqueue", { @@ -1469,7 +1491,7 @@ Deno.test("Manager: enqueue followed by multiple dequeues (catches state corrupt const TEST_TOKEN = "test-token-12345"; function makeTestHandler() { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); return createHandler(mgr, TEST_TOKEN); } @@ -1873,7 +1895,7 @@ Deno.test("auth: valid token on length returns 200", async () => { }); Deno.test("dequeue distinguishes string zero from number zero", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); await handler(new Request("http://localhost:3000/enqueue/q", { @@ -1903,7 +1925,7 @@ Deno.test("dequeue distinguishes string zero from number zero", async () => { }); Deno.test("peek returns JSON for a string payload", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore); + const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN); await handler(new Request("http://localhost:3000/enqueue/q", { @@ -1981,7 +2003,7 @@ Deno.test("HEAD /peek/:queue on empty queue returns 204 with empty body", async }); Deno.test("HEAD /peek/:queue on non-empty queue returns 200 with headers and empty body", async () => { - const mgr = new QueueManager(new Persistency.MemoryStore()); + const mgr = new QueueManager(new Persistency.MemoryStore()); mgr.enqueue("test-queue", "payload-1"); const handler = createHandler(mgr, API_TOKEN); diff --git a/tests/manager_test.ts b/tests/manager_test.ts index 2473790..979c391 100644 --- a/tests/manager_test.ts +++ b/tests/manager_test.ts @@ -4,13 +4,14 @@ import { createHandler } from "../src/handler.ts"; import * as Persistency from "../src/persist.ts"; import { RateLimiter } from "../src/rate_limiter.ts"; import { parseConfig, ConfigError } from "../src/config.ts"; +import type { Payload } from "../src/payload.ts"; // Shared helpers const API_TOKEN = "test-token"; const authHeaders = { "Authorization": `Bearer ${API_TOKEN}` }; function makeHandler(token = API_TOKEN, rateLimit = 100) { - const mgr = new QueueManager(new Persistency.MemoryStore()); + const mgr = new QueueManager(new Persistency.MemoryStore()); return createHandler(mgr, token, rateLimit); } diff --git a/tests/payload_test.ts b/tests/payload_test.ts index 8bea36c..aaf5936 100644 --- a/tests/payload_test.ts +++ b/tests/payload_test.ts @@ -11,7 +11,7 @@ import { } from "../src/payload.ts"; Deno.test("payload: parses valid payload from JSON string", () => { - const result = parsePayloadBody('{"payload":"hello world"}'); + const result = parsePayloadBody('{"payload":"hello world"}'); assertEquals(result, "hello world"); }); @@ -53,7 +53,7 @@ Deno.test("payload: rejects malformed JSON string", () => { Deno.test("payload: parses valid UTF-8 Uint8Array payload", () => { const bytes = new TextEncoder().encode('{"payload":"héllo 🌍"}'); - const result = parsePayloadBody(bytes); + const result = parsePayloadBody(bytes); assertEquals(result, "héllo 🌍"); }); @@ -161,7 +161,7 @@ Deno.test("readAndValidatePayload: reads and parses stream within size limit", a controller.close(); }, }); - const result = await readAndValidatePayload(stream); + const result = await readAndValidatePayload(stream); assertEquals(result, "streamed-data"); }); @@ -177,7 +177,7 @@ Deno.test("readAndValidatePayload: reassembles payload split across multiple chu controller.close(); }, }); - const result = await readAndValidatePayload(stream); + const result = await readAndValidatePayload(stream); assertEquals(result, "multi-chunk"); }); @@ -238,8 +238,10 @@ Deno.test("readAndValidatePayload: accepts exactly 1 MiB payload", async () => { controller.close(); }, }); - const result = await readAndValidatePayload(stream); - assertEquals(typeof result, "string"); + const result = await readAndValidatePayload(stream); + if (typeof result !== "string") { + throw new Error("Expected string payload"); + } assertEquals(result.length, DEFAULT_MAX_PAYLOAD_SIZE - emptyPayloadBody.length); }); @@ -263,5 +265,3 @@ Deno.test("readAndValidatePayload: rejects exactly 1 MiB + 1 byte", async () => }); - - diff --git a/tests/payload_types_test.ts b/tests/payload_types_test.ts new file mode 100644 index 0000000..70467a4 --- /dev/null +++ b/tests/payload_types_test.ts @@ -0,0 +1,29 @@ +import { assertEquals } from "jsr:@std/assert@1.0"; +import { parsePayloadBody, readAndValidatePayload } from "../src/payload.ts"; +import type { JsonValue, Payload } from "../src/payload.ts"; + +const nestedJson: JsonValue = { object: null, array: [null] }; +const acceptedPayload: Payload = { object: null, array: [null] }; +const parsedPayload: Payload = parsePayloadBody('{"payload":{"object":null,"array":[null]}}'); + +function verifyPayloadReadersDoNotAcceptCallerSelectedTypes(): void { + // @ts-expect-error Payload parsing does not accept a caller-selected return type. + parsePayloadBody('{"payload":42}'); + + // @ts-expect-error Payload stream reading does not accept a caller-selected return type. + readAndValidatePayload(null); +} + +// @ts-expect-error A payload reader cannot promise that arbitrary JSON is a string. +const mustNarrowBeforeUsingAsString: string = parsePayloadBody('{"payload":42}'); + +// @ts-expect-error Top-level null is rejected by the HTTP payload contract. +const topLevelNullIsNotPayload: Payload = null; + +Deno.test("payload types allow nested null while excluding top-level null", () => { + assertEquals(nestedJson, { object: null, array: [null] }); + assertEquals(acceptedPayload, parsedPayload); + void verifyPayloadReadersDoNotAcceptCallerSelectedTypes; + void mustNarrowBeforeUsingAsString; + void topLevelNullIsNotPayload; +}); diff --git a/tests/persist_test.ts b/tests/persist_test.ts index cf279bb..4eadec4 100644 --- a/tests/persist_test.ts +++ b/tests/persist_test.ts @@ -4,13 +4,14 @@ import { createHandler } from "../src/handler.ts"; import * as Persistency from "../src/persist.ts"; import { RateLimiter } from "../src/rate_limiter.ts"; import { parseConfig, ConfigError } from "../src/config.ts"; +import type { Payload } from "../src/payload.ts"; // Shared helpers const API_TOKEN = "test-token"; const authHeaders = { "Authorization": `Bearer ${API_TOKEN}` }; function makeHandler(token = API_TOKEN, rateLimit = 100) { - const mgr = new QueueManager(new Persistency.MemoryStore()); + const mgr = new QueueManager(new Persistency.MemoryStore()); return createHandler(mgr, token, rateLimit); } diff --git a/tests/rate_limiter_test.ts b/tests/rate_limiter_test.ts index 69dfd33..ff1f82a 100644 --- a/tests/rate_limiter_test.ts +++ b/tests/rate_limiter_test.ts @@ -4,13 +4,14 @@ import { createHandler } from "../src/handler.ts"; import * as Persistency from "../src/persist.ts"; import { RateLimiter } from "../src/rate_limiter.ts"; import { parseConfig, ConfigError } from "../src/config.ts"; +import type { Payload } from "../src/payload.ts"; // Shared helpers const API_TOKEN = "test-token"; const authHeaders = { "Authorization": `Bearer ${API_TOKEN}` }; function makeHandler(token = API_TOKEN, rateLimit = 100) { - const mgr = new QueueManager(new Persistency.MemoryStore()); + const mgr = new QueueManager(new Persistency.MemoryStore()); return createHandler(mgr, token, rateLimit); }