Files
psu-schedule/test/api.test.js

219 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { readFileSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { academicStartYear, todayIso } from "../shared/weeks.js";
// The API over HTTP, as the Android and iOS clients see it: the real server against a stand-in
// for the university's Nextcloud that serves the fixtures as a small catalog.
const SHARE = "/public.php/dav/files/no5zKbMrns6j5jQ/";
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];
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 `<d:response><d:href>${encode(path)}</d:href><d:propstat><d:prop>${
folder ? "<d:resourcetype><d:collection/></d:resourcetype>" : `<d:resourcetype/><d:getcontentlength>${size}</d:getcontentlength>`
}<d:getlastmodified>${MODIFIED}</d:getlastmodified></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`;
};
return `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">${[folder, ...children].map(entry).join("")}</d:multistatus>`;
};
let source, server, base, data;
const stats = { requests: 0, connections: 0 };
before(async () => {
source = createServer((req, res) => {
stats.requests++;
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));
data = mkdtempSync(join(tmpdir(), "psu-api-"));
process.env.PSU_SOURCE_ORIGIN = `http://127.0.0.1:${source.address().port}`;
process.env.PSU_DATA_DIR = data;
process.env.PSU_SOURCE_TIMEOUT_MS = "500";
const { app } = await import("../server/index.js");
server = app.listen(0, "127.0.0.1");
await new Promise((resolve) => server.on("listening", resolve));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
server?.closeAllConnections();
source?.closeAllConnections();
source?.close();
rmSync(data, { recursive: true, force: true });
});
const get = (path, headers = {}) => fetch(base + path, { headers });
const json = async (path) => {
const response = await get(path);
return { status: response.status, body: await response.json(), response };
};
const query = (params) => "?" + new URLSearchParams(params);
let doc;
test("API: до загрузки индекса сервер жив, но не готов", async () => {
const health = await json("/api/health");
assert.equal(health.status, 200);
assert.equal(health.body.ok, true);
assert.equal(health.body.ready, false);
assert.equal(typeof health.body.apiVersion, "number");
const ready = await json("/api/ready");
assert.equal(ready.status, 503);
assert.equal(ready.body.code, "not_ready");
assert.ok(ready.body.waiting.includes("index"));
});
test("API: группы находятся по каталогу, после этого сервер готов", async () => {
const { status, body } = await json("/api/groups" + query({ q: "23-БА" }));
assert.equal(status, 200);
const group = body.results.find((r) => r.group === "23-БА");
assert.ok(group, JSON.stringify(body));
assert.equal(group.file, "23-ЛГ_23-БА.docx");
doc = group.fileId;
// The calendar loads beside the index; the first timetable request waits for it.
await get("/api/calendar");
const ready = await json("/api/ready");
assert.equal(ready.status, 200, JSON.stringify(ready.body));
assert.equal(ready.body.ready, true);
// Two broken documents of four do not keep the server from being ready.
assert.equal(ready.body.documents, 2);
assert.equal((await json("/api/health")).body.ready, true);
});
test("API: расписание группы — форма ответа для приложений", async () => {
const { status, body, response } = await json("/api/timetable" + query({ doc, group: "23-БА", from: "2026-10-19", days: 7 }));
assert.equal(status, 200);
assert.equal(body.group, "23-БА");
assert.equal(body.document.id, doc);
assert.equal(body.document.name, "23-ЛГ_23-БА.docx");
assert.ok(Array.isArray(body.days) && body.days.length === 7);
assert.ok(Array.isArray(body.bells) && body.bells.length > 0);
assert.ok(Array.isArray(body.subgroups));
const lesson = body.days.flatMap((d) => d.lessons)[0];
assert.ok(lesson, "на неделе есть занятия");
for (const field of ["key", "subject", "type", "start", "end", "pair", "rooms", "teachers", "condition", "subgroup", "raw"])
assert.ok(field in lesson, `поле ${field}`);
assert.match(body.days[0].date, /^\d{4}-\d{2}-\d{2}$/);
assert.equal(response.headers.get("cache-control"), "no-cache");
assert.ok(response.headers.get("etag"));
});
test("API: неизменное расписание отдаётся пустым 304", async () => {
const path = "/api/timetable" + query({ doc, group: "23-БА", from: "2026-10-19", days: 7 });
const etag = (await get(path)).headers.get("etag");
// fetch adds "Cache-Control: no-cache" to a request with its own If-None-Match, which forbids
// a 304; OkHttp and URLCache revalidate without it, as this request does.
const again = await get(path, { "If-None-Match": etag, "Cache-Control": "max-age=0" });
assert.equal(again.status, 304);
assert.equal(await again.text(), "");
const other = await get("/api/timetable" + query({ doc, group: "23-ЛГ", from: "2026-10-19", days: 7 }), { "If-None-Match": etag, "Cache-Control": "max-age=0" });
assert.equal(other.status, 200);
});
test("API: ошибки с машиночитаемым кодом", async () => {
const cases = [
["/api/timetable" + query({ group: "23-БА" }), 400, "bad_request"],
["/api/timetable" + query({ doc, group: "23-БА", from: "19.10.2026" }), 400, "bad_request"],
["/api/timetable" + query({ doc: "нет-такого", group: "23-БА" }), 404, "document_not_found"],
["/api/timetable" + query({ doc, group: "99-ЯЯ" }), 404, "group_not_found"],
["/api/exams", 400, "bad_request"],
["/api/directory/day" + query({ date: "2026-10-19" }), 400, "bad_request"],
["/api/nope", 404, "unknown_endpoint"],
];
for (const [path, status, code] of cases) {
const result = await json(path);
assert.equal(result.status, status, path);
assert.equal(result.body.code, code, path);
assert.equal(typeof result.body.error, "string", path);
}
});
test("API: исходный документ, сбой и таймаут университета", async () => {
const catalog = (await json("/api/catalog")).body;
const id = (name) => catalog.files.find((f) => f.name === name).id;
const original = await get(`/api/original/${id("23-ЛГ_23-БА.docx")}`);
assert.equal(original.status, 200);
assert.deepEqual(Buffer.from(await original.arrayBuffer()), fixture("fef-23-ba.docx"));
const broken = await json(`/api/original/${id("Сломанный.docx")}`);
assert.equal(broken.status, 502);
assert.equal(broken.body.code, "source_unavailable");
const slow = await json(`/api/original/${id("Медленный.docx")}`);
assert.equal(slow.status, 504);
assert.equal(slow.body.code, "source_timeout");
});
test("API: диагностика каталога и её фильтры", async () => {
const all = await json("/api/diagnostics");
assert.equal(all.status, 200);
assert.equal(all.body.documents, 2);
assert.equal(all.body.failed.length, 2);
assert.equal(typeof all.body.counts, "object");
// 23-БА's "2,6-12-16н" is a typo in Word that stays flagged.
const ba = await json("/api/diagnostics" + query({ group: "23-БА" }));
const problems = ba.body.items.flatMap((i) => i.problems);
assert.ok(problems.some((p) => p.code === "weeks_unreadable"));
assert.ok(problems.every((p) => p.groups.includes("23-БА")));
const none = await json("/api/diagnostics" + query({ code: "нет-такого" }));
assert.equal(none.body.problems, 0);
});
test("API: импорт Word и лимит 10 файлов", async () => {
const send = (name, body) => {
const form = new FormData();
form.append("file", new Blob([body]), name);
return fetch(base + "/api/import", { method: "POST", body: form });
};
const ok = await send("23-ЛГ_23-БА.docx", fixture("fef-23-ba.docx"));
assert.equal(ok.status, 200);
assert.ok((await ok.json()).groups.includes("23-БА"));
const wrongType = await send("расписание.pdf", "%PDF");
assert.equal(wrongType.status, 400);
assert.equal((await wrongType.json()).code, "bad_request");
const garbage = await send("расписание.docx", "не Word");
assert.equal(garbage.status, 422);
assert.equal((await garbage.json()).code, "unreadable_file");
for (let i = 0; i < 7; i++) assert.equal((await send("23-ЛГ_23-БА.docx", fixture("fef-23-ba.docx"))).status, 200);
const limited = await send("23-ЛГ_23-БА.docx", fixture("fef-23-ba.docx"));
assert.equal(limited.status, 429);
assert.equal((await limited.json()).code, "too_many_requests");
assert.ok(Number(limited.headers.get("retry-after")) > 0);
});
test("API: запросы к университету идут по открытым соединениям", () => {
// Listings, the week table, bells and four documents (some twice) over a handful of sockets.
assert.ok(stats.requests >= 10, `запросов ${stats.requests}`);
assert.ok(stats.connections < stats.requests / 2, `соединений ${stats.connections} на ${stats.requests} запросов`);
});