Files
edgeever/apps/api/src/api-request-errors.test.mjs
T
Dariusz Koryto 518fafa86e fix(api): report bad request bodies and fractional limits as client errors (#484)
A malformed JSON body, or a body over the route's size limit, made Hono
throw an HTTPException that the API reported as a 500 internal error. In
the companion API the same requests returned 503 "unavailable, please
retry", so clients retried a request that could never succeed. These
now return 400 bad_request and 413 payload_too_large in the API's usual
error shape.

A fractional limit or offset such as ?limit=1.5 was passed to SQLite as
a float and failed with a datatype mismatch. Limits, offsets and cursors
are now truncated to whole numbers, and the two private copies of the
clamp helper reuse the shared one.
2026-10-06 21:39:22 +08:00

88 lines
3.6 KiB
JavaScript

import { afterEach, describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { clampNumber } from "./entity-utils.ts";
import { fetchEdgeEverApp } from "./index.ts";
import { createSelfHostedStorageAdapter } from "./self-hosted-storage-adapter.ts";
const temporaryDirectories = [];
const databases = [];
afterEach(async () => {
for (const database of databases.splice(0)) database.close();
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
const createEnvironment = async () => {
const directory = await mkdtemp(`${tmpdir()}/edgeever-request-errors-`);
temporaryDirectories.push(directory);
const database = new Database(join(directory, "edgeever.sqlite"), { create: true });
databases.push(database);
database.exec("PRAGMA foreign_keys = ON");
const migrationDirectory = new URL("../../../migrations/", import.meta.url);
for (const name of (await readdir(migrationDirectory)).filter((value) => value.endsWith(".sql")).sort()) {
database.exec(await readFile(new URL(name, migrationDirectory), "utf8"));
}
database.query("INSERT OR IGNORE INTO workspaces (id, name, is_personal) VALUES ('ws_default', 'Personal', 1)").run();
return {
storage: createSelfHostedStorageAdapter(database, join(directory, "resources")),
EDGE_EVER_ALLOW_UNAUTHENTICATED: "true",
EDGE_EVER_RUNTIME: "self-hosted-bun",
};
};
const request = (environment, path, init = {}) => fetchEdgeEverApp(
new Request(`http://edgeever.test${path}`, init),
environment,
{ waitUntil: () => undefined, passThroughOnException: () => undefined },
);
const jsonPost = (body) => ({
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
describe("API request errors", () => {
test("reports a malformed JSON body as a client error", async () => {
const environment = await createEnvironment();
const response = await request(environment, "/api/v1/notebooks", jsonPost("{\"name\":"));
expect(response.status).toBe(400);
expect(await response.json()).toEqual({
error: { code: "bad_request", message: "Malformed JSON in request body" },
});
});
test("reports an oversized body as payload too large", async () => {
const environment = await createEnvironment();
const oversized = JSON.stringify({ prompt: "x".repeat(450_000) });
for (const path of ["/api/v1/plugins/ai/generate", "/api/v1/companion/turns"]) {
const response = await request(environment, path, jsonPost(oversized));
expect({ path, status: response.status }).toEqual({ path, status: 413 });
expect(await response.json()).toEqual({
error: { code: "payload_too_large", message: "The request body is too large." },
});
}
});
test("accepts a fractional list limit instead of failing in SQL", async () => {
const environment = await createEnvironment();
const response = await request(environment, "/api/v1/memos?limit=1.5");
expect(response.status).toBe(200);
expect(Array.isArray((await response.json()).memos)).toBe(true);
});
test("clamps limits and offsets to whole numbers", () => {
expect(clampNumber(1.5, 1, 100)).toBe(1);
expect(clampNumber(99.9, 1, 100)).toBe(99);
expect(clampNumber(250, 1, 100)).toBe(100);
expect(clampNumber(-3, 0, 100)).toBe(0);
expect(clampNumber(Number.NaN, 1, 100)).toBe(1);
expect(clampNumber(Number.POSITIVE_INFINITY, 0, 50)).toBe(50);
});
});