src/map-renderer/server.test.ts
Rama main · réplica de lectura del repositorio original.
import type { AddressInfo } from "node:net";
import { request as httpRequest, type Server } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import type { LabelLanguage, Theme, TilePayload } from "./payload";
import { createRendererServer, QUEUE_LIMIT, type Renderer } from "./server";
// The server driven over real HTTP with the contract's own bodies, and a
// stand-in for the drawing so the queue can be held full on purpose.
const SIGNATURE = "a".repeat(64);
const ATTRIBUTION =
"[OpenFreeMap](https://openfreemap.org/) © [OpenStreetMap](https://www.openstreetmap.org/copyright)";
const IMAGE = Buffer.from("RIFF....WEBPVP8 ");
const STYLE = { version: 8, sources: {}, layers: [] };
const TILE = { kind: "tile", theme: "light", z: 9, x: 300, y: 215 };
const DARK_TILE = { kind: "tile", theme: "dark", z: 0, x: 0, y: 0 };
interface Stub extends Renderer {
healthy: boolean;
/** How many times the server asked, which it does as it admits a draw. */
checks: number;
drawn: TilePayload[];
/** Holds every draw until `release` is called, when set. */
hold: boolean;
release(): void;
fail: boolean;
/** What each style answer was asked for. */
styled: [Theme, LabelLanguage | undefined][];
/** Holds every style answer until the promise it is set to settles. */
styleGate: Promise<void>;
styleFails: boolean;
}
function stubRenderer(): Stub {
let waiting: (() => void)[] = [];
const stub: Stub = {
healthy: true,
checks: 0,
drawn: [],
hold: false,
fail: false,
styled: [],
styleGate: Promise.resolve(),
styleFails: false,
isHealthy: () => {
stub.checks += 1;
return stub.healthy;
},
async draw(payload) {
if (stub.hold) await new Promise<void>((done) => waiting.push(done));
stub.drawn.push(payload);
if (stub.fail) throw new Error("the provider answered 500");
return IMAGE;
},
async style(theme, language) {
stub.styled.push([theme, language]);
await stub.styleGate;
if (stub.styleFails) throw new Error("the provider answered 500");
return STYLE;
},
release() {
const pending = waiting;
waiting = [];
pending.forEach((done) => done());
},
};
return stub;
}
let server: Server | undefined;
afterEach(async () => {
server?.closeAllConnections();
await new Promise((done) => server?.close(done) ?? done(null));
server = undefined;
});
async function serve(renderer: Renderer, queueLimit?: number) {
server = createRendererServer({
renderer,
signature: SIGNATURE,
attribution: ATTRIBUTION,
queueLimit,
});
await new Promise<void>((done) => server!.listen(0, "127.0.0.1", done));
return `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
}
const render = (base: string, body: unknown, type = "application/json") =>
fetch(`${base}/render`, {
method: "POST",
headers: { "Content-Type": type },
body: typeof body === "string" ? body : JSON.stringify(body),
});
describe("the renderer's server", () => {
it("answers /healthz with whether it can render", async () => {
const renderer = stubRenderer();
const base = await serve(renderer);
expect((await fetch(`${base}/healthz`)).status).toBe(200);
renderer.healthy = false;
expect((await fetch(`${base}/healthz`)).status).toBe(503);
});
it("answers /signature with the signature it draws with and the basemap's credit", async () => {
const base = await serve(stubRenderer());
const response = await fetch(`${base}/signature`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
expect(await response.json()).toEqual({
signature: SIGNATURE,
attribution: ATTRIBUTION,
});
});
it("draws a tile in either theme, naming the signature it drew with", async () => {
const renderer = stubRenderer();
const base = await serve(renderer);
for (const body of [TILE, DARK_TILE]) {
const response = await render(base, body);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("image/webp");
expect(response.headers.get("x-map-signature")).toBe(SIGNATURE);
expect(Buffer.from(await response.arrayBuffer())).toEqual(IMAGE);
}
expect(renderer.drawn).toEqual([TILE, DARK_TILE]);
});
it("draws a tile in a language, and logs it with its square", async () => {
const lines: string[] = [];
const renderer = stubRenderer();
server = createRendererServer({
renderer,
signature: SIGNATURE,
attribution: ATTRIBUTION,
log: (line) => lines.push(line),
});
await new Promise<void>((done) => server!.listen(0, "127.0.0.1", done));
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
expect((await render(base, { ...TILE, language: "de" })).status).toBe(200);
expect(renderer.drawn).toEqual([{ ...TILE, language: "de" }]);
expect(lines).toEqual([
expect.stringMatching(/^drew a tile, light 9\/300\/215 in de, in \d+ ms/),
]);
});
it("logs each tile it draws by its square", async () => {
const lines: string[] = [];
server = createRendererServer({
renderer: stubRenderer(),
signature: SIGNATURE,
attribution: ATTRIBUTION,
log: (line) => lines.push(line),
});
await new Promise<void>((done) => server!.listen(0, "127.0.0.1", done));
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
await render(base, TILE);
expect(lines).toEqual([
expect.stringMatching(/^drew a tile, light 9\/300\/215, in \d+ ms/),
]);
});
it("takes a JSON content type with a charset", async () => {
const base = await serve(stubRenderer());
const response = await render(
base,
TILE,
"application/json; charset=utf-8",
);
expect(response.status).toBe(200);
});
it("answers 400 for a body that breaks the shape, and draws nothing", async () => {
const renderer = stubRenderer();
const base = await serve(renderer);
for (const [body, type] of [
[{ ...TILE, name: "Blue Hole" }, "application/json"],
[{ ...TILE, kind: "dive" }, "application/json"],
// Past the deepest zoom a map is fitted at, and outside the grid.
[{ ...TILE, z: 10, x: 0, y: 0 }, "application/json"],
[{ ...TILE, x: 512 }, "application/json"],
// A language the basemap does not carry, or one the API left unreduced.
[{ ...TILE, language: "en" }, "application/json"],
[{ ...TILE, language: "de-DE" }, "application/json"],
[{ ...TILE, language: null }, "application/json"],
[
{
kind: "dive",
theme: "light",
dive_sites: [{ latitude: 28.5721, longitude: 34.5372 }],
entry_latitude: null,
entry_longitude: null,
exit_latitude: null,
exit_longitude: null,
},
"application/json",
],
["{not json", "application/json"],
[JSON.stringify(TILE), "text/plain"],
] as const) {
const response = await render(base, body, type);
expect(response.status).toBe(400);
expect(await response.json()).toHaveProperty("detail");
}
expect(renderer.drawn).toEqual([]);
});
it("answers 503 while it cannot render", async () => {
const renderer = stubRenderer();
renderer.healthy = false;
const base = await serve(renderer);
expect((await render(base, TILE)).status).toBe(503);
expect(renderer.drawn).toEqual([]);
});
it("answers 503, and no partial image, when a draw fails", async () => {
const renderer = stubRenderer();
renderer.fail = true;
const base = await serve(renderer);
const response = await render(base, TILE);
expect(response.status).toBe(503);
expect(response.headers.get("content-type")).toBe("application/json");
});
it("answers 503 once its queue is full, and draws the rest in turn", async () => {
const renderer = stubRenderer();
renderer.hold = true;
const base = await serve(renderer);
const queued = Array.from({ length: QUEUE_LIMIT }, (_, index) =>
render(base, { ...TILE, x: index }),
);
// Every one of them admitted before the next arrives: the server asks
// whether it is healthy as it admits each, in the same tick.
await expect.poll(() => renderer.checks).toBe(QUEUE_LIMIT);
const turnedAway = await render(base, TILE);
expect(turnedAway.status).toBe(503);
expect(await turnedAway.json()).toEqual({
detail: "The renderer's queue is full",
});
renderer.release();
// Released one at a time, since each draw waits for the one before it.
const timer = setInterval(() => renderer.release(), 5);
const responses = await Promise.all(queued);
clearInterval(timer);
expect(responses.map((response) => response.status)).toEqual(
Array(QUEUE_LIMIT).fill(200),
);
expect(renderer.drawn.map((payload) => payload.x)).toEqual(
Array.from({ length: QUEUE_LIMIT }, (_, index) => index),
);
// And room again once they are done.
renderer.hold = false;
expect((await render(base, TILE)).status).toBe(200);
});
it("skips a queued draw whose client has gone", async () => {
const renderer = stubRenderer();
renderer.hold = true;
const base = await serve(renderer);
const first = render(base, TILE);
await new Promise((done) => setTimeout(done, 50));
// A second request, abandoned while the first is still drawing.
await new Promise<void>((done) => {
const { port } = server!.address() as AddressInfo;
const request = httpRequest({
host: "127.0.0.1",
port,
path: "/render",
method: "POST",
headers: { "Content-Type": "application/json" },
});
request.on("error", () => {});
request.end(JSON.stringify(DARK_TILE), () =>
setTimeout(() => request.destroy(), 50),
);
// Long enough for the server to see the connection close.
request.on("close", () => setTimeout(done, 50));
});
renderer.hold = false;
renderer.release();
expect((await first).status).toBe(200);
await new Promise((done) => setTimeout(done, 50));
expect(renderer.drawn).toEqual([TILE]);
});
it("answers /style/{theme} with the theme's style, in a language if asked", async () => {
const renderer = stubRenderer();
const base = await serve(renderer);
for (const path of ["/style/light", "/style/dark?language=de"]) {
const response = await fetch(`${base}${path}`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
expect(await response.json()).toEqual(STYLE);
}
expect(renderer.styled).toEqual([
["light", undefined],
["dark", "de"],
]);
});
it("answers 400 for a theme or a language a tile would be refused", async () => {
const renderer = stubRenderer();
const base = await serve(renderer);
for (const path of [
"/style/sepia",
"/style/light?language=en",
"/style/light?language=de-DE",
"/style/light?language=",
]) {
const response = await fetch(`${base}${path}`);
expect(response.status).toBe(400);
expect(await response.json()).toHaveProperty("detail");
}
expect(renderer.styled).toEqual([]);
});
it("answers 503 for a style while it cannot render", async () => {
const renderer = stubRenderer();
renderer.healthy = false;
const base = await serve(renderer);
expect((await fetch(`${base}/style/light`)).status).toBe(503);
expect(renderer.styled).toEqual([]);
});
it("answers 503, and nothing partial, when the style cannot be had", async () => {
const lines: string[] = [];
const renderer = stubRenderer();
renderer.styleFails = true;
server = createRendererServer({
renderer,
signature: SIGNATURE,
attribution: ATTRIBUTION,
log: (line) => lines.push(line),
});
await new Promise<void>((done) => server!.listen(0, "127.0.0.1", done));
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const response = await fetch(`${base}/style/light`);
expect(response.status).toBe(503);
expect(await response.json()).toHaveProperty("detail");
expect(lines).toEqual([expect.stringContaining("the provider answered")]);
});
it("answers a style past a full queue, while a draw holds it", async () => {
const renderer = stubRenderer();
renderer.hold = true;
const base = await serve(renderer, 1);
const held = render(base, TILE);
await expect.poll(() => renderer.checks).toBe(1);
expect((await render(base, TILE)).status).toBe(503);
expect((await fetch(`${base}/style/light`)).status).toBe(200);
renderer.release();
expect((await held).status).toBe(200);
});
it("gives a style no place in the queue", async () => {
const renderer = stubRenderer();
let finish = () => {};
renderer.styleGate = new Promise((done) => (finish = done));
const base = await serve(renderer, 1);
const styled = fetch(`${base}/style/light`);
await expect.poll(() => renderer.styled.length).toBe(1);
expect((await render(base, TILE)).status).toBe(200);
finish();
expect((await styled).status).toBe(200);
});
it("answers 404 and 405 for what it does not serve", async () => {
const base = await serve(stubRenderer());
expect((await fetch(`${base}/`)).status).toBe(404);
const get = await fetch(`${base}/render`);
expect(get.status).toBe(405);
expect(get.headers.get("allow")).toBe("POST");
const post = await fetch(`${base}/signature`, { method: "POST" });
expect(post.status).toBe(405);
expect(post.headers.get("allow")).toBe("GET");
const style = await fetch(`${base}/style/light`, { method: "POST" });
expect(style.status).toBe(405);
expect(style.headers.get("allow")).toBe("GET");
expect((await fetch(`${base}/style`)).status).toBe(404);
expect((await fetch(`${base}/style/light/extra`)).status).toBe(404);
});
});