import { createServer } from "node:http"; import { createHash } from "node:crypto"; import { readFileSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { academicStartYear, todayIso } from "../../shared/weeks.js"; // A stand-in for the university's Nextcloud that serves the fixtures as a small catalog, and the // real API server started against it with a temporary data folder. export const SHARE = "/public.php/dav/files/no5zKbMrns6j5jQ/"; export const fixture = (name) => readFileSync(new URL(`../fixtures/${name}`, import.meta.url)); const year = academicStartYear(todayIso()); const YEAR = `${year}-${year + 1}`; const FOLDER = `${SHARE}${YEAR}/Финансово-экономический факультет/Дневная форма/`; const MODIFIED = "Mon, 14 Sep 2026 13:05:15 GMT"; const files = { [`${SHARE}Расписание белых и зеленых недель.docx`]: fixture("weeks.docx"), [`${SHARE}Расписание звонков.docx`]: fixture("bells.docx"), [`${FOLDER}23-ЛГ_23-БА.docx`]: fixture("fef-23-ba.docx"), [`${FOLDER}24-ИТ.docx`]: fixture("fit-daily.docx"), // The university fails on one document and is too slow on another. [`${FOLDER}Сломанный.docx`]: 500, [`${FOLDER}Медленный.docx`]: "slow", }; const folders = [SHARE, `${SHARE}${YEAR}/`, `${SHARE}${YEAR}/Финансово-экономический факультет/`, FOLDER]; // Dates of files changed during a test; the others keep MODIFIED. const dates = {}; const dateOf = (path) => dates[path] || MODIFIED; // As in Nextcloud, a folder's ETag changes whenever anything below it does. const etagOf = (folder) => `"${createHash("sha1").update(Object.keys(files).filter((f) => f.startsWith(folder)).sort().map((f) => f + dateOf(f)).join("|")).digest("hex").slice(0, 13)}"`; const encode = (path) => path.split("/").map(encodeURIComponent).join("/"); const listing = (folder) => { const children = [...folders.filter((f) => f !== folder && f.startsWith(folder) && !f.slice(folder.length, -1).includes("/")), ...Object.keys(files).filter((f) => f.startsWith(folder) && !f.slice(folder.length).includes("/"))]; const entry = (path) => { const folder = path.endsWith("/"); const size = Buffer.isBuffer(files[path]) ? files[path].length : 1000; return `${encode(path)}${ folder ? "" : `${size}` }${dateOf(path)}${folder ? etagOf(path) : `"${dateOf(path)}"`}HTTP/1.1 200 OK`; }; return `${[folder, ...children].map(entry).join("")}`; }; /** * Starts the stand-in and the API server; `env` adds settings such as lower limits. * Returns the server's base URL, request statistics of the stand-in, `catalog` to change the * stand-in's documents during a test, and `stop()`. */ export async function startApi(env = {}) { const stats = { requests: 0, connections: 0, listings: 0 }; let down = false; const source = createServer((req, res) => { stats.requests++; if (req.method === "PROPFIND") stats.listings++; if (down) return res.writeHead(503).end(); const path = decodeURIComponent(req.url); if (req.method === "PROPFIND" && folders.includes(path)) { res.writeHead(207, { "Content-Type": "application/xml" }); return res.end(listing(path)); } const body = files[path]; if (body === "slow") return setTimeout(() => res.end("late"), 2000); if (typeof body === "number" || !body) return res.writeHead(body || 404).end(); res.end(body); }); source.on("connection", () => stats.connections++); await new Promise((resolve) => source.listen(0, "127.0.0.1", resolve)); const data = mkdtempSync(join(tmpdir(), "psu-api-")); Object.assign(process.env, { PSU_SOURCE_ORIGIN: `http://127.0.0.1:${source.address().port}`, PSU_DATA_DIR: data, PSU_SOURCE_TIMEOUT_MS: "500", // The request log would drown the test output; the metrics test turns it on. PSU_REQUEST_LOG: "off", ...env, }); const { app } = await import("../../server/index.js"); const server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server.on("listening", resolve)); let tick = 0; return { base: `http://127.0.0.1:${server.address().port}`, stats, catalog: { /** Publishes a document in the faculty folder (or republishes it) with a new date. */ put(name, body) { const path = FOLDER + name; files[path] = body; dates[path] = new Date(Date.parse(MODIFIED) + ++tick * 60_000).toUTCString(); }, /** The university stops (true) or starts again (false) answering. */ down(on) { down = on; }, }, stop() { server.close(); server.closeAllConnections(); source.closeAllConnections(); source.close(); rmSync(data, { recursive: true, force: true }); }, }; }