feat: Implement Telegram alerts for catalog updates and failures
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -22,6 +23,12 @@ const files = {
|
||||
[`${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) => {
|
||||
@@ -32,19 +39,23 @@ const listing = (folder) => {
|
||||
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>`;
|
||||
}<d:getlastmodified>${dateOf(path)}</d:getlastmodified><d:getetag>${folder ? etagOf(path) : `"${dateOf(path)}"`}</d:getetag></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>`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 and `stop()`.
|
||||
* 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 };
|
||||
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" });
|
||||
@@ -67,9 +78,22 @@ export async function startApi(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();
|
||||
|
||||
113
test/watch.test.js
Normal file
113
test/watch.test.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { startApi, fixture } from "./support/nextcloud.js";
|
||||
|
||||
// The server's own checks of the catalog: listings only of folders whose ETag changed, the index
|
||||
// rebuilt only for a changed document, and messages to the owner in Telegram (a stand-in here).
|
||||
let api, telegram, watch;
|
||||
const messages = [];
|
||||
const health = async () => (await fetch(api.base + "/api/health")).json();
|
||||
const lastMessage = () => messages.at(-1)?.text || "";
|
||||
|
||||
before(async () => {
|
||||
telegram = createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
messages.push({ path: req.url, ...JSON.parse(body) });
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => telegram.listen(0, "127.0.0.1", resolve));
|
||||
api = await startApi({
|
||||
TELEGRAM_BOT_TOKEN: "test-token",
|
||||
TELEGRAM_CHAT_ID: "42",
|
||||
TELEGRAM_API: `http://127.0.0.1:${telegram.address().port}`,
|
||||
PSU_PUBLIC_URL: "https://psu.example",
|
||||
PSU_ALERT_SOURCE_DOWN_MS: "0",
|
||||
// Every check reaches the stand-in, however soon after the last one.
|
||||
PSU_MIN_REFRESH_MS: "0",
|
||||
});
|
||||
watch = await import("../server/watch.js");
|
||||
});
|
||||
after(() => {
|
||||
api?.stop();
|
||||
telegram?.close();
|
||||
});
|
||||
|
||||
test("Наблюдение: первая проверка обходит весь каталог и включает оповещения", async () => {
|
||||
await watch.watchOnce();
|
||||
const { catalog, directory, telegram: tg, watch: status } = await health();
|
||||
assert.equal(catalog.lastCrawl.full, true);
|
||||
assert.equal(directory.documents, 2);
|
||||
assert.equal(tg.enabled, true);
|
||||
assert.equal(status.lastError, null);
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].path, "/bottest-token/sendMessage");
|
||||
assert.equal(messages[0].chat_id, "42");
|
||||
assert.match(lastMessage(), /Оповещения расписания ПГУ включены/);
|
||||
assert.match(lastMessage(), /2 документов, не читаются: 2/);
|
||||
});
|
||||
|
||||
test("Наблюдение: неизменный каталог — два листинга, индекс и ETag расписаний те же", async () => {
|
||||
const before = await health();
|
||||
const listings = api.stats.listings;
|
||||
await watch.watchOnce();
|
||||
const after = await health();
|
||||
assert.equal(api.stats.listings - listings, 2);
|
||||
assert.equal(after.catalog.lastCrawl.requests, 2);
|
||||
assert.equal(after.catalog.lastCrawl.full, false);
|
||||
assert.ok(after.catalog.lastCrawl.reused >= 1);
|
||||
assert.equal(after.directory.builtAt, before.directory.builtAt);
|
||||
assert.equal(messages.length, 1);
|
||||
});
|
||||
|
||||
test("Наблюдение: новый документ — листинг только изменившихся папок и сообщение", async () => {
|
||||
const listings = api.stats.listings;
|
||||
api.catalog.put("26-АРХ.docx", fixture("isf-credits-26-arh.docx"));
|
||||
await watch.watchOnce();
|
||||
const { catalog, directory } = await health();
|
||||
// The share's root, the year folder, the faculty folder and the day-time folder.
|
||||
assert.equal(api.stats.listings - listings, 4);
|
||||
assert.equal(catalog.lastCrawl.requests, 4);
|
||||
assert.equal(directory.documents, 3);
|
||||
assert.equal(messages.length, 2);
|
||||
assert.match(lastMessage(), /Новый документ: 26-АРХ\.docx/);
|
||||
assert.match(lastMessage(), /Группы: 26-АРХ/);
|
||||
const groups = await (await fetch(api.base + "/api/groups?" + new URLSearchParams({ q: "26-АРХ" }))).json();
|
||||
assert.ok(groups.results.some((g) => g.group === "26-АРХ"));
|
||||
});
|
||||
|
||||
test("Наблюдение: перезалитый документ с новыми подозрительными ячейками", async () => {
|
||||
// 23-БА's "2,6-12-16н" lands in a document that had no such cell.
|
||||
api.catalog.put("24-ИТ.docx", fixture("fef-23-ba.docx"));
|
||||
await watch.watchOnce();
|
||||
assert.equal(messages.length, 3);
|
||||
assert.match(lastMessage(), /Перезалит с новыми подозрительными ячейками: 24-ИТ\.docx/);
|
||||
assert.match(lastMessage(), /2,6-12-16н/);
|
||||
assert.match(lastMessage(), /https:\/\/psu\.example\/api\/diagnostics\?doc=/);
|
||||
// Republished again as it is: nothing new to say.
|
||||
api.catalog.put("24-ИТ.docx", fixture("fef-23-ba.docx"));
|
||||
await watch.watchOnce();
|
||||
assert.equal(messages.length, 3);
|
||||
});
|
||||
|
||||
test("Наблюдение: университет не отвечает и снова отвечает", async () => {
|
||||
api.catalog.down(true);
|
||||
await watch.watchOnce();
|
||||
const { watch: status, catalog } = await health();
|
||||
assert.ok(status.lastError);
|
||||
assert.ok(catalog.failingSince);
|
||||
assert.equal(messages.length, 4);
|
||||
assert.match(lastMessage(), /Каталог университета не отвечает/);
|
||||
// Still down: one message per outage.
|
||||
await watch.watchOnce();
|
||||
assert.equal(messages.length, 4);
|
||||
api.catalog.down(false);
|
||||
await watch.watchOnce();
|
||||
assert.equal(messages.length, 5);
|
||||
assert.match(lastMessage(), /снова отвечает/);
|
||||
assert.equal((await health()).watch.lastError, null);
|
||||
});
|
||||
Reference in New Issue
Block a user