const { test, before, after, beforeEach } = require("node:test"); const assert = require("node:assert"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); // Point the server at a throwaway data dir before importing it, so tests never // touch real question data. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fizika-test-")); const dataPath = path.join(tmpDir, "fizika.json"); const picsPath = path.join(tmpDir, "pics"); fs.mkdirSync(picsPath, { recursive: true }); process.env.DATA_PATH = dataPath; process.env.PICS_PATH = picsPath; const { app, isUnsafeFilename } = require("./server"); const { validateQuestionBank } = require("./validate-question-bank"); const seedQuestions = [ { id: 1, source: "2016/m1/1", description: "q1", a: "a", b: "b", c: "c", d: "d", correct: 1, type: "mk", image: null, }, { id: 2, source: "2016/m1/2", description: "q2", a: "a", b: "b", c: "c", d: "d", correct: 2, type: "md", image: null, }, ]; let server; let baseUrl; const api = (route, options) => fetch(`${baseUrl}${route}`, options); const sendJson = (route, method, body) => api(route, { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); before(async () => { server = app.listen(0); await new Promise((resolve) => server.once("listening", resolve)); baseUrl = `http://127.0.0.1:${server.address().port}`; }); after(() => { server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); beforeEach(() => { fs.writeFileSync(dataPath, JSON.stringify(seedQuestions, null, 2)); }); test("GET /api/fizika returns every question", async () => { const res = await api("/api/fizika"); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.length, 2); }); test("POST creates a question with an incremented id", async () => { const res = await sendJson("/api/admin/questions", "POST", { source: "2020/1/1", description: "new", a: "a", b: "b", c: "c", d: "d", correct: 3, type: "h", image: null, }); assert.equal(res.status, 201); const created = await res.json(); assert.equal(created.id, 3); const all = await (await api("/api/fizika")).json(); assert.equal(all.length, 3); }); test("PUT updates an existing question", async () => { const res = await sendJson("/api/admin/questions/1", "PUT", { description: "updated", }); assert.equal(res.status, 200); const updated = await res.json(); assert.equal(updated.description, "updated"); // unchanged fields are preserved assert.equal(updated.source, "2016/m1/1"); }); test("POST rejects a duplicate source without changing the bank", async () => { const before = fs.readFileSync(dataPath, "utf8"); const res = await sendJson("/api/admin/questions", "POST", seedQuestions[0]); assert.equal(res.status, 409); assert.equal((await res.json()).error, "Source identifier already exists"); assert.equal(fs.readFileSync(dataPath, "utf8"), before); }); test("PUT rejects another question's source without changing the bank", async () => { const before = fs.readFileSync(dataPath, "utf8"); const res = await sendJson("/api/admin/questions/2", "PUT", { source: seedQuestions[0].source, description: "must not be saved", }); assert.equal(res.status, 409); assert.equal((await res.json()).error, "Source identifier already exists"); assert.equal(fs.readFileSync(dataPath, "utf8"), before); }); test("PUT allows a question to keep its own source", async () => { const res = await sendJson("/api/admin/questions/1", "PUT", { source: seedQuestions[0].source, description: "updated", }); assert.equal(res.status, 200); assert.equal((await res.json()).description, "updated"); }); test("correcting October 2025 question 6 leaves exactly one result for 5 and 6", async () => { const questions = Array.from({ length: 15 }, (_, i) => ({ ...seedQuestions[0], id: 683 + i, source: `2025/2/${i + 1}`, })); questions[5].source = "2025/2/5"; questions[5].image = "202526.JPG"; fs.writeFileSync(dataPath, JSON.stringify(questions, null, 2)); const res = await sendJson("/api/admin/questions/688", "PUT", { source: "2025/2/6", }); assert.equal(res.status, 200); const bank = await (await api("/api/fizika")).json(); assert.deepEqual( bank.filter((q) => q.source === "2025/2/5").map((q) => q.id), [687], ); assert.deepEqual( bank.filter((q) => q.source === "2025/2/6").map((q) => q.id), [688], ); assert.equal(bank.find((q) => q.id === 688).image, "202526.JPG"); assert.deepEqual(validateQuestionBank(bank), []); }); test("PUT returns 404 for a missing question", async () => { const res = await sendJson("/api/admin/questions/999", "PUT", { a: "x" }); assert.equal(res.status, 404); }); test("DELETE removes a question", async () => { const res = await api("/api/admin/questions/1", { method: "DELETE" }); assert.equal(res.status, 200); const all = await (await api("/api/fizika")).json(); assert.equal(all.length, 1); assert.equal(all[0].id, 2); }); test("DELETE returns 404 for a missing question", async () => { const res = await api("/api/admin/questions/999", { method: "DELETE" }); assert.equal(res.status, 404); }); test("DELETE image rejects an unsafe filename", async () => { // Percent-encoded so the path survives client-side URL normalization and // reaches the route with a separator the guard must reject. const res = await api(`/api/admin/images/${encodeURIComponent("a\\b")}`, { method: "DELETE", }); assert.equal(res.status, 400); }); test("DELETE image returns 404 for a missing but valid filename", async () => { const res = await api("/api/admin/images/nope.png", { method: "DELETE" }); assert.equal(res.status, 404); }); test("unknown routes return 404 JSON", async () => { const res = await api("/api/does-not-exist"); assert.equal(res.status, 404); const body = await res.json(); assert.equal(body.error, "Not found"); }); test("isUnsafeFilename blocks traversal but allows plain names", () => { for (const bad of ["../etc", "a/b", "a\\b", "..", ".", "/abs.png", ""]) { assert.equal(isUnsafeFilename(bad), true, `${bad} should be unsafe`); } for (const ok of ["pic.png", "image_1.jpg", "2016-m1-1.png"]) { assert.equal(isUnsafeFilename(ok), false, `${ok} should be safe`); } });