Add test coverage retrofit for Phase 1/2 (worker, api, frontend)

Requested after Phase 2: from here on, MCMapper development follows
TDD (test-first) — this retrofits the pieces already built before that
request landed.

worker: unit tests for the tile rasterizer (background fill, exact
upscaled-block boundaries, full-grid painting, out-of-bounds columns) and
the block-color palette (distinctness checks, including that the
"unmapped block" placeholder never accidentally collides with a real
block's color). 16 tests total alongside the existing mesher tests.

api: wired up `bun test`. Unit tests for chunkOf's coordinate math.
Integration tests (real Postgres/Redis/MinIO, see README's new "Running
tests" section) for wsGateway.message() — auth accept/reject, upsert +
dedup on columns/sections, not-authenticated/invalid-JSON handling — and
for the tile/mesh/servers HTTP routes, driven through Elysia's in-process
`.handle()` rather than a bound port (sidesteps the stale dev-server
port-collision issue hit repeatedly this session). index.ts now exports
`app` and only calls `.listen()` when run directly, specifically so tests
can drive it this way.

frontend: extracted mesh.js's binary-format parser into its own ESM
module (mesh-format.js) so it's unit-testable without a browser/Babylon;
mesh.js now imports it. Tests build a buffer independently of the parser
(mirroring worker's encoder layout) so a mismatch in either direction —
Rust producer or JS consumer drifting — would be caught.
This commit is contained in:
2026-08-08 16:39:27 +02:00
parent 5ed4d32a56
commit dc7185c15e
16 changed files with 623 additions and 38 deletions
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"migrate": "bun run src/db/migrate.ts",
"seed": "bun run scripts/seed-server.ts"
"seed": "bun run scripts/seed-server.ts",
"test": "bun test"
},
"dependencies": {
"cookie": "^2.0.1",
+128
View File
@@ -0,0 +1,128 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { app } from "./index";
import { db } from "./db/client";
import { meshPointers, tilePointers } from "./db/schema";
import { minio, TILE_BUCKET } from "./minio";
import { createTestServer, deleteTestServer } from "./test-helpers";
// Elysia's `.handle()` drives the app in-process against a plain Request/Response, without
// binding a real port — avoids racing a real running instance for the port (see MCMapper's
// memory notes on stale dev-server processes double-binding a port on Windows) and is faster.
function get(path: string) {
return app.handle(new Request(`http://localhost${path}`));
}
describe("GET /health", () => {
test("reports ok", async () => {
const res = await get("/health");
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ status: "ok" });
});
});
describe("GET /api/servers", () => {
let server: { id: string; token: string };
beforeAll(async () => {
server = await createTestServer("servers-route");
});
afterAll(async () => {
await deleteTestServer(server.id);
});
test("includes the seeded server without leaking its token", async () => {
const res = await get("/api/servers");
expect(res.status).toBe(200);
const rows = (await res.json()) as any[];
const found = rows.find((r) => r.id === server.id);
expect(found).toEqual({ id: server.id, name: "test-servers-route" });
});
});
describe("GET /api/tiles/:serverId/:dimension/:zoom/:tileX/:tileY", () => {
let server: { id: string; token: string };
const storageKey = "test-tile-object.png";
const pngBytes = new Uint8Array([1, 2, 3, 4]);
beforeAll(async () => {
server = await createTestServer("tiles-route");
await minio.putObject(TILE_BUCKET, storageKey, Buffer.from(pngBytes));
await db.insert(tilePointers).values({
serverId: server.id,
dimension: 0,
zoom: 0,
tileX: 5,
tileZ: -3, // stored as chunkZ; the route negates the requested tileY to get here
storageKey,
contentHash: "test-hash",
});
});
afterAll(async () => {
await deleteTestServer(server.id);
await minio.removeObject(TILE_BUCKET, storageKey).catch(() => {});
});
test("negates tileY back to the stored chunkZ and streams the object", async () => {
// chunkZ = -3, so the frontend's tileY (see public/js/map.js) is 3.
const res = await get(`/api/tiles/${server.id}/0/0/5/3.png`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("image/png");
const body = new Uint8Array(await res.arrayBuffer());
expect(body).toEqual(pngBytes);
});
test("404s for a tile that hasn't been rendered", async () => {
const res = await get(`/api/tiles/${server.id}/0/0/999/999.png`);
expect(res.status).toBe(404);
});
});
describe("GET /api/meshes/...", () => {
let server: { id: string; token: string };
const storageKey = "test-mesh-object.bin";
const meshBytes = new Uint8Array([9, 9, 9]);
beforeAll(async () => {
server = await createTestServer("meshes-route");
await minio.putObject(TILE_BUCKET, storageKey, Buffer.from(meshBytes));
await db.insert(meshPointers).values({
serverId: server.id,
dimension: 0,
x: 2,
z: 3,
sectionY: 4,
storageKey,
contentHash: "test-hash",
});
});
afterAll(async () => {
await deleteTestServer(server.id);
await minio.removeObject(TILE_BUCKET, storageKey).catch(() => {});
});
test("lists the rendered sections for a chunk", async () => {
const res = await get(`/api/meshes/${server.id}/0/2/3`);
expect(await res.json()).toEqual([4]);
});
test("lists nothing for a chunk with no meshes yet", async () => {
const res = await get(`/api/meshes/${server.id}/0/999/999`);
expect(await res.json()).toEqual([]);
});
test("serves the mesh binary with the right content type", async () => {
const res = await get(`/api/meshes/${server.id}/0/2/3/4.bin`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("application/octet-stream");
const body = new Uint8Array(await res.arrayBuffer());
expect(body).toEqual(meshBytes);
});
test("404s for a section that hasn't been rendered", async () => {
const res = await get(`/api/meshes/${server.id}/0/2/3/15.bin`);
expect(res.status).toBe(404);
});
});
+9 -4
View File
@@ -7,7 +7,7 @@ import { minio, TILE_BUCKET, ensureTileBucket } from "./minio";
await ensureTileBucket();
const app = new Elysia()
export const app = new Elysia()
.get("/health", () => ({ status: "ok" }))
.get("/api/servers", async () => {
const rows = await db.select({ id: servers.id, name: servers.name }).from(servers);
@@ -91,7 +91,12 @@ const app = new Elysia()
open: wsGateway.open,
message: wsGateway.message,
close: wsGateway.close,
})
.listen(Number(process.env.PORT ?? 3000));
});
console.log(`[api] listening on :${app.server?.port}`);
// Only bind a real port when run directly (`bun run src/index.ts`) — tests import `app` and
// drive it via `.handle()` instead, so they don't race for a port with a real running instance
// (see MCMapper's memory notes on stale dev-server processes double-binding a port on Windows).
if (import.meta.main) {
app.listen(Number(process.env.PORT ?? 3000));
console.log(`[api] listening on :${app.server?.port}`);
}
+38
View File
@@ -0,0 +1,38 @@
import { eq } from "drizzle-orm";
import { db } from "./db/client";
import { servers } from "./db/schema";
/**
* Integration tests need real Postgres/Redis/MinIO — see README's "Running tests" section for
* how to start them. Each test file creates its own server row with a random token so runs
* never collide with each other or with dev data, and tears it down (which cascades to any
* chunk_columns/chunk_sections/tile_pointers/mesh_pointers rows via their FK's ON DELETE CASCADE).
*/
export async function createTestServer(namePrefix: string) {
const token = `test-${namePrefix}-${crypto.randomUUID()}`;
const [row] = await db.insert(servers).values({ name: `test-${namePrefix}`, token }).returning();
return row;
}
export async function deleteTestServer(serverId: string) {
await db.delete(servers).where(eq(servers.id, serverId));
}
/** Minimal stand-in for Elysia's WS connection object — enough for wsGateway.message()/close(). */
export class FakeSocket {
id = crypto.randomUUID();
sent: string[] = [];
closed = false;
send(data: string) {
this.sent.push(data);
}
close() {
this.closed = true;
}
lastMessage(): any {
return JSON.parse(this.sent[this.sent.length - 1]);
}
}
+163
View File
@@ -0,0 +1,163 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { and, eq } from "drizzle-orm";
import { db } from "./db/client";
import { chunkColumns, chunkSections } from "./db/schema";
import { redis, DIRTY_CHUNK_STREAM } from "./redis";
import { wsGateway, chunkOf } from "./ws-gateway";
import { createTestServer, deleteTestServer, FakeSocket } from "./test-helpers";
describe("chunkOf", () => {
test("floors toward negative infinity, matching Math.floor(coord/16)", () => {
expect(chunkOf(0)).toBe(0);
expect(chunkOf(15)).toBe(0);
expect(chunkOf(16)).toBe(1);
expect(chunkOf(-1)).toBe(-1);
expect(chunkOf(-16)).toBe(-1);
expect(chunkOf(-17)).toBe(-2);
});
});
describe("wsGateway.message", () => {
let server: { id: string; token: string };
beforeAll(async () => {
server = await createTestServer("ws-gateway");
});
afterAll(async () => {
await deleteTestServer(server.id);
});
test("hello with a valid token acknowledges with the server id", async () => {
const socket = new FakeSocket();
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token }));
expect(socket.closed).toBe(false);
expect(socket.lastMessage()).toEqual({ type: "hello_ack", ok: true, serverId: server.id });
});
test("hello with an invalid token is rejected and the connection closed", async () => {
const socket = new FakeSocket();
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: "not-a-real-token" }));
expect(socket.closed).toBe(true);
expect(socket.lastMessage()).toEqual({ type: "hello_ack", ok: false, error: "invalid_token" });
});
test("a message before hello is rejected as not_authenticated", async () => {
const socket = new FakeSocket();
await wsGateway.message(socket, JSON.stringify({ type: "columns", dimension: 0, columns: [] }));
expect(socket.closed).toBe(true);
expect(socket.lastMessage()).toEqual({ type: "error", error: "not_authenticated" });
});
test("invalid JSON gets an error reply, not a thrown exception", async () => {
const socket = new FakeSocket();
await wsGateway.message(socket, "{not json");
expect(socket.lastMessage()).toEqual({ type: "error", error: "invalid_json" });
});
test("columns upserts chunk_columns and marks exactly the touched chunks dirty", async () => {
const socket = new FakeSocket();
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token }));
const streamLenBefore = await redis.xlen(DIRTY_CHUNK_STREAM);
// Two columns in chunk (0,0), one in chunk (1,0) — should dedupe to exactly 2 dirty events.
await wsGateway.message(
socket,
JSON.stringify({
type: "columns",
dimension: 0,
columns: [
{ x: 1, z: 1, height: 64, blockId: 2, blockMeta: 0 },
{ x: 2, z: 2, height: 65, blockId: 3, blockMeta: 0 },
{ x: 16, z: 1, height: 70, blockId: 1, blockMeta: 0 },
],
}),
);
const rows = await db
.select()
.from(chunkColumns)
.where(and(eq(chunkColumns.serverId, server.id), eq(chunkColumns.dimension, 0)));
expect(rows).toHaveLength(3);
const byX = Object.fromEntries(rows.map((r) => [r.x, r]));
expect(byX[1]!.blockId).toBe(2);
expect(byX[1]!.height).toBe(64);
expect(byX[16]!.blockId).toBe(1);
const streamLenAfter = await redis.xlen(DIRTY_CHUNK_STREAM);
expect(streamLenAfter - streamLenBefore).toBe(2);
});
test("a repeated column overwrites rather than duplicating", async () => {
const socket = new FakeSocket();
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token }));
const send = (x: number, blockId: number) =>
wsGateway.message(
socket,
JSON.stringify({
type: "columns",
dimension: 0,
columns: [{ x, z: 100, height: 64, blockId, blockMeta: 0 }],
}),
);
await send(50, 2);
await send(50, 3);
const rows = await db
.select()
.from(chunkColumns)
.where(and(eq(chunkColumns.serverId, server.id), eq(chunkColumns.x, 50), eq(chunkColumns.z, 100)));
expect(rows).toHaveLength(1);
expect(rows[0]!.blockId).toBe(3);
});
test("sections upserts chunk_sections and marks the chunk dirty", async () => {
const socket = new FakeSocket();
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token }));
const blocks = Buffer.alloc(8192).toString("base64"); // all-air section is fine for this test
const streamLenBefore = await redis.xlen(DIRTY_CHUNK_STREAM);
await wsGateway.message(
socket,
JSON.stringify({
type: "sections",
dimension: 0,
chunkX: 9,
chunkZ: -3,
sections: [{ sectionY: 4, blocks }],
}),
);
const rows = await db
.select()
.from(chunkSections)
.where(
and(
eq(chunkSections.serverId, server.id),
eq(chunkSections.x, 9),
eq(chunkSections.z, -3),
eq(chunkSections.sectionY, 4),
),
);
expect(rows).toHaveLength(1);
expect(rows[0]!.blocks).toBe(blocks);
const streamLenAfter = await redis.xlen(DIRTY_CHUNK_STREAM);
expect(streamLenAfter - streamLenBefore).toBe(1);
});
test("close() forgets the connection's authentication state", async () => {
const socket = new FakeSocket();
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token }));
wsGateway.close(socket);
const socket2 = new FakeSocket();
socket2.id = socket.id; // simulate the same connection id being reused post-close
await wsGateway.message(socket2, JSON.stringify({ type: "columns", dimension: 0, columns: [] }));
expect(socket2.lastMessage()).toEqual({ type: "error", error: "not_authenticated" });
});
});
+1 -1
View File
@@ -45,7 +45,7 @@ interface ConnState {
const connections = new Map<string | number, ConnState>();
function chunkOf(coord: number): number {
export function chunkOf(coord: number): number {
return Math.floor(coord / 16);
}